Linear Search — Find the Bug¶
12 buggy implementations. Find the bug, explain it, and write the fix. Each bug is realistic — drawn from real-world code reviews and Stack Overflow questions.
Table of Contents¶
- Bug 1 — Off-by-One Loop Bound
- Bug 2 — Returning bool Instead of Index
- Bug 3 — Missing Not-Found Case
- Bug 4 — NaN Silent Failure
- Bug 5 — Mutation During Iteration
- Bug 6 — Recursive Stack Overflow
- Bug 7 — Parallel Race Condition
- Bug 8 — Comparator Symmetry Violation
- Bug 9 — Returns Last Instead of First
- Bug 10 — Case-Sensitive String Search
- Bug 11 — Integer Overflow in Counter
- Bug 12 — Generic Type Bounds
Bug 1 — Off-by-One Loop Bound¶
Buggy — Go¶
func LinearSearch(arr []int, target int) int {
for i := 0; i <= len(arr); i++ { // ← BUG
if arr[i] == target {
return i
}
}
return -1
}
Buggy — Java¶
public static int linearSearch(int[] arr, int target) {
for (int i = 0; i <= arr.length; i++) { // ← BUG
if (arr[i] == target) return i;
}
return -1;
}
Buggy — Python¶
def linear_search(arr, target):
for i in range(len(arr) + 1): # ← BUG
if arr[i] == target:
return i
return -1
Bug¶
Loop bound is <= len(arr) (inclusive) instead of < len(arr) (exclusive). The last iteration accesses arr[len(arr)] — index out of bounds. In Go: panic. In Java: ArrayIndexOutOfBoundsException. In Python: IndexError.
Fix¶
Use < (Go/Java) or range(len(arr)) (Python).
Lesson¶
Off-by-one is the most common bug in linear search. Use for x := range arr (Go) or enumerate(arr) (Python) to eliminate the index entirely when possible.
Bug 2 — Returning bool Instead of Index¶
Buggy — Go¶
func LinearSearch(arr []int, target int) bool {
for _, v := range arr {
if v == target {
return true
}
}
return false
}
// Caller:
idx := LinearSearch(arr, 5) // ← compile error or wrong type
arr[idx] = 99 // even if it compiled, semantics are wrong
Buggy — Java¶
public static boolean linearSearch(int[] arr, int target) {
for (int v : arr) {
if (v == target) return true;
}
return false;
}
Buggy — Python¶
Bug¶
The function returns a bool (true/false for present/absent), but the caller often needs the index to read or modify the element. Once you've thrown away the index, the caller must do their own linear search.
Fix¶
Return the index (-1 if absent). The caller can derive a bool with idx >= 0.
Lesson¶
Return more information, not less. Indices are richer than booleans; let the caller throw away what they don't need.
Bug 3 — Missing Not-Found Case¶
Buggy — Go¶
func LinearSearch(arr []int, target int) int {
for i, v := range arr {
if v == target {
return i
}
}
// ← BUG: no return statement
}
(Go won't compile — but the bug shape is real in JavaScript/TypeScript.)
Buggy — JavaScript¶
function linearSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i;
}
}
// ← BUG: returns undefined for not-found
}
// Caller:
const idx = linearSearch([1, 2, 3], 9); // → undefined
arr[idx]; // → undefined (but no error)
if (idx === -1) { ... } // → never true!
Buggy — Python¶
def linear_search(arr, target):
for i, v in enumerate(arr):
if v == target:
return i
# ← BUG: returns None for not-found
idx = linear_search([1, 2, 3], 9) # → None
if idx >= 0: # → TypeError: '>=' not supported with 'NoneType'
...
Bug¶
Forgetting the return -1 after the loop. In statically-typed languages, the compiler catches it. In dynamic languages, the function silently returns undefined / None, breaking callers that assume an integer.
Fix¶
Always have an explicit return -1 after the loop.
def linear_search(arr, target):
for i, v in enumerate(arr):
if v == target:
return i
return -1 # ← required
Lesson¶
Document and enforce the return type. In TypeScript: function linearSearch(...): number. In Python: type hints + mypy. Don't rely on duck typing for sentinel values.
Bug 4 — NaN Silent Failure¶
Buggy — Python¶
import math
arr = [1.0, 2.0, math.nan, 4.0]
def linear_search(arr, target):
for i, v in enumerate(arr):
if v == target: # ← BUG when target is NaN
return i
return -1
print(linear_search(arr, math.nan)) # → -1, but NaN IS in the array!
Buggy — Java¶
double[] arr = {1.0, 2.0, Double.NaN, 4.0};
double target = Double.NaN;
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) { // ← always false for NaN
return i;
}
}
return -1; // → -1, even though NaN is at index 2
Buggy — JavaScript¶
Bug¶
IEEE-754 specifies NaN != NaN. Any comparison involving NaN returns false, so equality-based search never finds NaN.
Fix¶
Use a NaN-aware equality:
def is_equal(a, b):
if isinstance(a, float) and isinstance(b, float):
if math.isnan(a) and math.isnan(b):
return True
return a == b
In JavaScript, use Array.prototype.includes (uses SameValueZero, which treats NaN as equal to itself).
In Java, use Double.equals (boxed) instead of ==.
Lesson¶
Floating-point equality is full of traps. Always be explicit about NaN handling, and consider whether equality is even the right check (vs. tolerance-based comparison).
Bug 5 — Mutation During Iteration¶
Buggy — Go¶
func RemoveAll(arr []int, target int) []int {
for i, v := range arr {
if v == target {
arr = append(arr[:i], arr[i+1:]...) // ← BUG: mutates while iterating
}
}
return arr
}
Buggy — Java¶
public static void removeAll(List<Integer> list, int target) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i) == target) {
list.remove(i); // ← BUG: shifts subsequent elements; we skip one
}
}
}
// Or with iterator:
for (Integer v : list) {
if (v == target) {
list.remove(v); // ← ConcurrentModificationException
}
}
Buggy — Python¶
def remove_all(arr, target):
for i, v in enumerate(arr):
if v == target:
arr.pop(i) # ← BUG: corrupts iteration
remove_all([1, 2, 2, 3], 2)
# Expected: [1, 3]
# Actual: [1, 2, 3] — second 2 was skipped
Bug¶
After pop(i), all subsequent indices shift down by 1. The for-loop's next iteration uses the old index, skipping an element. For consecutive matches (like [1, 2, 2, 3]), only every other one is removed.
Fix¶
Several options:
1. Iterate in reverse:
2. Build a new list:
3. Use the language idiom:
Lesson¶
Never mutate a collection while iterating over it. Either iterate over a copy, iterate in reverse (for index-based deletion), or use the language's bulk operations.
Bug 6 — Recursive Stack Overflow¶
Buggy — Python¶
import sys
def linear_search_recursive(arr, target, i=0):
if i >= len(arr):
return -1
if arr[i] == target:
return i
return linear_search_recursive(arr, target, i + 1)
big = list(range(10_000))
linear_search_recursive(big, 9999)
# RecursionError: maximum recursion depth exceeded
Buggy — Java¶
public static int linearSearchRecursive(int[] arr, int target, int i) {
if (i >= arr.length) return -1;
if (arr[i] == target) return i;
return linearSearchRecursive(arr, target, i + 1);
}
// For arr.length > ~10000, throws StackOverflowError.
Buggy — Go¶
func LinearSearchRecursive(arr []int, target, i int) int {
if i >= len(arr) { return -1 }
if arr[i] == target { return i }
return LinearSearchRecursive(arr, target, i+1)
}
// Go's default stack is 1 MB but grows up to 1 GB — can hit OOM
// before stack overflow on huge arrays.
Bug¶
Each recursive call adds a stack frame. For n > 1000 (Python), > ~10000 (Java), > millions (Go), the stack overflows.
Fix¶
Use iteration. There is no reason to recurse for linear search — it has no divide-and-conquer structure.
Lesson¶
Recursion is for tree/graph problems. Iteration is for sequential traversal. Use the natural fit. Python and Java do not optimize tail calls; even tail-recursive code overflows.
Bug 7 — Parallel Race Condition¶
Buggy — Go¶
import "sync"
func ParallelSearch(arr []int, target int) int {
found := -1 // ← shared mutable state
var wg sync.WaitGroup
chunks := 4
chunkSize := len(arr) / chunks
for c := 0; c < chunks; c++ {
wg.Add(1)
go func(start int) {
defer wg.Done()
for i := start; i < start+chunkSize; i++ {
if arr[i] == target {
found = i // ← BUG: data race on `found`
}
}
}(c * chunkSize)
}
wg.Wait()
return found
}
Buggy — Java¶
import java.util.concurrent.*;
public static int parallelSearch(int[] arr, int target) {
int[] found = {-1}; // ← shared mutable
ExecutorService es = Executors.newFixedThreadPool(4);
int chunkSize = arr.length / 4;
for (int c = 0; c < 4; c++) {
final int start = c * chunkSize;
es.submit(() -> {
for (int i = start; i < start + chunkSize; i++) {
if (arr[i] == target) {
found[0] = i; // ← race condition
}
}
});
}
es.shutdown();
es.awaitTermination(1, TimeUnit.MINUTES);
return found[0];
}
Buggy — Python¶
from concurrent.futures import ThreadPoolExecutor
def parallel_search(arr, target):
found = [-1] # ← shared
chunk = len(arr) // 4
def worker(start):
for i in range(start, start + chunk):
if arr[i] == target:
found[0] = i # ← Python's GIL makes this lucky, but still racy
with ThreadPoolExecutor(max_workers=4) as ex:
for c in range(4):
ex.submit(worker, c * chunk)
return found[0]
Bug¶
Multiple threads write to found concurrently. Without synchronization: - Go: data race detected by -race; result may be any match (not first). - Java: similar; no atomicity guarantees on found[0]. - Python: GIL provides accidental atomicity for single-store reads, but the result is still nondeterministic across runs.
Also, workers don't stop once a match is found — they keep scanning their entire chunks, wasting CPU.
Fix¶
Use atomic operations + cooperative cancellation:
import "sync/atomic"
import "context"
func ParallelSearch(arr []int, target int) int {
var found int64 = -1
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
chunkSize := (len(arr) + 3) / 4
for c := 0; c < 4; c++ {
wg.Add(1)
start := c * chunkSize
end := start + chunkSize
if end > len(arr) { end = len(arr) }
go func(lo, hi int) {
defer wg.Done()
for i := lo; i < hi; i++ {
select {
case <-ctx.Done(): return
default:
}
if arr[i] == target {
// CAS: only set if no smaller index already set
for {
cur := atomic.LoadInt64(&found)
if cur != -1 && cur < int64(i) { return }
if atomic.CompareAndSwapInt64(&found, cur, int64(i)) {
cancel()
return
}
}
}
}
}(start, end)
}
wg.Wait()
return int(atomic.LoadInt64(&found))
}
Lesson¶
Concurrent writes to shared state require atomic primitives (or message passing). Always run with -race (Go), ThreadSanitizer (C++), or static analyzers in CI.
Bug 8 — Comparator Symmetry Violation¶
Buggy — Java¶
import java.util.Comparator;
class Item {
int id;
String name;
}
Comparator<Item> wrongCmp = (a, b) -> a.name.compareToIgnoreCase(b.name);
// Linear search with custom comparator
public static int findItem(Item[] arr, Item target, Comparator<Item> cmp) {
for (int i = 0; i < arr.length; i++) {
if (cmp.compare(arr[i], target) == 0) {
return i;
}
}
return -1;
}
// Usage:
Item target = new Item();
target.name = null; // ← NPE inside compareToIgnoreCase
findItem(items, target, wrongCmp);
Bug¶
The comparator does not handle null names — null.compareToIgnoreCase(x) throws NPE. Also, the comparator is not symmetric if items with null names are compared in different orders (depending on which side is null).
Fix¶
Write a defensive comparator:
Comparator<Item> safeCmp = Comparator.nullsFirst(
Comparator.comparing(
i -> i.name,
Comparator.nullsFirst(String.CASE_INSENSITIVE_ORDER)
)
);
Lesson¶
Comparators must satisfy reflexivity, symmetry, transitivity, and null safety. Linear search with a custom comparator inherits all the comparator's bugs. Test the comparator independently with property-based tests.
Bug 9 — Returns Last Instead of First¶
Buggy — Go¶
func FindFirst(arr []int, target int) int {
found := -1
for i, v := range arr {
if v == target {
found = i // ← BUG: keeps overwriting, returns LAST match
}
}
return found
}
FindFirst([]int{5, 3, 5, 7, 5}, 5) // → 4 (last 5), expected 0
Buggy — Python¶
def find_first(arr, target):
found = -1
for i, v in enumerate(arr):
if v == target:
found = i # ← keeps updating
return found
Bug¶
Without an early return / break, the loop keeps overwriting found with each new match. The function returns the last index, not the first.
Fix¶
Return immediately on first match:
func FindFirst(arr []int, target int) int {
for i, v := range arr {
if v == target {
return i // ← early exit
}
}
return -1
}
Lesson¶
The "first match" contract requires early exit. If you genuinely want the last match, scan in reverse — don't scan forward and overwrite.
Bug 10 — Case-Sensitive String Search¶
Buggy — Python¶
words = ["Hello", "WORLD", "foo", "Bar"]
def find_word(words, target):
for i, w in enumerate(words):
if w == target:
return i
return -1
find_word(words, "hello") # → -1, but user expected 0 (case-insensitive search)
Buggy — Java¶
String[] words = {"Hello", "WORLD", "foo", "Bar"};
for (int i = 0; i < words.length; i++) {
if (words[i].equals("hello")) return i; // ← case-sensitive, misses "Hello"
}
Bug¶
String equality is case-sensitive by default. User-input search terms rarely match the exact case of stored data, leading to false negatives.
Fix¶
Normalize both sides — but be careful about Unicode:
def find_word(words, target):
target_lower = target.casefold() # ← casefold > lower for Unicode
for i, w in enumerate(words):
if w.casefold() == target_lower:
return i
return -1
words[i].equalsIgnoreCase("hello");
// Or for Unicode correctness:
words[i].toLowerCase(Locale.ROOT).equals("hello".toLowerCase(Locale.ROOT));
Lesson¶
String search needs an explicit policy on case, locale, accent normalization, and whitespace. casefold (Python) and equalsIgnoreCase (Java) handle the common case, but for Turkish dotless I, German ß, etc., use ICU.
Bug 11 — Integer Overflow in Counter¶
Buggy — Java¶
public static int countMatches(int[] arr, int target) {
int count = 0; // ← only goes up to 2^31 - 1
for (int v : arr) {
if (v == target) {
count++;
}
}
return count;
}
// On a 4-billion-element array (yes, possible with off-heap memory in Java 21),
// count silently wraps to negative.
Buggy — C¶
int count_matches(const int* arr, size_t n, int target) {
int count = 0; // ← int = 32-bit on most platforms
for (size_t i = 0; i < n; i++) {
if (arr[i] == target) count++;
}
return count; // ← undefined behavior on overflow
}
Buggy — Go¶
func Count(arr []int32, target int32) int32 {
var count int32 // ← max 2^31 - 1
for _, v := range arr {
if v == target {
count++
}
}
return count
}
Bug¶
A counter typed int (32-bit) overflows after 2^31 - 1 ≈ 2.1 billion matches. In Java, this wraps to a negative number silently. In C, it's undefined behavior (and can be exploited to break loops). In Go, similar wrap.
Fix¶
Use a wider type:
public static long countMatches(int[] arr, int target) {
long count = 0;
for (int v : arr) if (v == target) count++;
return count;
}
func Count(arr []int32, target int32) int64 {
var count int64
for _, v := range arr {
if v == target {
count++
}
}
return count
}
Lesson¶
Choose counter widths to exceed the maximum possible count, with margin. For very large data sets, default to 64-bit. In safety-critical code, use checked arithmetic (Math.addExact in Java).
Bug 12 — Generic Type Bounds¶
Buggy — Java¶
public static <T> int linearSearch(T[] arr, T target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) { // ← BUG: identity comparison, not equality
return i;
}
}
return -1;
}
String[] s = {"apple", new String("apple"), "banana"};
linearSearch(s, "apple"); // → 0 (lucky — interned)
linearSearch(s, new String("apple")); // → -1 (not the same object!)
Buggy — Go¶
// Pre-Go 1.18 — no generics. Hand-rolled with interface{}:
func LinearSearch(arr []interface{}, target interface{}) int {
for i, v := range arr {
if v == target { // ← BUG: works for primitives, but not for slice/map values
return i
}
}
return -1
}
// Go 1.18+ with generics:
func LinearSearchGeneric[T comparable](arr []T, target T) int {
for i, v := range arr {
if v == target {
return i
}
}
return -1
}
// `comparable` excludes slices, maps, funcs — but doesn't help with structs
// containing those, which panic at runtime when compared.
Bug¶
Java's == on object references checks identity, not value equality. Two String objects with the same content are different references unless interned, so == returns false.
In Go, the comparable constraint allows compile-time-comparable types but doesn't enforce semantic equality (e.g., two time.Time values with the same instant compare unequal due to monotonic clock).
Fix¶
Java: use Objects.equals (handles null + delegates to equals):
public static <T> int linearSearch(T[] arr, T target) {
for (int i = 0; i < arr.length; i++) {
if (Objects.equals(arr[i], target)) {
return i;
}
}
return -1;
}
Go: for types where == is the wrong notion of equality, accept a comparator:
func LinearSearchFunc[T any](arr []T, eq func(T) bool) int {
for i, v := range arr {
if eq(v) {
return i
}
}
return -1
}
// Usage:
idx := LinearSearchFunc(times, func(t time.Time) bool {
return t.Equal(target) // monotonic-aware equality
})
Lesson¶
Generic / dynamic equality is a minefield. Always think about what equality means for your type: - Primitives → == works. - Strings → equals (Java), == (Go), == (Python). - Custom objects → require an Equals / __eq__ / Comparable impl. - Floats → use tolerance. - Time → use a domain-aware comparison.
Summary¶
| # | Bug | Category |
|---|---|---|
| 1 | Off-by-one bound | Loop control |
| 2 | bool instead of index | API design |
| 3 | Missing not-found return | Control flow |
| 4 | NaN equality | Floating point |
| 5 | Mutation while iterating | Concurrency / iteration |
| 6 | Recursive stack overflow | Algorithm choice |
| 7 | Parallel race | Concurrency |
| 8 | Comparator violation | Custom equality |
| 9 | Returns last not first | Control flow |
| 10 | Case-sensitive search | Domain modeling |
| 11 | Counter overflow | Numeric correctness |
| 12 | Generic identity vs equality | Type system |
Linear search is "trivial" in the textbook sense, but production code is full of these bugs. Most modern style guides forbid hand-written linear search in favor of slices.Index, Objects.equals, Stream.findFirst, etc. — precisely because they handle these traps for you.