Skip to content

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:

  1. Composing structures for O(1)O(1) random access — a hash map gives lookup, but only a dense array gives uniform random choice.
  2. 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: O(1)O(1) deletion from an array, which is what makes O(1)O(1) 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 O(1)O(1) 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 O(n)O(n) because everything shifts. The trick: you do not care about order, so overwrite the hole with the last element and shrink.

randomized_set.py
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)
randomized_set.py
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 O(1)O(1) average, with O(n)O(n) space.

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:

weighted_pick.py
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)
weighted_pick.py
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 O(n)O(n), each pick O(logn)O(\log n), space O(n)O(n).

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:

fisher_yates.py
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 a
fisher_yates.py
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 a

For 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.

DesignPer operationSpace
RandomizedSetO(1)O(1) averageO(n)O(n)
Weighted pickO(logn)O(\log n) per pick, O(n)O(n) setupO(n)O(n)
Fisher-Yates shuffleO(n)O(n) per shuffleO(n)O(n) for the original
Reservoir samplingO(1)O(1) per itemO(k)O(k)

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:

reservoir.py
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 chosen
reservoir.py
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 chosen

Why it is uniform: item kk is chosen at step kk with probability 1/k1/k, and then survives every later step j > kj > k with probability 11/j=(j1)/j1 - 1/j = (j-1)/j. Multiplying:

1kkk+1k+1k+2n1n=1n\frac{1}{k} \cdot \frac{k}{k+1} \cdot \frac{k+1}{k+2} \cdots \frac{n-1}{n} = \frac{1}{n}

The terms telescope, so every item ends with probability exactly 1/n1/n — without ever knowing nn. That is the argument to give; it generalises to keeping kk samples by replacing a random reservoir slot with probability k/countk/count.

The variant map

VariantThe structureCanonical problem
O(1)O(1) random memberDense array + index map, swap-with-last380
Duplicates allowedMap from value to a set of positions381
Weighted pickPrefix sums + binary search528 · 497
Uniform shuffleFisher-Yates with an inclusive bound384
Random index of a targetReservoir sampling over matches, or a precomputed map398
Random node from a streamReservoir sampling382
Random point in non-overlapping rectanglesWeighted by area, then uniform within497

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 O(1)O(1) 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 O(n)O(n) — 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 O(1)O(1) average for all three. Space O(n)O(n).

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 later removeremove.
  • Removing the only element — then last == vallast == val and i == 0i == 0. The write index[last] = iindex[last] = i re-adds the key, and the following del 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 O(1)O(1) add, remove and membership, but no O(1)O(1) uniform random choice — random.choicerandom.choice requires a sequence, and random.sample(s, 1)random.sample(s, 1) on a set is O(n)O(n). 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. removeremove pops 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 O(n)O(n); a Fenwick tree gives O(logn)O(\log n) updates and picks.
  • O(1)O(1) worst case, not average?” Hash maps are only O(1)O(1) 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 O(n)O(n) to build, O(logn)O(\log n) per pick. Space O(n)O(n).

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) with bisect_rightbisect_right;
  • integer target in [1, total][1, total] with bisect_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:

  • O(1)O(1) per pick?” The alias method achieves O(1)O(1) after O(n)O(n) preprocessing, by chopping the weights into nn equal-probability buckets each holding at most two outcomes. Naming it is usually enough.
  • “Weights change between picks?” A Fenwick tree over the weights gives O(logn)O(\log n) updates and O(logn)O(\log n) picks, rather than rebuilding the prefix array in O(n)O(n).
  • “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’s random.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 pass cum_weightscum_weights, so it is O(n)O(n) 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 n×(n1)××1=n!n \times (n-1) \times \cdots \times 1 = n! equally likely outcomes.

Time O(n)O(n) per shuffle. Space O(n)O(n) 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 O(nlogn)O(n \log n) instead of O(n)O(n), 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 O(1)O(1) indexing, so you would copy to an array first.
  • “Cryptographically secure?” Use secrets.SystemRandomsecrets.SystemRandom; randomrandom is a Mersenne Twister and is predictable from enough output.

LeetCode problem set

#ProblemDifficultyThe twist
380Insert Delete GetRandom O(1)MediumSwap-with-last keeps the array dense; update the moved element’s index
528Random Pick with WeightMediumPrefix sums + binary search; match the target and bisect conventions
384Shuffle an ArrayMediumFisher-Yates with an inclusive bound
398Random Pick IndexMediumReservoir sampling over the matching indices, or precompute a map
497Random Point in Non-overlapping RectanglesMediumWeight rectangles by area, then pick uniformly inside
381Insert Delete GetRandom O(1) - Duplicates allowedHardValue maps to a set of positions; the swap update gets fiddly
382Linked List Random NodeMediumReservoir sampling — length unknown without a full pass

Interview follow-ups

They askWhat they’re checkingThe answer
“Why not just use a setset?”Understanding the constraintSets give O(1)O(1) add/remove but no O(1)O(1) uniform choice; you need a dense array
“Prove your shuffle is uniform”RigourPosition ii draws uniformly from i + 1i + 1 remaining candidates, giving n!n! equally likely outcomes
“What breaks with randint(0, i - 1)randint(0, i - 1)?”Whether you know the trapEvery element is forced to move, so only fixed-point-free permutations occur — 2 of 6 for [1,2,3][1,2,3]
O(1)O(1) weighted pick?”BreadthThe alias method: O(n)O(n) setup, O(1)O(1) picks
“Weights change over time?”AdaptabilityA Fenwick tree over the weights gives O(logn)O(\log n) update and pick
“Unknown-length stream?”The right toolReservoir sampling, with the telescoping-probability proof
“Is randomrandom good enough?”Practical awarenessIt is a Mersenne Twister — fine for interviews, predictable for security; use secretssecrets if it matters
O(1)O(1) worst case?”PrecisionHash maps are O(1)O(1) 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 return 00.
  • 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.
  • resetreset aliasing — never return the internal original list by reference.

Recap

  • Uniform random choice needs a dense array. Swap-with-last makes deletion O(1)O(1) without holes — and you must update the moved element’s recorded index.
  • A hash map alone cannot do this: sets have no O(1)O(1) 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 coffee

Was this page helpful?

Let us know how we did