Dependency resolver with cycle detection
Problem statement
Given packages (or Terraform modules, Helm charts, services in a startup sequence) and what each one depends on, produce an order in which to install them so every package comes after all of its dependencies. If that is impossible because of a dependency cycle, say exactly which cycle.
API (Java: DependencyResolver.resolve(Map<String, List<String>> graph) and resolve(graph, List<String> targets), throwing CycleException)
class CycleError(Exception) resolve(graph: dict[str, list[str]], targets: list[str] | None = None) -> list[str]graph[p] lists the packages p depends on.
Rules
- Every package appears after all of its dependencies.
- The output must be deterministic: whenever several packages are ready at once, take the alphabetically smallest first.
- A dependency that is not a key of
graphis a leaf package with no dependencies of its own. It is still part of the output. - With
targets, output only the targets and everything they need, directly or indirectly. - If there is a cycle, raise
CycleErrorwith the messagecycle: a -> b -> c -> a. To make the message deterministic, start from the alphabetically smallest package that cannot be installed and, at each step, follow its alphabetically smallest dependency that also cannot be installed, until a package repeats. - Duplicate entries in a dependency list are ignored.
Examples
Example 1
Input: graph = {
"app": ["db-client", "logger"],
"db-client": ["tls", "logger"],
"logger": [],
"metrics": ["logger"],
"tls": []
}
resolve(graph)
resolve(graph, targets=["db-client"])
Output: [logger, metrics, tls, db-client, app]
[logger, tls, db-client]
Explanation: logger and tls are ready first, and logger wins alphabetically. Installing it makes metrics ready, which sorts before tls. With a target, app and metrics are not needed.
Example 2
Input: resolve({"a": ["b"], "b": ["c"], "c": ["a"], "d": []})
resolve({"x": ["x"]})
resolve({"web": ["nginx-conf"]})
Output: error: cycle: a -> b -> c -> a
error: cycle: x -> x
[nginx-conf, web]
Explanation: A package depending on itself is a cycle of length one. nginx-conf is not a key, so it is a leaf.
Hints
Approach
Kahn's algorithm with a min-heap:
- Work out the node set: every package mentioned, or, with
targets, everything reachable from the targets by a DFS over dependencies. - For each package keep
pending, its set of uninstalled dependencies, and build the reverse mapdependents:d -> packages that need d. - Put every package with an empty
pendingset into a min-heap. Pop the smallest, append it to the order, and remove it from each dependent'spendingset, pushing any dependent whose set becomes empty. - If fewer packages were output than exist, the remainder contains a cycle. Every remaining package still has a remaining dependency, so start at the smallest one and keep following its smallest remaining dependency. Because the set is finite, a package must repeat, and the path from its first visit is the cycle.
Using sets for pending handles duplicate entries in a dependency list for free.
O((V + E) log V)Space O(V + E)import heapq class CycleError(Exception): pass def _closure(graph, targets): """Every package reachable from targets (or every package mentioned, if targets is None).""" if targets is None: nodes = set(graph) for deps in graph.values(): nodes.update(deps) return nodes nodes, stack = set(), list(targets) while stack: n = stack.pop() if n not in nodes: nodes.add(n) stack.extend(graph.get(n, [])) return nodes def _find_cycle(graph, remaining): """Every node left over has an unresolved dependency, so following one must loop.""" path, pos = [], {} cur = min(remaining) while cur not in pos: pos[cur] = len(path) path.append(cur) cur = min(d for d in graph.get(cur, []) if d in remaining) return path[pos[cur]:] + [cur] def resolve(graph, targets=None): nodes = _closure(graph, targets) pending = {n: set(graph.get(n, [])) for n in nodes} # unresolved deps per package dependents = {n: [] for n in nodes} for n, deps in pending.items(): for d in deps: dependents[d].append(n) ready = [n for n, deps in pending.items() if not deps] heapq.heapify(ready) # alphabetical tie-break order = [] while ready: n = heapq.heappop(ready) order.append(n) for m in dependents[n]: pending[m].discard(n) if not pending[m]: heapq.heappush(ready, m) if len(order) < len(nodes): cycle = _find_cycle(graph, nodes - set(order)) raise CycleError("cycle: " + " -> ".join(cycle)) return orderFollow-up questions
- Return install "waves": groups of packages that can install in parallel.
- One package fails to install. Which others must be skipped?
- Add version constraints (
tls>=1.2). Why does that make the problem much harder?
Frequently asked questions
Both give a valid topological order in O(V + E). A DFS post-order is shorter to write and finds the cycle path naturally from its recursion stack. Kahn's algorithm makes the "what is ready now?" set explicit, which is exactly what you need for deterministic tie-breaking, and for running independent packages in parallel: everything in the ready set at the same moment can install concurrently.
In Go: map[string][]string in, maps for pending counts and dependents, and container/heap over a []string for the ready set. Return an error wrapping the cycle path instead of panicking. Production resolvers (package managers, Terraform's graph walker, build systems) also run the ready set in parallel with a worker limit, stop scheduling dependents of a node that failed, and report every cycle found, not only the first.
Dependency ordering is everywhere in their tooling: Terraform resources, Helm subcharts, Ansible roles, service startup order, CI pipelines as DAGs. The question checks that you recognise topological sort and can give a useful error when the graph is broken.