Free Handbook · Every example compiled & verified

Problem Solving

A repeatable way to solve coding problems in Go: read it, break it down, test small cases, then apply one of eight patterns that cover most interviews.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 14 · what you'll be able to do

  • Turn a problem statement into inputs, outputs, constraints and edge cases before writing any Go
  • Test a solution with a small table of cases, the same shape as a Go table-driven test
  • Estimate the Big-O a problem needs from its input limits, in plain English
  • Recognise and apply eight patterns: two pointers, sliding window, hash map, stack, backtracking, sorting, BFS/DFS and DP-lite
  • Avoid the Go-specific traps in these patterns: slice aliasing in backtracking, map iteration order and byte-versus-rune strings
01

Read the problem before touching the keyboard

Most failed coding rounds fail in the first two minutes: the candidate starts typing a solution to a problem they have not fully read. Slow down and write these five things down, as a comment above the function or out loud, before any code.

  1. 1
    Restate it

    One sentence, in your own words. "Given a list of numbers, return the second-largest distinct value."

  2. 2
    Inputs and outputs, with Go types

    []int in. Out: an int — but what if there is no answer? In Go the honest signature is (int, bool), the same comma-ok shape as a map lookup, rather than a magic value like -1.

  3. 3
    Constraints

    How big is n? Can values be negative? Duplicates? Already sorted? These decide the algorithm (see the Big-O lesson below).

  4. 4
    Two examples by hand

    One normal, one tricky. Work them out on paper. If you cannot do it by hand, you cannot code it.

  5. 5
    Edge cases

    A nil or empty slice, one element, all values equal, negative numbers, values near the limits of int.

gomain.go
package main

import "fmt"

// Restated: the second-largest distinct value in nums.
// Input: []int, may be nil or empty. Output: (value, ok); ok is false if there is none.
func secondLargest(nums []int) (int, bool) {
	var first, second int
	haveFirst, haveSecond := false, false
	for _, n := range nums {
		switch {
		case !haveFirst || n > first:
			if haveFirst {
				second, haveSecond = first, true // old leader drops to second
			}
			first, haveFirst = n, true
		case n < first && (!haveSecond || n > second):
			second, haveSecond = n, true
		}
	}
	return second, haveSecond
}

func main() {
	fmt.Println(secondLargest([]int{4, 1, 7, 7, 3})) // normal, with a duplicate leader
	fmt.Println(secondLargest([]int{5, 5, 5}))       // all equal: no answer
	fmt.Println(secondLargest(nil))                  // empty
	fmt.Println(secondLargest([]int{-2, -9}))        // negatives
}
Outputcompiled & run with real Go
4 true
0 false
0 false
-9 true

The comments at the top are the reading step. The four calls in main are the examples and edge cases from that step. Starting first at 0 instead of tracking haveFirst would silently break the all-negative case.

02

Break it down and test with small cases

Break the problem into pieces you can test on their own. "Is this sentence a palindrome, ignoring case and punctuation?" is really two problems: normalise the text (keep letters and digits, lower-case them) and check that a sequence reads the same both ways. Then test with the smallest inputs that could break it. A slice of {input, want} structs and a loop is exactly the table-driven style from Module 09, just printed instead of run by go test.

gomain.go
package main

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

// normalise keeps letters and digits, lower-cased, as runes (not bytes).
func normalise(s string) []rune {
	var out []rune
	for _, r := range strings.ToLower(s) {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			out = append(out, r)
		}
	}
	return out
}

func isMirror(rs []rune) bool {
	for i, j := 0, len(rs)-1; i < j; i, j = i+1, j-1 {
		if rs[i] != rs[j] {
			return false
		}
	}
	return true
}

func isPalindrome(s string) bool { return isMirror(normalise(s)) }

func main() {
	cases := []struct {
		in   string
		want bool
	}{
		{"", true},    // empty
		{"a", true},   // one character
		{"ab", false}, // smallest false case
		{"Aba", true}, // case
		{"A man, a plan, a canal: Panama", true},
		{"race a car", false},
		{".,", true},  // only punctuation
		{"été", true}, // multi-byte runes
	}
	passed := 0
	for _, c := range cases {
		if got := isPalindrome(c.in); got != c.want {
			fmt.Printf("FAIL %q: want %t, got %t\n", c.in, c.want, got)
		} else {
			passed++
		}
	}
	fmt.Printf("%d passed, %d failed\n", passed, len(cases)-passed)
}
Outputcompiled & run with real Go
8 passed, 0 failed
Your turn

Break it on purpose: remove the strings.ToLower call and run again. Exactly two cases should report FAIL, and their inputs tell you what you broke. Then change normalise to work on bytes (s[i]) and watch the "été" case fail — é is two bytes in UTF-8.

The order to test in
Empty (or nil), one element, two elements, the example from the problem, then one case aimed at each branch of your code. Small inputs make a wrong answer obvious by eye. In Go, a nil slice and an empty slice both have len 0, so a function written with len and range handles both for free.
03

Big-O in plain English

Big-O answers one question: if the input gets ten times bigger, how much slower does this get? O(n) gets ten times slower, O(n²) a hundred times, O(log n) barely notices. Go does very roughly 108 to 109 simple operations per second, so the limits in a problem statement are a direct hint about which algorithm is expected. Module 13 lists what each Go operation costs; this lesson is about reading the problem.

Rules of thumb, not laws. They are good enough to rule out the wrong approach before you write it.
If n is up to…You can affordTypical approach
10 to 20O(2n) or O(n!)Backtracking: try every subset or ordering
~500O(n³)Three nested loops, small DP tables
~5,000O(n²)Two nested loops, 2-D DP
~106O(n log n)Sort first, a heap, binary search inside a loop
~108O(n)One pass: two pointers, sliding window, hash map, prefix sums
Anything largerO(log n) or O(1)Binary search on the answer, a formula

Here is the rule in action on a common problem: answer many "sum of the range l..r" queries. The naive version loops over the range for every query. The prefix sum version does one pass up front — prefix[i] is the sum of the first i values — and then answers each query with one subtraction. Both count their steps.

gomain.go
package main

import (
	"fmt"
	"slices"
)

var ops int

func naive(a []int, queries [][2]int) []int {
	out := make([]int, 0, len(queries))
	for _, q := range queries {
		sum := 0
		for i := q[0]; i <= q[1]; i++ {
			sum += a[i]
			ops++
		}
		out = append(out, sum)
	}
	return out
}

func withPrefix(a []int, queries [][2]int) []int {
	prefix := make([]int, len(a)+1) // prefix[i] = sum of a[:i]
	for i, v := range a {
		prefix[i+1] = prefix[i] + v
		ops++
	}
	out := make([]int, 0, len(queries))
	for _, q := range queries {
		out = append(out, prefix[q[1]+1]-prefix[q[0]])
		ops++
	}
	return out
}

func main() {
	for _, n := range []int{1_000, 10_000} {
		a := make([]int, n)
		for i := range a {
			a[i] = i % 7
		}
		queries := make([][2]int, n) // n queries, each over the whole slice: worst case
		for i := range queries {
			queries[i] = [2]int{0, n - 1}
		}
		ops = 0
		x := naive(a, queries)
		slow := ops
		ops = 0
		y := withPrefix(a, queries)
		fmt.Printf("n=%d  naive=%d  prefix=%d  same=%t\n", n, slow, ops, slices.Equal(x, y))
	}
}
Outputcompiled & run with real Go
n=1000  naive=1000000  prefix=2000  same=true
n=10000  naive=100000000  prefix=20000  same=true

Ten times the input: the naive version does a hundred times the work (O(n·q)), the prefix version ten times (O(n + q)). At n = 106 the naive one would need 1012 steps — minutes, not milliseconds.

Say it in one sentence
In an interview, state complexity as a sentence, not just a symbol: "one pass to build the prefix sums, then constant time per query, so O(n + q) time and O(n) extra space." Space counts too — the prefix slice is the price of the speed.
04

Pattern 1: two pointers

Signal: a sorted slice (or a string) and a question about pairs, or "do it in place". Put one index at each end and move them towards each other based on a comparison. Each step rules out a whole row of candidate pairs, so an O(n²) "try every pair" becomes O(n). A second flavour uses a slow write index and a fast read index moving the same way, to filter a slice in place.

gomain.go
package main

import "fmt"

// pairSum: a is sorted; indices of two values that add up to target.
func pairSum(a []int, target int) (int, int, bool) {
	i, j := 0, len(a)-1
	for i < j {
		sum := a[i] + a[j]
		switch {
		case sum == target:
			return i, j, true
		case sum < target:
			i++ // too small: a[i] cannot pair with anything, drop it
		default:
			j-- // too big: a[j] cannot pair with anything, drop it
		}
	}
	return -1, -1, false
}

// dedupe: a is sorted; removes repeats in place, returns the shortened slice.
func dedupe(a []int) []int {
	if len(a) == 0 {
		return a
	}
	w := 1 // next write position
	for r := 1; r < len(a); r++ {
		if a[r] != a[w-1] {
			a[w] = a[r]
			w++
		}
	}
	return a[:w]
}

func main() {
	a := []int{2, 5, 7, 9, 12, 15}
	fmt.Println(pairSum(a, 21))
	fmt.Println(pairSum(a, 100))
	fmt.Println(dedupe([]int{1, 1, 2, 3, 3, 3, 4}))
}
Outputcompiled & run with real Go
3 4 true
-1 -1 false
[1 2 3 4]
Your turn

Write closestPair(a []int, target int) (int, int) for a sorted slice: the same loop, but instead of stopping on an exact match, remember the pair whose sum has the smallest distance from target. For the slice above and target 26 there is no exact pair, and it should return the values 12 and 15 (sum 27, distance 1).

VisualizepairSum([2 5 7 9 12 15], 21)Step 1 / 11
i, j := 0, len(a)-1
for i < j {
sum := a[i] + a[j]
switch {
case sum == target:
return i, j, true
case sum < target:
i++
default:
j--
}
}
Line 1

Pointers at both ends.

Variables now
i0
j5
All 11 steps as a table
StepLineWhat happenedVariables now
11Pointers at both ends.i = 0 j = 5
232 + 15 = 17, too small.sum = 17
382 plus the largest value is still too small, so 2 pairs with nothing. Drop it.i = 1
435 + 15 = 20, too small.sum = 20
58Drop 5.i = 2
637 + 15 = 22, too big.sum = 22
71015 plus the smallest remaining value is too big, so 15 pairs with nothing. Drop it.j = 4
837 + 12 = 19, too small.sum = 19
98Drop 7.i = 3
1039 + 12 = 21.sum = 21
116Found: indices 3 and 4 (values 9 and 12), after 5 comparisons instead of up to 15 pairs.
05

Pattern 2: sliding window

Signal: "longest / shortest / best contiguous subarray or substring such that…", or "every window of size k". Grow a window by moving its right edge; when the window breaks the rule, shrink it from the left. Each index enters and leaves the window at most once, so the whole thing is O(n) even though there is a loop inside a loop.

gomain.go
package main

import "fmt"

// longestUnique: length of the longest substring with no repeated byte.
func longestUnique(s string) int {
	lastSeen := map[byte]int{}
	best, left := 0, 0
	for right := 0; right < len(s); right++ {
		c := s[right]
		if prev, ok := lastSeen[c]; ok && prev >= left {
			left = prev + 1 // jump the left edge past the earlier copy
		}
		lastSeen[c] = right
		best = max(best, right-left+1)
	}
	return best
}

// maxSumK: largest sum of any k consecutive values (fixed-size window).
func maxSumK(a []int, k int) int {
	sum := 0
	for i := 0; i < k; i++ {
		sum += a[i]
	}
	best := sum
	for i := k; i < len(a); i++ {
		sum += a[i] - a[i-k] // add the new right value, drop the old left one
		best = max(best, sum)
	}
	return best
}

func main() {
	fmt.Println(longestUnique("abcabcbb"), longestUnique("bbbbb"), longestUnique("pwwkew"), longestUnique(""))
	fmt.Println(maxSumK([]int{2, 1, 5, 1, 3, 2}, 3))
}
Outputcompiled & run with real Go
3 1 3 0
9

longestUnique indexes bytes, which is right for ASCII input. For arbitrary text, range over the string to get runes and key the map by rune — the window edges are then byte offsets from range, not rune counts. The built-in max needs Go 1.21 or later.

Your turn

Write minLenAtLeast(a []int, target int) int: the length of the shortest contiguous run of positive numbers whose sum is at least target, or 0 if none. Grow right; then, while the sum still qualifies, record the length and shrink from the left. For [2 3 1 2 4 3] and 7 the answer is 2.

06

Pattern 3: hash map lookups

Signal: "have I seen X before?", "count occurrences", "find the complement", "group things that share a property". Trade memory for time: store what you have seen in a map, and each lookup is O(1) on average instead of another loop. This is the most common interview pattern of all.

gomain.go
package main

import (
	"fmt"
	"slices"
)

// twoSum: a is NOT sorted; indices of two values adding to target.
func twoSum(nums []int, target int) (int, int, bool) {
	indexOf := map[int]int{} // value -> index where we saw it
	for i, n := range nums {
		if j, ok := indexOf[target-n]; ok {
			return j, i, true
		}
		indexOf[n] = i
	}
	return -1, -1, false
}

// groupAnagrams: words with the same letters, groups in first-seen order.
func groupAnagrams(words []string) [][]string {
	groupOf := map[string]int{} // sorted letters -> index into groups
	var groups [][]string
	for _, w := range words {
		b := []byte(w)
		slices.Sort(b)
		key := string(b)
		i, ok := groupOf[key]
		if !ok {
			i = len(groups)
			groupOf[key] = i
			groups = append(groups, nil)
		}
		groups[i] = append(groups[i], w)
	}
	return groups
}

func main() {
	fmt.Println(twoSum([]int{3, 8, 11, 2, 7}, 9))
	fmt.Println(groupAnagrams([]string{"eat", "tea", "tan", "ate", "nat", "bat"}))
}
Outputcompiled & run with real Go
3 4 true
[[eat tea ate] [tan nat] [bat]]

The sorted letters are the key: "eat", "tea" and "ate" all sort to "aet". The groups live in a slice, and the map only stores an index into it. Ranging over the map to build the answer would print the groups in a different order on each run, because Go randomises map iteration.

Check before you store
In twoSum the lookup happens before indexOf[n] = i. Swap the two lines and an input like [5] with target 10 would pair 5 with itself. Use the comma-ok form (j, ok := m[k]): a missing key returns the zero value 0, which is also a valid index.
07

Pattern 4: stack

Signal: matching pairs, "the next greater / smaller element", undo, or evaluating an expression. In Go a stack is just a slice: append to push, s[len(s)-1] to peek, s = s[:len(s)-1] to pop (Module 13 built one, and used it for bracket matching). A monotonic stack keeps indices whose values are still waiting for an answer; when a bigger value arrives, it resolves everything smaller on top. Every index is pushed and popped once: O(n).

gomain.go
package main

import (
	"fmt"
	"strconv"
	"strings"
)

// daysUntilWarmer: for each day, days until a warmer one (0 if never).
func daysUntilWarmer(temps []int) []int {
	answer := make([]int, len(temps))
	var waiting []int // indices; their temperatures only decrease up the stack
	for i, t := range temps {
		for len(waiting) > 0 && t > temps[waiting[len(waiting)-1]] {
			day := waiting[len(waiting)-1]
			waiting = waiting[:len(waiting)-1]
			answer[day] = i - day
		}
		waiting = append(waiting, i)
	}
	return answer
}

// evalRPN: evaluate postfix like "3 4 +"; errors instead of panicking.
func evalRPN(expr string) (int, error) {
	var stack []int
	for _, tok := range strings.Fields(expr) {
		switch tok {
		case "+", "-", "*":
			if len(stack) < 2 {
				return 0, fmt.Errorf("not enough operands for %s", tok)
			}
			a, b := stack[len(stack)-2], stack[len(stack)-1] // b was pushed last
			stack = stack[:len(stack)-2]
			switch tok {
			case "+":
				stack = append(stack, a+b)
			case "-":
				stack = append(stack, a-b)
			case "*":
				stack = append(stack, a*b)
			}
		default:
			n, err := strconv.Atoi(tok)
			if err != nil {
				return 0, err
			}
			stack = append(stack, n)
		}
	}
	if len(stack) != 1 {
		return 0, fmt.Errorf("malformed expression, stack %v", stack)
	}
	return stack[0], nil
}

func main() {
	fmt.Println(daysUntilWarmer([]int{73, 74, 75, 71, 69, 72, 76, 73}))
	fmt.Println(evalRPN("5 1 2 + 4 * + 3 -"))
	fmt.Println(evalRPN("1 +"))
}
Outputcompiled & run with real Go
[1 1 4 2 1 1 0 0]
14 <nil>
0 not enough operands for +

For subtraction the order matters: the value on top of the stack is the right operand. Reading both into named variables before popping makes that impossible to get backwards.

Your turn

Add "/" to evalRPN, returning an error for division by zero instead of letting Go panic with integer divide by zero. Test it with "7 0 /" and "7 2 /" (Go integer division prints 3).

08

Pattern 5: recursion and backtracking

Signal: "all combinations", "all permutations", "every way to…", and a small n (roughly 20 or fewer). Build a candidate one choice at a time, recurse, then undo the choice and try the next. The shape is always: choose, explore, un-choose. Module 13 generated subsets; here are the two other classics. A closure assigned to a var lets the recursive helper see out and cur without passing them around.

gomain.go
package main

import (
	"fmt"
	"slices"
)

func permutations(nums []int) [][]int {
	var out [][]int
	cur := make([]int, 0, len(nums))
	used := make([]bool, len(nums))
	var explore func()
	explore = func() {
		if len(cur) == len(nums) {
			out = append(out, slices.Clone(cur)) // copy: cur keeps changing
			return
		}
		for i, n := range nums {
			if used[i] {
				continue
			}
			used[i], cur = true, append(cur, n)    // choose
			explore()                              // explore
			used[i], cur = false, cur[:len(cur)-1] // un-choose
		}
	}
	explore()
	return out
}

// combinationSum: every multiset of cands (reuse allowed) that adds to target.
func combinationSum(cands []int, target int) [][]int {
	slices.Sort(cands)
	var out [][]int
	var cur []int
	var explore func(start, remain int)
	explore = func(start, remain int) {
		if remain == 0 {
			out = append(out, slices.Clone(cur))
			return
		}
		for i := start; i < len(cands); i++ {
			if cands[i] > remain {
				break // sorted, so every later candidate is too big as well
			}
			cur = append(cur, cands[i])
			explore(i, remain-cands[i]) // i, not i+1: the same value may repeat
			cur = cur[:len(cur)-1]
		}
	}
	explore(0, target)
	return out
}

func main() {
	fmt.Println(permutations([]int{1, 2, 3}))
	fmt.Println(combinationSum([]int{7, 3, 6, 2}, 7))
}
Outputcompiled & run with real Go
[[1 2 3] [1 3 2] [2 1 3] [2 3 1] [3 1 2] [3 2 1]]
[[2 2 3] [7]]

Forgetting slices.Clone is the Go version of the classic bug. Every cur saved into out would share one backing array, so all six permutations would print as [3 2 1] — the last values written into it.

Your turn

Predict the output of combinationSum([]int{2, 3, 6, 7}, 8) by hand, then run it. (Three combinations; the sort and the break keep them in ascending order.)

Why the cost is exponential
n distinct values have n! orderings: 10 values already give 3,628,800. That is why the input-size table puts backtracking at n ≤ 20 or less. Pruning — the break above — does not change the worst case, but it often cuts real inputs down enormously.
09

Pattern 6: sort first

Signal: intervals, "closest", "meeting rooms", duplicates, or anything where order would make the answer obvious. Sorting costs O(n log n) once and often turns the rest into one O(n) pass. In Go, slices.SortFunc with cmp.Compare sorts by any field.

gomain.go
package main

import (
	"cmp"
	"fmt"
	"slices"
)

// merge overlapping [start, end] intervals.
func merge(intervals [][]int) [][]int {
	slices.SortFunc(intervals, func(a, b []int) int { return cmp.Compare(a[0], b[0]) })
	var out [][]int
	for _, iv := range intervals {
		if n := len(out); n > 0 && iv[0] <= out[n-1][1] {
			out[n-1][1] = max(out[n-1][1], iv[1]) // overlap: extend the last one
		} else {
			out = append(out, []int{iv[0], iv[1]}) // gap: a new interval (a copy)
		}
	}
	return out
}

// minRooms: meetings needed at once; a meeting ending at t frees its room for one starting at t.
func minRooms(meetings [][2]int) int {
	starts := make([]int, len(meetings))
	ends := make([]int, len(meetings))
	for i, m := range meetings {
		starts[i], ends[i] = m[0], m[1]
	}
	slices.Sort(starts)
	slices.Sort(ends)
	rooms, e := 0, 0
	for _, s := range starts {
		if s >= ends[e] {
			e++ // the earliest-ending meeting is over: reuse its room
		} else {
			rooms++
		}
	}
	return rooms
}

func main() {
	fmt.Println(merge([][]int{{1, 3}, {8, 10}, {2, 6}, {15, 18}, {17, 20}}))
	fmt.Println(minRooms([][2]int{{0, 30}, {5, 10}, {15, 20}}), minRooms([][2]int{{1, 5}, {5, 8}, {8, 10}}))
}
Outputcompiled & run with real Go
[[1 6] [8 10] [15 20]]
2 1

Unsorted, you would compare every interval with every other one. Sorted by start, an interval can only overlap the last one kept. merge appends a copy so that extending the last interval never mutates the caller's inner slices — though it does still reorder the caller's outer slice, which is worth saying out loud in an interview.

10

Pattern 7: BFS and DFS on a grid

Signal: a grid, a maze, a map of cells, "connected", "reachable", "fewest steps". Treat each cell as a node with up to four neighbours — no adjacency list needed, the neighbours are computed from a direction table. DFS answers "how many separate regions?"; BFS answers "shortest number of moves?". Module 13 ran both on an explicit graph; grids are how they usually appear in interviews.

gomain.go
package main

import "fmt"

var dirs = [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}

func countIslands(rows []string) int {
	g := make([][]byte, len(rows))
	for i, r := range rows {
		g[i] = []byte(r) // strings are immutable; copy so we can mark cells
	}
	var sink func(r, c int)
	sink = func(r, c int) { // DFS: turn a whole island into water
		if r < 0 || c < 0 || r >= len(g) || c >= len(g[r]) || g[r][c] != '#' {
			return
		}
		g[r][c] = '.'
		for _, d := range dirs {
			sink(r+d[0], c+d[1])
		}
	}
	count := 0
	for r := range g {
		for c := range g[r] {
			if g[r][c] == '#' {
				count++
				sink(r, c)
			}
		}
	}
	return count
}

// shortest: fewest moves from (sr, sc) to (gr, gc) through '.', or -1.
func shortest(maze []string, sr, sc, gr, gc int) int {
	dist := make([][]int, len(maze))
	for i := range dist {
		dist[i] = make([]int, len(maze[i]))
		for j := range dist[i] {
			dist[i][j] = -1 // -1 = not visited yet
		}
	}
	dist[sr][sc] = 0
	queue := [][2]int{{sr, sc}}
	for len(queue) > 0 {
		cur := queue[0]
		queue = queue[1:]
		for _, d := range dirs {
			r, c := cur[0]+d[0], cur[1]+d[1]
			if r >= 0 && c >= 0 && r < len(maze) && c < len(maze[r]) && maze[r][c] == '.' && dist[r][c] == -1 {
				dist[r][c] = dist[cur[0]][cur[1]] + 1
				queue = append(queue, [2]int{r, c})
			}
		}
	}
	return dist[gr][gc]
}

func main() {
	fmt.Println("islands:", countIslands([]string{
		"##...",
		"#..#.",
		"..##.",
		"#...#",
	}))
	maze := []string{
		"...#",
		"##.#",
		"....",
		".##.",
	}
	fmt.Println("to (3,3):", shortest(maze, 0, 0, 3, 3))
	fmt.Println("to (3,0):", shortest(maze, 0, 0, 3, 0))
	fmt.Println("to (3,1):", shortest(maze, 0, 0, 3, 1))
}
Outputcompiled & run with real Go
islands: 4
to (3,3): 6
to (3,0): 7
to (3,1): -1

The dist grid doubles as the visited set: a cell is enqueued once, the first time BFS reaches it, which is along a shortest path. (3,1) is a wall, so it is never reached and stays -1. Recursive DFS on a huge grid can grow the goroutine stack a lot; Go grows stacks automatically up to a 1 GB limit, but an explicit slice-as-stack avoids the question.

Your turn

Change shortest to also return the path, as a slice of cells from start to goal. Store each cell's parent when you enqueue it, then walk parents back from the goal and reverse.

11

Pattern 8: DP-lite

Signal: "how many ways", "minimum cost", "maximum value", where the answer at position i depends on the answers just before it. Write the recurrence in words first — "best up to house i is the larger of skipping it, or robbing it plus the best that skipped house i-1" — then keep a small table or just two variables. Module 13 covered memoisation and a full table; interviews mostly want these rolling-variable versions.

gomain.go
package main

import "fmt"

// rob: max money from houses in a row, never robbing two neighbours.
func rob(houses []int) int {
	skip, take := 0, 0 // best so far if the previous house was skipped / taken
	for _, money := range houses {
		skip, take = max(skip, take), skip+money
	}
	return max(skip, take)
}

// maxSubarray: largest sum of a non-empty contiguous run (Kadane).
func maxSubarray(a []int) int {
	best, cur := a[0], a[0]
	for _, x := range a[1:] {
		cur = max(x, cur+x) // extend the run, or start again at x
		best = max(best, cur)
	}
	return best
}

// uniquePaths: ways from top-left to bottom-right moving only right or down.
func uniquePaths(rows, cols int) int {
	row := make([]int, cols)
	for c := range row {
		row[c] = 1 // one way along the top edge
	}
	for r := 1; r < rows; r++ {
		for c := 1; c < cols; c++ {
			row[c] += row[c-1] // from above (old row[c]) + from the left
		}
	}
	return row[cols-1]
}

func main() {
	fmt.Println("rob =", rob([]int{2, 7, 9, 3, 1}))
	fmt.Println("max subarray =", maxSubarray([]int{-2, 1, -3, 4, -1, 2, 1, -5, 4}))
	fmt.Println("paths 3x7 =", uniquePaths(3, 7))
}
Outputcompiled & run with real Go
rob = 12
max subarray = 6
paths 3x7 = 28

Go's tuple assignment evaluates the whole right-hand side before assigning, so skip, take = max(skip, take), skip+money uses the old skip on both sides — no temporary variable needed.

Your turn

Write minCostClimb(cost []int) int: you may start on step 0 or 1, each step costs cost[i] to stand on, and you may climb 1 or 2 steps at a time to get past the end. The recurrence is best[i] = cost[i] + min(best[i-1], best[i-2]). For [10 15 20] the answer is 15.

Visualizerob([2 7 9 3 1])Step 1 / 7
skip, take := 0, 0
for _, money := range houses {
skip, take = max(skip, take), skip+money
}
return max(skip, take)
Line 1

Nothing robbed yet.

Variables now
skip0
take0
All 7 steps as a table
StepLineWhat happenedVariables now
11Nothing robbed yet.skip = 0 take = 0
23House worth 2: skipping it keeps 0; taking it gives 0 + 2.money = 2 skip = 0 take = 2
33House worth 7: skipping keeps the best so far (2); taking adds 7 to the old skip (0).money = 7 skip = 2 take = 7
43House worth 9: skip = max(2, 7); take = 2 + 9.money = 9 skip = 7 take = 11
53House worth 3: skip = max(7, 11); take = 7 + 3.money = 3 skip = 11 take = 10
63House worth 1: skip = max(11, 10); take = 11 + 1.money = 1 skip = 11 take = 12
75The better of the two final states: houses 2, 9 and 1.
12

Choosing the pattern

The problem says…Try firstGo tool
sorted slice, pair / triplet with a sum, in placeTwo pointerstwo indices, a[:w]
longest / shortest contiguous substring or subarraySliding windowmap[byte]int or a count array
seen before, count, duplicate, complement, group byHash mapmap[K]V, map[K]struct{}
brackets, next greater, undo, expressionStackslice + append / reslice
all combinations / permutations, n ≤ 20Backtrackingrecursive closure + slices.Clone
intervals, closest, meeting roomsSort firstslices.SortFunc + cmp.Compare
grid, maze, network, connected, fewest stepsBFS (shortest) / DFS (regions)slice queue, direction table
how many ways, min cost, max value, depends on previousDProlling variables or a 1-D slice
top k, k-th largest, merge k sortedHeap (Module 13)container/heap
sorted data and "smallest X such that…"Binary search (Module 13)sort.Search, slices.BinarySearch
Talk while you solve
In a real interview, say the brute force first ("try every pair, O(n²)"), then name the pattern that improves it and why. An interviewer can give credit for a correct plan even if the code runs out of time; silence gives them nothing to grade. Module 15 walks through a whole coding round this way.
Brute force
The simplest correct solution, usually trying every possibility. State it first, then improve it.
Two pointers
Two indices moving through a slice (from both ends, or a slow writer and a fast reader) to avoid a nested loop.
Sliding window
A contiguous range [left, right] that grows on the right and shrinks on the left, touching each element at most twice.
Prefix sum
A slice where entry i is the sum of the first i values, so any range sum is one subtraction.
Monotonic stack
A stack whose values stay in increasing or decreasing order; used for next-greater / next-smaller problems.
Backtracking
Recursive search that makes a choice, explores, and then undoes the choice before trying the next.
Recurrence
A formula for an answer in terms of answers to smaller inputs; the heart of every DP solution.
Edge case
An input at the boundary of what is allowed: nil or empty, one element, maximum size, negative, overflow.
Quick check

Constraints say n ≤ 100,000. Which approach is most likely expected?

Quick check

A backtracking function appends cur to out without copying it. What happens?

Frequently asked questions

How do I get better at solving coding problems in Go?
Practise by pattern, not at random: do three to five problems per pattern (two pointers, sliding window, hash map, stack, backtracking, sorting, BFS/DFS, DP) until you recognise the signal in the problem statement. Write the brute force first, test with a small table of cases, and state the Big-O out loud.
Which Go packages do I need for coding interviews?
Slices and maps cover most problems, with the slices package (Sort, SortFunc, BinarySearch, Clone, Reverse), cmp.Compare, strings, strconv and unicode. Add container/heap for priority queues and sort.Search for binary search on an answer. The built-in min and max (Go 1.21+) save a lot of typing.
Is Go a good language for coding interviews?
Yes. It is slightly more verbose than Python, but it has no hidden costs: a slice is a dynamic array, a map is a hash table, and the standard library has sorting, heaps and binary search. Watch for three Go-specific traps: map iteration order is random, strings index bytes rather than characters, and saved slices can alias one backing array.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.