动态规划解题套路框架

1. 介绍

动态规划的问题一般是求最值。比如求最长递增子序列、最小编辑距离等。

1.1 核心问题

求解动态规划问题的核心问题就是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值。

1.2 三要素

重叠子问题、最优子结构、状态转移方程就是动态规划三要素。

1.2.1 重叠子问题

动态规划的穷举有点特别,因为这类问题存在“重叠子问题”,如果暴力穷举,效率会极其低下,所以需要“备忘录”或者“DP table”来优化穷举过程,避免不必要的计算。

1.2.2 最优子结构

动态规划问题一定会具备“最优子结构”,这样才能通过子问题的最值得到原问题的取值。

1.2.3 状态转移方程

穷举所有可行解其实并不是一件容易的事,只有列出正确的“状态转移方程”,才能正确的穷举。写出状态转移方程,这一步是最困难的。

1.3 写出正确的状态转移方程

想要写出正确的状态转移方程,一定要思考以下几点:

  • 这个问题的base case(最简单情况)是什么?
  • 这个问题有什么“状态”?
  • 对于每个“状态”,可以做出什么“选择”使得“状态”发生改变?
  • 如何定义dp数组/函数的含义来表现“状态”和“选择”?

1.4 框架

# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2,...)

下面通过斐波那契数列和凑零钱这两个例子来讲解动态规划的基本原理。前者解释什么是重叠子问题,后者专注于如何列出状态转移方程。

2. 斐波那契数列

2.1 暴力递归

斐波那契数列的数学形式就是递归的,写成代码就是这样的:

int fib(int N) {
    if(N == 0) return 0;
    if(N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

但是该递归效率十分低下,低效在哪里?假设n=20,画出递归树:


斐波那契数列

注意:但凡遇到需要递归解决的问题,最好都画出递归树,这对分析算法的复杂度。寻找算法低效的原因都有巨大帮助。

这个递归树的意思是说,要想计算f(20)的值,就应该先计算出子问题f(19)和f(18),然后计算f(19),以此类推。最后当遇到f(1)或者f(2)的时候,结果已知。这样就能直接返回结果,递归树不再往下生长了。

2.2 重叠子问题

观察该递归树,可以很明显的看出有很多重复计算,比如f(18)被计算了两次,更何况还不止f(18)这一个节点被重复计算,所以这个算法极其低效。

这就是动态规划问题的第一个性质:重叠子问题。下面我们来解决这个问题。

2.3 带备忘录的递归解法

耗时的原因是计算,那么我们就可以造一个“备忘录”,每次计算出某个子问题的答案后别急着返回,先将其记到“备忘录”里再返回;每次遇到一个子问题先去“备忘录”里查一查,如果发现之前已经解决过这个问题,就直接把答案拿出来用,不要再耗时进行计算了。

一般使用一个数组充当“备忘录”,也可以是使用哈希表。

代码:

int fib(int N) {
    if( N == 0 ) return 0;
    // 将备忘录全初始化为0
    vector<int> memo(N + 1, 0);
    // 进行带备忘录的递归
    return helper(memo, N);
}

int helper(vector<int>& memo, int n) {
    // base case
    if (n == 1 || n == 2) return 1;
    // 已经计算过
    if (memo[n] != 0) return memo[n];
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

由于本算法不存在子问题的计算,子问题就是f(1),f(2),f(3),...f(20),数量和N成正比,所以子问题个数为O(N)。

这种解法是自顶向下的,而动态规划的解法是自底向上的。即先计算f(1)和f(2)开始往上推,直到最终问题的答案f(20)。

2.4 dp数组的迭代解法

有了上一步“备忘录”的启发,我们可以把这个“备忘录”独立出来成为一张表,就叫做
DP table吧。

int fib(int N){
    if (N == 0) return 0;
    if (N == 1 || N == 2) return 1;
    vector<int> dp(N + 1, 0);
    // base case
    dp[1] = dp[2] = 1;
    for (int i=3; i<=N; i++){
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[N];
}

这里引出了“状态转移方程”这个名词,实际上就是描述问题结构的数学形式:

f(n)=\left\{\begin{aligned}
1,n=1,2 \\
f(n-1) + f(n-2), n>2    
\end{aligned}
\right.

其实当前状态只跟前边两个状态相关,所以只需要保存前两个即可,即只需要保存f(n-1)和f(n-2)。所以代码可以优化为:

int fib(int N){
    if( N == 0 ) return 0;
    if( N == 1 || N == 2) return 1;
    int prev = 1, curr = 1;
    for(int i = 3; i<=N; i++){
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

这个技巧就是所谓的“状态压缩”,如果我们发现每次状态转移只需要DP table中的一部分,那么可以尝试使用状态压缩来缩小DP table的大小,只记录必要的数据。

3. 凑零钱问题

先看下题目:给你k种面值的硬币,面值分别为c1,c2,..., ck,每种硬币的数量无限,再给一个总金额amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回-1。

算法的函数签名:

// coins中是可选硬币面值,amount是目标金额
int coinChange(int[] coins, int amount);

比如k=3,面值分别为1、2、5,总金额amount=11,那么最少需要3枚硬币凑出,即11=5+5+1。

3.1 暴力递归

首先是最困难的一步,写出状态转移方程,这个问题比较好写:


状态转移方程

其实,这个方程就用到了「最优子结构」性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。

    public static int coinChange(List<Integer> coins, int amount){
        if (amount == 0) return 0;
        int ans = Integer.MAX_VALUE;
        for (int coin : coins){
            if ((amount - coin) < 0) continue;
            int subResult = coinChange(coins, amount - coin);
            if (subResult == -1) continue;
            ans = Math.min(ans, subResult + 1);
        }
        return ans == Integer.MAX_VALUE ? -1: ans;
    }

3.2 带备忘录的递归算法

    public static int coinChangeMap(List<Integer> coins, int amount){
        if (amount == 0) return 0;
        int ans = Integer.MAX_VALUE;
        for (int coin : coins){
            if ((amount - coin) < 0) continue;
            int subResult = 0;
            if (result.containsKey(amount)){
                subResult = result.get(amount);
            }else {
                subResult = coinChangeMap(coins, amount - coin);
            }
            if (subResult == -1) continue;
            ans = Math.min(ans, subResult + 1);
        }
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

3.3 dp数组的迭代解法

    public static int coinChangeDP(List<Integer> coins, int amount){
        int[] dp = new int[amount+1];
        Arrays.fill(dp, amount+1);
        dp[0] = 0;
        //外层for循环遍历所有状态取值
        for (int i=1; i < dp.length; i++){
            //内层循环求所有选择的最小值
            for (int coin : coins){
                //子问题无解,跳过
                if ((i - coin) < 0) continue;
                dp[i] = Math.min(dp[i] , 1 + dp[i-coin]);
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }

参考资料

  1. 《labuladong的算法小抄》
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容