← Back to DevBytes

Solving House Robber Problem in Go: Step-by-Step Guide

Introduction to the House Robber Problem

The House Robber problem is one of the most classic dynamic programming challenges you will encounter in coding interviews and algorithmic problem-solving. It is a perfect entry point for understanding how overlapping subproblems can be solved efficiently using memoization or bottom-up tabulation.

In this tutorial, you will learn what the House Robber problem is, why it matters, how to solve it in Go using multiple approaches, and the best practices you should follow when implementing dynamic programming solutions.

What Is the House Robber Problem?

Imagine you are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, represented by an integer array. However, adjacent houses have security systems connected, and if you rob two adjacent houses on the same night, the alarm will trigger and alert the police.

Given an array of non-negative integers representing the amount of money at each house, your task is to determine the maximum amount of money you can rob tonight without alerting the police.

Example

Consider the input array [2, 7, 9, 3, 1]. The maximum amount you can rob is 12, by robbing houses at index 0 (value 2), index 2 (value 9), and index 4 (value 1). Robbing houses 1 and 3 would only yield 10, which is less.

Why the Problem Matters

The House Robber problem is more than just an interview question. It teaches several fundamental concepts that apply to real-world scenarios:

Understanding the Recurrence Relation

The key to solving this problem is recognizing the decision at each house. For every house at index i, you have two choices:

This gives us the recurrence relation:

dp[i] = max(dp[i-1], dp[i-2] + nums[i])

Where dp[i] represents the maximum amount that can be robbed from the first i + 1 houses.

Approach 1: Recursive Solution

The most intuitive approach is a recursive one. At each house, you decide whether to rob it or skip it, then recursively solve the remaining subproblem. While this works, it has exponential time complexity due to repeated calculations.

package main

import "fmt"

func robRecursive(nums []int, i int) int {
    if i < 0 {
        return 0
    }
    robCurrent := nums[i] + robRecursive(nums, i-2)
    skipCurrent := robRecursive(nums, i-1)
    if robCurrent > skipCurrent {
        return robCurrent
    }
    return skipCurrent
}

func rob(nums []int) int {
    return robRecursive(nums, len(nums)-1)
}

func main() {
    nums := []int{2, 7, 9, 3, 1}
    fmt.Println("Maximum amount:", rob(nums))
}

This solution is correct but inefficient. For an array of length n, the time complexity is O(2^n), which becomes unusable for even moderately sized inputs.

Approach 2: Top-Down Dynamic Programming with Memoization

To avoid recalculating the same subproblems, we can cache the results of each recursive call. This technique is called memoization and reduces the time complexity to O(n).

package main

import "fmt"

func robMemo(nums []int, i int, memo map[int]int) int {
    if i < 0 {
        return 0
    }
    if val, ok := memo[i]; ok {
        return val
    }
    robCurrent := nums[i] + robMemo(nums, i-2, memo)
    skipCurrent := robMemo(nums, i-1, memo)
    if robCurrent > skipCurrent {
        memo[i] = robCurrent
    } else {
        memo[i] = skipCurrent
    }
    return memo[i]
}

func rob(nums []int) int {
    memo := make(map[int]int)
    return robMemo(nums, len(nums)-1, memo)
}

func main() {
    nums := []int{2, 7, 9, 3, 1}
    fmt.Println("Maximum amount:", rob(nums))
}

By storing computed results in a map, each subproblem is solved only once. This is a significant improvement, though the recursive call stack still consumes O(n) space.

Approach 3: Bottom-Up Dynamic Programming with Tabulation

The bottom-up approach eliminates recursion entirely. We build a dp array from left to right, filling each entry based on the recurrence relation. This approach is often preferred in Go because it avoids potential stack overflow issues with deep recursion.

package main

import "fmt"

func rob(nums []int) int {
    n := len(nums)
    if n == 0 {
        return 0
    }
    if n == 1 {
        return nums[0]
    }
    dp := make([]int, n)
    dp[0] = nums[0]
    if nums[1] > nums[0] {
        dp[1] = nums[1]
    } else {
        dp[1] = nums[0]
    }
    for i := 2; i < n; i++ {
        robCurrent := dp[i-2] + nums[i]
        skipCurrent := dp[i-1]
        if robCurrent > skipCurrent {
            dp[i] = robCurrent
        } else {
            dp[i] = skipCurrent
        }
    }
    return dp[n-1]
}

func main() {
    nums := []int{2, 7, 9, 3, 1}
    fmt.Println("Maximum amount:", rob(nums))
}

This solution runs in O(n) time and uses O(n) space. It is clear, easy to debug, and performs well for most practical inputs.

Approach 4: Space-Optimized Solution

Looking closely at the recurrence relation, we only ever need the previous two values to compute the current one. This means we can replace the entire dp array with two variables, reducing space complexity to O(1).

package main

import "fmt"

func rob(nums []int) int {
    prev2 := 0
    prev1 := 0
    for _, money := range nums {
        current := prev1
        if prev2+money > prev1 {
            current = prev2 + money
        }
        prev2 = prev1
        prev1 = current
    }
    return prev1
}

func main() {
    nums := []int{2, 7, 9, 3, 1}
    fmt.Println("Maximum amount:", rob(nums))
}

This is the most efficient solution. It maintains the same O(n) time complexity while using only constant extra space. In production code, this is the version you would typically ship.

Handling Edge Cases

A robust solution must handle edge cases gracefully. Here are the scenarios you should consider:

The space-optimized solution handles all of these naturally because prev2 and prev1 start at 0, and the loop simply processes each house in sequence.

Testing Your Solution

Writing tests is essential to verify correctness. Go's built-in testing framework makes this straightforward. Below is a test file you can use alongside your implementation.

package main

import "testing"

func TestRob(t *testing.T) {
    tests := []struct {
        name     string
        nums     []int
        expected int
    }{
        {"empty array", []int{}, 0},
        {"single house", []int{5}, 5},
        {"two houses", []int{3, 7}, 7},
        {"classic example", []int{2, 7, 9, 3, 1}, 12},
        {"all same values", []int{4, 4, 4, 4}, 8},
        {"increasing values", []int{1, 2, 3, 4, 5}, 9},
        {"decreasing values", []int{5, 4, 3, 2, 1}, 9},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := rob(tt.nums)
            if result != tt.expected {
                t.Errorf("rob(%v) = %d, expected %d", tt.nums, result, tt.expected)
            }
        })
    }
}

Run the tests using the command go test -v to see detailed output for each case.

Best Practices

When implementing dynamic programming solutions in Go, keep the following best practices in mind:

Extending to the Circular House Robber Variant

A common follow-up question is the circular variant, where the first and last houses are also considered adjacent. To solve this, you run the standard solution twice: once excluding the first house, and once excluding the last house. The answer is the maximum of the two results.

package main

import "fmt"

func robLinear(nums []int) int {
    prev2 := 0
    prev1 := 0
    for _, money := range nums {
        current := prev1
        if prev2+money > prev1 {
            current = prev2 + money
        }
        prev2 = prev1
        prev1 = current
    }
    return prev1
}

func robCircular(nums []int) int {
    n := len(nums)
    if n == 0 {
        return 0
    }
    if n == 1 {
        return nums[0]
    }
    excludeFirst := robLinear(nums[1:])
    excludeLast := robLinear(nums[:n-1])
    if excludeFirst > excludeLast {
        return excludeFirst
    }
    return excludeLast
}

func main() {
    nums := []int{2, 3, 2}
    fmt.Println("Maximum amount (circular):", robCircular(nums))
}

This approach elegantly reuses the linear solution and handles the circular constraint by considering two separate scenarios.

Conclusion

The House Robber problem is a fantastic way to build your intuition for dynamic programming. By progressing from a naive recursive solution to a space-optimized one, you learn how to identify recurrence relations, eliminate redundant computations, and reduce memory usage. In Go, the bottom-up and space-optimized approaches are particularly effective because they avoid recursion depth issues and produce clean, idiomatic code. Whether you are preparing for interviews or sharpening your algorithmic skills, mastering this problem gives you a strong foundation for tackling more complex dynamic programming challenges. Practice the variants, write thorough tests, and always look for opportunities to optimize both time and space in your solutions.

— Ad —

Google AdSense will appear here after approval

← Back to all articles