1. 回顾
此篇和笔者之前记录的背包九讲系列笔记接轨,现在回顾下笔者总结的背包九讲的一些内容:
- 背一中,使用滚转数组优化了空间复杂度,针对不同的问题,初始状态的赋值也会相应不同。
- 背二中,对于输入数据提前优化,减少待处理数据量的,这种排序优化应该保证是稳定的。然后对于选多件物品的问题,又可以使用二进制优化,将原本
的复杂度降低到
,最后针对完全背包问题,将简单的使01背包的
v
递增就得以解决。 - 背三中,解决一个可行性问题,对于该问题重新定义 为前i件物品在填满空间为j的背包最多还剩下几件第i件物品。状态转移方程分为两个,首先更新F[i,j]的最大信息,然后更新F[i,0-v]的值。最后提出了是否DP只能求多解中的一个解的问题。
- 背四中,接触了抽象问题的能力,如果还按照一般的思路求解,可能得不到最优时间复杂度,但是通过将问题细分成不同的小问题,按照不同的小问题的时间复杂度解决,最终整体的时间复杂度要更加优化。
- 背五中,领会到题目有时候会多出来一种限制,此时可以通过增加一维解决,高维带来的空间复杂度的提升可以使用滚转数组解决(需要注意不要重复),最后提出了DP的最优子结构和无后效性的若干总结。
- 背六中,将相互冲突的物品分成不同的组,然后将不同的组看成01背包问题。还是将新问题转化成已知问题的能力。
- 背七中,接触到泛化物品的思想,实际上还是抽象问题的能力,主件和附件的组合变成一个分组,附件的选择变成01背包。这种动态规划的形式被崔描述为树形动态规划,此时问题的难度已经不是DP初学者考虑的程度了。
- 背八中,详细介绍了泛化物品的思想,将物品抽象成函数。笔者感觉,抽象不是为了复杂化问题,而是将复杂问题简单化。到了这里距离动态规划已经比较远了,所以我也就没有继续看总结性的背九。
除了背包九讲以外,还记录了 由Longest Palindromic Substring再看DP和由Combination Sum看看动态规划的适用性两篇实践笔记。笔者还做了一些DP的题,但是没有记录。
2. 定义
递归公式都能够直接翻译成递归算法,但是编译器并不能妥善对待递归算法,所以将子问题的答案记录在表内,递归算法重新写出非递归算法,利用这种方法的技巧称为动态规划。DSAA中给的动规的定义,其实对做题和学习动规没有太大帮助。
3. 实际的几个例子
斐波那契数
如果使用递归计算斐波那契,代码如下:
int Fib(int n){
if(n<=1)
return 1;
return Fib(n-1)+Fib(n-2);
}
使用动规的思路,假设F[i]表示当前的斐波那契数,则
代码如下:
vector<int> F(n+1,0)
F[0]=1;
F[1]=1;
for(int i=3;i<=n;++i)
F[i]=F[i-1]+F[i-2];
return F[n];
上面的状态转移方程满足最优子结构和无后效性,注意由F[i-1]与F[i-2]共同构成了现在的状态。可能会疑惑F[i]与F[i-2]的关系,它们同样也是当前与未来的关系。如果改成for(i=n;i>=3;--i)
在当前状态转移方程下,破坏了无后效性原则。
最优二叉查找树
给定一列单词 和它们出现的固定概率 。要以一种方式能使在一颗二叉查找树中安放这些单词使总的期望存储时间最小。
这里按照做题的思考模式去重新整理解决方案:
//1. 设F[i,j]代表当前i和j位置的形成的二叉搜索树的访问次数。
//2. 状态转移方程:sum(下标,上标,元素)是一个自己定义的求和函数
F[i,j]=min{F[i,k-1]+F[k+1,j]+sum(i,j,p)|i<=k<=j}
//3.
//1)状态:
F[0....n-1,0....n-1]=infinite
//2)初始状态
F[0,0]=0
F[i,j]=0 if i>j
//4. 伪代码实现:
for i=0 to n-1
for j=0 to n-1
for k=i to j
//注意k+1的边界条件,当j=n-1时会导致F[i,j]没有更新
if(0<=k-1&&k+1<n)
F[i,j]=min(F[i,j],F[i,k-1]+F[k+1,j]+sum(i,j,p))
else if( k == n-1)
F[i,j]=min(F[i,j],F[i,k-1]+sum(i,j,p))
/*5. 查询状态
1) 根据F[0,n-1],查找F[0,k-1]+F[k+1,n-1]+p[k]=F[0,n-1]成立的k
2)再查找各个子树,这里可以用递归程序实现,最终将整个树的结构提取出来。
3)0...n-1 是我们的中序遍历,所以只需要知道前序遍历结果就可以提出树的整个结构
以下是一种C++的实现查询状态的实现:
*/
void find_tree(vector<vector<int>>& F,vector<int> & p,vector<int> & ans,int left, int right){
if(left == right){
ans.push_back(k);
return
}
for(int k=left;k<=right;++k){
if(F[left][k-1]+p[k]+F[k+1][right]==F[left][right])
break;
}
ans.push_back(k);
find_tree(F,p,ans,left,k-1)
find_tree(F,p,ans,k+1,right)
}
如果该题放在考场上,应该是一道比较难的题了,状态、初始状态、转移方程及最后的如何还原结果都是不太容易想的。
所有点最短路径问题
在思考这个问题的时候,需要回顾单源最短路径问题DSAA之图论Dijkstra(贪婪算法)(五),在该篇中总结到:如果拓展到多源,则时间复杂度为 ,采用优先级堆优化时,时间复杂度为 。并且因为贪婪算法的“短视”,有负值边存在的情况可能得到错误的结论。现在重新考虑使用DP的思路解决这个问题,同样采取做题的方式:
//1. 设F[k,i,j]为只用1...k作为中间顶点的i到j的最短路径。
//2. 状态转移方程为:
F[k,i,j]=min(F[k-1,i,j],F[k-1,i,k]+F[k-1,k,j])
//3. 状态为:
F[0...n,0...n,0..n]
// 初始状态为:
F[0,0...n,0...n]=Cij; (Cij不存在为infinite) 0<=i<=n, 0<=j<=n
F[0...n,0,0...n]=infinite
F[0...n,0...n,0]=infinite
/*
4.最终答案为F[n,i,j]
5.考虑滚转数组优化
观察状态转移方程,我们只需要保存k-1的状态信息就可以。所以优化状态转移方程为:
*/
F[i,j]=min(F[i,j],F[i,k]+F[k,j])
//6. 伪代码实现:
for k=0 to n
for i=0 to n
for j=0 to n
F[i,j]=min(F[i,j],F[i,k]+F[k,j])
虽然动态规划的解法对于多源问题并没有改善 的界,但是因为其采用最优子结构的方式可以妥善解决Dijkstra算法不能有负值边的情况,因为动态规划喜欢放长线钓大鱼。
4. 小结
本篇其实信息量巨大,从笔者第一次接触动态规划,到现在已经小有所获。在leetcode上也做了较多的DP问题,最后用《数据结构与算法分析》中的话结尾:
In some sense, if you have seen one dynamic programming problem, you have seen them all.