← Back to DevBytes

Solving Container With Most Water in Go: Step-by-Step Guide

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

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

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:

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

Common Pitfalls

Variations and Follow-up Questions

Interviewers often extend this problem with follow-up questions. Here are some common variations to practice:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles