Cho hai số cỡ 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 , 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 có tính chất đó? Duyệt từng số tốn bước, với 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 là tính chất cần kiểm và là số lượng số nguyên thỏa và . Các số từ 0 tới gồm các số từ 0 tới cộng các số từ tới , nên số lượng thỏa trong đoạn là
Đâ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 ta cần , đếm trên đoạn rỗng, bằng 0; hàm phải trả 0 ngay khi , trước khi tách chữ số. Khi cho dạng chuỗi dài hàng nghìn chữ số, tính rất phiền; khi đó dùng , trong đó bằng 1 nếu hợp lệ, kiểm thẳng trên các chữ số của .
Hỏi nhanh
Hàm đếm trên bằng cách tách chữ số của . Đề cho , nên công thức cần . Chương trình nên xử lý thế nào?
to_string(-1) bắt đầu bằng dấu -, và trừ ký tự '0' cho ra một “chữ số” âm, nên không tự cho 0. Phải chặn từ đầu.
làm mất chính dù hợp lệ, còn cộng thừa khi không hợp lệ. Công thức đúng là .
Tách theo vị trí rẽ nhánh. Cho có chữ số , với cao nhất. Mọi viết được thành đúng chữ số bằng cách thêm số 0 vào đầu (với , 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 nằm trong đúng một khối : trùng ở chữ số đầu, chữ số thứ bằng , và chữ số cuối tùy ý. Các khối rời nhau; hợp của chúng cùng với là cả . Với ta có số.
Đ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 .
Đệ quy có nhớ với cờ chặn. Gọi 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 ; 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ố 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 ; mỗi lần rời chuỗi bằng chữ số ta rơi vào đúng khối , nên không đếm thiếu, không đếm trùng, và chuỗi kết thúc tại chính .
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à , vì mọi chữ số phía sau đều chạy từ 0 đến 9 dù 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 , nên không ghi; cũng không cần, vì chỉ có lời gọi như vậy. Gọi là số giá trị mà có thể nhận; số trạng thái tự do là , mỗi trạng thái xét 10 chữ số, nên cả bảng tốn .
Chương trình dưới đếm các số trong có tổng chữ số bằng ; trạng thái là tổng đã điền, .
#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
19
Chạy tay theo khối: vị trí 1 với cần tổng 3 từ ba chữ số tự do, 10 cách; cần tổng 2, 6 cách. Vị trí 2 (tiền tố 2): cần tổng 1 từ hai chữ số, 2 cách; cần tổng 0, 1 cách. Tiền tố 27 đã có tổng 9, vượt 3. Tổng . Khóa của bảng là rest chứ không phải pos: và 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 ; chỉ khi tham số của bài (ở đây là ) đổi giữa các truy vấn mới phải xóa bảng.
Hỏi nhanh
Với và , 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?
Giá trị của trạng thái bị chặn phụ thuộc các chữ số còn lại của , mà khóa (rest, sum) không chứa thông tin đó. Thêm tight vào khóa chỉ phân biệt “bị chặn” với “tự do” trong cùng một ; hai cận khác nhau vẫn cho hai giá trị khác nhau dưới cùng một khóa. Tổng 9 chỉ làm nhánh con trả 0 khi .
Ví dụDấu Vết 4 Và 9
Đề. Cho . Đếm các số trong 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ố 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ó trạng thái tự do. Cách khác là đếm bù: số không chứa 4 và 9 trong chỉ cần cờ chặn, rồi lấy trừ đi. Với ví dụ, cả bảy số từ 39 tới 45 đều có 4 hoặc 9.
#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
7
Số 0 dẫn đầu và cờ bắt đầu
Đệm số 0 cho đủ 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.
Chương trình dưới đếm số trong 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 vị trí chính là số 0, nên return st loại luôn số 0 khỏi .
#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
99
Kiểm tay: có 9 số một chữ số, số hai chữ số không có số 0, và với ba chữ số chỉ 111 đến 119 thỏa, tổng .
Hỏi nhanh
Một bạn đếm số không có chữ số 0 với như sau: viết 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?
Chuỗi 042 có số 0 đầu tiên nên số 42 bị loại dù nó không có chữ số 0 nào. Mọi số dưới 100 bị loại như vậy, chỉ còn 111 đến 119. Đáp án 99 là đáp án đúng của bài, và muốn có nó cờ bắt đầu phải làm số 0 đệm không bị tính; chương trình kia không có cờ này.
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 , , 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ố vừa đặt, từ 0 đến 9, thêm giá trị 10 nghĩa là “chưa có chữ số nào”. Chọn bằng thì bỏ qua; vì không trùng chữ số nào nên chữ số đầu tiên không bị cấm. Nếu và thì giữ nguyên 10 (số 0 đệm), còn lại mới bằng . Ở 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 đó là chữ số thật.
Có trạng thái tự do. Kiểm tay: ; trong 98, 99 chỉ 99 bị loại nên ; trong 100, 101, 102 chỉ 100 bị loại nên . Đoạn có số.
#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
3
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 | tổng các chữ số đã điền | |
| chia hết cho | phần dư của phần đã điền khi chia cho | |
| 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 | |
| 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á | tích hiện tại, mọi tích lớn hơn 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 , và với chỉ có 5194 giá trị không vượt . Mọi tích lớn hơn được gộp thành một trạng thái “quá ”, nhưng không được cắt nhánh đó: một chữ số 0 thật về sau đưa tích về 0, và . Khi lớn thì trạng thái càng phải gọn: tổng chữ số chia hết cho chỉ cần nhớ phần dư theo , vì hai tổng cùng phần dư có cùng tương lai.
Thời gian là , nên với thì cỡ vẫn nhẹ (khoảng phép tính), còn đã 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 dạng chuỗi và . Đếm các số có tổng chữ số chia hết cho , theo modulo .
Lời giải. Tổng chữ số tới , nhưng chỉ phần dư của nó theo ảnh hưởng tới tương lai, nên trạng thái là phần dư ; đặt chữ số thì . 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 có tính số 0 (tổng 0 chia hết cho mọi ), nên trừ 1. Có trạng thái tự do, mỗi trạng thái 10 chữ số: phép. Độ sâu đệ quy bằng số chữ số, tầng, vẫn an toàn.
#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
10
Ví dụChia Hết Cho Mọi Chữ Số
Đề. Có truy vấn, mỗi truy vấn cho ; đếm các số trong 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ố : phải chia hết cho bội chung nhỏ nhất của các chữ số khác 0. Ta chưa biết khi mới điền một phần, nhưng luôn là ước của , và 2520 chỉ có 48 ước. Vì nên khi và chỉ khi . Vậy trạng thái gồm phần dư của phần đã điền theo 2520 và ước hiện tại. Số 0 đệm không đổi cũng không đổi , nên không cần cờ bắt đầu; số 0 được đếm ở cả lẫn 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 . nxt[i][d] là số thứ tự của khi đặt thêm chữ số ; chữ số 0 giữ nguyên . Bảng dựng một lần, chuyển trạng thái chỉ còn là một lần tra.
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 thì và nxt[li][d]. Ở cuối, số hợp lệ khi . Trạng thái tự do (rest, r, li) được nhớ trong memo, đánh dấu chưa tính bằng .
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ả truy vấn. Với ví dụ, trong các số 10, 11, 12, 15, 20 hợp lệ.
#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
5
Có trạng thái tự do, mỗi trạng thái thử 10 chữ số: 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 thay vì một số.
Với trạng thái còn chữ số phải điền: là số cách điền hợp lệ, còn là tổng, trên mọi cách đó, của giá trị phần đuôi do chữ số vừa điền tạo thành. Chọn chữ số cho vị trí kế tiếp, có trọng số ; trạng thái con trả về . Mỗi cách của trạng thái con được ghép thêm ở phía trước, nên giá trị đuôi của nó tăng thêm :
Ở trạng thái xuất phát, phần đuôi chính là cả số, nên 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à để ở dạng đã lấy modulo. Cần tổng bình phương thì thêm thành phần thứ ba , vì . Cần tổng các chữ số thay vì tổng các số thì trọng số chỉ là , không nhân .
Chương trình sau tính tổng các số trong không chứa chữ số 7, với . 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).
#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
414
Kiểm tay: , 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ề như trên. Ở trạng thái còn chữ số, ta chọn cho vị trí kế tiếp và nhận từ trạng thái con còn chữ số. Phần đóng góp vào của trạng thái cha là gì?
Mỗi trong cách của trạng thái con được ghép với chữ số đứng trước, nên đuôi của nó tăng ; cộng cho cả cách được , thêm là tổng các đuôi cũ.
Bản quên rằng nằm ở hàng (nó đúng khi bài hỏi tổng các chữ số, không phải tổng các số). Bản chỉ cộng một lần dù có cách, còn bản có nhân thay vì cộng.
Ví dụTổng Chữ Số Trên Đoạn
Đề. Với mỗi truy vấn , in , với là tổng chữ số của . Dữ liệu kết thúc bằng cặp -1 -1.
Lời giải. Cùng khuôn cặp , nhưng là tổng các chữ số của phần đuôi chứ không phải giá trị, nên chữ số đóng góp , 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à với ; nên số 0 không ảnh hưởng. Tổng lớn nhất cỡ , vừa ll. Với ví dụ, và , tổng 46.
#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
46
9
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à có tối đa 31 hay 63 bit. Mọi thứ khác giữ nguyên; đếm số không có hai bit 1 kề nhau chỉ cần nhớ bit vừa điền.
Cận dạng chuỗi. Khi có tới chữ số, nhận chuỗi, đáp án lấy theo modulo, và dùng thay cho . Độ sâu đệ quy bằng , vẫn chấp nhận được; chi phí bắt buộc nhỏ.
Số thứ . không giảm khi tăng, nên số hợp lệ thứ là số nhỏ nhất có , 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 chỉ tốn 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ư “ chia hết cho tổng chữ số của ”, 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 giá trị. Duyệt từng giá trị của nó, chạy quy hoạch động chữ số với cố định (trạng thái gồm tổng và phần dư theo ) rồi cộng đáp án qua mọi .
Nhiều cận cùng lúc. Đếm cặp với cả hai không vượt 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 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 truy vấn , in số thứ .
Lời giải. Hai phần: đếm , số các số trong chứa 666, rồi tìm số thứ bằng chặt nhị phân trên .
Bước 1: đếm bằng trạng thái “số chữ số 6 cuối”. 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ì tăng 1, đặt chữ số khác thì về 0, còn 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 .
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. 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ứ là nhỏ nhất có . Đáp án với chưa tới , nên cận trên là dư.
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;
}
#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
666
1666
2666
Mỗi truy vấn khoảng 50 lần gọi ; bảng nhớ chỉ có 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ỡ bước trên đường bám cận.
Khi đi thi
- Thấy “đếm số trong đoạn theo tính chất của chữ số” với cận từ trở lên, viết cho một cận rồi lấy , nhớ ; nếu cho dạng chuỗi dài thì dùng .
- 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 và thử các cận : lỗi hay nằm ở số 0, ở đầu mút đoạn và ở số có đúng một chữ số.
Tóm tắt
- đếm trên ; đáp án đoạn là với .
- Mỗi số nhỏ hơn bám tới một vị trí rồi rẽ xuống chữ số nhỏ hơn, phần sau tự do; chỉ có 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 .- 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ần số thứ thì chặt nhị phân trên ; điều kiện phụ thuộc cả số thì duyệt giá trị tham số.
Bài tập
rest và .Case i:.? thử 10 chữ số.Đọc thêm
- Quy hoạch động chữ sốVNOI WikiBài tiếng Việt xây 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 và các lỗi hay gặp về số 0.