Skip to content

Table of Contents

Go Coding Challenges

Fifteen interview-style coding challenges ordered from beginner to advanced. Each one ships with a problem statement, a collapsible hint, an idiomatic Go solution, and a complexity note.

Difficulty legend: 🟢 beginner · 🟡 intermediate · 🔴 advanced

Table of Contents

  1. 🟢 Reverse a String (Unicode-aware)
  2. 🟢 FizzBuzz
  3. 🟢 Two Sum (Map)
  4. 🟢 Valid Anagram
  5. 🟢 Binary Search
  6. 🟡 Word Frequency Counter
  7. 🟡 Maximum Subarray (Kadane)
  8. 🟡 Merge Intervals
  9. 🟡 Detect Cycle in a Linked List
  10. 🟡 BFS and DFS on a Graph
  11. 🟡 Concurrent-Safe Counter
  12. 🔴 LRU Cache
  13. 🔴 Token-Bucket Rate Limiter
  14. 🔴 Worker Pool with errgroup
  15. 🔴 Fan-Out / Fan-In Pipeline with Context Cancellation

1. 🟢 Reverse a String (Unicode-aware)

Problem: Reverse a string so that multi-byte runes (emoji, accented characters, CJK) stay intact. A naive byte reversal corrupts UTF-8.

Hint A Go `string` is a read-only slice of bytes. Iterating with `range` decodes runes for you, or convert with `[]rune(s)`. Reverse the rune slice in place with a two-pointer swap, then convert back to `string`.
package main

import "fmt"

// ReverseString reverses s by code point, keeping multi-byte runes intact.
func ReverseString(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

func main() {
    fmt.Println(ReverseString("Hello, 世界 🌍")) // "🌍 界世 ,olleH"
}

Complexity: Time O(n), Space O(n) for the rune slice.


2. 🟢 FizzBuzz

Problem: Print numbers 1..n. For multiples of 3 print "Fizz", for multiples of 5 print "Buzz", and for multiples of both print "FizzBuzz".

Hint Build the string by appending. Check `i%3` and `i%5` independently so the "FizzBuzz" case falls out naturally, then fall back to the number when the accumulator is still empty.
package main

import (
    "fmt"
    "strconv"
)

// FizzBuzz returns the FizzBuzz sequence for 1..n.
func FizzBuzz(n int) []string {
    out := make([]string, 0, n)
    for i := 1; i <= n; i++ {
        s := ""
        if i%3 == 0 {
            s += "Fizz"
        }
        if i%5 == 0 {
            s += "Buzz"
        }
        if s == "" {
            s = strconv.Itoa(i)
        }
        out = append(out, s)
    }
    return out
}

func main() {
    fmt.Println(FizzBuzz(15))
}

Complexity: Time O(n), Space O(n) for the output.


3. 🟢 Two Sum (Map)

Problem: Given an integer slice and a target, return the indices of the two numbers that add up to the target. Assume exactly one solution exists.

Hint One pass. For each value `v` at index `i`, ask the map whether you have already seen `target-v`. Store `value -> index` as you go so you never reuse an element.
package main

import "fmt"

// TwoSum returns the indices of the two values summing to target, or ok=false.
func TwoSum(nums []int, target int) (int, int, bool) {
    seen := make(map[int]int, len(nums))
    for i, v := range nums {
        if j, ok := seen[target-v]; ok {
            return j, i, true
        }
        seen[v] = i
    }
    return 0, 0, false
}

func main() {
    i, j, ok := TwoSum([]int{2, 7, 11, 15}, 9)
    fmt.Println(i, j, ok) // 0 1 true
}

Complexity: Time O(n), Space O(n) for the map.


4. 🟢 Valid Anagram

Problem: Determine whether two strings are anagrams of each other, counting Unicode code points (not bytes).

Hint Anagrams have identical character multisets. Tally counts from the first string in a `map[rune]int`, then decrement while scanning the second. Any non-zero residual (or a length mismatch) means they are not anagrams.
package main

import "fmt"

// IsAnagram reports whether a and b contain the same runes with equal counts.
func IsAnagram(a, b string) bool {
    ra, rb := []rune(a), []rune(b)
    if len(ra) != len(rb) {
        return false
    }
    counts := make(map[rune]int, len(ra))
    for _, r := range ra {
        counts[r]++
    }
    for _, r := range rb {
        counts[r]--
        if counts[r] < 0 {
            return false
        }
    }
    return true
}

func main() {
    fmt.Println(IsAnagram("listen", "silent")) // true
    fmt.Println(IsAnagram("rat", "car"))        // false
}

Complexity: Time O(n), Space O(k) for k distinct runes.


Problem: Find the index of target in a sorted slice, or return -1 if it is absent.

Hint Maintain a half-open search window `[lo, hi)`. Compute `mid` as `lo + (hi-lo)/2` to avoid overflow. Shrink the window based on the comparison until it is empty.
package main

import "fmt"

// BinarySearch returns the index of target in the sorted slice, or -1.
func BinarySearch(nums []int, target int) int {
    lo, hi := 0, len(nums)
    for lo < hi {
        mid := lo + (hi-lo)/2
        switch {
        case nums[mid] == target:
            return mid
        case nums[mid] < target:
            lo = mid + 1
        default:
            hi = mid
        }
    }
    return -1
}

func main() {
    fmt.Println(BinarySearch([]int{1, 3, 5, 7, 9}, 7)) // 3
    fmt.Println(BinarySearch([]int{1, 3, 5, 7, 9}, 4)) // -1
}

Complexity: Time O(log n), Space O(1).


6. 🟡 Word Frequency Counter

Problem: Count word frequencies in text, case-insensitively, and return the top k words ordered by descending count (ties broken alphabetically).

Hint Tokenize with `strings.FieldsFunc`, lowercase, and tally into a map. Move the pairs into a slice and `sort.Slice` with a comparator that orders by count descending then word ascending. Slice off the top k.
package main

import (
    "fmt"
    "sort"
    "strings"
    "unicode"
)

type wordCount struct {
    Word  string
    Count int
}

// TopWords returns the k most frequent words in text, case-insensitively.
func TopWords(text string, k int) []wordCount {
    counts := make(map[string]int)
    fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
        return !unicode.IsLetter(r) && !unicode.IsNumber(r)
    })
    for _, w := range fields {
        counts[w]++
    }

    pairs := make([]wordCount, 0, len(counts))
    for w, c := range counts {
        pairs = append(pairs, wordCount{w, c})
    }
    sort.Slice(pairs, func(i, j int) bool {
        if pairs[i].Count != pairs[j].Count {
            return pairs[i].Count > pairs[j].Count
        }
        return pairs[i].Word < pairs[j].Word
    })

    if k > len(pairs) {
        k = len(pairs)
    }
    return pairs[:k]
}

func main() {
    fmt.Println(TopWords("the cat the dog the bird a cat", 2))
    // [{the 3} {cat 2}]
}

Complexity: Time O(n + m log m) for m distinct words, Space O(m).


7. 🟡 Maximum Subarray (Kadane)

Problem: Find the maximum sum of any contiguous subarray within an integer slice.

Hint Track the best sum ending at the current index: extend the previous run or restart at the current element, whichever is larger. Keep a running global maximum. Handle the all-negative case by seeding from the first element.
package main

import "fmt"

// MaxSubArray returns the largest sum of any contiguous subarray.
// Panics on an empty slice, by definition there is no subarray.
func MaxSubArray(nums []int) int {
    if len(nums) == 0 {
        panic("MaxSubArray: empty slice")
    }
    best, cur := nums[0], nums[0]
    for _, v := range nums[1:] {
        if cur+v > v {
            cur += v
        } else {
            cur = v
        }
        if cur > best {
            best = cur
        }
    }
    return best
}

func main() {
    fmt.Println(MaxSubArray([]int{-2, 1, -3, 4, -1, 2, 1, -5, 4})) // 6
}

Complexity: Time O(n), Space O(1).


8. 🟡 Merge Intervals

Problem: Given a list of [start, end] intervals, merge all overlapping intervals and return the result sorted by start.

Hint Sort by start. Walk the sorted list keeping the last merged interval; if the next start is within the current end, extend the end, otherwise push a new interval. Copy intervals before sorting if the caller's slice must stay intact.
package main

import (
    "fmt"
    "sort"
)

// MergeIntervals merges overlapping [start, end] intervals.
func MergeIntervals(intervals [][2]int) [][2]int {
    if len(intervals) == 0 {
        return nil
    }
    sorted := make([][2]int, len(intervals))
    copy(sorted, intervals)
    sort.Slice(sorted, func(i, j int) bool {
        return sorted[i][0] < sorted[j][0]
    })

    merged := [][2]int{sorted[0]}
    for _, iv := range sorted[1:] {
        last := &merged[len(merged)-1]
        if iv[0] <= last[1] {
            if iv[1] > last[1] {
                last[1] = iv[1]
            }
        } else {
            merged = append(merged, iv)
        }
    }
    return merged
}

func main() {
    fmt.Println(MergeIntervals([][2]int{{1, 3}, {2, 6}, {8, 10}, {15, 18}}))
    // [[1 6] [8 10] [15 18]]
}

Complexity: Time O(n log n), Space O(n).


9. 🟡 Detect Cycle in a Linked List

Problem: Determine whether a singly linked list contains a cycle.

Hint Floyd's tortoise and hare: advance one pointer by one node and another by two. If they ever meet, there is a cycle; if the fast pointer reaches `nil`, the list is acyclic. Constant extra space.
package main

import "fmt"

// ListNode is a singly linked list node.
type ListNode struct {
    Val  int
    Next *ListNode
}

// HasCycle reports whether the list starting at head contains a cycle.
func HasCycle(head *ListNode) bool {
    slow, fast := head, head
    for fast != nil && fast.Next != nil {
        slow = slow.Next
        fast = fast.Next.Next
        if slow == fast {
            return true
        }
    }
    return false
}

func main() {
    a := &ListNode{Val: 1}
    b := &ListNode{Val: 2}
    c := &ListNode{Val: 3}
    a.Next, b.Next, c.Next = b, c, a // cycle back to a
    fmt.Println(HasCycle(a))         // true
}

Complexity: Time O(n), Space O(1).


10. 🟡 BFS and DFS on a Graph

Problem: Given an adjacency-list graph, return the order of nodes visited from a start node using both breadth-first and depth-first traversal.

Hint Use a `map[int][]int` for adjacency and a `visited` set. BFS uses a FIFO queue; DFS uses recursion (or an explicit stack). Mark nodes visited when you enqueue or first reach them to avoid revisiting cycles.
package main

import "fmt"

type Graph map[int][]int

// BFS returns nodes in breadth-first order from start.
func (g Graph) BFS(start int) []int {
    visited := map[int]bool{start: true}
    queue := []int{start}
    var order []int
    for len(queue) > 0 {
        n := queue[0]
        queue = queue[1:]
        order = append(order, n)
        for _, nb := range g[n] {
            if !visited[nb] {
                visited[nb] = true
                queue = append(queue, nb)
            }
        }
    }
    return order
}

// DFS returns nodes in depth-first order from start.
func (g Graph) DFS(start int) []int {
    visited := make(map[int]bool)
    var order []int
    var walk func(int)
    walk = func(n int) {
        visited[n] = true
        order = append(order, n)
        for _, nb := range g[n] {
            if !visited[nb] {
                walk(nb)
            }
        }
    }
    walk(start)
    return order
}

func main() {
    g := Graph{0: {1, 2}, 1: {2}, 2: {0, 3}, 3: {3}}
    fmt.Println(g.BFS(2)) // [2 0 3 1]
    fmt.Println(g.DFS(2)) // [2 0 1 3]
}

Complexity: Time O(V + E), Space O(V).


11. 🟡 Concurrent-Safe Counter

Problem: Implement a counter that many goroutines can increment safely. Show both a sync.Mutex version and a lock-free sync/atomic version.

Hint A bare `count++` is a read-modify-write and races under concurrency. Guard it with a `sync.Mutex` (defer the unlock) or use `atomic.Int64`, which serializes the operation in hardware. Use a `WaitGroup` so the caller waits for all writers.
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// MutexCounter is safe for concurrent use via a mutex.
type MutexCounter struct {
    mu    sync.Mutex
    count int64
}

func (c *MutexCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *MutexCounter) Value() int64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

// AtomicCounter is a lock-free alternative.
type AtomicCounter struct {
    count atomic.Int64
}

func (c *AtomicCounter) Inc()         { c.count.Add(1) }
func (c *AtomicCounter) Value() int64 { return c.count.Load() }

func main() {
    const goroutines, perG = 100, 1000
    c := &AtomicCounter{}
    var wg sync.WaitGroup
    for i := 0; i < goroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < perG; j++ {
                c.Inc()
            }
        }()
    }
    wg.Wait()
    fmt.Println(c.Value()) // 100000
}

Complexity: Time O(n) increments, Space O(1). Run with go run -race to verify.


12. 🔴 LRU Cache

Problem: Implement a fixed-capacity Least-Recently-Used cache with O(1) Get and Put. Accessing or updating a key marks it most-recently-used; on overflow, evict the least-recently-used entry.

Hint Combine a `map[key]*list.Element` for O(1) lookup with a `container/list` doubly linked list for O(1) recency reordering. Store the key inside the list value so eviction can delete the map entry. Move touched nodes to the front; evict from the back.
package main

import (
    "container/list"
    "fmt"
)

type entry struct {
    key, value int
}

// LRUCache is a fixed-capacity LRU cache with O(1) operations.
type LRUCache struct {
    cap   int
    ll    *list.List               // front = most recently used
    items map[int]*list.Element    // key -> element holding *entry
}

func NewLRUCache(capacity int) *LRUCache {
    return &LRUCache{
        cap:   capacity,
        ll:    list.New(),
        items: make(map[int]*list.Element, capacity),
    }
}

func (c *LRUCache) Get(key int) (int, bool) {
    if el, ok := c.items[key]; ok {
        c.ll.MoveToFront(el)
        return el.Value.(*entry).value, true
    }
    return 0, false
}

func (c *LRUCache) Put(key, value int) {
    if el, ok := c.items[key]; ok {
        el.Value.(*entry).value = value
        c.ll.MoveToFront(el)
        return
    }
    if c.ll.Len() >= c.cap {
        oldest := c.ll.Back()
        if oldest != nil {
            c.ll.Remove(oldest)
            delete(c.items, oldest.Value.(*entry).key)
        }
    }
    c.items[key] = c.ll.PushFront(&entry{key, value})
}

func main() {
    c := NewLRUCache(2)
    c.Put(1, 1)
    c.Put(2, 2)
    fmt.Println(c.Get(1)) // 1 true
    c.Put(3, 3)           // evicts key 2
    fmt.Println(c.Get(2)) // 0 false
}

Complexity: Time O(1) per op, Space O(capacity).


13. 🔴 Token-Bucket Rate Limiter

Problem: Build a thread-safe token-bucket limiter. Tokens refill at a fixed rate up to a burst capacity; Allow consumes one token and reports whether the call is permitted.

Hint Track tokens as a float and the timestamp of the last refill. On each `Allow`, lazily add `elapsed * rate` tokens (capped at burst), then consume one if available. Guard shared state with a mutex. Lazy refill avoids a background ticker goroutine.
package main

import (
    "fmt"
    "sync"
    "time"
)

// TokenBucket is a thread-safe token-bucket rate limiter.
type TokenBucket struct {
    mu       sync.Mutex
    tokens   float64
    burst    float64
    ratePerS float64
    last     time.Time
}

// NewTokenBucket creates a bucket that refills ratePerSecond tokens per second,
// capped at burst, and starts full.
func NewTokenBucket(ratePerSecond, burst float64) *TokenBucket {
    return &TokenBucket{
        tokens:   burst,
        burst:    burst,
        ratePerS: ratePerSecond,
        last:     time.Now(),
    }
}

// Allow consumes a token if one is available.
func (b *TokenBucket) Allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(b.last).Seconds()
    b.last = now

    b.tokens += elapsed * b.ratePerS
    if b.tokens > b.burst {
        b.tokens = b.burst
    }
    if b.tokens >= 1 {
        b.tokens--
        return true
    }
    return false
}

func main() {
    b := NewTokenBucket(5, 2) // 5/s, burst 2
    fmt.Println(b.Allow())    // true
    fmt.Println(b.Allow())    // true
    fmt.Println(b.Allow())    // false (burst exhausted)
}

Complexity: Time O(1) per call, Space O(1). (Production code can use golang.org/x/time/rate.)


14. 🔴 Worker Pool with errgroup

Problem: Process a stream of jobs across a bounded pool of workers. Collect results, stop early on the first error, and cancel outstanding work without leaking goroutines.

Hint `golang.org/x/sync/errgroup` with `errgroup.WithContext` gives you a derived context that is cancelled on the first error. Fan jobs out over a channel, spawn N workers via `g.Go`, and select on `ctx.Done()` inside both producer and workers so cancellation propagates. `g.Wait()` returns the first error.
package main

import (
    "context"
    "fmt"
    "sort"
    "sync"

    "golang.org/x/sync/errgroup"
)

// SquareAll squares each input concurrently using a bounded worker pool.
func SquareAll(ctx context.Context, inputs []int, workers int) ([]int, error) {
    g, ctx := errgroup.WithContext(ctx)
    jobs := make(chan int)

    // Producer: feed jobs, stop if the context is cancelled.
    g.Go(func() error {
        defer close(jobs)
        for _, n := range inputs {
            select {
            case jobs <- n:
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        return nil
    })

    var (
        mu      sync.Mutex
        results []int
    )
    for i := 0; i < workers; i++ {
        g.Go(func() error {
            for n := range jobs {
                select {
                case <-ctx.Done():
                    return ctx.Err()
                default:
                }
                mu.Lock()
                results = append(results, n*n)
                mu.Unlock()
            }
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    sort.Ints(results)
    return results, nil
}

func main() {
    res, err := SquareAll(context.Background(), []int{1, 2, 3, 4, 5}, 3)
    fmt.Println(res, err) // [1 4 9 16 25] <nil>
}

Complexity: Time O(n) work over W workers, Space O(n). Requires golang.org/x/sync.


15. 🔴 Fan-Out / Fan-In Pipeline with Context Cancellation

Problem: Build a three-stage pipeline (generate -> fan-out transform -> fan-in) using channels. It must shut down cleanly when the caller cancels the context, with no leaked goroutines.

Hint Each stage returns a receive-only channel and owns closing it. Use a `select` on `ctx.Done()` at every send so a cancellation unblocks producers. For fan-in, spawn one forwarding goroutine per source and close the merged channel once a `sync.WaitGroup` reports all sources are drained.
package main

import (
    "context"
    "fmt"
    "sync"
)

// gen emits the inputs on a channel, stopping on cancellation.
func gen(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// square transforms one input channel (a fan-out worker).
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// merge fans multiple channels into one (fan-in).
func merge(ctx context.Context, chans ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(chans))
    for _, c := range chans {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            }
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    src := gen(ctx, 1, 2, 3, 4, 5)
    // Fan-out across two workers, then fan-in.
    out := merge(ctx, square(ctx, src), square(ctx, src))

    sum := 0
    for v := range out {
        sum += v
    }
    fmt.Println(sum) // 55
}

Complexity: Time O(n) over the pipeline, Space O(1) per stage buffer. Run with -race to confirm no leaks.