算法——动态规划

本文详细介绍了动态规划的概念、理论基础、解决步骤以及在实际问题中的应用,包括最大子数组和和凑零钱问题的解决方案,同时讨论了动态规划与备忘录、迭代解法的关系,以及如何利用动态规划优化最短路径算法等。
摘要由CSDN通过智能技术生成

算法之动态规划


前言

借助解决实际代码问题来理解动态规划!
对于可以用动态规划求解的问题可以使用暴力求解!——穷举出所有可能的结果!时间复杂度为指数级别
有种说法:动态规划利用空间换时间,因为有利用记忆解决的
基本思想:自底向上解决问题,从最简单的情况出发。将大问题分解成一个一个小问题,解决小问题,大问题自然就解决了
动态规划的应用:最短路径(弗洛伊德算法)、库存管理、资源分配、设备更新、排序、装载

1.1 相关定义&理论

定义&理解:求解决策过程最优化的过程

1、动态规划的基本结构SRTBOT

  • 子问题:状态
  • 关系:状态转移
  • 子问题之间的依赖和递归调用是该有向无环图的一个拓扑排序:动态规划对状态空间(所有子问题)的遍历构成一张有向无环图
  • 最原始的情况:子问题的初始值
  • 我们的原始问题:判断是否与子问题一致?
  • 时间复杂度

2、利用动态规划求解问题步骤:找到状态转移方程,即n得不同情况,像fib这样式子是什么?自变量?因变量?根据原问题来确定。

  • (1) 定义状态(子问题):dp[i]表示第i个子问题
  • (2) 状态转移方程(子问题之间的联系):法一分情况考虑;法二直接几种情况总和考虑(如求最大值就取这几种情况的最大值)
    • 「状态转移方程」:例如f(n) 的状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移。所有解法(暴力、备忘录、动态规划)例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。它是解决问题的核心,其实状态转移方程直接代表着暴力解法。
    • 「状态」:原问题和子问题中会变化的变量。即自变量x;「选择」:导致「状态」产生变化的行为。即因变量y;dp数组/函数的含义。
  • (3) 子问题初始值:能够显而易见得到的子问题的值
  • (4) 输出:组合所有子问题的解从而获得原问题的解。判断:问的问题(原始问题) ?= 子问题,因为对于简单的动态规划问题,问的问题就可以设计成为子问题。但如果不一致,则不能直接将最后一个状态返回回去。
  • (5) 是否优化空间.

3、如何利用动态规划求解问题?

  • 自顶向下:利用备忘录实现递归:存在大量重复计算的子问题时,可以使用记忆法。比如最短路径查找,1→5,可能会查找多次,我们是为了找最短的,比如之前已经查找过了得到最短(已经记忆的肯定是最短的,因为通过递归我们得到的就是最短的),后面再用的话可以直接使用我们已经保存的最短路径,就无需重复计算了而且还未必是最短的。
  • 自底向上:脱离递归由循环迭代完成计算。由边界条件求出fib(2)=fib(0)+fib(1),接着索引i从2依次递增到n,根据递归式使用循环,而非递归函数实现求解斐波那契函数。即把备忘录独立为一张表,在这张表上完成自底向上的推算!
    自底向上实现递归的本质就是填表(动态规划表)。每一个节点表示一个子问题,该子问题要么依赖于前一个结点,要么依赖于前两个节点。

4、对动态规划的理解:
动态规划对状态空间(所有子问题)的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。
有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。

5、补充:
无后效性:为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。

1.2 体会寻找子问题:最大子数组和

1、问题描述:一个整数数组 nums ,找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。其中子数组是数组中的一个连续部分。
2、解决
子问题中的自变量:以…结尾的连续数组;因变量:数组和。子问题中:因为输入的不同连续数组导致了和的改变。存在最优子结构:任意连续数组的和都是最大的。
最优子结构:比如输入连续子数组的和最大。那么其最优子结构如输入连续数组(以数组中序号0/1/2/3/4开头的连续子数组)的时候,其和也是最大的。
无论输入的是以哪个元素为结尾的连续子数组,在这过程中的该子数组的和都是最大的即最优解。

1、问题分析:对于输入数组:[-2,1,-3,4,-1,2,1,-5,4]

  • 初步定义子问题:经过输入数组的某个数(如-2)的连续子数组的最大和是多少?——这些子问题之间的联系不易看出,即子问题的描述还有不确定的地方(有后效性)
  • 不确定性的解决方法:将该数(如-2)定义成连续子数组的最后一个元素。因为不确定该数究竟是连续数组的第几个元素,因此这里将输入序列的前缀当成是一个子问题
  • 重新定义子问题:以输入数组的某个数(如-2)结尾的连续子数组的最大和是多少?——子问题之间有联系

2、动态规划解题步骤

  • (1) 定义状态(子问题):dp[i]表示以nums[i]结尾的连续子数组的最大和
  • (2) 状态转移方程(子问题之间的联系):法一分情况考虑:①dp[i-1]<=0得到dp[i]=nums[i]②dp[i-1]>0得到dp[i]=dp[i-1]+nums[i];
    法二取这几种情况的最大值max{dp[i-1]+nums[i],nums[i]}
  • (3) 子问题初始值:dp[0]=nums[0],该子问题只有一个数一定以nums[0]结尾。
  • (4) 输出:这里的状态定义不是题目中的问题的定义,因此不能直接将最后一个状态返回回去,如最后一个状态是以该数组的最后一个数结尾的连续数组的最大和是多少?这显然是不行的!
    这个问题的输出需要把所有的dp[0]、dp[1]…dp[n-1]都看一遍,取最大值
  • (5) 优化空间
class Solution {
    public int maxSubArray(int[] nums) {
        //1、定义状态(子问题)
        int[] dp = new int[nums.length];//dp[i]表示以nums[i]结尾的连续子数组的最大和
        //3、子问题的初始值
        dp[0] = nums[0];
        //2、子问题之间的关系
        for(int i = 1; i < nums.length; i++){
            if(dp[i-1]<=0){
                dp[i] = nums[i];
            }else{
                dp[i] = dp[i-1]+nums[i];
            } 
        }
        //查看各个子问题的解
        for(int i = 0; i < dp.length; i++){
            System.out.print(dp[i] + " ");
        }
        /** 最简单的:找数组dp中的最大值也可以视为一种动态规划:
        * 1、定义状态(子问题):第1个是最大值;第2个是最大值;...;第i-1个是最大值
        * 2、子问题之间的关系:当前比前一个大就是当前,比他小就是前一个
        * 3、子问题初始值:第1个是最大值
        * 
        */
        int res = dp[0];
        for(int i = 1; i < dp.length; i++){
            if(dp[i] > res){
                res = dp[i];
            }
        }
        return res;
    }
}

1.3 体会“备忘录”到“迭代解法”:fib函数(重叠子问题的消除方式)

说明:斐波那契数列没有求最值,所以严格来说不是动态规划问题
动态规划问题的一般形式就是求最值。一种最优化方法,比如求最长递增子序列、最小编辑距离等等。
求解动态规划的核心问题是穷举。例如求最值:把所有可行的答案穷举出来,然后在其中找最值。
动态规划的穷举:动态规划三要素:重叠子问题、最优子结构、状态转移方程

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

动态规划思维框架:

  • 明确 base case:
  • 明确「状态」:原问题和子问题中会变化的变量。即自变量x
  • 明确「选择」:导致「状态」产生变化的行为。即因变量y
    - 定义 dp 数组/函数的含义。

动态规划的最优化过程

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

解法一:暴力递归

递归树:递归问题→递归树,便于分析算法复杂度=子问题个数*解决一个子问题需要的时间
如下递归树理解:想要计算原问题 f(20),就得先计算出子问题 f(19) 和 f(18),然后要计算 f(19),就要先算出子问题 f(18) 和 f(17),以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果,递归树不再向下生长了。

fib的递归树

暴力递归:斐波那契的数学形式就是递归的
算法复杂度:子问题个数(递归树中节点的总数O(2^n))× 解决一个子问题需要的时间(O(1))= O(2^n) 。指数级别——爆炸

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

暴力递归算法低效的原因:存在大量【重复计算】,比如 f(18) 被计算了两次,并且以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算。这就是动态规划问题的第一个性质:重叠子问题。

解法二:带备忘录的递归解法
造一个「备忘录」解决暴力递归的重复计算问题,将每次算出某个子问题的答案先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

递归树——带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

带备忘录的递归树
备忘录自顶向下

带备忘录的递归解法
算法复杂度:子问题个数(子问题就是 f(1), f(2), f(3) … f(20)即图中节点的总数=输入规模n = 20成正比即O(n)) × 解决一个子问题需要的时间(没有循环即为O(1))。所以该算法的复杂度为O(n)。

int fib(int N) {
    if (N < 1) return 0;
    // 备忘录全初始化为 0
    vector<int> memo(N + 1, 0);
    // 进行带备忘录的递归
    return helper(memo, N);
}
 
int helper(vector<int>& memo, int n) {
    // base case 
    if (n == 1 || n == 2) return 1;
    // 已经计算过
    if (memo[n] != 0) return memo[n];
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

总结:带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

  • 「自顶向下」:如所画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案。
  • 「自底向上」:直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

解法三:动态规划dp的迭代解法
把「备忘录」独立出来成为一张表,可以叫做 DP table,在这张表上完成「自底向上」的推算!

自底向上

自底向上图解

动态规划迭代解法

int fib(int N) {
    vector<int> dp(N + 1, 0);
    // base case
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

细节优化:根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):
状态压缩:如果每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,上述例子就相当于把DP table 的大小从 n 缩小到 2。一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。

1.4 体会最优子结构:凑零钱问题

最优子结构:比如凑出总金额为11的硬币数量最少,那么其最优子结构如凑出目标金额为7/8/1的时候,使用的硬币数量也是最少的。
1、问题:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。
输入举例:amount = 11, coins = {1,2,5}

// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);

2、解决问题
自变量:目标金额;因变量:硬币的数量。子问题中:因为总金额改变了导致硬币的数量改变。存在最优结构:各个阶段使得硬币数量最少的最优的金额,全部合起来得到11,此时硬币的数量也是最少的!

  • 定义状态(子问题):dp[i]:输入一个目标金额 i ,返回凑出目标金额 i 的最少硬币数量!
  • 定义子问题之间的联系:dp[i] = min(dp[i],dp[i-1]+1)
int coinChange(int[] coins, int amount) {
	int[] dp = new dp[coins.length];
	dp[0] = 0;
	for (int i = 0; i <= amount; i++) {
		for(int j= 0; j < coins.length; j++){
			if (i - coin < 0) continue;//子问题无解跳过:注意硬币面额>目标金额要去掉!
			dp[i] = Math.min(dp[i], dp[i-1]+1);//要么是当前目标金额 i 的硬币数量,要么是前一个目标金额为i-1的硬币数量+1
		}
	}
	return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
  • 39
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值