1. 算法训练营第29天核心题目解析
作为一名参加过多次算法训练营的老学员,我清楚地记得第29天的内容总是让人又爱又恨。这天的四道题目看似简单,实则暗藏玄机,涵盖了贪心算法的多个经典应用场景。让我们从实际刷题的角度,逐题拆解其中的精妙之处。
1.1 加油站问题(LeetCode 134)
这道题我第一次做时陷入了暴力解法的泥潭。题目描述很简单:在一条环形路上有N个加油站,每个加油站有gas[i]汽油,到下一个加油站需要cost[i]汽油,问从哪个加油站出发可以走完全程。
关键突破点在于理解这个数学关系:如果总油量 >= 总消耗,那么一定存在解。具体实现时,我们可以用贪心的思路:
python复制def canCompleteCircuit(gas, cost):
total = 0
curr = 0
start = 0
for i in range(len(gas)):
total += gas[i] - cost[i]
curr += gas[i] - cost[i]
if curr < 0:
start = i + 1
curr = 0
return start if total >= 0 else -1
实战经验:当curr_sum变为负数时,说明之前的所有加油站都不能作为起点,直接从下一个加油站重新开始计算。这个思路的时间复杂度是O(n),比暴力解法O(n^2)高效得多。
1.2 分发糖果问题(LeetCode 135)
这道题考察的是双向贪心策略。规则是:每个孩子至少一个糖果;评分更高的孩子必须比相邻孩子获得更多糖果。
我第一次做的时候只考虑了从左到右的遍历,结果发现无法处理递减序列。正确的做法需要两次遍历:
python复制def candy(ratings):
n = len(ratings)
left = [1] * n
for i in range(1, n):
if ratings[i] > ratings[i-1]:
left[i] = left[i-1] + 1
right = 1
res = left[-1]
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
right += 1
else:
right = 1
res += max(left[i], right)
return res
踩坑记录:第二次从右向左遍历时,不需要维护整个数组,只需要一个变量right记录当前连续递减的长度即可,这样可以优化空间复杂度到O(1)。
2. 柠檬水找零问题深度剖析
2.1 问题建模与贪心选择
LeetCode 860这道题看似简单,但隐藏着对货币系统的深刻理解。题目说顾客只会支付5、10或20美元,柠檬水价格固定为5美元。
关键在于20美元找零时的贪心选择:优先使用10美元+5美元的组合,而不是三张5美元。因为10美元只能用于20美元的找零,而5美元用途更广。
python复制def lemonadeChange(bills):
five = ten = 0
for bill in bills:
if bill == 5:
five += 1
elif bill == 10:
if not five:
return False
five -= 1
ten += 1
else:
if ten and five:
ten -= 1
five -= 1
elif five >= 3:
five -= 3
else:
return False
return True
2.2 边界条件测试
在实际面试中,面试官可能会追问各种边界情况:
- 连续多个20美元支付
- 开头就是10美元或20美元
- 大量小额交易后的找零能力
我建议在练习时专门针对这些边界case设计测试样例,比如:
python复制print(lemonadeChange([5,5,10,10,20])) # False
print(lemonadeChange([5,5,5,10,20])) # True
3. 根据身高重建队列的巧妙解法
3.1 问题转化思维
LeetCode 406题要求我们按照身高h和前面不低于自己身高的人数k来重建队列。初次接触时很容易被两个维度的约束条件搞晕。
解题的关键在于先排序再插入的贪心策略:
- 按身高降序、k值升序排序
- 逐个按k值插入到结果列表中
python复制def reconstructQueue(people):
people.sort(key=lambda x: (-x[0], x[1]))
res = []
for p in people:
res.insert(p[1], p)
return res
3.2 为什么这样排序有效?
这里涉及到贪心算法的一个核心思想:局部最优导致全局最优。先处理高个子是因为矮个子的插入不会影响高个子的k值。举个例子:
输入 [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
排序后 [[7,0],[7,1],[6,1],[5,0],[5,2],[4,4]]
插入过程:
- [7,0] -> [[7,0]]
- [7,1] -> [[7,0],[7,1]]
- [6,1] -> [[7,0],[6,1],[7,1]]
- [5,0] -> [[5,0],[7,0],[6,1],[7,1]]
- [5,2] -> [[5,0],[7,0],[5,2],[6,1],[7,1]]
- [4,4] -> [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
性能提示:虽然insert操作的时间复杂度是O(n),导致整体O(n^2),但在实际面试中先给出正确解法比过度优化更重要。可以后续讨论如何用更高效的数据结构优化。
4. 贪心算法的通用解题框架
通过这四道题目,我们可以总结出贪心算法的一些通用解题模式:
4.1 问题识别特征
适合用贪心算法解决的问题通常具有:
- 最优子结构:局部最优解能导致全局最优解
- 无后效性:当前决策不影响后续决策
- 贪心选择性质:每一步的最优选择不需要考虑未来
4.2 解题四步法
- 问题分解:将问题分解为若干子问题
- 贪心选择:确定每一步的最优选择标准
- 合并子解:将子问题的解合并为最终解
- 正确性证明:验证贪心策略的正确性(通常用反证法)
4.3 常见陷阱与规避
- 过度依赖直觉:贪心算法需要严格证明,不能仅凭感觉
- 忽略边界条件:如空输入、极端值等
- 错误的问题识别:误用贪心算法解决动态规划问题
以分发糖果为例,很多同学会尝试一步到位的分配策略,但实际上需要左右两次遍历才能保证正确性。这提醒我们:有时候贪心策略需要多阶段配合。
5. 算法训练营的高效学习策略
5.1 每日刷题方法论
根据我的经验,在算法训练营中高效学习需要:
- 严格时间管理:每道题限制在30-45分钟内
- 三遍刷题法:
- 第一遍:独立思考尝试
- 第二遍:看题解理解
- 第三遍:隔天重写
- 错题分类整理:按算法类型和错误原因归类
5.2 这组题目的延伸练习
为了巩固今天的贪心算法,我推荐以下延伸题目:
-
- 买卖股票的最佳时机 II
-
- 用最少数量的箭引爆气球
-
- 划分字母区间
这些题目都体现了贪心算法的不同应用场景,建议在24小时内完成以形成知识联结。
5.3 调试技巧分享
在解决这类问题时,我常用的调试方法包括:
- 可视化跟踪:在纸上画出每一步的数据变化
- 小规模测试:先用简单例子验证算法正确性
- 变量监控:打印关键变量的中间状态
例如在加油站问题中,打印每个站点的curr和total值可以帮助快速定位逻辑错误:
python复制for i in range(len(gas)):
curr += gas[i] - cost[i]
print(f"Station {i}: curr={curr}, total={total}")
if curr < 0:
start = i + 1
curr = 0
6. 面试实战中的应对策略
6.1 问题澄清技巧
当面试官给出这类问题时,务必先澄清:
- 输入的范围和边界(如空输入是否可能)
- 输出要求(如只需返回是否存在解还是需要具体方案)
- 是否有空间或时间复杂度的限制
6.2 沟通解题思路
在面试中,建议采用以下沟通结构:
- 描述暴力解法及其复杂度
- 指出可以优化的部分
- 提出贪心策略并论证其正确性
- 讨论可能的边界情况
以柠檬水找零为例,可以这样表达:
"最直观的解法是维护所有零钱组合,但这样效率太低。注意到5美元和10美元的用途不同,我们可以优先使用10美元找零,因为它的使用场景更有限..."
6.3 代码书写规范
面试中写代码时要注意:
- 先写函数签名和注释
- 使用有意义的变量名(如five_count而非a)
- 适当添加空行分隔逻辑块
- 最后检查边界条件处理
例如分发糖果的代码可以这样组织:
python复制def candy(ratings):
"""
:type ratings: List[int]
:rtype: int
Strategy: Two-pass greedy
"""
# Left pass
n = len(ratings)
left = [1] * n
for i in range(1, n):
if ratings[i] > ratings[i-1]:
left[i] = left[i-1] + 1
# Right pass with space optimization
right = res = 0
for i in range(n-1, -1, -1):
if i < n-1 and ratings[i] > ratings[i+1]:
right += 1
else:
right = 1
res += max(left[i], right)
return res
7. 性能优化进阶思考
7.1 时间复杂度分析
让我们回顾这四道题的最优解法复杂度:
- 加油站:O(n)时间,O(1)空间
- 分发糖果:O(n)时间,O(n)空间(可优化到O(1))
- 柠檬水找零:O(n)时间,O(1)空间
- 身高重建队列:O(n^2)时间(因为insert操作),O(n)空间
7.2 空间优化技巧
以分发糖果为例,我们可以将空间优化到O(1):
python复制def candy(ratings):
n = len(ratings)
res = 1
inc = dec = pre = 1
for i in range(1, n):
if ratings[i] >= ratings[i-1]:
dec = 0
pre = (1 if ratings[i] == ratings[i-1] else pre + 1)
res += pre
inc = pre
else:
dec += 1
if dec == inc:
dec += 1
res += dec
pre = 1
return res
这个优化版本通过维护递增和递减序列的长度来动态计算糖果数,虽然代码更难理解,但在处理大规模数据时很有价值。
7.3 算法选择权衡
在实际工程中,我们需要考虑:
- 数据规模:小数据时简单算法可能更合适
- 可读性要求:团队协作时代码清晰很重要
- 扩展需求:未来可能需要支持更复杂的场景
以身高重建队列为例,虽然O(n^2)的解法在面试中可以接受,但在实际工程中如果n很大,可能需要考虑更高效的方案,比如使用平衡二叉搜索树来优化插入操作。
