算法学习笔记4: DP问题

DP问题

我的理解: 首先需要确定一个集合f(最重要的部分),每一维表示一个限制,然后可能会有多个状态转移到这个集合,然后对该集合进行分类讨论。

对于每一维的确定,如果是一个集合有多种状态的情况需要分类讨论,比如状压DP,那么就要把状态作为某一维。也相当于对集合进行划分,然后对集合的每个部分进行分析,判断可能可以从前面哪些状态转移过来。

背包DP

对于背包dp,本质上就是排列组合问题,问选择哪些数,使得满足某个条件。

转化思考方式的优化: 比如要求100精灵球最多能收服多少个精灵,那么可以通过转换一下,表示为收服x个精灵,最少需要多少个精灵球。如果精灵的数量少于精灵球的数量的时候,可以降低时间复杂度

题型:

  1. 求解体积<=j的最大价值(最小价值),此时物品的体积超过j都是不合法的方案。

  2. 求解体积至少为j的最大价值,此时物品的体积超过j也是合法方案,虽然此时j-v<0,但是等价于j-v=0,因为也是相当于体积全部都用掉了。Acwing.1020潜水员

  3. 求解方案数f数组的初始化不同(以二维情况为例)

    • 当体积最多是j,f[0,i]=1,0<=i<=m,其余是0
    • 当体积刚好是j,f[0,0]=1,其余是0
    • 当体积至少是j,f[0,0]=1,其余是0
  4. 求解最大值和最小值f数组的初始化

    • 体积最多是j,f[i,k],0<=i<=m,0<=k<=m
    • 体积恰好是j,f[0,0]=0,其余是INF或者-INF
    • 体积至少是j,f[0,0]=0,其余是INF(只会求最小值)
  5. 求解转移路径

    只要对比f[i-1,j]和f[i-1,j-v]+w的大小关系就可以知道f[i,j]从哪里转移过来,另外可以从前往后转移,也可以从后往前转移,意思是说可以逆着物品序对f数组进行求解,因为背包DP本质是排列组合问题,所以物品的顺序并不关键。

  6. 价值会发生变化的背包DP,在价值不变的情况下,不管以什么方式枚举物品,最优解都是不变的(与顺序无关)。但是价值一旦会发生变化,物品的枚举顺序就会影响最优解,所以此时需要首先确定物品能够获得最优解的枚举顺序(一般利用贪心求解)Awing734.能量石。从集合的角度理解,最优解是在所有方案中的最优解,然后所有方案就是指所有选法以及吃的顺序的组合,我们首先利用贪心的思想确定物品的枚举顺序,然后利用DP求解所有选法的最优解。

0/1背包: (物品只能选一次)

f[i][j] = max(f[i][j],f[i-1][j-v[i]]+w[i]);//二维
//滚动数组优化,当前行f[i]的状态只与上一行有关,所以可以用一维数组优化
//如果是从小到大,前一行的状态会被新一行的状态覆盖掉,这样使用前面已经求出来的状态就会出错
for(int j = m; j >= v[i]; j--)    //从大到小的重量
            f[j] = max(f[j], f[j - v[i]] + w[i]);//一维

完全背包:(物品可以选无数次)

完全背包问题也是可以解决组合问题,但是物品可以选无数多次的组合问题,比如货币的金额表示等等

/*
朴素做法,循环物品的次数,判断是否有最大值,即f[i][j]=max(f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2v]+2w....以此类推)

优化做法:由暴力做法可以的得知f[i][j]的值为多个值取最大值。
我们计算f[i][j-v]=max(f[i-1][j-v],f[i-1][j-2v]+w,f[i-1][j-3v]+2w...以此类推),可以发现f[i][j]与f[i][j-v]之间的关系,相差了一个w。
f[i][j]=max(f[i-1][j]+f[i][j-v]+w),所以就能推出以下二维转移方程
*/
f[i][j]=f[i-1][j];
if(j>=v[i]) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);	
/*
空间优化,由推出的二维转移方程可知,f[i][j]的值,都是与同层前面求出来的值有关,所以需要从前往后枚举。
与01背包不同,01背包的f[i][j]的值都与前一层求出的有关,所以j不能从大到小遍历,前面求出的新值会把上一层的旧值替换掉。
*/
for(int j = v[i] ; j<=m ;j++)//注意了,这里的j是从小到大枚举,和01背包不一样
    {
            f[j] = max(f[j],f[j-v[i]]+w[i]);//一维
    }

分组背包(同组的物品只能选一个):

/*
朴素做法
f[i][j]表示前i组物品,容量<=j的最大价值。
集合计算,f[i][j]=max{f[i][j],f[i-1][j-v1]+w1,f[i-1][j-v2]+w2....以此类推},循环遍历组中每一种物品被选中的情况。

需要是三重循环,最外层遍历组数,第二层遍历容量,第三层遍历组中的物品
*/

for(int i=1;i<=n;i++){
	int s;
	cin>>s;
    //输入体积和价值
	for(int j=1;j<=s;j++){
		cin>>v[j]>>w[j];
    }
	for(int j=0;j<=m;j++){
		f[i][j]=f[i-1][j];//不选第i组中的物品
		//遍历i组中的每一个物品 
		for(int k=1;k<=s;k++){
			if(j>=v[k]){
				f[i][j]=max(f[i][j],f[i-1][j-v[k]]+w[k]);
			}
		}
	}
}
/*
空间优化:f[i][j]每次计算只与上一层状态有关,所以利用滚动数组,对f[i][j]进行降维
对j(容量)的遍历需要从大到小,从小到达会导致前一层的旧值被新值覆盖
*/
for(int j=m;j>=0;j--){
	//遍历i组中的每一个物品 
	for(int k=1;k<=s;k++){
		if(j>=v[k]){
			f[j]=max(f[j],f[j-v[k]]+w[k]);
		}
	}
}			

多重背包

一种物品可以放有限次

/*
暴力做法,枚举物品放置的所有数量情况
时间复杂度O(n*m*s)    n<=100  n表示物品种类,m表示体积,s表示物品的有限个数
*/
for(int i=1;i<=n;i++){
		int v,w,s;
		cin>>v>>w>>s;
	    for(int j=m;j>=v;j--){
            //枚举所有物品放置情况
	        for(int k=0;k<=s && k*v<=j;k++){
	            f[j]=max(f[j],f[j-v*k]+w*k);
	        }
	    }
	}
/*
二进制优化方法:适用于某类物品数量很多的情况,如果物品的个数很少,那么和朴素做法的时间复杂度相近
n<=1000,用暴力做法会超时
对于一种物品可以放置多个,我们可以将多个物品拆成多类物品,比如价值为v,体积为w,个数为s的一类物品,可以拆成(v,w)、(v*2,w*2)、(v*4,w*4)......多类物品,把多重背包问题转化为01背包问题。
对于物品的拆法,不能一个一个拆,这样会超时,我们选择了二进制的拆法,比如10=1+2+4+3,这样拆物品的时间复杂度就降到了O(logn)
时间复杂度:(n*logs*m) s为每类物品的数量
*/
	vector<PII> vec;//把多个物品拆成多种物品 
	for(int i=1;i<=n;i++){
		int v,w,s;
		cin>>v>>w>>s;
        //二进制拆法
		for(int j=1;j<=s;j*=2){
			s-=j;
			vec.push_back(make_pair(j*v,j*w));
		} 
        //最后一个数拆不了,就单独算
		if(s>0) vec.push_back(make_pair(s*v,s*w));
	}
	//转化为01背包问题,每个物品只能选一次
	for(vector<PII>::iterator it=vec.begin();it!=vec.end();it++){
		PII good=*it;
		for(int j=m;j>=good.first;j--){
			f[j]=max(f[j],f[j-good.first]+good.second);
		}
	}
	
/*
单调队列优化。。。还看不明白。先放一放
*/

线性DP

求解满足一定条件的(最大值或最小值)的数量

最长上升子序列(LIS,Longest Increasing Subsequence)

考点:至少能用多少个递增序列或递减序列,能覆盖整个序列的个数。可以利用贪心求解,以递增序列为例,判断某数是加入原有序列还是新建一个序列。用一个数组来维护每个序列结尾的数,将待加入的数x,加入到<=x中最大的那个结尾的序列中去 (直接替换更新)。因为如果加入到较小的结尾中去,就可能会导致后续的数无法加入到该序列(不符合贪心思想)

/*
集合:f[i]  以a[i]结尾的最长子序列的长度,
集合计算:在满足a[i]>a[j],可以从f[1...i-1]的任意状态转移过来
*/
f[1]=1;
for(int i=2;i<=n;i++){
	for(int j=i-1;j>=1;j--){
		if(a[i]>a[j])f[i]=max(f[i],f[j]+1);
	}
	if(!f[i])f[i]=1;
}

最长公共子序列(LCS,Longest Common Subsequence)

/*
集合f[i][j] A[1~i]和B[1~j]中最长公共子序列的长度
*/
for(int i=1;i<=n;i++){
	for(int j=1;j<=m;j++){
		if(a[i]==b[j])f[i][j]=max(f[i][j],f[i-1][j-1]+1); 
		else f[i][j]=max(f[i-1][j],f[i][j-1]);
			
	}
}

最长公共上升子序列(LCIS,Longest Common Increasing Subsequence)

/*
集合:f[i][j]  A[1...i]和B[1...j]中以B[j]结尾的最长公共上升子序列的长度
集合计算:
①当a[i]!=b[j],a[i]不能加入到最长公共上升子序列中,所以f[i][j]=f[i-1][j]
②当a[i]=b[j],表示将a[i]加入到最长公共上升子序列中(以b[j]结尾,此时a[i]=b[j]),这时候对f[i][j]集合重新划分
类似于求解以b[j]结尾的最长上升子序列,当a[i]>b[1...j-1],可以从f[i-1][1...j-1]中任意位置转移过来,需要取最大值。
*/

//朴素做法,O(n^3)
for(int i=1;i<=n;i++){
    for(int j=1;j<=n;j++){
        f[i][j]=f[i-1][j];//不选a[i]加入集合
        if(a[i]==b[j]){
            //遍历b[1...j-1],当满足递增条件时,确定最长上升子序列从什么位置转移到f[i][j]
            for(int k=0;k<j;k++){
                if(a[i]>b[k])f[i][j]=max(f[i-1][k]+1,f[i][j]);
            }
        } 
    }
}

/*
注意!!这种优化方式还挺常见的,就是通过记录先前统计过的值,来减少一重循环,降低时间复杂度
优化版本
对于最内层循环,找到满足a[i]>b[k],且f[i-1][k]最大的情况。
当a[i]固定时,每循环一次j,都要从1~j-1寻找一次最大值,所以我们可以记录下每一次1~j-1的最大值maxv,在即将求解1~j的最大值时
,需要更新maxv的值,就是在满足a[i]>b[j]的情况下,比较maxv和f[i-1][j]+1的大小来更新maxv
maxv的最小值为1,就是集合中只有一个元素。
*/
for(int i=1;i<=n;i++){
    int maxv=1;//记录满足a[i]>b[k] (k<-[1,j-1]), f[1~j-1]中的最大值
    for(int j=1;j<=n;j++){     
        f[i][j]=f[i-1][j];
        if(a[i]==b[j])f[i][j]=max(f[i][j],maxv);
        if(a[i]>b[j])maxv=max(maxv,f[i-1][j]+1);
    }
}

最长连续子序列

/*
集合:f[i] 以a[i]结尾的连续最长子序列的和
集合计算:f[i]=max{f[i-1]+a[i],a[i]}  集合只包含自己一个元素|包含以a[i-1]结尾的最长连续子序列的和f[i-1]+a[i]
*/

区间DP

/*
集合:f[i][j]表示在i~j之间的目标值
集合计算:f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]),以k作为分界点
所有的区间dp问题枚举时,第一维通常是枚举区间长度,并且一般 len = 1 时用来初始化,枚举从 len = 2 开始;
第二维枚举起点 i (右端点 j 自动获得,j = i + len - 1)
*/

//石子合并,求合并代价最小问题
//先枚举区间长度
for(int len=2;len<=n;len++){
    //枚举左端点
    for(int i=1;i+len-1<=n;i++){
        int j=i+len-1;
        for(int k=i;k<j;k++){
            f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);
        }
    }
} 
cout<<f[1][n];

树形DP

注意: 树形DP不一定要递归的,也可能是和树相关的DP,比如说求解给定节点个数和树的深度,求解树的方案数,这时候f[i] [j]表示为i个节点,高度为j的二叉树的方案数,然后求解过程就是枚举左子树和右子树的节点个数和高度的所有情况,统计方案数。

对于树的问题,大部分都是利用dfs来搜索,进行要明白dfs的具体含义。

对于有些无向边的树,我们无法找到具体根节点的位置,我们可以把任意点当作根节点,在dfs是加入父节点father的信息,避免重复计算。

/*
对于树形DP,需要用邻接表存储结点之间的关系。然后求解时需要利用递归
集合:f[u][0]表示以u为根节点的子树,不选根结点u的情况,得到的max/min
	 f[u][1]表示以u为根节点的子树,选根节点u的情况,得到的max/min
*/

//没有上司的舞会(父节点和子节点不能同时被选中参加舞会,因为和上司一起吃饭不开心),求解开心度的最大值。
void dfs(int u){
	
	f[u][1]=happy[u];//选中根节点u
    //遍历子树
	for(int i=h[u];~i;i=ne[i]){
		int j=e[i];
		dfs(j);//递归子树 
		f[u][0]+=max(f[j][0],f[j][1]);//不选根节点u
		f[u][1]+=f[j][0];
	} 
}

树上背包DP

树上背包dp就是树形DP+背包DP。一些题目给定了树形结构,在这个树形结构中选取 一定数量(题目一般会指定) 的点或边(也可能是其他属性),使得某种与点权或者边权相关的花费最大或者最小。解决这类问题,一般要考虑使用树上背包。

//洛谷:P2014 [CTSC1997] 选课
int dfs(int u){
	int p = 1;//记录u子树中结点的数量 
  	f[u][1] = w[u];
	for(int i=h[u];~i;i=ne[i]){
		int son=e[i];
		int siz = dfs(son);//记录以son为根节点的子树的结点个数
        /*
        	这里解释一下为什么需要逆序枚举j?
        	类似于背包DP利用滚动数组优化空间复杂度。由于dfs的特性,以u为根节点的每棵子树都是按顺序一个一个访问的
        	对于这道题f[i][j]表示以i为根节点的子树,选择课程的数量是j的所获得的最大学分数。这里省略掉了一维k就是对于前k棵
        	以i为根节点的子树(类似于分组背包,一个子树一个子树考虑)。
        */
		for(int j=m+1;j>=1;j--){
			for(int k=0;k<=siz && k+j<=m+1;k++){
				f[u][j+k]=max(f[u][j+k],f[u][j]+f[son][k]);
			}
		}
    	p += siz;
  	}
	return p;
}

换根DP

换根DP问题又被称为二次扫描,通常不会指定根节点,主要求解根节点的变化,对子结点深度和、点权和的影响。通常需要两次dfs,第一次dfs指定一个根结点预处理出例如深度、子树结点个数、点权和的信息。第二次就是dfs,进行换根动态规划。(见洛谷P3478)

我的理解:

  1. 为什么可以利用DP求解呢?假设父节点为u,父节点的子结点为v,子结点的子结点为w,dfs先求解出u和v的换根后的结果。接着继续求解u和w换根的结果,因为已经求解出u和v换根的结果,那么只用递推求解v和w换根的结果。注意: 第一个dfs我们先是指定了一个根节点,并求出题目要求的值,然后第二个dfs执行的换根就是和第一步指定的根节点换(类似于前面的u结点),这样子就可以利用DP求解了。
  2. 理解1对于换根感觉有一定的局限性,一般来说,换根DP的第二次dfs一般都是从根节点往下进行递推,也就是当前结点和父节点的关系(而不是当前结点和子节点的关系)

数位DP

后续会完善,敬请期待…

状压DP

状态压缩DP本质上就是用二进制的方式表示所有的状态,因为状态总数的阶乘级的,所以适用于n比较小的情况,可以枚举所有的状态进行状态转移。一般需要先预处理合法状态,以及状态与状态之间转移的合法性。

概率DP

DP求期望: 期望通俗的讲就是求一个数出现的平均值。

DP求期望需要用到期望的可加性,E(Y)可以求解出所有可能的情况以及对应情况的概率,相乘后相加就是E(Y)。而期望的可加性是利用E(X+Y)=E(X)+E(Y),这样就可以把一个“大期望”分解为一个个“小期望”,这也是能够转化为DP来做的原因。

你可能感兴趣的:(算法学习,算法,学习,笔记,动态规划,c++)