15.22 Common Bugs
Divide-and-conquer algorithms often fail in ways that are difficult to see from the high-level design.
15.22 Common Bugs
Problem
Divide-and-conquer algorithms often fail in ways that are difficult to see from the high-level design.
The recurrence may be correct. The idea may be standard. The code may even work for ordinary examples.
Then a small input breaks it:
[]
[1]
[2, 1]
[1, 1, 1]
or a boundary case causes:
infinite recursion
missing element
duplicate element
wrong pivot position
stack overflow
incorrect count
How do you recognize and avoid the most common divide-and-conquer bugs?
Solution
Look for bugs at the three structural points of the algorithm:
base case
split
combine
Most failures occur because one of these is slightly wrong.
A reliable review checklist is:
- Does every recursive path reach a base case?
- Are subproblems strictly smaller?
- Are all elements included exactly once?
- Does the combine step preserve the intended invariant?
- Are boundary indexes inclusive or exclusive consistently?
- Does the algorithm handle duplicates?
- Does the implementation match the complexity analysis?
Bug 1: Base Case Too Narrow
A common recursive bug is stopping too late.
Bad merge sort:
func MergeSort(nums []int) []int {
if len(nums) == 1 {
return nums
}
mid := len(nums) / 2
left := MergeSort(nums[:mid])
right := MergeSort(nums[mid:])
return Merge(left, right)
}
This fails for:
[]
because:
len(nums) == 1
is false.
Then:
mid = 0
left = []
right = []
and recursion never makes progress.
Better:
func MergeSort(nums []int) []int {
if len(nums) <= 1 {
return nums
}
mid := len(nums) / 2
left := MergeSort(nums[:mid])
right := MergeSort(nums[mid:])
return Merge(left, right)
}
Use base cases that cover all minimal inputs.
Bug 2: Recursive Calls Do Not Shrink
Every recursive call must receive a strictly smaller problem.
Bad binary search:
func Search(nums []int, target int, low, high int) int {
if low > high {
return -1
}
mid := low + (high-low)/2
if nums[mid] == target {
return mid
}
if nums[mid] < target {
return Search(nums, target, mid, high)
}
return Search(nums, target, low, mid)
}
When low + 1 == high, mid may equal low.
The recursive call:
Search(nums, target, mid, high)
can repeat the same interval.
Correct version:
func Search(nums []int, target int, low, high int) int {
if low > high {
return -1
}
mid := low + (high-low)/2
if nums[mid] == target {
return mid
}
if nums[mid] < target {
return Search(nums, target, mid+1, high)
}
return Search(nums, target, low, mid-1)
}
Exclude the midpoint after checking it.
Bug 3: Inclusive and Exclusive Bounds Mixed
Index bugs often come from mixing conventions.
Two common styles are:
inclusive: [low, high]
exclusive: [low, high)
Both are valid. Mixing them is dangerous.
Exclusive style:
func sortRange(a []int, lo, hi int) {
if hi-lo <= 1 {
return
}
mid := lo + (hi-lo)/2
sortRange(a, lo, mid)
sortRange(a, mid, hi)
}
Inclusive style:
func sortRange(a []int, low, high int) {
if low >= high {
return
}
mid := low + (high-low)/2
sortRange(a, low, mid)
sortRange(a, mid+1, high)
}
Choose one convention and keep it through the function.
Bug 4: Lost Elements During Merge
A merge loop that stops when one side is exhausted must still copy the remaining side.
Buggy merge:
func Merge(left, right []int) []int {
result := make([]int, 0, len(left)+len(right))
i, j := 0, 0
for i < len(left) && j < len(right) {
if left[i] <= right[j] {
result = append(result, left[i])
i++
} else {
result = append(result, right[j])
j++
}
}
return result
}
For:
left = [1, 4]
right = [2]
this returns:
[1, 2]
and loses:
4
Correct:
result = append(result, left[i:]...)
result = append(result, right[j:]...)
A merge function should preserve element count.
Always test:
len(output) == len(left) + len(right)
Bug 5: Duplicate Handling in Partition
Simple quicksort partitioning often assumes distinct values.
Bad partition logic may fail or degrade badly on:
[5, 5, 5, 5, 5]
If recursive calls do not exclude the pivot or equal region properly, duplicates can cause infinite recursion or quadratic behavior.
For duplicate-heavy input, use three-way partitioning:
< pivot
== pivot
> pivot
Then recurse only on the less-than and greater-than regions.
This avoids repeatedly processing elements equal to the pivot.
Bug 6: Broken Stability
If a sorting algorithm promises stability, equal elements must keep their original order.
In merge sort, this detail matters:
if left[i] <= right[j] {
result = append(result, left[i])
i++
} else {
result = append(result, right[j])
j++
}
Using:
if left[i] < right[j]
chooses the right element first when values are equal.
That can break stability.
The algorithm may still sort correctly, but it no longer satisfies the stronger contract.
Bug 7: Wrong Midpoint Calculation
This expression is common:
mid := (low + high) / 2
For large integer indexes, low + high may overflow.
Safer:
mid := low + (high-low)/2
Many modern languages and input sizes make overflow less likely, but the safer form costs nothing and avoids a class of bugs.
Bug 8: Wrong Recurrence Due to Copying
A clean-looking implementation can accidentally copy too much data.
Example:
left := append([]int(nil), nums[:mid]...)
right := append([]int(nil), nums[mid:]...)
This copies at every recursive call.
For merge sort, copying subarrays plus allocating merge results can still be asymptotically acceptable, but the constant factor may be large.
For other algorithms, copying can change expected performance significantly.
Prefer index ranges or reusable buffers in production.
Bug 9: Assuming Balanced Splits
Some algorithms split evenly.
Merge sort:
n/2 and n/2
Some do not.
Quick sort:
pivot position determines split
If pivot selection is poor, quick sort may produce:
0 and n-1
repeatedly.
That gives:
$$ O(n^2) $$
time and:
$$ O(n) $$
stack space.
Do not claim balanced complexity unless the algorithm guarantees balanced splits or uses randomization with expected analysis.
Bug 10: Off-by-One in Quickselect Rank
Users often define:
k = 1
as the smallest element.
Array indexes define:
index 0
as the first element.
Convert once:
target := k - 1
Then use target consistently.
For the (k)-th largest element:
target := len(nums) - k
Mixing rank and index causes errors that only appear for boundary values like first, last, and median.
Bug 11: Incorrect Strip Condition in Closest Pair
In closest-pair algorithms, the strip should include points whose x-coordinate is within the current best distance of the dividing line.
Typical condition:
if math.Abs(p.X-midX) < best {
strip = append(strip, p)
}
Using the wrong coordinate, wrong midpoint, or wrong inequality can miss valid crossing pairs.
Another mistake is sorting the strip by x-coordinate. The strip scan depends on y-ordering.
Bug 12: Floating-Point Equality
Geometric and FFT algorithms often use floating-point numbers.
Bad test:
if got != want {
t.Fatal("wrong")
}
Better:
if math.Abs(got-want) > 1e-9 {
t.Fatal("wrong")
}
For distance comparisons, use squared distances to avoid unnecessary square roots and reduce numerical noise.
For FFT coefficient recovery, round values that should be integral.
Bug 13: Forgetting to Pad FFT Inputs
FFT-based convolution requires enough space for the full result.
If:
len(a) = m
len(b) = n
the result has:
m + n - 1
coefficients.
Pad to at least that length, often the next power of two.
Without padding, convolution wraps around.
This produces plausible but wrong results.
Bug 14: Stack Overflow
Recursive algorithms can exhaust the call stack.
Common causes:
- worst-case quick sort
- missing base case
- recursive call does not shrink
- very deep binary recursion over linked structures
- input size too large for recursive implementation
Mitigations:
- convert to iterative form
- recurse into smaller partition first
- use explicit stack
- randomize pivots
- add depth limits
Introsort uses a depth limit and switches strategy when recursion becomes suspicious.
Bug 15: Parallel Data Races
Parallel divide-and-conquer introduces concurrency bugs.
Bad pattern:
all workers append to shared result
Better pattern:
each worker writes to a disjoint range
combine after join
For parallel algorithms, verify:
- no overlapping writes
- no shared mutable state without synchronization
- no goroutine leaks
- no unbounded task creation
- no false sharing in hot counters
Correct sequential code can become incorrect after naive parallelization.
Review Checklist
Before trusting a divide-and-conquer implementation, check:
| Area | Question |
|---|---|
| Base case | Does it handle empty and single-element input? |
| Progress | Does every recursive call shrink the problem? |
| Bounds | Are indexes consistently inclusive or exclusive? |
| Coverage | Are all elements included exactly once? |
| Combine | Does the merge or partition preserve its invariant? |
| Duplicates | Are equal values handled deliberately? |
| Mutation | Does the function mutate input, and is that documented? |
| Space | Are allocations intentional? |
| Worst case | Can recursion become unbalanced? |
| Tests | Is there a brute-force oracle for small inputs? |
Discussion
Most divide-and-conquer bugs are structural. They are not caused by misunderstanding the algorithm at a high level. They arise because an implementation fails to preserve one of the small contracts that make recursion safe: base cases must stop, splits must shrink, combines must preserve data, and boundaries must be consistent.
The best defense is to make each contract explicit. Write helper functions with narrow responsibilities. Test merge, partition, feasibility, and strip scanning independently. Compare the whole algorithm against brute force on small randomized inputs. Include empty, duplicate-heavy, sorted, and reverse-sorted cases.
Divide-and-conquer code becomes reliable when its recursion shape is boring. The base case is obvious, the split convention is consistent, and the combine step has a clear invariant. Once those pieces are stable, the larger algorithm usually follows.