动态规划算法思路详解
文章目录
- 一、动态规划是什么?
- 二、动态规划原理
- 三、0-1背包问题
- 总结
一、动态规划是什么?
动态规划是一种解决最优化问题的复杂算法,其从形式上看与分治法类似,两者都是通过将大问题逐步分解为小问题,直到一个原子问题直接求解。但它们又有本质的不同。具体表现在如下方面:
- 子问题重叠性:动态规划算法分解的子问题具有重叠性,而使用分治法解决的子问题通常是独立的。
- 中间结果的存储:动态规划通过缓存中间结果来减少重复子问题带来的重复运算
- 算法使用要求:使用动态规划算法,要求问题具有最优子结构与重叠子问题性质,分治法无特殊要求
- 算法复杂度:分治法复杂度较高为O(2^ n),而动态规划时间复杂度为多项式级别O(n^ 2);
举例说明一下子问题重叠性质:对于数组{1,3,2,4}排序,使用分治法子问题图如:{1,3,2,4}-》{1,3},{2,4}-》{1},{3},{2},{4};而动态规划的子问题图形式如下:
其中每个顶点对应一个子问题,而每条边是子问题的一种选择。
使用动态规划算法解决问题的步骤大致分为以下四步:
其中1-3步是必选项,这3步仅仅是求出一个最优解的值,可以理解为给最优解问题的一个评估分数,这个分值既可以表示正向价值,也可以表示负向价值,若表示正向价值,则在求解子问题时取其所有选择中的max值,否则取min值。
例如对于钢条切割问题来说,这个值就是切割方案的最大经济价值,而对于矩阵链乘法问题,这个值是矩阵相乘的代价(负向价值)
第四步是可选项,如果不仅仅需要求出最优解的值,还需要给出具体方案,那就需要在求解过程中在关键选择处记录下关键信息,后续通过这些信息来反推方案。
动态规划有两种实现方式,一种是自顶向下带备忘的递归实现,一种是自底向上的循环实现
二、动态规划原理
接下来我会详细解释一下上述的四个步骤
首先第一步是刻画一个最优解的特征。
这一步骤的目的是为了判断问题是否具备使用动态规划算法的条件与性质。使用动态规划算法需要满足最优子结构与子问题重叠两个性质。
最优子结构:如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构
子问题重叠:子问题空间必须足够小,即问题的递归算法会反复地求解相同的子问题,而不是一直生成新的子问题
发掘一个问题的最优子结构实际上遵循如下的通用模式:
问题的最优解值是由选择本身所产生的收益(正向收益取最大值,反向收益表示代价取最小值)+子问题产生的收益两部分组成的,对于钢条切割问题其选择与子问题产生的收益如下如下:
而对于矩阵链乘法问题,如下:
框出的部分是选择本身所产生的收益,剩余部分是子问题收益。
我们需要假设某个选择是此时问题规模的最优解,并且假设子问题的最优解是已知的。
确定选择会产生哪些子问题,做出一个选择本质上就是决定如何对问题进行分解。子问题形式的选择会影响问题的复杂程度,我们需要决定如何刻画这个子问题,可能需要从多个维度考虑
最后一步证明,也是最关键的一步,证明此结构的正确性。即如果子问题+选择是问题的一个最优解,那么子问题的解本身也是其自身的最优解。可能你会觉的奇怪,这难道不应是理所当然的事情吗?实则不然,这个问题的本质实际上是在讨论一个问题的局部最优解是否能够推导出一个全局最优解。
那么哪些情况下会破坏最优子结构性质?我大致罗列了以下几点:
- 子问题之间存在干扰,一个子问题的选择会直接或间接的影响到其他子问题的求解。
- 当前解的选择影响到剩余子问题的可行解空间
- 问题的解依赖历史决策状态
- 目标函数的非线性组合,子问题之间不是简单的叠加,而是更复杂的运算
- 约束条件动态的变化,约束指状态转移方程中对自由度的约束,即括号中的部分
关于子问题之间相互干扰这里举个例子。求解无权有向图的最长简单路径:
导致此问题不满足最优子结构的根本原因,在于其子问题之间是非独立的,q->r的最长简单路径的求解会锁定r->t的最长简单路径的求解所需的资源(顶点)。无关性是指,问题分解出的子问题之间是互相独立的,不锁定对方的资源。
第二个需要满足的性质是子问题重叠。上面已经举过例子进行简单说明,下面再给一个更直观的图:
在矩阵链乘法问题中,不带备忘的递归版本实现中,图中的阴影部分是重复出现的子问题,这些问题会被重复的计算。
接下来讲解第二个步骤。递归定义最优解的值,也就是说在证明问题具备特征后,推导其出状态转移方程。
例如在钢条切割问题中只涉及一个子问题,而在矩阵链乘法中,涉及两个子问题。这两个问题中每个问题的解都存在多种选择,而在后续的背包问题中我们会看到仅有简单的两种选择的情况。
子问题的数量与选择的数量可以粗略估算算法总体复杂度。也可以通过子问题图来分析。图中每个顶点对应一个子问题,每条边对应一种选择。
这个步骤还有一个需要注意的点是,有时为了保证子问题具有一致的形式,需要对子问题进行一般化。
也就是说我们必须保证,分解出的所有子问题能够使用同一个状态转移方程求解。在矩阵链乘法问题中,我们会发现将一个链分解后,第二个链的形式会与第一个有所不同,为此我们引入额外的自由度来表示方程,使其更具有一般性,这也是为什么其解是由Aij 表示,而非A1j 。通过引入额外自由度,我们改变了子问题的刻画形式,而每个自由度最终都对应表格中的一个维度。通过改变子问题刻画形式有时还能改变问题求解的复杂度,我们需要明确这种刻画所表示的含义。
状态转移方程还应该包括临界值,它们对应着问题中的边界条件,是问题求解过程中的特殊情况,换句话说是指问题分解到原子问题时的情况,此时问题的解是一目了然的。当递推公式不满足其约束时会跳转到这个状态
第三个步骤,计算最优解的值,也就是将推导设计出的状态转移方程进行代码实现。
通常有两种实现方式,一种是带备忘的自上而下的实现,一种是自下而上的实现。
不带备忘的递归实现,会反复计算重复子问题。上面已经展示过这种情况,这种情况下其性能与暴力求解无异。复杂度为O(2^n)。
而带备忘的自上而下递归与自下而上的实现两种方式复杂度差不太多,大致都在O(n^2)的量级。
不管使用哪种方式实现,都需要维护一个对应子问题刻画形式所需维度的表,用于存储中间结果,因为动态规划的本质其实就是利用表项与表项之间的依赖关系来进行求解。而表项间的关系即是递推方程。当使用自顶向下法实现时,这个方程用递归形式表示,而当使用自底向上法实现时这个方程直接使用所依赖的表项来表示。
为此,在进行自底向上的实现时,我们需要仔细的分析问题的子问题空间,确定其所依赖的表项,从而确定表的填充顺序。以保证在求解每个问题时,其所依赖的每个子问题以经被解决。
举个例子,在矩阵链乘法问题中,其表项的依赖关系如下图所示:
第四个步骤,用所求的值构造最优解。
这个步骤需要维护一个记录关键选择的表,在每次做出关键选择时,将所作的选择记录下来。最后通常会使用逆推的方式回溯出完整的解决方案。
三、0-1背包问题
接着运用上述所讲的思维方式,来看看动态规划问题的具体求解过程。
我以经典的0-1背包问题举例说明。这个问题大致描述如下:
给定背包容量m,给定n件物品,给定n件物品的价值数组v,重量数组w,求如何选择物品使得在不超出背包最大容量的情况下物品的总价值最大。
分析过程如下:
问题总体太大我们无法得知结果,尝试对其进行分解,假设在r(m,set{i})为背包容量为m,物品集合为set{i}时的最大价值
假设此时我们选择第k件物品会使得背包中物品价值最大,此时r(m,set{i})=r(m-w[k],set{i}-k)(k属于set{i}且w[k]<=m)
通过假设选择我们把问题拆分成两个子问题,背包容量、物品集合都缩小了,但是分析这个推导出的方程发现其中的维度set{i}是一个无序的维度,这使得公式的实现变得复杂,得出结论——直观的分析结果不可行。
转换思维方式,对子问题刻画方式稍加修改,使用r(m,i)表示背包容量为m,前i件物品时的最大价值,注意我对此子问题刻画的表述,明确说明了前i件物品,这与之前的定义有很大不同,在这种定义下物品维度变的有序,简化了算法实现
接着推导其公式,因为背包容量与物品数量维度都是从小到大的增长,我们假设在求解r(m,i)时,i-1之前的所有物品都已经考察过,所以这种情况下我们只需要考察第i件物品即可。
对于第i件物品来说分两种情况:
- w[i]>m 此时物品根本无法放入,那么r(m,i)必然等于r(m,i-1)
- w[i]<=m,此时又会产生两种选择:
假设i是最优方案中的一员,即放入i,那么r(m,i)=r(m-w[i],i-1)+v[i];假设i不在最优方案中,那么r(m,i)=r(m,i-1)保持不变,这两种选择我们选择其中的max值;
再分析临界情况,当背包容量为0或物品数量为0时,最大价值显然是0,即r(m,i)=0 (m=0||i=0)。
简单证明一下公式正确性,假设r(m,i)为最优解时,子问题的解并非其最优解,那么我们可以通过取其最优解再与选择进行组合得出一个更优解从而与假设矛盾,从公式也可以看出其显然也满足重叠子问题性质。
公式有两个自由度,所以需要一个二维数组来进行缓存中间结果
得出公式接下来就可以代码落地实现了,首先展示自上而下带备忘的递归实现:
/** 背包问题:给定背包容量m,i件物品价值v,以及重量w,求* 在不超过背包容量的情况下如何拿取物品价值最大。*/
#region 自上而下带备忘的递归实现
//1.自上而下的带备忘递归实现
public static (int,List<int>) KnapsackUpdown(int m, int[] v, int[] w)
{int n = v.Length;//创建dp数组var dp = new int[n, m];//创建构造最优解的关键信息表(记录关键选择)var s = new int[n,m];List<int> solution = new List<int>();//赋初值(表示尚未计算状态的特殊值)for (int i = 0; i < n; i++){for (int j = 0; j < m; j++){dp[i, j] = int.MinValue;}}var r = KnapsackUpdown(m, n, v, w, dp, s);//关键信息以记录到s表,反推构造最优解while (m >= 1 && n >= 1){var select = s[n - 1, m - 1];if (select != 0){solution.Add(select);m -= w[select-1];}n--;}return (r,solution);
}
public static int KnapsackUpdown(int m, int i, int[] v, int[] w, int[,] dp, int[,]s)
{//递归终止条件(递推公式中的临界值)if (m == 0 || i == 0){return 0;}//如果结果缓存中存在则直接返回if (dp[i - 1, m - 1] >= 0){return dp[i - 1, m - 1];}//其他情况使用推导出的递推公式计算if (m >= w[i - 1]){var val1 = KnapsackUpdown(m - w[i - 1], i - 1, v, w, dp,s) + v[i - 1];var val2 = KnapsackUpdown(m, i - 1, v, w, dp, s);dp[i - 1, m - 1] = Math.Max(val1, val2);if (val1>val2){s[i - 1, m - 1] = i;}}else{dp[i - 1, m - 1] = KnapsackUpdown(m, i - 1, v, w, dp, s);}return dp[i - 1, m - 1];
}
#endregion
然后是自下而上的实现:
#region 自下而上实现//2.自下而上的实现public static (int, List<int>) KnapsackDownUp(int m, int[] v, int[] w){int n = v.Length;//创建dp表项数组var dp = new int[n + 1, m + 1];//创建构造最优解的关键信息表(记录关键选择)var s = new int[n, m];List<int> solution = new List<int>();//初始化临界值(m=0或i=0的情况),因默认初始化便为0,所以此步骤省略//填充表项,填充顺序从左到右,从上到下确保每个表项计算时其依赖的子问题已计算完成for (int i = 1; i <= n; i++)//i表示当前计算表项对应的前i件物品{for (int j = 1; j <= m; j++)//j表示表项对应的背包容量{if (w[i - 1] <= j){//背包容量大于第i件物品取放与不放两种情况较大值var val1 = dp[i - 1, j];//不放入var val2 = dp[i - 1, j - w[i - 1]] + v[i - 1];//放入dp[i, j] = Math.Max(val1, val2);if (val2 > val1){s[i - 1, j - 1] = i;}}else{//否则价值不变dp[i, j] = dp[i - 1, j];}}}var val = dp[n,m];//关键信息以记录到s表,反推构造最优解while (m >= 1 && n >= 1){var select = s[n - 1, m - 1];if (select != 0){solution.Add(select);m -= w[select - 1];}n--;}return (val, solution);}#endregion
最后再给出一种使用一维数组的进一步优化的实现:
#region 一维数组实现
//2.自下而上的实现进一步优化(使用一维数组)
public static (int, List<int>) KnapsackDownUp1D(int m, int[] v, int[] w)
{int n = v.Length;//创建dp表项数组var dp = new int[m+1];//创建构造最优解的关键信息表(记录关键选择)var s = new int[n, m];List<int> solution = new List<int>();//初始化临界值(m=0或i=0的情况),因默认初始化便为0,所以此步骤省略//填充表项,填充顺序从左到右,从上到下确保每个表项计算时其依赖的子问题已计算完成for (int i = 1; i <= n; i++)//i表示当前计算表项对应的前i件物品{for (int j = m; j>=1; j--)//j表示表项对应的背包容量{if (w[i - 1] <= j){//背包容量大于第i件物品取放与不放两种情况较大值var val1 = dp[j];//不放入var val2 = dp[j - w[i - 1]] + v[i - 1];//放入dp[j] = Math.Max(val1, val2);if (val2 > val1){s[i-1,j-1]= i;}}else{//否则价值不变dp[j] = dp[j];}}}var val = dp[m];//关键信息以记录到s表,反推构造最优解while (m >= 1 && n >= 1){var select = s[n - 1, m - 1];if (select != 0){solution.Add(select);m -= w[select - 1];}n--;}return (val, solution);
}
#endregion
简单说明一下可以使用一维数组进行优化的原因,分析公式可知,r(m,i)的计算依赖于r(m,i-1)即在计算r(m,i)时,i-2之前的信息实际已经没什么价值了,也就是说这部分空间可以复用,而之所以要逆序,是因为r(m,i)的计算还依赖于r(m+w[i],i-1),为防止结果还没计算出来依赖项就被覆盖掉,需要优先计算这些表项
这也就是所谓的利用表的访问模式进一步降低时空代价。
总结
本文通过详细展示动态规划问题的分析过程,力图给读者建立一个完善全面的动态规划知识体系,帮助读者建立思维方式,抓住动态规划思想本质,起到拨云见日的效果。动态规划、贪心算法等等作为高级的设计技术是一系列复杂工业算法的思想基础,它解决的是一类问题,而非某种特定问题,掌握其思维方式是程序员的重要成长。