This is a blog 3 of a series of blogs about algorithmic challenges I came across when creating tgen.
In this blog we will tackle:
- Create a data structure with time construction that can return with probability proportional to in time.
It is trivial to create such a data structure with query time: with a prefix sum array with values in , where , just pick a uniformly random value , and then do a binary search to find and return the first index such that .
Turns out that we can improve the query to time, with linear construction. This is the alias method.
If all weights were the same, , then it would be trivial: just return next(0, n-1). If not, some indices are small (), and some are big (). The idea is to “transfer” the exceeding probability of the small indices to the big ones.
To do that, after we choose a random index i = next(0, n-1), we will either return i with probability mass[i] / S, or return alias[i]. Note that there is no chaining, that is, we don’t follow many alias pointers; we either return i or alias[i]. Now we are left to show that it is always possible to choose an assignment for alias[i] and mass[i] in such a way that the probabilities are maintained.
To start, we scale every weight by to make everything an integer, so outcome has mass . The average scaled mass is exactly . Maintain two queues: for indices with current mass , and for indices with current mass . While both are nonempty, pop from and from . Index is finalized: we keep as the threshold for returning , and set , so the leftover of the pick is given to . This leftover is at most (since ), and , so we never subtract more from than it has. After subtracting that leftover from , we put back into the appropriate queue (depending on whether its remaining mass is small or big). Each step permanently finalizes one index.
At the end, all indices remaining in must have mass exactly (because the existence of an index with mass implies another with mass ), so those indices are finalized as a single outcome (they are their own alias).
struct alias_method {
int n;
long long S;
std::vector<long long> mass;
std::vector<int> alias;
alias_method(const std::vector<int>& w) : n(w.size()), alias(n) {
S = 0;
for (auto x : w) S += x;
std::queue<int> small, big;
for (int i = 0; i < n; i++) {
mass.push_back(n * w[i]);
if (mass[i] < S) small.push(i);
else big.push(i);
}
while (!small.empty() and !big.empty()) {
int s = small.front(); small.pop();
int b = big.front(); big.pop();
alias[s] = b;
mass[b] -= S - mass[s];
if (mass[b] < S) small.push(b);
else big.push(b);
}
while (!big.empty()) {
int b = big.front(); big.pop();
alias[b] = b;
mass[b] = S;
}
}
};
Algorithm 1: alias method construction.
To sample, pick a uniform index and compare a uniform sample in against the threshold stored in that index:
int next(const alias_method& a) {
int i = next(0, a.n - 1);
long long x = next(0, a.S - 1);
return x < a.mass[i] ? i : a.alias[i];
}
Algorithm 2: alias method query.
Theorem 1: Algorithm 2 returns index with probability .
Proof: Algorithm 2 picks an index uniformly at random, then returns if and otherwise, with uniform in . So, conditioned on picking , we return with probability and with probability .
Initially . Each pairing of a small index with a big index keeps units with and moves units from to the alias of .
The probability of returning is therefore
where . The first term is the case where we pick and keep it; each term in the sum is the case where we pick some and take the alias .
Initially , since there are no aliases yet. A pairing does not change or . We do not change , so stays the same. For , we subtract from , but we also set , which adds the same to the sum in . Thus at the end, so we get the final probability of returning :
References
Alias method (Wikipedia): https://en.wikipedia.org/wiki/Alias_method
Keith Schwarz’s exposition: https://www.keithschwarz.com/darts-dice-coins/