3. Lower bound, upper bound
Введение: За пределами точечного поиска
В предыдущей главе 2. Бинарный поиск мы подробно рассмотрели классический алгоритм логарифмического поиска со сложностью $O(\log N)$. У классической реализации есть принципиальная особенность: если в массиве присутствует серия одинаковых дубликатов, проверка условия arr[mid] == target вернет индекс случайного из них — того элемента, на который алгоритм наткнулся в процессе последовательного деления отрезка пополам.
Для реальных бизнес-требований и системных движков этого категорически недостаточно. Что если бэкенду необходимо обнаружить строго первое вхождение записи? Или мгновенно вычислить суммарное количество дубликатов в срезе? А если целевой ключ отсутствует — определить его точную, законную позицию в массиве?
В системном программировании (и особенно при проектировании движков баз данных и поисковых индексов) эти задачи решаются элегантными модификациями бинарного поиска: алгоритмами Lower Bound и Upper Bound (Нижняя и Верхняя границы).
Анатомия границ
Представим упорядоченный срез данных с повторяющимися ключами: [1, 2, 4, 4, 4, 6, 8].
Пусть целевым значением выступает число 4.
- Lower Bound (Нижняя граница): Отыскивает индекс первого элемента, значение которого больше либо равно ($\ge$) искомому.
- Для ключа
4результатом станет индекс2(позиция самой первой четверки). - Если запросить число
5(отсутствующее в наборе), Lower Bound вернет индекс5(ячейку числа6). Это так называемая точка вставки (Insertion Point).
- Для ключа
- Upper Bound (Верхняя граница): Отыскивает индекс первого элемента, значение которого строго больше ($>$) искомого.
- Для ключа
4результатом станет индекс5(позиция первого элемента, превосходящего четверку, — числа6).
- Для ключа
flowchart TD
subgraph Индексы
I0["0"] --- I1["1"] --- I2["2"] --- I3["3"] --- I4["4"] --- I5["5"] --- I6["6"]
end
subgraph Значения
V0["1"] --- V1["2"] --- V2["4"] --- V3["4"] --- V4["4"] --- V5["6"] --- V6["8"]
end
LB["Lower Bound для 4<br/>(Индекс 2)"] -.->|"arr[i] >= 4"| V2
UB["Upper Bound для 4<br/>(Индекс 5)"] -.->|"arr[i] > 4"| V5
classDef default fill:#0f172a,stroke:#334155,color:#f8fafc
classDef match fill:#1e293b,stroke:#10b981,color:#10b981
classDef boundary fill:#1e293b,stroke:#f43f5e,color:#f43f5e
classDef boundNode fill:#1e293b,stroke:#38bdf8,color:#38bdf8
class V0,V1,V6 default
class V2,V3,V4 match
class V5 boundary
class LB,UB boundNode
Mechanical Sympathy: Безветвистая магия
Вспомним структуру классического двоичного поиска:
if arr[mid] == target {
return mid
} else if arr[mid] < target {
left = mid + 1
} else {
right = mid - 1
}
Здесь присутствуют три независимые ветви логики. Как мы выяснили ранее, модуль Branch Predictor процессора регулярно ошибается на таких проверках из-за высокой энтропии ветвлений.
Алгоритмы Lower Bound и Upper Bound спроектированы более гармонично. Они полностью избавлены от проверки на равенство (==). Алгоритм никогда не останавливается досрочно. Границы left и right непрерывно сжимаются, пока строго не сойдутся в одной точке. В теле цикла остается ровно одно бинарное условие if. Процессор исполняет такой цикл с гораздо большей предсказуемостью (меньше сбросов конвейера), даже с учетом того, что поиск всегда выполняет ровно $\log_2 N$ шагов без случайных досрочных выходов.
Реализация на Go (Idiomatic)
1. Lower Bound
package main
import "cmp"
// LowerBound возвращает индекс первого элемента >= target.
// Если все элементы меньше target, возвращает len(arr).
func LowerBound[T cmp.Ordered](arr []T, target T) int {
left := 0
right := len(arr) // Внимание: right изначально указывает ЗА пределы массива
for left < right {
mid := left + (right - left) / 2
if arr[mid] >= target {
// Если элемент больше или равен, он МОЖЕТ быть искомой нижней границей.
// Смещаем правую границу, включая текущий mid.
right = mid
} else {
// Элемент строго меньше target, он гарантированно не подходит.
// Смещаем левую границу на следующий индекс.
left = mid + 1
}
}
return left // В момент выхода из цикла строго выполняется left == right
}
2. Upper Bound
// UpperBound возвращает индекс первого элемента строго > target.
// Если таких элементов нет, возвращает len(arr).
func UpperBound[T cmp.Ordered](arr []T, target T) int {
left := 0
right := len(arr)
for left < right {
mid := left + (right - left) / 2
if arr[mid] > target { // Единственное концептуальное отличие: строго больше (>)
right = mid
} else {
left = mid + 1
}
}
return left
}
Стандартная библиотека Go (slices.BinarySearch)
В современном Go (версии 1.21+) стандартная функция slices.BinarySearch под капотом построена строго на логике Lower Bound.
В исходных кодах рантайма (файл src/slices/sort.go) реализован именно этот элегантный алгоритм:
import (
"fmt"
"slices"
)
func main() {
arr := []int{1, 2, 4, 4, 4, 6, 8}
// Возвращает (индекс, bool)
idx, found := slices.BinarySearch(arr, 4)
fmt.Printf("Index: %d, Found: %v\n", idx, found) // Index: 2, Found: true
// Ищем отсутствующее значение
idx, found = slices.BinarySearch(arr, 5)
fmt.Printf("Index: %d, Found: %v\n", idx, found) // Index: 5, Found: false
}
Инженерный вывод: Возвращаемый индекс idx в стандартной библиотеке — это всегда точный Lower Bound. Если флаг found == false, возвращенный индекс указывает точную позицию, куда можно произвести операцию insert, сохранив упорядоченность массива без повторной пересортировки.
Паттерны задач (Собеседования)
Если в формулировке алгоритмической задачи присутствует фраза «дан отсортированный массив», это практически стопроцентный маркер применимости двоичного поиска и его граничных модификаций.
Паттерн 1: Подсчет дубликатов (Count Occurrences)
Задача: Дан отсортированный срез arr и искомое значение X. Требуется подсчитать количество вхождений числа X с асимптотикой строго $O(\log N)$.
Решение: Линейное сканирование (см. 1. Линейный поиск) после первого попадания потребует в худшем случае $O(N)$ времени, что вызовет таймаут на срезах с миллионами дубликатов.
Решение через границы:
count := UpperBound(arr, X) - LowerBound(arr, X)
Разность двух индексов дает точное число дубликатов ровно за $2 \cdot \log_2 N$ операций!
Паттерн 2: Поиск последнего вхождения
Задача: Найти индекс последнего вхождения числа X в отсортированном массиве.
Решение: Вычисляем idx := UpperBound(arr, X) - 1. Если условие idx >= 0 && arr[idx] == X истинно, найденный индекс указывает строго на последний дубликат.
Итог
- Lower Bound и Upper Bound — фундаментальные модификации двоичного поиска, которые не совершают случайных досрочных остановок, а детерминированно сужают интервал до границы перехода.
- Алгоритмы оптимизируют конвейер микропроцессора, избавляясь от многоветвистых условий и работая с минимальным числом инструкций условного перехода.
- Разность индексов
UpperBound(arr, X) - LowerBound(arr, X)дает количество одинаковых элементов за $O(\log N)$. - Стандартная функция
slices.BinarySearchв Go 1.21+ реализует именно семантику Lower Bound, возвращая точку вставки (Insertion Point) при промахе.
Граничный бинарный поиск идеален, когда цель поиска статична, а массив неизменен. Но что делать, если перед нами стоит задача отыскать пару элементов с заданной суммой или исследовать подмассивы переменной длины? Для этого применяется принципиально иная алгоритмическая техника.
В следующей главе мы исследуем: 4. Два указателя - техника two pointers.