1. 问题背景与核心挑战
1425题"带限制的子序列和"是LeetCode上一道颇具代表性的动态规划难题。题目要求我们从一个整数数组nums中找出一个子序列,使得该子序列的和最大,但同时要求子序列中相邻元素的原始下标差不超过给定的整数k。举个例子,对于数组[10,2,-10,5,20]和k=2,最优解是[10,2,5,20],其和为37。
这道题的难点在于如何在O(n)时间复杂度内解决。普通的动态规划解法会导致O(nk)的时间复杂度,当k接近n时会退化为O(n²),这在n较大时(比如10^5量级)会超出时间限制。因此,我们需要一种更聪明的数据结构来维护滑动窗口内的最大值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础动态规划解法分析
2.1 状态定义与转移方程
最直观的动态规划解法是定义dp[i]表示以nums[i]结尾的子序列能获得的最大和。状态转移方程为:
dp[i] = nums[i] + max(0, dp[i-k], dp[i-k+1], ..., dp[i-1])
这个方程的意思是:我们可以选择不接任何前面的元素(只取nums[i]自己),或者接上前k个位置中某个位置j的子序列(dp[j]),但要确保j在[i-k, i-1]范围内。
2.2 基础实现的局限性
直接实现这个转移方程会导致每次计算dp[i]时都需要检查前k个dp值,这使得时间复杂度为O(nk)。对于n=10^5和k=10^5的情况,这会导致10^10次操作,远超一般OJ系统的时间限制(通常要求10^8次操作以内)。
提示:在LeetCode的测试用例中,n和k都可能达到10^5量级,因此必须找到线性解法。
3. 单调队列优化技术
3.1 单调队列的核心思想
为了优化max(dp[i-k], ..., dp[i-1])的计算,我们可以使用单调队列(Monotonic Queue)这种数据结构。单调队列能在O(1)时间内获取窗口最大值,同时维护操作均摊时间复杂度为O(1)。
具体来说,我们维护一个双端队列,其中存储的是dp数组的索引,且这些索引对应的dp值是单调递减的。这样队列首部始终是当前窗口内dp值最大的元素。
3.2 实现步骤详解
-
初始化dp数组和双端队列:
python复制dp = [0] * len(nums) q = collections.deque() -
遍历数组,对于每个元素nums[i]:
- 首先检查队列头部是否已经超出窗口范围(i - q[0] > k),如果是则弹出
- 计算dp[i] = nums[i] + max(0, dp[q[0]] if q else 0)
- 维护队列单调性:从尾部弹出所有dp值小于dp[i]的索引
- 将i加入队列尾部
-
最终结果是max(dp)
3.3 完整Python实现
python复制import collections
def constrainedSubsetSum(nums, k):
n = len(nums)
dp = [0] * n
q = collections.deque()
for i in range(n):
# 移除超出窗口范围的元素
while q and i - q[0] > k:
q.popleft()
# 计算当前dp值
dp[i] = nums[i] + (dp[q[0]] if q else 0)
# 维护队列单调性
while q and dp[i] >= dp[q[-1]]:
q.pop()
# 只有dp[i]>0才加入队列
if dp[i] > 0:
q.append(i)
return max(dp)
4. 算法正确性证明
4.1 单调队列的性质保证
单调队列维护了两个重要性质:
- 队列中的元素索引都在当前窗口[i-k, i-1]范围内
- 对应的dp值是单调递减的
这两个性质保证了:
- 队列头部始终是窗口内最大dp值的索引
- 每次转移都能在O(1)时间内获取最大值
- 维护队列的操作均摊时间复杂度为O(1)
4.2 边界条件处理
几个关键边界情况需要注意:
- 当队列为空时,表示前面没有可取的元素,dp[i] = nums[i]
- 只有dp[i] > 0时才加入队列,因为负数不会对后续子序列和有贡献
- 最终结果不一定是dp[-1],需要取dp数组的最大值
5. 复杂度分析与优化对比
5.1 时间复杂度
- 每个元素最多入队一次、出队一次
- 外层循环n次,内层while循环总次数不超过2n
- 因此总时间复杂度为O(n)
5.2 空间复杂度
- 需要O(n)空间存储dp数组
- 队列最多存储k个元素
- 因此空间复杂度为O(n)
5.3 与优先队列解法的对比
另一种思路是使用最大堆(优先队列)来维护窗口最大值:
- 时间复杂度:O(n log n),因为堆操作是O(log n)
- 空间复杂度:O(n)
- 实现更简单但效率不如单调队列
6. 实际编码中的注意事项
6.1 队列存储内容的选择
在实现中,队列存储的是索引而非dp值本身,这是因为:
- 需要通过索引判断是否超出窗口范围
- dp值可以通过索引快速查询
- 比较时仍然可以访问dp值
6.2 负数处理技巧
题目允许子序列只包含一个元素,因此即使所有数都是负数,答案也是最大的那个负数。我们的实现中:
- dp[i]至少为nums[i]
- 只有dp[i]>0时才加入队列,避免负数的传播
6.3 测试用例设计
验证代码时需要特别考虑:
- 全负数数组
- k=1的边界情况
- 大k(接近n)的情况
- 数组中有0的情况
例如:
python复制assert constrainedSubsetSum([-1,-2,-3], 2) == -1
assert constrainedSubsetSum([10,-2,-10,-5,20], 2) == 23
assert constrainedSubsetSum([0,0,0,0], 3) == 0
7. 算法扩展与变种思考
7.1 类似问题模式识别
这种"滑动窗口最大值"问题模式常见于:
- 股票买卖问题中的冷却期限制
- 带时间窗口限制的任务调度
- 任何需要维护区间最值的动态规划问题
7.2 其他优化数据结构
除了单调队列,类似问题还可以考虑:
- 线段树:O(n log n)时间复杂度
- Sparse Table:O(n log n)预处理,O(1)查询
- 分块处理:将数组分块预处理
但单调队列在此问题中提供了最优的O(n)解法。
7.3 从子序列到子数组
如果题目改为要求连续子数组(而非子序列),问题就简化为标准的最大子数组和问题(Kadane算法),不需要考虑k的限制。这种对比有助于理解子序列问题的特殊性。
8. 实际工程中的应用场景
虽然这是一道算法题,但其核心思想在实际工程中有广泛应用:
- 金融分析:计算时间序列数据的滚动最大收益,考虑交易冷却期
- 网络流量控制:在滑动时间窗口内限制最大请求数
- 资源调度:在有限的时间范围内分配任务以获得最大收益
理解这种算法模式可以帮助工程师在面对类似约束优化问题时快速找到解决方案。
