dynamic programming is a method for solving a complex problem by breaking
it down into a collection of simpler subproblems, solving each of those subproblems
just once, and storing their solutions - ideally, using a memory-based data structure.1
以上定义摘自维基百科
下面则是百度百科里的解释:
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),
再合并子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给
定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重
复子问题的数目关于输入的规模呈指数增长时特别有用。[^2]
由此,我们可以简略的概括下:“动态规划”通过把原来较复杂的问题递归的分解
为一组较简单的子问题,并通过存储每个子问题的解,使得每个子问题只计算一次就可以解决原问题的思想。
这里的某一类问题通常情况下是指某些最优化问题。这类问题可以有很多可行解,每个解都有一个值
,我们希望寻找具有最优值(最小值或最大值)的解。[^3]
一言以蔽之——“动态规划”是解决某类问题的方法(或思想而不是算法)
根据以上定义,很多人可能会觉得动态规划的真谛就是通过递归或递推,用额外的空间记录下已解决的子问题的解,
从而通过空间换时间来降低时间复杂度。其实不然,动态规划的本质是对每个阶段状态的定义以及当前状态与下一阶段*状态
关系*的定义(状态转移方程)。而所谓的“存储每个子问题的解”则是隐含的包含在状态关系里,那些“额外的记录空间”
则只是其表现形式而非其内涵。
什么是状态?
我们先从最简单的Fibonacci数列谈起:
比如说我想计算第100个非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。
所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性
递增的,所以时间复杂度也是线性的。上面这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行
(a[i]=a[i-1]+a[i-2]),不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。
我们再来看一个动态规划的教学必备题:
给定一个数列,长度为N,求这个数列的最长上升(递增)子数列(LIS)的长度.以1 7 2 8 3 4为例。这个数列
的最长递增子数列是 1 2 3 4,长度为4;次长的长度为3, 包括 1 7 8; 1 2 3 等.
要解决这个问题,我们首先要定义这个问题和这个问题的子问题。有人可能会问了,题目都已经在这了,我们还
需定义这个问题吗?需要,原因就是这个问题在字面上看,找不出子问题,而没有子问题,这个题目就没办法解决。
所以我们来重新定义这个问题:
给定一个数列,长度为N,
设F(k)为:以数列中第k项结尾的最长递增子序列的长度.
求F(1)..F(N)中的最大值。
显然,这个新问题与原问题等价。而对于F(k)来讲,F(1)..F(k-1)都是F(k)的子问题:因为以第k项结尾的最长递
增子序列(下称LIS),包含着以第1..k-1中某项结尾的LIS。上述的新问题就可以叫做状态,定义中的“为数列中第
k项结尾的LIS的长度”,就叫做对状态的定义。
什么是状态转移方程?
上述状态定义好之后,状态和状态之间的关系式,就叫做状态转移方程。
比如,对于LIS问题。
设F(k):以数列中第k项结尾的最长递增子序列的长度.
设A为题中数列,状态转移方程为:
F(1) = 1(根据状态定义导出边界情况)
F(k) = max(F(i)+1|A(k)>A(i),i∈(1..k-1))(k>1)
用文字解释一下是:以第k项结尾的LIS的长度是:保证第i项比第k项小的情况下,以第i项结尾的LIS长度加一的最大值,
取遍i的所有值(i小于k)。
状态转移方程就是带有条件的递推式。
对于上述LIS问题我们可以进一步探索。
Talk is cheap , show me the code:
#include
using namespace std;
int lis(int A[], int n){
int *d = new int[n];
int len = 1;
for(int i=0; i1;
for(int j=0; jif(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<6)<return 0;
}
我们看到,这里多定义了一个数组d来存储每个阶段(状态)的最大值,即所谓的额外的存储空间,然而
这并不是动态规划的本质,通过上文的分析,我们知道,下一阶段的状态可由上一阶段的状态得到(我们已经定义了
状态和状态转移方程)。因此,额外的数组d是为了保存每个当前状态而开设的,如果没有状态及状态转移方程的定义,
数组d是没有任何意义的,换句话说额外的存储空间依附于前后状态的关系,因为有了状态关系才有了额外的存储空间。
再拿fibonacci数列举例:
定义fib(n):fibonacci数列第n项的值。
求解第10项fibonacci数列fib(10)
我们可以定义状态:
第n个状态为fib(n)的值。
状态转移方程:
fib(1)=1,fib(2)=1
fib(k) = fib(k-1)+fib(k-2) (k>2)
这里,我们可以通过多定义一个数组d来保存每个状态的值:
#include
using namespace std;
int fib(int n){
int *d;
d[1] = d[2] = 1;
for(int i=3;i<=n;i++){
d[i] = d[i-1]+d[i-2];
}
return d[n];
}
int main(){
cout<10)<return 0;
}
这里的数组d是为了保存每个状态而定义的,其实我们也可以不用开辟额外的存储空间来定义这个数组d:
#include
using namespace std;
int fib(int n){
int a,b;
a = b =1;
int t;
for(int i=3;i<=n;i++){
t = a+b;
a = b;
b = t;
}
return t;
}
int main(){
cout<10)<return 0;
}
至于LIS问题能不能不使用额外的存储空间呢,答案是否定的。为什么,因为其状态转移方程的表示”F(k) = max(F(i)+1|A(k)>A(i),i∈(1..k-1))(k>1)”
注定了使用一个额外的数组会更方便点。
由此可见,动态规划的本质是对每个阶段状态的定义以及当前状态与下一阶段*状态
关系*的定义(状态转移方程),而不是所谓的记忆化存储。