Coding Interview Patterns Module 21 – Topological sort and DAGs
Topological order with DFS
Get a topological order from the order in which a depth-first search finishes nodes, catch loops with three states, and handle chains of 100,000 nodes.
What you will learn
- Derive a topological order from the order in which a depth-first search finishes nodes
- Distinguish a loop from a node reached twice with three states per node
- Write the search with a stack of its own for chains deeper than the recursion limit
- Prove that the finishing order puts every node after what must come before it
- Choose between Kahn's algorithm and a depth-first search for a given task
Before you start
What you’ll learn · Before you start
What you will learn
- Derive a topological order from the order in which a depth-first search finishes nodes
- Distinguish a loop from a node reached twice with three states per node
- Write the search with a stack of its own for chains deeper than the recursion limit
- Prove that the finishing order puts every node after what must come before it
- Choose between Kahn's algorithm and a depth-first search for a given task
Before you start
On this page
A depth-first search produces a topological order as a side effect of how it ends each node: it finishes a node only after it has finished everything that node leads to. Follow arrows from each package to the packages it requires, write a package down at the moment it finishes, and the list is an install order in which every package comes after all it requires. Follow arrows the other way, from what comes first to what comes after, and the same list runs backwards, so it is reversed at the end. Three states per node, not reached, on the current path and finished, catch a loop on the way: an arrow back to a node that is still on the path means no order exists. Kept on a stack of its own instead of in nested calls, the search handles a chain of 100,000 packages as easily as six.
The problem: an install order for packages
The problem
requires maps each package’s name to the list of packages that must be installed before it, and a name that
appears only inside a list requires nothing. Return every name once, in an order that installs each package after
everything it requires, or [] if some packages require each other in a loop, a package that requires itself
included. Limits: up to 100,000 names and 200,000 requirements, and one chain of requirements may run through all of
them.
Four examples:
- The site in the diagram,
{"site": ["theme", "markdown"], "theme": ["fonts", "icons"], "icons": ["fonts"], "markdown": ["parser"]}, givesfonts, icons, theme, parser, markdown, site. Nine other orders are valid too, such asparser, fonts, icons, markdown, theme, site. {"auth": ["session"], "session": ["crypto", "auth"]}gives[]: auth and session wait for each other.{"x": ["x"]}gives[]: x waits for itself.pkg0requirespkg1, which requirespkg2, and so on topkg99999: the only order starts atpkg99999.
The site's packages, numbered in the order the search finishes them
Text description of the diagram
The diagram shows six packages as boxes, drawn from the top down, with an arrow from each package to every package it requires. Each box says when a depth-first search that starts at site finishes that package.
- site requires theme and markdown. It finishes 6th, last, because it waits for both.
- theme requires fonts and icons, and finishes 3rd.
- icons requires fonts, and finishes 2nd. Its arrow to fonts is dashed: when the search reads it, fonts is done already, so there is nothing to do and no loop.
- fonts requires nothing. Its box has a double border: it finishes 1st.
- markdown requires parser, and finishes 5th.
- parser requires nothing, and finishes 4th.
Read in finishing order, the packages are fonts, icons, theme, parser, markdown and site. Every arrow leads from a package to one that finished earlier, so this order installs each package after everything it requires.
The brute force: install what each package requires, every time
The direct code installs a package by first installing each package it requires, the same way, then the package itself unless it is installed already. It is right when there is no loop, but it remembers nothing it has explored:
"""The brute force: install what a package requires, recursively, with no memory of what was already explored."""
def naive_order(requires):
"""An install order and the number of calls it took (it never stops on a loop)."""
order, installed = [], set()
calls = 0
def install(p):
nonlocal calls
calls += 1
for r in requires.get(p, ()):
install(r) # explored again, however often it was explored before
if p not in installed:
installed.add(p)
order.append(p)
for p in requires:
install(p)
return order, calls
def diamonds(k):
"""top requires a1 and b1, both require m1, m1 requires a2 and b2, ..., down to mk."""
requires = {"top": ["a1", "b1"]}
for i in range(1, k + 1):
requires[f"a{i}"] = [f"m{i}"]
requires[f"b{i}"] = [f"m{i}"]
requires[f"m{i}"] = [f"a{i + 1}", f"b{i + 1}"] if i < k else []
return requires
site = {
"site": ["theme", "markdown"],
"theme": ["fonts", "icons"],
"icons": ["fonts"],
"markdown": ["parser"],
}
order, calls = naive_order(site)
print("site:", " ".join(order), f"({calls} calls for {len(order)} packages)")
print("\nA chain of diamonds: every level doubles the calls")
for k in (1, 2, 4, 8, 12, 16):
order, calls = naive_order(diamonds(k))
assert calls == 2 ** (k + 4) - 7 * k - 15 # the exact count, so it can be worked out for larger k
print(f"{k:2} diamonds: {len(order):2} packages, {calls:>9,} calls")
loop = {"auth": ["session"], "session": ["crypto", "auth"]}
try:
naive_order(loop)
except RecursionError:
print("\nauth and session require each other: the calls never end, and Python stops them with RecursionError") Output
site: fonts icons theme parser markdown site (15 calls for 6 packages) A chain of diamonds: every level doubles the calls 1 diamonds: 4 packages, 10 calls 2 diamonds: 7 packages, 35 calls 4 diamonds: 13 packages, 213 calls 8 diamonds: 25 packages, 4,025 calls 12 diamonds: 37 packages, 65,437 calls 16 diamonds: 49 packages, 1,048,449 calls auth and session require each other: the calls never end, and Python stops them with RecursionError
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 naive_install.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
It makes 15 calls for the site’s 6 packages. In a chain of diamonds, where two packages of each level require the
same package below them, every level doubles the calls, and the run checks each count against 2k+4 − 7k −
15. Thirty diamonds, 91 packages, would need about 1.7 × 10¹⁰ calls, roughly 30 minutes at the CPython rate assumed in
the lesson on constraints. On a loop the calls never end: auth
calls session, which calls auth, until Python raises RecursionError.
What to notice
- A package’s place depends only on what it requires, so once it is done, exploring it again changes nothing: remember it.
- Arriving at a package that is entered but not finished means a loop, and a single “seen” mark cannot tell that from arriving at a finished one.
The key idea: a package is done when its requirements are
Give each package one of three states: new, open (entered and not finished, so it is on the current path) and done. Start a search at every package that is still new. The search opens a package and reads its requirements one by one:
- a new requirement is opened and searched first;
- a done requirement is in the order already, so there is nothing to do;
- an open requirement is on the current path, so it requires the package being read, directly or through others:
that is a loop, and the answer is
[].
When its last requirement has been read, the package is done and goes to the end of the order, after everything it requires. The arrows here point from a package to what must come before it. With arrows from what comes first to what comes after, as in the lesson on Kahn’s algorithm, a node finishes after everything that must follow it, and that list must be reversed. Reversing a list that is already right gives one that is exactly backwards.
A recursive function is the shortest way to write the search, but a chain of 100,000 packages would need 100,000 nested calls. Keep the path in a list instead, and beside each package on it the position of the next requirement to read: the package on top decides the next step, and popping it returns to the one below where it stopped. Each name is opened once and each requirement read once, so the search costs O(n + m) time for n names and m requirements, and O(n) memory beside the input.
A dry run on the site
"""Every step of the depth-first search on two small sets of packages: what it reads, the path, and the order so far."""
NEW, OPEN, DONE = "new", "open", "done"
def trace(requires):
state, order = {}, []
for root in requires:
if state.get(root, NEW) != NEW:
continue
state[root] = OPEN
stack = [[root, 0]] # [package, position of the next requirement to read]
print(f" start at {root:<22} path: {root}")
while stack:
top = stack[-1]
p, i = top
needs = requires.get(p, [])
if i == len(needs):
stack.pop()
state[p] = DONE
order.append(p)
print(f" {p + ' is done':<31} order: {' '.join(order)}")
continue
top[1] += 1
r = needs[i]
s = state.get(r, NEW)
step = f"{p} needs {r}"
if s == DONE:
print(f" {step:<31} {r} is done already: nothing to do")
elif s == OPEN:
loop = [q for q, _ in stack[[q for q, _ in stack].index(r):]] + [r]
print(f" {step:<31} {r} is open, on the path: a loop, {' > '.join(loop)}")
return []
else:
state[r] = OPEN
stack.append([r, 0])
print(f" {step:<31} path: {' > '.join(q for q, _ in stack)}")
return order
site = {
"site": ["theme", "markdown"],
"theme": ["fonts", "icons"],
"icons": ["fonts"],
"markdown": ["parser"],
}
print("The site:")
print("Install order:", trace(site))
print("\nauth and session:")
print("Install order:", trace({"auth": ["session"], "session": ["crypto", "auth"]})) Output
The site: start at site path: site site needs theme path: site > theme theme needs fonts path: site > theme > fonts fonts is done order: fonts theme needs icons path: site > theme > icons icons needs fonts fonts is done already: nothing to do icons is done order: fonts icons theme is done order: fonts icons theme site needs markdown path: site > markdown markdown needs parser path: site > markdown > parser parser is done order: fonts icons theme parser markdown is done order: fonts icons theme parser markdown site is done order: fonts icons theme parser markdown site Install order: ['fonts', 'icons', 'theme', 'parser', 'markdown', 'site'] auth and session: start at auth path: auth auth needs session path: auth > session session needs crypto path: auth > session > crypto crypto is done order: crypto session needs auth auth is open, on the path: a loop, auth > session > auth Install order: []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace_dfs.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
fonts is finished first, so it is installed first. When icons reads fonts again, fonts is done: a package reached twice is not a loop. On the second input, session reads auth while auth is still open below it on the path: a loop.
Exercise: an install order for any input
Exercise · Medium · Python
Install every package after what it requires
install_order(requires) in install.py gets a dictionary that maps package names to the lists of packages they require. A name that appears only inside a list requires nothing. Return a list of every name, each exactly once, in which every package comes after everything it requires. Return [] when some packages require each other in a loop, a package that requires itself included.
install_order({"app": ["lib", "util"], "util": ["lib"]}) # ['lib', 'util', 'app']
install_order({"auth": ["session"], "session": ["auth"]}) # []: a loop
install_order({"x": [], "y": []}) # ['x', 'y'], or ['y', 'x']
Any valid order is accepted. The starter code is a recursive search that marks each package "seen". It gets small inputs without loops right, but it returns an order for a loop instead of [], and one sample test is a chain of 100,000 packages, far deeper than Python's default recursion limit of 1,000 frames. Make it report loops, then make it work without recursion.
Starter code · install.py
def install_order(requires):
"""Every package after everything it requires, or [] when some packages require each other in a loop."""
# This search is right on small inputs without loops. It misses loops, and it recurses once per package.
seen = set()
order = []
def visit(p):
seen.add(p)
for r in requires.get(p, []):
if r not in seen:
visit(r)
order.append(p)
for p in requires:
if p not in seen:
visit(p)
return order The sample tests · test_install.py
import copy
import random
from install import install_order
def names_of(requires):
return list(dict.fromkeys([*requires, *(r for rs in requires.values() for r in rs)]))
def has_loop(requires):
"""Installs, again and again, any package whose requirements are all installed: slow, but hard to get wrong."""
names = names_of(requires)
done = set()
while True:
ready = [p for p in names if p not in done and all(r in done for r in requires.get(p, []))]
if not ready:
return len(done) < len(names)
done.update(ready)
def check(requires):
got = install_order(requires)
if has_loop(requires):
assert got == [], f"install_order({requires}) returned {got}, but some packages require each other in a loop"
return
names = names_of(requires)
assert sorted(got) == sorted(names), f"install_order({requires}) returned {got}, not every package exactly once"
at = {p: i for i, p in enumerate(got)}
for p, rs in requires.items():
for r in rs:
assert at[r] < at[p], f"install_order({requires}) returned {got}, which puts {p} before {r}"
def test_the_examples():
"""the three calls of the prompt"""
assert install_order({"app": ["lib", "util"], "util": ["lib"]}) == ["lib", "util", "app"]
assert install_order({"auth": ["session"], "session": ["auth"]}) == []
assert sorted(install_order({"x": [], "y": []})) == ["x", "y"]
def test_the_site():
"""the lesson's site: fonts is reached twice, which is not a loop"""
check({"site": ["theme", "markdown"], "theme": ["fonts", "icons"], "icons": ["fonts"], "markdown": ["parser"]})
def test_loops_of_every_size():
"""a package that requires itself, a loop of two behind a package, a loop of four that the first package misses"""
check({"x": ["x"]})
check({"cart": ["auth"], "auth": ["session"], "session": ["crypto", "auth"]})
check({"a": [], "b": ["c"], "c": ["d"], "d": ["e"], "e": ["b"]})
def test_repeats_and_empty_input():
"""a requirement listed twice, a package with an empty list, and no packages at all"""
check({"app": ["lib", "lib"]})
check({"app": [], "lib": ["app"]})
assert install_order({}) == []
def test_the_input_is_left_unchanged():
"""the function reads the dictionary and changes nothing in it"""
requires = {"a": ["b"], "b": ["c"]}
before = copy.deepcopy(requires)
install_order(requires)
assert requires == before
def test_random_sets_of_packages():
"""400 random sets of up to 8 packages, half of them made without a loop"""
rng = random.Random(11)
for t in range(400):
names = [f"p{i}" for i in range(rng.randint(1, 8))]
requires = {}
for i, p in enumerate(names):
pool = names[:i] if t % 2 == 0 else names
if pool:
requires[p] = [rng.choice(pool) for _ in range(rng.randint(0, 3))]
elif rng.random() < 0.5:
requires[p] = []
keys = list(requires)
rng.shuffle(keys)
check({p: requires[p] for p in keys})
def test_a_chain_of_100000():
"""pkg0 requires pkg1, which requires pkg2, ... down to pkg99999; then the same chain closed into a loop"""
n = 100_000
chain = {f"pkg{i}": [f"pkg{i + 1}"] for i in range(n - 1)}
got = install_order(chain)
assert len(got) == n and got[0] == "pkg99999" and got[-1] == "pkg0", "the chain must be installed from its end"
chain["pkg99999"] = ["pkg0"]
assert install_order(chain) == [], "the chain closed into a loop has no install order" A hint
"Seen" mixes two different states. Give each package one of three: not reached yet, on the current path of the search, or done. A requirement that is on the current path closes a loop; one that is done is already in the order. For the deep chain, keep the path in a list of your own, and beside each package on it the position of the next requirement to read, so that you can come back to it where you left off.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Why the finishing order is valid, with the code in five languages Pro: opens with a Pro, Premium or Ultimate pass
The order is right for a reason that fits in a few lines. Below is that argument, then the search in Python, JavaScript, Java, C++ and Go with their recorded output, and a check on 3,000 random inputs.
In this part:
- Why every package finishes after what it requires: the proof
- The code in five languages, with their recorded output
- Checked against Kahn’s algorithm and graphlib on 3,000 random inputs
3 runnable examples (C++, Go, Java, JavaScript, Python) · about 4 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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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).
Deep chains measured in five languages, and six bugs found Pro: opens with a Pro, Premium or Ultimate pass
A recursive version passes small tests and fails on deep inputs, and where it fails depends on the language. These runs measure it, then run six plausible bugs against every input of up to three packages.
In this part:
- What recursion costs on a chain of 100,000 packages, measured
- Six bugs, each on the smallest input that breaks it
3 runnable examples (Python) · about 3 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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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).
Kahn or DFS, reporting the loop, and a practice set Pro: opens with a Pro, Premium or Ultimate pass
Kahn’s algorithm and the depth-first search reach the same kind of order with different strengths. A trade-off table, three variants that run and a practice set with answers show which one fits a task.
In this part:
- Kahn’s algorithm or a depth-first search: the trade-offs
- Reporting the loop, installing one package, and graphlib
- Why an alphabetical search is not the alphabetically first order
- A practice set with answers
1 runnable example (Python) · about 3 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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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).
Interview questions
-
Fresher to mid · Warm-up
When does a depth-first topological sort reverse its finishing order, and when must it not?
It depends on which way the arrows point. If an arrow a → b means "a comes before b", the search finishes a node only after everything reachable from it, which is everything that must come after it, so the nodes finish in reverse topological order and the list is reversed at the end. If each arrow points from a node to something that must come before it, as a package's list of requirements does, a node finishes after everything it requires, so the finishing order is already valid and must not be reversed: reversing it gives an order that is exactly backwards. The search, the three states and the O(V + E) cost are the same either way; only the last step depends on the direction.
-
Mid to senior
Your recursive depth-first install order passes every test, then crashes in production on a chain of 60,000 packages, each requiring the next. Why does it crash, and how do you change the code without changing the order it returns?
-
Fresher to mid
A teammate's depth-first install order marks each package "seen" and appends it after its requirements. It passes all the review tests. Which input makes it return a wrong answer, and what is the smallest change that fixes it?
-
Mid to senior
A registry holds 2 million packages and 9 million requirements. A user asks to install one package. How do you work out what to install and in which order, and what does it cost compared with ordering the whole registry?
Key takeaways
- Following arrows to requirements, the order in which a depth-first search finishes packages is an install order; with arrows from what comes first to what comes after, reverse it.
- Three states, new, open and done, tell a loop, an arrow to an open package, from a package reached twice.
- Keep the path and each package’s next position on a stack of your own: O(n + m) time, and no recursion limit to hit.
References
- Depth-First Search and Linear Graph Algorithms (R. E. Tarjan, SIAM Journal on Computing 1(2), pages 146–160) (Society for Industrial and Applied Mathematics (SIAM))
- depth-first search (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- topological sort (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- sys.setrecursionlimit() (The Python Standard Library) (Python Software Foundation)
- graphlib: functionality to operate with graph-like structures (The Python Standard Library) (Python Software Foundation)
- for...in: the traversal order of keys (MDN Web Docs)
- Map (MDN Web Docs)
- The java command: -Xss, the thread stack size (Java SE 25) (Oracle)
- List (Java SE 25 and JDK 25 API): getFirst() and getLast() (Oracle)
- C++ keyword: requires (since C++20) (cppreference.com)
- The Go Programming Language Specification: for statements with range clause (The Go Authors)
- runtime/debug.SetMaxStack (Go standard library) (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress