动态规划解题策略
前言
动态规划作为笔试、面试中的常驻嘉宾一般都是重量级的题型。没有一定的训练基础很多时候难以找到较为有效的解决思路,因此我结合自己的笔试面试经历以及自己的复习经验总结出一些个人的拙见,希望能给各位的刷题之路提供一些思路。
本帖内容只是在自己平时刷题时总结出的一般性规律,仅供参考,总结的规律只是作为解题的一种通用性思路并不是所有的题都一定要套用,目前很多的算法题都是多种数据结构和不同的算法结合起来考察,因此最终的解决密钥还是:题刷百遍,其意自现。最后:如有错误还望不吝指正!
一、动态规划是什么
1.1 定义
动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理学、计算机科学、经济学和生物信息学中经常使用的通过将原问题分解成相对简单的子问题的方式求解复杂问题的方法。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
1.2 适用范围
- 最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,我们称该问题具有最优子结构性质(满足最优化原理)。最优子结构的性质为动态规划的求解提供了方向。
- 无后效性:子问题的解一旦确定,就不再改变,不受这之后、包含它的更大问题的求解决策影响,即前一个状态影响后一个状态的决策、但后一个状态不能影响前一个状态。
- 子问题重叠性:子问题重叠性指的是在递归算法自顶向下进行求解时,每次产生的子问题总不是新问题,有些问题的计算存在重复性。动态规划针对这种情况一般都会采用缓存存储之前的结果,之后求解如果需要直接查询进一步降低时间复杂度
二、解题策略
根据对动态规划定义以及适用范围的了解,我将动态规划的问题总结为四步骤。之后遇到相关问题凡是符合动态规划特性的问题都可以按照这四步骤来实现。
2.1 步骤一:创建状态函数
状态函数的解释
状态函数一般对应求解问题的最终结果,同时在子问题中状态函数也不失一般性。一般我们求解的动态规划问题问题解主要是以下几类:
- 最优解问题: 某个变量的最值
- 存在性问题:是否存在
- 统计性问题:某个变量有多少
状态函数的设定
状态函数的设定一般为线性或二维性问题,所以一般都是dp[i]
,或者dp[i][j]
的形式。
注意:状态函数的设定是求解问题的第一步,好的状态函数有助于写出简介清晰的状态方程,否则很容易将思路带入死胡同,这一点需要大量的做题经验进行积累。此外这里提供一条设置经验:“尽量基于问什么设什么的原则”。这里可能比较抽象,之后会对这个原则详细展开。
2.2 步骤二:构建状态转移方程
状态转移方程的解释
状态转移方程反应的是在整个大问题下不同阶段子问题与前一个或前几子问题之间的关系,并且大问题的最终解是由递推公式给出的,反映在最终一个子问题上。
状态转移方程的设定
基于第一步所给出的状态函数,分类讨论当前问题的解的影响参数有几个,其中不同的决策会导致当前问题解如何去选择前面已经计算的问题解,用于更新当前子问题的解避免重复计算。这一点也是动态规划优化算法的关键所在
注意:具体问题具体分析,基于问题更容易理解状态转移方程设定的内在规律。记住一个设定的基本思想“分类讨论”;因为每个子问题涉及的状态有多种,并且不同子问题的影响状态又相似,这也是反应动态规划解决重复性子问题的特点
2.3 步骤三:边界初始化
边界初始化考虑的是当问题最初始状态情况下状态函数的值,因为前面没有可以依赖计算的状态值,所以这里初始值要自己设定,一般线性问题都是设置dp[0]
, 二维问题都是设定dp[0][0]
、dp[][]外围边界
,或者是dp[m][n]
2.4 步骤四:终止条件
即动态规划最终求解的结果,一般由动态转移时迭代方向确定
三、经典题举例
根据动态规划中问题特征,我们将动态规划的相关问题分为三大类,不同类别有不同的解题模板以及相应的优化策略
3.1 第一类:滚动数组
例题:打家劫舍
问题分析:
由于问题中要求遍历所有房间后,不触发警报能够窃取的最大金额。我们将问题抽象来看:问题最终就是想要遍历整个数组,求解数组中子序列最大和并且保证每个元素都不与其他元素相邻。
我们按照四步骤求解该问题:
1. 创建状态函数
由于问题中问最大的金额,那么基于问什么设什么的原则,于是就是有:
状态转移数组(函数):dp[i]
表示当经过第i个房间后小偷所拥有的最大金额
2. 状态转移方程
分类讨论:
- 每当经过一个房间 i 时候,小偷有两种选择,偷取当前房间的金额那么此时
dp[i] = money[i] + dp[i - 2]; // 由于相邻的房间不能偷
- 不偷取当前房间金额
dp[i] = dp[i - 1];
将上述状态进行合并就得到最终的状态转移方程:
dp[i] = Math.max(money[i] + dp[i - 2], dp[i - 1]);
3. 边界初始化
由于小偷从第一个房间偷取,那么没什么选择,当第二个房间时候,窃取与否取决于前一个房间与当前房间的金额大小
dp[0] = money[0];
dp[1] = Math.max(money[0], money[1]);
4. 终止条件
dp[n - 1];
代码实现
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0)
return 0;
int len = nums.length;
if (len == 1)
return nums[0];
// 动态数组
int[] dp = new int[len];
dp[0] = nums[0]; // 边界条件,一个房间
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < len; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i-1]);
}
return dp[len - 1];
}
}
代码优化,由于我们发现每次计算时候,dp[i]
只与dp[i - 1]
或者dp[i - 2]
有关,那么我们就可以将动态数组省略,使用两个变量存储迭代过程中dp[i - 1]
与 dp[i - 2]
;
3.2 第二类:坐标类DP
例题:最大正方形
问题分析:
由于题中问矩阵中,最大的正方形面积。因此直观的思路就是扫描整个矩阵检查正方形最大的面积,但是遍历过程中应当是从最小的一个单元入手,逐渐增加遍历的面积进而更新最大正方形。由于每次添加新的坐标时候,以前遍历过的坐标也会对当前的决策产生影响,因此这是一道典型的坐标型动态规划问题。具体解决四步骤如下:
1. 设定状态
基于问什么设什么的原则,设状态dp[i][j]
表示以当前坐标为正方形右下角,向左上扩展所计算的最大正方形边长
2. 状态转移方程
如果当前结点 m[i][j] == 1
:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1;
如果当前结点 m[i][j] == 0
:
dp[i][j] = 0; // 因为当前结点不可能在由1组成的正方形中
3. 边界初始化
dp[i][0] = m[i][0];
dp[0][j] = m[0][j];
4. 终止条件
dp[m][n];
代码实现
public int maximalSquare(char[][] matrix) {
int maxSide = 0; // 记录最大正方形边长
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return maxSide;
}
int rows = matrix.length, columns = matrix[0].length;
int[][] dp = new int[rows][columns];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
// 边界初始化
dp[i][j] = 1; // 如果值为1,边界初始化为1
}else {
// 动态规划方程
dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
maxSide = Math.max(maxSide, dp[i][j]);
}
}
}
return maxSide * maxSide;
}
代码优化:
可以对每一行进行dp
, 也就是当遍历行的时候,其实也是相当于一维上的动态规划,因此可以进行优化,使用两个数代替整个数组。之后我们就可将整个二维数组降维变成一维的数组进行动态规划
3.3 第三类:记忆化搜索
记忆化搜索本质上也是动态规划的一种,一般考察较少,其中具体的细节有些不同但是整体仍然满足动态规划的四步骤
**例题:最长连续递增子序列 **
由于四步骤我个人也总结的不好,这里贴一个leetcode
的题解:最长连续递增子序列
代码实现
int[][] dirs = {{0, 1}, {0, -1}, {-1, 0}, {1, 0}}; // 深搜方向
int row, column;
// 矩阵中递增的最长路径
public int longestIncreasingPath(int[][] matrix) {
// 特殊输入
if (matrix == null || matrix.length == 0 || matrix[0].length == 0)
return 0;
row = matrix.length;
column = matrix[0].length;
// 声明记忆化搜索矩阵
int[][] memo = new int[row][column];
int ans = 0;
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
ans = Math.max(ans, dfs(matrix, i, j, memo));
}
}
return ans;
}
private int dfs(int[][] matrix, int i, int j, int[][] memo) {
if (memo[i][j] != 0)
return memo[i][j];
memo[i][j]++; // 路径长度增加
for (int[] dir : dirs) {
int newRow = dir[0] + i, newCol = dir[1] + j;
// 判断是否越界,并且找到下一个能够递增的方向
if (newRow >= 0 && newRow < row && newCol >= 0 && newCol < column &&
matrix[newRow][newCol] > matrix[i][j]) {
memo[i][j] = Math.max(memo[i][j], dfs(matrix, newRow, newCol, memo) + 1);
}
}
return memo[i][j];
}
记忆化搜索的特征:
- 状态转移特别繁琐,没有顺序性
- 初始状态不容易找到
- 一般从大到小进行递推
3.4 第四类:博奕类DP
这类型的动态规划一般涉及两个人进行轮流决策,满足退出条件后比较两个人的变量大小进而提出整个过程中,使得某一方胜利的条件。这类问题也需要记忆化搜索进行优化求解,画决策树来辅助初始化边界条件,看一道例题:
已知有n个相同的硬币,两个人每次必须拿1 或者 2个,谁最后拿完谁赢。判断当前n条件下 先手是否必赢?
画出决策树,如图所示:
从最后决策树中可以分析得到,在后手抓取并剩余三个的话先手必赢由此我们按照四步骤来解决问题:
1. 状态函数
设dp[i]
表示当前剩余 i 个硬币,并且轮到先手拿取时,最后所代表的输赢情况(true & false)
2. 转移方程
根据决策树的展示,对于当前先手层只有i个硬币,那么下一次先手层的可能决策选项就有如下四个,必赢的轨迹必须保证如下条件
dp[i] = (dp[i - 2] && dp[i - 3]) || (dp[i - 3] && dp[i - 4])
3. 边界初始化
dp[0] = false; dp[1] = true; dp[2] = true; dp[3] = true;
4. 终止条件
dp[n];
代码实现
public boolean findWinner(int n) {
if (n < 3) return true;
// 初始化dp数组, dp[i] 代表还剩余i个且轮到先手抓, dp[i] = 1 & 2 & 0; 0 is empty
// 1 is false; 2 is true
int[] dp = new int[n + 1];
// 从大到小遍历
boolean res = memorySearch(dp, n);
return res;
}
// 记忆化搜素
private boolean memorySearch(int[] dp, int cur) {
if (dp[cur] != 0) {
if (dp[cur] == 1)
return false;
else
return true;
}
// 初始化边界状态
if (cur <= 0) dp[cur] = 1; // 没有被标记
else if (cur == 1) dp[cur] = 2;
else if (cur == 2) dp[cur] = 2;
else if (cur == 3) dp[cur] = 1;
else {
// 动态转移
if (memorySearch(dp, cur - 2) && memorySearch(dp, cur - 3) ||
memorySearch(dp, cur - 3) && memorySearch(dp, cur - 4)) {
dp[cur] = 2;
}else
dp[cur] = 1;
}
if (dp[cur] == 2) return true;
return false;
}
3.5 小结
1. 什么样的情况下使用动态规划?
- 求最大值最小值
- 判断是否可行(return true || false)
- 统计最终方案的个数
2. 什么情况下极不可能使用动态规划
- 求出所有方案的类型,即一个集合的所有子集
- 输入的数据是一个集合而不是序列
四、拓展参考题
接雨水
代码实现
// 动态规划实现
public int trap(int[] height) {
if (height == null || height.length == 0)
return 0;
int ans = 0; // 总的积数量
int size = height.length;
int[] leftMax = new int[size]; // 从左扫描,当前能观察到的最大高柱子
int[] rightMax = new int[size]; // 从右扫描,能看到的最高柱子
leftMax[0] = height[0];
// 从左扫描
for (int i = 1; i < size; i++)
leftMax[i] = Math.max(leftMax[i - 1], height[i]);
rightMax[size - 1] = height[size - 1];
// 从右扫描
for (int j = size - 2; j >= 0; j--)
rightMax[j] = Math.max(rightMax[j + 1], height[j]);
// 最后一次遍历,求积水量
for (int i = 1; i < size - 1; i++)
ans += Math.min(leftMax[i], rightMax[i]) - height[i];
return ans;
}
股票利润最大值
代码实现
class Solution {
public int maxProfit(int[] prices) {
if (prices.length < 2) return 0; // 没有卖出的可能性
// 定义状态,第i天卖出的最大收益
// int[] dp = new int[prices.length];
int dp = 0; // 初始边界
int cost = prices[0]; // 成本
for (int i = 1; i < prices.length; i++) {
dp = Math.max(dp, prices[i] - cost);
// 选择较小的成本买入
cost = Math.min(cost, prices[i]);
}
return dp;
}
}
礼物最大值
代码实现
class Solution {
public int maxValue(int[][] grid) {
int m = grid.length, n = grid[0].length;
for(int j = 1; j < n; j++) // 初始化第一行
grid[0][j] += grid[0][j - 1];
for(int i = 1; i < m; i++) // 初始化第一列
grid[i][0] += grid[i - 1][0];
for(int i = 1; i < m; i++)
for(int j = 1; j < n; j++)
grid[i][j] += Math.max(grid[i][j - 1], grid[i - 1][j]);
return grid[m - 1][n - 1];
}
}
交错字符串
待续…