DSA sheet · Graphs · Path enumeration (DFS + backtracking)

All Paths From Source to Target

Until now we only had to reach nodes. Here we must list every route from node 0 to the last node, so we have to remember the path we're walking. The teacher first solves it with BFS, where the queue holds whole paths. Then she uses DFS with one shared path list, which needs the most important new idea on this page: backtracking, which means add, recurse, remove. She also compares the space used by both. Backtracking comes back in subsets, permutations, N-Queens, word search and many more.

Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Graph basics this page needs

Nodes, edges, directed and weighted

A graph is a set of nodes joined by edges. An undirected edge (a)—(b) can be walked both ways. A directed edge (a)→(b) is one-way: from a you can go to b, but not back. A weighted edge carries a number like a cost. This problem is directed and unweighted.

DAG: Directed Acyclic Graph

Acyclic means "no cycle": if you follow the arrows, you can never come back to a node you already passed. A DAG is a directed graph with no cycles. It matters a lot here, as we'll see: no cycle means no infinite walk, even without a visited array.

a DAG ✓
(0)→(1)
 ↓    ↓
(2)→(3)
not a DAG ✗ (0→1→2→0 is a cycle)
(0)→(1)
 ↑    ↓
 └───(2)

Reading the input: an adjacency list is given directly

The input graph is already an adjacency list: graph[i] is the list of nodes you can go to from i (the children of i). Other problems give an edge list, which we'd convert like this:

edge list → adjacency list (directed)
n = 4
edges = [[0, 1], [0, 2], [1, 3], [2, 3]]     # u → v
graph = [[] for _ in range(n)]
for u, v in edges:
    graph[u].append(v)                       # one way only
print(graph)                                 # [[1, 2], [3], [3], []]

For an undirected graph you'd also do graph[v].append(u). An adjacency matrix would be an n × n grid of 0/1 where M[u][v] = 1 means an edge u → v.

Visited, components, BFS and DFS: a quick recap

In earlier problems (Provinces, Islands) we kept a visited array so each node was entered once. That was right there because we only cared about reaching nodes, not about the route. BFS uses a queue and spreads level by level. DFS uses recursion and dives deep first. Both appear on this page, but each needs a twist, because here the thing we store is a path, not a single node.

What is a path?

A path is a list of nodes where each consecutive pair is joined by an edge in the right direction, e.g. [0, 1, 3] uses 0→1 and 1→3. Two paths are different if their node lists differ, even if they share some nodes.


Part A · BFS: a queue of paths

LeetCode 797

1The question in simple words

You get a DAG with n nodes labelled 0 to n−1, given as an adjacency list. Return every path that starts at node 0 and ends at node n−1, following the arrows. You can return the paths in any order: for the example below, [[0,1,3],[0,2,3]] and [[0,2,3],[0,1,3]] are both accepted.

Example 1: graph = [[1,2],[3],[3],[]]
(0)→(1)
 ↓    ↓
(2)→(3)

paths: [0,1,3]  [0,2,3]
Example 2: graph = [[4,3,1],[3,2,4],[3],[4],[]]
0→4   0→3   0→1
1→3   1→2   1→4
2→3   3→4

paths: [0,4] [0,3,4] [0,1,3,4]
       [0,1,2,3,4] [0,1,4]

2What the constraints tell us

3Intuition: carry the whole route, not just the node

In normal BFS, the queue holds nodes. But if we only remember "I'm at node 3", we've forgotten how we got there, and the answer needs the route. So we make the queue hold whole paths. Think of many travellers starting at 0, each one carrying a notebook with the cities visited so far. At every fork the traveller splits into copies, one per road, and each copy writes its own next city. Whoever reaches the target hands their notebook to us.

4Building the logic from Example 1

Step 1: start

Every path starts at 0, so the queue starts with the path [0].

Step 2: pop a path, find where it ends, extend it to each child

Pop [0]. Its last node is 0, the node we're "standing on". The children of 0 are 1 and 2. For each child, make a new path = old path + child: [0,1] and [0,2]. Push both.

Doubt 1: why make a copy of the path for each child? Why not append to the same list?
→ The teacher shows what goes wrong. Append 1 to the popped list [0] → [0,1]. For the next child 2, append to the same list → [0,1,2]. But 0→1→2 is not a route at all! Both "travellers" would share one notebook. A fresh copy per child keeps the routes separate: copy [0] + 1 = [0,1], copy [0] + 2 = [0,2].

Step 3: keep going

Pop [0,1]. The last node is 1, and its child is 3 → push [0,1,3]. Pop [0,2]. The last node is 2, and its child is 3 → push [0,2,3].

Step 4: recognise a finished path

Pop [0,1,3]. Its last node is 3, which equals len(graph) - 1, so this is a complete valid path. Add it to the result. The same happens for [0,2,3]. The queue becomes empty, so we're done.

Doubt 2: after reaching the target, do we still extend the path?
→ No need. Every path must end at n−1, so a path can't usefully go past it. In this problem the target never has children anyway. It's a sink, because a DAG path ending at n−1 can't continue and still end at n−1.
Doubt 3: why is there no visited array, when Provinces and Islands needed one?
→ Two reasons (this one is my addition, not from the video). (1) It's a DAG, so there's no cycle and no infinite loop to guard against. (2) We want to reach the same node many times by different routes. Node 3 is on both answers in Example 1. A visited array would block the second route into 3 and lose [0,2,3].

5Approach steps

  1. target = len(graph) - 1, result = [], queue = deque([[0]]).
  2. While the queue isn't empty: pop a path, node = path[-1].
  3. If node == target: add the path to the result, then continue.
  4. Else, for each child of node: push path + [child] (a new list).
  5. Return the result.

6Code (Python)

All Paths with BFS (queue of paths)
from collections import deque

class Solution:
    def allPathsSourceTarget(self, graph):
        target = len(graph) - 1
        result = []
        queue = deque()
        queue.append([0])                        # the first path: just the source
        while queue:
            path = queue.popleft()
            node = path[-1]                      # where this path currently ends
            if node == target:
                result.append(path)              # complete route
                continue
            for child in graph[node]:
                queue.append(path + [child])     # a NEW list per child
        return result

7Code line by line

linewhat it means
target = len(graph) - 1The last node. Every answer ends here.
queue.append([0])The queue holds paths. The only path at the start is [0].
path = queue.popleft() node = path[-1]Take the oldest path. Its last element is the node we're standing on.
if node == target: result.append(path)Reached the end, so save this route. It's safe to save the list itself, because nobody changes it later (every extension is a new list).
for child in graph[node]:Each outgoing arrow is a different way to continue.
queue.append(path + [child])path + [child] builds a new list (it's the copy). The old path stays untouched for the next child.

8Dry run on Example 1

steppath poppedlast nodechildren checkedqueue afterresult after
0---[ [0] ][ ]
1[0]01, 2[ [0,1], [0,2] ][ ]
2[0,1]13[ [0,2], [0,1,3] ][ ]
3[0,2]23[ [0,1,3], [0,2,3] ][ ]
4[0,1,3]3 = target-[ [0,2,3] ][ [0,1,3] ]
5[0,2,3]3 = target-[ ][ [0,1,3], [0,2,3] ]
start0
step 10,10,2two travellers split from [0]
step 20,20,1,3
step 30,1,30,2,3both full paths sit in the queue at the same time
steps 4–5each ends at 3 → saved

9Complexity & remember

Remember (BFS)Queue of paths, not nodes. Pop → look at path[-1] → target? save : push path + [child] for each child. Always a new list per child.

Part B · DFS with backtracking (the teacher's code)

1The question

Same as Part A: list every path 0 → n−1.

2Constraints

Same. n ≤ 15 means the recursion is at most 15 calls deep, which is no problem for Python.

3Intuition: one notebook, written in pencil

Now there's only one traveller with one notebook. Walk forward along one road and write the city in the notebook. At the target, photocopy the notebook page and file the copy in the result. Then walk back one step and erase the last city, so the notebook again shows where you really are, and try the next road from there.

Writing before you go, and erasing after you come back, is backtracking. Because we erase, one list is enough for every route.

4Building the logic step by step

Step 1: the start

Make result = [] and path = [0]. Node 0 is in the path from the beginning, because every route starts there. Call dfs(0).

Step 2: the base case: when is a path complete?

Inside dfs(node), first ask: is node == len(graph) - 1? If yes, the current path holds a full valid route. Save it and return, since there's no point going further. At node 0 this check fails (0 isn't the target), so we move on to the children.

Doubt 1: why save list(path) (a copy) and not path itself?
→ path is one list that we keep editing. If we put that same list into the result, then later, when we erase 3 and 1 and write 2 and 3, the "saved" [0,1,3] would change too. Every entry in the result would point to the same list, and at the very end it would be just [0]. The copy freezes the route as it is right now. The teacher stresses this.

Step 3: try every child: add, recurse, remove

For each child of the current node:

  1. add: path.append(child). The notebook now shows the route up to the child.
  2. recurse: dfs(child). Since this is DFS, the child becomes the new "parent", and we explore everything below it before doing anything else here.
  3. remove: path.pop(). When that call returns, erase the child, so the path is back to what it was before step 1, and the next child starts from a clean state.
Doubt 2: what if I forget the remove?
→ In Example 1: after saving [0,1,3] we return with path still [0,1,3]. Back in dfs(0), the next child is 2, and we append it → [0,1,3,2]. Then 3 → [0,1,3,2,3], which gets saved as a "path". That's wrong! The teacher's rule: whatever you add by hand, you must remove by hand after the call returns.
Doubt 3: why remove the last element, and not search for the child?
→ The child was the last thing we appended. Everything the deeper calls appended, they already removed themselves before returning. So at that moment the child is exactly at the end, and path.pop() removes it in O(1). (In Java the teacher writes path.remove(path.size() - 1), the same thing.)

Step 4: the base case returns without removing. Is that a leak?

When dfs(3) saves and returns, it doesn't pop 3. That's fine, because it wasn't the one that added 3. Its caller added 3 just before calling, and its caller pops 3 right after the call. Each "add" is paired with exactly one "remove" in the same loop iteration.

5Approach steps

  1. result = [], path = [0], call dfs(0), return result.
  2. dfs(node): if node == len(graph) - 1 → append a copy of path to result, return.
  3. For each child in graph[node]: path.append(child) → dfs(child) → path.pop().

6Code (Python)

All Paths with DFS + backtracking
class Solution:
    def allPathsSourceTarget(self, graph):
        result = []
        path = [0]                               # every route starts at 0
        self.dfs(0, graph, path, result)
        return result

    def dfs(self, node, graph, path, result):
        if node == len(graph) - 1:               # reached the target
            result.append(list(path))            # save a COPY
            return
        for child in graph[node]:                # graph[node], not graph.get(node)
            path.append(child)                   # 1. add
            self.dfs(child, graph, path, result) # 2. recurse
            path.pop()                           # 3. remove (backtrack)

The teacher's first run failed because she wrote graph.get(node) on a plain array. She fixed it by indexing with brackets, graph[node].

7Code line by line

linewhat it means
path = [0]The one shared notebook, starting at the source.
if node == len(graph) - 1:Base case: the route is complete.
result.append(list(path))list(path) makes a new list with the same items, a snapshot that later edits can't touch.
returnNothing lies beyond the target, so go back.
for child in graph[node]:Try every outgoing arrow, one after another.
path.append(child)Add: step onto the child.
self.dfs(child, graph, path, result)Recurse: finish every route that continues through this child.
path.pop()Remove: step back off the child, so path is again "route up to node".

8Dry run with the call stack (Example 1)

(0)→(1)
 ↓    ↓
(2)→(3)        graph = [[1,2],[3],[3],[]], target = 3
stepwhat happenscall stack after (bottom → top)path afterresult after
1start: dfs(0), 0 ≠ 3 → loop over [1, 2]dfs(0)[0][ ]
2child 1: add 1, call dfs(1)dfs(0), dfs(1)[0,1][ ]
3dfs(1): 1 ≠ 3 → child 3: add 3, call dfs(3)dfs(0), dfs(1), dfs(3)[0,1,3][ ]
4dfs(3): 3 = target → save a copy, returndfs(0), dfs(1)[0,1,3][[0,1,3]]
5back in dfs(1): remove 3. No more children → returndfs(0)[0,1][[0,1,3]]
6back in dfs(0): remove 1dfs(0)[0][[0,1,3]]
7child 2: add 2, call dfs(2)dfs(0), dfs(2)[0,2][[0,1,3]]
8dfs(2): child 3: add 3, call dfs(3)dfs(0), dfs(2), dfs(3)[0,2,3][[0,1,3]]
9dfs(3): target → save a copy, returndfs(0), dfs(2)[0,2,3][[0,1,3],[0,2,3]]
10dfs(2): remove 3 → returndfs(0)[0,2]same
11dfs(0): remove 2 → loop ends → return(empty)[0][[0,1,3],[0,2,3]]
step 3: deepest, first route
dfs(0) · path [0]dfs(1) · path [0,1]dfs(3) · path [0,1,3] → save
step 6: back at the fork
dfs(0) · path [0] (1 erased)
step 8: second route
dfs(0) · path [0]dfs(2) · path [0,2]dfs(3) · path [0,2,3] → save

Look at the path column: it grows on the way down and shrinks on the way back up, mirroring the call stack exactly. That's the sign that backtracking is done right: when the stack is back to dfs(0), the path is back to [0].

the tree of calls (each branch = add → recurse → remove)
            dfs(0) [0]
           /          \
   +1 dfs(1)[0,1]   +2 dfs(2)[0,2]
         |                 |
  +3 dfs(3)[0,1,3]   +3 dfs(3)[0,2,3]
       SAVE              SAVE

9Complexity & remember

Doubt (a correction): is the time really O(n)?
→ No. A node is visited once per route through it, not once overall. Node 3 was visited twice in Example 1. The number of routes P can be huge: if every node i points to every later node j > i, each of the n−2 middle nodes can be in or out of a route, so P = 2n−2 (for n = 15 that's 8,192 routes). Copying each route costs up to n. So the true time is O(2n · n) in the worst case. That's exactly why the constraint keeps n ≤ 15. The output alone can be that big, so no algorithm can do better. The test file checks this count on the "complete DAG".
Remember (DFS) One shared path. Base case: node is the target → save list(path).
For each child: append → dfs → pop. What you add by hand, you remove by hand.

Part C · DFS without backtracking: pass a copy

The teacher shows one more way, and why she didn't choose it.

1–2Question & constraints

Same as before.

3Intuition

Instead of one notebook that we write and erase, give each recursive call its own fresh notebook: path + [child]. Nobody shares, so nobody needs to erase.

4Why no removal is needed here

Her explanation: if the child is added inside the call's arguments rather than on its own line, then only the next call sees the longer path. In the current loop the variable path is still, say, [0,1]. When the call returns there's nothing to undo, because our own list was never touched.

Doubt: so why not always do this? It looks simpler.
→ Every call creates a new list of up to n items, so lots of copies exist at the same time. That's the same kind of extra space the BFS version used. The teacher picks backtracking because it reuses one list. Here, because a new list is made on every call, the base case can save path directly without list().

5–6Steps and code

  1. dfs(node, path): if node is the target → save path, return.
  2. For each child → dfs(child, path + [child]). No append, no pop.
All Paths with DFS, passing a new list (no backtracking)
class Solution:
    def allPathsSourceTarget(self, graph):
        result = []
        target = len(graph) - 1

        def dfs(node, path):
            if node == target:
                result.append(path)              # this list is never changed again
                return
            for child in graph[node]:
                dfs(child, path + [child])       # a new list for the next call

        dfs(0, [0])
        return result

7Line by line (only what's new)

linewhat it means
def dfs(node, path): (inside the method)A nested function can read result, target and graph directly, so fewer parameters.
dfs(child, path + [child])The child goes only into the new list. Our own path is unchanged, so there's nothing to pop.

8Dry run (short)

dfs(0,[0]) → dfs(1,[0,1]) → dfs(3,[0,1,3]) saves → back in dfs(0), whose path is still [0] → dfs(2,[0,2]) → dfs(3,[0,2,3]) saves. Result: [[0,1,3],[0,2,3]] ✓. Three lists [0], [0,1], [0,1,3] existed at the deepest moment, where Part B had just one.

9Complexity

Time is the same, O(2n · n) worst case. Extra space: the lists alive along the current branch hold 1 + 2 + … + n items = O(n²), compared with O(n) for backtracking. That's a small difference at n = 15, but it's the habit the teacher wants us to build.


Part D · Revision page

BFS (queue of paths)DFS + backtrackingDFS + copy
what is storedmany full paths in a dequeone shared lista new list per call
extend to a childpath + [child] pushedappend → dfs → popdfs(child, path + [child])
saving a finished pathsave it directlysave a copy list(path)save it directly
extra spaceO(P · n)O(n)O(n²)
time (worst)O(2n · n): the number of routes can double with every extra node
earlier problemsthis problem
visit each node once → visited arrayvisit a node once per route → no visited array (a DAG has no cycles)
queue/stack holds nodesqueue/stack holds paths
If you remember only 5 lines 1. To list routes, carry the route, not just the node.
2. BFS: queue of paths, extend with path + [child], save when path[-1] is the target.
3. DFS: one shared path → append, recurse, pop (backtracking).
4. Save list(path), never the shared list itself.
5. A DAG needs no visited array, and node reuse across routes is wanted.
Mistakes to avoid ✗ result.append(path) with the shared list → every answer ends up as [0]
✗ forgetting path.pop() → routes glue together like [0,1,3,2,3]
✗ appending children to the same list in BFS → fake routes like [0,1,2]
✗ adding a visited array → second routes through a node are lost
✗ starting with path = [] → answers miss the leading 0
✗ calling the time O(n): it's exponential in the worst case
test it yourself (paste under any solution above)
s = Solution()
print(sorted(s.allPathsSourceTarget([[1, 2], [3], [3], []])))
# [[0, 1, 3], [0, 2, 3]]
print(sorted(s.allPathsSourceTarget([[4, 3, 1], [3, 2, 4], [3], [4], []])))
# [[0, 1, 2, 3, 4], [0, 1, 3, 4], [0, 1, 4], [0, 3, 4], [0, 4]]
print(s.allPathsSourceTarget([[1], []]))          # [[0, 1]]
print(s.allPathsSourceTarget([[], []]))           # []
complete = [[j for j in range(i + 1, 6)] for i in range(6)]
print(len(s.allPathsSourceTarget(complete)))      # 16

Based on this video: All Paths From Source to Target | Graph