6. 3Sum
«Сложные алгоритмические конструкции вырастают из правильного комбинирования базовых приёмов: отсортируйте данные один раз, и хаос превратится в строгий порядок».
— Никлаус Вирт
LeetCode 15. 3Sum
Сложность: Medium
Тема: Сортировка + Встречные указатели, дедупликация троек, ранняя отсечка (Pruning)
Ссылки: Three Sum (Обзор), Теория: Два указателя, Two Sum II, 4Sum
1. Введение и физическая интуиция
Если задача Two Sum — это дуэт, то 3Sum — классическое трио. Нам требуется найти все уникальные наборы из трех чисел, которые в сумме дают круглый ноль: $a + b + c = 0$.
Главная ловушка задачи — комбинаторный взрыв и повторяющиеся тройки. В массиве из 3 000 элементов наивный перебор трех индексов потребует:
$$ \frac{3000 \times 2999 \times 2998}{6} \approx 4.5 \times 10^9 \text{ операций} $$
Программа неизбежно зависнет в тайм-ауте (Time Limit Exceeded).
Решение в стиле классической инженерной школы — сведение задачи к уже решенной:
- Отсортируем массив за $O(N \log N)$.
- Зафиксируем первое число $nums[i]$.
- Теперь нам нужно найти два числа $nums[j]$ и $nums[k]$ в оставшейся правой части массива, сумма которых равна $-nums[i]$. А это не что иное, как задача Two Sum II!
- Встречные указатели решают эту подзадачу за $O(N)$ для каждого фиксированного $i$. Общая сложность схлопывается до комфортных $O(N^2)$.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["nums = [-1, 0, 1, 2, -1, -4]"]:::entry --> B["1. slices.Sort: [-4, -1, -1, 0, 1, 2]"]:::process
B --> C["2. Внешний цикл по i (от 0 до n-3)"]:::process
C --> D{"nums[i] > 0?"}:::warning
D -->|"Да (Все оставшиеся > 0)"| E["Досрочный break"]:::success
D -->|"Нет"| F{"i > 0 И nums[i] == nums[i-1]?"}:::warning
F -->|"Да (Дубликат i)"| G["continue"]:::process
F -->|"Нет"| H["3. Встречные указатели:<br>j = i+1, k = n-1<br>target = -nums[i]"]:::process
H --> I{"nums[j] + nums[k] == target?"}:::warning
I -->|"Да"| J["Записать тройку,<br>пропустить дубликаты j и k,<br>j++, k--"]:::success
I -->|"Меньше"| K["j++"]:::process
I -->|"Больше"| L["k--"]:::process
J --> H
K --> H
L --> H
2. Формулировка задачи и вопросы интервьюеру
Дан целочисленный массив nums. Найти все уникальные тройки [nums[i], nums[j], nums[k]] такие, что $i \ne j \ne k$ и $nums[i] + nums[j] + nums[k] == 0$.
Итоговый набор троек не должен содержать дубликатов (например, нельзя возвращать дважды [-1, 0, 1]).
Вопросы интервьюеру (Senior-стиль):
- Каков размер входного массива?
«$3 \le len(nums) \le 3000$. Это подтверждает, что $O(N^2)$ — эталонная сложность, а $O(N^3)$ недопустима.» - Можно ли модифицировать исходный срез?
«Да, мы можем сортироватьnumsin-place, не аллоцируя дополнительную память.» - Что возвращать, если троек нет?
«В Go принято возвращать пустой слайс[][]int{}илиnil.»
3. Разбор подходов
Подход 1: Тройной вложенный цикл + map дедупликация (Наивный)
- Сложность: $O(N^3)$ по времени, $O(N)$ по памяти под хэш-таблицу нормализованных троек
map[[3]int]struct{}. - Минусы: гарантированный TLE на LeetCode и отказ на интервью.
Подход 2: Внешний цикл + map дополнений (Субоптимальный)
- Фиксируем $i$, а для оставшейся части запускаем классический Two Sum с хэш-мапой.
- Минусы: хотя асимптотика $O(N^2)$, дедупликация требует отдельной громоздкой логики или мапы троек, что влечет сотни тысяч мелких аллокаций в куче и сильнейшую нагрузку на Garbage Collector.
Подход 3: Сортировка + Встречные указатели (Эталонный)
- Предварительная сортировка объединяет дубликаты в непрерывные группы.
- Дедупликация выполняется элементарным пропуском соседних равных значений (
nums[idx] == nums[idx±1]). - Дополнительная память: $O(1)$ (не считая среза результатов).
4. Эталонная реализация на Go
package main
import "slices"
// ThreeSum находит все уникальные тройки с суммой 0.
// Временная сложность: O(N^2) — квадратичный обход после быстрой сортировки O(N log N).
// Пространственная сложность: O(1) вспомогательной памяти (in-place).
func ThreeSum(nums []int) [][]int {
n := len(nums)
if n < 3 {
return nil
}
// В современном Go (1.21+) используем быстрый pdqsort
slices.Sort(nums)
var result [][]int
for i := 0; i < n-2; i++ {
// Ранняя отсечка (Pruning): если наименьшее число в тройке больше нуля,
// то сумма трех положительных чисел никогда не даст ноль!
if nums[i] > 0 {
break
}
// Дедупликация первого элемента: пропускаем одинаковые числа
if i > 0 && nums[i] == nums[i-1] {
continue
}
target := -nums[i]
left, right := i+1, n-1
for left < right {
currentSum := nums[left] + nums[right]
switch {
case currentSum == target:
// Тройка найдена
result = append(result, []int{nums[i], nums[left], nums[right]})
// Пропускаем дубликаты левого указателя
for left < right && nums[left] == nums[left+1] {
left++
}
// Пропускаем дубликаты правого указателя
for left < right && nums[right] == nums[right-1] {
right--
}
left++
right--
case currentSum < target:
left++
default: // currentSum > target
right--
}
}
}
return result
}
5. Пошаговая трассировка (Walkthrough)
Возьмем входной массив: nums = [-1, 0, 1, 2, -1, -4]
После slices.Sort: [-4, -1, -1, 0, 1, 2] ($n = 6$).
i = 0(nums[0] = -4),target = 4:
left = 1(-1),right = 5(2).
Максимально возможная сумма: $-1 + 2 = 1 < 4$. Указательleftдоходит до конца, пар нет.i = 1(nums[1] = -1),target = 1:
left = 2(-1),right = 5(2).- $-1 + 2 = 1 == target$! $\implies$ Записываем
[-1, -1, 2]. - Пропускаем дубликаты:
leftсдвигается на позицию 3 (0),rightна позицию 4 (1). - Новая проверка: $0 + 1 = 1 == target$! $\implies$ Записываем
[-1, 0, 1]. - Указатели сошлись (
left > right).
- $-1 + 2 = 1 == target$! $\implies$ Записываем
i = 2(nums[2] = -1):
Проверкаnums[2] == nums[1](-1 == -1) $\implies$ Дубликат, пропускаем (continue).i = 3(nums[3] = 0),target = 0:
left = 4(1),right = 5(2).
$1 + 2 = 3 > 0 \implies right--$. Указатели сошлись.i = 4(nums[4] = 1):
nums[4] > 0$\implies$ Срабатываетbreak(все оставшиеся элементы положительные).
Итоговый результат: [[-1, -1, 2], [-1, 0, 1]]. Без единого дубликата!
6. Mechanical Sympathy: почему алгоритм летит на CPU
- Паттерн сортировки
pdqsort(Pattern-Defeating Quicksort):
С версии Go 1.19/1.21 стандартный пакетslicesиспользуетpdqsort. Для частично упорядоченных массивов или массивов с большим числом дубликатов он переходит в $O(N)$ режим, сохраняя превосходную кэш-локальность. - Ранняя отсечка (Early Pruning):
Проверкаif nums[i] > 0 { break }отсекает до $50\%$ работы внешнего цикла на массивах с равномерным распределением чисел. - Отсутствие промежуточных аллокаций:
Вся математика работает на регистрах. В кучу попадают только результирующие срезы троек[]int{...}при вызовеappend.
7. Вопросы для собеседования
8. Инварианты и чек-лист самопроверки
- [x] Предварительная сортировка:
slices.Sort(nums)вызвана на старте. - [x] Дедупликация первого элемента: условие
i > 0 && nums[i] == nums[i-1]. - [x] Дедупликация встречных указателей: вложенные циклы
nums[left] == nums[left+1]иnums[right] == nums[right-1]. - [x] Ограничение
left < right: защищает от выхода за границы при сериях дубликатов в конце массива.
Теперь мы готовы масштабировать этот подход еще на один уровень вложенности в задаче 4Sum: 7. 4Sum.