Writing

tgen::miniblog(4): random trees

2026-09-12 · Originally published on Codeforces

This is a blog 4 of a series of blogs about algorithmic challenges I came across when creating tgen.

In this blog we will tackle:

  1. Generate a uniformly random tree on vertices [0,n)[0, n) given some preset edges, in O(n)\mathcal O(n) time.
  2. Generate a skewed tree on vertices [0,n)[0, n) in O(n)\mathcal O(n) time.

1. Uniform random tree with preset edges

Sometimes we want to force some edges to appear in the tree, for example because the test needs a specific path or star substructure. The preset edges may span several connected components; we need to connect those components into a single tree.

The key tool is the Prüfer sequence. Every labeled tree on kk vertices corresponds to a unique sequence of length k2k-2 with entries in [0,k)[0, k), and the degree of vertex ii in the tree is one plus its frequency in the sequence. A uniform random Prüfer sequence therefore gives a uniform random labeled tree.

When there are preset edges, the components play the role of super-vertices: we want to connect cc components into a tree, which requires exactly c1c-1 new edges. The new tree on cc super-vertices is generated via a random Prüfer sequence of length c2c-2. Larger components need to be more likely to be chosen, because they have more vertices that can be edge endpoints, so we sample each Prüfer entry with probability proportional to the component size using the alias method. For each new edge, a concrete vertex in each of its two components is then picked uniformly at random.

// c = number of components, comp_size[i] = size of component i
// component_ids[i] = list of vertex ids in component i
// many_by_distribution uses the alias method; returns i with probability proportional to comp_size[i]
std::vector<int> prufer = many_by_distribution(c - 2, comp_size);
for (auto [u, v] : edges_from_prufer(prufer))
    new_edges.emplace_back(pick(component_ids[u]), pick(component_ids[v]));

Algorithm 1: connecting components with a weighted Prüfer sequence.

Theorem 1: Algorithm 1 returns each labeled tree on [0,n)[0, n) that contains the given preset edges with the same probability.

Proof: Let the preset edges form cc components of sizes s0,,sc1s_0, \dots, s_{c-1}, and write n=isin = \sum_i s_i. Any labeled tree containing those edges contracts to a unique tree TT on the cc components. If TT has degrees d0,,dc1d_0, \dots, d_{c-1}, component ii appears di1d_i-1 times in the Prüfer sequence of TT.

Algorithm 1 samples each of the c2c-2 Prüfer entries independently with probability si/ns_i / n for component ii. Tree TT has a unique Prüfer sequence σ\sigma, so

P[T]=t=1c2sσtn=i(sin)di1=n(c2)isidi1.\mathbb P[T] = \prod_{t=1}^{c-2} \frac{s_{\sigma_t}}{n} = \prod_i \left(\frac{s_i}{n}\right)^{d_i-1} = n^{-(c-2)} \prod_i s_i^{d_i-1}.

Each super-edge i,j{i,j} is then replaced by an edge between uniformly random vertices of those components. A fixed choice of original endpoints has probability

{i,j}T1sisj=isidi.\prod_{\{i,j\} \in T} \frac{1}{s_i s_j} = \prod_i s_i^{-d_i}.

Multiplying, the probability of any specific labeled tree is

n(c2)isidi1isidi=1nc2isi,n^{-(c-2)} \prod_i s_i^{d_i-1} \cdot \prod_i s_i^{-d_i} = \frac{1}{n^{c-2} \prod_i s_i},

which does not depend on the tree. (If there are no preset edges, then c=nc = n and si=1s_i = 1, so this is the uniform distribution over all nn2n^{n-2} labeled trees.)

\square

Sampling and decoding the Prüfer sequence are O(c)\mathcal O(c), and building the components is O(n)\mathcal O(n), so the whole procedure runs in O(n)\mathcal O(n) time.

2. Skewed tree

A uniformly random labeled tree has expected height Θ(n)\Theta(\sqrt n) (see this comment). If the parent of ii is next(0, i-1), we get a classical random recursive tree, and the diameter is typically Θ(logn)\Theta(\log n). To force a star, we want that parent to be close to 00; to force a path (a skewed tree, with endpoints 00 and n1n-1), we want it close to i1i-1.

That bias is exactly wnext. For e0e \geq 0, wnext(i, e) is the maximum of e+1e+1 independent samples of next(0, i-1) (so it is biased toward i1i-1). For e<0e \lt 0, it is the minimum of e+1|e|+1 samples (biased toward 00).

int wnext(int i, int e) {
	int j = next(0, i - 1);
	if (e >= 0) {
		for (int t = 0; t < e; t++)
			j = std::max(j, next(0, i - 1));
	} else {
		for (int t = 0; t < std::abs(e); t++)
			j = std::min(j, next(0, i - 1));
	}
	return j;
}

std::vector<std::pair<int, int>> gen_skewed(int n, int e) {
	std::vector<std::pair<int, int>> edges;
	for (int i = 1; i < n; i++)
		edges.push_back({wnext(i, e), i});
	return edges;
}

Algorithm 2: skewed tree by wnext.

If ee is 00, this is the uniform recursive tree. If ee is negative and large in absolute value, this is likely a star centered at 00. If ee is large, this is likely a path with endpoints 00 and n1n-1.

Algorithm 2 with n=14n = 14 and e=6e = -6 (star-like).

Algorithm 2 with n=14n = 14 and e=6e = 6 (path-like).

In particular, as ee grows, wnext(i, e) concentrates on j=i1j = i-1, so vertex ii connects to i1i-1 with probability tending to 11. For e<0e \lt 0, it concentrates on j=0j = 0.

The loop above runs in O(e)\mathcal O(|e|) time, which is too slow for large e|e|. The same distribution can be sampled in O(1)\mathcal O(1) by inverse transform:

int wnext(int i, int e) {
	double r = next(0.0, 1.0);
	double x;
	if (e >= 0)
		x = std::pow(r, 1.0 / (e + 1));
	else
		x = 1.0 - std::pow(r, 1.0 / (std::abs(e) + 1));
	return (int)(x * i); // in [0, i)
}

Algorithm 3: wnext in O(1)\mathcal O(1) time.

Theorem 2: Algorithm 3 has the same distribution as wnext in Algorithm 2. Using it, Algorithm 2 runs in O(n)\mathcal O(n) time.

Proof: Let k=e+1k = |e| + 1, and let rr be uniform in [0,1)[0, 1). Write U1,,UkU_1, \dots, U_k for independent uniform samples from 0,,i1{0, \dots, i-1}.

If e0e \geq 0, Algorithm 3 returns ir1/k\lfloor i r^{1/k} \rfloor, and

P[ir1/kj]=P[r<((j+1)/i)k]=(j+1i)k,\mathbb P\big[\lfloor i r^{1/k} \rfloor \le j\big] = \mathbb P\big[r \lt ((j+1)/i)^k\big] = \left(\frac{j+1}{i}\right)^k,

which is also P[max(U1,,Uk)j]\mathbb P[\max(U_1, \dots, U_k) \le j].

If e<0e \lt 0, Algorithm 3 returns i(1r1/k)\lfloor i(1-r^{1/k}) \rfloor, and

P[i(1r1/k)j]=P[r>((ij1)/i)k]=1(ij1i)k,\mathbb P\big[\lfloor i(1-r^{1/k}) \rfloor \le j\big] = \mathbb P\big[r \gt ((i-j-1)/i)^k\big] = 1 - \left(\frac{i-j-1}{i}\right)^k,

which is also P[min(U1,,Uk)j]\mathbb P[\min(U_1, \dots, U_k) \le j].

Each call is O(1)\mathcal O(1), and Algorithm 2 makes n1n-1 calls.

\square

References

Prüfer sequence (Wikipedia): https://en.wikipedia.org/wiki/Pr%C3%BCfer_sequence

Alias method (miniblog 3): https://codeforces.com/blog/entry/156111

Random recursive tree (Wikipedia): https://en.wikipedia.org/wiki/Random_recursive_tree

Height of a uniform random labeled tree: https://codeforces.com/blog/entry/95463#comment-845014

testlib wnext tree generator: https://codeforces.com/blog/entry/18291