动态规划,简称DP;在面试中的算法题中也很常见,是求最优化解的一种常用思路,之前也是一直困扰我的一个问题。
通常的使用套路(一步一步优化)
① 暴力递归(自顶向下,出现了重叠子问题)
② 记忆化搜索(自顶向下)
③ 递推(自底向上)
动态规划常用步骤:
动态规划中的“动态”可以理解为是“会变化的状态”
① 定义状态(状态是原问题、子问题的解) 比如定义 dp(i) 的含义
② 设置初始状态(边界) 比如设置 dp(0) 的值
③ 确定状态转移方程 比如确定 dp(i) 和 dp(i – 1) 的关系
含义:
① 将复杂的原问题拆解成若干个简单的子问题
② 每个子问题仅仅解决1次,并保存它们的解
③ 最后推导出原问题的解
可以用动态规划来解决的问题,通常具备2个特点 最优子结构(最优化原理):
通过求解子问题的最优解,可以获得原问题的最优解;无后效性;某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响(未来与过去无关);在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的
无后效性

从起点(0, 0)走到终点(4, 4)一共有多少种走法?只能向右、向下走
假设 dp(i, j) 是从(0, 0)走到(i, j)的走法
dp(i, 0) = dp(0, j) = 1
dp(i, j) = dp(i, j – 1) + dp(i – 1, j)
无后效性
推导 dp(i, j) 时只需要用到 dp(i, j – 1)、dp(i – 1, j) 的值
不需要关心 dp(i, j – 1)、dp(i – 1, j) 的值是怎么求出来的
如果可以向左、向右、向上、向下走,并且同一个格子不能走 2 次
有后效性
dp(i, j) 下一步要怎么走,还要关心上一步是怎么来的
也就是还要关心 dp(i, j – 1)、dp(i – 1, j) 是怎么来的?
找零钱
在贪心算法那一张中我们也讲到了这一题,但是我们当时只用了贪心算法,在这道题中并不能求出最优解
贪心
今天我们利用动态规划来解决这道问题。
问题:
假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?
假设 dp(n) 是凑到 n 分需要的最少硬币个数
如果第 1 次选择了 25 分的硬币,那么 dp(n) = dp(n – 25) + 1
如果第 1 次选择了 20 分的硬币,那么 dp(n) = dp(n – 20) + 1
如果第 1 次选择了 5 分的硬币,那么 dp(n) = dp(n – 5) + 1
如果第 1 次选择了 1 分的硬币,那么 dp(n) = dp(n – 1) + 1
所以 dp(n) = min { dp(n – 25), dp(n – 20), dp(n – 5), dp(n – 1) } + 1
暴力递归
示例:
static int coins1(int n) {if (n < 1) return Integer.MAX_VALUE;if (n == 25 || n == 20 || n == 5 || n == 1) return 1;int min1 = Math.min(coins1(n - 25), coins1(n - 20));int min2 = Math.min(coins1(n - 5), coins1(n - 1));return Math.min(min1, min2) + 1;}
记忆化搜索
示例:
static int coins2(int n) {if (n < 1) return -1;int[] dp = new int[n + 1];int[] faces = {1, 5, 20, 25};for (int face : faces) {if (n < face) break;dp[face] = 1;}return coins2(n, dp);}static int coins2(int n, int[] dp) {if (n < 1) return Integer.MAX_VALUE;if (dp[n] == 0) {int min1 = Math.min(coins2(n - 25, dp), coins2(n - 20, dp));int min2 = Math.min(coins2(n - 5, dp), coins2(n - 1, dp));dp[n] = Math.min(min1, min2) + 1;}return dp[n];}
自顶向下的调用
为什么我们要用数组来存储对应的值呢?是不是有人想用哈希才存储值?那么问题来了,如我们去了解一下哈希表的底层,看一下哈希的实现,我们会发现,哈希的底层实现就是用数组来完成的,但是为了有更快的查找速度,在哈希表中,我们开辟的数组空间可能有很多都没有用上。所有我们使用数组会更加的减少内存的浪费。
递推实现
这种方法没有使用递归的方式
示例:
static int coins3(int n) {if (n < 1) return -1;int[] dp = new int[n + 1];for (int i = 1; i <= n; i++) {int min = Integer.MAX_VALUE;if (i >= 1){min = Math.min(dp[i-1],min);}if (i >= 5){min = Math.min(dp[i - 5], min);}if (i >= 20){min = Math.min(dp[i - 20], min);}if (i >= 25){min = Math.min(dp[i - 25], min);}dp[i] = min + 1;}return dp[n];}
它是从较小的值开始计算,如果n为41,那我们就从1开始计算,根据代码所示,在四之前我们都是用价值为1的硬币,当达到五之后我们用价值为5的硬币,……
整个dp数组存储着之前每个值所需的硬币数,每次寻找都是根据之前最少的硬币数,再加上现在所要的那一枚,
根据硬币所给的值,划分成不规则的分组。
通用实现
现在我们解决了找零钱的问题,但是我们做的是零钱地固定值,面试的时候面试官出的题不可能是和现在一的一样吧,就算代码上的数能改,要是每次给你的数都不一样呢?那咋办,哪能每次都改一下吗?那是不可能的,所以我们需要一个通解,但这不是让大家被代码,而是对这种类型题的更深层次理解。
第一件事就是根据上面的代码,我们要怎么才能知道我们每次凑齐的是哪些硬币
static int coins4(int n) {if (n < 1) return -1;int[] dp = new int[n + 1];// faces[i]是凑够i分时最后那枚硬币的面值int[] faces = new int[dp.length];for (int i = 1; i <= n; i++) {int min = dp[i - 1];faces[i] = 1;if (i >= 5 && dp[i - 5] < min) {min = dp[i - 5];faces[i] = 5;}if (i >= 20 && dp[i - 20] < min) {min = dp[i - 20];faces[i] = 20;}if (i >= 25 && dp[i - 25] < min) {min = dp[i - 25];faces[i] = 25;}dp[i] = min + 1;print(faces, i);}// print(faces, n);return dp[n];}static void print(int[] faces, int n) {System.out.print("[" + n + "] = ");while (n > 0) {System.out.print(faces[n] + " ");n -= faces[n];}System.out.println();}
我们利用一个faces数组来记录最后一个添加硬币的值,之后我们不断向上推导,我们就知道了它是由哪些硬币组成的。
static int coins0(int n, int[] faces) {
if (n < 1 || faces == null || faces.length == 0) return -1;
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
int min = Integer.MAX_VALUE;
for (int face : faces) {
if (i < face) continue;
min=Math.min(dp[i-face],min );
}
dp[i]=min+1;
}
return dp[n];
}
我们用数组来存贮硬币的面值,利用for循环来进行我们之前的判断
最大连续子序列和
问题:
给定一个长度为 n 的整数序列,求它的最大连续子序列和 比如 –2、1、–3、4、–1、2、1、–5、4 的最大连续子序列和是 4 + (–1) + 2 + 1 = 6
状态定义
假设 dp(i) 是以 nums[i] 结尾的最大连续子序列和(nums是整个序列)
以 nums[0] –2 结尾的最大连续子序列是 –2,所以 dp(0) = –2
以 nums[1] 1 结尾的最大连续子序列是 1,所以 dp(1) = 1
以 nums[2] –3 结尾的最大连续子序列是 1、–3,所以 dp(2) = dp(1) + (–3) = –2
以 nums[3] 4 结尾的最大连续子序列是 4,所以 dp(3) = 4
以 nums[4] –1 结尾的最大连续子序列是 4、–1,所以 dp(4) = dp(3) + (–1) = 3
以 nums[5] 2 结尾的最大连续子序列是 4、–1、2,所以 dp(5) = dp(4) + 2 = 5
以 nums[6] 1 结尾的最大连续子序列是 4、–1、2、1,所以 dp(6) = dp(5) + 1 = 6
以 nums[7] –5 结尾的最大连续子序列是 4、–1、2、1、–5,所以 dp(7) = dp(6) + (–5) = 1
以 nums[8] 4 结尾的最大连续子序列是 4、–1、2、1、–5、4,所以 dp(8) = dp(7) + 4 = 5
状态转移方程和初始状态
动态规划这个问题的精髓;我就觉得就是状态转移方程和初始状态,我们就算理解了DP的思想,知道DP是怎么回事,但是我们如果遇到的这道题,我们不能找出他的状态转移方程;我们对这道题还是束手无策。
状态转移方程
如果 dp(i – 1) ≤ 0,那么 dp(i) = nums[i]
如果 dp(i – 1) > 0,那么 dp(i) = dp(i – 1) + nums[i]
初始状态
dp(0) 的值是 nums[0]
最终的解
最大连续子序列和是所有 dp(i) 中的最大值 max { dp(i) },i ∈ [0, nums.length)
在我看来,状态转移方程,最开始并不是要你直接写出公式,而是明白了他其中是怎么变化的,经过自己慢慢的炼化,最后总结出的公式
示例:
static int maxSubArray1(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length];
dp[0] = nums[0];
int max = dp[0];
for (int i = 1; i < dp.length; i++) {
int prev = dp[i - 1];
if (prev <= 0) {
dp[i] = nums[i];
} else {
dp[i] = prev + nums[i];
}
max = Math.max(dp[i], max);
}
return max;
}
我们创建了一个dp数组来存储以每个数结尾的最大连续子序列
空间复杂度O(n)
时间复杂度O(n)
优化

我们为什么要存储那些较小的值呢?我们不断遍历,寻找最大值,当前项sum为负数,我们再相加就是减小自身,所以我们重新的地方开始
static int maxSubArray2(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int dp = nums[0];
int max = dp;
for (int i = 1; i < nums.length; i++) {
if (dp <= 0) {
dp = nums[i];
} else {
dp = dp + nums[i];
}
max = Math.max(dp, max);
}
return max;
}
最长上升子序列(LIS)
问题:
给定一个无序的整数序列,求出它最长上升子序列的长度(要求严格上升)
比如 [10, 2, 2, 5, 1, 7, 101, 18] 的最长上升子序列是 [2, 5, 7, 101]、[2, 5, 7, 18],长度是 4
状态定义
假设数组是 nums, [10, 2, 2, 5, 1, 7, 101, 18]
dp(i) 是以 nums[i] 结尾的最长上升子序列的长度,i ∈ [0, nums.length)
以 nums[0] 10 结尾的最长上升子序列是 10,所以 dp(0) = 1
以 nums[1] 2 结尾的最长上升子序列是 2,所以 dp(1) = 1
以 nums[2] 2 结尾的最长上升子序列是 2,所以 dp(2) = 1
以 nums[3] 5 结尾的最长上升子序列是 2、5,所以 dp(3) = dp(1) + 1 = dp(2) + 1 = 2
以 nums[4] 1 结尾的最长上升子序列是 1,所以 dp(4) = 1
以 nums[5] 7 结尾的最长上升子序列是 2、5、7,所以 dp(5) = dp(3) + 1 = 3
以 nums[6] 101 结尾的最长上升子序列是 2、5、7、101,所以 dp(6) = dp(5) + 1 = 4
以 nums[7] 18 结尾的最长上升子序列是 2、5、7、18,所以 dp(7) = dp(5) + 1 = 4
最长上升子序列的长度是所有 dp(i) 中的最大值 max { dp(i) },i ∈ [0, nums.length)
static int lengthOfLIS1(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length];
int max = dp[0] = 1;
for (int i = 1; i < dp.length; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (nums[i] <= nums[j]) continue;
dp[i] = Math.max(dp[i], dp[j] + 1);
}
max = Math.max(dp[i], max);
}
return max;
}
最长公共子序列(LCS)
问题
求两个序列的最长公共子序列长度
[1, 3, 5, 9, 10] 和 [1, 4, 9, 10] 的最长公共子序列是 [1, 9, 10],长度为 3
ABCBDAB 和 BDCABA 的最长公共子序列长度是 4,可能是
ABCBDAB 和 BDCABA > BDAB
ABCBDAB 和 BDCABA > BDAB
ABCBDAB 和 BDCABA > BCAB
ABCBDAB 和 BDCABA > BCBA
思路和我们刚刚的思路是一样的,原本我们对应的是一个值,现在是多个值
上面的解题思路和之前的十分相似,大家应该都看的明白,下面才是重点想说的
由图可知,我们知道这其中每个数规律之后,我们再一次优化我们的代码
这一次我们开辟了一个二维数组,来维护我们表中的数,算是比较浪费空间的一种做法,但其实我们可以用一维数组来维护表中的一行,再到下一行的时候,我们对一维数组进行更新,这时候就会有神奇的事情发生,我在我们更新的过程中就会出现上下两行
而此时,我们拐角处的值我们就可以进行计算,我们在定义一个变量lefttop来记录左上角的值,用来计算相等时加1
static int lcs(int[] num1,int[] num2){
if(num1==null||num1.length==0)return 0;
if(num2==null||num2.length==0)return 0;
int[] rowsnum=num1,closnum=num2;
if(num2.length>num1.length){
rowsnum=num2;
closnum=num1;
}
int[] dp=new int[closnum.length+1];
for(int i=1;i<=rowsnum.length;i++){
int cur=0;
for (int j=1;j<=closnum.length;j++){
int lefttop=cur;
cur=dp[j];
if(rowsnum[i-1]==closnum[j-1]){
dp[j]=lefttop+1;
}else {
dp[j]=Math.max(dp[j],dp[j-1]);
}
}
}
return dp[closnum.length];
}
我们现在只是用整型数组作为例子,我们应该用字符串的形式来做这道题,原理一样,Java中的字符串也可以变成数组的模样用到toCharArray()函数
