令牌放置 图解题解
这道题到底在问什么
- 输入
- tokens=[40,25,60,30,15,50], power=35
- 输出
- 2
最优解:为什么这么做
一句话答案:LeetCode 948 令牌放置用排序加对撞双指针:便宜令牌花能量换分、体力不够时把最贵的令牌卖成能量,两头相向夹逼榨出最高分数。时间 O(n log n)、空间 O(1)。
令牌能正面买分、反面买体力,这题求的是什么
给一个令牌数组 tokens 和初始能量 power。每个令牌只能用一次:正面朝上,能量够付 tokens[i] 就花掉换 +1 分;反面朝下,手里有分就花 1 分换回 tokens[i] 那么多能量。求整场攒到的最高分。题面 tokens=[40,25,60,30,15,50]、power=35 时答案是 2。
每个令牌都要选正反、还要排顺序,硬枚举有多大
每个令牌要么正面买分、要么反面卖分、要么不用,先后顺序还能任意排。想穷举所有组合,方案数随令牌个数指数级膨胀,n 个令牌光排列就 n! 种,稍多几个就算不完。得找条规律把「买哪个、卖哪个」定死,不再逐个搜索。
买分挑最便宜、卖分挑最值钱,为什么这样贪最优
买分是在花能量,花得越省、后面能买的次数越多,所以每次都挑当前最便宜的令牌买,代价最小。卖分正相反:卖一次要付出宝贵的 1 分,当然要换回最多能量,也就是挑当前最值钱的。tokens 排序后最便宜的全在左边、最值钱的全在右边,正好左指针负责买、右指针负责卖,两头往中间夹。
还有个容易漏掉的量:分数会为换能量主动跌回去,但答案要的是整场里的最高点。所以另记一个 ans 追历史峰值,score 每涨到新高就刷新 ans,卖分让它回落时 ans 不动。
左右指针每一步怎么定买还是卖,什么时候收手
循环只在左指针不超过右指针时继续,每步看三种情况:能量够买最便宜的那张令牌,就正面朝上——能量减掉它、score 加 1、左指针右移,顺手用 ans 记峰值;买不动但手里还有分(score > 0),就反面朝下卖最值钱的那张——能量加上它、score 减 1、右指针左移;既买不动又没分可卖,就直接跳出。两指针交错时同样停,返回 ans。
power=35 起手,两指针把这组 tokens 夹到底
先排序得 [15,25,30,40,50,60],左右指针分踞两端,power=35、score 与 ans 都是 0。第一步 power=35 ≥ 15,买 15:power=20、score=1,ans 刷成 1。第二步 power=20 < 25 买不动、还剩 1 分,卖 60:power=80、score=0。第三步 power=80 ≥ 25,买:power=55、score=1,ans 仍是 1。第四步 power=55 ≥ 30,买:power=25、score=2,ans 刷成 2。第五步 power=25 < 40 买不动,卖 50:power=75、score=1。第六步 power=75 ≥ 40,买:power=35、score=2,ans 仍是 2。左指针越过右指针,循环停,返回 2。
排序扛住主要开销,空数组和开局卡死各怎么收场
整个流程时间 O(n log n),瓶颈在排序;双指针只是一遍线性扫,额外空间 O(1)。两个边界要想清:tokens 为空时左指针在 0、右指针在 -1,循环条件一进就不成立,直接返回 0;开局 power 连最便宜的都付不起、score 又是 0,第一步撞上「既买不动又没分卖」跳出,答案是 0。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这句「小的换分、大的换能量、能买就买买不动就卖」,下面每一帧都在套它。答案 ans 只升不随 score 回落,记的是历史峰值。
- 4这是发到手的令牌,顺序是乱的。贪心策略要反复取「当前最小」和「当前最大」,乱序下没法一眼定位,所以第一步先排序。
- 5排好序后,最左边的 15 是最便宜的令牌、买分优先选它;最右边的 60 是最值钱的、卖分优先选它。一头一尾正好对应两个指针。
- 6左指针 i 指向最小令牌、负责买分,右指针 j 指向最大令牌、负责卖分。只要 i 不超过 j 就继续:先试着用 i 买,买不动再考虑用 j 卖。
- 7先看左指针的最小令牌 15。当前能量 35 不小于它,说明买得起,那就贪心地用最便宜的把这一分先拿下。
- 8正面朝上!花掉 15 点能量,能量从 35 降到 20,分数加到 1。这个令牌标绿,表示它被换成了分。
- 9分数 1 创了新高,ans 刷新到 1。左指针右移,去看下一个最便宜的令牌还能不能接着买。
- 10现在能量 20 连最小令牌 25 都买不起了。好在手里还有 1 分,可以反面朝下:盯住最值钱的 60,花 1 分把它换成一大笔能量。
- 11反面朝下!花 1 分换来 60 点能量,能量从 20 升到 80,分数回落到 0。注意 ans 记的是历史峰值 1,卖分不会把它抹掉。右指针左移。
- 12先看左指针的最小令牌 25。当前能量 80 不小于它,说明买得起,那就贪心地用最便宜的把这一分先拿下。
- 13正面朝上!花掉 25 点能量,能量从 80 降到 55,分数加到 1。这个令牌标绿,表示它被换成了分。
- 14分数 1 没超过历史最高 1,ans 保持不动。左指针右移,继续往下看。
- 15先看左指针的最小令牌 30。当前能量 55 不小于它,说明买得起,那就贪心地用最便宜的把这一分先拿下。
- 16正面朝上!花掉 30 点能量,能量从 55 降到 25,分数加到 2。这个令牌标绿,表示它被换成了分。
- 17分数 2 创了新高,ans 刷新到 2。左指针右移,去看下一个最便宜的令牌还能不能接着买。
- 18现在能量 25 连最小令牌 40 都买不起了。好在手里还有 2 分,可以反面朝下:盯住最值钱的 50,花 1 分把它换成一大笔能量。
- 19反面朝下!花 1 分换来 50 点能量,能量从 25 升到 75,分数回落到 1。注意 ans 记的是历史峰值 2,卖分不会把它抹掉。右指针左移。
- 20先看左指针的最小令牌 40。当前能量 75 不小于它,说明买得起,那就贪心地用最便宜的把这一分先拿下。
- 21正面朝上!花掉 40 点能量,能量从 75 降到 35,分数加到 2。这个令牌标绿,表示它被换成了分。
- 22分数 2 没超过历史最高 2,ans 保持不动。左指针右移,继续往下看。
- 23左右指针交错,i 已经超过 j,可用的令牌都处理完了。绿色是买分用掉的、灰色是卖能量用掉的,过程到此为止。
- 24回看整场:score 升到 2 时是最辉煌的一刻,后面虽然为换能量又卖回到 1,但 ans 早把这个峰值 2 记下了。所以最终答案就是 2。
⚠️ 容易写错的地方
✗ 错:买分时随便挑一个令牌花能量
✓ 对:永远买当前最小的(左指针)
买分要尽量省能量,花得越少越能多买
✗ 错:卖分时也挑小的换能量
✓ 对:永远卖当前最大的(右指针)
卖分只换 1 次,当然要换回最多的能量
✗ 错:用最终 score 当答案
✓ 对:用 ans 记历史峰值
为换能量会主动卖分,结尾的 score 可能比中途峰值低
✗ 错:score = 0 时还想卖分
✓ 对:没分不能反面朝下,直接跳出
反面朝下的前提是至少有 1 分可花
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def bagOfTokensScore(self, tokens: List[int], power: int) -> int:
tokens.sort()
ans = score = 0
i, j = 0, len(tokens) - 1
while i <= j:
if power >= tokens[i]:
power -= tokens[i]
score, i = score + 1, i + 1
ans = max(ans, score)
elif score:
power += tokens[j]
score, j = score - 1, j - 1
else:
break
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int bagOfTokensScore(vector<int>& tokens, int power) {
sort(tokens.begin(), tokens.end());
int ans = 0, score = 0;
for (int i = 0, j = tokens.size() - 1; i <= j;) {
if (power >= tokens[i]) {
power -= tokens[i++];
ans = max(ans, ++score);
} else if (score > 0) {
power += tokens[j--];
--score;
} else {
break;
}
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int bagOfTokensScore(int[] tokens, int power) {
Arrays.sort(tokens);
int ans = 0, score = 0;
for (int i = 0, j = tokens.length - 1; i <= j;) {
if (power >= tokens[i]) {
power -= tokens[i++];
ans = Math.max(ans, ++score);
} else if (score > 0) {
power += tokens[j--];
--score;
} else {
break;
}
}
return ans;
}
}复杂度
时间
O(n log n)
排序主导;之后双指针一遍线性扫
空间
O(1)
只用几个变量;不计排序自身开销
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 令牌放置 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么买分一定挑最小、卖分一定挑最大,能证明吗?+
买分要消耗能量,能量越省、后续能换分的机会越多,所以每次买都选代价最小的令牌不会让结果更差;卖分是用宝贵的 1 分去博一笔能量,当然要换回最多的,也就是卖最贵的。可以用交换论证:任取一个最优解,若它某次买的不是当前最小、或卖的不是当前最大,把这步换成「买更小、卖更大」,得到的能量只会不减、分数不减,结果不变差。所以总存在一个「买最小、卖最大」的最优解。
score 中途会掉,为什么最终答案取 ans 而不是结尾的 score?+
反面朝下换能量时 score 要减 1,是主动的回落。这么做只为攒够能量去博后面更高的分,不代表之前那些分白拿了。ans 每次 score 涨到新高就记一笔,是一路走来的历史最高分;结尾的 score 可能因为刚卖过分而低于中途峰值。题面这组走完 score 停在 2,恰好等于峰值,但若最后一步是卖分收尾,score 会比 ans 小——那时拿 score 当答案就错了。
什么时候该停止卖分,会不会把分全卖光?+
反面朝下的前提是至少有 1 分可花,所以 score 降到 0 就不能再卖。代码里当「买不动且 score 为 0」时直接跳出,正是这个含义。也不必担心把分卖光去硬换能量:卖分只在「买不动、但还有分」时才发生,而且卖完若能量够了会立刻回到买分那支继续攒;就算最后没能攒出更高分,ans 也早把中途峰值记住了,卖分不会抹掉已到手的战绩。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 令牌放置 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。