\(\texttt{0x00}\) 簡介
數位 dp 解決的是與數字有關的一類計數問題,在求解過程中常把一個數字的每一位都拆開來看,比如十進位制下就是把千位、百位、十位、個位上的數字都拆開來看,其他進位制類比十進位制。
數位 dp 的問題一般比較顯眼,有幾個常見形式:
-
要求統計滿足一定條件的數的數量(即,最終目的為計數);
-
這些條件經過轉化後可以使用「數位」的思想去理解和判斷;
-
輸入會提供一個數字區間(有時也只提供上界)來作為統計的限制;
-
上界很大(比如 \(10^{18}\)),暴力列舉驗證會超時。
(from OI Wiki)
在數位 dp 的實現上,我通常採用的是記憶化搜尋,這樣寫不僅容易,而且易於擴充,還可以當板子來背,這已經是 dp 中少見的了。
\(\texttt{0x01}\) 例題
P2657 [SCOI2009] windy 數
題目大意
求 \([l, r]\) 內有多少個數十進位制表示下所有的相鄰數位數值之差大於等於 \(2\)。
思路
考慮從最高位開始填數,在記憶化搜尋時記錄 \(pos\) 表示當前填到第幾位,\(pre\_num\) 表示上一個位置填的數是什麼,\(limit\) 記錄前面放的數是否頂上界,\(zero\) 記錄當前這位之前是否是前導零。
先把上界的每一位摳出來,那麼當搜尋放第 \(i\) 位時,要先確定這一位能放什麼數,若前面都是貼著上界放的,那麼這一位最多隻能放 \(num_{pos}\),否則就不受限制。
然後在列舉第 \(i\) 位放什麼時還要滿足相鄰數位數值之差大於等於 \(2\) 的限制,這個很好轉移。
當然,若是前導零的話還要特別注意,因為這時 \(0\sim 9\) 都可以放,而如果沒有考慮到這一點,最高位就只能至少放 \(2\) 了。
在加記憶化時還要注意,若出現了頂上界或前導零的情況是不能記憶化的(當然你也可以多開兩維來額外存,不過我覺得沒什麼必要,這個時候直接暴力搜尋就行了,反正也費不了多少時間)。
\(\texttt{Code:}\)
#include <cmath>
#include <vector>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 15;
int l, r;
int f[N][N];
vector<int> num;
int dfs(int pos, int pre_num, bool limit, bool zero) {
if(pos < 0) return 1; //邊界,若放完了最後一位就返回 1,因為我們一直是按要求放的,所以此時也是一種情況
if(!limit && pre_num >= 0 && f[pos][pre_num] != -1) return f[pos][pre_num]; //記憶化
int mx = (limit ? num[pos] : 9); //計算上界
int res = 0;
for(int i = 0; i <= mx; i++) {
if(abs(i - pre_num) < 2) continue;
if(!i && zero) //特判前導零的情況,這時 prenum 設為 -2 確保下一位不受任何限制
res += dfs(pos - 1, -2, limit && (i == num[pos]), 1);
else
res += dfs(pos - 1, i, limit && (i == num[pos]), 0);
}
if(!limit && !zero) f[pos][pre_num] = res;
return res;
}
int calc(int x) {
num.clear();
int tmp = x;
//先把上界的每一位摳出來
while(tmp) {
num.push_back(tmp % 10);
tmp /= 10;
}
//初始化
memset(f, -1, sizeof f);
return dfs(num.size() - 1, -2, 1, 1);
}
int main() {
scanf("%d%d", &l, &r);
//數位 dp 通常都有這種類似字首和的形式
printf("%d\n", calc(r) - calc(l - 1));
return 0;
}
P2602 [ZJOI2010] 數字計數
題目大意
求 \([l, r]\) 中的數在十進位制表示下 \(0\sim 9\) 各個數碼分別出現了多少次。
思路
對每個數碼分開計算,還是拆成 \(r\) 減去 \(l - 1\) 的形式。
在記憶化搜尋時記錄一下當前考慮的數碼 \(d\) 填了多少次,在所有位填完後再計算即可。
後面都只放主要程式碼了,因為真的很板子。
\(\texttt{Code:}\)
ll dfs(int pos, ll cnt, bool limit, bool zero, int d) {
if(pos < 0) return cnt;
if(!limit && !zero && f[pos][cnt] != -1) return f[pos][cnt];
int mx = (limit ? num[pos] : 9);
ll res = 0;
for(int i = 0; i <= mx; i++)
res += dfs(pos - 1, cnt + ((!zero || i) && (i == d)), limit && (i == num[pos]), zero && (!i), d);
if(!limit && !zero) f[pos][cnt] = res;
return res;
}
Digit Sum
題目大意
求 \([1, N]\) 中有多少個數在十進位制表示下數碼和是 \(D\) 的倍數。
資料範圍:\(1\le N\le 10^{10000},1\le D\le 100\)。
思路
很明顯的數位 dp。
首先把上界 \(N\) 的每一位摳出來,然後進行填數,個人喜歡從最高位開始填。
加上記憶化,設 \(f(pos, r)\) 表示在沒有頂上界和前導零的情況下,當前填到了第 \(pos\) 位,餘數為 \(r\) 的數的個數。
然後在搜尋過程中記一下當前數位和 \(\bmod p\) 等於多少,再簡單轉移一下即可,詳細註釋在程式碼中。
這裡再講一下數位 dp 如何分析時間複雜度。
注意到狀態數為 \(D\cdot\lg N\),每次轉移時最多列舉 \(10\) 個可填的數,所以時間複雜度為 \(O(D\cdot \lg N)\),可以透過此題。
注意!由於最後要 \(-1\),所以為防止減為負數要先加上模數再取模。
\(\texttt{Code:}\)
ll dfs(int pos, int r, bool limit, bool zero) {
if(pos < 0) return (r == 0);
if(!limit && !zero && f[pos][r] != -1) return f[pos][r];
int mx = (limit ? num[pos] : 9);
ll res = 0;
for(int i = 0; i <= mx; i++)
res = (res + dfs(pos - 1, (r + i) % d, limit && (i == num[pos]), zero && (!i))) % mod;
if(!limit && !zero) f[pos][r] = res;
return res;
}
P4127 [AHOI2009] 同類分佈
題目大意
求出 \([l, r]\) 中各位數字之和能整除原數的數的個數。
思路
若是要求整除的數是同一個數,那就和上一題一樣,但若除的數都不一樣該怎麼辦?
那我們就換一種思路,直接列舉數位和,然後在搜尋時每填一個數就相應地減去,同時記錄一下餘數,其他的引數照搬即可。
由於最多有 \(18\) 位,所以要列舉 \(1\sim 162\)。
\(\texttt{Code:}\)
ll dfs(int pos, int sum, int r, bool limit, bool zero) {
if(sum < 0) return 0;
if(pos < 0) return !sum && !r;
if(!limit && !zero && f[pos][sum][r] != -1) return f[pos][sum][r];
int mx = (limit ? num[pos] : 9);
ll res = 0;
for(ll i = 0; i <= mx; i++)
res += dfs(pos - 1, sum - i, (r * 10 + i) % d, limit && (i == num[pos]), zero && (!i));
if(!limit && !zero) f[pos][sum][r] = res;
return res;
}
擴充:
數位 dp 一般會與 Lucas 定理一起食用,畢竟 Lucas 定理就是逐位求組合數。
習題:
√P8764 [藍橋杯 2021 國 BC] 二進位制問題
√P6218 [USACO06NOV] Round Numbers S
√P4124 [CQOI2016] 手機號碼
√P4317 花神的數論題
P7976 「Stoi2033」園遊會
P3413 SAC#1 - 萌數
P3286 [SCOI2014] 方伯伯的商場之旅
P2481 [SDOI2010] 程式碼拍賣會