Skip to content
← All posts

Vertical Order Traversal of a Binary Tree: Coordinate Sorting

7 min read
leetcodeproblemhardtreeshash-mapsorting

Given the root of a binary tree, return the vertical order traversal of its nodes' values. For each node at position (row, col), its left child is at (row + 1, col - 1) and its right child is at (row + 1, col + 1). The root starts at (0, 0). Return the traversal as a list of lists, from the leftmost column to the rightmost. Within a column, nodes are ordered by row first, then by value if they share the same row.

This is LeetCode 987: Vertical Order Traversal of a Binary Tree.

For example, given root = [3, 9, 20, null, null, 15, 7], the output is [[9], [3, 15], [20], [7]]. Node 3 and node 15 share column 0, but node 3 is in row 0 and node 15 is in row 2, so 3 comes first.

col -1col 0col 1col 23920157(0,0)(1,-1)(1,1)(2,0)(2,2)Output: [[9], [3, 15], [20], [7]]
Each node gets a (row, col) coordinate. Nodes in the same column are grouped together, sorted by row then value.

Why this problem matters

Vertical order traversal combines tree traversal with coordinate geometry and sorting. You need three skills working together: traversing the tree to assign coordinates, sorting those coordinates by a multi-key rule, and grouping results by column. Each skill on its own is basic. The challenge is wiring them together cleanly.

This problem also shows up as a harder variant of LeetCode 314: Binary Tree Vertical Order Traversal. The difference is the tiebreaker. In 314, nodes at the same row and column keep their left-to-right BFS order. In 987, they are sorted by value. That one difference pushes you toward a sort-based approach instead of pure BFS.

Brute force

def vertical_traversal(root):
    coords = []

    def dfs(node, row, col):
        if not node:
            return
        coords.append((col, row, node.val))
        dfs(node.left, row + 1, col - 1)
        dfs(node.right, row + 1, col + 1)

    dfs(root, 0, 0)
    coords.sort()
    result = []
    prev_col = None
    for col, row, val in coords:
        if col != prev_col:
            result.append([])
            prev_col = col
        result[-1].append(val)
    return result

This is already the optimal approach. The brute force would be to collect all nodes, then for each unique column iterate through the entire list to extract that column's nodes. That wastes time re-scanning. But the DFS-plus-sort version above does everything in one pass plus a sort. For a tree problem, there is no way to avoid visiting every node, so the real question is how efficiently you process the coordinates after collecting them.

The key insight

Think of the tree as a 2D coordinate system. The root sits at (0, 0). Every left move shifts the column by -1 and the row by +1. Every right move shifts the column by +1 and the row by +1. Once you label every node with its (row, col), the problem reduces to sorting tuples.

The sort key is (col, row, value). Sorting by column first groups nodes into their vertical slices. Within the same column, sorting by row puts higher nodes before lower ones. And if two nodes share the same column and row, sorting by value breaks the tie. After sorting, you just group consecutive entries with the same column into sublists.

The triple sort key (col, row, value) is the heart of this problem. If you remember nothing else, remember that you are sorting by column first, row second, and value third. Everything else is just collecting coordinates and grouping the sorted result.

Walking through it

Let's trace through the tree [3, 9, 20, null, null, 15, 7]. Watch how DFS assigns coordinates and how sorting produces the final grouping.

Step 1: Visit root (3). Assign coordinate (0, 0).

3920157

Coords: [(0, 0, 3)]

Result: []

Start DFS at the root. Record its row, column, and value as a tuple.

Step 2: Visit left child (9). Coordinate (1, -1).

3920157

Coords: [(0, 0, 3), (1, -1, 9)]

Result: []

Go left: row increases by 1, col decreases by 1. Node 9 has no children.

Step 3: Visit right child (20). Coordinate (1, 1).

3920157

Coords: [(0, 0, 3), (1, -1, 9), (1, 1, 20)]

Result: []

Go right: row increases by 1, col increases by 1. Node 20 has two children.

Step 4: Visit 20's left child (15). Coordinate (2, 0).

3920157

Coords: [(0, 0, 3), (1, -1, 9), (1, 1, 20), (2, 0, 15)]

Result: []

From node 20 at col 1, go left: col becomes 0. Node 15 lands in the same column as root.

Step 5: Visit 20's right child (7). Coordinate (2, 2).

3920157

Coords: [(0, 0, 3), (1, -1, 9), (1, 1, 20), (2, 0, 15), (2, 2, 7)]

Result: []

From node 20 at col 1, go right: col becomes 2. All nodes are now visited.

Step 6: Sort by (col, row, value). Group by column.

3920157

Coords: [(0, 0, 3), (1, -1, 9), (1, 1, 20), (2, 0, 15), (2, 2, 7)]

Sorted: [(-1, 1, 9), (0, 0, 3), (0, 2, 15), (1, 1, 20), (2, 2, 7)]

Result: [[9], [3, 15], [20], [7]]

Sort tuples by (col, row, value). Group consecutive entries with the same col into sublists.

DFS visits every node and stamps it with a (col, row, value) tuple. After all nodes are visited, sorting by (col, row, value) puts them in the right order. Grouping consecutive entries by column produces the final answer: [[9], [3, 15], [20], [7]].

Notice that node 3 at (0, 0) and node 15 at (0, 2) share column 0. Since row 0 comes before row 2, node 3 appears first in that column's list. If two nodes shared the same row and column, the smaller value would come first.

Solution

from collections import defaultdict

def vertical_traversal(root):
    coords = []

    def dfs(node, row, col):
        if not node:
            return
        coords.append((col, row, node.val))
        dfs(node.left, row + 1, col - 1)
        dfs(node.right, row + 1, col + 1)

    dfs(root, 0, 0)
    coords.sort()
    result = []
    prev_col = None
    for col, row, val in coords:
        if col != prev_col:
            result.append([])
            prev_col = col
        result[-1].append(val)
    return result

Here is what each part does:

  • coords = [] collects (col, row, value) triples for every node in the tree.
  • dfs(node, row, col) recurses through the tree. Left children get col - 1, right children get col + 1, and both get row + 1.
  • coords.sort() sorts the triples. Python's tuple sort compares element by element: column first, then row, then value. This single sort handles all three tiebreaking rules at once.
  • The grouping loop scans through the sorted coordinates. Whenever the column changes, it starts a new sublist. Since the list is sorted by column, all entries for the same column are consecutive.

Complexity analysis

MetricValueWhy
TimeO(n log n)DFS visits n nodes in O(n). Sorting n tuples costs O(n log n). Grouping is O(n).
SpaceO(n)The coords list stores one tuple per node. The recursion stack uses O(h) where h is tree height.

The sorting step dominates the time complexity. You cannot avoid it because the problem requires ordering by value when two nodes share the same row and column. If the tiebreaker were just insertion order (as in LeetCode 314), you could use BFS and skip the sort entirely.

Building blocks

This problem is built on two reusable patterns:

1. DFS with coordinate tracking. Pass (row, col) as parameters through the recursion. At each node, record the coordinates and recurse with updated values. This pattern appears whenever you need to assign spatial positions to tree nodes:

def dfs(node, row, col):
    if not node:
        return
    record(row, col, node.val)
    dfs(node.left, row + 1, col - 1)
    dfs(node.right, row + 1, col + 1)

2. Multi-key sort and group. Collect items as tuples with the sort key built in, sort once, then scan linearly to group by the primary key. This is a general technique that works whenever you need to partition sorted data into buckets:

items.sort()
groups = []
prev_key = None
for key, *rest in items:
    if key != prev_key:
        groups.append([])
        prev_key = key
    groups[-1].append(rest)

Python's tuple comparison handles multi-key sorting for free. By constructing tuples as (col, row, value), a single sort() call applies all three sort keys in the right priority order. No custom comparator needed.

Edge cases

  • Empty tree (root is None): return an empty list []. The DFS never runs, and coords stays empty.
  • Single node (no children): return [[root.val]]. The only coordinate is (0, 0, root.val).
  • Same row and column, different values: two nodes can land on the same (row, col) if the tree branches and reconverges. The sort by value handles this: smaller values come first within the same position.
  • Left-skewed tree: every node shifts one column to the left. Each column has exactly one node, so the output is a list of single-element lists.
  • Complete binary tree: the column range spans from -depth to +depth. Nodes at opposite edges of the tree can share columns with nodes deeper in the tree.

Common mistakes

  • Forgetting the value tiebreaker. LeetCode 314 does not sort by value when two nodes share the same row and column. LeetCode 987 does. If you confuse the two, you will pass some test cases but fail on inputs where nodes overlap at the same position.
  • Using BFS order as tiebreaker. BFS naturally visits nodes left to right, which works for 314 but not 987. For 987 you must sort by value, not by insertion order.
  • Sorting by (row, col, value) instead of (col, row, value). Column must be the primary sort key because the output groups by column. Sorting by row first would interleave columns.

From understanding to recall

You just traced through a DFS that assigns coordinates and a sort that produces the final grouping. The solution is clean: collect tuples, sort, group. It makes sense right now. But in an interview, will you remember that the sort key is (col, row, value) and not (row, col, value)? Will you remember to group by column after sorting rather than using a hash map?

Understanding a solution is not the same as producing it under pressure. Spaced repetition bridges that gap. You practice writing the DFS coordinate assignment and the sort-and-group loop from memory at increasing intervals. First after a day, then three days, then a week. Each repetition reinforces the tuple structure and the grouping logic until the pattern is automatic.

DFS with coordinate tracking and multi-key sort-and-group are two of roughly 60 reusable building blocks that cover hundreds of LeetCode problems. Drilling them individually with spaced repetition is far more effective than grinding random problems and hoping the patterns stick.

Related posts

CodeBricks helps you internalize these tree traversal patterns so they become automatic. Instead of re-deriving the coordinate system and sort key from scratch every time, you build muscle memory through targeted repetition. The DFS-with-coordinates pattern and the sort-and-group pattern each get their own drill, so you can practice them independently and combine them when this problem comes up.