2020-12-11
这是啥
参考百度百科 😉 :
动态规划(Dynamic Programming, DP)是:
- 数学的二级学科运筹学下的三级学科
 - 求解多阶段决策过程最优化的过程, 是一种数学方法论, 没有公式可套 (但有套路)
 - 每个阶段的决策依赖当前状态, 而又会引起状态转移, 故称"动态"
 - 兄弟姐妹有 线型规划 、非线型规划、组合最优化、图论等
 
能干啥
主持大菊, 运筹帷幄
应用广泛, 如经济、工业、军事等领域, 并在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果...
画重点
这里只讨论编程领域的应用
怎么干
基础概念
(做出决策 = 计算出结果)
- 状态: 当前阶段上下文
 - 边界: 无法继续优化的阶段, 即决策确定 (无边界 = 无解/无数解 = 死循环)
 - 无后效性: 当前阶段做出决策后, 后续决策 不受 之前阶段 的影响, 即状态单向转移, 后续决策都是基于前面有限的阶段
 - 状态转移方程: 不同阶段间上下文关系
 - 重叠子结构: 不同阶段之间要解决的若干问题有重复
 - 备忘录(DP Table): 在寻找最优子结构, 化简状态转移方程的过程中用于记录各阶段状态或决策的额外数据, 应该尽量去掉
 - 最优子结构: 对阶段的划分和决策的过程最优化, 最优子结构一定也是由最优子结构组成
 
举例子
与分治法类似 (如快速/归并排序算法), 都是将待求解问题分为若干子问题, 从子问题的解得到原问题的解, 大事化小, 小事化了. 不同的是分治法一般是自顶向下递归求解, 而动态规划优化后一般是自底向上求解
以求解 斐波那契数列 第 n 位的值为例
问题分析🤔: 斐波那契数列 前两位为 1, 之后的每一位的值等于它前面两位的和
💡✨ 暴力求解无脑走一波(自顶向下)
普通递归算法 👎
export default function fibonacci(n: number): number {
  return n < 3 ? 1 : fibonacci(n - 1) + fibonacci(n - 2)
}
2
3
求解过程如下:
flowchart TB
  A("f(n)") --> A1("f(n - 1)")
    A1 --> A11("f(n - 2)")
      A11 --> A111(...)
        A111 --> A1111("f(1)") --> B111
        A111 --> A1112("f(2)") --> B111
      A11 --> A112(...)
        subgraph c
        A112 --> A1121("f(1)") --> B112
        A112 --> A1122("f(2)") --> B112
        end
    A1 --> A12("f(n - 3)")
      subgraph b
      A12 --> A121(...)
        A121 --> A1211("f(1)") --> B121
        A121 --> A1212("f(2)") --> B121
      A12 --> A122(...)
        A122 --> A1221("f(1)") --> B122
        A122 --> A1222("f(2)") --> B122
      end
  A --> A2("f(n - 2)")
  subgraph a
    A2 --> A21("f(n - 3)")
      A21 --> A211(...)
        A211 --> A2111("f(1)") --> B211
        A211 --> A2112("f(2)") --> B211
      A21 --> A212(...)
        A212 --> A2121("f(1)") --> B212
        A212 --> A2122("f(2)") --> B212
    A2 --> A22("f(n - 4)")
      A22 --> A221(...)
        A221 --> A2211("f(1)") --> B221
        A221 --> A2212("f(2)") --> B221
      A22 --> A222(...)
        A222 --> A2221("f(1)") --> B222
        A222 --> A2222("f(2)") --> B222
  end
  B1("f(n - 1)") --> B("f(n)")
    B11("f(n - 2)") --> B1
      B111(...) --> B11
      B112(...) --> B11
    B12("f(n - 3)") --> B1
      subgraph b
      B121(...) --> B12
      B122(...) --> B12
      end
  B2("f(n - 2)") --> B
    subgraph a
    B21("f(n - 3)") --> B2
      B211(...) --> B21
      B212(...) --> B21
    B22("f(n - 4)") --> B2
      B221(...) --> B22
      B222(...) --> B22
    end如图所示, 圈起来的都是重复子问题, 可以通过使用备忘录空间换时间的方式来实现剪枝
带备忘录递归算法 👍
let DPTable: { [key: number]: number }
function solve(n: number): number {
  return (
    DPTable[n] ||
    (DPTable[n] = n < 3 ? 1 : solve(n - 1) + solve(n - 2))
  )
}
export default function fibonacci(n: number): number {
  DPTable = {}
  const result = solve(n)
  DPTable = null! // 睁一只眼闭一只眼
  return result
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
动态规划算法 😍🤙
我们应用动态规划的概念来审视上面的解法
- 状态: 
n的值 - 重叠子结构: 上图框起来的部分, 比如 
f(n - 2)计算了两次,f(n - 3)计算了三次... - 备忘录: 使用一个简单对象(这里也可以是数组)缓存每个阶段计算结果
 - 最优子结构: 对于 
n > 2存在最优子结构, 如下图: 
flowchart TB
  A("f(n)") --> A1("f(n - 1)")
    A1 --> A11("f(n - 2)")
      A11 --> A111(...)
        A111 --> A1111("f(1)")
        A111 --> A1112("f(2)")
      A11 x-.-x A112(...)
        subgraph c
        A112 --> A1121("f(1)")
        A112 --> A1122("f(2)")
        end
    A1 x-.-x A12("f(n - 3)")
      subgraph b
      A12 --> A121(...)
        A121 --> A1211("f(1)")
        A121 --> A1212("f(2)")
      A12 --> A122(...)
        A122 --> A1221("f(1)")
        A122 --> A1222("f(2)")
      end
  A x-.-x A2("f(n - 2)")
  subgraph a
    A2 --> A21("f(n - 3)")
      A21 --> A211(...)
        A211 --> A2111("f(1)")
        A211 --> A2112("f(2)")
      A21 --> A212(...)
        A212 --> A2121("f(1)")
        A212 --> A2122("f(2)")
    A2 --> A22("f(n - 4)")
      A22 --> A221(...)
        A221 --> A2211("f(1)")
        A221 --> A2212("f(2)")
      A22 --> A222(...)
        A222 --> A2221("f(1)")
        A222 --> A2222("f(2)")
  end
class a,b,c mhs;- 边界: 
n = 1 或 2 - 无后效性: 
n > 2的每个阶段的结果完全取决于它前面两个阶段的结果 - 状态转移方程: 易得(已最简):
 
所以这么一波分析之后, 找到最优的求解过程应该如下
flowchart TB
  A1("f(1)") --> A3("f(3)")
  A2("f(2)") --> A3
  A2 --> A4("f(4)")
  A3 --> A4
  A3 --> Ad1(...)
  A4 --> Ad1
  A4 --> Ad2(...)
  Ad1 --> Ad2
  Ad1 --> An2("f(n - 2)")
  Ad2 --> An2
  Ad2 --> An1("f(n - 1)")
  An2 --> An1
  An2 --> An("f(n)")
  An1 --> An顺着备忘录的思路根据状态转移方程改为自底向上求解的形式:
export default function fibonacci(n: number) {
  const DPTable: { [key: number]: number } = { 1: 1, 2: 1 }
  for (let i = 3; i <= n; i++) {
    DPTable[i] = DPTable[i - 1] + DPTable[i - 2]
  }
  return DPTable[n]
}
2
3
4
5
6
7
8
9
很明显备忘录是可以优化掉的, 易得:
export default function fibonacci(n: number) {
  let prev = 1
  let curr = 1
  let next
  while (n-- > 2) {
    next = prev + curr
    prev = curr
    curr = next
  }
  return curr
}
2
3
4
5
6
7
8
9
10
11
12
对比
(function() {
function O(v){return v==4 ? 'O(2<sup>n</sup>)' : v==2 ? 'O(nlogn)' : v==1 ? 'O(n)' : v==0 ? 'O(0)' :''}
function A(v){return v==4 ? 'O(2^n)' : O(v)}
return {
  legend: {},
  tooltip: {
    trigger: 'axis',
    confine: true,
    formatter: function(p,t,c) {
      var d=p[0],e=p[1],f='<b style="margin-left:10px">',g='</b>',
        h='<i style="display:inline-block;width:12px;height:12px;margin-right:5px;vertical-align:middle;border-radius:100%;background:',i='"></i>'
      return c(t, '<h4>'+ d.name +'</h4><div style="text-align:left">'+
        h+ d.color +i+ d.seriesName +f+ O(d.value) +g+ '<br/>'+
        h+ e.color +i+ e.seriesName +f+ O(e.value) +g+ '</div>')
    }
  },
  xAxis: [{
    type: 'category',
    data: ['普通递归', '带备忘录递归', '带备忘录动态规划', '动态规划'],
    axisPointer: { type: 'shadow' }
  }],
  yAxis: [
    { type: 'value', name: '时间复杂度', axisLabel: { formatter: A } },
    { type: 'value', name: '空间复杂度', axisLabel: { formatter: A } }
  ],
  series: [
    { name: '时间复杂度', type: 'bar', data: [4, 2, 1, 1] },
    { name: '空间复杂度', type: 'bar', yAxisIndex: 1, data: [4, 2, 2, 1] }
  ]
}
})()讲套路
上面的示例只是体现了动态规划的一般过程, 动态规划是一种求解最值的方法论, 它的核心是尽可能压缩可能解空间, 具体做法是将大问题拆解为若干小问题, 求解小问题推导出大问题的解。
设计动态规划算法主要思路为:
- 明确状态, 搞清楚当前面对的问题
 - 分析上游状态, 弄明白要解决当前阶段的问题最直接的需求
 - 跟踪下游状态, 对当前问题的解决是否满足对下游的需求
 
上下游情况考虑清楚一个, 往往就能设计出状态转移方程. 从求解一个问题开始, 一般的解决过程如下:
flowchart TB
  A("分析问题, 尝试枚举出所有可能解(暴力求解)") --> B{"分析DP要素(重叠子结构/状态/边界/无后效性), 确定是否可DP"}
  B ------> |否| D(其他办法)
  B --> |是| C(DP开始)
  C --> C1(尝试自顶向下递归, 分析执行过程)
  C1 --> C2(找出最优子结构, 设计状态转移方程)
  C2 --> C3(使用备忘录剪枝重叠子问题)
  C3 --> C4(将算法改造成自底向上递推求解的形式)
  C -.-> C2
  C2 -.-> C4做练习
黄金矿工
有5座储量不尽相同的金矿, 每座金矿需要的矿工人数也不尽相同(如下表), 现有矿工10位, 每座金矿只能挖光或不挖, 不能只投入一部分人挖走部分金矿。求利益最大化方案(挖到最多金子)
| 金矿 | 金矿A | 金矿B | 金矿C | 金矿D | 金矿E | 
|---|---|---|---|---|---|
| 储量(kg) | 400 | 500 | 200 | 300 | 350 | 
| 需要的矿工数 | 5 | 5 | 3 | 4 | 3 | 
问题分析
穷举: 每座矿只有挖或不挖两种情况, 故挖矿方案共有 种, 从中找出投入不超过10位矿工并且获得金矿最多的方案即可, 算法实现的时间复杂度为 (空间复杂度, 金矿全都挖的时候)
尝试DP (以下 "x位矿工挖n座矿的最优方案" 简称 "x人n矿")
分解问题:
flowchart TB A(10人5矿) --> B(10人4矿) -.-> |若总收益大于右边方案| D[最优采矿方案] A --> C(x人1矿 + 10-x人4矿) -.-> |若总收益大于左边方案| D
😉发现动态规划要素了没?
继续分解看看:
flowchart TB A(10人5矿) --> B(10人4矿) A --> C(x人1矿 + 10-x人4矿) B --> D(10人3矿) B --> E(y人1矿 + 10-y人3矿) C --> F(10-x人3矿) C --> G(z人1矿 + 10-x-z人3矿)
若设金矿数量为 goldMineCount , 矿工数为 minerCount, 金矿储量依次为 reserves[] , 金矿用工数依次为 requiredMiners[] , 则10人5矿可以表示为:
递推下去这个问题会有边界么? 显然存在, 如下:
所以这个问题是可以用动态规划方法解决的✌️
由上可得状态转移方程如下:
🏃那么DP走起~
f(minerCount, goldMineCount) 备忘录
| minerCount / goldMineCount | 1 | 2 | 3 | 4 | 5 | 
|---|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 | 0 | 
| 2 | 0 | 0 | 0 | 0 | 0 | 
| 3 | 0 | 0 | 200 (C)  | 200 (C)  | 350 (E)  | 
| 4 | 0 | 0 | 200 (C)  | 300 (D)  | 350 (E)  | 
| 5 | 400 (A)  | 500 (B)  | 500 (B)  | 500 (B)  | 500 (B)  | 
| 6 | 400 (A)  | 500 (B)  | 500 (B)  | 500 (B)  | 550 (C + E)  | 
| 7 | 400 (A)  | 500 (B)  | 500 (B)  | 500 (B) / (C + D)  | 650 (D + E)  | 
| 8 | 400 (A)  | 500 (B)  | 700 (B + C)  | 700 (B + C)  | 850 (B + E)  | 
| 9 | 400 (A)  | 500 (B)  | 700 (B + C)  | 800 (B + D)  | 850 (B + E)  | 
| 10 | 400 (A)  | 900 (A + B)  | 900 (A + B)  | 900 (A + B)  | 900 (A + B)  | 
| 11 | 400 (A)  | 900 (A + B)  | 900 (A + B)  | 900 (A + B)  | 1050 (B + C + E)  | 
| 12 | 400 (A)  | 900 (A + B)  | 900 (A + B)  | 1000 (B + C + D)  | 1150 (B + D + E)  | 
| 13 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1100 (A + B + C)  | 1250 (A + B + E)  | 
| 14 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1200 (A + B + D)  | 1250 (A + B + E)  | 
| 15 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1200 (A + B + D)  | 1350 (B + C + D + E)  | 
| 16 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1200 (A + B + D)  | 1450 (A + B + C + E)  | 
| 17 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1400 (A + B + C + D)  | 1550 (A + B + D + E)  | 
| 18 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1400 (A + B + C + D)  | 1550 (A + B + D + E)  | 
| 19 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1400 (A + B + C + D)  | 1550 (A + B + D + E)  | 
| 20 | 400 (A)  | 900 (A + B)  | 1100 (A + B + C)  | 1400 (A + B + C + D)  | 1750 (A + B + C + D + E)  | 
类型声明
/** 金矿信息 */
export interface GoldMine {
  /** 黄金储量 */
  gold: number
  /** 需要矿工数 */
  cost: number
}
/** 得到黄金最多的 采矿方案 */
export interface Plan<T extends GoldMine = GoldMine> extends GoldMine {
  /** 要挖掘的金矿 */
  mines: T[]
}
2
3
4
5
6
7
8
9
10
11
12
13
planUtils
import type { GoldMine, Plan } from './types'
export function add<T extends GoldMine = GoldMine>(goldMine: T, plans: Plan<T>[]): Plan<T>[] {
  const { gold, cost } = goldMine
  let i = plans.length
  let plan: Plan<T>
  while (i) {
    plan = plans[--i]
    plan.gold += gold
    plan.cost += cost
    plan.mines.push(goldMine)
  }
  return plans
}
export function merge<T extends GoldMine = GoldMine, R extends GoldMine = T>(
  leftPlans: Plan<T>[],
  rightPlans: Plan<R>[]
): Plan<T | R>[] {
  const leftGold = leftPlans[0].gold
  const rightGold = rightPlans[0].gold
  if (leftGold === rightGold) {
    // 归并
    const mergedPlans: Array<Plan<T | R>> = []
    let l = leftPlans.length
    let r = rightPlans.length
    while (l && r) {
      mergedPlans.unshift(
        leftPlans[l - 1].cost > rightPlans[r - 1].cost ? leftPlans[--l] : rightPlans[--r]
      )
    }
    while (l) {
      mergedPlans.unshift(leftPlans[--l])
    }
    while (r) {
      mergedPlans.unshift(rightPlans[--r])
    }
    return mergedPlans
  }
  return leftGold > rightGold ? leftPlans : rightPlans
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
带备忘录递归算法 👍
只计算最大收益数值:
import type { GoldMine } from './types'
let DPTable: { [key: string]: number }
function solve<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount: number
): number {
  const key = minerCount + '.' + goldMineCount
  const result = DPTable[key]
  if (result !== undefined) {
    return result
  }
  if (goldMineCount < 2) {
    const goldMine = goldMines[0]
    return (DPTable[key] = minerCount < goldMine.cost ? 0 : goldMine.gold)
  }
  const goldMine = goldMines[--goldMineCount]
  const right =
    minerCount < goldMine.cost
      ? 0
      : minerCount === goldMine.cost
      ? goldMine.gold
      : goldMine.gold + solve(goldMines, minerCount - goldMine.cost, goldMineCount)
  const left = solve(goldMines, minerCount, goldMineCount)
  return (DPTable[key] = left > right ? left : right)
}
export default function getMostGold<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount = goldMines.length
): number {
  DPTable = {}
  const result = solve(goldMines, minerCount, goldMineCount)
  DPTable = null! // 睁一只眼闭一只眼
  return result
}
/* 验证
getMostGold(
  [
    { id: '1', gold: 400, cost: 5 },
    { id: '2', gold: 500, cost: 5 },
    { id: '3', gold: 200, cost: 3 },
    { id: '4', gold: 300, cost: 4 },
    { id: '5', gold: 350, cost: 3 },
  ],
  10
)
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
得到详细方案:
import { add, merge } from './planUtils'
import type { GoldMine, Plan } from './types'
let DPTable: { [key: string]: any }
function solve<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount: number
): Plan<T>[] {
  const key = minerCount + '.' + goldMineCount
  const result = DPTable[key]
  if (result !== undefined) {
    return result
  }
  let goldMine
  let cost
  if (goldMineCount < 2) {
    goldMine = goldMines[0]
    cost = goldMine.cost
    return (DPTable[key] =
      minerCount < cost
        ? [{ gold: 0, cost: 0, mines: [] }]
        : [{ gold: goldMine.gold, cost, mines: [goldMine] }])
  }
  goldMine = goldMines[--goldMineCount]
  cost = goldMine.cost
  if (minerCount < cost) {
    return (DPTable[key] = solve(goldMines, minerCount, goldMineCount))
  }
  let right
  if (minerCount === cost) {
    right = [{ gold: goldMine.gold, cost, mines: [goldMine] }]
  } else {
    right = add(goldMine, solve(goldMines, minerCount - cost, goldMineCount))
  }
  return (DPTable[key] = merge(solve(goldMines, minerCount, goldMineCount), right))
}
export default function getMostGold<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount = goldMines.length
): Plan<T>[] {
  DPTable = {}
  const result = solve(goldMines, minerCount, goldMineCount)
  DPTable = null! // 睁一只眼闭一只眼
  return result
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
动态规划算法 😍🤙
只计算最大收益数值:
import type { GoldMine } from './types'
export default function getMostGold<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount = goldMines.length
): number {
  const DPTable: { [goldMineCount: number]: number } = {}
  let goldMine: T
  let gold: number
  let cost: number
  let j: number
  while (goldMineCount) {
    goldMine = goldMines[--goldMineCount]
    gold = goldMine.gold
    cost = goldMine.cost
    for (j = minerCount; j >= cost; j--) {
      DPTable[j] = Math.max(DPTable[j] || 0, (DPTable[j - cost] || 0) + gold)
    }
  }
  return DPTable[minerCount]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
得到详细方案:
import { add, merge } from './planUtils'
import type { GoldMine, Plan } from './types'
function copyPlan<T extends GoldMine = GoldMine>(plan: Plan<T>): Plan<T> {
  return { ...plan, mines: [...plan.mines] }
}
export default function getMostGold<T extends GoldMine = GoldMine>(
  goldMines: T[],
  minerCount: number,
  goldMineCount = goldMines.length
): Plan<T>[] {
  const DPTable: { [goldMineCount: number]: Plan<T>[] } = {}
  let goldMine: T
  let gold: number
  let cost: number
  let j: number
  let leftPlans: Plan<T>[]
  let rightPlans: Plan<T>[]
  while (goldMineCount) {
    goldMine = goldMines[--goldMineCount]
    gold = goldMine.gold
    cost = goldMine.cost
    for (j = minerCount; j >= cost; j--) {
      leftPlans = DPTable[j - cost]
      leftPlans = leftPlans
        ? add(goldMine, leftPlans.map(copyPlan))
        : [{ gold, cost, mines: [goldMine] }]
      rightPlans = DPTable[j]
      DPTable[j] = rightPlans ? merge(leftPlans, rightPlans) : leftPlans
    }
  }
  return DPTable[minerCount]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
留作业
请思考:
黄金矿工各算法的时间/空间复杂度怎么评估?
动态规划一定比递归更优么?
例如矿工数远远大于金矿数的情况;空间/频繁读写敏感场景
 玉书