Introduction to the Jump Game Problem
The Jump Game is one of the most popular algorithmic problems you'll encounter in coding interviews and competitive programming. It tests your understanding of greedy algorithms, dynamic programming, and array traversal. In this tutorial, we'll explore how to solve the Jump Game problem using Go, walking through multiple approaches from brute force to optimal solutions.
What Is the Jump Game?
Given an array of non-negative integers nums, you start at the first index. Each element in the array represents the maximum jump length you can take from that position. The goal is to determine whether you can reach the last index of the array.
For example, given [2, 3, 1, 1, 4], you can jump from index 0 to index 1, then from index 1 to the last index. However, with [3, 2, 1, 0, 4], you'll get stuck at index 3 because the maximum jump is 0.
Why It Matters
The Jump Game problem matters because it teaches fundamental concepts that apply to real-world scenarios:
- Pathfinding and reachability: Similar logic applies to network routing and graph traversal problems.
- Greedy thinking: It demonstrates how local optimal choices can lead to global solutions.
- Interview readiness: It's a staple question at companies like Google, Amazon, and Microsoft.
- Optimization skills: You'll learn to reduce time complexity from exponential to linear.
Understanding the Problem Statement
Before writing any code, let's clearly define the problem. You are given an array nums where nums[i] represents the maximum number of steps you can jump forward from index i. You need to return true if you can reach the last index, otherwise false.
Key observations:
- You can jump any distance from 1 to
nums[i]at indexi. - You always start at index 0.
- An array with a single element is always reachable (you're already at the end).
- A zero in the middle of the array can block your path unless you can jump over it.
Approach 1: Recursive Solution (Brute Force)
The most intuitive approach is to try every possible jump from each position. At every index, you can jump between 1 and nums[i] steps, and you recursively check if any of those jumps leads to the last index.
package main
import "fmt"
func canJumpRecursive(nums []int, position int) bool {
if position >= len(nums)-1 {
return true
}
maxJump := nums[position]
for jump := 1; jump <= maxJump; jump++ {
if canJumpRecursive(nums, position+jump) {
return true
}
}
return false
}
func canJump(nums []int) bool {
return canJumpRecursive(nums, 0)
}
func main() {
fmt.Println(canJump([]int{2, 3, 1, 1, 4})) // true
fmt.Println(canJump([]int{3, 2, 1, 0, 4})) // false
}
While this solution works, it has a time complexity of O(n^n) in the worst case, making it impractical for large inputs. Each position can branch into up to n recursive calls, leading to exponential growth.
Approach 2: Dynamic Programming with Memoization
We can improve the recursive solution by caching results. If we've already determined whether a position can reach the end, we don't need to recompute it. This technique is called memoization.
package main
import "fmt"
func canJumpMemo(nums []int, position int, memo []int) bool {
if position >= len(nums)-1 {
return true
}
if memo[position] != 0 {
return memo[position] == 1
}
maxJump := nums[position]
for jump := 1; jump <= maxJump; jump++ {
if canJumpMemo(nums, position+jump, memo) {
memo[position] = 1
return true
}
}
memo[position] = -1
return false
}
func canJump(nums []int) bool {
memo := make([]int, len(nums))
return canJumpMemo(nums, 0, memo)
}
func main() {
fmt.Println(canJump([]int{2, 3, 1, 1, 4})) // true
fmt.Println(canJump([]int{3, 2, 1, 0, 4})) // false
}
Here, memo[i] stores 1 if position i can reach the end, -1 if it cannot, and 0 if unvisited. This reduces the time complexity to O(n^2) since each position is computed only once, and each computation iterates through at most n jumps.
Approach 3: Dynamic Programming (Bottom-Up)
Instead of recursing from the start, we can work backward from the last index. We mark the last position as reachable, then for each preceding index, we check if it can reach any already-reachable position.
package main
import "fmt"
func canJump(nums []int) bool {
n := len(nums)
if n == 0 {
return false
}
reachable := make([]bool, n)
reachable[n-1] = true
for i := n - 2; i >= 0; i-- {
maxJump := nums[i]
for jump := 1; jump <= maxJump && i+jump < n; jump++ {
if reachable[i+jump] {
reachable[i] = true
break
}
}
}
return reachable[0]
}
func main() {
fmt.Println(canJump([]int{2, 3, 1, 1, 4})) // true
fmt.Println(canJump([]int{3, 2, 1, 0, 4})) // false
}
This bottom-up approach eliminates recursion overhead and is easier to reason about. The time complexity remains O(n^2), but the space complexity is O(n) for the reachable array, with no recursion stack to worry about.
Approach 4: Greedy Algorithm (Optimal Solution)
The greedy approach is the most efficient way to solve the Jump Game. Instead of tracking which positions are reachable, we maintain a single variable representing the farthest index we can reach so far. As we iterate through the array, we update this maximum reach. If at any point our current index exceeds the maximum reach, we know we cannot proceed.
package main
import "fmt"
func canJump(nums []int) bool {
maxReach := 0
n := len(nums)
for i := 0; i < n; i++ {
if i > maxReach {
return false
}
if i+nums[i] > maxReach {
maxReach = i + nums[i]
}
if maxReach >= n-1 {
return true
}
}
return maxReach >= n-1
}
func main() {
fmt.Println(canJump([]int{2, 3, 1, 1, 4})) // true
fmt.Println(canJump([]int{3, 2, 1, 0, 4})) // false
fmt.Println(canJump([]int{0})) // true
fmt.Println(canJump([]int{0, 1})) // false
}
This solution runs in O(n) time with O(1) space, making it optimal. The key insight is that we don't need to know exactly how we reach the end—only whether it's possible. By tracking the farthest reachable index, we make a single pass through the array.
How the Greedy Approach Works Step by Step
Let's trace through [2, 3, 1, 1, 4]:
- Index 0:
maxReach = 0,i <= maxReach, updatemaxReach = max(0, 0+2) = 2 - Index 1:
maxReach = 2,i <= maxReach, updatemaxReach = max(2, 1+3) = 4 maxReach (4) >= n-1 (4), returntrue
Now let's trace through [3, 2, 1, 0, 4]:
- Index 0:
maxReach = 3 - Index 1:
maxReach = max(3, 1+2) = 3 - Index 2:
maxReach = max(3, 2+1) = 3 - Index 3:
maxReach = max(3, 3+0) = 3 - Index 4:
i (4) > maxReach (3), returnfalse
Variant: Jump Game II (Minimum Jumps)
A common follow-up asks for the minimum number of jumps needed to reach the last index. This variant uses a similar greedy approach but tracks jumps and the current range of reachable positions.
package main
import "fmt"
func jump(nums []int) int {
n := len(nums)
if n <= 1 {
return 0
}
jumps := 0
currentEnd := 0
farthest := 0
for i := 0; i < n-1; i++ {
if i+nums[i] > farthest {
farthest = i + nums[i]
}
if i == currentEnd {
jumps++
currentEnd = farthest
if currentEnd >= n-1 {
break
}
}
}
return jumps
}
func main() {
fmt.Println(jump([]int{2, 3, 1, 1, 4})) // 2
fmt.Println(jump([]int{2, 3, 0, 1, 4})) // 2
}
The idea is to divide the array into jump ranges. currentEnd marks the boundary of the current jump, and farthest tracks the maximum reach within that range. When we hit currentEnd, we must take a jump, and we set the new boundary to farthest.
Best Practices for Solving Jump Game Problems
Start with the Greedy Approach
Whenever you encounter a reachability problem involving maximum jumps or steps, consider a greedy solution first. Tracking the farthest reachable index is a powerful pattern that often leads to O(n) solutions.
Handle Edge Cases Early
Always consider edge cases such as empty arrays, single-element arrays, arrays starting with zero, and arrays where the first element is large enough to reach the end in one jump. Handling these upfront prevents bugs and improves code clarity.
func canJump(nums []int) bool {
n := len(nums)
if n == 0 {
return false
}
if n == 1 {
return true
}
if nums[0] == 0 {
return false
}
// ... rest of the logic
}
Use Descriptive Variable Names
Names like maxReach, farthest, and currentEnd make your code self-documenting. Avoid single-letter variables except for loop counters, as clarity matters more than brevity in interview and production settings.
Test with Diverse Inputs
Create a comprehensive test suite covering various scenarios:
package main
import "testing"
func TestCanJump(t *testing.T) {
tests := []struct {
nums []int
expected bool
}{
{[]int{2, 3, 1, 1, 4}, true},
{[]int{3, 2, 1, 0, 4}, false},
{[]int{0}, true},
{[]int{0, 1}, false},
{[]int{1, 0, 1, 0}, false},
{[]int{5, 0, 0, 0, 0, 0}, true},
{[]int{1, 1, 1, 1, 1}, true},
{[]int{1, 0, 0, 0, 1}, false},
}
for _, tt := range tests {
result := canJump(tt.nums)
if result != tt.expected {
t.Errorf("canJump(%v) = %v, expected %v", tt.nums, result, tt.expected)
}
}
}
Avoid Unnecessary Memory Allocation
The greedy approach uses O(1) space, which is ideal. If you find yourself allocating arrays for tracking state, reconsider whether a simpler approach exists. Go's value semantics mean slice allocations have real costs, so prefer in-place tracking variables when possible.
Common Pitfalls to Avoid
- Off-by-one errors: Remember that
nums[i]is the maximum jump, not the index to jump to. The destination isi + nums[i]. - Forgetting to check reachability before processing: Always verify
i <= maxReachbefore updating the maximum reach. If the current index is unreachable, the answer is immediately false. - Confusing Jump Game I with Jump Game II: The first asks whether you can reach the end; the second asks for the minimum jumps. They require different logic despite similar setups.
- Ignoring the single-element case: An array with one element is trivially solvable since you're already at the destination.
Performance Comparison
Here's a summary of the time and space complexities for each approach we covered:
- Recursive (brute force): O(n^n) time, O(n) space (recursion stack)
- Memoization (top-down DP): O(n^2) time, O(n) space
- Bottom-up DP: O(n^2) time, O(n) space
- Greedy (optimal): O(n) time, O(1) space
For any input larger than a few dozen elements, only the greedy approach performs well. The recursive solution will time out, and even the DP solutions may struggle with very large inputs.
Conclusion
The Jump Game problem is an excellent exercise in algorithmic thinking that progresses naturally from brute force to an elegant greedy solution. By starting with recursion, adding memoization, converting to bottom-up dynamic programming, and finally arriving at the greedy approach, you develop a deep understanding of how optimization works at each level. The key takeaway is that tracking the farthest reachable index in a single pass gives you an O(n) solution with O(1) space—the optimal answer for this problem. Master this pattern, and you'll be well-equipped to tackle similar reachability and greedy problems in Go, whether in interviews or real-world applications.