Facebook Pixel

988. Smallest String Starting From Leaf

Problem Description

You have a binary tree where each node contains a value between 0 and 25. These values represent letters of the alphabet, where 0 represents 'a', 1 represents 'b', and so on up to 25 representing 'z'.

The task is to find the lexicographically smallest string that can be formed by traveling from any leaf node up to the root node.

Key points to understand:

  • A leaf node is a node that has no children (neither left nor right child)
  • The string is formed by reading the node values from a leaf to the root (bottom-up), converting each value to its corresponding letter
  • Lexicographical order is dictionary order - a string is smaller if it would appear earlier in a dictionary
    • For example, "ab" comes before "aba" in dictionary order, making "ab" lexicographically smaller
    • When comparing strings, we check character by character from left to right

The solution uses depth-first search (DFS) to explore all paths from the root to each leaf. During traversal:

  1. It builds the path from root to leaf by appending characters
  2. When reaching a leaf node, it reverses the path (since we need leaf-to-root order) and compares it with the current smallest string
  3. It backtracks by removing the last character when returning from a node
  4. The initial answer is set to chr(ord('z') + 1) which is a character after 'z', ensuring any valid path will be smaller

For example, if you have a tree where the root is 0 ('a'), left child is 1 ('b'), and right child is 2 ('c'), both children being leaves, the possible strings would be "ba" (from left leaf to root) and "ca" (from right leaf to root). The answer would be "ba" as it's lexicographically smaller.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: We have a binary tree, which is a special type of graph with nodes (tree nodes) and edges (parent-child relationships).

Is it a tree?

  • Yes: The problem explicitly states we're working with a binary tree structure where we have a root node and each node can have at most two children (left and right).

DFS

  • We arrive at DFS: Since we're dealing with a tree structure, the flowchart directs us to use Depth-First Search.

Conclusion: The flowchart suggests using DFS (Depth-First Search) for this problem.

This makes perfect sense because:

  1. We need to explore all possible paths from leaves to the root
  2. DFS naturally traverses from root to leaves, allowing us to build paths incrementally
  3. When we reach a leaf node, we can evaluate the complete path (leaf to root)
  4. DFS with backtracking lets us efficiently explore all paths by maintaining the current path and removing nodes as we backtrack
  5. The tree structure guarantees no cycles, making DFS a safe and efficient choice

The DFS approach in the solution:

  • Recursively traverses the tree from root to leaves
  • Maintains a path list to track the current path from root to leaf
  • At each leaf, reverses the path to get the leaf-to-root string and compares it with the current minimum
  • Uses backtracking (via path.pop()) to explore all possible paths efficiently
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that we need to compare strings formed by traveling from different leaf nodes to the root. Since we want the lexicographically smallest string, we must examine all possible leaf-to-root paths.

Think about how we'd solve this manually: we'd identify all leaf nodes, trace each path back to the root while collecting characters, then compare all resulting strings. This naturally suggests a traversal approach where we visit every leaf.

DFS is perfect here because:

  1. It naturally reaches leaf nodes by going as deep as possible
  2. It maintains the current path as it traverses, which is exactly what we need to build our string
  3. When backtracking, it automatically "undoes" the path, allowing us to explore other branches

The clever part is building the path as we go down (root to leaf), then reversing it when we reach a leaf. Why? Because DFS naturally traverses from root to leaf, but we need the string from leaf to root. Rather than traversing upward (which would be complex), we simply reverse the accumulated path.

Consider a small example: if we have root 'a' with left child 'b' (a leaf), our DFS would:

  • Start at 'a', add it to path: ['a']
  • Move to 'b', add it to path: ['a', 'b']
  • Detect it's a leaf, reverse to get "ba" (leaf to root)
  • Compare with our current minimum

The initialization with chr(ord('z') + 1) is a sentinel value - any valid path will be lexicographically smaller than a character beyond 'z', ensuring our first valid path becomes the initial minimum.

The backtracking step (path.pop()) is crucial - it removes the current node from the path when we're done exploring its subtree, maintaining the correct path state as we explore other branches. This way, we efficiently explore all paths without needing to rebuild the entire path for each leaf.

Learn more about Tree, Depth-First Search, Backtracking and Binary Tree patterns.

Solution Approach

The implementation uses DFS with backtracking to explore all root-to-leaf paths and find the lexicographically smallest leaf-to-root string.

Key Components:

  1. Initialization:

    • Set ans = chr(ord('z') + 1) as a sentinel value that's guaranteed to be larger than any valid string we'll encounter
    • This ensures the first valid leaf-to-root string will replace it
  2. DFS Function Structure:

    def dfs(root, path):
        nonlocal ans
        if root:
            # Process current node
            # Recurse on children
            # Backtrack
  3. Path Building:

    • When visiting a node, convert its value to a character: chr(ord('a') + root.val)
    • Append this character to the current path list
    • The path grows as we traverse from root toward leaves
  4. Leaf Detection and String Formation:

    • A leaf is identified when root.left is None and root.right is None
    • At a leaf, reverse the path to get leaf-to-root order: ''.join(reversed(path))
    • Compare with current minimum using min() which naturally handles lexicographical comparison
  5. Recursive Exploration:

    • After processing the current node, recursively call dfs(root.left, path) and dfs(root.right, path)
    • The same path list is passed to both calls, maintaining the current path state
  6. Backtracking:

    • After exploring both children, remove the current node from the path: path.pop()
    • This ensures the path correctly represents the route to other nodes when we return to the parent

Algorithm Flow:

  1. Start DFS from root with an empty path
  2. At each node, add its character to the path
  3. If it's a leaf, create the reversed string and update the minimum if needed
  4. Explore left subtree (if exists)
  5. Explore right subtree (if exists)
  6. Remove current character from path (backtrack)
  7. Return the smallest string found

The beauty of this approach is that the path list automatically maintains the correct sequence of nodes from root to the current position, and backtracking ensures we can reuse this same list for all paths without creating new lists for each traversal.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example to illustrate the solution approach.

Consider this binary tree:

       0 (a)
      /     \
    1 (b)   2 (c)
   /
 3 (d)

We want to find the lexicographically smallest string from any leaf to the root.

Step-by-step DFS traversal:

  1. Start at root (0/'a')

    • path = ['a']
    • Not a leaf, continue exploring
  2. Go left to node (1/'b')

    • path = ['a', 'b']
    • Not a leaf (has left child), continue exploring
  3. Go left to node (3/'d')

    • path = ['a', 'b', 'd']
    • This IS a leaf!
    • Reverse path to get leaf-to-root: "dba"
    • Update ans = "dba" (was initially chr(ord('z') + 1))
    • No children to explore
  4. Backtrack to node (1/'b')

    • Pop 'd' from path: path = ['a', 'b']
    • No right child to explore
  5. Backtrack to root (0/'a')

    • Pop 'b' from path: path = ['a']
    • Now explore right subtree
  6. Go right to node (2/'c')

    • path = ['a', 'c']
    • This IS a leaf!
    • Reverse path to get leaf-to-root: "ca"
    • Compare with current ans = "dba"
    • Since "ca" < "dba" lexicographically, update ans = "ca"
    • No children to explore
  7. Backtrack to root (0/'a')

    • Pop 'c' from path: path = ['a']
    • Done exploring all paths

Final answer: "ca"

The two possible leaf-to-root strings were:

  • From leaf 3 to root: "dba"
  • From leaf 2 to root: "ca"

Since "ca" comes before "dba" in dictionary order (comparing first character: 'c' < 'd'), the answer is "ca".

The key insight is how the path list maintains the current route and how backtracking (popping from the path) allows us to efficiently explore all branches using the same list.

Solution Implementation

1# Definition for a binary tree node.
2# class TreeNode:
3#     def __init__(self, val=0, left=None, right=None):
4#         self.val = val
5#         self.left = left
6#         self.right = right
7
8class Solution:
9    def smallestFromLeaf(self, root: TreeNode) -> str:
10        # Initialize result with a character larger than 'z' for comparison
11        self.smallest_string = chr(ord('z') + 1)
12      
13        def dfs(node: TreeNode, path: list[str]) -> None:
14            """
15            Depth-first search to find the lexicographically smallest string
16            from leaf to root.
17          
18            Args:
19                node: Current tree node being processed
20                path: List storing characters from root to current node
21            """
22            if not node:
23                return
24          
25            # Convert node value (0-25) to corresponding character ('a'-'z')
26            current_char = chr(ord('a') + node.val)
27            path.append(current_char)
28          
29            # Check if current node is a leaf node
30            if node.left is None and node.right is None:
31                # Build string from leaf to root by reversing the path
32                leaf_to_root_string = ''.join(reversed(path))
33                # Update smallest string if current one is lexicographically smaller
34                self.smallest_string = min(self.smallest_string, leaf_to_root_string)
35          
36            # Recursively explore left and right subtrees
37            dfs(node.left, path)
38            dfs(node.right, path)
39          
40            # Backtrack: remove current character from path
41            path.pop()
42      
43        # Start DFS from root with empty path
44        dfs(root, [])
45      
46        return self.smallest_string
47
1/**
2 * Definition for a binary tree node.
3 * public class TreeNode {
4 *     int val;
5 *     TreeNode left;
6 *     TreeNode right;
7 *     TreeNode() {}
8 *     TreeNode(int val) { this.val = val; }
9 *     TreeNode(int val, TreeNode left, TreeNode right) {
10 *         this.val = val;
11 *         this.left = left;
12 *         this.right = right;
13 *     }
14 * }
15 */
16class Solution {
17    // StringBuilder to build the current path from root to leaf
18    private StringBuilder currentPath;
19  
20    // Stores the lexicographically smallest string found so far
21    private String smallestString;
22
23    /**
24     * Finds the lexicographically smallest string starting from a leaf node
25     * and ending at the root node of the binary tree.
26     * 
27     * @param root The root of the binary tree
28     * @return The lexicographically smallest string from leaf to root
29     */
30    public String smallestFromLeaf(TreeNode root) {
31        // Initialize the current path builder
32        currentPath = new StringBuilder();
33      
34        // Initialize with a string larger than any possible result
35        // Using 'z' + 1 ensures any valid path will be smaller
36        smallestString = String.valueOf((char) ('z' + 1));
37      
38        // Start DFS traversal from the root
39        dfs(root, currentPath);
40      
41        return smallestString;
42    }
43
44    /**
45     * Performs depth-first search to explore all root-to-leaf paths
46     * and updates the smallest string when a leaf is reached.
47     * 
48     * @param node The current node being processed
49     * @param currentPath The path from root to current node
50     */
51    private void dfs(TreeNode node, StringBuilder currentPath) {
52        // Base case: if node is null, return
53        if (node == null) {
54            return;
55        }
56      
57        // Convert node value to corresponding character (0->'a', 1->'b', etc.)
58        // and append to the current path
59        currentPath.append((char) ('a' + node.val));
60      
61        // Check if current node is a leaf node
62        if (node.left == null && node.right == null) {
63            // Reverse the path to get string from leaf to root
64            String leafToRootString = currentPath.reverse().toString();
65          
66            // Update smallest string if current path is lexicographically smaller
67            if (leafToRootString.compareTo(smallestString) < 0) {
68                smallestString = leafToRootString;
69            }
70          
71            // Reverse back to restore original order for backtracking
72            currentPath.reverse();
73        }
74      
75        // Recursively explore left subtree
76        dfs(node.left, currentPath);
77      
78        // Recursively explore right subtree
79        dfs(node.right, currentPath);
80      
81        // Backtrack: remove the current node's character from path
82        currentPath.deleteCharAt(currentPath.length() - 1);
83    }
84}
85
1/**
2 * Definition for a binary tree node.
3 * struct TreeNode {
4 *     int val;
5 *     TreeNode *left;
6 *     TreeNode *right;
7 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
8 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
9 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
10 * };
11 */
12class Solution {
13public:
14    string smallestFromLeaf(TreeNode* root) {
15        string result = "";
16        string currentPath = "";
17      
18        // Start DFS traversal from root
19        dfsTraversal(root, currentPath, result);
20      
21        return result;
22    }
23
24private:
25    /**
26     * Performs depth-first search to find the lexicographically smallest string
27     * @param node - Current node being processed
28     * @param currentPath - Path from current node to root (built top-down)
29     * @param result - Stores the lexicographically smallest string found so far
30     */
31    void dfsTraversal(TreeNode* node, string& currentPath, string& result) {
32        // Base case: if node is null, return
33        if (!node) {
34            return;
35        }
36      
37        // Append current node's character to path (0 -> 'a', 1 -> 'b', etc.)
38        currentPath += static_cast<char>('a' + node->val);
39      
40        // Check if current node is a leaf node
41        if (!node->left && !node->right) {
42            // Create a copy of the current path and reverse it
43            // (since we need string from leaf to root)
44            string leafToRootPath = currentPath;
45            reverse(leafToRootPath.begin(), leafToRootPath.end());
46          
47            // Update result if this is the first path or if it's lexicographically smaller
48            if (result.empty() || leafToRootPath < result) {
49                result = leafToRootPath;
50            }
51        }
52      
53        // Recursively traverse left subtree
54        dfsTraversal(node->left, currentPath, result);
55      
56        // Recursively traverse right subtree
57        dfsTraversal(node->right, currentPath, result);
58      
59        // Backtrack: remove current node's character from path
60        currentPath.pop_back();
61    }
62};
63
1/**
2 * Definition for a binary tree node.
3 */
4class TreeNode {
5    val: number;
6    left: TreeNode | null;
7    right: TreeNode | null;
8  
9    constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
10        this.val = (val === undefined ? 0 : val);
11        this.left = (left === undefined ? null : left);
12        this.right = (right === undefined ? null : right);
13    }
14}
15
16/**
17 * Finds the lexicographically smallest string from leaf to root
18 * @param root - The root node of the binary tree
19 * @returns The lexicographically smallest string from any leaf to root
20 */
21function smallestFromLeaf(root: TreeNode | null): string {
22    let result: string = "";
23    let currentPath: string[] = [];
24  
25    // Start DFS traversal from root
26    dfsTraversal(root, currentPath, result);
27  
28    return result;
29}
30
31/**
32 * Performs depth-first search to find the lexicographically smallest string
33 * @param node - Current node being processed
34 * @param currentPath - Path from root to current node (built top-down)
35 * @param result - Stores the lexicographically smallest string found so far
36 */
37function dfsTraversal(node: TreeNode | null, currentPath: string[], result: string): string {
38    // Base case: if node is null, return current result
39    if (!node) {
40        return result;
41    }
42  
43    // Append current node's character to path (0 -> 'a', 1 -> 'b', etc.)
44    currentPath.push(String.fromCharCode('a'.charCodeAt(0) + node.val));
45  
46    // Check if current node is a leaf node
47    if (!node.left && !node.right) {
48        // Create string from leaf to root by reversing the current path
49        const leafToRootPath: string = currentPath.slice().reverse().join('');
50      
51        // Update result if this is the first path or if it's lexicographically smaller
52        if (result === "" || leafToRootPath < result) {
53            result = leafToRootPath;
54        }
55    }
56  
57    // Recursively traverse left subtree
58    result = dfsTraversal(node.left, currentPath, result);
59  
60    // Recursively traverse right subtree
61    result = dfsTraversal(node.right, currentPath, result);
62  
63    // Backtrack: remove current node's character from path
64    currentPath.pop();
65  
66    return result;
67}
68

Time and Space Complexity

Time Complexity: O(N * L)

The algorithm performs a depth-first search (DFS) traversal of the binary tree, visiting each node exactly once, which takes O(N) time where N is the number of nodes in the tree. At each leaf node, we perform string operations:

  • ''.join(reversed(path)) creates a string from the path, which takes O(L) time where L is the length of the path (height of the tree)
  • The comparison min(ans, ''.join(reversed(path))) takes O(L) time in the worst case

Since we perform these O(L) operations at each leaf node, and there can be up to O(N/2) leaf nodes in a binary tree, the total time complexity is O(N * L). In the worst case of a skewed tree, L = N, making the time complexity O(N²). In the best case of a balanced tree, L = log(N), making it O(N * log(N)).

Space Complexity: O(L) or O(H)

The space complexity consists of:

  • The recursion call stack, which goes as deep as the height of the tree: O(H) where H is the height
  • The path list that stores characters along the current path: O(L) where L is the current path length
  • The ans string variable: O(L) for storing the smallest string

Since the path length L equals the height H at maximum, and we reuse the same path list throughout the traversal (adding and removing elements), the overall space complexity is O(H). In the worst case of a skewed tree, this becomes O(N), and in the best case of a balanced tree, it's O(log(N)).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect String Comparison Without Reversal

The Pitfall: A common mistake is forgetting to reverse the path when comparing strings, or reversing at the wrong time. Some developers might try to build the string in leaf-to-root order during traversal or compare the root-to-leaf string directly.

Wrong Implementation:

# INCORRECT - Comparing root-to-leaf instead of leaf-to-root
if node.left is None and node.right is None:
    current_string = ''.join(path)  # Missing reversal!
    self.smallest_string = min(self.smallest_string, current_string)

Why It's Wrong: The problem specifically asks for strings formed from leaf to root, not root to leaf. Without reversal, you'd be finding the lexicographically smallest root-to-leaf path instead.

Correct Solution: Always reverse the path when at a leaf node before comparison:

if node.left is None and node.right is None:
    leaf_to_root_string = ''.join(reversed(path))
    self.smallest_string = min(self.smallest_string, leaf_to_root_string)

2. Creating New Path Lists for Each Recursive Call

The Pitfall: Passing a new copy of the path to each recursive call instead of using backtracking with a shared list.

Wrong Implementation:

def dfs(node, path):
    if not node:
        return
  
    current_char = chr(ord('a') + node.val)
    new_path = path + [current_char]  # Creating new list
  
    if node.left is None and node.right is None:
        leaf_to_root = ''.join(reversed(new_path))
        self.smallest_string = min(self.smallest_string, leaf_to_root)
  
    dfs(node.left, new_path)   # Passing copies
    dfs(node.right, new_path)
    # No backtracking needed but inefficient

Why It's Problematic: While this works correctly, it's inefficient in terms of space complexity. Creating new lists for each recursive call uses O(N * H) extra space where N is the number of nodes and H is the height of the tree.

Correct Solution: Use a single list with backtracking:

def dfs(node, path):
    if not node:
        return
  
    current_char = chr(ord('a') + node.val)
    path.append(current_char)  # Modify shared list
  
    # ... process leaf ...
  
    dfs(node.left, path)   # Pass same list
    dfs(node.right, path)
  
    path.pop()  # Backtrack - crucial step!

3. Forgetting to Handle the Backtracking Step

The Pitfall: Forgetting to remove the current character from the path after exploring both children.

Wrong Implementation:

def dfs(node, path):
    if not node:
        return
  
    path.append(chr(ord('a') + node.val))
  
    if node.left is None and node.right is None:
        leaf_to_root = ''.join(reversed(path))
        self.smallest_string = min(self.smallest_string, leaf_to_root)
  
    dfs(node.left, path)
    dfs(node.right, path)
    # MISSING: path.pop() - forgot to backtrack!

Why It's Wrong: Without backtracking, the path will contain characters from previously visited branches when exploring new branches, leading to incorrect strings that don't represent actual root-to-leaf paths.

Example of the Bug: For a tree with root 'a', left child 'b', and right child 'c' (both leaves):

  • After visiting left leaf 'b', path = ['a', 'b']
  • Without pop(), when visiting right child 'c', path becomes ['a', 'b', 'c']
  • The string for right leaf would incorrectly be 'cba' instead of 'ca'

Correct Solution: Always include the backtracking step:

dfs(node.left, path)
dfs(node.right, path)
path.pop()  # Essential for maintaining correct path state
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following is a good use case for backtracking?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More