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 →

Đếm Phép Nhân (Thi thử HSG9 SQRTOJ 2025 – Ngày 1 Bài 3)

Đếm Phép Nhân (Thi thử HSG9 SQRTOJ 2025 – Ngày 1 Bài 3)
Nguồn: Thi thử HSG9 SQRTOJ 2025 – Ngày 1 Bài 3 Độ khó: Khó Quy hoạch động Bitmask Tổ hợp Chia căn

Đề bài

Câu 3. ĐẾM PHÉP NHÂN (6 điểm)

Mã nguồnDữ liệu vàoDữ liệu raThời gianBộ nhớ
MCOUNT.*MCOUNT.inpMCOUNT.out1 giây1024 MB

Quang có một dãy số nguyên dương $a_1, a_2, \ldots, a_n$. Quang đố Linh viết một chương trình đếm số lượng dãy con $b_1, b_2, \ldots, b_k$ của $a$ sao cho tích $b_1 \times b_2 \times \ldots \times b_k$ không vượt quá một số $x$ cố định.

Do Linh mới học lập trình nên chương trình của Linh phải mất 69420ms mới có thể đưa ra kết quả. Liệu bạn có thể viết một chương trình chỉ chạy trong 694ms hay không?

Nhắc lại: một dãy $b$ là một dãy con của dãy $a$ khi và chỉ khi có thể xóa một số phần tử trong $a$ và giữ nguyên thứ tự các phần tử còn lại để thu được dãy $b$.

Yêu cầu: Bạn hãy viết một chương trình kiểm tra xem kết quả chương trình của Linh đưa ra có đúng hay không.

Dữ liệu vào: Nhập từ file MCOUNT.inp:

  • Dòng đầu tiên gồm hai số nguyên dương $n, x$ $(1 \le n \le 10^5,\ 1 \le x \le 2 \times 10^5)$.
  • Dòng tiếp theo gồm $n$ số nguyên dương $a_1, a_2, \ldots, a_n$ $(1 \le a_i \le x)$.

Dữ liệu ra: Ghi ra file MCOUNT.out:

  • Một dòng duy nhất là số lượng dãy con thỏa mãn. Vì kết quả có thể rất lớn nên chỉ cần in ra phần dư của nó khi chia cho $10^9 + 7$.

Ràng buộc bổ sung:

  • $20\%$ số điểm có $n \le 20$.
  • $20\%$ số điểm khác có $a_i = 1$.
  • $20\%$ số điểm khác có $n, x \le 5000$.
  • $15\%$ số điểm khác có $a_i$ là lũy thừa của $2$.
  • $15\%$ số điểm khác có $a_i = i$.
  • $10\%$ số điểm còn lại không có giới hạn gì thêm.

Lưu ý: đề gốc đọc/ghi file MCOUNT.inp / MCOUNT.out; nộp trên web (SQRTOJ) thì đọc từ bàn phím và in ra màn hình. Code bên dưới có đoạn if(fopen(...)) nên nộp kiểu nào cũng chạy đúng.

MCOUNT.inp
4 6
2 3 3 4
MCOUNT.out
6

Giải thích (theo đề): Các dãy con thỏa mãn là:

  • 1 phần tử: $(2), (3), (3), (4)$.
  • 2 phần tử: $(2, 3), (2, 3)$.

Không có dãy con có 3 hoặc 4 phần tử nào thỏa mãn điều kiện.

Lời mở đầu

Đây là bài 3 ngày thi thứ nhất trong đợt thi thử HSG lớp 9 năm 2025 của câu lạc bộ SQRT. Đề có tới 6 subtask, mỗi subtask lại "gợi ý" một hướng đi khác nhau: duyệt bitmask, đếm tập con, quy hoạch động, bài toán cái túi, duyệt bội số và cuối cùng là kỹ thuật chia căn. Vì vậy mình viết lời giải theo đúng từng subtask để các bạn thấy được tư duy đi lên từng nấc nha. Bạn Linh mất 69420ms, mình với các bạn cố gắng xuống dưới 694ms nhé 😆. Hy vọng mang đến được giá trị cho các bạn 😇.

Mình giải thích ví dụ để bạn nào chưa hiểu đề sẽ hiểu rõ. Điểm quan trọng nhất: dãy con được phân biệt theo vị trí, không phải theo giá trị. Dãy $a = (2, 3, 3, 4)$ có hai số $3$ ở vị trí 2 và 3, nên $(3)$ được đếm 2 lần, $(2, 3)$ cũng được đếm 2 lần. Nói cách khác, ta đang đếm số tập con khác rỗng các chỉ số sao cho tích các phần tử tương ứng không vượt quá $x$. Thứ tự trong dãy con không ảnh hưởng tới tích, nên ta cứ nghĩ theo kiểu "chọn hay không chọn từng phần tử" là được.

Dãy con (theo vị trí)Tích$\le 6$?
(2) · (3)vt 2 · (3)vt 3 · (4)2 · 3 · 3 · 4Có, 4 dãy
(2, 3)vt 1,2 · (2, 3)vt 1,36 · 6Có, 2 dãy
(2, 4) · (3, 3) · (3, 4) · (3, 4)8 · 9 · 12 · 12Không
Các dãy 3, 4 phần tử$\ge 18$Không

Tổng cộng $4 + 2 = 6$ dãy con.

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

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

Subtask 1Duyệt mọi dãy con bằng bitmask – O(2ⁿ · n)

Với $n \le 20$, số dãy con khác rỗng là $2^n - 1 \approx 10^6$, đủ nhỏ để thử hết. Ta sẽ duyệt hết tất cả cách chọn dãy (không rỗng) có thể và kiểm tra xem tích của nó có nhỏ hơn hoặc bằng $x$ hay không.

Cách liệt kê quen thuộc là dùng bitmask: mỗi số $j$ từ $1$ đến $2^n - 1$ ứng với một dãy con, bit thứ $i$ của $j$ bằng $1$ nghĩa là có chọn phần tử $a_{i+1}$. Hàm testBit(j, i) trả về bit thứ $i$ của $j$ bằng phép j & (1 << i).

Ý chính
Với mỗi mặt nạ $j$, nhân dồn $g$ các $a_{i+1}$ có bit $i$ bật. Ngay khi $g \gt x$ thì break luôn – vừa đỡ tốn thời gian, vừa đảm bảo $g$ không bao giờ lớn quá $x \cdot \max a_i \le 4 \cdot 10^{10}$ (vẫn vừa long long). Cuối cùng nếu $g \le x$ thì tăng biến đếm sl.

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

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

int n, x;
int a[100005];
long long MOD = 1e9 + 7;

bool testBit(int x, int k){
    return x & (1 << k);
}

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    long long sl = 0;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
    }
    for (int j = 1; j < (1 << n); j ++){
        int g = 1;
        for (int i = 0; i < n; i ++){
            if (testBit(j, i)) g *= a[i + 1];
            if (g > x) break;
        }
        if (g <= x) sl ++;
    }
    cout << sl;
}
import sys, os

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    a = [0] + [int(v) for v in data[2:2 + n]]
    sl = 0
    for j in range(1, 1 << n):
        g = 1
        for i in range(n):
            if (j >> i) & 1:
                g *= a[i + 1]
            if g > x:
                break
        if g <= x:
            sl += 1
    print(sl)

main()
sys.stdout.flush()

Ở subtask này kết quả không vượt quá $2^{20}$ nên chưa cần chia lấy dư. Các bạn bấm Tiếp để chạy tay từng dòng nha, phần "Dữ liệu" tô xanh những phần tử đang được chọn trong mặt nạ $j$:

C++▶ Chạy tay từng dòng codeSubtask 1: duyệt mọi mặt nạ j từ 1 đến 2^n - 1, nhân dồn các phần tử được chọn, vượt x thì break.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7long long MOD = 1e9 + 7;
8
9bool testBit(int x, int k){
10 return x & (1 << k);
11}
12
13signed main(){
14 ios_base::sync_with_stdio(false);
15 cin.tie(0);
16 if(fopen("MCOUNT.inp", "r")){
17 freopen("MCOUNT.inp", "r", stdin);
18 freopen("MCOUNT.out", "w", stdout);
19 }
20 cin >> n >> x;
21 long long sl = 0;
22 for (int i = 1; i <= n; i ++){
23 cin >> a[i];
24 }
25 for (int j = 1; j < (1 << n); j ++){
26 int g = 1;
27 for (int i = 0; i < n; i ++){
28 if (testBit(j, i)) g *= a[i + 1];
29 if (g > x) break;
30 }
31 if (g <= x) sl ++;
32 }
33 cout << sl;
34}

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

  • Không gian: chỉ lưu mảng $a$: $O(n)$.
  • Thời gian: có $2^n$ mặt nạ, mỗi mặt nạ duyệt tối đa $n$ bit: $O(2^n \cdot n)$. Với $n = 20$ là khoảng $2 \cdot 10^7$ phép tính, ổn.
Không gian$O(n)$
Thời gian$O(2^n \cdot n)$
Chỉ dùng được khi n ≤ 20
Chỉ cần $n = 40$ thôi là $2^{40} \approx 10^{12}$ mặt nạ, chạy cả ngày không xong. Với $n = 10^5$ thì khỏi bàn 😅.

Subtask 2Tất cả aᵢ = 1 – đáp án là 2ⁿ − 1

Nếu tất cả các $a_i = 1$, thì có nghĩa là tất cả những tập con không rỗng có thể thu được từ $a$ đều thoả mãn yêu cầu đề bài, vì tích của chúng luôn bằng $1$ nên không quá $x$. Như vậy, ta chỉ cần tính số tập con không rỗng của $a$.

Giả sử ta đã có được số lượng tập con thu được từ $i - 1$ phần tử đầu, khi duyệt đến phần tử thứ $i$ sẽ có hai trường hợp:

  • Không chứa phần tử thứ $i$.
  • Chứa phần tử thứ $i$.

Do mỗi tập cũ có hai lựa chọn nên số tập con thu được từ $i$ phần tử đầu sẽ gấp đôi số tập con thu được từ $i - 1$ phần tử đầu. Bắt đầu với $1$ (tập rỗng), sau $n$ lần gấp đôi ta có $2^n$ tập con.

Công thức
Kết quả là $2^n - 1$ (không tính tập rỗng), nhớ lấy dư cho $10^9 + 7$ sau mỗi lần nhân đôi. Khi in ra, viết (sl - 1 + MOD) % MOD để phòng trường hợp sl sau khi lấy dư bằng $0$ thì trừ $1$ sẽ ra số âm.

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

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

int n, x;
int a[100005];
long long MOD = 1e9 + 7;

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    long long sl = 1;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
        sl *= 2;
        sl %= MOD;
    }
    cout << (sl - 1 + MOD) % MOD << endl;
}
import sys, os

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    MOD = 10**9 + 7
    sl = 1
    for i in range(n):
        sl = sl * 2 % MOD
    print((sl - 1 + MOD) % MOD)

main()
sys.stdout.flush()
C++▶ Chạy tay từng dòng codeSubtask 2: mỗi phần tử làm số tập con gấp đôi, đáp án 2^n - 1 (mod 10^9 + 7).
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7long long MOD = 1e9 + 7;
8
9signed main(){
10 ios_base::sync_with_stdio(false);
11 cin.tie(0);
12 if(fopen("MCOUNT.inp", "r")){
13 freopen("MCOUNT.inp", "r", stdin);
14 freopen("MCOUNT.out", "w", stdout);
15 }
16 cin >> n >> x;
17 long long sl = 1;
18 for (int i = 1; i <= n; i ++){
19 cin >> a[i];
20 sl *= 2;
21 sl %= MOD;
22 }
23 cout << (sl - 1 + MOD) % MOD << endl;
24}
Không gian$O(n)$ (thực ra $O(1)$ nếu không lưu mảng)
Thời gian$O(n)$

Subtask 3Quy hoạch động f[i][j] – O(n · x)

Ở subtask này $n, x \le 5000$, ta có thể dùng một bảng quy hoạch động kích thước $n \times x$.

Định nghĩa
Ta gọi $f[i][j]$ là số lượng cách chọn tập con từ $i$ phần tử đầu sao cho tích là $j$ (tính cả tập rỗng, quy ước tích của tập rỗng là $1$). Khởi tạo $f[0][1] = 1$.

Khi xét tới phần tử thứ $i$, có công thức truy hồi sau:

  • Cách chọn tập không chứa phần tử $i$: tích vẫn là $j$, có $f[i - 1][j]$ cách.
  • Cách chọn tập chứa phần tử $i$: chỉ có thể khi $j$ chia hết cho $a_i$, khi đó phần còn lại phải có tích $\dfrac{j}{a_i}$, có $f[i - 1]\left[\dfrac{j}{a_i}\right]$ cách.
f[i][j] = f[i−1][j] + (j % a[i] == 0 ? f[i−1][j / a[i]] : 0)

Đáp án là $\left(\sum_{j=1}^{x} f[n][j]\right) - 1$ (trừ đi tập rỗng nằm trong $f[n][1]$). Vì sao chỉ cần $j \le x$? Vì ta chỉ quan tâm những tích không vượt quá $x$, và một tập có tích $\gt x$ thì thêm phần tử nào vào (đều $\ge 1$) cũng vẫn $\gt x$, nên bỏ qua hoàn toàn được.

Các bạn nhập thử dữ liệu nhỏ vào khung minh hoạ dưới đây và bấm Tiếp: mỗi bước điền thêm một hàng của bảng $f$, ô xanh là ô được "cộng thêm" nhờ lấy phần tử $a_i$, ô tím ở hàng trên là ô nguồn $f[i-1][j / a_i]$:

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>
#define int long long
using namespace std;

int n, x;
int a[100005];
int f[5005][5005];
long long MOD = 1e9 + 7;

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    long long sl = 0;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
    }
    f[0][1] = 1;
    for (int i = 1; i <= n; i ++){
        for (int j = 1; j <= x; j ++){
            f[i][j] = f[i - 1][j];
            if (j % a[i] == 0) f[i][j] += f[i - 1][j / a[i]];
            f[i][j] %= MOD;
        }
    }
    for (int i = 1; i <= x; i ++){
        sl += f[n][i];
        sl %= MOD;
    }
    cout << (sl - 1 + MOD) % MOD << endl;
}
import sys, os

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    a = [0] + [int(v) for v in data[2:2 + n]]
    MOD = 10**9 + 7
    # prev = f[i - 1][...], cur = f[i][...]
    prev = [0] * (x + 1)
    prev[1] = 1
    for i in range(1, n + 1):
        cur = prev[:]                      # f[i][j] = f[i-1][j]
        ai = a[i]
        for j in range(ai, x + 1, ai):     # chỉ những j chia hết cho a[i]
            cur[j] = (cur[j] + prev[j // ai]) % MOD
        prev = cur
    sl = sum(prev[1:]) % MOD
    print((sl - 1 + MOD) % MOD)

main()
sys.stdout.flush()

Bản Python mình chỉ giữ 2 hàng (hàng trước và hàng hiện tại), và chỉ duyệt các $j$ là bội của $a_i$ vì những ô khác chỉ đơn giản là chép từ hàng trên xuống – cho nhanh hơn chứ công thức y hệt bản C++.

C++▶ Chạy tay từng dòng codeSubtask 3: f[i][j] = f[i-1][j] + f[i-1][j / a[i]] (nếu j chia hết a[i]); đáp án tổng f[n][1..x] - 1.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7int f[5005][5005];
8long long MOD = 1e9 + 7;
9
10signed main(){
11 ios_base::sync_with_stdio(false);
12 cin.tie(0);
13 if(fopen("MCOUNT.inp", "r")){
14 freopen("MCOUNT.inp", "r", stdin);
15 freopen("MCOUNT.out", "w", stdout);
16 }
17 cin >> n >> x;
18 long long sl = 0;
19 for (int i = 1; i <= n; i ++){
20 cin >> a[i];
21 }
22 f[0][1] = 1;
23 for (int i = 1; i <= n; i ++){
24 for (int j = 1; j <= x; j ++){
25 f[i][j] = f[i - 1][j];
26 if (j % a[i] == 0) f[i][j] += f[i - 1][j / a[i]];
27 f[i][j] %= MOD;
28 }
29 }
30 for (int i = 1; i <= x; i ++){
31 sl += f[n][i];
32 sl %= MOD;
33 }
34 cout << (sl - 1 + MOD) % MOD << endl;
35}

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

  • Không gian: bảng $f$ có $(n + 1) \times (x + 1)$ ô: $O(n \cdot x)$. Khai báo long long f[5005][5005] tốn khoảng 200 MB, vẫn nằm trong giới hạn 1024 MB của đề.
  • Thời gian: mỗi ô tính $O(1)$: $O(n \cdot x) = 2.5 \cdot 10^7$ phép tính với $n = x = 5000$.
Không gian$O(n \cdot x)$
Thời gian$O(n \cdot x)$
Không đủ cho n = 10⁵, x = 2·10⁵
Khi đó $n \cdot x = 2 \cdot 10^{10}$ – vừa quá thời gian, vừa không thể khai báo nổi bảng $f$. Nhưng công thức truy hồi này là "xương sống" cho các subtask sau, các bạn nắm thật chắc nha.

Subtask 4aᵢ là lũy thừa của 2 – quy về bài toán cái túi, O(n · log x)

Nhận xét rằng, khi mọi $a_i = 2^{e_i}$ thì tích của một tập con bây giờ sẽ có dạng

2^e₁ × 2^e₂ × … × 2^eₖ = 2^(e₁ + e₂ + … + eₖ)

Ta gọi $cnt$ là số lớn nhất sao cho $2^{cnt} \le x$ (với $x \le 2 \cdot 10^5$ thì $cnt \le 17$). Lúc này ta cần đếm số lượng tập con sao cho tích là $2^s$ mà $s \le cnt$. Nhận thấy rằng, khi ta đang có một tập con và nhân thêm một số nào đó trong mảng để tạo thêm tập con mới, số mũ trên tích sẽ thay đổi theo phép cộng. Tới đây, chúng ta sẽ quy về bài toán cái túi: mảng $a$ sẽ được đặt lại thành $a_i = e_i$ (số mũ), và ta tính số lượng tập con từ $a$ sao cho tổng nhỏ hơn hoặc bằng $cnt$.

Công thức
$f[i][j]$ = số tập con của $i$ phần tử đầu có tổng số mũ bằng $j$; $f[0][0] = 1$;
$f[i][j] = f[i-1][j] + f[i-1][j - e_i]$ (nếu $j \ge e_i$). Đáp án: $\sum_{j=0}^{cnt} f[n][j] - 1$.

Trong code, mảng q dùng để đổi nhanh $2^k \to k$: vòng lặp đầu tiên gán q[1] = 0, q[2] = 1, q[4] = 2, … cho mọi lũy thừa của 2 không vượt quá $x$, đồng thời đếm luôn $cnt$.

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

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

int n, x;
int a[100005];
int f[100005][20];
int q[200005];
long long MOD = 1e9 + 7;

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    long long sl = 1, cnt = 0;
    for (; sl <= x; sl *= 2){
        q[sl] = cnt;
        cnt ++;
    }
    cnt --;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
        a[i] = q[a[i]];
    }
    f[0][0] = 1;
    for (int i = 1; i <= n; i ++){
        for (int j = 0; j <= cnt; j ++){
            f[i][j] = f[i - 1][j];
            if (j >= a[i]) f[i][j] += f[i - 1][j - a[i]];
            f[i][j] %= MOD;
        }
    }
    sl = 0;
    for (int j = 0; j <= cnt; j ++){
        sl += f[n][j];
        sl %= MOD;
    }
    cout << (sl - 1 + MOD) % MOD << endl;
}
import sys, os

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    MOD = 10**9 + 7
    q = {}
    sl, cnt = 1, 0
    while sl <= x:
        q[sl] = cnt
        cnt += 1
        sl *= 2
    cnt -= 1
    a = [0] + [q.get(int(v), 0) for v in data[2:2 + n]]
    prev = [0] * (cnt + 1)
    prev[0] = 1
    for i in range(1, n + 1):
        cur = prev[:]
        for j in range(a[i], cnt + 1):
            cur[j] = (cur[j] + prev[j - a[i]]) % MOD
        prev = cur
    sl = sum(prev) % MOD
    print((sl - 1 + MOD) % MOD)

main()
sys.stdout.flush()
C++▶ Chạy tay từng dòng codeSubtask 4: đổi a[i] = 2^e thành e, đếm số tập con có tổng số mũ ≤ cnt (bài toán cái túi).
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7int f[100005][20];
8int q[200005];
9long long MOD = 1e9 + 7;
10
11signed main(){
12 ios_base::sync_with_stdio(false);
13 cin.tie(0);
14 if(fopen("MCOUNT.inp", "r")){
15 freopen("MCOUNT.inp", "r", stdin);
16 freopen("MCOUNT.out", "w", stdout);
17 }
18 cin >> n >> x;
19 long long sl = 1, cnt = 0;
20 for (; sl <= x; sl *= 2){
21 q[sl] = cnt;
22 cnt ++;
23 }
24 cnt --;
25 for (int i = 1; i <= n; i ++){
26 cin >> a[i];
27 a[i] = q[a[i]];
28 }
29 f[0][0] = 1;
30 for (int i = 1; i <= n; i ++){
31 for (int j = 0; j <= cnt; j ++){
32 f[i][j] = f[i - 1][j];
33 if (j >= a[i]) f[i][j] += f[i - 1][j - a[i]];
34 f[i][j] %= MOD;
35 }
36 }
37 sl = 0;
38 for (int j = 0; j <= cnt; j ++){
39 sl += f[n][j];
40 sl %= MOD;
41 }
42 cout << (sl - 1 + MOD) % MOD << endl;
43}

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

  • Không gian: bảng $f$ có $(n + 1) \times (cnt + 1)$ ô với $cnt \le 17$: $O(n \cdot \log x)$.
  • Thời gian: $O(n \cdot \log x)$, khoảng $1.8 \cdot 10^6$ phép tính.
Không gian$O(n \cdot \log x)$
Thời gian$O(n \cdot \log x)$
Để ý: số 1 = 2⁰
Các phần tử bằng $1$ có số mũ $0$, khi đó công thức thành $f[i][j] = 2 \cdot f[i-1][j]$ – đúng y như subtask 2: lấy hay không lấy số 1 đều không làm thay đổi tích.

Subtask 5aᵢ = i – QHĐ một chiều, chỉ duyệt bội số, O(x · log x)

Quay lại công thức của subtask 3: ta gọi $f[i][j]$ là số lượng tập con có thể tạo được từ $i$ phần tử đầu và có tích bằng $j$.

Ta nhận thấy rằng, giữa $f[i][j]$ và $f[i - 1][j]$ chỉ có thể xảy ra thay đổi khi $j$ chia hết cho $a_i$, vì như vậy mới có thể tồn tại các tập con sử dụng phần tử thứ $i$ để tạo ra tích $j$. Trường hợp $j$ không chia hết cho $a_i$, ta dễ dàng thấy cách tạo $f[i][j]$ sẽ tương đương với $f[i-1][j]$ (không thay đổi), vì nếu còn tồn tại $a_i$ trong tập con được tạo thì tích phải chia hết cho $a_i$ (vô lý).

Tối ưu thành một chiều
Gọi $f[j]$ là số cách tạo ra một tập con có tích bằng $j$ từ các phần tử đã xét tính đến bây giờ. Với mỗi $a_i$ xét đến, ta chỉ duyệt các $j$ là bội của $a_i$ và cập nhật:
$f_{\text{mới}}[j] = f_{\text{cũ}}[j] + f_{\text{cũ}}\!\left[\dfrac{j}{a_i}\right]$
Trong subtask này $a_i = i$ nên code dùng luôn $i$ thay cho $a_i$.

Có một chi tiết rất quan trọng: vòng lặp phải duyệt $j$ từ lớn về nhỏ (j = x - x % i là bội lớn nhất của $i$ không vượt $x$, rồi giảm dần j -= i). Vì $\dfrac{j}{i} \lt j$, khi duyệt giảm dần thì lúc cập nhật $f[j]$, ô $f[j / i]$ chưa bị cập nhật ở lượt này – tức vẫn là giá trị cũ $f_{\text{cũ}}$, đúng như công thức. Nếu duyệt tăng dần, $f[j / i]$ có thể đã được cộng thêm ở lượt này, nghĩa là phần tử $a_i$ bị dùng 2 lần – sai! Đây chính là mẹo quen thuộc của bài toán cái túi 0/1 dùng mảng một chiều.

Còn với $i = 1$ thì $j / i = j$: lệnh f[j] += f[j] đơn giản là nhân đôi $f[j]$ – đúng vì lấy hay không lấy số 1 tích vẫn vậy.

Các bạn xem khung minh hoạ: ô vàng là ô $f[j]$ đang cập nhật, ô tím là ô nguồn $f[j / i]$, ô xanh là các bội đã cập nhật xong trong lượt hiện tại:

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>
#define int long long
using namespace std;

int n, x;
int a[100005];
int f[200005];
long long MOD = 1e9 + 7;

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
    }
    f[1] = 1;
    for (int i = 1; i <= n; i ++){
        for (int j = x - (x % i); j >= i; j -= i){
            f[j] += f[j / i];
            f[j] %= MOD;
        }
    }
    int sl = 0;
    for (int j = 1; j <= x; j ++){
        sl += f[j];
        sl %= MOD;
    }
    cout << (sl - 1 + MOD) % MOD << endl;
}
import sys, os

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    MOD = 10**9 + 7
    f = [0] * (x + 1)
    f[1] = 1
    for i in range(1, n + 1):
        for j in range(x - x % i, i - 1, -i):
            f[j] = (f[j] + f[j // i]) % MOD
    sl = sum(f[1:]) % MOD
    print((sl - 1 + MOD) % MOD)

main()
sys.stdout.flush()
C++▶ Chạy tay từng dòng codeSubtask 5: a[i] = i, với mỗi i duyệt các bội j của i từ lớn về nhỏ: f[j] += f[j / i].
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7int f[200005];
8long long MOD = 1e9 + 7;
9
10signed main(){
11 ios_base::sync_with_stdio(false);
12 cin.tie(0);
13 if(fopen("MCOUNT.inp", "r")){
14 freopen("MCOUNT.inp", "r", stdin);
15 freopen("MCOUNT.out", "w", stdout);
16 }
17 cin >> n >> x;
18 for (int i = 1; i <= n; i ++){
19 cin >> a[i];
20 }
21 f[1] = 1;
22 for (int i = 1; i <= n; i ++){
23 for (int j = x - (x % i); j >= i; j -= i){
24 f[j] += f[j / i];
25 f[j] %= MOD;
26 }
27 }
28 int sl = 0;
29 for (int j = 1; j <= x; j ++){
30 sl += f[j];
31 sl %= MOD;
32 }
33 cout << (sl - 1 + MOD) % MOD << endl;
34}

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

  • Không gian: mảng $f$ một chiều: $O(x)$.
  • Thời gian: với mỗi $i$ chỉ duyệt $\dfrac{x}{i}$ bội số, tổng cộng $\dfrac{x}{1} + \dfrac{x}{2} + \ldots + \dfrac{x}{n} \approx x \ln n$. Chứng minh được khi duyệt bội thì độ phức tạp sẽ không quá $O(x \log x)$ (tổng điều hoà) – khoảng $2.4 \cdot 10^6$ phép tính với $x = 2 \cdot 10^5$.
Không gian$O(x)$
Thời gian$O(x \log x)$
Vì sao chưa dùng được cho trường hợp tổng quát?
Khi các $a_i$ đôi một khác nhau (như $a_i = i$) thì tổng $\sum \frac{x}{a_i}$ nhỏ. Nhưng nếu dãy có rất nhiều số nhỏ trùng nhau, ví dụ $10^5$ số $2$, thì mỗi lần vẫn phải duyệt $\frac{x}{2} = 10^5$ bội, tổng cộng $5 \cdot 10^9$ – quá chậm. Subtask cuối sẽ xử lý chuyện "trùng nhau" này.

Subtask 6Chia căn + tổ hợp – lời giải đầy đủ

Ta đặt $S = \lfloor \sqrt{2 \cdot 10^5} \rfloor = 447$ (trong code là biến cur).

Ta nhận thấy rằng, trong một tập con thoả mãn thì sẽ xuất hiện không quá một giá trị $a_i \gt S$, vì hai số lớn hơn $S$ nhân với nhau đã $\ge 448^2 = 200704 \gt 2 \cdot 10^5 \ge x$. Ta có hai trường hợp sau:

  • Nếu tồn tại một số $a_i \gt S$ trong tập, thì tích các số còn lại sẽ không quá $\left\lfloor \dfrac{x}{a_i} \right\rfloor \lt S$. Khi này ta chỉ cần tìm số cách lấy một tập con từ các số $\le S$ sao cho tích không quá $\dfrac{x}{a_i}$.
  • Nếu không tồn tại số nào lớn hơn $S$: các số trong tập đều không quá $S$, và việc ta cần là tính số cách lấy một tập con từ các số $\le S$ sao cho tích nhỏ hơn hoặc bằng $x$.

Tất cả những gì ta cần tính ở trên đều chỉ sử dụng đến những phần tử không quá $S$ – mà các giá trị này chỉ có $447$ loại! Thay vì xét từng phần tử như subtask 5 (bị chậm khi trùng nhiều), ta gom các phần tử cùng giá trị lại và xét theo từng giá trị.

Quy hoạch động theo giá trị
Gọi $cnt[v]$ là số lần xuất hiện của giá trị $v$ trong mảng $a$, và $f[v][j]$ là số lượng tập con tạo được từ $a$ sao cho các phần tử trong tập con không quá $v$ và tích là $j$. Nếu ta quyết định lấy đúng $l$ số có giá trị $v$, tích sẽ nhân thêm $v^l$ và có $C(cnt[v], l)$ cách chọn (do $l$ số đó có các chỉ số khác nhau):
$f[v][j \cdot v^l] \mathrel{+}= f[v - 1][j] \cdot C(cnt[v], l)$ với $l \ge 0$, $j \cdot v^l \le x$.

Vì $v \ge 2$ nên $v^l \le x \le 2 \cdot 10^5$ kéo theo $l \le 17$ ($2^{18} = 262144$): ta sẽ không bao giờ dùng quá $17$ phần tử có cùng giá trị $v$. Số tổ hợp $C(n, k)$ – số cách để chọn $k$ phần tử từ $n$ số – được tính trước bằng tam giác Pascal: $C(n, k) = C(n-1, k) + C(n-1, k-1)$ (lấy hoặc không lấy phần tử thứ $n$), chỉ cần $k \le 17$ nên bảng c[25][100005] là đủ.

Về cài đặt, code dùng 2 hàng f[0] (giá trị hiện tại, ứng với $f[v-1]$ rồi thành $f[v]$) và f[1] (phần cộng thêm khi lấy $l \ge 1$ số $v$). Trường hợp $l = 0$ chính là giữ nguyên f[0]. Sau khi tính hết phần cộng thêm vào f[1], ta cộng nó vào f[0] rồi xoá f[1] về $0$ – các tích mới đều là bội của $v$ nên chỉ cần duyệt j = v, 2v, 3v, ….

LƯU Ý: số 1
Ở những phần tính toán trên, ta sẽ không xét đến các phần tử có giá trị bằng $1$ (vì $1^l = 1$ với mọi $l$, vòng while (h <= x) sẽ chạy mãi). Gọi $c_1$ là số lượng phần tử có giá trị là $1$. Sau khi tính xong số lượng tập con thoả mãn điều kiện mà các phần tử không có giá trị $1$, ta thấy rằng việc thêm bao nhiêu số $1$ vào cũng không ảnh hưởng đến tích của tập con. Số cách thêm số $1$ vào sẽ là $2^{c_1}$ (biến sl trong code), ta sẽ nhân với kết quả ban đầu.
Đáp án = 2^c₁ × ( Σ_{a_i > S} Σ_{j ≤ x / a_i} f[j] + Σ_{j ≤ x} f[j] ) − 1

Số $-1$ cuối cùng là để bỏ tập rỗng (nằm trong $f[1]$ với $0$ số $1$ nào được chọn). Các bạn thử khung minh hoạ dưới đây: để dễ nhìn với $x$ nhỏ, khung dùng ngưỡng $S = \lfloor \sqrt{x} \rfloor$ (vẫn đúng vì hai số lớn hơn $\sqrt{x}$ có tích vượt $x$); còn code dùng hẳn $447$ cho mọi $x$. Bảng hiện các tích $j$ đang có $f[j] \gt 0$:

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>
#define int long long
using namespace std;

int n, x;
int a[100005];
int cur = sqrt(200000);
int f[2][200005];
int cnt[505];
int c[25][100005];
long long MOD = 1e9 + 7;

signed main(){
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    if(fopen("MCOUNT.inp", "r")){
        freopen("MCOUNT.inp", "r", stdin);
        freopen("MCOUNT.out", "w", stdout);
    }
    cin >> n >> x;
    long long sl = 1;
    for (int i = 1; i <= n; i ++){
        cin >> a[i];
        if (a[i] > 1 && a[i] <= cur) cnt[a[i]] ++;
        else if (a[i] == 1){
            sl *= 2;
            sl %= MOD;
        }
    }
    c[0][0] = 1;
    for (int j = 1; j <= n; j ++){
        for (int k = 0; k <= min(17ll, j); k ++){
            c[k][j] += c[k][j - 1];
            if (k) c[k][j] += c[k - 1][j - 1];
            c[k][j] %= MOD;
        }
    }
    f[0][1] = 1;
    for (int i = 2; i <= cur; i ++){
        if (!cnt[i]) continue;
        int h = i;
        int l = 1;
        while (h <= x){
            for (int j = 1; j <= x / h; j ++){
                f[1][h * j] += (f[0][j] * c[l][cnt[i]]);
                f[1][h * j] %= MOD;
            }
            h *= i;
            l ++;
        }
        for (int j = i; j <= x; j += i){
            f[0][j] += f[1][j];
            f[0][j] %= MOD;
            f[1][j] = 0;
        }
    }
    long long res = 0;
    for (int i = 1; i <= n; i ++){
        if (a[i] > cur){
            for (int j = 1; j <= cur; j ++){
                if (j * a[i] <= x){
                    res += ((long long) f[0][j] * sl) % MOD;
                    if (res >= MOD) res -= MOD;
                }
            }
        }
    }
    for (int i = 1; i <= x; i ++){
        res += (f[0][i] * sl) % MOD;
    }
    cout << (res - 1 + MOD) % MOD << endl;
}
import sys, os
from math import isqrt

def main():
    if os.path.exists('MCOUNT.inp'):
        sys.stdin = open('MCOUNT.inp')
        sys.stdout = open('MCOUNT.out', 'w')
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    a = [int(v) for v in data[2:2 + n]]
    MOD = 10**9 + 7
    cur = isqrt(200000)
    cnt = [0] * (cur + 1)
    sl = 1
    big = []
    for v in a:
        if 1 < v <= cur:
            cnt[v] += 1
        elif v == 1:
            sl = sl * 2 % MOD
        else:
            big.append(v)
    # c[k][j] = C(j, k) với k <= 17
    c = [[0] * (n + 1) for _ in range(18)]
    c[0] = [1] * (n + 1)
    for k in range(1, 18):
        ck, cp = c[k], c[k - 1]
        for j in range(k, n + 1):
            ck[j] = (ck[j - 1] + cp[j - 1]) % MOD
    f0 = [0] * (x + 1)
    f1 = [0] * (x + 1)
    f0[1] = 1
    for i in range(2, cur + 1):
        if not cnt[i]:
            continue
        h, l = i, 1
        while h <= x:
            w = c[l][cnt[i]] if l < 18 else 0
            for j in range(1, x // h + 1):
                f1[h * j] = (f1[h * j] + f0[j] * w) % MOD
            h *= i
            l += 1
        for j in range(i, x + 1, i):
            f0[j] = (f0[j] + f1[j]) % MOD
            f1[j] = 0
    # pre[t] = f0[1] + ... + f0[t] (thay cho vòng for j <= cur trong C++)
    pre = [0] * (x + 1)
    for t in range(1, x + 1):
        pre[t] = (pre[t - 1] + f0[t]) % MOD
    res = 0
    for v in big:
        res += pre[min(cur, x // v)] * sl
    res += pre[x] * sl
    print((res - 1) % MOD)

main()
sys.stdout.flush()

Giải thích 1 số chỗ trong code ở trên:

  1. Vòng đọc dữ liệu: số $1$ thì nhân đôi sl (để có $2^{c_1}$), số trong $[2, S]$ thì tăng cnt, còn số lớn hơn $S$ để nguyên trong mảng a, xử lý ở cuối.
  2. Vòng while (h <= x): h lần lượt là $v, v^2, v^3, \ldots$ và l là số lượng số $v$ được lấy. Với mỗi j mà h * j <= x, cộng f[0][j] * c[l][cnt[i]] vào f[1][h * j]. Nếu $l \gt cnt[v]$ thì c[l][cnt[v]] = 0 nên tự động không cộng gì.
  3. Phần số lớn: với mỗi $a_i \gt S$, cộng f[0][j] * sl với mọi $j \le S$ mà $j \cdot a_i \le x$. Bản Python mình dùng mảng cộng dồn pre để lấy luôn tổng $f[1] + \ldots + f[\lfloor x / a_i \rfloor]$ thay vì vòng lặp 447 lần, cho Python đỡ chậm – kết quả y hệt.
  4. Các phép nhân f[0][j] * c[l][cnt[i]] có thể tới cỡ $10^{18}$ nên phải dùng long long (code đã #define int long long).

Khung chạy tay bên dưới đi đúng thứ tự code. Vì vòng for i chạy tới $447$ và vòng cuối chạy tới $x$, mình gom lại những lượt "không làm gì" (giá trị $i$ không xuất hiện, hay ô $f = 0$ cộng thêm $0$) để các bạn đỡ phải bấm hàng trăm lần nha:

C++▶ Chạy tay từng dòng codeSubtask 6: gom số theo giá trị, QHĐ trên các giá trị ≤ 447 với tổ hợp C(cnt, l); số > 447 xử lý riêng; số 1 nhân 2^c1.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, x;
6int a[100005];
7int cur = sqrt(200000);
8int f[2][200005];
9int cnt[505];
10int c[25][100005];
11long long MOD = 1e9 + 7;
12
13signed main(){
14 ios_base::sync_with_stdio(false);
15 cin.tie(0);
16 if(fopen("MCOUNT.inp", "r")){
17 freopen("MCOUNT.inp", "r", stdin);
18 freopen("MCOUNT.out", "w", stdout);
19 }
20 cin >> n >> x;
21 long long sl = 1;
22 for (int i = 1; i <= n; i ++){
23 cin >> a[i];
24 if (a[i] > 1 && a[i] <= cur) cnt[a[i]] ++;
25 else if (a[i] == 1){
26 sl *= 2;
27 sl %= MOD;
28 }
29 }
30 c[0][0] = 1;
31 for (int j = 1; j <= n; j ++){
32 for (int k = 0; k <= min(17ll, j); k ++){
33 c[k][j] += c[k][j - 1];
34 if (k) c[k][j] += c[k - 1][j - 1];
35 c[k][j] %= MOD;
36 }
37 }
38 f[0][1] = 1;
39 for (int i = 2; i <= cur; i ++){
40 if (!cnt[i]) continue;
41 int h = i;
42 int l = 1;
43 while (h <= x){
44 for (int j = 1; j <= x / h; j ++){
45 f[1][h * j] += (f[0][j] * c[l][cnt[i]]);
46 f[1][h * j] %= MOD;
47 }
48 h *= i;
49 l ++;
50 }
51 for (int j = i; j <= x; j += i){
52 f[0][j] += f[1][j];
53 f[0][j] %= MOD;
54 f[1][j] = 0;
55 }
56 }
57 long long res = 0;
58 for (int i = 1; i <= n; i ++){
59 if (a[i] > cur){
60 for (int j = 1; j <= cur; j ++){
61 if (j * a[i] <= x){
62 res += ((long long) f[0][j] * sl) % MOD;
63 if (res >= MOD) res -= MOD;
64 }
65 }
66 }
67 }
68 for (int i = 1; i <= x; i ++){
69 res += (f[0][i] * sl) % MOD;
70 }
71 cout << (res - 1 + MOD) % MOD << endl;
72}

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

  • Không gian: mảng $f$ cỡ $2 \times x$, bảng tổ hợp $18 \times n$: $O(x + n)$.
  • Thời gian:
    • Bảng tổ hợp: $O(17 \cdot n)$.
    • Quy hoạch động: với mỗi giá trị $v \in [2, S]$ có xuất hiện, vòng while duyệt $\frac{x}{v} + \frac{x}{v^2} + \ldots \le \frac{x}{v - 1}$ ô, cộng thêm $\frac{x}{v}$ ô khi gộp f[1] vào f[0]. Tổng theo mọi $v$ là $O(x \log S)$ (lại là tổng điều hoà) – và điểm mấu chốt là không phụ thuộc số lần trùng nữa.
    • Phần số lớn: mỗi số lớn duyệt tối đa $S$ giá trị $j$: $O(n \cdot S) \approx 4.5 \cdot 10^7$ phép tính đơn giản.
Không gian$O(n + x)$
Thời gian$O(17n + x \log S + n \cdot S)$
Muốn nhanh hơn nữa?
Phần số lớn có thể dùng mảng cộng dồn của $f[0]$ để lấy tổng $f[1] + \ldots + f[\lfloor x / a_i \rfloor]$ trong $O(1)$ (như bản Python), khi đó độ phức tạp còn $O(17n + x \log S)$. Nhưng với giới hạn của đề thì code C++ của mình đã chạy rất nhanh rồi.
Ghi chú của mình: code C++ và Python của subtask 1, 3, 6 đều cho đúng output của ví dụ; code subtask 2, 4, 5 chỉ đúng trên dữ liệu thuộc subtask của nó (đúng như thiết kế) nên mình kiểm tra chúng bằng test riêng. Mình đã so với một code vét cạn khác (đếm trực tiếp số tập con theo từng tích) trên 720 bộ test ngẫu nhiên nhỏ sinh riêng cho từng subtask, cộng thêm 8 bộ $n \le 3000$, $x \le 5000$ – khớp hết. Với test lớn nhất $n = 10^5$, $x = 2 \cdot 10^5$: code subtask 6 (C++) chạy khoảng 0.03 – 0.11 giây, bản Python khoảng 0.3 – 0.9 giây, và cho cùng kết quả với code subtask 2, 4, 5 trên các test thuộc subtask đó. Subtask 1 với $n = 20$: C++ khoảng 0.15 giây, Python khoảng 2.6 – 2.9 giây; subtask 3 với $n = x = 5000$: C++ khoảng 0.4 giây, Python khoảng 2.2 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