2023 5月 dp做題記錄

Fire_Raku發表於2024-04-20

目錄
  • 5月 dp做題記錄
      • P1064 [NOIP2006 提高組] 金明的預算方案
      • P1941 [NOIP2014 提高組] 飛揚的小鳥
      • P2679 [NOIP2015 提高組] 子串
      • P1850 [NOIP2016 提高組] 換教室
      • P2831 [NOIP2016 提高組] 憤怒的小鳥
      • P5020 [NOIP2018 提高組] 貨幣系統
      • P6064 [USACO05JAN]Naptime G
      • P9344 去年天氣舊亭臺
      • P4095 [HEOI2013]Eden 的新揹包問題
      • P3174 [HAOI2009] 毛毛蟲
      • P2340 [USACO03FALL]Cow Exhibition G
      • P4059 [Code+#1]找爸爸
      • P4342 [IOI1998]Polygon
      • CF149D Coloring Brackets
      • UVA12991 Game Rooms
      • Generate a String
      • Games with Rectangle
      • CF837D Round Subset
      • CF14D Two Paths
      • CF527D Clique Problem
      • P4310 絕世好題
      • P4158 [SCOI2009]粉刷匠
      • P1772 [ZJOI2006] 物流運輸
      • P3861 拆分

5月 dp做題記錄

P1064 [NOIP2006 提高組] 金明的預算方案

物體與物體之間有從屬的限制關係,且從屬關係只有一層,最多有兩個個附件,所以我們很容易就可以想到列舉出每一組的組合關係,畢竟每組最多四種組合,再跑一遍分組揹包,從每組中至多選出一種組合,以滿足限制條件。

P1941 [NOIP2014 提高組] 飛揚的小鳥

發現題目是一張 \(n\times m\) 的地圖,容易想到初始狀態 \(dp_{i,j}\) 為現在飛到第 \(i\) 列,第 \(j\) 行的最少跳躍次數,又發現是從左到右一列一列飛行,每一列只與上一列相關,所以可以用滾動陣列滾掉一維。

再注意細節,如果跳躍過高是會頂格的,所以最高行是可以透過前一列所有小於跳躍高度的點轉移的,這點要單獨列舉。還有揹包列舉順序的問題,如果下落是不會再上升的,所以是先上升的 01 揹包,再上升的完全揹包,最後在下落的 01 揹包,防止下落的狀態被完全揹包轉移。

這題要注意的就是不能少轉移,因為用的是滾動陣列,前面的都要繼承下來。

\(\begin{cases}dp_{i,j}=\min(dp_{i-1,j+y_i},dp_{i-1,j-x_i}+1)\\dp_{i,m}=\min(dp_{i,m},dp_{i-1,k}+1)(k+x_i\ge m) \\dp_{i,j}=\min(dp_{i,j},dp_{i,j-x_i}+1) \end{cases}\)

P2679 [NOIP2015 提高組] 子串

匹配問題,有狀態 \(dp_{i,j,k,0/1}\) 表示 \(A\) 串中列舉到第 \(i\) 位,匹配到了 \(B\) 串的第 \(j\) 位,選了 \(k\) 段,並且第 \(i\) 位選/不選的方案數,轉移也很好寫,寫完發現第一維是可以滾動滾掉的,其餘注意第二行 \(dp_{i-1,j-1,k,0}\) 是不合法的,不能用這個轉移就行。

初始狀態 \(dp_{0,0,0,0}=dp_{1,0,0,0}=1\)

\(\begin{cases}dp_{i,j,k,0}=dp_{i-1,j,k,0}+dp_{i-1,j,k,1}\\dp_{i,j,k,1}=dp_{i-1,j-1,k,1}+dp_{i-1,j-1,k-1,0}+dp_{i-1,j-1,k-1,1}(a_i=b_k) \end{cases}\)

答案為 \(dp_{n,m,g,0}+dp_{n,m,g,1}\)

#include <bits/stdc++.h>
using namespace std;
int read(){
    int x = 0, f = 1;
    char c = getchar();
    while(c < '0' || c > '9'){
        if(c == '-') f = -1;
        c = getchar();
    }
    while(c >= '0' && c <= '9'){
        x = (x << 1) + (x << 3) + (c - '0');
        c = getchar();
    }
    return x * f;
}
int n, m, g;
char a[1010], b[1010];
long long dp[2][210][210][2];
int main(){
    n = read(), m = read(), g = read();
    scanf("%s%s", a + 1, b + 1);
    dp[0][0][0][0] = dp[1][0][0][0] = 1;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            for(int k = 1; k <= g; k++){
                dp[i & 1][0][0][0] = 1, dp[i & 1 ^ 1][0][0][0] = 1, dp[i & 1][j][k][0] = 0, dp[i & 1][j][k][1] = 0;
                dp[i & 1][j][k][0] = (dp[i & 1 ^ 1][j][k][0] + dp[i & 1 ^ 1][j][k][1]) % 1000000007;
                if(a[i] == b[j]){
                    dp[i & 1][j][k][1] = (dp[i & 1 ^ 1][j - 1][k][1] + dp[i & 1 ^ 1][j - 1][k - 1][1] + dp[i & 1 ^ 1][j - 1][k - 1][0]) % 1000000007;
                }
            }
        }
    }
    cout << (dp[n & 1][m][g][0] + dp[n & 1][m][g][1]) % 1000000007 << endl;
    return 0;
} 

P1850 [NOIP2016 提高組] 換教室

因為是選擇性問題,所以狀態就很套路,定義狀態為 \(dp_{i,j,0/1}\) 表示到了第 \(i\) 堂課,用了 \(j\) 次換課,第 \(i\) 堂課換/不換的最大期望,轉移長但好想,無非是成功的期望+失敗的期望,四種情況,這次沒換上次沒換,這次沒換上次換,這次換上次沒換,這次換上次換,哪次成功就乘 \(p_i\),失敗就乘 \(1-p_{i}\),注意上次換到 \(d\),下次預設還是在 \(c\),不會繼承。

\(\begin{cases}dp_{i,j,0}=\min(dp_{i-1,j,0},dp_{i-1,j,1}+f_{c_{i-1,c_i}}\times p_i+f_{d_{i-1},c_i}\times(1-p_{i-1}))\\dp_{i,j,1}=\min(dp_{i-1,j-1,0}+f_{c_{i-1},d_i}\times p_i+f_{c_{i-1},c_i}\times(1-p_i), dp_{i-1,j-1,1}+f_{c_{i-1},c_i}\times(1-p_{i-1})\times(1-p_i)+f_{c_{i-1},d_i}\times(1-p_{i-1})\times p_i+f_{d_{i-1},c_i}\times p_{i-1}\times(1-p_i)+f_{d_{i-1},d_i}\times p_{i-1}\times p_i) \end{cases}\)

#include<bits/stdc++.h>
using namespace std;
int n, m, a, b;
int c[2010], d[2010];
double k[2010], ans, dp[2010][2010][2], f[310][310];
int main(){
	cin >> n >> m >> a >> b;
	for(int i = 1; i <= n; i++) cin >> c[i];
	for(int i = 1; i <= n; i++) cin >> d[i];
	for(int i = 1; i <= n; i++) cin >> k[i];
	for(int i = 1; i <= a; i++){
		for(int j = 1; j <= a; j++) f[i][j] = 2147483647;
	}
	for(int i = 1; i <= b; i++){
		int u, v;
		double w;
		cin >> u >> v >> w;
		f[u][v] = f[v][u] = min(f[u][v], w);
	}
	for(int k = 1; k <= a; k++){
		f[k][k] = 0;
		for(int i = 1; i <= a; i++){
			for(int j = 1; j <= a; j++){
				f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
			}
		}
	}
	for(int i = 1; i <= n; i++){
		for(int j = 0; j <= m; j++) dp[i][j][0] = dp[i][j][1] = 2147483647;
	}
	dp[1][0][0] = dp[1][1][1] = 0;
	for(int i = 2; i <= n; i++){
		for(int j = 0; j <= min(i, m); j++){
			dp[i][j][0] = min(dp[i - 1][j][0] + f[c[i - 1]][c[i]], dp[i - 1][j][1] + f[c[i - 1]][c[i]] * (1 - k[i - 1]) + f[d[i - 1]][c[i]] * k[i - 1]);
			if(j != 0) dp[i][j][1] = min(dp[i - 1][j - 1][0] + f[c[i - 1]][c[i]] * (1 - k[i]) + f[c[i - 1]][d[i]] * k[i], dp[i - 1][j - 1][1] + f[c[i - 1]][c[i]] * (1 - k[i - 1]) * (1 - k[i]) + f[c[i - 1]][d[i]] * (1 - k[i - 1]) * k[i] + f[d[i - 1]][c[i]] * k[i - 1] * (1 - k[i]) + f[d[i - 1]][d[i]] * k[i - 1] * k[i]);
		}
	}
	ans = 0x3f3f3f3f;
	for(int j = 0; j <= m; j++){
		ans = min(ans, dp[n][j][0]);
		if(j != 0) ans = min(ans, dp[n][j][1]);
	}
	cout << fixed << setprecision(2) << ans << endl;
	return 0;
}

P2831 [NOIP2016 提高組] 憤怒的小鳥

這題需要一些數學知識,首先看資料範圍,確定是狀壓 dp,簡單的定義狀態 \(dp_S\) 為到 \(S\) 狀態的最少拋物線數,我們肯定不會去列舉子集,去判斷差集能不能被一次穿過,而是想透過 \(S\) 這個狀態能給哪些狀態轉移

橫座標不同的兩點確定一條形式為 \(y=ax^2+bx\) 的拋物線,\(a\)\(b\) 都是可以解出來的

\(\begin{cases}ax_1^2+bx_1=y_1\\ax_2^2+bx_2=y_2\end{cases}\)

②式 \(\Rightarrow ax_2^2\dfrac{x_1}{x_2}+bx_2\dfrac{x_1}{x_2}=y_2\dfrac{x_1}{x_2}\)

\(\Rightarrow ax_1x_2+bx_1=\dfrac{y_2x_1}{x_2}\) ③式

①-③ \((x_1x_2-x_1^2)a=\dfrac{y_2x_1}{x_2}-y_1=\dfrac{y_2x_1-y_1x_2}{x_2}\)

\(a=\dfrac{y_2x_1-y_1x_2}{x_2}\times \dfrac{1}{x_1x_2-x_1^2}=\dfrac{y_2x_1-y_1x_2}{x_1x_2^2-x_1^2x_2}\)

同理得,\(b=\dfrac{y_1x_2^2-y_2x_1^2}{x_1x_2^2-x_1^2x_2}\)

\(n^2\) 預處理一下每兩個點連成的拋物線上能有多少點,二進位制儲存一下

\(\begin{cases}dp_{S|(1<<(j-1))}=\min(dp_{S|(1<<(j-1))},dp_S+1)\\dp_{S|line_{j,k}}=\min(dp_{S|line_{j,k}},dp_S+1)\end{cases}\)

這樣的複雜度是 \(O(Tn^22^n)\) 的,要過這題還要最佳化到 \(O(Tn2^n)\) ,因為拋物線選擇的順序是不影響結果的最後我們所有小豬都要全部得到,所以 \(n^2\) 列舉所有拋物線的時候,實際上是列舉了原有的拋物線和很遠之後的拋物線的,最佳化的大部分是很遠之後的拋物線,這部分會一直重複不斷的列舉,但有用的只有一次,所以我們強制選擇一下,每次強制選經過最小的沒被經過的點的拋物線,這樣的拋物線只有 \(n\) 條,這樣從 \(0\) 窮舉到 \(2^n-1\) 都是有順序的選拋物線,不存在重複選和提早選,複雜度最佳化到 \(O(Tn2^n)\)

#include<bits/stdc++.h>
using namespace std;
int t, n, m;
double eps = 1e-8, x[20], y[20];
int line[20][20], dp[1 << 20], lowbit[1 << 20];
void findline(double &a, double &b, double x1, double y1, double x2, double y2){
	a = -(y1 * x2 - y2 * x1) / (x2 * x2 * x1 - x1 * x1 * x2);
	b = (y1 * x2 * x2 - y2 * x1 * x1) / (x1 * x2 * x2 - x2 * x1 * x1);
}
int main(){
	cin >> t;
	for(int i = 0; i < (1 << 18); i++){
		for(int j = 1; j <= 18; j++){
			if((i & (1 << (j - 1))) == 0){
				lowbit[i] = j;
				break;
			}
		}
	}
	while(t--){
		memset(line, 0, sizeof(line));
		memset(dp, 1, sizeof(dp));
		cin >> n >> m;
		for(int i = 1; i <= n; i++){
			scanf("%lf%lf", &x[i], &y[i]);
		}
		for(int i = 1; i <= n; i++){
			for(int j = 1; j <= n; j++){
				if(fabs(x[i] - x[j]) < eps) continue;
				double a, b;
				findline(a, b, x[i], y[i], x[j], y[j]);
				if(a > -eps) continue;
				for(int k = 1; k <= n; k++){
					if(fabs(a * x[k] * x[k] + b * x[k] - y[k]) < eps) line[i][j] |= (1 << (k - 1));
				}
			}
		}
		dp[0] = 0;
		for(int i = 0; i < (1 << n) - 1; i++){
			int j = lowbit[i];
			dp[i | (1 << (j - 1))] = min(dp[i | (1 << (j - 1))], dp[i] + 1);
			for(int k = 1; k <= n; k++) dp[i | line[j][k]] = min(dp[i | line[j][k]], dp[i] + 1);
		}
		cout << dp[(1 << n) - 1] << endl;
	}
	return 0;
}

P5020 [NOIP2018 提高組] 貨幣系統

要把 \(n\) 個數能表示出的數儘可能用最少的數表示出來,我們首先可以探究一下 \((n,a)\)\((m,b)\) 之間的關係。
\((n,a)\) 表示為集合 \(A\)\((m,b)\) 表示為集合 \(B\),要讓 \((n,a)=(m,b)\),我們建的集合 \(B\) 表示出的數不能有 \(A\) 沒有的,所以我們深入挖掘一下,如果 \(B\) 中用的數不是 \(A\) 中有的,我們擔心這些數 \(A\) 表示不出來,即可以用不在 \(A\) 裡的數來表示 \(A\) 的數嗎?

我們想驗證的猜想其實就是證 \(B\subseteq A\)。我們發現每個集合中的數分為兩類,第一類是不能被別人表示的數,第二類是能被別人表示的數,並且容易發現二類數一定能拆分成只用一類數的拼法(因為二類數可以一直拆,如果拆出二類數,那這個數還可以一直拆,直到不能再拆)。

要證 \(B\subseteq A\),因為一類數比較特殊,在 \(A\) 中不可少,所以首先可以證 \(A\) 中的一類數是否一定屬於 \(B\)。用反證法,假設一類數 \(x \subseteq A\),且 \(x\nsubseteq B\),那麼根據 \((n,a)=(m,b)\)\(B\) 中一定存在一些一類數能表示出 \(x\),這些一類數中一定存在至少一個數不在 \(A\) 中(如果都在 \(A\) 中,那 \(x\) 就不是一類數了),與 \((n,a)=(m,b)\) 矛盾,命題得證。

有了這個結論,就可以證 \(B\subseteq A\) 了,反證法,假設 \(x \subseteq B\),且 \(x \nsubseteq A\),那麼 \(A\) 中一定存在一些一類數能表示出 \(x\),根據上面的結論,這些一類數同時也在 \(B\) 中,那麼這些數本身就能表示出 \(x\) 了,為什麼 \(B\) 裡還要加一個 \(x\) 呢,根據 \(m\) 最小,得出 \(x\) 是多餘的,所以就不會存在 \(x\) 這種數,也就是 \(B\subseteq A\) 了。

知道了 \(B\subseteq A\) 這題就簡單了,題目就變成:\(A\) 中最少能留幾個數,把所有數表示出來。首先,\(A\) 中的一類數一定都得在 \(B\) 中,然後容易發現,剩下的都是二類數,直接就都能被一定得選的一類數表示出來,這樣,不需要多的數,既然必選的數能表示出原來的 \(A\),自然也就可以表示出 \((n,a)\),所以 \(A\) 中的一類數數量就是答案了。中我們把所有數排序,做完全揹包,如果當前列舉的數不能被之前小的數表示出來,它就是一類數,答案就是這些數的個數。

P6064 [USACO05JAN]Naptime G

對於每一段,我們都有睡或不睡兩種選擇,並且是選段問題,並且當前的效用值有沒有貢獻取決於上一次有沒有睡覺,所以我們的狀態可以是 \(dp_{i,j,0/1}\) 表示當前在第 \(i\) 段,選了 \(j\) 段睡覺,當前第 \(i\) 段睡/ 不睡的最大總效用值。

\(\begin{cases}dp_{1,0,0}=dp_{1,1,1}=0\\dp_{i,j,0}=\max(dp_{i-1,j,0},dp_{i-1,j,1})\\dp_{i,j,1}=\max(dp_{i-1,j-1,0},dp_{i-1,j-1,1}+a_i)\end{cases}\)

如果這題不可以睡到第二天早上,那麼就這樣就行了,但是我們發現這題是可以從第一天連著睡到第二天的前幾段的。但是容易發現這其中的區別其實就是多了第一段的效用值,其他段並沒有影響,因為每一段還是都只能睡一次,不會重複睡。這裡有一個技巧,可以把睡覺分為兩種情況,一種沒有第一段,另一種強制選第一段。在初始化時,把 \(dp_{1,0,0}=dp_{1,1,1}=a_1\) 就行了。

P9344 去年天氣舊亭臺

選若干段區間 \([i,j]\) (滿足 \(c_i=c_j\))覆蓋整個區間,使得 \(\sum a_i+a_j\) 最小。因為選區間的順序是不影響答案的,所以我們可以從左到右考慮,設狀態 \(dp_i\) 表示覆蓋 \([1,i]\) 用的最小費用,發現選的區間一定是一個緊貼著一個的,\(dp_i\) 跟前面所有的 \(dp_j\) 都相關,列舉斷點 \(j\),可以得出樸素的轉移方程是

\[dp_i=\min_{j\le i}(dp_{j-1}+a_j)+a_i(c_j=c_i) \]

每次都要列舉前面所有的 \(dp_j\) 這樣的方程複雜度是 \(O(n^2)\) 的。但是我們很容易可以發現,之前處理的 \(\min\) 之後還可以用,只不過每次處理完多一個 \(dp_{i}+a_{i+1}\) 而已,所以 \(\min\) 裡面的值是可以繼承的,也就是列舉 \(i\) 的時候,就可以一邊處理出當前最小的 \(\min\),考慮到 \(c_j=c_i\) 的限制,我們把 \(\min\) 分成 \(c_j=c_i=0\)\(c_j=c_i=1\) 兩種情況,分別更新就行了。這樣 \(c_i=0/1\) 時,就可以直接取出前面實時更新的 \(\min\)\(O(1)\) 更新 \(dp_i\) 了。

P4095 [HEOI2013]Eden 的新揹包問題

對於每一次詢問,給出不能選的一個物品 \(d\),和最大價錢 \(e\),做多重揹包後的最大價值。如果每一次詢問都做一次多重揹包,肯定超時。我們迴歸揹包的最原始狀態 \(dp_{i,j}\) 表示前 \(i\) 個物品,最大價錢為 \(j\) 能得到的最大價值。對於前 \(i\) 個,如果不能選的是第 \(i\) 個,那麼容易知道答案是 \(dp_{i-1,j}\)。回到這題,我們還要考慮 \(i\) 後面的最大價值,其實 \(i\) 在整個序列中就充當一個分隔左右兩部分的隔板,所以我們把 \(i\) 分成前 \(i\) 個和後 \(n-i\) 個兩種,分開考慮,每一種裡,\(i\) 都是最後一個,所以可以正著和反著分別做一遍多重揹包,知道了 \(i\) 字首和字尾的 \(dp\) 值,再列舉前面用的價錢 \(v\),後面的就是 \(e-v\) 了,左右兩段 \(dp\) 值相加就是答案。

答案即 \(ans=\max(ans,dp1_{l_d,i}+dp2_{r_d+1,e-i})\)

其中 \(l_i\)\(r_i\) 表示二進位制拆分後,一個物體的左端點和右端點。

P3174 [HAOI2009] 毛毛蟲

要找到一條鏈,使得鏈上的點加上點延伸出去的點最多,我們可以發現,對於一個點 \(u\),如果在他的子樹下連出了兩條邊,那麼鏈就一定在子樹裡了。樹中的每一條鏈我們都可以找到這樣一個點,使得鏈在這個點的子樹裡。只要 \(u\) 連出的邊超過兩條,在這個點下面的鏈肯定是連出兩條的這種,並且我們希望這條鏈最大,肯定是連出的兩條邊的貢獻都最大;除非是鏈,我們才會只連出一條邊。所以我們設狀態 \(dp_{u,0/1/2}\) 表示在 \(u\) 中連出了 \(0/1/2\) 條邊。轉移中,對於他的子節點可以繼續往下連,也可以到這就停止,\(dp_{u,2}\) 其實就是統計的作用的,他們不會用來轉移。

\(\begin{cases}dp_{u,0}=sz_u\\dp_{u,1}=\max(dp_{u,1},\max(dp_{v,1},dp_{v,0}))\\dp_{u,2}=sz_u+maxn1+maxn2-1/sz_u+maxn1/sz_u\end{cases}\)

\(sz_u\) 表示包括自己加上連出的點,兩個 \(maxn\) 分別為子樹裡的最長和次長,注意點 \(u\) 的父親也會被擴充,一定不要自以為是去掉然後漏了QWQ

這題的狀態還可以更簡單一點,可以去掉 \(0/1/2\) 這一維,因為不選其實囊括在 \(dp_{u,1}\) 裡了,不選一定是不優的(既然都走到 \(v\) 了,不往下不就虧了)。

後面學完了樹的直徑,這題還可以更簡單,其實就是我們現在每個點的最長鏈不是 \(0\) 了,而是它所連出的邊數加上自己,基於此,加上樹的直徑的 \(dp\) 求法,設狀態為 \(dp_i\) 表示以 \(i\) 為根的子樹的最長鏈是多少,轉移和直徑一模一樣。

\(\begin{cases}ans=\max(ans,dp_u+dp_v-1)\\dp_u=\max(dp_u,dp_v+sz_u-1)\end{cases}\)

\(-1\) 是因為多算了一遍 \(u\) 節點。

P2340 [USACO03FALL]Cow Exhibition G

這題雖然是黃題,但是讓我注意到了之前 \(01\) 揹包時沒有注意到的細節。

要求在滿足智商情商非負的情況下,智商情商和最大。我們平時寫 \(01\) 揹包,一般都不會初始化,因為我們一般只需要直接用到 \(dp_m\) 就可以了,它表示的是前 \(n\) 個,最多花費 \(m\) 的最大價值,\(m\) 是虛值,並不是我們真正用的重量,這也是我們為什麼可以不初始化 \(dp_{0} = 0\),其他的 \(dp_i=-inf\)。由於狀態中描述是最多,說明前面用的重量是多少我們不在意,所以這樣 \(dp_{j-w_i}\) 就可以從隨便一個地方開始(可以理解成假設前面用了一些價值是 \(0\) 的物品,把揹包塞到了 \(j-w_i\)),因為狀態都是 \(0\),前面有貢獻的自然會轉移,沒有就直接用 \(0\) 的價值轉移,所以 \(dp_m\) 是正確的,因為不需要裡面重量到底用了多少,只想知道價值直接查詢 \(dp_m\) 就行了,不需要一個個列舉重量多少。

但我們想在這題裡面同時知道智商和情商的花費情況,我們就需要初始化了,因為發現如果初始化了,狀態描述就變成了 \(dp_{i,j}\) 表示\(i\) 個物品,裝到 \(j\) 重量的最大價值。因為此時我們只能從 \(dp_{0,0}\) 轉移,每一個 \(j\) 都是真實能夠達到的。

所以在這題裡,我們隨便用一個智商表示重量,求此時情商最大。防止陣列越界,我們把重量加一個大的值。最後我們列舉正的智商 \(i\),這時候如果智商 \(i\) 能被組合出來,\(dp_i\) 就不會是 \(-inf\)\(ans=i+dp_i-inf\)

P4059 [Code+#1]找爸爸

序列匹配問題,一般狀態 \(dp_{i,j}\) 表示 \(A\) 序列前 \(i\) 個,匹配了 \(B\) 序列前 \(j\) 個的最大價值。但是我們發現這樣子對於這題還不夠,因為每一位的匹配不能表示出來,不能計算貢獻,所以考慮多加一維表示目前對齊完最後一位的情況

\(\bullet\) 兩個都是字母

\(\bullet\) \(A\) 序列是空格,\(B\) 序列是字母

\(\bullet\) \(A\) 序列是字母,\(B\) 序列是空格

\(\bullet\) 兩個都是空格

我們觀察四個情況,很容易發現最後一個情況是對答案一定是負面影響,因為我們加空格,是為了接近匹配的目標,而第四種不僅接近不了目標,還減少相似度,所以可以直接去掉。

最後我們的狀態 \(dp_{i,j,0/1/2}\) 表示 \(A\) 序列前 \(i\) 個,透過空格,匹配了 \(B\) 序列前 \(j\) 個,且對齊後當前的情況為上面說的 \(0/1/2\) 三種之一。如果是 \(dp_{i,j,0}\),沒有用空格,那麼它上一個狀態就是匹配數都少一個,為 \(dp_{i-1,j-1,0}/dp_{i-1,j-1,1}/dp_{i-1,j-1,2}\);如果是 \(dp_{i,j,1}\)\(A\) 目前的最後一位用空格,那麼上一個狀態就是少了一行後, \(i\) 不變(因為肯定這 \(i\) 不是在當前行匹配的),\(j\) 少一個為 \(dp_{i,j-1,0}/dp_{i,j-1,1}/dp_{i,j-1,2}\);如果是 \(dp_{i,j,2}\)\(B\) 目前的最後一位用空格,那麼和上一個同理。

\(\begin{cases}dp_{i,j,0}=\max(dp_{i-1,j-1,0},dp_{i-1,j-1,1},dp_{i-1,j-1,1})+d_{a_i,b_i}\\dp_{i,j,1}=\max(dp_{i,j-1,0}-a,dp_{i,j-1,1}-b,dp_{i,j-1,2}-a)\\dp_{i,j,2}=\max(dp_{i-1,j,0}-a,dp_{i-1,j,1}-a,dp_{i-1,j,2}-b)\end{cases}\)

注意這題的初始化比較多,大多是不可能達到或者不優的,比如 \(dp_{i,0,1}=dp_{0,i,2}=dp_{i,0,0}=dp_{0,i,0}=-inf\),前面兩個是一定不優(兩個空格),後兩個是不存在這樣的方案。還有的是 \(dp_{0,i,1}=dp_{i,0,2}=-A-B(i-1)\) 表示對 \(A/B\) 序列補了 \(i\) 個空格的費用。

這題細節還是多,要把細節好好回味一下

#include <bits/stdc++.h>
using namespace std;
int read(){
    int x = 0, f = 1;
    char c = getchar();
    while(c < '0' || c > '9'){
        if(c == '-') f = -1;
        c = getchar();
    }
    while(c >= '0' && c <= '9'){
        x = (x << 1) + (x << 3) + (c - '0');
        c = getchar();
    }
    return x * f;
}
string A, B;
int x, y;
int a[3010], b[3010], d[6][6], dp[3010][3010][3];
int find(char a){
    if(a == 'A') return 1;
    if(a == 'T') return 2;
    if(a == 'G') return 3;
    return 4;
}
int main(){
    cin >> A >> B;
    int n = A.length(), m = B.length();
    for(int i = 0; i < n; i++) a[i + 1] = find(A[i]);
    for(int i = 0; i < m; i++) b[i + 1] = find(B[i]);
    for(int i = 1; i <= 4; i++){
        for(int j = 1; j <= 4; j++) cin >> d[i][j];
    }
    cin >> x >> y;
    for(int i = 1; i <= max(n, m); i++){
        dp[i][0][0] = dp[0][i][0] = dp[i][0][1] = dp[0][i][2] = -0x3f3f3f3f;
        dp[i][0][2] = dp[0][i][1] = -x - y * (i - 1);
    }
    dp[0][0][1] = dp[0][0][2] = -0x3f3f3f3f;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            dp[i][j][0] = max(dp[i - 1][j - 1][0], max(dp[i - 1][j - 1][1], dp[i - 1][j - 1][2])) + d[a[i]][b[j]];
            dp[i][j][1] = max(dp[i][j - 1][0] - x, max(dp[i][j - 1][1] - y, dp[i][j - 1][2] - x));
            dp[i][j][2] = max(dp[i - 1][j][0] - x, max(dp[i - 1][j][1] - x, dp[i - 1][j][2] - y));
        }
    }
    cout << max(dp[n][m][0], max(dp[n][m][1], dp[n][m][2])) << endl;
    return 0;
}

P4342 [IOI1998]Polygon

一眼環形 \(dp\),並且是環形 \(dp\) 中經典的合併性問題,對於這種問題,我們可以很套路的斷環成鏈,即把原序列複製一份到它自身後面。其原理支撐就是環形問題中具有區間 dp 的性質(連續性)時,往往會發現在任意一種方案中都有至少一對相鄰關係用不上,也就是一圈裡有一條邊會用不上。並且這個性質還和題目中去掉一條邊完美吻合。

這道題就是普通的合併,所以設狀態 \(dp_{i,j}\) 表示區間 \([i,j]\) 合併的最大價值,轉移也很好列

\(\begin{cases}dp_{i,j}=\max(dp_{i,j},dp_{i,k}+dp_{k+1,j})(c_{k+1}='t') \\dp_{i,j}=\max(dp_{i,j},dp_{i,k}\times dp_{k+1,j})(c_{k+1}='x')\end{cases}\)

但是我們發現,這樣子每次區間都取最大最後合併出的不一定是最大的,原因就是數字中有負數,有可能我們的區間是負數乘負數得出的最大值。所以我們思考一下,發現大區間最大價值只跟小區間的最大值和最小值有關。所以我們可以維護兩個狀態 \(f_{i,j}\)\(g_{i,j}\) 分別表示區間 \([i,j]\) 合併後的最大價值/最小价值。

分類討論,斷點是加法,最大值和最小值互不影響;斷點是乘法,排列組合一下,最大乘最大,最大乘最小,最小乘最大,最大乘最小,最大值最小值乘法都一樣。

\(\begin{cases}f_{i,j}=\max(f_{i,j},f_{i,k}+f_{k+1,j})(c_{k+1}='t')\\f_{i,j}=\max(f_{i,j},f_{i,k}\times f_{k+1,j},g_{i,k}\times g_{k+1,j},g_{i,k}\times f_{k+1,j},f_{i,k}\times g_{k+1,j})(c_{k+1}='x')\\g_{i,j}=\min(g_{i,j},g_{i,k}+g_{k+1,j})(c_{k+1}='t')\\g_{i,j}=\min(g_{i,j},f_{i,k}\times f_{k+1,j},g_{i,k}\times g_{k+1,j},g_{i,k}\times f_{k+1,j},f_{i,k}\times g_{k+1,j})(c_{k+1}='x')\end{cases}\)

答案便為 \(\max(f_{i,i+n-1})\),遍歷一遍就行了。回到之前的性質,如果一個環形的合併狀態表示為 \(f_{i,i+n-1}\),那麼它沒用上的一條邊就是 \(i-1\),所以第二問也就解決了。

CF149D Coloring Brackets

一道經典的區間染色題,特別的是由於括號的原因,任意的一段區間不一定是合法的,這些本身就不合法的區間是不好用來轉移的。

我們要讓狀態轉移,關心的地方就是題目給出的性質,兩兩相鄰的括號顏色不同,而區間上特別的就是兩個端點,這是從小區間轉移到大區間我們最需要考慮的兩點,所以我們設狀態 \(dp_{l,r,0/1/2,0/1/2}\) 表示 \([l,r]\) 區間合法的染色方案數,當前左端點為沒染色/染紅色/染藍色,右端點為沒染色/染紅色/染藍色,並且預設 \([l,r]\) 區間為合法配對的括號序列。

狀態列出來,我們要考慮最開始的問題,怎樣保證每次轉移的狀態都是配對的括號序列呢?遞推是不好排除特殊情況的。很簡單的想法就是我們自己考慮往哪轉移,可以用記憶化搜尋從大的合法序列推向小的合法序列

首先,每個左括號對應的右括號是可以預處理出來的,用棧模擬匹配括號,看到左括號就推進去;看到右括號,它對應的一定是棧頂的左括號。

我們考慮一個合法的括號序列 \([l,r]\)

\(\bullet\) 如果左右端點是配對的,那麼它的小的合法序列就是去掉外面的,為 \([l+1,r-1]\)

\(\bullet\) 如果左右端點不配對,那麼序列就由兩個合法序列拼起來構成,我們找到左端點配對的右括號位置(一定在 \([l,r]\) 內),那另一半也是合法的,為 \([l,c_l]\)\([c_l+1,r]\)

初始狀態就是當 \(l+1=r\) 時,括號一定是長這樣 \(()\),顯然有 \(dp_{l,r,0,1}=dp_{l,r,0,2}=dp_{l,r,1,0}=dp_{l,r,2,0}=1\)

對於第一種情況,我們先遞迴 \([l+1,r-1]\),然後列舉端點的顏色:

\(\begin{cases}dp_{l,r,0,1}=dp_{l,r,0,1}+dp_{l+1,r-1,i,j}(j\ne 1)\\dp_{l,r,0,2}=dp_{l,r,0,2}+dp_{l+1,r-1,i,j}(j\ne 2)\\dp_{l,r,1,0}=dp_{l,r,1,0}+dp_{l+1,r-1,i,j}(i\ne 1)\\dp_{l,r,2,0}=dp_{l,r,2,0}+dp_{l+1,r-1,i,j}(i\ne 2)\end{cases}\)

對於第二種情況,先遞迴求出 \([l,c_l]\)\([c_l+1,r]\),根據乘法原理,方案就是左邊的方案乘右邊的方案,注意的是兩個序列拼起來的兩點顏色要不一樣,並且可以都不塗色(可能會被當做顏色相同)

\(dp_{l,r,i,j}=dp_{l,r,i,j}+dp_{l,c_l,i,p}\times dp_{c_{l+1},r,q,j}(p\ne q\ or\ p=q=0)\)

這題由於區間的是否合法性(畢竟只有先合法才能轉移),可以用記憶化搜尋,自己考慮往合法的區間遞迴。

/*
 * @Author: Fire_Raku 
 * @Date: 2023-05-21 12:06:46 
 * @Last Modified by: Fire_Raku
 * @Last Modified time: 2023-05-21 12:22:05
 */
#include <bits/stdc++.h>
using namespace std;
const int mod = 1000000007;
int read(){
    int x = 0, f = 1;
    char c = getchar();
    while(c < '0' || c > '9'){
        if(c == '-') f = -1;
        c = getchar();
    }
    while(c >= '0' && c <= '9'){
        x = (x << 1) + (x << 3) + (c - '0');
        c = getchar();
    }
    return x * f;
}
char s[710];
long long c[710], dp[710][710][3][3], ans;
stack<int> st; 
void dfs(int l, int r){
    if(l + 1 == r){
        dp[l][r][0][1] = dp[l][r][1][0] = dp[l][r][2][0] = dp[l][r][0][2] = 1;
        return;
    }
    if(c[l] == r){
        dfs(l + 1, r - 1);
        for(int i = 0; i <= 2; i++){
            for(int j = 0; j <= 2; j++){
                if(j != 1) dp[l][r][0][1] += dp[l + 1][r - 1][i][j], dp[l][r][0][1] %= mod;
                if(j != 2) dp[l][r][0][2] += dp[l + 1][r - 1][i][j], dp[l][r][0][2] %= mod;
                if(i != 1) dp[l][r][1][0] += dp[l + 1][r - 1][i][j], dp[l][r][1][0] %= mod;
                if(i != 2) dp[l][r][2][0] += dp[l + 1][r - 1][i][j], dp[l][r][2][0] %= mod;
            }
        }
        return;
    }
    else{
        dfs(l, c[l]), dfs(c[l] + 1, r);
        for(int i = 0; i <= 2; i++){
            for(int j = 0; j <= 2; j++){
                for(int k = 0; k <= 2; k++){
                    for(int p = 0; p <= 2; p++){ 
                        if((k != p) || (!k && !p)) dp[l][r][i][j] += (dp[l][c[l]][i][k] * dp[c[l] + 1][r][p][j]) % mod;
                    }
                }
            }
        }
        return;
    }
}
int main(){
    cin >> s + 1;
    int n = strlen(s + 1);
    for(int i = 1; i <= n; i++){
        if(s[i] == ')'){
            c[st.top()] = i;
            c[i] = st.top();
            st.pop();
        }
        else st.push(i);
    }
    dfs(1, n);
    for(int i = 0; i <= 2; i++){
        for(int j = 0; j <= 2; j++) ans += dp[1][n][i][j], ans %= mod;
    }
    cout << ans << endl;
    return 0;
}

UVA12991 Game Rooms

求如何放乒乓球桌和游泳池,才能讓樓層的人移動總和最小。狀態裡肯定有當前位置放的是什麼來轉移,並且因為放的數量沒有限制,我們肯定的全放滿最優。然後我們觀察他們移動的距離,如果放的是乒乓球桌,那麼喜歡游泳池的人們,就要走到離他們最近的游泳池,換句話說,就是走出他們所在的極大乒乓球區間,放游泳池同理。所以我們有一個結論,可以把樓層分為一個個連續的兵乓球桌區間和游泳池區間,並且一個區間固定,他裡面的人們移動的總距離也就確定

我們可以預處理出人們移動的距離,\(O(1)\) 計算。因為距離跟走的樓層有關,所以不能用一階字首和表示,用二階字首和,即字首和的字首和。

\(\begin{cases}prep_{i}=prep_{i-1}+pre_i\\prep_{i}=i\times a_1+(i-1)\times a_2+\cdot\cdot\cdot a_i\end{cases}\)

它一階一階的,很像我們要的距離。設區間 \([l,r]\) 的人都要往右走到 \(r+1\),總距離可以表示成 \(dis_{l,r}=prep_r-prep_{l-1}-(r-l+1)\times pre_{l-1}\)。相當於減去了 \([1,l-1]\) 中算的 \(a_1-a_{l-1}\)\([l,r]\) 中算的 \(a_1-a_{l-1}\)

顯然,如果區間 \([l,r]\) 兩邊都可以走人,那麼有 \(mid=(l+r)>>1\)\([l,mid]\) 走左邊, \([mid+1,r]\) 走右邊。

有了這個,我們還需要狀態。我們需要知道同色區間,但是現在兩端都不知道在哪。這題裡我們要轉移,就必須要知道某一段全是同一種的區間。而如果我們用區間 dp,很難去表示它(因為 \(dp_{l,r}\) 表示全是同一種顏色的話,最後 \(dp_{1,n}\) 是啥呢;如果不是同一種,那麼我們列舉這段區間有什麼用呢),最簡單的,區間 dp 複雜度 \(O(n^3)\),透過不了本題。所以我們考慮固定右端點,去列舉它的左端點,它們之間是同色段。設狀態 \(dp_{i,0/1}\) 表示已經考慮完前 \(i\) 個,第 \(i\) 個放乒乓球桌/游泳池的最小總距離,轉移跟上面說的一樣。

\(\begin{cases}dp_{i,0}=\min_{0\le j < i}(dp_{j,1}+cost(j+1,i,0))\\dp_{i,1}=\min_{0\le j < i}(dp_{j,0}+cost(j+1,i,1))\end{cases}\)

\(cost(l,r,co)\) 表示區間 \([l,r]\)\(co\) 色的代價。這樣,我們既可以考慮到同色段,也考慮到了同色段之外的段的貢獻,並且我們固定了右端點,就不需要考慮右端點右邊的代價(因為不管右邊怎麼變,左邊的代價還是隻跟左邊有關)。最後答案是 \(\min(dp_{n,0},dp_{n,1})\)

#include<bits/stdc++.h>
using namespace std;
int T, n, cas;
long long pre[4010][2], suf[4010][2], prep[4010][2], suff[4010][2], t[4010], p[4010], dp[4010][2];
void init(){
	memset(pre, 0, sizeof(pre));
	memset(suf, 0, sizeof(suf));
	memset(prep, 0, sizeof(prep));
	memset(suff, 0, sizeof(suff));
} 
long long cost(int l, int r, int co){
	if(l == 1 && r == n) return 0x3f3f3f3f3f3f3f; 
	if(l == 1) return prep[r][co];
	if(r == n) return suff[l][co];
	int mid = (l + r) >> 1;
	long long tot = 0;
	tot += suff[l][co] - suff[mid + 1][co] - (mid - l + 1) * suf[mid + 1][co];
	tot += prep[r][co] - prep[mid][co] - (r - mid) * pre[mid][co];
	return tot;
}
int main(){
	cin >> T;
	for(int k = 1; k <= T; k++){
		init();
		cas++;
		cin >> n;
		for(int i = 1; i <= n; i++){
			cin >> t[i] >> p[i];
			pre[i][0] = pre[i - 1][0] + t[i];
			pre[i][1] = pre[i - 1][1] + p[i];
			prep[i][0] = prep[i - 1][0] + pre[i][0];
			prep[i][1] = prep[i - 1][1] + pre[i][1];
		}
		for(int i = n; i >= 1; i--){
			suf[i][0] = suf[i + 1][0] + t[i];
			suf[i][1] = suf[i + 1][1] + p[i];
			suff[i][0] = suff[i + 1][0] + suf[i][0];
			suff[i][1] = suff[i + 1][1] + suf[i][1];
		}
		for(int i = 1; i <= n; i++) dp[i][0] = dp[i][1] = 0x3f3f3f3f3f3f3f;
		dp[0][0] = dp[0][1] = 0;
		for(int i = 1; i <= n; i++){
			for(int j = 0; j < i; j++){
				dp[i][0] = min(dp[i][0], dp[j][1] + cost(j + 1, i, 0));
				dp[i][1] = min(dp[i][1], dp[j][0] + cost(j + 1, i, 1));
			}
		}
		cout << "Case #" << cas << ": " << min(dp[n][0], dp[n][1]) << endl;
	}
	return 0;
}
``

### [P3959 [NOIP2017 提高組] 寶藏](https://www.luogu.com.cn/problem/P3959)

簡化題意,要在原圖上找一顆生成樹覆蓋所有邊,並且每個點連它的父親的長度乘上到根的距離的和最短。

看到資料範圍,猜測它是狀壓 dp。考慮當前連了多少點,並且由於轉移跟點到根的距離有關,我們可能會設 $dp_{i,j}$ 表示以 $i$ 為根,狀態為 $j$ 的最小代價。但是我們仔細想會發現這樣還是不好轉移(因為根不變,只有狀態還是不能轉移,並且我們還是不知道距離,並且,節點狀態為本身時,它就是根節點,而且根節點不能轉移,一看就是多餘的)。

我們再觀察樹,因為是一顆樹,所以點到根的距離其實就是它的深度,把樹整理好它會是一層一層的,並且如果要到更遠的點,前面一定會先走更近的點。樹中有一個東西和距離很像,樹高。它相當於表示目前最遠的一些點離根的距離,或許可以用它來轉移,因為**當前的樹高確定下來,只需要知道最後一層的節點,就可以知道代價,各個樹高之間是可以轉移的**。

設狀態 $dp_{i,j}$ 表示當前樹高為 $i$,已選節點狀態為 $j$ 時的最大價值。我們一層一層來轉移狀態,要算 $dp_{i,j}$,我們只需要再列舉樹高 $i-1$ 時的狀態 $k$,也就是 $j$ 的子集,轉移就是

$$dp_{i,j}=\min_{k\in j}(dp_{i-1,k}+cost_{k,j}\times (i-1))$$

$cost_{k,j}$ 表示從狀態 $k$ 到狀態 $j$ 的最小代價。這個是可以預處理出來的,因為我們從上一層到下一層,有且僅會經過一條邊,所以這裡的狀態 $k$ 到狀態 $j$,中間是不會經過其他點的,所以從 $k$ 變成 $j$ 需要的點一定只需要透過 $k$ 中其中一個點的一條邊就能連線。所以總最小代價就是**每個需要的點連線到狀態 $k$ 裡的最小代價之和**。兩個不能轉移的狀態直接賦值成極大值。

到這裡,問題是,**我們列舉的子集 $k$ 與原集合 $j$ 產生的差集一定會在第 $i$ 層嗎**?好像不能確定,如果是這樣那麼這麼轉移不會是錯解嗎?但是我們可以證明這是不會影響答案的,也就是差集不管在不在第 $i$ 層,最終答案都不會是由錯誤的計算更新的。

證:設 $p$ 為 $j$ 和 $k$ 的差集,假設 $p$ 中的有一些點不是由 $k$ 中的最大深度的點連線,那麼一定存在一個集合 $q$,為集合 $k$ 再加上這些不是由 $k$ 的最大深度的點連線的點,**從這個狀態 $dp_{i-1,q}$ 轉移一定會覆蓋掉 $dp_{i-1,k}$ 這樣的不優解**(因為不在最深層的邊你也按最深層的代價計算了,結果肯定會被 $q$ 這樣把算錯的點包含起來的狀態(相當於 $p$ 改錯好的狀態)覆蓋),證畢。

```cpp
#include<bits/stdc++.h>
using namespace std;
int n, m;
long long ans = 0x3f3f3f3f3f3f3f3f;
long long dp[15][1 << 14], f[15][15], po[15], cost[1 << 13][1 << 13];
int main(){
	cin >> n >> m;
	memset(f, 0x3f, sizeof(f));
	for(int i = 1; i <= m; i++){
		int u, v;
		long long w;
		cin >> u >> v >> w;
		f[u][v] = f[v][u] = min(f[u][v], w);
	}
	po[0] = 1;
	for(int i = 1; i <= n; i++){
		po[i] = po[i - 1] * 2;
	}
	for(int i = 1; i < (1 << n); i++){
		for(int j = i; j; j = (j - 1) & i){
			bool flg = 0;
			int now = i ^ j;
			for(int p = n; p >= 1; p--){
				long long minn = 0x3f3f3f3f3f3f3f3f;
				if((po[p - 1] & now) == po[p - 1]){
					for(int q = 1; q <= n; q++){
						if((po[q - 1] & j) == po[q - 1]){
							minn = min(minn, f[p][q]);
						}
					}
					if(minn == 0x3f3f3f3f3f3f3f3f){
						flg = 1;
						break;
					}
					cost[j][i] += minn;
				}
			}
			if(flg){
				cost[j][i] = 0x3f3f3f3f3f3f3f3f;
			}
		}
	}
	memset(dp, 0x3f, sizeof(dp));
	for(int i = 1; i <= n; i++) dp[1][1 << (i - 1)] = 0;
	for(int k = 2; k <= n; k++){
		for(int i = 1; i < (1 << n); i++){
			for(int j = i; j; j = (j - 1) & i){
				if(cost[j][i] == 0x3f3f3f3f3f3f3f3f) continue;
				dp[k][i] = min(dp[k][i], dp[k - 1][j] + cost[j][i] * (k - 1));
			}
		}
	}
	for(int i = 1; i <= n; i++) ans = min(ans, dp[i][(1 << n) - 1]);
	cout << ans << endl;
	return 0;
}

Generate a String

和入門動態規劃時講的題很像,但是它多了一種步驟,可以刪除字元,並且我們分析發現,這種做法並不是無用的。

如果我們按照之前的狀態 \(dp_i\) 表示生成前 \(i\) 字元的最小代價,轉移就是

\(\begin{cases}dp_i=\min(dp_{i-1},dp_{i+1})\\dp_i=\min(dp_i,dp_{i/2})\ (i\bmod 2=0)\end{cases}\)

這樣的 \(dp\) 是沒辦法遞推的,因為更新 \(dp_i\)\(dp_{i+1}\) 還沒更新。一種思路是把狀態抽象成一個點,去跑最短路,但是透過不了。

我們考慮*為什麼會有刪除操作,因為前面翻倍之後超過了 \(i\),要刪去,所以刪除操作還可以表示成 \(dp_i=\min_{2\times k\ge i}(dp_k+y+(2\times k - i)\times x)\),轉化一下就變成 \(dp_i=\min_{2\times k\ge i}(dp_k+2\times k\times x)+y-i\times x\) 容易發現 \(\min\) 裡面的值是可以用單調佇列維護的,佇列中的值對應在序列裡也是單調的。

在思考,發現刪除操作最多隻會連續做一次,因為如果超過兩次,為什麼不在翻倍前就刪呢,翻倍了刪的數也翻倍,一定不優。所以直接把 \(dp_{i+1}\) 的狀態再往前推,是 \(dp_{(i+1)/2}\),這樣第三行轉移就可以寫成 \(dp_i=\min(dp_i,dp_{(i+1)/2})\ (i\bmod 2=1)\)

Games with Rectangle

\(n\times m\) 的網格里選 \(k\) 個矩形,要求矩形一個套一個,不能重疊,求不同的方案數。

首先資料範圍是不允許超過 \(O(n^2)\) 的,所以也就不能同時列舉三個值或者列舉端點。

根據乘法原理,總方案數等於橫列合法方案數乘上縱列合法方案數,所以我們分別求出兩個的方案數再相乘就是答案。每次轉移跟層數和每次的長度有關,並且我們可以把 \(n\)\(m\) 作為最後的長度,即題目給出的網格是第 \(k+1\) 個矩形。所以設狀態 \(dp_{i,j}\) 表示已經從內到外考慮到第 \(i\) 層,第 \(i\) 層的長度為 \(j\) 的合法方案數,轉移是

\(dp_{i,j}=\sum\limits_{1\le k\le j-2}dp_{i-1,k}\times (j-k+1)\)

但是即使這樣複雜度還是 \(O(n^2k)\),考慮最佳化。空間上容易發現 \(i\) 這一維可以降維掉。後面的式子很有規律,隨著 \(k\) 的變化,乘的數不斷變大,似乎 \(dp_{i,j}\)\(dp_{i,j-1}\) 轉移之後區別不大

\(dp_{i,j}=\sum\limits_{1\le k\le j-2}dp_{i-1,k}\times (j-k+1)\)

\(dp_{i,j-1}=\sum\limits_{1\le k\le j-3}dp_{i-1,k}\times (j-k)\)

只是列舉的 \(k\) 多了一,列出式子發現之間就少了 \(\sum\limits_{1\le k \le j-2}dp_{i-1,k}\),這部分明顯可以用字首和維護。轉移可以寫成

\(dp_{i,j}=dp_{i,j-1}+\sum\limits_{1\le k \le j-2}dp_{i-1,k}\)

複雜度降到 \(O(n^2)\),初始化為 \(dp_{1,i}=1\) 答案為 \(dp_{k+1,n}\times dp_{k+1,m}\)\(k+1\) 具體在程式碼中表現為迴圈 \(k\)

仔細思考後,發現這題既然用乘法原理簡化到求橫縱座標各自的方案,其實就是在橫縱座標中各取 \(2\times k\) 個點的方案數,為 \(C^{2\times k}_{n-1}\)\(C^{2\times k}_{m-1}\) ,兩個相乘就是答案。為什麼不用考慮合不合法呢,因為對於每種組合,我們每次都取最大最小橫座標和最大最小縱座標作為下一步,重複這個操作,最後一定是合法的。

CF837D Round Subset

\(n\) 個數中選 \(k\) 個數,使得相乘後末尾的 \(0\) 最多。容易發現,末尾的 \(0\)\(2\times5\) 得到。所以末尾 \(0\) 的個數就是 \(k\) 個數中 \(2\) 的總個數和 \(5\) 的總個數的最小值。每個數我們都可以預處理出 \(2\)\(5\) 的個數。

這是一個選數問題,基本的狀態 \(dp_{i,j}\) 表示前 \(i\) 個數選了 \(j\) 個,因為同時要考慮到 \(2\)\(5\) 的個數,我們可以借鑑 P2340 [USACO03FALL]Cow Exhibition G 一題中的思想,把 \(2\) 抽象成重量,求 \(5\) 最多為多少。所以狀態可以再加一維,\(dp_{i,j,k}\) 表示前 \(i\) 個數選了 \(j\) 個,\(2\) 的個數為 \(j\) 個時,\(5\) 最多為多少個。由於 \(j\) 並不是虛值,所以我們要初始化 \(dp_{0,0,0}=0\)

\(dp_{i,j,k}=\max(dp_{i-1,j,k},dp_{i-1,j-1,k-b_i}+c_i)\)

\(b_i\) 表示 \(2\) 的個數,\(c_i\) 表示 \(5\) 的個數。可以發現 \(i\) 一維是可以用滾動陣列的。注意的是,用滾動陣列,不能少轉移,比如 \(k<b_i\) 時,也要寫 \(dp_{i,j,k}=dp_{i-1,j,k}\),不能直接去掉。統計答案時,我們列舉 \(2\) 的個數,\(ans=\max(ans,\min(i,dp_{n,k,i}))\),為 \(2\) 的個數與 \(5\) 的個數的最小值。

CF14D Two Paths

求樹中兩條鏈,鏈不相交,使得兩條鏈的長度相乘乘積最大。如果這題不是求乘積,其實就是求樹的直徑,設狀態 \(dp_u\) 表示以 \(u\) 為根的最長鏈。

\(\begin{cases}ans=\max(ans,dp_u+dp_v+1)\\dp_u=\max(dp_u,dp_v+1)\end{cases}\)

而思考不相交的兩條鏈,有的性質是,他們至少能用一條邊使得它們相連,也就是說,它們之間隔著至少一條邊

那思路就有了,我們列舉他們之間隔著的邊,這棵樹就被分成了兩棵樹,分別在兩棵樹裡面求樹的直徑,兩個相乘就是至少隔著這條邊時的最大值。依次更新 \(ans\) 就可以了。

CF527D Clique Problem

雖然這題的優解是貪心而不是 \(dp\),但是 \(dp\) 的角度也是很精彩的。這道題的突破口是 \(abs(x_i-x_j)\ge w_i+w_j\)。如果單純的不加最佳化,這樣是必須要列舉兩個數的,但是我們假設只列舉一個數 \(x_i\),並且 \(x_i\ge x_j\),式子就可以變成 \(-x_j-w_j\ge w_i-x_i\),這裡是把下標相同放一邊,也就是 \(x_j+w_j\le x_i-w_i\)。所以對答案有影響的就是每個點的 \(x_i+w_i\)\(x_i-w_i\)。我們列舉到一個 \(i\),就可以用 \(x_i-w_i\) 這個條件求出滿足的點,具體操作就是把記錄 \(x_i+w_i\) 的陣列排序,二分查詢第一個大於 \(x_i-w_i\) 的下標 \(pos\)\(pos-1\) 都是合法的。

解決完這個,我們考慮狀態,如果是 \(dp_i\) 表示前 \(i\) 個點的話,可我們上面篩出來的點並不是在 \([1,i)\) 之內的,並且我們要上面篩的點的 \(dp\) 值,如果是無序的不好找。所以狀態需要順序,設狀態 \(dp_i\) 為第 \(i\) 大的 \(x_i+w_i\) 構成的最大團點數。這時候更新是有順序的,需要轉移的狀態是在一起的,並且二分查詢可以去重了,我們要的是滿足條件的狀態,而不是點數,所以可以用到線段樹最佳化維護 \(dp_i\),二分查詢找到第 \(i\) 大對應的點,單點修改,查詢區間最大值,\(dp_i=\max(maxn+1)\)\(maxn\) 表示篩出的點中的最大 \(dp\) 值。複雜度為 \(O(nlogn)\)

貪心的方法就是轉換條件,可以發現 \(x_i+w_i\)\(x_i-w_i\) 對映在數軸上就是一段區間,把 \(l_i=x_i+w_i\)\(r_i=x_i-w_i\)。滿足 \(r_i\ge l_j\) 的兩點是有邊的,那麼題目就轉換成,在數軸上取最多線段,每條線段不相互覆蓋。直接貪心,從 \(r\) 最小的線段開始取,能取就取。複雜度為 \(O(nlogn)\),常數比 \(dp\) 小。

P4310 絕世好題

選數問題,通常從左往右考慮,這題的限制不多,只需要狀態 \(dp_i\) 表示考慮前 \(i\) 個,並且選了第 \(i\) 個的最長長度。這題唯一給出的題目限制是序列 \(b\)\(b_i\&b_{i-1}\ne0\)。 樸素的,如果不加最佳化,轉移是

\(dp_i=\max_{1\le j<i}(dp_i,dp_j+1)(a_i\&a_j\ne0)\)

這是很容易想到的,瓶頸是複雜度是 \(O(n^2)\) 的。我們考慮什麼樣的 \(j\)\(i\) 是有貢獻的。由於是 \(\&\),所以能不能轉移,跟二進位制中的 \(1\) 有關,容易發現,在二進位制中,如果 \(a_j\)\(a_i\) 在同一位上都有 \(1\),這樣的 \(j\) 是有貢獻的。所以就有一個想法,二進位制的每一位決定了你之後對什麼樣的數有貢獻,我們可以對二進位制的每一位都建立一個優先佇列,每列舉到一個數都進行二進位制拆分,拆到 \(1\)說明它在這一位上對之後的 \(a_i\) 是有貢獻的,把它存到這一位的優先佇列裡,之後如果有一個 \(a_i\) 的二進位制某一位是 \(1\),直接從這一位的優先佇列中取出最大值更新就可以了。

在這個最佳化裡,我們最佳化了列舉 \(j\) 的時間,換成了常數小的二進位制拆分,又因為我們只關心有貢獻的每一位上的最大值,所以用到了優先佇列,最佳化了在同一位上有貢獻的數多餘的更新。

複雜度約為 \(O(nlogn)\)

P4158 [SCOI2009]粉刷匠

給定 \(n\)\(m\) 格的木板,每次可以給一個區間塗色,要求最多塗 \(t\) 次正確塗色的最多格子數。觀察題目,不同木板之間是不會相互影響的,

所以我們可以很快求出一塊木板塗色幾次的最多正確格子數,設狀態 \(f_{i,j,k}\) 表示第 \(i\) 塊木板塗到第 \(j\) 格,期間塗了 \(k\) 次的最多正確格子數,由於此題對區間長度沒有要求,並且塗長了不會影響答案,我們可以直接預設塗的每個區間都是緊密貼合的,也就是不留不塗色的,這樣狀態也可以很快就列出來

\(f_{i,j,k}=\max_{1\le p\le j}(f_{i,j,k},f_{i,p-1,k-1}+\max(sum0_{i,j}-sum0_{i,p-1},sum1_{i,j}-sum1_{i,p-1}))\)

\(sum_{i,j}\) 表示我們預處理的每塊木板 \(1\)\(0\) 的字首和,列舉最近的區間左端點 \(p\),在區間 \([p,j]\) 我們塗 \(1\)\(0\) 中最多的數字,這樣是最優的。

於是我們就知道了每塊木板各種情況下的貢獻,不用擔心木板內部的情況,我們現在只需要考慮怎麼分配塗色次數。按順序塗色,把每塊木板抽象成一個物品,我們投入的次數越多,貢獻越大,所以我們從上到下考慮,設狀態 \(dp_{i,j}\) 表示前 \(i\) 塊木板最多塗了 \(j\) 次的最多正確格子數,每次列舉這次要在第 \(i\) 塊木板上塗 \(k\) 次,那麼狀態就是從 \(dp_{i-1,j-k}\) 上轉移而來。

\(dp_{i,j,k}=\max_{0\le k\le min(m,t)}(dp_{i-1,j},dp_{i-1,j-k}+f_{i,m,k})\)

答案 \(ans=\max_{0\le i\le t}(dp_{n,i})\)。題目中有些地方不用跑 \(t\) 次,原因是每塊木板最多 \(50\) 格,所以只需要知道塗色次數與格數的最小值之內的狀態就行了,

不然複雜度肯定過不了,還有要注意左端點是可以和右端點重合的,也就是隻塗一格,一塊木板也可以一次都不塗,也要轉移。

P1772 [ZJOI2006] 物流運輸

\(n\) 天,每天要從 \(1\) 送到 \(n\),這期間某些天某些點會損壞,更換路線要額外加錢,求 \(n\) 天所花的最少花費。一看到這題,就直接想到 \(dp_{i,j}\) 表示前 \(i\) 天換了 \(j\) 次,但是這樣列不出方程,因為我們不能決定要走哪條路,事實上當前的最短路並不是最優的,有可能這次最短但是導致要給換路價錢。

思考之前的狀態有什麼缺點,這道題並沒有限制我們的更換次數,我們記錄它並沒有用,我們記錄的初衷可能是想表示這次換不換,但是知道這一次換和不換不能顧及到整個局面,況且你不換也不一定就是你上次走的最短路。

我們重點要解決決定路線的問題,觀察條件,更換路線要加錢,並且每天都得送貨,這使得這 \(n\) 天被分為了若干個連續的區間,也可以觀察到並不是每一段連續的天數都可以走同一條路的(有的時候必須換路),而且每個區間裡走的是同一條路,並且一定是最短路。這是一個很特別的性質,這樣就有了一個結論,只要當前區間確定下來,並且是合法的(即可以走同一條路),你就可以知道價錢。所以這題就變成了一個分段問題,我們可以固定右端點,列舉左端點來確定一個最近的區間,並且預設左端點前的路線不同,固定這個區間走同一條路,跑這些天裡的最短路,就是價錢。

狀態只需要一維,即 \(dp_i\) 表示前 \(i\) 天的最短價錢,我們不關心換了幾次,每次換了就直接實時加換路錢就行了。

\(dp_i=\min_{1\le j\le i}(dp_i,dp_{j-1}+(i-j+1)\times cost+k)\)

由於只需要單源最短路,所以直接跑 \(spfa\)\(dijkstra\) 都可以。注意的是 \(j\) 可以倒序列舉,因為後面不能走的點,前面還是不能走,如果當前 \([j,i]\) 已經不能用同一條路了,那前面也不行,直接不列舉了。初始化 \(dp_0=-k\),因為第一次換路線不用錢,更新時遇到這個狀態是會直接把 \(k\) 抵消掉,相當於沒花錢。

為什麼可以預設前面的 \(j-1\) 路線不同呢? 我們想一下如果 \(j-1\) 路線一樣會怎麼樣,這時當我們往左就會列舉到真正意義上的斷點時(也就是不能走之前的路,換了一條路時),之前的在 \(j\) 時的左端點一定不優(因為相同路線換成相同路線還要花錢),並且一定會被真斷點給覆蓋掉。錯解一定會被覆蓋,不會影響答案。

#include <bits/stdc++.h>
using namespace std;
int read(){
    int x = 0, f = 1;
    char c = getchar();
    while(c < '0' || c > '9'){
        if(c == '-') f = -1;
        c = getchar();
    }
    while(c >= '0' && c <= '9'){
        x = (x << 1) + (x << 3) + (c - '0');
        c = getchar();
    }
    return x * f;
}
int n, m, k, e, cnt;
bool vis[300][1100], flg[300], inq[300];
int dp[1100], h[1100], dis[300];
struct node{
    int to, nxt, w;
}ed[4100];
void add(int u, int v, int w){
    ed[++cnt].to = v;
    ed[cnt].nxt = h[u];
    ed[cnt].w = w;
    h[u] = cnt;
}
int spfa(){
    queue<int> q;
    for(int i = 1; i <= m; i++) dis[i] = 0x3f3f3f3f, inq[i] = 0;
    inq[1] = 1;
    dis[1] = 0;
    q.push(1);
    while(!q.empty()){
        int u = q.front();
        q.pop();
        inq[u] = 0;
        for(int i = h[u]; i; i = ed[i].nxt){
            int v = ed[i].to;
            if(flg[v]) continue;
            if(dis[v] > dis[u] + ed[i].w){
                dis[v] = dis[u] + ed[i].w;
                if(!inq[v]){
                    inq[v] = 1;
                    q.push(v);
                }
            }
        }
    }
    return dis[m];
}
int main(){
    n = read(), m = read(), k = read(), e = read();
    for(int i = 1; i <= e; i++){
        int u = read(), v = read(), w = read();
        add(u, v, w), add(v, u, w);
    }
    int d = read();
    for(int i = 1; i <= d; i++){
        int num = read(), a = read(), b = read();
        for(int j = a; j <= b; j++){
            vis[num][j] = 1;
        }
    }
    memset(dp, 0x3f, sizeof(dp));
    dp[0] = -k;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++) flg[j] = 0;
        for(int j = i; j >= 1; j--){
            for(int k = 1; k <= m; k++) if(vis[k][j]) flg[k] = 1;
            int res = spfa();
            if(res == 0x3f3f3f3f) break;
            dp[i] = min(dp[i], dp[j - 1] + (i - j + 1) * res + k);
        }
    }
    cout << dp[n] << endl;
    return 0;
}

P3861 拆分

要求把 \(n\) 拆成一些不小於 \(2\) 的互不相同的數的乘積的方案數。看到資料範圍 \(n\le 10^{12}\),肯定要先縮小範圍。容易發現,\(n\) 只可能由它的因數相乘得來,每個數都是如此。所以 \(n\) 的轉移只與 \(n\) 的因數有關,我們可以用 \(O(\sqrt{n})\) 的方法把因數篩出來,並且因數很少,\(d(10^{12})\le 6720\)

由於 \(n\) 就是透過這些數得到,並且不需要合成,只需要把數選出來就行了,更不需要考慮選出的數有沒有互不相同的問題了(每個數獨一無二,只選一次)。

題目就轉化成了,在這些因數中選一些數,組成 \(n\) 的方案數有多少。這不就是揹包問題嗎,只是這裡選出的物品變成了相乘。先把因數從小到大排序,因為大數由小數得到。

因為 \(n\) 只可能由它的因數相乘得來,其他都是多餘的。如果兩個因數相乘不是因數,那就是沒有用的,因為它一定拼不出 \(n\)即我們要列舉的重量只可能是 \(n\) 的因數。所以,我們最終只關心因數的拆分方案,可以設狀態 \(dp_{i,j}\) 表示前 \(i\) 個數選出若干個數,表示出 \(p_j\) 的方案數,省去了很多空間和時間。

\(\begin{cases}dp_{i,j}=dp_{i-1,j}\\dp_{i,j}=dp_{i,j}+dp_{i-1,k}(p_j\bmod p_i=0)\end{cases}\)

這裡的 \(k=pos1_{p_j/p_i}\ /\ pos2_{n/p_j/p_i}\),其中 \(p_j/p_i\),必須是整除,如果是整除,那麼這個數一定在原來的因數中(\(a\mid b\)\(b\mid c\),則 \(a\mid c\))。\(pos1_i\) 是我們預處理的每個數的位置,對於大於 \(\sqrt{n}\) 的數,由於一定有一個小於 \(\sqrt{n}\) 的數與之對應,所以記 \(pos2_{p_i}=n-i+1\),空間複雜度降到 \(O(\sqrt{n})\)。這裡的轉移很基礎,就是分為選/不選第 \(i\) 個數,選了就是從 \(k\) 上轉移。

注意,這裡求的是方案數,在不能降維的情況下,一定不能漏轉移,也就是 \(j\) 必須從 \(1\)\(tot\) 不能遺漏轉移 \(dp_{i,j}=dp_{i-1,j}\),因為這對於所有情況都成立,其他情況在 \(j<i\) 時可以跳過。

複雜度約為 \(O(T(\sqrt{n}+d(n)^2))\)

#include<bits/stdc++.h>
using namespace std;
long long t, n, mod = 998244353, cnt;
long long dp[8010][8010], p[8010];
int pos1[1000010], pos2[1000010];
int main(){
	cin >> t;
	while(t--){
		cnt = 0;
		cin >> n;
		long long sqrn = sqrt(n);
		for(long long i = 1; i * i <= n; i++){
			if(n % i == 0){
				p[++cnt] = i;
				if(n / i != i) p[++cnt] = n / i;
			}
		}
		sort(p + 1, p + cnt + 1);
		for(int i = 1; i * 2 <= cnt + 1; i++){
			pos1[p[i]] = i;
			pos2[p[i]] = cnt - i + 1;
		}
		memset(dp, 0, sizeof(dp));
		dp[1][1] = 1;
		for(int i = 2; i <= cnt; i++){
			for(int j = cnt; j >= 1; j--){
				dp[i][j] = dp[i - 1][j];
				if(j < i) continue;
				if(p[j] % p[i] == 0){
					long long now = p[j] / p[i];
					int k = (now <= sqrn) ? pos1[now] : pos2[n / now];
					dp[i][j] = (dp[i][j] + dp[i - 1][k]) % mod;
				}
			}
		}
		cout << dp[cnt][cnt] - 1 << endl;
	}
	return 0;
}