../

Trees, heaps, tries & graphs

The non-linear structures: tree vocabulary, binary trees and their traversals, binary search trees, heaps (with the MinHeap<T> that JavaScript lacks), tries, graph representations and union-find. Algorithms that walk graphs (BFS, DFS, topological sort, Dijkstra, MST) are in Graph algorithms; costs are explained in Big-O; arrays, maps and deques in Arrays, hashing & lists.

Tree vocabulary

A tree is a connected graph with no cycles: nn nodes, exactly n−1n - 1 edges, one path between any two nodes. A rooted tree picks one node as the root, giving every other node a parent.

TermMeaning
Root / leaf / internaltop node / node with no children / node with at least one child
Parent, child, siblingone edge up / one edge down / same parent
Ancestor, descendantanywhere above / below on the path to the root
Subtreea node plus all its descendants
Depth of a nodeedges from the root (root depth 0)
Height of a treeedges on the longest root-to-leaf path (some books count nodes)
Levelall nodes of the same depth
Degreenumber of children
Binary treeat most 2 children, called left and right
Fullevery node has 0 or 2 children
Completeevery level full except possibly the last, filled left to right (heaps)
Perfectall levels full: height hh holds 2h+1−12^{h+1} - 1 nodes
Balancedheight O(log⁡n)O(\log n); for AVL, subtree heights differ by at most 1 everywhere
Degenerate (skewed)every node has one child: really a linked list, height n−1n - 1
BSTbinary tree where, at every node, keys on the left are smaller and keys on the right larger
N-ary treeany number of children (file systems, DOM, org charts)
Foresta set of disjoint trees

Most tree answers cost O(n)O(n) time (visit each node once) and O(h)O(h) space (the recursion stack), where hh is log⁡n\log n when balanced and nn when skewed.

Binary trees & traversals

TraversalOrderTypical use
Preordernode, left, rightcopy or serialize a tree, print a hierarchy
Inorderleft, node, righta BST in sorted order, k-th smallest
Postorderleft, right, nodedelete or free, compute heights and sizes (children first)
Level order (BFS)level by level, left to rightshortest depth, right-side view, per-level work

type Tree = TreeNode | null;
 
class TreeNode {
  val: number;
  left: Tree;
  right: Tree;
  constructor(val: number, left: Tree = null,
    right: Tree = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}
 
type Order = "pre" | "in" | "post";
 
// Recursive DFS: O(n) time, O(h) stack
function dfs(root: Tree, order: Order): number[] {
  const out: number[] = [];
  const walk = (n: Tree): void => {
    if (!n) return;
    if (order === "pre") out.push(n.val);
    walk(n.left);
    if (order === "in") out.push(n.val);
    walk(n.right);
    if (order === "post") out.push(n.val);
  };
  walk(root);
  return out;
}
 
// Height in nodes (0 for an empty tree)
function maxDepth(n: Tree): number {
  return n ? 1 + Math.max(maxDepth(n.left),
    maxDepth(n.right)) : 0;
}

Iterative traversals

Use an explicit stack when the tree may be deep enough to overflow the call stack (a skewed tree of 10⁵ nodes will), and a queue (or a list per level) for level order. All are O(n)O(n) time, O(h)O(h) or O(width)O(\text{width}) space.

function preorderIter(root: Tree): number[] {
  const out: number[] = [];
  const stack: TreeNode[] = root ? [root] : [];
  while (stack.length > 0) {
    const n = stack.pop()!;
    out.push(n.val);
    if (n.right) stack.push(n.right); // right first,
    if (n.left) stack.push(n.left);   // so left pops first
  }
  return out;
}
 
function inorderIter(root: Tree): number[] {
  const out: number[] = [];
  const stack: TreeNode[] = [];
  let n = root;
  while (n || stack.length > 0) {
    while (n) {          // go as far left as possible
      stack.push(n);
      n = n.left;
    }
    const top = stack.pop()!;
    out.push(top.val);
    n = top.right;
  }
  return out;
}
 
// Postorder = reverse of (node, right, left)
function postorderIter(root: Tree): number[] {
  const out: number[] = [];
  const stack: TreeNode[] = root ? [root] : [];
  while (stack.length > 0) {
    const n = stack.pop()!;
    out.push(n.val);
    if (n.left) stack.push(n.left);
    if (n.right) stack.push(n.right);
  }
  return out.reverse();
}
 
// Level order: one array per level, no shift()
function levelOrder(root: Tree): number[][] {
  const levels: number[][] = [];
  let level: TreeNode[] = root ? [root] : [];
  while (level.length > 0) {
    levels.push(level.map((n) => n.val));
    const next: TreeNode[] = [];
    for (const n of level) {
      if (n.left) next.push(n.left);
      if (n.right) next.push(n.right);
    }
    level = next;
  }
  return levels;
}

Binary search trees

Invariant: for every node, all keys in the left subtree are smaller and all in the right subtree are larger. An inorder walk yields the keys sorted.

OperationBalancedSkewedHow
SearchO(log⁡n)O(\log n)O(n)O(n)go left if smaller, right if larger
InsertO(log⁡n)O(\log n)O(n)O(n)search, attach at the empty spot
Delete, 0 or 1 childO(log⁡n)O(\log n)O(n)O(n)replace the node with its child
Delete, 2 childrenO(log⁡n)O(\log n)O(n)O(n)copy the inorder successor (leftmost of the right subtree) in, delete it there
Min / maxO(log⁡n)O(\log n)O(n)O(n)leftmost / rightmost node
Successor / predecessorO(log⁡n)O(\log n)O(n)O(n)leftmost of the right subtree, or the nearest ancestor you went left from
All in orderO(n)O(n)O(n)O(n)inorder traversal
k-th smallestO(h+k)O(h + k)O(n)O(n)inorder, stop at the k-th

Why balance matters: inserting sorted keys into a plain BST builds a linked list, so every operation degrades to O(n)O(n). Self-balancing trees rotate on insert and delete to keep the height O(log⁡n)O(\log n):

TreeGuaranteeWhere you meet it
AVLsubtree heights differ by ≤ 1; faster lookupsin-memory indexes
Red-blacklongest path ≤ 2 × shortest; cheaper updatesJava TreeMap, C++ std::map, Linux scheduler
B-tree / B+ treewide nodes, few levels; built for disk pagesdatabase indexes (Postgres), file systems
Skip listprobabilistic O(log⁡n)O(\log n)Redis sorted sets

Neither JS nor Python ships a sorted map. In interviews use a sorted array + binary search (O(log⁡n)O(\log n) lookup, O(n)O(n) insert), a heap if you only need the min or max, or say you would use a library (sortedcontainers in Python). Implementing AVL or red-black rotations is rarely asked.

function bstSearch(n: Tree, x: number): Tree {
  while (n && n.val !== x) n = x < n.val ? n.left : n.right;
  return n;
}
 
// Returns the (possibly new) root; duplicates ignored
function bstInsert(root: Tree, x: number): TreeNode {
  if (!root) return new TreeNode(x);
  if (x < root.val) root.left = bstInsert(root.left, x);
  else if (x > root.val)
    root.right = bstInsert(root.right, x);
  return root;
}
 
// Check against bounds from all ancestors, not just
// the parent: O(n) time, O(h) space
function isValidBST(
  n: Tree,
  lo = -Infinity,
  hi = Infinity,
): boolean {
  if (!n) return true;
  if (n.val <= lo || n.val >= hi) return false;
  return isValidBST(n.left, lo, n.val) &&
    isValidBST(n.right, n.val, hi);
}

Heaps

A binary heap is a complete binary tree where every parent is ≤ its children (min-heap) or ≥ them (max-heap). The root is always the min (or max); nothing else is sorted. Because the tree is complete it lives in a plain array with no pointers.

1 3 2 7 4 5 6 1 3 2 7 4 5 6 2i+1 2i+2 i = 0 i = 1 i = 2 i = 3 i = 4 i = 5 i = 6 [0] [1] [2] [3] [4] [5] [6] children of i: 2i+1, 2i+2 parent of i: (i − 1) // 2 no pointers stored
Level order in the tree is index order in the array
Index math (0-based)Formula
Children of i2 * i + 1, 2 * i + 2
Parent of i(i - 1) >> 1 in TS, (i - 1) // 2 in Python
Leavesindexes n // 2 to n - 1
Height⌊log⁡2n⌋\lfloor \log_2 n \rfloor
OperationCostHow
Peek minO(1)O(1)a[0]
PushO(log⁡n)O(\log n)append, then sift up (swap with parent while smaller)
Pop minO(log⁡n)O(\log n)move the last item to the root, then sift down (swap with the smaller child)
Heapify an arrayO(n)O(n)sift down from n // 2 - 1 to 0; most nodes are near the bottom
Search / delete arbitraryO(n)O(n)not what heaps are for
Top kk of nnO(nlog⁡k)O(n \log k)keep a size-kk heap of the opposite kind

JavaScript has no built-in heap or priority queue. This one takes a comparator like Array.prototype.sort (negative means x comes out first), so the same class is a min-heap, a max-heap or a priority queue of objects. Other sheets link here for it.

export class MinHeap<T> {
  private readonly a: T[] = [];
  private readonly cmp: (x: T, y: T) => number;
 
  // cmp(x, y) < 0: x comes out first (like sort)
  constructor(cmp: (x: T, y: T) => number) {
    this.cmp = cmp;
  }
 
  get size(): number {
    return this.a.length;
  }
 
  peek(): T | undefined {
    return this.a[0];
  }
 
  push(x: T): void {
    const a = this.a;
    a.push(x);
    let i = a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.cmp(a[i], a[p]) >= 0) break;
      [a[i], a[p]] = [a[p], a[i]];
      i = p;
    }
  }
 
  pop(): T | undefined {
    const a = this.a;
    if (a.length === 0) return undefined;
    const top = a[0];
    const last = a.pop()!;
    if (a.length > 0) {
      a[0] = last;
      this.siftDown(0);
    }
    return top;
  }
 
  private siftDown(i: number): void {
    const a = this.a;
    for (;;) {
      const l = 2 * i + 1;
      const r = l + 1;
      let m = i;
      if (l < a.length && this.cmp(a[l], a[m]) < 0) m = l;
      if (r < a.length && this.cmp(a[r], a[m]) < 0) m = r;
      if (m === i) return;
      [a[i], a[m]] = [a[m], a[i]];
      i = m;
    }
  }
}

Max-heaps & priorities

const minH = new MinHeap<number>((x, y) => x - y);
const maxH = new MinHeap<number>((x, y) => y - x);
for (const x of [5, 1, 4]) {
  minH.push(x);
  maxH.push(x);
}
const low = minH.pop();  // 1
const high = maxH.pop(); // 5
 
// [priority, label]: lowest priority first, ties by label
type Task = [number, string];
const tasks = new MinHeap<Task>(
  (x, y) => x[0] - y[0] || x[1].localeCompare(y[1]),
);
tasks.push([2, "write"]);
tasks.push([1, "plan"]);
const first = tasks.pop(); // [1, "plan"]

Negation only works for numbers; for strings or objects in a max-heap, wrap them in a class whose __lt__ is reversed, or use the 3.14 _max functions. Heaps can't decrease a key in place: push the new entry and skip stale ones when popped ("lazy deletion", used by Dijkstra in Graph algorithms).

Tries

A trie (prefix tree) stores strings character by character; each path from the root spells a prefix, and a flag marks where a whole word ends. Lookups cost the length of the word, not the number of words.

OperationCostNotes
Insert word of length LLO(L)O(L)creates at most LL nodes
Search wordO(L)O(L)must end on a node flagged isWord
Starts with prefixO(L)O(L)any node reached is enough
All words with a prefixO(L+output)O(L + \text{output})walk to the prefix node, then DFS
SpaceO(total characters)O(\text{total characters})times the alphabet if children are fixed arrays

Children as a Map / dict keep memory proportional to the edges used; a 26-slot array is faster for lowercase-only input. Tries power autocomplete, spell check, IP routing (longest prefix match) and "Word Search II".

class TrieNode {
  children = new Map<string, TrieNode>();
  isWord = false;
}
 
export class Trie {
  private readonly root = new TrieNode();
 
  insert(word: string): void {
    let node = this.root;
    for (const ch of word) {
      let next = node.children.get(ch);
      if (!next) {
        next = new TrieNode();
        node.children.set(ch, next);
      }
      node = next;
    }
    node.isWord = true;
  }
 
  search(word: string): boolean {
    return this.find(word)?.isWord ?? false;
  }
 
  startsWith(prefix: string): boolean {
    return this.find(prefix) !== undefined;
  }
 
  private find(prefix: string): TrieNode | undefined {
    let node: TrieNode | undefined = this.root;
    for (const ch of prefix) {
      node = node.children.get(ch);
      if (!node) return undefined;
    }
    return node;
  }
}

Graph representations

A graph is vertices VV joined by edges EE. Directed edges go one way (u→vu \to v); undirected edges both ways. Weighted edges carry a cost. A graph is sparse when EE is close to VV and dense when EE approaches V2V^2.

Graph 0 1 2 3 Adjacency list 0: [1, 2] 1: [0, 2] 2: [0, 1, 3] 3: [2] Adjacency matrix 0 1 2 3 0 1 2 3 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 E: (0,1) (0,2) (1,2) (2,3) O(V + E) space neighbors of v: O(deg v) best for sparse graphs O(V²) space; edge check O(1)
One graph, two layouts: lists store only the edges; the matrix stores every pair
Edge listAdjacency listAdjacency matrix
Shape[u, v][]number[][] / Map<K, K[]>V × V booleans or weights
SpaceO(E)O(E)O(V+E)O(V + E)O(V2)O(V^2)
Is u–v an edge?O(E)O(E)O(deg⁡u)O(\deg u) (or O(1)O(1) with sets)O(1)O(1)
Neighbors of uO(E)O(E)O(deg⁡u)O(\deg u)O(V)O(V)
Visit every edgeO(E)O(E)O(V+E)O(V + E)O(V2)O(V^2)
Best forinput format, Kruskal's MSTalmost everything: BFS, DFS, Dijkstradense graphs, Floyd–Warshall
VariantAdjacency list entry
Undirectedadd v to u and u to v
Directedadd v to u only; keep reverse edges too if you need in-degrees or predecessors
Weightedstore [v, w] pairs
Labeled nodes (strings)Map<string, string[]> / defaultdict(list)
Gridimplicit: neighbors are the 4 (or 8) adjacent cells, no list needed

type Edge = [number, number];
type WEdge = [number, number, number]; // u, v, weight
 
// Nodes 0..n-1: O(V + E) time and space
function adjList(
  n: number,
  edges: Edge[],
  directed = false,
): number[][] {
  const g: number[][] = Array.from({ length: n }, () => []);
  for (const [u, v] of edges) {
    g[u].push(v);
    if (!directed) g[v].push(u);
  }
  return g;
}
 
// Weighted, directed: g[u] holds [v, w] pairs
function weightedAdj(n: number, edges: WEdge[]) {
  const g: [number, number][][] =
    Array.from({ length: n }, () => []);
  for (const [u, v, w] of edges) g[u].push([v, w]);
  return g;
}
 
// Grid as an implicit graph: in-bounds 4-neighbors
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
function neighbors(
  r: number, c: number, rows: number, cols: number,
): [number, number][] {
  const out: [number, number][] = [];
  for (const [dr, dc] of DIRS) {
    const nr = r + dr;
    const nc = c + dc;
    if (nr >= 0 && nr < rows && nc >= 0 && nc < cols)
      out.push([nr, nc]);
  }
  return out;
}

Traversing these (BFS, DFS, cycle detection, topological sort, shortest paths) is covered in Graph algorithms.

Union-find

Union-find (disjoint set union, DSU) tracks which items are in the same group while groups merge. Each group is a tree stored in a parent array; the root names the group.

OperationNaiveWith both optimizations
find(x): root of x's groupO(n)O(n)O(α(n))O(\alpha(n)) amortized
union(a, b): merge two groupsO(n)O(n)O(α(n))O(\alpha(n)) amortized
Same group? find(a) === find(b)O(n)O(n)O(α(n))O(\alpha(n)) amortized
SpaceO(n)O(n)O(n)O(n)
  • Path compression: after find, point every node on the path straight at the root.
  • Union by size (or rank): hang the smaller tree under the larger, keeping trees shallow.
  • α(n)\alpha(n) is the inverse Ackermann function: at most 4 for any realistic nn, so effectively O(1)O(1).

Uses: connected components as edges arrive, cycle detection in undirected graphs (an edge whose ends are already joined), Kruskal's MST, grouping equal accounts or equations.

export class UnionFind {
  private readonly parent: number[];
  private readonly size: number[];
  count: number; // number of groups
 
  constructor(n: number) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.size = new Array<number>(n).fill(1);
    this.count = n;
  }
 
  find(x: number): number {
    let root = x;
    while (this.parent[root] !== root)
      root = this.parent[root];
    while (this.parent[x] !== root) { // path compression
      const next = this.parent[x];
      this.parent[x] = root;
      x = next;
    }
    return root;
  }
 
  // false if a and b were already in one group
  union(a: number, b: number): boolean {
    let ra = this.find(a);
    let rb = this.find(b);
    if (ra === rb) return false;
    if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
    this.parent[rb] = ra; // smaller under larger
    this.size[ra] += this.size[rb];
    this.count--;
    return true;
  }
}

Choosing a structure

Problem saysReach forCost
Hierarchy, nesting, "each node has children"tree + DFSO(n)O(n)
Sorted order with inserts and deletesbalanced BST (or sorted list + bisect)O(log⁡n)O(\log n)
Repeatedly take the smallest / largestheapO(log⁡n)O(\log n) per op
k largest, k closest, median of a streamheap(s) of size kk or two heapsO(nlog⁡k)O(n \log k)
Merge k sorted inputsheap of the k headsO(nlog⁡k)O(n \log k)
Prefixes, autocomplete, many words on a boardtrieO(L)O(L) per word
Relationships, dependencies, networks, gridsgraph (adjacency list)O(V+E)O(V + E) to traverse
"Are these connected?" as edges arriveunion-findO(α(n))O(\alpha(n))
Shortest path, ordering, cyclesGraph algorithms

Recipes

K-th smallest with a heap

Use for "Kth Largest Element in an Array" and its mirror: keep the kk best seen so far in a heap whose top is the worst of them. O(nlog⁡k)O(n \log k) time, O(k)O(k) space (quickselect averages O(n)O(n), see Sorting & searching).

// Max-heap of the k smallest; its top is the answer
function kthSmallest(xs: number[], k: number): number {
  const heap = new MinHeap<number>((x, y) => y - x);
  for (const x of xs) {
    heap.push(x);
    if (heap.size > k) heap.pop(); // drop the largest
  }
  return heap.peek()!;
}

Serialize a binary tree

Use for "Serialize and Deserialize Binary Tree": preorder with a marker for empty children is enough to rebuild the exact shape. O(n)O(n) time and space both ways.

function serialize(root: Tree): string {
  const out: string[] = [];
  const walk = (n: Tree): void => {
    if (!n) {
      out.push("#");
      return;
    }
    out.push(String(n.val));
    walk(n.left);
    walk(n.right);
  };
  walk(root);
  return out.join(",");
}
 
function deserialize(s: string): Tree {
  const tokens = s.split(",");
  let i = 0;
  const build = (): Tree => {
    const t = tokens[i++];
    if (t === "#") return null;
    const node = new TreeNode(Number(t));
    node.left = build();
    node.right = build();
    return node;
  };
  return build();
}

Word search with a trie

Use for "Word Search II": put all words in a trie, then DFS from every cell, following trie edges and abandoning a path as soon as no word has that prefix. Time O(R⋅C⋅3L)O(R \cdot C \cdot 3^{L}) worst case for longest word LL; space O(total characters)O(\text{total characters}).

type Node = { kids: Map<string, Node>; word?: string };
 
function findWords(
  board: string[][],
  words: string[],
): string[] {
  const root: Node = { kids: new Map() };
  for (const w of words) {
    let n = root;
    for (const ch of w) {
      let next = n.kids.get(ch);
      if (!next) {
        next = { kids: new Map() };
        n.kids.set(ch, next);
      }
      n = next;
    }
    n.word = w;
  }
  const R = board.length;
  const C = board[0].length;
  const found: string[] = [];
  const dfs = (r: number, c: number, parent: Node) => {
    if (r < 0 || r >= R || c < 0 || c >= C) return;
    const ch = board[r][c];
    const node = parent.kids.get(ch);
    if (!node) return; // no word has this prefix
    if (node.word !== undefined) {
      found.push(node.word);
      node.word = undefined; // report once
    }
    board[r][c] = "#"; // mark visited
    dfs(r + 1, c, node);
    dfs(r - 1, c, node);
    dfs(r, c + 1, node);
    dfs(r, c - 1, node);
    board[r][c] = ch; // restore
  };
  for (let r = 0; r < R; r++)
    for (let c = 0; c < C; c++) dfs(r, c, root);
  return found;
}

Pruning helps a lot in practice: delete a trie child once its subtree has no words left.

Count connected components

Use for "Number of Connected Components in an Undirected Graph", "Number of Provinces" and "Redundant Connection": union every edge; the group count is the answer, and a failed union means the edge closes a cycle. O(V+E⋅α(V))O(V + E \cdot \alpha(V)) time, O(V)O(V) space.

function components(
  n: number,
  edges: [number, number][],
): { count: number; cycleEdge: [number, number] | null } {
  const uf = new UnionFind(n);
  let cycleEdge: [number, number] | null = null;
  for (const [a, b] of edges) {
    if (!uf.union(a, b)) cycleEdge ??= [a, b];
  }
  return { count: uf.count, cycleEdge };
}

Merge k sorted lists

Use for "Merge k Sorted Lists" (and k-way merges of files or streams): a heap holds the current head of each list. O(nlog⁡k)O(n \log k) time for nn items in total, O(k)O(k) extra space.

// Heap entries: [value, which list, index in that list]
type Head = [number, number, number];
 
function mergeK(lists: number[][]): number[] {
  const heap = new MinHeap<Head>((x, y) => x[0] - y[0]);
  lists.forEach((xs, li) => {
    if (xs.length > 0) heap.push([xs[0], li, 0]);
  });
  const out: number[] = [];
  while (heap.size > 0) {
    const [v, li, i] = heap.pop()!;
    out.push(v);
    const xs = lists[li];
    if (i + 1 < xs.length)
      heap.push([xs[i + 1], li, i + 1]);
  }
  return out;
}

References