9

前言

最近刚好有在刷Leetcode,所以顺便也分享一些常见基础的算法解析。

正文

动态规划是一种相当常用的算法,一般用在求解最优解问题中,理解它的原理十分有必要(最直接的好处是可以提升刷题效率~)。

初识-从梯子问题说起

直接进入正题。从一个简单的爬楼梯问题说起:

问题1:小明爬楼梯时,有2种选择:每次跨1步,或者每次跨2步;那么爬完一个n阶的楼梯,请问一共有几种方法?(建议先自行思考再看解析)

没有学过算法的时候,我们的第一反应经常是先枚举:

  • n=1时,有1种走法:跨1步就完成;
  • n=2时,有2种走法:每次跨1步,走两次;或者是一次跨2步,直接走完。
  • n=3时,有3种走法:[1,1,1] 或者[1,2] 或者[2,1]
  • ...

当n 逐渐增大时,我们会发现情况变得越来越多,也越来越复杂,不可能继续枚举下去。这时候我们就要尝试着寻找不同的思路。

接下来介绍今天的主题 -- 动态规划

首先来看它的官方描述:

动态规划(dynamic programming)是把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解的优化方法。

其实更具体的来说,就是把问题不断拆解为子问题去求解

听的云里雾里?没关系,我们直接拿前面的问题来开刀: 对于n阶的梯子,无论前面怎么走,我们先思考一个问题:
在登上最后一个阶梯的前一次, 小明有可能处于第几阶?

由于题设给出了每次只能跨1或者2步,所以答案很显然,是2种:

  1. 小明在第n-1阶,并且最后用1步跨上了第n阶
  2. 小明处于第n-2阶,并且最后用2步跨上了第n阶

换句话说:小明爬n阶楼梯的方式总数,一定是爬n-1阶的方式的总数,加上小明爬n-2阶的方式的总数。 这应该也不难理解。

所以我们现在可以得到一个很关键的状态转移公式:

// w(n) 表示爬上个n阶的楼梯一共有的方法总数
w(n) = w(n-1) + w(n-2)

看到这里,再回头理解一下关于动态规划的描述关键:把多阶段过程转化为一系列单阶段问题,对应到上文,就是把求解一次爬n阶楼梯的问题,分解成爬n阶楼梯与前一个状态值的关系

细心的同学可能会发现,上面的状态转移公式,很像初学各个编程语言时,遇到的递归
当然,我们完全可以写一个递归来求解,例如:

const getSumWaysOfClinbStairs = function (n){
    //1或者2时 前面已经枚举过了
    if(n === 1 || n===2){
        return n;
    } else {
        // 关键代码
        return getSumStairs(n-1) + getSumStairs(n-2);
    }
}

但是不难发现其实递归的效率很差(有兴趣可以用time自测下),只要n为稍微大一点的数字,就要重复嵌套调用非常多次,所以我们不妨再稍微反向思考下,如果我们每次都从n=1开始计算,保存中间的状态值,是不是就可以省下一部分时间呢?(其实有点空间换时间的意思) 所以可以写成以下的样子:

const getSumWaysOfClinbStairs = function (n){
    // init
    const sumStairs = [];
    sumStairs[1] = 1;
    sumStairs[2] = 2;
    
    for(let i = 3; i<=n ; i++){
        sumStairs[i] = sumStairs[i-2] + sumStairs[i-1]
    }
    return sumStairs[n];
}

这个改良过的代码会比第一种写法快很多,可以用一个比较大的n去测试下运行时间,我偷懒一下就不放相关数据了。当然,这个代码还是可以继续优化的,比如没必要缓存中间的所有数据,在循环体种每次都只要保留最新的sumStairs[i-2]sumStairs[i-1]就行,可以省一点内存空间(不过在js里面,这点空间其实也没啥大影响...)。

进阶-理解动态规划的本质

在前一个问题里,我们大概已经认识了动态规划,接下来我拿一个简化过LeetCode里的题目来说明一下:
问题2:假如有m行n列的表格,从坐标 [0,0] 到坐标 [m-1,n-1],一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向右、下移动一格(不能移动到方格外),请问:要移动到右下角的格子,有几种移动方案?(简略的画了个3x3表格以供大家思考)

【原点】 空格 空格
空格 空格 空格
空格 空格 【终点】

这道题相对于梯子的问题,看起来复杂了一点,但是其实关键点也是一样的,再问一下那个灵魂问题:在登上终点的前一步, 机器人有可能处于什么位置?

根据题意可知:机器人只能向右或者向下移动,所以在登上终点的前一步,肯定是在终点的左侧格子,或者是终点的上一个格子。与问题1同理,也可以得到关键的状态转移方程:

// w(i,j) 表示从原点[0,0]走到格子[i,j]的方法总数。
w(i,j) = w(i-1, j) + w(i, j-1)

这里比问题1稍微要多想一步:当i=0或者j=0时,(i,j)表示在第一行或者第一列的格子,从原点到这类格子的方法,其实只有一种--直线向右或者直线向下,应该不难理解。

然后,其实就可以写出核心代码:

const getSumWaysOfMoveGrid = function (m,n) {    
    // 初始化m x n的二维数组 table[i][j] 表示走到 table[i][j] 有几种方法
    const table = new Array(m).fill(0);
    for(let i = 0; i < m ;i++){
        table[i] = new Array(n).fill(0);
    }
    
    // dp过程
     for(let i = 0; i < m ;i++){
        for(let j = 0; j < n ;j++){
            if(i === 0 || j === 0){
                // 处理侧边格子
                table[i][j] = 1;
            } else{
                table[i][j] = table[i-1][j] + table[i][j-1]
            }
        }
    }
    
    return table[m-1][n-1];
}

getSumWaysOfMoveGrid(3, 3) //6

其实这道题的解题关键依然是前面提到的把多阶段过程转化为一系列单阶段问题

变形-类背包问题

对于有学过算法的同学,背包问题算是一个非常经典的入门题型,其实它体现的是一类寻找最优解的题目,我们也可以稍微把前面问题2改造一下变成类似的题目(因为我懒得再编一个背包问题了)。

问题3:假如有m行n列的表格,从坐标 [0,0] 到坐标 [m-1,n-1],并且每个格子上有一个数字,表示经过这个格子时,机器人要耗费的电量,一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向右、下移动一格(不能移动到方格外),请问移动到右下角的格子,请问最小耗电量是多少?(依然花个3x3表格以供大家思考)

原点 耗电量0 2 1
2 3 1
1 4 终点 耗电量0

问题3和问题2有了些许的不同:

  • 首先是条件里,这次加入了【耗电量】的概念,
  • 其次问题也变成了哪一种走法可以使总的耗电量最小。

如果没有前面两个问题的引导,一下子看到这个问题会觉得复杂-- 如何保证总的耗电量最小呢? 当前一步走的耗电小,可能下一步走的耗电就大,如何进行比较?(一开始有这种想法很正常,没关系,现在我们马上解决它)

依然是那个灵魂问题探路:机器人按照规定用最小耗电量走到终点时,前一个状态可能是什么?

根据题意可知:机器人只能向右或者向下移动,所以在登上终点的前一步,肯定是在终点的左侧格子,或者是终点的上一个格子。如果在走到终点时能让耗电量最小,那么在终点前一个状态时,耗电量也一定要是截止至移动到当前位置时最小的,换句话说,此时的状态方程为:

//  minTot(i,j) 表示从原点[0,0]走到格子[i,j]的最小耗电量。
//  e(i,j)表示坐标为[i,j]格子的耗电量
minTot(i,j) = min(minTot(i-1, j), minTot(i, j-1)) + e(i,j);

解释一下:

  • minTot(i-1, j): 走到[i,j]左侧格子的最小耗电量
  • minTot(i, j-1): 走到[i,j]上方格子的最小耗电量
  • min(minTot(i-1, j), minTot(i, j-1)),两者之间取小的一个值

(看到这里建议稍微回顾对比下 捋一捋 因为前两道题刚好求的都是方法总数 为了避免读者陷入误区 特意补充了这个问题)

这下应该就比较清晰了。其实js代码也就呼之欲出了。可以根据问题2的代码稍微改改(主要还是边界条件的处理要注意):

// 这时参数要变成一个二维数组
const getMinEleOfMoveGrid= function (eleArr) {    
    // 初始化 
    if(!eleArr.length || !eleArr[0]){
        return 0;
    }
    const m = eleArr.length;
    const n = eleArr[0].length;
    const minTot = new Array(m).fill(0);
    for(let i = 0; i < m ;i++){
        minTot[i] = new Array(n).fill(0);
    }
    minTot[0][0] = 0;
    
    // dp过程
     for(let i = 0; i < m ;i++){
        for(let j = 0; j < n ;j++){
            if(i === 0 && j > 0){
                // 处理第一列格子 这类格子只有一种走法 所以耗电量固定 无所谓最小
                minTot[i][j] = minTot[i][j-1] + eleArr[i][j];
            } else if(j === 0 && i > 0){
                // 处理第一行格子 这类格子只有一种走法 所以耗电量固定 无所谓最小
                minTot[i][j] = minTot[i-1][j] + eleArr[i][j];
            } 
            else if(i > 0 && j > 0){
                minTot[i][j] = Math.min(minTot[i-1][j], minTot[i][j-1]) +eleArr[i][j];
            }
        }
    }
    
    return minTot[m-1][n-1];
}
const arr =[
    [0,2,1],
    [2,3,1],
    [1,4,0]
] 
getMinEleOfMoveGrid(arr) // 4

到这里其实大概的内容也都说完了。

小结

动态规划的内容,已经基本都说完了。希望读者从上面的3个问题里,或多或少能学到一些内容。如果看不懂的有错误,欢迎评论区指出。

说句题外话,今年是不寻常的一年,经历了艰难困苦,才更知道生命的可贵。希望大家都能更热爱生活,更加积极向上~


惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址


最后顺便打个小广告:

  • RingCentral厦门地区目前有大量hc
  • 纯美资外企,有工作优生活(5点多下班 个人时间超长 可以随心所欲撸猫撸厨房撸算法)
  • 海景办公,零食水果,节日福利多多
  • 免费英文口语课,硅谷工作机会,入职享受10天起超长带薪年假
  • 需要内推请私信或投递简历到邮箱ma13635251979@163.com
  • 全程跟进面试进度,提供力所能及的咨询帮助~

安歌
7k 声望5.5k 粉丝

目前就职于Ringcentral厦门,随缘答题, 佛系写文章,欢迎私信探讨.