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.

12 мин чтенияалгоритмы · бинарный поиск · python

Коротко

ПодходВремяПамятьКогда говорить на собеседовании
Слить массивы и взять серединуO(m+n)O(m + n)O(m+n)O(m+n)Первым, как базовое решение
Два указателя без слиянияO(m+n)O(m + n)O(1)O(1)Сразу после, как улучшение по памяти
k-я порядковая статистикаO(log⁡(m+n))O(\log(m + n))O(1)O(1)Если ближе идея «отбрасываем половину»
Бинарный поиск по разбиениюO(log⁡min⁡(m,n))O(\log \min(m, n))O(1)O(1)Ожидаемый ответ на Hard-задачу
  • Главная идея: медиана делит объединённый массив на две равные половины. Достаточно найти, сколько элементов левой половины взять из первого массива, — остальное возьмётся из второго.
  • Бинарный поиск идёт по более короткому массиву, поэтому сложность зависит от min⁡(m,n)\min(m, n).
  • Половина ошибок — в граничных случаях: пустой массив, разбиение у края, чётная суммарная длина.

Условие

Даны два отсортированных по неубыванию массива nums1 длины mm и nums2 длины nn. Нужно вернуть медиану их объединения. В оригинальной формулировке LeetCode (задача 4, уровень Hard) требуется сложность O(log⁡(m+n))O(\log(m + n)).

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

Время O(m+n)O(m + n), память O(m+n)O(m + n). Вариант sorted(nums1 + nums2) короче, но даёт O((m+n)log⁡(m+n))O((m+n)\log(m+n)) и выбрасывает информацию о том, что массивы уже отсортированы. На собеседовании его лучше упомянуть только как отправную точку.

Решение 2: два указателя без лишней памяти

Сливать целиком не нужно: медиана лежит на позиции около (m+n)/2(m+n)/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

Память O(1)O(1), время всё ещё линейное. Условие j >= n or ... закрывает случай, когда один массив закончился раньше. Это хороший промежуточный ответ: интервьюер видит, что вы понимаете структуру задачи, и обычно спрашивает: «А быстрее можно?»

Решение 3: k-я порядковая статистика

Обобщим задачу: найти kk-й по счёту элемент объединения двух отсортированных массивов. Медиана — это k=⌈(m+n)/2⌉k = \lceil (m+n)/2 \rceil (и ещё следующий элемент при чётной длине).

Идея: сравним элементы на позиции k/2k/2 в обоих массивах. Если a[k/2 - 1] <= b[k/2 - 1], то первые k/2k/2 элементов массива a точно не могут быть kk-м элементом: перед любым из них стоит не больше k/2−1k/2 - 1 элементов из a и не больше k/2−1k/2 - 1 из b, итого меньше k−1k - 1. Их можно отбросить и искать (k−k/2)(k - k/2)-й элемент в остатке. Каждый шаг уменьшает kk вдвое.

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/2k/2 элементов, берём сколько есть. Отбрасываем ровно столько, сколько реально прошли, поэтому k уменьшается на ni - i + 1, а не на half.

Сложность — O(log⁡k)=O(log⁡(m+n))O(\log k) = O(\log(m+n)), то есть формально требование задачи выполнено. Тот же приём «отбросить заведомо лишнюю часть» лежит в основе quickselect, только там отбрасывание вероятностное.

Решение 4: бинарный поиск по разбиению

Это решение, которое ждут на собеседовании. Оно быстрее и, если понять идею, пишется без рекурсии и без двух вызовов.

Интуиция

Разрежем nums1 после ii элементов, а nums2 — после jj элементов:

nums1: left1_part (i штук) | right1_part nums2: left2_part (j штук) | right2_part

Левая половина объединения — это left1_part + left2_part. Чтобы она действительно была «левой половиной отсортированного объединения», нужны два условия:

  1. Размер: i+j=⌊(m+n+1)/2⌋i + j = \lfloor (m + n + 1) / 2 \rfloor. При нечётной сумме левая половина на один элемент больше — тогда медиана лежит в ней.
  2. Порядок: каждый элемент слева не больше каждого элемента справа. Внутри одного массива это и так верно, поэтому достаточно проверить перекрёстно:
nums1[i−1]≤nums2[j]иnums2[j−1]≤nums1[i]\text{nums1}[i-1] \le \text{nums2}[j] \quad \text{и} \quad \text{nums2}[j-1] \le \text{nums1}[i]

Из первого условия jj однозначно определяется через ii. Значит, перебирать нужно только ii от 00 до mm — и делать это бинарным поиском:

  • если nums1[i-1] > nums2[j] — из nums1 взяли слишком много, двигаем i влево;
  • если nums2[j-1] > nums1[i] — взяли слишком мало, двигаем i вправо.

Монотонность здесь очевидна: чем больше ii, тем больше nums1[i-1] и тем меньше nums2[j]. Это та же схема, что и в бинарном поиске по ответу: ищем границу, где условие начинает выполняться.

Когда разбиение найдено:

  • нечётная длина: медиана = max⁡(left1,left2)\max(\text{left1}, \text{left2});
  • чётная: медиана = (max⁡(left1,left2)+min⁡(right1,right2))/2\big(\max(\text{left1}, \text{left2}) + \min(\text{right1}, \text{right2})\big) / 2.

Код

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('массивы не отсортированы')

Бесконечности решают проблему краёв: если из массива ничего не взяли слева (i=0i = 0), левого соседа нет и он не должен мешать проверке — подставляем −∞-\infty. Аналогично справа +∞+\infty.

Почему именно по короткому массиву

Обмен nums1 и nums2 в начале — не косметика. Если nums1 длиннее, то при некоторых ii получится j=half−i<0j = \text{half} - i < 0 или j>nj > n, и индексация nums2[j - 1] сломается (в Python — молча возьмёт элемент с конца). Когда m≤nm \le n, для любого i∈[0,m]i \in [0, m] выполняется 0≤j≤n0 \le j \le n:

j=⌊m+n+12⌋−i≥⌊m+n+12⌋−m≥0j = \left\lfloor \frac{m+n+1}{2} \right\rfloor - i \ge \left\lfloor \frac{m+n+1}{2} \right\rfloor - m \ge 0

Бонус — сложность становится O(log⁡min⁡(m,n))O(\log \min(m, n)), это лучше, чем требует условие.

Разбор на примере

nums1 = [1, 3, 8, 9, 15], nums2 = [7, 11, 18, 19, 21, 25]. Всего 11 элементов, half = 6, nums1 короче — ищем i∈[0,5]i \in [0, 5].

Шагlo..hiijleft1right1left2right2Вывод
10..52438192119 > 8: мало из nums1, lo = 3
23..54291511189 ≤ 18 и 11 ≤ 15 — нашли

Левая половина: [1, 3, 8, 9] + [7, 11], правая: [15] + [18, 19, 21, 25]. Длина нечётная, медиана = max⁡(9,11)=11\max(9, 11) = 11. Проверка слиянием: [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]m=0m = 0, цикл с одной итерацией, left1 = -inf
Все элементы одного меньше всех другого[1, 2], [3, 4]Разбиение на краю: i=mi = m или i=0i = 0
Дубликаты[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 проблемы нет. Заглушки ±∞\pm\infty в Java обычно заменяют на Integer.MIN_VALUE / Integer.MAX_VALUE: в итоговую формулу они никогда не попадают, потому что при найденном корректном разбиении хотя бы один из двух кандидатов — настоящий элемент.

Сложность

РешениеВремяПамять
sorted(nums1 + nums2)O((m+n)log⁡(m+n))O((m+n)\log(m+n))O(m+n)O(m + n)
СлияниеO(m+n)O(m + n)O(m+n)O(m + n)
Два указателяO(m+n)O(m + n)O(1)O(1)
k-я статистикаO(log⁡(m+n))O(\log(m + n))O(1)O(1)
Бинарный поиск по разбиениюO(log⁡min⁡(m,n))O(\log \min(m, n))O(1)O(1)

Для m=n=106m = n = 10^6 разница между линейным и логарифмическим решением — два миллиона операций против примерно двадцати итераций.

Типичные ошибки

  1. Бинарный поиск по длинному массиву. jj уходит в минус, Python молча берёт nums2[-1], тесты иногда проходят, иногда нет. Всегда меняйте массивы местами.
  2. hi = m - 1 вместо hi = m. Разбиение i=mi = m («взять весь nums1 влево») — допустимый вариант. Если отрезать его, сломается пример [1, 2], [3, 4].
  3. Неправильный half. С (m + n) // 2 при нечётной длине медиана окажется справа, и формулу max(left1, left2) придётся менять на min(right1, right2). Оба варианта рабочие, но смешивать их нельзя. Выберите один и проговорите его.
  4. Строгие сравнения. left1 < right2 вместо <= ломает решение на дубликатах: равные элементы по обе стороны разреза — нормальная ситуация.
  5. Заглушки 0 или -1 вместо бесконечностей. Работают только на положительных данных.
  6. Целочисленное деление в Java/C++ — уже упоминали выше, но это самая частая причина Wrong Answer у тех, кто пишет не на Python.

Как рассказывать на собеседовании

Хорошая последовательность — та же, что в статье:

  1. Уточнить: массивы отсортированы, может ли один быть пустым, какой тип ответа.
  2. Назвать слияние за O(m+n)O(m+n) и сразу оговорить, что оно не использует логарифм из условия.
  3. Сформулировать ключевое наблюдение: медиана = разрез объединения на две равные половины; разрез задаётся одним числом ii.
  4. Объяснить монотонность, из-за которой работает бинарный поиск, и почему он идёт по короткому массиву.
  5. Написать код, проговорить бесконечности на краях и пройти пример из таблицы выше.

Если вы застряли на индексах, не молчите: проговорите инвариант (что означают i, j, half) — это лучший способ выбраться и одновременно показать ход мыслей. Об этом подробнее в статье про рассуждения вслух.

Похожие задачи

  • k-й элемент двух отсортированных массивов — прямое обобщение, решается функцией kth выше.
  • Медиана потока данных — когда элементы приходят по одному, а не лежат в массивах. Разбор с двумя кучами — в статье Find Median from Data Stream.
  • Поиск в повёрнутом отсортированном массиве — ещё одна задача, где бинарный поиск идёт не по значению, а по структурному свойству: Search in Rotated Sorted Array.
  • Основы бинарного поиска и шаблоны границ — в статье бинарный поиск.

Потренироваться в спокойной обстановке

Все задачи