โ† Back to DevBytes

Solving Range Sum Query in Go: Step-by-Step Guide

Introduction to Range Sum Query

The Range Sum Query (RSQ) is one of the most fundamental problems in computer science and competitive programming. Given an array of numbers, the task is to efficiently answer multiple queries that ask for the sum of elements between two indices, typically denoted as sum(arr[l..r]). While a naive approach would iterate through the range for each query, this becomes prohibitively slow when dealing with large datasets and many queries.

In Go, solving the Range Sum Query efficiently requires understanding several data structures and algorithms, each with its own trade-offs. This tutorial walks through the most common approaches, from the simplest brute force method to advanced techniques like Prefix Sums, Segment Trees, Binary Indexed Trees (Fenwick Trees), and Sparse Tables. By the end, you will know exactly which approach to choose for your specific use case.

Why Range Sum Query Matters

Range Sum Query is not just an academic exercise. It appears in many real-world scenarios:

The key challenge is that queries often arrive in large batches, and the underlying data may or may not change between queries. Choosing the right data structure depends on whether you need static (read-only) or dynamic (mutable) range sums, and whether you prioritize query speed, update speed, or memory usage.

Approach 1: Brute Force

The simplest solution is to iterate through the array for each query and sum the elements in the requested range. This approach requires no preprocessing and uses O(1) extra space, but each query takes O(n) time.

Implementation

package main

import "fmt"

// BruteForceRangeSum computes the sum of arr[l..r] inclusive.
func BruteForceRangeSum(arr []int, l, r int) int {
    sum := 0
    for i := l; i <= r; i++ {
        sum += arr[i]
    }
    return sum
}

func main() {
    arr := []int{3, 1, 4, 1, 5, 9, 2, 6}
    fmt.Println(BruteForceRangeSum(arr, 2, 5)) // Output: 19 (4+1+5+9)
}

This works fine for small arrays or a handful of queries. However, if you have q queries on an array of size n, the total time complexity becomes O(q * n), which is unacceptable for large inputs.

Approach 2: Prefix Sum Array

The Prefix Sum (also called Cumulative Sum) technique is the go-to solution when the array is static โ€” meaning it does not change between queries. The idea is to precompute an array where each element at index i contains the sum of all elements from index 0 to i. Once built, any range sum query can be answered in O(1) time.

How It Works

Given an array arr, the prefix sum array prefix is defined as:

To compute the sum of arr[l..r], you use the formula: sum = prefix[r] - prefix[l-1]. When l == 0, the sum is simply prefix[r].

Implementation

package main

import "fmt"

// PrefixSum holds the precomputed prefix array.
type PrefixSum struct {
    prefix []int
}

// NewPrefixSum builds the prefix sum array in O(n) time.
func NewPrefixSum(arr []int) *PrefixSum {
    n := len(arr)
    prefix := make([]int, n)
    prefix[0] = arr[0]
    for i := 1; i < n; i++ {
        prefix[i] = prefix[i-1] + arr[i]
    }
    return &PrefixSum{prefix: prefix}
}

// Query returns the sum of arr[l..r] inclusive in O(1) time.
func (ps *PrefixSum) Query(l, r int) int {
    if l == 0 {
        return ps.prefix[r]
    }
    return ps.prefix[r] - ps.prefix[l-1]
}

func main() {
    arr := []int{3, 1, 4, 1, 5, 9, 2, 6}
    ps := NewPrefixSum(arr)

    fmt.Println(ps.Query(0, 3))  // Output: 9  (3+1+4+1)
    fmt.Println(ps.Query(2, 5))  // Output: 19 (4+1+5+9)
    fmt.Println(ps.Query(4, 7))  // Output: 22 (5+9+2+6)
}

The prefix sum approach is ideal when you have many queries on a read-only array. Preprocessing takes O(n) time and O(n) space, and each query is O(1). The major limitation is that any update to the original array requires rebuilding the entire prefix array, which is O(n) per update.

Approach 3: Segment Tree

When the array is dynamic โ€” meaning elements can be updated between queries โ€” the Segment Tree is a powerful and flexible data structure. It supports both range sum queries and point updates in O(log n) time, making it suitable for scenarios where data changes frequently.

How It Works

A Segment Tree is a binary tree where each node represents a segment (range) of the array. The root node represents the entire array, and each child node represents half of its parent's segment. Leaf nodes represent individual elements. Each internal node stores the sum of its children, allowing efficient queries and updates by traversing only the relevant portions of the tree.

The tree is typically stored in an array of size 4 * n to ensure enough space for all nodes. Building the tree takes O(n) time, and both queries and updates run in O(log n) time.

Implementation

package main

import "fmt"

// SegmentTree supports point updates and range sum queries.
type SegmentTree struct {
    tree []int
    n    int
}

// NewSegmentTree builds the tree from the input array in O(n) time.
func NewSegmentTree(arr []int) *SegmentTree {
    n := len(arr)
    st := &SegmentTree{
        tree: make([]int, 4*n),
        n:    n,
    }
    st.build(arr, 0, 0, n-1)
    return st
}

// build recursively constructs the segment tree.
func (st *SegmentTree) build(arr []int, node, start, end int) {
    if start == end {
        st.tree[node] = arr[start]
        return
    }
    mid := (start + end) / 2
    leftChild := 2*node + 1
    rightChild := 2*node + 2
    st.build(arr, leftChild, start, mid)
    st.build(arr, rightChild, mid+1, end)
    st.tree[node] = st.tree[leftChild] + st.tree[rightChild]
}

// Query returns the sum of arr[l..r] in O(log n) time.
func (st *SegmentTree) Query(l, r int) int {
    return st.query(0, 0, st.n-1, l, r)
}

func (st *SegmentTree) query(node, start, end, l, r int) int {
    // No overlap
    if r < start || end < l {
        return 0
    }
    // Complete overlap
    if l <= start && end <= r {
        return st.tree[node]
    }
    // Partial overlap
    mid := (start + end) / 2
    leftSum := st.query(2*node+1, start, mid, l, r)
    rightSum := st.query(2*node+2, mid+1, end, l, r)
    return leftSum + rightSum
}

// Update sets arr[idx] to val in O(log n) time.
func (st *SegmentTree) Update(idx, val int) {
    st.update(0, 0, st.n-1, idx, val)
}

func (st *SegmentTree) update(node, start, end, idx, val int) {
    if start == end {
        st.tree[node] = val
        return
    }
    mid := (start + end) / 2
    if idx <= mid {
        st.update(2*node+1, start, mid, idx, val)
    } else {
        st.update(2*node+2, mid+1, end, idx, val)
    }
    st.tree[node] = st.tree[2*node+1] + st.tree[2*node+2]
}

func main() {
    arr := []int{3, 1, 4, 1, 5, 9, 2, 6}
    st := NewSegmentTree(arr)

    fmt.Println(st.Query(2, 5)) // Output: 19

    // Update arr[3] from 1 to 10
    st.Update(3, 10)
    fmt.Println(st.Query(2, 5)) // Output: 28 (4+10+5+9)
}

The Segment Tree is versatile and can be extended to support range updates (using lazy propagation), min/max queries, and other associative operations. Its main drawback is the O(4n) space requirement and the complexity of implementation compared to simpler approaches.

Approach 4: Binary Indexed Tree (Fenwick Tree)

The Binary Indexed Tree (BIT), also known as the Fenwick Tree, is a more memory-efficient alternative to the Segment Tree for range sum queries and point updates. It uses exactly O(n) space and achieves O(log n) time for both queries and updates, with a simpler implementation and lower constant factors.

How It Works

The BIT leverages the binary representation of indices. Each index i in the BIT stores the sum of a range of elements whose length is determined by the lowest set bit of i. To compute a prefix sum up to index i, you add up the values at indices i, i - (i & -i), and so on, stripping the lowest set bit each time. To update an element, you propagate the change to all indices that cover it.

The key operation is i & -i, which isolates the lowest set bit of i. This allows efficient traversal up and down the tree structure encoded in the array.

Implementation

package main

import "fmt"

// BIT (Binary Indexed Tree) supports point updates and prefix sum queries.
type BIT struct {
    tree []int
    n    int
}

// NewBIT constructs a BIT from the input array in O(n) time.
func NewBIT(arr []int) *BIT {
    n := len(arr)
    bit := &BIT{
        tree: make([]int, n+1), // 1-indexed
        n:    n,
    }
    for i := 0; i < n; i++ {
        bit.Update(i, arr[i])
    }
    return bit
}

// Update adds delta to arr[idx] in O(log n) time.
func (bit *BIT) Update(idx int, delta int) {
    idx++ // Convert to 1-indexed
    for idx <= bit.n {
        bit.tree[idx] += delta
        idx += idx & -idx
    }
}

// PrefixSum returns sum of arr[0..idx] in O(log n) time.
func (bit *BIT) PrefixSum(idx int) int {
    idx++ // Convert to 1-indexed
    sum := 0
    for idx > 0 {
        sum += bit.tree[idx]
        idx -= idx & -idx
    }
    return sum
}

// RangeSum returns sum of arr[l..r] inclusive in O(log n) time.
func (bit *BIT) RangeSum(l, r int) int {
    if l == 0 {
        return bit.PrefixSum(r)
    }
    return bit.PrefixSum(r) - bit.PrefixSum(l-1)
}

func main() {
    arr := []int{3, 1, 4, 1, 5, 9, 2, 6}
    bit := NewBIT(arr)

    fmt.Println(bit.RangeSum(2, 5)) // Output: 19

    // Add 9 to arr[3] (1 becomes 10)
    bit.Update(3, 9)
    fmt.Println(bit.RangeSum(2, 5)) // Output: 28
}

Note that the BIT's Update method adds a delta rather than setting an absolute value. If you need to set a value, you must first query the current value and compute the difference. The BIT is generally preferred over the Segment Tree for simple sum queries due to its lower memory usage and faster constant factors, though it is less flexible for other types of range operations.

Approach 5: Sparse Table

The Sparse Table is an advanced data structure optimized for static arrays. It precomputes answers for all ranges of lengths that are powers of two, enabling O(1) range sum queries after O(n log n) preprocessing. While it shines for idempotent operations like range minimum queries, it can also be used for range sums with a slightly different approach.

Implementation

package main

import (
    "fmt"
    "math"
)

// SparseTable supports O(1) range sum queries on a static array.
type SparseTable struct {
    table [][]int
    log   []int
    n     int
}

// NewSparseTable builds the table in O(n log n) time.
func NewSparseTable(arr []int) *SparseTable {
    n := len(arr)
    if n == 0 {
        return &SparseTable{}
    }

    // Precompute log values
    log := make([]int, n+1)
    log[1] = 0
    for i := 2; i <= n; i++ {
        log[i] = log[i/2] + 1
    }

    k := log[n] + 1
    table := make([][]int, k)
    table[0] = make([]int, n)
    copy(table[0], arr)

    for j := 1; j < k; j++ {
        table[j] = make([]int, n-(1<<uint(j))+1)
        for i := 0; i+(1<<uint(j)) <= n; i++ {
            table[j][i] = table[j-1][i] + table[j-1][i+(1<<uint(j-1))]
        }
    }

    return &SparseTable{table: table, log: log, n: n}
}

// Query returns the sum of arr[l..r] in O(log n) time.
// Note: For truly O(1) queries, use prefix sums instead.
// Sparse Table is more useful for idempotent operations like min/max.
func (st *SparseTable) Query(l, r int) int {
    result := 0
    length := r - l + 1
    for j := 0; (1 << uint(j)) <= length; j++ {
        if length&(1<<uint(j)) != 0 {
            result += st.table[j][l]
            l += 1 << uint(j)
        }
    }
    return result
}

func main() {
    arr := []int{3, 1, 4, 1, 5, 9, 2, 6}
    st := NewSparseTable(arr)

    fmt.Println(st.Query(2, 5)) // Output: 19
    fmt.Println(st.Query(0, 7)) // Output: 31
}

It is worth noting that for range sum queries specifically, the Sparse Table does not offer a significant advantage over the simpler Prefix Sum approach, since both achieve O(1) queries but the Prefix Sum uses less preprocessing time and space. The Sparse Table truly shines for idempotent operations like range minimum or range maximum, where overlapping intervals do not affect the result.

Comparing the Approaches

Here is a summary of the time and space complexities for each approach:

Best Practices

When implementing Range Sum Query solutions in Go, keep the following best practices in mind:

Conclusion

Solving the Range Sum Query problem in Go requires understanding the trade-offs between preprocessing time, query speed, update speed, and memory usage. For static arrays, the Prefix Sum approach is unbeatable in its simplicity and O(1) query performance. For dynamic arrays with frequent updates, the Segment Tree and Binary Indexed Tree both offer O(log n) operations, with the BIT being more memory-efficient and the Segment Tree being more flexible. The Sparse Table, while powerful for idempotent operations, is generally overkill for range sums. By selecting the right data structure based on your specific access patterns and constraints, you can build efficient and maintainable solutions that scale gracefully with your data.

๐Ÿ›  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