1. 问题背景与题目解析
今天我们来拆解LeetCode第1052题"爱生气的书店老板",这是一道典型的滑动窗口应用题,难度评级为中等偏低。题目描述了一位书店老板在特定时间段内的营业情况,我们需要计算在特定约束条件下能够获得的最大顾客满意度。
题目场景是这样的:书店老板每天营业customers.length分钟,每分钟会有customers[i]位顾客进店。老板有个脾气,在某些分钟会生气(grumpy[i] = 1),这时顾客会不满意;不生气时(grumpy[i] = 0)顾客会满意。现在给你一个整数minutes,表示你可以使用特殊技巧让老板连续minutes分钟不生气。问题是:如何运用这minutes分钟的技巧,使得一天中满意的顾客数量最大化?
提示:这道题的关键在于理解滑动窗口的应用场景,以及如何高效地计算窗口内的增益值。
2. 暴力解法与问题分析
2.1 最直观的暴力解法
对于刚接触这类问题的开发者,最直接的想法可能是:
- 先计算不使用技巧时的基础满意顾客数
- 然后枚举所有可能的minutes长度窗口
- 对每个窗口,计算如果在这个窗口使用技巧能增加的满意顾客数
- 找出增益最大的窗口,加到基础值上
这种解法的时间复杂度是O(n^2),当n较大时(比如10^5量级)会超时。让我们用Python代码表示这个思路:
python复制def maxSatisfied(customers, grumpy, minutes):
n = len(customers)
base = 0
for i in range(n):
if grumpy[i] == 0:
base += customers[i]
max_gain = 0
for i in range(n - minutes + 1):
gain = 0
for j in range(i, i + minutes):
if grumpy[j] == 1:
gain += customers[j]
max_gain = max(max_gain, gain)
return base + max_gain
2.2 暴力解法的问题
在实际测试中,当输入规模达到10^5时,这种双重循环的解法会明显超时。我在LeetCode上测试时,对于大输入用例,执行时间超过了限制,这促使我们寻找更优的解决方案。
3. 滑动窗口优化解法
3.1 滑动窗口的基本思想
滑动窗口是一种常用的算法技巧,特别适合处理数组/字符串的子区间问题。它的核心思想是维护一个窗口,通过移动窗口的左右边界来避免重复计算。
对于本题,我们可以:
- 先计算基础满意顾客数(老板不生气时的顾客总和)
- 然后计算第一个窗口(前minutes分钟)内能"挽救"的顾客数
- 滑动窗口,每次移动一位,减去左边离开窗口的值,加上右边进入窗口的值
- 记录滑动过程中的最大值
3.2 优化解法的实现
下面是优化后的Python代码实现:
python复制def maxSatisfied(customers, grumpy, minutes):
n = len(customers)
base = 0
for i in range(n):
if grumpy[i] == 0:
base += customers[i]
# 初始窗口增益
gain = 0
for i in range(minutes):
if grumpy[i] == 1:
gain += customers[i]
max_gain = gain
# 滑动窗口
for i in range(minutes, n):
left = i - minutes
if grumpy[left] == 1:
gain -= customers[left]
if grumpy[i] == 1:
gain += customers[i]
max_gain = max(max_gain, gain)
return base + max_gain
这个解法的时间复杂度降到了O(n),空间复杂度是O(1),能够高效处理大规模输入。
4. 代码优化与边界处理
4.1 进一步代码优化
我们可以将基础满意顾客数的计算和初始窗口增益的计算合并到一个循环中,减少一次遍历:
python复制def maxSatisfied(customers, grumpy, minutes):
n = len(customers)
base = 0
gain = 0
max_gain = 0
# 合并计算基础值和初始窗口
for i in range(n):
if grumpy[i] == 0:
base += customers[i]
else:
if i < minutes:
gain += customers[i]
max_gain = gain
# 滑动窗口
for i in range(minutes, n):
left = i - minutes
if grumpy[left] == 1:
gain -= customers[left]
if grumpy[i] == 1:
gain += customers[i]
max_gain = max(max_gain, gain)
return base + max_gain
4.2 边界条件处理
在实际编码中,我们需要考虑以下边界情况:
- minutes为0:相当于不使用技巧,直接返回基础满意顾客数
- minutes等于数组长度:可以覆盖整个营业时间,返回所有顾客总和
- customers或grumpy数组为空:返回0
修改后的完整代码如下:
python复制def maxSatisfied(customers, grumpy, minutes):
if not customers or not grumpy:
return 0
n = len(customers)
if minutes == 0:
return sum(c for i, c in enumerate(customers) if grumpy[i] == 0)
if minutes >= n:
return sum(customers)
base = 0
gain = 0
max_gain = 0
for i in range(n):
if grumpy[i] == 0:
base += customers[i]
else:
if i < minutes:
gain += customers[i]
max_gain = gain
for i in range(minutes, n):
left = i - minutes
if grumpy[left] == 1:
gain -= customers[left]
if grumpy[i] == 1:
gain += customers[i]
max_gain = max(max_gain, gain)
return base + max_gain
5. 复杂度分析与同类问题
5.1 时间复杂度分析
优化后的解法包含两次遍历:
- 第一次遍历计算基础值和初始窗口:O(n)
- 第二次滑动窗口:O(n)
总体时间复杂度为O(n),空间复杂度为O(1),只使用了常数个额外变量。
5.2 空间复杂度分析
我们只使用了固定数量的变量(base, gain, max_gain等),没有使用额外的数据结构,因此空间复杂度是O(1)。
5.3 类似题目推荐
掌握了这道题的解法后,可以尝试以下类似题目巩固滑动窗口技巧:
- LeetCode 643 - 子数组最大平均数 I
- LeetCode 209 - 长度最小的子数组
- LeetCode 424 - 替换后的最长重复字符
- LeetCode 1004 - 最大连续1的个数 III
这些题目都使用了滑动窗口的思想,但各自有不同的约束条件和变体,适合进一步练习。
6. 实际应用与扩展思考
6.1 滑动窗口的实际应用场景
滑动窗口算法在实际开发中有广泛应用,例如:
- 网络流量控制中的带宽限制
- 实时数据流分析(如最近1分钟的错误率)
- 用户行为分析(如连续登录天数)
- 金融领域的移动平均计算
6.2 算法选择的思考过程
当我第一次遇到这个问题时,思考路径是这样的:
- 理解题意:需要找到连续minutes分钟,其中grumpy为1的customers总和最大
- 暴力解法明显不高效,因为要枚举所有可能的窗口
- 识别到窗口是固定大小的,且每次移动只改变两个边界
- 想到可以使用滑动窗口来优化重复计算
- 将问题分解为基础值和窗口增益两部分
这种分步思考的方法可以帮助我们更好地理解和解决类似问题。
6.3 测试用例设计建议
为了验证代码的正确性,建议设计以下测试用例:
- 常规情况:customers = [1,0,1,2,1,1,7,5], grumpy = [0,1,0,1,0,1,0,1], minutes = 3
- 边界情况:minutes为0或等于数组长度
- 全为0的情况:grumpy全为0
- 全为1的情况:grumpy全为1
- 大输入测试:验证算法效率
7. 常见错误与调试技巧
7.1 新手常见错误
在解决这类问题时,新手常犯的错误包括:
- 窗口大小处理不当:忘记minutes可以等于0或大于数组长度
- 索引越界:在滑动窗口时没有正确处理边界
- 重复计算:没有有效利用滑动窗口的特性,导致时间复杂度高
- 条件判断错误:混淆grumpy为0和为1时的逻辑
7.2 调试技巧
当你的代码不能通过测试时,可以:
- 打印中间变量:在滑动过程中打印gain和max_gain的值
- 使用小测试用例:手动计算预期结果,与程序输出对比
- 检查边界条件:特别是minutes为0或等于数组长度时
- 逐步执行:使用调试器一步步跟踪代码执行过程
我在实际解题过程中发现,打印窗口滑动过程中的gain变化非常有帮助,可以直观地看到算法是否正确工作。
8. 性能优化与语言特性
8.1 Python特定优化
在Python中,我们可以使用内置函数和生成器表达式来使代码更简洁:
python复制def maxSatisfied(customers, grumpy, minutes):
n = len(customers)
base = sum(c for c, g in zip(customers, grumpy) if g == 0)
max_gain = gain = sum(c for c, g in zip(customers[:minutes], grumpy[:minutes]) if g == 1)
for i in range(minutes, n):
gain += grumpy[i] * customers[i] - grumpy[i - minutes] * customers[i - minutes]
max_gain = max(max_gain, gain)
return base + max_gain
这种写法利用了Python的zip和生成器表达式,虽然时间复杂度相同,但代码更简洁。
8.2 其他语言的实现差异
在不同语言中实现时需要注意:
- C++:可以使用std::accumulate计算基础值,注意迭代器范围
- Java:注意数组边界检查,避免ArrayIndexOutOfBoundsException
- JavaScript:可以使用reduce方法计算初始值
9. 算法可视化理解
为了更好理解滑动窗口的工作方式,可以想象:
- 有一个长度为minutes的窗口在customers数组上滑动
- 窗口每次向右移动一位
- 左边出去的值如果是grumpy为1的,就从gain中减去
- 右边进来的值如果是grumpy为1的,就加到gain中
- 始终保持max_gain记录最大的gain值
这种可视化方法可以帮助我们更直观地理解算法的运作过程。
10. 总结与个人心得
这道"爱生气的书店老板"题目很好地展示了滑动窗口算法的应用场景。通过这道题,我总结了以下几点经验:
-
识别滑动窗口适用场景:当问题涉及固定长度的子数组/子串,并且需要计算某种聚合值时,滑动窗口通常是高效的选择。
-
分而治之的思考方式:将问题分解为基础值和窗口增益两部分,分别处理,最后合并结果。
-
边界条件的重要性:在实际编码中,特别要注意处理minutes为0或等于数组长度的情况。
-
从暴力解法出发:先理解问题的暴力解法,再思考如何优化,这种思考路径对解决新问题很有帮助。
在实际面试中,这类题目经常出现,因为它们既能考察算法思想,又能检验编码实现能力。建议大家在掌握基本解法后,多思考变种问题,比如如果minutes不是固定的,或者grumpy有更多状态时,该如何解决。
