3. Merge Intervals
«Слияние отрезков времени напоминает течение рек: сливаясь воедино, они образуют мощный непрерывный поток».
— Брайан Керниган
Задача Merge Intervals (LeetCode 56, Medium) — это канонический эталон жадного подхода в сочетании с предварительной сортировкой.
В реальных бэкенд-системах этот алгоритм работает непрерывно:
- Сети и безопасность: Консолидация правил брандмауэров (WAF / iptables) и объединение CIDR-диапазонов IP-адресов.
- Оптимизация ввода-вывода (IO Coalescing): Ядро Linux объединяет смежные запросы чтения/записи к блочным устройствам в один непрерывный запрос, уменьшая нагрузку на NVMe/SSD контроллер.
- Календари и бронирование: Вычисление окон доступности пользователей в Google Calendar или слотов репликации в СУБД.
1. Постановка задачи и математический инвариант
Дано срез интервалов intervals, где каждый интервал задан парой чисел [start, end]. Необходимо объединить все перекрывающиеся интервалы и вернуть срез неперекрывающихся диапазонов, покрывающий всё исходное множество.
Пример:
[[1, 3], [2, 6], [8, 10], [15, 18]] $\to$ [[1, 6], [8, 10], [15, 18]].
Почему наивный подход $O(N^2)$ проваливается?
Если интервалы не упорядочены, каждый новый отрезок приходится сравнивать со всеми уже объединенными отрезками, вставляя и удаляя элементы в произвольные позиции среза. Это приводит к квадратичному времени и хаосу в управлении памятью.
Инвариант сортировки по времени старта
Если отсортировать интервалы по времени начала (a[0] < b[0]), то:
- Любой интервал $I_k$ гарантированно начинается не раньше, чем предыдущий $I_{k-1}$.
- Следовательно, $I_k$ может пересекаться только с самым последним объединенным интервалом!
- Если $I_k.\text{start} \le I_{\text{last}}.\text{end}$, происходит пересечение: мы просто растягиваем правый конец $I_{\text{last}}.\text{end} = \max(I_{\text{last}}.\text{end}, I_k.\text{end})$.
- Если $I_k.\text{start} > I_{\text{last}}.\text{end}$, наступил разрыв: мы фиксируем $I_{\text{last}}$ и начинаем формировать новый объединенный отрезок.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef sort fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef loop fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef check fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
In["Входной срез intervals [][]int"]:::entry --> S["Сортировка по Start:<br>slices.SortFunc за O(N log N)"]:::sort
S --> Init["merged = append(merged, intervals[0])"]:::loop
Init --> Loop["Итерация i от 1 до N-1"]:::loop
Loop --> Check{"intervals[i][0] <= last[1]?"}:::check
Check -- "Да (Пересечение)" --> Merge["last[1] = max(last[1], intervals[i][1])"]:::loop
Check -- "Нет (Разрыв)" --> Append["merged = append(merged, intervals[i])"]:::loop
Merge --> Next{"Есть еще элементы?"}:::loop
Append --> Next
Next -- "Да" --> Loop
Next -- "Нет" --> Out["Результат: merged"]:::success
2. Идиоматичная реализация на современном Go (1.21+)
Начиная с Go 1.21, стандартный пакет slices предоставляет функцию slices.SortFunc, которая работает быстрее sort.Slice, полностью безопасна по типам и не создает аллокаций интерфейсов:
package main
import "slices"
// Merge объединяет все перекрывающиеся интервалы.
// Временная сложность: O(N log N) — доминирует сортировка.
// Память: O(N) — результирующий срез.
func Merge(intervals [][]int) [][]int {
if len(intervals) <= 1 {
return intervals
}
// 1. Сортируем интервалы по времени начала (без аллокаций)
slices.SortFunc(intervals, func(a, b []int) int {
return a[0] - b[0]
})
// 2. Предвыделяем срез с емкостью N для исключения реаллокаций базового массива
merged := make([][]int, 0, len(intervals))
merged = append(merged, intervals[0])
// 3. Линейное жадное слияние за один проход O(N)
for i := 1; i < len(intervals); i++ {
last := merged[len(merged)-1]
curr := intervals[i]
if curr[0] <= last[1] {
// Пересечение: обновляем правый край последнего интервала
last[1] = max(last[1], curr[1])
} else {
// Разрыв: добавляем новый изолированный интервал
merged = append(merged, curr)
}
}
return merged
}
3. High-Performance оптимизация: In-Place сжатие за $\mathcal{O}(1)$ Extra Space
Если интервьюер требует: «Минимизируйте расход памяти до $\mathcal{O}(1)$ дополнительно, мутируя исходный массив», мы применяем алгоритм с двумя указателями:
package main
import "slices"
// MergeInPlace выполняет слияние прямо во входном массиве intervals.
// Дополнительная память: O(1) extra space.
func MergeInPlace(intervals [][]int) [][]int {
if len(intervals) <= 1 {
return intervals
}
slices.SortFunc(intervals, func(a, b []int) int {
return a[0] - b[0]
})
writeIdx := 0 // Указатель на границу объединенных интервалов
for readIdx := 1; readIdx < len(intervals); readIdx++ {
if intervals[readIdx][0] <= intervals[writeIdx][1] {
// Пересечение: расширяем правый край
intervals[writeIdx][1] = max(intervals[writeIdx][1], intervals[readIdx][1])
} else {
// Разрыв: продвигаем writeIdx и перезаписываем
writeIdx++
intervals[writeIdx] = intervals[readIdx]
}
}
// Возвращаем срез от 0 до writeIdx включительно
return intervals[:writeIdx+1]
}
4. Mechanical Sympathy: Branch Prediction и выравнивание кэша
1. Монотонность и Branch Predictor
После сортировки по полю Start условие curr[0] <= last[1] демонстрирует выраженную кластеризацию.
В реальных логах и временных рядах пересечения распределены «всплесками» (bursts): группа интервалов пересекается подряд, затем следует длинный период затишья.
Аппаратный предсказатель ветвлений процессора (Branch Target Buffer) быстро адаптируется к таким сериям переходов, снижая вероятность штрафа за сброс конвейера (Branch Misprediction Penalty) практически до нуля.
2. Защита от деградации QuickSort
В стандартной библиотеке Go функция slices.SortFunc использует гибридный алгоритм pdqsort (Pattern-Defeating Quicksort).
В отличие от классического Quicksort, pdqsort:
- Распознает уже отсортированные или частично отсортированные массивы за линейное время $\mathcal{O}(N)$.
- Переключается на Heapsort при обнаружении неблагоприятных паттернов, гарантируя сложность $\mathcal{O}(N \log N)$ в худшем случае и полную защиту от алгоритмических атак замедления (DoS).
5. Граничные случаи (Edge Cases)
- Вложенные интервалы:
[[1, 10], [2, 6]].
Первый интервал полностью поглощает второй. Благодаряmax(last[1], curr[1])конец $10$ не перезаписывается шестеркой: $\max(10, 6) = 10$. - Точечные касания:
[[1, 4], [4, 5]].
Условие4 <= 4истинно, интервалы объединяются в[1, 5]. - Одинаковые интервалы:
[[1, 4], [1, 4]].
Объединяются в один[1, 4]. - Уже разделенные интервалы:
[[1, 2], [3, 4]].
Возвращаются без изменений.
6. Вопросы на собеседовании
7. Резюме
- Сортировка по
Start: Математически доказывает, что каждый следующий интервал может пересекаться только с последним объединенным. - Идиоматичный Go 1.21+: Используйте
slices.SortFuncвместо устаревшегоsort.Slice. - In-place компактификация: Паттерн двух указателей
readIdxиwriteIdxпозволяет объединять интервалы за $\mathcal{O}(1)$ дополнительной памяти. - pdqsort под капотом: Гарантирует $\mathcal{O}(N \log N)$ в худшем случае и отсутствие DoS-уязвимостей.
В следующей лекции мы разберем динамическую вставку нового интервала в уже упорядоченный набор: 4. Insert Interval.