一.前言
周末果然是堕落的根源,原谅我两天没刷题(PS:主要是因为周末搬家去了)。上次在这个题的时候,看到网上很多方法都是用动态规划做的,但是本渣渣实在不知道动态规划具体是怎样的,于是就专门花了花时间去研究了一下。肯定没这么快弄懂,只能说是稍微入门,于是写下这篇文章,帮助自己也帮助别人理解动态规划。
二.理论部分
动态规划是什么呢? 百度百科上的定义是:动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。用通俗的话来讲,动态规划是针对与某一类问题的解决方法。只要我们弄清楚了某一类问题是什么,那我们就知道什么是动态规划。
首先,我们举一个简单的例子,也是dp的入门经典问题:如果我们有面值为1元、5元和11元的纸币若干枚,如何用最少的纸币凑够12元?
按照人类的正常思维来说,首先我们会尽可能的使用大面值的纸币,所以会得出 12 = 11 x 1 + 1 x 1,最少使用两张纸币。这样一看好像这种方法没什么问题,可是当我们把问题换成凑够15元时,继续按照上面这种思路,我们会得到 15 = 11 x 1 + 1 x 4,也就是说,按照惯性思路,我们会认为最少需要五张纸币才能凑齐15元。但是我们可以轻易的看出,15 = 5 x 3,最少只需要三张纸币就能够凑齐15元。那么是为什么导致了这样的问题呢?因为我们之前的思路是,尽可能使用较大面值的纸币,来减小总额的大小,但是没有考虑到凑齐4元的需要四张纸币,这个代价非常大,这就是因为我们只考虑到了眼前的情况。这种方法其实就是常说的贪心算法,不过不在本篇的重点,以后有机会再补上。
那么我们继续做这个题,换种思路,首先设使用最少的纸币凑齐15元的问题为F(15),那么如果我们使用一张11元的纸币,剩下的问题就是F(4),如果使用了一张5元的纸币,那么剩下的问题就是F(10),如果使用了一张1元的纸币,那么剩下的问题就是F(14),因此,我们可以得到一个公式F(15) = Min{F(4), F(10), F(14)} + 1 ,这个时候假设我们已经知道了F(4) = 4 ,F(10) = 2, F(14) = 4(ps:先不用去管是怎么知道的),我们就可以轻易得出F(15) = F(10) + 1 = 2 + 1 = 3。上面的公式可以写成F(15) = Min{F(15 - 11), F(15 - 5), F(15 - 1)} + 1,如果把15换成N,那么我们就可以得到公式F(N) = Min{F(N - 11), F(N - 5), F(N - 1)} + 1 。这样,我们就把一个问题,转换成了它的几个子问题,这就是DP(动态规划:将一个大问题,拆为几个子问题,分别求解这些子问题,即可推断出大问题的解)。
知道了什么是动态规划后,我们来认识几个概念:
1.状态:什么是状态?状态就是当前我们所研究的问题,也就是上题中的F(N);
2.状态转移方程:状态转移方程就是我们如何从已知的状态,推导出现在未知状态的一个公式,也就是上题中的F(N) = Min{F(N - 11), F(N - 5), F(N - 1)} + 1 。
3.无滞后性:“未来与过去无关”,这句话是什么意思呢?以上题为例,F(4), F(10), F(14)为过去的状态,F(15)为当前到状态,F(N)为未来的状态,我们要计算F(15)的时候,需要使用F(4), F(10), F(14),而当F(15)被确定之后,F(N)的计算如果需要使用到F(15)的值,则会直接使用F(15)的值,而不需要去关系F(15)是怎么计算出来的,也就是与F(4), F(10), F(14)无关了。总结一下,就是某一阶段的状态一旦被确定,那么未来的状态的发展就不会受这一阶段之前状态的影响,这就是未来与过去无关。
4.最优子结构:F(n)的定义是使用最少到纸币凑齐N元,所以F(n)就是n元时的最优解,而我们求解F(n)的时候,需要使用到F(N - 11), F(N - 5), F(N - 1)的值,而这些状态就代表了自己的最优解,也就是说,大问题的最优解可以由小问题的最优解推出,这个性质叫做“最优子结构性质”。
5.重叠子问题:这个名词很好理解,我们在计算F(15)的时候利用到了F(10)这个子问题的解,而我们在计算F(21)的时候,也会利用到F(10)这个子问题的解,这就是重叠子问题,在计算的时候,子问题并不是独立的,而是会被重复使用多次。
了解了这几个概念之后,那么我们就来看看,如何确定一个问题能否使用动态规划来解决,又怎么样使用动态规划来解决。
dp问题的特点:一个问题具有最优子结构的性质,那么它就能够使用dp来解决,而具有重叠子问题的性质,则表示它用dp解决会更占优势。
dp问题的解法:1.先确定问题的状态
2.推导出状态转移方程
3.选择合适的数据结构保存子问题的解(一般是一维数组和二维数组)
4.给选定的数据结构赋初始值
三.dp经典例题
俗话说的好,纸上得来终觉浅 绝知此事要躬行。讲了一大堆的理论知识,可能大家看得也是半懂不懂(ps:也可能是我讲的太菜了),还是要从题目中入手,将理论知识运用到实际中,才算真正的理解了。
3.1 斐波那契数列
题目:大家都熟悉斐波那契数列,1 1 2 3 5 8 13 21 ...,现在求斐波那契数列第n位数。
定性:1.最优子结构:本来这个问题是没有包含最优的,但是第n位数的值都是唯一的,也可以看作是最优,并且第n位数可以由n-1和n-2的值推出,所以满足最优子结构。
2.重叠子问题:当n为3的时候,f(3) = f(2) + f(1) ,当n为4的时候,f(4) = f(3) + f(2) 。由此可以看到,f(2)被重复利用到了,所以存在重叠子问题。
解题:确定了可以使用dp之后,就可以按照我们的步骤来进行解题。
1.状态:F(n) 为斐波那契数列的第n位数。
2. 状态转移方程:F(n) = F(n-1) + F(n - 2)
3. 使用一个一维数组arr,保存每一位的值
4.初始值arr[0] = 1 , arr[1] = 1;
代码如下:
1 class Solution { 2 public int fib(int N) { 3 if(N <= 0){ 4 return 0; 5 } 6 if(N <= 2 ){ 7 return 1; 8 } 9 int[] arr = new int[N]; 10 arr[0] = 1; 11 arr[1] = 1; 12 for(int i = 2; i < N; i++){ 13 arr[i] = arr[i - 1] + arr[i - 2]; 14 } 15 return arr[N - 1]; 16 } 17 }
3.2 数组最大不连续递增子序列
题目:给定一个数组,求它的不连续最大递增子序列的长度,arr[] = {3,1,4,1,5,9,2,6,5}的最长递增子序列长度为4。即为:1,4,5,9。
定性:1.最优子结构,假设数组长度为n,如果我们知道了在n-1的所有以自己为结尾的最长递增子序列长度,那么我们就可以推算出以n结尾的最长递增子序列,所以满足最优子结构。
2.重叠子问题,在数组每增加一个元素后,计算以该元素为结尾的最长递增子序列长度,需要与前面每个元素做对比,并利用了它们的解,所以满足重叠子问题。
解题:1.状态F(N):这次的F(N)并不是数组长度为n时的最长递增子序列,而是以n为结尾时的最长递增子序列。如果采用前面那个作为状态F(N)的定义,那么则不满足最优子结构的性质,我们并不能由子问题的解推导出大问题的解,所以有时候状态的定义并不是那么直观,还需要转换一下,只有状态定义正确了,我们才能正确的使用dp。
2.状态转移方程:F(N) = Max{ arr[n - 1] > arr[ j] && F(j) + 1}
3.使用一个一维数组arr,保存每一个以它为结尾的最长递增子序列的值。
4.整个数组的值都初始化为1,因为每个位置最小的子序列就是它自己。
代码如下:
1 class Solution { 2 public int lengthOfLIS(int[] nums) { 3 if(nums == null || nums.length == 0){ 4 return 0; 5 } 6 int[] arr = new int[nums.length]; 7 for(int i = 0; i < arr.length; i++){ 8 arr[i] = 1; 9 } 10 int max = 1; 11 //从第二个元素开始,每个元素都需要与之前的元素进行比较,如果比它大,则将它保存的值+1 12 for(int i = 1; i < nums.length; i++){ 13 for(int j = 0; j < i; j++){ 14 if(nums[i] > nums[j]){ 15 max = max > arr[j] + 1 ? max : arr[j] + 1; 16 } 17 } 18 arr[i] = max; 19 max = 1; 20 } 21 //找出整个数组的最大值,则为答案 22 max = arr[0]; 23 for(int i = 1; i < arr.length; i++){ 24 max = max > arr[i] ? max : arr[i]; 25 } 26 return max; 27 } 28 }
3.3 数组最大连续子序列和
题目:给定一个数组,其中元素可正可负,求其中最大连续子序列的和。如arr[] = {6,-1,3,-4,-6,9,2,-2,5}的最大连续子序列和为14。即为:9,2,-2,5
定性:1.最优子结构:假设数组长度是n,我们在得到了以n-1为结尾的最大连续子序列的和,那么我们就可以推出以n为结尾的最大连续子序列的和,所以满足。
2.重叠子问题:因为我们在计算以n为结尾的最大连续子序列时,只需要利用到n-1的解,所以不满足重叠子问题,可以用dp解,但是不一定非要用dp解
解题:1.状态:F(N):以n为结尾时最大连续子序列的和
2.状态转移方程: F(N) = F(N-1) > 0 : F(N-1) + N : N
3.使用一个一维数组保存每一个值
4.初始化,无需初始化
代码如下:(因为这题目没有涉及到重叠子问题,所以用其他方法做也都是可以的,这里就不演示了)
1 class Solution { 2 public int maxSubArray(int[] nums) { 3 if(nums == null || nums.length <= 0){ 4 return 0; 5 } 6 int[] arr = new int[nums.length]; 7 arr[0] = nums[0]; 8 int max = arr[0]; 9 for(int i = 1; i < nums.length; i++){ 10 arr[i] = arr[i - 1] > 0 ? arr[i - 1] + nums[i] : nums[i]; 11 max = max > arr[i] ? max : arr[i]; 12 } 13 return max; 14 } 15 }
3.4 两个字符串最大公共子序列
题目:求两个字符串的最大公共子序列和,比如字符串1:BDCABA;字符串2:ABCBDAB,则这两个字符串的最长公共子序列长度为4,最长公共子序列是:BCBA
定性: 1.最优子结构:假设我们知道了i,j(1串以i结尾,2串以j结尾)的最大公共子序列,那么我们可以推出i+1,j的最长公共子序列,也可以推出i,j+1的最大公共子序列,所以满足。
2.重叠子问题:当我们在计算3,2的最大公共子序列时,需要用到2,2的最大公共子序列和3,1的最大公共子序列;当我们在计算4,1的最大公共子序列时,同意也用到了3,1的最大公共子序列,所以满足重叠子问题。
解题:1状态:F(i,j):字符串1以i结尾,字符串2以j结尾时,最大的公共子序列
2.状态转移方程:分两种情况,当i 和 j的字符相等时,这个字符一定在最大公共子序列中,所以F(i, j) = F(i -1, j -1) + 1;
当i和j不相等的时候,F(i,j),i和j这两个字符,肯定不会同时存在在最大公共子序列中,所以F(i,j)=Math(F(i,j-1),F(i - 1,j));
3.定义一个二维数组,用于存放结果,数组大小时字符串长度+1,多一行用来存储0字符的情况
4.初始化数组,将i为0或者j为0的都设置成0(当其中一个字符串为空时,两个字符串则不存在公共子序列)
代码如下:(本题的难点在于弄清楚状态转移方程,为什么分这两种情况)
1 public int maxTwoArraySameOrder(String str1, String str2){ 2 int[][] arr = new int[str1.length() + 1][str2.length() + 1]; 3 for(int i = 0; i <= str1.length(); i++){ 4 arr[i][0] = 0; 5 } 6 for (int j = 0; j <= str2.length(); j++){ 7 arr[0][j] = 0; 8 } 9 for (int i = 1; i <= str1.length(); i++){ 10 for (int j = 1; j <= str2.length(); j++){ 11 if (str1.charAt(i - 1) == str2.charAt(j -1)){ 12 arr[i][j] = arr[i - 1][j - 1] + 1; 13 }else { 14 arr[i][j] = Math.max(arr[i][j - 1], arr[i - 1][j]); 15 } 16 } 17 } 18 return arr[str1.length()][str2.length()]; 19 }
3.5 经典题目---01背包问题
在N件物品取出若干件放在容量为C的背包里,每件物品的体积为W1,W2……Wn(Wi为整数),与之相对应的价值为P1,P2……Pn(Pi为整数),求背包能够容纳的最大价值。对于每件物品只有放(1)和不放(0)两种状态,所以叫做01背包问题。
定性:1.最优子结构:对于第k个物品来说,只有两种情况,一种是放,一种是不放。假设我们得到了不放第k件物品时,前k -1个物品的最大值m1,同时我们也得到了在背包中预留第k个物品的空间后,前面k-1个物品所能得到的最大值m2,根据这两个值,我们就能够推算出第k个物品放还是不放所得到的价值更大。同时m1和m2也是前k-1个物品分别在不同容积下的最大值,这就满足了最优子结构。
2.重叠子问题:因为后面物体的体积不同,所以子问题存在重叠的情况。
解题:1.状态:F(K, C): 容量为C时,从前K个物品中选取的最大价值。
2.状态转移方程:F(K,C) = F(K - 1,C) (当第K件物品的体积大于C时,最后一件物品放不下,直接从前面K件物品中选择)
= Max{F(K - 1, C), F(K - 1,C- Wk) + Pk} (对于第K件物品,只有两种选择,要么放,要么不放,所以从两种选择中,选出最大的那一种结果);
3.定义一个二维数组,用来存放子问题
4.初始化数组:当没有物品的时候,全部为0,所以arr【0】【】初始化为0。
代码如下:
1 public static int knapsack(int[] w, int[] v, int c){ 2 int[][] arr = new int[w.length + 1][c + 1]; 3 for (int i = 0; i <= c; i++){ 4 arr[0][i] = 0; 5 } 6 for (int i = 1; i <= w.length; i++){ 7 for (int j = 1 ; j <= c ; j++){ 8 if (j < w[i - 1]){ 9 arr[i][j] = arr[i - 1][j]; 10 }else{ 11 arr[i][j] = Math.max(arr[i - 1][j], arr[i - 1][j - w[i - 1]] + v[i - 1]); 12 } 13 } 14 } 15 return arr[w.length][c]; 16 }
对于这个01背包问题,我们使用了一个二维数组来记录子问题的解,网上给出了一个优化方案,使用一个一维数组(滑动数组解法)来进行优化。(ps:我一开始看了半天也没看明白这个一维数组的使用,网上的描述也是不尽如人意,最后在纸上画了好几遍才弄明白的)这个优化方案是怎么样的呢?还要从上面的解法说起,大家会发现,当我们在计算前i个物品的解时,根据我们的状态转移方程可以发现,我们只会与前i - 1个物品那一列的解有关,而不会再使用到之前的解。所以我们可以使用一个一维数组表示上一列的值,本次计算的时候再进行替换。
新的状态转移方程为:F(K) = F(K) (Wk > K , 第k件物品的体积大于背包容积,所以该物品放不下,与上一列的值相同)
= F(K - Wk)
注意点:有一个要注意的地方,使用一维数组的时候,我们要逆序进行计算,也就是说在一次循环中,我们要先从背包容量最大的情况开始计算,直到背包容量为0,这是因为我们在计算时,使用到了上一列的值,如果从前往后进行计算,一维数组的值会被更新掉,后面计算时使用到的值就是错误的,而逆序则不会出现这种情况,使用二维数组计算时,顺序逆序都可以。
代码如下:
1 public static int knapsack(int[] w, int[] v, int c){ 2 int[] arr = new int[c + 1]; 3 for (int i = 0; i <= c; i++){ 4 arr[i] = 0; 5 } 6 for (int i = 1; i <= w.length; i++){ 7 for (int j = c ; j >= w[i - 1] ; j--){ 8 arr[j] = Math.max(arr[j], arr[j - w[i - 1]] + v[i - 1]); 9 } 10 } 11 return arr[c]; 12 }
通过上面这些例子我们可以发现,dp问题最重要的部分就是定义状态,只有定义了正确的状态,才能较好的写出状态转移方程,做题时更多时间是花在分析上,等分析清楚了,代码就是水到渠成的事。大家千万不要以为弄懂上面的例子,就是已经掌握dp大法了,我们还只是刚入门的小白,要想真正的熟练运用dp,还是需要大量的锻炼。