โ† Back to DevBytes

Solving Subtree of Another Tree in Go: Step-by-Step Guide

Introduction to the Subtree Problem

The "Subtree of Another Tree" problem is a classic algorithmic challenge frequently encountered in coding interviews and real-world tree manipulation scenarios. Given two binary trees โ€” a root tree and a subRoot tree โ€” the task is to determine whether the subRoot tree is an exact structural and value match of any subtree within the root tree. This means every node, edge, and value in subRoot must appear identically somewhere inside root, with no extra children dangling off the matching portion.

This problem matters because it tests two fundamental skills simultaneously: tree traversal and deep equality checking. It appears in contexts like document comparison, DOM diffing, syntax tree analysis, and hierarchical data validation. Mastering it sharpens your ability to reason recursively about nested structures, a skill that transfers directly to more complex tree and graph problems.

Understanding the Problem Statement

Before writing any code, let's clarify exactly what qualifies as a subtree. A subtree of a binary tree is a node in that tree plus all of its descendants. The entire tree is also considered a subtree of itself. Crucially, the match must be complete โ€” if subRoot has no left child at a particular node, then the corresponding node in root must also have no left child.

Consider this example:

Root tree:          SubRoot tree:
    3                   4
   / \                 / \
  4   5               1   2
 / \
1   2

Here, the subtree rooted at node 4 in the root tree exactly matches subRoot, so the answer is true. However, if the root tree had an extra child under node 2, the match would fail because subRoot does not contain that child.

Defining the Tree Structure in Go

Go does not have built-in tree types, so we define our own. The standard representation uses a struct with a value and pointers to left and right children:

package main

import "fmt"

// TreeNode represents a node in a binary tree.
type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}

This minimal struct is sufficient. The Val field holds integer data, while Left and Right are pointers that may be nil, indicating the absence of a child. Using pointers allows us to distinguish between "no child" and "a child with a zero value."

Approach One: Recursive Depth-First Search

The most intuitive solution combines two recursive operations. First, we traverse every node in the root tree. At each node, we ask: "Does the tree starting here exactly match subRoot?" If any node answers yes, we return true. This decomposes the problem into a traversal plus a same-tree check.

Implementing the Same-Tree Helper

The helper function checks whether two trees are structurally and value-wise identical. Both must be nil, or both must be non-nil with matching values and matching children:

// isSameTree reports whether two trees are identical.
func isSameTree(p, q *TreeNode) bool {
    if p == nil && q == nil {
        return true
    }
    if p == nil || q == nil {
        return false
    }
    if p.Val != q.Val {
        return false
    }
    return isSameTree(p.Left, q.Left) && isSameTree(p.Right, q.Right)
}

The order of checks matters. We first handle the case where both are nil, then the case where exactly one is nil, then the value comparison. Only when values match do we recurse into both children. This short-circuits early on mismatches, avoiding unnecessary work.

Implementing the Main Function

With the helper in place, the main function walks the root tree and applies the helper at each node:

// isSubtree reports whether subRoot is a subtree of root.
func isSubtree(root, subRoot *TreeNode) bool {
    if root == nil {
        return subRoot == nil
    }
    if isSameTree(root, subRoot) {
        return true
    }
    return isSubtree(root.Left, subRoot) || isSubtree(root.Right, subRoot)
}

If root is nil, the only possible subtree is also nil. Otherwise, we check the current node for a match, and if that fails, we recurse into both children. The logical OR ensures we stop as soon as any branch yields a match.

Putting It Together

Here is a complete runnable example:

package main

import "fmt"

type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}

func isSameTree(p, q *TreeNode) bool {
    if p == nil && q == nil {
        return true
    }
    if p == nil || q == nil {
        return false
    }
    if p.Val != q.Val {
        return false
    }
    return isSameTree(p.Left, q.Left) && isSameTree(p.Right, q.Right)
}

func isSubtree(root, subRoot *TreeNode) bool {
    if root == nil {
        return subRoot == nil
    }
    if isSameTree(root, subRoot) {
        return true
    }
    return isSubtree(root.Left, subRoot) || isSubtree(root.Right, subRoot)
}

func main() {
    root := &TreeNode{Val: 3,
        Left: &TreeNode{Val: 4,
            Left:  &TreeNode{Val: 1},
            Right: &TreeNode{Val: 2},
        },
        Right: &TreeNode{Val: 5},
    }

    subRoot := &TreeNode{Val: 4,
        Left:  &TreeNode{Val: 1},
        Right: &TreeNode{Val: 2},
    }

    fmt.Println("Is subtree?", isSubtree(root, subRoot)) // true
}

Complexity Analysis

Let n be the number of nodes in root and m be the number of nodes in subRoot. In the worst case, we call isSameTree at every node of root, and each call may traverse up to m nodes. This gives a time complexity of O(n * m). The space complexity is O(max(n, m)) due to recursion stack depth, which corresponds to the height of the trees.

This quadratic behavior is acceptable for moderately sized trees but becomes a bottleneck for very large inputs. The next section explores an optimization.

Approach Two: Serialization and String Matching

A more efficient approach serializes both trees into strings and then checks whether the subRoot's serialization appears as a substring of the root's serialization. With a good substring search algorithm like KMP, this reduces time complexity to O(n + m).

The key is to choose a serialization format that preserves structure unambiguously. A pre-order traversal with explicit null markers works well:

import "strings"

// serialize converts a tree into a unique string representation.
func serialize(node *TreeNode) string {
    if node == nil {
        return "#"
    }
    return "^" + intToStr(node.Val) + " " +
        serialize(node.Left) + " " + serialize(node.Right)
}

// intToStr is a minimal integer-to-string helper.
func intToStr(n int) string {
    if n == 0 {
        return "0"
    }
    negative := n < 0
    if negative {
        n = -n
    }
    digits := []byte{}
    for n > 0 {
        digits = append([]byte{byte('0' + n%10)}, digits...)
        n /= 10
    }
    if negative {
        digits = append([]byte{'-'}, digits...)
    }
    return string(digits)
}

// isSubtreeSerialized uses string matching to detect the subtree.
func isSubtreeSerialized(root, subRoot *TreeNode) bool {
    rootStr := serialize(root)
    subStr := serialize(subRoot)
    return strings.Contains(rootStr, subStr)
}

The ^ prefix before each value prevents false matches caused by overlapping digits. For example, without a delimiter, a node with value 12 could falsely match part of a node with value 123. The # marker represents nil and ensures structural differences are captured in the string.

Note that strings.Contains in Go uses a naive algorithm, so the practical complexity remains O(n * m). To achieve true O(n + m), you would implement KMP or use a rolling hash. For most interview and production scenarios, the built-in function is fast enough due to optimized assembly implementations.

Best Practices

Testing Your Implementation

Robust testing is essential. Here is a table-driven test suite covering common cases:

package main

import "testing"

func buildTree(vals []interface{}) *TreeNode {
    if len(vals) == 0 || vals[0] == nil {
        return nil
    }
    root := &TreeNode{Val: vals[0].(int)}
    queue := []*TreeNode{root}
    i := 1
    for len(queue) > 0 && i < len(vals) {
        node := queue[0]
        queue = queue[1:]
        if i < len(vals) && vals[i] != nil {
            node.Left = &TreeNode{Val: vals[i].(int)}
            queue = append(queue, node.Left)
        }
        i++
        if i < len(vals) && vals[i] != nil {
            node.Right = &TreeNode{Val: vals[i].(int)}
            queue = append(queue, node.Right)
        }
        i++
    }
    return root
}

func TestIsSubtree(t *testing.T) {
    tests := []struct {
        name     string
        root     []interface{}
        subRoot  []interface{}
        expected bool
    }{
        {"basic match", []interface{}{3, 4, 5, 1, 2}, []interface{}{4, 1, 2}, true},
        {"no match", []interface{}{3, 4, 5, 1, 2, nil, nil, nil, nil, 0}, []interface{}{4, 1, 2}, false},
        {"identical trees", []interface{}{1, 2, 3}, []interface{}{1, 2, 3}, true},
        {"single node match", []interface{}{1}, []interface{}{1}, true},
        {"empty subRoot", []interface{}{1, 2}, []interface{}{}, true},
        {"subRoot larger", []interface{}{1}, []interface{}{1, 2}, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            root := buildTree(tt.root)
            subRoot := buildTree(tt.subRoot)
            result := isSubtree(root, subRoot)
            if result != tt.expected {
                t.Errorf("expected %v, got %v", tt.expected, result)
            }
        })
    }
}

The buildTree helper constructs trees from level-order slices, where nil represents an absent node. This makes test cases concise and readable while exercising diverse tree shapes.

Conclusion

Solving the Subtree of Another Tree problem in Go elegantly demonstrates the power of recursive thinking applied to hierarchical data. The straightforward two-function approach โ€” combining a traversal with a same-tree check โ€” is clear, correct, and sufficient for most use cases. When performance demands it, the serialization strategy offers a path to linear time by reframing a structural problem as a string matching problem. By understanding both approaches, their trade-offs, and the edge cases that trip up naive implementations, you equip yourself with reusable techniques that extend far beyond this single problem to any domain involving tree comparison, document diffing, or pattern matching in nested structures.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles