Median of Two Sorted Arrays: медиана двух массивов за O(log min(m, n))
Разбор задачи Median of Two Sorted Arrays: слияние за O(m+n), поиск k-й порядковой статистики и бинарный поиск по разбиению за O(log min(m,n)). Граничные случаи и код на Python.
Коротко
| Подход | Время | Память | Когда говорить на собеседовании |
|---|---|---|---|
| Слить массивы и взять середину | Первым, как базовое решение | ||
| Два указателя без слияния | Сразу после, как улучшение по памяти | ||
| k-я порядковая статистика | Если ближе идея «отбрасываем половину» | ||
| Бинарный поиск по разбиению | Ожидаемый ответ на Hard-задачу |
- Главная идея: медиана делит объединённый массив на две равные половины. Достаточно найти, сколько элементов левой половины взять из первого массива, — остальное возьмётся из второго.
- Бинарный поиск идёт по более короткому массиву, поэтому сложность зависит от .
- Половина ошибок — в граничных случаях: пустой массив, разбиение у края, чётная суммарная длина.
Условие
Даны два отсортированных по неубыванию массива nums1 длины и nums2 длины . Нужно вернуть медиану их объединения. В оригинальной формулировке LeetCode (задача 4, уровень Hard) требуется сложность .
nums1 = [1, 3], nums2 = [2] → 2.0 nums1 = [1, 2], nums2 = [3, 4] → 2.5 nums1 = [], nums2 = [1] → 1.0
Напомним, что такое медиана. Если элементов нечётное число, это средний элемент отсортированной последовательности. Если чётное — среднее арифметическое двух средних.
Задача знаменита тем, что наивное решение пишется за пять минут, а оптимальное многие не могут довести до рабочего состояния и за час. Разница — в аккуратности с индексами.
Решение 1: слияние за O(m + n)
Самое прямолинейное: слить два отсортированных массива в один (как в шаге сортировки слиянием) и взять середину.
def find_median_merge(nums1, nums2): merged = [] i = j = 0 while i < len(nums1) and j < len(nums2): if nums1[i] <= nums2[j]: merged.append(nums1[i]) i += 1 else: merged.append(nums2[j]) j += 1 merged.extend(nums1[i:]) merged.extend(nums2[j:]) n = len(merged) if n % 2 == 1: return float(merged[n // 2]) return (merged[n // 2 - 1] + merged[n // 2]) / 2
Время , память . Вариант sorted(nums1 + nums2) короче, но даёт и выбрасывает информацию о том, что массивы уже отсортированы. На собеседовании его лучше упомянуть только как отправную точку.
Решение 2: два указателя без лишней памяти
Сливать целиком не нужно: медиана лежит на позиции около . Пройдём двумя указателями ровно до неё, запоминая текущий и предыдущий элемент.
def find_median_two_pointers(nums1, nums2): m, n = len(nums1), len(nums2) total = m + n i = j = 0 prev = cur = 0 for _ in range(total // 2 + 1): prev = cur if i < m and (j >= n or nums1[i] <= nums2[j]): cur = nums1[i] i += 1 else: cur = nums2[j] j += 1 if total % 2 == 1: return float(cur) return (prev + cur) / 2
Память , время всё ещё линейное. Условие j >= n or ... закрывает случай, когда один массив закончился раньше. Это хороший промежуточный ответ: интервьюер видит, что вы понимаете структуру задачи, и обычно спрашивает: «А быстрее можно?»
Решение 3: k-я порядковая статистика
Обобщим задачу: найти -й по счёту элемент объединения двух отсортированных массивов. Медиана — это (и ещё следующий элемент при чётной длине).
Идея: сравним элементы на позиции в обоих массивах. Если a[k/2 - 1] <= b[k/2 - 1], то первые элементов массива a точно не могут быть -м элементом: перед любым из них стоит не больше элементов из a и не больше из b, итого меньше . Их можно отбросить и искать -й элемент в остатке. Каждый шаг уменьшает вдвое.
def kth(a, b, k): """k-й по счёту (с 1) элемент объединения двух отсортированных массивов.""" i = j = 0 while True: if i == len(a): return b[j + k - 1] if j == len(b): return a[i + k - 1] if k == 1: return min(a[i], b[j]) half = k // 2 ni = min(i + half, len(a)) - 1 nj = min(j + half, len(b)) - 1 if a[ni] <= b[nj]: k -= ni - i + 1 i = ni + 1 else: k -= nj - j + 1 j = nj + 1 def find_median_kth(nums1, nums2): total = len(nums1) + len(nums2) if total % 2 == 1: return float(kth(nums1, nums2, total // 2 + 1)) left = kth(nums1, nums2, total // 2) right = kth(nums1, nums2, total // 2 + 1) return (left + right) / 2
Тонкое место — min(i + half, len(a)): если в массиве осталось меньше элементов, берём сколько есть. Отбрасываем ровно столько, сколько реально прошли, поэтому k уменьшается на ni - i + 1, а не на half.
Сложность — , то есть формально требование задачи выполнено. Тот же приём «отбросить заведомо лишнюю часть» лежит в основе quickselect, только там отбрасывание вероятностное.
Решение 4: бинарный поиск по разбиению
Это решение, которое ждут на собеседовании. Оно быстрее и, если понять идею, пишется без рекурсии и без двух вызовов.
Интуиция
Разрежем nums1 после элементов, а nums2 — после элементов:
nums1: left1_part (i штук) | right1_part nums2: left2_part (j штук) | right2_part
Левая половина объединения — это left1_part + left2_part. Чтобы она действительно была «левой половиной отсортированного объединения», нужны два условия:
- Размер: . При нечётной сумме левая половина на один элемент больше — тогда медиана лежит в ней.
- Порядок: каждый элемент слева не больше каждого элемента справа. Внутри одного массива это и так верно, поэтому достаточно проверить перекрёстно:
Из первого условия однозначно определяется через . Значит, перебирать нужно только от до — и делать это бинарным поиском:
- если
nums1[i-1] > nums2[j]— изnums1взяли слишком много, двигаемiвлево; - если
nums2[j-1] > nums1[i]— взяли слишком мало, двигаемiвправо.
Монотонность здесь очевидна: чем больше , тем больше nums1[i-1] и тем меньше nums2[j]. Это та же схема, что и в бинарном поиске по ответу: ищем границу, где условие начинает выполняться.
Когда разбиение найдено:
- нечётная длина: медиана = ;
- чётная: медиана = .
Код
def find_median_sorted_arrays(nums1, nums2): if len(nums1) > len(nums2): nums1, nums2 = nums2, nums1 m, n = len(nums1), len(nums2) half = (m + n + 1) // 2 lo, hi = 0, m while lo <= hi: i = (lo + hi) // 2 # сколько берём из nums1 j = half - i # сколько берём из nums2 left1 = nums1[i - 1] if i > 0 else float('-inf') right1 = nums1[i] if i < m else float('inf') left2 = nums2[j - 1] if j > 0 else float('-inf') right2 = nums2[j] if j < n else float('inf') if left1 <= right2 and left2 <= right1: if (m + n) % 2 == 1: return float(max(left1, left2)) return (max(left1, left2) + min(right1, right2)) / 2 if left1 > right2: hi = i - 1 else: lo = i + 1 raise ValueError('массивы не отсортированы')
Бесконечности решают проблему краёв: если из массива ничего не взяли слева (), левого соседа нет и он не должен мешать проверке — подставляем . Аналогично справа .
Почему именно по короткому массиву
Обмен nums1 и nums2 в начале — не косметика. Если nums1 длиннее, то при некоторых получится или , и индексация nums2[j - 1] сломается (в Python — молча возьмёт элемент с конца). Когда , для любого выполняется :
Бонус — сложность становится , это лучше, чем требует условие.
Разбор на примере
nums1 = [1, 3, 8, 9, 15], nums2 = [7, 11, 18, 19, 21, 25]. Всего 11 элементов, half = 6, nums1 короче — ищем .
| Шаг | lo..hi | i | j | left1 | right1 | left2 | right2 | Вывод |
|---|---|---|---|---|---|---|---|---|
| 1 | 0..5 | 2 | 4 | 3 | 8 | 19 | 21 | 19 > 8: мало из nums1, lo = 3 |
| 2 | 3..5 | 4 | 2 | 9 | 15 | 11 | 18 | 9 ≤ 18 и 11 ≤ 15 — нашли |
Левая половина: [1, 3, 8, 9] + [7, 11], правая: [15] + [18, 19, 21, 25]. Длина нечётная, медиана = . Проверка слиянием: [1, 3, 7, 8, 9, 11, 15, 18, 19, 21, 25], шестой элемент — 11.
То же решение на JavaScript (у вас могут попросить другой язык):
function findMedianSortedArrays(nums1, nums2) { if (nums1.length > nums2.length) [nums1, nums2] = [nums2, nums1] const m = nums1.length const n = nums2.length const half = (m + n + 1) >> 1 let lo = 0 let hi = m while (lo <= hi) { const i = (lo + hi) >> 1 const j = half - i const left1 = i > 0 ? nums1[i - 1] : -Infinity const right1 = i < m ? nums1[i] : Infinity const left2 = j > 0 ? nums2[j - 1] : -Infinity const right2 = j < n ? nums2[j] : Infinity if (left1 <= right2 && left2 <= right1) { if ((m + n) % 2 === 1) return Math.max(left1, left2) return (Math.max(left1, left2) + Math.min(right1, right2)) / 2 } if (left1 > right2) hi = i - 1 else lo = i + 1 } throw new Error('arrays are not sorted') }
Граничные случаи
Прежде чем сказать «готово», прогоните решение по этому списку вслух. Интервьюеры часто спрашивают именно про них.
| Случай | Пример | Что должно сработать |
|---|---|---|
| Один массив пуст | [], [1] | , цикл с одной итерацией, left1 = -inf |
| Все элементы одного меньше всех другого | [1, 2], [3, 4] | Разбиение на краю: или |
| Дубликаты | [2, 2, 2], [2, 2] | Нестрогие <= в проверке, иначе цикл не сойдётся |
| Чётная суммарная длина | [1, 3], [2, 4] | Берём min справа, а не только max слева |
| Массивы разной длины | [5], [1, 2, 3, 4, 6, 7] | Обмен, чтобы искать по короткому |
| Отрицательные числа | [-5, -3], [-2] | Бесконечности, а не 0 или -1 как заглушки |
Ещё два вопроса, которые стоит уточнить у интервьюера в начале:
- Тип результата. На LeetCode ответ —
double. В Java и C++ деление(a + b) / 2на целых числах обрежет дробную часть: пишите(a + b) / 2.0. - Переполнение. В языках с фиксированной разрядностью
a + bможет переполнитьint, если значения близки к границе. Безопаснееa / 2.0 + b / 2.0или приведение кlong. В Python проблемы нет. Заглушки в Java обычно заменяют наInteger.MIN_VALUE/Integer.MAX_VALUE: в итоговую формулу они никогда не попадают, потому что при найденном корректном разбиении хотя бы один из двух кандидатов — настоящий элемент.
Сложность
| Решение | Время | Память |
|---|---|---|
sorted(nums1 + nums2) | ||
| Слияние | ||
| Два указателя | ||
| k-я статистика | ||
| Бинарный поиск по разбиению |
Для разница между линейным и логарифмическим решением — два миллиона операций против примерно двадцати итераций.
Типичные ошибки
- Бинарный поиск по длинному массиву. уходит в минус, Python молча берёт
nums2[-1], тесты иногда проходят, иногда нет. Всегда меняйте массивы местами. hi = m - 1вместоhi = m. Разбиение («взять весьnums1влево») — допустимый вариант. Если отрезать его, сломается пример[1, 2],[3, 4].- Неправильный
half. С(m + n) // 2при нечётной длине медиана окажется справа, и формулуmax(left1, left2)придётся менять наmin(right1, right2). Оба варианта рабочие, но смешивать их нельзя. Выберите один и проговорите его. - Строгие сравнения.
left1 < right2вместо<=ломает решение на дубликатах: равные элементы по обе стороны разреза — нормальная ситуация. - Заглушки
0или-1вместо бесконечностей. Работают только на положительных данных. - Целочисленное деление в Java/C++ — уже упоминали выше, но это самая частая причина Wrong Answer у тех, кто пишет не на Python.
Как рассказывать на собеседовании
Хорошая последовательность — та же, что в статье:
- Уточнить: массивы отсортированы, может ли один быть пустым, какой тип ответа.
- Назвать слияние за и сразу оговорить, что оно не использует логарифм из условия.
- Сформулировать ключевое наблюдение: медиана = разрез объединения на две равные половины; разрез задаётся одним числом .
- Объяснить монотонность, из-за которой работает бинарный поиск, и почему он идёт по короткому массиву.
- Написать код, проговорить бесконечности на краях и пройти пример из таблицы выше.
Если вы застряли на индексах, не молчите: проговорите инвариант (что означают i, j, half) — это лучший способ выбраться и одновременно показать ход мыслей. Об этом подробнее в статье про рассуждения вслух.
Похожие задачи
- k-й элемент двух отсортированных массивов — прямое обобщение, решается функцией
kthвыше. - Медиана потока данных — когда элементы приходят по одному, а не лежат в массивах. Разбор с двумя кучами — в статье Find Median from Data Stream.
- Поиск в повёрнутом отсортированном массиве — ещё одна задача, где бинарный поиск идёт не по значению, а по структурному свойству: Search in Rotated Sorted Array.
- Основы бинарного поиска и шаблоны границ — в статье бинарный поиск.