引言

亲爱的技术同仁们,大家好!我是你们的博主 qmwneb946。在算法的世界里,动态规划(Dynamic Programming, DP)无疑是一颗璀璨的明珠。它以其优雅的“分而治之”思想,能够高效地解决那些看似复杂、拥有重叠子问题和最优子结构特性的难题,从最短路径到背包问题,从字符串匹配到生物信息学,DP的身影无处不在。

然而,掌握DP并非一蹴而就。当你能够熟练地定义状态、推导转移方程时,一个新的挑战随之而来:如何优化你的DP算法?即使是DP,在面对海量数据或严苛的时间、空间限制时,也可能变得力不从心。一个朴素的 O(N3)O(N^3) 甚至 O(N2)O(N^2) 的DP解法,在 NN 达到 10510^5 级别时,就可能导致时间超限(TLE)。同样,一个 O(N2)O(N^2) 的空间复杂度,在 NN 达到 10410^4 级别时,就可能导致内存溢出(MLE)。

因此,理解并掌握动态规划的优化技巧,是每一个算法工程师进阶的必经之路。本文将带你深入探索动态规划的各种优化策略,从基础的空间优化到利用高级数据结构加速,再到精妙的决策单调性等数学性质。我们将一起揭开这些“黑科技”的神秘面纱,助你突破DP的性能瓶颈,让你的算法在效率上更上一层楼!

准备好了吗?让我们一起踏上这场DP优化之旅!

动态规划基础回顾

在深入优化之前,我们先快速回顾一下动态规划的核心概念。动态规划的本质在于将一个大问题分解成相互关联的子问题,并通过存储子问题的解来避免重复计算。它主要依赖于两个关键特性:

  1. 最优子结构 (Optimal Substructure):问题的最优解包含其子问题的最优解。
  2. 重叠子问题 (Overlapping Subproblems):在解决原问题时,很多子问题被重复计算。

DP通常有两种实现方式:

  • 记忆化搜索 (Top-Down / Memoization):从原问题出发,递归地解决子问题,并将子问题的解存储起来。
  • 递推 (Bottom-Up / Tabulation):从最简单的子问题开始,逐步计算出更大规模子问题的解,直到原问题。

让我们以经典的斐波那契数列为例:Fn=Fn1+Fn2F_n = F_{n-1} + F_{n-2},其中 F0=0,F1=1F_0 = 0, F_1 = 1

记忆化搜索实现

1
2
3
4
5
6
7
8
9
10
11
12
13
memo = {} # 用于存储已计算的斐波那契数

def fib_memo(n):
if n <= 1:
return n
if n in memo:
return memo[n]

result = fib_memo(n - 1) + fib_memo(n - 2)
memo[n] = result
return result

# print(fib_memo(10)) # 输出 55

递推实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def fib_tabulation(n):
if n <= 1:
return n

dp = [0] * (n + 1) # dp[i] 存储第 i 个斐波那契数
dp[0] = 0
dp[1] = 1

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

return dp[n]

# print(fib_tabulation(10)) # 输出 55

这两种方法都将原始指数级的复杂度优化到了 O(N)O(N) 的时间复杂度。然而,递推实现中,我们使用了 O(N)O(N) 的空间复杂度来存储所有的 dpdp 值。对于更大的 NN,这仍然可能成为瓶颈。这就引出了我们的第一个优化方向:空间优化。

核心优化策略:状态压缩

状态压缩是动态规划中最常见且效果显著的优化手段之一。它主要通过减少DP数组的维度或利用位运算来表示状态,从而大幅节省内存空间。

滚动数组优化 (Space Optimization / Rolling Array)

原理:
许多DP问题的状态转移方程 dp[i]dp[i] 只依赖于前 kk 个状态,即 dp[i]dp[i] 的计算仅需要 dp[i1],dp[i2],,dp[ik]dp[i-1], dp[i-2], \dots, dp[i-k]。在这种情况下,我们无需存储整个 dpdp 数组,而只需维护一个固定大小(通常是 k+1k+1 或更小)的数组来存储当前和前 kk 个所需的状态。这被称为“滚动数组”优化。

示例:斐波那契数列 (空间优化到 O(1)O(1))

在上面的递推实现中,dp[i] 只依赖于 dp[i-1]dp[i-2]。因此,我们只需要两个变量来存储前两个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
def fib_optimized(n):
if n <= 1:
return n

# 只需要存储最近的两个值
a, b = 0, 1

for _ in range(2, n + 1):
a, b = b, a + b # 更新 a 为旧 b,b 为旧 a+b

return b

# print(fib_optimized(10)) # 输出 55

空间复杂度从 O(N)O(N) 降低到了 O(1)O(1)

示例:0/1 背包问题 (空间优化到 O(W)O(W))

经典的0/1背包问题定义:给定 NN 件物品,每件物品有重量 wiw_i 和价值 viv_i。背包容量为 WW。求在不超过背包容量的前提下,能装入物品的最大总价值。
朴素的DP状态定义:dp[i][j]dp[i][j] 表示考虑前 ii 件物品,背包容量为 jj 时的最大价值。
转移方程:
dp[i][j]=dp[i1][j]dp[i][j] = dp[i-1][j] (不选第 ii 件物品)
dp[i][j]=max(dp[i1][j],dp[i1][jwi]+vi)dp[i][j] = \max(dp[i-1][j], dp[i-1][j-w_i] + v_i) (如果 jwij \ge w_i,可以选第 ii 件物品)

观察发现,dp[i][j]dp[i][j] 只依赖于 dp[i1][]dp[i-1][\dots]。这意味着我们只需要两行空间:当前行和上一行。甚至可以进一步优化到一行。

如果我们使用一行数组 dp[j]dp[j] 表示当前容量为 jj 时的最大价值。当我们处理第 ii 件物品时,为了确保 dp[jwi]dp[j-w_i] 仍然是“上一件物品”的状态,我们需要逆序遍历 jj

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def knapsack_optimized(weights, values, capacity):
n = len(weights)
# dp[j] 表示容量为 j 时的最大价值
dp = [0] * (capacity + 1)

for i in range(n): # 遍历物品
# 逆序遍历容量,确保 dp[j-weights[i]] 是上一件物品的状态
# 如果是正序,dp[j-weights[i]] 会在当前物品的计算中被更新
for j in range(capacity, weights[i] - 1, -1):
dp[j] = max(dp[j], dp[j - weights[i]] + values[i])

return dp[capacity]

# weights = [2, 1, 3]
# values = [4, 2, 3]
# capacity = 4
# print(knapsack_optimized(weights, values, capacity)) # 输出 6 (选物品1和物品2)

空间复杂度从 O(NW)O(N \cdot W) 降低到 O(W)O(W)

示例:最长公共子序列 (LCS) (空间优化到 O(min(N,M))O(\min(N, M)))

LCS 问题朴素的DP状态:dp[i][j]dp[i][j] 表示字符串 S1S1 的前 ii 个字符和 S2S2 的前 jj 个字符的最长公共子序列长度。
转移方程:
如果 S1[i1]==S2[j1]S1[i-1] == S2[j-1]dp[i][j]=dp[i1][j1]+1dp[i][j] = dp[i-1][j-1] + 1
否则:dp[i][j]=max(dp[i1][j],dp[i][j1])dp[i][j] = \max(dp[i-1][j], dp[i][j-1])

dp[i][j]dp[i][j] 同样只依赖于 dp[i1]dp[i-1]dp[i]dp[i] 当前行的前一个值。
我们可以使用两个一维数组 prev_dpcurrent_dp 来交替存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def lcs_optimized(s1, s2):
m, n = len(s1), len(s2)

# 确保滚动数组是针对较短的那个字符串
# 这样空间复杂度是 O(min(m, n))
if m < n:
s1, s2 = s2, s1
m, n = n, m

prev_dp = [0] * (n + 1)
current_dp = [0] * (n + 1)

for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
current_dp[j] = prev_dp[j-1] + 1
else:
current_dp[j] = max(prev_dp[j], current_dp[j-1])

# 更新 prev_dp 为当前行的状态,为下一轮循环做准备
prev_dp = list(current_dp) # 注意这里需要深拷贝,或者交换引用
# Python中直接 prev_dp = current_dp 会指向同一个列表
# 也可以通过 prev_dp, current_dp = current_dp, prev_dp 交换引用
# 但那样需要一个临时的数组来保存旧的 current_dp
# 最直接的做法是:
# for k in range(n + 1):
# prev_dp[k] = current_dp[k]

return prev_dp[n]

# print(lcs_optimized("abcde", "ace")) # 输出 3

空间复杂度从 O(MN)O(M \cdot N) 降低到 O(min(M,N))O(\min(M, N))

位运算状态压缩 (Bitmask DP)

原理:
当DP的状态需要表示一个集合、子集、或者某种布尔状态组合时,如果集合的元素数量较小(通常不超过20),我们可以使用一个整数的二进制位来表示这些状态。整数的第 kk 位为1表示第 kk 个元素在集合中,为0表示不在。这种技术被称为位压缩DP或状压DP。

适用场景:

  • 需要遍历所有子集的问题。
  • 涉及路径规划、任务分配等,且节点/任务数量不多的问题 (N20N \le 20)。
  • 需要表示一个集合的连通性或访问状态。

示例:旅行商问题 (TSP)

旅行商问题是一个经典的NP-hard问题,但对于小规模的图 (N20N \le 20),可以通过状压DP求解。
问题:给定 NN 个城市和城市间距离,从某个城市出发,遍历所有城市恰好一次,并回到起点,求最短总距离。

状态定义:
dp[mask][last_city]dp[\text{mask}][\text{last\_city}] 表示已经访问过的城市集合为 mask(一个二进制数),并且当前停留在 last_city 时的最短路径。
mask 的第 ii 位为1表示城市 ii 已经访问过。

转移方程:
dp[mask(1j)][j]=min(dp[mask(1j)][j],dp[mask][i]+dist[i][j])dp[\text{mask} | (1 \ll j)][j] = \min(dp[\text{mask} | (1 \ll j)][j], dp[\text{mask}][i] + \text{dist}[i][j])
其中,jj 是未访问过的城市,且从 iijj 有路径。

初始状态:
dp[1start_city][start_city]=0dp[1 \ll \text{start\_city}][\text{start\_city}] = 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import math

def tsp_bitmask(dist_matrix, start_city=0):
n = len(dist_matrix)
# dp[mask][last_city]
# mask: 访问过的城市集合的位掩码
# last_city: 当前所在的城市
dp = [[math.inf] * n for _ in range(1 << n)]

# 初始状态:从start_city出发,只访问了start_city自己
dp[1 << start_city][start_city] = 0

# 遍历所有可能的mask (从1开始,因为0表示空集)
for mask in range(1, 1 << n):
# 遍历当前mask中的所有城市i,表示当前停留在i
for i in range(n):
if (mask >> i) & 1: # 如果城市i在mask中
# 遍历所有可能的下一个城市j
for j in range(n):
if not ((mask >> j) & 1): # 如果城市j不在mask中 (未访问过)
new_mask = mask | (1 << j) # 将j加入mask
# 更新dp[new_mask][j]
dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist_matrix[i][j])

# 最后,从所有已访问所有城市 (mask = (1 << n) - 1) 的状态中,
# 找到回到起点start_city的最短路径
min_cost = math.inf
final_mask = (1 << n) - 1
for i in range(n):
min_cost = min(min_cost, dp[final_mask][i] + dist_matrix[i][start_city])

return min_cost

# 示例:4个城市,距离矩阵
# dist = [
# [0, 10, 15, 20],
# [10, 0, 35, 25],
# [15, 35, 0, 30],
# [20, 25, 30, 0]
# ]
# print(tsp_bitmask(dist, 0)) # 期望结果 80 (0->1->3->2->0: 10+25+30+15=80)

TSP的复杂度是 O(N22N)O(N^2 \cdot 2^N),虽然对于大N不可行,但对于 N20N \le 20 却是一种高效的精确解法。

优化转移方程计算:数据结构加速

当DP转移方程的计算涉及到区间查询或最值问题时,朴素的循环计算可能导致时间复杂度过高。这时,引入合适的数据结构可以显著加速转移过程。

单调队列优化 (Monotonic Queue Optimization)

原理:
单调队列(通常是双端队列 deque)常用于优化形如 dp[i]=minj[ik,i1](dp[j]+C(j))dp[i] = \min_{j \in [i-k, i-1]} (dp[j] + C(j))dp[i]=maxj[ik,i1](dp[j]+C(j))dp[i] = \max_{j \in [i-k, i-1]} (dp[j] + C(j)) 的DP转移方程。它能在一个滑动窗口内快速找到最值,将 O(K)O(K) 的窗口扫描优化为 O(1)O(1) 的均摊时间复杂度。

适用场景:

  • DP转移依赖于一个固定大小的滑动窗口内的最值。
  • 决策点 jj 的范围是 ikj<ii-k \le j < i

示例:滑动窗口最大值 (DP应用)

虽然这不是一个典型的DP问题,但其核心思想可以应用于DP优化。假设我们需要一个DP问题,其中 dp[i]dp[i] 依赖于前 KKdpdp 值的最大值,即 dp[i]=Ai+max(dp[iK],,dp[i1])dp[i] = A_i + \max(dp[i-K], \dots, dp[i-1])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from collections import deque

def max_sliding_window_dp_like(nums, k):
n = len(nums)
if n == 0:
return []

# dp[i] = nums[i] + max_val_in_window_of_dp_vals_ending_at_i-1
# 假设我们实际需要的是一个累积的某种最大值,这里简化为滑动窗口最大值
# 这是一个辅助函数,展示单调队列的用法

output = []
# deque 存储 (值, 索引) 对
# 队首是当前窗口的最大值(或最小值,取决于问题)
# 队内元素值单调递减 (为了找最大值)
dq = deque()

for i in range(n):
# 移除窗口外的元素
if dq and dq[0][1] <= i - k:
dq.popleft()

# 移除所有小于当前元素的元素(因为它们不可能成为最大值了)
while dq and dq[-1][0] < nums[i]:
dq.pop()

dq.append((nums[i], i))

# 当窗口形成后,队首就是当前窗口的最大值
if i >= k - 1:
output.append(dq[0][0])

return output

# nums = [1,3,-1,-3,5,3,6,7], k = 3
# print(max_sliding_window_dp_like(nums, 3)) # [3, 3, 5, 5, 6, 7]

当DP转移方程是 dp[i]=cost(i)+minj[ik,i1](dp[j])dp[i] = \text{cost}(i) + \min_{j \in [i-k, i-1]} (dp[j]) 时,单调队列维护的是 dp[j]dp[j] 的单调递增序列。每次计算 dp[i]dp[i] 时,队首就是当前窗口内的 dp[j]dp[j] 最小值。

线段树/树状数组优化 (Segment Tree/Fenwick Tree Optimization)

原理:
线段树和树状数组是强大的数据结构,能够高效地处理区间查询(如求和、求最值)和单点/区间修改操作。当DP转移方程中涉及到对一个范围内的DP值进行查询取最值或求和时,它们可以将 O(N)O(N) 的遍历优化到 O(logN)O(\log N)

适用场景:

  • DP转移方程形如 dp[i]=maxj<i and condition(j)(dp[j]+value(j))dp[i] = \max_{j < i \text{ and condition}(j)} (dp[j] + \text{value}(j))
  • condition(j) 使得 jj 的取值范围不固定,或者 value(j)value(j)jj 有关。
  • 需要快速查询某个区间内的最值或求和。

示例:最长上升子序列 (LIS) (优化到 O(NlogN)O(N \log N))

LIS问题朴素DP:dp[i]dp[i] 表示以 nums[i]nums[i] 结尾的最长上升子序列长度。
转移方程:dp[i]=1+max({dp[j]j<i and nums[j]<nums[i]})dp[i] = 1 + \max(\{dp[j] \mid j < i \text{ and } nums[j] < nums[i]\})
朴素实现的时间复杂度为 O(N2)O(N^2)

我们可以用线段树来优化这个过程。将 nums[i]nums[i] 的值域离散化(如果值域很大),然后将线段树的叶节点表示离散化后的值,线段树节点存储该值域范围内遇到的最大 dpdp 值。

  1. 离散化:nums 数组中的所有元素进行离散化,将它们映射到 0M10 \dots M-1 的范围,其中 MM 是不同元素的数量。
  2. 线段树: 构建一个线段树,其叶子节点对应离散化后的值。每个节点存储其覆盖值域范围内的最大LIS长度。
  3. DP过程: 遍历 nums[i]nums[i]
    • 查询:在 nums[i]nums[i] 对应的离散化值之前的所有值域范围内,查询最大的LIS长度(即 max_len=query(0,val[i]1)max\_len = \text{query}(0, \text{val}[i]-1))。
    • 更新:当前 dp[i]=max_len+1dp[i] = max\_len + 1。然后用 max_len+1max\_len + 1 更新线段树中 nums[i]nums[i] 对应位置的值(取最大值)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 伪代码演示,线段树的具体实现略去
# 假设有一个 SegmentTree 类,支持 update(index, value) 和 query(start, end)
class SegmentTree:
def __init__(self, size):
self.tree = [0] * (4 * size) # 通常用 4*N 的数组来存储线段树
self.size = size

def update(self, idx, val, tree_idx=1, left=0, right=None):
if right is None:
right = self.size - 1

if left == right:
self.tree[tree_idx] = max(self.tree[tree_idx], val) # 更新为最大值
return

mid = (left + right) // 2
if idx <= mid:
self.update(idx, val, 2 * tree_idx, left, mid)
else:
self.update(idx, val, 2 * tree_idx + 1, mid + 1, right)

self.tree[tree_idx] = max(self.tree[2 * tree_idx], self.tree[2 * tree_idx + 1])

def query(self, q_left, q_right, tree_idx=1, left=0, right=None):
if right is None:
right = self.size - 1

if q_left > q_right: # 查询范围无效
return 0

if q_left <= left and right <= q_right:
return self.tree[tree_idx]

mid = (left + right) // 2
res = 0
if q_left <= mid:
res = max(res, self.query(q_left, q_right, 2 * tree_idx, left, mid))
if q_right > mid:
res = max(res, self.query(q_left, q_right, 2 * tree_idx + 1, mid + 1, right))
return res

def length_of_lis_optimized(nums):
if not nums:
return 0

# 离散化处理,将元素值映射到 0 到 M-1
sorted_unique_nums = sorted(list(set(nums)))
val_to_idx = {val: i for i, val in enumerate(sorted_unique_nums)}

# 线段树的大小是离散化后值的数量 M
max_val_idx = len(sorted_unique_nums)
segment_tree = SegmentTree(max_val_idx)

max_lis_len = 0
for num in nums:
# 获取当前 num 离散化后的索引
current_idx = val_to_idx[num]

# 查询在 num 之前(值更小)的元素中,最长的 LIS 长度
# 对应的离散化索引范围是 [0, current_idx - 1]
prev_max_lis = segment_tree.query(0, current_idx - 1)

# 当前 num 形成的 LIS 长度
current_lis = prev_max_lis + 1

# 用 current_lis 更新线段树中 num 对应位置的最大值
segment_tree.update(current_idx, current_lis)

max_lis_len = max(max_lis_len, current_lis)

return max_lis_len

# nums = [10, 9, 2, 5, 3, 7, 101, 18]
# print(length_of_lis_optimized(nums)) # 输出 4 (2, 3, 7, 18 或 2, 5, 7, 18)

通过线段树,LIS问题的时间复杂度从 O(N2)O(N^2) 降低到 O(NlogM)O(N \log M) (其中 MM 是不同元素的数量,通常 MNM \le N),或者 O(NlogVmax)O(N \log V_{max}) 如果不离散化而是直接使用值域。

高级优化技巧

这些高级优化技巧通常依赖于DP问题的特定数学性质,如决策单调性或凸性。它们能将原本 O(N2)O(N^2) 甚至更高的复杂度优化到 O(NlogN)O(N \log N)O(Nlog2N)O(N \log^2 N)

决策单调性优化

当DP转移方程的决策点(即 jj 的取值)具有单调性时,我们可以利用这一性质来加速计算。

斜率优化 (Convex Hull Trick, CHT)

原理:
斜率优化是一种用于优化形如 dp[i]=minj<i(A[j]B[i]+C[j])dp[i] = \min_{j < i} (A[j] \cdot B[i] + C[j]) 的DP转移方程的技术。
这类方程可以变形为 dp[i]=B[i]A[j]+C[j]dp[i] = B[i] \cdot A[j] + C[j]
y=dp[j]+C[j]y = dp[j] + C[j]m=A[j]m = A[j]x=B[i]x = B[i]b=C[j]b = C[j]
我们希望在给定 x=B[i]x=B[i] 时,找到最小的 yj=mjx+bjy_j = m_j x + b_j
这相当于在一堆直线上,求在给定 xx 坐标时的最低点。这些最低点会构成一个下凸壳(Convex Hull)。
通过维护一个凸壳,我们可以利用直线的斜率单调性(或 xx 坐标的单调性)来快速找到最优决策点,通常使用单调队列或平衡二叉搜索树来维护凸壳。

数学推导:
假设我们有两个决策点 j1j_1j2j_2,其中 j1<j2j_1 < j_2
如果 j1j_1 优于 j2j_2,则 dp[j1]+cost(j1,i)<dp[j2]+cost(j2,i)dp[j_1] + \text{cost}(j_1, i) < dp[j_2] + \text{cost}(j_2, i)
A[j]A[j]B[i]B[i] 都单调时,可以维护一个单调队列(存放直线的索引),每次计算 dp[i]dp[i] 时,移除队首不再最优的直线,然后将新直线加入队尾。

适用场景:

  • DP转移方程可以化为 dp[i]=min(AjBi+Cj)dp[i] = \min (A_j \cdot B_i + C_j) 的形式。
  • 通常需要 AjA_jBiB_i 至少有一个是单调的。

示例:任务调度问题 (简化版)
假设有 NN 个任务,每个任务有一个处理时间 tit_i 和一个冷却时间 cic_i。我们需要将任务分组处理,每组任务在处理完成后需要一个固定的冷却时间 SS。求完成所有任务的最小总时间。
dp[i]dp[i] 表示处理完前 ii 个任务的最小总时间。
dp[i]=min0j<i(dp[j]+(k=j+1itk)+S)dp[i] = \min_{0 \le j < i} (dp[j] + (\sum_{k=j+1}^i t_k) + S)
令前缀和 Ti=k=1itkT_i = \sum_{k=1}^i t_k
dp[i]=min0j<i(dp[j]+(TiTj)+S)dp[i] = \min_{0 \le j < i} (dp[j] + (T_i - T_j) + S)
dp[i]=Ti+S+min0j<i(dp[j]Tj)dp[i] = T_i + S + \min_{0 \le j < i} (dp[j] - T_j)
这不是典型的斜率优化形式。考虑另一个变种:
dp[i]=minj<i(dp[j]+(TiTj)2+C)dp[i] = \min_{j < i} (dp[j] + (T_i - T_j)^2 + C)
dp[i]=minj<i(dp[j]+Ti22TiTj+Tj2+C)dp[i] = \min_{j < i} (dp[j] + T_i^2 - 2T_i T_j + T_j^2 + C)
dp[i]=Ti2+C+minj<i(dp[j]+Tj22TiTj)dp[i] = T_i^2 + C + \min_{j < i} (dp[j] + T_j^2 - 2T_i T_j)
dp[j]+Tj2=Yjdp[j] + T_j^2 = Y_j2Ti=Xi2T_i = X_iTj=MjT_j = M_j
dp[i]=Ti2+C+minj<i(YjXiMj)dp[i] = T_i^2 + C + \min_{j < i} (Y_j - X_i M_j)
dp[i]=Ti2+C+minj<i(MjXi+Yj)dp[i] = T_i^2 + C + \min_{j < i} (-M_j X_i + Y_j)
这就是 y=mx+by = mx + b 的形式,其中 m=Mj=Tjm = -M_j = -T_jx=Xi=2Tix = X_i = 2T_ib=Yj=dp[j]+Tj2b = Y_j = dp[j] + T_j^2
由于 TjT_j 是单调递增的(Tj0T_j \ge 0),所以 Tj-T_j 是单调递减的。而 2Ti2T_i 也是单调递增的。
我们可以用一个双端队列维护一个下凸壳。

代码框架 (使用单调队列维护下凸壳):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from collections import deque

def solve_convex_hull_trick_dp(N, T_values, C):
# dp[i] = T_i^2 + C + min_{j < i} (-2 * T_j * (2 * T_i) + (dp[j] + T_j^2))
# Y = dp[j] + T_j^2
# M = -2 * T_j (斜率)
# X = T_i (横坐标)
# B = Y (截距)
# 我们要找最小的 Y - M*X,即最低的截距。
# 因为 M=-2*T_j,而 T_j 递增,所以 M 递减。
# X=T_i 递增。

dp = [0] * (N + 1)
# dq 存储索引 j,这些 j 对应的直线 (M_j, Y_j) 构成下凸壳
# 队首是当前最优决策点
dq = deque()
dq.append(0) # dp[0] = 0, T_0 = 0 (假设)

# 辅助函数:计算两条直线的交点横坐标
def intersect(j1, j2):
# Y1 - M1*X = Y2 - M2*X
# Y1 - Y2 = (M1 - M2)*X
# X = (Y1 - Y2) / (M1 - M2)
# 对应到 dp[j] + T_j^2 - (-2*T_j)*X = dp[j'] + T_j'^2 - (-2*T_j')*X
# 设 X 为 x 轴坐标,M 为斜率,Y 为 y 轴截距
# Y_j = dp[j] + T_j^2
# M_j = -2 * T_j
# 交点横坐标 x = (Y_j2 - Y_j1) / (M_j1 - M_j2)
# 注意 M_j1 < M_j2
return (dp[j2] + T_values[j2]**2 - (dp[j1] + T_values[j1]**2)) / ((-2 * T_values[j1]) - (-2 * T_values[j2]))

for i in range(1, N + 1):
# 因为 X (即 T_i) 是单调递增的,所以队首不再最优的直线可以弹出
# 检查队首的第二条直线和第一条直线的交点是否在当前 X 之前
# 如果是,说明第一条直线不再是队首在 X 处的最低点
while len(dq) >= 2 and intersect(dq[0], dq[1]) <= T_values[i]:
dq.popleft()

j = dq[0] # 当前最优决策点
# 转移方程:dp[i] = T_i^2 + C + (dp[j] + T_j^2 - 2*T_i*T_j)
dp[i] = T_values[i]**2 + C + (dp[j] + T_values[j]**2 - 2 * T_values[i] * T_values[j])

# 将新的直线 (i) 加入队列尾部
# 维护凸壳的下凸性:如果新加入的直线与队尾的倒数第二条直线的交点
# 比队尾倒数第一条直线和队尾的交点更左,则队尾倒数第一条直线被“覆盖”了
# 需要弹出队尾的倒数第一条直线
while len(dq) >= 2 and intersect(dq[-2], dq[-1]) >= intersect(dq[-1], i):
dq.pop()
dq.append(i)

return dp[N]

# 假设 T_values 是前缀和,T_0=0
# N = 3
# T_values = [0, 1, 3, 6] # T_0=0, T_1=1, T_2=3, T_3=6
# C = 10
# print(solve_convex_hull_trick_dp(N, T_values, C))

斜率优化将复杂度从 O(N2)O(N^2) 降低到 O(N)O(N)O(NlogN)O(N \log N)(如果 XX 不单调,需要用平衡二叉搜索树维护凸壳)。

分治优化 (Divide and Conquer Optimization / D&C Optimization)

原理:
分治优化适用于一类特殊的DP问题,其决策点 k[i][j]k[i][j](表示计算 dp[i][j]dp[i][j] 时的最优决策点)满足单调性:k[i][j1]k[i][j]k[i+1][j]k[i][j-1] \le k[i][j] \le k[i+1][j]。这被称为四边形不等式性质
当满足这种性质时,可以通过分治策略来加速计算。我们不必对每个 dp[i][j]dp[i][j] 都遍历所有可能的 kk,而是利用 kk 的单调性将搜索范围限制在一个更小的区间内。

核心思想:
如果 dp[i][j]=minkj(dp[i1][k]+cost(k,j))dp[i][j] = \min_{k \le j} (dp[i-1][k] + \text{cost}(k, j)),且决策点 kk 满足单调性,我们可以用分治法递归地计算。
compute(L, R, optL, optR) 函数负责计算 dpdp 数组在 [L,R][L, R] 范围内的值,并已知这些值的最优决策点在 [optL,optR][optL, optR] 范围内。
mid = (L+R)/2,然后在其 [optL,optR][optL, optR] 范围内暴力查找 midmid 的最优决策点 kmidk_{mid}
接着,递归调用 compute(L, mid-1, optL, k_{mid})compute(mid+1, R, k_{mid}, optR)

适用场景:

  • 形如 dp[i][j]=mink<j(dp[i][k]+dp[k+1][j]+cost(i,j))dp[i][j] = \min_{k < j} (dp[i][k] + dp[k+1][j] + \text{cost}(i, j)) 的区间DP。
  • 需要证明决策点 k[i][j]k[i][j] 具有单调性。

示例:区间DP (Knuth Optimization)
最经典的例子是矩阵链乘问题或最优二叉搜索树问题。
对于 dp[i][j]dp[i][j] 表示区间 [i,j][i, j] 的最优解,转移方程通常是:
dp[i][j]=minik<j(dp[i][k]+dp[k+1][j]+W(i,j))dp[i][j] = \min_{i \le k < j} (dp[i][k] + dp[k+1][j] + W(i, j))
如果 W(i,j)W(i, j) 满足四边形不等式:对于任意 abcda \le b \le c \le d,有 W(a,c)+W(b,d)W(a,d)+W(b,c)W(a, c) + W(b, d) \le W(a, d) + W(b, c)
W(i,j)W(i, j) 满足单调性:对于任意 iji \le j,有 W(i,j)W(i,j+1)W(i, j) \le W(i, j+1)W(i,j)W(i1,j)W(i, j) \le W(i-1, j)
那么决策点 k[i][j]k[i][j] 满足 k[i][j1]k[i][j]k[i+1][j]k[i][j-1] \le k[i][j] \le k[i+1][j]
此时,可以将 O(N3)O(N^3) 的区间DP优化到 O(N2)O(N^2)

Knuth 优化流程:

  1. 计算 dp[i][i]dp[i][i]k[i][i]k[i][i](通常为 ii)。
  2. 从区间长度 len=2len = 2 遍历到 NN
  3. 对于每个 iij=i+len1j = i + len - 1
    • dp[i][j]dp[i][j] 的决策点 kk 的搜索范围不是 [i,j1][i, j-1],而是 [k[i][j1],k[i+1][j]][k[i][j-1], k[i+1][j]]
    • 在新的较小范围内找到最优的 kk,更新 dp[i][j]dp[i][j]k[i][j]k[i][j]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 伪代码演示 Knuth 优化
# 假设 cost[i][j] 满足四边形不等式和单调性
def knuth_optimized_dp(n, cost_matrix):
dp = [[0] * n for _ in range(n)]
k_opt = [[0] * n for _ in range(n)] # 存储决策点

# 初始化长度为1的区间
for i in range(n):
dp[i][i] = cost_matrix[i][i] # 这里的cost_matrix[i][i]应根据具体问题定义
k_opt[i][i] = i

# 遍历区间长度
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1

dp[i][j] = float('inf')

# 决策点 k 的搜索范围从 k[i][j-1] 到 k[i+1][j]
# 注意边界条件,k[i][j-1] 至少为 i,k[i+1][j] 最多为 j-1
# Python中索引可能需要调整
start_k = k_opt[i][j-1] if j > i else i
end_k = k_opt[i+1][j] if i + 1 < n else j - 1

# 确保搜索范围有效且在 [i, j-1] 内
start_k = max(i, start_k)
end_k = min(j - 1, end_k)

for k in range(start_k, end_k + 1):
current_cost = dp[i][k] + dp[k+1][j] + cost_matrix[i][j]
if current_cost < dp[i][j]:
dp[i][j] = current_cost
k_opt[i][j] = k

return dp[0][n-1]

Knuth 优化将 O(N3)O(N^3) 复杂度降至 O(N2)O(N^2)

Meet-in-the-Middle (MITM)

原理:
分治法的一种特例,当问题的状态空间过大,不能直接用DP解决时,可以尝试将问题分成两半,分别计算这两半的子问题,然后将两半的结果合并。这通常能将 O(CN)O(C^N)O(2N)O(2^N) 的复杂度降低到 O(CN/2)O(C^{N/2})O(2N/2)O(2^{N/2})

适用场景:

  • 状态数量呈指数级增长,但可以被拆分成两个独立的子问题。
  • 合并两个子问题的结果可以在较短的时间内完成(如排序后双指针)。

示例:子集和问题
给定一个整数集合 SS 和一个目标和 TT,问 SS 中是否存在一个子集,其元素之和等于 TT
朴素的DP:dp[i][s]dp[i][s] 表示前 ii 个数是否能组成和 ss,复杂度 O(NT)O(N \cdot T)
如果 TT 很大,但 NN 较小(如 N=40N=40),直接DP不可行。

MITM 策略:

  1. SS 分成两半 S1S_1S2S_2
  2. 分别计算 S1S_1 的所有子集和,存储在一个列表 sums1 中。
  3. 分别计算 S2S_2 的所有子集和,存储在一个列表 sums2 中。
  4. sums2 进行排序。
  5. 对于 sums1 中的每一个和 s1s_1,在 sums2 中查找是否存在 Ts1T - s_1。这可以通过二分查找或双指针在排序后的 sums2 中完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def subset_sum_mitm(nums, target):
n = len(nums)

# 分成两半
half1 = nums[:n // 2]
half2 = nums[n // 2:]

# 计算第一半的所有子集和
sums1 = []
for i in range(1 << len(half1)):
current_sum = 0
for j in range(len(half1)):
if (i >> j) & 1:
current_sum += half1[j]
sums1.append(current_sum)

# 计算第二半的所有子集和
sums2 = []
for i in range(1 << len(half2)):
current_sum = 0
for j in range(len(half2)):
if (i >> j) & 1:
current_sum += half2[j]
sums2.append(current_sum)

sums2.sort() # 对第二半的子集和排序,以便二分查找

# 检查是否存在 sum1 + sum2 = target
for s1 in sums1:
needed_s2 = target - s1

# 使用二分查找在 sums2 中寻找 needed_s2
# Python 的 bisect_left 可以找到第一个大于或等于 needed_s2 的索引
import bisect
idx = bisect.bisect_left(sums2, needed_s2)
if idx < len(sums2) and sums2[idx] == needed_s2:
return True

return False

# nums = [1, 2, 3, 4, 5], target = 9
# print(subset_sum_mitm(nums, 9)) # True (例如 1+3+5=9 或 4+5=9)
# nums = [1, 2, 3, 4, 5], target = 100
# print(subset_sum_mitm(nums, 100)) # False

时间复杂度从 O(2N)O(2^N) 优化到 O(2N/2log(2N/2))=O(2N/2N/2)O(2^{N/2} \cdot \log(2^{N/2})) = O(2^{N/2} \cdot N/2)。对于 N=40N=40 的情况,原始复杂度为 2402^{40},优化后约为 220202^{20} \cdot 20,显著提升。

结论

恭喜你,已经走过了动态规划优化的诸多秘境!从基础的滚动数组到精妙的位压缩,从借力数据结构的单调队列与线段树,到深挖数学性质的斜率优化与分治优化,再到打破指数瓶颈的Meet-in-the-Middle,我们探讨了各种将DP算法推向极致的策略。

掌握这些优化技巧,不仅仅是为了通过算法竞赛中的复杂测试用例,更重要的是它能培养你对问题结构更深层次的理解,以及对算法性能分析的敏锐洞察力。每一次成功的优化,都源于对DP状态、转移方程及其内在数学性质的精准把握。

回顾全文,以下是几个关键 takeaways:

  • 空间优化 (滚动数组/位压缩):当DP状态仅依赖于有限个前驱状态时,考虑使用滚动数组。当状态是小规模集合时,位压缩能显著节约空间并简化状态表示。
  • 数据结构优化 (单调队列/线段树/树状数组):当DP转移涉及区间最值查询或求和时,这些数据结构能将 O(N)O(N) 的暴力查询优化到 O(logN)O(\log N)
  • 高级数学优化 (斜率优化/分治优化):当DP方程的决策点或代价函数满足特定数学性质(如单调性、凸性、四边形不等式)时,这些技巧能将多项式复杂度进一步降低。
  • 分治合并 (Meet-in-the-Middle):对于状态空间过大的问题,将其分解为两半分别计算,再高效合并结果,是突破指数级复杂度的利器。

动态规划是一个广阔而深邃的领域,其优化技巧更是层出不穷。面对一个DP问题时,首先,清晰地定义状态和转移方程;其次,分析其时间与空间复杂度;最后,带着这些优化工具箱中的“秘籍”去审视和尝试,你将发现一片新的天地。

实践是检验真理的唯一标准。我鼓励大家多动手,选择一些经典的DP问题,尝试用不同的优化方法去解决它们,体会每种优化带来的性能提升和背后的原理。

DP的魅力不仅仅在于解决问题,更在于它训练了我们系统性思考和抽象问题的能力。在人工智能、机器学习、生物信息学等前沿领域,动态规划思想及其变种依然是解决复杂优化问题的核心工具。

感谢你的阅读,希望这篇博客文章能为你理解和应用动态规划的优化技巧提供有价值的指导。如果你有任何疑问或心得,欢迎在评论区与我交流。让我们一起在算法的海洋中,乘风破浪,精进不休!

我是 qmwneb946,期待与你下次相遇!