Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Depth-First Search (DFS)

Builds on Bottom-Up DFS, which you haven’t finished yet.

P0EasyMental model~3 min speedrun

Preorder DFS (Top-Down Recursion)

Grows into
BST BoundsRoot-to-Leaf Paths
The problem

Count Good Nodes in Binary Tree (LC 1448)(opens leetcode.com)

You're given a binary tree root. Call a node X good when no node on the path from the root down to X has a value greater than X's own value.

Count how many nodes in the tree qualify as good.

Example 1
Input:root = [3,1,4,3,null,1,5]
Output:4
Four nodes qualify: the root (3) is always good since nothing precedes it; node 4 is good because 4 is the largest value on its path (3, 4); node 5 is good because 5 is the largest on its path (3, 4, 5); and the rightmost node 3 is good because 3 ties the largest value on its path (3, 1, 3).
313145

Top-Down DFS

Root ➔ Preorder Argument Passing ➔ Descendant Evaluation
Spot it when: Whether a node counts depends on the path from the root to it: path sums, 'good' nodes, values compared with ancestors.
Top-Down DFS
Python
1from typing import Callable, Optional, TypeVar
2 
3S = TypeVar("S")
4R = TypeVar("R")
5 
6class TreeNode:
7 def __init__(self, val: int = 0, left: Optional["TreeNode"] = None, right: Optional["TreeNode"] = None):
8 self.val = val
9 self.left = left
10 self.right = right
11 
12 
13def top_down(root: Optional[TreeNode], start: S, next_state: Callable[[S, TreeNode], S], visit: Callable[[S, TreeNode], R], combine: Callable[[R, R, R], R], empty: R) -> R:
14 """Top-Down DFS: combine visit(state, node) over every node, where state describes
15 the path above node.
16 start is the state above the root. next_state(state, node) is the state handed to
17 node's children.
18 combine(here, left, right) joins a node's own result with its subtrees' results, for
19 example a sum or a max.
20 empty is the result of an empty subtree.
21 Precondition: every node is visited, so the template cannot remove nodes from the
22 tree.
23 N = number of nodes. H = height. T = the cost of one next_state, visit or combine
24 call.
25 Time: O(N * T) | Space: O(H) recursion stack
26 """
27 def dfs(node: Optional[TreeNode], state: S) -> R:
28 if not node:
29 return empty # Empty subtree: the neutral result
30 
31 here = visit(state, node) # Decide with the state passed down
32 child_state = next_state(state, node) # Update the state for the children only
33 
34 return combine(here, dfs(node.left, child_state), dfs(node.right, child_state))
35 
36 return dfs(root, start)
Invariant: max_so_far always equals the largest value on the path from the root down to the parent of node.

A node's answer depends on what lies above it, so pass that knowledge down as an argument (max_so_far) instead of searching back up the tree. Each call decides using only its argument and node.val, then hands the updated state (new_max) to both children; because arguments belong to one call, siblings never see each other's changes. Start the state at -inf, so the root counts as good without a special case.

See it run

Top-down DFS (Preorder), step by step

max_so_far -inf · good so far 0 · answer not yet

max_so_far -inf · good so far 0 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 2 · answer not yet

max_so_far 3 · good so far 2 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 5 · good so far 4 · answer not yet

max_so_far - · good so far 4 · answer 4

Each circle is a tree node. The value max_so_far travels down the edges, from parent to child, as a call argument. The list under the tree shows what each node received.

  • visiting the call that is running now
  • good a node whose value is at least max_so_far, so it is counted
  • not good a node with a value below max_so_far
  • passed down the edge a max_so_far argument travels along, to the child

Call dfs on the root 3 with max_so_far = -inf. The root has no ancestors, so it starts with the smallest possible value.

Call dfs on node 3 with max_so_far = -inf.

3 >= -inf is True, so node 3 is good. Both children receive new_max = 3.

Call dfs on node 1 with max_so_far = 3. The value came down from node 3 as an argument.

1 >= 3 is False, so node 1 is not good. Both children receive new_max = 3.

Call dfs on node 3 with max_so_far = 3. The value came down from node 1 as an argument.

3 >= 3 is True, so node 3 is good. Both children receive new_max = 3.

Call dfs on node 4 with max_so_far = 3. This is the value from node 3, not from the left subtree.

4 >= 3 is True, so node 4 is good. Both children receive new_max = 4.

Call dfs on node 1 with max_so_far = 4. The value came down from node 4 as an argument.

1 >= 4 is False, so node 1 is not good. Both children receive new_max = 4.

Call dfs on node 5 with max_so_far = 4. This is the value from node 4, not from the left subtree.

5 >= 4 is True, so node 5 is good. Both children receive new_max = 5.

Every node has been visited once. The number of good nodes is 4.

313145
node: max_so_far it received
empty
1 / 14
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Starting max_so_far at 0 instead of -inf

    Mistake
    return dfs(root, 0)
    Fix
    return dfs(root, float('-inf'))

    What happens: The root has no ancestors, so it is always a good node. Node values can be negative. With a start of 0, a negative root fails node.val >= max_so_far, so it is not counted. Starting at -inf, or at root.val, makes the root count.

    Example: root = [-1,-2,-3]
    Mistake: returns 0Fix: returns 1
  2. 2

    Gotcha 2: Keeping the running max in one shared variable

    Mistake
    nonlocal max_so_far
    max_so_far = max(max_so_far, node.val)
    Fix
    new_max = max(max_so_far, node.val)

    What happens: With one shared variable, the left subtree raises max_so_far. The raised value is still there when the walk enters the right subtree. So a right-side node is compared with values that are not its ancestors. An argument belongs to one call, so each child sees only its own path.

    Example: root = [1,5,2]
    Mistake: returns 2Fix: returns 3
  3. 3

    Gotcha 3: Checking the sum at an empty child, not a leaf

    Mistake
    if not node:
        return rem == 0
    Fix
    if not node:
        return False
    if not node.left and not node.right:
        return rem == node.val

    What happens: Path Sum (LC 112) counts only paths that end at a leaf, a node with no children. A node with one child also has one empty side. A check at the empty side accepts the path that stops at that node. That path does not end at a leaf.

    Example: root = [1,2], targetSum = 1
    Mistake: returns TrueFix: returns False
  4. 4

    Gotcha 4: Adding cur when node is None

    Mistake
    if not node:
        return cur
    cur = cur * 10 + node.val
    Fix
    if not node:
        return 0
    cur = cur * 10 + node.val
    if not node.left and not node.right:
        return cur

    What happens: In Sum Root to Leaf Numbers (LC 129), the state passed down is cur * 10 + node.val. A number is complete only at a leaf. A leaf has two None children, so returning cur at None adds each leaf's number twice. A node with one child also adds its partial number from its None side.

    Example: root = [1,2,3]
    Mistake: returns 50Fix: returns 25
Not this one if…
  • A node's answer comes from what its subtrees hold, as in Balanced Binary Tree (LC 110): nothing above the node changes it. See Bottom-Up DFS
  • The output is the list of whole root-to-leaf paths, as in Path Sum II (LC 113), not a count or a yes/no for each node. See Root-to-Leaf Paths

Deep Dive

Why an argument can carry the whole path above a node

A node is good when its value is at least every value on the path from the root down to it. To check that, a call needs the largest value above it. Searching back up the tree is not possible, because a node has no link to its parent. So the parent sends the largest value down, as the argument max_so_far.

The call on the root receives max_so_far = -inf. It has no ancestors, so no value can be larger. Each call checks node.val >= max_so_far, then builds new_max = max(max_so_far, node.val). It passes new_max to both children. This makes the invariant true for every call: max_so_far is the largest value on the path from the root to the parent of node.

Take root = [3, 1, 4, 3, null, 1, 5]. The calls in order receive these values: node 3 gets -inf and is good. Node 1 gets 3 and is not good. The next node 3 gets 3 and is good, because 3 >= 3. Node 4 gets 3 and is good. Node 1 gets 4 and is not good. Node 5 gets 4 and is good. The good nodes are 3, 3, 4 and 5, so the answer is 4.

Information flows down: the argument, not a return value

This card is the second way information can travel in a tree recursion. The information goes down, from parent to children, in an argument. The card Bottom-Up DFS is the first way: information goes up, from children to parent, in a return value.

The two ways answer different questions. Look at the same subtree, a single node with value 3, in two trees: [5, 3] and [1, 3]. The subtree is identical. In [5, 3] the node 3 is not good, and the answer is 1. In [1, 3] the node 3 is good, and the answer is 2. The subtree cannot tell the verdict. Only the path above can. That is why the value must come down as an argument.

The call still returns a number. Here it is the count of good nodes in the subtree, and the parent adds the two counts to its own. But the number that makes the decision is the argument. A short test: if the decision at a node needs something from above it, such as a running maximum, a remaining sum or a depth, use this card. If the decision needs something from below it, use Bottom-Up DFS.

Many problems use both. Binary Tree Maximum Path Sum (LC 124) returns values up. Validate Binary Search Tree (LC 98) passes a lower and an upper bound down. The order of the calls tells you which way a value travels: an argument is fixed before the children run, and a return value exists only after they finish.

The trap: one shared variable instead of an argument

An argument belongs to one call. Each call has its own copy, so a change made inside a call is invisible to the calls next to it. This is what makes the pass-down correct.

A shared variable breaks it. Suppose the code keeps one max_so_far outside the function and updates it with max_so_far = max(max_so_far, node.val). The left subtree raises the value. The raised value is still there when the right subtree starts. So a node on the right is compared with values that are not its ancestors.

Take root = [1, 5, 2]. The correct code gives 3: the root 1 is good, 5 is good because 5 >= 1, and 2 is good because its only ancestor is 1. The shared variable code gives 2. After the left child 5 raises the variable to 5, the right child 2 is compared with 5 and fails.

A second trap is the start value. The root must count as good. With float('-inf') it does. With 0, a root of -1 fails -1 >= 0. For root = [-1, -2, -3] the correct answer is 1 and the code that starts at 0 returns 0.

The cost, and how it differs from Bottom-Up DFS

Every node is visited once, and each visit does constant work: one comparison and one max. The time is O(N). The space is O(H) for the recursion stack, where H is the height. The argument adds one number per call, so it does not change the order of the space.

The state can be more than one number. Sum Root to Leaf Numbers (LC 129) passes cur * 10 + node.val down. Path Sum (LC 112) passes the remaining target. Each call still receives one value and passes an updated value to both children.

Be careful about where the answer is read. Path Sum (LC 112) counts only paths that end at a leaf. A node with one child has one empty side, and a check at the empty side accepts a path that stops at that node. For root = [1, 2] with targetSum = 1, the correct answer is False. The check must happen at a node that has no children.

Compare with Bottom-Up DFS. There, a subtree is enough, and the parent waits for the children. Here, the path above is needed, and the parent speaks first. If the question wants whole paths as output, as in Path Sum II (LC 113), the argument is also a growing list, and the card Root-to-Leaf Paths is the better fit.

60-Second Pitch

"A node is good when no ancestor is larger, so the decision depends on the path above the node. I pass that information down as an argument. My function is dfs(node, max_so_far). The root gets -inf. A node is good when node.val >= max_so_far. Then I compute new_max = max(max_so_far, node.val) and pass it to both children. I return the node's own count plus the counts of the two subtrees. The argument belongs to one call, so the left subtree cannot change what the right subtree sees. A shared variable would break this. Each node is visited once, so the time is O(N). The space is O(H) for the recursion stack."

Common Questions

Why do you pass max_so_far as an argument and not keep it in a variable outside the function?

An argument is a separate copy for each call. After the left subtree finishes, the right subtree still receives the value from the parent. A shared variable keeps the changes of the left subtree. In [1, 5, 2] that gives 2 instead of 3, because the node 2 is compared with 5, which is not its ancestor.

Why start with -inf and not 0?

The root has no ancestors, so it must always count. Node values can be negative. With a start of 0, the root of [-1, -2, -3] fails -1 >= 0 and the code returns 0 instead of 1. -inf is smaller than every value, so the root always passes.

What are the time and space complexity?

O(N) time, because each node is visited once with constant work. O(H) space for the recursion stack, where H is the height. That is about log N for a balanced tree and N for a chain.

How do you know whether to pass information down or return it up?

Ask what the answer for a node needs. If it needs only its subtrees, return it up. If it needs the path above, pass it down. Moving the same subtree to another tree changes the answer in the second case, and that is the sign. Maximum Depth of Binary Tree (LC 104) is the first case. Count Good Nodes in Binary Tree (LC 1448) is the second.

What changes if the state has more than one value, as in Validate Binary Search Tree (LC 98)?

The argument becomes a pair, such as (low, high). Each call checks low < node.val < high. It passes (low, node.val) to the left child and (node.val, high) to the right child. The recursion shape is the same. Only next_state changes.

SWE Reasoning

Spot it

  • Whether a node counts depends on the path from the root to it: a running maximum, a remaining sum, a depth or a count of ancestors.
  • The same subtree can have a different answer in a different tree, because the answer depends on what lies above it.
  • The wording says "on the path from the root", "greater than all ancestors", "root to leaf" or "so far".
  • The state can be written as one small value, such as a number or a pair, that each node can update for its children.
  • A node has no link to its parent, so searching upward is not possible. The information must come down.

The invariant, step by step

  1. The call on the root receives a start value that fits "no ancestors": -inf for a running maximum.
  2. Each call decides about node using only its argument and node.val. Here, it counts the node when node.val >= max_so_far.
  3. The call then builds new_max = max(max_so_far, node.val). This is the largest value on the path from the root through node.
  4. It passes new_max as an argument to both children. Each child receives its own copy, so the children do not affect each other.
  5. So for every call, max_so_far is the largest value from the root down to the parent of node. The result is the count of the node plus the counts from both subtrees.

Traps

  • A shared variable. One max_so_far outside the function lets the left subtree change what the right subtree sees. Use a new argument for each child.
  • A start value of 0. A root with a negative value is not counted. Start at -inf, or at the root's value.
  • Checking at an empty child. For path problems, a node with one child also has one empty side. Check at a node that has no children.
  • Counting a leaf twice. A leaf has two empty children. Returning the number at the empty child adds the leaf's number twice.
  • Using this card when the subtree is enough. If the answer depends only on the subtrees, use Bottom-Up DFS. It needs no argument.

Practice

Canonical LeetCode:Count Good Nodes in Binary Tree (LC 1448)Path Sum (LC 112)Maximum Depth of Binary Tree (LC 104)Binary Tree Longest Consecutive Sequence (LC 298)Sum of Root To Leaf Binary Numbers (LC 1022)Sum Root to Leaf Numbers (LC 129)Maximum Difference Between Node and Ancestor (LC 1026)Sum of Left Leaves (LC 404)Insufficient Nodes in Root to Leaf Paths (LC 1080)
Theory Context•Depth-First Search (DFS)

Builds on Bottom-Up DFS, which you haven’t finished yet.

P0EasyMental model~3 min speedrun

Preorder DFS (Top-Down Recursion)

Grows into
BST BoundsRoot-to-Leaf Paths
The problem

Count Good Nodes in Binary Tree (LC 1448)(opens leetcode.com)

You're given a binary tree root. Call a node X good when no node on the path from the root down to X has a value greater than X's own value.

Count how many nodes in the tree qualify as good.

Example 1
Input:root = [3,1,4,3,null,1,5]
Output:4
Four nodes qualify: the root (3) is always good since nothing precedes it; node 4 is good because 4 is the largest value on its path (3, 4); node 5 is good because 5 is the largest on its path (3, 4, 5); and the rightmost node 3 is good because 3 ties the largest value on its path (3, 1, 3).
313145

Top-Down DFS

Root ➔ Preorder Argument Passing ➔ Descendant Evaluation
Spot it when: Whether a node counts depends on the path from the root to it: path sums, 'good' nodes, values compared with ancestors.
Top-Down DFS
Python
1from typing import Callable, Optional, TypeVar
2 
3S = TypeVar("S")
4R = TypeVar("R")
5 
6class TreeNode:
7 def __init__(self, val: int = 0, left: Optional["TreeNode"] = None, right: Optional["TreeNode"] = None):
8 self.val = val
9 self.left = left
10 self.right = right
11 
12 
13def top_down(root: Optional[TreeNode], start: S, next_state: Callable[[S, TreeNode], S], visit: Callable[[S, TreeNode], R], combine: Callable[[R, R, R], R], empty: R) -> R:
14 """Top-Down DFS: combine visit(state, node) over every node, where state describes
15 the path above node.
16 start is the state above the root. next_state(state, node) is the state handed to
17 node's children.
18 combine(here, left, right) joins a node's own result with its subtrees' results, for
19 example a sum or a max.
20 empty is the result of an empty subtree.
21 Precondition: every node is visited, so the template cannot remove nodes from the
22 tree.
23 N = number of nodes. H = height. T = the cost of one next_state, visit or combine
24 call.
25 Time: O(N * T) | Space: O(H) recursion stack
26 """
27 def dfs(node: Optional[TreeNode], state: S) -> R:
28 if not node:
29 return empty # Empty subtree: the neutral result
30 
31 here = visit(state, node) # Decide with the state passed down
32 child_state = next_state(state, node) # Update the state for the children only
33 
34 return combine(here, dfs(node.left, child_state), dfs(node.right, child_state))
35 
36 return dfs(root, start)
Invariant: max_so_far always equals the largest value on the path from the root down to the parent of node.

A node's answer depends on what lies above it, so pass that knowledge down as an argument (max_so_far) instead of searching back up the tree. Each call decides using only its argument and node.val, then hands the updated state (new_max) to both children; because arguments belong to one call, siblings never see each other's changes. Start the state at -inf, so the root counts as good without a special case.

See it run

Top-down DFS (Preorder), step by step

max_so_far -inf · good so far 0 · answer not yet

max_so_far -inf · good so far 0 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 1 · answer not yet

max_so_far 3 · good so far 2 · answer not yet

max_so_far 3 · good so far 2 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 4 · good so far 3 · answer not yet

max_so_far 5 · good so far 4 · answer not yet

max_so_far - · good so far 4 · answer 4

Each circle is a tree node. The value max_so_far travels down the edges, from parent to child, as a call argument. The list under the tree shows what each node received.

  • visiting the call that is running now
  • good a node whose value is at least max_so_far, so it is counted
  • not good a node with a value below max_so_far
  • passed down the edge a max_so_far argument travels along, to the child

Call dfs on the root 3 with max_so_far = -inf. The root has no ancestors, so it starts with the smallest possible value.

Call dfs on node 3 with max_so_far = -inf.

3 >= -inf is True, so node 3 is good. Both children receive new_max = 3.

Call dfs on node 1 with max_so_far = 3. The value came down from node 3 as an argument.

1 >= 3 is False, so node 1 is not good. Both children receive new_max = 3.

Call dfs on node 3 with max_so_far = 3. The value came down from node 1 as an argument.

3 >= 3 is True, so node 3 is good. Both children receive new_max = 3.

Call dfs on node 4 with max_so_far = 3. This is the value from node 3, not from the left subtree.

4 >= 3 is True, so node 4 is good. Both children receive new_max = 4.

Call dfs on node 1 with max_so_far = 4. The value came down from node 4 as an argument.

1 >= 4 is False, so node 1 is not good. Both children receive new_max = 4.

Call dfs on node 5 with max_so_far = 4. This is the value from node 4, not from the left subtree.

5 >= 4 is True, so node 5 is good. Both children receive new_max = 5.

Every node has been visited once. The number of good nodes is 4.

313145
node: max_so_far it received
empty
1 / 14
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Starting max_so_far at 0 instead of -inf

    Mistake
    return dfs(root, 0)
    Fix
    return dfs(root, float('-inf'))

    What happens: The root has no ancestors, so it is always a good node. Node values can be negative. With a start of 0, a negative root fails node.val >= max_so_far, so it is not counted. Starting at -inf, or at root.val, makes the root count.

    Example: root = [-1,-2,-3]
    Mistake: returns 0Fix: returns 1
  2. 2

    Gotcha 2: Keeping the running max in one shared variable

    Mistake
    nonlocal max_so_far
    max_so_far = max(max_so_far, node.val)
    Fix
    new_max = max(max_so_far, node.val)

    What happens: With one shared variable, the left subtree raises max_so_far. The raised value is still there when the walk enters the right subtree. So a right-side node is compared with values that are not its ancestors. An argument belongs to one call, so each child sees only its own path.

    Example: root = [1,5,2]
    Mistake: returns 2Fix: returns 3
  3. 3

    Gotcha 3: Checking the sum at an empty child, not a leaf

    Mistake
    if not node:
        return rem == 0
    Fix
    if not node:
        return False
    if not node.left and not node.right:
        return rem == node.val

    What happens: Path Sum (LC 112) counts only paths that end at a leaf, a node with no children. A node with one child also has one empty side. A check at the empty side accepts the path that stops at that node. That path does not end at a leaf.

    Example: root = [1,2], targetSum = 1
    Mistake: returns TrueFix: returns False
  4. 4

    Gotcha 4: Adding cur when node is None

    Mistake
    if not node:
        return cur
    cur = cur * 10 + node.val
    Fix
    if not node:
        return 0
    cur = cur * 10 + node.val
    if not node.left and not node.right:
        return cur

    What happens: In Sum Root to Leaf Numbers (LC 129), the state passed down is cur * 10 + node.val. A number is complete only at a leaf. A leaf has two None children, so returning cur at None adds each leaf's number twice. A node with one child also adds its partial number from its None side.

    Example: root = [1,2,3]
    Mistake: returns 50Fix: returns 25
Not this one if…
  • A node's answer comes from what its subtrees hold, as in Balanced Binary Tree (LC 110): nothing above the node changes it. See Bottom-Up DFS
  • The output is the list of whole root-to-leaf paths, as in Path Sum II (LC 113), not a count or a yes/no for each node. See Root-to-Leaf Paths

Deep Dive

Why an argument can carry the whole path above a node

A node is good when its value is at least every value on the path from the root down to it. To check that, a call needs the largest value above it. Searching back up the tree is not possible, because a node has no link to its parent. So the parent sends the largest value down, as the argument max_so_far.

The call on the root receives max_so_far = -inf. It has no ancestors, so no value can be larger. Each call checks node.val >= max_so_far, then builds new_max = max(max_so_far, node.val). It passes new_max to both children. This makes the invariant true for every call: max_so_far is the largest value on the path from the root to the parent of node.

Take root = [3, 1, 4, 3, null, 1, 5]. The calls in order receive these values: node 3 gets -inf and is good. Node 1 gets 3 and is not good. The next node 3 gets 3 and is good, because 3 >= 3. Node 4 gets 3 and is good. Node 1 gets 4 and is not good. Node 5 gets 4 and is good. The good nodes are 3, 3, 4 and 5, so the answer is 4.

Information flows down: the argument, not a return value

This card is the second way information can travel in a tree recursion. The information goes down, from parent to children, in an argument. The card Bottom-Up DFS is the first way: information goes up, from children to parent, in a return value.

The two ways answer different questions. Look at the same subtree, a single node with value 3, in two trees: [5, 3] and [1, 3]. The subtree is identical. In [5, 3] the node 3 is not good, and the answer is 1. In [1, 3] the node 3 is good, and the answer is 2. The subtree cannot tell the verdict. Only the path above can. That is why the value must come down as an argument.

The call still returns a number. Here it is the count of good nodes in the subtree, and the parent adds the two counts to its own. But the number that makes the decision is the argument. A short test: if the decision at a node needs something from above it, such as a running maximum, a remaining sum or a depth, use this card. If the decision needs something from below it, use Bottom-Up DFS.

Many problems use both. Binary Tree Maximum Path Sum (LC 124) returns values up. Validate Binary Search Tree (LC 98) passes a lower and an upper bound down. The order of the calls tells you which way a value travels: an argument is fixed before the children run, and a return value exists only after they finish.

The trap: one shared variable instead of an argument

An argument belongs to one call. Each call has its own copy, so a change made inside a call is invisible to the calls next to it. This is what makes the pass-down correct.

A shared variable breaks it. Suppose the code keeps one max_so_far outside the function and updates it with max_so_far = max(max_so_far, node.val). The left subtree raises the value. The raised value is still there when the right subtree starts. So a node on the right is compared with values that are not its ancestors.

Take root = [1, 5, 2]. The correct code gives 3: the root 1 is good, 5 is good because 5 >= 1, and 2 is good because its only ancestor is 1. The shared variable code gives 2. After the left child 5 raises the variable to 5, the right child 2 is compared with 5 and fails.

A second trap is the start value. The root must count as good. With float('-inf') it does. With 0, a root of -1 fails -1 >= 0. For root = [-1, -2, -3] the correct answer is 1 and the code that starts at 0 returns 0.

The cost, and how it differs from Bottom-Up DFS

Every node is visited once, and each visit does constant work: one comparison and one max. The time is O(N). The space is O(H) for the recursion stack, where H is the height. The argument adds one number per call, so it does not change the order of the space.

The state can be more than one number. Sum Root to Leaf Numbers (LC 129) passes cur * 10 + node.val down. Path Sum (LC 112) passes the remaining target. Each call still receives one value and passes an updated value to both children.

Be careful about where the answer is read. Path Sum (LC 112) counts only paths that end at a leaf. A node with one child has one empty side, and a check at the empty side accepts a path that stops at that node. For root = [1, 2] with targetSum = 1, the correct answer is False. The check must happen at a node that has no children.

Compare with Bottom-Up DFS. There, a subtree is enough, and the parent waits for the children. Here, the path above is needed, and the parent speaks first. If the question wants whole paths as output, as in Path Sum II (LC 113), the argument is also a growing list, and the card Root-to-Leaf Paths is the better fit.

60-Second Pitch

"A node is good when no ancestor is larger, so the decision depends on the path above the node. I pass that information down as an argument. My function is dfs(node, max_so_far). The root gets -inf. A node is good when node.val >= max_so_far. Then I compute new_max = max(max_so_far, node.val) and pass it to both children. I return the node's own count plus the counts of the two subtrees. The argument belongs to one call, so the left subtree cannot change what the right subtree sees. A shared variable would break this. Each node is visited once, so the time is O(N). The space is O(H) for the recursion stack."

Common Questions

Why do you pass max_so_far as an argument and not keep it in a variable outside the function?

An argument is a separate copy for each call. After the left subtree finishes, the right subtree still receives the value from the parent. A shared variable keeps the changes of the left subtree. In [1, 5, 2] that gives 2 instead of 3, because the node 2 is compared with 5, which is not its ancestor.

Why start with -inf and not 0?

The root has no ancestors, so it must always count. Node values can be negative. With a start of 0, the root of [-1, -2, -3] fails -1 >= 0 and the code returns 0 instead of 1. -inf is smaller than every value, so the root always passes.

What are the time and space complexity?

O(N) time, because each node is visited once with constant work. O(H) space for the recursion stack, where H is the height. That is about log N for a balanced tree and N for a chain.

How do you know whether to pass information down or return it up?

Ask what the answer for a node needs. If it needs only its subtrees, return it up. If it needs the path above, pass it down. Moving the same subtree to another tree changes the answer in the second case, and that is the sign. Maximum Depth of Binary Tree (LC 104) is the first case. Count Good Nodes in Binary Tree (LC 1448) is the second.

What changes if the state has more than one value, as in Validate Binary Search Tree (LC 98)?

The argument becomes a pair, such as (low, high). Each call checks low < node.val < high. It passes (low, node.val) to the left child and (node.val, high) to the right child. The recursion shape is the same. Only next_state changes.

SWE Reasoning

Spot it

  • Whether a node counts depends on the path from the root to it: a running maximum, a remaining sum, a depth or a count of ancestors.
  • The same subtree can have a different answer in a different tree, because the answer depends on what lies above it.
  • The wording says "on the path from the root", "greater than all ancestors", "root to leaf" or "so far".
  • The state can be written as one small value, such as a number or a pair, that each node can update for its children.
  • A node has no link to its parent, so searching upward is not possible. The information must come down.

The invariant, step by step

  1. The call on the root receives a start value that fits "no ancestors": -inf for a running maximum.
  2. Each call decides about node using only its argument and node.val. Here, it counts the node when node.val >= max_so_far.
  3. The call then builds new_max = max(max_so_far, node.val). This is the largest value on the path from the root through node.
  4. It passes new_max as an argument to both children. Each child receives its own copy, so the children do not affect each other.
  5. So for every call, max_so_far is the largest value from the root down to the parent of node. The result is the count of the node plus the counts from both subtrees.

Traps

  • A shared variable. One max_so_far outside the function lets the left subtree change what the right subtree sees. Use a new argument for each child.
  • A start value of 0. A root with a negative value is not counted. Start at -inf, or at the root's value.
  • Checking at an empty child. For path problems, a node with one child also has one empty side. Check at a node that has no children.
  • Counting a leaf twice. A leaf has two empty children. Returning the number at the empty child adds the leaf's number twice.
  • Using this card when the subtree is enough. If the answer depends only on the subtrees, use Bottom-Up DFS. It needs no argument.

Practice

Canonical LeetCode:Count Good Nodes in Binary Tree (LC 1448)Path Sum (LC 112)Maximum Depth of Binary Tree (LC 104)Binary Tree Longest Consecutive Sequence (LC 298)Sum of Root To Leaf Binary Numbers (LC 1022)Sum Root to Leaf Numbers (LC 129)Maximum Difference Between Node and Ancestor (LC 1026)Sum of Left Leaves (LC 404)Insufficient Nodes in Root to Leaf Paths (LC 1080)
© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•