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 →

Chiếu hoa (Đề tự luyện HSG Tin quốc gia – Bài 2) – Chu kỳ xâu khi được sửa tối đa E ký tự: hàm Z, hash + chặt nhị phân nhảy qua các vị trí lệch, đếm theo đoạn cùng màu

Nguồn: Đề tự luyện HSG Tin học quốc gia – Bài 2 (đề tự soạn)Độ khó: KhóXâuHàm ZHash đa thứcChặt nhị phân LCPChu kỳ xâu

Đề bài

Chiếu hoa

Tấm chiếu gồm $N$ sợi lác, sợi thứ $i$ có màu $s_i$ (một chữ cái thường). Tấm chiếu có chu kỳ $p$ ($1 \le p \le N$) nếu $s_i = s_{i+p}$ với mọi $1 \le i \le N - p$. Số $p$ là đạt nếu có thể nhuộm lại (đổi màu tùy ý) không quá $E$ sợi để tấm chiếu có chu kỳ $p$ (mỗi $p$ xét riêng).

Tìm số $p$ đạt nhỏ nhất và đếm số lượng số $p$ đạt.

Dữ liệu vào: dòng đầu $N, E$ ($1 \le N \le 2 \cdot 10^5$; $0 \le E \le 3$); dòng hai là xâu $s$ gồm $N$ chữ cái thường.

Kết quả: hai số – $p$ đạt nhỏ nhất và số lượng $p$ đạt.

  • Subtask 1 (20%): $N \le 2000$.
  • Subtask 2 (30%): $E = 0$.
  • Subtask 3 (50%): không có ràng buộc gì thêm.
Input
13 2
bcabcabcabbab
Output
3 7
Giải thích

Các $p$ đạt: $3, 6, 9, 10, 11, 12, 13$. Với $p = 3, 6, 9$: nhuộm sợi $11$ từ b sang c (được bcabcabcabcab); $p = 10, 11$: nhuộm hai sợi; $p = 12, 13$: không cần nhuộm. Các $p$ khác cần ít nhất $3$ sợi (chẳng hạn $p = 1$ cần $7$ sợi).

Input
8 0
abaababa
Output
5 3
Giải thích

Không được nhuộm sợi nào; tấm chiếu có đúng ba chu kỳ $5, 7, 8$.

Dành cho học viênNộp bài trên ClueOJ

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é. Bộ test có chia subtask và có cả những test "bẫy", nên code đúng ý tưởng nhưng sai chi tiết nhỏ cũng sẽ bị trừ điểm đó.

Nộp bài Chiếu hoa

Lời mở đầu

Trước hết hãy trả lời câu hỏi: với một $p$ cố định, phải nhuộm lại ít nhất bao nhiêu sợi? Điều kiện $s_i = s_{i+p}$ nối sợi $i$ với sợi $i + p$, sợi $i + p$ với sợi $i + 2p$, … Vì vậy các sợi chia thành $p$ dãy độc lập: dãy thứ $r$ gồm các sợi $r, r + p, r + 2p, \ldots$ Tấm chiếu có chu kỳ $p$ khi và chỉ khi mỗi dãy chỉ có một màu. Trong mỗi dãy, cách tốt nhất là giữ nguyên màu xuất hiện nhiều nhất và nhuộm lại các sợi còn lại: $$cost(p) = \sum_{\text{dãy}} \big(\text{độ dài dãy} - \text{số sợi của màu nhiều nhất trong dãy}\big).$$ Số $p$ đạt khi $cost(p) \le E$.

s = bcabcabcabbab, p = 3: các sợi i, i + 3, i + 6, … phải cùng màu b 1 c 2 a 3 b 4 c 5 a 6 b 7 c 8 a 9 b 10 b 11 a 12 b 13 Dãy 1 b b b b b 5 sợi, giữ 5 nhuộm 0 Dãy 2 c c c b 4 sợi, giữ 3 nhuộm 1 Dãy 3 a a a a 4 sợi, giữ 4 nhuộm 0 Số sợi ít nhất phải nhuộm lại = tổng của từng dãy (độ dài dãy − số sợi của màu nhiều nhất) = 0 + 1 + 0 = 1.

Ví dụ 1 với $p = 3$: ba dãy, chỉ dãy 2 có một sợi khác màu nên $cost(3) = 1 \le 2$.

Tính $cost(p)$ trực tiếp tốn $O(N)$ cho mỗi $p$, tức $O(N^2)$ cho mọi $p$ – quá chậm với $N = 2 \cdot 10^5$. Nút thắt của bài là: $E$ rất nhỏ ($\le 3$), và ta chỉ cần biết $cost(p) \le E$ hay không, chứ không cần giá trị chính xác khi nó lớn. Lộ trình:

Cách 1Tính cost(p) trực tiếp – O(N²) · 20%

Làm đúng công thức ở trên: với mỗi $p$, duyệt từng dãy $r = 0, 1, \ldots, p - 1$ (đánh số từ $0$ trong code), đếm số sợi của từng màu bằng mảng $26$ phần tử rồi lấy màu nhiều nhất. Mỗi $p$ duyệt đúng $N$ sợi, tổng $O(N^2) = 4 \cdot 10^6$ phép tính khi $N \le 2000$. Vì duyệt $p$ tăng dần, $p$ đạt đầu tiên chính là $p$ nhỏ nhất.

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

int main() {
    int n, E;
    string s;
    cin >> n >> E >> s;
    int best = -1, cnt = 0;
    for (int p = 1; p <= n; p++) {
        int cost = 0;                                  // so soi phai nhuom lai de co chu ky p
        for (int r = 0; r < p && r < n; r++) {         // day r, r + p, r + 2p, ...
            int c[26] = {0}, len = 0, mx = 0;
            for (int i = r; i < n; i += p) { c[s[i] - 'a']++; len++; }
            for (int x = 0; x < 26; x++) mx = max(mx, c[x]);
            cost += len - mx;                          // giu mau nhieu nhat, nhuom phan con lai
        }
        if (cost <= E) {
            cnt++;
            if (best < 0) best = p;
        }
    }
    cout << best << " " << cnt << "\n";
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); E = int(data[1]); s = data[2]
    best = -1; cnt = 0
    for p in range(1, n + 1):
        cost = 0                                   # so soi phai nhuom lai de co chu ky p
        for r in range(min(p, n)):                 # day r, r + p, r + 2p, ...
            c = [0] * 26
            ln = 0
            for i in range(r, n, p):
                c[s[i] - 97] += 1
                ln += 1
            cost += ln - max(c)                    # giu mau nhieu nhat, nhuom phan con lai
        if cost <= E:
            cnt += 1
            if best < 0:
                best = p
    print(best, cnt)

main()
Không gian$O(N)$
Thời gian$O(N^2 + 26 \cdot \sum p) = O(N^2)$

Cách 2E = 0: chu kỳ đúng bằng hàm Z – O(N) · 50%

Khi không được nhuộm, $p$ đạt khi và chỉ khi $s_i = s_{i+p}$ với mọi $i$, tức là đoạn đầu $s[0..N-p)$ trùng đoạn cuối $s[p..N)$ (đánh số từ $0$). Gọi $z[p]$ là độ dài tiền tố chung dài nhất của $s$ và $s[p..]$ (hàm Z). Khi đó $$p \text{ là chu kỳ} \iff z[p] \ge N - p \quad (\text{và } p = N \text{ luôn là chu kỳ}).$$ Hàm Z tính mọi $z[i]$ trong $O(N)$: giữ "hộp" $[l, r)$ là đoạn $s[l..r)$ trùng với tiền tố $s[0..r-l)$ có $r$ lớn nhất. Khi xét $i < r$, đoạn $s[i..r)$ trùng $s[i-l..r-l)$ nên $z[i] \ge \min(r - i, z[i - l])$; sau đó chỉ cần so tiếp từng ký tự. Mỗi lần so thành công đều đẩy $r$ sang phải, nên tổng số lần so là $O(N)$.

Với ví dụ 2 ($s = $ abaababa, $N = 8$): $z = [8, 0, 1, 3, 0, 3, 0, 1]$.

$p$12345678
$z[p]$0130301–
$N - p$76543210
Chu kỳ?khôngkhôngkhôngkhôngcókhôngcócó

Đáp án 5 3. Các em bấm "Tiếp" để xem hàm Z tính từng $z[i]$ và chiếc hộp $[l, r)$ dịch chuyển thế nào:

Ví dụ nhanh:

Code dưới đây dùng hàm Z khi $E = 0$ và quay về Cách 1 khi $E > 0$.

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

vector<int> zfunc(const string& t) {        // z[i] = do dai tien to chung cua t va t[i..]
    int m = t.size();
    vector<int> z(m, 0);
    z[0] = m;
    for (int i = 1, l = 0, r = 0; i < m; i++) {
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < m && t[z[i]] == t[i + z[i]]) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

int main() {
    int n, E;
    string s;
    cin >> n >> E >> s;
    int best = -1, cnt = 0;
    if (E == 0) {                            // chu ky dung: s[i] = s[i + p] <=> z[p] >= n - p
        vector<int> z = zfunc(s);
        for (int p = 1; p <= n; p++)
            if (p == n || z[p] >= n - p) {
                cnt++;
                if (best < 0) best = p;
            }
    } else {                                 // Cach 1
        for (int p = 1; p <= n; p++) {
            int cost = 0;
            for (int r = 0; r < p && r < n; r++) {
                int c[26] = {0}, len = 0, mx = 0;
                for (int i = r; i < n; i += p) { c[s[i] - 'a']++; len++; }
                for (int x = 0; x < 26; x++) mx = max(mx, c[x]);
                cost += len - mx;
            }
            if (cost <= E) { cnt++; if (best < 0) best = p; }
        }
    }
    cout << best << " " << cnt << "\n";
    return 0;
}
import sys

def zfunc(t):                                # z[i] = do dai tien to chung cua t va t[i..]
    m = len(t)
    z = [0] * m
    z[0] = m
    l = r = 0
    for i in range(1, m):
        k = 0
        if i < r:
            k = z[i - l]
            if r - i < k:
                k = r - i
        while i + k < m and t[k] == t[i + k]:
            k += 1
        z[i] = k
        if i + k > r:
            l = i; r = i + k
    return z

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); E = int(data[1]); s = data[2]
    best = -1; cnt = 0
    if E == 0:                               # chu ky dung: s[i] = s[i + p] <=> z[p] >= n - p
        z = zfunc(s)
        for p in range(1, n + 1):
            if p == n or z[p] >= n - p:
                cnt += 1
                if best < 0:
                    best = p
    else:                                    # Cach 1
        for p in range(1, n + 1):
            cost = 0
            for r in range(min(p, n)):
                c = [0] * 26
                ln = 0
                for i in range(r, n, p):
                    c[s[i] - 97] += 1
                    ln += 1
                cost += ln - max(c)
            if cost <= E:
                cnt += 1
                if best < 0:
                    best = p
    print(best, cnt)

main()
Không gian$O(N)$
Thời gian$O(N)$ khi $E = 0$

Cách 3Hàm Z + hash nhảy qua các chỗ lệch – O(N · E · log N) · 100%

Với $p$ cố định, gọi $i$ ($0 \le i < N - p$) là một chỗ lệch nếu $s_i \ne s_{i+p}$, và $H(p)$ là số chỗ lệch. Ba nhận xét then chốt:

Nhận xét 1 – chỉ cần tìm tối đa $2E + 1$ chỗ lệch. Nhuộm lại sợi $x$ chỉ ảnh hưởng tới hai cặp $(x - p, x)$ và $(x, x + p)$. Muốn hết mọi chỗ lệch thì mỗi chỗ lệch phải bị "chạm" bởi ít nhất một sợi được nhuộm, nên $$cost(p) \ge \left\lceil H(p) / 2 \right\rceil, \qquad cost(p) \le E \implies H(p) \le 2E \le 6.$$ Vậy với mỗi $p$, ngay khi đã thấy $2E + 1$ chỗ lệch là loại được $p$.

Nhận xét 2 – biết các chỗ lệch là tính được $cost(p)$. Chỗ lệch $i$ nằm trong dãy $r = i \bmod p$, giữa phần tử thứ $t = \lfloor i / p \rfloor$ và $t + 1$ của dãy. Các chỗ lệch cắt mỗi dãy thành những đoạn cùng màu; dãy không có chỗ lệch thì đã một màu (đóng góp $0$). Với mỗi dãy có chỗ lệch, cộng độ dài các đoạn theo từng màu rồi giữ màu có tổng lớn nhất. Chú ý: hai đoạn cùng màu không liền nhau vẫn được giữ chung!

Một dãy r, r + p, r + 2p, … có 11 sợi và 4 chỗ "lệch" (hai sợi liền nhau trong dãy khác màu) a a a b a a a a b b a a×3 b×1 a×4 b×2 a×1 Màu a: 3 + 4 + 1 = 8 sợi, màu b: 1 + 2 = 3 sợi → giữ màu a, nhuộm lại 11 − 8 = 3 sợi. Sai lầm: chỉ giữ đoạn dài nhất (a×4) thì phải nhuộm 7 sợi – các đoạn cùng màu tách rời vẫn được giữ chung!

Độ dài dãy $r$ là $\lfloor (N - 1 - r) / p \rfloor + 1$ nên độ dài đoạn cuối tính được mà không cần duyệt dãy.

Hai cái bẫy
(1) Không phải cứ $H(p) \le 2E$ là đạt: dãy aaaabbbb chỉ có một chỗ lệch nhưng phải nhuộm $4$ sợi. (2) Không được chỉ giữ đoạn cùng màu dài nhất – như hình trên. Cả hai cách hiểu sai này đều vẫn cho đúng ví dụ 1 của đề.

Nhận xét 3 – tìm chỗ lệch tiếp theo thật nhanh. Từ vị trí $i$, chỗ lệch tiếp theo là $i + \text{lcp}(i, i + p)$ với $\text{lcp}(a, b)$ là độ dài đoạn chung dài nhất bắt đầu tại $a$ và $b$. Ta tìm các chỗ lệch bằng cách "nhảy": tìm một chỗ lệch, bước qua nó, rồi nhảy tiếp – tối đa $2E + 1$ lần nhảy cho mỗi $p$.

  • Chỗ lệch đầu tiên chính là $z[p]$ (hàm Z của $s$). Nếu $z[p] \ge N - p$ thì $p$ là chu kỳ đúng.
  • Chỗ lệch cuối cùng cũng có sẵn: gọi $zr$ là hàm Z của xâu đảo ngược, $zr[p]$ là độ dài hậu tố chung của $s[0..N-p)$ và $s[p..N)$, nên chỗ lệch cuối là $N - p - 1 - zr[p]$.
  • Các chỗ lệch ở giữa: tính $\text{lcp}(i, i + p)$ bằng hash đa thức + chặt nhị phân theo độ dài: hai đoạn dài $L$ bằng nhau thì hash bằng nhau, nên "đoạn chung dài $L$" là tính chất đơn điệu theo $L$. Mỗi lần $O(\log N)$.

Mỗi $p$ tốn $O(E \log N)$, tổng $O(N \cdot E \cdot \log N) \approx 2 \cdot 10^5 \cdot 5 \cdot 18 \approx 2 \cdot 10^7$ phép so hash. Code duyệt $p$ từ $N - 1$ giảm dần nên $p$ đạt cuối cùng ghi được là $p$ nhỏ nhất.

Mô phỏng dưới đây xét lần lượt từng $p$ cho ví dụ 1: các chỗ lệch được tô đỏ, các "bước nhảy" lcp được liệt kê, rồi tính $cost(p)$ theo đoạn cùng màu.

Ví dụ nhanh:

Chọn hash thế nào cho an toàn? Lời giải dùng modulo nguyên tố $2^{61} - 1$ với cơ số ngẫu nhiên chọn lúc chạy, nhân hai số $61$ bit bằng __uint128_t. Đừng dùng kiểu "để tràn số tự nhiên" (modulo $2^{64}$): với xâu Thue–Morse, khối $2^{11}$ ký tự và khối "đảo màu" của nó có cùng hash modulo $2^{64}$ với mọi cơ số lẻ – bộ test có hai test được dựng đúng như vậy. Bản Python dùng hai modulo cỡ $2^{31}$ để mọi phép nhân vẫn nằm trong số nguyên $64$ bit (PyPy chạy nhanh).

#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull MOD = (1ULL << 61) - 1;

int n, E;
string s;
vector<ull> h, pw;

ull mulmod(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    ull r = (ull)(c & MOD) + (ull)(c >> 61);
    return r >= MOD ? r - MOD : r;
}

ull getHash(int i, int len) {               // hash cua s[i .. i + len - 1]
    ull x = h[i + len] + MOD - mulmod(h[i], pw[len]);
    return x >= MOD ? x - MOD : x;
}

int lcp(int i, int j, int lim) {            // so ky tu dau tien bang nhau cua s[i..] va s[j..] (toi da lim)
    int lo = 0, hi = lim;
    while (lo < hi) {
        int mid = (lo + hi + 1) / 2;
        if (getHash(i, mid) == getHash(j, mid)) lo = mid;
        else hi = mid - 1;
    }
    return lo;
}

vector<int> zfunc(const string& t) {        // z[i] = do dai tien to chung cua t va t[i..]
    int m = t.size();
    vector<int> z(m, 0);
    z[0] = m;
    for (int i = 1, l = 0, r = 0; i < m; i++) {
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < m && t[z[i]] == t[i + z[i]]) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

int main() {
    cin >> n >> E >> s;
    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    ull base = rng() % (MOD - 1000) + 500;
    h.assign(n + 1, 0); pw.assign(n + 1, 1);
    for (int i = 0; i < n; i++) {
        h[i + 1] = mulmod(h[i], base) + (ull)s[i];
        if (h[i + 1] >= MOD) h[i + 1] -= MOD;
        pw[i + 1] = mulmod(pw[i], base);
    }
    vector<int> z = zfunc(s);
    vector<int> zr = zfunc(string(s.rbegin(), s.rend()));
    int best = n, cnt = 1;                  // p = n luon dat
    vector<int> mis;
    for (int p = n - 1; p >= 1; p--) {
        int m = n - p;                      // so cap (i, i + p) can so sanh
        if (z[p] >= m) { cnt++; best = p; continue; }   // khop hoan toan: chu ky dung
        int first = z[p], last = m - 1 - zr[p];         // vi tri lech dau tien va cuoi cung
        mis.clear();
        mis.push_back(first);
        int i = first + 1;
        while (i < last && (int)mis.size() < 2 * E) {   // nhay qua cac doan khop bang lcp
            i += lcp(i, i + p, last - i);
            if (i >= last) break;
            mis.push_back(i);
            i++;
        }
        if (i < last) continue;             // da co >= 2E + 1 vi tri lech
        if (last != first) mis.push_back(last);
        if ((int)mis.size() > 2 * E) continue;
        // tinh so soi phai nhuom lai: moi day r, r+p, r+2p,... bi cat thanh cac doan cung mau
        sort(mis.begin(), mis.end(), [&](int a, int b) {
            return make_pair(a % p, a) < make_pair(b % p, b);
        });
        int cost = 0;
        for (int a = 0; a < (int)mis.size(); ) {
            int r = mis[a] % p, b = a;
            while (b < (int)mis.size() && mis[b] % p == r) b++;
            int len = (n - 1 - r) / p + 1;  // so soi trong day r
            int cntc[26] = {0}, st = 0, mx = 0;
            for (int q = a; q <= b; q++) {
                int en = (q < b) ? mis[q] / p : len - 1;   // doan cung mau [st, en]
                int c = s[r + (long long)st * p] - 'a';
                cntc[c] += en - st + 1;
                mx = max(mx, cntc[c]);
                st = en + 1;
            }
            cost += len - mx;
            a = b;
        }
        if (cost <= E) { cnt++; best = p; }
    }
    cout << best << " " << cnt << "\n";
    return 0;
}
import sys, random

def zfunc(t):                                # z[i] = do dai tien to chung cua t va t[i..]
    m = len(t)
    z = [0] * m
    z[0] = m
    l = r = 0
    for i in range(1, m):
        k = 0
        if i < r:
            k = z[i - l]
            if r - i < k:
                k = r - i
        while i + k < m and t[k] == t[i + k]:
            k += 1
        z[i] = k
        if i + k > r:
            l = i; r = i + k
    return z

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); E = int(data[1]); s = data[2]
    M1 = 2147483647; M2 = 2147483629          # hai modulo nguyen to (tich < 2^62)
    b1 = random.randrange(1000, M1 - 1000); b2 = random.randrange(1000, M2 - 1000)
    h1 = [0] * (n + 1); h2 = [0] * (n + 1); p1 = [1] * (n + 1); p2 = [1] * (n + 1)
    for i in range(n):
        c = s[i]
        h1[i + 1] = (h1[i] * b1 + c) % M1; h2[i + 1] = (h2[i] * b2 + c) % M2
        p1[i + 1] = p1[i] * b1 % M1; p2[i + 1] = p2[i] * b2 % M2
    z = zfunc(s)
    zr = zfunc(s[::-1])
    best = n; cnt = 1                        # p = n luon dat
    lim2 = 2 * E
    for p in range(n - 1, 0, -1):
        m = n - p                            # so cap (i, i + p) can so sanh
        if z[p] >= m:                        # khop hoan toan: chu ky dung
            cnt += 1; best = p
            continue
        first = z[p]; last = m - 1 - zr[p]   # vi tri lech dau tien va cuoi cung
        mis = [first]
        i = first + 1
        while i < last and len(mis) < lim2:  # nhay qua cac doan khop bang lcp (hash + chat nhi phan)
            lo = 0; hi = last - i; j = i + p
            while lo < hi:
                mid = (lo + hi + 1) >> 1
                if (h1[i + mid] - h1[i] * p1[mid] - h1[j + mid] + h1[j] * p1[mid]) % M1 == 0 and \
                   (h2[i + mid] - h2[i] * p2[mid] - h2[j + mid] + h2[j] * p2[mid]) % M2 == 0:
                    lo = mid
                else:
                    hi = mid - 1
            i += lo
            if i >= last:
                break
            mis.append(i)
            i += 1
        if i < last:                         # da co >= 2E + 1 vi tri lech
            continue
        if last != first:
            mis.append(last)
        if len(mis) > lim2:
            continue
        # tinh so soi phai nhuom lai tren tung day r, r + p, r + 2p, ...
        mis.sort(key=lambda x: (x % p, x))
        cost = 0
        a = 0; k = len(mis)
        while a < k:
            r = mis[a] % p; b = a
            while b < k and mis[b] % p == r:
                b += 1
            ln = (n - 1 - r) // p + 1        # so soi trong day r
            cntc = {}; st = 0; mx = 0
            for q in range(a, b + 1):
                en = mis[q] // p if q < b else ln - 1   # doan cung mau [st, en]
                c = s[r + st * p]
                v = cntc.get(c, 0) + en - st + 1
                cntc[c] = v
                if v > mx:
                    mx = v
                st = en + 1
            cost += ln - mx
            a = b
        if cost <= E:
            cnt += 1; best = p
    print(best, cnt)

main()
Không gian$O(N)$
Thời gian$O(N \cdot E \cdot \log N)$
Tóm tắt
Với mỗi $p$: chỗ lệch đầu tiên $= z[p]$, cuối cùng $= N - p - 1 - zr[p]$, ở giữa thì nhảy bằng lcp (hash + chặt nhị phân), dừng khi quá $2E$ chỗ lệch; còn lại tính $cost(p)$ bằng cách cộng độ dài các đoạn cùng màu theo từng màu trên mỗi dãy có chỗ lệch.

Chạy tay từng dòng lời giải chuẩn (dữ liệu nhỏ, $N \le 14$; hash dùng cơ số cố định $131$ cho dễ theo dõi – kết quả không phụ thuộc cơ số):

C++▶ Chạy tay từng dòng codeTheo dõi hàm Z, các bước nhảy lcp, các chỗ lệch và cost của từng p (N ≤ 14).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3typedef unsigned long long ull;
4const ull MOD = (1ULL << 61) - 1;
5
6int n, E;
7string s;
8vector<ull> h, pw;
9
10ull mulmod(ull a, ull b) {
11 __uint128_t c = (__uint128_t)a * b;
12 ull r = (ull)(c & MOD) + (ull)(c >> 61);
13 return r >= MOD ? r - MOD : r;
14}
15
16ull getHash(int i, int len) { // hash cua s[i .. i + len - 1]
17 ull x = h[i + len] + MOD - mulmod(h[i], pw[len]);
18 return x >= MOD ? x - MOD : x;
19}
20
21int lcp(int i, int j, int lim) { // so ky tu dau tien bang nhau cua s[i..] va s[j..] (toi da lim)
22 int lo = 0, hi = lim;
23 while (lo < hi) {
24 int mid = (lo + hi + 1) / 2;
25 if (getHash(i, mid) == getHash(j, mid)) lo = mid;
26 else hi = mid - 1;
27 }
28 return lo;
29}
30
31vector<int> zfunc(const string& t) { // z[i] = do dai tien to chung cua t va t[i..]
32 int m = t.size();
33 vector<int> z(m, 0);
34 z[0] = m;
35 for (int i = 1, l = 0, r = 0; i < m; i++) {
36 if (i < r) z[i] = min(r - i, z[i - l]);
37 while (i + z[i] < m && t[z[i]] == t[i + z[i]]) z[i]++;
38 if (i + z[i] > r) { l = i; r = i + z[i]; }
39 }
40 return z;
41}
42
43int main() {
44 cin >> n >> E >> s;
45 mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
46 ull base = rng() % (MOD - 1000) + 500;
47 h.assign(n + 1, 0); pw.assign(n + 1, 1);
48 for (int i = 0; i < n; i++) {
49 h[i + 1] = mulmod(h[i], base) + (ull)s[i];
50 if (h[i + 1] >= MOD) h[i + 1] -= MOD;
51 pw[i + 1] = mulmod(pw[i], base);
52 }
53 vector<int> z = zfunc(s);
54 vector<int> zr = zfunc(string(s.rbegin(), s.rend()));
55 int best = n, cnt = 1; // p = n luon dat
56 vector<int> mis;
57 for (int p = n - 1; p >= 1; p--) {
58 int m = n - p; // so cap (i, i + p) can so sanh
59 if (z[p] >= m) { cnt++; best = p; continue; } // khop hoan toan: chu ky dung
60 int first = z[p], last = m - 1 - zr[p]; // vi tri lech dau tien va cuoi cung
61 mis.clear();
62 mis.push_back(first);
63 int i = first + 1;
64 while (i < last && (int)mis.size() < 2 * E) { // nhay qua cac doan khop bang lcp
65 i += lcp(i, i + p, last - i);
66 if (i >= last) break;
67 mis.push_back(i);
68 i++;
69 }
70 if (i < last) continue; // da co >= 2E + 1 vi tri lech
71 if (last != first) mis.push_back(last);
72 if ((int)mis.size() > 2 * E) continue;
73 // tinh so soi phai nhuom lai: moi day r, r+p, r+2p,... bi cat thanh cac doan cung mau
74 sort(mis.begin(), mis.end(), [&](int a, int b) {
75 return make_pair(a % p, a) < make_pair(b % p, b);
76 });
77 int cost = 0;
78 for (int a = 0; a < (int)mis.size(); ) {
79 int r = mis[a] % p, b = a;
80 while (b < (int)mis.size() && mis[b] % p == r) b++;
81 int len = (n - 1 - r) / p + 1; // so soi trong day r
82 int cntc[26] = {0}, st = 0, mx = 0;
83 for (int q = a; q <= b; q++) {
84 int en = (q < b) ? mis[q] / p : len - 1; // doan cung mau [st, en]
85 int c = s[r + (long long)st * p] - 'a';
86 cntc[c] += en - st + 1;
87 mx = max(mx, cntc[c]);
88 st = en + 1;
89 }
90 cost += len - mx;
91 a = b;
92 }
93 if (cost <= E) { cnt++; best = p; }
94 }
95 cout << best << " " << cnt << "\n";
96 return 0;
97}

Bộ test chấm "bắt" những lỗi nào?

Cách làmSub 1 (10 test)Sub 2 (8 test)Sub 3 (12 test)Điểm
Cách 1 – tính cost(p) trực tiếp O(N²)ĐúngTLETLE20
Cách 2 – E = 0: hàm ZĐúngĐúngTLE50
Cách 3 – Z + hash nhảy qua chỗ lệchĐúngĐúngĐúng100
Sai: chỉ kiểm tra H(p) ≤ 2E4/108/80/1238,0
Sai: mỗi dãy chỉ giữ đoạn cùng màu dài nhất9/108/87/1277,2
Sai: hash tràn số mod 2^6410/108/810/1291,7
Gần đúng: tìm chỗ lệch bằng cách so từng ký tự10/104/8 (TLE)4/12 (TLE)51,7

Ghi chú của mình: các cách C++ ở trên đã được chấm thử với đúng bộ test của bài: bộ test có 30 test (10 + 8 + 12), mỗi cách đạt đúng số điểm công bố, còn các lời giải sai bị bắt như bảng trên. Lời giải chuẩn sol.cpp chạy test lâu nhất khoảng 0,1 giây; bản Python sol.py cho cùng đáp án trên mọi test (trên CPython mất tới khoảng 7 giây ở test nặng nhất, nên các em nhớ chọn PyPy 3 khi nộp – giới hạn cho Python là 5 giây). Cách 1 và Cách 2 (khi $E > 0$) phải làm $N^2 = 4 \cdot 10^{10}$ phép tính nên chạy quá 4 giây mỗi test lớn. "TLE" nghĩa là chạy quá 3 lần giới hạn thời gian. Lời giải "gần đúng" so từng ký tự vẫn nhanh với xâu ngẫu nhiên (chỗ lệch xuất hiện rất sớm), nhưng bị TLE với những tấm chiếu gần như một màu – khi đó với mọi $p$ các chỗ lệch nằm rải rác khắp chiều dài. Còn hash tràn số chỉ "chết" ở hai test Thue–Morse, đủ để mất gần 10 điểm oan đó!

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