Chương 1·Bài 13

Digit DP

Cho hai số a≤ba \le b cỡ 101810^{18} và một tính chất chỉ nhìn vào các chữ số thập phân: tổng chữ số bằng ss, không có hai chữ số kề nhau bằng nhau, chia hết cho mọi chữ số khác 0 của chính nó. Hỏi có bao nhiêu số trong đoạn [a,b][a, b] có tính chất đó? Duyệt từng số tốn b−a+1b - a + 1 bước, với 101810^{18} thì không bao giờ xong. Nhưng mỗi số chỉ có tối đa 19 chữ số, và các số gần nhau có phần đuôi giống hệt nhau: từ 7000 đến 7999, ba chữ số cuối chạy qua đúng những giá trị mà chúng chạy qua từ 8000 đến 8999. Quy hoạch động chữ số (digit DP) dựng số từng chữ số từ trái sang phải, gộp mọi cách dựng dẫn tới cùng một “tình trạng” rồi đếm chúng một lần. Với 19 vị trí và một trạng thái nhỏ, cả bài chỉ tốn vài chục nghìn phép tính.

Đếm trên [0, X] bằng cờ chặn

Gọi P(x)P(x) là tính chất cần kiểm và F(X)F(X) là số lượng số nguyên xx thỏa 0≤x≤X0 \le x \le X và P(x)P(x). Các số từ 0 tới bb gồm các số từ 0 tới a−1a - 1 cộng các số từ aa tới bb, nên số lượng xx thỏa PP trong đoạn [a,b][a, b] là

F(b)−F(a−1).F(b) - F(a - 1).

Đây là mẹo của tổng tiền tố ở bài 02: đếm trên hai tiền tố rồi trừ. Khi a=0a = 0 ta cần F(−1)F(-1), đếm trên đoạn rỗng, bằng 0; hàm FF phải trả 0 ngay khi X<0X < 0, trước khi tách chữ số. Khi aa cho dạng chuỗi dài hàng nghìn chữ số, tính a−1a - 1 rất phiền; khi đó dùng F(b)−F(a)+[P(a)]F(b) - F(a) + [P(a)], trong đó [P(a)][P(a)] bằng 1 nếu aa hợp lệ, kiểm thẳng trên các chữ số của aa.

Hỏi nhanh

Hàm F(X)F(X) đếm trên [0,X][0, X] bằng cách tách chữ số của XX. Đề cho a=0a = 0, nên công thức F(b)−F(a−1)F(b) - F(a - 1) cần F(−1)F(-1). Chương trình nên xử lý thế nào?

Tách theo vị trí rẽ nhánh. Cho XX có nn chữ số x1x2…xnx_1 x_2 \dots x_n, với x1x_1 cao nhất. Mọi y∈[0,X]y \in [0, X] viết được thành đúng nn chữ số bằng cách thêm số 0 vào đầu (với n=4n = 4, số 42 thành 0042). Hai chuỗi cùng độ dài so sánh theo vị trí đầu tiên chúng khác nhau, trùng với thứ tự giá trị, nên mỗi y<Xy < X nằm trong đúng một khối (i,c)(i, c): yy trùng XX ở i−1i - 1 chữ số đầu, chữ số thứ ii bằng c<xic < x_i, và n−in - i chữ số cuối tùy ý. Các khối rời nhau; hợp của chúng cùng với {X}\{X\} là cả [0,X][0, X]. Với X=2718X = 2718 ta có 2⋅1000+7⋅100+1⋅10+8+1=27192 \cdot 1000 + 7 \cdot 100 + 1 \cdot 10 + 8 + 1 = 2719 số.

Mỗi hàng là một khối: các ô đầu trùng X, ô vàng rẽ xuống chữ số nhỏ hơn, dấu sao là tự do. Gõ y để xem nó thuộc khối nào.

Điểm mấu chốt: trong một khối, phần đầu đã cố định còn phần cuối hoàn toàn tự do, nên số cách điền phần cuối chỉ phụ thuộc vào còn bao nhiêu chữ số và phần đầu để lại “tình trạng” gì, không phụ thuộc XX.

Đệ quy có nhớ với cờ chặn. Gọi σ\sigma là trạng thái của phần đã điền. Hàm go(pos, σ, tight) trả về số cách điền các vị trí pos, ..., n-1 sao cho cả số cuối cùng hợp lệ và không vượt XX; dig[0] là chữ số cao nhất. Cờ tight bằng đúng khi các chữ số đã điền trùng với dig[0..pos-1]; khi đó chữ số kế tiếp chỉ được tới dig[pos], còn nếu không thì được tới 9. Chọn chữ số dd thì cờ mới là tight && d == hi. Các lời gọi có tight bật tạo thành một chuỗi đi theo từng chữ số của XX; mỗi lần rời chuỗi bằng chữ số c<xic < x_i ta rơi vào đúng khối (i,c)(i, c), nên không đếm thiếu, không đếm trùng, và chuỗi kết thúc tại chính XX.

Một lời gọi tự do go(pos, σ, false) chỉ phụ thuộc vào số chữ số còn lại rest = n - pos và σ\sigma, vì mọi chữ số phía sau đều chạy từ 0 đến 9 dù XX là bao nhiêu. Ta nhớ nó theo khóa (rest, σ). Lời gọi đang bị chặn thì giá trị phụ thuộc phần còn lại của XX, nên không ghi; cũng không cần, vì chỉ có nn lời gọi như vậy. Gọi ∣S∣|S| là số giá trị mà σ\sigma có thể nhận; số trạng thái tự do là n⋅∣S∣n \cdot |S|, mỗi trạng thái xét 10 chữ số, nên cả bảng tốn O(10⋅n⋅∣S∣)O(10 \cdot n \cdot |S|).

Bảng dưới là các trạng thái tự do đã nhớ; mỗi vị trí của chuỗi tight cộng vài ô của bảng.

Chương trình dưới đếm các số trong [a,b][a, b] có tổng chữ số bằng ss; trạng thái là tổng đã điền, ∣S∣≤172|S| \le 172.

C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll s, memo[20][200];
bool vis[20][200];
vector<ll> dig;

ll go(ll pos, ll sum, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return sum == s;
    if (!tight && vis[rest][sum]) return memo[rest][sum];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) res += go(pos + 1, sum + d, tight && d == hi);
    if (!tight) {
        vis[rest][sum] = 1;
        memo[rest][sum] = res;
    }
    return res;
}

ll F(ll X) {
    if (X < 0) return 0;
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 0, 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll a, b;
    cin >> a >> b >> s;
    cout << F(b) - F(a - 1) << '\n';
}
0 2718 3

Chạy tay theo khối: vị trí 1 với c=0c = 0 cần tổng 3 từ ba chữ số tự do, 10 cách; c=1c = 1 cần tổng 2, 6 cách. Vị trí 2 (tiền tố 2): c=0c = 0 cần tổng 1 từ hai chữ số, 2 cách; c=1c = 1 cần tổng 0, 1 cách. Tiền tố 27 đã có tổng 9, vượt 3. Tổng 10+6+2+1=1910 + 6 + 2 + 1 = 19. Khóa của bảng là rest chứ không phải pos: F(b)F(b) và F(a−1)F(a - 1) có thể có số chữ số khác nhau, và chỉ rest mới mang cùng ý nghĩa ở cả hai lần gọi. Nhờ vậy một bảng dùng chung cho mọi lần gọi FF; chỉ khi tham số của bài (ở đây là ss) đổi giữa các truy vấn mới phải xóa bảng.

Hỏi nhanh

Với X=2718X = 2718 và X′=2790X' = 2790, cả hai lần gọi đều đi qua trạng thái bị chặn sau khi điền hai chữ số 2, 7: còn hai chữ số, tổng đã điền là 9. Nếu ghi kết quả của trạng thái này vào memo[2][9] dùng chung cho mọi lần gọi, điều gì sai?

Ví dụDấu Vết 4 Và 9

Đề. Cho 1≤A≤B≤10181 \le A \le B \le 10^{18}. Đếm các số trong [A,B][A, B] có ít nhất một chữ số bằng 4 hoặc 9.

Lời giải. Trạng thái là một cờ has: phần đã điền đã có chữ số 4 hoặc 9 chưa. Đặt chữ số dd thì has mới là has || d == 4 || d == 9; ở cuối trả has. Số 0 đệm không phải 4 hay 9 nên không làm hỏng cờ. Chỉ có 19⋅219 \cdot 2 trạng thái tự do. Cách khác là đếm bù: số không chứa 4 và 9 trong [0,X][0, X] chỉ cần cờ chặn, rồi lấy X+1X + 1 trừ đi. Với ví dụ, cả bảy số từ 39 tới 45 đều có 4 hoặc 9.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll memo[20][2];
bool vis[20][2];
vector<ll> dig;

ll go(ll pos, bool has, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return has;
    if (!tight && vis[rest][has]) return memo[rest][has];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) res += go(pos + 1, has || d == 4 || d == 9, tight && d == hi);
    if (!tight) {
        vis[rest][has] = 1;
        memo[rest][has] = res;
    }
    return res;
}

ll F(ll X) {
    if (X < 0) return 0;
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 0, 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll a, b;
    cin >> a >> b;
    cout << F(b) - F(a - 1) << '\n';
}
39 45

Số 0 dẫn đầu và cờ bắt đầu

Đệm số 0 cho đủ nn chữ số rất tiện, nhưng chương trình giờ thấy số 42 là 0042 và đọc hai số 0 đệm như hai chữ số thật. Với tổng chữ số, phần dư hay bội chung nhỏ nhất của các chữ số khác 0, số 0 đứng đầu vô hại vì không làm đổi tổng, phần dư hay bội chung. Nhưng với “không có chữ số 0”, “không có hai chữ số kề nhau bằng nhau”, “tích các chữ số” hay “mỗi chữ số xuất hiện chẵn lần”, các số 0 đệm phải bị bỏ qua.

Cách xử lý là thêm cờ bắt đầu (st): đã đặt chữ số khác 0 chưa. Chưa bắt đầu mà chọn 0 thì vẫn chưa bắt đầu và trạng thái của tính chất không đổi; chọn chữ số khác 0 thì bắt đầu. Từ lúc bắt đầu, mọi chữ số, kể cả 0, là chữ số thật.

Ô mờ là số 0 đệm; cờ st bật từ chữ số khác 0 đầu tiên.

Chương trình dưới đếm số xx trong [1,N][1, N] không có chữ số 0. Chưa bắt đầu mà chọn 0 là số 0 đệm, cho qua; đã bắt đầu mà chọn 0 là chữ số 0 thật, bị cấm. Ở cuối, đường đi chưa bắt đầu suốt nn vị trí chính là số 0, nên return st loại luôn số 0 khỏi [1,N][1, N].

C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll n, memo[20][2];
bool vis[20][2];
vector<ll> dig;

ll go(ll pos, bool st, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return st;
    if (!tight && vis[rest][st]) return memo[rest][st];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) {
        if (d == 0 && st) continue;
        res += go(pos + 1, st || d > 0, tight && d == hi);
    }
    if (!tight) {
        vis[rest][st] = 1;
        memo[rest][st] = res;
    }
    return res;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (char c : to_string(n)) dig.push_back(c - '0');
    cout << go(0, 0, 1) << '\n';
}
120

Kiểm tay: có 9 số một chữ số, 9⋅9=819 \cdot 9 = 81 số hai chữ số không có số 0, và với ba chữ số chỉ 111 đến 119 thỏa, tổng 9+81+9=999 + 81 + 9 = 99.

Hỏi nhanh

Một bạn đếm số x∈[1,N]x \in [1, N] không có chữ số 0 với N=120N = 120 như sau: viết NN thành ba chữ số 1, 2, 0, duyệt mọi chuỗi ba chữ số không vượt quá 120 và loại chuỗi nào có chữ số 0 ở bất kỳ vị trí nào, kể cả số 0 đệm. Chương trình in ra bao nhiêu?

Cờ bắt đầu không nhất thiết là một chiều riêng của trạng thái. Khi trạng thái đã có “chữ số vừa điền”, ta thêm cho nó giá trị 10 nghĩa là “chưa có chữ số nào”, như ở ví dụ dưới.

Ví dụHai Chữ Số Kề Nhau

Đề. Đếm các số trong [a,b][a, b], 0≤a≤b≤10180 \le a \le b \le 10^{18}, không có hai chữ số liền nhau bằng nhau trong biểu diễn thập phân. Số 0 hợp lệ.

Lời giải. Tính chất chỉ phụ thuộc chữ số đứng ngay trước, nhưng số 0 đệm phải bị bỏ qua: 42 hợp lệ, còn 0042 đọc như thể có hai số 0 liền nhau. Trạng thái là chữ số pp vừa đặt, từ 0 đến 9, thêm giá trị 10 nghĩa là “chưa có chữ số nào”. Chọn dd bằng pp thì bỏ qua; vì p=10p = 10 không trùng chữ số nào nên chữ số đầu tiên không bị cấm. Nếu p=10p = 10 và d=0d = 0 thì pp giữ nguyên 10 (số 0 đệm), còn lại pp mới bằng dd. Ở cuối mọi đường còn lại đều hợp lệ, kể cả đường toàn 0 biểu diễn số 0. Hai số 0 liền nhau ở giữa số, như trong 100, vẫn bị bắt vì lúc đó p=0p = 0 là chữ số thật.

Có 19⋅1119 \cdot 11 trạng thái tự do. Kiểm tay: F(99)=10+81=91F(99) = 10 + 81 = 91; trong 98, 99 chỉ 99 bị loại nên F(97)=90F(97) = 90; trong 100, 101, 102 chỉ 100 bị loại nên F(102)=93F(102) = 93. Đoạn [98,102][98, 102] có 93−90=393 - 90 = 3 số.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll memo[20][11];
bool vis[20][11];
vector<ll> dig;

ll go(ll pos, ll p, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return 1;
    if (!tight && vis[rest][p]) return memo[rest][p];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) {
        if (d == p) continue;
        res += go(pos + 1, (p == 10 && d == 0) ? 10 : d, tight && d == hi);
    }
    if (!tight) {
        vis[rest][p] = 1;
        memo[rest][p] = res;
    }
    return res;
}

ll F(ll X) {
    if (X < 0) return 0;
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 10, 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll a, b;
    cin >> a >> b;
    cout << F(b) - F(a - 1) << '\n';
}
98 102

Chọn trạng thái

Trạng thái trả lời câu hỏi: chỉ biết những điều này về phần đã điền, ta có đủ để đếm phần còn lại không? Hai phần đầu có cùng thông tin thì có cùng tương lai nên được gộp, và đó là chỗ quy hoạch động tiết kiệm. Ngược lại, thiếu thông tin thì hai phần đầu có tương lai khác nhau bị gộp nhầm, và đáp án sai.

Tính chất của số Cần nhớ về phần đã điền Số trạng thái
tổng chữ số bằng ss tổng các chữ số đã điền 9n+19n + 1
chia hết cho mm phần dư của phần đã điền khi chia cho mm mm
không có hai chữ số kề nhau bằng nhau chữ số vừa điền, hoặc “chưa có” 11
mỗi chữ số xuất hiện chẵn lần mặt nạ bit: chữ số nào đã xuất hiện lẻ lần 2102^{10}
chứa cặp “13” liên tiếp chữ số vừa điền có là 1 không, và đã gặp cặp “13” chưa 4
tích các chữ số không quá KK tích hiện tại, mọi tích lớn hơn KK gộp thành một dưới sáu nghìn

Hàng cuối: tích các chữ số chỉ có dạng 2a3b5c7d2^a 3^b 5^c 7^d, và với K≤109K \le 10^9 chỉ có 5194 giá trị không vượt KK. Mọi tích lớn hơn KK được gộp thành một trạng thái “quá KK”, nhưng không được cắt nhánh đó: một chữ số 0 thật về sau đưa tích về 0, và 0≤K0 \le K. Khi nn lớn thì trạng thái càng phải gọn: tổng chữ số chia hết cho DD chỉ cần nhớ phần dư theo DD, vì hai tổng cùng phần dư có cùng tương lai.

Thời gian là n⋅∣S∣⋅10n \cdot |S| \cdot 10, nên với n=19n = 19 thì ∣S∣|S| cỡ 10510^5 vẫn nhẹ (khoảng 2⋅1072 \cdot 10^7 phép tính), còn ∣S∣≈106|S| \approx 10^6 đã chạm giới hạn cả thời gian lẫn bộ nhớ. Mặt nạ tập chữ số sẽ gặp lại ở bài 15, và trạng thái “đang khớp tới đâu trong các mẫu cho trước” cần Aho-Corasick ở bài 73.

Ví dụTổng Chữ Số Chia Hết

Đề. Cho số nguyên dương K<1010000K < 10^{10000} dạng chuỗi và D≤100D \le 100. Đếm các số 1≤x≤K1 \le x \le K có tổng chữ số chia hết cho DD, theo modulo 109+710^9 + 7.

Lời giải. Tổng chữ số tới 9⋅1049 \cdot 10^4, nhưng chỉ phần dư của nó theo DD ảnh hưởng tới tương lai, nên trạng thái là phần dư rr; đặt chữ số dd thì r→(r+d) mod Dr \to (r + d) \bmod D. Cận là chuỗi, nên dig lấy thẳng từ chuỗi, và đáp án đếm theo modulo. Số 0 đệm không đổi tổng nên không cần cờ bắt đầu, nhưng hàm đếm trên [0,K][0, K] có tính số 0 (tổng 0 chia hết cho mọi DD), nên trừ 1. Có 104⋅10010^4 \cdot 100 trạng thái tự do, mỗi trạng thái 10 chữ số: 10710^7 phép. Độ sâu đệ quy bằng số chữ số, 10410^4 tầng, vẫn an toàn.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll MOD = 1e9 + 7;
string K;
ll D, n, memo[10005][105];

ll go(ll pos, ll r, bool tight) {
    ll rest = n - pos;
    if (rest == 0) return r == 0;
    if (!tight && memo[rest][r] != -1) return memo[rest][r];
    ll hi = tight ? K[pos] - '0' : 9, res = 0;
    for (ll d = 0; d <= hi; d++) res = (res + go(pos + 1, (r + d) % D, tight && d == hi)) % MOD;
    if (!tight) memo[rest][r] = res;
    return res;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> K >> D;
    n = K.size();
    memset(memo, -1, sizeof(memo));
    cout << (go(0, 0, 1) - 1 + MOD) % MOD << '\n';
}
20
2

Ví dụChia Hết Cho Mọi Chữ Số

Đề. Có T≤10T \le 10 truy vấn, mỗi truy vấn cho 1≤l≤r≤9⋅10181 \le l \le r \le 9 \cdot 10^{18}; đếm các số trong [l,r][l, r] chia hết cho từng chữ số khác 0 của nó.

Lời giải. Điều kiện nhắc tới toàn bộ số xx: xx phải chia hết cho bội chung nhỏ nhất LL của các chữ số khác 0. Ta chưa biết LL khi mới điền một phần, nhưng LL luôn là ước của lcm(1,…,9)=2520\mathrm{lcm}(1, \dots, 9) = 2520, và 2520 chỉ có 48 ước. Vì L∣2520L \mid 2520 nên L∣xL \mid x khi và chỉ khi L∣(x mod 2520)L \mid (x \bmod 2520). Vậy trạng thái gồm phần dư rr của phần đã điền theo 2520 và ước LL hiện tại. Số 0 đệm không đổi rr cũng không đổi LL, nên không cần cờ bắt đầu; số 0 được đếm ở cả F(r)F(r) lẫn F(l−1)F(l - 1) rồi triệt tiêu.

Bước 1: đánh số các ước của 2520 và bảng chuyển. dv liệt kê 48 ước, id[v] cho số thứ tự của ước vv. nxt[i][d] là số thứ tự của lcm(dvi,d)\mathrm{lcm}(\text{dv}_i, d) khi đặt thêm chữ số d≠0d \ne 0; chữ số 0 giữ nguyên LL. Bảng dựng một lần, chuyển trạng thái chỉ còn là một lần tra.

C++
for (ll v = 1; v <= M; v++)
    if (M % v == 0) {
        id[v] = dv.size();
        dv.push_back(v);
    }
for (ll i = 0; i < (ll)dv.size(); i++)
    for (ll d = 0; d <= 9; d++) nxt[i][d] = id[d == 0 ? dv[i] : lcm(dv[i], d)];

Bước 2: đếm theo khung chữ số. Đặt dd thì r→(10r+d) mod 2520r \to (10r + d) \bmod 2520 và L→L \to nxt[li][d]. Ở cuối, số hợp lệ khi L∣rL \mid r. Trạng thái tự do (rest, r, li) được nhớ trong memo, đánh dấu chưa tính bằng −1-1.

C++
ll go(ll pos, ll r, ll li, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return r % dv[li] == 0;
    if (!tight && memo[rest][r][li] != -1) return memo[rest][r][li];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) res += go(pos + 1, (r * 10 + d) % M, nxt[li][d], tight && d == hi);
    if (!tight) memo[rest][r][li] = res;
    return res;
}

Bảng memo không phụ thuộc truy vấn (chỉ phụ thuộc số chữ số còn lại, không phụ thuộc cận), nên dùng chung cho cả TT truy vấn. Với ví dụ, trong [10,20][10, 20] các số 10, 11, 12, 15, 20 hợp lệ.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll M = 2520;
ll t, id[M + 1], nxt[48][10], memo[20][M][48];
vector<ll> dv, dig;

ll go(ll pos, ll r, ll li, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return r % dv[li] == 0;
    if (!tight && memo[rest][r][li] != -1) return memo[rest][r][li];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) res += go(pos + 1, (r * 10 + d) % M, nxt[li][d], tight && d == hi);
    if (!tight) memo[rest][r][li] = res;
    return res;
}

ll F(ll X) {
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 0, id[1], 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    for (ll v = 1; v <= M; v++)
        if (M % v == 0) {
            id[v] = dv.size();
            dv.push_back(v);
        }
    for (ll i = 0; i < (ll)dv.size(); i++)
        for (ll d = 0; d <= 9; d++) nxt[i][d] = id[d == 0 ? dv[i] : lcm(dv[i], d)];
    memset(memo, -1, sizeof(memo));
    cin >> t;
    while (t--) {
        ll l, r;
        cin >> l >> r;
        cout << F(r) - F(l - 1) << '\n';
    }
}
1
10 20

Có 19⋅2520⋅48≈2,3⋅10619 \cdot 2520 \cdot 48 \approx 2{,}3 \cdot 10^6 trạng thái tự do, mỗi trạng thái thử 10 chữ số: 2,3⋅1072{,}3 \cdot 10^7 phép và khoảng 19 MB bảng.

Đếm kèm tổng

Nhiều bài hỏi tổng của một đại lượng trên các số hợp lệ: tổng giá trị, tổng chữ số, tổng số lần xuất hiện một chữ số. Khi đó go trả về một cặp (C,T)(C, T) thay vì một số.

Với trạng thái còn rr chữ số phải điền: CC là số cách điền hợp lệ, còn TT là tổng, trên mọi cách đó, của giá trị phần đuôi do rr chữ số vừa điền tạo thành. Chọn chữ số dd cho vị trí kế tiếp, có trọng số 10r−110^{r-1}; trạng thái con trả về (Cd,Td)(C_d, T_d). Mỗi cách của trạng thái con được ghép thêm dd ở phía trước, nên giá trị đuôi của nó tăng thêm d⋅10r−1d \cdot 10^{r-1}:

C=∑dCd,T=∑d(Td+d⋅10r−1⋅Cd).C = \sum_{d} C_d, \qquad T = \sum_{d} \bigl(T_d + d \cdot 10^{r-1} \cdot C_d\bigr).

Ở trạng thái xuất phát, phần đuôi chính là cả số, nên TT là tổng các số hợp lệ. Nếu đề hỏi theo modulo, lấy modulo sau mỗi phép nhân và để 10r−110^{r-1} ở dạng đã lấy modulo. Cần tổng bình phương thì thêm thành phần thứ ba QQ, vì (a+v)2=a2+2av+v2(a + v)^2 = a^2 + 2av + v^2. Cần tổng các chữ số thay vì tổng các số thì trọng số chỉ là dd, không nhân 10r−110^{r-1}.

Chương trình sau tính tổng các số trong [a,b][a, b] không chứa chữ số 7, với b≤109b \le 10^9. Tính chất này không cần trạng thái nào ngoài cờ chặn, nên bảng nhớ chỉ có chỉ số rest; mỗi ô là một pair (số cách, tổng).

C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll pw[20];
bool vis[20];
pair<ll, ll> memo[20];
vector<ll> dig;

pair<ll, ll> go(ll pos, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return {1, 0};
    if (!tight && vis[rest]) return memo[rest];
    ll hi = tight ? dig[pos] : 9;
    pair<ll, ll> res = {0, 0};
    for (ll d = 0; d <= hi; d++) {
        if (d == 7) continue;
        pair<ll, ll> sub = go(pos + 1, tight && d == hi);
        res.first += sub.first;
        res.second += sub.second + d * pw[rest - 1] * sub.first;
    }
    if (!tight) {
        vis[rest] = 1;
        memo[rest] = res;
    }
    return res;
}

ll F(ll X) {
    if (X < 0) return 0;
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 1).second;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    pw[0] = 1;
    for (ll i = 1; i < 19; i++) pw[i] = pw[i - 1] * 10;
    ll a, b;
    cin >> a >> b;
    cout << F(b) - F(a - 1) << '\n';
}
1 30

Kiểm tay: 1+2+⋯+30=4651 + 2 + \dots + 30 = 465, bỏ ba số có chữ số 7 là 7, 17, 27 (tổng 51) còn 414. Số 0 đứng đầu không đóng góp gì vào giá trị, nên bài này không cần cờ bắt đầu.

Hỏi nhanh

Hàm go trả về (C,T)(C, T) như trên. Ở trạng thái còn rr chữ số, ta chọn dd cho vị trí kế tiếp và nhận (Cd,Td)(C_d, T_d) từ trạng thái con còn r−1r - 1 chữ số. Phần dd đóng góp vào TT của trạng thái cha là gì?

Ví dụTổng Chữ Số Trên Đoạn

Đề. Với mỗi truy vấn 0≤a≤b≤1090 \le a \le b \le 10^9, in S(a)+S(a+1)+⋯+S(b)S(a) + S(a + 1) + \dots + S(b), với S(x)S(x) là tổng chữ số của xx. Dữ liệu kết thúc bằng cặp -1 -1.

Lời giải. Cùng khuôn cặp (C,T)(C, T), nhưng TT là tổng các chữ số của phần đuôi chứ không phải giá trị, nên chữ số dd đóng góp d⋅Cdd \cdot C_d, không nhân trọng số hàng. Không có ràng buộc nào ngoài cận, nên trạng thái tự do chỉ là rest và bảng dùng chung cho mọi truy vấn. Đáp án là F(b)−F(a−1)F(b) - F(a - 1) với F(−1)=0F(-1) = 0; S(0)=0S(0) = 0 nên số 0 không ảnh hưởng. Tổng lớn nhất cỡ 81⋅109/281 \cdot 10^9 / 2, vừa ll. Với ví dụ, S(1)+⋯+S(9)=45S(1) + \dots + S(9) = 45 và S(10)=1S(10) = 1, tổng 46.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

bool vis[20];
pair<ll, ll> memo[20];
vector<ll> dig;

pair<ll, ll> go(ll pos, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return {1, 0};
    if (!tight && vis[rest]) return memo[rest];
    ll hi = tight ? dig[pos] : 9;
    pair<ll, ll> res = {0, 0};
    for (ll d = 0; d <= hi; d++) {
        pair<ll, ll> sub = go(pos + 1, tight && d == hi);
        res.first += sub.first;
        res.second += sub.second + d * sub.first;
    }
    if (!tight) {
        vis[rest] = 1;
        memo[rest] = res;
    }
    return res;
}

ll F(ll X) {
    if (X < 0) return 0;
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 1).second;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll a, b;
    while (cin >> a >> b && a != -1) cout << F(b) - F(a - 1) << '\n';
}
1 10
20 22
-1 -1

Các biến thể hay gặp

Cơ số khác. Trong hệ nhị phân chữ số chỉ là 0 hoặc 1, hi là 1 thay cho 9, và XX có tối đa 31 hay 63 bit. Mọi thứ khác giữ nguyên; đếm số x≤Nx \le N không có hai bit 1 kề nhau chỉ cần nhớ bit vừa điền.

Cận dạng chuỗi. Khi NN có tới 10410^4 chữ số, FF nhận chuỗi, đáp án lấy theo modulo, và dùng F(b)−F(a)+[P(a)]F(b) - F(a) + [P(a)] thay cho a−1a - 1. Độ sâu đệ quy bằng nn, vẫn chấp nhận được; chi phí n⋅∣S∣⋅10n \cdot |S| \cdot 10 bắt buộc ∣S∣|S| nhỏ.

Số thứ kk. F(X)F(X) không giảm khi XX tăng, nên số hợp lệ thứ kk là số XX nhỏ nhất có F(X)≥kF(X) \ge k, tìm bằng chặt nhị phân (bài 05). Nhờ bảng nhớ dùng chung, mỗi lần gọi FF chỉ tốn O(10n)O(10n) lời gọi bị chặn.

Điều kiện phụ thuộc cả con số. Khi điều kiện nhắc tới một đại lượng của toàn số, như “xx chia hết cho tổng chữ số của xx”, ta không biết phải chia cho gì khi mới điền một phần. Nhưng tổng chữ số chỉ nhận tối đa 9n9n giá trị. Duyệt từng giá trị ss của nó, chạy quy hoạch động chữ số với ss cố định (trạng thái gồm tổng và phần dư theo ss) rồi cộng đáp án qua mọi ss.

Nhiều cận cùng lúc. Đếm cặp (a,b)(a, b) với cả hai không vượt XX cần hai cờ chặn, mỗi lần chuyển thử mọi cặp chữ số của hai số; trên đoạn [l,r][l, r] thì dùng bao hàm loại trừ bốn tiền tố như ở tổng tiền tố hai chiều.

Ví dụSố Thứ K Chứa 666

Đề. Liệt kê tăng dần các số nguyên dương có chứa chuỗi con 666. Với mỗi trong T≤100T \le 100 truy vấn k≤107k \le 10^7, in số thứ kk.

Lời giải. Hai phần: đếm F(X)F(X), số các số trong [0,X][0, X] chứa 666, rồi tìm số thứ kk bằng chặt nhị phân trên FF.

Bước 1: đếm bằng trạng thái “số chữ số 6 cuối”. cc là số chữ số 6 liên tiếp ở cuối phần đã điền, chặn ở 3 nghĩa là “đã gặp 666”. Đặt 6 thì cc tăng 1, đặt chữ số khác thì cc về 0, còn c=3c = 3 thì giữ nguyên dù đặt gì. Ở cuối trả c == 3. Số 0 đệm không phải 6 nên không làm sai cc.

C++
for (ll d = 0; d <= hi; d++) {
    ll nc = c == 3 ? 3 : (d == 6 ? c + 1 : 0);
    res += go(pos + 1, nc, tight && d == hi);
}

Bước 2: chặt nhị phân trên đáp án. FF tăng đúng 1 tại mỗi số chứa 666 và không đổi ở các số khác, nên số thứ kk là XX nhỏ nhất có F(X)≥kF(X) \ge k. Đáp án với k=107k = 10^7 chưa tới 101010^{10}, nên cận trên 101510^{15} là dư.

C++
ll l = 1, r = 1e15, res = r;
while (l <= r) {
    ll mid = (l + r) >> 1;
    if (F(mid) >= k) r = (res = mid) - 1;
    else l = mid + 1;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll t, memo[20][4];
bool vis[20][4];
vector<ll> dig;

ll go(ll pos, ll c, bool tight) {
    ll rest = dig.size() - pos;
    if (rest == 0) return c == 3;
    if (!tight && vis[rest][c]) return memo[rest][c];
    ll hi = tight ? dig[pos] : 9, res = 0;
    for (ll d = 0; d <= hi; d++) {
        ll nc = c == 3 ? 3 : (d == 6 ? c + 1 : 0);
        res += go(pos + 1, nc, tight && d == hi);
    }
    if (!tight) {
        vis[rest][c] = 1;
        memo[rest][c] = res;
    }
    return res;
}

ll F(ll X) {
    dig.clear();
    for (char c : to_string(X)) dig.push_back(c - '0');
    return go(0, 0, 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> t;
    while (t--) {
        ll k;
        cin >> k;
        ll l = 1, r = 1e15, res = r;
        while (l <= r) {
            ll mid = (l + r) >> 1;
            if (F(mid) >= k) r = (res = mid) - 1;
            else l = mid + 1;
        }
        cout << res << '\n';
    }
}
3
1
2
3

Mỗi truy vấn khoảng 50 lần gọi FF; bảng nhớ chỉ có 20⋅420 \cdot 4 trạng thái tự do và dùng chung cho mọi lần gọi, nên mỗi lần gọi chỉ tốn cỡ 19⋅1019 \cdot 10 bước trên đường bám cận.

Khi đi thi

  • Thấy “đếm số trong đoạn [a,b][a, b] theo tính chất của chữ số” với cận từ 10710^7 trở lên, viết F(X)F(X) cho một cận rồi lấy F(b)−F(a−1)F(b) - F(a - 1), nhớ F(−1)=0F(-1) = 0; nếu aa cho dạng chuỗi dài thì dùng F(b)−F(a)+[P(a)]F(b) - F(a) + [P(a)].
  • Trước khi gõ code, tự hỏi hai câu: phần còn lại cần biết gì về phần đã điền (đó là trạng thái), và số 0 dẫn đầu có làm đổi tính chất không (nếu có thì thêm cờ bắt đầu hoặc giá trị “chưa có” vào trạng thái).
  • Kiểm bằng vét cạn trên [0,105][0, 10^5] và thử các cận 0,9,10,99,100,10k−1,10k0, 9, 10, 99, 100, 10^k - 1, 10^k: lỗi hay nằm ở số 0, ở đầu mút đoạn và ở số có đúng một chữ số.

Tóm tắt

  • F(X)F(X) đếm trên [0,X][0, X]; đáp án đoạn là F(b)−F(a−1)F(b) - F(a - 1) với F(−1)=0F(-1) = 0.
  • Mỗi số nhỏ hơn XX bám XX tới một vị trí rồi rẽ xuống chữ số nhỏ hơn, phần sau tự do; chỉ có nn trạng thái bị chặn, mọi trạng thái còn lại là tự do.
  • go(pos, σ, tight) đệ quy qua từng chữ số; chỉ nhớ trạng thái tự do theo khóa (rest, σ), nên bảng dùng chung cho mọi cận, với độ phức tạp O(10⋅n⋅∣S∣)O(10 \cdot n \cdot |S|).
  • Trạng thái là thông tin tối thiểu về phần đã điền mà phần còn lại cần; thêm cờ bắt đầu khi số 0 đệm đổi tính chất.
  • Cần tổng thì trả cặp (C,T)(C, T); cần số thứ kk thì chặt nhị phân trên FF; điều kiện phụ thuộc cả số thì duyệt giá trị tham số.

Bài tập

1
Đúng K Chữ Số Khác Khôngcanuc80k1301Nhớ số chữ số khác 0 đã đặt; NN cho dạng chuỗi, số 0 đứng đầu không làm tăng bộ đếm.
LQDOJ
2
Tối Đa Ba Chữ Số Khác Khôngcanuc80k1310Cùng ý nhưng nhiều truy vấn đoạn: dùng chung bảng nhớ theo rest và F(r)−F(l−1)F(r) - F(l - 1).
LQDOJ
3
Không Có Hai Bit 1 Liền Nhaucanuc80k1325Cơ số 2: trạng thái là bit vừa đặt, đếm các số trong [0,n][0, n] không có hai bit 1 liền nhau.
LQDOJ
4
Tránh Cặp Mười Bacanuc80k1308Nhớ chữ số vừa đặt có là 1 không; đặt 3 ngay sau 1 thì cấm. Đọc truy vấn tới hết dữ liệu, AA có thể bằng 0.
LQDOJ
5
Bao Nhiêu Chữ Số Mộtcanuc80k1306Cần tổng số lần chữ số 1 xuất hiện: trả cặp (số cách, tổng số chữ số 1) như mục đếm kèm tổng.
LQDOJ
6
Tổng Các Cặp Bit 11canuc80k1312Cơ số 2, trả cặp (số cách, tổng số cặp 11) với trạng thái là bit vừa đặt; in đúng mẫu Case i:.
LQDOJ
7
Khoảng Cách Giữa Hai Chữ Sốcanuc80k1311Nhớ chữ số trước, dùng “chưa có” cho số 0 dẫn đầu; điều kiện ∣d−prev∣≥2\lvert d - \mathrm{prev} \rvert \ge 2.
LQDOJ
8
Điền Chữ Số Theo Phần Dưcanuc80k1313Không có cận: duyệt vị trí từ trái, nhớ phần dư theo 13, mỗi ? thử 10 chữ số.
LQDOJ
9
Bội Của 13 Có Mẫu 13canuc80k1323Nhớ phần dư theo 13, chữ số trước có là 1 không, và đã gặp mẫu “13” chưa.
LQDOJ
10
Tích Các Chữ Số Không Quá Kcanuc80k1316Tích chữ số chỉ có dạng 2a3b5c7d2^a 3^b 5^c 7^d; gộp mọi tích lớn hơn KK vào một trạng thái, đừng cắt nhánh, và cần cờ bắt đầu.
LQDOJ
11
Chẵn Lần Trong Cơ Số Bcanuc80k1337Mặt nạ chẵn lẻ số lần xuất hiện của từng chữ số trong cơ số bb; cần cờ bắt đầu vì số 0 đệm đổi chẵn lẻ của chữ số 0.
LQDOJ
12
Tổng Đoạn Ít Chữ Sốcanuc80k1338Mặt nạ các chữ số đã dùng, giữ không quá kk bit bật; trả cặp (số cách, tổng giá trị) theo modulo.
LQDOJ
13
Chia Hết Cho Tổng Chữ Sốcanuc80k1317Điều kiện nhắc tới tổng chữ số của chính số đó: duyệt tổng mục tiêu s≤9ns \le 9n, trạng thái gồm tổng và phần dư theo ss.
LQDOJ
14
Dọn Dẹp Mùa Xuâncanuc80k1345a+b=a⊕ba + b = a \oplus b khi hai số không có bit 1 chung; đếm cặp với hai cận trên (hai cờ chặn) rồi bao hàm loại trừ.
LQDOJ
15
LIS Của Dãy Chữ Sốcanuc80k1342Trạng thái là mặt nạ các phần tử cuối của dãy con tăng ngắn nhất theo kiểu patience sorting; độ dài LIS là số bit bật.
LQDOJ

Đọc thêm

  • Quy hoạch động chữ sốVNOI WikiBài tiếng Việt xây G(X)G(X) từ hai trường hợp có và không có giới hạn, kèm ba bài ví dụ và mẹo dọn bảng nhớ một lần.
  • Digit DPOI WikiCộng đồng Trung Quốc giải thích vì sao đếm theo vị trí rẽ nhánh, khi nào được nhớ trạng thái, có bài số windy và một bài kết hợp Aho-Corasick.
  • Digit DPUSACO GuideTrạng thái gồm vị trí, bộ đếm, cờ đã nhỏ hơn và cờ đã bắt đầu, kèm danh sách bài luyện từ dễ tới khó.
  • 桁DPnishio (Scrapbox)Ghi chú ngắn của cộng đồng Nhật: phần tiền tố đang bám cận chỉ có một giá trị nên chỉ cần lưu riêng một ô.
  • Hack não số lớn với Digit DPVibloGiải thích từng bước bằng tiếng Việt, có bài tổng chữ số chia hết cho DD và các lỗi hay gặp về số 0.