动态规划
来源:互联网 发布:深圳少儿编程培训 编辑:程序博客网 时间:2024/06/14 10:56
动态规划
对很多问题,动态规划(Dynamic Programming)是个强有力的武器,因为大部分情况下它可以大大降低算法的时间复杂度。动态规划的一个重要的应用是在满足最优性原理的优化问题,所谓最优性原理指的是问题的一个最优解总是包含子问题的最优解,但这并不是说所有子问题的最优解都对最终解做贡献。动态规划与分治法(Divide-and-Conquer)策略比较类似,都是将一个问题分解成子问题,但是分治法一般用于独立子问题的情形,就是说子问题之间没有重叠,而动态规划对子问题重叠的情形特别有效。如同分治法一样,动态规划是一种通用性的方法,所以我们还是从例子出发来深刻理解动态规划。
从斐波那契数列说起
斐波那契数列数列大家再熟悉不过了,其满足以下递推式:
递归式已经给出了,我们很容易使用递归的方式来实现计算斐波那契数列第
// 使用递归的方式计算斐波那契数列int fib(int n){ if (n <= 2) return 1; return fib(n-1) + fib(n-2);}
代码是那么的简洁,但是当你计算的
// table是大小为n+1的数组,为了利用已经计算的结果int fib(int n, vector<int>& table){ // 重复利用 if (table[n] != 0) return table[n]; // 无,那就要将结果保存至table if (n <= 2) { table[n] = 1; } else { table[n] = fib(n - 1, table) + fib(n - 2, table); } return table[n];}
利用一个查询表,我们可以避免很多子问题的重复计算。这就是动态规划的思想。但是有一点,我们采用了递归的方式,这意味着我们在设计方案时,采用的是“自上而下”的方式,这是分治法经常使用的方案:从原问题出发,拆分子问题,继续……,直到无法拆分。尽管看起来是自上而下,但是其实计算时还是先从小的实例开始。我们不禁会想,既然我们使用利用子问题的解来求解原问题,可不可以先从最小的实例开始,即直接采用“自下而上”的方案。完全没有问题,其实动态规划大部分都是采用“自下而上”的思路。这样做有两个好处,首先我们避免的代价较高的递归,因为可以采用循环的方式。其次,有时候我们可以减少内存的使用,因为可能不必存储所有子问题,子问题利用后就被丢弃,不会影响会面的计算。基于这种思路,我们给出了最终版本:
int fib(int n){ int a = 1, b = 1; for (int i = 3; i <= n; ++i) { int tmp = b; b += a; a = tmp; } return b;}
上面我们从最小的实例开始,利用循环的方式来高效地完成整个计算。可以看到动态规划的思路一般是:(1)确立一种递归关系,就是联系原问题与子问题解之间联系;(2)首先从最小实例开始,以自下而上的方式求解原问题。有时候,我们需要记录每个子问题的解,有时候却并不需要。其实采用“自下而上”的方式是优先考虑的方式,但是这并不代表你不可以采用“自上而下”的方案。
最大公共子序列
斐波那契数列毕竟过于简单,这里开始一个复杂一点的例子:最大公共子序列(Longest Common Subsequence,LCS)。其问题是给定两个序列
- (1)
S[i]≠T[j] ,此时相当于去掉S[i] 或者T[j] ,其分别对应于求解LCS[i−1][j] 和LCS[i][j−1] ,所以LCS[i][j] 可以取两者的最大值:LCS[i][j]=max(LCS[i−1][j],LCS[i][j−1]) - (3)
S[i]=T[j] ,此时两个元素匹配,而且它们两个正好匹配一定是最好的结果,假如你想让S[i] 匹配T[j] 之前的元素,那么S[i] 直接匹配T[j] 的结果一定不会差于这个结果。所以,最优情况是让两者匹配,那么LCS[i][j] 就依赖于LCS[i−1][j−1] :LCS[i][j]=1+LCS[i−1][j−1]
可以看到原问题总是可以利用子问题的解,这正好符合动态规划的原则。我们使用数组
int lcs(const string& s, const string& t){ // 保存子问题最大公共子序列长度,初始化为0 vector<vector<int>> len(s.size() + 1, vector<int>(t.size() + 1, 0)); for (int i = 1; i <= s.size(); ++i) { for (int j = 1; j <= t.size(); ++j) { if (s[i - 1] == t[j - 1]) { len[i][j] = len[i - 1][j - 1] + 1; } else { len[i][j] = max(len[i][j - 1], len[i - 1][j]); } } } return len[s.size()][t.size()];}
可以看到上面的算法复杂度为
string lcs(const string& s, const string& t){ // 保存子问题最大公共子序列长度,初始化为0 vector<vector<int>> len(s.size() + 1, vector<int>(t.size() + 1, 0)); for (int i = 1; i <= s.size(); ++i) { for (int j = 1; j <= t.size(); ++j) { if (s[i - 1] == t[j - 1]) { len[i][j] = len[i - 1][j - 1] + 1; } else { len[i][j] = max(len[i][j - 1], len[i - 1][j]); } } } // 计算lcs string ls = ""; for (int i = s.size(), j = t.size(); i >= 1 && j >= 1; ) { if (len[i][j] == len[i - 1][j]) { --i; } else if (len[i][j] == len[i][j - 1]) { --j; } else { ls += s[i-1]; --i; --j; } } reverse(ls.begin(), ls.end()); return ls;}
采用“自下而上”的方式,我们总是从较小的实例开始,然后利用递归关系前进。算法是采用循环的方式来完成的。在循环时,一定要确保较小的实例先被计算出来。但是你同样可以选择“自上而下”的思维,那就是利用递归,因为有了递归关系,写出一个递归函数是那么地自然:
// 效率低下的递归int recursionLcs(const string& s, int i, const string& t, int j){ // 边界条件 if (i == 0 || j == 0) return 0; // 递归关系 if (s[i - 1] == t[j - 1]) { return 1 + recursionLcs(s, i - 1, t, j - 1); } return max(recursionLcs(s, i, t, j - 1), recursionLcs(s, i - 1, t, j));}int lcs(const string& s, const string& t){ return recursionLcs(s, s.size(), t, t.size());}
是不是很简单,但是这个效率很低下,时间复杂度是指数级的。因为会造成重复计算,所以还是要建立一个查询表,一旦较小的实例已经被计算,就放入这个表中,以供下次查询使用。所以,修改如下:
// 动态规划int recursionLcs(const string& s, int i, const string& t, int j, vector<vector<int>>& table){ // 先进行查询 (-1代表没有计算过) if (table[i][j] != -1) return table[i][j]; // 边界条件 if (i == 0 || j == 0) { table[i][j] = 0; } // 递归关系 else if (s[i - 1] == t[j - 1]) { table[i][j] = 1 + recursionLcs(s, i - 1, t, j - 1, table); } else { table[i][j] = max(recursionLcs(s, i, t, j - 1, table), recursionLcs(s, i - 1, t, j, table)); } return table[i][j];}int lcs(const string& s, const string& t){ vector<vector<int>> table(s.size() + 1, vector<int>(t.size() + 1, -1)); return recursionLcs(s, s.size(), t, t.size(), table);}
我们增加了一个查询表,从而避免相同子问题的重复计算,这有利于提升效率。这是“自上而下”式的动态规划,从本质上两者没有任何区别。前面说过,我们优先选择循环式的“自下而上”的设计。其实,这也不尽然。因为“自上而下”的方式有可能更高效。为什么呢?因为递归的过程中只会计算真正需要使用的子问题,但是“自下而上”的方式往往需要把所有子问题计算出来,因为大部分时候我们可能并不知道到底哪些子问题是后面计算需要的。孰优孰劣,很难说。看你自己的选择,不过还是优先推荐“自下而上”的策略。
背包问题
背包问题估计你不会陌生,因为提到它,就想到了动态规划。我们先来定义问题:假设有
那么我们考虑使用动态规划来解决背包问题,关键是看大问题可以不以利用子问题。假如
上面的递推式要特别注意容量约束。计算出
int knapsack(const vector<int>& w, const vector<int>& p, int W){ // 定义二维数组 vector<vector<int>> ops(w.size() + 1, vector<int>(W + 1, 0)); // 利用递推关系 for (int i = 1; i <= w.size(); ++i) { for (int j = 1; j <= W; ++j) { if (w[i - 1] <= j) { ops[i][j] = max(ops[i - 1][j], p[i - 1] + ops[i - 1][j - w[i - 1]]); } else { ops[i][j] = ops[i - 1][j]; } } } return ops[w.size()][W];}
两层循环,解决问题。但是上面只是计算出了背包最优解的最大价值,但是如果你想求得这个最优解到底包含哪些物品,你可以用与最大公共子序列类似的方法,从这个二维数组最低下开始,逆着前推。这里就不贴代码了,基本和上面是类似的思路。
“自下而上”的方式讲完了,但是就像前面说的,我们必须把所有的子问题都计算出来。但是其实,我们有时候并不需要所有的子例。这里我们以前面的那个例子来从
// i代表的是物品序号,W代表的是背包容量int knapsack(const vector<int>& w, const vector<int>& p, int i, int W){ // 没有选择物品 if (i == 0) return 0; // 递归关系 if (w[i - 1] > W) return knapsack(w, p, i - 1, W); return max(knapsack(w, p, i - 1, W), p[i - 1] + knapsack(w, p, i - 1, W - w[i - 1]));}
有了递归关系,递归函数写起来总是那么简单。但是上面的递归没有利用查询表,这样会重复计算子问题。所以,我们还是要修改一下,但是此时我们不再使用二维数组作为查询表,因为实际上并不需要计算那么多子问题。我们可以使用关联容器来作为查询表:
// i代表的是物品序号,W代表的是背包容量int knapsack(const vector<int>& w, const vector<int>& p, int i, int W, map<pair<int, int>, int>& table){ pair<int, int> item{ i, W }; // 重复利用 if (table.find(item) != table.end()) { return table[item]; } // 没有选择物品 if (i == 0) { table[item] = 0; } else if (w[i - 1] > W) { table[item] = knapsack(w, p, i - 1, W, table); } else { table[item] = max(knapsack(w, p, i - 1, W, table), p[i - 1] + knapsack(w, p, i - 1, W - w[i - 1], table)); } return table[item];}
我们使用上面的例子测试这个代码,会发现确实只是计算了需要用到的子问题:
int main(){ map<pair<int,int>, int> table; vector<int> w{ 5, 10, 20 }; vector<int> p{ 50, 60, 140 }; cout << knapsack(w, p, 3, 30, table) << endl; for (auto& i : table) { cout << i.first.first << ',' << i.first.second << ": " << i.second << endl; } return 0;}输出:2000,0: 00,5: 00,10: 00,15: 00,20: 00,25: 00,30: 01,0: 01,10: 501,20: 501,30: 502,10: 602,30: 1103,30: 200
此时,“自上而下”的递归实现就显得有优势,但是递归可能需要较深的栈。所以,还是视情况定吧。背包问题就说这么多了。
Floyd最短路径算法
这里我们介绍一个复杂一点的例子,就是最短路径算法。考虑这样的场景,当两个城市之间没有直飞的航班时,如何选择一个最短路线。要解决这个问题,我们首先要把它抽象出来,这就要涉及到图模型了。图(graph)由顶点(vertice)与边(edge)组成,所以我们一般将图模型用G(E,V)表示。这里,每个顶点可以代表一个城市,而边代表两个城市之间的路线,一般边会有权重值,比如代表距离,这时就称为有权图。我们可以用一个矩阵
这种表示方法为邻接矩阵法。我们的问题是现在要找出每个顶点到其它顶点的最短路径。假如用
- 情况1:顶点
vk 对顶点vi 到顶点vj 的最短路径没有帮助,即至少有一条最短路径(最短路径可能不止一条)可以不使用顶点vk ,那么很显然,此时D(k)[i][j]=D(k−1)[i][j] ; - 情形2:顶点
vk 对顶点vi 到顶点vj 的最短路径有帮助,即所有可能的最短路径一定包含顶点vk ,也可以说是这个顶点成为了关键中间顶点,由于必须用这个顶点,此时最短路径就要看顶点vi 到顶点vk 的最短路径,以及顶点vk 到顶点vj 的最短路径,与两个子路径的最优路径相关,并且这些最短路径使用的顶点集为{v1,v2,...,vk−1} ,这里要说明一下,这些最优子路径不可能使用顶点vk ,为什么呢?因为vk 是两个子路径的端点。所以最短路径是两个最短子路径之和:D(k)[i][j]=D(k−1)[i][k]+D(k−1)[k][j] 。
就这两种情况,所以可以取两种情况的最小值就是最短路径:
我们得到了递归关系,但是情况有点复杂。因为原来我们仅关注这个顶点
// 最短路径void minPath(const vector<vector<int>>& W, vector<vector<int>>& D){ int n = W.size(); // 顶点总数 D = W; // 存储最短路径,此时是k=0的结果 // k = 1开始 for (int k = 1; k <= n; ++k) { for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { D[i][j] = min(D[i][j], D[i][k - 1] + D[k - 1][j]); } } }}
一个运行实例为:
int main(){ const int INFITY = 10000; vector<vector<int>> W{ {0, 1, INFITY, 1, 5}, {9, 0, 3, 2, INFITY} , {INFITY, INFITY, 0, 4, INFITY}, {INFITY, INFITY, 2, 0, 3 }, { 3, INFITY, INFITY, INFITY, 0 } }; vector<vector<int>> D; minPath(W, D); for (auto& i : D) { for (auto j : i) { cout << j << " "; } cout << '\n'; } return 0;}输出:0 1 3 1 48 0 3 2 510 11 0 4 76 7 2 0 33 4 6 4 0
但是如果想具体知道顶点
// 最短路径void minPath(const vector<vector<int>>& W, vector<vector<int>>& D, vector<vector<int>>& P){ int n = W.size(); // 顶点总数 D = W; // 存储最短路径,此时是k=0的结果 // 初始化P:保存中间节点 P = vector<vector<int>>(n, vector<int>(n, 0)); // k = 1开始 for (int k = 1; k <= n; ++k) { for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { if (D[i][j] > D[i][k - 1] + D[k - 1][j]) // 不要使用>= { P[i][j] = k; D[i][j] = D[i][k - 1] + D[k - 1][j]; } } } }}// 输出顶点start到顶点end最短路径void printPath(const vector<vector<int>>& P, int start, int end){ if (P[start - 1][end - 1] != 0) // 有中间节点 { // 先输出中间节点前的顶点 printPath(P, start, P[start - 1][end - 1]); // 输出中间节点 cout << P[start - 1][end - 1] << "->"; // 输出中间节点后的顶点 printPath(P, P[start - 1][end - 1], end); }}int main(){ const int INFITY = 10000; vector<vector<int>> W{ {0, 1, INFITY, 1, 5}, {9, 0, 3, 2, INFITY} , {INFITY, INFITY, 0, 4, INFITY}, {INFITY, INFITY, 2, 0, 3 }, { 3, INFITY, INFITY, INFITY, 0 } }; vector<vector<int>> D; vector<vector<int>> P; minPath(W, D, P); cout << "顶点5到顶点3的最短路径长为: " << D[4][3]; cout << ",最短路径为: 5->"; printPath(P, 5, 3); cout << "3\n"; return 0;}输出:顶点5到顶点3的最短路径长为: 4,最短路径为: 5->1->4->3
利用递归的方式,我们可以输出两个顶点之间的最短路径。但是,上面有一点要注意,就是在比较D[i][j]
与 D[i][k - 1] + D[k - 1][j]
时,不要使用等号,这不会影响D
的结果,但是会造成P
错误。因为当k-1==i
或者k-1==j
时,会取等号,但是此时没有意义,因为k
不再是中间节点,而是端点。所以根据P
利用递归方式输出路径就会有误。最后,这里我们采用“自下而上”的策略,那么使用”自上而下“的递归方式是否可以呢?大家不妨试试。。。不过我觉得“自下而上”的方式是最合适的,因为我们这里是求得所有顶点之间的最短路径,而且它们之间相关联,那么所有的子问题都是需要计算的。
最长递增子序列
最后一个例子是最长递增子序列(longest increasing subsequence , LIS),我们首先定义问题:给定一个整数序列
一旦我们计算出包含
int lis(const vector<int>& s){ vector<int> ls(s.size(), 0); // 对于k=1 ls[0] = 1; // 只有一个元素 for (int i = 1; i < s.size(); ++i) { ls[i] = 1; // 仅含有自己 for (int j = 0; j < i; ++j) { if (s[j] <= s[i] && ls[i] < 1 + ls[j]) { ls[i] = 1 + ls[j]; } } } return *max_element(ls.begin(), ls.end());}
实现包含两层循环,所以算法复杂度为
vector<int> lis(const vector<int>& s){ vector<int> ls(s.size(), 0); vector<int> ps(s.size(), -1); // 对于k=1 ls[0] = 1; // 只有一个元素 ps[0] = -1; // 无前接紧邻节点 for (int i = 1; i < s.size(); ++i) { ls[i] = 1; // 仅含有自己 ps[i] = 0; // 无前接紧邻节点 for (int j = 0; j < i; ++j) { if (s[j] <= s[i] && ls[i] < 1 + ls[j]) { ls[i] = 1 + ls[j]; ps[i] = j; } } } int pos = max_element(ls.begin(), ls.end()) - ls.begin(); // lis的最后一个元素位置 vector<int> result; result.push_back(s[pos]); while (ps[pos] != -1) { pos = ps[pos]; result.push_back(s[pos]); } reverse(result.begin(), result.end()); return result;}
上面的算法复杂度为
vector<int> lis(const vector<int>& s){ vector<int> m; // 初始化 m.push_back(s[0]); for (int i = 1; i < s.size(); ++i) { // lis增加1 if (s[i] >= m.back()) { m.push_back(s[i]); } else { // 利用lower_bound函数找到第一个大于或等于s[i]的位置 *lower_bound(m.begin(), m.end(), s[i]) = s[i]; } } return m;}
这种处理很巧妙,看来动态规划灵活的地方太多了。
后记
动态规划只是一种思想,并无定型,这是我的感受。网上有人说递推关系不是动态规划的本质,说动态规划的本质是状态转移方程。其实感觉还是一回事。递推式也罢,状态转移方程也好。要想使用动态规划,你需要首先学会分解问题,然后想着如何利用分解的问题解来计算当前解,当然最终的目的是提升效率。还有一点,就是两种思维模式:“自下而上”与“自下而上”。还是多多练习,才是王道。
References
[1] Mark Allen Weiss, Data Structures and Algorithm Analysis in C++, fourth edition, 2013.
[2] Richard E. Neapolitan, Foundations of Algorithms, fifth edition, 2016.
[3] Dynamic Programming 笔记.
[4] 渡部有隆(日)著, 支鹏浩 译,挑战程序设计竞赛2:算法和数据结构, 2016.
- 动态规划!!!动态规划!!!
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- 动态规划
- linux gdb调试命令
- IMWeb训练营作业
- 名企笔试:360研发工程师笔试题(挑选镇长)2017-03-14 算法爱好者
- Android handler常见api
- 烧程序测试记录Day1
- 动态规划
- linux1
- params可变参数
- c++primer里的文本查询程序扩展
- InputStream与OutputStream文件操作
- hdu2669 Romantic(扩展欧几里得入门)
- 人工智能会不会取代开发它的人?
- 日期问题模版ZOJ Problem Set -3950 How Many NinesZOJ 找9
- COGS 729. [网络流24题] 圆桌聚餐