1. 程式人生 > 其它 >數位dpの學習筆記

數位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\)

P4317 花神的數論題

按照我們上面記憶化搜尋的模式,我們在搜尋時,需要設 \(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\)

P2657 [SCOI2009] windy 數

由於需要判斷當前數字和上一個數字的差是否為 \(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\)

P4124 [CQOI2016]手機號碼

對於此題,我們的記憶化搜尋參量就不止 \(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\)

P4127 [AHOI2009]同類分佈

很明顯,如果按照之前的模板操作,這道題是輕而易舉的。

但是,當我們記錄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\)

CF55D Beautiful numbers

待更(((