Bài 48: Digit DP¶
Tác giả: FPTOJ Team
Tham khảo: CP-Algorithms, USACO Guide
Bản chất vấn đề¶
Bài toán kinh điển¶
Cho số nguyên dương \(N\). Đếm số lượng số nguyên \(x\) trong khoảng \([0, N]\) thỏa mãn một điều kiện nào đó liên quan đến chữ số.
Ví dụ: Cho \(N = 47\), đếm số lượng số trong \([0, 47]\) có tổng chữ số chẵn.
Phương pháp naïve¶
Duyệt từng số từ \(0\) đến \(N\), kiểm tra điều kiện cho mỗi số.
- Độ phức tạp: \(O(N)\)
- Khi \(N = 10^{18}\), phương pháp naïve cần \(10^{18}\) phép tính — không thể chạy trong thời gian cho phép.
Tại sao cần Digit DP?¶
Mọi số nguyên đều có thể biểu diễn dưới dạng chuỗi chữ số. Thay vì duyệt từng số, ta xây dựng số từ trái sang phải, chữ số này qua chữ số khác. Số chữ số của \(N\) chỉ là \(O(\log_{10} N)\) — rất nhỏ (khoảng 18 chữ số khi \(N = 10^{18}\)).
Khi nào sử dụng Digit DP?¶
| Tiêu chí | Mô tả |
|---|---|
| Khoảng giá trị | \(N\) rất lớn (\(10^{18}\)), không thể duyệt |
| Điều kiện | Liên quan đến chữ số (tổng, tích, bitmask, ...) |
| State | Có thể biểu diễn qua state có kích thước nhỏ |
Tư duy cốt lõi¶
Xây dựng số từng chữ số¶
Cho \(N\) có \(n\) chữ số, ký hiệu \(d_0 d_1 \ldots d_{n-1}\) (từ trái sang phải). Ta duyệt từng vị trí \(pos\) từ \(0\) đến \(n-1\), tại mỗi vị trí chọn chữ số \(d\) sao cho số tạo thành không vượt quá \(N\).
Ràng buộc chặt (Tight Constraint)¶
Khi chọn chữ số tại vị trí \(pos\), ta cần biết các chữ số đã chọn trước đó có bằng đúng prefix của \(N\) hay không:
| Giá trị \(tight\) | Ý nghĩa | Chữ số được chọn |
|---|---|---|
| \(1\) (true) | Các chữ số đã chọn bằng đúng prefix của \(N\) | \(0\) đến \(d_{pos}\) (chữ số thứ \(pos\) của \(N\)) |
| \(0\) (false) | Các chữ số đã chọn nhỏ hơn prefix của \(N\) | \(0\) đến \(9\) (tự do) |
Minh họa với \(N = 47\)¶
\(N = 47\) có 2 chữ số: \(d_0 = 4\), \(d_1 = 7\).
Khi \(d_0 = 4\) (bằng đúng chữ số đầu của \(N\)), ta vẫn bị ràng buộc: \(d_1\) chỉ được chọn từ \(0\) đến \(7\). Khi \(d_0 < 4\), \(d_1\) được chọn tự do từ \(0\) đến \(9\).
Tổng số: \(10 + 10 + 10 + 10 + 8 = 48\) số (từ \(0\) đến \(47\)).

State của Digit DP¶
State tổng quát có dạng \(dp[pos][tight][\ldots]\), trong đó:
| Thành phần | Ý nghĩa | Kích thước |
|---|---|---|
| \(pos\) | Vị trí chữ số hiện tại | \(0\) đến \(n-1\), tối đa \(20\) |
| \(tight\) | Có bị ràng buộc bởi \(N\) không | \(0\) hoặc \(1\) |
| \(\ldots\) | Các state bổ sung tùy bài | Tùy bài toán |
Các state bổ sung thường gặp:
| State bổ sung | Ý nghĩa | Kích thước tối đa |
|---|---|---|
| \(sum\) | Tổng chữ số đã chọn | \(9 \times 18 = 162\) |
| \(mask\) | Bitmask chữ số đã dùng | \(2^{10} = 1024\) |
| \(last\_digit\) | Chữ số trước đó | \(10\) |
| \(has\_zero\) | Đã gặp chữ số \(0\) chưa | \(2\) |
| \(started\) | Đã bắt đầu số chưa | \(2\) |
Quá trình chuyển state¶
Tại mỗi trạng thái \((pos, tight, \ldots)\):
- Nếu \(pos = n\) (đã xét hết chữ số): kiểm tra điều kiện, trả về \(0\) hoặc \(1\).
- Xác định giới hạn: \(limit = tight ? d_{pos} : 9\).
- Duyệt \(d\) từ \(0\) đến \(limit\), gọi đệ quy với state mới.
- Cộng dồn kết quả.
Bảng theo dõi quá trình với \(N = 47\), đếm số có tổng chữ số chẵn¶
State: \(dp[pos][tight][sum \bmod 2]\)
| \(pos\) | \(tight\) | Chữ số chọn (\(d\)) | \(sum \bmod 2\) mới | \(tight\) mới | Kết quả |
|---|---|---|---|---|---|
| \(0\) | \(1\) | \(0\) | \(0\) | \(0\) | \(5\) |
| \(0\) | \(1\) | \(1\) | \(1\) | \(0\) | \(5\) |
| \(0\) | \(1\) | \(2\) | \(0\) | \(0\) | \(5\) (memoized) |
| \(0\) | \(1\) | \(3\) | \(1\) | \(0\) | \(5\) (memoized) |
| \(0\) | \(1\) | \(4\) | \(0\) | \(1\) | \(4\) |
Tổng: \(5 + 5 + 5 + 5 + 4 = 24\) số có tổng chữ số chẵn trong \([0, 47]\).
Đếm số trong khoảng \([L, R]\)¶
Sử dụng tính chất:
Lưu ý: Khi \(L = 0\), \(count(0, -1) = 0\).
Phân tích tính đúng đắn¶
Tại sao chỉ memoize khi \(tight = 0\)?¶
Khi \(tight = 1\), state \((pos, tight=1, \ldots)\) bị ràng buộc bởi \(N\). Mỗi đường đi từ gốc đến lá trong cây đệ quy là duy nhất — không có hai nhánh nào có cùng state với \(tight = 1\) mà cho kết quả giống nhau (vì chúng đều đi theo đúng prefix của \(N\)).
Ngược lại, khi \(tight = 0\), các chữ số đã chọn nhỏ hơn prefix của \(N\). Mọi chữ số tiếp theo đều được chọn tự do (\(0\) đến \(9\)). Do đó, state \((pos, tight=0, \ldots)\) có thể xuất hiện nhiều lần và kết quả là giống nhau — đủ điều kiện để memoize.
Tại sao cần phân biệt leading zeros?¶
Khi xây dựng số \(007\), chữ số \(0\) ở vị trí đầu tiên không phải là "chữ số thực sự" của số \(7\). Nếu không phân biệt, ta sẽ đếm sai các state liên quan đến chữ số \(0\).
Giải pháp: Sử dụng state \(started\) hoặc \(last\_digit\) với giá trị đặc biệt:
| State | Giá trị | Ý nghĩa |
|---|---|---|
| \(started = 0\) | false | Đang ở phần leading zeros |
| \(started = 1\) | true | Đã chọn ít nhất 1 chữ số khác \(0\) |
Hoặc dùng \(last\_digit = 10\) (giá trị sentinel) để biểu thị "chưa chọn chữ số nào".
Tại sao \(new\_tight = tight \land (d == limit)\)?¶
- Nếu trước đó đã \(tight = 0\) (đã nhỏ hơn prefix): \(new\_tight = 0\) (vẫn tự do).
- Nếu trước đó \(tight = 1\) và \(d < limit\): \(new\_tight = 0\) (đã nhỏ hơn tại vị trí này).
- Nếu trước đó \(tight = 1\) và \(d = limit\): \(new\_tight = 1\) (vẫn bằng đúng prefix).
Đây là phép AND logic — chỉ duy trì ràng buộc khi tất cả các chữ số trước đó đều bằng đúng prefix của \(N\) và chữ số hiện tại cũng bằng đúng giới hạn.
Kiểm tra correctness với ví dụ nhỏ¶
Cho \(N = 47\), đếm số có tổng chữ số chẵn trong \([0, 47]\):
| Số | Tổng chữ số | Chẵn? |
|---|---|---|
| \(0\) | \(0\) | Yes |
| \(1\) | \(1\) | No |
| \(2\) | \(2\) | Yes |
| \(3\) | \(3\) | No |
| \(4\) | \(4\) | Yes |
| \(5\) | \(5\) | No |
| \(6\) | \(6\) | Yes |
| \(7\) | \(7\) | No |
| \(8\) | \(8\) | Yes |
| \(9\) | \(9\) | No |
| \(10\) | \(1\) | No |
| \(11\) | \(2\) | Yes |
| \(12\) | \(3\) | No |
| \(13\) | \(4\) | Yes |
| \(14\) | \(5\) | No |
| \(15\) | \(6\) | Yes |
| \(16\) | \(7\) | No |
| \(17\) | \(8\) | Yes |
| \(18\) | \(9\) | No |
| \(19\) | \(10\) | Yes |
| \(20\) | \(2\) | Yes |
| \(21\) | \(3\) | No |
| \(22\) | \(4\) | Yes |
| \(23\) | \(5\) | No |
| \(24\) | \(6\) | Yes |
| \(25\) | \(7\) | No |
| \(26\) | \(8\) | Yes |
| \(27\) | \(9\) | No |
| \(28\) | \(10\) | Yes |
| \(29\) | \(11\) | No |
| \(30\) | \(3\) | No |
| \(31\) | \(4\) | Yes |
| \(32\) | \(5\) | No |
| \(33\) | \(6\) | Yes |
| \(34\) | \(7\) | No |
| \(35\) | \(8\) | Yes |
| \(36\) | \(9\) | No |
| \(37\) | \(10\) | Yes |
| \(38\) | \(11\) | No |
| \(39\) | \(12\) | Yes |
| \(40\) | \(4\) | Yes |
| \(41\) | \(5\) | No |
| \(42\) | \(6\) | Yes |
| \(43\) | \(7\) | No |
| \(44\) | \(8\) | Yes |
| \(45\) | \(9\) | No |
| \(46\) | \(10\) | Yes |
| \(47\) | \(11\) | No |
Liệt kê: \(0, 2, 4, 6, 8, 11, 13, 15, 17, 19, 20, 22, 24, 26, 28, 31, 33, 35, 37, 39, 40, 42, 44, 46\) — tổng cộng \(24\) số. Kết quả từ Digit DP: \(24\). ✓
Đánh giá độ phức tạp¶
Độ phức tạp tổng quát¶
- Thời gian: \(O(n \times 2 \times S \times 10)\), trong đó \(n = \lfloor \log_{10} N \rfloor + 1\), \(S\) là kích thước state bổ sung.
- Bộ nhớ: \(O(n \times 2 \times S)\) (chỉ memoize khi \(tight = 0\) nên thực tế giảm một nửa).
| State bổ sung | \(S\) | Thời gian | Bộ nhớ |
|---|---|---|---|
| Không có | \(1\) | \(O(n)\) | \(O(n)\) |
| \(sum \bmod 2\) | \(2\) | \(O(40n)\) | \(O(4n)\) |
| \(sum\) (tổng chữ số) | \(162\) | \(O(3240n)\) | \(O(324n)\) |
| \(mask\) (bitmask \(10\) bit) | \(1024\) | \(O(20480n)\) | \(O(2048n)\) |
| \(mask + last\_digit\) | \(10240\) | \(O(204800n)\) | \(O(20480n)\) |
Với \(n \leq 18\) (khi \(N \leq 10^{18}\)), ngay cả trường hợp \(S = 1024\), thời gian chỉ khoảng \(18 \times 2 \times 1024 \times 10 \approx 3.7 \times 10^5\) phép tính.
Giới hạn state¶
Khi kết hợp nhiều state bổ sung, tổng số state có thể bùng nổ:
Nếu tổng states quá lớn (\(> 10^7\)), có thể bị MLE. Giải pháp:
- Chỉ memoize khi \(tight = 0\) (giảm một nửa số states cần lưu).
- Dùng \(map\) / \(unordered\_map\) thay vì mảng nếu nhiều state rỗng.
- Giảm dimensions: dùng modulo thay vì giá trị tuyệt đối.
Template¶
Template Digit DP¶
Ví dụ 1: Đếm số có tổng chữ số bằng \(K\)¶
Bài toán: Cho \(N\) và \(K\). Đếm số lượng số trong \([0, N]\) có tổng các chữ số đúng bằng \(K\).
State: \(dp[pos][tight][sum]\), trong đó \(sum\) là tổng chữ số đã chọn.
Điều kiện cắt nhánh: Nếu \(sum > K\), trả về \(0\).
Ví dụ 2: Đếm số không chứa chữ số \(4\)¶
Bài toán: Cho \(N\). Đếm số lượng số trong \([0, N]\) không chứa chữ số \(4\).
State: \(dp[pos][tight]\) — không cần state bổ sung vì điều kiện chỉ phụ thuộc vào chữ số hiện tại.
Ví dụ 3: Đếm số có chữ số không giảm¶
Bài toán: Cho \(N\). Đếm số lượng số trong \([0, N]\) mà các chữ số từ trái sang phải không giảm (ví dụ: \(1123\), \(4559\), \(7\)).
State: \(dp[pos][tight][last\_digit]\), trong đó \(last\_digit\) là chữ số trước đó đã chọn. Dùng \(last\_digit = 10\) để biểu thị "chưa chọn chữ số nào" (leading zeros).
Ví dụ 4: Đếm số trong khoảng \([L, R]\) có tổng chữ số chẵn¶
Các state bổ sung thường gặp¶
Bitmask chữ số đã dùng¶
Đếm số trong \([0, N]\) sử dụng đúng \(K\) chữ số khác nhau.
State: \(dp[pos][tight][mask]\), trong đó \(mask\) là bitmask \(10\) bit, bit \(i = 1\) nếu chữ số \(i\) đã xuất hiện.
Không có chữ số nào lặp lại¶
Đếm số trong \([0, N]\) mà không có chữ số nào xuất hiện quá một lần.
State: \(dp[pos][tight][mask]\). Nếu bit \(d\) đã bật trong \(mask\) mà chọn \(d\) nữa — bỏ qua.
Tích các chữ số bằng \(0\)¶
Đếm số trong \([0, N]\) mà tích các chữ số bằng \(0\) (tức có ít nhất \(1\) chữ số \(0\) thực sự, không tính leading zeros).
State: \(dp[pos][tight][has\_zero]\), trong đó \(has\_zero = 1\) nếu đã gặp chữ số \(0\) thực sự.
GCD các chữ số lớn hơn \(1\)¶
Đếm số trong \([0, N]\) mà GCD tất cả các chữ số lớn hơn \(1\).
State: \(dp[pos][tight][gcd\_sofar]\). Khi chưa chọn chữ số nào, \(gcd = 0\).
Cạm bẫy thường gặp¶
Off-by-one trong khoảng \([L, R]\)¶
Công thức đúng: \(count(L, R) = count(0, R) - count(0, L - 1)\).
Nếu dùng \(count(0, R) - count(0, L)\), ta sẽ thiếu số \(L\). Đặc biệt, khi \(L = 0\), hàm \(count(0, -1)\) phải trả về \(0\).
Quên reset dp giữa các test case¶
Khi chạy nhiều test case, phải gọi memset(dp, -1, sizeof(dp)) cho mỗi test case vì \(N\) khác nhau dẫn đến \(s\) và \(n\) khác nhau.
State explosion¶
Khi kết hợp quá nhiều state bổ sung, tổng số state có thể vượt quá giới hạn bộ nhớ. Cần tính toán trước kích thước state:
Nếu vượt quá, cân nhắc giảm dimensions hoặc dùng \(map\) thay vì mảng.
Bài tập¶
| # | Tên bài | Nguồn | Độ khó | Ghi chú |
|---|---|---|---|---|
| 1 | Digit Sum | AtCoder DP | ★★☆ | Tổng chữ số chia hết cho \(D\) |
| 2 | LIDS | CF | ★★★ | Dãy con tăng dài nhất theo chữ số |
| 3 | Palindromic Numbers | CF | ★★☆ | Đếm số palindrome |
| 4 | Magic Numbers | CF | ★★☆ | Số "magic" với digit \(m\) |
| 5 | Classy Numbers | CF | ★★☆ | Tối đa \(3\) chữ số khác \(0\) |
| 6 | XOR Palindrome | CF | ★★★ | Palindrome XOR |
| 7 | Investigation | Kattis | ★★☆ | Chia hết cho \(K\), tổng chữ số |
| 8 | Digit Count | SPOJ | ★★☆ | Tổng chữ số từ \(A\) đến \(B\) |
| 9 | Sum of Digits | CF | ★★☆ | Đếm substring có tổng chữ số \(= K\) |
| 10 | Number of Numbers | CodeChef | ★★★ | Nhiều query, Digit DP |
| 11 | Counting Numbers | CSES | ★★☆ | Đếm số không có chữ số liền kề giống nhau |
Bài tập luyện tập¶
Dễ (làm quen):
- Đếm số trong \([0, N]\) mà tổng chữ số chia hết cho \(3\).
- Đếm số trong \([0, N]\) không chứa chữ số \(9\).
- Đếm số trong \([0, N]\) mà chữ số đầu tiên là số lẻ.
Trung bình:
- Đếm số trong \([0, N]\) mà hiệu lớn nhất và nhỏ nhất các chữ số \(\leq K\).
- Đếm số trong \([0, N]\) mà tổng chữ số là số nguyên tố.
- Đếm số trong \([L, R]\) mà tích các chữ số \(> 0\).
Khó:
- Đếm số trong \([0, N]\) mà là palindrome.
- Đếm số trong \([0, N]\) mà không có \(3\) chữ số liên tiếp giống nhau.
- Đếm số trong \([0, N]\) mà mỗi chữ số xuất hiện tối đa \(K\) lần.
Bài tiếp theo: Bài 49: Interval DP