动态规划入门

什么是动态规划

动态规划(Dynamic Programming, DP)是一种用来解决一类最优化子问题的算法思想。简单来说,动态规划将一个复杂的问题分解成了若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解过后的子问题的解记录下来,这样下次碰到同样的子问题时可以使用之前记录的结果,而不是重复计算。这可以提高计算效率,却不是真正的核心。

一般可以使用递归或者递推的方法来实现动态规划。其中,递归写法又被称为记忆化搜索

动态规划的递归写法

我们以斐波那契数列的求解为例,来说明动态规划是如何记录子问题并避免重复计算的。

这是最朴素的递归写法:

int F(int n) {
    if(n == 0 || n == 1) return 1;
    else return F(n - 1) + F(n - 2);
}

事实上我们通常会避免这种看起来简洁的写法,因为它会涉及很多重复性运算。

例如,n = 5时,我们会计算F(5) = F(4) + F(3), F(4) = F(3) + F(2)......

这时候如果不采取一些措施,F(3)会被重复计算两遍。在n足够大的时候,重复计算的次数是恐怖的(时间复杂度为O(2^n))。这就需要我们找到方法来保存中间计算的结果,避免重复计算。

为了避免重复计算,我们想到开一个一维数组dp来保存已经计算过的结果。其中dp[n]记录F(n)的结果,并用dp[n] = -1来表示F(n)没有被计算过。

int F(int n) {
    if(n == 0 || n == 1) return 1;
    if(dp[n] != -1) return dp[n];
    else{
        dp[n] = F(n -1) + F(n -2);
        return dp[n];
    }
}

dp就相当于我们的记忆,当我们需要计算同样的内容时,它帮助我们回忆起上次计算的结果,省去重复而无效的计算。这就是记忆化搜索名字的由来。通过一个空间为O(n)的小小数组,我们竟然可以将复杂度从O(2^n)降到O(n),这就是记忆化搜索的威力。

通过这个例子,我们引申出了一个概念:如果一个问题可以被分解成若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题(Overlapping Subproblems)。动态规划通过记录重叠子问题的解,来避免大量重复计算。因此,一个问题必须拥有重叠子问题,才能用动态规划来解决。

动态规划的递推写法

以经典的数塔问题为例:

 Problem Description
在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描述的:
有如下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则经过的结点的数字之和最大是多少?

Input
输入数据首先包括一个整数C,表示测试实例的个数,每个测试实例的第一行是一个整数N(1 <= N <= 100),表示数塔的高度,接下来用N行数字表示数塔,其中第i行有个i个整数,且所有的整数均在区间[0,99]内。

Output
对于每个测试实例,输出可能得到的最大和,每个实例的输出占一行。

 

输入样例

1
5
7
3 8
8 1 0 
2 7 4 4
4 5 2 6 5
 

输出样例

30

此时我们看到,除了最后一层,每个数字底下都有两个相连的数字可以选。如果我们穷举所有路径 的话,时间复杂度是O(2^n),这在n很大的时候是不可接受的。为什么复杂度如此之高?

又是重复计算。读者可以考虑一下,先按9→12→6的路径来到6,接着枚举从6出发的所有路径;接着按9→15→6的路径来到6,再一次枚举从6出发的所有路径。这就导致了很多子问题被重复计算了。这就需要我们将每个点的“状态”(本题中,状态代表以该点为起点向下走所能得到的最大数字和)存储起来,再进行“状态计算”(其实就是记忆化搜索的技巧)。

由上面的考虑,我们想到用dp[i][j]表示第i行第j个数字出发到最底层的所有路径中最大数字和,即“状态”。那么,我们要求的其实就是dp[1][1]。对于dp[1][1],我们有:

dp[1][1]=max(dp[2][1],dp[2][2])+f[1][1]

这样问题就很清楚了:如果要求dp[i][j],一定要先求出它的两个子问题,即:选左下方数字最终能得到的数字和大,还是选右下方数字最终能得到的数字和大?写成式子就是:

dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j]

动态规划中,我们把这样的式子称作状态转移方程。它把求解状态dp[i][j]转移到求解状态dp[i+1][j]与dp[i+1][j+1]。那么什么时候才到头呢?可以发现,最后一层的dp值总等于自身。从这层出发层层递推,就可以求得最终的解。

DP代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 110;
int f[N][N], dp[N][N];
int main(){
    int C, n;
    cin >> C;
    while(C--) {
        scanf("%d", &n);
        for(int i = 1; i <= n; i ++){
            for(int j = 1; j <= i; j ++){
                scanf("%d", &f[i][j]);
            }
        }
        for(int j = 1; j <= n; j ++)
            dp[n][j] = f[n][j];
        for(int i = n - 1; i >= 1; i --){
            for(int j = 1; j <= i; j ++){
                dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + f[i][j];
            }
        }
        printf("%d\n", dp[1][1]);
    }

    return 0;
}

显然,使用递归也可以实现上面同样的目的。两者的区别在于,递推写法自底向上(Bottom-up Approach),从边界开始不断向上解决问题;递归写法自顶向下(Top-down Approach),即从目标问题开始,将其分解成子问题的组合,直到分解至边界为止。

通过上面的例子再引出一个概念:如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题拥有最优子结构(Optimal Substructrue)。最优子结构保证动态规划中原问题的最优解可以由子问题的最优解推导而来,因此一个问题能用动态规划解决的前提是拥有最优子结构。

这里再说明无后效性的概念:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去改变未来的决策。一如我们的宇宙,过去的所有信息都蕴含在当前的状态中,并且只能通过改变当前状态来影响下一个时刻的状态。这是动态规划问题极为重要的一个性质,后续在题目中会不断展现。

对于动态规划可以求解的问题来说,总是有很多设计状态的方式,但并不是所有状态都具有无后效性,而设计出无后效性的状态及其相应的状态转移方程正是动态规划的核心问题,也是难点所在。

两对概念的区别

分治与动态规划

分治与动态规划都是将问题分解成子问题,然后合并子问题的解得出原问题的解。不同的是,分治分解的子问题是不重叠的(如快速排序将问题分为处理左序列与右序列),而动态规划问题相反。同时,动态规划一定解决的是最优化问题,而分治不一定。

贪心与动态规划

它们在初学者看来往往难以分清。它们都是将问题变成处理子问题,都要求原问题拥有最优子结构。二者的区别在于,贪心类似于“自顶向下”,但是是通过一种最优策略直接选择一个子问题求解,其它子问题则直接舍弃。这就是说,它总是在上一步选择的基础上继续选择,整个过程是单链的。显然,这种“最优选择策略”的正确性需要进一步归纳证明。比如数塔问题,贪心法会从第一层开始,选择下面两数更大的那个一直走下去,显然这并不一定能得到最优解。而动态规划不管是自顶向下还是自底向上,本质上都是从边界开始得到目标问题的最优解。也就是说,它会考虑完所有子问题,并选择继承能得到最优结果的那个,对于暂时没被继承的子问题也不会直接舍弃——由于重叠子问题的存在,后期可能会再次考虑它们,因此它们仍然有机会成为全局最优的一部分。

贪心是一种壮士断腕的决策——只要进行的选择就一直走下去不后悔;动态规划则是动态地考虑问题,看谁能笑到最后——局部的领先不代表全局的领先。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AryCra_07

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值