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
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Ước Chung Lớn Nhất (GCD – Bài 4, đề HSG)

Ước Chung Lớn Nhất (GCD – Bài 4, đề HSG)
Nguồn: Đề tuyển sinh lớp 10 chuyên Tin – PTNK 2019-2020 (Bài 4) Độ khó: Khá GCD Cửa sổ trượt Chia cụm

Đề bài

Bài 4. ƯỚC CHUNG LỚN NHẤT (2.5 điểm)

Ước chung lớn nhất của một tập $A$ các số nguyên dương là số nguyên dương $d$ lớn nhất sao cho $d$ là ước của mọi số trong tập $A$.

Yêu cầu: Cho dãy gồm $n$ số nguyên $a_1, a_2, \dots, a_n$ và số $k$ $(2 \le k \le n)$. Hãy tìm một đoạn liên tiếp các phần tử $a_i, a_{i+1}, \dots, a_{i+k-1}$ sao cho ước chung lớn nhất của các phần tử này là lớn nhất.

Dữ liệu: Vào từ file văn bản GCD.INP

  • Dòng đầu tiên chứa hai số nguyên $n$ và $k$ $(2 \le k \le n \le 10^5)$.
  • Dòng thứ hai chứa $n$ số nguyên $a_1, a_2, \dots, a_n$ $(1 \le a_i \le 10^{18})$.

Kết quả: Ghi ra file văn bản GCD.OUT một số nguyên là kết quả của bài toán.

Ràng buộc: 50% số test đầu tiên tương ứng với $n \le 1000$.

Lưu ý: trên hệ thống chấm online, chương trình đọc dữ liệu từ bàn phím (stdin) và ghi ra màn hình (stdout), không dùng file.

Ví dụ – GCD.INP
10 3
2 6 4 3 18 12 24 8 7 5
Ví dụ – GCD.OUT
6

Giải thích: Trong tất cả các bộ 3 phần tử liên tiếp của dãy $(2, 6, 4, 3, 18, 12, 24, 8, 7, 5)$ thì ba phần tử liên tiếp $(18, 12, 24)$ có ước chung lớn nhất bằng $6$ là lớn nhất.

Lời mở đầu

Bài này là bài cuối cùng (cũng là bài khó nhất) trong đề thi tuyển sinh đầu vào lớp 10 chuyên Tin của trường Phổ Thông Năng Khiếu năm 2019 – 2020. Mình được một em học viên hỏi và đã hướng dẫn chi tiết cho em ấy, tin rằng sẽ có giá trị với nhiều bạn nên mình viết lại thành bài giải đầy đủ ở đây. Hi vọng mang đến được giá trị cho các bạn 🥰.

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Trước tiên các bạn cần biết: trong C++ đã có sẵn hàm tìm ước chung lớn nhất của 2 số nguyên: __gcd(a, b) (ở đầu là 2 dấu gạch dưới), độ phức tạp $O(\log(\min(a, b)))$. Python thì có math.gcd(a, b).

Vậy cần tìm ước chung lớn nhất của nhiều số thì sao? Ví dụ 3 số $a, b, c$ thì chính là __gcd(__gcd(a, b), c). Cứ "gộp" dần như vậy cho nhiều số thì kết quả sau cùng chính là ước chung lớn nhất của cả dãy.

Tính chất quan trọng
$\gcd$ có tính kết hợp và giao hoán: $\gcd(a, b, c, d) = \gcd(\gcd(a, b), \gcd(c, d))$. Nghĩa là muốn tính GCD của một đoạn, ta có thể cắt đoạn đó làm 2 phần bất kỳ, tính GCD từng phần rồi gcd hai kết quả lại. Đây chính là chìa khoá của cách 2.

Mình giải thích ví dụ mẫu cho bạn nào chưa hiểu đề: $n = 10$, $k = 3$, ta có $n - k + 1 = 8$ đoạn con liên tiếp độ dài 3:

ĐoạnCác phần tửGCD
1 → 3{2, 6, 4}2
2 → 4{6, 4, 3}1
3 → 5{4, 3, 18}1
4 → 6{3, 18, 12}3
5 → 7{18, 12, 24}6
6 → 8{12, 24, 8}4
7 → 9{24, 8, 7}1
8 → 10{8, 7, 5}1

Trong các GCD tìm được $\{2, 1, 1, 3, 6, 4, 1, 1\}$ thì $6$ là lớn nhất nên đáp án in ra $6$.

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

Cách 1Vét cạn từng đoạn – O((N−K+1)·K·log A)

Ý tưởng đơn giản nhất: xét qua tất cả các đoạn con liên tiếp có độ dài $K$, với mỗi đoạn thì đi tìm GCD của nó bằng cách gộp dần từng phần tử, và kết quả là giá trị lớn nhất trong tất cả các GCD đó – đúng y như bảng ở trên.

Mình dùng 2 biến l, r là đầu và cuối đoạn: bắt đầu từ l = 1, r = k, mỗi lần xong một đoạn thì cùng tăng l++, r++ để "trượt" sang đoạn kế tiếp, cho tới khi r vượt quá n.

Vì sao Max khởi tạo bằng 1?
Mọi $a_i \ge 1$ nên GCD của đoạn nào cũng $\ge 1$. Khởi tạo Max = 1 là an toàn, đáp án không bao giờ nhỏ hơn 1.

Source code để các bạn tham khảo nếu làm theo hướng này:

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

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, k;
    cin >> n >> k;
    vector<long long> a(n + 1);
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    int l = 1, r = k;
    long long Max = 1;
    while(r <= n){
        long long x = a[l];
        for(int i = l + 1; i <= r; ++i){
            x = __gcd(x, a[i]);
        }
        Max = max(Max, x);
        l++;
        r++;
    }
    cout << Max;
    return 0;
}
import sys
import math

input_data = sys.stdin.read().split()
idx = 0
n = int(input_data[idx])
idx += 1
k = int(input_data[idx])
idx += 1
a = [0] * (n + 1)
for i in range(1, n + 1):
    a[i] = int(input_data[idx])
    idx += 1
l = 1
r = k
Max = 1
while r <= n:
    x = a[l]
    for i in range(l + 1, r + 1):
        x = math.gcd(x, a[i])
    Max = max(Max, x)
    l += 1
    r += 1
print(Max)
C++▶ Chạy tay từng dòng codeCách 1: với mỗi đoạn [l, r] độ dài k, gộp gcd từng phần tử một rồi cập nhật Max.
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, k;
8 cin >> n >> k;
9 vector<long long> a(n + 1);
10 for(int i = 1; i <= n; ++i){
11 cin >> a[i];
12 }
13 int l = 1, r = k;
14 long long Max = 1;
15 while(r <= n){
16 long long x = a[l];
17 for(int i = l + 1; i <= r; ++i){
18 x = __gcd(x, a[i]);
19 }
20 Max = max(Max, x);
21 l++;
22 r++;
23 }
24 cout << Max;
25 return 0;
26}

Đánh giá độ phức tạp của cách làm này:

  • Không gian: chỉ cần mảng $a$ nên là $O(N)$.
  • Thời gian: xét qua $N - K + 1$ đoạn con, mỗi đoạn có $K$ phần tử và gọi hàm gcd liên tục, nên là $O((N-K+1) \cdot K \cdot \log A)$ với $A$ là giá trị lớn nhất của $a_i$.
Không gian$O(N)$
Thời gian$O((N-K+1) \cdot K \cdot \log A)$
Chỉ ăn được 50% số test
Với $N \le 1000$ thì chạy vô tư. Nhưng lỡ $N = 10^5$ và $K = N/2 = 5 \cdot 10^4$ thì $N - K + 1 \approx 5 \cdot 10^4$, nên $(N-K) \cdot K = 5 \cdot 10^4 \cdot 5 \cdot 10^4 = 25 \cdot 10^8$. Còn $a_i$ tối đa $10^{18}$ thì $\log(a_i) \approx 60$. Tổng cộng cỡ $25 \cdot 10^8 \cdot 60 = 15 \cdot 10^{10}$, tức là 150 tỷ phép tính, vượt xa ngưỡng ~$10^8$ phép tính/giây ⇒ chắc chắn TLE. Ta cần một cách tối ưu hơn.

Cách 2Chia cụm K phần tử, ghép GCD trái/phải – O(N·log A)

Bây giờ mình chia mảng $N$ phần tử thành các cụm $K$ phần tử liên tiếp. Ví dụ $N = 20$, $K = 5$ thì có 4 cụm: $[1..5]$, $[6..10]$, $[11..15]$, $[16..20]$ (nếu $N$ không chia hết cho $K$ thì cụm cuối ngắn hơn, không sao cả).

Ta tạo 2 mảng left và right, mỗi mảng có $N$ phần tử, nhưng tính riêng trong từng cụm:

  • left[i] = GCD từ đầu cụm chứa $i$ tới $i$ (đi từ trái qua phải). Ở cụm $[1..5]$: left[1] = a[1], left[2] = gcd(left[1], a[2]), …, left[5] = gcd(left[4], a[5]). Sang cụm mới thì bắt đầu lại: left[6] = a[6] chứ không phải gcd(left[5], a[6]) đâu nha.
  • right[i] = GCD từ $i$ tới cuối cụm chứa $i$ (đi từ phải qua trái). Ở cụm $[1..5]$: right[5] = a[5], right[4] = gcd(a[4], right[5]), …, right[1] = gcd(a[1], right[2]). Cụm $[6..10]$ lại bắt đầu từ right[10] = a[10].

Có 2 mảng này rồi thì nhìn lại các đoạn cần tính GCD với $N = 20$, $K = 5$. Một đoạn độ dài $K$ bất kỳ hoặc trùng khít một cụm, hoặc vắt qua đúng một ranh giới giữa 2 cụm liền nhau:

Đoạn cần tínhBản chất làDùng mảng
a[1..5]trùng khít cụm 1left[5]
a[2..6]gcd( a[2..5] , a[6..6] )gcd(right[2], left[6])
a[3..7]gcd( a[3..5] , a[6..7] )gcd(right[3], left[7])
a[4..8]gcd( a[4..5] , a[6..8] )gcd(right[4], left[8])
a[5..9]gcd( a[5..5] , a[6..9] )gcd(right[5], left[9])
a[6..10]trùng khít cụm 2left[10]
a[7..11]gcd( a[7..10] , a[11..11] )gcd(right[7], left[11])
………
Công thức cho đoạn [l, r] với r = l + K − 1
  • Nếu $r \bmod K = 0$: đoạn trùng khít một cụm ⇒ GCD là left[r].
  • Ngược lại: đoạn vắt qua ranh giới ⇒ GCD là gcd(right[l], left[r]). Vì right[l] là GCD từ $l$ tới cuối cụm của $l$, còn left[r] là GCD từ đầu cụm của $r$ tới $r$ – hai phần này ghép lại vừa khít đoạn $[l, r]$.

Vì sao đoạn độ dài $K$ không bao giờ vắt qua 2 ranh giới? Vì giữa 2 ranh giới là trọn một cụm $K$ phần tử, đoạn phải dài ít nhất $K + 2$ mới vắt qua được cả hai. Nên mỗi đoạn chỉ cần 1 lần gọi gcd là xong 😎.

Để ý điều kiện bắt đầu/kết thúc cụm
  • Vị trí $i$ là đầu cụm khi i % k == 1 (các vị trí $1, K+1, 2K+1, \dots$). Riêng khi $K = 1$ thì không có trường hợp này, nhưng đề cho $K \ge 2$ nên yên tâm.
  • Vị trí $i$ là cuối cụm khi i % k == 0 hoặc i == n – vì cụm cuối có thể bị thiếu (khi $N$ không chia hết cho $K$), lúc đó phần tử $a_n$ là cuối cụm.

Minh hoạ mảng left / right và các đoạn

Em nhập dãy $a$ và $k$ rồi bấm Tiếp để xem từng bước: dựng left (trái → phải trong mỗi cụm), dựng right (phải → trái), rồi trượt từng đoạn và lấy đúng 2 ô cần dùng:

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

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

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, k;
    cin >> n >> k;
    vector<long long> a(n + 1), left(n + 1), right(n + 1);
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
        if(i % k == 1){
            left[i] = a[i];
        }
        else{
            left[i] = __gcd(left[i - 1], a[i]);
        }
    }
    for(int i = n; i >= 1; --i){
        if(i % k == 0 || i == n){
            right[i] = a[i];
        }
        else{
            right[i] = __gcd(right[i + 1], a[i]);
        }
    }
    int l = 1, r = k;
    long long Max = 1;
    while(r <= n){
        if(r % k == 0){
            Max = max(Max, left[r]);
        }
        else{
            Max = max(Max, __gcd(right[l], left[r]));
        }
        l++;
        r++;
    }
    cout << Max;
    return 0;
}
import sys
import math

input_data = sys.stdin.read().split()
idx = 0
n = int(input_data[idx])
idx += 1
k = int(input_data[idx])
idx += 1
a = [0] * (n + 1)
left = [0] * (n + 1)
right = [0] * (n + 1)
for i in range(1, n + 1):
    a[i] = int(input_data[idx])
    idx += 1
    if i % k == 1:
        left[i] = a[i]
    else:
        left[i] = math.gcd(left[i - 1], a[i])
for i in range(n, 0, -1):
    if i % k == 0 or i == n:
        right[i] = a[i]
    else:
        right[i] = math.gcd(right[i + 1], a[i])
l = 1
r = k
Max = 1
while r <= n:
    if r % k == 0:
        Max = max(Max, left[r])
    else:
        Max = max(Max, math.gcd(right[l], left[r]))
    l += 1
    r += 1
print(Max)
C++▶ Chạy tay từng dòng codeCách 2: dựng left (trái→phải) và right (phải→trái) theo từng cụm k phần tử, mỗi đoạn chỉ cần 1 lần gcd.
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, k;
8 cin >> n >> k;
9 vector<long long> a(n + 1), left(n + 1), right(n + 1);
10 for(int i = 1; i <= n; ++i){
11 cin >> a[i];
12 if(i % k == 1){
13 left[i] = a[i];
14 }
15 else{
16 left[i] = __gcd(left[i - 1], a[i]);
17 }
18 }
19 for(int i = n; i >= 1; --i){
20 if(i % k == 0 || i == n){
21 right[i] = a[i];
22 }
23 else{
24 right[i] = __gcd(right[i + 1], a[i]);
25 }
26 }
27 int l = 1, r = k;
28 long long Max = 1;
29 while(r <= n){
30 if(r % k == 0){
31 Max = max(Max, left[r]);
32 }
33 else{
34 Max = max(Max, __gcd(right[l], left[r]));
35 }
36 l++;
37 r++;
38 }
39 cout << Max;
40 return 0;
41}

Đánh giá độ phức tạp của cách làm này:

  • Không gian: 3 mảng a, left, right nên là $O(N)$.
  • Thời gian: dựng 2 mảng left, right chỉ duyệt qua $N$ phần tử, mỗi phần tử gọi gcd 1 lần ⇒ $O(N \cdot \log A)$. Bước xét $N - K + 1$ đoạn, mỗi đoạn gọi gcd đúng 1 lần ⇒ $O((N - K) \cdot \log A)$. Lấy thành phần lớn nhất ⇒ tổng là $O(N \cdot \log A)$.

Với $N = 10^5$, $\log(10^{18}) \approx 60$ thì $N \cdot \log A = 6 \cdot 10^6$, tức là chỉ khoảng 6 triệu phép tính, nhỏ hơn rất nhiều so với ngưỡng 100 triệu/giây nên yên tâm không bị TLE.

Không gian$O(N)$
Thời gian$O(N \cdot \log A)$
Nhớ dùng long long
$a_i$ lên tới $10^{18}$ nên mảng phải là long long (tối đa khoảng $9.2 \cdot 10^{18}$), dùng int là tràn ngay. Biến Max cũng phải là long long.
Mở rộng
Kỹ thuật "chia cụm K + tiền tố/hậu tố trong cụm" này dùng được cho mọi phép toán có tính kết hợp trên cửa sổ trượt cố định độ dài $K$: GCD, min, max, AND, OR… mà không cần cấu trúc dữ liệu phức tạp như Sparse Table hay Segment Tree.
Ghi chú của mình: code C++ và Python của cả 2 cách ở trên đã được kiểm tra với 500 bộ test ngẫu nhiên nhỏ ($n \le 12$, có cả số tới $10^{18}$), so với code vét cạn viết riêng – khớp hết. Với test lớn nhất $n = 10^5$, $k = 5 \cdot 10^4$, $a_i$ cỡ $10^{18}$: cách 2 (C++) chạy khoảng 0.3 giây, bản Python khoảng 2 giây; còn cách 1 (C++) chạy hơn 30 giây vẫn chưa xong ⇒ TLE đúng như phân tích.
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 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét