1 打家劫舍系列

1.1 主线

三题共享同一个决策:当前这个位置,选还是不选

  • 选了,就必须跳过与它相邻的位置
  • 不选,就继承邻居的最优解

区别只在"相邻"的拓扑长什么样:

题相邻结构处理办法
198一条链直接线性 DP
213一个环拆成两条链各做一次 198
337一棵树后序遍历,每个节点向上返回一对值

1.2 打家劫舍

状态定义: dp[i] = 考虑区间 [0, i] 的房间时,能偷到的最大金额

注意"考虑"两个字 定义是"前 i 间里随便选",不是"必须偷第 i 间"。所以答案直接是 dp[n-1],不需要打擂台。这跟后面 300/718 那种"必须以 i 结尾"的定义正好是一对反例,可以拿来对照记。

状态转移:

偷第 i 间:   dp[i] = nums[i] + dp[i-2]
不偷第 i 间: dp[i] = dp[i-1]

dp[i] = max(dp[i-1], nums[i] + dp[i-2])

初始化:

dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])

代码(滚动数组):

1class Solution: 
2	def rob(self, nums: List[int]) -> int: 
3		n = len(nums) 
4		if n == 1: 
5			return nums[0] 
6		a, b = nums[0], max(nums[0], nums[1]) 
7		for i in range(2, n): 
8			a, b = b, max(b, a + nums[i]) 
9		return b

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

1.3 打家劫舍 II

思路: 状态定义与转移完全同 198,唯一的新麻烦是首尾相邻。关键观察:首、尾至多只能偷其中一间(也可能两间都不偷)。于是把环拆成两条链,各跑一次 198,取较大者:

  • 情况 A:不考虑最后一间 → [0, n-2]
  • 情况 B:不考虑第一间 → [1, n-1]

213 环形拆解

代码:

 1class Solution: 
 2	def rob(self, nums: List[int]) -> int: 
 3		n = len(nums) 
 4		if n == 1: 
 5			return nums[0] 
 6		if n == 2: 
 7			return max(nums[0], nums[1]) 
 8		
 9		def cal(l, r): 
10			a, b = nums[l], max(nums[l], nums[l + 1]) 
11			for i in range(l + 2, r): 
12				a, b = b, max(b, a + nums[i]) 
13			return b 
14		
15		return max(cal(0, n - 1), cal(1, n))

复杂度:

  • 时间 O(n)(跑两遍)
  • 空间 O(1)

1.4 打家劫舍 III(树形 DP)

状态定义: 每个节点向上返回一个二元组:

  • rob = 偷了该节点时,以它为根的子树能拿到的最大金额
  • skip = 不偷该节点时,以它为根的子树能拿到的最大金额

337 树形 DP

状态转移(后序遍历:先拿左右孩子的 (rob, skip)):

偷node:  rob  = node.val + lskip + rskip
不偷node:skip = max(lrob, lskip) + max(rrob, rskip)

代码:

 1def rob(root) -> int:
 2    def dfs(node) -> tuple[int, int]:
 3        if not node:
 4            return 0, 0                    # 空节点:偷不偷都是 0
 5        lrob, lskip = dfs(node.left)
 6        rrob, rskip = dfs(node.right)
 7        rob = node.val + lskip + rskip
 8        skip = max(lrob, lskip) + max(rrob, rskip)
 9        return rob, skip
10
11    return max(dfs(root))

复杂度:

  • 时间 O(n),每个节点访问一次
  • 空间 O(h),递归栈

2 买卖股票系列

2.1 主线

六道题共享同一个状态骨架:第 i 天结束时,我处在哪个状态。状态无非两类:手上有股票(持有)、手上没股票(不持有)。题目加的每个限制,都只是往这个骨架上再挂一维:

题限制状态数
121只能买卖 1 次2
122不限次数2
123最多 2 次4(+ 1 个"不操作")
188最多 k 次2k(+ 1)
309不限次数 + 冷冻期4
714不限次数 + 手续费2

统一编号后 188 就是母题,其余全是特例: 188 状态编号

一条贯穿全系列的记忆线 买入 = 减 prices[i],卖出 = 加 prices[i],保持 = 继承昨天同一状态。所有转移方程都是这三句话的排列组合。


2.2 买卖股票的最佳时机(只能 1 次)

状态定义:

  • dp[i][0]:第 i 天结束时持有股票的最大现金
  • dp[i][1]:第 i 天结束时不持有股票的最大现金

状态转移:

dp[i][0] = max(dp[i-1][0], -prices[i])              # 保持 / 今天买入
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])  # 保持 / 今天卖出

这题的分水岭在 dp[i][0] 买入写的是 -prices[i],不是 dp[i-1][1] - prices[i]。因为只能交易一次,第一次买入之前的现金必然是 0,不能带着上一轮的利润再买。改成 dp[i-1][1] - prices[i] 就退化成 122

初始化:dp[0][0] = -prices[0],dp[0][1] = 0

代码:

1class Solution: 
2	def maxProfit(self, prices: List[int]) -> int: 
3		n = len(prices) 
4		d0, d1 = -prices[0], 0 
5		for i in range(1, n): 
6			d0, d1 = max(d0, -prices[i]), max(d1, d0 + prices[i]) 
7		return d1

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

2.3 买卖股票 II(不限次数)

状态定义:

  • dp[i][0]:第 i 天结束时持有股票的最大现金
  • dp[i][1]:第 i 天结束时不持有股票的最大现金

状态转移(只有买入那行变了):

dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])   # 可以带着历史利润再买
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])

初始化:dp[0][0] = -prices[0],dp[0][1] = 0

代码:

1class Solution: 
2	def maxProfit(self, prices: List[int]) -> int: 
3		n = len(prices) 
4		d0, d1 = -prices[0], 0 
5		for i in range(1, n): 
6			d0, d1 = max(d0, d1 - prices[i]), max(d1, d0 + prices[i]) 
7		return d1

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

2.4 买卖股票 III(最多 2 次)

状态定义(4 个):

  • dp[i][0]:第 1 次持有  dp[i][1]:第 1 次卖出后不持有
  • dp[i][2]:第 2 次持有  dp[i][3]:第 2 次卖出后不持有

状态转移:

dp[i][0] = max(dp[i-1][0], -prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])
dp[i][2] = max(dp[i-1][2], dp[i-1][1] - prices[i])
dp[i][3] = max(dp[i-1][3], dp[i-1][2] + prices[i])

初始化: 考虑定义

dp[0][0] = -prices[0]     dp[0][1] = 0
dp[0][2] = -prices[0]     dp[0][3] = 0

dp[0][2] = -prices[0] 是什么意思 字面上是"第 0 天完成了买-卖-买三个动作"。现实里这没有收益意义,但作为初值它是合法且不劣的:它保证了第 2 次交易的起点不会被漏掉。DP 初始化只要"不引入非法优势、不遗漏合法路径"就够了,不必句句对应现实动作

代码:

1class Solution: 
2	def maxProfit(self, prices: List[int]) -> int: 
3		n = len(prices) 
4		d0, d1, d2, d3 = -prices[0], 0, -prices[0], 0 
5		for i in range(1, n): 
6			d0, d1, d2, d3 = max(d0, -prices[i]), max(d1, d0 + prices[i]), max(d2, d1 - prices[i]), max(d3, d2 + prices[i]) 
7		return d3

答案取 dp[n-1][3] 就够 不用跟 dp[n-1][1] 打擂台。因为"只做 1 次交易"完全可以被"第 2 次买了又原价卖"模拟出来,dp[i][3] 已经涵盖了交易次数少于 2 的所有情况。

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

2.5 买卖股票 IV(最多 k 次)

状态定义: 把 123 的 4 个状态推广成 2k+1 个,下标 0 … 2k:

  • 0:不操作
  • 奇数 2j-1:第 j 次持有
  • 偶数 2j:第 j 次卖出后不持有

状态转移:

j 为奇数:dp[i][j] = max(dp[i-1][j], dp[i-1][j-1] - prices[i])
j 为偶数:dp[i][j] = max(dp[i-1][j], dp[i-1][j-1] + prices[i])

初始化:第 0 天,所有奇数列 d[2 * j - 1]= -prices[0], 偶数列d[2 * j] = 0

代码:

1class Solution: 
2	def maxProfit(self, k: int, prices: List[int]) -> int: 
3		n = len(prices) 
4		d = [0] + [-prices[0], 0] * k 
5		for i in range(1, n): 
6			for j in range(1, k + 1): 
7				d[2 * j - 1], d[2 * j] = max(d[2 * j - 1], d[2 * j - 2] - prices[i]), max(d[2 * j], d[2 * j - 1] + prices[i]) 
8		return d[-1]

复杂度:

  • 时间 O(n·k)
  • 空间 朴素 O(n·k) / 滚动 O(k)

2.6 买卖股票含冷冻期

状态定义(4 个):

  • dp[i][0]:持有股票
  • dp[i][1]:今天卖出(当天刚卖)
  • dp[i][2]:冷冻期(昨天卖的,今天啥也干不了)
  • dp[i][3]:保持卖出(冷冻期已过,空仓待命)

309 状态机

状态转移:

dp[i][0] = max(dp[i-1][0], dp[i-1][3] - prices[i], dp[i-1][2] - prices[i])
dp[i][1] = dp[i-1][0] + prices[i]
dp[i][2] = dp[i-1][1]
dp[i][3] = max(dp[i-1][3], dp[i-1][2])

初始化:dp[0][0] = -prices[0],其余三个都是 0

答案:max(dp[n-1][1], dp[n-1][2], dp[n-1][3]),最后一天可能停在"刚卖出"、“冷冻中”、“空仓"三种状态的任意一个

代码(滚动数组):

1class Solution: 
2	def maxProfit(self, prices: List[int]) -> int: 
3		n = len(prices) 
4		d0, d1, d2, d3 = -prices[0], 0, 0, 0 
5		for i in range(1, n): 
6			d0, d1, d2, d3 = max(d0, d2 - prices[i], d3 - prices[i]), d0 + prices[i], d1, max(d2, d3) 
7		return max(d1, d2, d3)

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

2.7 买卖股票含手续费

状态转移(就是 122,卖出时扣一次 fee):

dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i] - fee)

初始化:dp[0][0] = -prices[0],dp[0][1] = 0

fee 扣在买还是卖 扣在卖出(+ p - fee)或买入(- p - fee)都行,只要一次完整交易只扣一次。扣在卖出更稳妥:扣在买入时,若最后一天仍持有股票,那笔 fee 就白扣了(虽然它不会进入答案)。

代码:

1class Solution: 
2	def maxProfit(self, prices: List[int], fee: int) -> int: 
3		n = len(prices) 
4		d0, d1 = -prices[0], 0 
5		for i in range(1, n): 
6			d0, d1 = max(d0, d1 - prices[i]), max(d1, d0 + prices[i] - fee) 
7		return d1

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

3 子序列 / 子数组系列

3.1 主线

这组题的全部难度都压在状态定义的一句话上,具体是两种写法的抉择:

定义写法含义不匹配时答案位置
以 i 结尾必须用上第 i 个元素归零 / 重新起步全局打擂台 max(dp)
前 i 个前 i 个里随便挑可以继承邻居表的末尾 dp[n]

“连续”(子数组)几乎总是逼你用第一种;“可跳过”(子序列)通常允许第二种。先想清楚哪一种,再写转移,顺序反了就会卡住。

718 vs 1143

3.2 最长递增子序列

状态定义: dp[i] = 以 nums[i] 结尾的最长递增子序列长度

状态转移:

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

300 vs 674

初始化:dp[i] = 1(每个元素自己就是一个长度为 1 的子序列)

返回:max(dp),不是 dp[-1]

代码(O(n²) 朴素版):

1def lengthOfLIS(nums: list[int]) -> int:
2    n = len(nums)
3    dp = [1] * n
4    for i in range(1, n):
5        for j in range(i):
6            if nums[j] < nums[i]:
7                dp[i] = max(dp[i], dp[j] + 1)
8    return max(dp)

代码(O(n log n) 贪心 + 二分):

 1def lengthOfLIS(nums: list[int]) -> int:
 2    tails = []                       # tails[k] = 长度为 k+1 的递增子序列的最小结尾
 3    for x in nums:
 4        l, r = 0, len(tails)         # 找第一个 >= x 的位置(左闭右开)
 5        while l < r:
 6            mid = (l + r) // 2
 7            if tails[mid] >= x:
 8                r = mid
 9            else:
10                l = mid + 1
11        if l == len(tails):
12            tails.append(x)
13        else:
14            tails[l] = x
15    return len(tails)

复杂度:

  • 朴素 O(n²) / O(n)
  • 二分版 O(n log n) / O(n)

3.3 最长连续递增序列

状态定义: dp[i] = 以 nums[i] 结尾的最长连续递增序列长度

状态转移:

if nums[i-1] < nums[i]:  dp[i] = dp[i-1] + 1
else:                    dp[i] = 1

和 300 的唯一差别 “连续"意味着只能接在 i-1 后面,不能回头挑任意的 j。于是内层循环消失,O(n²) 直接降到 O(n)。这个降维的来源不是什么优化技巧,纯粹是题意收紧了转移的来源集合。

初始化:全部 dp[i] = 1

返回:max(dp)

代码(朴素版 + 滚动版):

 1def findLengthOfLCIS(nums: list[int]) -> int:       # 朴素
 2    n = len(nums)
 3    dp = [1] * n
 4    for i in range(1, n):
 5        if nums[i - 1] < nums[i]:
 6            dp[i] = dp[i - 1] + 1
 7    return max(dp)
 8
 9
10def findLengthOfLCIS(nums: list[int]) -> int:       # 滚动
11    ans = cur = 1
12    for i in range(1, len(nums)):
13        cur = cur + 1 if nums[i - 1] < nums[i] else 1
14        ans = max(ans, cur)
15    return ans

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

3.4 最大子数组和

状态定义: dp[i] = 以 nums[i] 结尾的最大子数组和

状态转移:

接上前面:dp[i] = dp[i-1] + nums[i]
另起炉灶:dp[i] = nums[i]

dp[i] = max(dp[i-1] + nums[i], nums[i])

初始化:dp[0] = nums[0]

返回:max(dp)

一句话理解转移 如果 dp[i-1] <= 0,它对后面只有拖累,果断丢掉从 nums[i] 重开。这就是 Kadane 算法,和贪心版本"和一旦为负就清零"是同一件事的两种说法。

代码(朴素版 + 滚动版):

 1def maxSubArray(nums: list[int]) -> int:            # 朴素
 2    n = len(nums)
 3    dp = [0] * n
 4    dp[0] = nums[0]
 5    for i in range(1, n):
 6        dp[i] = max(dp[i - 1] + nums[i], nums[i])
 7    return max(dp)
 8
 9
10def maxSubArray(nums: list[int]) -> int:            # 滚动
11    cur = ans = nums[0]
12    for x in nums[1:]:
13        cur = max(cur + x, x)
14        ans = max(ans, cur)
15    return ans

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

3.5 乘积最大子数组

状态定义:

  • fmax[i]:以 nums[i] 结尾的连续子数组的最大乘积
  • fmin[i]:以 nums[i] 结尾的连续子数组的最小乘积

为什么要维护最小值 乘法与加法的关键区别:负负得正。当前最小值(很大的负数)乘上一个负数,可能瞬间变成最大值。只维护 fmax 会丢掉这条路径。 反例:[-2, 3, -4],正确答案是 (-2)·3·(-4) = 24。只维护最大值走到 -4 时,前面最大是 3,3·(-4) = -12,丢掉了 -6·(-4) = 24。

状态转移:

fmax[i] = max(fmax[i-1] * x, fmin[i-1] * x, x)
fmin[i] = min(fmax[i-1] * x, fmin[i-1] * x, x)

其中 x = nums[i]。三者取 max/min 中的 x 对应"从当前位置重新开始”。

滚动变量的覆盖陷阱 fmin 的转移需要旧的 fmax。如果先更新 fmax 再算 fmin,用的就是新值,结果会错。解决方法:用 Python 元组同时赋值,右边整体求值后再赋给左边。

初始化:fmax = fmin = nums[0],ans = nums[0]

返回:ans(全局打擂台)

代码(方法一:元组同时赋值):

 1class Solution:
 2	def maxProduct(self, nums: List[int]) -> int:
 3		ans = fmax = fmin = nums[0]
 4		for x in nums[1:]:
 5			fmax, fmin = (
 6				max(fmax * x, fmin * x, x),
 7				min(fmax * x, fmin * x, x),
 8			)
 9			ans = max(ans, fmax)
10		return ans

代码(方法二:遇负交换):

 1class Solution:
 2	def maxProduct(self, nums: List[int]) -> int:
 3		ans = fmax = fmin = nums[0]
 4		for x in nums[1:]:
 5			if x < 0:
 6				fmax, fmin = fmin, fmax
 7			fmax = max(x, fmax * x)
 8			fmin = min(x, fmin * x)
 9			ans = max(ans, fmax)
10		return ans

两种写法的等价性 方法二在 x < 0 时先交换 fmax/fmin,这样后续 max/min 的含义就和 x > 0 时一致了:乘以(交换后的)大值得大值。本质是把三路 max 里的分类讨论提前做掉。

复杂度:

  • 时间 O(n)
  • 空间 O(1)

3.6 最长有效括号

状态定义: dp[i] = 以 s[i] 结尾的最长有效括号子串的长度

以 ( 结尾的一定是 0,只需处理 s[i] == ')'。

状态转移(分两种情况):

情况 1:s[i-1] == '(',形如 ...()

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

直接和前一个 ( 配对,再拼上 dp[i-2](前面可能紧邻的有效串)。

情况 2:s[i-1] == ')',形如 ...))

先跳过内部已匹配的 dp[i-1] 长度,看 s[i - dp[i-1] - 1] 是否为 (:

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

三部分:内部已匹配长度 + 当前这对 + 再往前拼接的有效串。

边界检查 情况 1 需要 i >= 2 才能访问 dp[i-2];情况 2 需要 i - dp[i-1] - 1 >= 0 和 i - dp[i-1] - 2 >= 0。越界时对应的 dp 值视为 0。

初始化:dp = [0] * n,全零

返回:填表过程中打擂台取全局 max

代码(DP):

 1class Solution:
 2	def longestValidParentheses(self, s: str) -> int:
 3		n = len(s)
 4		dp = [0] * n
 5		res = 0
 6		for i in range(1, n):
 7			if s[i] == ')':
 8				if s[i - 1] == '(':
 9					dp[i] = (dp[i - 2] if i >= 2 else 0) + 2
10				elif i - dp[i - 1] - 1 >= 0 and s[i - dp[i - 1] - 1] == '(':
11					dp[i] = dp[i - 1] + 2 + (dp[i - dp[i - 1] - 2] if i - dp[i - 1] - 2 >= 0 else 0)
12				res = max(res, dp[i])
13		return res

代码(栈):

栈底始终放"最后一个未匹配右括号的下标”,作为有效子串的左边界分隔点。初始压入 -1 作为哨兵;遇 ( 压下标;遇 ) 弹栈顶,栈非空则 i - stack[-1] 是当前有效长度,栈空则压入 i 作为新分隔点。

 1class Solution:
 2	def longestValidParentheses(self, s: str) -> int:
 3		stack = [-1]
 4		res = 0
 5		for i, c in enumerate(s):
 6			if c == '(':
 7				stack.append(i)
 8			else:
 9				stack.pop()
10				if stack:
11					res = max(res, i - stack[-1])
12				else:
13					stack.append(i)
14		return res

栈 vs DP 对比 两种方法时空复杂度相同。栈的思路更直觉(分隔点天然处理了拼接),DP 的转移方程更显式(面试时容易讲清每一步的来源)。推荐两种都掌握。

复杂度:

  • 时间 O(n)
  • 空间 O(n)

3.7 最长重复子数组

状态定义: dp[i][j] = 以 nums1[i-1] 和 nums2[j-1] 结尾的最长公共子数组长度,表的尺寸 (m+1) × (n+1)

状态转移:

nums1[i-1] == nums2[j-1] :dp[i][j] = dp[i-1][j-1] + 1
不相等                    :dp[i][j] = 0

初始化:全 0(第 0 行 / 第 0 列表示"空数组",长度自然为 0)

返回:填表过程中打擂台取全局 max

代码(朴素版):

 1def findLength(nums1: list[int], nums2: list[int]) -> int:
 2    m, n = len(nums1), len(nums2)
 3    dp = [[0] * (n + 1) for _ in range(m + 1)]
 4    ans = 0
 5    for i in range(1, m + 1):
 6        for j in range(1, n + 1):
 7            if nums1[i - 1] == nums2[j - 1]:
 8                dp[i][j] = dp[i - 1][j - 1] + 1
 9                ans = max(ans, dp[i][j])
10    return ans

代码(滚动数组,一维):

1def findLength(nums1: list[int], nums2: list[int]) -> int:
2    m, n = len(nums1), len(nums2)
3    dp = [0] * (n + 1)
4    ans = 0
5    for i in range(1, m + 1):
6        for j in range(n, 0, -1):                 # 必须倒序
7            dp[j] = dp[j - 1] + 1 if nums1[i - 1] == nums2[j - 1] else 0
8            ans = max(ans, dp[j])
9    return ans

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

3.8 最长公共子序列

状态定义 dp[i][j] = text1 的前 i 个字符与 text2 的前 j 个字符的最长公共子序列长度

状态转移:

text1[i-1] == text2[j-1] :dp[i][j] = dp[i-1][j-1] + 1
不相等                    :dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  • 不匹配时的两个候选分别对应"扔掉 text1 的第 i 个字符"和"扔掉 text2 的第 j 个字符"。

初始化:全 0

返回:dp[m][n]

代码(朴素版):

 1def longestCommonSubsequence(text1: str, text2: str) -> int:
 2    m, n = len(text1), len(text2)
 3    dp = [[0] * (n + 1) for _ in range(m + 1)]
 4    for i in range(1, m + 1):
 5        for j in range(1, n + 1):
 6            if text1[i - 1] == text2[j - 1]:
 7                dp[i][j] = dp[i - 1][j - 1] + 1
 8            else:
 9                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
10    return dp[m][n]

代码(滚动数组,一维):

 1def longestCommonSubsequence(text1: str, text2: str) -> int:
 2    m, n = len(text1), len(text2)
 3    dp = [0] * (n + 1)
 4    for i in range(1, m + 1):
 5        prev = 0                                  # prev = dp[i-1][j-1]
 6        for j in range(1, n + 1):
 7            tmp = dp[j]                           # 存下 dp[i-1][j],给下一轮当左上角
 8            if text1[i - 1] == text2[j - 1]:
 9                dp[j] = prev + 1
10            else:
11                dp[j] = max(dp[j], dp[j - 1])     # dp[j] 是上一行,dp[j-1] 是本行
12            prev = tmp
13    return dp[n]

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

3.9 不相交的线

思路:完全等价于 1143。 连线不能相交 ⟺ 被连的元素在两个数组里相对顺序一致 ⟺ 它们构成一个公共子序列。 所以问题就是"最长公共子序列有多长",代码原封不动搬 1143,只把字符换成数字。

 1def maxUncrossedLines(nums1: list[int], nums2: list[int]) -> int:
 2    m, n = len(nums1), len(nums2)
 3    dp = [[0] * (n + 1) for _ in range(m + 1)]
 4    for i in range(1, m + 1):
 5        for j in range(1, n + 1):
 6            if nums1[i - 1] == nums2[j - 1]:
 7                dp[i][j] = dp[i - 1][j - 1] + 1
 8            else:
 9                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
10    return dp[m][n]

复杂度:

  • 时间 O(m·n)
  • 空间 O(m·n)(滚动同 1143,O(n))

4 双串编辑操作系列

4.1 主线

接着 1143 往下走,这四题的表格骨架、下标偏移、遍历顺序和 1143 一模一样,唯一变化的是:题目允许你做哪些操作。允许的操作越多,dp[i][j] 能从越多的格子转移过来。

状态定义都是同一句话的变体: dp[i][j] = 只看 s 的前 i 个和 t 的前 j 个字符时,某个量的最优值 / 计数 / 真假

转移永远在问同一件事:s[i-1] 和 t[j-1] 这一对末尾字符怎么处理。相等就白拿一格斜线,不等就看被允许往哪些方向退:

题允许的操作不等时能退到哪
392只能删 t左 dp[i][j-1]
115只能删 s(且是计数)上 dp[i-1][j]
583s、t 都能删上、左,取 min +1
72删、增、改上、左、左上,取 min +1

4.2 判断子序列

状态定义: dp[i][j] = s 的前 i 个字符是否是 t 的前 j 个字符的子序列(布尔值),表的尺寸 (m+1) × (n+1)

状态转移:

s[i-1] == t[j-1]:dp[i][j] = dp[i-1][j-1]   # 配上了,两边同时退一格
不相等           :dp[i][j] = dp[i][j-1]     # 只能删 t 的这个字符

392 转移与初始化

为什么没有 dp[i-1][j] 这条路 删 s 的字符 = 放弃 s 里的某个字符不去匹配,可 s 必须整个被匹配上才叫子序列。所以 s 这一侧的下标只能在"配上了"的时候前进,不能主动跳过。这是 392 与 1143 的唯一差别:LCS 两边都能跳,392 只有 t 能跳。

初始化:

dp[0][j] = True   (j ∈ [0, n])   空串是任何串的子序列
dp[i][0] = False  (i ≥ 1)        非空串不可能是空串的子序列
dp[i][j] = False

返回:dp[m][n]

代码(朴素版):

 1class Solution:
 2	def isSubsequence(self, s: str, t: str) -> bool:
 3		m, n = len(s), len(t)
 4		dp = [[False] * (n + 1) for _ in range(m + 1)]
 5		for j in range(n + 1):
 6			dp[0][j] = True
 7		for i in range(1, m + 1):
 8			for j in range(1, n + 1):
 9				if s[i - 1] == t[j - 1]:
10					dp[i][j] = dp[i - 1][j - 1]
11				else:
12					dp[i][j] = dp[i][j - 1]
13		return dp[m][n]

代码(双指针,本题的实际最优解):

1class Solution:
2	def isSubsequence(self, s: str, t: str) -> bool:
3		i = 0
4		for ch in t:
5			if i < len(s) and s[i] == ch:
6				i += 1
7		return i == len(s)

复杂度:

  • 时间 DP O(m·n) / 双指针 O(m+n)
  • 空间 朴素 O(m·n) / 滚动 O(n) / 双指针 O(1)

4.3 不同的子序列

状态定义: dp[i][j] = 在 s 的前 i 个字符中,能凑出 t 的前 j 个字符的方案数

“前 i 个"是前缀,挑出来的才是子序列 i 限定的是在哪个前缀范围内挑,挑出来的字符不要求连续。这跟 718 那种"子数组"是两码事。

状态转移:

s[i-1] == t[j-1]:dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
不相等           :dp[i][j] = dp[i-1][j]

115 用与不用的分叉

初始化:

dp[i][0] = 1  (i ∈ [0, m])   t 是空串:把 s 全删光,恰好 1 种方案
dp[0][j] = 0  (j ≥ 1)        s 是空串却要凑出非空的 t:0 种

返回:dp[m][n]

代码(朴素版):

 1class Solution:
 2	def numDistinct(self, s: str, t: str) -> int:
 3		m, n = len(s), len(t)
 4		dp = [[0] * (n + 1) for _ in range(m + 1)]
 5		for i in range(m + 1):
 6			dp[i][0] = 1
 7			
 8		for i in range(1, m + 1):
 9			for j in range(1, n + 1):
10				if s[i - 1] == t[j - 1]:
11					dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]
12				else:
13					dp[i][j] = dp[i - 1][j]
14		return dp[m][n]

代码(滚动数组,一维):

1class Solution:
2	def numDistinct(self, s: str, t: str) -> int:
3		n = len(t)
4		dp = [1] + [0] * n
5		for ch in s:
6			for j in range(n, 0, -1):          # 必须倒序
7				if ch == t[j - 1]:
8					dp[j] += dp[j - 1]
9		return dp[n]

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

4.4 两个字符串的删除操作

状态定义: dp[i][j] = 让 s 的前 i 个与 t 的前 j 个变得相同,所需的最少删除次数(两个串都能删)

状态转移:

相等:  dp[i][j] = dp[i-1][j-1]               # 末尾天然对上,不花步数
不相等:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + 1  # 删 s[i-1] 或删 t[j-1]

583 转移与初始化

初始化:

dp[i][0] = i      另一串为空,只能把这一串全删掉
dp[0][j] = j

返回:dp[m][n]

代码(朴素版):

 1class Solution:
 2	def minDistance(self, word1: str, word2: str) -> int:
 3		m, n = len(word1), len(word2)
 4		dp = [[0] * (n + 1) for _ in range(m + 1)]
 5		for i in range(m + 1):
 6			dp[i][0] = i
 7		for j in range(n + 1):
 8			dp[0][j] = j
 9			
10		for i in range(1, m + 1):
11			for j in range(1, n + 1):
12				if word1[i - 1] == word2[j - 1]:
13					dp[i][j] = dp[i - 1][j - 1]
14				else:
15					dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + 1
16		return dp[m][n]

代码(滚动数组,一维):

 1class Solution:
 2	def minDistance(self, word1: str, word2: str) -> int:
 3		m, n = len(word1), len(word2)
 4		dp = list(range(n + 1))                 # 第 0 行:dp[0][j] = j
 5		for i in range(1, m + 1):
 6			prev = dp[0]                        # prev = dp[i-1][j-1]
 7			dp[0] = i                           # dp[i][0] = i
 8			for j in range(1, n + 1):
 9				tmp = dp[j]
10				if word1[i - 1] == word2[j - 1]:
11					dp[j] = prev
12				else:
13					dp[j] = min(dp[j], dp[j - 1]) + 1   # dp[j] 上一行,dp[j-1] 本行
14				prev = tmp
15		return dp[n]

另一条路:直接用 1143 删除次数 = 两串各自删到只剩公共子序列:

1return len(word1) + len(word2) - 2 * lcs(word1, word2)

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

4.5 编辑距离

状态定义: dp[i][j] = 把 s 的前 i 个字符转换成 t 的前 j 个字符,所需的最少操作数(增、删、改各算一步)

状态转移:

相等: dp[i][j] = dp[i-1][j-1]
不相等:dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

72 三种操作

推导的统一姿势:假设最后一步做了某个操作,做完之后剩下的是哪个子问题?

最后一步做什么做完之后还剩什么式子
删掉 s[i-1]s 前 i−1 个 ⇒ t 前 j 个dp[i-1][j] + 1
在 s 末尾插入 t[j-1]这一对已配平,剩 s 前 i 个 ⇒ t 前 j−1 个dp[i][j-1] + 1
把 s[i-1] 改成 t[j-1]这一对当场配平,剩 s 前 i−1 个 ⇒ t 前 j−1 个dp[i-1][j-1] + 1

初始化:

dp[i][0] = i  (i ∈ [0, m])   t 是空串:把 s 的 i 个字符全删掉
dp[0][j] = j  (j ∈ [0, n])   s 是空串:插入 t 的 j 个字符

返回:dp[m][n]

代码(朴素版):

 1class Solution:
 2	def minDistance(self, word1: str, word2: str) -> int:
 3		m, n = len(word1), len(word2)
 4		dp = [[0] * (n + 1) for _ in range(m + 1)]
 5		for i in range(m + 1):
 6			dp[i][0] = i
 7		for j in range(n + 1):
 8			dp[0][j] = j
 9		for i in range(1, m + 1):
10			for j in range(1, n + 1):
11				if word1[i - 1] == word2[j - 1]:
12					dp[i][j] = dp[i - 1][j - 1]
13				else:
14					dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
15		return dp[m][n]

代码(滚动数组,一维):

 1class Solution:
 2	def minDistance(self, word1: str, word2: str) -> int:
 3		m, n = len(word1), len(word2)
 4		dp = list(range(n + 1))
 5		for i in range(1, m + 1):
 6			prev = dp[0]                        # prev = dp[i-1][j-1]
 7			dp[0] = i
 8			for j in range(1, n + 1):
 9				tmp = dp[j]                     # 存下 dp[i-1][j],下一轮当左上角
10				if word1[i - 1] == word2[j - 1]:
11					dp[j] = prev
12				else:
13					dp[j] = min(dp[j], dp[j - 1], prev) + 1
14				prev = tmp
15		return dp[n]

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

5 回文系列

5.1 主线

前面四章的表都是"两个串各拿一个前缀”,这一章换成一个串、两个端点: dp[i][j] 描述的是区间 s[i..j],i 是左端点、j 是右端点。

这类表有三个共同点:

  • 只有上三角有意义:i > j 的格子代表"左端点跑到右端点右边",无意义
  • 对角线是种子:dp[i][i] 对应单个字符,回文性 True、长度 1
  • 转移都要读 dp[i+1][j-1]:掐掉两头后的内层区间

回文系列的填表顺序

这一章唯一的新东西就是遍历顺序 dp[i+1][j-1] 在当前格的左下方,所以必须 i 从下往上(倒序)、j 从左往右(正序,且从 j = i 起步)。 前面所有双串 DP 的来源都在上方 / 左方,所以 i、j 都正序——顺序不是背下来的,画一格看它依赖谁,方向就定了。

三题的差别只在"表里存什么":

题dp 存什么不等时
647区间是不是回文(布尔)False,没有退路
5同 647,额外记录最长的那段边界False
516区间里最长回文子序列的长度max(下, 左),两条退路

5.2 回文子串

状态定义: dp[i][j] = 子串 s[i..j] 是否是回文串(布尔值),表的尺寸 n × n

状态转移:

  • s[i] 与 s[j] 不等时直接 False
  • 相等时还要看中间剩了什么:
i == j    :dp[i][j] = True          单个字符
j - i == 1:dp[i][j] = True          "aa",中间空无一物
j - i > 1 :dp[i][j] = dp[i+1][j-1]  中间那段说了算

647 三种情况

合并成一行:

if s[i] == s[j] and (j - i <= 1 or dp[i+1][j-1]):
    dp[i][j] = True
    ans += 1

初始化:全 False

遍历顺序:i 从 n-1 到 0,j 从 i 到 n-1

返回:计数 ans

代码(区间 DP):

 1class Solution:
 2	def countSubstrings(self, s: str) -> int:
 3		n = len(s)
 4		dp = [[False] * n for _ in range(n)]
 5		ans = 0
 6		for i in range(n - 1, -1, -1):          # i 倒序
 7			for j in range(i, n):               # j 从 i 起,只填上三角
 8				if s[i] == s[j] and (j - i <= 1 or dp[i + 1][j - 1]):
 9					dp[i][j] = True
10					ans += 1
11		return ans

代码(中心扩散,空间 O(1)):

 1class Solution:
 2	def countSubstrings(self, s: str) -> int:
 3		n, ans = len(s), 0
 4		for c in range(2 * n - 1):              # 2n-1 个中心:n 个字符 + n-1 个缝隙
 5			l, r = c // 2, c // 2 + c % 2
 6			while l >= 0 and r < n and s[l] == s[r]:
 7				ans += 1
 8				l -= 1
 9				r += 1
10		return ans

复杂度:

  • 时间 O(n²)
  • 空间 DP O(n²) / 中心扩散 O(1)

5.3 最长回文子串

思路:和 647 是同一张表、同一套转移、同一个遍历顺序,只是统计口径换了:647 数有多少个 True,本题记录哪个 True 对应的区间最长。

状态定义: dp[i][j] = 子串 s[i..j] 是否是回文串

状态转移:同 647

初始化:全 False

返回:用 ans、l、r 记录最长那段的长度和左右端点,最后切片 s[l:r+1]

代码(区间 DP):

 1class Solution:
 2	def longestPalindrome(self, s: str) -> str:
 3		n = len(s)
 4		dp = [[False] * n for _ in range(n)]
 5		ans = l = r = 0
 6		for i in range(n - 1, -1, -1):
 7			for j in range(i, n):
 8				if s[i] == s[j] and (j - i <= 1 or dp[i + 1][j - 1]):
 9					dp[i][j] = True
10					if j - i + 1 > ans:
11						ans = j - i + 1
12						l, r= i, j
13						
14		return s[l:r + 1]

代码(中心扩散):

 1class Solution:
 2	def longestPalindrome(self, s: str) -> str:
 3		n = len(s)
 4		start, length = 0, 1
 5		for c in range(2 * n - 1):
 6			l, r = c // 2, c // 2 + c % 2
 7			while l >= 0 and r < n and s[l] == s[r]:
 8				if r - l + 1 > length:
 9					start, length = l, r - l + 1
10				l -= 1
11				r += 1
12		return s[start:start + length]

复杂度:

  • 时间 O(n²)
  • 空间 DP O(n²) / 中心扩散 O(1)

5.4 最长回文子序列

状态定义: dp[i][j] = 区间 s[i..j] 内最长回文子序列的长度

状态转移:

s[i] == s[j]:dp[i][j] = dp[i+1][j-1] + 2        两头必然能当最外层,白拿 2
s[i] != s[j]:dp[i][j] = max(dp[i+1][j], dp[i][j-1])   两头留不住,扔一个

516 两种转移

初始化:dp[i][i] = 1(单个字符自成长度 1 的回文),其余 0

遍历顺序:i 从 n-1 到 0,j 从 i+1 到 n-1

返回:dp[0][n-1],整个串这个最大区间的答案

代码:

 1class Solution:
 2	def longestPalindromeSubseq(self, s: str) -> int:
 3		n = len(s)
 4		dp = [[0] * n for _ in range(n)]
 5		for i in range(n - 1, -1, -1):
 6			dp[i][i] = 1                        # 对角线顺手填,不必单开一层循环
 7			for j in range(i + 1, n):
 8				if s[i] == s[j]:
 9					dp[i][j] = dp[i + 1][j - 1] + 2
10				else:
11					dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
12		return dp[0][n - 1]

复杂度:

  • 时间 O(n²)
  • 空间 O(n²)

6 递推入门系列

6.1 斐波那契数

状态定义: dp[i] = 第 i 个斐波那契数

状态转移:

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

初始化:

dp[0] = 0
dp[1] = 1

返回:dp[n]

代码(滚动数组):

1class Solution:
2	def fib(self, n: int) -> int:
3		if n < 2:
4			return n
5		a, b = 0, 1
6		for _ in range(2, n + 1):
7			a, b = b, a + b
8		return b

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

6.2 爬楼梯

状态定义: dp[i] = 爬到第 i 阶的方法总数

状态转移:

dp[i] = dp[i-1] + dp[i-2]
        └ 从 i-1 迈一步   └ 从 i-2 迈两步

初始化:

dp[1] = 1     一阶:只有 1 种
dp[2] = 2     两阶:1+1 或 2

返回:dp[n]

代码(滚动数组):

1class Solution:
2	def climbStairs(self, n: int) -> int:
3		a, b = 1, 1                  # dp[0], dp[1]
4		for _ in range(n - 1):
5			a, b = b, a + b
6		return b

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

6.3 使用最小花费爬楼梯

状态定义: dp[i] = 站上第 i 阶所需的最低花费

花的是"出发那一阶"的钱 cost[i] 的含义是从第 i 阶往上迈要付的费用,不是"落在第 i 阶"要付的费用。所以站上某一阶本身不花钱,dp[0] = dp[1] = 0。 这个方向一旦搞反,转移就会写成 dp[i-1] + cost[i],答案整体偏一格。

状态转移:

dp[i] = min( dp[i-1] + cost[i-1],  dp[i-2] + cost[i-2] )
             └ 从 i-1 迈一步       └ 从 i-2 迈两步

初始化:dp[0] = dp[1] = 0(题目允许从第 0 或第 1 阶起步)

返回:dp[n]

746 下标示意

cost 有 n 个,dp 要开 n+1 个 楼顶是第 n 阶,它没有 cost[n]——因为没人需要从楼顶再往上迈。dp 数组长度是 n+1,下标 0 … n,答案在 dp[n]。

代码(滚动数组):

1class Solution:
2	def minCostClimbingStairs(self, cost: List[int]) -> int:
3		n = len(cost)
4		a, b = 0, 0                  # dp[0], dp[1]
5		for i in range(2, n + 1):
6			a, b = b, min(b + cost[i - 1], a + cost[i - 2])
7		return b

复杂度:

  • 时间 O(n)
  • 空间 朴素 O(n) / 滚动 O(1)

6.4 杨辉三角

状态定义: res[i][j] = 第 i 行第 j 个元素的值(0-indexed)

状态转移:

res[i][j] = res[i-1][j-1] + res[i-1][j]    (1 <= j <= i-1)

两端 j = 0 和 j = i 恒为 1。

初始化:每行先全填 1,再用转移方程覆盖中间位置

返回:res

代码:

1class Solution:
2	def generate(self, numRows: int) -> List[List[int]]:
3		res = []
4		for i in range(numRows):
5			row = [1] * (i + 1)
6			for j in range(1, i):
7				row[j] = res[-1][j - 1] + res[-1][j]
8			res.append(row)
9		return res

滚动数组 如果只需要第 n 行(119 题),可以只维护一行,从右往左更新(避免覆盖左邻):

1row = [1] * (n + 1)
2for i in range(2, n + 1):
3	for j in range(i - 1, 0, -1):
4		row[j] += row[j - 1]

这和 01 背包倒序遍历是完全同构的。

复杂度:

  • 时间 O(n²),空间 O(n²)(存储所有行);只要第 n 行则空间 O(n)

7 网格路径系列

7.1 主线

三题共用同一张 m × n 的网格表,也共用同一句话:只能向右、向下走,所以每个格子的来源只有上方和左方。

62/63/64 依赖方向与一维滚动

题dp[i][j] 含义转移
62到达 (i,j) 的路径数dp[i-1][j] + dp[i][j-1]
63同上,但有障碍同 62;障碍处直接 0
64到达 (i,j) 的最小路径和min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

7.2 不同路径

状态定义: dp[i][j] = 从起点走到 (i,j) 的不同路径总数

状态转移:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

初始化:第一行、第一列全为 1(只能一路向右 / 一路向下,各只有一种走法)

遍历顺序:从上往下,从左往右

返回:dp[m-1][n-1] or dp[-1][-1]

代码(一维滚动):

1class Solution:
2	def uniquePaths(self, m: int, n: int) -> int:
3		dp = [1] * n                     # 第一行
4		for i in range(1, m):
5			for j in range(1, n):        # j 从 1 开始,j = 0 那列恒为 1
6				dp[j] += dp[j - 1]
7		return dp[-1]

内层从 j = 1 起,不是 j = 2 j = 0 那一列是第一列,恒为 1,不参与递推(它没有左邻居);从 j = 1 开始才对。手稿写的 j > 1 会漏掉第二列。

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

7.3 不同路径 II

思路:转移与 62 完全一致,只多一条规则,障碍格的路径数是 0

63 障碍处的初始化

状态转移:

grid[i][j] == 1:dp[i][j] = 0
否则            :dp[i][j] = dp[i-1][j] + dp[i][j-1]

初始化:

第一行:从左往右填 1,碰到障碍立刻 break(它右边的全是 0)
第一列:从上往下填 1,碰到障碍立刻 break(它下面的全是 0)

返回:dp[m-1][n-1]

代码(一维滚动):

 1class Solution:
 2    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
 3        if obstacleGrid[0][0] == 1 or obstacleGrid[-1][-1] == 1:
 4            return 0
 5        m, n = len(obstacleGrid), len(obstacleGrid[0])
 6        dp = [0] * n
 7        for j in range(n):
 8            if obstacleGrid[0][j] == 0:
 9                dp[j] = 1
10            else:
11                break
12
13        for i in range(1, m):
14            for j in range(n):
15                if obstacleGrid[i][j] == 1:
16                    dp[j] = 0
17                elif j > 0:
18                    dp[j] += dp[j - 1]
19        return dp[-1]

一维版里第一列是怎么被处理的 j = 0 时既不清零也不累加,dp[0] 就保持上一行的值——正是"第一列只能从上面下来"的语义;而一旦某行的 grid[i][0] 是障碍,dp[0] 会被清零并一直传下去。第一列的初始化被这两条自动兜住了,不用单独写一遍。

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

7.4 最小路径和

状态定义: dp[i][j] = 从起点走到 (i,j) 的最小路径和

状态转移:

dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

初始化:第一行、第一列是前缀和(只有一条路可走,把沿途的值累加起来)

返回:dp[m-1][n-1]

代码(一维滚动):

 1class Solution:
 2	def minPathSum(self, grid: List[List[int]]) -> int:
 3		m, n = len(grid), len(grid[0])
 4		dp = [0] * n
 5		dp[0] = grid[0][0]
 6		for j in range(1, n):                   # 第一行前缀和
 7			dp[j] = dp[j - 1] + grid[0][j]
 8		for i in range(1, m):
 9			dp[0] += grid[i][0]                 # 第一列只能从上面下来
10			for j in range(1, n):
11				dp[j] = grid[i][j] + min(dp[j], dp[j - 1])
12		return dp[-1]

代码(哨兵版,省掉边界初始化):

 1class Solution:
 2	def minPathSum(self, grid: List[List[int]]) -> int:
 3		m, n = len(grid), len(grid[0])
 4		INF = float('inf')
 5		dp = [[INF] * (n + 1) for _ in range(m + 1)]
 6		dp[0][1] = 0                            # 只开一个口子当起点的"上方"
 7		for i in range(1, m + 1):
 8			for j in range(1, n + 1):
 9				dp[i][j] = grid[i - 1][j - 1] + min(dp[i - 1][j], dp[i][j - 1])
10		return dp[m][n]

哨兵那一格为什么是 dp[0][1] = 0 把表扩成 (m+1) × (n+1)、全填 INF,越界方向就自动"走不通"(INF 永远不会被 min 选中),于是第一行第一列的前缀和不用手写了。 但必须留一个入口:起点 (1,1) 的两个来源 dp[0][1] 和 dp[1][0] 若都是 INF,起点就永远到不了。任选其一置 0 即可(dp[1][0] = 0 等价)。 这个技巧只对求最小值的题好用;62 那种求计数的题,哨兵应该填 0 而不是 INF。

复杂度:

  • 时间 O(m·n)
  • 空间 朴素 O(m·n) / 滚动 O(n)

8 拆分与计数系列

8.1 主线

dp 的下标不再是坐标,而是规模(数字 i 本身、节点个数 i)。枚举一个分割点,把规模 i 拆成两个更小的子问题。

题dp[i] 含义枚举什么合并方式
343拆分 i 得到的最大乘积第一个数 j乘
96i 个节点的 BST 形状数谁当根左右相乘再求和

8.2 整数拆分

状态定义: dp[i] = 把 i 拆成至少两个正整数之和后,各数乘积的最大值

状态转移: 枚举拆出来的第一个数 j,剩下的 i - j 有两种命运:

343 两条分支

i-j 不再拆:j × (i - j)
i-j 继续拆:j × dp[i - j]

dp[i] = max(dp[i], j × (i - j), j × dp[i - j])   j = 1 … i//2

两条分支缺一不可 只写 j × dp[i-j] 会漏掉"恰好拆成两个数"的情况,而它常常就是最优解(比如 dp[4] = 2 × 2)。 因为 dp[i-j] 的定义是"至少拆成两个数",它压根不包含"i-j 保持原样"这种可能。转移里出现的每个 dp 值,都要回头核对它的定义包不包含你现在需要的情况。

初始化:dp[2] = 1(2 = 1 + 1),其余 0

返回:dp[n]

代码(朴素版):

1class Solution:
2	def integerBreak(self, n: int) -> int:
3		dp = [0] * (n + 1)
4		dp[2] = 1
5		for i in range(3, n + 1):
6			for j in range(1, i // 2 + 1):
7				dp[i] = max(dp[i], j * (i - j), j * dp[i - j])
8		return dp[n]

8.2.1 内层优化:j 其实只需要 2 和 3

推导链条:

  1. 算出前几项 dp[1..5] = 1, 1, 2, 4, 6,可以看出 i ≥ 4 时 dp[i] ≥ i(大数拆开只会更大)
  2. 所以拆出的因子越大越不划算,j 只需要试 1、2、3
  3. j = 1 时 dp[i] = 1 × dp[i-1] = dp[i-1],乘个 1 不产生任何新价值,可以丢掉
  4. 于是只剩:dp[i] = max(2 × dp[i-2], 3 × dp[i-3])

这一步要连带改定义:上式里的 dp[i-2]、dp[i-3] 必须允许"不拆",否则 dp[4] = 2 × dp[2] = 2 × 1 = 2 就错了。所以把定义放宽成:

dp[i] = i 自身与 i 的所有拆分乘积中的最大值(允许不拆)

对应初始化改成 dp[1] = 1, dp[2] = 2, dp[3] = 3

定义放宽之后,n ≤ 3 必须特判 新定义下 dp[2] = 2、dp[3] = 3,但题目要求至少拆成两个数,正确答案是 1 和 2。所以 n ≤ 3 时直接 return n - 1,从 i = 4 起才用递推。 这是优化版最容易翻车的地方:为了递推方便改了定义,就要检查改动有没有污染边界的答案。

代码(优化版):

1class Solution:
2	def integerBreak(self, n: int) -> int:
3		if n <= 3:
4			return n - 1
5		dp = [0, 1, 2, 3] + [0] * (n - 3)       # 放宽定义:允许不拆
6		for i in range(4, n + 1):
7			dp[i] = max(2 * dp[i - 2], 3 * dp[i - 3])
8		return dp[n]

8.2.2 数学解法:尽量拆 3

由上面的分析可知,最优拆分里只会出现 2 和 3。设有 m 个 2、k 个 3,则 n = 2m + 3k。而 2 + 2 + 2 = 3 + 3,但 2³ = 8 < 3² = 9,三个 2 不如两个 3,所以 2 最多留两个。

于是按 n % 3 分三类:

r == 0:ans = 3^q
r == 1:ans = 3^(q-1) × 4      # 借一个 3 出来,3+1 拆成 2+2 更优
r == 2:ans = 3^q × 2

代码(数学版):

 1class Solution:
 2	def integerBreak(self, n: int) -> int:
 3		if n <= 3:
 4			return n - 1
 5		q, r = divmod(n, 3)
 6		if r == 0:
 7			return 3 ** q
 8		if r == 1:
 9			return 3 ** (q - 1) * 4
10		return 3 ** q * 2

复杂度:

  • 时间 朴素 O(n²) / 优化 O(n) / 数学 O(1)(幂运算按 O(1) 记)
  • 空间 O(n) / O(1)

8.3 不同的二叉搜索树

状态定义: dp[i] = 由 i 个节点组成的不同二叉搜索树的个数

状态分析(先手推几项,规律自己会浮出来):

n = 0:空树              dp[0] = 1
n = 1:单点              dp[1] = 1
n = 2:两种              dp[2] = 2
n = 3:五种              dp[3] = 5

dp[3] 是怎么来的——枚举谁当根:

96 枚举根节点

根=1:左 0 个、右 2 个 → dp[0] × dp[2] = 2
根=2:左 1 个、右 1 个 → dp[1] × dp[1] = 1
根=3:左 2 个、右 0 个 → dp[2] × dp[0] = 2
                                  合计 5

状态转移:

dp[i] += dp[j] × dp[i-j-1]      j = 0 … i-1
         └ 左子树 j 个   └ 右子树 i-j-1 个(-1 是根自己)

初始化:dp[0] = 1,其余 0

返回:dp[n]

代码:

1class Solution:
2	def numTrees(self, n: int) -> int:
3		dp = [0] * (n + 1)
4		dp[0] = 1
5		for i in range(1, n + 1):
6			for j in range(i):                  # j = 左子树节点数
7				dp[i] += dp[j] * dp[i - j - 1]
8		return dp[n]

这就是卡特兰数 1, 1, 2, 5, 14, 42, …,通项 C(2n, n) / (n+1)。合法括号序列、出栈序列、凸多边形三角剖分都是同一个数列:认出"枚举分割点 + 左右相乘"这个形状,就认出了卡特兰数。

复杂度:

  • 时间 O(n²)
  • 空间 O(n)

9 01 背包模板

9.1 问题与分类

背包问题:有一个最大容量为 W 的背包,和若干物品,每件物品有重量 w[i] 和价值 v[i]。挑哪些物品装进去,能让总价值最大?

背包问题的构件

按每件物品可以拿几次分家:

类型每件物品一维遍历方向
01 背包最多拿 1 次容量倒序
完全背包想拿几次拿几次容量正序

这份笔记的核心就一句话 “每件只能拿一次"这个约束,最后落在代码里就是一个倒序循环。 后面四道题全是同一个模板换皮:难的不是 DP,是看出它是个背包——把题目里的东西翻译成"容量、重量、价值”。


9.2 二维写法

状态定义: dp[i][j] = 从下标 [0, i] 的物品里任选,装进容量为 j 的背包,能得到的最大价值

状态转移: 第 i 件物品只有拿与不拿两种命运:

不拿:dp[i][j] = dp[i-1][j]
拿  :dp[i][j] = dp[i-1][j - w[i]] + v[i]      需要 j >= w[i]

dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])

01 背包二维表

初始化(表的尺寸是 n × (W+1),n 件物品、容量 0…W):

第 0 行:j >= w[0] 时 dp[0][j] = v[0],其余为 0
第 0 列:容量为 0 什么都装不下,全 0

遍历顺序:两个来源都在上一行,所以行必须从上往下;同一行内 j 正序倒序都可以(互不干扰)

代码(二维):

 1class Solution:
 2    def knapsack_2d(self, n: int, nums: List[int]) -> int:
 3        """
 4        args:
 5            n: 背包容量
 6            nums: 背包的体积和价值(相等)
 7        """
 8        m = len(nums)
 9        dp = [[0] * (n + 1) for _ in range(m)]
10
11        for j in range(nums[0], n + 1):
12            dp[0][j] = nums[0]
13
14        for i in range(1, m):
15            for j in range(n + 1):
16                if j < nums[i]:
17                    dp[i][j] = dp[i - 1][j]
18                else:
19                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i])
20        return dp[-1][-1]

代码(加哨兵行,省掉初始化):

 1class Solution:
 2    def knapsack_2d(self, n: int, nums: List[int]) -> int:
 3        """
 4        args:
 5            n: 背包容量
 6            nums: 背包的体积和价值(相等)
 7        """
 8        m = len(nums)
 9        dp = [[0] * (n + 1) for _ in range(m + 1)]
10
11        for i in range(1, m + 1):
12            num = nums[i - 1]
13            for j in range(n + 1):
14                if j < num:
15                    dp[i][j] = dp[i - 1][j]
16                else:
17                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - num] + num)
18        return dp[-1][-1]

复杂度:

  • 时间 O(mn)
  • 空间 O(mn)

9.3 一维滚动写法

状态定义: dp[j] = 容量为 j 的背包能装下的最大价值(把二维表的行压掉,原地滚动)

状态转移:

j < w[i] :dp[j] 保持不变(装不下,直接沿用上一轮)
j >= w[i]:dp[j] = max(dp[j], dp[j - w[i]] + v[i])
                    └ 旧值=不拿   └ 旧值=拿

初始化:全 0,不需要额外处理第一件物品

遍历顺序:外层物品正序,内层容量倒序

一维为什么必须倒序

两层循环不能交换 必须是外层物品、内层容量。如果反过来(外层容量、内层物品),在倒序的容量下每个容量只会装进一件物品,答案会变小——那已经不是 01 背包了。 完全背包的一维写法可以交换(因为它正序、可重复拿),但 01 背包不行。这是这个模板里唯一不能动的结构。

代码(一维):

 1class Solution:
 2    def knapsack_1d(self, n: int, nums: List[int]) -> int:
 3        """
 4        args:
 5            n: 背包容量
 6            nums: 背包的体积和价值(相等)
 7        """
 8        dp = [0] * (n + 1)
 9        for num in nums:
10            for j in range(n, num - 1, -1):
11                dp[j] = max(dp[j], dp[j - num] + num)
12        return dp[-1]

复杂度:

  • 时间 O(mn)
  • 空间 O(n)

9.4 判定型:能否恰好装满 分割等和子集

问题转换: 把数组分成两个和相等的子集:只要找出一个子集,其和为 total / 2,剩下的自然就是另一半。所以不必真的去"分",只需回答:能否从数组里挑出若干个数,恰好凑出 total // 2

翻译成背包:

背包的概念本题对应
容量 Wtotal // 2
物品重量 w[i]nums[i]
物品价值 v[i]nums[i](同一个数既是重量也是价值)
求什么装到最满是多少 → 是否等于容量

“恰好装满"的判定技巧 直接跑最大价值背包,最后看 dp[target] == target:既然重量和价值取同一个数,那么"装进去的总价值 = 装进去的总重量”。如果最大价值恰好顶到容量,说明真的把背包塞满了。

提前判断:total 为奇数时直接 False,奇数没法均分

代码:

 1class Solution:
 2	def canPartition(self, nums: List[int]) -> bool:
 3		total = sum(nums)
 4		if total % 2:
 5			return False
 6		target = total // 2
 7		dp = [0] * (target + 1)
 8		for num in nums:
 9			for j in range(target, num - 1, -1):
10				dp[j] = max(dp[j], dp[j - num] + num)
11		return dp[target] == target

复杂度:

  • 时间 O(n · total)
  • 空间 O(total)

9.5 最优型:尽量装满 最后一块石头的重量 II

问题转换: 每次相撞留下 |x - y|,多次相撞后的结果,等价于给每块石头配一个 ±1 的符号: $$ans = |\sum Sign_{i} * stone_{i}|, i \in {-1, 1}$$ 于是问题变成把石头分成两堆 A、B,求两堆之差的最小值:

1049 分两堆

A + B = total,  |A - B| = ans
设 B 是较小的那堆  ⇒  ans = total - 2B
想让 ans 尽量小  ⇔  让 B 尽量大,且 B <= total / 2

翻译成背包:容量 total // 2,重量 = 价值 = 石头重量,能装多满装多满。

容量取 total // 2 向下取整,前提就自动成立 装出来的 B 必然 <= total/2,所以"B 是较小那堆"不用额外验证,返回值 total - 2 * dp[target] 也一定非负,不需要再套 abs

返回:total - 2 * dp[total // 2]

代码:

1class Solution:
2	def lastStoneWeightII(self, stones: List[int]) -> int:
3		total = sum(stones)
4		target = total // 2
5		dp = [0] * (target + 1)
6		for s in stones:
7			for j in range(target, s - 1, -1):
8				dp[j] = max(dp[j], dp[j - s] + s)
9		return total - 2 * dp[target]

和 416 就差最后一行 416 问"能不能刚好劈成两半"(dp[target] == target),1049 问"劈得最接近时差多少"(total - 2*dp[target])。同一个背包,两种读法。 416 其实就是 1049 结果为 0 的特例。

复杂度:

  • 时间 O(n · total)
  • 空间 O(total)

9.6 计数型:装满的方案数 目标和

问题转换: 设取 + 号的数之和为 P、取 - 号的为 N:

494 方程推导

P - N = target
P + N = total        
⇒   
P = (total + target) / 2

N 被 P 唯一决定,所以只需要数:从 nums 里挑若干个数,和恰好为 P,有多少种挑法,即"装满容量 P 的背包有几种装法"。

状态转移(求方案数,用加法):

dp[j] += dp[j - num]

初始化:dp[0] = 1

这个 1 是种子,写成 0 全表塌掉 含义是"凑出和为 0 的方案有 1 种:一个数都不选"。它是所有累加的起点,填 0 的话 dp 会恒为 0。

必须先挡掉的两个边界:

(total + target) 为奇数  → P 不是整数,返回 0
abs(target) > total      → 怎么配符号都够不着,返回 0

注意 target 可以是负数 第二条判断要写 abs(target) > total。只写 target > total 的话,target 很负时 P 会算成负数,[0] * (p + 1) 直接开出一个负长度的数组。

代码:

 1class Solution:
 2	def findTargetSumWays(self, nums: List[int], target: int) -> int:
 3		total = sum(nums)
 4		if abs(target) > total or (total + target) % 2:
 5			return 0
 6		p = (total + target) // 2
 7		dp = [0] * (p + 1)
 8		dp[0] = 1
 9		for num in nums:
10			for j in range(p, num - 1, -1):
11				dp[j] += dp[j - num]
12		return dp[p]

复杂度:

  • 时间 O(n · total)
  • 空间 O(total)

9.7 二维费用背包 一和零

问题转换: 物品是字符串,但它占两种资源:用掉几个 0、用掉几个 1。所以背包有两个容量维度:

背包的概念本题对应
容量两个:最多 m 个 0、最多 n 个 1
物品重量该字符串里 0 的个数、1 的个数
物品价值1(每选一个字符串,答案 +1)
求什么最大价值 = 最多能选几个字符串

状态定义: dp[i][j] = 最多用 i 个 0、j 个 1 时,能凑出的最大字符串个数。表的尺寸 (m+1) × (n+1)

这里的 i、j 是两个容量,不是"物品 × 容量" 别跟前面二维背包的 dp[物品][容量] 混起来。这张表已经把物品那一维压掉了(等价于一维滚动),i、j 分别是 0 的预算和 1 的预算。 对应关系也别写反:i ↔ 0 的个数 ↔ m,j ↔ 1 的个数 ↔ n。

状态转移:

不选这个字符串:dp[i][j]
选这个字符串  :dp[i - zeros][j - ones] + 1

dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)

初始化:全 0

遍历顺序:外层遍历物品(字符串),内层两个容量维度都倒序

二维费用不过是把倒序做两遍 本质仍是 01 背包的一维滚动,只是"当前状态"由一个下标变成一对下标。多一个费用维度,就多一层倒序循环,其余一模一样。

代码:

 1class Solution:
 2	def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
 3		dp = [[0] * (n + 1) for _ in range(m + 1)]
 4		for s in strs:                                  # 先物品
 5			zeros = s.count('0')
 6			ones = len(s) - zeros
 7			for i in range(m, zeros - 1, -1):           # 两个容量都倒序
 8				for j in range(n, ones - 1, -1):
 9					dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)
10		return dp[m][n]

复杂度:

  • 时间 O(len(strs) · m · n)
  • 空间 O(m·n)

10 完全背包

10.1 主线:和 01 背包只差一个下标

问题:有一个容量为 W 的背包和 N 件物品,第 i 件物品重量为 w[i]、价值为 v[i],每种物品都有无穷多个,怎么拿能让背包价值最大?

和 01 背包相比,唯一的变化是"每件物品能拿几次":

01 背包完全背包
每件物品最多拿 1 次想拿几次拿几次
“拿"这一支的来源上一行 dp[i-1][j-w]本行 dp[i][j-w]
一维遍历方向容量倒序容量正序

完全背包与 01 背包对比


10.2 二维写法

状态定义: dp[i][j] = 从下标为 [0, i] 的物品里选,每种可以拿无数次,放进容量为 j 的背包,价值总和最大是多少

状态转移:

不拿:dp[i][j] = dp[i-1][j]
拿  :dp[i][j] = dp[i][j - w[i]] + v[i]      注意是 dp[i],不是 dp[i-1]

dp[i][j] = max(dp[i-1][j], dp[i][j - w[i]] + v[i])

初始化(两种写法,和 01 背包的两种写法完全对应):

① 不加哨兵,表尺寸 m × (W+1):

第 0 行需要手填:j >= w[0] 时 dp[0][j] = dp[0][j - w[0]] + v[0],其余 0

② 加哨兵行,表尺寸 (m+1) × (W+1),全 0:

第 0 行代表"一件都不选",天然全 0,不需要手填

遍历顺序:从上往下,从左往右(j 正序,因为"拿"这一支要读本行左边已经算出来的值)


10.3 一维滚动写法

状态定义: dp[j] = 处理到当前物品时,容量为 j 的背包里,价值最大总和

状态转移:

不拿:dp[j](沿用旧值)
拿  :dp[j - w[i]] + v[i]

dp[j] = max(dp[j], dp[j - w[i]] + v[i])

初始化:全 0

遍历顺序:外层物品从上到下,内层容量正序(从左往右)

代码(二维,加哨兵):

 1class Solution:
 2	def backpackfull(self, n: int, nums: List[int]) -> int:
 3		"""
 4		n: 容量  
 5		nums: 可选物品,体积和价值相等
 6		"""
 7		m = len(nums)
 8		dp = [[0] * (n + 1) for _ in range(m + 1)]
 9		for i in range(1, m + 1):
10			num = nums[i - 1]
11			for j in range(n + 1):
12				dp[i][j] = dp[i - 1][j]
13				if j >= num:
14					dp[i][j] = max(dp[i][j], dp[i][j - num] + num)
15		return dp[m][n]

代码(一维):

1class Solution:
2	def backpackfull(self, n: int, nums: List[int]) -> int:
3		dp = [0] * (n + 1)
4		for num in nums:
5			for j in range(num, n + 1):          # 正序
6				dp[j] = max(dp[j], dp[j - num] + num)
7		return dp[n]

复杂度:

  • 时间 O(n·W)
  • 空间 二维 O(n·W) / 一维 O(W)

10.4 计数型:组合还是排列,看物品维还在不在

这一节两道题的转移几乎一样,但循环顺序刚好相反,答案也刚好不同,判据只有一句话:顺序不同的方案算不算两种。

518 vs 377 对比

循环顺序物品维求什么
518外层物品,内层容量存在(被压进外层循环)组合数
377外层容量,内层物品不存在(枚举"最后一步选谁”)排列数

判断一道题要哪种,别背规则,读题 找题干里有没有"顺序不同的序列算不同方案"这句话。写着就是排列(377),没写就是组合(518)。377 的题名叫"组合总和",但题目内容其实求的是排列——名字不可靠,题干才可靠。


10.4.1 零钱兑换 II

问题分析:物品是硬币,价值都等于面额本身,求"凑出 amount 的组合数"——顺序不同不重复计数,所以求的是组合数,遍历顺序为外层物品、内层容量。

状态定义: dp[j] = 凑成金额 j 的方案数

状态转移:

j < coin :dp[j] 不变
j >= coin:dp[j] += dp[j - coin]

初始化:关注定义,dp[0] = 1(凑出 0 元有 1 种方法:什么都不拿),这是整表累加的种子

遍历顺序:外层硬币(保证组合不重复),内外循环不可交换

返回:dp[amount]

代码:

1class Solution:
2	def change(self, amount: int, coins: List[int]) -> int:
3		dp = [0] * (amount + 1)
4		dp[0] = 1
5		for c in coins:
6			for j in range(c, amount + 1):
7				dp[j] += dp[j - c]
8		return dp[amount]

复杂度:

  • 时间 O(len(coins)·amount)
  • 空间 O(amount)

10.4.2 组合总和 Ⅳ

问题分析:题目明确"顺序不同的序列视为不同的组合",所以此题求的其实是排列数,遍历顺序为外层容量、内层物品。

状态定义: dp[j] = 凑成和为 j 的排列个数

状态转移:

j < num :dp[j] 不变
j >= num:dp[j] += dp[j - num]

转移式和 518 一字不差,差别全在循环顺序 dp[j] = Σ dp[j-num](对所有 num)本质是在枚举"最后一个数是谁"——这里根本没有物品维,j 只能放外层,每个 j 都要把所有面额过一遍。1+2 和 2+1 因为"最后一步"不同而被分开计数。

初始化:考虑定义,dp[0] = 1(空序列的和为 0)

遍历顺序:外层容量正序,内层遍历 nums

返回:dp[target]

代码:

1class Solution:
2	def combinationSum4(self, nums: List[int], target: int) -> int:
3		dp = [0] * (target + 1)
4		dp[0] = 1
5		for j in range(1, target + 1):
6			for num in nums:
7				if j >= num:
8					dp[j] += dp[j - num]
9		return dp[target]

复杂度:

  • 时间 O(target·len(nums))
  • 空间 O(target)

10.5 最优型:求最少个数

这两题物品的"价值"都恒为 1(每选一次贡献 1 个计数),求的是最少选几次——完全背包求最优值。转移式的形状统一:

dp[i] = min(dp[i], dp[i - 该物品] + 1)

价值恒为 1、求最小值时,两层循环谁在外都无所谓(跟计数型不同,最优值不区分"怎么凑出来的",只看结果)

10.5.1 零钱兑换

问题分析:视为背包,物品是硬币,价值都为 1(不是面额本身),求装满容量 amount 所需的最少物品数(不是最大价值)。

状态定义: dp[j] = 凑出金额 j 所需的最少硬币数

状态转移:

dp[j] = min(dp[j], dp[j - c] + 1)
        └不拿(旧值)  └拿(用了这枚硬币)

初始化:这题求最小值,不能填 0,填 0 会让 dp[j-c]+1 从一个虚假的 0 起步。

dp[j] = INF   (j >= 1)
dp[0] = 0

返回:

dp[amount]         若 dp[amount] != INF
-1                 若 dp[amount] == INF

代码:

1class Solution:
2	def coinChange(self, coins: List[int], amount: int) -> int:
3		dp = [float("inf")] * (amount + 1)
4		dp[0] = 0
5		for c in coins:
6			for j in range(c, amount + 1):
7				dp[j] = min(dp[j], dp[j - c] + 1)
8		return dp[amount] if dp[amount] != float("inf") else -1

复杂度:

  • 时间 O(len(coins)·amount)
  • 空间 O(amount)

10.5.2 完全平方数

状态定义: dp[i] = 凑出 i 所需的最少完全平方数个数

状态转移:

dp[i] = min(dp[i], dp[i - j*j] + 1)     j*j <= i

初始化:求最小值,只能填 INF:

dp[i] = INF   (i >= 1)
dp[0] = 0

代码:

 1class Solution:
 2	def numSquares(self, n: int) -> int:
 3		dp = [float("inf")] * (n + 1)
 4		dp[0] = 0
 5		for i in range(1, n + 1):
 6			j = 1
 7			while j * j <= i:
 8				dp[i] = min(dp[i], dp[i - j * j] + 1)
 9				j += 1
10		return dp[n]

复杂度:

  • 时间 O(n√n)
  • 空间 O(n)

BFS:把"凑出 n"看成图上的最短路——从 n 出发,每步减去一个完全平方数,最少几步到 0。

 1from collections import deque
 2
 3class Solution:
 4	def numSquares(self, n: int) -> int:
 5		squares = []
 6		k = 1
 7		while k * k <= n:
 8			squares.append(k * k)
 9			k += 1
10		visited = {0, n}
11		q = deque([n])
12		steps = 0
13		while q:
14			steps += 1
15			for _ in range(len(q)):
16				cur = q.popleft()
17				for s in squares:
18					nxt = cur - s
19					if nxt == 0:
20						return steps
21					if nxt > 0 and nxt not in visited:
22						visited.add(nxt)
23						q.append(nxt)
24		return -1

BFS 为什么常常更快 DP 老老实实填满 dp[1..n] 每一格;BFS 一旦触达 0 立刻返回。这题答案通常很小(下面数学解法会说明最多 4),大多数状态根本不需要探索。实测 n=12345 时 BFS 比 DP 快一个数量级以上。

数论公式(拉格朗日四平方和定理 + 勒让德三平方和定理):答案只有 1/2/3/4 四种可能,可以 O(√n) 直接判定。

 1import math
 2
 3class Solution:
 4	def numSquares(self, n: int) -> int:
 5		def is_square(x: int) -> bool:
 6			r = math.isqrt(x)
 7			return r * r == x
 8
 9		if is_square(n):
10			return 1
11		t = n
12		while t % 4 == 0:
13			t //= 4
14		if t % 8 == 7:
15			return 4
16		for a in range(math.isqrt(n) + 1):
17			if is_square(n - a * a):
18				return 2
19		return 3

10.6 判定型:物品不是数字 单词拆分

问题分析:给定字符串 s 和词典 wordDict,判断 s 能否被切分成词典单词的拼接(单词可重复使用)。这题的"物品"是字符串而不是数字,重量、价值这些数值概念不再适用,但"容量正序、每种物品可重复取"的骨架不变——本质仍是完全背包,只是"装满"变成了字符串完全匹配。

状态定义: dp[i] = 前缀 s[0:i] 能否被词典单词拼出

状态转移:枚举最后一个单词的起点 j

dp[i] = True,若存在 j < i 使得 dp[j] = True 且 s[j:i] ∈ words

初始化:

dp[i] 全部为 False    (i ∈ [0, n],先全填 False)
dp[0] = True          (空前缀天然可切)

剪枝:j 只需要枚举 [max(0, i - maxlen), i),maxlen 是词典里最长单词的长度

139 转移与剪枝

为什么可以跳过更早的 j 若 j < i - maxlen,s[j:i] 的长度已经超过词典最长单词,不可能出现在 words 里,连查都不用查。内层枚举范围从 O(i) 收窄到 O(maxlen)。

返回:dp[n]

代码:

 1class Solution:
 2	def wordBreak(self, s: str, wordDict: List[str]) -> bool:
 3		words = set(wordDict)
 4		maxlen = max(len(w) for w in words)
 5		n = len(s)
 6		dp = [False] * (n + 1)
 7		dp[0] = True
 8		for i in range(1, n + 1):
 9			for j in range(max(0, i - maxlen), i):
10				if dp[j] and s[j:i] in words:
11					dp[i] = True
12		return dp[n]

复杂度:

  • 不剪枝是 O(n²) 次子串匹配
  • maxlen 剪枝后是 O(n·maxlen),词典单词都短时效果明显