动态规划
采用动态规划的两要素:存在最优子结构和重叠子问题。
- 最优子结构:
如果一个问题中包含子问题的最优解,则该问题具有最优子结构,注意子问题之间应当互不影响。一个问题可以有多个子问题,要求一个问题的最优解,则要先求多个子问题中的最优子问题的解。
动态规划用自底向上的的方式来利用最优子结构,也就是说先求子问题的最优解,解决子问题,再找到上级问题的最优解。贪心算法也适用于最优子结构,但它是以自顶向下的方式使用最优子结构,它会先做选择,在当时看来是最优的选择,然后再求解一个结果子问题,而不是先寻找子问题的最优解再做选择。 - 重叠子问题:
用来解原问题的递归算法可反复地解同样的子问题,也就是说当一个递归算法不断地调用同一问题时,我们说该问题包含重叠子问题。
相反的,适合用分治法的问题往往在递归的每一步都会产生全新的问题。而动态规划总是充分利用重叠子问题,即通过每个子问题只解一次,然后把解保存在一个可以随时访问的表中(这是动态规划的特性)(查表时间为常数),每次遇到直接查表中是否存在,再决定是否递归。
eg. 计算斐波拉契序列递归时会有很多数字被重复在递归,明显出现了重叠子问题,我们可以用动态规划思想优化它。
装配线调度
求解一个制造问题。汽车公司在有两条装配线的工厂内生产汽车,如图所示。一个汽车底盘在进入每一条装配线后,在一些装配站中会在底盘上安装部件,然后,完成的汽车在装配线的末端离开。每一条装配线上有n个装配站,编号为j=1,2,⋯,n。将装配线i(i为1或2)的第j个装配站表示为Si,j,装配线1的第j个站(S1,j)和装配线2的第j个站(S2,j)执行相同的功能。然而,这些装配站是在不同的时间建造的,并且采用了不同的技术,因此,每个站上所需的时间是不同的,即使是在两条不同装配线相同位置的装配站上也是这样。我们把在装配站Si,j上所需的装配时间记为ai,j。如图15-1所示,一个汽车底盘进入其中一条装配线,然后从每一站进行到下一站。底盘进人装配线i的进入时间为ei,装配完的汽车离开装配线i的离开时间为xi。
在正常情况下一旦一个底盘进入一条装配线后,它只会经过该条装配线,在相同的装配线中,从一个装配站到下一个装配站所花的时间可以忽略。偶尔会来一个特别急的订单,客户要求尽可能快地制造这些汽车。对这些加急的订单,底盘仍然依序经过n个装配站,但是工厂经理可以将部分完成的汽车在任何装配站上从一条装配线移到另一条装配线上。把已经通过装配站Si,j的一个底盘从Si,j移走到另一条线上的时间为ti,j,其中i=1,2,j=1,2,……,n-1(因为在第n个装配站后,装配已经完成)。
问题是:要确定在装配线1内选择哪些站以及在装配线2内选择哪些站,以使汽车通过的总时间最小。
寻找最优子结构,即用子问题的最优解寻找原问题的最优解
观察一条通过装配站S1,j的最快路线,会发现它必定是经过装配线1或2上的装配站j一1。因此,通过装配站S1,j的最快路线只能是以下二者之一:
- 通过装配站S1,j-1的最快路线,然后直接通过装配站S1,j;
- 通过装配站S2,j-1的最快路线,从装配线2移动到装配线1然后通过装配站S1,j;
利用对称的推理思想,通过装配站S2,j的最快路线也只能是以下二者之一:
- 通过装配站S2,j-1的最快路线,然后直接通过装配站S2,j;
- 通过装配站S1,j-1的最快路线,从装配线1移动到装配线2然后通过装配站S2,j;
为了解决这个问题,即寻找通过任一条装配线上的装配站i的最快路线,我们解决它的子问题,即寻找通过两条装配线上的装配站j一1的最快路线。
在这里我定义有n条装配线,每条线有m个站。因此通过装配站Si,j的最快路线如下:
- 通过装配站Si,j-1的最快路线,然后直接通过装配站Si,j;
- 通过装配站S非i,j-1的最快路线,从装配线非i移动到装配线i然后通过装配站Si,j;
所以对于装配线调度问題,通过建立子问题的最优解,就可以建立原问题某个实例的一个最优解了。
算法如下:
源码:https://github.com/yangbijia/algorithm.git
/**
* 动态规划--装配线调度
* m条装配线,每条装配线下n个装配站,计算从进入装配线到出去的最短时间和路径
* @author bijiayang
*/
public class Main {
...
/**
* 递归计算计算到给定节点的最短路径
* @param stationPos
* @param linePos
* @return
*/
public static Integer fastestwayRecursion(Integer stationPos, Integer linePos) {
LineAndTime lineAndTime;
// 中间节点
if (stationPos > 0 && stationPos < station_size - 1) {
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for (int k = 0; k < line_size; k++) {
Integer time = k == linePos ? f.get(linePos).get(stationPos) : -1, line = linePos;
if (time == -1) {
Integer currentTime = fastestwayRecursion(stationPos - 1, k) + (k == linePos ?
0 : move.get(k).get(stationPos - 1)) + station.get(linePos).get(stationPos);
time = currentTime;
line = k;
}
map.put(line, time);
}
lineAndTime = min(map);
// 开始节点
} else if (stationPos == 0){
Integer time = f.get(linePos).get(stationPos);
if (time == -1) {
Integer currentTime = enter.get(linePos) + station.get(linePos).get(stationPos);
time = currentTime;
}
lineAndTime = new LineAndTime(linePos, time);
// 结束节点
} else {
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for (int k = 0; k < line_size; k++) {
Integer time = k == linePos ? f.get(linePos).get(stationPos) : -1, line = linePos;
if (time == -1) {
time = fastestwayRecursion(stationPos - 1, k) + (k == linePos ?
0 : move.get(k).get(stationPos - 1)) + station.get(linePos).get(stationPos) + exit.get(linePos);
line = k;
}
map.put(line, time);
}
lineAndTime = min(map);
}
Integer line = lineAndTime.getLine();
Integer time = lineAndTime.getTime();
if (f.get(linePos).get(stationPos) == -1) {
f.get(linePos).set(stationPos, time);
}
if (stationPos > 0 && l.get(linePos).get(stationPos - 1) == -1) {
l.get(linePos).set(stationPos - 1, line + 1);
}
return time;
}
/**
* 非递归最短时间和路径
*/
public static void fastway() {
int i,j;
for (i = 0; i < line_size; i++) {
Integer fi1 = enter.get(i) + station.get(i).get(0);
f.get(i).set(0, fi1);
}
LineAndTime currentLineAndTime;
for (j = 1; j < station_size; j++) {
for (i = 0; i < line_size; i++) {
List<Integer> times = new ArrayList<Integer>();
for (int k = 0; k < line_size; k++) {
times.add(f.get(k).get(j - 1) + (k == i ? 0 : move.get(k).get(j - 1)) + station.get(i).get(j));
}
currentLineAndTime = min(times);
f.get(i).set(j, currentLineAndTime.getTime());
l.get(i).set(j - 1, currentLineAndTime.getLine() + 1);
}
}
List<Integer> times = new ArrayList<Integer>();
for (i = 0; i < line_size; i++) {
Integer exitTime = exit.get(i) + f.get(i).get(station_size - 1);
times.add(exitTime);
}
currentLineAndTime = min(times);
System.out.println("fasttime = " + currentLineAndTime.getTime().toString());
printWay(currentLineAndTime);
}
...
}
矩阵链相乘
这是解决矩阵链相乘问题的一个动态规划经典算法。给定由n个要相乘的矩阵构成的序列(链)<A1,A2,…,An>,要计算乘积
A1A2…An(15.10)
为计算式(15.10),可将两个矩阵相乘的标准算法作为一个子程序,根据括号给出的计算顺序做全部的矩阵乘法。一组矩阵的乘积是加全部括号的(fullyparenthesized),如果它是单个的矩阵,或是两个加全部括号的矩阵的乘积外加括号而成。矩阵的乘法满足结合率,故无论怎样加括号都会产生相同的结果。例如,如果矩阵链为<A1,A2,A3,A4>,乘积A1A2A3A4可用五种不同方式加全部括号:
(A1(A2(A3A4))),
(A1((A2A3)A4)),
((A1A2)(A3A4)),
((A1(A2A3))A4),
(((A1A2)A3)A4)。
仅当两个矩阵A和B相容(即A的列数等于B的行数)时,才可以进行相乘运算。如果A是pxq矩阵,B是qxr矩阵,则结果矩阵C是一个pxr矩阵,计算C的时间由矩阵的标量乘法的次数决定,这里为pqr。下面对时间代价的计算均按照乘法次数来表示。
问题是:求矩阵(A1A2……An)的最优加全括号,及最优时间代价。
这里用Ai,j表示对乘积AiAi+1……Aj求值的结果,其中i<=j
分为以下两种情况:
- 如果i = j(即只有一个矩阵A1),则代价为0
- 如果i < j,则必有乘积Ai,j的任何加全括号形式都将在Ak与Ak+1之间分开(即在Ak与Ak+1之间必定存在括号,称k为裂变点,当然我们现在还不知道k的具体值,i <= k < j),这样加全括号的代价就是计算Ai,k和Ak+1,j的代价之和,再加上两者相乘的代价。再从分解出来的子问题继续向下分解,直到分解到最小子问题。
根据子问题的最优解来递归定义一个最优解的代价。
这里用m[i,j]表示最优时间代价,得出结论:
- m[i, j] = 0 ,i = j
- m[i, j] = min{m[i, k] + m[k + 1, j] + Pi-1PkPj} ,i < j
下方左图为m[i, j](最优时间代价),右图为s[i, j](最优裂变位置)
下方为输入矩阵
算法如下:
源码:https://github.com/yangbijia/algorithm.git
/**
* 计算矩阵A1*A2*……*An乘积的最优乘法,即最优加全括号
* @author ellin
* @since 2019/03/19
*/
public class Main {
...
/**
* 递归计算矩阵i-j从k处开始裂变的标量值
* 计算矩阵A1*A2*……*An乘积的最优代价,两个矩阵相乘 A1mxn * A2nxr 的代价为:m*n*r
* 计算矩阵从i到j的最优代价
* @param i 矩阵下标Ai
* @param j 矩阵下标Aj
* @param k 裂变位置
* @return
*/
public static Integer optimalSolution(int i, int j, int k) {
if (i == j) {
m[i][j] = 0;
return 0;
} else {
int min_left = m[i][k] == -1 ? optimalSolution(i, k, i) : m[i][k],
min_right = m[k + 1][j] == -1 ? optimalSolution(k + 1, j, k + 1) : m[k + 1][j];
int s_left = i, s_right = k + 1;
// 计算k裂变点及其左边矩阵乘积的最优代价
for (int pos = i + 1; pos < k; pos++) {
int res = m[i][k] == -1 ? optimalSolution(i, k, pos) : m[i][k];
if (res < min_left) {
s_left = pos;
min_left = res;
}
}
if (s[i][k] == -1){
s[i][k] = s_left;
}
m[i][k] = min_left;
// 计算k右边矩阵乘积的最优代价
for (int pos = k + 2; pos < j; pos++) {
int res = m[k + 1][j] == -1 ? optimalSolution(k + 1, j, pos) : m[k + 1][j];
if (res < min_right) {
s_right = pos;
min_right = res;
}
}
if (s[k + 1][j] == -1){
s[k + 1][j] = s_right;
}
m[k + 1][j] = min_right;
int m_ijk = min_left + min_right + p.get(i - 1) * p.get(j) * p.get(k);
return m_ijk;
}
}
/**
* 打印出相乘矩阵的最优加全括号
* @param i 开始矩阵下标
* @param j 末尾矩阵下标
*/
public static void printOptimalParens(int i, int j) {
if (i == j) {
System.out.print("A" + i);
return;
}
else {
System.out.print("(");
printOptimalParens(i, s[i][j]);
printOptimalParens(s[i][j] + 1, j);
System.out.print(")");
}
}
}
最长公共子序列(LCS)
子序列概念:在给定序列中去掉0个或多个元素
对于两个序列S1和S2,可以找出第三个序列S3,在S3中的元素都出现在S1和S2中,而且这些元素必须以相同的顺序出现,但可以不是连续的,则称S3为序列S1和S2的公共子序列,找到的最长的序列S3为最长公共子序列(LCS)。
例子说明:
两个序列 X = {A, B, C, B, D, A, B}, Y = {B, D, C, A, B, A}
结果分析:
{B, C, A} 为X和Y的一个公共子序列
{B, C, B, A} 和 {B, D, A, B}也是公共子序列,且都是最长公共子序列(因为没有更长的子序列了)
问题:给定两个序列 X = {x1, x2, …, xm} 和 Y = {y1, y2, … ,yn}
希望找出最大长度的公共子序列(设 Z = {z1, z2, … , zk} 为X和Y的任意一个LCS)。
前缀序列: 对于序列X,令Xi = {x1, x2, … , xi} (i ≤ m) 为序列X的前缀序列
分析:
-
xm = yn,即两个序列的最后一个元素相等
则两个序列的最后一个元素必然是最长公共子序列的最后一个元素,即xm=yn=zk,可得出Zk-1是Xm-1和Yn-1的一个LCS -
xm ≠ yn,即两个序列的最后一个元素不相等
如果 zk = xm,则Zk是Xm和Yn-1的一个LCS;
如果 zk = yn,则Zk是Yn和Xm-1的一个LCS;
以上分析说明:两个序列的LCS包含它们的前缀序列的LCS,说明LCS问题具有最优子结构性质。可以看出问题的解由两个子问题的解得出,很容易用动态规划自底向上来计算。
我们用 c[i, j] 表示序列 Xi 和 Yj 的最长公共子序列的长度,可以得出:
- c[i, j] = c[i - 1, j - 1] + 1,(Si == Sj)
- c[i, j] = max{ c[i - 1, j], c[i, j - 1] } ,(Si ≠ Sj)
我们利用c[i, j]就能打印出相应的最长公共子串了,我们能在O(1)的时间复杂度下,在其中判断计算出出当前c[i, j]是从哪个位置c[_i, _j]计算出来的。
note: 其实也可以利用在构造长度表格c[i, j]时,同时构造一张表b[i, j]。计算c[i,j]时,同时存储下当前c[i,j]是从哪个方向计算出来的,递归查找b[i, j]就能知道打印最长公共子串字符方向了。
算法如下:
源码:https://github.com/yangbijia/algorithm/tree/master/src/main/java/dynamicprogramming/lcs
/**
* 初始化最长公共子序列长度表
* @param s1 序列1
* @param s2 序列2
* @param c 最长公共子序列长度表
*/
public static void initLcsLength(String s1, String s2, int[][] c) {
if (s1.isEmpty() || s2.isEmpty()) {
return ;
}
for (int i = 1; i < s1.length() + 1; i++) {
char c1 = s1.charAt(i - 1);
for (int j = 1; j < s2.length() + 1; j++) {
char c2 = s2.charAt(j - 1);
if (c1 == c2) {
c[i][j] = c[i - 1][j - 1] + 1;
} else {
c[i][j] = Math.max(c[i - 1][j], c[i][j - 1]);
}
}
}
}
/**
* 利用最长公共子序列长度的表格c[i,j],打印出所有的最长公共子序列
* @param s1 序列1
* @param s2 序列2
* @param c 最长公共子序列长度表
*/
public static void printLcs(String s1, String s2, int[][] c) {
int maxLen = c[c.length - 1][c[0].length - 1];
List<StringBuilder> list = new ArrayList<StringBuilder>();
List<Root> rootChars = new ArrayList<>();
// 找出所有最长公共子串的末尾元素,即找出c[i,j]值最大且字符相等的位置
int i = c.length - 1, j = c[0].length - 1;
boolean end = false;
while (i > 0 && j > 0 && !end) {
while (j > 0) {
if (c[i][j] < maxLen) {
if (j == c[0].length - 1) {
end = true;
}
break;
}
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
Root myChar = new Root();
myChar.i = i;
myChar.j = j;
rootChars.add(myChar);
break;
} else {
j--;
}
}
i--;
j = c[0].length - 1;
}
// 将末尾元素作为起点元素向下寻找
for (Root root : rootChars) {
StringBuilder sub = new StringBuilder();
int _i = root.i, _j = root.j, last_j = _j, lastLen = c[_i][_j];
// 寻找公共子序列长度差1的,并且c[i,j]处字符相等的元素,加入公共子序列中
while (_i > 0) {
while (_j > 0 && c[_i][_j] > 0 && c[_i][_j] >= lastLen - 1) {
if (s1.charAt(_i - 1) == s2.charAt(_j - 1)) {
sub.append(s1.charAt(_i - 1));
lastLen = c[_i][_j];
last_j = _j;
break;
}
_j--;
}
if (_j > 0 && c[_i][_j] < lastLen - 1) {
_j = last_j - 1;
} else if (c[_i][_j] > 0){
_j--;
}
_i--;
}
list.add(sub);
}
for (StringBuilder sub : list) {
System.out.println("z = " + sub.reverse().toString());
}
}