你可能需要的算法思想——动态规划

一、动态规划

动态规划的核心是:把原问题拆成若干更小的子问题,并利用这些子问题的答案推出原问题的答案。

分析一道动态规划题,通常都要先回答下面几个问题:

1.状态是什么

dp[i] 或 dp[i][j] 表示什么。

2.状态怎么转移

当前状态由哪些更小的状态推出来。

3.初始条件是什么

哪些最小规模的子问题可以直接得到答案。

4.遍历顺序是什么

先算谁,后算谁,才能保证转移时依赖项已经求出。

爬楼梯 这道题为例,每次可以爬 1 阶或 2 阶,问到第 n 阶有多少种方法。

设 dp[i] 表示到达第 i 阶的方法数。那么到第 i 阶,最后一步只有两种情况:

从第 i-1 阶走 1 步上来

从第 i-2 阶走 2 步上来

如果最后一步是从第 i-1 阶上来,前面部分就是到第 i-1 阶的方法数,到第 i-1 阶的每一种走法,后面再走 1 步,都能变成一种到第 i 阶的走法。同样的到第 i-2 阶的每一种走法,后面再走 2 步,也都能变成一种到第 i 阶的走法

所以有转移:

python 复制代码
dp[i] = dp[i-1] + dp[i-2]

这就是动态规划的思路,把原问题可以拆成规模更小的同类子问题,并将子问题的答案保存下来重复使用,一步步推出答案。


二、从递归到DP

很多动态规划题,最自然的起点其实不是直接写 dp 数组,而是先写出递归。因为递归更贴近人的直觉:先想"当前问题怎么拆成更小的问题",再逐步优化。

这一过程通常可以分为三个阶段:

1.暴力递归:直接按照问题定义去拆分

2.记忆化搜索:在递归基础上加缓存,避免重复计算

3.自底向上的动态规划:把递归关系改写成循环,按顺序求解所有子问题

用同一道题来贯穿这三个阶段,例子:零钱兑换

给定一个整数数组 coins,表示不同面额的硬币;再给定一个整数 amount,表示总金额。每种硬币可以使用无限次,问凑成总金额 amount 所需的最少硬币个数。如果无法凑出,返回 -1。

2.1 暴力递归

如果要凑出金额 amount,那么第一步可以选择任意一种硬币。

假设当前选择了面额 coin,那么问题就变成:凑出 amount - coin 最少需要多少枚硬币。

它的递归关系就是:

python 复制代码
dfs(rem) = min(dfs(rem - coin) + 1)

其中 coin 遍历所有硬币面额。

递归终止条件有两个:

如果 rem == 0,说明刚好凑出,返回 0

如果 rem < 0,说明当前选择无效,返回一个不合法值

代码如下:

python 复制代码
class Solution(object):
    def coinChange(self, coins, amount):
        def dfs(rem):
            if rem == 0:
                return 0
            if rem < 0:
                return float('inf')

            ans = float('inf')
            for coin in coins:
                ans = min(ans, dfs(rem - coin) + 1)
            return ans

        res = dfs(amount)
        return -1 if res == float('inf') else res

它的问题是会重复计算大量相同的子问题。

例如在求 dfs(11) 时,可能会递归到 dfs(10)、dfs(9)、dfs(6);而在求 dfs(10) 时,又会继续递归到 dfs(9)、dfs(8)、dfs(5)。

这样 dfs(9)、dfs(8) 等子问题会被反复计算很多次。效率很低


2.2 记忆化搜索

既然递归中存在大量重复子问题,一个自然的优化就是:每个子问题只算一次,算完之后把结果存起来,后面直接复用。

只需要增加一个缓存结构,例如字典 memo,用来记录金额 rem 的答案已经算过了。如果下次再遇到 dfs(rem),就不用重新递归,直接返回缓存结果。

代码如下:

python 复制代码
class Solution(object):
    def coinChange(self, coins, amount):
        memo = {}

        def dfs(rem):
            if rem == 0:
                return 0
            if rem < 0:
                return float('inf')
            if rem in memo:
                return memo[rem]

            ans = float('inf')
            for coin in coins:
                ans = min(ans, dfs(rem - coin) + 1)

            memo[rem] = ans
            return ans

        res = dfs(amount)
        return -1 if res == float('inf') else res

2.3 自底向上的动态规划

在记忆化搜索里,子问题的答案会被重复利用,每个状态只需要算一次。这时就可以进一步把递归改写成循环,也就是自底向上的动态规划。

状态定义:设 dp[i] 表示凑出金额 i 所需的最少硬币数。

枚举最后一枚使用的硬币 coin,前一步所需硬币数就是 dp[i - coin] + 1

得到转移

python 复制代码
dp[i] = min(dp[i], dp[i - coin] + 1)

初始值:

dp[0] = 0,因为凑出金额 0 不需要任何硬币

代码如下:

python 复制代码
class Solution(object):
    def coinChange(self, coins, amount):
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0

        for i in range(1, amount + 1):
            for coin in coins:
                if i - coin >= 0:
                    dp[i] = min(dp[i], dp[i - coin] + 1)

        return -1 if dp[amount] == float('inf') else dp[amount]

记忆化搜索是从大问题出发,递归地去求更小的问题,需要时才计算某个状态;动态规划是从最小状态出发,按顺序把所有状态都填出来,最后直接得到答案


三、例子

3.1 leetCode 70. 爬楼梯

定义状态:

dp[i] 表示 爬到第 i 阶楼梯,一共有多少种不同的方法。

因为每次只能爬 1 阶或者 2 阶,所以到第 i 阶,最后一步只可能在i-1 阶或i-2阶

python 复制代码
dp[i] = dp[i-1] + dp[i-2]

初始条件就是:

python 复制代码
dp[1] = 1

dp[2] = 2

从小到大遍历,从 3 到 n。

代码为

python 复制代码
class Solution(object):
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n == 1:
            return 1
        if n == 2:
            return 2

        dp = [0] * (n + 1)
        dp[1] = 1
        dp[2] = 2

        for i in range(3, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]

        return dp[n]

3.2 LeetCode 198. 打家劫舍

我们要求的是:从前若干间房子里,在不偷相邻房子的前提下,最多能偷多少钱。

定义:dp[i] 表示 偷前 i 间房屋时,能够偷到的最高金额。

现在最核心的问题是:当处理前 i 间房时,第 i 间房到底偷不偷?可以分两种情况

1. 不偷第 i 间房,最多能偷的就是前 i-1 间房的最优结果,即dp[i-1]

2. 偷第 i 间房,那第 i-1 间就绝对不能偷。最多是"前 i-2 间房"的最优结果,再加上第 i 间房的钱。dp[i-2] + nums[i-1]

那么,第 i 间房偷还是不偷,取更大的那个:

python 复制代码
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])

初始条件

python 复制代码
dp[0] = 0
dp[1] = nums[0]

遍历顺序是从2 到 n

python 复制代码
class Solution(object):
    def rob(self, nums):
        n = len(nums)
        
        if n == 0:
            return 0
        if n == 1:
            return nums[0]
        
        dp = [0] * (n + 1)
        dp[0] = 0
        dp[1] = nums[0]
        
        for i in range(2, n + 1):
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])
        
        return dp[n]

3.3 LeetCode 300. 最长递增子序列

题目要找的是:最长严格递增子序列的长度

定义状态 dp[i] 表示以 nums[i] 这个元素结尾的最长严格递增子序列长度。

最后整个数组的最长递增子序列,不一定正好以最后一个元素结尾,所以最终答案是:

python 复制代码
max(dp)

现在来想怎么求 dp[i],也就是以 nums[i] 结尾 的最长递增子序列长度是多少?

既然要以 nums[i] 结尾,那它前面那个元素一定来自某个位置 j,满足:

j < i,因为前面的元素才能接到后面

nums[j] < nums[i],因为要严格递增

所以:

如果 nums[j] < nums[i],那么dp[i] = dp[j] + 1

但前面的 j 可能有很多个,我们当然要选能让长度最大的那个。

python 复制代码
dp[i] = max(dp[i], dp[j] + 1)

初始条件是:dp[i] = 1,默认每个位置都至少能单独成一个长度为 1 的序列。

遍历顺序

外层:i 从 0 到 n-1

内层:j 从 0 到 i-1

python 复制代码
class Solution(object):
    def lengthOfLIS(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        n = len(nums)
        dp = [1] * n

        for i in range(n):
            for j in range(i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)

        return max(dp)

3.4 LeetCode 152. 乘积最大子数组

我们要求的是:以当前位置结尾的连续子数组,乘积最大是多少。但这题只记一个最大值不够,因为负数会把最小值翻成最大值。

所以要定义两个状态:

max_dp[i] 表示 以 nums[i] 结尾的连续子数组中的最大乘积

min_dp[i] 表示 以 nums[i] 结尾的连续子数组中的最小乘积

假设当前数是:nums[i],以它结尾的连续子数组,只有三种可能:

第一种,只选它自己 即从当前位置重新开始,乘积就是:nums[i]

第二种,把它接到前一个"最大乘积子数组"后面,乘积是:max_dp[i-1] * nums[i]

第三种,把它接到前一个"最小乘积子数组"后面,乘积是:min_dp[i-1] * nums[i]

因此:

python 复制代码
max_dp[i] = max(nums[i], max_dp[i-1] * nums[i], min_dp[i-1] * nums[i])
min_dp[i] = min(nums[i], max_dp[i-1] * nums[i], min_dp[i-1] * nums[i])

当 i = 0 时,只有一个数。以 nums[0] 结尾的连续子数组,只能是它自己。

python 复制代码
max_dp[0] = nums[0]
min_dp[0] = nums[0]

代码为:

python 复制代码
class Solution(object):
    def maxProduct(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        n = len(nums)
        max_dp = [0] * n
        min_dp = [0] * n

        max_dp[0] = nums[0]
        min_dp[0] = nums[0]
        ans = nums[0]

        for i in range(1, n):
            max_dp[i] = max(nums[i], max_dp[i - 1] * nums[i], min_dp[i - 1] * nums[i])
            min_dp[i] = min(nums[i], max_dp[i - 1] * nums[i], min_dp[i - 1] * nums[i])
            ans = max(ans, max_dp[i])

        return ans

3.5 LeetCode 72. 编辑距离

定义状态

dp[i][j] 表示把 word1 的前 i 个字符,变成 word2 的前 j 个字符,最少需要多少步。

这种定义下的初始值,0表示空字符串,则有

python 复制代码
dp[0][0] = 0
dp[i][0] = i
dp[0][j] = j

把 word1[:i] 变成 word2[:j] 要看最后一个字符:word1[i - 1]、word2[j - 1]

如果最后一个字符相同

python 复制代码
word1[i - 1] == word2[j - 1]

问题就变成:把前面的部分 word1[:i-1] 变成 word2[:j-1]

python 复制代码
dp[i][j] = dp[i - 1][j - 1]

如果最后一个字符不同

那就要操作一次,有三种操作

1. 删除 word1[i - 1]

相当于先把 word1[:i-1] 变成 word2[:j],再加一步删除操作

python 复制代码
dp[i][j] = dp[i - 1][j] + 1

2. 插入word2[j-1]

先把 word1[:i] 变成 word2[:j-1],最后插入

python 复制代码
dp[i][j] = dp[i][j - 1] + 1

3. 把 word1[i - 1] 直接替换成 word2[j - 1]

python 复制代码
dp[i][j] = dp[i - 1][j - 1] + 1

取三种操作中最小的,完整的转移方程为

python 复制代码
if word1[i - 1] == word2[j - 1]:
    dp[i][j] = dp[i - 1][j - 1]
else:
    dp[i][j] = min(
        dp[i - 1][j],      # 删除
        dp[i][j - 1],      # 插入
        dp[i - 1][j - 1]   # 替换
) + 1

完整代码

python 复制代码
class Solution(object):
    def minDistance(self, word1, word2):
        """
        :type word1: str
        :type word2: str
        :rtype: int
        """
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(m + 1):
            dp[i][0] = i

        for j in range(n + 1):
            dp[0][j] = j

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1

        return dp[m][n]
相关推荐
唯创知音2 小时前
WTK6900FC鼾声识别芯片:基于DNN-HMM算法的高性能鼾声识别检测处理方案
人工智能·算法·dnn·鼾声识别芯片·鼾声检测芯片
Thomas.Sir2 小时前
第七章:RAG知识库开发之【RAG开源应用完全解析:从RAGFlow到Dify的实战指南】
人工智能·python·开源·fastgpt·dify·ragflow
Tisfy2 小时前
LeetCode 3474.字典序最小的生成字符串:暴力填充
算法·leetcode·字符串·题解
deephub2 小时前
不依赖对话日志检测Prompt注入,一套隐私优先的实现方案
人工智能·python·prompt·大语言模型
Alicx.2 小时前
map容器是个好东西
数据结构·算法·蓝桥杯
郝学胜-神的一滴2 小时前
张量维度操控心法:从reshape到升维降维,吃透PyTorch形状操作的底层逻辑
人工智能·pytorch·python·深度学习·程序人生·算法·机器学习
老虎06272 小时前
LeetCode热题100 刷题笔记(第五天)多维动态规划(中心扩展法) 「 最长回文子串」
笔记·leetcode·动态规划
王者鳜錸3 小时前
闲鱼商品自动发布实战:基于Java实现API轮询与批量上架
java·开发语言·python·商品自动发布
源码之家3 小时前
计算机毕业设计:汽车数据可视化分析系统 Django框架 Scrapy爬虫 可视化 数据分析 大数据 大模型 机器学习(建议收藏)✅
大数据·python·信息可视化·flask·汽车·课程设计·美食