Meet in the middle: встреча посередине для subset sum при n до 40
Техника meet in the middle простыми словами: subset sum для n≈40 за O(2^(n/2)·n), 4Sum II через хеш-таблицу, Closest Subsequence Sum и сравнение с ДП по сумме. Код на Python.
Коротко
| Вопрос | Ответ |
|---|---|
| Главная идея | Делим входные данные пополам, перебираем каждую половину отдельно и склеиваем результаты через хеш-таблицу, сортировку или два указателя |
| Сложность | по времени и по памяти вместо полного перебора |
| Когда применять | от 30 до 45, числа большие (до ) или отрицательные, поэтому ДП по сумме не помещается |
| Сигнал в условии | «» — почти прямой намёк: не перебрать, а легко |
| Типичные задачи | Subset sum, Closest Subsequence Sum (LeetCode 1755), 4Sum II (LeetCode 454), разбиение массива на две части с минимальной разностью (LeetCode 2035) |
Meet in the middle («встреча посередине») — это не отдельный алгоритм, а приём. Если задача решается полным перебором за , а результат можно собрать из двух независимых половин, перебор сокращается до двух переборов по . Разница огромная: при это против двух миллионов.
Задача, на которой всё видно
Дан массив nums из целых чисел, возможно отрицательных и по модулю до . Нужно посчитать, сколько подмножеств дают в сумме ровно target.
Это классический subset sum, только в версии с подсчётом. Пустое подмножество тоже подмножество, его сумма равна нулю.
nums = [3, 1, 4, 2, 5], target = 6 Подходят: {1, 5}, {4, 2}, {3, 1, 2} → ответ 3
Наивное решение: перебор масок
Каждое подмножество кодируется битовой маской из бит: бит равен единице, если элемент взят. Подробнее про маски — в статье про ДП по подмножествам, а перебор подмножеств рекурсией разобран в статье Subsets.
def count_brute(nums, target): n = len(nums) cnt = 0 for mask in range(1 << n): s = 0 for i in range(n): if mask >> i & 1: s += nums[i] if s == target: cnt += 1 return cnt print(count_brute([3, 1, 4, 2, 5], 6)) # 3
Это . При около 20 миллионов операций, терпимо даже на Python. При — , то есть часы или дни. Обычный перебор не годится.
А почему не ДП по сумме?
Первая мысль на собеседовании — задача о рюкзаке: dp[s] — число способов набрать сумму s. Для неотрицательных чисел это выглядит так:
def count_dp(nums, target): dp = [0] * (target + 1) dp[0] = 1 for x in nums: for s in range(target, x - 1, -1): dp[s] += dp[s - x] return dp[target] print(count_dp([3, 1, 4, 2, 5], 6)) # 3
Сложность , где — диапазон сумм. Если числа до , а их 40, сумма может дойти до , и массив такой длины не выделить. С отрицательными числами диапазон ещё и удваивается. ДП по сумме — псевдополиномиальный алгоритм: он быстрый, только пока сами числа маленькие.
Отсюда простое правило выбора, к которому вернёмся в конце: маленькие числа — ДП по сумме, маленькое — встреча посередине.
Идея встречи посередине
Разрежем массив на две половины: L = nums[:n//2] и R = nums[n//2:]. Любое подмножество всего массива однозначно раскладывается на подмножество L и подмножество R, а его сумма — это a + b, где a — сумма левой части, b — сумма правой.
Значит, задача превращается в такую:
- Выписать все сумм подмножеств левой половины.
- Выписать все сумм подмножеств правой половины.
- Посчитать пары
(a, b), у которыхa + b == target.
Третий шаг — это Two Sum в версии с подсчётом пар: для каждой a ищем, сколько раз в правом списке встречается target - a. С хеш-таблицей это одна операция на элемент.
Получается, что мы «идём навстречу» с двух сторон: слева набираем часть суммы, справа — остаток, и встречаемся в хеш-таблице.
Как быстро получить все суммы половины
Можно перебирать маски, как выше, но это лишние на каждую. Удобнее удваивать список: каждый новый элемент либо не берём (старые суммы остаются), либо берём (ко всем старым суммам прибавляем его).
def subset_sums(arr): sums = [0] for x in arr: sums += [s + x for s in sums] return sums print(subset_sums([3, 1])) # [0, 3, 1, 4]
Каждый шаг удваивает длину, всего выходит сложений, то есть для половины из элементов. Дешевле не бывает: столько сумм всё равно придётся выписать.
Полное решение
from collections import Counter def count_subsets(nums, target): half = len(nums) // 2 left = subset_sums(nums[:half]) right = Counter(subset_sums(nums[half:])) return sum(right[target - s] for s in left) print(count_subsets([3, 1, 4, 2, 5], 6)) # 3
Counter важен: одна и та же сумма в правой половине может получаться разными подмножествами, и каждое из них — отдельный ответ. Если положить суммы в set, ответ будет занижен.
Разбор на примере
nums = [3, 1, 4, 2, 5], target = 6, half = 2.
Левая половина [3, 1], её суммы: 0, 3, 1, 4.
Правая половина [4, 2, 5], её суммы:
| Подмножество | {} | {4} | {2} | {4,2} | {5} | {4,5} | {2,5} | {4,2,5} |
|---|---|---|---|---|---|---|---|---|
| Сумма | 0 | 4 | 2 | 6 | 5 | 9 | 7 | 11 |
Теперь для каждой левой суммы ищем дополнение до 6:
Левая сумма a | Ищем 6 − a | Найдено справа | Подмножество |
|---|---|---|---|
| 0 | 6 | 1 раз | {4, 2} |
| 3 | 3 | 0 | — |
| 1 | 5 | 1 раз | {1, 5} |
| 4 | 2 | 1 раз | {3, 1, 2} |
Итого 3, как и у перебора. Вместо 32 подмножеств мы посмотрели 4 + 8 сумм и 4 поиска в словаре.
Сложность
Пусть .
- Генерация сумм: для каждой половины.
- Подсчёт через
Counter: в среднем. - Если вместо хеш-таблицы сортировать (это нужно для задач «ближайшая сумма», см. ниже), добавляется .
Отсюда стандартная оценка . Память — : при это два списка по чисел. На Python такой список занимает десятки мегабайт, что обычно укладывается в лимиты.
Для сравнения в числах при :
| Подход | Операций | Реально |
|---|---|---|
| Перебор масок | не дождаться | |
| Встреча посередине | около полсекунды на Python | |
| ДП по сумме | , при | не хватит памяти |
Цифра «полсекунды» — замер решения из следующего раздела на 40 случайных числах до на обычном ноутбуке. На C++ или Java это будут десятки миллисекунд.
Про то, как вообще читать такие оценки, есть отдельная статья: Big O нотация.
Closest Subsequence Sum: когда точного совпадения нет
Самая популярная задача на эту технику — LeetCode 1755 Closest Subsequence Sum. Дан массив до 40 чисел от до и goal. Нужно выбрать подмножество, сумма которого ближе всего к goal, и вернуть .
Хеш-таблица тут не поможет: она ищет только точное совпадение. Зато поможет сортировка. Правые суммы сортируем, а для каждой левой суммы a бинарным поиском находим место, куда встала бы goal - a. Ближайший кандидат — один из двух соседей этого места.
from bisect import bisect_left def min_abs_difference(nums, goal): half = len(nums) // 2 left = subset_sums(nums[:half]) right = sorted(subset_sums(nums[half:])) best = abs(goal) # пустое подмножество for s in left: i = bisect_left(right, goal - s) if i < len(right): best = min(best, abs(s + right[i] - goal)) if i > 0: best = min(best, abs(s + right[i - 1] - goal)) if best == 0: break return best print(min_abs_difference([5, -7, 3, 5], 6)) # 0 print(min_abs_difference([7, -9, 15, -2], -5)) # 1 print(min_abs_difference([1, 2, 3], -7)) # 7
Проверяем обоих соседей, потому что bisect_left возвращает первый элемент не меньше искомого, а ближайшим может оказаться и предыдущий, который меньше.
Вариант на двух указателях
Если отсортировать обе половины, можно обойтись без бинарного поиска. Левый указатель идёт от самых маленьких сумм, правый — от самых больших. Сумма меньше цели — двигаем левый вперёд, больше — правый назад. Это та же логика, что в Container With Most Water и в статье про два указателя.
def min_abs_two_pointers(nums, goal): half = len(nums) // 2 left = sorted(subset_sums(nums[:half])) right = sorted(subset_sums(nums[half:])) i, j = 0, len(right) - 1 best = abs(goal) while i < len(left) and j >= 0: s = left[i] + right[j] best = min(best, abs(s - goal)) if s < goal: i += 1 elif s > goal: j -= 1 else: return 0 return best print(min_abs_two_pointers([7, -9, 15, -2], -5)) # 1
Асимптотика та же, из-за сортировок. На собеседовании можно показать любой вариант, главное — объяснить, почему указатели не пропускают ответ: если left[i] + right[j] < goal, то с left[i] любая правая сумма меньше right[j] даст ещё меньше, так что left[i] больше не нужен.
Вариация: максимальная сумма не больше лимита
Та же схема решает «рюкзак без стоимостей» с огромной вместимостью: найти максимальную сумму подмножества, не превышающую limit. Для каждой левой суммы a ищем в отсортированном правом списке наибольшее b <= limit - a через bisect_right(right, limit - a) - 1. Это пригодится в задачах вида «40 предметов с весами до , рюкзак вместимостью ».
4Sum II: встреча посередине без подмножеств
LeetCode 454 4Sum II: даны четыре массива a, b, c, d длины . Нужно посчитать четвёрки индексов, для которых a[i] + b[j] + c[k] + d[l] == 0.
Перебор — , при это . Встреча посередине: считаем все суммы пар из a и b в Counter, затем для каждой пары из c и d ищем противоположную сумму.
from collections import Counter def four_sum_count(a, b, c, d): ab = Counter(x + y for x in a for y in b) return sum(ab[-(x + y)] for x in c for y in d) print(four_sum_count([1, 2], [-2, -1], [-1, 2], [0, 2])) # 2
Время и память . Здесь «половины» — это не половины массива, а половины слагаемых: две из четырёх групп слева, две справа. Суть та же: независимые части, перебор каждой и склейка через поиск дополнения.
Не путайте с обычным 4Sum (LeetCode 18), где нужно вернуть уникальные четвёрки из одного массива. Там стандарт — сортировка и два указателя за , как в 3Sum с ещё одним внешним циклом. Встреча посередине для 4Sum формально даёт пар, но аккуратно убрать дубликаты и не использовать один индекс дважды заметно сложнее, поэтому на интервью её обычно не ждут.
Где ещё встречается приём
- LeetCode 2035, Partition Array Into Two Arrays to Minimize Sum Difference. чисел, , нужно разбить на две группы по элементов с минимальной разностью сумм. Встреча посередине плюс группировка левых и правых сумм по количеству взятых элементов: если слева взяли
cnt, справа нужно взятьn - cnt. - LeetCode 805, Split Array With Same Average. До 30 чисел, ищем подмножество с тем же средним. Сводится к поиску подмножества фиксированного размера с фиксированной суммой, и встреча посередине — один из рабочих подходов.
- Двунаправленный поиск в графах. BFS одновременно от старта и от финиша: каждый поиск проходит глубину , и вместо вершин просматривается порядка . Классика — Word Ladder.
- Криптография. Атака «встреча посередине» объясняет, почему двойное шифрование DES не даёт 112 бит стойкости: перебор ключей с двух сторон превращает в порядка операций. Поэтому на практике использовали тройной DES.
- Дискретный логарифм. Алгоритм baby-step giant-step для — тоже встреча посередине: , таблица для одной половины, перебор другой, итого вместо .
ДП по сумме или встреча посередине
| Признак | ДП по сумме | Встреча посередине |
|---|---|---|
| Что ограничено | Сумма (до –) | Количество элементов (до 40–45) |
| Время | ||
| Память | ||
| Отрицательные числа | Нужен сдвиг диапазона | Работают как есть |
| Большие числа | Не подходит | Не влияют на скорость |
| Ближайшая сумма к цели | Да, если маленькая | Да, через сортировку |
Порог легко оценить в голове. При встреча посередине стоит порядка — операций. ДП дешевле, если меньше этого, то есть при сумме до миллиона. Если в условии и числа до 1000 — это ДП. Если и числа до — встреча посередине.
Бывают и промежуточные случаи: с маленькими числами решает только ДП, а с большими не решает ни то, ни другое (там ищут дополнительную структуру).
Типичные ошибки
setвместоCounterпри подсчёте. Одна сумма может получаться несколькими подмножествами, а нужно посчитать их все.- Забыли пустое подмножество.
subset_sumsначинается с[0], и это правильно: левое пустое плюс правое непустое — нормальный ответ. Если по условию пустое подмножество не считается, вычтите его отдельно в самом конце (оно даёт сумму 0), а не убирайте 0 из половин. - Проверили только одного соседа в бинарном поиске. Для «ближайшей суммы» нужны оба:
right[i]иright[i - 1]. - Разрезали неравномерно. Половины по и дают , что при уже . Режьте строго пополам.
- Генерация сумм через маски с внутренним циклом. Это добавляет множитель и на Python может не пройти по времени. Удвоение списка быстрее.
- Хранение самих подмножеств. Если нужно вернуть подмножество, а не число, храните рядом с суммой маску (одно число), а не список элементов. Иначе память вырастет в раз.
Как это выглядит на собеседовании
Встреча посередине редко звучит в условии напрямую. Обычно интервьюер даёт задачу, где перебор очевиден, и смотрит, заметите ли вы ограничение . Хороший ход рассуждений вслух:
- «Полный перебор — , при это , не проходит».
- «ДП по сумме не подходит: числа до , таблица не влезет».
- «Подмножество раскладывается на левую и правую часть независимо. Переберу каждую по , а склею как Two Sum».
- «Время из-за сортировки, память ».
Если задача на точное совпадение — склейка через хеш-таблицу. Если на «ближайшее», «не больше», «минимальная разность» — через сортировку и бинарный поиск или два указателя.
Для тренировки сначала решите Two Sum и 3Sum в тренажёре: шаг склейки во встрече посередине — это ровно они. Потом возьмитесь за 4Sum II и Closest Subsequence Sum на LeetCode, а для сравнения подходов — за Partition Equal Subset Sum (LeetCode 416), где числа маленькие и выигрывает ДП.