255 lines
12 KiB
YAML
255 lines
12 KiB
YAML
title: Bag of Tokens
|
|
slug: bag-of-tokens
|
|
difficulty: medium
|
|
leetcode_id: 948
|
|
leetcode_url: https://leetcode.com/problems/bag-of-tokens/
|
|
categories:
|
|
- arrays
|
|
- sorting
|
|
- two-pointers
|
|
patterns:
|
|
- two-pointers
|
|
- greedy
|
|
|
|
function_signature: "def bag_of_tokens_score(tokens: list[int], power: int) -> int:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { tokens: [100], power: 50 }
|
|
expected: 0
|
|
- input: { tokens: [200, 100], power: 150 }
|
|
expected: 1
|
|
- input: { tokens: [100, 200, 300, 400], power: 200 }
|
|
expected: 2
|
|
hidden:
|
|
- input: { tokens: [], power: 100 }
|
|
expected: 0
|
|
- input: { tokens: [100], power: 100 }
|
|
expected: 1
|
|
- input: { tokens: [100, 100, 100], power: 200 }
|
|
expected: 2
|
|
- input: { tokens: [71, 55, 82], power: 54 }
|
|
expected: 0
|
|
- input: { tokens: [26, 33, 59], power: 45 }
|
|
expected: 1
|
|
|
|
description: |
|
|
You start with an initial **power** of `power`, an initial **score** of `0`, and a bag of tokens given as an integer array `tokens`, where each `tokens[i]` denotes the value of the i<sup>th</sup> token.
|
|
|
|
Your goal is to **maximise** the total **score** by strategically playing these tokens. In one move, you can play an **unplayed** token in one of two ways (but not both for the same token):
|
|
|
|
- **Face-up**: If your current power is **at least** `tokens[i]`, you may play the token, losing `tokens[i]` power and gaining `1` score.
|
|
- **Face-down**: If your current score is **at least** `1`, you may play the token, gaining `tokens[i]` power and losing `1` score.
|
|
|
|
Return *the **maximum** possible score you can achieve after playing **any** number of tokens*.
|
|
|
|
constraints: |
|
|
- `0 <= tokens.length <= 1000`
|
|
- `0 <= tokens[i], power < 10^4`
|
|
|
|
examples:
|
|
- input: "tokens = [100], power = 50"
|
|
output: "0"
|
|
explanation: "Since your score is 0 initially, you cannot play the token face-down. You also cannot play it face-up since your power (50) is less than tokens[0] (100)."
|
|
- input: "tokens = [200, 100], power = 150"
|
|
output: "1"
|
|
explanation: "Play token 1 (100) face-up, reducing your power to 50 and increasing your score to 1. There is no need to play token 0, since you cannot play it face-up to add to your score."
|
|
- input: "tokens = [100, 200, 300, 400], power = 200"
|
|
output: "2"
|
|
explanation: "Play token 0 (100) face-up, reducing power to 100 and increasing score to 1. Play token 3 (400) face-down, increasing power to 500 and reducing score to 0. Play token 1 (200) face-up, reducing power to 300 and increasing score to 1. Play token 2 (300) face-up, reducing power to 0 and increasing score to 2."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're playing a strategic card game where you have two currencies: **power** (energy) and **score** (points). Each token card can be played in two directions:
|
|
|
|
- Play it "face-up" to spend power and earn a point
|
|
- Play it "face-down" to spend a point and gain power
|
|
|
|
The key insight is that **not all tokens are equal**. A cheap token (low value) is valuable for earning points because it costs little power. An expensive token (high value) is valuable for gaining power when you need to sacrifice a point.
|
|
|
|
Think of it like this: you want to **buy low and sell high**. You "buy" score by spending power on cheap tokens, and you "sell" score to gain power from expensive tokens.
|
|
|
|
This naturally suggests sorting the tokens and using two pointers — one at each end. The left pointer points to the cheapest unplayed token (ideal for gaining score), while the right pointer points to the most expensive unplayed token (ideal for gaining power).
|
|
|
|
The greedy strategy becomes clear:
|
|
1. If you can afford the cheapest token, play it face-up (gain score cheaply)
|
|
2. If you can't afford anything but have score to spare, play the most expensive token face-down (maximise power gained per score spent)
|
|
3. Keep track of the maximum score achieved at any point
|
|
|
|
approach: |
|
|
We solve this using a **Two Pointers with Greedy** approach:
|
|
|
|
**Step 1: Sort the tokens array**
|
|
|
|
- Sorting allows us to access the smallest tokens (cheapest to play face-up) and largest tokens (best for playing face-down) efficiently
|
|
- After sorting, `tokens[0]` is the cheapest and `tokens[n-1]` is the most expensive
|
|
|
|
|
|
|
|
**Step 2: Initialise variables**
|
|
|
|
- `left`: Pointer starting at index `0` (smallest token)
|
|
- `right`: Pointer starting at index `n-1` (largest token)
|
|
- `score`: Current score, starting at `0`
|
|
- `max_score`: Best score achieved so far, starting at `0`
|
|
|
|
|
|
|
|
**Step 3: Process tokens using two pointers**
|
|
|
|
- While `left <= right` (unplayed tokens remain):
|
|
- **If we can afford the cheapest token** (`power >= tokens[left]`):
|
|
- Play it face-up: subtract `tokens[left]` from power, add `1` to score
|
|
- Move `left` pointer right
|
|
- Update `max_score` if current score is higher
|
|
- **Else if we have score to spend** (`score >= 1`):
|
|
- Play the most expensive token face-down: add `tokens[right]` to power, subtract `1` from score
|
|
- Move `right` pointer left
|
|
- **Else**: We can't make any more moves, break out of the loop
|
|
|
|
|
|
|
|
**Step 4: Return the result**
|
|
|
|
- Return `max_score` — the highest score achieved at any point during play
|
|
- Note: We track `max_score` separately because spending score to gain power might temporarily decrease our score, but the final answer is the maximum we ever achieved
|
|
|
|
common_pitfalls:
|
|
- title: Not Sorting First
|
|
description: |
|
|
Without sorting, you can't efficiently decide which token to play face-up or face-down. You'd need to search for the minimum and maximum unplayed tokens each iteration, leading to O(n^2) complexity.
|
|
|
|
The greedy strategy **only works on sorted data** where you can always access the cheapest (left) and most expensive (right) tokens in O(1).
|
|
wrong_approach: "Processing tokens in original order"
|
|
correct_approach: "Sort first, then use two pointers"
|
|
|
|
- title: Returning Current Score Instead of Maximum Score
|
|
description: |
|
|
Your score can go up and down during play. Consider `tokens = [100, 200, 300, 400]` with `power = 200`:
|
|
|
|
- Play 100 face-up: score = 1 ✓
|
|
- Play 400 face-down: score = 0
|
|
- Play 200 face-up: score = 1
|
|
- Play 300 face-up: score = 2 ✓
|
|
|
|
If you only track the final score, you'd correctly get 2. But imagine a case where the optimal strategy involves temporarily sacrificing score and you can't recover it all — you need to remember the maximum you ever reached.
|
|
wrong_approach: "Return score at the end of processing"
|
|
correct_approach: "Track and return max_score throughout"
|
|
|
|
- title: Playing Face-Down When Not Beneficial
|
|
description: |
|
|
Don't automatically play face-down whenever you can't afford face-up. Only sacrifice score for power if it will eventually help you gain more score.
|
|
|
|
The two-pointer approach handles this naturally: we only play face-down when `left <= right` (there are still cheaper tokens we might afford after gaining power). If `left > right`, we've processed all tokens and should stop.
|
|
wrong_approach: "Always play face-down when face-up isn't possible"
|
|
correct_approach: "Only play face-down when unprocessed tokens remain"
|
|
|
|
- title: Empty Array Edge Case
|
|
description: |
|
|
When `tokens` is empty, there are no moves to make. The answer is `0` (initial score).
|
|
|
|
The two-pointer loop condition `left <= right` handles this: with an empty array, `left = 0` and `right = -1`, so the loop never executes and we correctly return `max_score = 0`.
|
|
|
|
key_takeaways:
|
|
- "**Greedy with sorting**: When optimising choices from a collection, sorting often reveals the optimal strategy (pick extremes first)"
|
|
- "**Two pointers on sorted data**: Use left pointer for minimum operations, right pointer for maximum operations"
|
|
- "**Track running maximum**: When a value can fluctuate (increase and decrease), track the best value seen, not just the final value"
|
|
- "**Resource conversion problems**: This pattern (trading one resource for another at different rates) appears in many problems — always consider which trades give the best value"
|
|
|
|
time_complexity: "O(n log n). Dominated by the sorting step. The two-pointer traversal is O(n) since each pointer moves at most n times."
|
|
space_complexity: "O(1) or O(n) depending on the sorting algorithm. The two-pointer logic uses only constant extra space. Python's Timsort uses O(n) space in the worst case, but many implementations sort in-place."
|
|
|
|
solutions:
|
|
- approach_name: Two Pointers (Greedy)
|
|
is_optimal: true
|
|
code: |
|
|
def bag_of_tokens_score(tokens: list[int], power: int) -> int:
|
|
# Sort to access cheapest and most expensive tokens easily
|
|
tokens.sort()
|
|
|
|
left = 0
|
|
right = len(tokens) - 1
|
|
score = 0
|
|
max_score = 0
|
|
|
|
while left <= right:
|
|
if power >= tokens[left]:
|
|
# Can afford cheapest token: play face-up for +1 score
|
|
power -= tokens[left]
|
|
score += 1
|
|
left += 1
|
|
# Update best score achieved
|
|
max_score = max(max_score, score)
|
|
elif score >= 1:
|
|
# Can't afford anything, but have score: trade for power
|
|
power += tokens[right]
|
|
score -= 1
|
|
right -= 1
|
|
else:
|
|
# Can't play face-up (no power) or face-down (no score)
|
|
break
|
|
|
|
return max_score
|
|
explanation: |
|
|
**Time Complexity:** O(n log n) — Sorting dominates; the two-pointer loop is O(n).
|
|
|
|
**Space Complexity:** O(1) extra space for the pointers and counters. Sorting may use O(n) depending on implementation.
|
|
|
|
We greedily play the cheapest available token face-up when we have enough power, and play the most expensive token face-down when we need more power. By always making the locally optimal choice, we achieve the globally maximum score.
|
|
|
|
- approach_name: Simulation (Brute Force)
|
|
is_optimal: false
|
|
code: |
|
|
def bag_of_tokens_score(tokens: list[int], power: int) -> int:
|
|
from itertools import permutations
|
|
|
|
def simulate(order: list[int], power: int) -> int:
|
|
"""Simulate playing tokens in given order, maximising score."""
|
|
score = 0
|
|
max_score = 0
|
|
played = [False] * len(order)
|
|
|
|
# Try to play each token, making greedy face-up/face-down choices
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for i, token in enumerate(order):
|
|
if played[i]:
|
|
continue
|
|
# Try face-up first (gaining score is our goal)
|
|
if power >= token:
|
|
power -= token
|
|
score += 1
|
|
max_score = max(max_score, score)
|
|
played[i] = True
|
|
changed = True
|
|
break
|
|
if not changed and score >= 1:
|
|
# Find largest unplayed token for face-down
|
|
best_idx = -1
|
|
best_val = -1
|
|
for i, token in enumerate(order):
|
|
if not played[i] and token > best_val:
|
|
best_val = token
|
|
best_idx = i
|
|
if best_idx != -1:
|
|
power += best_val
|
|
score -= 1
|
|
played[best_idx] = True
|
|
changed = True
|
|
|
|
return max_score
|
|
|
|
if not tokens:
|
|
return 0
|
|
|
|
# For small inputs, we could try all permutations
|
|
# But this is O(n! * n) which is only feasible for tiny n
|
|
return simulate(tokens, power)
|
|
explanation: |
|
|
**Time Complexity:** O(n^2) for this greedy simulation, or O(n! * n) if trying all permutations.
|
|
|
|
**Space Complexity:** O(n) for the played array.
|
|
|
|
This approach simulates the process by repeatedly finding the cheapest affordable token for face-up plays and the most expensive for face-down plays. While correct, it's less elegant and slower than the sorted two-pointer approach. A true brute force trying all permutations would be exponential and impractical.
|