Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Ước Lớn Nhất (BESTUOC)

Ước Lớn Nhất (BESTUOC)
Nguồn: Codeforces Group Độ khó: Trung bình Ước chung lớn nhất Tiền tố – hậu tố

Đề bài

Sắp đến đợt kiểm tra học kì, Nam rất lo lắng về điểm môn tin học. Vì Nam là 1 học sinh giỏi toàn diện nên cậu muốn điểm môn tin phải cao. Sau khi học tất cả kiến thức cơ bản, cậu thấy môn tin học cực kì dễ dàng. Đến hôm kiểm tra, thầy cho 1 bài khá khó nên Nam không làm được, vì thế nên Nam nhờ các Luyencode Coder làm hộ bài.

Đề bài: cho 1 dãy $a_i$ có $n$ phần tử. Bạn hãy thay thế 1 phần tử trong đoạn $[1, 10^9]$ của dãy $a_i$ để tạo ước chung lớn nhất của dãy $a_i$. Hãy in ra ước chung lớn nhất của dãy $a_i$.

(Hiểu là: chọn đúng 1 phần tử, thay nó bằng một số nguyên bất kỳ trong đoạn $[1, 10^9]$, sao cho ước chung lớn nhất của cả dãy sau khi thay là lớn nhất có thể. In ra giá trị lớn nhất đó.)

Dữ liệu vào:

  • Dòng 1 là số nguyên $n$ $(1 \le n \le 10^6)$.
  • Dòng 2 là $n$ số nguyên $a_i$ $(1 \le a_i \le 10^6)$.

Dữ liệu ra: là đáp án của bài toán mà Nam nhờ code hộ.

Ví dụ – input
3
4 6 3
Ví dụ – output
3

Gợi ý của đề: thay đổi $a_1$ thành bất kì số nào chia hết cho 3. Khi đó dãy là $(3k, 6, 3)$ có ước chung lớn nhất bằng $3$.

Lời mở đầu

Bài này mình được một em học viên hỏi và mình đã hướng dẫn chi tiết cách thức giải quyết cho em ấy. Tin rằng sẽ có giá trị với nhiều bạn nên mình chia sẻ đề bài để các bạn thử sức làm và kèm thêm đó là toàn bộ tin nhắn của mình với em ấy, để bạn nào không giải quyết được thì có thể tham khảo nhen. Hi vọng mang đến được giá trị cho các bạn. Chúc các bạn học tốt và gặt hái nhiều thành công 😇.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên Codeforces

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Gợi ýThay thế 1 phần tử = bỏ nó ra

HV
“bài này thì em chưa có ý tưởng luôn a. anh cho e 1 vài gợi ý bài này với”

Gợi ý à, hmmm… em sẽ thấy rằng việc nói thay thế 1 giá trị bất kỳ bằng 1 giá trị khác, bản chất của nó có thể nói thẳng ra là loại bỏ giá trị đó ra và tính ước chung lớn nhất của các thằng còn lại thôi 🙂.

Tại vì sao? Giả sử có 5 thằng, giờ cần thay thế 1 thằng, thì ước chung lớn nhất của 4 thằng còn lại giả sử là 4 đi, nhưng vì tính ước chung lớn nhất với cái thằng mà mình đang tính thay thế nên ước chung lớn nhất chỉ còn 2. Vậy nếu mình bỏ thằng đó ra thì ước chung lớn nhất của 4 thằng sẽ là 4 rồi, thì cái thằng mình bỏ ra mình sẽ thay thế nó chính bằng 4 luôn, hoặc 1 số bất kỳ chia hết cho 4, thì coi như ước chung lớn nhất vẫn sẽ được bảo toàn.

Chắc chắn không thể có cách nào thay thế mà ước chung lớn nhất vượt qua 4 được, tại vì đã là ước thì nó chỉ có thể từ đó giảm xuống chứ không có chuyện tăng lên.

Kết luận
Đáp án $= \max\limits_{i} \text{UCLN}(\text{tất cả các phần tử trừ } a_i)$. Thay $a_i$ bằng chính giá trị UCLN đó (luôn $\le 10^6 \le 10^9$ nên hợp lệ) là đạt được.

Đó, thì em hãy nghĩ đến điều đó 😀 gợi ý của anh là như thế, nói thêm nữa sẽ mất vui nè ^^

Kiến thứcTìm UCLN của cả dãy N phần tử

Cũng nói thêm cho em biết làm thế nào để tìm được ước chung lớn nhất của dãy $N$ phần tử từ $a_1$ đến $a_n$?

Đầu tiên nó tìm ước chung lớn nhất của $a_1$ và $a_2$. Sau đó lấy ước chung lớn nhất đó và tìm ước chung lớn nhất của chính nó với $a_3$. Và cứ thế làm tương tự với $a_4, a_5, \ldots$ cho đến $a_n$. Sau cùng kết quả thu được nó là ước chung lớn nhất của dãy $N$ phần tử.

UCLN(a1, a2, …, an) = UCLN( … UCLN( UCLN(a1, a2), a3) …, an)

Mẹo nhỏ: $\text{UCLN}(0, x) = x$, nên có thể cho biến kết quả bắt đầu từ $0$ rồi cứ lần lượt "gộp" từng phần tử vào. Hàm __gcd của C++ và math.gcd của Python đều chạy đúng như vậy.

HV
“A gợi ý xí xi nưaz a Sơn”

Ok. Với từng vị trí $i$, hãy tìm ước chung lớn nhất của toàn bộ các phần tử còn lại ngoài vị trí $i$. Và so sánh kết quả Max với lần lượt từng vị trí $i$. Ví dụ test 4 6 3:

Vị trí bỏDãyUCLN phần còn lại
Bỏ đi 4
463
UCLN(6, 3) = 3✔ Max
Bỏ đi 6
463
UCLN(4, 3) = 1
Bỏ đi 3
463
UCLN(4, 6) = 2

⇒ Max chọn 3.

Cách 1Vét cạn: bỏ từng vị trí rồi tính lại – O(N² log A)

Làm đúng y như gợi ý trên: với mỗi $i$, duyệt lại cả dãy (trừ $a_i$) để tính UCLN. Code này mình viết thêm để các bạn dễ hình dung và để dùng làm code đối chứng:

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n);
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    if(n == 1){
        cout << 1000000000;
        return 0;
    }
    int Max = 0;
    for(int i = 0; i < n; ++i){
        int g = 0;
        for(int j = 0; j < n; ++j){
            if(j != i){
                g = __gcd(g, a[j]);
            }
        }
        Max = max(Max, g);
    }
    cout << Max;
    return 0;
}
import sys
from math import gcd

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:1 + n]))
if n == 1:
    print(10**9)
else:
    Max = 0
    for i in range(n):
        g = 0
        for j in range(n):
            if j != i:
                g = gcd(g, a[j])
        Max = max(Max, g)
    print(Max)
C++▶ Chạy tay từng dòng codeCách 1 (vét cạn): bỏ từng vị trí i rồi tính lại UCLN cả phần còn lại, O(N² log A).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 vector<int> a(n);
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 if(n == 1){
14 cout << 1000000000;
15 return 0;
16 }
17 int Max = 0;
18 for(int i = 0; i < n; ++i){
19 int g = 0;
20 for(int j = 0; j < n; ++j){
21 if(j != i){
22 g = __gcd(g, a[j]);
23 }
24 }
25 Max = max(Max, g);
26 }
27 cout << Max;
28 return 0;
29}

Riêng trường hợp $n = 1$: bỏ phần tử duy nhất đi thì không còn ai ràng buộc, ta thay nó bằng số lớn nhất được phép là $10^9$, UCLN của dãy 1 phần tử chính là nó, nên đáp án là $10^9$.

Nhưng chẳng lẽ cứ mỗi lần không tính 1 vị trí $i$ mà ta lại phải duyệt lại toàn bộ $n - 1$ phần tử để tìm ước chung lớn nhất (gọi tắt là UCLN)? Như thế thì rất lâu. Bởi vì UCLN của $n - 1$ phần tử là phải xét qua $n - 2$ cặp, với mỗi cặp tìm ra UCLN của 2 đứa. Em có biết là hàm __gcd có sẵn tìm UCLN của 2 số nó được xây dựng tối ưu rồi thì độ phức tạp sẽ là $O(\log(\min(a, b)))$ không? Mà mỗi lần loại đi 1 vị trí $i$ là phải xét $n - 2$ cặp, mà ta có đến $n$ vị trí $i$ cần xét, vậy số cặp phải xét là $n \cdot (n - 2)$. Vậy có thể thấy độ phức tạp sẽ là $n \cdot (n - 2) \cdot \log(\min(a, b))$, nội $n \cdot (n - 2)$ đã coi như $n^2$.

Bài đó cho $n$ tối đa $10^6$ thì như thế $n^2$ là phải xét qua cả $10^{12}$ cặp, là đủ TLE rồi (vì ngưỡng tối đa 1 giây chỉ là $10^8$ thôi), còn chưa tính đến chi phí tìm UCLN giữa 2 thằng. Nên chắc chắn làm như vậy sẽ bị TLE.

Cách 2Hai mảng phụ left, right – O(N log A) – AC

HV
“triển khai codde này có cần kỹ thuật dùng 2 mảng phụ để sử lý à anh”

Đúng, cần phải có mảng phụ.

HV
“tại sao phải tìm gcd cả dãy theo 2 chiều vậy anh”

Có phải nếu mảng $n$ phần tử, với từng vị trí $i$ ta sẽ giả sử loại bỏ phần tử $a_i$ đó không tính nó, chỉ tính UCLN của các phần tử còn lại, nghĩa là UCLN của $a_0, a_1,$ $\ldots,$ $a_{i-1},$ $a_{i+1},$ $\ldots,$ $a_{n-1}$. Như Cách 1 đã phân tích, tính lại từ đầu cho mỗi $i$ thì quá lâu. Vậy nên 1 cách tốt hơn là tạo:

  • Mảng left với ý nghĩa left[i] là UCLN của toàn bộ các phần tử từ đầu mảng tính đến ngay tại vị trí $i$, tức là left[i] = UCLN$(a_0, a_1, a_2, \ldots, a_i)$.
  • Tương tự có mảng right với ý nghĩa right[i] là UCLN của toàn bộ các phần tử từ vị trí $i$ trở về cuối mảng, tức là right[i] = UCLN$(a_i, a_{i+1}, a_{i+2}, \ldots, a_{n-1})$.

Vậy có phải khi xét đến 1 vị trí $i$ cần loại bỏ thì thay vì phải xét tìm UCLN của $n - 1$ thằng còn lại, lúc này ta chỉ cần tìm UCLN giữa thằng left[i - 1] và thằng right[i + 1] là coi như nó chính là kết quả UCLN của toàn bộ mảng sau khi đã loại thằng a[i] ra. Em ngẫm xem đúng không nè ^^

a01211827324430
left01216213141
right01112136430
Bỏ a[2] = 7 → UCLN(a[0], a[1], a[3], a[4]) = UCLN(left[1] = 6, right[3] = 6) = 6

Hai đầu mút thì đặc biệt hơn chút: bỏ $a_0$ thì phần còn lại chính là right[1], bỏ $a_{n-1}$ thì phần còn lại chính là left[n - 2]. Em nhập dãy rồi bấm Tiếp để xem cả quá trình dựng 2 mảng và ghép kết quả:

Ví dụ nhanh:

Source code giải quyết theo tư tưởng anh đã trình bày ở trên và cũng đã nộp thành công trên web, em xem để được hiểu rõ hơn. Anh nói trước là code này anh code vội ra theo đúng tư tưởng đã chia sẻ đó với em chứ chưa có clean đâu nha. Anh sợ clean lại mất công em đọc không hiểu. Em xem hiểu rồi clean lại sau nhé:

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n), left(n), right(n);
    cin >> a[0];
    left[0] = a[0];
    for(int i = 1; i < n; ++i){
        cin >> a[i];
        left[i] = __gcd(left[i - 1], a[i]);
    }
    right[n - 1] = a[n - 1];
    for(int i = n - 2; i >= 0; --i){
        right[i] = __gcd(right[i + 1], a[i]);
    }
    int Max = max(right[1], left[n - 2]);
    int sz = n - 1;
    for(int i = 1; i < sz; ++i){
        Max = max(Max, __gcd(left[i - 1], right[i + 1]));
    }
    cout << Max;
    return 0;
}
import sys
import math

input = sys.stdin.readline

n = int(input())
a = list(map(int, input().split()))
left = [0] * n
right = [0] * n

left[0] = a[0]
for i in range(1, n):
    left[i] = math.gcd(left[i - 1], a[i])

right[n - 1] = a[n - 1]
for i in range(n - 2, -1, -1):
    right[i] = math.gcd(right[i + 1], a[i])

Max = max(right[1], left[n - 2])
sz = n - 1
for i in range(1, sz):
    Max = max(Max, math.gcd(left[i - 1], right[i + 1]))

print(Max)
C++▶ Chạy tay từng dòng codeCách 2: mảng UCLN tiền tố left và hậu tố right, bỏ a[i] = UCLN(left[i−1], right[i+1]), O(N log A).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 vector<int> a(n), left(n), right(n);
10 cin >> a[0];
11 left[0] = a[0];
12 for(int i = 1; i < n; ++i){
13 cin >> a[i];
14 left[i] = __gcd(left[i - 1], a[i]);
15 }
16 right[n - 1] = a[n - 1];
17 for(int i = n - 2; i >= 0; --i){
18 right[i] = __gcd(right[i + 1], a[i]);
19 }
20 int Max = max(right[1], left[n - 2]);
21 int sz = n - 1;
22 for(int i = 1; i < sz; ++i){
23 Max = max(Max, __gcd(left[i - 1], right[i + 1]));
24 }
25 cout << Max;
26 return 0;
27}
  1. Vừa đọc a[i] vừa tính luôn left[i] = __gcd(left[i - 1], a[i]).
  2. Duyệt ngược từ cuối để tính right[i] = __gcd(right[i + 1], a[i]).
  3. Max khởi tạo bằng kết quả của 2 đầu mút: max(right[1], left[n - 2]).
  4. Các vị trí ở giữa $1 \le i \le n - 2$: __gcd(left[i - 1], right[i + 1]).
Lưu ý trường hợp n = 1
Đề cho $1 \le n$, nhưng code trên khi $n = 1$ sẽ truy cập right[1] và left[-1], ra ngoài mảng (bản Python sẽ báo lỗi IndexError). Code này đã AC trên hệ thống nên có lẽ bộ test không có $n = 1$. Để chắc ăn, em có thể thêm vào ngay sau khi đọc $n$: if(n == 1){ cout << 1000000000; return 0; } như trong Cách 1 và Cách 3 nhé.
Không gian$O(N)$
Thời gian$O(N \log A)$
HV
“Ồ em hiểu r ạ. Cái này là lúc làm anh nghĩ ra luôn hay kinh nghiệm thế anh”

Lúc anh đọc vào đề là sau tầm vài phút anh nghĩ ra ý tưởng này á ^^ Đại khái là cứ hãy luôn nghĩ trong đầu: hãy làm sao để tiết kiệm chi phí phải xét duyệt. Cứ luôn nghĩ về điều đó sẽ giúp ta nhìn ra được những hướng giải tốt hơn.

Thậm chí anh còn đang suy nghĩ: bài đó cả thảy anh dùng 3 mảng. Anh cho rằng có thể bỏ bớt 1 mảng đi. Em thử nghĩ xem ^^

Cách 3Bỏ bớt mảng left – lời giải câu đố của anh

Đây là phần mình viết thêm để trả lời câu đố trên, các bạn tự nghĩ trước rồi hãy đọc nha 😉. Để ý là khi duyệt $i$ từ trái sang phải, left[i - 1] chỉ được dùng đúng 1 lần rồi bỏ. Vậy thay vì lưu cả mảng left, ta dùng 1 biến g = UCLN của các phần tử đứng trước $i$, vừa duyệt vừa gộp dần:

  • Tính mảng right như cũ, nhưng cho thêm 1 ô right[n] = 0 (vì UCLN$(0, x) = x$, ô này đóng vai "rỗng").
  • Với mỗi $i$ từ $0$ đến $n - 1$: kết quả khi bỏ $a_i$ là __gcd(g, right[i + 1]), rồi gộp $a_i$ vào: g = __gcd(g, a[i]).
  • Nhờ g = 0 lúc đầu và right[n] = 0, hai đầu mút được xử lý luôn trong cùng 1 vòng lặp, không cần tách riêng.
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n), right(n + 1, 0);
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    if(n == 1){
        cout << 1000000000;
        return 0;
    }
    for(int i = n - 1; i >= 0; --i){
        right[i] = __gcd(right[i + 1], a[i]);
    }
    int Max = 0, g = 0;
    for(int i = 0; i < n; ++i){
        Max = max(Max, __gcd(g, right[i + 1]));
        g = __gcd(g, a[i]);
    }
    cout << Max;
    return 0;
}
import sys
from math import gcd

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:1 + n]))
if n == 1:
    print(10**9)
else:
    right = [0] * (n + 1)
    for i in range(n - 1, -1, -1):
        right[i] = gcd(right[i + 1], a[i])
    Max = 0
    g = 0
    for i in range(n):
        Max = max(Max, gcd(g, right[i + 1]))
        g = gcd(g, a[i])
    print(Max)
C++▶ Chạy tay từng dòng codeCách 3: chỉ giữ mảng right, phần UCLN bên trái gộp dần vào biến g, O(N log A).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 vector<int> a(n), right(n + 1, 0);
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 if(n == 1){
14 cout << 1000000000;
15 return 0;
16 }
17 for(int i = n - 1; i >= 0; --i){
18 right[i] = __gcd(right[i + 1], a[i]);
19 }
20 int Max = 0, g = 0;
21 for(int i = 0; i < n; ++i){
22 Max = max(Max, __gcd(g, right[i + 1]));
23 g = __gcd(g, a[i]);
24 }
25 cout << Max;
26 return 0;
27}

Vẫn là $O(N \log A)$ thời gian, nhưng chỉ còn 2 mảng $a$ và right. Thật ra cũng có thể bỏ luôn mảng $a$ bằng cách đọc dữ liệu 2 lần… nhưng đọc input chỉ được 1 lần thôi, nên 2 mảng là gọn nhất rồi 😄.

Ghi chú của mình: Cách 1, Cách 3 và các bản Python đi kèm là mình viết thêm; bản Python của Cách 2 là của anh. Tất cả các bản đã được kiểm tra với 400 bộ test ngẫu nhiên (có cả $n = 1$, $n = 2$ và các dãy nhiều bội chung) so với cách tính trực tiếp bằng định nghĩa: khớp 100%. Với $n = 10^6$, bản C++ Cách 2 và Cách 3 chạy khoảng 0.05–0.07 giây, bản Python khoảng 0.7 giây trên máy mình.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇

Nhận xét