动态规划(Dynamic Programming)
动态规划,简称DP,它是求解最优化问题的一种常见策略。例如前面章节中提到的找零钱问题,要求找的硬币个数最少;或者最大连续子序列和问题。所以,很多时候,有带最子的问题,都可以使用动态规划的方式来解决。
动态规划的使用套路:一步一步进行优化。也就是说,当对动态规划不够熟悉时,可能并不能直接就能想到使用动态规划的解法来解决一个问题,很有可能是一步一步进行优化,例如进行如下的方式
- 暴力递归(自顶向下,出现了重叠子问题)
- 记忆化搜索(自顶向下)
- 递推(自底向上)
另外,动态规划还有其他的基本理论,这里先不着急进行介绍。先通过一个示例场景问题,对动态规划有一个初步的了解。
场景一:找零钱[LeetCode]
假设有25分,20分,5分,1分的硬币,现在要找给客户41分零钱,如何办到硬币个数最少
在前面,这个问题曾经使用过贪心策略来进行求解,但是并没有得到最优解(贪心得到的解是5枚),但是使用动态规划来解决这个问题的话,就能得到最优解。
使用动态规划解该问题,其中假设dp(n)是凑到n分需要的最少硬币数量,存在以下几种情况:
- 如果第一次选择了25分的硬币,那么dp(n) = dp(n - 25) + 1;
- 如果第一次选择了20分的硬币,那么dp(n) = dp(n - 20) + 1;
- 如果第一次选择了5分的硬币,那么dp(n) = dp(n - 5) + 1;
- 如果第一次选择了1分的硬币,那么dp(n) = dp(n - 1) + 1;
所以现在dp(n)存在以上4中情况
那么,最小dp(n)的值为 dp(n) = min{dp(n - 25),dp(n - 20),dp(n - 5),dp(n - 1)} +1,也就是说从dp(n - 25),dp(n - 20),dp(n - 5),dp(n - 1)4中情况中选出硬币数量最少的一种,就是dp(n)硬币数量最少的情况
所以,现在相当于把dp(n)的所有情况都考虑到了。
通过上面的分析,最终得到的代码如下
static int coins(int n) {
if (n < 1) return Integer.MAX_VALUE;
if (n == 25 || n == 20 || n == 5 || n ==1) return 1;
int min1 = Math.min(coins(n - 25),coins(n - 20));
int min2 = Math.min(coins(n - 5),coins(n - 1));
return Math.min(min1,min2) + 1;
}
通过,这种方法,再次对41分零钱进行求解的话,最终得到的结果为3枚。是最终想要的结果
代码优化
仔细观察可以发现,上面这种计算硬币最少数量的解法,与前面介绍的斐波那锲数列的解法非常相似,存在非常多的重复计算问题,所以可以使用记忆搜索来进行优化,即计算过的值,进行记录后面直接使用,不在计算,所以,通过优化,得到代码如下
static int coins(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;
}
dp[1] = dp[5] = dp[20] = dp[25] = 1;
return coins(n,dp);
}
static int coins(int n, int[] dp) {
if (n < 1)return Integer.MAX_VALUE;
if (dp[n] == 0) {
int min1 = Math.min(coins(n - 25,dp),coins(n - 20,dp));
int min2 = Math.min(coins(n - 5,dp),coins(n - 1,dp));
dp[n] = Math.min(min1,min2) + 1;
}
return dp[n];
}
这种优化方式依然是采用自顶向下的方式来计算的,并且依然是递归调用, 所以还存在栈空间的开销,所以现在可以考虑采用另外的方式进行优化,结合前面的介绍,使用记忆化搜索以后,可以使用递推的方式来进行进行的优化,所以接下来使用递推的方式来进行优化
递推
递推采用的是自底向上的方式 来进行计算的。所以可以将上面的代码修改为如下方式
static int coins(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];
}
通过递推的方式来进行求解的话,其时间复杂度与空间复杂度均为O(n)
现在,通过递推的方式,将上面求解的代码进行整合,变得更加通用,结果如下
static int generalCoins(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];
}
为什么这种解法,叫做动态规划?
现在来回顾一下解决该问题,经过的步骤。
- 暴力递归
- 记忆化搜索
- 递推
所以,在最开始的时候,是使用暴力递归的方式,来对问题进行求解,对计算找n分零钱,最少需要多少枚硬币,转换为计算n - 1, n - 5 , n - 20, n - 25,这4中情况中中最少的一种情况再+1,就是找n分零钱最少的硬币数量。
后来,发现在进行暴力递归时,有很多重复的计算,所以就将暴力递归的方式进行了优化,改为了记忆化搜索的方式,记录某一面值,需要硬币数量的情况。但是记忆化搜索仅仅是解决了重复计算的问题, 它依然是自上而下的一种调用方式,并没有解决递归调用,消耗栈空间的问题。
最终,经过进一步的观察,发现可以将自顶向下的方式改为自底向上地方方式来进行计算,通过计算出较小的值,然后利用较小的值推导出较大值的这种方式,这样的话,就将递归调用给去掉了,所以最终的实现,就变为了上面最后的方式。
以上的整个过程,就可以理解为在执行动态规划的过程。结合上面的场景问题,接下来将对动态规划的常规步骤进行研究。
动态规划的常规步骤
动态规划中的“动态”可以理解为“会变化的状态”,其步骤如下
-
定义状态(何为状态:状态就是原问题,子问题的解)
-
例如定义dp(i)的含义
在解决零钱兑换问题时,就定义了dp(n)的含义,即凑到n分需要的硬币个数,所以dp(41)就表示凑够41分,需要的硬币个数;dp(4)就问凑够4分,需要的硬币个数
可以发现,定义的状态中的dp(n)就位最终要得到的解,同时也是子问题的解
-
-
设置边界条件(边界条件)
-
例如设置一些可以确定的值
在解决零钱兑换问题是,就定义了dp(1),dp(5),dp(20),dp(25)的值,这些值都是可以直接确定的
确定了初始状态以后,就可以进行下一步了
-
-
确定状态转移方程
- 比如确定dp(i)与dp(i - 1)之间的关系(利用旧的状态,推导出新的状态,比如现一直dp(i - 1)的值,利用dp(i - 1)推导出dp(i)的值)
动态规划的相关概念
来自维基百科的解释
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
根据上面的解释,可以得到以下结论
- 将复杂的原问题,拆解成若干个简单的子问题
- 每个子问题仅仅解决一次,并保存它们的解
- 最后推导出原问题的解
一般来讲,若可以利用动态规划来解决的问题,通常具备2个特点
-
最优子结构(最优化原理):通过求解子问题的最优解,可以获得原问题的最优解
- 上面的零钱兑换,是满足这个条件的
-
无后效性(也就意味着,该问题仅仅拥有最优子机构是不够的,还需要有无后效性)
-
即某阶段的状态一旦确定,则此后过程的演变,不会受到此前各状态及决策的影响(未来与过去无关)
例如,在零钱兑换时,是通过零钱比较少的状态,推导出零钱比较多的状态。在零钱少时确定的状态,并不会随着后面零钱边多后,最终的结果发生变化,而且后面零钱变多后的推导过程,也不会受前面零钱少时状态的影响
-
在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的
在零钱兑换时,兑换dp(41)的零钱,只关心dp(40),dp(36),dp(16),dp(21)的值是多少,并不关心这些值是怎么计算出来的
-
无后效性举例
现在有如下图所示的5*5格子
现在要从起点(0,0)走到终点(4,4),要计算一共有多少种走法(规定只能往右,往下走)
利用动态规划来解决的话,首先第一步是定义状态,定义状态如下
假设dp(i,j)是从(0,0)走到(i,j)的走法
可以发现,定义的这个状态,既是原问题的解,也是子问题的解
接下来设置边界条件,如下所示
dp(i,0) = dp(0,j) = 1
确定好边界条件以后,就可以设置状态转移方程了,通过规定可以知道,某个点dp(i,j)只可能从两个方向走过来,一个是从dp(i - 1,j)向右走一步到达dp(i,j),或者是从dp(i,j-1)往下走一步,到达dp(i,j),所以可以知道
dp(i,j) = dp(i - 1,j) + dp(i,j-1)
通过动态规划的步骤,最终就可以算出到达(4,4)一共的走法。
在这里,无后效性体现在:
推导dp(i,j) =时,只需要用到dp(i - 1,j) 与 dp(i,j-1)的结果,并不需要关心dp(i - 1,j) 与 dp(i,j-1)的值是怎么计算出来的
有后效性举例
现在同样有如下的格子
要从起点(0,0)走到终点(4,4),要计算一共有多少种走法。但是现在规定可以向左,向右,向上,向下走,并且同一格子不能走两次
有后效性体现在
现在假设要走到(i,j)这个点,要推导出下一个点dp(i + 1,j) 或 dp(i,j+1),由于规定同一个格子不能走两次,所以前面格子的走法就决定了后面点的走法,才能保证下一步的走法不会导致同一个点走两次
对动态规划的概念和步骤有一定的了解以后,接下来利用一些场景问题,对动态规划进行深入的理解。
场景二:最大连续子序列和
最大连续子序列和,这个问题,在分治策略部分章节曾有接触过,当时是通过分治的方法来进行求解,分别计算左边部分的最大连续子序列和/右边部分的最大连续子序列和/左右种均存在的最大子序列和,在计算出这三个值中最大的值,这是采用分治策略的一种解法。虽然通过这种方法计算出了最终的结果,但是分治策略来计算并不是最优的,接下来,就使用动态规划的方式来计算。
给定一个长度为n的整数序列,求它的最大连续子序列和
- 比如-2,1,-3,4,-1,2,1,-5,4的最大连续子序列和是4 + (-1) + 2 + 1 = 6
定义状态
采用动态规划的方式来进行求解的话,首先要做的就是定义状态dp(i),但是问题在于,dp(i)代表着什么含义呢?
在这里,期望得到的结果为计算出当前序列的最大连续子序列和。其实,等价于计算当前序列的最大连续子序列,只不过在计算出最大连续子序列以后,将这些序列的值加起来,就可以得到最大连续子序列了
但是,并不是任意一个子序列都是最终期望的解,所以可以先排除一些不必要的结果,也就是说,最大连续子序列可能是以序列中的某个元素结尾的序列,例如最大子序列可能为(1,-3),则是以-3结尾的序列,也可能是(4,-1,2,1),则是以1结尾的序列,也可能是(-1,2,1,-5),则是以-5结尾的序列
通过上面的分析,可以先计算出以序列中元素结尾的最大子序列出来,然后再选择这些最大子序列中,最大的序列,所以可以通过如下的方式来定义dp(i)的状态
dp(i)是以nums[i]结尾的最大连续子序列和(nums是整个序列)
所以,结合上面的序列,可以得到以下的结论:
- dp(0)表示以nums(0)结尾的最大连续子序列和,所以dp(0)的序列为(-2),那么要计算的序列就是以-2结尾的最大连续子序列,所以dp(0) = -2,最大子序列为(-2)
- 以nums(1)结尾的最大连续子序列和,所以dp(1)的序列为(-2,1),那么要计算的序列就是以1结尾的最大连续子序列,所以dp(1) = 1,最大子序列为(1)
- 以nums(2)结尾的最大连续子序列和,所以dp(2)的序列为(-2,1,-3),那么要计算的序列就是以-3结尾的最大连续子序列,所以dp(2) = -2(该序列必须包含-3,所以结果不能是1),最大子序列为(1,-3)
- 以nums(3)结尾的最大连续子序列和,所以dp(3)的序列为(-2,1,-3,4),那么要计算的序列就是以4结尾的最大连续子序列,所以dp(3) = 4,最大子序列为(4)
- 以nums(4)结尾的最大连续子序列和,所以dp(4)的序列为(-2,1,-3,4,-1),那么要计算的序列就是以-1结尾的最大连续子序列,所以dp(4) = 3,最大子序列为(4,-1)
- 以nums(5)结尾的最大连续子序列和,所以dp(5)的序列为(-2,1,-3,4,-1,2),那么要计算的序列就是以2结尾的最大连续子序列,所以dp(5) = 5,最大子序列为(4,-1,2)
- 以nums(6)结尾的最大连续子序列和,所以dp(6)的序列为(-2,1,-3,4,-1,2,1),那么要计算的序列就是以1结尾的最大连续子序列,所以dp(6) = 6,最大子序列为(4,-1,2,1)
- 以nums(7)结尾的最大连续子序列和,所以dp(7)的序列为(-2,1,-3,4,-1,2,1,-5),那么要计算的序列就是以-5结尾的最大连续子序列,所以dp(7) = 1,最大子序列为(4,-1,2,1,-5)
- 以nums(8)结尾的最大连续子序列和,所以dp(8)的序列为(-2,1,-3,4,-1,2,1,-5,4),那么要计算的序列就是以4结尾的最大连续子序列,所以dp(8) = 5,最大子序列为(4,-1,2,1,-5,4)
这样,就计算出了以每一个元素结尾的最大连续子序列和,最终要计算的整一个序列的最大连续子序列和,就是所有dp(i)中的最大值,即在本示例中的dp(0)至dp(8)的最大值,就为最终要得到的结果
规律:以nums(1)为例,要计算当前序列的最大连续子序列,就要参考前面的nums(0)的最大连续子序列的值,如果nums(0)的最大连续子序列和的结果小于等于0,那么nums(1)的最大连续子序列和就不需要加上nums(0)的值,所以,dp(i - 1) <= 0,那么dp(i)的值就位nums(i),否则dp(i) = dp(i - 1) + nums(i)
通过以上的规律,则可以得到以下的状态转移方程
- 如果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 maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length];
dp[0] = nums[0];
int max = dp[0];
System.out.println("dp[0] = " + dp[0]);
for (int i = 1; i < nums.length; i++) {
if (dp[i - 1] < 0) {
dp[i] = nums[i];
} else {
dp[i] = dp[i - 1] + nums[i];
}
max = Math.max(dp[i],max);
}
return max;
}
通过这种方式,不但计算出了当前序列的最大子序列和,并且还把以每一个元素结尾的最大连续子序列和也计算出来了。
通过这种方式的实现,空间复杂度为O(n),时间复杂度为O(n)
优化
由于现在的这种实现方式,空间复杂度比较高,通过对前面流程的分析,可以知道,在计算dp(i)的值时,只需要使用到dp(i - 1)的值,所以可以考虑将数组中的元素数量进行优化,优化后的结果如下
static int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int dp = nums[0];
int max = dp;
System.out.println("dp[0] = " + dp);
for (int i = 1; i < nums.length; i++) {
if (dp < 0) {
dp = nums[i];
} else {
dp += nums[i];
}
max = Math.max(dp,max);
System.out.println("dp[" + i + "] = " + dp);
}
return max;
}
通过这种方式进行优化以后,空间复杂度变为了O(1)
场景三:最长上升子序列(LIS)[LeetCode]
最长上升子序列(最长递增子序列,Longest Increasing Subsequence,LIS)
给定一个无序的整数序列,求出它最长上升子序列的长度(要求严格上升)
比如[10,2,2,5,1,7,101,18]的最长上升子序列是[2,5,7,101],[2,5,7,18],长度为4
定义状态:
上面要求求出最长上升子序列的长度,也就意味着,需要知道最长上升子序列的结果是什么。其实结合场景二的问题可以发现,最长上升的子序列,依然可以利用计算以nums(i - 1)时元素的最长上升子序列,进而推导出nums(i)时元素的最长上升子序列,所以通过上面的序列,可以得到以下的计算过程
- 以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) = 2
- 以nums[4],即1结尾的最长上升子序列是(2,5),即dp(4) = 2
- 以nums[5],即7结尾的最长上升子序列是(2,5,7),即dp(5) = 3
- 以nums[6],即101结尾的最长上升子序列是(2,5,7,101),即dp(6) = 4
- 以nums[7],即18结尾的最长上升子序列是(2,5,7,18),即dp(7) = 4
所以,最长上升子序列的长度是所有dp(i)中的最大值max{dp(i)},i∈[0,nums.length)
状态转移方程
设定另外一个变量j,其中j属于[0,j)
遍历j中的所有dp
- 当nums[i] > nums[j]时
- 说明nums[i]可以接在nums[j]后面, 形成一个比dp[j]更长的上升子序列,长度为dp(j) + 1
- dp(i) = max(dp(i),dp(j) + 1)
- 当nums[i] <= nums[j]时
- nums[i]不能接在nums[j]后面,需要跳过此次遍历
其中,初始状态dp[0] = 1;
所以,结合前面的分析,可以得到如下的代码
static int lengthOfLongestIncreasingSubsequence(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length];
dp[0] = 1;
int max = 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;
}
通过这种方式实现,其空间复杂度为O(n),时间复杂度为O(n^2)。需要注意,这里的空间复杂度不能像前面场景二的方式进行优化,因为每当要计算i为的dp值时,需要i前面所有的dp值,每一次都要用到前面的dp值,所以每一个dp值都需要存储,因此不能优化
不过,最长上升子序列还有更优的解法。可以利用二分搜索的思路来解决。由于在本节中主要是对动态规划进行讨论,所以二分搜索这种思路,在后面再进行介绍。接下来,继续对动态规划这种策略进行深入探讨。
场景四:最长公共子序列(LCS)[LeetCode]
最长公共子序列(Longest Common Subsequence,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
可以发现,这个问题与前面的最大连续子序列/最长上升子序列非常相似,所以依然可以利用动态规划的方式来进行解决。
前面在利用动态规划来解决问题时,首先要做的就是定义状态。但是在该场景下,不同的地方在于,这里有2个序列,所以现在的状态,就会与前面的不一样了
思路
现假设2个序列分别是nums1,nums2;并定义如下两个变量
- i ∈ [0,nums1.length]
- j ∈ [0,nums2.length]
然后,利用这两个参数来定义状态
假设dp(i,j)是【nums1前i个元素】与【nums2的前j个元素】的最长公共子序列,可以得到以下的信息
-
dp(i,0),dp(0,j)初始值均为0
-
并且如果nums1[i-1] (即nums1中的第i位)位的元素与nums2[j - 1] (即nums2中的第i位)位的元素相等的话,则有d(i,j) = dp(i - 1, j - 1) +1(只比较最后一位的元素是否相等即可,然后利用小的子问题,推导出大的子问题)
-
如果nums1[i-1] (即nums1中的第i位)位的元素与nums2[j - 1] (即nums2中的第i位)位的元素不相等的话,则存在两种情况
- 查看nums1的nums1[i - 2]与nums2[i - 1]的最长公共子序列是多少。因为可能nums2新增的这个元素,虽然不与nums1新增的这个元素相等, 但是可能与nums1[i - 2]序列中的元素相等
- 查看nums2的nums2[i - 2]与nums1[i - 1]的最长公共子序列是多少。原因同上
所以,如果nums1[i - 1] ≠ nums2[j - 1],那么dp(i,j) = max{dp(i - 1,j),dp(i,j - 1)}
这样,就确定了该问题的状态转移方程。
利用前面的分析,通过递归的方式实现如下:
static int longestCommonSubsequence(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0) return 0;
if (nums2 == null || nums2.length == 0) return 0;
return longestCommonSubsequence(nums1,nums1.length,nums2,nums2.length);
}
/*
* 求nums1前i个元素与nums2前j个元素的最长公共子序列
* */
static int longestCommonSubsequence(int[] nums1,int i,int[] nums2, int j) {
if (i == 0 || j == 0) return 0;
if (nums1[i - 1] == nums2[j - 1]) {
return longestCommonSubsequence(nums1,i - 1,nums2,j - 1) +1;
}
//最后一个元素不相等
return Math.max(longestCommonSubsequence(nums1,i,nums2,j - 1),longestCommonSubsequence(nums1,i - 1,nums2,j ));
}
这种方式实现的空间复杂度为:O(k),其中k = min{n,m},n,m是2个序列的长度;
由于这里的空间复杂度主要取决于递归的深度,所以,可以发现,当n,m中有一个值递减为0时,递归就会结束,所以取决于两个序列中长度更短的序列。
时间复杂度为:O(2^n),当n = m 时
将上面的递归实现改为非递归实现,结果如下
static int longestCommonSubsequence(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0) return 0;
if (nums2 == null || nums2.length == 0) return 0;
int[][] dp = new int[nums1.length + 1][nums2.length + 1];
for (int i = 1; i <= nums1.length; i++) {
for (int j = 1; j <= nums2.length; j++) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j],dp[i][j - 1] );
}
}
}
return dp[nums1.length][nums2.length];
}
这种方式实现
- 空间复杂度为:O(n*m)
- 时间复杂度为:O(n*m)
通过观察可以发现,dp(i,j)值的来源有三个地方,分别是dp[i - 1] [j - 1],dp[i - 1] [j] ,dp[i] [j - 1]。所以前面定义的二维数组,当计算到后面(i增大以后)的值时,前面存储的一些值,其实永远也用不上了,使用到的值可能是第i行的数据与i - 1行的数据,所以可以通过这种方式,利用滚动数组对上面的实现进行优化。
优化后的结果如下:
static int longestCommonSubsequence(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0) return 0;
if (nums2 == null || nums2.length == 0) return 0;
int[][] dp = new int[2][nums2.length + 1];
for (int i = 1; i <= nums1.length; i++) {
int row = i & 1;
int prevRow = (i - 1) & 1;
for (int j = 1; j <= nums2.length; j++) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[row][j] = dp[prevRow][j - 1] + 1;
} else {
dp[row][j] = Math.max(dp[prevRow][j],dp[row][j - 1] );
}
}
}
return dp[nums1.length & 1][nums2.length];
}
将二维数组进一步优化为一维数组
static int longestCommonSubsequence(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0) return 0;
if (nums2 == null || nums2.length == 0) return 0;
int[] dp = new int[nums2.length + 1];
for (int i = 1; i <= nums1.length; i++) {
int cur = 0;//保存左上角的值
for (int j = 1; j <= nums2.length; j++) {
int leftTop = cur;
cur = dp[j];
if (nums1[i - 1] == nums2[j - 1]) {
dp[j] = leftTop + 1;
} else {
dp[j] = Math.max(dp[j],dp[j - 1] );
}
}
}
return dp[nums2.length];
}
由于发现,nums1和nums2,在循环操作是,哪个数组作为i变量,哪个数组作为j变量,其实是没有影响的,通过两个循环,都能考虑到所有的情况,所以基于这种情况,可以进一步将dp数组的空间进行优化。可以选择length更小的数组来作为dp数组的长度
优化后的结果如下
static int longestCommonSubsequence(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0) return 0;
if (nums2 == null || nums2.length == 0) return 0;
int[] rowsNums = nums1, colsNums = nums2;
if (nums1.length < nums2.length) {
rowsNums = nums2;
colsNums = nums1;
}
int[] dp = new int[colsNums.length + 1];
for (int i = 1; i <= rowsNums.length; i++) {
int cur = 0;//保存左上角的值
for (int j = 1; j <= colsNums.length; j++) {
int leftTop = cur;
cur = dp[j];
if (rowsNums[i - 1] == colsNums[j - 1]) {
dp[j] = leftTop + 1;
} else {
dp[j] = Math.max(dp[j],dp[j - 1] );
}
}
}
return dp[colsNums.length];
}
通过这种优化,不管从空间复杂度还是时间复杂度,都已经达到了最优。
场景五:最长公共子串
最长公共子串(Longest Common Substring)
- 子串是连续的子序列
例如:ABCBA和BABCA的最长公共子串是ABC,长度为3
思路
假设两个字符串分别为str1,str2,并定义如下两个变量
- i ∈ [0,str1.length]
- j ∈ [0,str2.length]
假设状态dp(i,j)是以str1[i - 1],str2[j - 1]结尾的最长公共子串长度
其中规定dp(i,0),dp(0,j)的初始值均为0
所以,结合前面最长公共子序列的经验可以知道有如下转移方程情况
- str1[i - 1] = str2[j - 1],那么dp(i,j) = dp(i - 1,j - 1) + 1
- str1[i - 1] ≠ str2[j - 1],那么dp(i,j) = 0
最终,期望得到的值就是所有dp(i,j)中最大的值,即max{dp(i,j)}
通过上面的思路,最终得到的代码如下
static int longestCommonSubstring(String str1,String str2) {
if (str1 == null || str2 == null) return 0;
if (str1.length() == 0 || str2.length() == 0) return 0;
int[][] dp = new int[str1.length() + 1][str2.length() + 1];
char[] chars1 = str1.toCharArray();
char[] chars2 = str2.toCharArray();
int max = 0;
for (int i = 1; i <= chars1.length; i++) {
for (int j = 1; j <= chars2.length; j++) {
if (chars1[i - 1] == chars2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
max = Math.max(max,dp[i][j]);
}
// else {//值默认就是0 所以不用赋值
// dp[i][j] = 0;
// }
}
}
return max;
}
通过这种实现的空间复杂度与时间复杂度为:O(n*m)。结合前面最长公共子序列的优化经验可以知道,这里的二维数组同样可以进行优化,因为计算dp[i] [j]的值时,只会用到dp[i - 1] [j - 1]的值,所以优化思路和最长公共子串是一样的。
所以,优化后的结果如下:
static int longestCommonSubstring(String str1,String str2) {
if (str1 == null || str2 == null) return 0;
if (str1.length() == 0 || str2.length() == 0) return 0;
char[] chars1 = str1.toCharArray();
char[] chars2 = str2.toCharArray();
char[] rowsChars = chars1,colsChars = chars2;
if (chars1.length < chars2.length) {
rowsChars = chars2;
colsChars = chars1;
}
int[] dp = new int[colsChars.length + 1];
int max = 0;
for (int row = 1; row <= rowsChars.length; row++) {
int cur = 0;
for (int col = 1; col <= colsChars.length; col++) {
int leftTop = cur;
cur = dp[col];
if (chars1[row - 1] == chars2[col - 1]) {
dp[col] = leftTop + 1;
max = Math.max(max,dp[col]);
}
else {
dp[col] = 0;
}
}
}
return max;
}
通过优化后,空间复杂度为:O(k),k = min(m,n);
时间复杂度为:O(n*m)
场景六:0-1背包问题
0-1背包问题,在讨论贪心策略时,曾经介绍过该问题,但是当时也说到,利用贪心策略来解决该问题时,并不靠谱,因为根据不同的优先策略最终得到结果并不一样,所以并不靠谱,但是,如果利用动态规划的策略来解决该问题的话,最终的结果,就很固定了。
首先来回顾一下0-1背包问题的场景
有n件物品和一个最大承重为W的背包,没见物品的重量是w,价值是v
在保证总重量不超过W的前提下,将哪几件物品装入背包,可使得背包的总价值最大?
所以,现在就考虑使用动态规划来解决该问题。
定义状态:
现假设values是价值数组,weights是重量数组
- 编号为k的物品,价值是values[k],重量为weights[k],其中k∈[0,n)(n为物品的数量)
定义如下状态
dp(i,j)表示最大承重为j时,有前i件物品可选时的最大总价值,其中i ∈ [0,n],j ∈ [0,W]
例如:dp(3,7)
现有10件物品,但是只能选择前面的3件物品
最大承重量为7
初始值:
dp(i,0),dp(0,j)初始值为0;因为分别表示最大承重量为0时和可选的物品为0时的状态
那么,dp{i,j}如何才能通过子问题推导出来呢?
当面临i件物品时,有两种选择,
- 如果不选择第i件物品时,dp(i,j) = dp(i - 1, j),因为在第i件物品时,没有选,所以背包的称重量没有发生变换,只是现在的情况下,可选的物品比原来多了一件,但是最终的价值是一样的
- 如果选择第i件物品时,dp(i,j) = dp(i - 1, j - weights[i - 1]) + values[i - 1]
最终决定是否选择该件物品由最终的价值决定,所以取这两种情况中的最大值dp(i,j) = max(dp(i - 1, j),dp(i - 1, j - weights[i - 1]) + valuess[i - 1])
不过需要注意,除了上面容易考虑到的两种情况以外,还有另外一种情况,即当前最大称重量j小于第i件物品的重量时,该件物品就不可能选择,所以这个时候,有i件物品可选与i-1件物品可选时的结果是一样的,所以这个时候的结果为dp(i,j) = dp(i - 1, j)
所以,最终的状态转移方程如下
- j < weights[i - 1],那么dp(i,j) = dp(i - 1, j)
- j ≥ weights[i - 1],那么dp(i,j) = max(dp(i - 1, j),dp(i - 1, j - weights[i - 1]) + valuess[i - 1])
通过上面的分析思路,最终得到的代码如下
static int maxValue(int[] values,int[] weights, int capacity) {
if (
values == null ||
weights == null ||
values.length == 0 ||
weights.length == 0 ||
values.length != weights.length ||
capacity <= 0
) return 0;
int[][] dp = new int[values.length + 1][capacity + 1];
for (int i = 1; i <= values.length; i++) {
for (int j = 1; j <= capacity; j++) {
if (j < weights[i - 1]) {//当前最大称重量j小于第i件物品的重量时
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j],values[i - 1] + dp[i - 1][j - weights[i - 1]]);
}
}
}
return dp[values.length][capacity];
}
结合前面的经验,可以知道,计算第i行数据时,只会用到i-1行的数据,所以可以想到,dp数组是可以进行优化的。而且在利用前面的值计算后面的值时,用到了dp[i - 1] [j - weights[i - 1]]的值进行计算,所以如果还是用前面的方法来进行优化的话,[j - weights[i - 1]]的值是拿不到的,因为会被覆盖,所以为了保证能拿到[j - weights[i - 1]]的数据,现在考虑利用重量大的情况,推导出重量小的情况。这样就不会覆盖[j - weights[i - 1]]的值
最终,优化后的结果如下
static int maxValue(int[] values,int[] weights, int capacity) {
if (
values == null ||
weights == null ||
values.length == 0 ||
weights.length == 0 ||
values.length != weights.length ||
capacity <= 0
) return 0;
int[] dp = new int[capacity + 1];
for (int i = 1; i <= values.length; i++) {
for (int j = capacity; j >= 1; j--) {
if (j < weights[i - 1]) continue;
dp[j] = Math.max(dp[j],values[i - 1] + dp[j - weights[i - 1]]);
}
}
return dp[capacity];
}
扩展
最长上升子序列-二分搜索实现
在前面,利用动态规划遍历序列来进行查找,所以,通过动态规划的方式来查找,其时间复杂度为O(n^2),如果使用二分搜索的方式实现,则会更优。其思路如下
假设序列如下图所示
为了能更好的理解,现在请将每一个数组都看做为一张扑克牌,然后从左往右按顺序处理每一张扑克牌
- 将正在处理的牌,放在第一张大于等于当前牌的牌堆之上
- 如果找不到第一张牌大于等于当前正在处理的牌的牌堆时,就将它放入一个新的牌堆中
例如,
-
在最开始的时候,拿到的是10这张牌,但是这时候没有牌堆,所以这个时候10会成立一个新的牌堆
-
接下来,会拿到2这张牌,通过从左往右寻找牌堆,所以先找到了10这个牌堆,这个牌堆的牌顶是10,10这张牌是大于2这张牌的,所以会将2这张牌压在10这张牌的上面,结果如下
-
接下来,继续出列下一张牌2,通过寻找牌堆,发现现在有一个牌堆的牌顶是2,所以就会将正在处理的这张牌,压到牌顶为2的牌堆上,结果如下
-
接下来,会拿到5这张牌,然后从左往右查找,并没有发现牌顶大于等于5的牌堆,所以会新建一个牌堆
-
然后拿到下一章牌1,并且从左往右开始寻找牌堆,发现当前有一个牌堆的牌顶为2,大于等于当前的牌,所以就会将1这张牌,放在牌顶为2的牌堆中
-
拿到下一章牌7,发现所有牌堆的牌顶都不大于当前的牌,所以会新建一个牌堆,最终的结果如下
-
继续拿到下一张牌101,依然发现所有牌堆的牌顶都不大于当前的牌,所以又会新建一个牌堆,结果如下
-
接下来,会拿到最后一张牌18,从左往右查找发现,前面3个牌堆的牌顶都不大于当前的牌,所以最终18会放到最后一个牌堆上
现在,所有牌都已经排好了,并且发现,现在的最长上升子序列已经产生了,为2,5,7,101或者2,5,7,18.并且最终牌堆的数量,就是最长上升子序列的长度,且组成的元素来自于4个牌堆,通过这种思路,得到的代码如下
static int lengthOfLongestIncreasingSubsequence(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int len = 0;//牌堆的数量
int[] top = new int[nums.length];//牌顶数组
//遍历所有的牌
for (int num : nums) {
int i = 0;
for (; i < len; i++) {
if (top[i] >= num) {//找到了一个大于等于num的牌顶
top[i] = num;
break;
}
}
if (i == len) {
//所有牌顶小于当前的牌,新建牌堆
len++;
top[i] = num;
}
}
return len;
}
通过这种方式实现,可以发现如果是最坏的情况的话,时间复杂度依然是O(n^2),所以对比动态规划,也没有太多的提升。所以在这种情况下,可以利用二分搜索来进行优化。
由于发现牌堆从左往右的牌顶,是升序的。当有一个新的牌要放在合适的位置时,就可以利用二分搜索的方式来找到其对应的位置,然后找到位置后,直接覆盖牌顶即可,所以通过二分搜索优化后的结果如下
static int lengthOfLongestIncreasingSubsequence(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int len = 0;//牌堆的数量
int[] top = new int[nums.length];//牌顶数组
//遍历所有的牌
for (int num : nums) {
int begin = 0;
int end = len;
while (begin < end) {
int mid = (begin + end) >> 1;
if (num <= top[mid]) {
end = mid;
} else {
begin = mid + 1;
}
}
//覆盖牌顶
top[begin] = num;
//检查是否要新建牌堆
if (begin == len) len++;
}
return len;
}
完!
网友评论