DP chữ số (Digit DP)
DP chữ số (Digit DP) là kỹ thuật quy hoạch động dùng để đếm số lượng số nguyên trong đoạn thỏa mãn một tính chất nào đó liên quan đến các chữ số của chúng (ví dụ: tổng chữ số chia hết cho , không có hai chữ số giống nhau đứng cạnh nhau, chứa đúng chữ số ...).
Điểm mạnh: thay vì duyệt trâu từng số trong với chi phí (vô vọng khi tới ), Digit DP xây số theo từng chữ số từ trái sang phải và đếm gộp, đạt độ phức tạp chỉ phụ thuộc vào số chữ số — khoảng , tức gần như tức thời.
Ý tưởng / Trực giác
1. Quy bài toán về tiền tố:
Đếm trên khó vì có hai biên. Ta tách đôi bằng nguyên lý bù trừ:
trong đó = số lượng số trong thỏa điều kiện. Giờ chỉ còn một biên trên duy nhất là — dễ xử lý hơn nhiều.
2. Vì sao xây theo chữ số lại đúng?
Gọi có các chữ số (cao → thấp). Xét tập tất cả số có đúng chữ số (cho phép số 0 đứng đầu) trong khoảng . Mọi số rơi vào đúng một trong hai loại tại vị trí chữ số đầu tiên mà nó khác :
- Bám sát biên (tight): mọi chữ số từ đầu đến
posđều bằng . Lúc này chữ số tạiposkhông được vượt , vì vượt là lớn hơn . - Đã thoát biên (free): ở một vị trí nào đó trước
pos, ta đã chọn chữ số nhỏ hơn chữ số tương ứng của . Khi đó phần đuôi còn lại được chọn tự do từ0đến9, chắc chắn vẫn .
Cờ tight chính là thứ phân biệt hai loại này. Nhận xét then chốt: khi đã free (tight = false), kết quả đếm cho phần đuôi không còn phụ thuộc vào nữa, chỉ phụ thuộc vào trạng thái bài toán. Đó là lý do ta có thể ghi nhớ (memo) các trạng thái free và tái sử dụng — nguồn gốc của tốc độ.
3. Trạng thái cần mang theo
Khi xây dở dang một số, ta chỉ cần lưu đủ thông tin để biết phần đuôi nào hợp lệ:
| Tham số | Ý nghĩa |
|---|---|
pos |
đang chọn chữ số thứ mấy (0 = cao nhất) |
tight |
còn bám sát biên trên không? |
started |
đã bắt đầu điền chữ số khác 0 chưa? (để xử lý số 0 đứng đầu) |
state |
trạng thái riêng của bài (tổng chữ số, chữ số cuối, số dư mod ...) |
Vì state thường nhỏ và pos , tổng số trạng thái rất ít → DP chạy nhanh.
Ví dụ chạy tay
Bài: đếm số trong có tổng chữ số bằng . Đáp án mong đợi: ? — nhưng , nên chỉ có → 3 số.
Ta gọi . Chuỗi digits = "21", . Trạng thái dp(pos, sum, tight) = số cách điền các vị trí từ pos để tổng cuối đạt .
Cây đệ quy (chỉ vẽ nhánh tight, ghi limit = chữ số tối đa được chọn):
dp(0, sum=0, tight=1) digits[0]=2 -> limit=2
│
├─ d=0 -> dp(1, sum=0, tight=0) free, limit=9 cần chữ số = 3 -> CHỌN d=3 (1 cách) => "03" = 3 ✔
│
├─ d=1 -> dp(1, sum=1, tight=0) free, limit=9 cần chữ số = 2 -> CHỌN d=2 (1 cách) => "12" ✔
│
└─ d=2 -> dp(1, sum=2, tight=1) digits[1]=1 -> limit=1
├─ d=0 -> sum=2 != 3 ✗
└─ d=1 -> sum=3 == 3 ✔ => "21"
Bảng đánh dấu ô đang xét tại pos = 1 (hàng = sum tích lũy tới giờ, cột = giá trị trả về):
| pos=1, sum tới giờ | cần thêm | đếm được |
|---|---|---|
| sum=0 (free) ← từ d=0 | 3 | 1 |
| sum=1 (free) ← từ d=1 | 2 | 1 |
| sum=2 (tight) ← từ d=2 | 1, mà limit=1 | 1 |
Cộng lại: . Khớp đáp án. Để ra kết quả trên thật, ta lấy .
Cài đặt
Đếm số trong có tổng chữ số bằng (mẫu cơ bản, có tight):
#include <bits/stdc++.h>
using namespace std;
int S;
string digits;
long long memo[20][200][2]; // [pos][sum][tight]
// Số cách điền từ vị trí pos sao cho TỔNG CUỐI = S,
// với sum = tổng đã tích lũy, tight = còn bám biên trên không.
long long dp(int pos, int sum, bool tight) {
if (sum > S) return 0; // cắt nhánh: đã vượt
if (pos == (int)digits.size()) // điền xong cả số
return (sum == S) ? 1 : 0;
long long &ret = memo[pos][sum][tight];
if (ret != -1) return ret; // đã tính -> dùng lại
int limit = tight ? (digits[pos] - '0') : 9; // tight: tối đa = digits[pos]
ret = 0;
for (int d = 0; d <= limit; d++) {
// chỉ còn tight nếu trước đó tight VÀ chọn đúng cận d == limit
ret += dp(pos + 1, sum + d, tight && (d == limit));
}
return ret;
}
long long f(long long N) {
if (N < 0) return 0; // f(-1) = 0
digits = to_string(N);
memset(memo, -1, sizeof(memo)); // BẮT BUỘC xóa trước mỗi f()
return dp(0, 0, /*tight=*/true); // bắt đầu luôn bám biên
}
int main() {
long long L, R; int s;
cin >> L >> R >> s; S = s;
cout << f(R) - f(L - 1) << "\n";
}
Mẫu tổng quát có started (xử lý số 0 đứng đầu)
Khi điều kiện đếm phụ thuộc độ dài thực của số (ví dụ "số có đúng chữ số khác 0"), phải phân biệt 7 với 007. Thêm cờ started: chừng nào chưa điền chữ số khác 0 thì không cập nhật trạng thái.
long long dp(int pos, int state, bool tight, bool started) {
if (pos == (int)digits.size())
return started ? (state == /*target*/0) : 0; // số 0 thường tính riêng
long long &ret = memo[pos][state][tight][started];
if (ret != -1) return ret;
int limit = tight ? (digits[pos] - '0') : 9;
ret = 0;
for (int d = 0; d <= limit; d++) {
bool nStarted = started || (d != 0);
// khi chưa started và d==0: vẫn là "số 0 đệm", KHÔNG đổi state
int nState = nStarted ? transition(state, d) : state;
ret += dp(pos + 1, nState, tight && (d == limit), nStarted);
}
return ret;
}
Độ phức tạp
Gọi là số chữ số ( với ) và là số trạng thái riêng.
- Thời gian: . Mỗi bộ
(pos, state, tight)được tính đúng một lần nhờ memo (đây là điểm mấu chốt), mỗi lần duyệt giá trị chữ số. Với , hằng số nhỏ → vài chục nghìn phép tính, gần như tức thời, kể cả khi có truy vấn. - Bộ nhớ: cho mảng
memo. Chiềutightthực ra chỉ cần cho nhánh bám biên; nhiều người bỏ luôntightkhỏi memo và chỉ memo nhánh free để tiết kiệm.
Lý do nhanh hơn duyệt trâu: duyệt trâu là (có thể ), còn Digit DP gộp toàn bộ phần đuôi "free" thành một lần tính rồi tái sử dụng.
⚠️ Lỗi thường gặp
- Không xóa
memogiữaf(R)vàf(L-1). Mảngmemochứa kết quả cho nhánh free — vốn không phụ thuộc nên dùng chung được — nhưng nếu bạn (lỡ) memo cả ô cótight = truethì giá trịdigitsđã đổi sang số khác, kết quả sai. An toàn nhất:memset(memo, -1, ...)ở đầu mỗif(N). Triệu chứng: test đơn lẻ đúng, nhiều truy vấn liên tiếp ra sai. - Memo nhầm theo
tight. Khitight = true, kết quả phụ thuộc vào các chữ số của tại vị trí đó, không được ghi nhớ và tái dùng cho khác. Chỉ ôtight = falsemới an toàn để cache. Nếu vẫn để chung mảng, ít nhất phải reset mỗif(). - Quên cờ
startedkhi điều kiện phụ thuộc độ dài số. Đếm "số có tổng chữ số = 0" mà không cóstartedsẽ vô tình đếm vô số số 0 đệm (00,000...) là hợp lệ. Triệu chứng: kết quả lớn bất thường, hoặc đếm dư số 0. - Tràn số. Đáp số có thể vượt
int(đếm tới số). Dùnglong longcho cảmemolẫn biến cộng dồn; nếu đề yêu cầu lấy dư thì% MODsau mỗi lần cộng, và cẩn thận modulo âm khi tính : viết((f(R) - f(L-1)) % MOD + MOD) % MOD. - Quên
f(L - 1)khi . Phải đểf(-1) = 0(thêmif (N < 0) return 0;), nếu khôngto_string(-1)sẽ tạo chuỗi"-1"và sập logic. - Sai cập nhật
tight. Công thức đúng lànewTight = tight && (d == limit). Viết nhầm thànhd == 9haytight && d <= limitđều làm đếm sai (đếm thiếu hoặc thừa các số bám biên).
Biến thể / Mở rộng
| Bài toán | Trạng thái cần theo dõi |
|---|---|
| Tổng chữ số chia hết cho | sum % K |
| Số là bội của | value % K (cập nhật (val*10+d) % K) |
| Không có hai chữ số liền nhau bằng nhau | lastDigit |
| Đếm số lần xuất hiện chữ số | count (hoặc tần suất 10 chữ số) |
| Số chữ số lẻ = số chữ số chẵn | diff = #lẻ − #chẵn (dời gốc cho không âm) |
| Số "may mắn" (chỉ gồm 4 và 7) | lọc ngay trong vòng lặp, if (d != 4 && d != 7) continue; |
Liên quan: kỹ thuật bù trừ ở đây giống ý tưởng tổng tiền tố (prefix sum) trên trục số; phần memo theo trạng thái là tư duy chung của Quy hoạch động.
Bài tập luyện
- Đồng Hồ Đo Quãng Đường (odometers) — (Advanced) Đếm các số "khó đọc" trong đoạn dựa trên hình dạng chữ số — bài nhập môn
tight+ đếm theo điều kiện chữ số. - Đếm số theo ràng buộc tần suất chữ số (numcnt) — (Veteran) Trạng thái là tần suất xuất hiện của các chữ số, luyện thiết kế
statenhiều chiều. - Đếm số theo ràng buộc chữ số (digcnt) — (Veteran) Đếm số trong đoạn thỏa ràng buộc trên các chữ số — mẫu Digit DP kinh điển có
started. - Khối chữ số modulo (blockmod) — (Veteran) Kết hợp Digit DP với số dư modulo, luyện trạng thái
value % Kvà lấy dư an toàn. - Xếp Chồng Giấy (pilpaper) — (Veteran) Bài Digit DP nâng cao trộn yếu tố toán học, đòi hỏi định nghĩa trạng thái khéo và xử lý biên cẩn thận.