LeetCode笔记--动态规划

动态规划

最优子结构:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。即一个问题的最优解包含其子问题的最优解。

独立求解的理解:如算法导论P218中所说,要保证分解后得到的子问题之间可以独立求解,也即同一分解中不同子问题所用资源之间没有交集

动态规划与分治

动态规划与分治法很相似,都是通过组合子问题的解来解原问题,但是分治法的子问题之间是不相交的,没有公共子子问题,比如归并排序,每一次分割数组的时候,子数组都是不想交的;而动态规划在分解子问题的时候,不同的子问题拥有公共的子子问题,比如爬楼梯问题,将n规模的问题分解为了n-1规模和n-2规模的问题,而n-1规模的子问题中包含n-2规模的问题的解。

动态规划与递归

由于动态规划分解得到的子问题之间含有公共的子子问题,所以如果只是普通的递归处理的话,会进行很多重复的计算,时间复杂度会成指数级增长,动态规划在求解的时候,会保存每个子问题的解,这样当再次遇到该子问题的时候直接拿来使用就可,从而节省了时间。

爬楼梯问题

链接:爬楼梯
经典的爬楼梯问题:由于一次只能上一个台阶或者两个台阶,所以对于N阶楼梯的问题可以分为两个子问题,“(N - 2) + 2”或者“(N - 1) + 1”两中划分;而显然对于(N - 1)的子问题中,包含(N - 2)这个子问题的解。所以如果使用分治法的话,会产生大量的重复计算:

分治法:

public int climbStairs(int n) {
    if (n == 1)
        return 1;
    if (n == 2)
        return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
}  

对n = 36的情况进行测试,分治法所需的时间为70ms

动态规划:

public int climbStairs(int n) {
    int[] results = new int[n + 1];
    results[1] = 1;
    results[0] = 1;
    for (int i = 2; i <= n; i ++) {
        results[i] = results[i - 1] + results[i - 2];
    }
    return results[n];
}

对n = 36的情况进行测试,动态规划所需的时间为0ms

上面采取的动态规划的策略是:自底向上。也就是,当求解一个问题的时候,其所有子问题、子子问题等都已经被求出、保存了下来,这样才能够当再次遇到该子问题的时候直接使用。

上面的递归法效率低下的问题在于,对于子问题存在重复计算的情况,如果我们也能够保存遇到过的子问题的解,当我们分解问题的时候可以查看该子问题对应的解是否已经有了,如果有的话,我们直接使用,而不是不管不顾地一直递归下去,这就引出了动态规划另一策略:自顶向下。我们在递归的代码上修改一下:

class Solution {
    int[] results;
    public int climbStairs(int n) {
        results = new int[n + 1];
        results[0] = 1;
        results[1] = 1;
        return r(n);
    }

    // 方法三:动态规划自顶向下  
    public int r(int n) {
        if (results[n] != 0)
            return results[n];
        results[n] = r(n - 1) + r(n - 2);
        return results[n];
    }
}  

自顶向下和自底向上的区别在于,在求解问题的时候其子问题的解不一定已知,但是我们对同一个子问题只会求一次;而自底向上法中,我们先求的是子问题的解,所以当我们求解规模更大的问题的时候,子问题一定已知了。

最小代价的爬楼梯问题

链接:使用最小花费爬楼梯
我们来通过这个问题来学习一下最优子结构的分析。

对于N介台阶的问题,我们设情况A为最后一步刚好到第N阶,情况B为倒数第二步到N-1阶之后直接一次迈两步(由于直接登顶,所以这次的代价为0)到达第N阶。

照这么分析,那么递推关系式为P(N) = min{P(N-1), P(N-2)+cost[N]},不同于之前那个朴素的爬楼梯问题,这里的P(N)被分成了两种情况A和B,也就是说每一个P(N)、P(N-1)...等都是两种情况中的较优的那一个,这看起来没什么问题,但却有一个漏洞,那就是P(N)隐含的意义是最终你的脚必须踩到第N阶上,但是根据题意,我们可以一次迈两步,也就是情况A下我们最终没有踩到第N阶上,而是就在第N-1阶上,这样一来就产生了逻辑错误,因为P(N)有可能等于P(N - 1)问题的规模减小了,但是代价却一样?这显然违背逻辑,因此便失败了:

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] results = new int[cost.length];
        results[0] = cost[0];
        results[1] = cost[1];
        for (int i = 2; i < results.length; i ++) {
            results[i] = Math.min(results[i - 1], results[i - 2] + cost[i]);
        }

        return results[results.length - 1];
    }
}   

上面的代码就是根据其思路来的,但是是错的。比如cost为[10, 15, 20]显然最小代价为15,但是上面的代码算得的是10,原因在于对于子问题cost[10, 15]其最优解为"10",即情况B,但是原问题的最优解是"15"即是子问题cost[10, 15]的情况A。所以这么分解的话,原问题的最优解不能用子问题的最优解合成,动态规划便失效了。

正确的分解

上面的错误的问题分解在于原问题的最优解不能够用子问题的最优解合成;而如果我们不去一步到位,我们先只考虑情况A,即每一个问题的最后一步一定恰好在N、N-1、N-2...

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] results = new int[cost.length];
        results[0] = cost[0];
        results[1] = cost[1];
        for (int i = 2; i < results.length; i ++) {
            results[i] = Math.min(results[i - 1] + cost[i], results[i - 2] + cost[i]);
        }
    } 
}  

这样的话,results数组中就保存了所有情况A的最小代价,那么原问题的最优解不光有情况A,还有情况B,我们还要思考情况B呢!仔细分析原问题发现情况B即为N-1下的情况A,所以完整的代码是:

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] results = new int[cost.length];
        results[0] = cost[0];
        results[1] = cost[1];
        for (int i = 2; i < results.length; i ++) {
            results[i] = Math.min(results[i - 1] + cost[i], results[i - 2] + cost[i]);
        }

        return Math.min(results[cost.length - 1], results[cost.length - 2]);
    }
}  

在返回值上做了手脚。

最大子序和

链接:最大子序和
我们再来一个例子来看看子问题的分析。对于这个问题,假如我们只是简单的将P(N)看做数组从1到N段的最优解,那么对于子问题的最优解P(N-1)我们便不能够得到P(N)因为此时P(N)的最优解可能会与P(N-1)有不同的元素。
同样,假如我们将P(N)看做是在包含第N个元素情况下的解,那么P(N)与P(N-1)之间的关系就能够确认为P(N) = max{P(N-1) + nums[N], nums[N]}。这样的话,原问题的解便为从1到N的所有P的最大的一个

class Solution {
    public int maxSubArray(int[] nums) {
        int[] results = new int[nums.length];
        if (nums.length == 0)
            return 0;
        results[0] = nums[0];
        int max = results[0];
        for (int i = 1; i < nums.length; i ++) {
            if(results[i - 1] >= 0)
                results[i] = nums[i] + results[i - 1];
            else
                results[i] = nums[i];
            max = Math.max(results[i], max);
        }
        return max;
    }
}

实战演练

动态规划的重点和难点在于对子问题的定义,有些题目中,题干所问并不能直接作为子问题的定义或者很难去将它进行分解。

1. 动态规划与数组

对于动态规划背景下的数组的某些问题,我们通常是将数组的从第一个元素开始的、连续的子数组作为子问题规模的指标,即通常是dp[i]代表数组从1到i这个子数组

例1:最大子序和该题目中,我们将dp[i]定义为从数组的第1到第i这个连续子数组的最大和(PS:最大和必须包含数组的第i个元素)。这样一来,状态转移方程为

dp[i] = max{dp[i - 1] + nums[i], nums[i]}  

这样一来,问题的答案就是max{dp[i]}

例2:乘积最大子序列该题目中,我们依旧将dp[i]定义为[0...i]这个连续的子数组的最大乘积的话,我们把子问题的解向后应用的时候就会发生问题,如果nums[i]是一个负数的话,那么dp[i - 1]如果是正的,那么与nums[i]相乘会变为最小的,此时如果我们把状态转移方程依然写成:

dp[i] = max{dp[i - 1] + nums[i], nums[i]}   

那么以[2, -2, -3]为例,dp[3]理应为12,但由于dp[2] = -2,此时dp[3]成为了6.那么宣告我们dp[i]的定义失败了。

本例题的核心是正负数的变化,一个负数再乘上一个负数之后可能变为乘积最大的了,一个原本是子问题的最大乘积,乘上一个负数之后可能变成了乘积最小的了。正是这种特点,我们需要将最大和最小都保存起来,因为他们的地位可能会发生多次互换

if (a < 0) {
    min[i] = max[i - 1] == 0 ? a : max[i - 1] * a;
    max[i] = min[i - 1] * a;
 } else if (a > 0){
    min[i] = a * min[i - 1];
    max[i] = max[i - 1] == 0 ? a : max[i - 1] * a;
 } else {
    max[i] = 0;
    min[i] = 0;
 }      

这样一来,问题的答案就是max{max[i]}

感悟:同时这个例题也给了我们一个提醒,不是所有的题目都是可以一步到位的,就像这里的min[i]用来保存最小的数,起辅助作用的工具在很多题目中都可以看到。我们要敢于突破,敢于否定自己

例3:最长上升子序列这里我们依旧将dp[i]定义为子数组[i...i]中包含nums[i]元素的最长上升子序列。但此时子问题的最优解往后利用的形式发生了变化:

dp[i] = max{dp[j] + i}(j < i, nums[j] < nums[i])  

这样一来,问题的答案就是max{dp[i]}

例4:最长重复子数组这里由于涉及到两个数组,所以肯定是要使用dp[i][j]这种形式了,我们尝试吧其定义为数组A中[0...i]与B中[0...j]中包含A[i]与B[j]最长公共子数组的长度,那么我们的状态转移方程就为:

dp[i][j] = dp[i - 1][j - 1] + i  (A[i] == B[j])  
dp[i][j] = 0                     (A[i] != B[j])  

那么问题的答案是max{dp[i][j]}

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

推荐阅读更多精彩内容

  • 动态规划 动态规划是一种高性能的牛逼算法,由美国的R.Bellman提出,它是动态就是体现在此算法是基于一个递推公...
    董泽平阅读 1,160评论 0 12
  • 0. 动态规划分析 0.1 动态规划、递归和贪心算法的区别 动态规划就是利用分治思想和解决冗余的办法来处理问题,所...
    dreamsfuture阅读 7,389评论 2 6
  • 来自公众号:码海 前言 动态规划(dynamic programming,简称 dp)是工程中非常重要的解决问题的...
    码农小光阅读 1,198评论 2 6
  • 目录1 动态规划是什么2 如何判断一个问题可以使用动态规划来解决3 判断要解决问题在动态规划经典问题中的分类4 动...
    wanghuohuo0716阅读 1,720评论 0 0
  •   为了准备三月份蓝桥杯的比赛和提高下自己数据结构和算法方面的水平,所以开始在leetcode上刷起了题。刚刚开始...
    冯宇祺阅读 3,571评论 0 5