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

Sách Cũ

Sách Cũ
Nguồn: Codeforces Group Độ khó: Dễ Tham lam Mảng đánh dấu

Đề bài

Trong một cuốn sách cổ có ghi lại rất nhiều các con số bí ẩn mà chúng có mối liên hệ với 30. Sau một thời gian nghiên cứu, các chuyên gia đã tìm được cách giải mã các số đó: hoán vị các chữ số của số bí ẩn đó để thu được một số là bội số lớn nhất của 30.

Yêu cầu: hãy viết chương trình giúp các chuyên gia giải mã các con số bí ẩn đó.

Dữ liệu nhập: gồm một số nguyên dương $N$ duy nhất.

Kết quả: in ra một số duy nhất là kết quả tìm được. Nếu không giải mã được hãy in $-1$.

Ràng buộc

  • 50% test: $1 \le N \le 10^9$.
  • 50% test: $N$ có tối đa $2 \cdot 10^7$ chữ số.
Ví dụ 1 – input
1002
Ví dụ 1 – output
2100
Ví dụ 2 – input
12498567
Ví dụ 2 – output
-1
Ví dụ 3 – input
100
Ví dụ 3 – output
-1

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, kèm lời giải để 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:

50%Tư tưởng vét cạn: thử mọi hoán vị

Một tư tưởng vét cạn cho bài này là xét qua tất cả các hoán vị có thể có, với mỗi hoán vị ta kiểm tra xem nó có chia hết cho 30 không (nói 30 là bội số nghĩa là số đó chia hết cho 30, vì các bội số của 30 là 30, 60, 90, 120, 150… cứ lấy 30 nhân lần lượt 1, 2, 3, 4…). Nếu thoả mãn thì so sánh với Max, lớn hơn thì cập nhật lại. Sau cùng Max chính là kết quả.

Ví dụ với $N = 1002$, liệt kê hết các hoán vị khác nhau và kiểm tra:

Hoán vịTổng chữ số (chia hết 3?)Chữ số cuối (= 0?)Kết luận
21003 ✔0 ✔✔ bội của 30 ← lớn nhất
20103 ✔0 ✔✔ bội của 30
20013 ✔1 ✘✘
12003 ✔0 ✔✔ bội của 30
10203 ✔0 ✔✔ bội của 30
10023 ✔2 ✘✘
02103 ✔0 ✔✔ bội của 30
02013 ✔1 ✘✘
01203 ✔0 ✔✔ bội của 30
01023 ✔2 ✘✘
00213 ✔1 ✘✘
00123 ✔2 ✘✘

Nếu làm theo tư tưởng này, với $N$ là độ dài thì cả thảy sẽ có $N \cdot N!$ thao tác. Trong 50% test đầu, độ dài tối đa 9 chữ số thì $9 \cdot 9! = 3\,265\,920$, vẫn ổn vì chưa vượt ngưỡng tiêu chuẩn 1 giây là khoảng $(3 \text{ đến } 5) \cdot 10^7$, cao lắm là $10^8$. Tuy nhiên với 50% test cuối, độ dài có thể lên tới hàng chục triệu chữ số thì $N!$ là con số cực kỳ cực kỳ lớn, không thể xét hết nổi. Nên cách này rất tệ, bạn nào không nghĩ ra cách tối ưu hơn thì mới làm theo để ráng vớt vát 50% điểm.

Để liệt kê hoán vị, bạn có thể tự viết theo bài liệt kê các hoán vị từ 1 đến $N$: ví dụ số 385 có 3 chữ số, liệt kê các hoán vị của {1, 2, 3} là {1, 2, 3}, {1, 3, 2}, {2, 1, 3}, {2, 3, 1}, {3, 1, 2}, {3, 2, 1}; vị trí 1 ứng với chữ số 3, vị trí 2 ứng với 8, vị trí 3 ứng với 5, ta có 6 số 385, 358, 835, 853, 538, 583. Hoặc trong C++ có sẵn hàm next_permutation để liệt kê các hoán vị.

Toán họcKhi nào một số chia hết cho 30?

Ta cần vận dụng kiến thức toán học và tư tưởng tham lam là giải quyết gọn bài này. Đem phân tích 30 thành tích các thừa số nguyên tố: $30 = 2 \cdot 3 \cdot 5$. Gom nhóm $2 \cdot 5 = 10$ thì $30 = 10 \cdot 3$.

Một số chia hết cho 30 khi và chỉ khi thoả đồng thời 2 điều kiện
① Chia hết cho 10: chữ số tận cùng phải là 0 (tận cùng là 1 đến 9 thì chia 10 bị lẻ, ví dụ 25/10 = 2.5, 83/10 = 8.3).
② Chia hết cho 3: tổng các chữ số phải chia hết cho 3.

Chỉ cần không thoả 1 trong 2 điều kiện thì số đó không phải bội của 30. Các bạn cứ thử lấy 30 nhân với nhiều số khác nhau ghi ra giấy là sẽ thấy ngay quy luật này. Một điều rất hay: hoán vị các chữ số không làm thay đổi tổng các chữ số, nên điều kiện ② chỉ cần kiểm tra 1 lần cho số ban đầu!

Tham lamSố lớn nhất: xếp chữ số giảm dần

Tham lam nghĩa là ở mỗi bước ta chọn cách tốt nhất trong các lựa chọn hiện tại, rồi cứ thế đi tiếp theo hướng tốt nhất. Không có công thức từng bước cụ thể như các thuật toán khác, miễn tư tưởng luôn chọn theo hướng tốt nhất. Đọc đề thấy "sau cùng đáp án phải lớn nhất/nhỏ nhất" là thấy mùi tham lam ngay 😄.

Một số muốn lớn nhất thì đưa các chữ số lớn nhất về đầu, các chữ số nhỏ nhất về cuối, tức là sắp xếp các chữ số giảm dần. Ví dụ có đủ 10 chữ số 0 đến 9 thì số lớn nhất là 9876543210. Và thật trùng hợp: xếp giảm dần thì chữ số 0 (nếu có) tự động nằm ở cuối cùng, đúng điều kiện ①!

Vì số có thể dài tới hàng chục triệu chữ số, kiểu số nguyên lớn nhất của C/C++ (8 byte) cũng chỉ chứa khoảng 19, 20 chữ số, nên bắt buộc dùng chuỗi để lưu. Nhờ chuỗi mà ta truy xuất được từng chữ số qua chỉ số index, rất tiện.

Đừng dùng sort cho chuỗi dài!
Nếu sort toàn bộ chuỗi giảm dần rồi mới kiểm tra, độ phức tạp là $O(N \log N)$. Với $N = 10^7$ thì $\log_2 N \approx 24$, tức khoảng $24 \cdot 10^7$ phép tính, vượt ngưỡng 1 giây, chắc chắn TLE.

100%Mảng đánh dấu (đếm chữ số) – O(N)

Hợp lý nhất là dùng kỹ thuật mảng đánh dấu để thống kê tần suất xuất hiện của từng chữ số. Tạo mảng $a$ có 10 phần tử, index 0 đến 9 trùng khớp với các chữ số 0 đến 9: a[i] là số lần xuất hiện của chữ số $i$, ban đầu tất cả bằng 0. Xét từng ký tự s[i], đổi sang số bằng cách trừ 48 (ký tự '0' có mã ASCII 48, '1' là 49…, nên '1' − 48 = 1), rồi a[x]++, đồng thời cộng dồn vào sum.

Sau cùng: nếu a[0] == 0 (không có chữ số 0) hoặc sum không chia hết cho 3 thì in $-1$. Ngược lại in ra a[i] lần chữ số $i$ với $i$ chạy từ 9 về 0, như thế chữ số cuối cùng chắc chắn là 0.

Em tự chạy thử từng bước dưới đây nhé, gõ số bất kỳ (tối đa 40 chữ số) vào ô N:

Ví dụ nhanh:

Mỗi lần truy xuất mảng đánh dấu chỉ tốn $O(1)$. Nếu dùng map thì mỗi lần tìm tốn $O(\log N)$ do map cài đặt bằng cây đỏ đen, rất có khả năng vẫn TLE. Còn unordered_map (bảng băm) tốt nhất là $O(1)$ nhưng khi hàm băm không tốt có thể tới $O(N)$, rất nguy hiểm. Tối ưu nhất vẫn là mảng đánh dấu.

Source code cho tư tưởng trình bày ở trên để các bạn tham khảo:

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

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    string s;
    cin >> s;
    int a[10] = {0}, sum = 0;
    for(int i = 0; i < (int)s.length(); ++i){
        int x = s[i] - 48;
        a[x]++;
        sum += x;
    }
    if(a[0] == 0 || sum % 3 != 0){
        cout << -1;
    }
    else{
        for(int i = 9; i >= 0; --i){
            while(a[i]--){
                cout << i;
            }
        }
    }
    return 0;
}
s = input().strip()
a = [0] * 10
total = 0
for ch in s:
    x = ord(ch) - 48
    a[x] += 1
    total += x
if a[0] == 0 or total % 3 != 0:
    print(-1)
else:
    print(''.join(str(i) * a[i] for i in range(9, -1, -1)))
C++▶ Chạy tay từng dòng codeCách 1: mảng đếm a[0..9], rồi in các chữ số từ 9 xuống 0 – O(N).
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 string s;
8 cin >> s;
9 int a[10] = {0}, sum = 0;
10 for(int i = 0; i < (int)s.length(); ++i){
11 int x = s[i] - 48;
12 a[x]++;
13 sum += x;
14 }
15 if(a[0] == 0 || sum % 3 != 0){
16 cout << -1;
17 }
18 else{
19 for(int i = 9; i >= 0; --i){
20 while(a[i]--){
21 cout << i;
22 }
23 }
24 }
25 return 0;
26}

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

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài cần nhập xuất nhiều, như bài này chuỗi đầu vào rất dài và bước cuối có thể phải xuất ra từng ấy ký tự, ta hãy nên để 2 dòng này vào để chạy nhanh hơn. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé. Các bạn có thể thử cùng đoạn code như trên nhưng bỏ đi 2 dòng đó, bảo đảm sẽ bị TLE với thời gian chạy gần 1100 ms, trong khi thêm 2 dòng đó vào thì chỉ còn tầm 900 ms và AC.
  2. while(a[i]--){ cout << i; } nghĩa là xuất ra đủ a[i] lần giá trị $i$. Viết đầy đủ sẽ hiểu là: liên tục giảm a[i] đến khi về 0 thì dừng, mỗi lần lặp in ra chữ số $i$. Ban đầu a[i] là 3 thì vòng lặp lặp đủ 3 lần.
Không gian$O(1)$
Thời gian$O(N)$

Không gian $O(1)$ vì mảng $a$ chỉ có 10 phần tử, xem như không đáng kể. Thời gian là $O(N)$ cho bước xét từng ký tự cộng $O(N)$ cho bước xuất đáp án, tổng $O(2N)$, theo quy tắc BigO nhân hằng số thì vẫn là $O(N)$. Trường hợp xấu nhất lặp cả thảy $2 \cdot 10^7$ lần, cũng tiệm cận ngưỡng 1 giây, nên không ngạc nhiên khi chạy lên tới 900 ms, thậm chí nộp nhiều lần có lúc bị TLE nếu máy chấm đang bận.

Nhanh gấp 3Tối ưu cách xuất: gom chuỗi theo từng chữ số

Trong bảng chấm bài này có những code Accepted chỉ tầm 250 – 300 ms, trong khi phần lớn ở ngưỡng 900 ms như code trên. Vậy có cách làm khác tối ưu hơn? Theo mình là không, $O(N)$ là tối ưu nhất rồi. Muốn nhanh hơn thì phải xem lại cách cài đặt có chỗ nào rườm rà.

Vấn đề nằm ở chỗ: chuỗi dài $10^7$ thì lệnh cout << bị gọi tới $10^7$ lần. Có cách nào giảm số lần gọi không? Đây là mấu chốt!

Ý tưởng
Thay vì mảng $a$ lưu số lần xuất hiện, ta cho mảng $a$ lưu trực tiếp chuỗi các chữ số đó. Ví dụ gặp chữ số '2' mười lần thì a[2] = "2222222222", sau cùng chỉ cần 1 lệnh cout << a[2] thay vì 10 lệnh. Cả bài chỉ còn đúng 10 lệnh xuất!
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    string s, a[10];
    cin >> s;
    int sum = 0;
    for(int i = 0; i < (int)s.length(); ++i){
        int x = s[i] - 48;
        a[x].push_back(s[i]);
        sum += x;
    }
    if(a[0] == "" || sum % 3 != 0){
        cout << -1;
    }
    else{
        for(int i = 9; i >= 0; --i){
            cout << a[i];
        }
    }
    return 0;
}
import sys
s = sys.stdin.readline().strip()
cnt = [s.count(str(d)) for d in range(10)]      # đếm từng chữ số (chạy bằng C bên trong nên rất nhanh)
total = sum(d * cnt[d] for d in range(10))
if cnt[0] == 0 or total % 3 != 0:
    print(-1)
else:
    sys.stdout.write(''.join(str(d) * cnt[d] for d in range(9, -1, -1)) + '\n')
C++▶ Chạy tay từng dòng codeTối ưu xuất: gom chữ số vào 10 xâu a[0..9], in mỗi xâu 1 lần.
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 string s, a[10];
8 cin >> s;
9 int sum = 0;
10 for(int i = 0; i < (int)s.length(); ++i){
11 int x = s[i] - 48;
12 a[x].push_back(s[i]);
13 sum += x;
14 }
15 if(a[0] == "" || sum % 3 != 0){
16 cout << -1;
17 }
18 else{
19 for(int i = 9; i >= 0; --i){
20 cout << a[i];
21 }
22 }
23 return 0;
24}

Mảng $a$ giờ là mảng string. Gặp ký tự nào thì push_back thêm vào cuối chuỗi tương ứng, thêm vào cuối nên chỉ $O(1)$. Sau cùng nếu a[0] vẫn là chuỗi rỗng (không có số 0 nào) hoặc sum không chia hết cho 3 thì in $-1$, ngược lại xuất a[9] về a[0].

Độ phức tạp tổng quát vẫn là $O(N)$, nhưng khúc sau chỉ cần lặp 10 lần thay vì thêm $N$ lần, và cout không bị gọi quá nhiều. Nhờ vậy thời gian chạy dưới 300 ms, nhanh gấp cả 3 lần cách trước đó.

Ghi chú của mình: 2 bản Python là mình viết thêm theo đúng 2 tư tưởng trên (bản 2 dùng s.count() để đếm từng chữ số rất nhanh và nối chuỗi một lần). Tất cả các bản đã được kiểm tra với 300 số ngẫu nhiên so với cách vét cạn mọi hoán vị; với số dài $2 \cdot 10^7$ chữ số, bản C++ chạy khoảng 0.1 giây trên máy mình, bản Python khoảng 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 😇

Đếm Xâu Tốt

Đếm Xâu Tốt
Nguồn: Codeforces Group Độ khó: Khó Digit DP KMP

Đề bài

Cho hai xâu ký tự s1 và s2 có cùng độ dài $n$ và một xâu ký tự evil. Hãy đếm số lượng xâu "tốt" thỏa mãn đồng thời các điều kiện sau:

  1. Có độ dài bằng $n$.
  2. Có thứ tự từ điển lớn hơn hoặc bằng s1.
  3. Có thứ tự từ điển nhỏ hơn hoặc bằng s2.
  4. Không chứa xâu evil như một xâu con (substring).

Vì kết quả có thể rất lớn, bạn chỉ cần in ra phần dư của nó khi chia cho $10^9 + 7$.

Input

  • Dòng đầu tiên chứa một số nguyên dương $n$ ($1 \le n \le 500$) là độ dài của các xâu s1 và s2.
  • Dòng thứ hai chứa xâu ký tự s1 (chỉ gồm các chữ cái tiếng Anh in thường).
  • Dòng thứ ba chứa xâu ký tự s2 (chỉ gồm các chữ cái tiếng Anh in thường).
  • Dòng thứ tư chứa xâu ký tự evil (chỉ gồm các chữ cái tiếng Anh in thường, độ dài không quá 50).

Dữ liệu đảm bảo s1 có thứ tự từ điển không lớn hơn s2 (tức là s1 $\le$ s2).

Output: một số nguyên duy nhất là kết quả của bài toán sau khi lấy modulo $10^9 + 7$.

Subtask

  • 20% số test đầu tiên có độ dài các xâu s1, s2, evil $\le 10$.
  • 80% số test còn lại không có giới hạn gì thêm.
InputOutputGiải thích
2
aa
da
b
51Có 25 xâu tốt bắt đầu bằng 'a': "aa", "ac", "ad", …, "az". Có 25 xâu tốt bắt đầu bằng 'c': "ca", "cc", "cd", …, "cz". Và 1 xâu tốt bắt đầu bằng 'd': "da".
7
program
progres
pro
0Tất cả các xâu nằm giữa "program" và "progres" đều bắt đầu bằng tiền tố "pro". Do đó, mọi xâu đều chứa "pro" là xâu con, không có xâu nào tốt.
2
gx
gz
x
2Các xâu độ dài 2 nằm giữa "gx" và "gz" là "gx", "gy", "gz". Xâu "gx" có chứa "x" nên chỉ có 2 xâu "gy" và "gz" là hợp lệ.

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 lời giải để 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 🥰.

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

Tóm tắt đề bài: cho 2 chuỗi s1 và s2 có cùng độ dài. Đếm có bao nhiêu chuỗi có thứ tự từ điển $\ge$ s1, $\le$ s2 và không chứa chuỗi evil như một chuỗi con.

Phương pháp giải: quy hoạch động. Độ phức tạp $O(\text{len}(s) \cdot \text{len}(evil) \cdot 26 \cdot 4)$ với $\text{len}(s)$ là độ dài của 2 chuỗi.

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

Bước 1Bảng lastPos – ý tưởng của KMP

Bài này là một bài khá khó vì áp dụng quy hoạch động chữ số, đồng thời có sử dụng ý tưởng của thuật toán KMP, nên bạn đọc cần cân nhắc trước khi xem nha.

Ta cần đếm số chuỗi sao cho khi build các chuỗi này, không có chuỗi con nào giống toàn bộ chuỗi evil. Do đó ta cần một chiều trạng thái cho biết hiện tại phần cuối của chuỗi đang khớp được với tiền tố dài bao nhiêu của chuỗi evil.

Trước khi vào phần quy hoạch động, gọi lastPos[i][c] là độ dài tiền tố dài nhất của evil có thể khớp tiếp, nếu ta đang khớp được $i$ ký tự đầu của evil và thêm một ký tự $c$ vào sau. Ví dụ evil = "ababc": xét lastPos[4]['a'], tức là chuỗi đang xét là "abab" + 'a' = "ababa", khác với "ababc"; tuy nhiên 3 ký tự cuối "aba" trùng với tiền tố 3 ký tự đầu của evil, nên lastPos[4]['a'] = 3 (ô tô vàng). Ô tô đỏ là lúc khớp đủ cả chuỗi evil, trạng thái này bị cấm:

i (đã khớp)tiền tốthêm 'a'thêm 'b'thêm 'c'ký tự khác
0(rỗng)1000
1a1200
2ab3000
3aba1400
4abab3050

Cách build bảng: lấy chuỗi st là $i$ ký tự đầu tiên của evil, thêm ký tự $c$ vào sau. Duyệt độ dài $p$ từ $i + 1$ trở về 1, kiểm tra $p$ ký tự đầu tiên của evil có trùng với $p$ ký tự cuối cùng của chuỗi vừa tạo hay không, nếu có thì gán ngay lastPos[i][c] = p và dừng. Trong code, bảng này tên là pos[i][c], được tính trong hàm createLastPos (riêng trường hợp đang khớp 0 ký tự được xử lý thẳng trong vòng quy hoạch động: thêm đúng evil[0] thì khớp 1, không thì khớp 0).

100%Bước 2: quy hoạch động chữ số

Gọi f[i][j][state1][state2] là số lượng chuỗi có độ dài $i$, hiện tại đang khớp được tiền tố độ dài $j$ của chuỗi evil, và:

  • state1 = 0: chuỗi đã lớn hơn hẳn s1 (ở một vị trí nào đó trước đây); state1 = 1: chuỗi đang bằng đúng tiền tố của s1.
  • state2 = 0: chuỗi đã nhỏ hơn hẳn s2; state2 = 1: chuỗi đang bằng đúng tiền tố của s2.

Bài toán cơ sở: bắt đầu từ vị trí 0, f[0][0][1][1] = 1 (chuỗi rỗng, vẫn đang bằng cả tiền tố của s1 lẫn s2).

Chuyển trạng thái: từ f[i][j][state1][state2], thử gắn 1 ký tự $c$ vào vị trí thứ $i + 1$:

  • nextj = lastPos[j][c]. Nếu nextj bằng độ dài evil, đồng nghĩa với việc toàn bộ chuỗi evil xuất hiện trong chuỗi, ta bỏ qua trường hợp này.
  • nextstate1: nếu state1 = 0, hoặc state1 = 1 và $c \gt$ s1[i + 1] thì nextstate1 = 0; nếu state1 = 1 và $c$ bằng s1[i + 1] thì vẫn là 1; nếu state1 = 1 và $c \lt$ s1[i + 1] thì chuỗi sẽ nhỏ hơn s1, bỏ qua.
  • nextstate2: nếu state2 = 0, hoặc state2 = 1 và $c \lt$ s2[i + 1] thì nextstate2 = 0; bằng thì vẫn là 1; nếu state2 = 1 và $c \gt$ s2[i + 1] thì bỏ qua.
  • Cộng f[i + 1][nextj][nextstate1][nextstate2] thêm một lượng f[i][j][state1][state2].

Kết quả: tổng các f[n][j][state1][state2] với state1, state2 từ 0 đến 1, và $j$ từ 0 đến len(evil) - 1 ($j$ không được phép bằng len(evil)).

Em nhập s1, s2, evil rồi bấm Tiếp để xem từng bước: mỗi bước thêm 1 ký tự, các xâu được gom theo trạng thái (đang khớp bao nhiêu ký tự của evil, còn bằng s1 / s2 hay đã thoát giới hạn) và mình chỉ đếm số lượng ở mỗi trạng thái chứ không liệt kê từng xâu:

Ví dụ nhanh:
Liên hệ với bài "Tổng Các Chữ Số": 2 biến state1, state2 chính là cờ "đang đi sát theo giới hạn" của quy hoạch động chữ số, chỉ khác là ở đây có cả giới hạn dưới (s1) lẫn giới hạn trên (s2) cùng lúc, nên cần 2 cờ thay vì 1.

Source code để các bạn tham khảo nếu làm theo những gì mình phân tích ở trên:

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

const int mod = 1000000007;
int dp[505][55][2][2];
int pos[55][26];

void createLastPos(const string &s){
    int n = (int)s.size();
    string st = "";
    for(int i = 1; i < n; i++){
        st += s[i - 1];
        for(int c = 0; c <= 25; c++){
            string tmp = st;
            tmp += (char)(c + 'a');
            for(int j = i + 1; j >= 1; j--){
                if(c == s[j - 1] - 'a'){
                    int p = i + 1;
                    int q = j;
                    while(q > 0){
                        if(s[q - 1] != tmp[p - 1]){
                            break;
                        }
                        p--;
                        q--;
                    }
                    if(q == 0){
                        pos[i][c] = j;
                        break;
                    }
                }
            }
        }
    }
}

int findGoodStrings(int n, string s1, string s2, string evil){
    memset(dp, 0, sizeof(dp));
    memset(pos, 0, sizeof(pos));
    int lenevil = (int)evil.size();
    createLastPos(evil);
    dp[0][0][1][1] = 1;
    for(int i = 0; i < n; i++){
        for(int j = 0; j < lenevil; j++){
            for(int state1 = 0; state1 <= 1; state1++){
                for(int state2 = 0; state2 <= 1; state2++){
                    if(dp[i][j][state1][state2] == 0){
                        continue;
                    }
                    for(int nextChar = 0; nextChar <= 25; nextChar++){
                        if(j == lenevil - 1 && nextChar == evil[j] - 'a'){
                            continue;
                        }
                        if(state1 == 1 && nextChar < s1[i] - 'a'){
                            continue;
                        }
                        if(state2 == 1 && nextChar > s2[i] - 'a'){
                            continue;
                        }
                        int nextState1 = state1;
                        if(state1 == 1 && nextChar > s1[i] - 'a'){
                            nextState1 = 0;
                        }
                        int nextState2 = state2;
                        if(state2 == 1 && nextChar < s2[i] - 'a'){
                            nextState2 = 0;
                        }
                        int nextj = 0;
                        if(j == 0){
                            if(nextChar == evil[j] - 'a'){
                                nextj = 1;
                            }
                        }
                        else{
                            nextj = pos[j][nextChar];
                        }
                        if(nextj == lenevil){
                            continue;
                        }
                        (dp[i + 1][nextj][nextState1][nextState2] += dp[i][j][state1][state2]) %= mod;
                    }
                }
            }
        }
    }
    int ans = 0;
    for(int j = 0; j < lenevil; j++){
        for(int state1 = 0; state1 <= 1; state1++){
            for(int state2 = 0; state2 <= 1; state2++){
                (ans += dp[n][j][state1][state2]) %= mod;
            }
        }
    }
    return ans;
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    string s1, s2, evil;
    cin >> n >> s1 >> s2 >> evil;
    cout << findGoodStrings(n, s1, s2, evil);
    return 0;
}
import sys

mod = 1000000007

def createLastPos(s):
    # pos[i][c]: độ dài tiền tố dài nhất của evil là hậu tố của (i ký tự đầu của evil + ký tự c)
    n = len(s)
    pos = [[0] * 26 for _ in range(n + 1)]
    for i in range(1, n):
        st = s[:i]
        for c in range(26):
            tmp = st + chr(c + 97)
            for j in range(i + 1, 0, -1):
                if tmp.endswith(s[:j]):
                    pos[i][c] = j
                    break
    return pos

def findGoodStrings(n, s1, s2, evil):
    lenevil = len(evil)
    pos = createLastPos(evil)
    ev = [ord(ch) - 97 for ch in evil]
    # dp[j][state1][state2] cho độ dài hiện tại i (chỉ giữ 2 tầng i và i + 1)
    dp = [[[0, 0], [0, 0]] for _ in range(lenevil)]
    dp[0][1][1] = 1
    for i in range(n):
        lo = ord(s1[i]) - 97
        hi = ord(s2[i]) - 97
        ndp = [[[0, 0], [0, 0]] for _ in range(lenevil)]
        for j in range(lenevil):
            for state1 in range(2):
                for state2 in range(2):
                    cur = dp[j][state1][state2]
                    if cur == 0:
                        continue
                    start = lo if state1 == 1 else 0
                    end = hi if state2 == 1 else 25
                    for nextChar in range(start, end + 1):
                        nextState1 = 1 if (state1 == 1 and nextChar == lo) else 0
                        nextState2 = 1 if (state2 == 1 and nextChar == hi) else 0
                        if j == 0:
                            nextj = 1 if nextChar == ev[0] else 0
                        else:
                            nextj = pos[j][nextChar]
                        if nextj == lenevil:
                            continue
                        cell = ndp[nextj][nextState1]
                        cell[nextState2] = (cell[nextState2] + cur) % mod
        dp = ndp
    ans = 0
    for j in range(lenevil):
        for state1 in range(2):
            for state2 in range(2):
                ans = (ans + dp[j][state1][state2]) % mod
    return ans

data = sys.stdin.read().split()
n = int(data[0]); s1, s2, evil = data[1], data[2], data[3]
print(findGoodStrings(n, s1, s2, evil))
C++▶ Chạy tay từng dòng codeDựng bảng pos (ý tưởng KMP) rồi quy hoạch động chữ số dp[i][j][state1][state2].
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const int mod = 1000000007;
5int dp[505][55][2][2];
6int pos[55][26];
7
8void createLastPos(const string &s){
9 int n = (int)s.size();
10 string st = "";
11 for(int i = 1; i < n; i++){
12 st += s[i - 1];
13 for(int c = 0; c <= 25; c++){
14 string tmp = st;
15 tmp += (char)(c + 'a');
16 for(int j = i + 1; j >= 1; j--){
17 if(c == s[j - 1] - 'a'){
18 int p = i + 1;
19 int q = j;
20 while(q > 0){
21 if(s[q - 1] != tmp[p - 1]){
22 break;
23 }
24 p--;
25 q--;
26 }
27 if(q == 0){
28 pos[i][c] = j;
29 break;
30 }
31 }
32 }
33 }
34 }
35}
36
37int findGoodStrings(int n, string s1, string s2, string evil){
38 memset(dp, 0, sizeof(dp));
39 memset(pos, 0, sizeof(pos));
40 int lenevil = (int)evil.size();
41 createLastPos(evil);
42 dp[0][0][1][1] = 1;
43 for(int i = 0; i < n; i++){
44 for(int j = 0; j < lenevil; j++){
45 for(int state1 = 0; state1 <= 1; state1++){
46 for(int state2 = 0; state2 <= 1; state2++){
47 if(dp[i][j][state1][state2] == 0){
48 continue;
49 }
50 for(int nextChar = 0; nextChar <= 25; nextChar++){
51 if(j == lenevil - 1 && nextChar == evil[j] - 'a'){
52 continue;
53 }
54 if(state1 == 1 && nextChar < s1[i] - 'a'){
55 continue;
56 }
57 if(state2 == 1 && nextChar > s2[i] - 'a'){
58 continue;
59 }
60 int nextState1 = state1;
61 if(state1 == 1 && nextChar > s1[i] - 'a'){
62 nextState1 = 0;
63 }
64 int nextState2 = state2;
65 if(state2 == 1 && nextChar < s2[i] - 'a'){
66 nextState2 = 0;
67 }
68 int nextj = 0;
69 if(j == 0){
70 if(nextChar == evil[j] - 'a'){
71 nextj = 1;
72 }
73 }
74 else{
75 nextj = pos[j][nextChar];
76 }
77 if(nextj == lenevil){
78 continue;
79 }
80 (dp[i + 1][nextj][nextState1][nextState2] += dp[i][j][state1][state2]) %= mod;
81 }
82 }
83 }
84 }
85 }
86 int ans = 0;
87 for(int j = 0; j < lenevil; j++){
88 for(int state1 = 0; state1 <= 1; state1++){
89 for(int state2 = 0; state2 <= 1; state2++){
90 (ans += dp[n][j][state1][state2]) %= mod;
91 }
92 }
93 }
94 return ans;
95}
96
97int main(){
98 ios::sync_with_stdio(0);
99 cin.tie(0);
100 int n;
101 string s1, s2, evil;
102 cin >> n >> s1 >> s2 >> evil;
103 cout << findGoodStrings(n, s1, s2, evil);
104 return 0;
105}

Mình đã kiểm tra bản C++ và bản Python mình viết thêm với 140 bộ test ngẫu nhiên (độ dài tới 4, so với cách liệt kê mọi chuỗi), đều cho cùng kết quả. Với $n = 500$ và evil dài 50, bản C++ chạy khoảng 0.01 giây, bản Python khoảng 0.15 giây. Bản Python chỉ giữ 2 tầng $i$ và $i + 1$ của mảng f để tiết kiệm bộ nhớ, và với mỗi trạng thái chỉ thử các ký tự nằm trong khoảng cho phép thay vì thử cả 26 rồi bỏ qua.

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 😇