引言
亲爱的技术同仁们,大家好!我是你们的博主 qmwneb946。在算法的世界里,动态规划(Dynamic Programming, DP)无疑是一颗璀璨的明珠。它以其优雅的“分而治之”思想,能够高效地解决那些看似复杂、拥有重叠子问题和最优子结构特性的难题,从最短路径到背包问题,从字符串匹配到生物信息学,DP的身影无处不在。
然而,掌握DP并非一蹴而就。当你能够熟练地定义状态、推导转移方程时,一个新的挑战随之而来:如何优化你的DP算法?即使是DP,在面对海量数据或严苛的时间、空间限制时,也可能变得力不从心。一个朴素的 O ( N 3 ) O(N^3) O ( N 3 ) 甚至 O ( N 2 ) O(N^2) O ( N 2 ) 的DP解法,在 N N N 达到 10 5 10^5 1 0 5 级别时,就可能导致时间超限(TLE)。同样,一个 O ( N 2 ) O(N^2) O ( N 2 ) 的空间复杂度,在 N N N 达到 10 4 10^4 1 0 4 级别时,就可能导致内存溢出(MLE)。
因此,理解并掌握动态规划的优化技巧,是每一个算法工程师进阶的必经之路。本文将带你深入探索动态规划的各种优化策略,从基础的空间优化到利用高级数据结构加速,再到精妙的决策单调性等数学性质。我们将一起揭开这些“黑科技”的神秘面纱,助你突破DP的性能瓶颈,让你的算法在效率上更上一层楼!
准备好了吗?让我们一起踏上这场DP优化之旅!
动态规划基础回顾
在深入优化之前,我们先快速回顾一下动态规划的核心概念。动态规划的本质在于将一个大问题分解成相互关联的子问题,并通过存储子问题的解来避免重复计算。它主要依赖于两个关键特性:
最优子结构 (Optimal Substructure) :问题的最优解包含其子问题的最优解。
重叠子问题 (Overlapping Subproblems) :在解决原问题时,很多子问题被重复计算。
DP通常有两种实现方式:
记忆化搜索 (Top-Down / Memoization) :从原问题出发,递归地解决子问题,并将子问题的解存储起来。
递推 (Bottom-Up / Tabulation) :从最简单的子问题开始,逐步计算出更大规模子问题的解,直到原问题。
让我们以经典的斐波那契数列为例:F n = F n − 1 + F n − 2 F_n = F_{n-1} + F_{n-2} F n = F n − 1 + F n − 2 ,其中 F 0 = 0 , F 1 = 1 F_0 = 0, F_1 = 1 F 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
递推实现
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[0 ] = 0 dp[1 ] = 1 for i in range (2 , n + 1 ): dp[i] = dp[i-1 ] + dp[i-2 ] return dp[n]
这两种方法都将原始指数级的复杂度优化到了 O ( N ) O(N) O ( N ) 的时间复杂度。然而,递推实现中,我们使用了 O ( N ) O(N) O ( N ) 的空间复杂度来存储所有的 d p dp d p 值。对于更大的 N N N ,这仍然可能成为瓶颈。这就引出了我们的第一个优化方向:空间优化。
核心优化策略:状态压缩
状态压缩是动态规划中最常见且效果显著的优化手段之一。它主要通过减少DP数组的维度或利用位运算来表示状态,从而大幅节省内存空间。
滚动数组优化 (Space Optimization / Rolling Array)
原理:
许多DP问题的状态转移方程 d p [ i ] dp[i] d p [ i ] 只依赖于前 k k k 个状态,即 d p [ i ] dp[i] d p [ i ] 的计算仅需要 d p [ i − 1 ] , d p [ i − 2 ] , … , d p [ i − k ] dp[i-1], dp[i-2], \dots, dp[i-k] d p [ i − 1 ] , d p [ i − 2 ] , … , d p [ i − k ] 。在这种情况下,我们无需存储整个 d p dp d p 数组,而只需维护一个固定大小(通常是 k + 1 k+1 k + 1 或更小)的数组来存储当前和前 k k k 个所需的状态。这被称为“滚动数组”优化。
示例:斐波那契数列 (空间优化到 O ( 1 ) 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 return b
空间复杂度从 O ( N ) O(N) O ( N ) 降低到了 O ( 1 ) O(1) O ( 1 ) 。
示例:0/1 背包问题 (空间优化到 O ( W ) O(W) O ( W ) )
经典的0/1背包问题定义:给定 N N N 件物品,每件物品有重量 w i w_i w i 和价值 v i v_i v i 。背包容量为 W W W 。求在不超过背包容量的前提下,能装入物品的最大总价值。
朴素的DP状态定义:d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 表示考虑前 i i i 件物品,背包容量为 j j j 时的最大价值。
转移方程:
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j] = dp[i-1][j] d p [ i ] [ j ] = d p [ i − 1 ] [ j ] (不选第 i i i 件物品)
d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w i ] + v i ) dp[i][j] = \max(dp[i-1][j], dp[i-1][j-w_i] + v_i) d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w i ] + v i ) (如果 j ≥ w i j \ge w_i j ≥ w i ,可以选第 i i i 件物品)
观察发现,d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 只依赖于 d p [ i − 1 ] [ … ] dp[i-1][\dots] d p [ i − 1 ] [ … ] 。这意味着我们只需要两行空间:当前行和上一行。甚至可以进一步优化到一行。
如果我们使用一行数组 d p [ j ] dp[j] d p [ j ] 表示当前容量为 j j j 时的最大价值。当我们处理第 i i i 件物品时,为了确保 d p [ j − w i ] dp[j-w_i] d p [ j − w i ] 仍然是“上一件物品”的状态,我们需要逆序 遍历 j j j 。
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 = [0 ] * (capacity + 1 ) for i in range (n): for j in range (capacity, weights[i] - 1 , -1 ): dp[j] = max (dp[j], dp[j - weights[i]] + values[i]) return dp[capacity]
空间复杂度从 O ( N ⋅ W ) O(N \cdot W) O ( N ⋅ W ) 降低到 O ( W ) O(W) O ( W ) 。
示例:最长公共子序列 (LCS) (空间优化到 O ( min ( N , M ) ) O(\min(N, M)) O ( min ( N , M )) )
LCS 问题朴素的DP状态:d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 表示字符串 S 1 S1 S 1 的前 i i i 个字符和 S 2 S2 S 2 的前 j j j 个字符的最长公共子序列长度。
转移方程:
如果 S 1 [ i − 1 ] = = S 2 [ j − 1 ] S1[i-1] == S2[j-1] S 1 [ i − 1 ] == S 2 [ j − 1 ] :d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j] = dp[i-1][j-1] + 1 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1
否则:d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = \max(dp[i-1][j], dp[i][j-1]) d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ])
d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 同样只依赖于 d p [ i − 1 ] dp[i-1] d p [ i − 1 ] 和 d p [ i ] dp[i] d p [ i ] 当前行的前一个值。
我们可以使用两个一维数组 prev_dp
和 current_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) 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 = list (current_dp) return prev_dp[n]
空间复杂度从 O ( M ⋅ N ) O(M \cdot N) O ( M ⋅ N ) 降低到 O ( min ( M , N ) ) O(\min(M, N)) O ( min ( M , N )) 。
位运算状态压缩 (Bitmask DP)
原理:
当DP的状态需要表示一个集合、子集、或者某种布尔状态组合时,如果集合的元素数量较小(通常不超过20),我们可以使用一个整数的二进制位来表示这些状态。整数的第 k k k 位为1表示第 k k k 个元素在集合中,为0表示不在。这种技术被称为位压缩DP或状压DP。
适用场景:
需要遍历所有子集的问题。
涉及路径规划、任务分配等,且节点/任务数量不多的问题 (N ≤ 20 N \le 20 N ≤ 20 )。
需要表示一个集合的连通性或访问状态。
示例:旅行商问题 (TSP)
旅行商问题是一个经典的NP-hard问题,但对于小规模的图 (N ≤ 20 N \le 20 N ≤ 20 ),可以通过状压DP求解。
问题:给定 N N N 个城市和城市间距离,从某个城市出发,遍历所有城市恰好一次,并回到起点,求最短总距离。
状态定义:
d p [ mask ] [ last_city ] dp[\text{mask}][\text{last\_city}] d p [ mask ] [ last_city ] 表示已经访问过的城市集合为 mask
(一个二进制数),并且当前停留在 last_city
时的最短路径。
mask
的第 i i i 位为1表示城市 i i i 已经访问过。
转移方程:
d p [ mask ∣ ( 1 ≪ j ) ] [ j ] = min ( d p [ mask ∣ ( 1 ≪ j ) ] [ j ] , d p [ 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]) d p [ mask ∣ ( 1 ≪ j )] [ j ] = min ( d p [ mask ∣ ( 1 ≪ j )] [ j ] , d p [ mask ] [ i ] + dist [ i ] [ j ])
其中,j j j 是未访问过的城市,且从 i i i 到 j j j 有路径。
初始状态:
d p [ 1 ≪ start_city ] [ start_city ] = 0 dp[1 \ll \text{start\_city}][\text{start\_city}] = 0 d p [ 1 ≪ start_city ] [ 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 mathdef tsp_bitmask (dist_matrix, start_city=0 ): n = len (dist_matrix) dp = [[math.inf] * n for _ in range (1 << n)] dp[1 << start_city][start_city] = 0 for mask in range (1 , 1 << n): for i in range (n): if (mask >> i) & 1 : for j in range (n): if not ((mask >> j) & 1 ): new_mask = mask | (1 << j) dp[new_mask][j] = min (dp[new_mask][j], dp[mask][i] + dist_matrix[i][j]) 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
TSP的复杂度是 O ( N 2 ⋅ 2 N ) O(N^2 \cdot 2^N) O ( N 2 ⋅ 2 N ) ,虽然对于大N不可行,但对于 N ≤ 20 N \le 20 N ≤ 20 却是一种高效的精确解法。
优化转移方程计算:数据结构加速
当DP转移方程的计算涉及到区间查询或最值问题时,朴素的循环计算可能导致时间复杂度过高。这时,引入合适的数据结构可以显著加速转移过程。
单调队列优化 (Monotonic Queue Optimization)
原理:
单调队列(通常是双端队列 deque)常用于优化形如 d p [ i ] = min j ∈ [ i − k , i − 1 ] ( d p [ j ] + C ( j ) ) dp[i] = \min_{j \in [i-k, i-1]} (dp[j] + C(j)) d p [ i ] = min j ∈ [ i − k , i − 1 ] ( d p [ j ] + C ( j )) 或 d p [ i ] = max j ∈ [ i − k , i − 1 ] ( d p [ j ] + C ( j ) ) dp[i] = \max_{j \in [i-k, i-1]} (dp[j] + C(j)) d p [ i ] = max j ∈ [ i − k , i − 1 ] ( d p [ j ] + C ( j )) 的DP转移方程。它能在一个滑动窗口内快速找到最值,将 O ( K ) O(K) O ( K ) 的窗口扫描优化为 O ( 1 ) O(1) O ( 1 ) 的均摊时间复杂度。
适用场景:
DP转移依赖于一个固定大小的滑动窗口内的最值。
决策点 j j j 的范围是 i − k ≤ j < i i-k \le j < i i − k ≤ j < i 。
示例:滑动窗口最大值 (DP应用)
虽然这不是一个典型的DP问题,但其核心思想可以应用于DP优化。假设我们需要一个DP问题,其中 d p [ i ] dp[i] d p [ i ] 依赖于前 K K K 个 d p dp d p 值的最大值,即 d p [ i ] = A i + max ( d p [ i − K ] , … , d p [ i − 1 ] ) dp[i] = A_i + \max(dp[i-K], \dots, dp[i-1]) d p [ i ] = A i + max ( d p [ i − K ] , … , d p [ 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 dequedef max_sliding_window_dp_like (nums, k ): n = len (nums) if n == 0 : return [] output = [] 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
当DP转移方程是 d p [ i ] = cost ( i ) + min j ∈ [ i − k , i − 1 ] ( d p [ j ] ) dp[i] = \text{cost}(i) + \min_{j \in [i-k, i-1]} (dp[j]) d p [ i ] = cost ( i ) + min j ∈ [ i − k , i − 1 ] ( d p [ j ]) 时,单调队列维护的是 d p [ j ] dp[j] d p [ j ] 的单调递增序列。每次计算 d p [ i ] dp[i] d p [ i ] 时,队首就是当前窗口内的 d p [ j ] dp[j] d p [ j ] 最小值。
线段树/树状数组优化 (Segment Tree/Fenwick Tree Optimization)
原理:
线段树和树状数组是强大的数据结构,能够高效地处理区间查询(如求和、求最值)和单点/区间修改操作。当DP转移方程中涉及到对一个范围内的DP值进行查询取最值或求和时,它们可以将 O ( N ) O(N) O ( N ) 的遍历优化到 O ( log N ) O(\log N) O ( log N ) 。
适用场景:
DP转移方程形如 d p [ i ] = max j < i and condition ( j ) ( d p [ j ] + value ( j ) ) dp[i] = \max_{j < i \text{ and condition}(j)} (dp[j] + \text{value}(j)) d p [ i ] = max j < i and condition ( j ) ( d p [ j ] + value ( j )) 。
condition(j)
使得 j j j 的取值范围不固定,或者 v a l u e ( j ) value(j) v a l u e ( j ) 与 j j j 有关。
需要快速查询某个区间内的最值或求和。
示例:最长上升子序列 (LIS) (优化到 O ( N log N ) O(N \log N) O ( N log N ) )
LIS问题朴素DP:d p [ i ] dp[i] d p [ i ] 表示以 n u m s [ i ] nums[i] n u m s [ i ] 结尾的最长上升子序列长度。
转移方程:d p [ i ] = 1 + max ( { d p [ j ] ∣ j < i and n u m s [ j ] < n u m s [ i ] } ) dp[i] = 1 + \max(\{dp[j] \mid j < i \text{ and } nums[j] < nums[i]\}) d p [ i ] = 1 + max ({ d p [ j ] ∣ j < i and n u m s [ j ] < n u m s [ i ]}) 。
朴素实现的时间复杂度为 O ( N 2 ) O(N^2) O ( N 2 ) 。
我们可以用线段树来优化这个过程。将 n u m s [ i ] nums[i] n u m s [ i ] 的值域离散化(如果值域很大),然后将线段树的叶节点表示离散化后的值,线段树节点存储该值域范围内遇到的最大 d p dp d p 值。
离散化: 对 nums
数组中的所有元素进行离散化,将它们映射到 0 … M − 1 0 \dots M-1 0 … M − 1 的范围,其中 M M M 是不同元素的数量。
线段树: 构建一个线段树,其叶子节点对应离散化后的值。每个节点存储其覆盖值域范围内的最大LIS长度。
DP过程: 遍历 n u m s [ i ] nums[i] n u m s [ i ] :
查询:在 n u m s [ i ] nums[i] n u m s [ i ] 对应的离散化值之前的所有值域范围内,查询最大的LIS长度(即 m a x _ l e n = query ( 0 , val [ i ] − 1 ) max\_len = \text{query}(0, \text{val}[i]-1) ma x _ l e n = query ( 0 , val [ i ] − 1 ) )。
更新:当前 d p [ i ] = m a x _ l e n + 1 dp[i] = max\_len + 1 d p [ i ] = ma x _ l e n + 1 。然后用 m a x _ l e n + 1 max\_len + 1 ma x _ l e n + 1 更新线段树中 n u m s [ i ] nums[i] n u m s [ 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 class SegmentTree : def __init__ (self, size ): self .tree = [0 ] * (4 * size) 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 sorted_unique_nums = sorted (list (set (nums))) val_to_idx = {val: i for i, val in enumerate (sorted_unique_nums)} max_val_idx = len (sorted_unique_nums) segment_tree = SegmentTree(max_val_idx) max_lis_len = 0 for num in nums: current_idx = val_to_idx[num] prev_max_lis = segment_tree.query(0 , current_idx - 1 ) current_lis = prev_max_lis + 1 segment_tree.update(current_idx, current_lis) max_lis_len = max (max_lis_len, current_lis) return max_lis_len
通过线段树,LIS问题的时间复杂度从 O ( N 2 ) O(N^2) O ( N 2 ) 降低到 O ( N log M ) O(N \log M) O ( N log M ) (其中 M M M 是不同元素的数量,通常 M ≤ N M \le N M ≤ N ),或者 O ( N log V m a x ) O(N \log V_{max}) O ( N log V ma x ) 如果不离散化而是直接使用值域。
高级优化技巧
这些高级优化技巧通常依赖于DP问题的特定数学性质,如决策单调性或凸性。它们能将原本 O ( N 2 ) O(N^2) O ( N 2 ) 甚至更高的复杂度优化到 O ( N log N ) O(N \log N) O ( N log N ) 或 O ( N log 2 N ) O(N \log^2 N) O ( N log 2 N ) 。
决策单调性优化
当DP转移方程的决策点(即 j j j 的取值)具有单调性时,我们可以利用这一性质来加速计算。
斜率优化 (Convex Hull Trick, CHT)
原理:
斜率优化是一种用于优化形如 d p [ i ] = min j < i ( A [ j ] ⋅ B [ i ] + C [ j ] ) dp[i] = \min_{j < i} (A[j] \cdot B[i] + C[j]) d p [ i ] = min j < i ( A [ j ] ⋅ B [ i ] + C [ j ]) 的DP转移方程的技术。
这类方程可以变形为 d p [ i ] = B [ i ] ⋅ A [ j ] + C [ j ] dp[i] = B[i] \cdot A[j] + C[j] d p [ i ] = B [ i ] ⋅ A [ j ] + C [ j ] 。
令 y = d p [ j ] + C [ j ] y = dp[j] + C[j] y = d p [ j ] + C [ j ] ,m = A [ j ] m = A[j] m = A [ j ] ,x = B [ i ] x = B[i] x = B [ i ] ,b = C [ j ] b = C[j] b = C [ j ] 。
我们希望在给定 x = B [ i ] x=B[i] x = B [ i ] 时,找到最小的 y j = m j x + b j y_j = m_j x + b_j y j = m j x + b j 。
这相当于在一堆直线上,求在给定 x x x 坐标时的最低点。这些最低点会构成一个下凸壳(Convex Hull)。
通过维护一个凸壳,我们可以利用直线的斜率单调性(或 x x x 坐标的单调性)来快速找到最优决策点,通常使用单调队列或平衡二叉搜索树来维护凸壳。
数学推导:
假设我们有两个决策点 j 1 j_1 j 1 和 j 2 j_2 j 2 ,其中 j 1 < j 2 j_1 < j_2 j 1 < j 2 。
如果 j 1 j_1 j 1 优于 j 2 j_2 j 2 ,则 d p [ j 1 ] + cost ( j 1 , i ) < d p [ j 2 ] + cost ( j 2 , i ) dp[j_1] + \text{cost}(j_1, i) < dp[j_2] + \text{cost}(j_2, i) d p [ j 1 ] + cost ( j 1 , i ) < d p [ j 2 ] + cost ( j 2 , i ) 。
当 A [ j ] A[j] A [ j ] 和 B [ i ] B[i] B [ i ] 都单调时,可以维护一个单调队列(存放直线的索引),每次计算 d p [ i ] dp[i] d p [ i ] 时,移除队首不再最优的直线,然后将新直线加入队尾。
适用场景:
DP转移方程可以化为 d p [ i ] = min ( A j ⋅ B i + C j ) dp[i] = \min (A_j \cdot B_i + C_j) d p [ i ] = min ( A j ⋅ B i + C j ) 的形式。
通常需要 A j A_j A j 和 B i B_i B i 至少有一个是单调的。
示例:任务调度问题 (简化版)
假设有 N N N 个任务,每个任务有一个处理时间 t i t_i t i 和一个冷却时间 c i c_i c i 。我们需要将任务分组处理,每组任务在处理完成后需要一个固定的冷却时间 S S S 。求完成所有任务的最小总时间。
d p [ i ] dp[i] d p [ i ] 表示处理完前 i i i 个任务的最小总时间。
d p [ i ] = min 0 ≤ j < i ( d p [ j ] + ( ∑ k = j + 1 i t k ) + S ) dp[i] = \min_{0 \le j < i} (dp[j] + (\sum_{k=j+1}^i t_k) + S) d p [ i ] = min 0 ≤ j < i ( d p [ j ] + ( ∑ k = j + 1 i t k ) + S )
令前缀和 T i = ∑ k = 1 i t k T_i = \sum_{k=1}^i t_k T i = ∑ k = 1 i t k 。
d p [ i ] = min 0 ≤ j < i ( d p [ j ] + ( T i − T j ) + S ) dp[i] = \min_{0 \le j < i} (dp[j] + (T_i - T_j) + S) d p [ i ] = min 0 ≤ j < i ( d p [ j ] + ( T i − T j ) + S )
d p [ i ] = T i + S + min 0 ≤ j < i ( d p [ j ] − T j ) dp[i] = T_i + S + \min_{0 \le j < i} (dp[j] - T_j) d p [ i ] = T i + S + min 0 ≤ j < i ( d p [ j ] − T j )
这不是典型的斜率优化形式。考虑另一个变种:
d p [ i ] = min j < i ( d p [ j ] + ( T i − T j ) 2 + C ) dp[i] = \min_{j < i} (dp[j] + (T_i - T_j)^2 + C) d p [ i ] = min j < i ( d p [ j ] + ( T i − T j ) 2 + C )
d p [ i ] = min j < i ( d p [ j ] + T i 2 − 2 T i T j + T j 2 + C ) dp[i] = \min_{j < i} (dp[j] + T_i^2 - 2T_i T_j + T_j^2 + C) d p [ i ] = min j < i ( d p [ j ] + T i 2 − 2 T i T j + T j 2 + C )
d p [ i ] = T i 2 + C + min j < i ( d p [ j ] + T j 2 − 2 T i T j ) dp[i] = T_i^2 + C + \min_{j < i} (dp[j] + T_j^2 - 2T_i T_j) d p [ i ] = T i 2 + C + min j < i ( d p [ j ] + T j 2 − 2 T i T j )
令 d p [ j ] + T j 2 = Y j dp[j] + T_j^2 = Y_j d p [ j ] + T j 2 = Y j ,2 T i = X i 2T_i = X_i 2 T i = X i ,T j = M j T_j = M_j T j = M j 。
则 d p [ i ] = T i 2 + C + min j < i ( Y j − X i M j ) dp[i] = T_i^2 + C + \min_{j < i} (Y_j - X_i M_j) d p [ i ] = T i 2 + C + min j < i ( Y j − X i M j ) 。
d p [ i ] = T i 2 + C + min j < i ( − M j X i + Y j ) dp[i] = T_i^2 + C + \min_{j < i} (-M_j X_i + Y_j) d p [ i ] = T i 2 + C + min j < i ( − M j X i + Y j )
这就是 y = m x + b y = mx + b y = m x + b 的形式,其中 m = − M j = − T j m = -M_j = -T_j m = − M j = − T j , x = X i = 2 T i x = X_i = 2T_i x = X i = 2 T i , b = Y j = d p [ j ] + T j 2 b = Y_j = dp[j] + T_j^2 b = Y j = d p [ j ] + T j 2 。
由于 T j T_j T j 是单调递增的(T j ≥ 0 T_j \ge 0 T j ≥ 0 ),所以 − T j -T_j − T j 是单调递减的。而 2 T i 2T_i 2 T 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 dequedef solve_convex_hull_trick_dp (N, T_values, C ): dp = [0 ] * (N + 1 ) dq = deque() dq.append(0 ) def intersect (j1, 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 ): while len (dq) >= 2 and intersect(dq[0 ], dq[1 ]) <= T_values[i]: dq.popleft() j = dq[0 ] dp[i] = T_values[i]**2 + C + (dp[j] + T_values[j]**2 - 2 * T_values[i] * T_values[j]) while len (dq) >= 2 and intersect(dq[-2 ], dq[-1 ]) >= intersect(dq[-1 ], i): dq.pop() dq.append(i) return dp[N]
斜率优化将复杂度从 O ( N 2 ) O(N^2) O ( N 2 ) 降低到 O ( N ) O(N) O ( N ) 或 O ( N log N ) O(N \log N) O ( N log N ) (如果 X X X 不单调,需要用平衡二叉搜索树维护凸壳)。
分治优化 (Divide and Conquer Optimization / D&C Optimization)
原理:
分治优化适用于一类特殊的DP问题,其决策点 k [ i ] [ j ] k[i][j] k [ i ] [ j ] (表示计算 d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 时的最优决策点)满足单调性:k [ i ] [ j − 1 ] ≤ k [ i ] [ j ] ≤ k [ i + 1 ] [ j ] k[i][j-1] \le k[i][j] \le k[i+1][j] k [ i ] [ j − 1 ] ≤ k [ i ] [ j ] ≤ k [ i + 1 ] [ j ] 。这被称为四边形不等式性质 。
当满足这种性质时,可以通过分治策略来加速计算。我们不必对每个 d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 都遍历所有可能的 k k k ,而是利用 k k k 的单调性将搜索范围限制在一个更小的区间内。
核心思想:
如果 d p [ i ] [ j ] = min k ≤ j ( d p [ i − 1 ] [ k ] + cost ( k , j ) ) dp[i][j] = \min_{k \le j} (dp[i-1][k] + \text{cost}(k, j)) d p [ i ] [ j ] = min k ≤ j ( d p [ i − 1 ] [ k ] + cost ( k , j )) ,且决策点 k k k 满足单调性,我们可以用分治法递归地计算。
compute(L, R, optL, optR)
函数负责计算 d p dp d p 数组在 [ L , R ] [L, R] [ L , R ] 范围内的值,并已知这些值的最优决策点在 [ o p t L , o p t R ] [optL, optR] [ o pt L , o ptR ] 范围内。
取 mid = (L+R)/2
,然后在其 [ o p t L , o p t R ] [optL, optR] [ o pt L , o ptR ] 范围内暴力查找 m i d mid mi d 的最优决策点 k m i d k_{mid} k mi d 。
接着,递归调用 compute(L, mid-1, optL, k_{mid})
和 compute(mid+1, R, k_{mid}, optR)
。
适用场景:
形如 d p [ i ] [ j ] = min k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + cost ( i , j ) ) dp[i][j] = \min_{k < j} (dp[i][k] + dp[k+1][j] + \text{cost}(i, j)) d p [ i ] [ j ] = min k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + cost ( i , j )) 的区间DP。
需要证明决策点 k [ i ] [ j ] k[i][j] k [ i ] [ j ] 具有单调性。
示例:区间DP (Knuth Optimization)
最经典的例子是矩阵链乘问题或最优二叉搜索树问题。
对于 d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 表示区间 [ i , j ] [i, j] [ i , j ] 的最优解,转移方程通常是:
d p [ i ] [ j ] = min i ≤ k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + W ( i , j ) ) dp[i][j] = \min_{i \le k < j} (dp[i][k] + dp[k+1][j] + W(i, j)) d p [ i ] [ j ] = min i ≤ k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + W ( i , j ))
如果 W ( i , j ) W(i, j) W ( i , j ) 满足四边形不等式 :对于任意 a ≤ b ≤ c ≤ d a \le b \le c \le d a ≤ b ≤ c ≤ 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 ( a , c ) + W ( b , d ) ≤ W ( a , d ) + W ( b , c ) ,
且 W ( i , j ) W(i, j) W ( i , j ) 满足单调性 :对于任意 i ≤ j i \le j i ≤ j ,有 W ( i , j ) ≤ W ( i , j + 1 ) W(i, j) \le W(i, j+1) W ( i , j ) ≤ W ( i , j + 1 ) 且 W ( i , j ) ≤ W ( i − 1 , j ) W(i, j) \le W(i-1, j) W ( i , j ) ≤ W ( i − 1 , j ) ,
那么决策点 k [ i ] [ j ] k[i][j] k [ i ] [ j ] 满足 k [ i ] [ j − 1 ] ≤ k [ i ] [ j ] ≤ k [ i + 1 ] [ j ] k[i][j-1] \le k[i][j] \le k[i+1][j] k [ i ] [ j − 1 ] ≤ k [ i ] [ j ] ≤ k [ i + 1 ] [ j ] 。
此时,可以将 O ( N 3 ) O(N^3) O ( N 3 ) 的区间DP优化到 O ( N 2 ) O(N^2) O ( N 2 ) 。
Knuth 优化流程:
计算 d p [ i ] [ i ] dp[i][i] d p [ i ] [ i ] 和 k [ i ] [ i ] k[i][i] k [ i ] [ i ] (通常为 i i i )。
从区间长度 l e n = 2 len = 2 l e n = 2 遍历到 N N N 。
对于每个 i i i 和 j = i + l e n − 1 j = i + len - 1 j = i + l e n − 1 :
d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 的决策点 k k k 的搜索范围不是 [ i , j − 1 ] [i, j-1] [ i , j − 1 ] ,而是 [ k [ i ] [ j − 1 ] , k [ i + 1 ] [ j ] ] [k[i][j-1], k[i+1][j]] [ k [ i ] [ j − 1 ] , k [ i + 1 ] [ j ]] 。
在新的较小范围内找到最优的 k k k ,更新 d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] 和 k [ 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 def knuth_optimized_dp (n, cost_matrix ): dp = [[0 ] * n for _ in range (n)] k_opt = [[0 ] * n for _ in range (n)] for i in range (n): dp[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' ) 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 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 ( N 3 ) O(N^3) O ( N 3 ) 复杂度降至 O ( N 2 ) O(N^2) O ( N 2 ) 。
Meet-in-the-Middle (MITM)
原理:
分治法的一种特例,当问题的状态空间过大,不能直接用DP解决时,可以尝试将问题分成两半,分别计算这两半的子问题,然后将两半的结果合并。这通常能将 O ( C N ) O(C^N) O ( C N ) 或 O ( 2 N ) O(2^N) O ( 2 N ) 的复杂度降低到 O ( C N / 2 ) O(C^{N/2}) O ( C N /2 ) 或 O ( 2 N / 2 ) O(2^{N/2}) O ( 2 N /2 ) 。
适用场景:
状态数量呈指数级增长,但可以被拆分成两个独立的子问题。
合并两个子问题的结果可以在较短的时间内完成(如排序后双指针)。
示例:子集和问题
给定一个整数集合 S S S 和一个目标和 T T T ,问 S S S 中是否存在一个子集,其元素之和等于 T T T 。
朴素的DP:d p [ i ] [ s ] dp[i][s] d p [ i ] [ s ] 表示前 i i i 个数是否能组成和 s s s ,复杂度 O ( N ⋅ T ) O(N \cdot T) O ( N ⋅ T ) 。
如果 T T T 很大,但 N N N 较小(如 N = 40 N=40 N = 40 ),直接DP不可行。
MITM 策略:
将 S S S 分成两半 S 1 S_1 S 1 和 S 2 S_2 S 2 。
分别计算 S 1 S_1 S 1 的所有子集和,存储在一个列表 sums1
中。
分别计算 S 2 S_2 S 2 的所有子集和,存储在一个列表 sums2
中。
对 sums2
进行排序。
对于 sums1
中的每一个和 s 1 s_1 s 1 ,在 sums2
中查找是否存在 T − s 1 T - s_1 T − 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() for s1 in sums1: needed_s2 = target - s1 import bisect idx = bisect.bisect_left(sums2, needed_s2) if idx < len (sums2) and sums2[idx] == needed_s2: return True return False
时间复杂度从 O ( 2 N ) O(2^N) O ( 2 N ) 优化到 O ( 2 N / 2 ⋅ log ( 2 N / 2 ) ) = O ( 2 N / 2 ⋅ N / 2 ) O(2^{N/2} \cdot \log(2^{N/2})) = O(2^{N/2} \cdot N/2) O ( 2 N /2 ⋅ log ( 2 N /2 )) = O ( 2 N /2 ⋅ N /2 ) 。对于 N = 40 N=40 N = 40 的情况,原始复杂度为 2 40 2^{40} 2 40 ,优化后约为 2 20 ⋅ 20 2^{20} \cdot 20 2 20 ⋅ 20 ,显著提升。
结论
恭喜你,已经走过了动态规划优化的诸多秘境!从基础的滚动数组到精妙的位压缩,从借力数据结构的单调队列与线段树,到深挖数学性质的斜率优化与分治优化,再到打破指数瓶颈的Meet-in-the-Middle,我们探讨了各种将DP算法推向极致的策略。
掌握这些优化技巧,不仅仅是为了通过算法竞赛中的复杂测试用例,更重要的是它能培养你对问题结构更深层次的理解,以及对算法性能分析的敏锐洞察力。每一次成功的优化,都源于对DP状态、转移方程及其内在数学性质的精准把握。
回顾全文,以下是几个关键 takeaways:
空间优化 (滚动数组/位压缩) :当DP状态仅依赖于有限个前驱状态时,考虑使用滚动数组。当状态是小规模集合时,位压缩能显著节约空间并简化状态表示。
数据结构优化 (单调队列/线段树/树状数组) :当DP转移涉及区间最值查询或求和时,这些数据结构能将 O ( N ) O(N) O ( N ) 的暴力查询优化到 O ( log N ) O(\log N) O ( log N ) 。
高级数学优化 (斜率优化/分治优化) :当DP方程的决策点或代价函数满足特定数学性质(如单调性、凸性、四边形不等式)时,这些技巧能将多项式复杂度进一步降低。
分治合并 (Meet-in-the-Middle) :对于状态空间过大的问题,将其分解为两半分别计算,再高效合并结果,是突破指数级复杂度的利器。
动态规划是一个广阔而深邃的领域,其优化技巧更是层出不穷。面对一个DP问题时,首先,清晰地定义状态和转移方程;其次,分析其时间与空间复杂度;最后,带着这些优化工具箱中的“秘籍”去审视和尝试,你将发现一片新的天地。
实践是检验真理的唯一标准。我鼓励大家多动手,选择一些经典的DP问题,尝试用不同的优化方法去解决它们,体会每种优化带来的性能提升和背后的原理。
DP的魅力不仅仅在于解决问题,更在于它训练了我们系统性思考和抽象问题的能力。在人工智能、机器学习、生物信息学等前沿领域,动态规划思想及其变种依然是解决复杂优化问题的核心工具。
感谢你的阅读,希望这篇博客文章能为你理解和应用动态规划的优化技巧提供有价值的指导。如果你有任何疑问或心得,欢迎在评论区与我交流。让我们一起在算法的海洋中,乘风破浪,精进不休!
我是 qmwneb946,期待与你下次相遇!