9. Merge Sorted Array
«Если свободное место находится позади вас, не пытайтесь пробиваться вперёд сквозь толпу — развернитесь и спокойно заполняйте пространство с конца».
— Брайан Керниган
LeetCode 88. Merge Sorted Array (Easy / Core)
Вам даны два отсортированных по неубыванию целочисленных массива nums1 и nums2, а также целые числа $m$ и $n$, обозначающие количество значащих элементов в каждом из них соответственно.
Срез nums1 имеет общую длину $m + n$: первые $m$ позиций содержат исходные данные, а последние $n$ позиций заполнены нулями и служат буфером для слияния.
Требование: Объединить элементы nums2 в nums1 так, чтобы результирующий массив оставался отсортированным. Алгоритм обязан работать in-place за $O(m + n)$ по времени и со строгим $O(1)$ дополнительной памяти.
Калибровка условий и пограничные случаи
Вход: nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
Вывод: nums1 = [1, 2, 2, 3, 5, 6]
Архитектурный инвариант: Инвертированное слияние с хвоста
Если двигаться привычным путем от начала массивов (слева направо), каждая вставка числа из nums2 в nums1 потребует сдвига всех последующих элементов nums1 вправо, что приведет к квадратичной деградации $O(m \times n)$!
Но инженеры смотрят на топологию памяти: пустое место находится в самом конце nums1!
Следовательно, мы выставляем три указателя:
p1 = m - 1— указывает на наибольший неразмещённый элемент вnums1;p2 = n - 1— указывает на наибольший неразмещённый элемент вnums2;p = m + n - 1— указывает на позицию текущей записи с конца массиваnums1.
На каждом шаге мы сравниваем nums1[p1] и nums2[p2]. Большее из чисел ставится в позицию nums1[p]. Мы гарантированно никогда не перезапишем значащие элементы nums1, так как число свободных слотов в хвосте всегда превосходит количество оставшихся элементов nums2.
Визуальный процесс обратного слияния
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Start["Старт: p1 = m-1, p2 = n-1, p = m+n-1"]:::entry --> Check{"p2 >= 0?"}:::warning
Check -->|"Нет (nums2 исчерпан)"| Finish["Готово!<br>Остаток nums1 уже на своих местах"]:::success
Check -->|"Да (в nums2 еще есть числа)"| Compare{"p1 >= 0 и<br>nums1[p1] > nums2[p2]?"}:::accent
Compare -->|"Да: победил nums1"| Write1["nums1[p] = nums1[p1]<br>p1--"]:::process
Compare -->|"Нет: победил nums2"| Write2["nums1[p] = nums2[p2]<br>p2--"]:::process
Write1 --> DecP["p--"]:::process
Write2 --> DecP
DecP --> Check
Идиоматичная реализация на Go
package main
func merge(nums1 []int, m int, nums2 []int, n int) {
p1 := m - 1
p2 := n - 1
// Заполняем nums1 строго с конца
for p := m + n - 1; p >= 0; p-- {
// Если все элементы nums2 уже перенесены,
// оставшиеся элементы nums1 уже находятся на своих правильных позициях!
if p2 < 0 {
break
}
if p1 >= 0 && nums1[p1] > nums2[p2] {
nums1[p] = nums1[p1]
p1--
} else {
nums1[p] = nums2[p2]
p2--
}
}
}
Mechanical Sympathy: Почему алгоритм работает без промахов кэша
- Последовательное чтение назад (Reverse Streaming): Аппаратные блоки предвыборки современных процессоров (x86-64, ARM64 Neoverse) умеют распознавать декрементные шаги указателей не хуже инкрементных. Кэш-линии плавно выгружаются в L1d.
- Zero Extra Memory: Никаких временных слайсов, никакого участия сборщика мусора (GC), нулевая фрагментация кучи.
- Отсутствие вызовов встроенных функций: Простое линейное ветвление, которое процессор отлично оптимизирует в конвейере.
Резюме
Merge Sorted Array — наглядный урок того, как нестандартная смена направления обхода (справа налево вместо слева направо) превращает сложную задачу с потенциальными сдвигами элементов в чистый линейный алгоритм за $O(m + n)$.
В следующей лекции мы разберем технику фильтрации массива на месте с сохранением относительного порядка элементов — задачу Move Zeroes: 10. Move Zeroes.