动态规划基础原理与题目说明

动态规划基础原理与题目说明

文章目录

🔗 查看完整专栏(LeetCode基础算法专栏

特别说明:

本文为个人的 LeetCode 刷题与学习笔记,内容仅供学习与交流使用,禁止转载或用于商业用途。需要强调的是,文中的题目解法不一定是最优解(可能存在时间或空间复杂度的进一步优化空间),主要目的是分享个人的解题思路与逻辑实现,仅供参考。 笔记内容为个人理解与总结,可能存在疏漏或偏差,欢迎读者自行甄别并交流探讨。

一、 什么是动态规划(Dynamic Programming)?

一句话定义:

动态规划 = 把一个复杂问题拆成有重叠子问题的最优子结构问题,用"存表"(dp 数组)的方式避免重复计算。

动态规划通常具备三个核心特征

  1. 最优子结构:大问题的最优解,可以由小问题的最优解推导出来。
  2. 重叠子问题:在暴力递归中,很多相同的子问题会被反复计算,DP 通过查表来优化。
  3. 状态转移方程:当前状态可以由之前的状态通过某种固定规则(方程)计算得到。

1.1 动态规划 vs 贪心算法

二者的共通点是都要求问题具备"最优子结构"。但它们的核心决策逻辑完全不同:

  • 贪心算法 :要求局部最优天然能推出全局最优(贪心选择性质)。每一步只选择当前看起来最好的,选完不回头,不后悔
  • 动态规划 :每一步会把前面子问题的最优结果拿过来比较,综合评估后再选择全局最优。
维度 贪心算法 (Greedy) 动态规划 (DP)
做选择 只看当下最优,直接选 综合所有子问题最优,再做选择
回溯 / 比较 从不回头,不比较其他可行方案 会比较所有可能引发当前状态的子方案
核心前提 最优子结构 + 贪心选择性质 最优子结构 + 重叠子问题
时间效率 极快,通常 O ( n ) O(n) O(n) 较慢,常见 O ( n 2 ) , O ( n k ) O(n^2), O(nk) O(n2),O(nk)
适用场景 必须严格满足:局部最优 = 全局最优 更通用,几乎覆盖所有最优化问题

💡 反例演示:凑硬币问题

题目 :用硬币 [1, 3, 4] 凑出金额 6,最少用几枚?

  • 贪心思路(错误) :每次选当前最大的。选 4 → \rightarrow → 剩 2 → \rightarrow → 选 1+1。总共 3 枚 。贪心失败,因为当前最大 ≠ \neq = 全局最优。
  • DP 思路(正确)dp[6] = min(dp[5]+1, dp[3]+1, dp[2]+1) = min(2+1, 1+1, 2+1) = 2。即选 3+3,只需 2 枚

1.2 黄金法则:背包问题的遍历方向

在解决背包问题时,内层循环(容量 j)的正序与倒序决定了物品能被使用多少次:

  • 正序遍历 j j j**(从小到大)** :用于完全背包(物品无限次使用)。因为计算大容量时,会用到刚被当前数字更新过的小容量状态,相当于同一个数字被重复用了多次。
  • 倒序遍历 j j j**(从大到小)** :用于0-1 背包(物品只能用 1 次)。因为计算时,用到的小容量状态还没被当前物品更新过,是纯粹的「上一层历史状态」,严格保证了当前物品只被决策 1 次。

二、 一维基础动态规划

70. 爬楼梯

题目描述

假设你正在爬楼梯。需要 n n n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

解题思路

爬到第 n n n 阶的方案数可由前两阶的方案数推导而来,属于最经典的斐波那契数列型一维 DP。

  • 状态定义dp[i] 为爬到第 i i i 阶的方案数。
  • 状态转移 :最后一步可走 1 阶或 2 阶,故 dp[i] = dp[i-1] + dp[i-2]
  • 边界条件dp[0] = 1dp[1] = 1

核心代码

py 复制代码
class Solution:
    def climbStairs(self, n: int) -> int:
        func = [0] * (n + 1)
        func[0] = 1
        func[1] = 1

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

198. 打家劫舍

题目描述

计算在不触动警报装置(不能连偷相邻房屋)的情况下,一夜之内能够偷窃到的最高金额。

解题思路

  • 状态定义func[i] 表示前 i + 1 i+1 i+1 间房屋能偷窃到的最高金额。
  • 状态转移 :对于第 i i i 间房屋,选择偷(则不能偷 i − 1 i-1 i−1,最高金额为 func[i-2] + nums[i]),选择不偷(最高金额为 func[i-1])。取两者最大值:func[i] = max(func[i-1], func[i-2] + nums[i])

核心代码

py 复制代码
from typing import List

class Solution:
    def rob(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 1:
            return nums[0]

        func = [0] * n
        func[0] = nums[0]
        func[1] = max(nums[0], nums[1])

        for i in range(2, n):
            func[i] = max(func[i-1], func[i-2] + nums[i])
        
        return func[-1]

三、 二维与网格动态规划

118. 杨辉三角

解题思路

利用杨辉三角的核心数学性质:每个非首尾位置的数字等于其上一行相邻两个数字之和。状态转移:fm[i][j] = fm[i-1][j-1] + fm[i-1][j]

核心代码

py 复制代码
from typing import List

class Solution:
    def generate(self, numRows: int) -> List[List[int]]:
        # 初始化,每行首尾皆为1
        fm = [[1] * (i + 1) for i in range(numRows)]

        for i in range(2, numRows):
            for j in range(1, i):
                fm[i][j] = fm[i-1][j-1] + fm[i-1][j]
                
        return fm

62. 不同路径

解题思路

机器人只能向右或向下走。

  • 状态定义fm[i][j] 表示从左上角走到坐标 ( i , j ) (i, j) (i,j) 的总路径数。
  • 边界初始化:第一行、第一列所有位置路径数都为 1(只能一直右/一直下)。
  • 状态转移fm[i][j] = fm[i-1][j] + fm[i][j-1]

核心代码

py 复制代码
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        fm = [[1] * n for _ in range(m)]

        for i in range(1, m):
            for j in range(1, n):
                fm[i][j] = fm[i-1][j] + fm[i][j-1]
        
        return fm[m-1][n-1]

64. 最小路径和

解题思路

类似于"不同路径",只是求极值。fm[i][j] = min(上方路径和, 左方路径和) + 当前格子值

核心代码

py 复制代码
from typing import List

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        fm = [[float('inf')] * n for _ in range(m)]
        fm[0][0] = grid[0][0]

        for j in range(1, n):
            fm[0][j] = fm[0][j-1] + grid[0][j]
        for i in range(1, m):
            fm[i][0] = fm[i-1][0] + grid[i][0]

        for i in range(1, m):
            for j in range(1, n):
                fm[i][j] = min(fm[i-1][j], fm[i][j-1]) + grid[i][j]
        
        return fm[m-1][n-1]

四、 子串与子序列问题

5. 最长回文子串

解题思路

回文串的天然转移特性:如果一个子串首尾字符相同,且去掉首尾后的内部串也是回文,则该子串必是回文。

  • 状态转移dp[i][j] = dp[i+1][j-1] and (s[i] == s[j])
  • 按子串长度 L L L 从小到大枚举,保证状态转移时子串结果已计算过。

核心代码

py 复制代码
class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        if n < 2: return s
        
        fm = [[False] * n for _ in range(n)]
        for i in range(n):
            fm[i][i] = True

        start, max_len = 0, 1 
        
        for L in range(2, n + 1):
            for left in range(n):
                right = left + L - 1
                if right >= n: break
                
                if s[left] != s[right]:
                    fm[left][right] = False
                else:
                    if right - left < 2:
                        fm[left][right] = True
                    else:
                        fm[left][right] = fm[left+1][right-1]
                        
                if fm[left][right] and right - left + 1 > max_len:
                    max_len = right - left + 1
                    start = left
        
        return s[start:start+max_len]

300. 最长递增子序列 (LIS)

解题思路

  • 状态定义func[i] 表示以 nums[i] 结尾的最长严格递增子序列的长度。
  • 状态转移 :对于每个元素 i i i,遍历其之前的所有元素 j j j,只要 nums[i] > nums[j],即可尝试将 i i i 接在 j j j 后面,更新 func[i] = max(func[i], func[j] + 1)

核心代码

py 复制代码
from typing import List

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 0: return 0

        func = [1] * n

        for i in range(1, n):
            for j in range(0, i):
                if nums[i] > nums[j]:
                    func[i] = max(func[i], func[j] + 1)
        
        return max(func)

1143. 最长公共子序列 (LCS)

解题思路

  • 状态转移
    • text1[i-1] == text2[j-1],加入 LCS,dp[i][j] = dp[i-1][j-1] + 1
    • 若不相同,意味着这两个字符不可能同时出现在 LCS 中,取删除两者其一的最大值:dp[i][j] = max(dp[i-1][j], dp[i][j-1])

核心代码

py 复制代码
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        fm = [[0] * (n + 1) for _ in range(m + 1)]

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i-1] == text2[j-1]:
                    fm[i][j] = fm[i-1][j-1] + 1
                else:
                    fm[i][j] = max(fm[i-1][j], fm[i][j-1])
        
        return fm[m][n]

152. 乘积最大子数组

解题思路

由于乘法存在负负得正特性,仅维护最大值会丢失最优解(当前的最小负数乘以一个负数可能变成最大正数)。必须同时维护双状态。

  • max_dp[i] = max(nums[i], max_dp[i-1]*nums[i], min_dp[i-1]*nums[i])

核心代码

py 复制代码
from typing import List

class Solution:
    def maxProduct(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 0: return 0

        max_func = [0] * n
        min_func = [0] * n
        max_func[0] = min_func[0] = nums[0]

        for i in range(1, n):
            max_func[i] = max(nums[i], max_func[i-1] * nums[i], min_func[i-1] * nums[i])
            min_func[i] = min(nums[i], max_func[i-1] * nums[i], min_func[i-1] * nums[i])
            
        return max(max_func)

五、 背包问题系列

416. 分割等和子集 (0-1 背包)

解题思路

问题转化为:给定容量为 sum // 2 的背包,数组元素为物品,每个物品只能用一次(0-1背包),能否恰好装满?

  • 0-1 背包必须倒序遍历容量,保证每个数字只被选择一次。

核心代码

py 复制代码
from typing import List

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        total = sum(nums)
        if total % 2 != 0: return False
        
        target = total // 2
        if max(nums) > target: return False
        
        dp = [False] * (target + 1)
        dp[0] = True

        for num in nums:
            # 01背包,倒序遍历容量
            for j in range(target, num - 1, -1):
                dp[j] = dp[j] or dp[j - num]
        
        return dp[target]

322. 零钱兑换 (完全背包)

解题思路

硬币无限用,属于完全背包 。正序遍历容量。dp[i] = min(dp[i], dp[i-coin] + 1)

核心代码

py 复制代码
from typing import List

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        func = [float('inf')] * (amount + 1)
        func[0] = 0

        for i in range(1, amount + 1):
            for j in coins:
                if i >= j and func[i-j] != float('inf'):
                    func[i] = min(func[i], func[i-j] + 1)
                 
        return func[amount] if func[amount] != float('inf') else -1

279. 完全平方数 (完全背包)

解题思路

与零钱兑换如出一辙,物品变成了 1 , 4 , 9 , 16... 1, 4, 9, 16... 1,4,9,16... 这些完全平方数,由于平方数可以无限重复使用,依然是完全背包问题。

核心代码

py 复制代码
class Solution:
    def numSquares(self, n: int) -> int:
        dp = [float('inf')] * (n + 1)
        dp[0] = 0
        
        for i in range(1, n + 1):
            # 枚举所有小于等于当前 i 的完全平方数 j*j
            j = 1
            while j * j <= i:
                dp[i] = min(dp[i], dp[i - j * j] + 1)
                j += 1
                
        return dp[n]

六、 高阶状态转移

139. 单词拆分

解题思路

  • 状态定义dp[i] 表示前 i 个字符能否被拆分成字典中的单词。
  • 转移方程 :只要存在一个切割点 j,使得 dp[j] == True 并且 s[j:i] 在字典中,则 dp[i] = True

核心代码

py 复制代码
import collections
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        n = len(s)
        hashword = set(wordDict)
        
        func = [False] * (n + 1)
        func[0] = True

        for i in range(1, n + 1):
            for j in range(0, i):
                if func[j] and s[j:i] in hashword:
                    func[i] = True
                    break  # 只要找到一种合法拆分即可
        
        return func[n]

72. 编辑距离

解题思路

编辑距离是机器学习(自然语言处理)评估文本相似度的基石算法。允许操作有插入、删除、替换。

  • 状态定义dp[i][j] 表示 word1i 个字符转换到 word2j 个字符的最少步数。
  • 核心推导
    • 若末尾字符相同 :直接继承,dp[i][j] = dp[i-1][j-1]
    • 若不同,三选一
      1. 插入word1 变到 word2 缺一个尾字符,先转换为 word2j-1 的状态,再插入。 → \rightarrow → dp[i][j-1] + 1
      2. 删除word1 多一个尾字符,先用 word1i-1 的状态转换过去,再把末尾删掉。 → \rightarrow → dp[i-1][j] + 1
      3. 替换 :直接把 word1i 个字符替换成 word2j 个字符。 → \rightarrow → dp[i-1][j-1] + 1

核心代码

py 复制代码
class Solution:
    def minDistance(self, word1: str, word2: str) -> 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][j-1] + 1,    # 插入
                                   dp[i-1][j] + 1,    # 删除
                                   dp[i-1][j-1] + 1)  # 替换

        return dp[m][n]

32. 最长有效括号

解题思路

  • 状态定义dp[i] 表示以 s[i] 结尾的最长有效括号长度。
  • 状态转移 (仅考虑当前是 ) 的情况,因为以 ( 结尾必然是 0):
    • 前一个是 ( :形如 ...()。直接匹配,dp[i] = dp[i-2] + 2
    • 前一个是 ) :形如 ...))。说明前面有个完整的有效括号串,长度为 dp[i-1]。我们需要跳过这串括号,看看它更前面 是不是有个 ( 可以和当前的 ) 配对。
      • 待配对的位置为 idx = i - dp[i-1] - 1
      • 如果 s[idx] == '(',则配对成功:dp[i] = 中间长度(dp[i-1]) + 当前对(2) + 配对前位置的有效长度(dp[idx-1])

核心代码

py 复制代码
class Solution:
    def longestValidParentheses(self, s: str) -> int:
        n = len(s)
        if n < 2: return 0
        
        dp = [0] * n
        max_len = 0
        
        for i in range(1, n):
            if s[i] == ')':
                # 1. 情况一:形如 ()
                if s[i-1] == '(':
                    dp[i] = (dp[i-2] if i >= 2 else 0) + 2
                # 2. 情况二:形如 ))
                else:
                    # 寻找可以和当前 ')' 匹配的 '(' 的索引
                    match_idx = i - dp[i-1] - 1
                    if match_idx >= 0 and s[match_idx] == '(':
                        # 配对成功:内部合法长度 + 当前这2个 + 匹配点之前的合法长度
                        dp[i] = dp[i-1] + 2 + (dp[match_idx-1] if match_idx >= 1 else 0)
                        
                max_len = max(max_len, dp[i])
                
        return max_len
相关推荐
纤纡.1 小时前
从课堂视频转写结构化数据:Python + 讯飞 + 通义千问全流程实战
python·阿里云·语言模型·讯飞
大志出奇迹1 小时前
传输协议为大端,STM32为小端,数据传输的字节序问题
c语言·stm32·单片机·mcu·算法·rtos
闵孚龙1 小时前
Claude Code工具执行编排全解析:权限控制、并发调度、流式执行、中断恢复与AI Agent工程实战
人工智能
龙侠九重天1 小时前
C# 调用 TensorFlow:迁移学习与模型推理实战指南
人工智能·深度学习·机器学习·c#·tensorflow·迁移学习·tensorflow.net
我爱cope1 小时前
【滑动窗口:力扣438找到字符串中所有字母异位词】
算法·leetcode·职场和发展
Lsk_Smion1 小时前
让 CLIP 看懂病灶:TGC-Net 如何用三重校准打通医学图文分割
人工智能·深度学习·计算机视觉
happyprince1 小时前
06-FlagEmbedding 核心算法详解
算法
dhashdoia1 小时前
Claude Code /goal功能深度解析:从自动化编程到目标驱动开发
运维·人工智能·自动化·claude
洛水水1 小时前
【力扣100题】27. 二叉树的最大深度
算法·leetcode·图论