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 →

Tặng Quà (QUA) (HSG 12 Nam Định 2021 – 2022)

Tặng Quà (QUA) (HSG 12 Nam Định 2021 – 2022)
Nguồn: Đề thi HSG Tin học 12 tỉnh Nam Định 2021 – 2022 (Bài 1) Độ khó: Dễ Số học Ước số Mảng đánh dấu

Đề bài

Bài 1. (5 điểm) Tặng quà – file chương trình QUA.*, dữ liệu vào QUA.INP, kết quả QUA.OUT.

Trong một trận đấu bóng đá, người ta bán được $N$ vé $(N \lt 3 \cdot 10^5)$. Mỗi khán giả có một vé, trên vé ghi số nguyên dương. Người ta muốn tặng quà cho những người có vé thuộc dãy số hạnh phúc. Dãy số hạnh phúc là một dãy số chỉ bao gồm các số hoàn thiện.

(Số hoàn thiện được định nghĩa là một số mà nó có tổng các ước không kể nó bằng chính nó. VD: $6$ có 3 ước thỏa mãn là $1, 2, 3$ tổng các ước là $6$.)

Yêu cầu: Từ số vé bóng đá đã cho, em hãy tìm ra những người được tặng quà thỏa mãn yêu cầu trên.

Dữ liệu: Vào từ tệp văn bản QUA.INP gồm:

  • Dòng đầu tiên là số nguyên $N$ là số lượng vé đã bán $(1 \le N \le 3 \cdot 10^5)$.
  • Dòng tiếp theo ghi $N$ số nguyên dương $A_1, A_2, \ldots, A_N$ $(0 \lt A_i \lt 3 \cdot 10^5$, $i = 1, 2, \ldots, N)$. Mỗi số cách nhau một khoảng trắng (dấu cách).

Kết quả: Ghi ra file QUA.OUT gồm:

  • Dòng đầu tiên là số lượng người được tặng quà. Nếu không có ai được tặng quà thì đưa ra số $0$.
  • Dòng thứ hai là số vé được tặng quà theo thứ tự xuất hiện trong tệp dữ liệu vào, mỗi số cách nhau một dấu cách.

Thời gian làm bài cả đề: 150 phút (4 bài). Bài được viết bằng Pascal hoặc C++. (Code trong bài đọc/ghi chuẩn – bàn phím/màn hình; khi đi thi đọc ghi file thì các bạn thêm freopen nhé, không là 0 điểm đó.)

QUA.INP
9
5 18 7 4 6 13 8 11 27
QUA.OUT
1
6

Lời mở đầu

Bài này là bài đầu tiên (cũng là bài dễ nhất) trong đề thi học sinh giỏi Tin học lớp 12 của tỉnh Nam Định năm học 2021 – 2022 mà bạn học viên mình đi thi. Sẵn bạn nhờ giải đề thì mình chịu khó viết chi tiết ra luôn. Hy 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

Đề bài thì dông dài quá, chốt lại là bài này yêu cầu các bạn đếm và liệt kê các số hoàn thiện có trong dãy số. Số hoàn thiện là số mà tổng các ước số của nó (những số mà nó chia hết), không tính chính nó, cộng lại bằng đúng nó. Ví dụ $6$ có 4 ước là $1, 2, 3, 6$; bỏ ước $6$ ra thì $1 + 2 + 3 = 6$ ⇒ $6$ là số hoàn thiện. Cứ thế xét qua toàn bộ dãy, in ra số lượng số hoàn thiện, rồi in cụ thể từng số theo thứ tự xuất hiện từ trái qua phải.

Với ví dụ: dãy $5, 18, 7, 4, 6, 13, 8, 11, 27$ chỉ có đúng số $6$ là số hoàn thiện (thử số $18$: $1 + 2 + 3 + 6 + 9 = 21 \ne 18$) ⇒ in $1$ rồi in $6$.

Nhắc lại ngưỡng TLE
Với giới hạn 1 giây, tổng số lần lặp của cả bài chỉ nên ở mức $(3 \div 5) \cdot 10^7$ là an toàn với phần lớn hệ thống chấm, cao lắm là $10^8$ (hên xui). Đó là với C/C++; Python, C#, Java chạy chậm hơn nên ngưỡng phải thấp hơn. Đề cho 2 giây thì nhân ngưỡng lên 2, 3 giây thì nhân 3, 100 ms thì chia 10, cứ thế thôi.

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

Cách 1Tính tổng ước trong O(√X) + mảng đánh dấu

Để làm được bài này, chắc chắn trước tiên phải có bước kiểm tra một số có là số hoàn thiện không, rồi gọi bước đó với từng số trong dãy, số nào thỏa thì đưa vào mảng kết quả. Sau cùng in số phần tử của mảng kết quả rồi in từng phần tử. Do đề bắt in số lượng trước rồi mới tới các số, nên ta buộc phải lưu lại kết quả; chứ nếu đề chỉ cần in các số thỏa mãn thì gặp số nào in số đó luôn, khỏi cần mảng.

Duyệt qua từng phần tử là bước không thể bỏ qua được (nó chính là bước đọc dữ liệu). Vậy tối ưu hay không là nằm ở bước kiểm tra số hoàn thiện: tính tổng các ước nhỏ hơn nó rồi so sánh. Có 3 cách liệt kê ước số với độ phức tạp tối ưu dần: duyệt $1..X$ là $O(X)$, duyệt $1..X/2$ là $O(X/2)$, và duyệt tới $\sqrt{X}$ là $O(\sqrt{X})$.

Duyệt ước tới √X

Với mỗi $j$ từ $1$ trong khi $j \cdot j \le X$: nếu $X$ chia hết cho $j$ thì ta có luôn 2 ước là $j$ và $X / j$, cộng cả hai vào tổng. Sau vòng lặp:

  • Nếu $(j - 1) \cdot (j - 1) = X$ (tức $X$ là số chính phương, $j - 1 = \sqrt{X}$) thì ước $\sqrt{X}$ bị cộng trùng 2 lần ⇒ trừ đi $j - 1$ một lần. Ví dụ $X = 36$: xét $j = 6$ thì ước còn lại $36/6$ cũng là $6$.
  • Trừ đi chính $X$, vì ngay lần đầu $j = 1$ thì ước còn lại $X / 1 = X$ đã bị cộng vào rồi.
Kiểm tra số hoàn thiện
$\text{sum} = \sum_{j \le \sqrt{X},\ j \mid X} \left(j + \dfrac{X}{j}\right) \;-\; [\,X \text{ chính phương}\,]\cdot\sqrt{X} \;-\; X$, rồi $X$ hoàn thiện $\iff \text{sum} = X$.

Em nhập một số $X$ rồi bấm Tiếp để xem từng bước $j$ chạy tới $\sqrt{X}$, cặp ước nào được cộng vào tổng:

Ví dụ nhanh:

Ước lượng độ phức tạp

Nếu cứ thế gọi hàm kiểm tra cho từng số thì với $N$ tối đa $3 \cdot 10^5$ và mỗi giá trị $X$ cũng tối đa cỡ $3 \cdot 10^5$:

Cách liệt kê ướcĐộ phức tạpTrường hợp xấu nhấtKết luận
Duyệt $1..X$$O(N \cdot X)$$9 \cdot 10^{10}$ (90 tỷ)TLE
Duyệt $1..X/2$$O(N \cdot X/2)$$4.5 \cdot 10^{10}$ (45 tỷ)TLE
Duyệt tới $\sqrt{X}$$O(N \cdot \sqrt{X})$$3 \cdot 10^5 \cdot 548 \approx 1.6 \cdot 10^8$Hên xui

Cách duyệt tới $\sqrt{X}$ đã tốt hơn hai cách trước rất nhiều, nhưng vẫn vượt ngưỡng $10^8$ gần gấp đôi. Theo quan điểm cá nhân mình, may mắn không gặp test xấu nhất thì vẫn ăn trọn điểm, còn xui thì chắc chỉ được tầm 90%.

Đã làm thì ta đều muốn làm theo cách tốt nhất đúng không các bạn? Trường hợp xấu nhất là khi $N = 3 \cdot 10^5$ và số nào cũng chạm mức lớn nhất. Mà chờ đã, các bạn có thấy điều gì chứ? 🤔 Nếu các giá trị trong mảng giống nhau như vậy thì việc gì phải chạy vòng lặp xét lại? Chỉ cần làm lần đầu tiên, các lần sau lấy kết quả đã kiểm tra ra dùng thôi.

Mảng đánh dấu check
Vì $0 \lt X \lt 3 \cdot 10^5$, ta tạo mảng check có $3 \cdot 10^5$ phần tử, ban đầu tất cả là $-1$ (chưa xét). Gặp $X$:
  • check[X] == -1: đi kiểm tra $X$ trong $O(\sqrt{X})$, thỏa thì gán check[X] = 1, không thỏa thì check[X] = 0.
  • check[X] == 1: đã biết là số hoàn thiện, đưa luôn vào kết quả trong $O(1)$.
  • check[X] == 0: đã biết không thỏa, bỏ qua.

Nhờ vậy mỗi giá trị khác nhau chỉ bị kiểm tra đúng 1 lần. Mà các giá trị khác nhau chỉ có tối đa $3 \cdot 10^5$ số, nên tổng số lần lặp không bao giờ vượt quá $\sum_{X=1}^{3\cdot 10^5} \sqrt{X} \approx \tfrac{2}{3}\,(3 \cdot 10^5)^{1.5} \approx 1.1 \cdot 10^8$ – và đó là khi dãy chứa đủ mọi giá trị khác nhau, còn dãy toàn số lớn lặp lại thì gần như không tốn gì.

Cẩn thận tràn stack
Mảng check $3 \cdot 10^5$ phần tử hay mảng a $N$ phần tử thì nên dùng mảng động (vector hoặc con trỏ cấp phát động) chứ đừng khai báo mảng tĩnh cục bộ trong hàm. Ví dụ Visual Studio chỉ cấp cho stack tầm 1 MB $= 1\,048\,576$ byte; mảng int 4 byte thì chỉ chứa được $262\,144$ phần tử là hết. Dùng short 2 byte thì chứa được $524\,288$ phần tử (vì check chỉ cần 3 giá trị $-1, 0, 1$), còn quen xài int thì cứ cấp phát động – vùng nhớ heap cho tới cỡ 1 GB, xài thoải mái.

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(false);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    vector<int> a(n);
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    vector<int> check(300000, -1);
    vector<int> res;
    for(int i = 0; i < n; ++i){
        if(check[a[i]] == -1){
            int sum = 0;
            int j;
            for(j = 1; j * j <= a[i]; ++j){
                if(a[i] % j == 0){
                    sum += j;
                    sum += a[i] / j;
                }
            }
            if((j - 1) * (j - 1) == a[i]){
                sum -= (j - 1);
            }
            sum -= a[i];
            if(sum == a[i]){
                res.push_back(a[i]);
                check[a[i]] = 1;
            }
            else{
                check[a[i]] = 0;
            }
        }
        else if(check[a[i]] == 1){
            res.push_back(a[i]);
        }
    }
    cout << (int)res.size() << endl;
    for(int i = 0; i < (int)res.size(); ++i){
        cout << res[i] << " ";
    }
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    check = [-1] * 300000
    res = []
    for x in a:
        if check[x] == -1:
            s = 0
            j = 1
            while j * j <= x:
                if x % j == 0:
                    s += j
                    s += x // j
                j += 1
            if (j - 1) * (j - 1) == x:
                s -= (j - 1)
            s -= x
            if s == x:
                res.append(x)
                check[x] = 1
            else:
                check[x] = 0
        elif check[x] == 1:
            res.append(x)
    print(len(res))
    print(*res)

main()

Giải thích một số chỗ trong code:

  1. 3 dòng đầu hàm main (ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);): hiểu cơ bản là giúp cin/cout chạy nhanh gần bằng nhập xuất của C. Bài này phải đọc tới $3 \cdot 10^5$ số nên nhớ thêm 3 dòng này vào đầu hàm main nhé.
  2. vector<int> check(300000, -1);: khởi tạo mảng $300\,000$ phần tử, tất cả mang giá trị $-1$.
  3. Biến j khai báo ngoài vòng for: để sau vòng lặp vẫn dùng được, $j - 1$ chính là $\lfloor\sqrt{X}\rfloor$ – dùng để kiểm tra số chính phương.
C++▶ Chạy tay từng dòng codeKiểm tra từng số bằng tổng ước O(√X), nhớ kết quả vào mảng check.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main() {
5 ios::sync_with_stdio(false);
6 cin.tie(0);
7 cout.tie(0);
8 int n;
9 cin >> n;
10 vector<int> a(n);
11 for(int i = 0; i < n; ++i){
12 cin >> a[i];
13 }
14 vector<int> check(300000, -1);
15 vector<int> res;
16 for(int i = 0; i < n; ++i){
17 if(check[a[i]] == -1){
18 int sum = 0;
19 int j;
20 for(j = 1; j * j <= a[i]; ++j){
21 if(a[i] % j == 0){
22 sum += j;
23 sum += a[i] / j;
24 }
25 }
26 if((j - 1) * (j - 1) == a[i]){
27 sum -= (j - 1);
28 }
29 sum -= a[i];
30 if(sum == a[i]){
31 res.push_back(a[i]);
32 check[a[i]] = 1;
33 }
34 else{
35 check[a[i]] = 0;
36 }
37 }
38 else if(check[a[i]] == 1){
39 res.push_back(a[i]);
40 }
41 }
42 cout << (int)res.size() << endl;
43 for(int i = 0; i < (int)res.size(); ++i){
44 cout << res[i] << " ";
45 }
46 return 0;
47}

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

  • Không gian: $O(N + V)$ với $V = 3 \cdot 10^5$ là kích thước mảng đánh dấu.
  • Thời gian: mỗi giá trị khác nhau chỉ kiểm tra 1 lần trong $O(\sqrt{X})$, các lần gặp lại chỉ $O(1)$ ⇒ tối đa cỡ $N + 1.1 \cdot 10^8$ phép lặp trong trường hợp xấu nhất (dãy gồm toàn số khác nhau và lớn), thực tế phép lặp rất đơn giản nên C++ chạy chưa tới nửa giây.
Không gian$O(N + V)$
Thời gian$O(N + \sum \sqrt{X_{\text{khác nhau}}})$
Lưu ý với Python
Bản Python của cách 1 đúng nhưng chậm: với test xấu nhất ($3 \cdot 10^5$ số khác nhau) mình đo mất khoảng 14 giây. Bạn nào dùng Python thì xài cách 2 bên dưới nhé.

Cách 2Nhận xét: chỉ có 4 số hoàn thiện – O(N)

Thực ra bài này giải như trên là chưa tối ưu đâu các bạn nha. Ta có thể làm tốt hơn nữa cả về thời gian, bộ nhớ lẫn code trình bày.

Bạn nào từng làm qua sẽ biết các số hoàn thiện rất ít, không nhiều như số nguyên tố đâu. 4 số hoàn thiện nhỏ nhất là $6, 28, 496, 8128$, còn số tiếp theo đã là $33\,550\,336$. Vậy nên với $0 \lt A_i \lt 3 \cdot 10^5$ thì yên tâm chỉ có đúng 4 trường hợp:

Số hoàn thiệnCác ước nhỏ hơn nó
$6$$1 + 2 + 3$
$28$$1 + 2 + 4 + 7 + 14$
$496$$1 + 2 + 4 + 8 + 16 + 31 + 62 + 124 + 248$
$8128$$1 + 2 + 4 + 8 + 16 + 32 + 64 + 127 + 254 + 508 + 1016 + 2032 + 4064$

Đến đây mọi thứ đơn giản hẳn: với mỗi giá trị đọc vào, không cần mảng đánh dấu hay vòng lặp gì cả, chỉ một câu if xem nó có rơi vào 4 số này không, có thì đưa vào mảng kết quả. Thậm chí chẳng cần mảng lưu dãy đầu vào, vừa đọc vừa kiểm tra luôn, tiết kiệm thêm bộ nhớ hihi.

Mẹo "chạy trước ở nhà"
Thực ra ngay khi đọc đề là mình đã nhìn ra cách này rồi, vì hồi sinh viên rảnh rỗi mình từng viết code liệt kê các số hoàn thiện nhỏ hơn 1 triệu và vẫn nhớ chỉ có 4 số này thôi 😁 (search "List Perfect Numbers" là ra ngay bảng). Kể cả không biết điều này, hay đề đổi thành kiểm tra một quy luật nào khác, thì khi thấy giá trị chỉ từ $0$ đến $3 \cdot 10^5$, mình sẽ chạy trước ở máy để biết số nào thỏa, rồi nạp sẵn vào chương trình (mảng đánh dấu true/false) – khi xét chỉ cần tra là biết ngay, khỏi lặp gì cả.

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(false);
    cin.tie(0);
    cout.tie(0);
    int n, x;
    cin >> n;
    vector<int> res;
    while(n--){
        cin >> x;
        if(x == 6 || x == 28 || x == 496 || x == 8128){
            res.push_back(x);
        }
    }
    cout << (int)res.size() << endl;
    for(int i = 0; i < (int)res.size(); ++i){
        cout << res[i] << " ";
    }
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    res = []
    for i in range(1, n + 1):
        x = int(data[i])
        if x == 6 or x == 28 or x == 496 or x == 8128:
            res.append(x)
    print(len(res))
    print(*res)

main()

while(n--) là cách viết gọn của vòng lặp chạy đúng $n$ lần (mỗi lần kiểm tra $n \ne 0$ rồi giảm $n$ đi 1). Mỗi lần lặp đọc một giá trị vào $x$, kiểm tra $x$ có là 1 trong 4 số thỏa mãn không, thỏa thì đưa vào mảng kết quả; sau cùng in mảng kết quả.

C++▶ Chạy tay từng dòng codeVừa đọc vừa so với 4 số hoàn thiện 6, 28, 496, 8128.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main() {
5 ios::sync_with_stdio(false);
6 cin.tie(0);
7 cout.tie(0);
8 int n, x;
9 cin >> n;
10 vector<int> res;
11 while(n--){
12 cin >> x;
13 if(x == 6 || x == 28 || x == 496 || x == 8128){
14 res.push_back(x);
15 }
16 }
17 cout << (int)res.size() << endl;
18 for(int i = 0; i < (int)res.size(); ++i){
19 cout << res[i] << " ";
20 }
21 return 0;
22}

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

  • Không gian: $O(K)$ với $K$ là số phần tử của mảng kết quả.
  • Thời gian: $O(N)$ đọc dữ liệu + $O(K)$ in kết quả; $K$ chỉ là tập con của $N$, theo quy tắc cộng Big-O lấy thành phần lớn nhất ⇒ $O(N)$. Xấu nhất toàn bộ đều là số hoàn thiện thì lặp $2N = 6 \cdot 10^5$ lần, ít hơn ngưỡng cả 100 đến 200 lần, yên tâm không thể TLE.
Không gian$O(K)$
Thời gian$O(N)$

Đến đây các bạn đã thấy cái hay của giải thuật chưa? Từ cách làm cần lặp hết 90 tỷ lần, rút xuống 45 tỷ, rồi gần 200 triệu, cuối cùng chỉ còn 600 ngàn lần lặp. Quá là tuyệt vời đúng không các bạn 😍

Ghi chú của mình: code C++ và Python của cả 2 cách đã được kiểm tra với 400 bộ test ngẫu nhiên ($N \le 30$, có trộn nhiều số hoàn thiện và các số sát bên), so với code vét cạn tính tổng ước từ $1$ đến $X - 1$ – khớp hết. Với $N = 3 \cdot 10^5$ toàn số khác nhau (test xấu nhất của cách 1): cách 1 bản C++ chạy khoảng 0.35 giây, bản Python khoảng 14 giây; cách 2 cả C++ lẫn Python đều dưới 0.4 giây.
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