1. 问题背景与核心挑战
今天想和大家聊聊LeetCode上第713题"乘积小于K的子数组"这个看似简单实则暗藏玄机的问题。作为一道中等难度的数组题目,它在各大科技公司的面试中出现频率相当高,尤其是考察候选人对于滑动窗口算法的理解和应用能力。
我第一次遇到这个问题时,直觉反应是暴力解法——枚举所有可能的子数组然后计算乘积。但当我尝试用这种方法处理一个长度为10000的数组时,程序直接卡死了。这才意识到,这道题真正的价值在于引导我们思考如何用O(n)的时间复杂度优雅地解决问题。
2. 暴力解法与性能瓶颈
2.1 最直观的暴力思路
让我们先看看最直接的解法:对于数组中的每个元素nums[i],将其作为子数组的起点,然后向后扩展子数组的终点nums[j],同时计算当前子数组的乘积。当乘积超过k时,停止扩展并记录符合条件的子数组数量。
def numSubarrayProductLessThanK(nums, k): count = 0 n = len(nums) for i in range(n): product = 1 for j in range(i, n): product *= nums[j] if product < k: count += 1 else: break return count2.2 时间复杂度分析
这种解法的时间复杂度是O(n²),对于小规模数据(比如n≤100)还能接受。但当n=10000时,需要执行约5000万次操作,这在LeetCode的判题系统中肯定会超时。
实际测试:当n=10000时,暴力解法在LeetCode上运行时间超过2秒(Python),而题目要求通常在1秒内完成。
3. 滑动窗口的优化思路
3.1 滑动窗口的基本原理
滑动窗口算法通过维护一个动态的窗口来减少不必要的计算。对于这个问题,我们可以:
- 维护一个窗口[left, right],初始时left=right=0
- 扩展右边界right,同时计算窗口内元素的乘积
- 当乘积≥k时,收缩左边界left直到乘积<k
- 在每次右边界移动后,统计新增的有效子数组数量
3.2 为什么滑动窗口有效
关键在于观察到:当窗口[left, right]的乘积小于k时,所有以nums[right]结尾的子数组[left,right], [left+1,right], ..., [right,right]都满足乘积小于k。这样我们可以在O(1)时间内计算出新增的有效子数组数量,而不需要重新计算。
4. 滑动窗口的具体实现
4.1 标准实现代码
def numSubarrayProductLessThanK(nums, k): if k <= 1: # 重要边界条件 return 0 count = 0 product = 1 left = 0 for right in range(len(nums)): product *= nums[right] while product >= k: product /= nums[left] left += 1 count += right - left + 1 return count4.2 关键点解析
边界条件处理:当k≤1时,由于数组元素都是正整数,不可能有乘积小于1的子数组(空数组不算),直接返回0。
窗口维护逻辑:每次右指针移动时,先更新乘积,然后通过移动左指针来保证乘积小于k。
子数组计数技巧:新增的有效子数组数量等于right-left+1,这是理解这个算法的关键。
5. 算法正确性证明
5.1 为什么这样计数是正确的
假设当前窗口是[left, right],那么新增的有效子数组包括:
- [left, right]
- [left+1, right]
- ...
- [right, right]
总共有right-left+1个。这些子数组都满足乘积小于k,因为更大的窗口[left-1, right]已经因为乘积≥k而被排除了。
5.2 数学归纳法验证
我们可以用数学归纳法证明:
- 初始时left=right=0,显然成立
- 假设对于right=n成立,那么对于right=n+1:
- 如果乘积仍然<k,新增n+1-left+1个子数组
- 如果乘积≥k,移动left直到乘积<k,此时新增的子数组确实都是以n+1结尾的有效子数组
6. 复杂度分析与优化效果
6.1 时间复杂度
虽然代码中有嵌套循环,但每个元素最多被左指针和右指针各访问一次,因此时间复杂度是O(n),相比暴力解法的O(n²)有了质的飞跃。
6.2 空间复杂度
只使用了常数级别的额外空间,空间复杂度是O(1)。
7. 边界条件与特殊测试用例
7.1 必须考虑的边界情况
k=0或k=1:由于数组元素都是正整数,没有子数组乘积小于等于0,小于1的子数组也不存在(空数组不算)。
assert numSubarrayProductLessThanK([1,2,3], 1) == 0 assert numSubarrayProductLessThanK([1,2,3], 0) == 0空数组:应该返回0。
assert numSubarrayProductLessThanK([], 10) == 0所有元素都大于k:只有单个元素的子数组可能满足条件。
assert numSubarrayProductLessThanK([10,20,30], 5) == 0
7.2 容易出错的测试用例
# 元素包含1的情况 assert numSubarrayProductLessThanK([1,1,1], 2) == 6 # 大数测试 assert numSubarrayProductLessThanK([10,5,2,6], 100) == 8 # 长数组性能测试 long_nums = [random.randint(1, 100) for _ in range(10000)] # 滑动窗口解法应该能在毫秒级完成8. 算法变种与扩展思考
8.1 如果数组包含0或负数
原题假设数组元素都是正整数,如果允许0或负数,问题会变得复杂:
- 包含0:当遇到0时,乘积会突然变为0,需要特殊处理。
- 包含负数:乘积的符号会变化,可能需要维护最大乘积和最小乘积。
8.2 类似问题的通用解法
滑动窗口适用于解决很多子数组/子字符串问题,如:
- 和大于/小于某值的子数组
- 包含某类字符的最短子字符串
- 不重复字符的最长子字符串
它们的共同特点是都需要在数组中寻找满足某种条件的连续子序列。
9. 实际面试中的考察点
在面试中,面试官通常会期待候选人:
- 首先提出暴力解法并分析其缺点
- 然后逐步引导到滑动窗口解法
- 能够正确实现滑动窗口并解释其工作原理
- 处理各种边界条件
- 分析时间/空间复杂度
常见follow-up问题包括:
- 如果数组包含0怎么办?
- 如果数组包含负数怎么办?
- 你能证明这个算法的正确性吗?
10. 个人解题心得与建议
在多次解决这个问题后,我总结了一些经验:
先写暴力解法:虽然效率低,但能确保理解题意,也为优化提供基础。
画图辅助理解:在纸上画出数组和窗口移动的过程,更容易发现规律。
重视边界条件:特别是k≤1的情况,很多人在面试中会忽略。
计数技巧是关键:理解为什么新增子数组数量是right-left+1这一点至关重要。
测试用例要全面:包括空数组、全大于k的数组、包含1的数组等特殊情况。
对于正在准备面试的同学,我的建议是:
- 彻底理解滑动窗口的应用场景
- 多练习类似的子数组问题
- 每次解题后都思考可能的变种和扩展