算法-谈谈我对动态规划的理解

本文深入探讨了动态规划的三大特性:重叠子问题、最优子结构和无后效性,并通过实例解析如何判断问题是否满足这些特性。动态规划的求解通常包括明确状态、定义状态转移方程、确定basecase以及选择遍历方向。文中以斐波那契数列为案例,展示了动态规划的两种形式:记忆化搜索和自底向上迭代,并提供了相应的代码实现。动态规划是解决具有最优子结构和重叠子问题的有效方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

动态规划的三大特性

一个问题只有同时满足以下三个特性,才适合用动态规划进行求解

  • 重叠子问题
  • 最优子结构
  • 无后效性

重复子问题:在计算的过程中,有一些问题会重复计算,必须记住结果。没有重复子问题的话,可以「分而治之」求解;

最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。
怎么才能推出?要求子问题之间必须互相独立,否则无法推出。

无后效性:我们转移某个状态需要用到某个值,但是并不关心该值是如何而来的。

怎么样才满足最优子结构

「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。

那怎么样才算满足最优子结构呢?有没有一个范式去判断,有的,下面举例理解:

1、假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在要计算全校最高的成绩——符合最优子结构,每个班的最高成绩相互独立。

2、假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差。
——不符合最优子结构,因为你没办通过每个班的最大分数差推出全校的最大分数差,即:没办法通过子问题的最优值推出规模更大的问题的最优值
为什么推不出?
因为全校的最高分和最低分可能分布在两个班级,那么子问题就不独立了。

总结:想满足最优子结构,子问题之间必须互相独立

怎么判断是否有重叠子问题

简单粗暴的方式就是画图,把递归树画出来,看看有没有重复的节点,有重复节点就说明存在重叠子问题。

比如下面我以斐波那契数列为例,画出其递归树

斐波那契数列求解伪代码:
int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

递归树:
在这里插入图片描述
可以看到上面存在大量的类似f(17)的重复节点,我们可以在第一个计算到f(17)时将其数值记录下来,下次再遇到时可以不在重复计算了,也就是剪枝思想。

动态规划问题具体该怎么求解呢

现在已经明确了什么样的问题适合用DP进行求解,下面说下我总结的DP求解的具体步骤:

1、明确「状态」并确定 dp 数组
DP 的状态定义很大程度是靠经验去猜的
确定状态后,由状态确定数组维度:有几个状态,数组就是几维的
比如斐波那契数列问题中,状态只有一个,就是当前数值大小,所以可以定义dp数组为一维的,即dp[i]:表示数值i的斐波那契数列的数值。

2、明确「选择」,根据选择写出状态转移方程
如果我们的状态定义猜对了,基本上「状态转移方程」就是呼之欲出,因此一定程度上,状态转移方程可以反过来验证我们状态定义猜得是否正确.
如果猜了一个状态定义,然后发现无法列出涵盖所有情况的状态转移方程,多半就是状态定义猜错了,赶紧换个思路,而不是去死磕状态转移方程。
比如斐波那契数列问题中,选择是规定好的,所以状态转移方程也是显而易见的

 dp[i] = dp[i - 1] + dp[i - 2]; 

3、由状态转移方程明确 base case,初始化dp数组 。
base case就是一开始就已知的基本情况,比如斐波那契数列问题中就是初始的dp[0]=0,dp[1]=1

4、选择遍历方向,进行迭代求出最终结果
记住两点就行了:

  1. 遍历的过程中,当前状态所需的状态必须是已经计算出来的。
  2. 遍历的终点必须是存储最终结果的那个位置。

举例:
1、比如编辑距离这个经典的问题,我们通过对 dp 数组的定义,确定了 base case 是 dp[..][0]dp[0][..],最终答案是 dp[m][n];而且我们通过状态转移方程知道 dp[i][j] 需要从 dp[i-1][j], dp[i][j-1], dp[i-1][j-1] 转移而来,如下图:
在这里插入图片描述
那么,参考刚才说的两条原则,你该怎么遍历 dp 数组?肯定是正向遍历:

for (int i = 1; i < m; i++)
    for (int j = 1; j < n; j++)
        // 通过 dp[i-1][j], dp[i][j - 1], dp[i-1][j-1]
        // 计算 dp[i][j]

因为,这样每一步迭代的左边、上边、左上边的位置都是之前计算过的,而且最终结束在我们想要的答案 dp[m][n]

2、再举一例,回文子序列问题,我们通过对 dp 数组的定义,确定了 base case 处在中间的对角线,dp[i][j] 需要从 dp[i+1][j], dp[i][j-1], dp[i+1][j-1] 转移而来,想要求的最终答案是 dp[0][n-1],如下图:
在这里插入图片描述

这种情况根据刚才的两个原则,就可以有两种正确的遍历方式:
要么从左至右斜着遍历,要么从下向上从左到右遍历,这样才能保证每次 dp[i][j] 的左边、下边、左下边已经计算完毕,得到正确结果。
在这里插入图片描述

动态规划的两种形式

还是拿斐波拉契数列举例,该问题的求解实际有两种写法,一种自顶向下递归+剪枝,也叫记忆化搜索,一种自底向上迭代,通常我们将这种方式叫做动态规划。

这两种解法的思想是一样的,大部分情况下,效率也基本相同。可以说是DP的两种写法,只是我们通常狭义的只把第二种写法成为DP,而把第一种称为记忆化搜索、

补充:有没有发现记忆化搜索和回溯很像,没错就是很像,都是自顶向下递归,都面临着【做选择】,可以这样理解:记忆化搜索=回溯+剪枝,并且剪枝方式一定得是复用之前的结果从而减少重复计算,不然剪枝效果达不到DP的要求。

  • 自顶向下递归-记忆化搜索
    注意我上面在重叠子问题中画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说f(20),向下逐渐分解规模,直到f(1)和f(2)触底,然后逐层返回答案,这就叫「自顶向下」。

  • 自底向上迭代-动态规划
    反过来,我们直接从最底下,最简单,问题规模最小的f(1)和f(2)开始往上推,直到推到我们想要的答案f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

下面分别写出斐波拉契数列的这两种解法,方便大家进一步的理解DP

暴力递归

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

这样写代码十分低效,为了计算原问题 f(20),产生了大量的重复计算,比如f(1)重复计算了很多很多次。

在这里插入图片描述

记忆化搜索

耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

class Solution {
    int[]memo;
    public int fib(int n) {
        memo=new int[n+1];
        return f(n);
    }

    private int f(int n) {
        if (n < 2) {
            return n;
        }else if(memo[n]!=0){
            return memo[n];
        }
        memo[n]=f(n - 1) + f(n - 2);
        return memo[n];
    }
}

动态规划

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」
在这里插入图片描述
画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table

下面根据我前面总结的dp求解步骤,一步一步进行求解:
1、确定状态
状态只有一个,就是当前数值大小,所以可以定义dp数组为一维的,即dp[i]:表示数值i的斐波那契数列的数值。
2、确定选择,选择是确定的,所以状态转移方程为:dp[i]=dp[i-1]+dp[i-2]
3、由状态转移方程明确 base case,初始化dp数组 。
base case就是一开始就已知的基本情况,比如斐波那契数列问题中就是初始的dp[0]=1,dp[1]=1

4、选择遍历方向,进行迭代求出最终结果
遍历方向从前往后即可

5、代码:

int fib(int N) {
    if (N == 0) return 0;
    if (N == 1) return 1;
   int[] dp=new int[N+1];
    // base case
    dp[0]=0;
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2]; //从3往上计算 自底向上
    return dp[N];
}

实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。

状态压缩优化
当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化把空间复杂度降为 O(1)

int fib(int n) {
    if (n == 2 || n == 1) 
        return 1;
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值