《算法竞赛》第 5 章 动态规划

1. DP概念和编程方法

1.1 DP的概念

DP是求解多阶段决策问题最优化的一种算法思想。

解决的问题具有两个特征。

重叠子问题:子问题会重叠,即多个不同问题会用到相同子问题

最优子结构:大问题的最优解由小问题最优解得来,但小问题最优解求解过程与大问题无关。

1.2 DP的两种编程方法

自顶向下(先大问题,再小问题),即递归记忆化搜索。

自底向上(先小问题,再大问题),即递推。

自底向上的优点是编码更直接。

1.3 DP的设计和实现

刷题。

1.4 滚动数组

滚动数组可以减少空间复杂度,但也会损失DP数组存储的信息。

如 01 背包使用滚动数组优化后,无法输出具体方案。

有的DP状态已经是最小空间复杂度,无法再使用滚动数组优化。

两种实现滚动数组的方式。

交替滚动:用 \(dp[0][]\) 和 \(dp[1][]\) 交替滚动

例如01背包

int now=0,old=1;
for(int i=1;i<=n;i++){
    swap(now,old);//交替滚动
    for(int j=0;j<=c;j++){
        if(v[i]>j) dp[now][j]=dp[old][j];
        else dp[now][j]=max(dp[old][j],dp[old][j-v[i]]+w[i]);
    }
}

自我滚动:自我覆盖

例如01背包

for(int i=1;i<=n;i++){
    for(int j=c;j>=v[i];j--){
        dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
    }
}

2. 线性DP

一些经典线性DP问题。

1. 分组背包

有一些物品,分为 n 组,第 i 组第 k 个物品体积为 \(c[i][k]\),价值为 \(w[i][k]\)。每组最多选一个,给定容量为 C 的背包,如何选使总价值最大。

P1757 通天之分组背包 - 洛谷

状态:\(dp[i][j]\),前 i 组物品装容量 j 的背包获得最大价值。

状态转移方程:\(dp[i][j]=max(dp[i-1][j],dp[i-1][j-c[i][k]]+w[i][k])\)

对于 \(dp[i][j]\) 只使用到 \(dp[i-1][]\),所以可使用滚动数组优化。

即状态转移方程:\(dp[j]=max(dp[j],dp[j-c[i][k]]+w[i][k])\)

for(int i=1;i<=zu;i++)//组数
    for(int j=m;j>=0;j--)//体积
        for(int k=1;k<=a[i];k++)//第i组物品数
            if(j>=v[i][k])
                dp[j]=max(dp[j],dp[j-v[i][k]]+w[i][k]);

2. 多重背包

给定 n 种物品和一个背包,第 i 种物品的体积为 \(c_i\),价值为 \(w_i\),并且有 \(m_i\) 个,背包总容量为 C。使背包总价值最大。

P1776 宝物筛选 - 洛谷

4 种解法。

第一种,转换为01背包,把每种的背包都看作单独物品。

时间复杂度为\(O(C\sum _{i=1}^n m_i)\)。

cin>>n>>C;
for(int i=1;i<=n;i++){
    int a,b,c;cin>>a>>b>>c;
    while(c--) w.push_back(a),v.push_back(b);
}
for(int i=0;i<w.size();i++){
    for(int j=C;j>=v[i];j--){
        dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
    }
}
cout<<dp[C]<<"\n";

第二种,转换为分组背包,时间复杂度也为\(O(C\sum _{i=1}^n m_i)\)。

cin>>n>>C;
for(int i=1;i<=n;i++) cin>>w[i]>>c[i]>>m[i];
for(int i=1;i<=n;i++){
    for(int j=C;j>=0;j--){
        for(int k=1;k<=m[i];k++){
            if(k*c[i]>j) break;
            dp[j]=max(dp[j],dp[j-k*c[i]]+k*w[i]);
        }
    }
}
cout<<dp[C];

第三种,二进制拆分优化

若第 i 种物品有 m 个,放进背包的方案有 m+1 种,组合出这 m+1 种并不需要m个物品。根据二进制的原理,任何一个十进制数 X 都可以使用 2 的不同幂次方相加得到,这些幂次方个数只有 \(log_2 X\) 个。所以第 i 种物品的 m 个物品,可以使用 \(log_2 m\) 个表示。复杂度从 \(O(C \sum_{i=1}^n m_i)\) 优化到 \(O(C \sum_{i=1}^n log_2 ~ m_i)\) 。

注意拆分的具体实现,不能全部拆成 2 的倍数,而是先按 2 的幂次方从小到大拆,最后一个小于或等于最大倍数的余数。这样能保证拆出的数相加在 \([1,m_i]\) 范围内,不会大于 \(m_i\) 。

例如,\(m_i=25\),把它拆成 \(1+2+4+8+10\),最后余数 10,可保证这拆分数组合不会超过 25。

int n,C,dp[N];
int w[N],c[N],m[N];
int new_n;//二进制拆分后的新物品总数量
int new_w[N],new_c[N],new_m[N];//二进制拆分后的新物品
void solve(){
	cin>>n>>C;
	for(int i=1;i<=n;i++) cin>>w[i]>>c[i]>>m[i];
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m[i];j<<=1){//二进制枚举
			m[i]-=j;//减去已拆分的
			new_c[++new_n]=j*c[i];//新物品
			new_w[new_n]=j*w[i];
		}
		if(m[i]){
			new_c[++new_n]=m[i]*c[i];
			new_w[new_n]=m[i]*w[i];
		}
	}
	//下面是滚动数组的 01背包
	for(int i=1;i<=new_n;i++)
		for(int j=C;j>=new_c[i];j--)
			dp[j]=max(dp[j],dp[j-new_c[i]]+new_w[i]);
	cout<<dp[C]<<"\n";
}

二进制拆分可以看作多重背包问题的标准解法,下面是最优解法。

第四种,单调队列优化

原理后续DP优化再补。时间复杂度为 \(O(nC)\) ,是最优的解法。

int n,C,dp[N],ans,tmp;
int f[N],que[N],num[N];
void solve(){
	cin>>n>>C;
	for(int i=1;i<=n;i++){
		int v,w,m;
		cin>>w>>v>>m;
		if(v==0){ans+=m*w;continue;}//体积为0,避免除数为0的情况
		int can_use=min(m,C/v);//can_use表示最大可用数量
		for(int j=0;j<v;j++){//枚举每一个余数
			//注意这要保证转移到所有可以转移点
			int all=(C-j)/v,head=1,tail=0;//找到区间内这个余数下有机会转移到的所有点
			//每次重置队列
			for(int k=0;k<=all;k++){//转移数量从0到all不等
				int push_in=f[j+k*v]-k*w;
				while(head<=tail&&push_in>=que[tail]) tail--;//维护队列取最大
				tail++;
				que[tail]=push_in;
				num[tail]=k;
				while(head<=tail&&num[head]+can_use<k) head++;//无法实现转移的话
				f[j+k*v]=max(f[j+k*v],que[head]+k*w);
				tmp=max(tmp,f[j+k*v]);
			}
		}
	}
	cout<<tmp+ans<<"\n";
}

3. 最长公共子序列(LCS)

给定两个序列 X 和 Y ,找出 X 和 Y 的一个最长公共子序列。

状态:\(dp[i][j]=序列X在第i位置序列Y在第j位置的最长公共子序列\)

状态转移方程: \(dp[i][j]=dp[i-1][j-1]+1,~~X[i]==Y[i];\\ dp[i][j]=max(dp[i-1][j],dp[i][j-1]),~X[i]!=Y[j];\) 时空复杂度都为 \(O(n^2)\)。

P1439 最长公共子序列 - 洛谷

P1439这题序列长度为1e5,正常复杂度过不了,但是多了排列的限制条件,就可以将此题转化为LIS。

int a[N],b[N];
void solve(){
	int n,len=0;cin>>n;
	map<int,int> mp;
	for(int i=1;i<=n;i++){
		int x;cin>>x;
		mp[x]=i;
	}
	for(int i=1;i<=n;i++){
		int x;cin>>x;
		a[i]=mp[x];
	}
	for(int i=1;i<=n;i++){
		if(b[len]<=a[i]) b[++len]=a[i];
		else{
			int x=upper_bound(b+1,b+len+1,a[i])-b;
			b[x]=a[i];
		}
	}
	cout<<len<<"\n";
}

Problem - 1159

基础的LCS

int dp[N][N];
void solve(){
	string s1,s2;cin>>s1>>s2;
	int n=s1.size(),m=s2.size();
	s1=" "+s1,s2=" "+s2;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			if(s1[i]==s2[j]) dp[i][j]=dp[i-1][j-1]+1;
			else dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
		}
	}
	cout<<dp[n][m]<<"\n";
}

4. 最长递增子序列(LIS)

给定一个长度为 n 的数组,找出一个最长的单调递增子序列。

状态:\(dp[i]\) 在以第 \(i\) 个数为结尾的最长上升子序列的最大长度

转移方程:\(dp[i]=max(dp[j]+1),0<j<i,A_j<A_i\)

DP不是LIS问题的最优解,使用树状数组或者贪心二分复杂度更低。

使用DP求解是 \(O(n^2)\) 的,采用树状数组或者贪心+二分是 \(Olog_2n\) 的。

B3637 最长上升子序列 - 洛谷

int dp[N],a[N];//DP
void solve(){
	int n,ans=0;cin>>n;
	for(int i=1;i<=n;i++) cin>>a[i];
	for(int i=1;i<=n;i++){
		dp[i]=1;
		for(int j=1;j<i;j++)
			if(a[i]>a[j]) dp[i]=max(dp[i],dp[j]+1);
		ans=max(ans,dp[i]);
	}
	cout<<ans<<"\n";
}
int a[N],b[N];//贪心+二分
void solve(){
	int n,len=0;cin>>n;
	for(int i=1;i<=n;i++) cin>>a[i];
	b[++len]=a[1];
	for(int i=2;i<=n;i++){
		if(a[i]>b[len]) b[++len]=a[i];
		else{
			int x=lower_bound(b+1,b+len+1,a[i])-b;
			b[x]=a[i];
		}
	}
	cout<<len<<"\n";
}

Problem - 1257

上面这题也是一道LIS的模板。

5. 编辑距离

给定两个单词,计算将第一个单词转换为第二个单词所需的最小操作数。单词允许插入一个字符、删除一个字符、替换一个字符。

状态:\(dp[i][j]\) 表示从 s1 的前 \(i\) 个字符转换到 s2 的前 \(j\) 个字符所需最小操作数。

如果 \(s1[i]==s2[j]\) ,则 \(dp[i][j]=dp[i-1][j-1]\),因为不需要操作。

如果不等,有三种情况

  • 删除 s1 的最后字符(相当于 s1 最后插入),\(dp[i-1][j]+1\)
  • 在 s2 插入 s1 的最后字符(相当于 s2 最后删除),\(dp[i][j-1]+1\)
  • 将某个单词最后字符替换,\(dp[i-1][j-1]+1\)

即如果 \(s1[i]!=s2[j]\),则 \(dp[i][j]=min{dp[i-1][j-1],dp[i-1][j],dp[i][j-1]}+1\)

注意初始化,需要将 \(dp[i][0]和dp[0][i]\) 赋值为 \(i\),因为某串无字符时,需要删加长度字符。

算法复杂度为 \(O(mn)\)。

P2758 编辑距离 - 洛谷

int dp[N][N];
void solve(){
	string s1,s2;cin>>s1>>s2;
	int n=s1.size(),m=s2.size();
	s1=" "+s1,s2=" "+s2;
	for(int i=0;i<=n;i++) dp[i][0]=i;
	for(int i=0;i<=m;i++) dp[0][i]=i;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			if(s1[i]==s2[j]) dp[i][j]=dp[i-1][j-1];
			else dp[i][j]=min(dp[i-1][j-1],min(dp[i-1][j],dp[i][j-1]))+1;
		}
	}
	cout<<dp[n][m];
}

6. 最小划分

给出一个正整数数组,把它分成 \(S_1\) 和 \(S_2\) 两部分,使 \(S_1\) 的数字和与 \(S_2\) 的数字和的差绝对值最小。

转化为求最大容量为 \(sum/2\) 的背包问题。

724 · 最小划分 - LintCode

P3010 Dividing the Gold S - 洛谷

这题不仅需要求最小差,还需要求方案数量。

可以使状态 \(dp[i]\) 为总和为 \(i\) 的方案数。

初始化为 \(dp[0]=1\),转移方程为 \(dp[i]+=dp[i-a[i]]\)。

但是需要从后往前求出最接近 \(sum/2\) 的值,但是取模可能会破坏。

所以我们使用一个变量存储。

int a[N],ans,dp[N];
void solve(){
	int n,sum=0;cin>>n;
	for(int i=1;i<=n;i++){
		cin>>a[i];sum+=a[i];
	}
	dp[0]=1;
	for(int i=1;i<=n;i++){
		for(int j=sum/2;j>=a[i];j--){
			dp[j]+=dp[j-a[i]];
			if(dp[j]) ans=max(ans,j);
			dp[j]%=mod;
		}
	}
	cout<<sum-ans*2<<"\n"<<dp[ans]%mod;
}

7. 行走问题

给定一个整数 \(n\) ,表示距离的步骤,一个人每次能走 \(1\sim 3\) 步,问走到 \(n\) 的方案数。

状态:\(dp[i]\) 为走到 \(i\) 的方案数 转移方程:\(dp[i]=dp[i-1]+dp[i-2]+dp[i-3],i>2\),初始化需判断大小

我觉得可以归结于计数类DP。

8. 矩阵最长递增路径

给定一个矩阵,找最长一条路径,要求路径上的数字递增。矩阵的每个点可以向四个方向移动。

第一种方法,记忆化搜索。

第二种方法,DP。

为了满足DP的无后效性,我们需要先从低的点算起,后面高的点对低的没有影响。

从低的点算起可以使用优先队列。

状态:\(dp[i][j]\) 表示以坐标 \((i,j)\) 为终点的最长路径长度

转移方程:\(dp[i][j]=max(dp[i-1][j],dp[i+1][j],dp[i][j-1],dp[i][j+1])+1,a[i][j]>a[next][next]\)

struct nod{
	int i,j,num;
	friend bool operator < (nod a,nod b){
		return a.num>b.num;
	}
};
priority_queue<nod> q;
int bu[4][2]={1,0,0,1,-1,0,0,-1};
int n,m,dp[N][N],a[N][N],ans;
void solve(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			dp[i][j]=1;
			cin>>a[i][j];
			nod x;x.i=i,x.j=j,x.num=a[i][j];
			q.push(x);
		}
	}
	while(!q.empty()){
		nod x=q.top();q.pop();
		int i=x.i,j=x.j,num=x.num;
		for(int k=0;k<4;k++){
			int xx=bu[k][0],yy=bu[k][1];
			if(a[i+xx][j+yy]<num) dp[i][j]=max(dp[i][j],dp[i+xx][j+yy]+1);
		}
		ans=max(ans,dp[i][j]);
	}
	cout<<ans;
}

P1434 滑雪 - 洛谷

9. 子集和问题

题意:给定一个非负整数的集合 S,一个值 M,问 S 中是否有一个子集,其子集和为 M。

题解:

暴力的时间复杂度为 \(O(2^n)\)。

状态:\(dp[i][j]=1\) 表示 S 的前 i 个元素存在一个子集和等于 j 。

转移方程:

  • 若 \(s[i]>j\),则不能放入,\(dp[i][j]=dp[i-1][j]\)。
  • 若 \(s[i]<=j\),可以放或者不放,$$dp[i][j]=dp[i-1][j]~   ~dp[i-1][j-s[i]]$$

10. 最优游戏策略

题意:有 n 堆硬币排成一行,价值为 \(v_i\),n为偶数;两人交替拿硬币,每次只能拿走第一堆或者最后一堆,先手拿的最大价值是多少?

状态:\(dp[i][j]\) 表示从第 i 堆到第 j 堆区间内,先手能拿到的最大值。

在区间 \([i,j]\) ,先手有两个选择:

  • 拿 i ,接着对手也可以选择拿 i+1 剩下\([i+2,j]\),或者拿 j 剩下 \([i+1,j-1]\)
  • 拿 j ,接着对手也可以选择拿 i 剩下 \([i+1,j-1]\),或者拿 j-1 剩下 \([i,j-2]\)。

于是得到状态转移方程:

  • 如果 \(i=j\),\(dp[i][j]=v[i]\)
  • 如果 \(i+1=j\),\(dp[i][j]=max(v[i],v[j])\)
  • 否则,后手必然对先手不利的拿法,\(dp[i][j]=max(v[i]+min(dp[i+2][j],dp[i+1][j-1]),v[j]+min(dp[i+1][j-1],dp[i][j-2]))\)

其实也就变化成了区间DP。

11. 矩阵链乘法

题意:给出一个数组 p,其中 \(p[i-1]*p[i]\) 表示矩阵 \(A_i\) 的尺寸,输出最少乘法次数。

还是一个区间DP。

状态:\(dp[i][j]\),表示区间 \([i,j]\) 的最少乘法次数

转移方程:

  • 若 \(i=j\),\(dp[i][j]=0\)。
  • \[dp[i][j]=min(dp[i][k]+dp[k+1][j]+p_{i-1}p_kp_i),i≤k<j\]

12. 布尔括号问题

题意:布尔变量有真假两种取值,3 种逻辑操作与、或、异或。现在输入 n 个取值和 n-1 个逻辑操作,要使结果为真,有多少种括号方案?

状态:

  • \(dp[i][j][1]\) 表示子表达式 \([i,j]\) 结果为true的方式数
  • \(dp[i][j][0]\) 表示子表达式 \([i,j]\) 结果为false的方式数
#include <bits/stdc++.h>
using namespace std;
bool evaluate(int b1, int b2, char op){
	if (op == '&') return b1 & b2;
	if (op == '|') return b1 | b2;
	return b1 ^ b2;
}
int countWays(string s){
	int n = s.length();
	vector<vector<vector<int>>> dp(n, vector<vector<int>>(n, vector<int>(2, 0)));
	for (int i = 0; i < n; i += 2){
		dp[i][i][1] = (s[i] == 'T');
		dp[i][i][0] = (s[i] == 'F');
	}
	for (int len = 2; len < n; len += 2){    
		for (int i = 0; i < n - len; i += 2){
			int j = i + len;
			dp[i][j][0] = dp[i][j][1] = 0; 
			for (int k = i + 1; k < j; k += 2){
				char op = s[k];
				int leftTrue = dp[i][k - 1][1], leftFalse = dp[i][k - 1][0];
				int rightTrue = dp[k + 1][j][1], rightFalse = dp[k + 1][j][0];
				if (evaluate(1, 1, op))
					dp[i][j][1] += leftTrue * rightTrue;
				if (evaluate(1, 0, op))
					dp[i][j][1] += leftTrue * rightFalse;
				if (evaluate(0, 1, op))
					dp[i][j][1] += leftFalse * rightTrue;
				if (evaluate(0, 0, op))
					dp[i][j][1] += leftFalse * rightFalse;
				if (!evaluate(1, 1, op))
					dp[i][j][0] += leftTrue * rightTrue;
				if (!evaluate(1, 0, op))
					dp[i][j][0] += leftTrue * rightFalse;
				if (!evaluate(0, 1, op))
					dp[i][j][0] += leftFalse * rightTrue;
				if (!evaluate(0, 0, op))
					dp[i][j][0] += leftFalse * rightFalse;
			}
		}
	}
	return dp[0][n - 1][1]; 
}

int main(){
	string s = "T|T&F^T";
	cout << countWays(s) << endl;
	return 0;
}

13. 最短公共超序列

题意:给定两个字符串,求一个最短的字符串使两个字符串都是它的子序列。

找到两个字符串的LCS,然后将非LCS字符按照原始顺序插入到LCS中。

只求长度的话,\(最短超序列长度=两字符串长度和-LCS长度\)。

3. 数位统计DP

3.1 数位DP的递推实现

3.2 数位DP的记忆化搜索实现

P2602 数字计数 - 洛谷

题意:在 \([a,b]\) 中,\(0\sim 9\)出现了多少次。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=15;
ll dp[N][N][2][2];//位数,前面几个符合条件,前导0,某位上限
int num[N],now;
ll dfs(int pos,int sum,bool lead,bool limit){
	ll ans=0;
	if(pos==0) return sum;//递归到0位数,结束返回
	if(dp[pos][sum][lead][limit]!=-1) return dp[pos][sum][lead][limit];
	int up=(limit?num[pos]:9);
	for(int i=0;i<=up;i++){
		//计算000~099
		if(i==0&&lead) ans+=dfs(pos-1,sum,1,limit&&i==up);
		//计算200~299
		else if(i==now) ans+=dfs(pos-1,sum+1,0,limit&&i==up);
		//计算100~199
		else if(i!=now) ans+=dfs(pos-1,sum,0,limit&&i==up);
	}
	dp[pos][sum][lead][limit]=ans;
	return ans;
}
ll solve(ll x){
	int len=0;
	while(x){
		num[++len]=x%10;
		x/=10;
	}
	memset(dp,-1,sizeof(dp));
	return dfs(len,0,1,1);
}
int main(){
	ll a,b;cin>>a>>b;
	for(int i=0;i<=9;i++){
		now=i;
		cout<<solve(b)-solve(a-1)<<" ";
	}
	return 0;
}

3.3 数位DP例题

P2657 windy 数 - 洛谷

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=15;
ll dp[N][N][2][2];//位数,前面几个符合条件,前导0,某位上限
int num[N],now;
ll dfs(int pos,int pre,bool lead,bool limit){
	if(pos==0) return 1;
	if(dp[pos][pre][lead][limit]!=-1) return dp[pos][pre][lead][limit];
	ll ans=0;
	int up=limit?num[pos]:9;
	for(int i=0;i<=up;i++){
		if(abs(i-pre)<2) continue;
		if(i==0&&lead) ans+=dfs(pos-1,-2,1,limit&&i==up);
		else ans+=dfs(pos-1,i,0,limit&&i==up);
	}
	dp[pos][pre][lead][limit]=ans;
	return ans;
}
ll solve(ll x){
	int len=0;//cout<<"x=="<<x<<"\n";
	while(x){
		num[++len]=x%10;
		x/=10;
	}
	memset(dp,-1,sizeof(dp));
	return dfs(len,-2,1,1);
}
int main(){
	int T=1;//cin>>T;
	while(T--){
		ll a,x;cin>>a>>x;
//		cout<<solve(a)<<"\n";
		cout<<solve(x)-solve(a-1)<<"\n";
	}
	return 0;
}

P4124 手机号码 - 洛谷

ll dp[N][N][N][2][2][2];//位数,上上位,上一位,是否有4,是否有8
int num[N],now;
ll dfs(int pos,int pre1,int pre2,bool n3,bool n4,bool n8,bool limit){
	if(n4&&n8) return 0;
	if(pos==0) return n3;
	if(!limit&&dp[pos][pre1][pre2][n3][n4][n8]!=-1) return dp[pos][pre1][pre2][n3][n4][n8];
	ll ans=0;
	int up=limit?num[pos]:9;
	for(int i=0;i<=up;i++)
		ans+=dfs(pos-1,pre2,i,(i==pre1&&i==pre2)||n3,i==4||n4,i==8||n8,limit&&(i==up));
	if(!limit) dp[pos][pre1][pre2][n3][n4][n8]=ans;
	return ans;
}
ll solve(ll x){
	int len=0;
	while(x){num[++len]=x%10;x/=10;}
	if(len!=11) return 0;
	memset(dp,-1,sizeof(dp));
	ll ans=0;
	for(int i=1;i<=num[len];i++)
		ans+=dfs(len-1,-1,i,0,i==4,i==8,i==num[len]);
	return ans;
}

4. 状态压缩DP

4.1 Hamilton问题

用 \(dp[S][j]\) 表示集合 S 内的最短Hamilton路径。

转移方程: \(dp[S][j]=min\{dp[S-j][k]+dist(k,j)\},k \in S-j\)

int n,dp[1<<20][21];
int dist[21][21];
int solve(){
	memset(dp,0x3f,sizeof(dp));
	cin>>n;
	for(int i=0;i<n;i++)//邻接矩阵
		for(int j=0;j<n;j++)
			cin>>dist[i][j];
	dp[1][0]=0;
	for(int S=1;S<(1<<n);S++)//从小集合扩展到大集合
		for(int j=0;j<n;j++)//枚举点j
			if((S>>j)&1)//S中含有点j
				for(int k=0;k<n;k++)
					if((S^(1<<j))>>k&1)//k属于S-j集合中
						dp[S][j]=min(dp[S][j],dp[S^(1<<j)][k]+dist[k][j]);
	return dp[(1<<n)-1][n-1];
}

4.2 状压DP原理

4.3 状压DP例题

4.4 三进制状压DP

HDU 3001

状态:\(dp[j][i]\) 表示从城市 j 出发,按路径 i 访问 i 中所有城市的最小费用。

转移方程: \(dp[j][i]=min(dp[j][i],dp[k][l]+graph[k][j]) ,k \in (i-j)\) 其中,\(l=i-bit[j]\) ,表示从路径 i 中去掉城市 j。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=6e4,inf=0x3f3f3f3f;
int n,m;
int bit[12]={0,1,3,9,27,81,243,729,2187,6561,19683,59049};
int tri[N][11];
int dp[11][N];
int graph[11][11];//存图
void init(){//初始化,求所有可能路径
	for(int i=0;i<59050;i++){
		int t=i;
		for(int j=1;j<=10;j++){
			tri[i][j]=t%3;
			t/=3;
		}
	}
}
int com_dp(){
	int ans=inf;
	memset(dp,inf,sizeof(dp));
	for(int j=0;j<=n;j++)
		dp[j][bit[j]]=0;//初始化,从第j城市出发,只访问j,费用0
	for(int i=0;i<bit[n+1];i++){//遍历所有路径,每个i是一条路径
		int flag=1;//所有城市都遍历过一次以上
		for(int j=1;j<=n;j++){//遍历城市,以j为起点
			if(tri[i][j]==0){//是否有一个城市访问次数为0
				flag=0;//还没经过所有点
				continue;
			}
			for(int k=1;k<=n;k++){//遍历路径i-j的所有城市
				int l=i-bit[j];//从路径i中去掉第j个城市
				dp[j][i]=min(dp[j][i],dp[k][l]+graph[k][j]);
			}
		}
		if(flag)//找最小费用
			for(int j=1;j<=n;j++)
				ans=min(ans,dp[j][i]);//路径i上最下费用
	}
	return ans;
}
int main(){
	init();
	while(cin>>n>>m){
		memset(graph,inf,sizeof(graph));
		while(m--){
			int a,b,c;cin>>a>>b>>c;
			if(c<graph[a][b]) graph[a][b]=graph[b][a]=c;
		}
		int ans=com_dp();
		if(ans==inf) cout<<"-1\n";
		else cout<<ans<<"\n";
	}
	return 0;
}

5. 区间DP

5.1 石子合并问题

P1775 石子合并(弱化版) - 洛谷

int n,a[N],b[N];
int dp[N][N];
void solve(){
	cin>>n;
	memset(dp,0x3f,sizeof(dp));
	for(int i=1;i<=n;i++){
		cin>>a[i];b[i]=b[i-1]+a[i];
		dp[i][i]=0;
	}
	for(int len=1;len<n;len++)
		for(int i=1;i<=n-len;i++){
			int j=i+len;
			for(int k=i;k<j;k++){
				dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+b[j]-b[i-1]);
			}
		}
	cout<<dp[1][n]<<"\n";
}

5.2 模板代码

int n,a[N],b[N];
int dp[N][N];
void solve(){
	//初始化
    
	for(int len=1;len<n;len++)//区间长度
		for(int i=1;i<=n-len;i++){//区间左端点
			int j=i+len;//区间右端点
			for(int k=i;k<j;k++){
				dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+w[i][j]);
			}
		}
	return dp[1][n];
}

5.3 区间DP例题

5.4 二维区间DP

Problem - F - Codeforces

题意:有一个 n*n 的方格图,某些方块为黑色,其余为白色。一次操作可以选定一个 h*w 的矩形,把其中所有方格涂成白色,代价是 \(max(h,w)\),求最小代价涂所有方格。

时间复杂度 \(O(n^5)\)。

int n;
char a[N][N];
int dp[N][N][N][N];
void solve(){
	cin>>n;
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++) cin>>a[i][j];
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			if(a[i][j]=='.') dp[i][j][i][j]=0;
			else dp[i][j][i][j]=1;//黑格涂成白色需一次
	for(int lenx=1;lenx<=n;lenx++)
		for(int leny=1;leny<=n;leny++)
			for(int x1=1;x1<=n-lenx+1;x1++)
				for(int y1=1;y1<=n-leny+1;y1++){
					int x2=x1+lenx-1,y2=y1+leny-1;
					if(x1==x2&&y1==y2) continue;
					dp[x1][y1][x2][y2]=max(abs(x1-x2),abs(y1-y2))+1;//初始值
					for(int k=x1;k<x2;k++)
						dp[x1][y1][x2][y2]=min(dp[x1][y1][x2][y2],dp[x1][y1][k][y2]+dp[k+1][y1][x2][y2]);
					for(int k=y1;k<y2;k++)
						dp[x1][y1][x2][y2]=min(dp[x1][y1][x2][y2],dp[x1][y1][x2][k]+dp[x1][k+1][x2][y2]);
				}
	cout<<dp[1][1][n][n]<<"\n";
}

6. 树形DP

6.1 树形DP基本操作

P2015 二叉苹果树 - 洛谷

const int N=1e2+10;
struct nod{
	int v=0;
	int w=0;
};
vector<nod> edge[N];
int dp[N][N],sum[N];
int n,q;
void dfs(int u,int fa){
	for(int i=0;i<edge[u].size();i++){//用i遍历u的所有子节点
		int v=edge[u][i].v,w=edge[u][i].w;
		if(v==fa) continue;//不回头搜索,避免循环
		dfs(v,u);//递归到最深的叶子节点,然后返回
		sum[u]+=sum[v]+1;//子树上的总边数
		for(int j=min(q,sum[u]);j>=0;j--)
			for(int k=0;k<=min(sum[v],j-1);k++)
				dp[u][j]=max(dp[u][j],dp[u][j-k-1]+dp[v][k]+w);
	}
}
void solve(){
	cin>>n>>q;
	for(int i=1;i<n;i++){
		int u,v,w;cin>>u>>v>>w;
		edge[u].push_back({v,w});
		edge[v].push_back({u,w});
	}
	dfs(1,0);
	cout<<dp[1][q];
}

P1352 没有上司的舞会 - 洛谷

时间复杂度为 \(O(n)\)。

vector<int> edge[N];
int dp[N][2],a[N],fa[N];
int n,q;
void dfs(int u){
	dp[u][0]=0,dp[u][1]=a[u];
	for(int i=0;i<edge[u].size();i++){//用i遍历u的所有子节点
		int v=edge[u][i];
		dfs(v);
		dp[u][1]+=dp[v][0];
		dp[u][0]+=max(dp[v][0],dp[v][1]);
	}
}
void solve(){
	cin>>n;
	for(int i=1;i<=n;i++) cin>>a[i];
	for(int i=1;i<n;i++){
		int u,v;cin>>u>>v;
		edge[v].push_back(u);
		fa[u]=v;
	}
	int t=1;
	while(fa[t]) t=fa[t];
	dfs(t);
	cout<<max(dp[t][0],dp[t][1])<<"\n";
}

6.2 背包与树形DP

7. 一般优化

P3287 方伯伯的玉米田 - 洛谷

树状数组优化

#define lowbit(x) ((x)&-(x))
const int N=5e3+505;
int a[2*N],dp[2*N][505],t[505][N],n,k;
void update(int x,int y,int d){//更新区间
	for(int i=x;i<=k+1;i+=lowbit(i))
		for(int j=y;j<=5500;j+=lowbit(j))
			t[i][j]=max(t[i][j],d);
}
int query(int x,int y){//查询区间最大值
	int ans=0;
	for(int i=x;i>0;i-=lowbit(i))
		for(int j=y;j>0;j-=lowbit(j))
			ans=max(ans,t[i][j]);
	return ans;
}
void solve(){
	cin>>n>>k;
	for(int i=1;i<=n;i++) cin>>a[i];
	for(int i=1;i<=n;i++)
		for(int j=k;j>=0;j--){
			dp[i][j]=query(j+1,a[i]+j)+1;
			update(j+1,a[i]+j,dp[i][j]);
		}
	cout<<query(k+1,5500);
}

8. 单调队列优化

8.1 单调队列优化原理

8.2 单调队列优化例题

P2627 Mowing the Lawn G - 洛谷

ll n,k,a[N],sum[N],dp[N];
ll ds[N];
int q[N],head=0,tail=1;//递减的单调队列,队头最大
ll que_max(int j){
	ds[j]=dp[j-1]-sum[j];
	while(head<=tail&&ds[q[tail]]<ds[j]) tail--;//去掉队尾
	q[++tail]=j;//进队
	while(head<=tail&&q[head]<j-k) head++;
	return ds[q[head]];
}
void solve(){
	cin>>n>>k;
	for(int i=1;i<=n;i++){
		cin>>a[i];sum[i]=sum[i-1]+a[i];
	}
	for(int i=1;i<=n;i++) dp[i]=que_max(i)+sum[i];
	cout<<dp[n];
}

9. 斜率优化/凸壳优化

9.1 状态转移方程变换平面斜率问题

9.2 求一个DP[ i ]

9.3 求所有DP[ i ]

9.4 例题

10. 四边形不等式优化

10.1 应用场合

10.2 四边形不等式优化操作

10.3 四边形不等式定义和单调性定义

10.4 四边形不等式定理

10.5 例题