5. First Bad Version
«Поиск первой ошибки во времени — это всегда поиск точки перелома между тем, что работало, и тем, что сломалось».
— Брайан Керниган
В статье 4. Search Insert Position мы научились находить границу в физическом массиве, непрерывно лежащем в оперативной памяти. Но в реальной инженерии мы редко имеем дело со статическими срезами. Данные могут находиться за сетевым RPC-вызовом, лежать в распределенной базе данных или вычисляться на лету тяжелой функцией.
Задача First Bad Version (LeetCode 278) — это фундаментальный пример бинарного поиска поверх абстрактного предиката (черного ящика). В повседневной инженерной практике ровно этот алгоритм лежит в основе инструмента git bisect, позволяющего локализовать баговый коммит в истории репозитория из десятков тысяч ревизий за $O(\log N)$ шагов.
Формулировка задачи: Продукт имеет версионирование от $1$ до $n$. Начиная с некоторой версии $k$, сборка сломалась, и все последующие версии ($k, k+1, \dots, n$) также являются дефектными. Предоставлен API-метод isBadVersion(version int) bool. Необходимо найти номер первой сломанной версии $k$, минимизировав количество обращений к API.
Архитектура: Монотонный предикат и Lower Bound
Хотя физического массива нет, последовательность результатов вызова isBadVersion(i) от $i = 1$ до $n$ формирует строго монотонную булеву последовательность:
$$\underbrace{[\text{false}, \text{false}, \dots, \text{false}]}_{\text{Рабочие версии}} \;\underbrace{[\text{true}, \text{true}, \dots, \text{true}]}_{\text{Сломанные версии}}$$
Задача сводится к поиску первого индекса со значением true. Это классический паттерн Lower Bound:
- Границы диапазона:
left = 1,right = n. Поскольку гарантируется, что хотя бы одна плохая версия существует, ответ лежит строго в отрезке $[1, n]$. - Если
isBadVersion(mid) == true: версияmidдефектна. Она может быть как первой плохой, так и одной из последующих. Мы не можем исключатьmid, поэтому сжимаем правую границу:right = mid. - Если
isBadVersion(mid) == false: версияmidисправна. Первая ошибка гарантированно появилась позже. Сдвигаем левую границу:left = mid + 1.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Start["Интервал версий: [1 ... n]"]:::entry --> Check{"isBadVersion(mid) == true?"}:::warning
Check -->|"Да (версия плохая)<br>Кандидат найден"| GoLeft["right = mid<br>Ищем более ранний сбой"]:::process
Check -->|"Нет (версия чистая)<br>Сбой случился позже"| GoRight["left = mid + 1<br>Исключаем чистую часть"]:::process
GoLeft --> Loop{"left < right?"}:::warning
GoRight --> Loop
Loop -->|"Да"| Check
Loop -->|"Нет (left == right)"| End["Возврат left<br>Первая дефектная версия"]:::success
Идиоматичная реализация на Go
package main
// isBadVersion предоставлен рантаймом/внешним API:
// func isBadVersion(version int) bool
// firstBadVersion находит номер первой дефектной версии.
// Временная сложность: O(log N) обращений к API
// Пространственная сложность: O(1)
func firstBadVersion(n int) int {
left, right := 1, n
for left < right {
// Защита от переполнения 32-битного int при больших n
mid := int(uint(left+right) >> 1)
if isBadVersion(mid) {
// mid сломан, ищем левее, сохраняя mid как кандидата
right = mid
} else {
// mid исправен, первый сбой строго справа
left = mid + 1
}
}
// По сходимости left == right указывает на первую плохую версию
return left
}
Взгляд под капот: Mechanical Sympathy и цена абстракции
1. Накладные расходы на вызов функции (Call Overhead)
В задаче 3. Binary Search доступ к nums[mid] выполнялся за $\sim 0.5$ нс (одна инструкция MOV из L1-кэша). В firstBadVersion каждый шаг — это вызов функции:
- Если
isBadVersionне может быть заинлайнена (например, из-за внешнего связывания или сложности), рантайм выполняет инструкциюCALL, переключение стековых кадров и сохранение регистров caller-saved. - При $N = 2 \times 10^9$ последовательный перебор $O(N)$ занял бы годы. Бинарный поиск делает всего 31 вызов функции, сокращая время работы до микросекунд.
2. Из CPU-Bound в I/O-Bound
В реальных production-системах (например, распределенная трассировка или репликация WAL-логов) isBadVersion — это не ассемблерная функция, а сетевой RPC по gRPC или HTTP к удаленному узлу.
- Каждый сетевой вызов стоит от $0.5$ до $5$ миллисекунд.
- Алгоритм становится полностью зависимым от I/O (I/O-Bound). Сокращение числа сетевых запросов с 1000 до 10 экономит 5 секунд клиентского времени.
Подводные камни и вопросы с собеседований
Резюме
Паттерн поиска по предикату позволяет применять бинарный поиск к системам любой сложности:
- Физический массив не требуется — достаточно монотонности булевой функции $[0, \dots, 0, 1, \dots, 1]$.
- Сходимость полуинтервала $left = right$ гарантирует минимальное число вызовов $O(\log N)$.
- Никакой избыточной памяти и хэш-таблиц — только чистая работа регистров.
В следующей лекции мы разберем случай, когда монотонность данных нарушена циклическим сдвигом: 6. Find Minimum in Rotated Sorted Array.