定义及原理
动态规划和分治法类似,都是通过组合子问题的解来求解原问题。动态规划应用于子问题重叠的情况,及不同的子问题具有公共的子子问题。在这种情况下,分治法会做许多不必要的工作,重复计算求解那些公共子子问题。而动态规划只求解一次,避免了不必要的工作。
我们通常按照下面四个步骤来设置动态规划算法:
- 刻画一个最优解的结构特征
- 递归地定义最优解的值
- 计算最优解的值,通常采用自底向上的方法
- 利用计算出的信息构造一个最优解
前三步是动态规划算法求解问题的基础。如果我们仅仅需要一个最优解的值,可以忽略步骤4,如果需要解本身,就需要做步骤4,同时在步骤3维护一些额外的信息。
适用于动态规划方法求解的最优化问题应该具备两个要素:最优子结构和子问题重叠
最优子结构
如果问题的最优解包含的其子问题的解也是最优解,我们就称该问题具有最优子结构性质(即满足最优化原理)
在尝试使用动态规划方法时一定要小心,要注意问题是否具有最优子结构性质。
子问题重叠
如果使用递归算法会反复求解相同的子问题,我们就称该最优化问题具有重叠子问题性质。对每个子问题求解一次,将解存入表中,当再次需要这个子问题时,直接查表,查表的代价为常量时间。与之相反,适用于分治法的问题,通常在递归的每一步都生成全新的子问题,无法通过存储提高时间效率。
钢条切割问题
问题描述及分析
Serling公司购买长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。假定我们知道Serling公司出售一段长为i英寸的钢条的价格为pi(i=1,2,…,单位为美元)。钢条的长度均为整英寸。下图给出了一个价格表的样例。
钢条切割问题是这样的:给定一段长度为n英寸的钢条和一个价格表pn(i=1,2,…n),求切割钢条方案,使得销售收益rn最大。注意,如果长度为n英寸的钢条的价格pn足够大,最优解可能就是完全不需要切割。
我们可以用一个通用的公式来描述最优切割收益:
第一个参数pn代表不切割的收益。其他n-1个参数对应另外n-1中方案。由于无法预知那种方案会获得最大收益,所以我们必须考虑所有的可能。
除了上面的公式,我们还可以找到一种更简单的递归方法:
钢条切割问题满足最优子结构性质:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
递归实现(自顶向下)
代码实现:
/**
* 递归算法
* rn = max(pi + r(n-i))
*
* @param p 价格表,下标从1开始
* @param n 钢条长度
*/
private static int recursiveCutRod(int[] p, int n)
{
if (n == 0)
return 0;
int q = Integer.MIN_VALUE;
for (int i = 1; i <= n; i++)
{
q = Math.max(q, p[i] + recursiveCutRod(p, n - i));
}
return q;
}
自顶向下递归实现的效率很差,因为反复用相同的参数对自己进行递归调用,即它反复求解相同的问题。下图为n = 4时的调用过程。该方法考察了全部2n-1种可能的切割方案,所以时间复杂度为O(2n)。
动态规划实现
带备忘的自顶向下法
递归算法效率低是因为反复求解相同的子问题。所以我们可以将每个求解过的子问题的解保存下来,随后再次用到此问题的解,直接找保存的结果即可。这种方式付出额外的空间来节省时间,不过这种空间的付出会带来执行效率的显著提升。
代码实现:
/**
* 带备忘的自顶向下法
*
* @param p 价格表,下标从1开始
* @param n 钢条长度
* @param r 辅助数组,用来存储已经计算过的子问题收益,初始化为最小的整数
*/
private static int memoizedCutRod(int[] p, int n, int[] r)
{
if (r[n] > Integer.MIN_VALUE)
return r[n];
if (n == 0)
return 0;
int q = Integer.MIN_VALUE;
for (int i = 1; i <= n; i++)
{
q = Math.max(q, p[i] + memoizedCutRod(p, n - i, r));
}
r[n] = q;
return q;
}
自底向上法
使用这种方式的前提条件是任何子问题的求解都只依赖更小的子问题的求解。因此我们可以将子问题按规模排序,由小到大求解。
代码实现:
/**
* 自底向上法
*
* @param p 价格表,下标从1开始
* @param n 钢条长度
*/
private static int BottomUpCutRod(int[] p, int n)
{
int[] r = new int[n + 1];
r[0] = 0;
// 用来记录解本身,从而输出最优解,而不仅仅是一个值
int[] s = new int[n + 1];
for (int i = 1; i <= n; i++)
{
int q = Integer.MIN_VALUE;
for (int j = 1; j <= i; j++)
{
if (q < p[j] + r[i - j])
{
q = p[j] + r[i - j];
s[i] = j;
}
}
r[i] = q;
}
return r[n];
}
如果只需要最终子问题的值,而不需要解本身,可以不需要数据s,反之则需要数据s来记录解本身。
这两种算法的时间复杂度接近,都是O(n2)。由于没有频繁递归调用的开销,所以自底向上的方法的时间复杂度通常具有更小的系数。
最长公共子序列
问题描述及分析
子序列: 不改变串的顺序,从串中去掉任意的元素而获得的新串。
子串:子串是串的一个连续分部分。
最大公共子序列问题: 给定两个序列X和Y,求X和Y长度最大的公共子序列。
该问题的最优子结构如下:
根据最优子结构,可以得到如下公式:
根据公式,我们可以很容易地使用递归算法来计算结果。但为了更高的效率,我们可以用动态规划方法,自底向上求解。
代码实现
/**
* 求最大公共子序列
*/
public static int LCSLength(String text1, String text2)
{
// 如果长度为0,则公共子序列为0,直接返回
if (text1.length() == 0 || text2.length() == 0)
return 0;
// 创建[m+1][n+1]的数组,[0][n]或[n][0]用来让算法更通用,减少特殊判断
int[][] step = new int[text1.length() + 1][text2.length() + 1];
// 从1开始,可以直接取[m-1]或[n-1]
for (int i = 1; i <= text1.length(); i++)
{
for (int j = 1; j <= text2.length(); j++)
{
if (text1.charAt(i - 1) == text2.charAt(j - 1))
{
step[i][j] = step[i - 1][j - 1] + 1;
}
else
{
step[i][j] = Math.max(step[i - 1][j], step[i][j - 1]);
}
}
}
return step[text1.length()][text2.length()];
}
如果想要构造最优解,可以添加一个二维数组,记录每次跳转路径,也可以对数组result,从[m][n]开始,像左上角查找,每次只需验证三个位置即可。
最优二叉搜索树
问题描述及分析
代码实现
// TODO