前言
最近刷leetcode题目感触极深,随手摸到一道题都得写个一两个小时,然后看到题解里面各种大神说的啥啥啥这个用dp很好做啊,那个用BFS做的又击败了100%的人啊巴拉巴拉的…萌新看的就是不知所云,所以花了一天时间入门学习了一下dp算法(动态规划),记录下心得体会。
算法介绍
动态规划算法是五种常见的算法之一,通常用于求解具有某种最优性质的问题。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题。(摘自百度百科)
个人理解
这个算法和高中所学的递归的思想很相似,我们知道如果在程序中写大量的递归调用函数,会在栈区创建大量的指向函数的函数指针,也就是会导致栈的溢出。为了解决这种问题,我们可以用牺牲内存的方法来记录递归的每一步骤的结果,拿斐波那契数列前几个数来说:0, 1, 1, 2, 3, 5。
递归写法:
int fib(int num)
{
//递归
if ((num == 1) || (num == 0))
{
return num;
}
return fib(num-1)+fib(num-2);
}
这代码应该很好理解,0, 1, 1, 2, 3, 5一共六个数。
输入fib(6)
= fib(5) + fib(4)
= fib(4) + fib(3) + fib(3) + fib(2)
= fib(3) + fib(2) + fib(2) + fib(1) + fib(2) + fib(1) + fib(1) + fib(0)
…
这里我们可以看出,递归的重点有两个;
1、初始条件易知(在例子中就是:fib(0) = 0和fib(1) = 1)
2、存在递归关系(在例子中就是:fib(n) = fib(n - 1) + fib(n - 2),n>=2)
动态规划写法:
int fib(int num)
{
if ((num == 1) || (num == 0))
{
return num;
}
int[] dp = new int[num];
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i < num; i++)
{
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[num - 1];
}
这段代码分了四块:
- 状态:dp[i]表示动态规划算法的状态,找准状态的含义十分重要,在一些复杂的动态规划算法题目中,起了至关重要的作用,选错了会导致一步错步步错的后果。在例子中它的含义是第i个斐波那契数列值。
- 初始化:初始化dp[0] = 0,dp[1] = 1,在实用中一般是初始化易知的几个值。
- 状态转移方程:其实也就是递推关系式,在例子中是dp[i] = dp[i - 1] + dp[i - 2],这个便是动态规划算法的核心所在也是最难的部分,找出了转移方程,算法基本上就完成了80%。
- 结果:dp[num - 1]这便是这个例子的结果。
下面把上述的代码划分成这四块分析一下:
int fib(int num)
{
//特殊情况剔除
if ((num == 1) || (num == 0))
{
return num;
}
//初始化部分
int[] dp = new int[num];
dp[0] = 0;
dp[1] = 1;
//状态转移方程
for(int i = 2; i < num; i++)
{
dp[i] = dp[i - 1] + dp[i - 2];
}
//结果
return dp[num - 1];
}
这便是动态规划算法的一个最简单的模型了,这个算法把从第0到num - 1的斐波那契数列值都存储在dp[i]中了,我们只要访问相对应的索引就可以找到我们要的结果,这样省去了大量的递归调用函数。接下来记录下算法题实战。
算法实战
例题一
LeetCode-53:最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
解:
这里我们分别从状态、初始化、状态转移方程、结果四个方面来分析问题。
步骤一:确定状态dp[i]的含义
我们不妨设dp[i]表示前i个数的连续最大和。
为什么这么设呢,因为我们很容易知道nums的长度为1时,最大子序列和很好求,nums长度为2时求解的难度在nums为长度1时的基础上加了一个数,也很好求,以此类推。我们好像就已经找到了一个递进的关系。
步骤二:初始化
dp[0] = muns[0]
我们最容易知道的结果自然是nums中只有一个数,于是只用初始化dp[0] = muns[0];
步骤三:状态转移方程
dp[i] = Math.max(dp[i-1] + nums[i], nums[i])
对输入进行分析 [-2,1,-3,4,-1,2,1,-5,4],
1、当有一个数时[-2]:
显然dp[0] = -2。
2、当有两个数时[-2,1]
从1前把分成两部分,左半部分连续最大和是dp[0],右半部分是新加入的1,这时候要找包括1的连续最大和要么是前一部分和1的加和,要么就是单独的一个1,因为1自己本身一个数也算是连续子序列。很显然因为我们要更大的,所以我们在dp[0] + 1和1之间作比较取更大的存到dp[1]中。
3、当有三个数时[-2,1,-3]:
同样,从-3的左边分成两部分,这时候连续最大也是考虑两种情况,第一种-3和dp[1],第二种-3自己。由此我们已经可以很明显的看出动态转移方程:dp[i] = Math.max(dp[i-1] + nums[i], nums[i])
步骤四:结果
根据以上分析可以知道,i个长度的连续子序列和最大存在了dp[i]中,于是我们只要去除dp数组中的最大值即可。
步骤五:代码实现
class Solution {
public int maxSubArray(int[] nums) {
int length = nums.length; //记录数组长
int[] dp = new int[length]; //生成同样长度的dp数组
int max = dp[0]; //在计算dp的同时记录最大值,省的最后再算一遍dp数组中的最大数
for(int i = 1; i < length; i++) //遍历nums
{
dp[i] = Math.max(dp[i-1] + nums[i], nums[i]);//转移状态方程
max = dp[i] > max ? dp[i]:max; //更新最大值
}
return max; //返回结果
}
}
总结:
这说白就是找规律的题目,规律不好找的时候就从最简单的枚举,多列举自然就看出了玄机。这个算法的时间复杂度是o(n),可以看到执行时间还是很短的。
例题二
LeetCode-62:不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?例如
,上图是一个7 x 3 的网格。有多少可能的路径?
说明
:m 和 n 的值均不超过 100。
示例 1
:
输入
: m = 3, n = 2
输出
: 3
解释
:
从左上角开始,总共有 3 条路径可以到达右下角。
- 向右 -> 向右 -> 向下
- 向右 -> 向下 -> 向右
- 向下 -> 向右 -> 向右
转自https://leetcode-cn.com/problems/unique-paths/
解:
这道题目同样从状态、初始化、状态转移方程、结果四个方面来分析问题。
步骤一:确定状态dp[i][j]的含义
dp[i][j]表示第i行第j列那一格到最右小角有几种方式。
我们首先找最容易解决的问题:
(1)1 * 1的表格:
显然dp[0][0]=1。
(2)1*2的表格:
显然dp[0][0] = 1,dp[0][1] = 1。
… …
所以为了方便我们很自然的想到了定义二维的dp表。
步骤二:状态转移方程
dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
同样从最简单的例子开始找规律:
2 * 2的 表格:
前文已经确定了dp[1][0] = 1,dp[0][1] = 1,这很明显咯,因为右侧和下面已经到边界了,所以最右侧一列只能选择向下移动,最下测一列只能选择向右移动。
那么从(0,0)到(0,1)的方法也只有向右侧移动一个的方式,所以根据这个图我们既可以看出:
dp[0][0] = dp[0][1] + dp[1][0]
然后我们也发现了特例就是:
1、右边界的所有只能向下移动。
2、下边界的所有只能向右移动。
这也为我们确定初始化的条件做好了铺垫。
步骤三:初始化
(1)右边界所有的dp[i][j] = 1
(2)下边界的所有dp[i][j] = 1
步骤四:结果
结果我们从2 * 2的表格就可以看出自然是存在了dp[0][0]中。
步骤五:代码实现
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[n][m];
for(int i = n - 1; i >= 0; i--)//遍历行列
{
for(int j = m - 1; j >= 0; j--)
{
if(i == n - 1 && j == m - 1)//最右下角一格
{
dp[i][j] = 1;
}else if(i == n - 1 && j != m - 1)//最底下一行
{
dp[i][j] = dp[i][j+1];
}else if(i != n - 1 && j == m - 1)//最右边一列
{
dp[i][j] = dp[i + 1][j];
}else//其他不在边界的情况
{
dp[i][j] = dp[i + 1][j] + dp[i][j + 1];//状态转移方程
}
}
}
return dp[0][0];//结果
}
}
算法时间复杂度o(m*n)
例题三
LeetCode63-不同路径2
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
说明
:m 和 n 的值均不超过 100。
示例 1
:
输入
:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出
: 2
解释
:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
- 向右 -> 向右 -> 向下 -> 向下
- 向下 -> 向下 -> 向右 -> 向右
这道题目就是上一题基础上稍微加了点,直接贴代码啦。
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if(obstacleGrid[0][0] == 1)
return 0;
int n = obstacleGrid.length;//行
int m = obstacleGrid[0].length;//列
int[][] dp = new int[n][m];
for(int i = n - 1; i >= 0; i--)
{
for(int j = m - 1; j >= 0; j--)
{
if(i == n - 1 && j == m - 1)
{
dp[i][j] = 1;
}else if(i == n - 1 && j != m - 1)//最底下一行
{
if(obstacleGrid[i][j + 1] == 1)
{
dp[i][j] = 0;
}else
{
dp[i][j] = dp[i][j+1];
}
}else if(i != n - 1 && j == m - 1)//最右边一列
{
if(obstacleGrid[i + 1][j] == 1)
{
dp[i][j] = 0;
}else
{
dp[i][j] = dp[i + 1][j];
}
}else
{
if(obstacleGrid[i + 1][j] == 1 && obstacleGrid[i][j + 1] != 1)
{
dp[i][j] = dp[i][j + 1];
}else if(obstacleGrid[i][j + 1] == 1 && obstacleGrid[i + 1][j] != 1)
{
dp[i][j] = dp[i + 1][j];
}else if(obstacleGrid[i + 1][j] == 1 && obstacleGrid[i][j + 1] == 1)
{
dp[i][j] = 0;
}else
{
dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
}
}
}
}
return dp[0][0];
}
}
例题四
LeetCode64-最小路径和
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:grid[n][m]
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
链接:https://leetcode-cn.com/problems/minimum-path-sum
解:
这道题目同样从状态、初始化、状态转移方程、结果四个方面来分析问题。
步骤一:确定状态dp[i][j]的含义
dp[i][j]表示第i行第j列到右下角的总和最小值
类似例题二,我们要求左上角的数到右下角的最小和,不妨就设dp[i][j]为每个方格到右下角的最小和。
步骤二:状态转移方程
dp[i][j] += Math.min(dp[i + 1][j],dp[i][j + 1])
同样从最简单开始找。
(1)、1 * 1
dp[0][0] = grid[0][0]
(2)、2 * 1
dp[0][0] = grid[0][0] + dp[1][0]
(3)、2 * 2
5可以和dp[0][1]或者dp[1][0]结合,但因为要最小和,所以我们自然可以得到:
dp[0][0] = Math.min(dp[0][1],dp[1][0]) + grid[0][0];
由此我们就可以看出状态转移方程了。
步骤三:初始化
(1)、右边界dp[i][j] = grid[i][j] + dp[i + 1][j]
(2)、下边界dp[i][j] = grid[i][j] + dp[i][j + 1]
(3)、右下角dp[i][j] = grid[i][j]
步骤四:结果
结果存在dp[0][0]中
步骤五:代码实现
class Solution {
public int minPathSum(int[][] grid) {
int n = grid.length;//获取行
int m = grid[0].length;//获取列
for(int i = n - 1; i >= 0; i--)//遍历行
{
for(int j = m - 1; j >= 0; j--)//遍历列
{
if(i == n - 1 && j != m - 1)//下边界
{
grid[i][j] += grid[i][j+1];
}else if(i != n - 1 && j == m - 1)//右边界
{
grid[i][j] += grid[i + 1][j];
}else if(i != n - 1 && j != m - 1)//其他情况
{
grid[i][j] += Math.min(grid[i + 1][j],grid[i][j + 1]);//状态转移方程
}
}
}
return grid[0][0];//结果
}
}
这段代码没有定义dp数组,因为grid数组里面的值只要利用一次即可,所以利用完之后可以对该位置上的值进行覆盖,这样可以减少内存的使用。
例题五
LeetCode70-爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出 :2
解释 :有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
链接:https://leetcode-cn.com/problems/climbing-stairs
简单说明
这题目和斐波那契数列很像,比如有5个台阶,从第五个向前跳,这时候你只有两个选择,跳到四或者跳到三。那如果我们已经知道了有三个台阶跳到终点的方法和四个台阶跳到终点的方法,我们就已经解决了5个台阶的问题啦。由此可见递推关系是:dp[i] = dp[i - 1] + dp[i - 2] (i>=2)
代码实现
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n + 1];//dp表
if(n<=1)//特殊情况剔除
{
return 1;
}
dp[0] = 1;//初始化
dp[1] = 1;//初始化
for(int i = 2; i <= n; i++)//遍历所有
{
dp[i] = dp[i - 1] + dp[i - 2];//状态转移方程
}
return dp[n]; //结果
}
}
惊了。。。生平第一次
例题六(难度+++)
LeetCode72-编辑距离
给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
(1)插入一个字符
(2)删除一个字符
(3)替换一个字符
示例 1:
输入: word1 = “horse”, word2 = “ros”
输出: 3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
示例 2:
输入: word1 = “intention”, word2 = “execution”
输出: 5
解释:
intention -> inention (删除 ‘t’)
inention -> enention (将 ‘i’ 替换为 ‘e’)
enention -> exention (将 ‘n’ 替换为 ‘x’)
exention -> exection (将 ‘n’ 替换为 ‘c’)
exection -> execution (插入 ‘u’)
来源:力扣(LeetCode)
未完待续。。。