4. Search Insert Position
«Вставка элемента в упорядоченную структуру — это поиск места, где он должен был находиться с самого начала».
— Никлаус Вирт
В предыдущей статье 3. Binary Search мы разобрали классический бинарный поиск (Exact Match), который отвечает на строгий вопрос: «Присутствует ли заданное число в массиве?».
Но в системном программировании, при разработке B-Tree индексов в СУБД (PostgreSQL, SQLite), при поддержке упорядоченных in-memory структур (Log-Structured Merge-trees в RocksDB/Pebble) или в стандартной библиотеке Go (slices.BinarySearch), инженера чаще интересует другой вопрос: «Если элемента нет в массиве, то в какую позицию его необходимо поместить, чтобы сохранить сортировку?».
Задача Search Insert Position (LeetCode 35) — это канонический пример фундаментального паттерна Lower Bound (поиск нижней границы). Это строгая база, на которой строится поиск по ответу и навигация в структурах данных ядра.
Архитектура: Инвариант Lower Bound против Exact Match
В классическом поиске точного совпадения (Exact Match) мы использовали закрытый интервал [0, len(nums)-1] и три ветки: < target, > target и == target. При совпадении мы немедленно завершали работу (return mid).
В поиске позиции вставки (Lower Bound) архитектура меняется:
- Мы ищем первый индекс $i$, для которого
nums[i] >= target: Если точный элемент найден, мы не имеем права выходить досрочно, если массив может содержать дубликаты — вставка обязана происходить перед первым дубликатом (требование стабильности порядка). - Полуинтервал
[0, len(nums)): Верхняя границаrightинициализируется значениемlen(nums), а неlen(nums) - 1. Еслиtargetбольше любого элемента в срезе, его законная позиция для вставки — в самый конец, то есть на индексlen(nums). - Безусловная сходимость: Условие цикла
left < rightгарантирует, что полуинтервал схлопывается в единственную точкуleft == right, которая и является искомой позицией.
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["Старт: nums = [1, 3, 5, 6], target = 2<br>left = 0, right = 4"]:::entry --> Check{"nums[mid] >= target?"}:::warning
Check -->|"Да (3 >= 2)<br>Кандидат найден"| GoLeft["right = mid<br>Ищем левее, не теряя mid"]:::process
Check -->|"Нет (1 < 2)<br>Слишком мало"| GoRight["left = mid + 1<br>Ответ строго правее"]:::process
GoLeft --> Loop{"left < right?"}:::warning
GoRight --> Loop
Loop -->|"Да"| Check
Loop -->|"Нет (left == right == 1)"| End["Возврат left = 1<br>Позиция для вставки числа 2"]:::success
Идиоматичная реализация на Go
Этот шаблон полуинтервала [left, right) — золотой стандарт конкурентного и системного программирования. Он полностью исключает ошибки выхода за границы среза и неоднозначности выхода из цикла.
package main
// searchInsert возвращает индекс элемента, если он найден в отсортированном срезе.
// Если элемент отсутствует, возвращается индекс, куда его следует вставить
// с сохранением порядка сортировки (паттерн Lower Bound).
// Временная сложность: O(log N)
// Пространственная сложность: O(1)
func searchInsert(nums []int, target int) int {
// right инициализируется длиной среза: возможная позиция вставки в конец
left, right := 0, len(nums)
// Цикл сужает полуинтервал [left, right) до тех пор, пока left != right
for left < right {
// Защищенное от переполнения деление сдвигом в регистре CPU
mid := int(uint(left+right) >> 1)
if nums[mid] >= target {
// Текущий элемент подходит или больше искомого:
// ответ может быть в позиции mid или левее.
// Сдвигаем правую границу, сохраняя mid в полуинтервале [left, mid).
right = mid
} else {
// Элемент nums[mid] строго меньше target:
// позиция вставки гарантированно находится правее.
left = mid + 1
}
}
// По выходу из цикла left == right — это точная позиция вставки
return left
}
Взгляд под капот: Mechanical Sympathy
1. Предсказуемость ветвлений (Branch Prediction)
В цикле for left < right содержится всего одно бинарное условие (if ... else ...), в отличие от классического поиска с тремя исходами (<, >, ==).
- Для аппаратного блока Branch Predictor в процессорах AMD Zen или Intel Core предсказание исхода двух ветвей на монотонных данных требует минимальных ресурсов.
- Поскольку цикл не содержит досрочного выхода
return mid, число итераций детерминировано и составляет строго $\lceil \log_2 (N+1) \rceil$. Это позволяет суперскалярному конвейеру процессора непрерывно спекулятивно вычислять инструкции без сбросов конвейера (Pipeline Flushes).
2. Доказанная безопасность Bounds Check (BCE)
Возникает резонный вопрос: если right = len(nums), не вызовет ли обращение nums[mid] панику рантайма runtime.panicIndex?
- Формула
mid := int(uint(left + right) >> 1)математически производит округление вниз. - Даже в худшем сценарии, когда
left = len(nums) - 1иright = len(nums), их полусумма даетmid = len(nums) - 1. - Значение
midстрого меньшеlen(nums)на любой итерации цикла. Компилятор Go (cmd/compile) при включенной оптимизации доказывает данный инвариант и устраняет машинные инструкции проверки выхода за границы (CMPQ+JAE runtime.panicIndexOutBounds).
Подводные камни и вопросы с собеседований
Резюме
Search Insert Position демонстрирует мощь полуинтервального подхода [left, right):
right = len(nums)естественным образом включает индекс вставки за пределы массива.nums[mid] >= targetсжимает правую границу без потери кандидата.- Никаких скрытых аллокаций памяти — вычисления целиком происходят в регистрах процессора.
В следующей задаче мы абстрагируемся от физического массива в оперативной памяти и применим Lower Bound к динамическому интерфейсу поиска дефектов: 5. First Bad Version.