數位dpの學習筆記
0x10 數位dp簡介
數位dp通常用於解決這類題目:
給定一個範圍 \(l\) ~ \(r\) ,求出這個範圍內,符合某種條件的數字個數、數字的和或數字的積。
給出的數字非常之巨大,採用 \(O(N)\) 的演算法無法通過題目,當我們遇到這類題目時,通常是拿不到暴力分的。
於是,我們在遇到這類題目時,通常要使用數位dp來解決。
0x20 數位dp實現方式
我們通常都使用記憶化搜尋的方法來進行實現,可以 \(AC\)。
注意,當我們的記憶化不當時,或搜尋次數、參量處理不當時,我們很有可能會超時或得到錯誤的答案。
所以,設計好的參量和狀態,認真處理每個參量,是十分重要的。
這裡給出記憶化搜尋的模板,一個好的模板可以幫助你靈活面對難度較大的題目。
int tot = 0;//tot為數字的位數。 int dig[20];//dig為數字拆分後的各個位上的數字。 int dp[255][255];//dp為設計的轉移狀態。 int dfs (int now, int cnt, int zero, int limit) { //now為當前的處理的位數。 //cnt為變數,用來儲存例如1的個數。數位累計和等。 //zero標記當前是否有前導零。 //limit為限制,也就是是否到達數字的邊界。 if (now > tot) { return 1; /*處理的位數已經超過了數字的個數,也就表示該數字處理完成,退出迴圈。 而通常返回1,表示該數合法,因為之前的操作都是當處理位數合法的時候進行的。 當然,根據題目和寫法的不同,可能操作上有差異。 */ } if (!limit && !zero && dp[now][cnt] != -1) { return dp[now][cnt]; //該操作就是記憶化的體現。 //對於有的題目,前導零可能對於答案沒有影響,所以需要靈活調整條件。 //而狀態初始為-1,噹噹前狀態不為-1時,也就是該狀態處理過了。 //而根據記憶化的操作,我們就可以直接返回答案了。 } int up = limit ? dig[tot - now + 1] : 9; //up表示邊界,噹噹前數字已經是邊界了的時候,就是dig[tot-now+1]. //否則,該進位制的最大數位數字,十進位制就是9,二進位制就是1。 int ans = 0; //在大部分情況中,答案不止於int範圍,依舊需要根據情況理智處理。 for (int i = 0; i <= up; i ++) {//列舉數字從0到up if ()......//按情況寫條件 ans += dfs (now + 1, 當前狀態轉移, (zero && i == 0), (i == dig[now])); //對於第三個參量,搜尋當前標記zero為真且列舉當前數字為0時,zero標記才為真,否則為假。 //對於第四個參量,當前數字為當前搜尋的數字時,limit標記才為真,否則為假 } if (!limit && !zero) { dp[now][cnt] = ans; //這裡是記憶化的儲存,條件依舊需要根據情況書寫。 } return ans; //返回答案 }
0x30 數位dp基礎
在難度不是很高的情況下,記憶化搜尋就能幫助我們完成很多題目。既然我們瞭解了數位dp的概念和基本實現方法,那我們就用幾道基礎例題來快速提升自己吧!
0x31 基礎例題 \(1\)
按照我們上面記憶化搜尋的模式,我們在搜尋時,需要設 \(4\) 個引數。但是我們在該題可以很直接的看出,前導零是不會對答案造成影響的,因為它並不會影響二進位制下 \(1\) 的個數。所以,我們搜尋的時候,只需要設立 \(3\) 個參量就可以了。
而dp陣列的狀態對我們來說其實是不太重要的,我們只要將其運用固定地用在記憶化的地方就可以了。
該題我們直接採用暴力累乘,並不用擔心時間,將模板套用即可。
可能需要吸氧。
程式碼講解如下:
#include <cstdio>
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
using namespace std;
typedef long long ll;
const int mod = 10000007;
const int N = 115;
int dig[15], tot = 0;
ll dp[N][N];
ll dfs (int now, int cnt, int lim) {
//只需要設立三個引數。
//cnt:二進位制下1的個數。
if (now > tot) {
return cnt;
//當處理的位數超過數字的個數時,表示處理結束,返回答案。
}
if (!lim && dp[now][cnt] != -1) {
return dp[now][cnt];
//滿足記憶化的要求。
}
ll res = 1;//因累乘,所以初始化為1。
int up = lim ? dig[tot - now + 1] : 1;//定義邊界。
for (int i = 0; i <= up; i ++) {
ll tp = dfs (now + 1, cnt + (i == 1), (lim && (i == up)));
//對於第二個參量,噹噹前數字為1時才增加答案。
//當邊界標記為真且當前數字為邊界時,邊界標記才為真。
res = max (1ll, tp) * res % mod;
//tp可能小於1,所以和1取max,直接暴力累乘。
}
if (!lim) {
dp[now][cnt] = res;
//記憶化的記錄答案。
}
return res;
}
ll calc (ll x) {
memset (dp, -1, sizeof (dp));
//狀態的初始值。
do {
dig[++tot] = x % 2;
x /= 2;
}while (x);
//二進位制拆分。
return dfs (1, 0, 1);
//初始處理第1位,1的個數為0,邊界標記為真。
}
int main() {
ll n;
scanf ("%lld", &n);
printf ("%lld", calc (n));
return 0;
}
0x32 基礎例題 \(2\)
由於需要判斷當前數字和上一個數字的差是否為 \(2\) ,所以要記錄上一位的數字。於是記憶化搜尋的變數設定為上一個數字。
在列舉下一個數字時,如果不滿足相差為 \(2\) 的條件,那麼可以直接跳過。注意,如果當前有前導零,那麼該數位可以任意填。
根據字首和的思想,題目給出的 \(a\) 和 \(b\) 區間,我們計算出的是 \(1\) 到 \(a\) 和 \(1\) 到 \(b\) 滿足條件的數字個數。
於是我們需要輸出 \(1\) 到 \(b\) 滿足條件的數字個數減去 \(1\) 到 \(a-1\) 滿足條件的個數,就是我們最終的答案。
於是這樣,大體的思路就出來了,靈活運用模板就可以通過。
程式碼講解如下:
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <iostream>
#include <cmath>
using namespace std;
typedef long long ll;
int dig[15], dp[115][115], tot = 0;
int dfs (int now, int last, int zero, int limit) {
if (now > tot) {
return 1;
//處理完了返回該數合法
}
if (!limit && dp[now][last] != -1) {
return dp[now][last];
//記憶化
}
int sum = 0, up = limit ? dig[tot - now + 1] : 9;
//定義邊界
for (int i = 0; i <= up; i ++) {
if (i < last + 2 && i > last - 2 && !zero) {
continue;
//不符合相差為2的條件
}
sum += dfs (now + 1, i, (zero && !i), (limit && (i == up)));
//對於第三個參量,當列舉的數位為0且當前為0時,前導零標記才為真。
//當前邊界標記為真且當前列舉的數位到達邊界,邊界標記才為真。
}
if (!zero && !limit) {
dp[now][last] = sum;
//記憶化的記錄答案。
}
return sum;
}
ll calc (ll x) {
memset (dp, -1, sizeof (dp));
memset (dig, 0, sizeof (dig));
tot = 0;
//這裡的初始化很重要,不要忘記。
do {
dig[++tot] = x % 10;
x /= 10;
}while (x);
//十進位制拆分。
return dfs (1, -1, 1, 1);
}
int main() {
ll a, b;
scanf ("%lld%lld", &a, &b);
printf ("%lld", calc (b) - calc (a - 1));
//字首和思想的運用。
return 0;
}
0x33 基礎例題 \(3\)
對於此題,我們的記憶化搜尋參量就不止 \(4\) 個了。
我們需要多增加 \(3\) 個參量:
\(1.\) 標記是否出現過 \(4\)。
\(2.\) 標記是否出現過 \(8\)。
\(3.\) 標記是否構成三連號。
另外,我們依舊有用到字首和的思想。但是當我們將左邊界減去一後,無法滿足電話號碼 \(11\) 位的條件,這裡我們需要特判。
我們依舊需要記憶化來優化時間,大體的程式碼就能寫出來了。
程式碼講解如下:
#include <cstdio>
#include <algorithm>
#include <iostream>
#include <cstring>
using namespace std;
typedef long long ll;
int dig[15], tot = 0;
ll dp[35][15][15][3][3][3][3];
ll dfs (int pos, int now, int last, bool con3, bool f4, bool f8, bool limit) {
if (f4 == true && f8 == true) {
return 0;
//同時出現4和8時,退出搜尋。
}
if (pos == 0) {
return con3 == true ? 1 : 0;
//搜尋完時,判斷是否三連號,否則不合法。
}
if (!limit && dp[pos][now][last][con3][f4][f8][limit] != -1) {
return dp[pos][now][last][con3][f4][f8][limit];
//記憶化返回答案。
}
int up = limit ? dig[pos] : 9;//邊界。
ll ans = 0;
for (int i = 0; i <= up; i ++) {
bool c3 = false;
if (last == i && i == now) {
c3 = true;
//三連號標記。
}
ans += dfs (pos - 1, i, now, (c3 || con3), (i == 4 || f4), (i == 8 || f8), limit && i == dig[pos]);
//對於第四個參量,只要當前構成三連號並且三連號標記為真,接下去搜索的三連號標記就為真。
//對於第五個參量,只要當前有4或者標記4為真,接下去搜索的4標記就為真。
//對於第六個參量,只要當前有8或者標記8為真,接下去搜索的8標記就為真。
//對於第七個參量,當邊界標記為真且當前數位到達邊界,邊界標記為真。
}
if (!limit) {
dp[pos][now][last][con3][f4][f8][limit] = ans;
//記憶化記錄答案。
}
return ans;
}
ll calc (ll x) {
tot = 0;
do {
dig[++tot] = x % 10;
x /= 10;
}while (x);
//拆分。
ll ans = 0;
for (int i = 1; i <= dig[tot]; i ++) {
ans += dfs (tot - 1, i, -1, false, (i == 4), (i == 8), (i == dig[tot]));
//累計答案。
}
return ans;
}
int main() {
ll l, r;
scanf ("%lld%lld", &l, &r);
memset (dp, -1, sizeof (dp));
if (l == 10000000000) {
printf ("%lld", calc (r));
return 0;
//特判。
}
printf ("%lld", calc(r) - calc (l - 1));
return 0;
}
0x40 數位dp進階
對於一些難度較高的題目,單憑記憶化搜尋的時間優化無法通過。
於是,我們需要對於狀態進行合併、預處理或剪枝等。這些進階的方法可以幫助我們實現時間上的巨大優化。
接下來,就從幾道進階的數位dp例題來學習優化時間的方法吧。
0x41 進階例題 \(1\)
很明顯,如果按照之前的模板操作,這道題是輕而易舉的。
但是,當我們記錄dp狀態的時候,就會發現情況不對。
這裡的數字會達到 \(10^{18}\) ,我們無法記錄 dp狀態了。
於是這裡,我們就很容易想到取模的做法。
那我們將什麼作為模數呢?
我們可以列舉所有的數位之和來當做模數。
於是,判斷 \(mod\) 與數位上的和相同且原來的數字模數位上的和為 \(0\) ,這個答案就合法。
程式碼講解如下:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cstdio>
#include <cmath>
using namespace std;
typedef long long ll;
ll l, r;
ll dp[22][222][222];
int dig[22], tot = 0, mod;
ll dfs (int now, int cnt, ll rem, int limit) {
//now:當前處理的數位。
//cnt:枚舉了的數位之和。
//rem:數字的總和模去mod後的得數。
//limit:標記是否到達邊界。
if (now > tot) {
if (cnt == 0) {//不符合題意。
return 0;
}
}
if (now > tot) {
if (rem == 0 && mod == cnt) {
//這是符合題意的情況。
return 1;
}
else {
return 0;
}
}
if (!limit && dp[now][cnt][rem] != -1) {
return dp[now][cnt][rem];
}
ll res = 0;
int up = limit ? dig[tot - now + 1] : 9;
for (int i = 0; i <= up; i ++) {
res += dfs (now + 1, cnt + i, (10 * rem + i) % mod, i == up && limit);
}
if (!limit) {
dp[now][cnt][rem] = res;
}
return res;
}
ll calc (ll x) {
tot = 0;
do {
dig[++tot] = x % 10;
x /= 10;
}while (x);
ll ans = 0;
for (mod = 1; mod <= 9 * tot; mod ++) {
//列舉模數。
memset (dp, -1, sizeof (dp));
//每次都要初始化dp狀態。
ans += dfs (1, 0, 0, 1);
}
return ans;
}
int main() {
scanf ("%lld%lld", &l, &r);
printf ("%lld", calc (r) - calc (l - 1));
return 0;
}
0x42 進階例題 \(2\)
待更(((