1. 问题背景与题目解析
今天我们来拆解一道LeetCode中等难度题目——948. Bag of Tokens(令牌放置)。这道题在周赛和日常练习中出现的频率较高,考察的是对贪心算法和双指针技巧的综合运用能力。题目描述如下:
你有一个初始能量为P的背包和初始分数为0的分数。给定一组令牌tokens,其中每个token[i]表示第i个令牌的能量值。你可以通过以下两种方式操作令牌:
- 消耗当前令牌的能量值(token[i]),获得1分(必须满足P >= token[i])
- 消耗1分,获得当前令牌的能量值(必须至少有1分)
你可以以任意顺序使用令牌,也可以不使用某些令牌。求通过最优操作能够获得的最大分数。
提示:这道题的关键在于理解"能量"和"分数"之间的转换关系,以及如何通过合理的操作顺序最大化最终得分。
2. 贪心策略与双指针解法
2.1 基本解题思路
这道题的核心在于如何在"消耗能量获取分数"和"消耗分数获取能量"之间找到最优平衡点。经过分析,我们可以得出以下关键观察:
- 当能量充足时,优先消耗小能量令牌获取分数(因为这样可以用最少的能量换取分数)
- 当能量不足时,用分数换取大能量令牌(因为这样可以用最少的分数换取最多的能量)
- 最终目标是最大化分数,而不是能量
基于这些观察,我们可以采用贪心算法结合双指针的策略:
def bagOfTokensScore(tokens, P): tokens.sort() left, right = 0, len(tokens) - 1 score = max_score = 0 while left <= right: if P >= tokens[left]: P -= tokens[left] score += 1 left += 1 max_score = max(max_score, score) elif score > 0: P += tokens[right] score -= 1 right -= 1 else: break return max_score2.2 算法步骤详解
排序预处理:首先对令牌数组进行排序(时间复杂度O(n log n))。这使得我们可以方便地获取最小和最大能量的令牌。
初始化指针和变量:
- left指针指向当前最小能量令牌
- right指针指向当前最大能量令牌
- score记录当前分数
- max_score记录历史最高分数
主循环逻辑:
- 当剩余能量足够获取最小令牌时:消耗能量获取分数,左指针右移
- 当能量不足但还有分数时:用分数换取最大令牌的能量,右指针左移
- 当既不能获取分数也不能换取能量时:终止循环
边界条件处理:
- 确保left和right指针不会越界
- 每次获得新分数时更新max_score
3. 时间复杂度与空间复杂度分析
3.1 时间复杂度
该算法的时间复杂度主要由两部分组成:
- 排序阶段:O(n log n),使用Python内置的Timsort算法
- 双指针遍历阶段:O(n),每个令牌最多被访问一次
因此总体时间复杂度为O(n log n),对于LeetCode题目来说是完全可接受的。
3.2 空间复杂度
空间复杂度取决于排序的实现方式:
- 如果使用原地排序(如Python的list.sort()),则空间复杂度为O(1)
- 如果使用非原地排序,则可能需要O(n)的额外空间
在实际编程竞赛和面试中,通常可以认为Python的sort()是原地排序,因此空间复杂度为O(1)。
4. 常见错误与调试技巧
4.1 典型错误案例
在解决这个问题时,容易出现以下几种错误:
未排序直接操作:直接使用原始令牌顺序,导致无法正确应用贪心策略
- 示例错误:
tokens = [100,200,300,400], P = 200,正确结果应为2,但未排序可能得到错误结果
- 示例错误:
指针移动条件错误:在能量不足时错误地移动指针
- 错误示例:在
P < tokens[left]且score == 0时仍移动指针
- 错误示例:在
max_score更新时机不当:只在循环结束时更新max_score,错过中间更高分数
4.2 调试技巧
为了验证算法的正确性,可以采用以下测试用例:
test_cases = [ ([100], 50), # 预期: 0 ([100,200], 150), # 预期: 1 ([100,200,300,400], 200), # 预期: 2 ([71,55,82], 54), # 预期: 0 ([], 100), # 预期: 0 ([50,50,50], 100) # 预期: 2 ]在实现时,建议添加详细的打印语句跟踪变量变化:
print(f"left={left}, right={right}, P={P}, score={score}, max_score={max_score}")5. 算法优化与变种思考
5.1 可能的优化方向
虽然当前解法已经相当高效,但仍有一些优化空间:
- 提前终止条件:当剩余令牌数小于等于当前max_score时,可以提前终止循环
- 能量累积检查:在能量不足时,可以检查剩余令牌是否值得用分数换取
优化后的代码可能如下:
def bagOfTokensScore(tokens, P): tokens.sort() left, right = 0, len(tokens) - 1 score = max_score = 0 while left <= right: if P >= tokens[left]: P -= tokens[left] score += 1 left += 1 max_score = max(max_score, score) elif score > 0 and (tokens[right] - tokens[left]) > 0: P += tokens[right] score -= 1 right -= 1 else: break # 提前终止条件 if right - left + 1 <= max_score: break return max_score5.2 相关变种题目
理解这道题的核心思想后,可以尝试解决以下类似题目:
- LeetCode 860. Lemonade Change(柠檬水找零)
- LeetCode 455. Assign Cookies(分发饼干)
- LeetCode 122. Best Time to Buy and Sell Stock II(买卖股票的最佳时机II)
这些题目都涉及到贪心策略的应用,通过解决它们可以加深对这类问题的理解。
6. 实际应用场景分析
虽然这道题看起来是纯粹的算法练习,但其核心思想在实际开发中有多种应用:
- 资源分配系统:如何在有限资源下最大化收益,类似于云计算中的资源调度
- 游戏开发:角色能量与技能点的转换机制设计
- 金融交易:资金与资产的转换策略,寻找最优交易时机
理解这种能量-分数转换模型,可以帮助我们在面对类似的实际问题时快速建立解决方案。
7. 个人解题心得
在多次解决这类贪心算法题目后,我总结出以下几点经验:
- 排序是贪心的好朋友:大多数贪心问题都需要先对数据进行某种排序
- 双指针的灵活性:不仅可以用在数组求和,还能用于这种资源转换问题
- 边界条件的重要性:特别是当能量和分数都为0时的处理很容易遗漏
- 变量命名的清晰性:好的变量名如max_score比简单的res更能表达意图
在实际面试中,建议先明确说出解题思路,再开始编码。对于这道题,可以这样表达:
"我计划先对令牌排序,然后用双指针策略,左边获取分数,右边获取能量,同时跟踪最大分数..."
这种结构化的表达方式会给面试官留下良好印象。