24.7 sort.Search: Binary Search
Right, so you want to find something in a sorted slice. You could just loop through it, checking each element one by one. That’s a linear search, and it’s fine for your grocery list. But if you’re dealing with a sorted list of 100,000 users by ID, checking every single one is like reading the dictionary cover-to-cover to find the word “zebra.” It works, but it’s an intellectual embarrassment.
This is where sort.Search comes in. It’s Go’s built-in implementation of a binary search, and it’s so fast it feels like cheating. The core idea is simple: you start in the middle of your sorted slice. If the element there is less than your target, you know your target must be in the right half; if it’s greater, it’s in the left half. You then repeat the process on that half, and so on, halving the search space each time. You go from 100,000 elements to 50,000 to 25,000 to… you get the idea. You’ll find your target (or confirm its absence) in about 17 steps. Logarithmic time. Chef’s kiss.
But here’s the thing Go does that’s a bit quirky and trips everyone up at first: sort.Search doesn’t return the value you’re looking for. It returns the position where the value would be if it were there. It’s up to you to check that index to see if you actually hit the bullseye or just got close.
The Signature and How It Thinks
The function signature is:
func Search(n int, f func(int) bool) int
You tell it the length of your slice (n) and give it a function f. This function f is the secret sauce. Search will call your function f(i) with various indices i (from 0 to n-1) until it finds the first index for which f(i) returns true. It then assumes that f(j) is also true for all j > i. Your job is to write f to test the condition “is the element at index i >= my target value?”
This is the critical mental flip. You’re not searching for a value; you’re searching for the first index that satisfies a condition. For a typical “find x” search, the condition is “is this element >= x?”.
The Standard “Find” Pattern
Let’s say you have a sorted slice of ints and you’re looking for the number 42.
package main
import (
"fmt"
"sort"
)
func main() {
nums := []int{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
target := 42
// The search function: is the element at index i >= target?
i := sort.Search(len(nums), func(i int) bool {
return nums[i] >= target
})
// Now, we must check if we actually found it.
if i < len(nums) && nums[i] == target {
fmt.Printf("Found %d at index %d\n", target, i)
} else {
fmt.Printf("%d not found. It would be inserted at index %d.\n", target, i)
}
}
This will output: 42 not found. It would be inserted at index 4. Which is correct; it slots right between 40 and 50.
Why This Design is Actually Brilliant (Once You Get It)
This seemingly indirect approach is what makes sort.Search incredibly powerful. It’s not just for finding exact matches. Because you define the condition, you can use it for all sorts of things.
Need to find the first name that starts with ‘Q’? Your condition becomes return names[i] >= "Q". Need to find the first person who is at least 18 years old? return ages[i] >= 18. The algorithm doesn’t care what your data is; it just efficiently finds the point where your boolean condition flips from false to true.
The Classic Pitfall: Off-by-One and Index Panics
The most common mistake is forgetting the second part of the check. The returned index i is only valid if i is within the bounds of the slice. If your target is greater than every element in the slice, Search will return n (the length of the slice). Trying to do nums[i] will panic.
Always, always check the index bounds before accessing the slice, unless you’re absolutely certain the value must be present.
A More Complex Example: Searching a Custom Type
Let’s prove this isn’t just for integers. Here we have a slice of Person structs sorted by last name, and we want to find the first person with a last name >= “Smith”.
package main
import (
"fmt"
"sort"
)
type Person struct {
FirstName string
LastName string
}
func main() {
people := []Person{
{"Alice", "Adams"},
{"Bob", "Brown"},
{"Carol", "Jones"},
{"Dave", "Smith"},
{"Eve", "Smith"}, // Duplicate last names are fine!
{"Frank", "Zappa"},
}
targetLastName := "Smith"
// Search for the first index where LastName >= "Smith"
i := sort.Search(len(people), func(i int) bool {
return people[i].LastName >= targetLastName
})
if i < len(people) {
fmt.Printf("Found first '%s' at index %d: %s %s\n",
targetLastName, i, people[i].FirstName, people[i].LastName)
} else {
fmt.Printf("No one with a last name >= '%s' found.\n", targetLastName)
}
}
This works perfectly, demonstrating how you delegate the actual comparison logic to your function. The algorithm just handles the efficient traversal. It’s a beautiful separation of concerns, even if it requires a bit of a brain twist to set up correctly the first time. Master this, and you’ll never look at a sorted dataset the same way again.