动态规划问题以及诸多实例分析
首先先分析一个叫做“钢条切割”的问题,这个问题从递归开始导入,然后引入带备忘录的自顶向下方法,最后得到自底向上的动态规划的解法,发现所有的问题都可以遵循这样的解决方法。然后分析用递归方法和动态规划的方法解这类问题的一般思路。
钢条切割问题:
问题描述,给定一个数组,表示的是出售长度为i的钢条的价格。如p = [1, 5, 8, 9, 10, 17, 17, 20, 24, 30] 表示的是长度为1的钢条为1美元,长度为2的钢条为5美元,以此类推。 现在有一个钢条长度为n,那么如何切割钢条能够使得收益最高,切割的时候不消耗费用。来源于算法导论15.1。
在下面的分析当中我们来这样约定:$r_n$表示的是长度为n的钢条的最高收益,$p_i$表示长度为i的钢条的售价。对于一般的问题,我们这样描述$r_n = \max (p_n, r_1+r_{n-1}, r_2 + r_{n-2},...,r_{n-1}+r_1$,若将$p_n$看做切割成为0和n两段,那么长度为n的钢条的最高收益为:所有可能长度为i和n-i的钢条的最高收益和 中的最大值。这样,把原来的钢条切割求解最优解的问题转化为切割成为两段后求解最优解的子问题,子问题的最优解的和是原问题的最优解,我们说这个问题满足最优子结构问题。因为动态规划问题往往解决的是最优化问题,所以最优子结构问题很重要。
暴力解法:长度为n的钢条,一共有$2^{n-1}$种切法,即在每个长度为1的位置上决定切还是不切。如何编程?我发现我不会编写暴力的解法,虽然我知道如何划分。这需要多少个循环?
递归方法:我们从钢条的左边切割下来一段长度为i的钢条,它不再进行切割,收益为$p_i$,对后面的钢条进行递归切割。所以原问题的最优解是,所有可能左边切割结果的收益和右边递归切割收益和的最大值,所以,最优解为:$r_n = \max \limits_{1 \le i \le n} (p_i,r_{n-i})$
def cut_rod(price, rod_length): """递归方法求解""" if rod_length == 0: return 0 profit = float('-inf') for i in range(1, rod_length + 1): profit = max(profit, price[i-1] + cut_rod(price, rod_length - i)) return profit
递归过程实际上是尝试了所有的$s^{n-1}$种可能,它的算法复杂度为$2^{n}$。在分析为什么递归过程复杂度如此之高的过程,我们看下面这种图:
上面这种图反应的是切割长度为4的钢条的情况,节点的数字表明钢条右边剩余的长度时的切割情况。比如3表示在左边切1个长度,右边剩余3个长度。在每一步都会求出来节点切割的最优解,在图中有很多值重复的节点,而这些节点在计算的过程当中被重复计算,所以复杂度很高,动态规划基本上是保存了中间的这些值,让复杂度变成多项式级别。
带备忘录的自顶向下法
对于上述问题最朴素的解决方法是引入一个记忆数组,保存每次求出来的最优解,这样再次遇到的时候直接返回,而不是进行重复求解。
def memoized_cut_rod(price, rod_length): memoized_arr = [float('-inf')] * (rod_length+1) # 记忆数组 return memoized_cut_rod_aux(price, rod_length, memoized_arr) def memoized_cut_rod_aux(price, rod_length, memoized_arr): """递归求解,但是遇到保存的值直接返回""" if memoized_arr[rod_length] >= 0: return memoized_arr[rod_length] if rod_length == 0: profile = 0 else: profile = float('-inf') for i in range(1, rod_length+1): profile = max(profile, price[i-1] + memoized_cut_rod_aux(price, rod_length - i, memoized_arr)) memoized_arr[rod_length] = profile return profile
自底向上版本:
需要对问题的规模进行界定,当前长度的最优解依赖规模更小的子问题的最优解,求解当前规模最优解的时候,最小子问题的最优解已经求解完毕。
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示长度为i的钢条的最优收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): # 长度为i的钢条的最优解为:(所有长度为j的最优解+长度为i-j钢条最优解)中的最大值,j=0,1...,i profile = max(profile, price[j - 1] + memoized_arr[i - j]) memoized_arr[i] = profile return memoized_arr[rod_length]
带备忘录的自顶向下的方法和自底向上的方法时间复杂度都为$O(n^2)$。对于后者,它的结构如下所示,它将上图当中所有需要可能重复求解的点合并成为一个点,求解顶层节点需要求出它依赖的底层节点:
重构解:
上面的解决方法给出了最优解,但是并没有说明是如何划分的,对上面的解法进行稍微的改进可以得到划分的方法,如下:
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示长度为i的钢条的最优收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 # cur_arr[i]表示的是长度为i的钢条的第一段的切割方案 cut_arr = [0] * (rod_length + 1) for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): if profile < price[j-1] + memoized_arr[i-j]: profile = price[j-1] + memoized_arr[i-j] cut_arr[i] = j memoized_arr[i] = profile return memoized_arr, cut_arr def print_cut_rod_solution(price, rod_length): memoized_arr, cut_arr = bottom_up_cut_rod(price, rod_length) print('profile is: ',memoized_arr[rod_length]) print('cut solution is:', end=' ') while rod_length > 0: cut_length = cut_arr[rod_length] print(cut_length, end=' ') rod_length = rod_length - cut_length print() if __name__ == '__main__': p = [1, 5, 8, 9, 10, 17, 17, 20, 24, 30] print_cut_rod_solution(p, 9) # result """ profile is: 25 cut solution is: 3 6 """
带切割开销的切割方法
假设每次切割都要划分固定的成本c,那么最后的收益等于钢条的价格减去切割的成本。
# 多新建一个数组m[0...n]记录每个长度的钢条最优解的切割段数, # 当完成长度为i的钢条最优解时,更新长度为i+1时使m[i+1] = m[j] + 1,其 # 中长度为i+1的钢条切割成长度为(i+1-j)和j的两大段,长度为j的钢条继续切割。 # copy url: https://blog.csdn.net/chan15/article/details/50603255 def bottom_up_cut_rod(price, rod_length, c): # memoized_arr[i]表示长度为i的钢条的最优收益 memoized_arr = [float('-inf')] * (rod_length+1) memoized_arr[0] = 0 # 每个长度的钢条最优解的切割段数 rod_num_arr = [0] * (rod_length+1) for i in range(1, rod_length+1): profile = float('-inf') for j in range(1, i + 1): # 切割段数为x,那么切割次数为x-1, 现在有切割一次,现在切割次数为x,顾乘以rod_num_arr[i-j] previous_profile = price[j-1] + memoized_arr[i-j] - rod_num_arr[i-j]*c if profile < previous_profile: profile = previous_profile rod_num_arr[i] = rod_num_arr[i-j] + 1 memoized_arr[i] = profile return memoized_arr[rod_length]
动态规划问题:
事实上上面分析的过程,首先是用递归的方法来求解所有可能情况的暴力解法,发现暴力解法当中存在的重复计算的问题,然后增加一个记忆表,将程序修改成为自上而下的带记忆表的递归过程,最后是修改成为自下而上的我们熟悉的动态规划的过程。的确动态规划的过程可以这样引出来,
所以实际当中对于动态规划有两种解决办法,1:先写出递归的式子,然后将递归的式子进行修改得到自上而下的方法,最后得到自下而上的方法。这样能够保障思路清晰,而且相对来说容易一些。 2:直接写动态规划过程,可以说是在第一种方法熟练的基础上直接进行。
递归解法的一般思路:
要构建一个递归过程,就需要很好的描述它,当你能够很好的描述它的时候,这个递归问题已经很好写了。
描述的时候要用到最优子结构:如何将这个问题转化为相同的子问题。最优子结构是:原问题的最优解可以由相同子问题的最优解来进行构造。 这样就可以递归的求解原来的问题。求解父问题的过程其实是一个选择过程,从下面的很多问题当中可以看出,其实是在诸多的选择问题当中选择一个最优解。
动态规划解法的一般思路:
动态规划可以由递归生成,所以,能用动态规划来解的问题,一定可以用递归来解。
动态规划解法分为两步:1.确定状态,2.根据状态列出状态转移方程。 什么是状态?当我们把原问题分解为子问题的时候,那些子问题就是状态,什么是状态转移方程,我们如何由子问题构造出来父问题的过程,这个过程就是状态转移的过程。这个过程往往是自上而下的,先定义和求解最简单的子问题,然后一步一步向上转移和求解。
问题分析:
下面我用一些问题来说明如何用递归思路和动态规划的思路来分析问题。每个问题都用递归和动态规划两种思路来解。
钢条切割
【递归思路】在上面钢条切割问题当中,原问题是求解长度为n的钢条的最优解,这个问题是在i=1到n当中,长度为i的钢条价格+长度为n-1的钢条的价格的最优解,这样将原来的问题转化为n个子问题,只需要求解这n个子问题的最大值就可以得到原问题的最优解。让我再贴一遍代码:
def cut_rod(price, rod_length): """递归方法求解""" if rod_length == 0: return 0 profit = float('-inf') for i in range(1, rod_length + 1): profit = max(profit, price[i-1] + cut_rod(price, rod_length - i)) return profit
【动态规划思路】钢条切割问题当中的状态,也就是子问题是:如何求解长度为i的钢条的最优收益。长度为0的钢条的最优收益为0,长度为1的钢条的最优收益为它的价值本身。 状态转移方程:假设长度为0到i-1的钢条的最优收益都已经有了,那么如何来求解长度为i的钢条的最优收益,这个时候应该考虑所有可能组合的情况,而不仅仅是长度为i-1的钢条和长度为1的钢条等等,状态转移方程是在这些所有的组合当中求解最大值。所以,再贴一遍代码:
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示长度为i的钢条的最优收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): # 长度为i的钢条的最优解为:(所有长度为j的最优解+长度为i-j钢条最优解)中的最大值,j=0,1...,i profile = max(profile, price[j - 1] + memoized_arr[i - j]) memoized_arr[i] = profile return memoized_arr[rod_length]
数字三角形
【问题】给定一个数字三角形,找到从顶部到底部的最小路径和。每一步可以移动到下面一行的相邻数字上,如下图所示,最小的和为2+3+5+1 = 11。
【递归思路】父问题:求从第i行第j列开始,到底部的最小和的最优解。这个问题可以由两个子问题的解来进行构造:1:从第i+1行,第j列(i行j列的左节点)开始到底部的最小和。 2:第i+1行,第j+1列(i行j列的右节点)开始到底部的最小和。原问题的最小和=min(子问题1的最小和,子问题2的最小和)+第i行j列的值。
def solve(arr): return process(arr, len(arr), 0, 0) def process(arr, heigth, i, j): if i == (heigth - 1): return arr[i][j] x = process(arr, heigth, i+1, j) y = process(arr, heigth, i+1, j+1) return arr[i][j] + min(x, y)
【动态规划思路】状态:第i行,第j列的最小路径和。对于最下面的一行数来说,它们的最小路径和就是它们自身的数值。状态转移方程:知道了第i行的最小路径和,那么可以求出来第i-1行的最小路径和,第i-1行的最小路径和是第i行的最小路径和的最小值加上它们数值本身,这里需要用一个数组来存储中间过程。
def numerical_triangle(arr): if arr is None or len(arr) == 0: return 0 dp = arr.copy() for i in range(len(arr)-2, -1, -1): for j in range(len(dp[i])): dp[i][j] = arr[i][j] + min(dp[i+1][j], dp[i+1][j+1]) return dp[0][0]
背包问题
【问题】:在n个物品,它们的重量是数组w(weight),价值是数组v(value),那么给定背包能房屋能放入物品的最大重量m,问在不超过背包容量m的情况下能够拿到的物品的最大的价值是多少?
【递归思路】原问题,n个物品能装入背包的最大值,可以由1个子问题来进行构造,即n-1个物品放入背包的最大值,那么对于当前物品,会有两种策略,放入或者不放入。所以,n个物品放入背包的最大值=max(选择当前物品放入背包+ 其余n-1个物品放入背包的最大值, 当前物品不放入背包+其余n-1个物品放入背包的最大值),可能会有疑问,上面的max肯定会选择第一个,其实这两个子问题不相等,因为它们是在考虑不同整体重量的情况下n-1个物品放入背包的最大值。
def solver(w, v, m): """ Args: w: 物品的重量 v:物品的价值 m:背包的容量 """ return process(w, v, m, 0, 0) def process(w, v, m, i, s): """ Args: arr: 背包 m: 背包能够装的最大重量 s: 当前背包装的重量 i: 当前指向第几个背包 """ if i == len(w): return 0 if s + w[i] > m: return process(w, v, m, i+1, s) else: return max(process(w, v, m, i+1, s + w[i]) + v[i], process(w, v, m, i+1, s))
【动态规划思路】背包问题应该以什么作为状态,这在刚开始思考的时候有点难,可以参考 动态规划之01背包问题(最易理解的讲解)。
背包问题的描述是这样的:重量为m的背包,装入n个物品的最大价值,这里的状态是f[i,j],即前i件物品放入重量为j的背包的最大价值。 那么现在的状态转移方程是:$f[i,j] = \max \{ f[i-1, j-w_i] +v_i \ (j \ge w_i), f[i-1,j]\} $,这个状态转移方程可以描述为这样的:前i件物品放入重量为j的背包的最大值等于 是否选择将第i件物品放入背包的最大值。
这个状态可以看做是两维的,即重量m和前n个物品,因为你发现,缺少任何一维在写状态转移的时候是没有办法写的。
def max_bag_problem(w, v, m): item_number = len(w) # memoriezed 是 item_number * m+1 维的, 初始化后全为0, # memmoriezed[i,j] 表示的是重量为j的背包, 能够装前i个物品的最大重量 memorized = [[0 for i in range(m+1)] for i in range(item_number)] # 初始化背包第一行的值,表示重量为m的背包装入第一个物品的价值,装不下为0,能装下为第一个物品的价值 for i in range(m+1): if i >= w[0]: memorized[0][i] = v[0] for item_index in range(1, item_number): for weight in range(1, m+1): if weight > w[item_index]: memorized[item_index][weight] = max( memorized[item_index-1][weight], v[item_index] + memorized[item_index-1][weight-w[item_index]]) else: # 如果当前物品的重量大于背包容量,那么不可能装入当前物品,总价值和前n-1个物品价值相等 memorized[item_index][weight] = memorized[item_index-1][weight] return memorized[item_number-1][m]
公共子串
【问题】给出两个字符串,找到最长公共子串,并返回其长度
【递归思路】说实话,这个问题有点难,至少对于我来说,最优解是找出两个字符串当中最长的公共字符串,这里把问题分解为从两个字符串开始的最长公共字符串,在这里一个问题是这样的:原问题并没有分解为子问题,因为子问题和父问题在这里面的描述是不一样的,父问题是找出两个字符串当中的最长公共字符串,没有什么限定,而子问题必须从开始位置找。 比如‘abc’,‘cba’,子问题返回的0,只要第一个不相等那么,它就返回0。但是很显然,对于父问题它应该返回1,所以,这里并没有很好的体现出最优子结构。
def common_str(a, b): max_value = 0 for i in range(len(a)): for j in range(len(b)): max_value = max(max_value, helper(a, b, i, j)) return max_value def helper(a, b, i, j): if i == len(a) or j == len(b): return 0 if a[i] == b[j]: return 1 + helper(a, b, i+1, j+1) else: return 0
【动态规划思路】和LCS问题很像,但是当两个字符串不相等的时候,不会进行后续的操作,只有当连续的字符串出现的时候才会不断的增加。
def common_str(str1, str2): if len(str1) == 0 or len(str2) == 0: return 0 m = len(str1) n = len(str2) max_length = 0 # memorized[i][j]表示的是第二个字符串到j位置和第一个字符串到第i位置的公共子串 memorized = [[0 for i in range(n+1)] for j in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if str1[i-1] == str2[j-1]: memorized[i][j] = memorized[i-1][j-1] + 1 max_length = max(max_length, memorized[i][j]) return max_length
公共子序列
【问题】给出两个字符串,找到最长公共子序列(LCS),返回LCS的长度。
【递归思路】相对于公共字符串问题,这个解决方法就可以用递归了。 要找两个字符串的LCS,那么子问题是寻找从0到i位置和从0到j位置两个字符串的LCS。那么原问题可以分为两种选择:如果原来字符串i位置和j位置相等,那么LCS加1,继续向后寻找。如果不相等,那么比较后面i+1和j以及i和j+1两种情况的最大值。
def lcs(a, b): return process(a, b, 0, 0) def process(a, b, i, j): if i == len(a) or j == len(b): return 0 if a[i] == b[j]: return 1 + process(a, b, i+1, j+1) else: return max(process(a, b, i+1, j), process(a, b, i, j+1))
【动态规划思路】我们用C[i,j]来表示状态,表示的是第一个字符串到i位置和第二个字符串到j位置的lcs。那么状态转移方程为:$$C[i,j]= \begin{cases} 0, & 当 i=0或j=0 \\ C[i-1,j-1]+1, &当i,j>0 且x_i=y_j \\ MAX(C[i,j-1],C[i-1,j]) &当i,j>0且x_i≠y_j \end{cases} $$
def lcs(str1, str2): if len(str1) == 0 or len(str2) == 0: return 0 m = len(str1) n = len(str2) # memorized[i][j]表示的是第二个字符串到j位置和第一个字符串到第i位置的lcs memorized = [[0 for i in range(n+1)] for j in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if str1[i-1] == str2[j-1]: memorized[i][j] = memorized[i-1][j-1] + 1 else: memorized[i][j] = max(memorized[i-1][j], memorized[i][j-1]) return memorized[-1][-1]
以’cnblogs’和’belongs‘为例 最长公共子序列和最长公共字符串的memorized值分别为
打劫房屋问题
【问题】假设你是一个专业的窃贼,准备沿着一条街打劫房屋。每个房子都存放着特定金额的钱。你面临的唯一约束条件是:相邻的房子装着相互联系的防盗系统,且 当相邻的两个房子同一天被打劫时,该系统会自动报警。
给定一个非负整数列表,表示每个房子中存放的钱, 算一算,如果今晚去打劫,你最多可以得到多少钱 在不触动报警装置的情况下。
【递归思路】问题可以描述为,抢劫所有的房屋,使得抢劫的钱最多。 那么,这个问题的最优解,可以由子问题的最优解构造:除了当前房屋以外,抢劫剩余房屋获得的最多的钱,那么对于当前房屋会有两种策略,抢劫或者不抢劫。 所以,抢劫所有房屋获得的做多的钱 = max(抢劫当下房屋的钱+抢劫剩余n-2个房屋获得的最多的钱, 抢劫剩余n-1个房屋获得的钱)
递归问题可以描述为:从0位置开始到结束位置打劫的最高收益,它等于打劫当前房屋+从下下个位置开始打劫的最高收益 或者 从下个位置开始打劫的最高收益的 两者的最大值。
class solution(object): def rob(self, nums): return self.search(len(nums) - 1, nums) def search(self, i, nums): if i < 0: return 0 return max(self.search(i - 1, nums), self.search(i - 2, nums) + nums[i])
【动态规划思路】状态是打劫前n个房屋的最大收益f(n),状态转移方程式:f(n) = max{f(n-1), A[n]+f(n-2)}。 即打劫前n个房屋的最大收益是是否选择打劫当前房屋这个决定产生的两个结果的最大值。
def rob_house(nums): if nums is None or len(nums) == 0: return 0 house_number = len(nums) memorized = [0] * (house_number) memorized[0] = nums[0] memorized[1] = max(memorized[0], nums[1]) for i in range(2, house_number): memorized[i] = max(memorized[i-1], nums[i]+memorized[i-2]) return memorized[-1]
递归和动态规划的总结:
纵观上面所有的问题,可以发现,在用递归问题解决问题的时候,实际上运用了最优子结构,要求原问题的最优解,就要求子问题的最优解,由子问题的最优解构造出来原问题的最优解。在构造的过程当中,其实是在多钟可能的结果当中选择最优的那一个。
动态规划问题,建立在递归的基础之上,它解决了子问题重叠的问题,所以相比于递归算法,它的好处有1:复杂度明显降低,它的时间复杂度是多项式级别的,而递归的复杂度是指数级别的。在LCS问题当中,假如两个字符串的长度都大于10,那么递归方法可能用上10分钟左右,而动态规划方法时间是毫秒和微妙级别的。2:递推算法可以保存中间结果的值,不仅可以得到我们要的值,而且可以分析这些值的组成方式。
动态规划和分治法的异同点
相同点:都是将问题划分为子问题,子问题有最优子结构。
不同点:分治法的子问题独立,动态规划的子问题重叠。(虽然是这样描述的,倒不如说是,它们求解的时候让子问题重叠或者不重叠。分治法可以求解子问题重叠的问题,只不过求解的时候还是对重叠的部分进行了重复计算,只不过子问题重叠的问题一般用动态规划来解,所以才说动态规划的子问题重叠)
参考:
一道题看清动态规划的前世今生 ( 一 ) 抢劫房屋问题, 用三种方法求解
一道题看清动态规划的前世今生 ( 二 ) 背包问题2, 用三种方法求解
【算法】动态规划问题集锦与讲解 很多动态规划的问题,都是直接使用动态规划方法来求解,java代码实现。
动态规划:从新手到专家 主要讲解了动态规划的状态,和状态转移方程