算法基础——动态规划

动态规划(Dynamic Programming)

动态规划的模板

1、 设计状态
2、 写出状态转移方程
3、 设定初始状态
4、 执行状态转移
5、 返回需要时刻状态的解

DP的核心在于状态只和上个(或几个)的状态有关,和转移到该状态的路径无关,和之后的状态也无关,本质是一个有向无环图,状态是结点,关系是边,从开头往结尾转移。
关于起点的初始化,个人理解是往前倒推,直到某个值没法推了,需要按任务要求直接赋值,还有一些问题需要对整个数组初始化,如图的各自路径任务,看具体任务需求,有的要初始化为0,有的要初始为INF,有的无所谓等等。
动态规划处理大量数据(结点)效率不高,所以如果数据量n很大没法映射到数组(结点)中的话,一般不是动态规划问题(至少不是最优解),可能是贪心或者用二分优化等等。

动态规划的关键点

dp[i]的含义,递推公式,dp的初始化,遍历顺序/方向。
如果定义好dp但不知道递推公式,可以找个样例把dp数组手动推出来观察,笨但也许有效。所以最最关键的是dp的定义。
动态规划debug可以打印dp数组观察状态变化,分析是思路问题还是代码问题。
线性DP:
时间复杂度只和状态数成线性关系,O(n);
而状态转移与n无关,只需要O(1);

动态数组的优化

空间优化

动态规划的主要优化内容。
利用滚动数组思想做状态压缩。当我们定义的状态在动态规划的转移方程中只和某几个状态相关,且这些状态可以与某个维度无关,且任务后续不需要再使用之前的状态,就可以用这种方法把那个维度压缩掉,给空间复杂度降低一维,只维护我们单次转移需要的空间数量即可,尤其是当数据量、维度很大的时候。
利用滚动数组隐藏了一个维度的特性,在dp初始化时可以更自由,比如某些递推公式从数学上可以推导数组下标为负的情况,二维数组初始化下标i只能从0开始,有些可能还要手动算几步,而滚动数组的递推i可以从-1甚至更前面开始,也许默认0就可以,或者只初始化dp[0],这样下标含义的灵活度更高。
注意有些滚动数组压缩还伴随着剩余维度倒序遍历,因为原本正序的递推公式为了不破坏前面的数组,需要从后往前更新。
没法完全忽略压缩维度到1,还可以考虑用交替数组。

时间优化

通常可以写成贪心的DP,贪心的复杂度一定<=DP,滚动数组可以使DP的空间复杂度和贪心一致,而想要优化时间可以尝试改变dp状态的定义,最常用的方法是按贪心的思想将dp的状态和状态值含义交换(不是简单的直接交换),如dp[i]的值原本表示以nums[i]为结尾的最长子序列长度,改成长度为i的最长子序列末尾元素的最小值。好吧,其实本质上就是转换成贪心了,所以如果有说明可以优化时间,通常就是让你改成贪心,贪心的思路可以从调换dp含义去考虑。

背包问题

01背包

即每种物品只有一个,只有选0个和选1个两种状态,总状态为2的n次方,也是暴力枚举的复杂度。01指的是每种物品数量只有0和1两种状态,并非物品的限制维度。

0. 完整代码
int solve(vector<int>& weight, vector<int>& value, int m) {
	int n = weight.size();
	// if (!n || !m) return 0; // 如果考虑异常输入
	vector<vector<int>> dp(n, vector<int>(m + 1, 0));
	// dp[i][j]代表i个物品装进背包空间为j的最大价值,注意j是能等于m的,所以列是m+1
	// 初始化两条边dp[i][0]、dp[0][j],其他的无所谓
	for (int i = 0; i < n; i++) {// 这个可以省去,默认是0
		dp[i][0] = 0;
	}
	for (int j = weight[0]; j < m + 1; j++) {
		// 从背包空间大于weight[0]开始赋值,前面都是默认0
		dp[0][j] = value[0];
	}
	//递推公式
	for (int i = 1; i < n; i++) {
		//先遍历物品大小,01背包问题先后都行,因为该递推公式任意遍历顺序都可以得到当前dp
		for (int j = 1; j < m + 1; j++) {//再遍历背包大小
			if (j < weight[i]) dp[i][j] = dp[i - 1][j];//当前物品大小比背包空间还要大
			else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
			//之前不用当前物品的状态和要用当前物品预留空间的状态取最大值
		}
    }
    return dp[n - 1][m];
}
1. dp含义

dp[i][j] 表示第0到第i个物品要装进背包空间为j的最大价值。dp[i-1][j]表示背包空间还是j,但少了第i物品的情况。

2. 递推公式

先解释dp[i - 1][j - weight[i]]的含义:前面到第i-1个物品装进背包空间为j-weight[i]的最大价值,为什么是j-weight[i]?因为该情况是要放进当前物品i的,故背包要预留出weight[i],看剩下的空间装前面i-1的物品最优解是多少。这是背包问题的递推公式关键。
dp[i][j]有两种情况:
1、不放第i个物品,则dp[i][j] = dp[i - 1][j]
这种结果又包含了两种具体情况:
a) 第i个物品大小比背包空间还要大,条件为j <weight[i],显然怎么都放不进去
b) 前面的物品性价比都比第i个物品高,条件为dp[i - 1][j]> dp[i - 1][j - weight[i]] + value[i]
2、放了第i个物品,这个结果又包含了两种具体情况:
a) 在不需要调整前面物品的情况下,剩余空间够放第i个物品,且此时最优
b) 在调整前面物品的情况之后,剩余空间够放第i个物品,且此时最优
但这么想就复杂了,其实我们并不需要关心到底是哪种具体情况,我们只需要关心对于dp[i][j],放进或不放进第i个物品这两种情况可以从何处推导而来,哪种更优,这也是动规的关键。
i) 假设放进了第i个物品,则除去第i个物品的重量,我们需要知道在前面第i-1个物品中选取放进背包空间为j-weight[i]的最大价值,即dp[i-1][ j-weight[i]],那么这种情况的dp[i][j]就等于dp[i-1][ j-weight[i]]+value[i];
ii) 假设没放进第i个物品,则我们只需要知道dp[i-1][j],而dp[i][j]= dp[i-1][j]。
所以我们只需要比较两种结果的最大值即可,当然要加上(1、a)的特殊情况,因为出现该情况没有操作空间。
if (j < weight[i]) dp[i][j] = dp[i - 1][j];//第i个物品大小比背包空间还要大
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

3. dp的初始化

从递推公式可知,dp[i][j]需要由dp[i-1][j]或dp[i - 1][j - weight[i]]推导而来,如表。
在这里插入图片描述

dp[i][j]可能需要得知其上一行同列往前所有位置的信息,如表中红色格子所示。
所以第一行是没法推导而来的,需要初始化。示例代码中对第一行的初始化只将满足j>=weight[0]的位置初始化为value[0],是因为前面的dp默认就是0,如果在定义dp数组时,没有默认为0 ,是需要把前面的也赋为0的,其他位置无所谓,因为dp[i][j]不依赖自身的初始值。
注意虽然可以直接看出第一列为0,但其实第一列也是可以由递推公式推导而来,因为背包空间j=0,必然小于物品大小,故会沿用dp[i - 1][0],可以不初始化,而且默认也就是0。

4. 遍历顺序

于上表中模拟先物品后背包(一行一行遍历),或先背包后物品(一列一列遍历)的过程,均可以获得递推公式中的前文信息(上一行同列往前的所有状态),因此对于纯01背包问题,两种遍历顺序都是可以的,该题只需调换两个for循环顺序,但对于一些变体01背包问题是不一定的。
观察遍历的代码逻辑,先遍历物品更好理解。
先物品后背包的遍历方向上,物品i不能反向遍历(从左下往右上),因为i行需要i-1行同列之前的信息,但是背包j可以反向遍历(从右先往左,再从上往下),因为j不需要同行j之前列的信息,需要的是上一行j之前列的信息。先背包后物品的遍历方向上,i和j都不能反向遍历(从右下往左上)。
关于滚动数组优化,如图把每次更新过程还原成二维的,4个区域中如果递推公式的前文信息是来自于左上或右下,则要使用倒序,反之则要使用正序。
在这里插入图片描述

如01背包dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
需要的dp[i - 1][j], dp[i - 1][j - w[i]] 都来自左上,用倒序;
完全背包dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i]);
来自左下(和上面一个),用正序。

状态压缩(优化)

观察递推公式或者状态表,可以发现每次状态转移过程只需要上一行的信息,且任务不需要记忆全部状态,故可以利用滑动数组把二维dp数组降成一维,每次只更新维护一行空间的状态dp[i]即可。
例如该题每次递推需要dp[i-1][x],即按行更新,我们可以省去行下标,只需维护某一行的状态,每次转移原地操作。为了便于理解,使用dp[j]来表示,j还是代表当前行背包空间为j时能装的最大价值。核心代码如下:

vector<int> dp(m + 1, 0);
for (int i = 0; i < n; i++) { // 也可以用for(auto num:nums){},从0开始是隐含了部分初始化
    for (int j = m; j >= weight[i]; j--) {
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

由于我们是优化掉了物品i的维度,故只能是先物品后背包的遍历顺序(一行一行遍历),同时背包的遍历顺序只能反向,首先前文已经说明了该顺序的可行性,现在说明为什么不能正向。由于是原地操作,使用的是同一个一维数组,而每个状态dp[j]依赖其前面的状态,故如果从前往后会覆盖掉原数组,影响后面状态的推导,反向则不会影响前面的状态,从意义上看,由于后面背包j的更新会依赖前面的背包j-x,而背包j-x可能已经把当前物品i放进去了,导致再考虑背包j的时候如果选择累加背包j-x的状态会导致再多放一个当前物品i(这就是为什么完全背包要正序放)。不能正向的问题也可以通过用两个一维数组来回倒腾解决,把二维dp数组只定义有dp[0][j]和dp[1][j],然后对循环中dp的i索引对2取余即可,dp[i%2][j] = max(dp[(i – 1)%2][j], dp[(i – 1)%2][j - weight[i]] + value[i]);

在背包的遍历条件上,当j<weight[i]时,就是二维数组中背包空间j小于第i个物品大小dp[i][j]=dp[i-1][j]的情况了,由于我们是倒序之后j–只会更小,且是原地操作,也不需要更新。

关于初始化,该代码需要全部初始化为0,因为递推公式中出现了dp[j]与自身的原始值有关(只是滚动数组在表达式上的表现,实际意义并不是),所以根据题意,需要初始化为0,这样在max()时不会影响结果。其实相对于原本的二维数组中往上初始化了一个dp[-1][j]的全0行也可,这也是压缩后外层遍历i=0的原因,因为初始状态其实是i=-1。
其实对于滚动数组的初始化,通常可以隐含一部分在i=0,只初始化前几个即可,如果递推公式是max(),因为滚动数组的max()会涉及自身的比较,为不影响第一行的更新,大概率都初始化为0即可,当然也看具体任务。

注意,对于滚动数组依然可以调换遍历物品、背包的先后顺序(如果二维是可以的话),但是滚动数组dp的含义不同,一个是dp[i]表示当前空间的背包去装前i个物品,按增大背包每列更新;一个是dp[j]表示当前物品用背包j的能装多少 ,按新增物品每行更新。

完全背包

从每个物品变成每种物品,即每种物品不止一个,其状态有0~满。
从滚动数组的代码上看,完全背包和01背包的区别只在于的遍历方向,为什么是正序看状态压缩(优化)一节,如果还原成二维数组,还有i下标的区别。
滚动数组:

for (int i = 0; i < n; i++) { // 先遍历物品,从0开始是隐含了初始化
	for (int j = weight[i]; j < m + 1; j++) { // 再遍历背包
		// 背包大小j直接从weight[i]开始,前面都装不了当前物品i,原地继承即可
		dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

还原成二维数组:

for (int i = 1; i < n; i++) { // 先遍历物品
	for (int j = 1; j < m + 1; j++) { // 再遍历背包
		/* 递推式第二项要是dp下标是i,因为完全背包可以重复放物品,这样再加上正序遍历,
		会考虑同一行有当前物品i的时候,预留一个当前物品i的状态dp[i][j - weight[i]] */
		if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 当前物品大小比背包空间还要大
		else dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
		// 之前不用当前物品的状态和要用当前物品预留空间的状态取最大值
		// 因为完全背包物品可重复,所以后者状态是dp[i],即在有当前物品的情况下预留空间
    }
}

纯完全背包问题的物品和背包遍历顺序也是可以颠倒的。
爬楼梯问题也是一种特殊的背包问题,其物品只有2个,重量为1和2(阶楼梯),要求达到背包n(阶楼梯),还可以改成每次可以爬1-k步,那就是把物品数组改成长度为k。还能再变体为求方案数,爬楼梯的方案数显然为排列问题,121和112是不同的方案。

常见变体

以01背包为例,首先dp定性不同,如求价值和、方法数、物品数,同时再组合定量不同,如求恰好装capacity时(等于容量),至多装capacity时(不超过容量的最大值),至少装capacity时(不小于容量的最小值)。

求某状态的方案数

dp[i][j]代表前i个物品装进空间为j的背包的方案数
LeetCode:494.目标和,LeetCode:518.零钱兑换II
求到达某种状态有多少种方案,通常的递推公式为一维dp:

dp[j] += dp[j - nums[i]];

或二维dp:

dp[i][j]=dp[i-1][j]; 
if(j>=nums[i-1]) dp[i][j]+=dp[i-1][j-nums[i-1]];

即每行预留当前物品nums[i]的状态累加起来。

方案数的遍历顺序(组合与排列)

对于求方案数的组合问题(零钱对话II),背包的遍历顺序必须是先物品再背包:

for (int i = 0; i < n; i++) { // 先遍历物品
    for (int j = nums[i]; j < m + 1; j++) { // 再遍历背包,j<nums[i]的不用更新
        dp[j] += dp[j - nums[j]];
    }
}

这种顺序求出来是组合数,因为物品的放入是有顺序的,nums[i]一定在nums[i-x]后面,组合内部顺序是唯一的,{…, nums[i-x], nums[i], nums[i-x], …}。
如果先背包再物品:

for (int j = 0; j < m + 1; j++) { // 先遍历背包,外层还没有nums[i],在里面判断
    for (int i = 0; i < n; i++) { // 再遍历物品
        if(j>=nums[i]) dp[j] += dp[j - nums[j]];
    }
}

这种顺序求出来是排列数,因为每次更新是背包增大了,可能之前背包大小里装不了nums[i-x],现在扩大了又可以放进去了,导致nums[i-x]在nums[i]之后。
有点像回溯法里对树层或树枝去重的区别。

求某状态的物品(最大/最小)数量

LeetCode:474.一和零

dp[i][j]代表前i个物品装进空间为j的背包的最大/最小物品数

dp[i][j]=max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);

即上一行没有当前物品或上一行预留当前物品的状态+1,这个1是把当前物品放入,再多一个物品数量。
该题对物品有2个容量限制维度,故二维dp[i][j]已经是滚动数组降维后的,每层for循环都是倒序。
该变体问题物品、背包的先后遍历顺序无所谓。

要求排列顺序

LeetCode:139.单词拆分

对物品放入的顺序有要求,常规的先物品再背包的顺序只能是按物品的给定顺序,如果不要求排列顺序,只是组合问题,什么顺序都行,如果排列顺序会影响结果,则要换成先背包再物品才能遍历到所有排列顺序。
如果不要求返回每种方案的顺序,也可先用哈希表存放一遍物品,然后按常规的先物品后背包也可,判断条件加个在哈希表中是否存在。完全背包用set,因为单种物品可无限用,有就行,01背包用map,计数不小于0就行。

环形问题

LeetCode:213.打家劫舍II

动态数组首尾相连,可能会出现dp[0]也受尾部dp的影响。
解决方法:拆分成完全不考虑尾部和完全不考虑首部的两种线性问题,然后再对两者结果进行取舍。
线性问题函数subrob(),环形问题函数rob()

int subrob(vector<int>& nums) {
    int n = nums.size();
    if (n == 1) return nums[0];
    vector<int> dp(n, 0);
    dp[0] = nums[0]; dp[1] = max(nums[0], nums[1]);
    for (int i = 2; i < n; i++) {
        dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    return dp[n - 1];
}
int rob(vector<int>& nums) {
    if (nums.size() == 1) return nums[0];
    vector<int> nums1(nums.begin(), nums.end() - 1);
    vector<int> nums2(nums.begin() + 1, nums.end());
    return max(subrob(nums1), subrob(nums2));
}

树形DP

LeetCode:337.打家劫舍III、LeetCode:968.监控二叉树

用一个维度vector(2)代表每个结点的状态,然后分成选当前结点和不选当前结点两种状态,再写每种状态的递推公式。这种思想不只适用于树形DP,其他DP如股票问题也有使用,实际上这就是动态规划的主要形式之一。
打家劫舍问题推广到一般树:
在这里插入图片描述

由于在树中不能像数组一次性遍历递推(有些树形问题可以用中序遍历把树里的val提取转换成数组来解决),所以可以退化成递归算法,另一种形式的动态规划。
其实就是线性dp数组只有一条递推路径,一次遍历从头到尾全搞定,但树形dp有多条分叉,必然要用递归的方法,对每个递归内部进行递推公式,由于递归用到了栈,必然要先递到最底层,先开辟所有需要的空间,可能没法用滚动数组优化。当然本质还是靠归的递推过程,要用后序遍历先获得左右子树的状态。
那这种递归的复杂度为什么是dp思想而不是回溯算法呢?因为这种递归并不存在重复路径,整个二叉树上每个结点意义都不相同。之前的回溯问题中的树是抽象意义的树,所有结点是由有重复含义的元素组成的,n<<Tree,而树形dp是真有一个树,所有结点就对应一个数组的每个元素,n=Tree,也就是说我们还是对这个树的高度、形状等已知。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值