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
- For example,
The solution uses depth-first search (DFS) to explore all paths from the root to each leaf. During traversal:
- It builds the path from root to leaf by appending characters
- When reaching a leaf node, it reverses the path (since we need leaf-to-root order) and compares it with the current smallest string
- It backtracks by removing the last character when returning from a node
- 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:
- We need to explore all possible paths from leaves to the root
- DFS naturally traverses from root to leaves, allowing us to build paths incrementally
- When we reach a leaf node, we can evaluate the complete path (leaf to root)
- DFS with backtracking lets us efficiently explore all paths by maintaining the current path and removing nodes as we backtrack
- 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
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:
- It naturally reaches leaf nodes by going as deep as possible
- It maintains the current path as it traverses, which is exactly what we need to build our string
- 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:
-
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
- Set
-
DFS Function Structure:
def dfs(root, path): nonlocal ans if root: # Process current node # Recurse on children # Backtrack
-
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
- When visiting a node, convert its value to a character:
-
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
- A leaf is identified when
-
Recursive Exploration:
- After processing the current node, recursively call
dfs(root.left, path)
anddfs(root.right, path)
- The same path list is passed to both calls, maintaining the current path state
- After processing the current node, recursively call
-
- 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
- After exploring both children, remove the current node from the path:
Algorithm Flow:
- Start DFS from root with an empty path
- At each node, add its character to the path
- If it's a leaf, create the reversed string and update the minimum if needed
- Explore left subtree (if exists)
- Explore right subtree (if exists)
- Remove current character from path (backtrack)
- 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 EvaluatorExample 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:
-
Start at root (0/'a')
path = ['a']
- Not a leaf, continue exploring
-
Go left to node (1/'b')
path = ['a', 'b']
- Not a leaf (has left child), continue exploring
-
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 initiallychr(ord('z') + 1)
) - No children to explore
-
Backtrack to node (1/'b')
- Pop 'd' from path:
path = ['a', 'b']
- No right child to explore
- Pop 'd' from path:
-
Backtrack to root (0/'a')
- Pop 'b' from path:
path = ['a']
- Now explore right subtree
- Pop 'b' from path:
-
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, updateans = "ca"
- No children to explore
-
Backtrack to root (0/'a')
- Pop 'c' from path:
path = ['a']
- Done exploring all paths
- Pop 'c' from path:
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 takesO(L)
time whereL
is the length of the path (height of the tree)- The comparison
min(ans, ''.join(reversed(path)))
takesO(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)
whereH
is the height - The
path
list that stores characters along the current path:O(L)
whereL
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
Which of the following is a good use case for backtracking?
Recommended Readings
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Want a Structured Path to Master System Design Too? Don’t Miss This!