动态规划套路

什么问题是动态规划问题?

动态规划问题的一般形式就是求最值;
求解动态规划的核心问题是穷举。

  • 动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP
    table」来优化穷举过程,避免不必要的计算。 而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。
    另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的「状态转移方程」才能正确地穷举。

动态规划三要素:

  1. 重叠子问题、 解决:备忘录 或者 DP table
  2. 最优子结构、 注意:必须子问题是独立的!
  3. 状态转移方程、

辅助你思考状态转移方程:

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。

如何列出正确的状态转移方程?

根据下面案例进行解释:

根据案例: 凑零钱问题–>解决最优子结构
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:
// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);
比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
你认为计算机应该如何解决这个问题?显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。
2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。
3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。
所以我们可以这样定义 dp 函数:dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量。

实战一、斐波那契数列–>解决重叠子问题

分析:
base case:显然目标数值n<0 =1 =2 的时候的取值;
状态:–
选择: –
状态转移函数:当前数值为前两个数的和

package com.learn.science.algorithm.zhijian;

import io.swagger.models.auth.In;

import java.util.HashMap;
import java.util.Map;

/**
 * @author kaka
 * @date 2021/2/7
 */
public class DPZ101fib {

    /**
     剑指 Offer 10- I. 斐波那契数列
     写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下:
     F(0) = 0,   F(1) = 1
     F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
     斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
     答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
     示例 1:
     输入:n = 2
     输出:1
     示例 2:
     输入:n = 5
     输出:5
     提示:0 <= n <= 100

     1 1 2 3 5 8 13 ... 
     */
    // 方法一:递归暴力请求,效率低下(原因:大量重复计算子问题)
    public static int fib_dg(int n) {
        if (n == 0 || n == 1) {
            return n;
        }
        return fib_dg(n - 1) + fib_dg(n -2);
    }
    // 方法二:带有备忘录的求解
    // 思想:将重复计算优化掉
    // 使用备忘录,将结果子问题记录,解决问题先去查是否处理过子问题,如果处理过直接拿结果,如果没有在处理,处理完存入备忘录;
    public static int fib_bwl(int n) {
        if (n < 1) {
            return 0;
        }
        // 备忘录全初始化为 0
        Map<Integer, Integer> memo = new HashMap<>();
        // 进行带备忘录的递归
        return fib_bwl_dg(memo, n);
    }
    public static int fib_bwl_dg(Map<Integer, Integer> memo, int n) {
        // base case
        if (n == 1 || n == 2) {
            return 1;
        }
        // 已经计算过
        if (memo.get(n) != null) {
            return memo.get(n);
        }
        // 没计算过计算
        int result = fib_bwl_dg(memo, n - 1) + fib_bwl_dg(memo, n - 2);
        // 存储备忘录
        memo.put(n, result);

        return memo.get(n);
    }
    // 方法三:dp数组的迭代解法
    // 思想:有了上面的备忘录解法,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算
    public static int fib_dp(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return 1;
        }
        // dp初始化
        Map<Integer, Integer> dp = new HashMap<>();
       // base case
        dp.put(1, 1);
        dp.put(2, 1);
        for (int i = 3; i <= n; i ++) {
            dp.put(i, dp.get(i - 1) + dp.get(i - 2));
        }
        return dp.get(n);
    }

    // 方法四:基于dp的状态压缩
    // 因为此题不是最值问题,不需要记录全部状态,只需要记录前两个状态即可;所以可以将状态记录的DPtable压缩;
    public static int fid_ztys(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return 1;
        }
        int per1 = 1, per2 = 1; // 前面两个值,不需要全部的状态值,这个只需要记录两个状态值。
        int sum = 0;
        for (int i = 3; i <= n; i ++) {
            sum = per1 + per2;
            per1 = per2;
            per2 = sum;
        }
        return sum;
    }

    public static int fib(int n) {
        int a = 0, b = 1, sum;
        for (int i = 0; i < n; i ++) {
            // sum = a + b;
            sum = (a + b)%1000000007;
            a = b;
            b = sum;
        }
        return a;
    }

    public static void main(String[] args) {
        System.out.println(fid_ztys(10));

    }
}

实战二、凑零钱问题–>解决最优子结构

二、凑零钱问题–>解决最优子结构
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:
// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);
比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
你认为计算机应该如何解决这个问题?显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

分析动态规划的条件:
base case:这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。
状态: amount
选择: 硬币种类,目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
状态转移函数:动态规划又做自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。
所以我们可以这样定义 dp 函数:
dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量。

package com.learn.science.algorithm.zhijian;

import io.swagger.models.auth.In;

import java.util.HashMap;
import java.util.Map;

/**
 * @author kaka
 * @date 2021/2/9
 */
public class DPcoinChange {

    /**
     先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再
     给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。
     算法的函数签名如下:
     // coins 中是可选硬币面值,amount 是目标金额
     int coinChange(int[] coins, int amount);
     比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
     你认为计算机应该如何解决这个问题?显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

     1,暴力递归
        最优子结构,子问题必须互相独立;
     原问题:要考总分最高分;
     子问题:每一门课都考最高分,语文,数据,英语都考最高。  子问题不互相影响,考语文不会影响我数据和英语的分数;
     原问题:给定金额找出用最少多少枚硬币;
     子问题,米格
     */
    /**
    // coins 中是可选硬币面值:List[int]= 1 2 5 三种面值的硬币
    // amount 是目标金额:int amount=11 总金额
    // 伪码框架
    int coinChange(int[] coins, int amount) {
        // 定义凑出金额n,至少要dp(n)个硬币
        def dn(n) {
            // 做选择,选择需要硬币最少的那个结果
            for (int coin : coins) {
                res = min(res, 1 + dp(n - coin));
                return res;
            }
        }
        // 题目要求的最终结果是 dp(amount)
        return dp(amout);
    }
    */
    /**
     如何列出正确的状态转移方程?
     1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。
     2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。
     3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
     4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。
     // 所以我们可以这样定义 dp 函数:!!!!!!!!!!!! 最难的是找到状态转移函数
     // dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量。
     */
    // 方法一:暴力递归
    public static int coinChange_dg(int[] coins, int amount) {
        // base case
        if (amount < 0) {
            return -1;
        }
        if (amount == 0) {
            return 0;
        }
        // 状态:amount
        // 选择:选择硬币,导致状态变化
        int cnums = Integer.MAX_VALUE; // 求最小值,设置为无穷大
        for (int coin : coins) {
            // 子问题选择硬币
            int subproblem = coinChange_dg(coins, amount - coin);
            // 子问题无解跳过
            if (subproblem == -1) {
                continue;
            }
            // 有解看哪个小
            cnums = Math.min(cnums, subproblem + 1);
        }
        if (cnums == Integer.MAX_VALUE) {
            cnums = -1;
        }
        return cnums;
    }

    // 方法二:带有备忘录的求解
    // 思想:将重复计算优化掉
    // 使用备忘录,将结果子问题记录,解决问题先去查是否处理过子问题,如果处理过直接拿结果,如果没有在处理,处理完存入备忘录;
    public static int coinChange_bwl(int[] coins, int amount) {
        if (amount < 0) {
            return -1;
        }
        if (amount == 0) {
            return 0;
        }
        // 初始化备忘录 : key:状态金额,value:硬币数
        Map<Integer, Integer> map = new HashMap<>();
        // 找最小硬币数
        return coinChange_bwl_dg(coins, amount, map);
    }
    public static int coinChange_bwl_dg(int[] coins, int amount, Map<Integer, Integer> map) {
        // base case
        if (amount < 0) {
            return -1;
        }
        if (amount == 0) {
            return 0;
        }
        // 备忘录查找
        if (map.get(amount) != null) {
            return map.get(amount);
        }
        int cnums = Integer.MAX_VALUE;
        // 没有,进行选择计算
        for (int coin : coins) {
            // 子问题
            int sub_num = coinChange_bwl_dg(coins, amount - coin, map);
            // 无解
            if (sub_num == -1) {
                continue;
            }
            cnums = Math.min(cnums, sub_num + 1);
        }
        // 记录备忘录
        map.put(amount, cnums == Integer.MAX_VALUE ? -1 : cnums);

        return coinChange_bwl_dg(coins, amount, map);
    }

    // 方法三:dp数组的迭代解法
    // 思想:有了上面的备忘录解法,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算
    public static int coinChange_dp(int[] coins, int amount) {
        if (amount < 0) {
            return -1;
        }
        if (amount == 0) {
            return 0;
        }
        // 定义dp数组
        Map<Integer, Integer> map = new HashMap<>();
        return coinChange_dp_dg(coins, amount, map);
    }
    public static int coinChange_dp_dg(int[] coins, int amount, Map<Integer, Integer> map) {
        // base case
        map.put(0, 0);
        // 外层 for 循环在遍历所有状态的所有取值
        for (int i = 1; i <= amount; i ++) {
            // 内层 for 循环在求是所有选择的最小值
            for (int coin : coins) {
                // 子问题无解,跳过
                if (i - coin < 0) {
                    continue;
                }
                // 子问题解
                int sub_num = coinChange_dp_dg(coins, i - coin, map);
                sub_num = map.get(i) == null ? sub_num + 1 : Math.min(map.get(i), sub_num + 1);
                // 存储dp
                map.put(i, sub_num);
            }
        }
        return map.get(amount) == null ? -1 : map.get(amount);
    }

    public static void main(String[] args) {
        System.out.println(coinChange_dg(new int[]{1,2,5}, 11));
        System.out.println(coinChange_bwl(new int[]{1,2,5}, 11));
        System.out.println(coinChange_dp(new int[]{1,2,5}, 11));

    }

}

读书笔记,学习笔记。

上一篇:Swift 枚举


下一篇:系统学习消息队列分享(九) 如何使用异步设计提升系统性能?