4. Subsets II
«Избавиться от повторений постфактум может любой; истинное мастерство состоит в том, чтобы не порождать их вовсе».
— Брайан Керниган
В задаче 3. Subsets все элементы исходного среза были уникальны. Однако в реальных данных дубликаты неизбежны.
Задача Subsets II (LeetCode 90) проверяет инженерную зрелость разработчика:
Условие: Дан целочисленный срез nums, который может содержать дублирующиеся элементы. Требуется вернуть все уникальные подмножества (степенное множество). Результирующий список не должен содержать одинаковых подмножеств в любом порядке.
Наивный программист сгенерирует все $2^N$ комбинаций и профильтрует их через map[string]struct{}. Senior-инженер отсечет генерацию изоморфных ветвей в момент их зарождения, сохранив линейную память и нулевой оверхед по аллокациям.
Архитектура: Инвариант пропуска на одном уровне дерева
Рассмотрим массив nums = [1, 2, 2].
Если мы отсортируем срез, одинаковые элементы расположатся строго рядом:
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,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
Root["Корень: []"]:::entry
Root -->|"выбор 1"| N1["[1]"]:::process
Root -->|"выбор 2 (первая)"| N2["[2]"]:::process
Root -.->|"выбор 2 (дубликат): i > start && nums[i]==nums[i-1]"| Skip1["ПРЕЗРЕНО / PRUNED"]:::error
N1 -->|"выбор 2 (первая)"| N12["[1, 2]"]:::process
N1 -.->|"выбор 2 (дубликат): отсечение"| Skip2["ПРЕЗРЕНО / PRUNED"]:::error
N12 -->|"выбор 2 (вторая, глубже): разрешено!"| N122["[1, 2, 2]"]:::success
N2 -->|"выбор 2 (вторая, глубже): разрешено!"| N22["[2, 2]"]:::success
Разгадка условия i > start && nums[i] == nums[i-1]
Почему именно i > start, а не i > 0?
- По горизонтали (на одном уровне дерева рекурсии):
Когдаi > start, это означает, что мы уже попробовали запустить ветвь с элементомnums[i-1]на текущей позиции. Если текущийnums[i] == nums[i-1], то запуск новой ветви с тем же числом породит абсолютно идентичное поддерево! Мы обязаны сделатьcontinue. - По вертикали (вглубь рекурсии):
Когда рекурсия переходит на следующий уровень, параметрstartстановится равнымi + 1. На первом шаге нового уровня текущий индексiравенstart(i == start). Условиеi > startоказывается ложным, и алгоритм беспрепятственно берет второй дубликат в подмножество.
Благодаря этому подмножество [2, 2] формируется без проблем, но комбинация {первая 2} и {вторая 2} как два изолированных подмножества никогда не дублируются!
Идиоматичная реализация на Go
package main
import "sort"
// subsetsWithDup генерирует все уникальные подмножества из массива с дубликатами.
// Временная сложность: O(N * 2^N) в худшем случае, существенно меньше при дубликатах
// Пространственная сложность: O(N) дополнительной памяти под стек и буфер path
func subsetsWithDup(nums []int) [][]int {
// 1. Сортировка обязательна: группирует одинаковые числа в непрерывный кластер
sort.Ints(nums)
n := len(nums)
var result [][]int
path := make([]int, 0, n)
var backtrack func(start int)
backtrack = func(start int) {
// Сохраняем снимок текущего уникального состояния
snapshot := make([]int, len(path))
copy(snapshot, path)
result = append(result, snapshot)
for i := start; i < n; i++ {
// Отсекаем дубликаты на одном горизонтальном уровне дерева
if i > start && nums[i] == nums[i-1] {
continue
}
path = append(path, nums[i])
backtrack(i + 1)
path = path[:len(path)-1] // Откат
}
}
backtrack(0)
return result
}
Взгляд под капот: Mechanical Sympathy
Почему фильтрация через map[string]struct{} — антипаттерн
Рассмотрим, что происходит в памяти при «наивном» решении с мапой:
- Каждое из $2^N$ подмножеств сериализуется в строку (например, через
fmt.Sprintили конкатенацию байт). Это вызывает от 1 до 3 аллокаций в куче на каждое подмножество. - Строка передается в хэш-таблицу
map. Рантайм Go вычисляет хэш по алгоритму AES-NI / Wyhash (runtime.aeshash), разрешает коллизии в бакетах и аллоцирует узлы карты в куче. - GC испытывает колоссальное давление, очищая сотни тысяч короткоживущих строк.
Наше решение с сортировкой и условием i > start && nums[i] == nums[i-1]:
- Сортировка
sort.Intsработает in-place по алгоритму pdqsort (Pattern-Defeating Quicksort) с $O(1)$ дополнительной памяти. - Проверка
nums[i] == nums[i-1]— это одна процессорная инструкцияCMPрегистров без единого выхода в память. - Алгоритм в принципе не посещает дублирующиеся ветви, экономя такты CPU и память.
Подводные камни и вопросы с собеседований
Резюме
Subsets II демонстрирует фундаментальный закон эффективного поиска с возвратом:
- Сортировка переводит хаотичные дубликаты в предсказуемые кластеры.
- Условие
i > start && nums[i] == nums[i-1]блокирует повторные ветви на одном уровне выбора. - Отсечение экономит память и процессорное время, обходясь без тяжелых
map.
В следующей лекции мы переходим к классу задач, где порядок элементов критичен — генерации перестановок: 5. Permutations.