Design with Randomization
Randomized design problems have an unusual property: a solution can be subtly wrong in a way that still looks right. A biased shuffle still returns a permutation. A skewed picker still returns valid indices. The tests pass, the output looks plausible, and the distribution is wrong.
So these problems are really about two things:
- Composing structures for random access — a hash map gives lookup, but only a dense array gives uniform random choice.
- Being able to argue that a distribution is uniform — because “it looks random” is not a proof, and the classic off-by-one here silently destroys uniformity.
What you’ll learn
- The swap-with-last trick: deletion from an array, which is what makes random selection possible.
- Prefix sums plus binary search for weighted picking.
- Fisher-Yates, why the loop bound is inclusive, and a concrete demonstration of what goes wrong when it is not.
- Reservoir sampling — picking uniformly from a stream of unknown length.
- Three real LeetCode problems solved in the browser: 380, 528, 384.
The cue
Trick 1 — swap with last
To pick uniformly at random in you need a dense array — contiguous,
no holes — so that random.randrange(len(arr))random.randrange(len(arr)) is a valid index.
But deleting from the middle of an array is because everything shifts. The trick: you do not care about order, so overwrite the hole with the last element and shrink.
import random
class RandomizedSet:
def __init__(self):
self.values = [] # dense array -> O(1) uniform random choice
self.index = {} # value -> its position in `values`
def insert(self, val):
if val in self.index:
return False
self.index[val] = len(self.values)
self.values.append(val)
return True
def remove(self, val):
if val not in self.index:
return False
i = self.index[val]
last = self.values[-1]
self.values[i] = last # move the last element into the hole
self.index[last] = i # UPDATE ITS RECORDED POSITION
self.values.pop()
del self.index[val]
return True
def getRandom(self):
return random.choice(self.values)import random
class RandomizedSet:
def __init__(self):
self.values = [] # dense array -> O(1) uniform random choice
self.index = {} # value -> its position in `values`
def insert(self, val):
if val in self.index:
return False
self.index[val] = len(self.values)
self.values.append(val)
return True
def remove(self, val):
if val not in self.index:
return False
i = self.index[val]
last = self.values[-1]
self.values[i] = last # move the last element into the hole
self.index[last] = i # UPDATE ITS RECORDED POSITION
self.values.pop()
del self.index[val]
return True
def getRandom(self):
return random.choice(self.values)All three operations are average, with space.
Trick 2 — prefix sums plus binary search
For weights [1, 3][1, 3], index 1 must be picked three times as often. Build the
cumulative weights and throw a dart at the total:
import bisect
import random
class WeightedPicker:
def __init__(self, w):
self.prefix = []
total = 0
for x in w:
total += x
self.prefix.append(total) # e.g. [1, 3] -> [1, 4]
self.total = total
def pickIndex(self):
target = random.random() * self.total # uniform in [0, total)
return bisect.bisect_right(self.prefix, target)import bisect
import random
class WeightedPicker:
def __init__(self, w):
self.prefix = []
total = 0
for x in w:
total += x
self.prefix.append(total) # e.g. [1, 3] -> [1, 4]
self.total = total
def pickIndex(self):
target = random.random() * self.total # uniform in [0, total)
return bisect.bisect_right(self.prefix, target)The prefix array turns weights into adjacent intervals on a number line:
index 0 owns [0, 1)[0, 1), index 1 owns [1, 4)[1, 4). A uniform dart lands in each
interval with probability proportional to its width — which is exactly the
weight. bisect_rightbisect_right finds which interval contains the dart.
Construction , each pick , space .
Trick 3 — Fisher-Yates, and the bias trap
To shuffle uniformly, walk from the end and swap each position with a random earlier-or-equal position:
import random
def shuffle(a):
for i in range(len(a) - 1, 0, -1):
j = random.randint(0, i) # INCLUSIVE of i -- element may stay put
a[i], a[j] = a[j], a[i]
return aimport random
def shuffle(a):
for i in range(len(a) - 1, 0, -1):
j = random.randint(0, i) # INCLUSIVE of i -- element may stay put
a[i], a[j] = a[j], a[i]
return aFor reset()reset(), keep an untouched copy of the original and return a copy or
reassign, being careful not to hand back a list that later shuffles will
mutate.
| Design | Per operation | Space |
|---|---|---|
| RandomizedSet | average | |
| Weighted pick | per pick, setup | |
| Fisher-Yates shuffle | per shuffle | for the original |
| Reservoir sampling | per item |
Reservoir sampling
When the input is a stream of unknown length you cannot pick an index up front. Reservoir sampling keeps one candidate and replaces it with decreasing probability:
import random
def pick_one(stream):
chosen = None
for count, item in enumerate(stream, start=1):
if random.randrange(count) == 0: # probability 1/count
chosen = item
return chosenimport random
def pick_one(stream):
chosen = None
for count, item in enumerate(stream, start=1):
if random.randrange(count) == 0: # probability 1/count
chosen = item
return chosenWhy it is uniform: item kk is chosen at step kk with probability , and
then survives every later step j > kj > k with probability .
Multiplying:
The terms telescope, so every item ends with probability exactly — without
ever knowing nn. That is the argument to give; it generalises to keeping kk
samples by replacing a random reservoir slot with probability .
The variant map
| Variant | The structure | Canonical problem |
|---|---|---|
| random member | Dense array + index map, swap-with-last | 380 |
| Duplicates allowed | Map from value to a set of positions | 381 |
| Weighted pick | Prefix sums + binary search | 528 · 497 |
| Uniform shuffle | Fisher-Yates with an inclusive bound | 384 |
| Random index of a target | Reservoir sampling over matches, or a precomputed map | 398 |
| Random node from a stream | Reservoir sampling | 382 |
| Random point in non-overlapping rectangles | Weighted by area, then uniform within | 497 |
Practice — real LeetCode problems
LC 380 — Insert Delete GetRandom O(1) · Medium
Problem. Implement a set with insert(val)insert(val), remove(val)remove(val) (both returning
whether the set changed) and getRandom()getRandom() returning a uniformly random element.
All three must be average.
Constraints. -2^31 <= val <= 2^31 - 1-2^31 <= val <= 2^31 - 1, up to 2 * 10^52 * 10^5 calls, and
getRandomgetRandom is only called when the set is non-empty.
Examples. insert(1)insert(1) gives TrueTrue, remove(2)remove(2) gives FalseFalse,
insert(2)insert(2) gives TrueTrue, getRandom()getRandom() gives 11 or 22, remove(1)remove(1) gives
TrueTrue, insert(2)insert(2) gives FalseFalse
Editorial — approach, complexity, follow-ups
Uniform random choice needs a dense array so that a random index is always valid. Deleting from the middle of an array is normally — but since a set is unordered, you may fill the hole with the last element and shrink. The map keeps positions so removal knows where the hole is.
Time average for all three. Space .
Two details, both exercised by the tests:
self.index[last] = iself.index[last] = i— the moved element’s position must be corrected. Skipping it leaves a stale entry that corrupts a laterremoveremove.- Removing the only element — then
last == vallast == valandi == 0i == 0. The writeindex[last] = iindex[last] = ire-adds the key, and the followingdel index[val]del index[val]removes it again, leaving the structure clean. The final four operations in the test (remove(2)remove(2),insert(5)insert(5),getRandom()getRandom()) verify the structure still works after being emptied — a case that a subtly wrong ordering fails.
A setset alone cannot do this: Python sets support add, remove and
membership, but no uniform random choice — random.choicerandom.choice requires a
sequence, and random.sample(s, 1)random.sample(s, 1) on a set is . That is exactly why the
dense array exists, and it is the answer to “why not just use a set?“.
Follow-ups you should expect:
- “Allow duplicates (LC 381)?” Map each value to a set of positions.
removeremovepops any one position; the swap-with-last then has to update the moved element’s position set, which is fiddlier — watch the case where the moved element is the one being removed. - “Weighted
getRandomgetRandom?” LC 528’s prefix-sum approach, but rebuilding prefix sums on every mutation is ; a Fenwick tree gives updates and picks. - ” worst case, not average?” Hash maps are only average; worst-case guarantees need perfect hashing or a different model.
LC 528 — Random Pick with Weight · Medium
Problem. Given an array ww where w[i]w[i] is the weight of index ii,
implement pickIndex()pickIndex() returning index ii with probability
w[i] / sum(w)w[i] / sum(w).
Constraints. 1 <= len(w) <= 10^41 <= len(w) <= 10^4, 1 <= w[i] <= 10^51 <= w[i] <= 10^5, and
pickIndexpickIndex may be called up to 10^410^4 times.
Examples. w = [1]w = [1] always gives 00 · w = [1, 3]w = [1, 3] gives 00 about 25% of
the time and 11 about 75%
Editorial — approach, complexity, follow-ups
Cumulative weights lay the indices out as adjacent intervals whose widths are
their weights. A uniform point in [0, total)[0, total) falls in each interval with
probability proportional to its width, and binary search locates it.
Time to build, per pick. Space .
For w = [2, 5, 3]w = [2, 5, 3] the prefix array is [2, 7, 10][2, 7, 10], so index 0 owns
[0, 2)[0, 2), index 1 owns [2, 7)[2, 7) and index 2 owns [7, 10)[7, 10). Index 1’s width is
5 out of 10 — hence the ~0.5 share the test checks.
Getting the convention right matters more than it looks. Both of these are correct:
- float target in
[0, total)[0, total)withbisect_rightbisect_right; - integer target in
[1, total][1, total]withbisect_leftbisect_left.
Mixing them (float with bisect_leftbisect_left, or integer starting at 00) skews the
distribution slightly — a bug that passes any test which only checks validity.
That is precisely why this exercise counts outcomes.
Follow-ups you should expect:
- ” per pick?” The alias method achieves after
preprocessing, by chopping the weights into
nnequal-probability buckets each holding at most two outcomes. Naming it is usually enough. - “Weights change between picks?” A Fenwick tree over the weights gives updates and picks, rather than rebuilding the prefix array in .
- “Random point in a set of rectangles (LC 497)?” Weight each rectangle by its area using exactly this technique, then pick uniformly within the chosen rectangle.
- “Why not
random.choicesrandom.choices?” Python’srandom.choices(range(n), weights=w)random.choices(range(n), weights=w)does this for you — worth mentioning for fluency, but it rebuilds the cumulative weights on every call unless you passcum_weightscum_weights, so it is per pick as usually written.
LC 384 — Shuffle an Array · Medium
Problem. Implement shuffle()shuffle() returning a uniformly random permutation of
the array, and reset()reset() returning the original configuration. All permutations
must be equally likely.
Constraints. 1 <= len(nums) <= 501 <= len(nums) <= 50, values in
[-10^6, 10^6][-10^6, 10^6], up to 10^410^4 calls.
Examples. For [1,2,3][1,2,3]: shuffle()shuffle() returns some permutation,
reset()reset() returns [1,2,3][1,2,3], and over many calls each of the 6 permutations
appears about equally often.
Editorial — approach, complexity, follow-ups
Fisher-Yates builds a uniform permutation in one pass. Walking ii downward, the
element placed at position ii is chosen uniformly from the i + 1i + 1 candidates
that have not yet been fixed. Multiplying the choices gives
equally likely outcomes.
Time per shuffle. Space for the original copy.
The inclusive bound is the whole problem. With randint(0, i - 1)randint(0, i - 1), position
ii can never keep its own element, so every permutation with a fixed point is
unreachable. Empirically, shuffling [1,2,3][1,2,3] that way produces only
(2,3,1)(2,3,1) and (3,1,2)(3,1,2) — 2 of 6 — while the correct version produces all six at
roughly 1/6 each. This is why the exercise counts permutations rather than
merely checking validity.
reset()reset() returning a fresh copy matters too: returning self.originalself.original
directly would hand the caller the reference copy, and a later in-place shuffle
of that same list would corrupt the baseline.
Note random.shuffle(self.arr)random.shuffle(self.arr) is the one-line stdlib answer and is itself
Fisher-Yates. Mention it, then implement the loop — the question exists to check
you know what the library is doing.
Follow-ups you should expect:
- “Prove it is uniform.” The counting argument above; be ready to give it, since this is the most likely follow-up.
- “What is wrong with
sort(key=lambda _: random.random())sort(key=lambda _: random.random())?” It is instead of , and while random keys do give a uniform result, the related “swap each element with any random index” shuffle is not uniform — a common and instructive mistake. - “Shuffle a stream / linked list?” Reservoir sampling for a subset; a linked list has no indexing, so you would copy to an array first.
- “Cryptographically secure?” Use
secrets.SystemRandomsecrets.SystemRandom;randomrandomis a Mersenne Twister and is predictable from enough output.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 380 | Insert Delete GetRandom O(1) | Medium | Swap-with-last keeps the array dense; update the moved element’s index |
| 528 | Random Pick with Weight | Medium | Prefix sums + binary search; match the target and bisect conventions |
| 384 | Shuffle an Array | Medium | Fisher-Yates with an inclusive bound |
| 398 | Random Pick Index | Medium | Reservoir sampling over the matching indices, or precompute a map |
| 497 | Random Point in Non-overlapping Rectangles | Medium | Weight rectangles by area, then pick uniformly inside |
| 381 | Insert Delete GetRandom O(1) - Duplicates allowed | Hard | Value maps to a set of positions; the swap update gets fiddly |
| 382 | Linked List Random Node | Medium | Reservoir sampling — length unknown without a full pass |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
“Why not just use a setset?” | Understanding the constraint | Sets give add/remove but no uniform choice; you need a dense array |
| “Prove your shuffle is uniform” | Rigour | Position ii draws uniformly from i + 1i + 1 remaining candidates, giving equally likely outcomes |
“What breaks with randint(0, i - 1)randint(0, i - 1)?” | Whether you know the trap | Every element is forced to move, so only fixed-point-free permutations occur — 2 of 6 for [1,2,3][1,2,3] |
| ” weighted pick?” | Breadth | The alias method: setup, picks |
| “Weights change over time?” | Adaptability | A Fenwick tree over the weights gives update and pick |
| “Unknown-length stream?” | The right tool | Reservoir sampling, with the telescoping-probability proof |
“Is randomrandom good enough?” | Practical awareness | It is a Mersenne Twister — fine for interviews, predictable for security; use secretssecrets if it matters |
| ” worst case?” | Precision | Hash maps are average; worst-case needs a different model |
Edge-case checklist
- Removing the only element (LC 380) —
last == vallast == val; verify the map is left clean and the structure still works afterwards. - Remove then re-insert — catches stale index entries.
- Removing a value that is already last — the swap is a no-op but the map updates must still be right.
- Single weight (LC 528) —
[1][1]must always return00. - Very uneven weights —
[1, 100000][1, 100000]; index 0 is rare but must remain reachable. - Single-element array (LC 384) — the shuffle loop body never runs; still valid.
- Two-element array — the smallest case where bias is observable.
- Duplicate values when shuffling — permutations are indistinguishable, so count distinct arrangements carefully if testing.
resetresetaliasing — never return the internal original list by reference.
Recap
- Uniform random choice needs a dense array. Swap-with-last makes deletion without holes — and you must update the moved element’s recorded index.
- A hash map alone cannot do this: sets have no uniform choice.
- Weighted picks: prefix sums turn weights into adjacent intervals; a uniform dart plus binary search selects proportionally. Keep the target/bisect conventions consistent.
- Fisher-Yates with an inclusive bound.
randint(0, i - 1)randint(0, i - 1)is the classic bug and produces only 2 of 6 permutations on a 3-element array — while still always returning a valid permutation. - Reservoir sampling handles unknown-length streams; the telescoping product is the uniformity proof.
- Randomized bugs are invisible to validity checks. Test distributions, and be ready to argue uniformity rather than assert it.
Next: Design Trackers and Feeds — time-windowed counters, merged feeds, and the systems-flavoured end of design questions.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
