由于这周开始我们开始学习动态规划(Dynamic Programming)算法,这个算法的核心就是状态转移方程。因此思路说明都围绕着状态转移方程进行展开。
这个小题老师在课堂上让我们分析过复杂度,以及写出递归方程。这是一个显然的分治的问题: 我们想要求一个规模为 N N N 的矩阵连乘,可以想象假设我们已经知道了某个顺序去计算整个连乘积, 那么最后一步一定是某两个矩阵相乘, 并且组成这两个矩阵的元素包含了所有这N个元素。即我们对N个元素做一个划分,分为两部分:原问题转化为划分为左侧矩阵积的种类数与右侧矩阵积的种类数。
上面这个思想我们转化为算式,就可以得到这个问题的递归方程。即:
f ( n ) = ∑ 1 k = n − 1 f ( k ) ∗ f ( n − k ) f(n) = \sum^{k=n-1}_{1}f(k) * f(n - k) f(n)=∑1k=n−1f(k)∗f(n−k)
这就是对于规模为 N N N的问题的解的数目。根据这个方程我们可以写出相应的递归程序把具体解求出。
这是递归的思路,然而我写这道题采用的仍然是动态规划的方法。但是其实本质的方程还是一致的。只不过是实现上的区别而已。递归分治主要依赖递归的程序结构:这种结构通常会导致开发的高效性与程序的低效性(这两者经常是拮抗出现的),而动态规划通常表现为循环实现。
首先我们来考虑, 假设我们不需要得到具体的可能结果而只需要结果的数目,那么大部分人都会采用循环解决,对于一个问题规模为 N N N的解可以在 O ( N 2 ) O(N^2) O(N2)的时间内得到。现在加上需要具体结果,则我们需要保存的子问题不仅是子问题的解的数目,也要把所有子问题的解存下来。
关键程序代码如下:
//更新一次的操作函数
inline void update(vector > & res, int target_index, int l, int r) {
for (int i = 0; i < res[l].size(); ++i) {
for (int j = 0; j < res[r].size(); ++j) {
res[target_index].push_back("(" + res[l][i] + res[r][j] + ")");
}
}
return ;
}
//......
vector > res(n + 1, vector());
//定义初始状态
res[1].push_back("M");
res[2].push_back("(MM)");
for (int i = 3; i <= n; ++i) {
for (int j = 1; j < i; ++j) {
update(res, i, j, i - j);
}
}
下面我们来考虑这种算法的复杂度,可惜的是仍然是指数时间复杂度, 因为在拼接时的复杂度为 f ( k ) ∗ f ( n − k ) f(k) * f(n - k) f(k)∗f(n−k)
,这个项的复杂度为指数时间,因 f ( n ) f(n) f(n)的解的数量为 O ( 2 n ) O(2^n) O(2n)个。
但是仍然可以知道,这个算法所需的时间要比递归算法小。关键在于它不需要计算重复子问题。所有更小规模的问题的解都已经被计算并存入 v e c t o r vector vector。但正因如此,其空间复杂度很高(也为 O ( 2 n ) O(2^n) O(2n)), 加上这种方法时间复杂度并没有得到本质的改进,因此对于计算具体解通常递归算法用的较多,这里只是为了更好地熟悉一下动态规划的“避免重复子问题”的思路, 所以用其进行求解。
这道小题其实分了很多部分:
主要部分是第一部分的主问题求解。这个问题的关键也就是如下状态转移方程:
r e s [ s t ] [ e d ] = m i n ( r e s [ s t ] [ e d ] , r e s [ s t ] [ i ] + r e s [ i ] [ e d ] + n u m s [ s t ] ∗ n u m s [ e d ] ∗ n u m s [ i ] ) , i ∈ [ s t + 1 , e d − 1 ] res[st][ed] =min(res[st][ed], res[st][i] + res[i][ed] + nums[st] * nums[ed] * nums[i]), i\isin[st + 1,ed - 1] res[st][ed]=min(res[st][ed],res[st][i]+res[i][ed]+nums[st]∗nums[ed]∗nums[i]),i∈[st+1,ed−1]
这个状态转移方程的含义:
将在 [ s t , e d ] [st, ed] [st,ed]区间内的矩阵分解为两个更小的区间(子问题),选择划分标准为: 划分出来的子问题组成的对当前 [ s t , e d ] [st,ed] [st,ed]区间矩阵的解为最优。
一个重要的点需要说明的是,能用这种做法的前提是该问题具有最优子结构性质。这种性质大致可以描述为: 问题的最优解一定由子问题的最优解中产生。这个条件并不是一个十分宽松的条件。
状态转移方程写出了之后,需要考虑的是下一步的两个问题:
先来考虑较为简单的边界值。明显的边界是,当所计算的区间仅包含一个矩阵时,显然不需要乘法计算,即 d p [ i , i + 1 ] = 0 dp[i, i + 1] = 0 dp[i,i+1]=0。这就是初始状态。其他还有一些边界诸如终点下标必定大于起点下标等。
然后是规划搜索路线。搜索路线要求新计算的状态必须由原先计算过的状态中产生。可以比较简单想到的搜索顺序是按照区间长度搜索: 先搜索区间长度较短的,再更新区间长度较大的状态时, 分割出的子问题必定区间长度要更小, 即已被计算过。这使我们很容易想到以步长作为外层循环变量逐次递增,内层循环变量用于设定起始矩阵位置下标。
然后是对解进行逆推。其实使用的代码几乎没有差别,只不过从算出的结果逆向和可能的多个值进行逐一对比,找出不同状态的转移过程即可。在找出转移的同时加上括号即可将最后的结果输出。
对于最长公共子序列问题还是首先考虑其状态转移方程。
首先定义lcs[i][j]
为str1[0:i]
与str2[0:j]
两个子串的lcs的解,则其子问题为 lcs[x][y](x < i && y < j)
。再考虑其状态转移过程,对于在具体计算某个具体状态lcs[i][j]
时,通过比较str1[i]
与str2[j]
是否相同,有可能以下两种情况:
str1[i] == str2[j]
。对于子问题lcs[i][j]
,该字符必然出现在最后的结果里面(因为str1[i]
与str2[j]
均为当前子串的最后一位),而DP的主要思想就是通过计算子问题的解来推演得到总问题的解。这里注意该字符事实上并不一定出现在最终两个总的字符串的lcs中,然而在计算lcs[i][j]
时我们不需要考虑这一点:我们应该清楚的是如果最终结果不包含该字符,那么最终状态绝不是从当前子问题的解状态转移得到。str1[i]!=str2[j]
。此时正在比较的字符不同,故当前的lcs[i][j]
只能是由先前计算过的子问题中的解直接得到(因当前新增的部分是不同的, 不可能出现在公共子序列中)对于这两种情况,分别考虑其状态转移过程:
lcs[i][j]
问题的解中。所以状态就是由这两个字符串中去掉该字符的子串中产生,即lcs[i - 1][j - 1]
,所以对于这种情况,lcs[i][j] = lcs[i - 1][j - 1] + 1
i,j
的递增, lcs[i][j]
也是非严格递增的。因此对于更小的子问题中,最大值只可能是lcs[i][j - 1]
或者lcs[i - 1][j]
中产生。所以对于这种情况, lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1])
考虑完转移方程,最后一个小问题就是边界情况。即一个字符与一个字符串相匹配的情况。显然只要该字符在字符串中出现设值为1,否则为0。
最后说一下求具体公共子序列的做法。
这里一个偷懒的做法:你直接对于每个子问题都记录当前具体的最长公共子序列(字符串)就完了…
正经做法就是从终点通过状态记录数组回溯一条通往起点的路线,路线的走法和转移方程一致即可。这里的复杂度需求为 O ( M + N ) O(M+N) O(M+N)(M, N 为两个字符串的长度)
课件上要求是使用两个一维数组,但是其实可以更低,即只使用一个一维数组(与一个额外变量)。
对于空间复杂度的优化在有先前分析的基础上其实并不复杂。**只需要搞清楚每次状态转移使用的子问题解,哪些先前计算过的子问题的状态可以丢弃(之后不会再被用到)。**首先先分析对于lcs问题为什么我们可以进行空间压缩。对于lcs[i][j]
的解,涉及的可能的子问题解为:lcs[i - 1][j], lcs[i][j - 1], lcs[i - 1][j - 1]
.考虑到我们的搜索顺序是外层循环迭代i
,内层循环迭代j
,因此lcs[i][j - 1]
为刚被计算出的状态,被保留应该是毫无疑问的。剩下两个状态为外层循环的上一次迭代的状态。
分析到这里,不难发现保留两个一维数组的压缩方式了: **每次计算当前问题解时,其依赖的子问题仅仅存在于外层循环的上一轮迭代与本层迭代。所以每一次仅保留外层循环上一层迭代的一组子问题解(一个一维数组)并且用一个一维数组储存当前需要计算的本层的子问题解,计算完本层之后准备计算下一层时,先前保留的上一层的子问题解已经不会再被用到,我们可以用它来储存下一层迭代的结果…如此循环,我们始终维持更新两个一维数组,最终迭代到总问题的解。**这就是课件中的使用滚动数组来压缩的方式。
但是其实更进一步,我们可以继续压缩到仅使用一个一维数组,我觉得这部分才比较有意思。压缩到仅使用一个一维数组的方法思想上和上面这种相同,但是需要更进一步。这里提炼出两个事实:
事实2指出了我们通过之前滚动数组的方式仍然存在内存冗余: 即我们保存了上一层的所有状态,但是它们并不会在整个迭代过程中被使用。
现在这里提出一种方式:还是用一维数组储存上一层迭代的状态。而对于本轮迭代的新状态不使用新空间储存。而是重写到之前的数组。相当于一直对数组进行更新操作而不是赋值。这样的话我们就只使用了一个一维数组。直接使用肯定会有问题,即需要使用的前一轮的状态会被覆盖而无法取其值。这里, 对于lcs[i][j]
需要用到的lcs[i - 1][j],lcs[i - 1][j - 1]
两个状态分别讨论:
lcs[i - 1][j]
这个状态比较简单,事实上它并没有被覆盖,而是正要更新的数组位置的当前值。考虑到前面列出的事实1,在更新lcs[i][0..j - 1]
这些本层的新状态时,覆盖掉的是lcs[i - 1][0..j - 1]
,因此尽管使用一维数组,这个状态还是在计算新问题时被保留下来。lcs[i - 1][j - 1]
这个状态确实被计算的时候被覆盖掉了。但是我们注意到,仅仅是刚被覆盖掉而已(即计算lcs[i][j - 1]
时被覆盖),并且每次都是如此。这样想之后不难发现只要用一个变量专门记录刚被覆盖掉的这个状态就可以了。这里给出我经过Leetcode正确性验证的状态压缩代码:链接
(这块图太多了,傻逼CSDN要一张张上传,就懒得弄了。)
动态规划与之前学的递归分治有很多相似的地方。最主要的一点就是,两者都从寻找原问题与子问题的关系出发,希望将问题的规模不断减小从而得到简化。不太相同的地方在于,通常分治更适合的问题是将原问题划分为若干个规模大致相同的子问题,即复杂度表现为 T n = k ∗ T n m + O ( n x ) T_n = k * T_{\frac{n}{m}} + O(n^x) Tn=k∗Tmn+O(nx),每次分治后子问题规模以指数级下降。而动态规划的子问题通常与原问题规模很接近(基本都是在某个维度上规模比原问题-1)。这部分差异是由于对子问题求解的时间复杂度决定的。对于分治的子问题通常是全新的问题(先前没有计算过),而动态规划得到的子问题必然在先前已经被算过了。这使得尽管dp每次分割操作都做的很小,但是通常复杂度还是比递归分治要更低一些。
既然都是划分子问题,那么我们能否利用递归来实现dp的思想呢? 递归被人诟病最多的是其程序效率过低,而造成的过低效率很重要的一点是对于重复子问题的计算。致命的是这种重复往往随着被重复子问题的不断分割而呈现指数级飙升。而DP最核心的一个思想就是避免重复子问题的计算。我们可以看到DP往往伴随着一个子问题状态矩阵,用于记录子问题的解。而递归同样也可以采用这种方式,在每次需要计算一个子问题时,先看其是否被计算过。如果计算结果已经被储存,那么就不再重复进行计算。在这样操作过后其实递归的时间复杂度和动态规划是一致的,这种方式叫做记忆化。
使用递归来编程显而易见的好处是写起来快,简单。对于动态规划转记忆化递归这种方式,我认为还有一个好处是不需要太在意搜索顺序。回想矩阵连乘的题目,当时我们采用步长的方式进行搜索,而体现在状态矩阵中我们的求解路线是从左上角开始沿对角线不断斜向下求解。**对于动态规划之前提到的重要一点,是在求解每个问题时,其需要用到的子问题已经处于先前的求解路线上。**这使得我们需要小心安排求解路线来实现这一点。而记忆化的方式则不必考虑。每次程序遇到新的子问题就会递归进行计算,相当于是在整个递归深入和返回的过程中程序自动地选择了一条求解路线。
这次作业我们练习了若干道简单的动态规划习题。事实上动态规划这类题目,一直给我的感觉就是,在状态转移方程确定之后,剩下的都不是问题。我个人对这类问题的弱项我认为是状态划分。就是如何将一个问题划分为具有最优子结构的子问题。拿到一个这种类型的题划分不出子状态是常事。举一个简单的例子,在分治的章节中有一个正整数划分问题(也是作业题),对于该问题若仅需要输出其数目是DP可解的。需要划分的两个维度状态分别是需要划分的正整数n
以及划分时限制的划分序列最大值m
.这个问题中正整数n作为一维状态是都能想到的,难的地方在于如何思考得到m是可以作为另一维状态使得两个状态共同确定划分的子问题之间是可转移的(并且具有最优子结构)。甚至我做到某些题可以有多种状态划分方法,一个好的状态划分能使解题效率大幅提升。因此我觉得接下来的DP学习过程中我需要重点加强一下自己对于如何状态划分这方面的能力。