Part V · Aggregates, Stacks and Graphs Pattern 15 3 problems

Topological Sort

Order a set of tasks so each task comes after the things it depends on. The same code also tells you when no order exists.

“X must happen before Y” describes a directed graph. The question is almost always one of two. Give me a valid order. Or is a valid order even possible? About twenty lines of C# answer both. The second answer falls out of the first for free.

Contents

  1. When to use
  2. Core idea
  3. Cycle detection for free
  4. The templates
  5. Common mistakes
  6. Course Schedule
  7. Course Schedule II
  8. Alien Dictionary
  9. Recap

When to use

The trigger. You have pairwise ordering constraints over a set of items. You need one global order that respects all of them. The graph must be directed. For an answer to exist, it must also be acyclic. Undirected graphs have no topological order.
The answer is usually not unique. Two items with no path between them may appear in either order. Ask whether the problem needs a specific tie-break. If it wants the lexicographically smallest order, swap the queue for a min-heap. In C# that is PriorityQueue<int, int>. Everything else stays the same.

Core idea

Kahn’s algorithm. Count how many prerequisites each item has. That count is its in-degree. Items with in-degree zero are ready now, so put them all in a queue. Take one out and add it to the order. Then decrement the in-degree of everything it unlocks. When a count hits zero, that item is ready and joins the queue.
0 1 2 3 4 5 in 0in 0 in 1in 1 in 2in 1 rounds ready now: 0, 1 then: 2, 3 then: 4 then: 5 6 nodes emitted, so no cycle
Figure 15.1 — Kahn’s algorithm peels the graph one ready layer at a time. Colour groups are the BFS rounds.

Reading the figure. Each arrow points from a prerequisite to the node it unlocks. The small grey label is the starting in-degree. Nodes 0 and 1 start at zero, so they go first (green). Each colour after that is one more round of nodes whose count just hit zero.

The edge direction question

Get the arrow right before you write anything. Say a pair [a, b] means “to take a, first take b”. That is an edge from b to a. It raises the in-degree of a. Reversing this is the most common way to fail these problems. The reversed code still gives a plausible answer on symmetric tests. Read the statement twice. Write one example edge on the board.

Cycle detection for free

Count how many nodes come out. Say the algorithm emits fewer nodes than the graph has. The missing ones are exactly those trapped in a cycle. Each waits on another, so no count in that group reaches zero. None of them ever enters the queue. So order.Count == n is a full cycle test. It costs one comparison.

So “can you finish all courses” and “give me the order” are the same method. Only the return statement differs.

Kahn or DFS?

The templates

C# has no built-in deque. Kahn only needs a plain FIFO, so Queue<T> is the right tool. Edges are passed as named tuples (int Before, int After)[]. A test can then write [(0, 1), (1, 2)] as a collection expression.

Template A — Kahn’s algorithm
public static class TopoKahn
{
    /// <summary>Orders nodes 0..n-1 so every edge (Before, After) is respected.</summary>
    /// <param name="n">Number of nodes, labelled 0 to n - 1.</param>
    /// <param name="edges">Pairs where Before must come ahead of After.</param>
    /// <returns>A valid order, or an empty list if the graph has a cycle.</returns>
    /// <example>TopologicalOrder(3, [(0, 1), (1, 2)]) returns [0, 1, 2].</example>
    public static List<int> TopologicalOrder(int n, (int Before, int After)[] edges)
    {
        // One empty successor list per node. i runs over labels 0..n-1.
        var successors = new List<int>[n];
        for (int i = 0; i < n; i++) successors[i] = [];
        // indegree[node] = prerequisites not placed yet. new int[n] starts every count at 0.
        var indegree = new int[n];

        foreach (var (before, after) in edges)
        {
            successors[before].Add(after);
            indegree[after]++;                      // +1: after waits on one more node
        }

        // Seed with every node at indegree 0: nothing blocks them.
        var queue = new Queue<int>();
        for (int node = 0; node < n; node++)        // node: label being tested, 0..n-1
            if (indegree[node] == 0) queue.Enqueue(node);
        var order = new List<int>(n);               // n: the final size when there is no cycle

        // Invariant: every node in order, or in the queue, has all its
        // prerequisites already placed in order.
        while (queue.Count > 0)                     // 0: an empty queue means nothing is ready
        {
            int node = queue.Dequeue();
            order.Add(node);

            // Placing node clears one prerequisite for each successor.
            foreach (int next in successors[node])
            {
                indegree[next]--;                   // -1: one fewer prerequisite left
                if (indegree[next] == 0)            // 0: its last prerequisite just cleared
                    queue.Enqueue(next);
            }
        }

        // Fewer than n placed means some nodes never reached 0: a cycle.
        return order.Count == n ? order : [];
    }
}

Decrement, then test, in that order. Test for exactly zero. Testing <= 0 would enqueue a node twice on a multi-edge.

Template B — DFS with three colours
/// <summary>Node states for the DFS template.</summary>
public enum Colour
{
    White,   // 0, the default: not visited yet
    Grey,    // on the current DFS path
    Black,   // finished, all descendants done
}

public static class TopoDfs
{
    /// <summary>Same result as Kahn, using post-order DFS. Grey means a cycle.</summary>
    /// <param name="n">Number of nodes, labelled 0 to n - 1.</param>
    /// <param name="edges">Pairs where Before must come ahead of After.</param>
    /// <returns>A valid order, or an empty list if the graph has a cycle.</returns>
    /// <example>TopologicalOrderDfs(3, [(0, 1), (1, 2)]) returns [0, 1, 2].</example>
    public static List<int> TopologicalOrderDfs(int n, (int Before, int After)[] edges)
    {
        // One empty successor list per node. i runs over labels 0..n-1.
        var successors = new List<int>[n];
        for (int i = 0; i < n; i++) successors[i] = [];
        foreach (var (before, after) in edges) successors[before].Add(after);

        // new Colour[n] fills with White, the enum's 0 value: every node unvisited.
        var colour = new Colour[n];
        var order = new List<int>(n);               // n: the final size when there is no cycle

        // Returns false if a cycle is reachable from node, else true.
        bool Visit(int node)
        {
            if (colour[node] == Colour.Grey) return false;    // back edge: a cycle
            if (colour[node] == Colour.Black) return true;    // already finished

            colour[node] = Colour.Grey;             // node is now on the current path
            foreach (int next in successors[node])
                if (!Visit(next)) return false;     // pass the cycle signal up
            colour[node] = Colour.Black;            // all descendants done, leave the path

            order.Add(node);                        // post-order: after all descendants
            return true;
        }

        // Start a DFS from every node so disconnected parts are covered.
        for (int node = 0; node < n; node++)        // node: label 0..n-1
            if (!Visit(node)) return [];            // empty list: a cycle, no valid order

        order.Reverse();                            // post-order is the reverse topo order
        return order;
    }
}

Grey means “on the current path”. Reaching a grey node means the path loops back on itself. Black means “done, and safe to skip”. Two colours are not enough. They cannot tell those two cases apart. A local function keeps colour and order in scope without extra parameters.

no cycle: DFS is deep in the path 0 → 1 → 2 0 1 2 3 black: done grey: on the path white order so far: [2] 2 finished first, so the list is reversed at the end cycle: 0 → 1 → 2 → 0 0 1 2 back edge Visit(2) reaches 0, which is still grey, so the path loops: return false
Figure 15.2 — A grey node seen again means the current path has looped back on itself.

Reading the figure. Amber nodes are grey, which means they are on the current DFS path. Green is black, meaning the node and all its descendants are done. Neutral is white, not visited yet. On the left, node 2 finishes first, so post-order lists it first. On the right, the red edge from 2 points at a grey node. That back edge is the cycle signal.

Template C — lexicographically smallest order
public static class TopoSmallest
{
    /// <summary>Kahn's algorithm with a min-heap instead of a FIFO queue.</summary>
    /// <param name="n">Number of nodes, labelled 0 to n - 1.</param>
    /// <param name="edges">Pairs where Before must come ahead of After.</param>
    /// <returns>The smallest valid order, or an empty list on a cycle.</returns>
    /// <example>SmallestTopologicalOrder(4, [(0, 3), (1, 2)]) returns [0, 1, 2, 3].</example>
    public static List<int> SmallestTopologicalOrder(int n, (int Before, int After)[] edges)
    {
        // One empty successor list per node. i runs over labels 0..n-1.
        var successors = new List<int>[n];
        for (int i = 0; i < n; i++) successors[i] = [];
        var indegree = new int[n];                  // new int[n]: every count starts at 0

        foreach (var (before, after) in edges)
        {
            successors[before].Add(after);
            indegree[after]++;                      // +1: after waits on one more node
        }

        // PriorityQueue is a min-heap. The node label is also its priority.
        var ready = new PriorityQueue<int, int>();
        for (int node = 0; node < n; node++)        // node: label being tested, 0..n-1
            if (indegree[node] == 0) ready.Enqueue(node, node);
        var order = new List<int>(n);               // n: the final size when there is no cycle

        // Invariant: ready holds exactly the unplaced nodes with no blockers left.
        while (ready.Count > 0)                     // 0: nothing is ready
        {
            int node = ready.Dequeue();             // always the smallest available node
            order.Add(node);

            foreach (int next in successors[node])
            {
                indegree[next]--;                   // -1: one fewer prerequisite left
                if (indegree[next] == 0)            // 0: nothing blocks it now
                    ready.Enqueue(next, next);
            }
        }

        // Fewer than n placed means a cycle blocked the rest.
        return order.Count == n ? order : [];
    }
}

One data-structure swap turns any valid order into the smallest one. Cost rises from O(V + E) to O(V log V + E). Each node enters the heap once, so the missing decrease-key in PriorityQueue does not matter here.

0 3 1 2 edges 0 → 3 and 1 → 2 Queue<int> 0 1 3 2 order (FIFO) PriorityQueue 0 1 2 3 order (min-heap) After 0 and 1 leave, 3 and 2 are both ready. The queue takes 3 first. The heap takes 2.
Figure 15.3 — Swapping the queue for a min-heap picks the smallest ready node every time.

Reading the figure. Both runs start with 0 and 1 ready, so the gray cells match. Then 3 and 2 become ready in that order. Queue<int> hands them out in arrival order (amber). PriorityQueue<int, int> hands out the smallest first (green). Both orders are valid. Only the heap gives the smallest one.

Common mistakes

The problems

1. Course Schedule Medium

Problem

There are numCourses courses labelled 0 to numCourses - 1. Each pair [a, b] means you must take course b before course a. Return true if you can finish all courses.

Approach

Solution

public static class CourseSchedule
{
    /// <summary>True if every course can be taken, given the prerequisite pairs.</summary>
    /// <param name="numCourses">Courses are labelled 0 to numCourses - 1.</param>
    /// <param name="prerequisites">Pairs [a, b] meaning b must be taken before a.</param>
    /// <returns>True if the prerequisite graph is acyclic.</returns>
    /// <example>CanFinish(2, [[1, 0]]) returns true. CanFinish(2, [[1, 0], [0, 1]]) returns
    /// false.</example>
    public static bool CanFinish(int numCourses, int[][] prerequisites)
    {
        // unlocks[c] = courses that list c as a prerequisite. i runs over labels 0..n-1.
        var unlocks = new List<int>[numCourses];
        for (int i = 0; i < numCourses; i++) unlocks[i] = [];
        var indegree = new int[numCourses];         // new int[n]: every count starts at 0

        foreach (int[] pair in prerequisites)
        {
            // [0] is the course, [1] is the course it needs first.
            int course = pair[0], needs = pair[1];
            // Edge runs from the prerequisite to the course it unlocks.
            unlocks[needs].Add(course);
            indegree[course]++;                     // +1: course waits on one more
        }

        // Start with every course that has 0 prerequisites.
        var queue = new Queue<int>();
        for (int c = 0; c < numCourses; c++)        // c: course label being tested
            if (indegree[c] == 0) queue.Enqueue(c);
        int taken = 0;                              // 0: no courses taken yet

        // Invariant: taken counts courses whose prerequisites were all taken first.
        while (queue.Count > 0)                     // 0: nothing is ready
        {
            int course = queue.Dequeue();
            taken++;                                // +1: one more course done

            foreach (int next in unlocks[course])
            {
                indegree[next]--;                   // -1: one prerequisite of next is done
                if (indegree[next] == 0)            // 0: next is ready to take
                    queue.Enqueue(next);
            }
        }

        // Anything never emitted is stuck waiting inside a cycle.
        return taken == numCourses;
    }
}

Walkthrough

Take numCourses = 2 and prerequisites = [[1, 0], [0, 1]]. Course 1 needs 0, and course 0 needs 1. Both in-degrees are 1. So the first queue is empty and the loop never runs. taken stays 0. Since 0 != 2, the answer is false. No explicit cycle-finding code was needed.

0 1 in 1 in 1 1 needs 0 0 needs 1 no in-degree is 0 start queue: [ ] the loop never runs taken = 0, and 0 != 2 return false
Figure 15.4 — Nodes in a cycle never reach in-degree zero, so they never enter the queue.

Reading the figure. Each arrow points from a prerequisite to the course it unlocks. Both nodes are red because each waits on the other. Neither count can drop to 0. So the queue starts empty and the count of taken courses stays below 2.

TimeO(V + E)SpaceO(V + E)

V is numCourses and E is the number of prerequisite pairs. Every node is queued once. Every edge is relaxed once.

The free follow-up: minimum number of semesters

public static class CourseSemesters
{
    /// <summary>Fewest terms to finish everything, taking any ready courses per term.</summary>
    /// <param name="numCourses">Courses are labelled 0 to numCourses - 1.</param>
    /// <param name="prerequisites">Pairs [a, b] meaning b must be taken before a.</param>
    /// <returns>The number of semesters, or -1 if a cycle blocks some course.</returns>
    /// <example>MinimumSemesters(3, [[1, 0], [2, 1]]) returns 3.</example>
    public static int MinimumSemesters(int numCourses, int[][] prerequisites)
    {
        // unlocks[c] = courses that list c as a prerequisite. i runs over labels 0..n-1.
        var unlocks = new List<int>[numCourses];
        for (int i = 0; i < numCourses; i++) unlocks[i] = [];
        var indegree = new int[numCourses];         // new int[n]: every count starts at 0

        foreach (int[] pair in prerequisites)
        {
            // [0] is the course, [1] is the course it needs first.
            unlocks[pair[1]].Add(pair[0]);
            indegree[pair[0]]++;                    // +1: course waits on one more
        }

        // The first semester is every course with 0 prerequisites.
        var queue = new Queue<int>();
        for (int c = 0; c < numCourses; c++)        // c: course label being tested
            if (indegree[c] == 0) queue.Enqueue(c);
        int taken = 0;                              // 0: no courses taken yet
        int semesters = 0;                          // 0: no terms used yet

        // Invariant: at the top of each pass, the queue holds exactly the
        // courses that can be taken this semester.
        while (queue.Count > 0)                     // 0: nothing is ready
        {
            // Snapshot the count first. The queue grows while we drain it.
            int size = queue.Count;
            for (int k = 0; k < size; k++)          // k: courses taken so far this term
            {
                int course = queue.Dequeue();
                taken++;                            // +1: one more course done
                foreach (int next in unlocks[course])
                {
                    indegree[next]--;               // -1: one prerequisite of next is done
                    if (indegree[next] == 0)        // 0: ready, so it joins next semester
                        queue.Enqueue(next);
                }
            }

            semesters++;                            // +1: that semester is over
        }

        // -1: some courses sit in a cycle and can never be taken.
        return taken == numCourses ? semesters : -1;
    }
}

Read queue.Count into size once, before the inner loop. Writing k < queue.Count in the loop header would re-read a growing count. It would merge semesters. This is the level-size trick from Pattern 7, unchanged. The layers of a topological sort are BFS rounds.

Edge cases to raise

Say this out loud: “Can I finish everything is the same question as is the graph acyclic. I run Kahn and compare the number of courses emitted against the total. That comparison is the cycle test.”

2. Course Schedule II Medium

Problem

Same input, but return an actual order in which all courses can be taken. Return an empty list if no order exists.

Approach

Solution

public static class CourseScheduleII
{
    /// <summary>An order in which all courses can be taken, or empty if impossible.</summary>
    /// <param name="numCourses">Courses are labelled 0 to numCourses - 1.</param>
    /// <param name="prerequisites">Pairs [a, b] meaning b must be taken before a.</param>
    /// <returns>A valid ordering, or an empty list when the graph has a cycle.</returns>
    /// <example>FindOrder(4, [[1, 0], [2, 0], [3, 1], [3, 2]]) returns [0, 1, 2, 3].</example>
    public static List<int> FindOrder(int numCourses, int[][] prerequisites)
    {
        // unlocks[c] = courses that list c as a prerequisite. i runs over labels 0..n-1.
        var unlocks = new List<int>[numCourses];
        for (int i = 0; i < numCourses; i++) unlocks[i] = [];
        var indegree = new int[numCourses];         // new int[n]: every count starts at 0

        foreach (int[] pair in prerequisites)
        {
            // [0] is the course, [1] is the course it needs first.
            int course = pair[0], needs = pair[1];
            unlocks[needs].Add(course);             // edge: prerequisite -> course
            indegree[course]++;                     // +1: course waits on one more
        }

        // Start with every course that has 0 prerequisites.
        var queue = new Queue<int>();
        for (int c = 0; c < numCourses; c++)        // c: course label being tested
            if (indegree[c] == 0) queue.Enqueue(c);
        var order = new List<int>(numCourses);      // capacity: the full size if no cycle

        // Invariant: every course in order came after all its prerequisites.
        while (queue.Count > 0)                     // 0: nothing is ready
        {
            int course = queue.Dequeue();
            order.Add(course);

            foreach (int next in unlocks[course])
            {
                indegree[next]--;                   // -1: one prerequisite of next is done
                if (indegree[next] == 0)            // 0: next is ready to take
                    queue.Enqueue(next);
            }
        }

        // A short order means some courses never became available: a cycle.
        return order.Count == numCourses ? order : [];
    }
}

Walkthrough

Take numCourses = 4 and pairs [[1,0],[2,0],[3,1],[3,2]]. Course 0 unlocks 1 and 2. Both of those unlock 3.

The result is [0, 1, 2, 3]. [0, 2, 1, 3] is equally valid. Which one you get depends only on the order inside the queue.

start 0 1 2 3 in [0, 1, 1, 2] queue [0] order [] emit 0 0 1 2 3 in [0, 0, 0, 2] queue [1, 2] order [0] emit 1 0 1 2 3 in [0, 0, 0, 1] queue [2] order [0, 1] emit 2 0 1 2 3 in [0, 0, 0, 0] queue [3] order [0, 1, 2] emit 3 0 1 2 3 in done queue [] order [0, 1, 2, 3]
Figure 15.5 — Course 3 waits until both of its prerequisites are emitted, then joins the queue.

Reading the figure. Each box is one step. Green nodes are already in the order. Amber nodes are in the queue. Gray nodes still wait on a prerequisite. The text under each graph shows the in-degree list, the queue, and the order so far. Node 3 needs two decrements. So it turns amber only after both 1 and 2 are emitted.

TimeO(V + E)SpaceO(V + E)

Why the result is correct, not just plausible

Invariant. When a course is added to order, all its prerequisites are already there. Why? The course entered the queue only when its in-degree reached zero. Each decrement came from a prerequisite at the moment it was emitted. Induction on the emissions finishes the proof. Stating this in two sentences shows you understand the code, not just remember it.

Edge cases to raise

Say this out loud: “A course is emitted only after its last prerequisite is emitted, so the order is correct by construction. And a short output list means the rest are stuck in a cycle.”

3. Alien Dictionary Hard

Problem

You get a list of words in an alien language. They are sorted by that language’s letter order. Recover one possible ordering of its letters. Return "" if the input is inconsistent.

The reduction

Compare each pair of adjacent words. Walk them together until the characters differ. That first difference is all the pair tells you. The character in the earlier word comes first. Everything after it is unconstrained, so stop right there. Collect those constraints as edges. Then topologically sort the letters.

Non-adjacent pairs add nothing. If a < b and b < c, then a < c follows. The topological sort handles that transitivity. So n - 1 comparisons are enough, not n². Saying that is worth a point on its own.

The three ways this fails

The prefix rule is the case everyone forgets. Say two adjacent words match up to the length of the shorter one. If the first word is longer, the input contradicts itself. In any dictionary, a prefix sorts before the word that extends it. Handle it explicitly. No edge is produced, so the sort would happily return a wrong answer.

Two C# notes. C# has no for/else, so a bool flag records whether a difference was found. And Dictionary order is not guaranteed. A separate List<char> keeps letters in first-seen order, so the output is deterministic.

Solution

using System.Text;

public static class AlienDictionary
{
    /// <summary>Recovers a letter order consistent with a sorted alien word list.</summary>
    /// <param name="words">Words sorted by the unknown alphabet.</param>
    /// <returns>One valid order of every letter that appears, or "" if the input is
    /// contradictory.</returns>
    /// <example>AlienOrder(["wrt", "wrf", "er", "ett", "rftt"]) returns "wertf".</example>
    public static string AlienOrder(string[] words)
    {
        // letter -> letters that must come after it. A HashSet blocks duplicate edges.
        var successors = new Dictionary<char, HashSet<char>>();
        // Every letter that appears must be in the answer, even with no edges.
        var indegree = new Dictionary<char, int>();
        // First-seen order, because Dictionary order is not guaranteed.
        var letters = new List<char>();
        foreach (string word in words)
            foreach (char ch in word)
                if (indegree.TryAdd(ch, 0))         // 0: no incoming edges counted yet
                {
                    letters.Add(ch);
                    successors[ch] = [];
                }

        // i: index of the second word in each adjacent pair. Starts at 1 so i - 1 exists.
        for (int i = 1; i < words.Length; i++)
        {
            // i - 1: the word just before words[i].
            string first = words[i - 1], second = words[i];
            int shared = Math.Min(first.Length, second.Length);
            bool foundDifference = false;

            // j: position compared. Invariant: first[..j] equals second[..j].
            for (int j = 0; j < shared; j++)
            {
                char a = first[j], b = second[j];
                if (a == b) continue;
                // The first difference is the only constraint this pair gives.
                if (successors[a].Add(b))           // Add returns false if the edge exists
                    indegree[b]++;                  // +1: b waits on one more letter
                foundDifference = true;
                break;
            }

            // No difference in the shared length. A longer word may not precede its prefix.
            if (!foundDifference && first.Length > second.Length)
                return "";                          // "": the input is contradictory
        }

        // Start with every letter that has 0 incoming edges.
        var queue = new Queue<char>();
        foreach (char ch in letters)
            if (indegree[ch] == 0) queue.Enqueue(ch);
        var order = new StringBuilder(letters.Count);   // strings are immutable: build once

        // Invariant: each letter in order comes after every letter that must precede it.
        while (queue.Count > 0)                     // 0: nothing is ready
        {
            char ch = queue.Dequeue();
            order.Append(ch);

            foreach (char next in successors[ch])
            {
                indegree[next]--;                   // -1: one blocker of next is placed
                if (indegree[next] == 0)            // 0: next is free to place
                    queue.Enqueue(next);
            }
        }

        // Missing letters were stuck in a cycle, so return "" for no valid order.
        return order.Length == letters.Count ? order.ToString() : "";
    }
}

Walkthrough

Input ["wrt", "wrf", "er", "ett", "rftt"]:

The edges form one chain, w → e → r → t → f. So the order is "wertf", and it is unique here. Each pair produced exactly one edge. Everything after the first difference was ignored.

w r t w r f t → f w r f e r w → e e r e t t r → t e t t r f t t e → r Compare each adjacent pair. Only the first difference (amber) gives an edge. w e r t f topological order: "wertf"
Figure 15.6 — Each adjacent pair yields at most one edge, and the edges chain into the letter order.

Reading the figure. Each column is one pair of adjacent words. Gray letters match. The amber letters are the first place the words differ, and they give one edge. Dashed letters come after the difference and are ignored. The green chain at the bottom is the topological sort of those four edges.

TimeO(C)SpaceO(1) or O(U)

C is the total number of characters across all words. U is the alphabet size. The graph has at most U nodes and U² edges. With a fixed alphabet, the space is constant. Quote O(1) with that reason. It is the sharper answer.

Three details that decide the interview

Edge cases to raise

Say this out loud: “Only adjacent words matter, and only their first differing character. That gives one edge per pair, then it is a topological sort. The trap is the prefix rule, where a longer word precedes its own prefix.”

Recap

The six things to carry forward

Where this goes next

Pattern 16, Union-Find, is the other graph structure worth owning. Topological sort answers questions about direction and order. Union-Find answers questions about connectivity, on a graph that keeps changing. The Python version of this page is here.


← 14 — Monotonic Stack 16 — Union-Find →