01 / The idea
A weighted choice can be cheap to set up and expensive to repeat.
A cumulative scan draws a random number from 0 through 99, then walks Common, Rare, Epic, and Legendary until the cumulative weight passes it. That is O(n) work for every chest opening. The usual fix is to store the running totals once and binary-search them, which brings each draw down to O(log n).
The alias method turns the same distribution into equal-width columns, each with a threshold and possibly an alternate outcome. A draw now does two bounded choices and at most one alias jump.
The result is still random. The optimization changes where the work happens: preprocessing knows the weights so the hot draw loop does not need to, not even for a binary search.
Preprocess the table, then draw in constant time.
Give every loot outcome a weight.
Common has weight 50, Rare 25, Epic 15, and Legendary 10, out of 100. A scan could draw them, but every draw would walk the weights again.
Reduced motion: choose a scene to see its completed state.
Read this scene
Common has weight 50, Rare 25, Epic 15, and Legendary 10, out of 100. A scan could draw them, but every draw would walk the weights again.
Four loot outcomes with whole-number weights totaling 100: Common 50, Rare 25, Epic 15, Legendary 10. No columns built yet.
Watch and Step through replay the lesson’s own construction trace, one pairing at a time, then one seeded draw decision and a thousand counts. Try it lets you pick or type the weights, then builds and samples with the same alias table code.
Step through the three pairings, one draw that follows an alias, and the counts from a thousand seeded draws. Rare is the one to watch: it starts on the large list, lends 60 to Legendary, drops to 40, and is topped up from Common in its turn. The units are whole numbers so TypeScript and Go make the same decisions.
02 / Name the rule
Make every column exactly one bucket wide.
With n outcomes and total weight W, scale each weight by n. A scaled value below W is small; one at or above W is large. Pair a small column with a large one: keep the small value as its threshold
and use the large outcome as its alias for the leftover.
draw = column; result = coin < threshold ? column : alias[column]
The large column gives exactly what the small one was missing, so the large one shrinks by
that much. What it has left decides which list it goes back on: below W it is
small now, and it will be topped up in its turn; at or above W it stays large
and can lend again. Each pairing fills one column for good, so the work stops when the small
list is empty, and every outcome still owns exactly n times its weight across the
columns.
Alastair Walker described the alias method in 1974. The small-and-large pairing used here is Michael Vose’s 1991 construction, which builds the table in linear time.
Equalize the buckets
Multiply every weight by the number of outcomes so each column has the same total capacity.
Fill the leftover
Give a small column its own threshold and borrow the missing part from a large column.
Accept or jump
One coin checks the threshold. A rejection follows the stored alias exactly once.
When cumulative weights are enoughThe trade is setup for repeated draws
A cumulative scan is simpler and often the right choice for a small or changing table. Prefix sums with a binary search are the common middle ground: O(n) to build, O(log n) per draw, and a single weight change only means rebuilding the totals. With four outcomes, as here, none of the three is measurably faster; the alias table starts to pay when there are many outcomes and many draws. The alias method pays extra memory and rebuild work when the same fixed weights will be sampled many times. It does not make arbitrary weight updates free: changing a weight means rebuilding or choosing a different dynamic sampler.
03 / Read the shape
The table stores probability work for the future.
Basic form validates non-negative integer weights and builds the columns by
pairing small and large ones. traceAliasTable records each pairing as it goes,
which is what the film replays; buildAliasTable keeps only the table. In the wild draws from a seeded source, accepts the column or follows its
alias, and counts many draws. At the call site preprocesses a loot table, prints each pairing, and counts many
outcomes without walking the weights per draw. Both languages print the same five lines.
Validate integer weights and build equal-width threshold/alias columns with a stack of small and large outcomes.
export type AliasErrorCode =
| 'empty-table'
| 'too-big'
| 'bad-id'
| 'duplicate-id'
| 'bad-weight'
| 'zero-total'
| 'bad-draw-count'
| 'bad-seed'
| 'bad-bound';
export class AliasError extends Error {
readonly code: AliasErrorCode;
constructor(code: AliasErrorCode, message: string) {
super(message);
this.name = 'AliasError';
this.code = code;
}
}
export interface LootWeight {
id: string;
weight: number;
}
export interface AliasEntry {
probability: number;
alias: number;
}
export interface AliasTable {
items: LootWeight[];
totalWeight: number;
entries: AliasEntry[];
}
function validate(weights: LootWeight[]): number {
if (weights.length === 0)
throw new AliasError('empty-table', 'Alias table needs at least one outcome.');
if (weights.length > MAX_OUTCOMES) {
throw new AliasError('too-big', `Alias table is limited to ${MAX_OUTCOMES} outcomes.`);
}
const seen = new Set<string>();
let total = 0;
for (const item of weights) {
if (item.id.length > MAX_ID_LENGTH || !ID.test(item.id)) {
throw new AliasError('bad-id', 'Outcome ids must be lowercase slugs.');
}
if (seen.has(item.id)) throw new AliasError('duplicate-id', 'Outcome ids must be unique.');
seen.add(item.id);
if (!Number.isInteger(item.weight) || item.weight < 0 || item.weight > MAX_WEIGHT) {
throw new AliasError('bad-weight', `Weights must be whole numbers from 0 to ${MAX_WEIGHT}.`);
}
total += item.weight;
}
if (total <= 0)
throw new AliasError('zero-total', 'At least one outcome must have a positive weight.');
return total;
}
/** One pairing: a short column keeps its own height and borrows the rest from a tall one. */
export interface AliasStep {
small: number;
large: number;
threshold: number;
borrowed: number;
largeBefore: number;
largeAfter: number;
largeBecomes: 'small' | 'large';
}
export interface AliasTrace {
scaled: number[];
small: number[];
large: number[];
steps: AliasStep[];
}
export function traceAliasTable(weights: LootWeight[]): { table: AliasTable; trace: AliasTrace } {
const totalWeight = validate(weights);
const items = weights.map((item) => ({ ...item }));
const scaled = items.map((item) => item.weight * items.length);
const small: number[] = [];
const large: number[] = [];
for (const [index, value] of scaled.entries()) (value < totalWeight ? small : large).push(index);
const trace: AliasTrace = {
scaled: [...scaled],
small: [...small],
large: [...large],
steps: []
};
const entries = items.map(() => ({ probability: totalWeight, alias: -1 }));
while (small.length > 0 && large.length > 0) {
const smallIndex = small.pop()!;
const largeIndex = large.pop()!;
const borrowed = totalWeight - scaled[smallIndex];
entries[smallIndex] = { probability: scaled[smallIndex], alias: largeIndex };
const largeBefore = scaled[largeIndex];
scaled[largeIndex] -= borrowed;
const largeBecomes = scaled[largeIndex] < totalWeight ? 'small' : 'large';
(largeBecomes === 'small' ? small : large).push(largeIndex);
trace.steps.push({
small: smallIndex,
large: largeIndex,
threshold: scaled[smallIndex],
borrowed,
largeBefore,
largeAfter: scaled[largeIndex],
largeBecomes
});
}
// Anything left in either list is already exactly one column tall.
return { table: { items, totalWeight, entries }, trace };
}
export function buildAliasTable(weights: LootWeight[]): AliasTable {
return traceAliasTable(weights).table;
} type LootWeight struct {
ID string
Weight int
}
type AliasEntry struct {
Probability int
Alias int
}
type AliasTable struct {
Items []LootWeight
TotalWeight int
Entries []AliasEntry
}
type AliasError struct {
Code string
Message string
}
func (e *AliasError) Error() string { return e.Message }
func validate(weights []LootWeight) (int, error) {
if len(weights) == 0 {
return 0, &AliasError{Code: "empty-table", Message: "Alias table needs at least one outcome."}
}
if len(weights) > 32 {
return 0, &AliasError{Code: "too-big", Message: "Alias table is limited to 32 outcomes."}
}
seen := map[string]bool{}
total := 0
for _, item := range weights {
if len(item.ID) == 0 || len(item.ID) > 24 || item.ID[0] < 'a' || item.ID[0] > 'z' {
return 0, &AliasError{Code: "bad-id", Message: "Outcome ids must be lowercase slugs."}
}
for _, character := range item.ID[1:] {
if !((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '-') {
return 0, &AliasError{Code: "bad-id", Message: "Outcome ids must be lowercase slugs."}
}
}
if seen[item.ID] {
return 0, &AliasError{Code: "duplicate-id", Message: "Outcome ids must be unique."}
}
seen[item.ID] = true
if item.Weight < 0 || item.Weight > 1_000_000 {
return 0, &AliasError{Code: "bad-weight", Message: "Weights must be whole numbers from 0 to 1000000."}
}
total += item.Weight
}
if total <= 0 {
return 0, &AliasError{Code: "zero-total", Message: "At least one outcome must have a positive weight."}
}
return total, nil
}
// AliasStep is one pairing: a short column keeps its own height and borrows the rest from a tall one.
type AliasStep struct {
Small int
Large int
Threshold int
Borrowed int
LargeBefore int
LargeAfter int
LargeBecomes string
}
type AliasTrace struct {
Scaled []int
Small []int
Large []int
Steps []AliasStep
}
func traceAliasTable(weights []LootWeight) (AliasTable, AliasTrace, error) {
total, err := validate(weights)
if err != nil {
return AliasTable{}, AliasTrace{}, err
}
items := append([]LootWeight(nil), weights...)
scaled := make([]int, len(items))
small, large := []int{}, []int{}
for index, item := range items {
scaled[index] = item.Weight * len(items)
if scaled[index] < total {
small = append(small, index)
} else {
large = append(large, index)
}
}
trace := AliasTrace{
Scaled: append([]int(nil), scaled...),
Small: append([]int{}, small...),
Large: append([]int{}, large...),
Steps: []AliasStep{},
}
entries := make([]AliasEntry, len(items))
for index := range entries {
entries[index] = AliasEntry{Probability: total, Alias: -1}
}
for len(small) > 0 && len(large) > 0 {
smallIndex := small[len(small)-1]
small = small[:len(small)-1]
largeIndex := large[len(large)-1]
large = large[:len(large)-1]
borrowed := total - scaled[smallIndex]
entries[smallIndex] = AliasEntry{Probability: scaled[smallIndex], Alias: largeIndex}
largeBefore := scaled[largeIndex]
scaled[largeIndex] -= borrowed
largeBecomes := "large"
if scaled[largeIndex] < total {
largeBecomes = "small"
small = append(small, largeIndex)
} else {
large = append(large, largeIndex)
}
trace.Steps = append(trace.Steps, AliasStep{
Small: smallIndex,
Large: largeIndex,
Threshold: scaled[smallIndex],
Borrowed: borrowed,
LargeBefore: largeBefore,
LargeAfter: scaled[largeIndex],
LargeBecomes: largeBecomes,
})
}
// Anything left in either list is already exactly one column tall.
return AliasTable{Items: items, TotalWeight: total, Entries: entries}, trace, nil
}
func buildAliasTable(weights []LootWeight) (AliasTable, error) {
table, _, err := traceAliasTable(weights)
return table, err
} Reading the TypeScriptSmall and large stacks
The small and large lists are plain arrays used as stacks: pop takes the
most recent index, and a large column that drops below the total is pushed onto small. An entry stores a threshold in the same integer units as the total
and an alias index, -1 when the column needs none.
Each AliasStep names the two columns, the threshold, what was borrowed, the
large column’s height before and after, and the list it went back on. largeBecomes is a string union, 'small' | 'large'. Errors are
an AliasError whose code matches the Go version.
Reading the GoThe same seeded decisions
Go uses uint32 state with the same linear-congruential constants and rejection-safe bounded draws. It copies the input weights before building the table and, like the TypeScript, keeps the first 12 draws for inspection.
traceAliasTable returns the table, the trace, and an error. The lists are
slices, popped by reslicing to len - 1, and LargeBecomes is
the string "small" or "large". Errors are an *AliasError with the same codes.
What is refusedA valid distribution
Both versions require 1 to 32 outcomes (empty-table, too-big),
unique lowercase ids (bad-id, duplicate-id), whole-number
weights from 0 to 1,000,000 (bad-weight), and a positive total (zero-total). Zero-weight outcomes may stay in the table; an all-zero table can’t define a
distribution. A draw count must be a whole number from 0 to 100,000 (bad-draw-count).
04 / Try a decision
What does a draw need after preprocessing?
The alias table has already paid for the distribution. Follow one draw through a column that has an alias.
05 / Follow the cost
Pay once when the same weights will be drawn often.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Validate and total weights | O(n) | O(1) | Read every outcome once and require a positive total weight. |
| Build threshold and alias columns | O(n) | O(n) | Each pairing fills one short column for good, so there are at most n − 1 of them. |
| Draw after preprocessing | O(1) | O(1) | Choose a column, draw one weight unit, then accept or follow one alias. |
| Store the sampler | — | O(n) | Keep one item list and one threshold/alias entry per outcome. |
With n outcomes, preprocessing and storage are O(n), and each later draw is O(1).
Prefix sums with a binary search cost the same O(n) setup and O(log n) per draw. The comparison
is about repeated work, not a promise that the table is always faster: for a tiny or frequently
changing distribution, a cumulative scan may be simpler and cheaper overall.
Integer units make the teaching fixture exact. Production samplers should also define their random source, bounded-draw policy, weight precision, and rebuild behavior so rounding and updates are not left implicit.
06 / Give it a real job
Build the drop table when it ships, not when a chest opens.
A game’s drop service owns the loot tables. Designers change weights between releases, not
between chest openings, so the service builds one alias table per drop table when a new
version is deployed and keeps it in memory. Every chest opened after that is one call to draw: a column, a unit, and at most one jump, however many items the table
holds. When the next version ships, it builds the new table first and then swaps it in, so
no chest ever draws from a half-built one.
What it leaves out is anything that changes the chances between builds. A “guaranteed Legendary within 50 chests” rule isn’t in the table; it’s a separate check before the draw, and it changes what players actually get, so it belongs in the published odds too. The service also keeps the random source to itself. This runs on the game server that owns the drop table; the client only shows what came out, because a draw made in the player’s browser is a draw the player can repeat.
The same trade shows up in general-purpose libraries. R’s sample(), drawing
with replacement, switches to Walker’s alias method “when there are more than 200 reasonably
probable values.” Rust’s rand_distr crate ships a WeightedAliasIndex whose documentation describes this lesson’s draw: “Sampling
is O(1), it makes a call to Uniform<u32>::sample and a call to Uniform<W>::sample.” One number picks the column; the other is the unit.
07 / Make the call
Choose setup, update, and draw costs together.
Use the alias method for a fixed weighted distribution with many independent draws: loot tables, weighted simulation events, or a recommendation experiment with a stable batch of arms.
Use Fisher–Yates when every item should be equally likely in a permutation. Use reservoir sampling when the data arrives as a stream and the goal is a fair sample, not a weighted one.
Three boundaries to rememberSimilar randomness, different guarantees
- Uniform shuffle: Fisher–Yates selects positions without replacement.
- Weighted draw: Alias preserves fixed outcome weights with replacement.
- Changing weights: rebuild the table or choose a data structure designed for updates.
SourcesTwo papers and two libraries, checked 23 September 2026
- A. J. Walker, “New fast method for generating discrete random numbers with arbitrary frequency distributions”, Electronics Letters 10(8), 127–128, 1974.
- M. D. Vose, “A linear algorithm for generating random numbers with a given distribution”, IEEE Transactions on Software Engineering 17(9), 972–975, 1991.
- R’s
sampledocumentation, on when it uses Walker’s alias method. rand_distr0.6.0,WeightedAliasIndex, on its O(n) construction and O(1) sampling.
For the two papers, only the bibliographic records were checked, not the papers.
08 / Take the idea with you
Explain a weighted draw without saying “alias method.”
“Cut the odds into equal columns, one per outcome. Each column is mostly one outcome, and whatever room is left over belongs to one other outcome. To draw, pick a column at random, then a height in it: below the line you get the column’s own outcome, above it you get the other one.”
Before moving on, find a weighted choice in your work: a loot table, a traffic split, a sampling rate per event type. Ask how often its weights change and how often it is drawn from, and whether anything draws from it in a loop hot enough to care.
Connections to follow nextRelated lessons
- Two pointers and prefix sums builds the running totals that a cumulative draw searches.
- Binary search finds which running total a draw landed in, in O(log n).
- Fisher–Yates shuffle makes every bounded draw fair with the same trick of throwing away the uneven top.