目录
前言
最近备战蓝桥杯,刚好刷到了动态规划的专题。和之前的大部分算法不同,动态规划是一种极其巧妙的算法思想,没有固定的模板,十分多变,需要我们做到具体问题具体分析。然而网上大部分文章一上来就直接讨论动态规划的思想,并没有写出推导的过程。于是本文主要介绍及总结一些简单的动态规划的方法和模型、方便初次接触动态规划的小伙伴有所理解。
什么是动态规划
动态规划(Dynamic Programming,以下简称DP)是一种用来解决最优值问题的一套方法论。与我们之前所接触到大多数算法不同,DP没有一套固定的模板,需要我们具体问题具体分析。
通常来说,DP需要将一个复杂的待求解问题分解成若干个子问题,并且先去求解子问题的最优解,从而得到原问题的最优解。需要注意的是,这些子问题并不是独立的,也就是说每个子问题之间存在某种联系,而且DP会将求解过的每个子问题的解记录下来,这样当我们下一次遇到这个子问题时,就可以直接使用之前记录了的解,而不是重复计算(DP采用这种方式来提高效率,然而这并不是DP的核心思想)。
那么我们怎样分辨出什么样的问题适合用DP求解呢?这里我们还要引入两个概念——【最优子结构】和【重叠子问题】。
如果看到这里一脸懵逼,没有关系,那是因为我们还没有结合具体的题目去理解罢了,大家只需要记住这两个概念,后面就会明白了。
详解动规例题
为方便大家理解动态规划,先来讲讲一个经典的DP问题——斐波那契(Fibonacci)数列。
斐波那契数列的定义为:。
递归树
我们曾在树的概念中提到过递归写法,以斐波那契数列来说,它的递归写法如下所示:
int fibonacci(int n)
{
if(n == 0 || n == 1)return 1;
else return fibonacci(n - 1) + fibonacci(n - 2);
}
结合代码,我们会发现return fibonacci(n - 1) + fibonacci(n - 2);表达式执行时总是使程序创建了两个“分支”,这个结构和我们所熟知的二叉树是一样的,我们把其抽象为递归树。
下图就是当n == 5时的递归树。
事实上,当我们观察这棵递归树时,会发现一个问题:当n == 5时,F(5) = F(4) + F(3),而当n==4时,F(4) = F(3) + F(2),这时候F(3)的表达式将会被计算两次,而往下的F(2)同样是如此。
此时的时间复杂度为O(n^2),而如果n很大时,这是我们不能承受的。
为了避免重复的计算,我们可以创建一个数组dp来记录已经出现过的结果。
int dp[n + 1];
int fibonacci(int n)
{
if(n == 0 || n == 1)return 1;
if(dp[n] != 0)return dp[n]; //如果dp不是0,说明已经计算过了该结果,直接返回
else
{
dp[n] = fibonacci(n - 1) + fibonacci(n - 2);
return dp[n];
}
}
这样一来,我们把复杂度从O(n^2)降到了O(n),也就是线性的级别了。
这就是重叠子问题:
- 如果一个问题可以分解为若干个子问题,且这些子问题会重复的出现,那么就称这个问题拥有重叠子问题。
递推形式
现在有这样一道题:
每年冬天,北大未名湖上都是滑冰的好地方。北大体育组准备了许多冰鞋,可是人太多了,每天下午收工后,常常一双冰鞋都不剩。
每天早上,租鞋窗口都会排起长龙,假设有还鞋的m个,有需要租鞋的n个。现在的问题是,这些人有多少种排法,可以避免出现体育组没有冰鞋可租的尴尬场面。(两个同样需求的人(比如都是租鞋或都是还鞋)交换位置是同一种排法)
按照题目要求:这道题需要求解一个排序队列的排法的最优解,我们可以选择使用模拟的方法,当然如果我们仔细的读题就会发现,这道题其实和斐波那契数列是一样的。
现在我们将题目简化成:有m人个还鞋的队列和n个人借鞋的队列,问把n个人插入到m人的队列中,且使得借鞋的人数始终不大于还鞋的人数的排序方法最大有几种。
再比如,假设m == 3,n == 2,那么解决上述问题的排法总共有5种。
这看起来是在做数学题,实则不然。我们可以通过穷举法,把所有可能的排序都列出来,再筛去那些不符合条件的排法。
这其实和模拟的思路是一样的,我们只在乎队首的属性。也就是说对于队列来说,只存在是【还】还是【借】的情况。如果位于队首的人是来还鞋的,那么还鞋队列中的人数肯定要减一;如果位于队首的人是来借鞋的,那么借鞋队列中的人数肯定要减一,当然这时要进行判断,如果不够借的,那么肯定失败了是吧。
那么我们可以用递归写法,穷举出它每一次的排序,代码如下:
int func(int n, int m)
{
if(n > m)return 0; //不够借的情况
if(n == 0)return 1; //要借的人全部借完的情况
return func(n - 1, m) + func(n, m - 1);
}
同理我们可以得到它的递归树:
我们依旧可以通过上文中的方法剪枝以优化复杂度。
但是,如果尝试去穷举出所有的路径,那么当n很大很大的时候,我们得到的递归树也将很大很大(一般来说当递归深度超过20时,认为这是不好的),这同样是我们不可以接收的。
出于上述考虑,我们不妨观察这棵递归树,可以发现,树的叶子始终是n==0或者n<m时,此时它的值是1或者0,而自底向上地去看,发现叶子的父亲的值,总是且仅是由它的两个子树的值的和得出,假设n = m = 1时,即dp[1][1] = d[1][0] + dp[0][1]。
由此可以归纳出一个结论,当n==0时,dp[i][0] = 1;而当n > m时,dp[i][j] = 0 (i < j);从而得出其递推关系式就是:
我们把dp[i][j]称为问题的状态,而把上述式子称为状态转移方程,它把状态dp[i][j]转移为dp[i - 1][j] + dp[i][j - 1]。而把dp[i][0]称作边界。
通常来说,DP总是自底(边界)向上,通过状态转移方程扩散到整个dp数组(显然递归是自顶向下推导的)。
根据这种思路写出动态规划的代码:
for(int i = 1; i <= m; i++)
{
dp[i][0] = 1;
for(int j = 1; j <= n; j++)
{
if(i < j)dp[i][j] = 0;
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
cout << dp[m][n] << endl;
上述过程描述的就是最优子结构:
- 如果一个问题的最优解可以通过其子问题的最优解有效的构造出来,那么称这个问题拥有最优子结构;
最优子结构保证了动态规划中原问题的最优解可以由子问题的最优解推导而来。因此,一个问题必须拥有重叠子问题和最优子结构才能使用动态规划区解决。
事实上,如何得出状态和状态转移方程,才是动态规划的核心,也是它的难点。
最后我们再谈谈动态规划、贪心以及分治的区别。
动态规划和贪心的区别
贪心和DP都要求原问题必须拥有最优子结构。然而不同的是,贪心的思想是自顶向下的,是通过决策直接的选择当前问题的最优解,没有被选择的子问题就被抛弃了。也就是说,贪心总是在上一步的基础上去选择的。
而动态规划类问题,不管是自顶向下或是自底向上,总是从边界开始推广至全局,也就是说,DP永远会考虑所有的子问题,即使某个子问题当前被忽略了,但由于重叠子问题的性质,之后会再次考虑它。
如下图的数塔问题:
如果使用的贪心,那么得出的最大值是5>8>12>10>5 == 40,这显然是不对的,因为如果我们使用的是动态规划,那么得出的最大值是5>3>16>11>9 == 44。
(数塔问题也是经典的动态规划问题模型之一,小伙伴们可以尝试将它的代码写出)
动态规划和分治的区别
分治和DP都是将问题分解为子问题,然后合并子问题得出原问题的解。然而不同的是,分治的子问题不是重叠的。
而动态规划的子问题是重叠的。
如我们之前讲过的归并排序和快速排序算法,用到的就是分治,将左右序列分开处理再结合它们的结果,过程中没有出现重叠子问题。同时,分治解决的问题不一定是最优的,而DP一定是。
后话
希望大家看完本文能对动态规划的问题有一个初步的认识,同时提升算法能力的最好办法就是多刷题,大家可以在各个平台上找DP的问题去做,随着我们练习的题目越多,对DP的理解会越来越深入。