美文网首页
算法练习4:爬楼梯和挖金矿(动态规划)

算法练习4:爬楼梯和挖金矿(动态规划)

作者: miao8862 | 来源:发表于2021-04-06 22:52 被阅读0次

    所谓动态规划,其实就是使用分治的方法将问题最简化,再从简化的步骤再逆推回复杂问题的最优解。和归并排序的思想很类似。

    爬楼梯

    题目:如果有n阶楼梯,你一次只能爬一阶或二阶两种爬法,问到达n阶一共有几种爬法?

    爬楼梯-自顶向下-递归解法

    按照动态规划的方法,先是自顶向下,从最后解开始得答案:

    1. 到达n阶的方法,自然是n-1走1阶或者n-2走2阶这两种;所以n就是(到达n-1的方法)和(到达n-2的方法)之和,用公式就是f(n) = f(n-1) + f(n-2)
    2. 到达n-1的方法,自然又是n-2走1阶或者n-3走2阶这两种;f(n-1) = f(n-2) + f(n-3)
    3. 到达n-2的方法,就是n-3走1除非或者n-4走2除非这两种:f(n=2) = f(f-3) + f(n-4)
      ....
    4. 直到到达2楼梯时,只有2种:就是1阶1阶走,或者走2阶,这就是我们说的边界值, f(2) = 2
    5. 到达1阶楼梯的方法,只有1种,就是开始走1阶, f(1) = 1

    所以按照以上公式可以得出:
    f(n) = n, (n<=2)
    f(n-1) + f(n-2), (n>2)

    按照这个思路,其实可以看出它每次都分割成2个分支,然后分割次数为n-1次,所以它的时间复杂度为O(2^n-1) ,约等于O(2^n),是指数级别的
    它的实现也很简单:

    function climbStairs(n) {
      if(n <= 2) {
        return n;
      }
      return climbStairs(n-1) + climbStairs(n-2)
    }
    console.time()
    console.log(climbStairs(45)); // 1836311903
    console.timeEnd() // default: 7589.035ms
    

    爬楼梯-自顶向下-备忘录解法

    使用自顶向下的方法,虽然能解出问题,但指数级的时间复杂度过高,呃..当我运行要到100阶时,55s了还没运行完,受不了,直接强制关掉!

    我们可以查看它的实现,会发现它其实有很多重复运算,比如:
    f(5) = f(4) + f(3)

    • 对于到达4阶的分支:
      f(4) = f(3) + f(2)

      • 3分支:
        f(3) = f(2) + f(1)
        f(2) = 2
        f(1) = 1
      • 2分支:
        f(2) = 2
        f(1) = 1
    • 对于到达3阶的分支
      f(3) = f(2) + f(1)
      f(2) = 2
      f(1) = 1

      • 2分支:
        f(2) = 2
        f(1) = 1
      • 1分支:
        f(1) = 1

    可以看出才到5阶这个过程就已经重复了计算f(1), f(2), f(3)好几次,如果到n阶,这些计算不是还要重复很多次,这根本就是不必要的。我们每一次的运算其实都可以利用上一次的结果,所以只需要运用缓存来存储上一次的结果,再一次调用时,直接获取结果就好,这就是我们说的备忘录算法
    按照这个思想,我们优化得到:

    function climbStairs2(n,map) {
      let value = 0;
      if(n <= 2) {
        return n;
      }
      if(map.get(n)) {
        return map.get(n)
      }else {
        value = climbStairs2(n-1, map) + climbStairs2(n-2, map)
        map.set(n, value)
        return value
      }
    }
    
    console.time()
    // 创建一个哈希表,用来存储已经计算过的n阶结果
    console.log(climbStairs2(45, new Map())); // 1836311903
    console.timeEnd() // default: 2.263ms
    

    这时候可以看到所用时长,已经减少了很多,看看现在的复杂度:

    • 时间复杂度:只用1次计算F(n)-F(1)的结果,所以是O(n)
    • 空间复杂度:要存储从F(n)-F(1)的结果,所以是O(n)

    爬楼梯-自底向上-动态规划解法(优化空间复杂度)

    既然我们要算上层的结果,必须要先递归得到下层的结果,然后再整合得回上层结果(上-下-上),为什么不干脆反过来,我先拿到下层结果,再直接递推到上层,不就省了第一步从上到下的递归步骤?

    楼梯数 1 2 3 4 5 6 7 8 9 10 ...
    走法数 1 2 3 5 8 13 21 34 55 89 ...

    看结果,是不是很像斐波那契数列,只不过它从1和2开始而已。这时,我们的实现:

    function climbStairs3(n) {
      // 因为2是边界值,1阶是1种方法,2阶是2种方法,所以循环从3阶开始
      let s1 = 1; // 记录到达n-2阶的方法数
      let s2 = 2; // 记录到达n-1阶的方法数
      let s = 0; // 记录到达n阶的方法
      for(let i = 3; i <= n; i++) {
        s = s1 + s2
        s1 = s2
        s2 = s
      }
      return s;
    }
    
    console.time()
    console.log(climbStairs3(45)); // 1836311903
    console.timeEnd() // default: 2.223ms
    

    这时候时间复杂度还是O(n),空间复杂时因为只用了3个变量值,是常量级,所以已经变成了O(1)

    下面再以一个采金矿的问题进一步说明

    采金矿

    题目:

    有5个金矿:
    第1个金矿400kg,需要5个人挖,
    第2个金矿500kg,需要5个人挖
    第3个金矿200kg,需要3个人挖
    第4个金矿300kg,需要4个人挖
    第5个金矿350kg,需要3个人挖
    问,总共只能有10个去挖,怎么分配能挖到的金矿最多呢?

    采金矿-自顶向下-递归解法

    解题思想:

    • 只要剩余人数够挖这个金矿,那么它就有两种情况,挖这个金矿和不挖这个金矿;如果人数不够挖这个金矿,那就只有一种情况,忽略这个金矿,因为挖不了

    • 初始有10个人,对于第1个金矿(400kg)来说:
      a.挖:要5人 ,得400kg金矿,剩5个人:
      b. 不挖,不用人,没有金矿,剩10人;

    下面,为了简化问题,我就拿a分支来举例:

    • 对第二个金矿(500kg)来说,要5个人:
      a.1 剩5人,挖,则用5人,剩0人,得金400+500kg=900kg,这个分支结束
      a.2 剩5人,不挖,用0人,得金还是之前的400kg,剩5人

    • 对于第三个金矿(200kg)来说,要3个人:
      a.2.1,之前剩5人,挖,用3人,得金200+400kg=600kg,最后剩2人
      a.2.2,之前剩5人,不挖,用0人,得金不变400kg,最后剩5人

    • 对于第四个金矿(300kg)来说,要4个人:
      a.2.1,之前剩2人,不能挖,忽略这个金矿,保持600kg
      a.2.2.1 之前剩5个,挖,用4人,得金300kg+400kg,最后剩1人
      a.2.2.2 之前剩5个,不挖,用0人,得金和前面一样400kg,最后剩5人

    • 对于第五个金(350g)来说,要3个人:
      a.2.1,之前剩2人,还是不能挖,忽略这个金矿,此时没有金矿了,分支结束,所以这个分支保持600kg
      a.2.2.1 之前剩1个,不能挖,忽略这个金矿,此时没有金矿了,分支结束,所以这个分支保持700kg
      a.2.2.2.1 之前剩5个,挖,用3人,分支结束,得金350kg+400kg=750kg,最后剩2人
      a.2.2.2.2 之前剩5个,不挖,用0人,保持得金400kg,最后剩5人

    所以对于a分支各个分支来说,它的解分别为:900kg, 600kg, 700kg, 750kg, 400kg,最优解是900kg

    b分支也同理
    它的解分别是:0,350kg,650kg,550kg,500kg,850kg,800kg,700kg,最优解是850kg

    最后,a、b分支比较,最优解为900kg
    可以看得出来,每个分支的最优解,就是它再下面分支的最大解,所以只要判断出什么情况下会出现这个分支就是解决问题的关键,而这个关系,用数学公式来表达的话,就叫状态转移方程式

    回到这句话:只要剩余人数够挖这个金矿,那么它就有两种情况,挖这个金矿和不挖这个金矿;如果人数不够挖这个金矿,那就只有一种情况,忽略这个金矿,因为挖不了

    • 假设金矿分布为g: [400, 500, 200, 300, 350]
    • 金矿数所需人数对应为p: [5, 5, 3, 4, 3 ]
    • 剩余人数表示为w
      w(当剩余人数) < p[n-1](当前金矿数所要人数)时,人力不足, 没资格挖,会直接忽略当前金矿,跳到下一个金矿,所以此时f(n,w) = f(n-1, w)
      w>p(n-1)时,就会分为两个分支:
      • 一个是挖当前金矿f(n-1, w - p[n-1]) + g[n-1],g[n-1]是当前金矿数,p[n-1]是挖当前金矿所而人数,w-p[n-1]是下一次剩余人数,n-1是下一个金矿
      • 一种是不挖当前金矿f(n-1, w),则剩余人数不变,直接到下一个金矿(视金钱为粪土,我有人也不挖!)
        然后地主爸爸都是贪心的,虽然我分两条路走,但是哪个有钱我要哪个!所以他要分支中金最多的那个仔!

    以上总结起来:
    0, (n=0 || w=0,即没矿或没人时)
    F(n,w) = F(n-1, w), (n>=1且w<p[n-1], 人数不足以挖当前金矿时,忽略当前的金矿,直接找下一个金矿)
    max(F(n-1, w), F(n-1, w-p[n-1]) + g[n-1]), (n>=1且w>=p[n-1]) ,人数充足时,为两分支中的最大值

    /**
     * @param w: 工人数
     * @param n: 可挖金矿数
     * @param p: 金矿开采所需的工人数量
     * @param g: 金矿含量
    */
    
    function getBestGold(w, n, p, g) {
      // 如果剩余工人数或剩余采矿数为0,则可供可继续采矿量为0
      if(w===0 || n===0) {
        return 0;
      }
      // 如果剩余人数小于金矿所需人数,则忽略此金矿,去采下一个金矿
      if(w < p[n-1]) {
        getBestGold(w, n-1, p, g)
      }
    
      // 如果剩余人数充足,则返回采集下一个金矿和不采集下一个金矿中的最大值
      return Math.max(getBestGold(w, n-1, p, g), getBestGold(w-p[n-1], n-1, p, g) + g[n-1])
    }
    
    let w = 10  // 剩余挖矿人数
    let p = [5, 5, 3, 4, 3] // 每个矿需要的挖矿人数
    let g = [400, 500, 200, 300, 350] // 每个矿的金矿含量
    console.time()
    console.log(getBestGold(w, g.length, p, g)); // // 900
    console.timeEnd() // default: 2.675ms
    

    可以看到,这个方法的时间复杂度是O(2^n),属于能做,就是慢....

    采金矿-自顶向下-备忘录解法

    从刚刚爬楼梯的例子类似,自顶向下之所以慢,是因为它做了很多重复的操作,这时我们同样使用一个哈希表来存储每种金矿分配情况的值,减少重复操作
    注意,这时的key,是有两个自变量的,一个金矿对应的个数,一个是当前剩余工人数

    function getBestGold2(w, n, p, g, map) {
      // console.log('map:', map);
      
      // 如果剩余工人数或剩余采矿数为0,则可供可继续采矿量为0
      if((w===0) || (n===0)) {
        return 0;
      }
      // 如果剩余人数小于金矿所需人数,则忽略此金矿,去采下一个金矿
      if(w < p[n-1]) {
        return getBestGold2(w, n-1, p, g, map)
      }
      
      // 如果剩余人数充足,则返回采集下一个金矿和不采集下一个金矿中的最大值
    
      // 获取哈希表中的key,当前的key是由两个变量控制的,金矿对应个数和当前剩余工人数
      let key = JSON.stringify([w, n])
      // 判断哈希表中,是否有当前key的值,如果有,直接取值
      if(map.get(key)) {
        return map.get(key)
      }else {
        // 如果没有,则计算
        let value = Math.max(getBestGold2(w, n-1, p, g, map), getBestGold2(w-p[n-1], n-1, p, g, map) + g[n-1])
        map.set(key, value)
        return value
      }
    }
    console.time()
    console.log(getBestGold2(w, g.length, p, g, new Map())); // // 900
    console.timeEnd() // default: 2.675ms
    

    可以看出,这时候的时间复杂度近似是O(nw),空间复杂度也同样的是O(nw)

    采金矿-自底向上-动态规划解法

    在爬楼梯的例子里,因为变量只有一个n,所以比较简单。但在金矿的例子里,有两个变量,所以这时,我们借助一个二维表来分析:

    剩余工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 0 0 0 0 0 0
    金矿2 0 0 0 0 0 0 0 0 0 0
    金矿3 0 0 0 0 0 0 0 0 0 0
    金矿4 0 0 0 0 0 0 0 0 0 0
    金矿5 0 0 0 0 0 0 0 0 0 0

    对于第一个金矿来说,人数(5人)不足时,不能采,人数足时,只有一个金矿,只能采当前金矿(400kg):
    当然也可以套公式:
    F(1,5) = max(F(0,5), F(0,0) + G[0]) = max(0, 0+ 400) = 400

    剩余工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 400 400 400 400 400 400

    对于第二个金矿来说,人数(5人)不足时,也不能采,当够5人时,可以采当前金矿(G[1],500kg),就可以套公式了:
    F(2, 5) = max(F(1, 5), F(1,0) + G[1]), F(1, 5)查表,400kg;F(1,0)边界值为0,G[1]为当前金矿500kg,最大值为500kg
    F(2, 6) = max(F(1, 5), F(1,1) + G[1]), F(1, 5)查表,400kg;F(1,1)查表为0,G[1]为当前金矿500kg,最大值为500kg
    ...
    F(2, 10) = max(F(1, 5), F(1,5) + G[1]), F(1, 5)查表,400kg;G[1]为当前金矿500kg,最大值为400+500=900kg

    剩余工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 400 400 400 400 400 400
    金矿2 0 0 0 0 500 500 500 500 500 900

    对于第三个金矿来说,人数(3人)不足时,也不能采,当够3人时,可以采当前金矿(G[2] 200kg),就可以套公式了:
    F(3,3) = max(F(2,4), F(2,0) + G[2]) = max(0, 0 + 200) = 200
    F(3,4) = max(F(2,4), F(2,1) + G[2]) = max(0, 0 + 200) = 200
    F(3,5) = max(F(2,5), F(2,2) + G[2]) = max(500, 0 + 200) = 500
    ...
    F(3,10) = max(F(2,10), F(2,7) + G[2]) = max(900, 500 + 200) = 900

    剩余工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 400 400 400 400 400 400
    金矿2 0 0 0 0 500 500 500 500 500 900
    金矿3 0 0 200 200 500 500 500 700 700 900

    对于第四个金矿来说,人数(4人)不足时,也不能采当前矿,递推采以前的;当够4人时,可以采当前金矿(G[3] 300kg),就可以套公式了:
    F(4,3) = F(3,3) = 200,人数不足时,等于上一个金矿对应人数的值
    F(4,4) = max(F(3,4), F(3,0) + G[3]) = max(200, 0 + 300) = 300
    F(4,5) = max(F(3,5), F(3,1) + G[3]) = max(500, 0 + 300) = 500
    ...
    F(4,10) = max(F(3,10), F(3,6) + G[3]) = max(900, 500 + 300) = 900

    剩余工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 400 400 400 400 400 400
    金矿2 0 0 0 0 500 500 500 500 500 900
    金矿3 0 0 200 200 500 500 500 700 700 900
    金矿4 0 0 200 300 500 500 500 700 800 900

    对于第五个金矿来说,人数(3人)不足时,也不能采(只能采以前的,发现以前的也都不能采);当够3人时,可以采当前金矿(G[4] 350kg),就可以套公式了:
    F(5,3) = max(F(4,3), F(4,0) + G[4]) = max(200, 0 + 350) = 350
    F(5,4) = max(F(4,4), F(4,1) + G[4) = max(300, 0 + 350) = 350
    F(5,5) = max(F(4,5), F(4,2) + G[4]) = max(500, 0 + 350) = 500
    ...
    F(5,10) = max(F(4,10), F(4,7) + G[4]) = max(900, 500 + 350) = 900

    工人数 1 2 3 4 5 6 7 8 9 10
    金矿1 0 0 0 0 400 400 400 400 400 400
    金矿2 0 0 0 0 500 500 500 500 500 900
    金矿3 0 0 200 200 500 500 500 700 700 900
    金矿4 0 0 200 300 500 500 500 700 800 900
    金矿5 0 0 350 350 500 550 650 850 850 900

    推导完后,大家开始来找规律了

    用代码实现方式为:

    function getBestGold3(w,n, p, g) {
      plen = p.length
      let preArr = new Array(w)
      let curArr = new Array(w)
      // 添加第一行边界值
      for(let i = 0; i <= w; i++) {
        // 当人数少于第一个金矿所需人数时,没金可挖
        if(i < p[0]) {
          preArr[i] = 0
          // 当人数大于等于第一个金矿人数时,可以挖第一个金矿
        }else {
          preArr[i] = g[0]
        }
      }
      // 遍历的金矿数(也就是行)
      for(let i = 1; i < n; i++) {
        // 遍历工人数(也就是每一个金矿的人数)
        for(let j = 0; j <= w; j++) {
          // 人数不足以挖当前金矿时,就等于上一行同列那个格式数
          if(j < p[i]) {
            curArr[j] = preArr[j]
            // 如果人数充足时,就为 上一行同列格子数 和 上一行往前p[i]列的格式数+当前金矿数 这两数中的最大值
          }else {
            curArr[j] = Math.max(preArr[j], preArr[j - p[i]] + g[i])
          }
        }
        preArr = [...curArr]
      }  
      return curArr[w]
    }
    
    
    let w = 10  // 剩余挖矿人数
    let p = [5, 5, 3, 4, 3] // 每个矿需要的挖矿人数
    let g = [400, 500, 200, 300, 350] // 每个矿的金矿含量
    console.time()
    console.log(getBestGold3(w, p.length, p, g)); // 900
    console.timeEnd() // default: 2.456ms
    

    它的时间复杂度为O(n*w), 空间复杂度为O(2w),约为O(w)

    相关文章

      网友评论

          本文标题:算法练习4:爬楼梯和挖金矿(动态规划)

          本文链接:https://www.haomeiwen.com/subject/vzqekltx.html