1. 题目背景与核心思路拆解
上周的LeetCode第493场周赛第三题是一道典型的前后缀分解问题。这类题型在算法竞赛中非常常见,主要考察选手对数组预处理技巧的掌握程度。题目大意是给定一个整数数组,要求找到满足特定条件的子数组数量。
我第一次看到这道题时,立刻意识到暴力解法的时间复杂度会是O(n^2),这在n较大时显然无法通过。于是开始思考如何通过预处理来优化。前后缀分解的核心思想就是通过预先计算并存储某些信息,将原本需要嵌套循环的计算转化为线性时间的查询。
2. 前后缀分解的基本原理
2.1 什么是前后缀分解
前后缀分解是一种数组处理技巧,通过预先计算并存储每个位置的前缀信息和后缀信息,从而将原本需要多次重复计算的信息保存下来。这种方法特别适合解决那些需要频繁查询数组某个区间统计量的问题。
在实际应用中,我们通常会:
- 从左到右遍历数组,计算并存储前缀数组
- 从右到左遍历数组,计算并存储后缀数组
- 利用这两个预处理数组来快速得到我们需要的信息
2.2 本题的具体应用
对于这道周赛题,我们需要统计满足某种条件的子数组数量。使用前后缀分解可以:
- 预处理每个位置左边满足条件的元素个数(前缀数组)
- 预处理每个位置右边满足条件的元素个数(后缀数组)
- 然后通过组合这些预处理信息来快速计算结果
这种方法将时间复杂度从O(n^2)降低到了O(n),只需要额外的O(n)空间来存储预处理信息。
3. 详细解题步骤与实现
3.1 问题分析与转化
首先我们需要明确题目要求的具体条件。假设题目要求统计所有满足子数组中最大值与最小值之差不超过k的子数组数量(具体条件可能不同,这里以常见情况为例)。
直接思路是枚举所有子数组,检查条件,但这样时间复杂度太高。我们需要找到一种更高效的方法。
3.2 前缀与后缀数组构建
我们可以构建两个数组:
- left[i]:表示以i结尾的子数组中,满足条件的最大左边界
- right[i]:表示以i开头的子数组中,满足条件的最小右边界
构建left数组的过程:
left = [0] * n for i in range(1, n): j = i - 1 while j >= 0 and abs(nums[i] - nums[j]) <= k: j -= 1 left[i] = j + 1构建right数组的过程类似,只是方向相反。
3.3 统计有效子数组
有了这两个数组后,我们可以通过以下方式统计有效子数组数量:
res = 0 for i in range(n): l = left[i] r = right[i] res += (i - l + 1) * (r - i + 1) - 1 # 减去重复计算的单元素子数组这个计算利用了组合数学的思想,将问题转化为区间覆盖的计数问题。
4. 优化与边界处理
4.1 单调栈优化
上述解法中构建left和right数组的过程最坏情况下仍然是O(n^2)的。我们可以使用单调栈来优化这个过程,将时间复杂度降低到O(n)。
以left数组为例,优化后的构建方法:
stack = [] left = [0] * n for i in range(n): while stack and nums[i] - nums[stack[-1]] > k: stack.pop() left[i] = stack[-1] + 1 if stack else 0 stack.append(i)4.2 边界条件处理
在实际实现中需要特别注意以下边界情况:
- 空数组或单元素数组的处理
- 所有元素都相同的情况
- k=0时的特殊情况
- 数组中有重复元素时的处理
5. 完整代码实现
结合上述优化,完整的Python解决方案如下:
def countSubarrays(nums, k): n = len(nums) if n == 0: return 0 # 计算left数组 left = [0] * n stack = [] for i in range(n): while stack and nums[i] - nums[stack[-1]] > k: stack.pop() left[i] = stack[-1] + 1 if stack else 0 stack.append(i) # 计算right数组 right = [n-1] * n stack = [] for i in range(n-1, -1, -1): while stack and nums[stack[-1]] - nums[i] > k: stack.pop() right[i] = stack[-1] - 1 if stack else n-1 stack.append(i) # 统计结果 res = 0 for i in range(n): l = left[i] r = right[i] res += (i - l + 1) * (r - i + 1) - 1 return res6. 复杂度分析与对比
6.1 时间复杂度
优化后的算法:
- 构建left数组:O(n)
- 构建right数组:O(n)
- 计算结果:O(n) 总体时间复杂度为O(n)
6.2 空间复杂度
需要额外的O(n)空间存储left和right数组,因此空间复杂度也是O(n)
6.3 与暴力解法的对比
暴力解法需要枚举所有O(n^2)个子数组,每个子数组需要O(n)时间检查条件,总时间复杂度为O(n^3)。即使优化检查过程,也很难低于O(n^2)。相比之下,前后缀分解的方法在时间和空间上都有显著优势。
7. 类似题目与扩展
7.1 LeetCode上的类似题目
- 子数组的最小值之和
- 柱状图中最大的矩形
- 接雨水
这些题目都可以使用类似的前后缀分解或单调栈技巧来解决。
7.2 问题变种
这种技巧可以应用于多种变种问题:
- 统计满足不同条件的子数组数量
- 处理二维数组的情况
- 结合其他算法如滑动窗口一起使用
8. 实际应用与注意事项
8.1 竞赛中的应用技巧
在算法竞赛中遇到类似问题时:
- 先确认是否真的需要前后缀分解,有时滑动窗口可能更简单
- 注意预处理数组的定义,确保它能准确反映我们需要的信息
- 考虑是否可以用单调栈优化预处理过程
8.2 调试技巧
实现这类算法时常见的错误包括:
- 预处理数组的初始化不正确
- 边界条件处理不当
- 单调栈的维护逻辑有误
调试时可以:
- 打印中间结果,检查预处理数组是否正确
- 用小规模测试用例手动验证
- 对比暴力解法的结果
9. 个人解题心得
在实际解决这道题的过程中,我最初尝试了滑动窗口的方法,但发现无法直接应用。转而思考预处理方法时,一开始构建的前缀数组定义不够准确,导致计算结果总是偏大。经过多次调试才意识到需要在预处理时考虑双向的信息。
另一个教训是关于单调栈的维护。最初没有正确处理相等元素的情况,导致某些测试用例失败。这提醒我在使用单调栈时,必须仔细考虑元素相等时的处理逻辑。