Introduction to the Container With Most Water Problem
The "Container With Most Water" problem is one of the most classic algorithmic challenges you'll encounter in coding interviews and competitive programming. Given an array of non-negative integers where each integer represents the height of a vertical line drawn at that index, the task is to find two lines that, together with the x-axis, form a container that can hold the maximum amount of water.
In this tutorial, we'll walk through solving this problem in Go (Golang) from scratch. We'll start with a brute-force approach, understand its limitations, and then optimize it using the two-pointer technique. By the end, you'll have a solid grasp of both the problem and how to implement an efficient solution in Go.
Understanding the Problem
Imagine you have an array like [1, 8, 6, 2, 5, 4, 8, 3, 7]. Each value represents the height of a vertical bar at that index. If you pick any two bars, the water container formed between them has a width equal to the distance between the two indices and a height equal to the shorter of the two bars (because water would overflow the shorter side).
The area of water held is calculated as:
Area = min(height[left], height[right]) * (right - left)
Your goal is to find the maximum possible area among all pairs of bars.
Why This Problem Matters
- It tests your ability to recognize optimization opportunities in seemingly O(n²) problems.
- It demonstrates mastery of the two-pointer technique, a fundamental pattern in algorithm design.
- It appears frequently in interviews at major tech companies like Google, Amazon, and Meta.
- It teaches you how to reason about trade-offs between width and height when searching for an optimum.
The Brute-Force Approach
Before jumping to the optimal solution, let's implement the brute-force method. This approach checks every possible pair of lines and keeps track of the maximum area found. While correct, it runs in O(n²) time, which becomes impractical for large inputs.
package main
import "fmt"
func maxAreaBruteForce(height []int) int {
maxArea := 0
n := len(height)
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
// Calculate the width between the two lines
width := j - i
// The height is limited by the shorter line
h := height[i]
if height[j] < h {
h = height[j]
}
area := width * h
if area > maxArea {
maxArea = area
}
}
}
return maxArea
}
func main() {
height := []int{1, 8, 6, 2, 5, 4, 8, 3, 7}
fmt.Println("Max area (brute force):", maxAreaBruteForce(height))
}
Running this code outputs 49, which is the correct answer. However, for an array of 100,000 elements, this approach would perform roughly 5 billion comparisons, making it far too slow for real-world use.
The Two-Pointer Technique
The key insight for optimization is that we don't need to check every pair. By starting with the widest possible container (the first and last lines) and gradually moving the pointers inward, we can eliminate pairs that cannot possibly hold more water than what we've already found.
Here's the reasoning: when we move a pointer inward, the width decreases. The only way the area could increase is if the height increases. Since the height is determined by the shorter line, we should move the pointer pointing to the shorter line inward, hoping to find a taller line that compensates for the reduced width.
Step-by-Step Algorithm
- Initialize two pointers:
leftat index 0 andrightat the last index. - Initialize a variable
maxAreato 0. - While
leftis less thanright, calculate the current area. - Update
maxAreaif the current area is larger. - Move the pointer pointing to the shorter line inward.
- Repeat until the pointers meet.
Implementing the Optimal Solution in Go
Now let's translate the two-pointer approach into clean, idiomatic Go code:
package main
import "fmt"
func maxArea(height []int) int {
left := 0
right := len(height) - 1
maxArea := 0
for left < right {
width := right - left
h := height[left]
if height[right] < h {
h = height[right]
}
area := width * h
if area > maxArea {
maxArea = area
}
// Move the pointer pointing to the shorter line
if height[left] < height[right] {
left++
} else {
right--
}
}
return maxArea
}
func main() {
testCases := [][]int{
{1, 8, 6, 2, 5, 4, 8, 3, 7},
{1, 1},
{4, 3, 2, 1, 4},
{1, 2, 1},
{1, 2, 4, 3},
}
for _, tc := range testCases {
fmt.Printf("Input: %v -> Max area: %d\n", tc, maxArea(tc))
}
}
When you run this program, you should see output like:
Input: [1 8 6 2 5 4 8 3 7] -> Max area: 49
Input: [1 1] -> Max area: 1
Input: [4 3 2 1 4] -> Max area: 16
Input: [1 2 1] -> Max area: 2
Input: [1 2 4 3] -> Max area: 4
Using Go's Built-in Functions for Cleaner Code
Go's standard library provides helper functions that can make our code more readable. For example, we can use math.Min for comparing heights, though we need to be careful about type conversions since math.Min works with float64 values. A cleaner approach is to define a small helper function:
package main
import "fmt"
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func maxAreaClean(height []int) int {
left, right := 0, len(height)-1
result := 0
for left < right {
area := (right - left) * min(height[left], height[right])
result = max(result, area)
if height[left] < height[right] {
left++
} else {
right--
}
}
return result
}
func main() {
height := []int{1, 8, 6, 2, 5, 4, 8, 3, 7}
fmt.Println("Max area:", maxAreaClean(height))
}
Note that starting with Go 1.21, the min and max functions are built into the language, so you no longer need to define them yourself. If you're using Go 1.21 or later, you can remove the helper functions entirely.
Writing Tests for Your Solution
A robust solution deserves proper testing. Go's built-in testing framework makes this straightforward. Create a file named max_area_test.go in the same package:
package main
import "testing"
func TestMaxArea(t *testing.T) {
tests := []struct {
name string
height []int
expected int
}{
{"standard case", []int{1, 8, 6, 2, 5, 4, 8, 3, 7}, 49},
{"two equal bars", []int{1, 1}, 1},
{"symmetric tall bars", []int{4, 3, 2, 1, 4}, 16},
{"small array", []int{1, 2, 1}, 2},
{"increasing then decreasing", []int{1, 2, 4, 3}, 4},
{"single element", []int{5}, 0},
{"empty array", []int{}, 0},
{"all same height", []int{3, 3, 3, 3, 3}, 12},
{"decreasing heights", []int{5, 4, 3, 2, 1}, 6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maxArea(tt.height)
if result != tt.expected {
t.Errorf("maxArea(%v) = %d, expected %d",
tt.height, result, tt.expected)
}
})
}
}
func BenchmarkMaxArea(b *testing.B) {
height := make([]int, 10000)
for i := range height {
height[i] = i % 100
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
maxArea(height)
}
}
Run the tests with go test -v and the benchmark with go test -bench=.. The benchmark will show that the two-pointer solution handles large inputs efficiently, typically completing in microseconds even for arrays with tens of thousands of elements.
Complexity Analysis
Understanding the time and space complexity of your solution is crucial, especially in interview settings:
- Time Complexity: O(n) — We traverse the array once with the two pointers, each element is visited at most once.
- Space Complexity: O(1) — We only use a constant amount of extra space for the pointers and the max area variable, regardless of input size.
Compare this to the brute-force approach, which has O(n²) time complexity and O(1) space complexity. The two-pointer solution provides a dramatic speedup while using the same amount of memory.
Best Practices and Common Pitfalls
Best Practices
- Always start with the brute-force solution to ensure you understand the problem before optimizing.
- Use descriptive variable names like
left,right, andmaxArearather than single letters for readability. - Write table-driven tests in Go to cover edge cases like empty arrays, single-element arrays, and arrays with all identical values.
- Leverage Go 1.21+'s built-in
minandmaxfunctions to reduce boilerplate code. - Consider benchmarking your solution with large inputs to verify performance characteristics.
Common Pitfalls
- Using
<=instead of<in the loop condition: This can cause an out-of-bounds access or unnecessary iteration when the pointers meet. - Moving the wrong pointer: Always move the pointer at the shorter line. Moving the taller line's pointer would only reduce the width without any chance of increasing the height.
- Forgetting edge cases: Arrays with fewer than two elements should return 0 since no container can be formed.
- Integer overflow: While Go's
inttype is platform-dependent (usually 64-bit), be mindful if you're working with extremely large arrays on 32-bit systems.
Variations and Follow-up Questions
Interviewers often extend this problem with follow-up questions. Here are some common variations to practice:
- Trapping Rain Water: Instead of a single container, calculate how much water can be trapped between all the bars after it rains. This requires a different approach using prefix and suffix maximum arrays or a stack.
- Return the indices: Modify the solution to return the indices of the two lines that form the maximum container, not just the area.
- Multiple containers: Find the top K largest containers, which requires a more sophisticated data structure like a heap.
- 3D container: Extend the problem to two dimensions, where you have a grid of heights and need to find the largest rectangular volume.
Here's a quick implementation that returns both the area and the indices:
package main
import "fmt"
func maxAreaWithIndices(height []int) (int, int, int) {
left, right := 0, len(height)-1
maxArea, bestLeft, bestRight := 0, 0, 0
for left < right {
width := right - left
h := height[left]
if height[right] < h {
h = height[right]
}
area := width * h
if area > maxArea {
maxArea = area
bestLeft = left
bestRight = right
}
if height[left] < height[right] {
left++
} else {
right--
}
}
return maxArea, bestLeft, bestRight
}
func main() {
height := []int{1, 8, 6, 2, 5, 4, 8, 3, 7}
area, l, r := maxAreaWithIndices(height)
fmt.Printf("Max area: %d, indices: [%d, %d]\n", area, l, r)
}
Conclusion
The Container With Most Water problem is a perfect example of how a clever algorithmic insight can transform an O(n²) brute-force solution into an elegant O(n) algorithm. By using the two-pointer technique and understanding that moving the shorter line inward is the only way to potentially find a larger container, we achieve both optimal time and space complexity. Go's simplicity and performance make it an excellent language for implementing such algorithms, and its built-in testing and benchmarking tools help ensure your solution is both correct and efficient. Whether you're preparing for coding interviews or building real-world applications that require efficient array processing, mastering this pattern will serve you well across a wide range of algorithmic challenges.