什么是动态规划算法,常见的动态规划问题分析与求解
理解动态规划
动态规划中递推式的求解方法不是动态规划的本质。
我曾经给学校参加NOIP的同学多次讲过动态规划,我试着讲一下我理解的动态规划,争取深入浅出。希望你看了我的答案,能够喜欢上动态规划。
0. 动态规划的本质,是对问题状态的定义 和状态转移方程 的定义。
引自维基百科
dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.动态规划是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
本题下的其他答案,大多都是在说递推的求解方法,但如何拆分问题,才是动态规划的核心。
而拆分问题,靠的就是状态的定义和状态转移方程的定义。
1. 什么是状态的定义?
首先想说大家千万不要被下面的数学式吓到,这里只涉及到了函数相关的知识。我们先来看一个动态规划的教学必备题:
给定一个数列,长度为N,要解决这个问题,我们首先要定义这个问题和这个问题的子问题。
求这个数列的最长上升(递增)子数列(LIS)的长度.
以 1 7 2 8 3 4 为例。
这个数列的最长递增子数列是 1 2 3 4,长度为4;
次长的长度为3, 包括 1 7 8; 1 2 3 等.
有人可能会问了,题目都已经在这了,我们还需定义这个问题吗?需要,原因就是这个问题在字面上看,找不出子问题,而没有子问题,这个题目就没办法解决。
所以我们来重新定义这个问题:
给定一个数列,长度为N,显然,这个新问题与原问题等价。
设为:以数列中第k项结尾的最长递增子序列的长度.
求中的最大值.
而对于
上述的新问题
之所以把
对状态的定义只有一种吗?当然不是。
我们甚至可以二维的,以完全不同的视角定义这个问题:
给定一个数列,长度为N,这个新定义与原问题的等价性也不难证明,请读者体会一下。
设为:
在前i项中的,长度为k的最长递增子序列中,最后一位的最小值..
若在前i项中,不存在长度为k的最长递增子序列,则为正无穷.
求最大的x,使得不为正无穷。
上述的
2. 什么是状态转移方程?
上述状态定义好之后,状态和状态之间的关系式,就叫做状态转移方程。比如,对于LIS问题,我们的第一种定义:
设设A为题中数列,状态转移方程为:为:以数列中第k项结尾的最长递增子序列的长度.
用文字解释一下是:(根据状态定义导出边界情况)
以第k项结尾的LIS的长度是:保证第i项比第k项小的情况下,以第i项结尾的LIS长度加一的最大值,取遍i的所有值(i小于k)。
第二种定义:
设设A为题中数列,状态转移方程为:为:在数列前i项中,长度为k的递增子序列中,最后一位的最小值
若(边界情况需要分类讨论较多,在此不列出,需要根据状态定义导出边界情况。)则
否则:
大家套着定义读一下公式就可以了,应该不难理解,就是有点绕。
这里可以看出,这里的状态转移方程,就是定义了问题和子问题之间的关系。
可以看出,状态转移方程就是带有条件的递推式。
3. 动态规划迷思
本题下其他用户的回答跟动态规划都有或多或少的联系,我也讲一下与本答案的联系。a. “缓存”,“重叠子问题”,“记忆化”:
这三个名词,都是在阐述递推式求解的技巧。以Fibonacci数列为例,计算第100项的时候,需要计算第99项和98项;在计算第101项的时候,需要第100项和第99项,这时候你还需要重新计算第99项吗?不需要,你只需要在第一次计算的时候把它记下来就可以了。
上述的需要再次计算的“第99项”,就叫“重叠子问题”。如果没有计算过,就按照递推式计算,如果计算过,直接使用,就像“缓存”一样,这种方法,叫做“记忆化”,这是递推式求解的技巧。这种技巧,通俗的说叫“花费空间来节省时间”。都不是动态规划的本质,不是动态规划的核心。
b. “递归”:
递归是递推式求解的方法,连技巧都算不上。
c. "无后效性",“最优子结构”:
上述的状态转移方程中,等式右边不会用到下标大于左边i或者k的值,这是"无后效性"的通俗上的数学定义,符合这种定义的状态定义,我们可以说它具有“最优子结构”的性质,在动态规划中我们要做的,就是找到这种“最优子结构”。
在对状态和状态转移方程的定义过程中,满足“最优子结构”是一个隐含的条件(否则根本定义不出来)。对状态和“最优子结构”的关系的进一步解释,什么是动态规划?动态规划的意义是什么? - 王勐的回答 写的很好,大家可以去读一下。
需要注意的是,一个问题可能有多种不同的状态定义和状态转移方程定义,存在一个有后效性的定义,不代表该问题不适用动态规划。这也是其他几个答案中出现的逻辑误区:
动态规划方法要寻找符合“最优子结构“的状态和状态转移方程的定义,在找到之后,这个问题就可以以“记忆化地求解递推式”的方法来解决。而寻找到的定义,才是动态规划的本质。
有位答主说:
分治在求解每个子问题的时候,都要进行一遍计算这就像说多加辣椒的菜就叫川菜,多加酱油的菜就叫鲁菜一样,是存在误解的。
动态规划则存储了子问题的结果,查表时间为常数
文艺的说,动态规划是寻找一种对问题的观察角度,让问题能够以递推(或者说分治)的方式去解决。寻找看问题的角度,才是动态规划中最耀眼的宝石!(大雾)
王勐动态规划的本质
理解动态规划并不需要数学公式介入,只是完全解释清楚需要点篇幅…首先需要明白哪些问题不是动态规划可以解决的,才能明白为神马需要动态规划。不过好处时顺便也就搞明白了递推贪心搜索和动规之间有什么关系,以及帮助那些总是把动规当成搜索解的同学建立动规的思路。当然熟悉了之后可以直接根据问题的描述得到思路,如果有需要的话再补充吧。
动态规划是对于 某一类问题 的解决方法!!重点在于如何鉴定“某一类问题”是动态规划可解的而不是纠结解决方法是递归还是递推!
怎么鉴定dp可解的一类问题需要从计算机是怎么工作的说起…计算机的本质是一个状态机,内存里存储的所有数据构成了当前的状态,CPU只能利用当前的状态计算出下一个状态(不要纠结硬盘之类的外部存储,就算考虑他们也只是扩大了状态的存储容量而已,并不能改变下一个状态只能从当前状态计算出来这一条铁律)
当你企图使用计算机解决一个问题是,其实就是在思考如何将这个问题表达成状态(用哪些变量存储哪些数据)以及如何在状态中转移(怎样根据一些变量计算出另一些变量)。所以所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!
太抽象了还是举个例子吧:
比如说我想计算第100个非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性递增的,所以时间复杂度也是线性的。
上面这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行(a[i]=a[i-1]+a[i-2]),不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。对于这样的解法,我们叫递推。
非波那契那个例子过于简单,以至于让人忽视了阶段的概念,所谓阶段是指随着问题的解决,在同一个时刻可能会得到的不同状态的集合。非波那契数列中,每一步会计算得到一个新数字,所以每个阶段只有一个状态。想象另外一个问题情景,假如把你放在一个围棋棋盘上的某一点,你每一步只能走一格,因为你可以东南西北随便走,所以你当你同样走四步可能会处于很多个不同的位置。从头开始走了几步就是第几个阶段,走了n步可能处于的位置称为一个状态,走了这n步所有可能到达的位置的集合就是这个阶段下所有可能的状态。
现在问题来了,有了阶段之后,计算新状态可能会遇到各种奇葩的情况,针对不同的情况,就需要不同的算法,下面就分情况来说明一下:
假如问题有n个阶段,每个阶段都有多个状态,不同阶段的状态数不必相同,一个阶段的一个状态可以得到下个阶段的所有状态中的几个。那我们要计算出最终阶段的状态数自然要经历之前每个阶段的某些状态。
好消息是,有时候我们并不需要真的计算所有状态,比如这样一个弱智的棋盘问题:从棋盘的左上角到达右下角最短需要几步。答案很显然,用这样一个弱智的问题是为了帮助我们理解阶段和状态。某个阶段确实可以有多个状态,正如这个问题中走n步可以走到很多位置一样。但是同样n步中,有哪些位置可以让我们在第n+1步中走的最远呢?没错,正是第n步中走的最远的位置。换成一句熟悉话叫做“下一步最优是从当前最优得到的”。所以为了计算最终的最优值,只需要存储每一步的最优值即可,解决符合这种性质的问题的算法就叫贪心。如果只看最优状态之间的计算过程是不是和非波那契数列的计算很像?所以计算的方法是递推。
既然问题都是可以划分成阶段和状态。这样一来我们一下子解决了一大类问题:一个阶段的最优可以由前一个阶段的最优得到。
如果一个阶段的最优无法用前一个阶段的最优得到呢?
什么你说只需要之前两个阶段就可以得到当前最优?那跟只用之前一个阶段并没有本质区别。最麻烦的情况在于你需要之前所有的情况才行。
再来一个迷宫的例子。在计算从起点到终点的最短路线时,你不能只保存当前阶段的状态,因为题目要求你最短,所以你必须知道之前走过的所有位置。因为即便你当前再的位置不变,之前的路线不同会影响你的之后走的路线。这时你需要保存的是之前每个阶段所经历的那个状态,根据这些信息才能计算出下一个状态!
每个阶段的状态或许不多,但是每个状态都可以转移到下一阶段的多个状态,所以解的复杂度就是指数的,因此时间复杂度也是指数的。哦哦,刚刚提到的之前的路线会影响到下一步的选择,这个令人不开心的情况就叫做有后效性。
刚刚的情况实在太普遍,解决方法实在太暴力(爆搜),有没有哪些情况可以避免如此的暴力呢?
契机就在于后效性。
有一类问题,看似需要之前所有的状态,其实不用。不妨也是拿最长上升子序列的例子来说明为什么他不必需要暴力搜索,进而引出动态规划的思路。
假装我们年幼无知想用搜索去寻找最长上升子序列。怎么搜索呢?需要从头到尾依次枚举是否选择当前的数字,每选定一个数字就要去看看是不是满足“上升”的性质,这里第i个阶段就是去思考是否要选择第i个数,第i个阶段有两个状态,分别是选和不选。哈哈,依稀出现了刚刚迷宫找路的影子!咦慢着,每次当我决定要选择当前数字的时候,只需要和之前选定的一个数字比较就行了!这是和之前迷宫问题的本质不同!这就可以纵容我们不需要记录之前所有的状态啊!既然我们的选择已经不受之前状态的组合的影响了,那时间复杂度自然也不是指数的了啊!虽然我们不在乎某序列之前都是什么元素,但我们还是需要这个序列的长度的。所以只需要记录以某个元素结尾的LIS长度就好!因此第i个阶段的最优解只是由前i-1个阶段的最优解得到的,然后就得到了DP方程(感谢@韩曦 指正)
<span style="font-size:18px;">#include <iostream> using namespace std; int lis(int A[], int n) { int *d = new int[n]; int len = 1; for(int i=0; i<n; ++i) { d[i] = 1; for(int j=0; j<i; ++j) if(A[j]<=A[i] && d[j]+1>d[i]) d[i] = d[j] + 1; if(d[i]>len) len = d[i]; } delete[] d; return len; } int main(){ int A[] = {5, 3, 4, 8, 6, 7}; cout<<lis(A, 6)<<endl; return 0; }</span>该算法的时间复杂度是O(n^2 ),并不是最优的解法。还有一种很巧妙的算法可以将时间复杂度降到O(nlogn),网上已经有各种文章介绍它,主要是维护一个上升序列表,然后对每一个新加入的数字尝试去二分插入。
传送门:LIS的O(nlogn)解法。此题还可以用“排序+LCS”来解,感兴趣的话可自行Google。
所以一个问题是该用递推、贪心、搜索还是动态规划,完全是由这个问题本身阶段间状态的转移方式决定的!
每个阶段只有一个状态->递推;
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到这个性质叫做最优子结构;
而不管之前这个状态是如何得到的,这个性质叫做无后效性。
另:其实动态规划中的最优状态的说法容易产生误导,以为只需要计算最优状态就好,LIS问题确实如此,转移时只用到了每个阶段“选”的状态。但实际上有的问题往往需要对每个阶段的所有状态都算出一个最优值,然后根据这些最优值再来找最优状态。比如背包问题就需要对前i个包(阶段)容量为j时(状态)计算出最大价值。然后在最后一个阶段中的所有状态种找到最优值。
常见的动态规划问题分析与求解
五岳——算法设计手册常用算法归类
动态规划(Dynamic Programming,简称DP),虽然抽象后进行求解的思路并不复杂,但具体的形式千差万别,找出问题的子结构以及通过子结构重新构造最优解的过程很难统一,并不像回溯法具有解决绝大多数问题的银弹(全面解析回溯法:算法框架与问题求解)。为了解决动态规划问题,只能靠多练习、多思考了。本文主要是对一些常见的动态规划题目的收集,希望能有所帮助。难度评级受个人主观影响较大,仅供参考。
目录(点击跳转)
3.最长公共子序列(Longest Common Subsequence,lcs)
4.最长递增子序列(Longest Increasing Subsequence,lis)
判断问题的子结构(也可看作状态),当具有最优子结构时,动态规划可能适用。
求解重叠子问题。一个递归算法不断地调用同一问题,递归可以转化为查表从而利用子问题的解。分治法则不同,每次递归都产生新的问题。
重新构造一个最优解。
动态规划的一种变形,使用自顶向下的策略,更像递归算法。
初始化时表中填入一个特殊值表示待填入,当递归算法第一次遇到一个子问题时,计算并填表;以后每次遇到时只需返回以前填入的值。
实例可以参照矩阵链乘法部分。
1.硬币找零
难度评级:★
假设有几种硬币,如1、3、5,并且数量无限。请找出能够组成某个数目的找零所使用最少的硬币数。
解法:
用待找零的数值k描述子结构/状态,记作sum[k],其值为所需的最小硬币数。对于不同的硬币面值coin[0...n],有sum[k] = min(sum[k-coin[0]] , sum[k-coin[1]], ...)+1。对应于给定数目的找零total,需要求解sum[total]的值。
typedef struct { int nCoin; //使用硬币数量 //以下两个成员是为了便于构造出求解过程的展示 int lastSum;//上一个状态 int addCoin;//从上一个状态达到当前状态所用的硬币种类 } state;
state *sum = malloc(sizeof(state)*(total+1)); //init for(i=0;i<=total;i++) sum[i].nCoin = INF; sum[0].nCoin = 0; sum[0].lastSum = 0; for(i=1;i<=total;i++) for(j=0;j<n;j++) if(i-coin[j]>=0 && sum[i-coin[j]].nCoin+1<sum[i].nCoin) { sum[i].nCoin = sum[i-coin[j]].nCoin+1; sum[i].lastSum = j; sum[i].addCoin = coin[j]; } if(sum[total].nCoin == INF) { printf("can't make change.\n"); return 0; } else //output ;
通过sum[total].lastSum和sum[total].addCoin,很容易通过循环逆序地或者编写递归调用的函数正序地输出从结束状态到开始状态使用的硬币种类。以下各题输出状态转换的方法同样,不再赘述。下面为了方便起见,有的题没有在构造子结构的解时记录状态转换,如果需要请类似地完成。
扩展:
(1)一个矩形区域被划分为N*M个小矩形格子,在格子(i,j)中有A[i][j]个苹果。现在从左上角的格子(1,1)出发,要求每次只能向右走一步或向下走一步,最后到达(N,M),每经过一个格子就把其中的苹果全部拿走。请找出能拿到最多苹果数的路线。
难度评级:★
分析:
这道题中,当前位置(i,j)是状态,用M[i][j]来表示到达状态(i,j)所能得到的最多苹果数,那么M[i][j] = max(M[i-1][j],M[i][j-1]) + A[i][j] 。特殊情况是M[1][1]=A[1][1],当i=1且j!=1时,M[i][j] = M[i][j-1] + A[i][j];当i!=1且j=1时M[i][j] = M[i-1][j] + A[i][j]。
求解程序略。
难度评级:★
2.字符串相似度/编辑距离(edit distance)
难度评级:★
对于序列S和T,它们之间距离定义为:对二者其一进行几次以下的操作(1)删去一个字符;(2)插入一个字符;(3)改变一个字符。每进行一次操作,计数增加1。将S和T变为同一个字符串的最小计数即为它们的距离。给出相应算法。
解法:
将S和T的长度分别记为len(S)和len(T),并把S和T的距离记为m[len(S)][len(T)],有以下几种情况:
如果末尾字符相同,那么m[len(S)][len(T)]=m[len(S)-1][len(T)-1];
如果末尾字符不同,有以下处理方式
修改S或T末尾字符使其与另一个一致来完成,m[len(S)][len(T)]=m[len(S)-1][len(T)-1]+1;
在S末尾插入T末尾的字符,比较S[1...len(S)]和S[1...len(T)-1];
在T末尾插入S末尾的字符,比较S[1...len(S)-1]和S[1...len(T)];
删除S末尾的字符,比较S[1...len(S)-1]和S[1...len(T)];
删除T末尾的字符,比较S[1...len(S)]和S[1...len(T)-1];
总结为,对于i>0,j>0的状态(i,j),m[i][j] = min( m[i-1][j-1]+(s[i]==s[j])?0:1 , m[i-1][j]+1, m[i][j-1] +1)。
这里的重叠子结构是S[1...i],T[1...j]。
以下是相应代码。考虑到C语言数组下标从0开始,做了一个转化将字符串后移一位。
#include <stdio.h> #include <string.h> #define MAXLEN 20 #define MATCH 0 #define INSERT 1 #define DELETE 2 typedef struct { int cost; int parent; } cell; cell m[MAXLEN+1][MAXLEN+1]; int match(char a,char b) { //cost of match //match: 0 //not match:1 return (a==b)?0:1; } int string_compare(char *s,char *t) { int i,j,k; int opt[3]; //row_init(i); for(i=0;i<=MAXLEN;i++) { m[i][0].cost = i; if(i==0) m[i][0].parent = -1; else m[i][0].parent = INSERT; } //column_init(i); for(i=0;i<=MAXLEN;i++) { m[0][i].cost = i; if(i==0) continue; else m[0][i].parent = INSERT; } char m_s[MAXLEN+1] = " ",m_t[MAXLEN+1] =" "; strcat(m_s,s); strcat(m_t,t); for(i=1;i<=strlen(s);i++) { for(j=1;j<=strlen(t);j++) { opt[MATCH] = m[i-1][j-1].cost + match(m_s[i],m_t[j]); opt[INSERT] = m[i][j-1].cost + 1; opt[DELETE] = m[i-1][j].cost + 1; m[i][j].cost = opt[MATCH]; m[i][j].parent = MATCH; for(k=INSERT;k<=DELETE;k++) if(opt[k]<m[i][j].cost) { m[i][j].cost = opt[k]; m[i][j].parent = k; } } } i--,j--; //goal_cell(s,t,&i,&j); return m[i][j].cost; } int main() { char t[] = "you should not"; char p[] = "thou shalt not"; int n = string_compare(t,p); printf("%d\n",n); } 字符串相似度/edit distance
应用:
难度评级:★★
修改两处即可进行子串匹配:
row_init(int i) { m[0][i].cost = 0; /* note change */ m[0][i].parent = -1; /* note change */ } goal_cell(char *s, char *t, int *i, int *j) { int k; /* counter */ *i = strlen(s) - 1; *j = 0; for (k=1; k<strlen(t); k++) if (m[*i][k].cost < m[*i][*j].cost) *j = k; } <span style="font-family:Microsoft YaHei;">修改部分</span>
如果j= strlen(S) - strlen(T),那么说明T是S的一个子串。
(这部分是根据《算法设计手册》8.2.4和具体实例Skiena与Skienaa、Skiena与somta的分析获得的,解释不够全面,可能有误,请注意)
难度评级:★★
将match时不匹配的代价转化为最大长度即可:
int match(char c, char d) { if (c == d) return(0); else return(MAXLEN); }
此时,最小值是两者不同部分的距离。
(这部分同样也不好理解,对于最长公共子序列,建议直接使用下一部分中的解法)
扩展:
如果在编辑距离中各个操作的代价不同,如何寻找最小代价?