Go Book / 5 Data Structure & Algorithms / 常见算法思想8:动态规划法

常见算法思想8:动态规划法

动态规划问题的分类

  • 求最大最小值
    • 从左上角走到右下角路径的最大数字和
    • 最长上升子序列长度
  • 计数
    • 有多少种方式…
    • 有多少种方法选出k个数使得和是sum
  • 求存在性
    • 取石子游戏,先手是否必胜
    • 能不能选出k个数使得和是sum

常见动态规划问题的类型

  • 坐标型动态规划(20%) :二维数组下标就是坐标,如机器人路线问题
  • 序列型动态规划(20%) :
  • 划分型动态规划(20%) :给一个字符串或数组让划分成若干段满足一些性质
  • 区间型动态规划(15%) :选择连续区间而符合一定条件,f(i,j)
  • 背包型动态规划(10%) :一定空间的背包最多带多少物品的问题
  • 最长序列型动态规划(5%) : 最长上升子序列等类似问题
  • 博弈型动态规划(5%) : 博弈算出一个人是否必胜还是必败或者胜率问题
  • 综合性动态规划(5%) : 综合前面类型或和其他算法结合
  • 动态规划空间优化问题,如数组降维
  • 动态规划打印路径问题,打印解

动态规划问题解决步骤

  • 0.为解题的每一步开辟一个等容量的数组存储每一步的结果
  • 1.确定状态
    • 最后一步:研究最优策略的最后一步;
    • 子问题:转换成可不断缩小规模的子问题;
  • 2.转移方程
    • 根据可不断缩小规模的子问题直接得到;
      • 求最值用统计函数min,max等
      • 求计数用累加
      • 求可行性用or and布尔运算
  • 3.初始条件和边界情况
    • 细心考虑周全,某些初始条件可直接得到结果;
  • 4.计算顺序
    • 使用数组存储每一步的计算结果,并在每一步利用之前的计算结果。

例题解析

一、求最值问题:

你有三种硬币,面值分别为2,5,7,每种硬币都有足够多。现在你需要买一本书需要27元,如何用最少的硬币组合正好付清,不需要对方找钱?

1.确定状态
  • 状态在动态规划中的作用属于定海神针
  • 简单地说,解动态规划需要开一个数组,数组的每个元素f[i]或者f[i][j]代表什么?
    • 类似于解数学题中,x,y,z代表什么?
  • 确定状态需要两个意识
    • 最后一步
      • 虽然不知道最优策略是什么,但是最优策略肯定是k枚硬币a1,a2,…,ak,面值加起来是x

      • 所以一定有一枚最后的硬币:ak

      • 除掉这枚硬币,前面硬币的面值加起来是27-ak

      • 关键点1:不关心签名的k-1枚是怎样拼出27-ak的,而且现在还不知道ak和k,但是确定签名的硬币拼出了27-ak

      • 关键点2:因为是最优策略,所以拼出27-ak的硬币一定要最少。

    • 子问题
      • 所以我们就要去:最少用多少枚硬币可以拼出27-ak
      • 原问题是最少用多少枚硬币拼出27
      • 把原问题规模变小成为一个子问题
      • 为了简化定义,我们设状态f(x) = 最少用多少枚硬币拼出x
      • 首先确定最后那枚硬币ak是多少:
        • 可能性:2,5,7
          • 如果是2,最后枚数:f(27)= f(27-2) + 1
          • 如果是5,最后枚数:f(27)= f(27-5) + 1
          • 如果是7,最后枚数:f(27)= f(27-7) + 1
        • 即f(27) = min{ f(27-2)+1, f(27-5)+1, f(27-7)+1}
2.转移方程
  • 设状态f[x] = 最少用多少枚硬币拼出x
  • 对于任意x: f[x] = = min{f[x-2]+1, f[x-5]+1, f[x-7]+1 }
3.初始条件和边界情况
  • f[x] = = min{f[x-2]+1, f[x-5]+1, f[x-7]+1 }

  • 两个问题: x-2,x-5,x-7小于0怎么办? 什么时候停下来?

  • 如果不能拼出y,则定义f[y]= 正无穷

    • 例如f[-1]=f[-2]=…=正无穷
  • 所以f[1] = min{f[-1]+1, f[-4]+1, f[-6]+1} = 正无穷,表示拼不出来1

  • 初始条件:f[0] = 0

4.计算顺序
  • 拼出x所需要的最少硬币数:f[x] = min{f[x-2]+1, f[x-5]+1, f[x-7]+1 }
  • 初始条件:f[0] = 0
  • 然后计算f[1],f[1],…f[27]
  • 当我们计算到f[x]时,f[x-2], f[x-5], f[x-7]都已经得到结果了。
func CoinChange(coinList []int, total int) int {
	// 0,....,n : [n+1]
	// 0,....,n-1 : [n]
	stepList := make([]int, total+1) // 存储每1元需要多少枚硬币的结果
	coinNum := len(coinList)         // 硬币的类型数量

	// 初始条件
	stepList[0] = 0

	var i, j int
	for i = 1; i <= total; i++{

		// 每一元的情况需要多少枚硬币,默认设置无穷大
		stepList[i] = math.MaxInt64
		// 最后一枚硬币 stepList[j]
		// stepList[i]= math.Min(stepList[i-coinList[j]])+1,stepList[i])
		for j = 0; j < coinNum; j++ {
			// total需大于硬币面值
			if i >= coinList[j] && stepList[i-coinList[j]] != math.MaxInt64 {
				// 转移方程
				stepList[i] = int(math.Min(float64(stepList[i-coinList[j]])+1, float64(stepList[i])))
			}
		}
	}

	if stepList[total] == math.MaxInt64 {
		stepList[total] = -1
	}

	return stepList[total]

}

二、求计数:

给定m行n列的网格,有一个机器人从左上角(0,0)出发,每一步可以向下或者向右走一步,问有多少种不同的方式走到右下角。

1.确定状态
  • 最后一步:
    • 无论机器人用何种方式到达右下角,总有最后挪动的一步:向右或向下;
    • 右下角坐标设置为(m-1,n-1)
    • 那么前一步机器人一定是在(m-2,n-1)或者(m-1,n-2)
  • 子问题:
    • 如果机器人有X种方式从左上角走到(m-2,n-1),有Y种方式从左上角走到(m-1,n-2),则机器人有X+Y种方式走到 - 如果机器人有X种方式从左上角走到(m-2,n-1),有Y种方式从左上角走到(m-1,n-2),则机器人有X+Y种方式走到(m-1,n-1)。 =》 数学组合中的加法原理
    • 问题转化为机器人有多少种方式从左上角走到(m-2,n-1)和(m-1,n-2);
    • 原题要求有多少种方式从左上角走到(m-1,n-1)。
    • 转化子问题:状态:设f[i][j]为机器人有多少种方式从左上角走到(i,j)
2.转移方程
  • 对于任一格子(i,j):f[i][j] = f[i-1][j] + f[i][j-1]
3.初始条件和边界情况
  • 初始条件:f[0][0] = 1,因为机器人只有一种方式到左上角
  • 边界情况:i=0或j=0,则前一步只能由一个方向过滤:f[i][j] = 1

4.计算顺序

  • f[0][0] = 1
  • 计算第0行:f[0][0],f[0][1],…,f[0][n-1]
  • 计算第1行:f[1][0],f[1][1],…,f[1][n-1]
  • 计算第m-1行:f[m-1][0],f[m-1][1],…,f[m-1][n-1]
  • 答案是:f[m-1][n-1]
  • 时间复杂度(计算步数):O(MN),空间复杂度(数组大小):O(MN)
// 输入行列参数m,n
func UniquePaths(m, n int) int{
	// 开一个二维数组m行,存储走到当前每一格的可能性步数
	f := make([][]int, m, m)

	var i, j int
	for i = 0; i < m; i++ { // row:top to bottom
		// 每行开n列的数组
		f[i] = make([]int, n, n)
		for j = 0; j < n; j++ { // column:left to right
			if i == 0 || j == 0 {
				// 初始格为1
				f[i][j] = 1
			}else {
				// 转移方程:当前格 = 上一格往下的情况 + 上一格往右的情况
				f[i][j] = f[i-1][j] + f[i][j-1]
			}
		}
	}

	// 只需返回最后一格作为结果即可
	return f[m-1][n-1]
}

三、求可能性:

有n块石头分别在x轴的0,1,…,n-1位置,一只青蛙在石头0,想跳到石头n-1,如果青蛙在第i块石头上,它最多可以向右跳距离ai,问青蛙能否跳到石头n-1? 例子:输入a=[2,3,1,1,4],输出true;输入a=[3,2,1,0,4],输出false;

1.确定状态
  • 最后一步:
    • 如果青蛙能跳到最后一块石头n-1,我们考虑它跳的最后一步
    • 这一步从石头i跳过来,i<n-1
    • 这需要两个条件同时满足:
      • 青蛙可以跳到石头i (不好判断)
      • 最后一步不超过跳跃的最大距离:n-1-i<=ai (好判断)
  • 子问题:
    • 我们需要知道青蛙能不能跳到石头i(i<n-1)
    • 而我们原来要求青蛙能不能跳到石头n-1
    • 状态:设f[j]表示青蛙能不能跳到石头j
2.转移方程
  • 设f[j]表示青蛙能不能跳到石头j
  • f[j] = OR(f[i] AND i + a[i] >= j) (0<=i<j)
    • f[j] : 表示青蛙能不能跳到石头j;
    • 0<=i<j : 表示枚举上一个跳到的石头i;
    • f[i] AND i : 表示青蛙能不能跳到石头i;
    • a[i] >= j : 表示最后一步的距离不能超过ai
3.初始条件和边界情况
  • 初始条件:设f[i]表示青蛙能不能跳到石头i

  • 边界条件:f[0] = true,因为青蛙一开始就在石头0

4.计算顺序
  • 设f[i]表示青蛙能不能跳到石头i
  • f[j] = OR(f[i] AND i + a[i] >= j) (0<=i<j)
  • f[0] = true
  • 计算f[1],f[1],…f[n-1]
  • 答案是f[n-1]
  • 时间复杂度:O(N²),空间复杂度(数组大小):O(N)
func CanJump(list []int) bool {
	// 石头块数
	n := len(list)

	// 开辟数组,存储跳到每个石头的可能性
	canList := make([]bool, n, n)
	canList[0] = true // 初始位置0石头肯定可以

	// 从位置1开始
	for j := 1; j < n; j++ {
		canList[j] = false // 假设不可行

		// 从当前石头i开始
		// 最后一跳是i到j,j必须在i后面
		for i := 0; i < j; i++ {
			// 转移方程
			if canList[i] && i+list[i] >= j {
				canList[j] = true
				break
			}
		}
	}

	return canList[n-1]
}