今天看啥  ›  专栏  ›  miao8862

爬楼梯和挖金矿(动态规划)

miao8862  · 简书  ·  · 2021-04-06 22:52

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

爬楼梯

题目:如果有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 ...

这时,我们的实现:

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)




原文地址:访问原文地址
快照地址: 访问文章快照