vlambda博客
学习文章列表

这些算法题有助你加深动态规划算法的理解

    昨日我们通过对斐波拉契数列的解题推导介绍了动态规划算法的基本原理,我们在这里再总结一下符合使用动态规划算法的题目的特点:

    1、最优子结构性质:指的是一个最优化策略,无论过去状态和决策如何,对前面的决策的状态而言,余下的决策必须构成最优策略,简单来说就是一个最优策略的子策略总是最优的。

    2、无后效性,指的是某个状态下的决策的收益,只与状态和决策相关,与达到该状态的方式无关。

    3、子问题的重叠性,动态规划算法针对暴力搜索算法改进的时间复杂度的优化,其中的关键在于解决了冗余、重复的计算问题。

    4、本质上动态规划是一种空间换时间的算法。

    

    今天我们先来看一看经典的动态规划算法的0-1背包问题,继续加深对算法的理解。先看问题:

给定一组多个(n)物品,每种物品都有自己的重量(wi)和价值(vi),在限定的总重量/总容量(W)内,选择其中若干个(也即每种物品可以选0个或1个),设计选择方案使得物品的总价值最高。

    面对这个问题,如果采用暴力算法穷举的有2^n种组合方式,其时间复杂度是指数级的,肯定不行。那么这个问题是否具备最优子结构呢?

    我们可以假设f(i,w(i))为前[0,i]个物品中总重量为w的价值最大,这时显然w<W,针对i+1个物品时,我们面临以下选择

    1.如果wi+1超过了W,明显是无法放入的,这时候f(i+1,w(i+1))=f(i,w(i))。

    2.如果没有超过,则需要考虑是选择i+1价值大还是不选择价值大? 按照题意,我们需要选择最大的价值。即f(i+1,w(i+1))=max(v(i+1)+f(i,w(i)),f(i,w(i))) 

    那么这个最优子结构的解是否为问题的最优解,可以用反证法来判断。假定拿走ai这个物品后,剩下的这些物品没有构成W-wi重量范围的最佳价值选择。那么肯定有另外i-1个元素,他们在W-wi重量范围内构成的价值更大。我们再在第i个物品,他们构成的最终W重量范围内的价值就是最优的。这和我们的假设矛盾,因此我们可以肯定此题符合最优子结构问题。

    具体解题代码:

/** * 0-1背包问题 * weights数组和values等长, * values为物品的价值数组 weights为物品的重量 */public static int getGreatestValue(int[] weights,int[] values,int N){ int[][] dp=new int[weights.length+1][N+1]; for(int i = 1; i <= weights.length; i++) { for(int j = 0; j <= N ; j++) { //如果容量为j的背包放得下第i个物体 if(j >= weights[i-1]) { dp[i][j] = Math.max(dp[i - 1][j - weights[i-1]] + values[i-1], dp[i - 1][j]); }else { //放不下,只能选择不放第i个物体 dp[i][j] = dp[i - 1][j]; } } } return dp[weights.length][N];}

    相似的背包问题还有

    1.完全背包问题

有M个物品,每种物品有无限个,每个物品的重量为weight[i],每个物品的价值为value[i]。现在有一个背包,它所能容纳的重量为N, 问:当你面对这么多有价值的物品时,你的背包所能带走的最大价值是多少?

    2.多重背包问题

有N个物品,每种物品有nums[i]个,每个物品的重量为weight[i],每个物品的价值为value[i]。现在有一个背包,它所能容纳的重量为V, 问:当你面对这么多有价值的物品时,你的背包所能带走的最大价值是多少?


    在LeetCode中,并没有相关背包的问题,我们继续来看下LeetCode中跟动态规划算法相关的问题,先来看这道跟斐波拉契数列极为相近的跳台阶的问题。

面试题10- II. 青蛙跳台阶问题 

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。

    在分析这个问题时,假设f(n)表示跳上n级台阶的方案数,当n为1时,f(n) = 1,n为2时,f(n) =2。也就是说:当只有一级台阶时,跳法只有一种,有两级台阶时,跳法为2。如果要跳上n级台阶,必然是从n-1级台阶跳1级或者是从n-2级台阶跳2级,所以到达n级台阶的跳法必然是n-1级台阶的种数加上到达n-2级台阶的种数之和。即f(n) = f(n-1)+f(n-2),这个公式是不是很熟悉,对,它就是斐波拉契数列。

    相较于前篇文章中的解题算法,我们只用a,b来缓存前两个位置的方案数。代码如下:

public int numWays(int n) { int a = 1, b = 1, sum; for(int i = 0; i < n; i++){        sum = (a + b) % 1000000007;//题目要求取模 1e9+7 a = b; b = sum; } return a;}

    再看看LeetCode第300题.最长上升子序列

给定一个无序的整数数组,找到其中最长上升子序列的长度。 

示例:   

    输入: [10,9,2,5,3,7,101,18]   

    输出: 4   

    解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

    这一题,由于一个子序列一定会以一个数结尾,于是将状态定义成:dp[i] 表示以 nums[i] 结尾的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素。

    状态转移方程又如何确定呢,当我们 遍历到 nums[i] 时,需要把下标 i 之前的所有的数都遍历一遍;

    只要 nums[i] 严格大于在它位置之前的某个数,那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列;

    因此,dp[i] 就等于下标 i 之前严格小于 nums[i] 的状态值的最大者 +1。

    因此可以得到以下状态转移方程:

    初始值我们可以设置为dp[i]=1,即 个字符显然是长度为  的上升子序列。


具体代码如下:

public int lengthOfLIS(int[] nums) { if (nums.length == 0) { return 0; } int[] dp = new int[nums.length]; dp[0] = 1; int maxans = 1; for (int i = 1; i < dp.length; i++) { int maxval = 0; for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { maxval = Math.max(maxval, dp[j]); } } dp[i] = maxval + 1; maxans = Math.max(maxans, dp[i]); } return maxans;}

    此题解题时间复杂度为O(n*n),优化的话可以加入贪心算法和分治算法降低复杂度到O(n*logn),这一部有兴趣的同学可以考虑下看如何实现。

    72. 编辑距离

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。 

你可以对一个单词进行如下三种操作:

    插入一个字符 

    删除一个字符 

    替换一个字符

    我们设计 dp[i][j] 代表 word1 到 i 位置转换成 word2 到 j 位置需要最少步数。

    当 word1[i] == word2[j],dp[i][j] = dp[i-1][j-1];

    当 word1[i] != word2[j],dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1

    其中,dp[i-1][j-1] 表示替换操作,dp[i-1][j] 表示删除操作,dp[i][j-1] 表示插入操作。

    在拆解问题时,我们可以考虑如果word1或者word2为''字符时,需要进行的操作步骤。我们以horse和ros为例推导过程,状态转移如下:


''
r
o
s
''
0
1 2
3
h
1

h!=r

=min(d[0,0],d[0,1],d[1][0])+1=1

h!=o

=min(d[0,1],d[0,2],d[1][1])+1=2

h!=s

=min(d[0,2],d[0,3],d[1][2])+1=3

o
2

o!=r

=min(d[1,0],d[1,1],d[2][0])+1=2

o==o

=d[1,1]=1

o!=s

=min(d[1,2],d[1,3],d[2][2])+1=2

r 3 2
2 2
s 4 3
3 2
e 5 4
4 3

具体代码实现

public int minDistance(String word1, String word2) { int n = word1.length(); int m = word2.length(); if (n * m == 0) return n + m; int [][] dp = new int[n + 1][m + 1]; for (int i = 0; i < n + 1; i++) { dp[i][0] = i; } for (int j = 0; j < m + 1; j++) { dp[0][j] = j;    } for (int i = 1; i < n + 1; i++) { for (int j = 1; j < m + 1; j++) { if (word1.charAt(i - 1) != word2.charAt(j - 1)) { dp[i][j] = Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1; }else{ dp[i][j] = dp[i - 1][j - 1]; } } } return dp[n][m];}

    322. 零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

说明:
你可以认为每种硬币的数量是无限的。

    我们采用自下而上的方式进行思考。仍定义 F(i) 为组成金额 i 所需最少的硬币数量,假设在计算 F(i) 之前,我们已经计算出 F(0)-F(i-1)的答案。则 F(i) 对应的转移方程应为

其中 cj代表的是第 j 枚硬币的面值,即我们枚举最后一枚硬币面额是 cj,那么需要从 i-cj这个金额的状态 F(i-cj)转移过来,再算上枚举的这枚硬币数量 1的贡献,由于要硬币数量最少,所以 F(i) 为前面能转移过来的状态的最小值加上枚举的硬币数量 1 。

例子1:假设

coins = [1, 2, 5], amount = 11

则,当 i==0 时无法用硬币组成,为 0 。当 i<0时,忽略 F(i)

F(i) 最小硬币数量
F(0) 0  //金额为0不能由硬币组成
F(1) 1 //F(1)=min(F(1-1),F(1-2),F(1-5))+1=1
F(2) 1 //F(2)=min(F(2-1),F(2-2),F(2-5))+1=1
F(3) 2 //F(3)=min(F(3-1),F(3-2),F(3-5))+1=2
F(4) 2 //F(4)=min(F(4-1),F(4-2),F(4-5))+1=2


F(11) 3 //F(11)=min(F(11-1),F(11-2),F(11-5))+1=3

代码如下:

public int coinChange(int[] coins, int amount) { int max = amount + 1; int[] dp = new int[amount + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (int j = 0; j < coins.length; j++) { if (coins[j] <= i) { dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1); } } } return dp[amount] > amount ? -1 : dp[amount];}

 

    在研究动态规划算法的过程中,其实细心的同学可以发现动态规划算法和贪心算法、分治算法具有一些相似之处。动态规划算法的是通过分解问题为更小的、相似的子问题,并通过求解子问题产生一个全局最优解。这一点在分治算法和贪心算法中均有体现。

    不同的是分治算法的各个子问题是相互独立的,通过递归地求解子问题,自下向上的合并完成问题的求解过程。而贪心算法则是当前选择要依赖已经作出的所有选择,但不依赖于有待于做出的选择和子问题。因此贪心法自顶向下,一步一步作出贪心选择。