动态规划框架模板总结

算法框架系列之动态规划

总结自labuladong算法小抄

1. 动态规划基础知识

  • 动态规划问题的⼀般形式就是求最值 ,⽐如说让你求最⻓递增⼦序列、最⼩编辑距离等等

  • 动态规划三要素:重叠⼦问题、 最优⼦结构、 状态转移⽅程

问题:你怎么知道这个问题是个动态规划问题呢,你怎么知道它就存在「重叠⼦问题」 呢?(以编辑距离为例)
1.先抽象出算法框架:

      def dp(i, j):   # 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
        
		dp(i - 1, j - 1) #1
		dp(i, j - 1) #2
		dp(i - 1, j) #3
		

2.提出类似的问题 : 请问如何从原问题 dp(i, j) 触达⼦问题 dp(i - 1, j - 1) ?

⾄少有两种路径

  1. #1 : 即 dp(i , j ) => dp(i -1, j -1),
  2. #2 => #3 : 即 dp(i, j) => dp(i , j - 1) -> dp(i - 1, j - 1)。

一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
因此, 本问题一定存在重叠⼦问题, 一定需要动态规划的优化技巧来处理。

2. 求解动态规划问题的思路:

  • 核⼼问题 :穷举(这类问题存在「重叠⼦问题」==> 需要「备忘录」 或者「DP table」来优化穷举过程, 避免不必要的计算 )。
  • 动态规划问题⼀定会具备「最优⼦结构 , 才能通过⼦问题的最值得到原问题的最值
  • 思考状态转移⽅程: 明确「状态」 -> 定义 dp(也即helper) 数组/函数的含义 -> 明确「选择」 -> 明确 base case

动态规划三点:状态,选择,dp 数组的定义。最后的代码就可以套这个框架:

# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

A. 备忘录

备忘录的作用:剪枝

针对于'自顶向下',备忘录的作用:记录dp 1,2,3...的值
    String key = i+","+j;
	//备忘录就是一个HashMap,因为其存取复杂度都是O(1)
    HashMap<String, Boolean> memo = new HashMap<>();
	memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
1.'自顶向下':计算 f(20) --> 先计算出⼦问题 f(19)f(18) --> 计算 	 f(19) --> f(18)f(17) 实际上先求的还是f(1)f(2),
故而需要'memo记录dp函数值'
    1) memo接收dp返回值,最后return memo
    2) 情况分支太多,我们可以使用res接受结果值,在return res之前put进memo
-----------------------------------------
    带有备忘录的自顶向下递归求解:
    情形一:
          def minDistance(s1, s2) -> int:
            memo = dict() # 备忘录
            def dp(i, j):
                if (i, j) in memo:
                return memo[(i, j)]
                ...
                if s1[i] == s2[j]:
                memo[(i, j)] = ...
                else:
                memo[(i, j)] = ...
                return memo[(i, j)]
                    
            return dp(len(s1) - 1, len(s2) - 1)
-----------------------------------------
    情形二:
	boolean dp(String s,String p ,int i , int j,HashMap<String, Boolean> memo){                            
        String key = i+","+j;
        if (memo.containsKey(key)){
            return memo.get(key);
        }
        boolean res = false;
        //相同时
        if (){
            if (){
                res = dp...
            }else {  
                res = dp...
            }
        }else {          
            if (){
                res = dp...
            }else {   
                res = dp...
            }
        }
        memo.put(key,res);
        return res;



2.自底向上:对于自底向上的情况'不需要新建备忘录',因为'dp本身就是备忘录'
int minDistance(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    int[][] dp = new int[m + 1][n + 1];
    // base case 
    for (int i = 1; i <= m; i++)
        dp[i][0] = i;
    for (int j = 1; j <= n; j++)
        dp[0][j] = j;
    // 自底向上求解
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            if (s1.charAt(i-1) == s2.charAt(j-1))
                dp[i][j] = dp[i - 1][j - 1];
            else               
                dp[i][j] = min(
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i-1][j-1] + 1
                );
    // 储存着整个 s1 和 s2 的最小编辑距离
    return dp[m][n];
} 

在这里插入图片描述
在这里插入图片描述

B. dp函数(数组)

/'dp函数(数组)定义:'/
1、第一种思路模板是一个'一维的 dp 数组'/-------------------------------------------------------------
int n = array.length;
int[] dp = new int[n];

for (int i = 1; i < n; i++) {
    for (int j = 0; j < i; j++) {
        dp[i] = 最值(dp[i], dp[j] + ...)
    }
}
-------------------------------------------------------------/
举个我们写过的例子'最长递增子序列',在这个思路中 dp 数组的定义是:
在子数组'array[0..i]'中,以'array[i]'结尾的'目标子序列(最长递增子序列)'的长度是'dp[i]'。

为啥最长递增子序列需要这种思路呢?因为'这样符合归纳法',可以找到状态转移的关系。
    

2、第二种思路模板是一个'二维的 dp 数组'/-------------------------------------------------------------
int n = arr.length;
int[][] dp = new dp[n][n];

for (int i = 0; i < n; i++) {
    for (int j = 1; j < n; j++) {
        if (arr[i] == arr[j]) 
            dp[i][j] = dp[i][j] + ...
        else
            dp[i][j] = 最值(...)
    }
}
-------------------------------------------------------------/
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列。本思路中 dp 数组含义又分为'「只涉及一个字符串」和「涉及两个字符串」'两种情况。

2.1 '涉及两个字符串/数组时'特点:两个数组对比(比如最长公共子序列/编辑距离),
   dp 数组的含义如下:
   在子数组'「arr1[0..i]和子数组arr2[0..j]」'中,我们要求的子序列(最长公共子序列)长度为'dp[i][j]'

2.2 '只涉及一个字符串/数组时'(比如最长回文子序列/气球问题/),
   dp 数组的含义如下:
   在子数组'「array[i..j]」'中,我们要求的子序列(最长回文子序列)长度为'dp[i][j]'

涉及'两个状态参数'的问题:
1.高楼扔蛋: '最坏情况'下,你'至少要扔几次鸡蛋',才能确定这个楼层F呢(高于F的楼层都会碎,低于F的楼层都不会碎)
  参数: 当前拥有的鸡蛋数K和需要测试的楼层数N
  dp(K, N): #当前状态为(K 个鸡蛋,N 层楼),返回这个状态下的最优结果
          base case: if K == 1: return N  if N == 0: return 0
      	  res = min( res, 
                     max( 
                        dp(K - 1, i - 1), # 碎
                        dp(K, N - i)      # 没碎
                     ) + 1 # '在第 i 楼扔了一次'
                   )

2.背包问题: 1) 0-1背包  2) 子集背包 3)完全背包
 2.1 0-1背包:  
   这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。
   'dp[i][w]的定义'如下:对于前i个物品,当前背包的容量为w,这种情况下可以装的最大价值是dp[i][w]
    0-1背包框架:
    int dp[N+1][W+1]
    dp[0][..] = 0
    dp[..][0] = 0

    for i in [1..N]:
        for w in [1..W]:
            dp[i][w] = max(
                把物品 i 装进背包,
                不把物品 i 装进背包
            )
    return dp[N][W]
  2.2子集背包:
	给一个'可装载重量为sum/2的背包和N个物品',每个物品的重量为nums[i]。现在让你装物品,是否存在一种装法,能够恰好将背包装满?
    dp[i][j] = x表示,对于前i个物品,当前背包的容量为j时,若x为true,则说明可以恰好将背包装满,若x为false,则说明不能恰好将背包装满。想求的最终答案就是dp[N][sum/2]
    由于i是从 1 开始的,而数组索引是从 0 开始的,所以第i个物品的重量应该是nums[i-1].
    代码框架:
    	int target = sum / 2;
		int n = nums.length;
        // 创建二维状态数组,行:物品索引,列:容量(包括 0)
        boolean[][] dp = new boolean[n][target + 1];

		//base case:
		//如果不选取任何正整数,则被选取的正整数等于 0 
        for (int i = 0; i < n; i++) {
            dp[i][0] = true;
        }
        //当 i==0 时,只有一个正整数nums[0]可以被选取
        dp[0][nums[0]] = true;

        for (int i = 1; i < n; i++) {
            int num = nums[i];
            for (int j = 1; j <= target; j++) {
                if (j >= num) { //容量够用,可以选也可以不选
                    dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
                } else { //容量不够,不选 j不变
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[n - 1][target];//n个商品 --> 0...n-1

  2.3 完全背包    '物品可以重复使用'
  若只使用前i个物品,当背包容量为j时,有dp[i][j]种方法可以装满背包。
  若只使用coins中的前i个硬币的面值,若想凑出金额j,有dp[i][j]种凑法。
    '代码框架:
    int dp[N+1][amount+1]
    dp[0][..] = 0
    dp[..][0] = 1

    for i in [1..N]:
        for j in [1..amount]:
            把物品 i 装进背包,
            不把物品 i 装进背包
    return dp[N][amount]

                
3.气球问题
   dp[i][j] = x : 戳破'气球i和气球j之间'(开区间,不包括i和j)的所有气球,可以'获得的最高分数为x'。
   dp[i][j] = dp[i][k] + dp[k][j] + points[i]*points[k]*points[j]
                
4.取钱问题:
   dp(int[] nums, int start) : 返回 nums[start..] 能抢到的最大值,'从start家开始之后可取到的钱'
        '代码框架:
        private int dp(int[] nums, int start) {
            if (start >= nums.length) {
                return 0;
            }

            int res = Math.max(
                    // 不抢,去下家
                    dp(nums, start + 1), 
                    // 抢,去下下家
                    nums[start] + dp(nums, start + 2)
                );
            return res;
        }

4. 遍历方式
在这里插入图片描述

从下往上遍历:
 // i 应该从下往上
    for (int i = n; i >= 0; i--) {
        // j 应该从左往右
        for (int j = i + 1; j < n + 2; j++) {

三种遍历方式代码:

  • 正向遍历:
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
    for (int j = 0; j < n; j++)
        // 计算 dp[i][j]
  • 反向遍历:
for (int i = m - 1; i >= 0; i--)
    for (int j = n - 1; j >= 0; j--)
        // 计算 dp[i][j]
  • 斜向遍历:
for (int l = 2; l <= n; l++) {
    for (int i = 0; i <= n - l; i++) {
        int j = l + i - 1;
        // 计算 dp[i][j]
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值