区间动态规划是一种解决涉及区间或序列问题的动态规划策略。这类问题通常要求在一个序列或者数组上进行操作,以达到某种优化目标,例如最大化或最小化某个量。区间动态规划在很多场景下非常有用,比如在游戏理论、字符串处理、以及序列分析等领域。
基本策略
区间动态规划的基本策略涉及以下几个关键步骤:
-
定义状态: 首先定义状态
dp[i][j]
,表示对于序列的一个子区间[i, j]
(包括端点i
和j
),问题的解(如最大值、最小值、最优方案等)。状态的定义根据具体问题的需求来定。 -
状态转移方程: 接着是确定状态转移方程,即如何从小区间的解推导出大区间的解。这通常涉及到枚举区间内某个分割点,将大区间分为两个或多个小区间,然后根据小区间的解来构造大区间的解。状态转移方程的形式依赖于问题的具体性质。
-
初始化: 对于一些基本情况(如最小的区间),直接给出
dp
数组的初始值。例如,在很多问题中,长度为 1 的区间(即i == j
的情况)的解是显而易见的,可以直接初始化。 -
计算顺序: 确定计算
dp
数组的顺序。由于大区间的解依赖于小区间的解,因此需要从最小的区间开始计算,逐渐扩大区间的长度,直到计算出整个序列的解。这通常意味着使用两层循环,外层循环控制区间长度,内层循环控制区间的起点。 -
结果提取: 最后,根据问题的需求从
dp
数组中提取结果。在很多情况下,整个序列的最优解就存储在dp[0][n-1]
中,其中n
是序列的长度。
技巧和注意事项
- 边界处理: 在定义状态转移方程时,要注意处理好边界条件,确保所有可能的情况都被考虑到,避免出现数组越界等错误。
- 空间优化: 在某些情况下,当前状态可能只依赖于有限的几个其他状态,因此可以通过滚动数组等技巧来减少空间复杂度。
- 复杂度分析: 在设计区间动态规划算法时,需要分析时间和空间复杂度,以确保算法的效率。通常情况下,如果有两层循环遍历所有区间,并且在每个区间内部还有枚举操作,则时间复杂度可能会达到O(n3),这在
n
较大时可能不够高效。
例子:
2024.2.3 每日一题 1690. 石子游戏 VII
在这个游戏中,两个玩家轮流从石子数组的开始或结束处移除石子。每次移除石子后,玩家的得分增加剩余石子的总和。目标是最大化当前玩家与对手的得分差。
状态定义
定义 dp[i][j]
为在石子数组的子区间 [i, j]
内,当前玩家相对于对手可以获得的最大分数差。注意,这里的最大分数差考虑了双方都采取最优策略的情况。
状态转移
对于任意区间 [i, j]
,当前玩家有两种选择:
-
移除左端的石子:这时,区间变为
[i+1, j]
。当前玩家获得的得分是剩余石子的总和,即sum[i+1][j]
(prefix_sum[j + 1] - prefix_sum[i + 1])。之后,对手在[i+1, j]
区间内相对于当前玩家的最大分数差是dp[i+1][j]
。因此,如果当前玩家选择这个策略,他与对手的分数差将是sum[i+1][j] - dp[i+1][j]
。 -
移除右端的石子:这时,区间变为
[i, j-1]
。类似地,当前玩家获得的得分是sum[i][j-1](prefix_sum[j] - prefix_sum[i])
,对手在[i, j-1]
区间内相对于当前玩家的最大分数差是dp[i][j-1]
。因此,当前玩家与对手的分数差将是sum[i][j-1] - dp[i][j-1]
。
最优策略
因为每个玩家都在尽可能地最大化自己相对于对手的分数差,所以当前玩家会选择以上两种策略中使自己分数差最大的一个。因此,状态转移方程为:
dp[i][j]=max(sum[i+1][j]−dp[i+1][j],sum[i][j−1]−dp[i][j−1])
这个方程考虑了当前玩家在区间 [i, j]
内的每一步都是最优的,即最大化其相对于对手的分数差。通过这种方式,我们可以确保无论对手如何响应,当前玩家都能获得该区间内可能的最大分数差。
初始化
对于任何区间 [i, i]
(即区间长度为 1),dp[i][i] = 0
,因为没有剩余的石子可以加到玩家的得分上。
通过这种方法,我们可以填充整个 dp
表,并最终找到在整个石子数组中,第一个玩家相对于第二个玩家的最大分数差,即 dp[0][n-1]
,其中 n
是石子数组的长度。
class Solution:
def stoneGameVII(stones):
n = len(stones)
prefix_sum = [0] * (n + 1)
for i in range(n):
prefix_sum[i + 1] = prefix_sum[i] + stones[i]
dp = [[0] * n for _ in range(n)]
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
left = prefix_sum[j + 1] - prefix_sum[i + 1] - dp[i + 1][j]
right = prefix_sum[j] - prefix_sum[i] - dp[i][j - 1]
dp[i][j] = max(left, right)
return dp[0][n - 1]
在石子游戏 VII 问题中,区间 [i, j]
的最优解依赖于 [i+1, j]
和 [i, j-1]
(即移除左端或右端石子后剩下的区间)的解。因此,我们必须先解决这些较小的区间,才能解决较大的区间。
动态规划表的填充
在实际的动态规划实现中,通常会使用一个表(二维数组)来存储子问题的解。从小区间到大区间的顺序确保当我们尝试填充表中的一个条目(即解决一个子问题)时,该条目所依赖的所有其他条目(即较小的子问题的解)都已经被计算并填充好了。这避免了重复计算,并确保了我们在解决当前子问题时可以直接引用这些依赖项。
计算效率
从小区间开始,逐步扩大区间的计算方式,不仅符合问题的依赖结构,而且提高了计算效率。因为较小的子问题只需要计算一次,其结果就可以被多次复用来解决更大的问题。这大大减少了计算量,因为不需要对相同的小区间重复进行计算。
确保正确性
这种方法还有助于确保解决方案的正确性。通过先解决所有可能的小问题,我们可以构建一个坚实的基础,确保在解决更大和更复杂的问题时,所需的所有基本构建块都是正确和可用的。
在“石子游戏 VII”的例子中,从长度为 2 的区间开始,然后逐渐增加区间长度,这保证了当我们计算一个区间 [i, j]
的最大分数差时,我们已经知道了所有可能影响这个决策的较小区间的最优解。这种方法简化了问题的复杂性,使得动态规划成为解决此类问题的一种有效工具。