算法初步:动态规划
原创 by zoe.zhang (重新整理)
1. 分治法
分治法:分治的思想在各种算法中都很重要,分治的思想在于将原问题划分成N个规模较小的结构与原问题相似的子问题,递归解决这些子问题。其关键在于分解和合并;不管是递归还是贪心还是动态规划,其基本思想就是来自于分治思想;
2. 什么是动态规划,动态规划的意义
-- 动态规划的本质是:一个合适的状态转移方程。这其中有三个要素:1.状态,2.状态转移方程 3.合适的。也就是说,一个适用于DP解决的问题,第一步是寻找到合适的角度定义出可以得到解的结果的状态和状态转移方程,如果做到了这一步,问题基本已经解决一半了。剩下的问题就是根据状态转移方程来求解问题,在求解过程中,根据条件进行程序空间、时间以及效率上的优化。
a.问题定义:要找出本质或者等效的子问题。状态转移方程某种程度上可以看做是有条件的递推式,这一过程是需要一定基础的联系,才可以熟练或者说敏锐地找到问题的关键所在。
b.缓存,记忆化,备忘录:这几个名词阐述的是DP求解过程中的技巧,因为有重叠子问题的存在,为了避免重复计算浪费时间,记录下已经计算的结果,用空间来换取时间。在熟练掌握DP问题后,可根据题目的情况使用"滚动数组“的优化技巧来节省空间。
c.无后效性 + 最优子结构:无后效性和最优子结构是能够使用动态规划的判断条件。无后效性和最优子结构的存在保证状态转移方程的合理性。在数学形式上表现为转移方程等式右边不会用到大于左边i或者k的下标。
--动态规划所适用于解决的问题:
(1)递推:最常见也是最经典的递推例子是:斐波那契数列,每求解一个新的状态时只需要前两种状态,不需要更多的状态,这是一个基本的递推式。我们通常会使用一个数组来保存前面计算的结果,用于方便后面状态的递推而免去重复计算。(这里就是备忘录的caching思想,提高程序效率),甚至想要减少空间消耗,而只用两个变量保存前两种状态就可以了(这里其实体现了节省空间的滚动数组的思想)。此时斐波那契数列的时间和空间复杂度均是线性的。
(2)实际问题中,问题的解决通常可以分成n个阶段或者n个步骤来解决,每个阶段时都有多种状态或者多种选择。
a.贪心:在贪心算法适合解决的问题中,我们不考虑之前的选择是什么样的,只要当前的选择是最优的就可以了,下一步最优可以从当前最优得到,每一步的选择都是最优的,最终值便是最优的,较经典的题目时部分背包问题。
b.如果当前阶段的最优与前面做出的选择有关,也就是说状态的最优是从状态选择的组合中产生的,需要根据之前的状态来考虑。那么此时又要分两种情况来考虑:
无后效性:该性质是指在某一阶段状态一旦确定,此后过程的演变不再受此前的各状态及决策的影响,准确说,未来与过去怎么演变的无关,当前状态是此前历史的一个完整的总结,至于历史怎么演变到现在这个状态我们并不关心,影响下一个状态选择的因素只有当前状态,与未来未发生的状态也没有关系,这种情况我们通常采用动态规划,也就是我们这里要说的主题。(理解一下总结这个词)
有后效性:当前的选择或者最优的状态与之前的状态和选择都有关系,也会影响到后面的选择,需要搜索之前状态的组合来获取最优解。这个时候需要使用的就是搜索,也叫作暴力求解,搜索可以解决大部分问题,但通常其时间和空间的消耗都很大,在搜索时有很多的技巧可以使用。
每个阶段只有一个状态->递推;
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。
--动态规划实现方法:
(1)动态规划是解决问题的一种办法;试图用动态规划的时候首先要变化看问题的角度,获取动态规划的问题形式,以及获取状态转移方程。
(2)动态规划的实现有两种很成熟的方法:
自顶向下:top-down Dynamic programming :一般选择自顶向下,(空间换取时间)能够方便使用递归公式,递归实现,容易理解,使用caching技术;逻辑直观。
自底向上:down-top Dynamic Programming:可以进行空间复杂度的优化;也可以用caching技术;逻辑相对不怎么直观。
递归是动态规划的一种实现方式,属于自顶向下的方式,动态规划的实现很多时候采用递归的形式,有时也可以采用非递归的方式。
3. 王道程序员宝典上的解释:
(1)动态规划的备忘录法:避免大量的重复计算,利用空间换取时间;
(2)最原本的动态规划是自底向上求解问题的,其变形采用自顶向下的策略,具备动态规划的效率,即备忘原问题的自然但低效的递归算法,维护了一个记录了子问题解的表。
(3)动态规划适用的分解的子问题往往不是独立的。即重叠子问题,自顶向下对问题求解时,有些子问题会被重复计算很多次。用一个表来记录所有已解的子问题的答案,不管该子问题会不会用到,只要被计算,就将其填入表内,即填表格式。
备忘录:caching技术。
4.例题
(1)LCS:最长公共子序列的问题
![](https://images2015.cnblogs.com/blog/1142038/201704/1142038-20170423120226944-936929699.png)
在获取到了最大公共子序列的长度后,输出结果有两种:输出一个最长公共子序列和输出所有的最长公共子序列,因为LCS通常不唯一,因此需要在动态规划表上进行回溯,
-
如果格子
table[i][j]
对应的X[i-1] == Y[j-1]
,则把这个字符放入 LCS 中,并跳入table[i-1][j-1]
中继续进行判断; -
如果格子
table[i][j]
对应的X[i-1] ≠ Y[j-1]
,则比较table[i-1][j]
和table[i][j-1]
的值,跳入值较大的格子继续进行判断; -
直到 i 或 j 小于等于零为止,倒序输出 LCS 。
-
如果出现table[i-1][j]
等于table[i][j-1]
的情况,说明最长公共子序列有多个,故两边都要进行回溯(这里用到递归)。
include <iostream> #include <algorithm> #include <string> using namespace std;
//单纯输出LCS长度 int c[100][100]; int LCS_Length(string x, string y) { if (x.empty() || y.empty()) // string == "" or x.isempty return 0; int m = x.length(); int n = y.length(); //为0 的初始化 for (int i = 1; i <= m; ++i) c[i][0] = 0; for (int i = 1; i <= n; ++i) c[0][i] = 0; c[0][0] = 0; //递推填表 for (int i = 1; i <= m; ++i) { for (int j = 1; j <= n; ++j) { if (x[i-1] == y[j-1]) // 注意这里 逻辑理解和实际数组下标 数组下标应该从0 开始(x[i] == y[j]) c[i][j] = c[i - 1][j - 1] + 1; else c[i][j] = max(c[i][j - 1], c[i - 1][j]); } } return c[m][n]; } int main() { string x, y; int num; cin >> x >> y; num = LCS_Length(x, y); cout << num; system("pause"); return 0; }
输出LCS序列:添加记录的回溯表,输出一个最长子序列;
#include <iostream> #include <algorithm> #include <string> using namespace std; #define INF 999999 int c[100][100]; int b[100][100] = { 0 }; // fill in the form using caching —— reduce time int LCS_MM(string x, string y, int i, int j) { if (c[i][j]< INF) return c[i][j]; if (i == 0 || j == 0) c[i][j] = 0; else if (x[i - 1] == y[j - 1]) { c[i][j] = LCS_MM(x, y, i - 1, j - 1) + 1; b[i][j] = 0; } else{ int p = LCS_MM(x, y, i - 1, j); int q = LCS_MM(x, y, i, j - 1); if (p >= q) { c[i][j] = p; b[i][j] = 1; } else{ c[i][j] = q; b[i][j] = -1; } } return c[i][j]; } int LCS_length(string x, string y) { int m = x.length(); int n = y.length(); memset(c, INF, sizeof(c)); return LCS_MM(x, y, m, n); } void printLCS(int b[][100], string x, int i, int j) { if (i == 0 || j == 0) return; if (b[i][j] == 0) { printLCS(b, x, i - 1, j - 1); //注意输出与递归的顺序 cout << x[i - 1]; } else if (b[i][j] == 1) { printLCS(b, x, i - 1, j); } else printLCS(b, x, i, j - 1); } int main() { string x, y; int num; cin >> x >> y; num = LCS_length(x, y); cout << num<<endl; printLCS(b, x, x.length(), y.length()); system("pause"); return 0; }
输出所有的最长子序列:采用set容器实现;借鉴自其它博客(不重复输出相同的结果)。
#include <iostream> #include <algorithm> #include <string> #include <set> using namespace std; int c[100][100]; set<string> setofLCs; //保存所有LCS int LCS_Length(string x, string y) { if (x.empty() || y.empty()) // string == "" or x.isempty return 0; int m = x.length(); int n = y.length(); //为0 的初始化 for (int i = 1; i <= m; ++i) c[i][0] = 0; for (int i = 1; i <= n; ++i) c[0][i] = 0; c[0][0] = 0; //递推填表 for (int i = 1; i <= m; ++i) { for (int j = 1; j <= n; ++j) { if (x[i-1] == y[j-1]) // 注意这里 逻辑理解和实际数组下标 数组下标应该从0 开始(x[i] == y[j]) c[i][j] = c[i - 1][j - 1] + 1; else c[i][j] = max(c[i][j - 1], c[i - 1][j]); } } return c[m][n]; } //字符串逆序 string reverse(string str) { int low = 0; int high = str.length() - 1; char temp; while (low < high) { temp = str[low]; str[low] = str[high]; str[high] = temp; high--; low++; } return str; } //返回所有的LCS子序列 void printall(string x, string y, int i, int j, string lcs_str) { while (i > 0 && j > 0) { if (x[i - 1] == y[j - 1]) { lcs_str.push_back(x[i - 1]); --i; --j; } else { if (c[i - 1][j] > c[i][j - 1]) --i; if (c[i - 1][j] < c[i][j - 1]) --j; else //如果相等 { printall(x, y, i - 1, j, lcs_str); printall(x, y, i, j - 1, lcs_str); return; } } } setofLCs.insert(reverse(lcs_str)); } int main() { string x, y,str; int num; cin >> x >> y; num = LCS_Length(x, y); cout << num<<endl; printall(x, y, x.length(), y.length(), str); //set of value set<string>::iterator first = setofLCs.begin(); for (; first != setofLCs.end(); first++) { cout << *first << endl; } system("pause"); return 0; }
5.动态规划加深理解:(我的理解)
(1)递归:递归的本质在于从上向下一步步分解子问题,并不断深入求解子问题,再返回到上层调用。但是递归如果不做程序上的优化,很多时候会产生子问题重复计算的问题。如果要求解的问题复杂后者数据量庞大,重复计算会带来时间复杂度指数级的增长。什么是子问题重复计算:就是每做一步递归的时候都可能要重复计算很多已经计算过的东西。比如最简单的斐波那契数列,虽然每一步的新结果的产生只需要前两步状态结果的相加,但是如果没有使用caching优化,每一步的计算里就会包含前面所有步骤的计算,此时的递归函数虽然看上去简单直观易理解,但是递归的复杂度的增长是指数级爆炸的。
所以这个时候许多优化算法就显示出它的优异性,诸如动态规划、贪心算法等等,这些算法都适用于某一种情境,用于解决某一大类的问题。
(2)动态规划:从定义和资料上理解,动态规划也是如递归一样将大类问题划分到每一类的小问题,从问题的本质出发,将问题分解为每一步的状态,和每一步状态到装填之间的迁移,因此使用动态规划首先需要找到合适的问题角度,得到状态转移方程。目前通常使用动态规划主要有两种方式:
- 自顶向下;
- 自底向上;
先来说自底向上,因为自底向上反映了动态规划的基本思想。为什么说自底向上反映DP的基本思想呢?先来看状态转移方程,状态是一步步迁移变化的,从最开始的小问题到最后最终问题的解决。动态规划的巧妙之处就在这里了,我们获取到了状态转移方程,就可以实现一种称之为“填表”的思想。那么什么是填表思想的内涵?简单说,就是从最小的子问题出发,自底向上,每解决一个子问题,就记录下子问题的答案,这样就类似于获得了一张答案表。从状态转移方程知道,每一个新状态的答案都可以由表中前面的状态答案推导出来,巧妙在于,前面的状态答案我已经填在表里了,我只要拿来用就好了,因而避免了再去重复无谓的计算。
这里要提一下DP问题两个重要的特点:最优子结构,和无后效性。最优子结构是指问题的最优解包含的子问题的解也是最优的。无后效性就是说前面的前面当前状态已经选择完了,我已经达到了最优的状态,我的这个最优状态只跟我前面的状态有关系,(因为我是由之前状态推导来的嘛),后面怎么做决策都不会影响到我现在这个最优状态下的情况选择。
动态规划的效率性就在于解决了子问题重复计算的问题,因为需要重复计算的地方都可以“查表得知”,从已经保存下来的结果获取,不用再去没完没了的递归,指数级的运算就嗖的一下降了下来。
下面来说一下自顶向下和自底向上之间的联系:
自顶向下的方法是比较流行的,在熟练DP思想后,也建议使用自顶向下的方法。在逻辑上它更直观,从形式上来说比较像递归。这里的递归,更详细来说,叫做递归填表。从逻辑上来说我们解决问题都是从上向下一步步分解问题,最后得到最容易解决的最小子问题,然后从最小子问题返回上面再去解决上一个问题。从上向下,递归进入,但这里不同于递归的地方就在于,自顶向下会使用caching/缓存/备忘录技术,这里随便哪个名词都行,因为实际也都是记录下子问题的解,以供下一个问题解时候不需要再做计算。
自底向上的方法实际上是一步步组合已经解决的子问题,最后获得一个大问题的解决。因此看上去可能没那么直观。但是理解了DP思想后,就会发现两种方法的内涵是相同的。
(3)下面通过一个例子来更深刻加深理解
例2:字符串距离,或者称为字符串相似程度:给定任意两个字符串,可以在给定位置插入字符,替换任意字符,删除任意字符,使得源串等于目标串,最小的操作数即为字符串之间的距离,其倒数得到为其相似程度。
#include <iostream> #include <string> #include <algorithm> using namespace std; int minvalue(int a, int b, int c) { int min = a; if (a > b) min = b; if (min > c) min = c; return min; } //从字符串最后推导所得 int calulateDistance(string x, string y) { int lenx = x.length(); int leny = y.length(); //动态申请数组; int **c = new int*[lenx + 1]; //+1 for (int i = 0; i <= lenx; i++) //lenx 注意别写错了 c[i] = new int[leny + 1];//+1 //初始化 c[0][j] =j; c[i][0]=i; for (int i = 1; i <= lenx ; i++) c[i][0] = i; // i for (int j = 1; j <= leny ; j++) c[0][j] = j; // j c[0][0] = 0; //动态规划 for (int i = 1; i <= lenx; i++) for (int j = 1; j <= leny; j++) { if (x[i - 1] == y[i - 1]) c[i][j] = c[i - 1][j - 1]; else c[i][j] = minvalue(c[i - 1][j - 1], c[i - 1][j], c[i][j - 1])+1; // +1 很重要 } int res = c[lenx][leny]; //保留数据 //delete new 出来的空间 for (int i = 0; i <= lenx; i++) delete[]c[i]; delete[]c; return res; } int main() { string x, y; int res; cin >> x >> y; res = calulateDistance(x, y); cout << res << endl; system("pause"); return 0; }
7.简单的趣味题(来自编程之美):最大连续子数组和问题
//时间复杂度为2次方的 普通解法 int maxsum(int *x, int n) { int maximum = -INF; int sum; for (int i = 0; i < n; ++i) { sum = 0; for (int j = i; j < n; ++j) { sum += x[j]; if (sum > maximum) maximum = sum; } } return maximum; }
//动态规划:
int max(int x, int y) { return (x > y) ? x : y; } int maxsum(int *A, int n) { start = A[n - 1]; //外部定义 max_sum = A[n - 1]; for (i = n - 2; i >= 0; i--) { start = max(start + A[i], A[i]); max_sum = max(max_sum, start); } return max_sum; }
//相对完善的过程 //整形数组,数据元素有正数也有复数,求元素组合成的连续子数组之和最大的子数组,时间复杂度为O(n) #include <iostream> #include <vector> using namespace std; int maxsum(vector<int> x, int n) { //参数输入检查 if (x.empty() || n <= 0) return 0; int cur_sum = 0, max_sum = 0; //int index_start =0,index_end =0; for (int i = 0; i < n; i++) { cur_sum += x[i]; if (cur_sum < 0) cur_sum = 0; //index_start = i+1 if (cur_sum > max_sum) max_sum = cur_sum; //index_end = i; } //考虑全为负数的情况; if (max_sum == 0) { max_sum = x[0]; for (int i = 1; i < n; i++) { if (x[i] > max_sum) max_sum = x[i]; //index_start = index_end =i; } } return max_sum; } void main() { vector<int> vec; int i; int res; while (cin >> i) vec.push_back(i); res = maxsum(vec, vec.size()); cout << res << endl; system("pause"); }
8. 背包问题:0-1背包问题和部分背包问题
0-1背包问题:假设有n件物品,编号为1,2,3,……n,其中编号为 i 的物品的价值为Vi,重量为Wi,简化问题起见,假定价值和重量都是整数值,现在有一个背包,最大装载的重量为W,现在从n件物品中挑选物品,使得物品价值最大化。那么如何挑选呢?这里的物品不可分割,因此成为0-1背包问题,也就是说对于一件物品,只有拿走(1)或者不拿走(0)两种状态,Xn =(0/1,0/1……0/1)为解空间,(0/1意味着取或者不取)。0-1背包问题一般用动态规划来解决。
部分背包问题:这n件物品是可以分割,可以拿走部分物品的,就比如最常见的比喻,这里表示不同品质的金沙。部分背包问题一般用贪婪算法来解决。
(1)首先来看0-1背包问题。如果采用暴力搜索,实际上有2^n种选择方式,每种都遍历的话,n较大的时候时间复杂度很容易超出限制。
根据动态规划的要素,首先来寻找一下问题的最优子结构。假设选择K个物品,此时物品总重量<=W,且价值最大,即现在为最优解,那么拿掉第K个物品,对于前k-1个物品,和剩下的W-Wk来说,前K-1也构成最优解。(反证法论证,如果不是最优解,而存在更优解,则与K个物品构成最优解矛盾)。
定义函数c[i,w]:从0-i元素为止,限制总重量为w情况下我们所能选择到的最优解。
上述为状态转移方程,为在考虑了重量和价值总值最大情况比较下得到的。注意边界条件:i=0或者w=0的时候,所得c值均为0。
#define Max 1000 int dp[Max][Max]; int record[Max];//记录最优选择 int C; // 限制的背包最大质量 int KnapSack(int n, int w[], int v[]) //物品个数n,w质量,v价值 { //边界条件 for (int j = 0; j <= C; j++) dp[0][j] = 0; for (int i = 0; i <= n; i++) dp[i][0] = 0; //Dynamic programming for (int j = 1; j <= C; j++){ for (int i = 1; i <= n; i++) { if (w[i] > j) dp[i][j] = dp[i - 1][j]; else dp[i][j] = max(dp[i-1][j-w[i]]+v[i],dp[i-1][j]); } } //最优解 int wight = C; for (int i = n; i > 0; i--) { if (dp[i][wight] > dp[i - 1][weight]) { record[i] = 1; wight = wight - w[i]; } else { record[i] = 0; } } return dp[n][C]; }
(2)部分背包问题:贪心算法,每次选择最优单位价格的物品,知道背包的重量达到限制要求。