Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

System Design (High-Level Design)  Module 14 – Search, geospatial and probabilistic data structures

Typeahead: tries and top-k per prefix

How typeahead serves suggestions from a trie that stores the most searched completions at every node, built from query logs and fast on slow networks.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free intro, Pro: 5 of 20 minutes free
Free: How a top-k trie answers a prefix. With a pass: the other 8 sections and 3 interview answers, about 15 minutes.

What you will learn

  • Build prefix suggestions from a trie with precomputed top-k lists
  • Estimate the memory of a suggestion trie and decide how to shard and replicate it
  • Update suggestions from query logs without slowing reads
  • Optimise typeahead for slow mobile networks with debouncing, prefix caches and cancelled requests
  • Choose between a search engine's completion suggester and a trie built from query logs

Before you start

What you’ll learn · Before you start

What you will learn

  • Build prefix suggestions from a trie with precomputed top-k lists
  • Estimate the memory of a suggestion trie and decide how to shard and replicate it
  • Update suggestions from query logs without slowing reads
  • Optimise typeahead for slow mobile networks with debouncing, prefix caches and cancelled requests
  • Choose between a search engine's completion suggester and a trie built from query logs

Before you start

On this page

Typeahead, or autocomplete, suggests whole queries while someone is still typing: after b it offers bluetooth speaker, after blu it narrows to blue jeans. Every keystroke is a query of its own, and a suggestion only helps if it appears before the next keystroke, so the whole round trip has a budget of tens of milliseconds. The standard design is a trie (prefix tree): A tree with one node per prefix, in which each edge adds one character, so finding a prefix takes one step per character. A top-k trie also stores the k most searched completions at every node. that stores the best few completions at every node, built in advance from query logs and served from memory, so that a lookup walks down the prefix and reads one stored list instead of searching everything below it.

How a top-k trie answers a prefix

A trie has one node per prefix: the node for bl has a child for each character that follows bl in some query, so finding a prefix takes one step per character. The slow part would come next. The node for b sits above every query that starts with b, and ranking all of them on every keystroke means visiting a large part of the trie. So the trie does that work once, when it is built: every node keeps its k most searched completions, best first, and a lookup costs the length of the prefix plus k, whatever the size of the trie.

A trie from the start node down to b, ba, bl and blu, and to c; each node lists the three most searched completions.(start)b1 bluetooth speaker2 black shoes3 backpackc1 cable2 camera3 candleba1 backpack2 battery3 bagbl1 bluetooth speaker2 black shoes3 blue jeansblu1 bluetooth speaker2 blue jeans3 blue shirtbcalu

Every node stores the best completions of its prefix

Text description of the diagram

The diagram shows part of a trie built from the example's query log, from the top down.

  • The start node has two children, reached by the characters b and c.
  • Node "c" stores cable, camera and candle.
  • Node "b" stores bluetooth speaker, black shoes and backpack. It has two children: "ba", reached by a, and "bl", reached by l.
  • Node "ba" stores backpack, battery and bag.
  • Node "bl" stores bluetooth speaker, black shoes and blue jeans. Its child "blu", reached by u, stores bluetooth speaker, blue jeans and blue shirt.

A lookup for a prefix follows one edge per character and reads the list at the node it reaches.

Step through three keystrokes on that trie:

Interactive · step by step Three keystrokes, three steps down the trie Step through the typing. Each keystroke moves one node down and reads the list stored there; nothing below the node is visited.
Three keystrokes, three steps down the trie, step 4 of 4. Showing Start, b, c, bl, ba, blu. In focus: Start, b, bl, blu.bclauStartb54 nodes in its subtreeccable · camera · candlebl40 nodes in its subtreebabackpack · battery · bagblu30 nodes in its subtree

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Type b. The person types b. The server takes one step from the start node and reads the list stored at b, best first. A search of b's subtree would have visited 54 nodes to rank the same queries.
  2. Type l. One more step, to bl, and another stored list. blue jeans has taken backpack's place, because backpack does not start with bl.
  3. Type u. The walk reaches blu and reads its list. Three keystrokes cost three steps and three lists of three entries; no node below blu was visited.
  4. The cost. A lookup costs one step per character plus the k entries it reads, however many queries share the prefix. The search it replaces grows with the subtree, 54 nodes at b here and millions in a real trie.

The program builds such a trie from a small query log, in 13 languages, and compares each walk with the search it replaces:

One program · 13 languages A top-3 trie built from a small query log O(p + k) per lookup for a prefix of p characters

Python 3.14 · main.py

K = 3
LOG = [
    ("bluetooth speaker", 9400),
    ("black shoes", 8100),
    ("backpack", 7300),
    ("blue jeans", 6900),
    ("blue shirt", 4700),
    ("battery", 4400),
    ("bag", 3900),
    ("blue saree", 3300),
    ("cable", 8800),
    ("camera", 7700),
    ("candle", 2100),
]


class Node:
    def __init__(self) -> None:
        self.children: dict[str, Node] = {}
        self.top: list[tuple[int, str]] = []


def insert(root: Node, query: str, count: int) -> None:
    # Offer the query to every node on its path, and keep each node's best K.
    node = root
    for ch in query:
        node = node.children.setdefault(ch, Node())
        node.top = sorted([*node.top, (count, query)], key=lambda e: (-e[0], e[1]))[:K]


def find(root: Node, prefix: str) -> Node | None:
    node = root
    for ch in prefix:
        if ch not in node.children:
            return None
        node = node.children[ch]
    return node


def size(node: Node) -> int:
    return 1 + sum(size(child) for child in node.children.values())


root = Node()
for query, count in LOG:
    insert(root, query, count)
print(f"{len(LOG)} queries, {size(root) - 1} trie nodes, top {K} kept at each node")
for prefix in ["b", "bl", "blu", "blue ", "c", "x"]:
    node = find(root, prefix)
    if node is None:
        print(f'  "{prefix}" -> no suggestions')
    else:
        shown = ", ".join(f"{q} ({c})" for c, q in node.top)
        print(f'  "{prefix}" -> {shown}; {size(node)} nodes below')

In Python: sorted with the key (-count, query) puts the most searched first and breaks ties by query, and slicing keeps the best K; setdefault creates a child the first time a character appears.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

const K = 3;
const LOG = [
  ["bluetooth speaker", 9400],
  ["black shoes", 8100],
  ["backpack", 7300],
  ["blue jeans", 6900],
  ["blue shirt", 4700],
  ["battery", 4400],
  ["bag", 3900],
  ["blue saree", 3300],
  ["cable", 8800],
  ["camera", 7700],
  ["candle", 2100],
];

const newNode = () => ({ children: new Map(), top: [] });
const better = (a, b) => b[0] - a[0] || (a[1] < b[1] ? -1 : 1);

function insert(root, query, count) {
  // Offer the query to every node on its path, and keep each node's best K.
  let node = root;
  for (const ch of query) {
    if (!node.children.has(ch)) node.children.set(ch, newNode());
    node = node.children.get(ch);
    node.top.push([count, query]);
    node.top.sort(better);
    if (node.top.length > K) node.top.pop();
  }
}

function find(root, prefix) {
  let node = root;
  for (const ch of prefix) {
    node = node.children.get(ch);
    if (!node) return null;
  }
  return node;
}

const size = (node) => [...node.children.values()].reduce((n, child) => n + size(child), 1);

const root = newNode();
for (const [query, count] of LOG) insert(root, query, count);
console.log(`${LOG.length} queries, ${size(root) - 1} trie nodes, top ${K} kept at each node`);
for (const prefix of ["b", "bl", "blu", "blue ", "c", "x"]) {
  const node = find(root, prefix);
  if (!node) {
    console.log(`  "${prefix}" -> no suggestions`);
    continue;
  }
  const shown = node.top.map(([count, query]) => `${query} (${count})`).join(", ");
  console.log(`  "${prefix}" -> ${shown}; ${size(node)} nodes below`);
}

In JavaScript: A Map per node holds its children, and the comparator better breaks ties by comparing the query strings, so the order never depends on the engine's sort.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
  static final int K = 3;

  record Entry(String query, int count) {}

  static final class Node {
    final Map<Character, Node> children = new HashMap<>();
    final List<Entry> top = new ArrayList<>();
  }

  static final Comparator<Entry> BEST =
      Comparator.comparingInt(Entry::count).reversed().thenComparing(Entry::query);

  static void insert(Node root, Entry entry) {
    // Offer the query to every node on its path, and keep each node's best K.
    Node node = root;
    for (char ch : entry.query().toCharArray()) {
      node = node.children.computeIfAbsent(ch, c -> new Node());
      node.top.add(entry);
      node.top.sort(BEST);
      if (node.top.size() > K) {
        node.top.remove(K);
      }
    }
  }

  static Node find(Node root, String prefix) {
    Node node = root;
    for (char ch : prefix.toCharArray()) {
      node = node.children.get(ch);
      if (node == null) {
        return null;
      }
    }
    return node;
  }

  static int size(Node node) {
    return 1 + node.children.values().stream().mapToInt(Main::size).sum();
  }

  public static void main(String[] args) {
    List<Entry> log =
        List.of(
            new Entry("bluetooth speaker", 9400),
            new Entry("black shoes", 8100),
            new Entry("backpack", 7300),
            new Entry("blue jeans", 6900),
            new Entry("blue shirt", 4700),
            new Entry("battery", 4400),
            new Entry("bag", 3900),
            new Entry("blue saree", 3300),
            new Entry("cable", 8800),
            new Entry("camera", 7700),
            new Entry("candle", 2100));
    Node root = new Node();
    log.forEach(entry -> insert(root, entry));
    String counts = log.size() + " queries, " + (size(root) - 1) + " trie nodes";
    System.out.println(counts + ", top " + K + " kept at each node");
    for (String prefix : List.of("b", "bl", "blu", "blue ", "c", "x")) {
      Node node = find(root, prefix);
      String head = "  \"" + prefix + "\" -> ";
      if (node == null) {
        System.out.println(head + "no suggestions");
        continue;
      }
      var shown = new ArrayList<String>();
      node.top.forEach(e -> shown.add(e.query() + " (" + e.count() + ")"));
      System.out.println(head + String.join(", ", shown) + "; " + size(node) + " nodes below");
    }
  }
}

In Java: Comparator.comparingInt(Entry::count).reversed().thenComparing(Entry::query) states the order in one line, and computeIfAbsent creates a child node on first use.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <algorithm>
#include <format>
#include <map>
#include <memory>
#include <print>
#include <string>
#include <utility>
#include <vector>

constexpr std::size_t K = 3;
using Entry = std::pair<int, std::string>; // minus the count, then the query

struct Node {
  std::map<char, std::unique_ptr<Node>> children;
  std::vector<Entry> top;
};

void insert(Node &root, const std::string &query, int count) {
  // Offer the query to every node on its path, and keep each node's best K.
  Node *node = &root;
  for (const char ch : query) {
    auto &child = node->children[ch];
    if (!child)
      child = std::make_unique<Node>();
    node = child.get();
    node->top.emplace_back(-count, query);
    std::ranges::sort(node->top);
    if (node->top.size() > K)
      node->top.pop_back();
  }
}

const Node *find(const Node &root, const std::string &prefix) {
  const Node *node = &root;
  for (const char ch : prefix) {
    if (!node->children.contains(ch))
      return nullptr;
    node = node->children.at(ch).get();
  }
  return node;
}

int size(const Node &node) {
  int n = 1;
  for (const auto &[ch, child] : node.children)
    n += size(*child);
  return n;
}

int main() {
  const std::vector<std::pair<std::string, int>> log = {
      {"bluetooth speaker", 9400},
      {"black shoes", 8100},
      {"backpack", 7300},
      {"blue jeans", 6900},
      {"blue shirt", 4700},
      {"battery", 4400},
      {"bag", 3900},
      {"blue saree", 3300},
      {"cable", 8800},
      {"camera", 7700},
      {"candle", 2100}};
  Node root;
  for (const auto &[query, count] : log)
    insert(root, query, count);
  std::println("{} queries, {} trie nodes, top {} kept at each node",
               log.size(), size(root) - 1, K);
  for (const std::string prefix : {"b", "bl", "blu", "blue ", "c", "x"}) {
    const Node *node = find(root, prefix);
    if (node == nullptr) {
      std::println("  \"{}\" -> no suggestions", prefix);
      continue;
    }
    std::string shown;
    for (const auto &[minus_count, query] : node->top)
      shown += std::format("{}{} ({})", shown.empty() ? "" : ", ", query,
                           -minus_count);
    std::println("  \"{}\" -> {}; {} nodes below", prefix, shown, size(*node));
  }
}

In C++: Children are owned by std::unique_ptr in a std::map, so the trie is freed when root goes out of scope; each list holds pairs of minus the count and the query, so plain std::ranges::sort puts the most searched first.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

const int K = 3;
(string Query, int Count)[] log =
[
    ("bluetooth speaker", 9400),
    ("black shoes", 8100),
    ("backpack", 7300),
    ("blue jeans", 6900),
    ("blue shirt", 4700),
    ("battery", 4400),
    ("bag", 3900),
    ("blue saree", 3300),
    ("cable", 8800),
    ("camera", 7700),
    ("candle", 2100),
];

var root = new Node();
foreach (var (query, count) in log)
    Insert(root, query, count);
Console.WriteLine($"{log.Length} queries, {Size(root) - 1} trie nodes, top {K} kept at each node");
foreach (string prefix in new[] { "b", "bl", "blu", "blue ", "c", "x" })
{
    Node? node = Find(root, prefix);
    if (node is null)
    {
        Console.WriteLine($"  \"{prefix}\" -> no suggestions");
        continue;
    }
    string shown = string.Join(", ", node.Top.Select(e => $"{e.Query} ({e.Count})"));
    Console.WriteLine($"  \"{prefix}\" -> {shown}; {Size(node)} nodes below");
}

static void Insert(Node root, string query, int count)
{
    // Offer the query to every node on its path, and keep each node's best K.
    Node node = root;
    foreach (char ch in query)
    {
        if (!node.Children.TryGetValue(ch, out Node? child))
            node.Children[ch] = child = new Node();
        node = child;
        node.Top.Add((query, count));
        node.Top.Sort(Better);
        if (node.Top.Count > K)
            node.Top.RemoveAt(K);
    }
}

static int Better((string Query, int Count) a, (string Query, int Count) b) =>
    a.Count != b.Count ? b.Count.CompareTo(a.Count) : string.CompareOrdinal(a.Query, b.Query);

static Node? Find(Node root, string prefix)
{
    Node node = root;
    foreach (char ch in prefix)
    {
        if (!node.Children.TryGetValue(ch, out Node? child))
            return null;
        node = child;
    }
    return node;
}

static int Size(Node node) => 1 + node.Children.Values.Sum(Size);

sealed class Node
{
    public Dictionary<char, Node> Children { get; } = [];
    public List<(string Query, int Count)> Top { get; } = [];
}

In C#: TryGetValue reports a missing child without an exception, and a static comparison function, Better, sorts each list with string.CompareOrdinal for ties.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

C is left out of this example: C's standard library has no map or growable list, so every node's children and top list would be hand-sized arrays and the program another design.

PHP 8.5 · main.php

<?php

declare(strict_types=1);

const K = 3;
const LOG = [
    ['bluetooth speaker', 9400],
    ['black shoes', 8100],
    ['backpack', 7300],
    ['blue jeans', 6900],
    ['blue shirt', 4700],
    ['battery', 4400],
    ['bag', 3900],
    ['blue saree', 3300],
    ['cable', 8800],
    ['camera', 7700],
    ['candle', 2100],
];

final class Node
{
    /** @var array<string, Node> */
    public array $children = [];
    /** @var list<array{int, string}> */
    public array $top = [];
}

function insert(Node $root, string $query, int $count): void
{
    // Offer the query to every node on its path, and keep each node's best K.
    $node = $root;
    foreach (str_split($query) as $ch) {
        $node = $node->children[$ch] ??= new Node();
        $node->top[] = [$count, $query];
        usort($node->top, fn(array $a, array $b): int => [$b[0], $a[1]] <=> [$a[0], $b[1]]);
        $node->top = array_slice($node->top, 0, K);
    }
}

function find(Node $root, string $prefix): ?Node
{
    $node = $root;
    foreach (str_split($prefix) as $ch) {
        $node = $node->children[$ch] ?? null;
        if ($node === null) {
            return null;
        }
    }
    return $node;
}

function size(Node $node): int
{
    return 1 + array_sum(array_map(size(...), $node->children));
}

$root = new Node();
foreach (LOG as [$query, $count]) {
    insert($root, $query, $count);
}
printf("%d queries, %d trie nodes, top %d kept at each node\n", count(LOG), size($root) - 1, K);
foreach (['b', 'bl', 'blu', 'blue ', 'c', 'x'] as $prefix) {
    $node = find($root, $prefix);
    if ($node === null) {
        echo "  \"{$prefix}\" -> no suggestions\n";
        continue;
    }
    $shown = implode(', ', array_map(fn(array $e): string => "{$e[1]} ({$e[0]})", $node->top));
    echo "  \"{$prefix}\" -> {$shown}; " . size($node) . " nodes below\n";
}

In PHP: The ??= operator creates a child only when none exists, and comparing the arrays [$b[0], $a[1]] and [$a[0], $b[1]] sorts by count down, then by query up.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

use std::collections::BTreeMap;

const K: usize = 3;
const LOG: [(&str, u32); 11] = [
    ("bluetooth speaker", 9400),
    ("black shoes", 8100),
    ("backpack", 7300),
    ("blue jeans", 6900),
    ("blue shirt", 4700),
    ("battery", 4400),
    ("bag", 3900),
    ("blue saree", 3300),
    ("cable", 8800),
    ("camera", 7700),
    ("candle", 2100),
];

#[derive(Default)]
struct Node {
    children: BTreeMap<char, Node>,
    top: Vec<(u32, &'static str)>,
}

fn insert(root: &mut Node, query: &'static str, count: u32) {
    // Offer the query to every node on its path, and keep each node's best K.
    let mut node = root;
    for ch in query.chars() {
        node = node.children.entry(ch).or_default();
        node.top.push((count, query));
        node.top.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
        node.top.truncate(K);
    }
}

fn find<'a>(root: &'a Node, prefix: &str) -> Option<&'a Node> {
    prefix
        .chars()
        .try_fold(root, |node, ch| node.children.get(&ch))
}

fn size(node: &Node) -> usize {
    1 + node.children.values().map(size).sum::<usize>()
}

fn main() {
    let mut root = Node::default();
    for (query, count) in LOG {
        insert(&mut root, query, count);
    }
    let header = format!("{} queries, {} trie nodes", LOG.len(), size(&root) - 1);
    println!("{header}, top {K} kept at each node");
    for prefix in ["b", "bl", "blu", "blue ", "c", "x"] {
        let Some(node) = find(&root, prefix) else {
            println!("  \"{prefix}\" -> no suggestions");
            continue;
        };
        let shown: Vec<String> = node.top.iter().map(|(c, q)| format!("{q} ({c})")).collect();
        let (shown, below) = (shown.join(", "), size(node));
        println!("  \"{prefix}\" -> {shown}; {below} nodes below");
    }
}

In Rust: The entry API creates a child on first use, truncate keeps the best K, and find folds over the prefix's characters with try_fold, stopping at the first missing child.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

type Entry = [count: number, query: string];
type TrieNode = { children: Map<string, TrieNode>; top: Entry[] };

const K = 3;
const LOG: [string, number][] = [
  ["bluetooth speaker", 9400],
  ["black shoes", 8100],
  ["backpack", 7300],
  ["blue jeans", 6900],
  ["blue shirt", 4700],
  ["battery", 4400],
  ["bag", 3900],
  ["blue saree", 3300],
  ["cable", 8800],
  ["camera", 7700],
  ["candle", 2100],
];

const newNode = (): TrieNode => ({ children: new Map(), top: [] });
const better = (a: Entry, b: Entry): number => b[0] - a[0] || (a[1] < b[1] ? -1 : 1);

function insert(root: TrieNode, query: string, count: number): void {
  // Offer the query to every node on its path, and keep each node's best K.
  let node = root;
  for (const ch of query) {
    const child = node.children.get(ch) ?? newNode();
    node.children.set(ch, child);
    node = child;
    node.top.push([count, query]);
    node.top.sort(better);
    if (node.top.length > K) node.top.pop();
  }
}

function find(root: TrieNode, prefix: string): TrieNode | undefined {
  let node: TrieNode | undefined = root;
  for (const ch of prefix) node = node?.children.get(ch);
  return node;
}

const size = (node: TrieNode): number =>
  [...node.children.values()].reduce((n, child) => n + size(child), 1);

const root = newNode();
for (const [query, count] of LOG) insert(root, query, count);
console.log(`${LOG.length} queries, ${size(root) - 1} trie nodes, top ${K} kept at each node`);
for (const prefix of ["b", "bl", "blu", "blue ", "c", "x"]) {
  const node = find(root, prefix);
  if (!node) {
    console.log(`  "${prefix}" -> no suggestions`);
    continue;
  }
  const shown = node.top.map(([count, query]) => `${query} (${count})`).join(", ");
  console.log(`  "${prefix}" -> ${shown}; ${size(node)} nodes below`);
}

In TypeScript: find walks with optional chaining, node?.children.get(ch), so a missing child turns the rest of the walk into undefined without a branch.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import (
	"cmp"
	"fmt"
	"slices"
	"strings"
)

const k = 3

type entry struct {
	query string
	count int
}

type node struct {
	children map[rune]*node
	top      []entry
}

func insert(root *node, e entry) {
	// Offer the query to every node on its path, and keep each node's best K.
	n := root
	for _, ch := range e.query {
		if n.children[ch] == nil {
			n.children[ch] = &node{children: map[rune]*node{}}
		}
		n = n.children[ch]
		n.top = append(n.top, e)
		slices.SortFunc(n.top, func(a, b entry) int {
			return cmp.Or(cmp.Compare(b.count, a.count), cmp.Compare(a.query, b.query))
		})
		n.top = n.top[:min(len(n.top), k)]
	}
}

func find(root *node, prefix string) *node {
	n := root
	for _, ch := range prefix {
		if n = n.children[ch]; n == nil {
			return nil
		}
	}
	return n
}

func size(n *node) int {
	total := 1
	for _, child := range n.children {
		total += size(child)
	}
	return total
}

func main() {
	log := []entry{
		{"bluetooth speaker", 9400}, {"black shoes", 8100}, {"backpack", 7300},
		{"blue jeans", 6900}, {"blue shirt", 4700}, {"battery", 4400}, {"bag", 3900},
		{"blue saree", 3300}, {"cable", 8800}, {"camera", 7700}, {"candle", 2100},
	}
	root := &node{children: map[rune]*node{}}
	for _, e := range log {
		insert(root, e)
	}
	fmt.Printf("%d queries, %d trie nodes, top %d kept at each node\n", len(log), size(root)-1, k)
	for _, prefix := range []string{"b", "bl", "blu", "blue ", "c", "x"} {
		n := find(root, prefix)
		if n == nil {
			fmt.Printf("  %q -> no suggestions\n", prefix)
			continue
		}
		var shown []string
		for _, e := range n.top {
			shown = append(shown, fmt.Sprintf("%s (%d)", e.query, e.count))
		}
		fmt.Printf("  %q -> %s; %d nodes below\n", prefix, strings.Join(shown, ", "), size(n))
	}
}

In Go: Map iteration order is random, which size does not mind because it only adds; cmp.Or chains the two comparisons that sort each node's list.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

let k = 3
let log = [
  ("bluetooth speaker", 9400),
  ("black shoes", 8100),
  ("backpack", 7300),
  ("blue jeans", 6900),
  ("blue shirt", 4700),
  ("battery", 4400),
  ("bag", 3900),
  ("blue saree", 3300),
  ("cable", 8800),
  ("camera", 7700),
  ("candle", 2100),
]

final class Node {
  var children: [Character: Node] = [:]
  var top: [(count: Int, query: String)] = []
}

func insert(_ root: Node, _ query: String, _ count: Int) {
  // Offer the query to every node on its path, and keep each node's best K.
  var node = root
  for ch in query {
    let child = node.children[ch] ?? Node()
    node.children[ch] = child
    node = child
    node.top.append((count, query))
    node.top.sort { $0.count != $1.count ? $0.count > $1.count : $0.query < $1.query }
    node.top = Array(node.top.prefix(k))
  }
}

func find(_ root: Node, _ prefix: String) -> Node? {
  var node: Node? = root
  for ch in prefix {
    node = node?.children[ch]
  }
  return node
}

func size(_ node: Node) -> Int {
  node.children.values.reduce(1) { $0 + size($1) }
}

let root = Node()
for (query, count) in log {
  insert(root, query, count)
}
print("\(log.count) queries, \(size(root) - 1) trie nodes, top \(k) kept at each node")
for prefix in ["b", "bl", "blu", "blue ", "c", "x"] {
  guard let node = find(root, prefix) else {
    print("  \"\(prefix)\" -> no suggestions")
    continue
  }
  let shown = node.top.map { "\($0.query) (\($0.count))" }.joined(separator: ", ")
  print("  \"\(prefix)\" -> \(shown); \(size(node)) nodes below")
}

In Swift: Node is a class because the trie's nodes are shared references; the list of each node is an array of named tuples, sorted with a closure.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

K = 3
LOG = [
  ['bluetooth speaker', 9400], ['black shoes', 8100], ['backpack', 7300],
  ['blue jeans', 6900], ['blue shirt', 4700], ['battery', 4400], ['bag', 3900],
  ['blue saree', 3300], ['cable', 8800], ['camera', 7700], ['candle', 2100]
].freeze

Node = Struct.new(:children, :top)

def insert(root, query, count)
  # Offer the query to every node on its path, and keep each node's best K.
  node = root
  query.each_char do |ch|
    node = (node.children[ch] ||= Node.new({}, []))
    node.top = (node.top + [[count, query]]).sort_by { |c, q| [-c, q] }.first(K)
  end
end

def find(root, prefix)
  prefix.each_char.reduce(root) { |node, ch| node&.children&.fetch(ch, nil) }
end

def size(node) = 1 + node.children.values.sum { |child| size(child) }

root = Node.new({}, [])
LOG.each { |query, count| insert(root, query, count) }
puts "#{LOG.size} queries, #{size(root) - 1} trie nodes, top #{K} kept at each node"
['b', 'bl', 'blu', 'blue ', 'c', 'x'].each do |prefix|
  node = find(root, prefix)
  next puts "  #{prefix.inspect} -> no suggestions" if node.nil?

  shown = node.top.map { |count, query| "#{query} (#{count})" }.join(', ')
  puts "  #{prefix.inspect} -> #{shown}; #{size(node)} nodes below"
end

In Ruby: Struct gives the node its two fields, sort_by with [-count, query] orders each list, and the safe navigation operator &. lets find stop at a missing child.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

const val K = 3

val log =
    listOf(
        "bluetooth speaker" to 9400,
        "black shoes" to 8100,
        "backpack" to 7300,
        "blue jeans" to 6900,
        "blue shirt" to 4700,
        "battery" to 4400,
        "bag" to 3900,
        "blue saree" to 3300,
        "cable" to 8800,
        "camera" to 7700,
        "candle" to 2100,
    )

class Node {
    val children = mutableMapOf<Char, Node>()
    var top = listOf<Pair<Int, String>>()
}

fun insert(
    root: Node,
    query: String,
    count: Int,
) {
    // Offer the query to every node on its path, and keep each node's best K.
    var node = root
    for (ch in query) {
        node = node.children.getOrPut(ch) { Node() }
        val offered = node.top + (count to query)
        node.top = offered.sortedWith(compareBy({ -it.first }, { it.second })).take(K)
    }
}

fun find(
    root: Node,
    prefix: String,
): Node? = prefix.fold(root as Node?) { node, ch -> node?.children?.get(ch) }

fun size(node: Node): Int = 1 + node.children.values.sumOf { size(it) }

fun main() {
    val root = Node()
    for ((query, count) in log) insert(root, query, count)
    println("${log.size} queries, ${size(root) - 1} trie nodes, top $K kept at each node")
    for (prefix in listOf("b", "bl", "blu", "blue ", "c", "x")) {
        val node = find(root, prefix)
        if (node == null) {
            println("  \"$prefix\" -> no suggestions")
            continue
        }
        val shown = node.top.joinToString(", ") { (count, query) -> "$query ($count)" }
        println("  \"$prefix\" -> $shown; ${size(node)} nodes below")
    }
}

In Kotlin: getOrPut creates a child on first use, compareBy takes one selector for the count and one for the query, and find folds over the prefix's characters.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

const k = 3;
const log = [
  ('bluetooth speaker', 9400),
  ('black shoes', 8100),
  ('backpack', 7300),
  ('blue jeans', 6900),
  ('blue shirt', 4700),
  ('battery', 4400),
  ('bag', 3900),
  ('blue saree', 3300),
  ('cable', 8800),
  ('camera', 7700),
  ('candle', 2100),
];

class Node {
  final children = <String, Node>{};
  var top = <(int, String)>[];
}

int better((int, String) a, (int, String) b) =>
    a.$1 != b.$1 ? b.$1 - a.$1 : a.$2.compareTo(b.$2);

void insert(Node root, String query, int count) {
  // Offer the query to every node on its path, and keep each node's best K.
  var node = root;
  for (final ch in query.split('')) {
    node = node.children.putIfAbsent(ch, Node.new);
    node.top = ([...node.top, (count, query)]..sort(better)).take(k).toList();
  }
}

Node? find(Node root, String prefix) {
  Node? node = root;
  for (final ch in prefix.split('')) {
    node = node?.children[ch];
  }
  return node;
}

int size(Node node) => node.children.values.fold(1, (n, c) => n + size(c));

void main() {
  final root = Node();
  for (final (query, count) in log) {
    insert(root, query, count);
  }
  print(
    '${log.length} queries, ${size(root) - 1} trie nodes, top $k kept at each node',
  );
  for (final prefix in ['b', 'bl', 'blu', 'blue ', 'c', 'x']) {
    final node = find(root, prefix);
    if (node == null) {
      print('  "$prefix" -> no suggestions');
      continue;
    }
    final shown = node.top.map((e) => '${e.$2} (${e.$1})').join(', ');
    print('  "$prefix" -> $shown; ${size(node)} nodes below');
  }
}

In Dart: The cascade ..sort(better) sorts the new list in place and returns it, so offering, sorting and keeping the best K fit in one expression.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

11 queries, 67 trie nodes, top 3 kept at each node
  "b" -> bluetooth speaker (9400), black shoes (8100), backpack (7300); 54 nodes below
  "bl" -> bluetooth speaker (9400), black shoes (8100), blue jeans (6900); 40 nodes below
  "blu" -> bluetooth speaker (9400), blue jeans (6900), blue shirt (4700); 30 nodes below
  "blue " -> blue jeans (6900), blue shirt (4700), blue saree (3300); 15 nodes below
  "c" -> cable (8800), camera (7700), candle (2100); 13 nodes below
  "x" -> no suggestions

For b the walk takes one step where a search of its subtree would visit 54 of the 67 nodes, and in a trie of millions of queries that gap is millions of nodes on every keystroke. The stored lists also make each prefix’s suggestions its own: bl drops backpack for blue jeans, and blue followed by a space keeps only blue items.

A production design surrounds this structure, and each piece has one job:

  • Offline builds count queries from the logs, build the trie and swap a read-only snapshot onto the servers.
  • A streaming layer counts the last few minutes of queries, so that new terms appear before the next build.
  • The client waits for a pause in typing, caches prefixes it has fetched and ignores replies that arrive late.
  • Shards and replicas split the trie by prefix and copy its busiest parts, which are the short prefixes.
  • Search engines’ suggesters, such as the completion field of Elasticsearch, offer the same pattern ready-made.

Exercise · Medium · Python

Build a trie that keeps the top suggestions

Write the two functions of suggest.py.

  • insert(root, query, count) walks down the query one character at a time, creating a Node for any character that has none yet, and offers (count, query) to the top list of every node it passes. Each list keeps at most K entries: the highest counts first, and equal counts in alphabetical order of the query.
  • top(root, prefix) walks down the prefix and returns the queries of that node's list, best first, or an empty list if the prefix is not in the trie.

A lookup then costs one step per character of the prefix, however many queries share it. The tests insert queries in both orders, so do not rely on the most searched query coming first.

Starter code · suggest.py

K = 3


class Node:
    def __init__(self):
        self.children = {}  # next character -> Node
        self.top = []       # up to K (count, query) pairs: highest count first, ties in alphabetical order


def insert(root, query, count):
    """Add a query searched `count` times: create the nodes of its characters and update every node's top-K list."""
    # Replace this line with your code.
    pass


def top(root, prefix):
    """The suggestions for a prefix, as a list of queries (at most K), best first."""
    # Replace this line with your code.
    return []
The sample tests · test_suggest.py
from suggest import K, Node, insert, top

LOG = [("bluetooth speaker", 9400), ("black shoes", 8100), ("backpack", 7300), ("blue jeans", 6900),
       ("bed sheet", 6200), ("blender", 5800), ("blue shirt", 4700), ("battery", 4400), ("bag", 3900)]


def build(log):
    root = Node()
    for query, count in log:
        insert(root, query, count)
    return root


def test_best_three_for_a_letter():
    """the most searched completions come first"""
    assert top(build(LOG), "b") == ["bluetooth speaker", "black shoes", "backpack"]


def test_longer_prefixes():
    """each node keeps its own list"""
    root = build(LOG)
    assert top(root, "bl") == ["bluetooth speaker", "black shoes", "blue jeans"]
    assert top(root, "blue ") == ["blue jeans", "blue shirt"]
    assert top(root, "ba") == ["backpack", "battery", "bag"]


def test_unknown_prefix():
    """a prefix nobody searched has no suggestions"""
    assert top(build(LOG), "x") == []


def test_order_of_insertion_does_not_matter():
    """inserting the least searched queries first gives the same lists"""
    assert top(build(list(reversed(LOG))), "b") == ["bluetooth speaker", "black shoes", "backpack"]


def test_ties_are_alphabetical():
    """equal counts are ordered by the query text"""
    root = build([("cap", 50), ("cable", 50), ("camera", 50), ("candle", 10)])
    assert top(root, "ca") == ["cable", "camera", "cap"]


def test_lists_hold_at_most_k():
    """no node keeps more than K entries"""
    root = build(LOG)
    node = root.children["b"]
    assert len(node.top) == K
A hint

In insert, after moving to each child node, add the new pair and keep the best K: node.top = sorted(node.top + [(count, query)], key=lambda e: (-e[0], e[1]))[:K]. In top, follow node.children.get(ch) for each character, return [] when it is None, and return [q for _, q in node.top] at the end.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Checkpoint

Check: How a top-k trie answers a prefix

  1. Question 1 of 2 In the program's top-3 trie, what does the lookup of the prefix "blue " (with its space) cost?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 5 steps down the trie, one per character, then reading the 3 entries stored at that node

    A top-k trie answers a prefix by walking one edge per character and reading the list kept at the node it reaches: 5 steps and 3 entries for "blue ", where a search of its subtree would visit 15 nodes.

  2. Question 2 of 2 The list stored at b ends with backpack, and the list stored at bl ends with blue jeans. Why do they differ?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: backpack does not start with bl, so it is not below bl, and blue jeans is the next most searched query that is

    Every node keeps the top k of its own subtree. bluetooth speaker and black shoes start with bl, backpack does not, so the third place at bl goes to the next query below it, blue jeans with 6,900 searches.

What a top-k trie costs in memory, measured and extrapolated Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Building suggestions from query logs without slowing reads
  • The client half: debouncing, prefix caches and stale replies, measured
  • Serving at scale: shards, hot prefixes and replicas, with numbers
  • When a search engine’s suggester is the better tool
  • Practise: check yourself
  • Interview questions
    • Design search suggestions for 10 million daily users that must appear within 100 ms of each keystroke, including on slow mobile networks. Where does the time go, and what runs on the client?
    • Suggestions are rebuilt from query logs every night. A breaking-news term must start appearing within 5 minutes of people searching for it, but a rebuild takes 2 hours. How do you get there?
    • Your top-k suggestion trie for 50 million distinct queries no longer fits on one server. Estimate its memory, and describe how you would shard and serve it.
  • Key takeaways

2 diagrams · 2 runnable examples (JavaScript, Python) · 3 interview answers · about 34 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Design a unique ID generator

Terms in this lesson

debounce
Waiting until no new keystroke has arrived for a set time, such as 200 ms, before sending a request, so that a burst of typing sends one request instead of one per key.
trie (prefix tree)
A tree with one node per prefix, in which each edge adds one character, so finding a prefix takes one step per character. A top-k trie also stores the k most searched completions at every node.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.