Подмножества и множество всех подмножеств
Сгенерируйте все подмножества множества с помощью перебора с возвратом и побитовых масок; для обработки дубликатов отсортируйте элементы и пропускайте повторяющиеся
«Подмножества и множество всех подмножеств» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.
Подмножества и булеан
Булеан множества S — это совокупность всех возможных подмножеств S, включая пустое множество и само S. Множество из n элементов содержит ровно 2ⁿ подмножеств. Для [1, 2, 3] это 8 подмножеств: [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]. Это фундаментальная комбинаторная задача, которая встречается в вопросах на собеседованиях о поиске всех возможных комбинаций, разбиений или вариантов выбора.
# A set of n elements → 2^n subsets
for n in range(5):
print(f'n={n}: {2**n} subsets')
# n=0: 1 (just the empty set)
# n=1: 2 ([], [x])
# n=2: 4 ([], [a], [b], [a,b])
# n=3: 8 (as enumerated above)
# n=4: 16Генерация подмножеств с помощью поиска с возвратом
Используйте шаблон «выбрать — исследовать — отменить выбор». Ключевое проектное решение состоит в том, чтобы на каждом рекурсивном вызове немедленно добавлять текущий частичный путь в результаты (до выбора новых элементов). Благодаря этому каждое состояние — пустое, частичное и полное — сохраняется как допустимое подмножество. Увеличивайте индекс start, чтобы рассматривать только элементы правее последнего выбранного элемента, избегать дубликатов и сохранять порядок.
def subsets(nums):
result = []
def backtrack(start, path):
result.append(list(path)) # every state is a valid subset
for i in range(start, len(nums)):
path.append(nums[i]) # CHOOSE
backtrack(i + 1, path) # EXPLORE (advance start)
path.pop() # UNCHOOSE
backtrack(0, [])
return result
print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]Подход с битовой маской
Альтернатива поиску с возвратом — битовая маска: каждому подмножеству соответствует n-битное число, в котором единичный бит i означает, что элемент i включён. Перебирайте числа от 0 до 2ⁿ - 1 и для каждого числа извлекайте биты, чтобы построить подмножество. Этот подход итеративный, на практике часто работает быстрее и очень прост в реализации. Однако он не так естественно обобщается на задачи с ограничениями (например, с ограничением суммы).
def subsets_bitmask(nums):
n = len(nums)
result = []
for mask in range(1 << n): # 0 to 2^n - 1
subset = []
for i in range(n):
if mask & (1 << i): # bit i is set
subset.append(nums[i])
result.append(subset)
return result
print(subsets_bitmask([1, 2, 3]))
# Same 8 subsets, order may differИтеративная генерация подмножеств
Итеративный подход постепенно строит булеан, добавляя элементы один за другим. Начните с [[] ] (пустого множества). Для каждого нового элемента дублируйте все существующие подмножества и добавьте новый элемент в каждый дубликат. После обработки n элементов результат содержит все 2ⁿ подмножеств. Это эквивалентно использованию битовой маски, но более понятно тем, кто не знаком с побитовыми операциями.
def subsets_iterative(nums):
result = [[]] # start with empty set
for num in nums:
# For each existing subset, create a new subset with num added
result += [subset + [num] for subset in result]
return result
print(subsets_iterative([1, 2, 3]))
# After num=1: [[], [1]]
# After num=2: [[], [1], [2], [1,2]]
# After num=3: [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]Подмножества II: обработка дубликатов
Если входные данные содержат дубликаты, наивный подход создаёт повторяющиеся подмножества. Для [1, 2, 2] оба вхождения 2 независимо создали бы [1, 2]. Исправление: сначала отсортируйте массив, затем пропускайте кандидата на текущем уровне, если он равен предыдущему кандидату на том же уровне. В частности, в цикле: if i > start and nums[i] == nums[i-1]: continue.
def subsets_with_dups(nums):
nums.sort() # sort to group duplicates together
result = []
def backtrack(start, path):
result.append(list(path))
for i in range(start, len(nums)):
# Skip duplicates at the same tree level
if i > start and nums[i] == nums[i-1]:
continue
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
print(subsets_with_dups([1, 2, 2]))
# [[], [1], [1,2], [1,2,2], [2], [2,2]] — no duplicate subsetsПочему пропуск дубликатов работает
Условие i > start and nums[i] == nums[i-1] пропускает дубликат только на одном и том же уровне рекурсии (при одном и том же start). Оно не запрещает выбирать одно и то же значение на разных глубинах. Для [1, 2, 2]: на уровне 0 мы добавляем первый 2 (индекс 1), затем на следующем уровне (start=2) добавляем второй 2, чтобы получить [2, 2]. Но если бы мы снова попытались добавить второй 2 на уровне 0, условие обнаружило бы это и пропустило его.
# Visual: [1, 2, 2] sorted
# Level 0 (start=0): pick nothing, pick 1, pick first-2, pick second-2 (SKIP)
# Level 1 after picking 1 (start=1): pick first-2, pick second-2 (SKIP)
# Level 2 after picking 1,first-2 (start=2): pick second-2
# → [1,2,2] is generated but only once
nums = [1, 2, 2]
nums.sort()
result_set = set(tuple(sorted(s)) for s in subsets_with_dups(nums[:]))
result_naive = set(tuple(sorted(s)) for s in subsets(nums))
print('With dedup:', sorted(result_set))
print('Same results:', result_set == result_naive)
def subsets(nums):
result = []
def bt(start, path):
result.append(list(path))
for i in range(start, len(nums)):
path.append(nums[i]); bt(i+1, path); path.pop()
bt(0, [])
return result
def subsets_with_dups(nums):
result = []
def bt(start, path):
result.append(list(path))
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i-1]: continue
path.append(nums[i]); bt(i+1, path); path.pop()
bt(0, [])
return result
print(len(subsets_with_dups([1,2,2])), 'unique subsets') # 6Подмножества фиксированного размера (сочетания размера k)
Генерация только подмножеств ровно размера k (LeetCode 77: Сочетания) добавляет условие досрочного завершения: если оставшихся элементов недостаточно, чтобы дополнить путь до размера k, ветвь нужно отсечь. Условие отсечения задаётся как i > n - (k - len(path)): если оставшихся элементов недостаточно, работу с этой ветвью следует немедленно прекратить. Это значительно уменьшает пространство поиска по сравнению с генерацией всех подмножеств и последующей фильтрацией.
def combine(n, k):
result = []
def backtrack(start, path):
if len(path) == k:
result.append(list(path))
return
# Prune: need (k - len(path)) more elements from [start..n]
# At most (n - start + 1) elements remain
if n - start + 1 < k - len(path):
return # not enough elements left
for i in range(start, n + 1):
path.append(i)
backtrack(i + 1, path)
path.pop()
backtrack(1, [])
return result
print(combine(4, 2)) # [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
print(len(combine(10, 3))) # C(10,3) = 120Применение степенного множества
Шаблон степенного множества встречается во многих вариантах задач на собеседованиях: (1) Разбиение на два равных подмножества — проверить, существует ли подмножество с суммой total/2. (2) Максимальный XOR двух подмножеств — перебрать все пары подмножеств. (3) Минимальная стоимость выбора k элементов — перечислить все подмножества из k элементов. Хотя прямое перечисление имеет экспоненциальную сложность, для многих таких задач возможны решения с помощью DP, если распознать их структуру. Представление задачи через степенное множество помогает определить пространство состояний, даже если впоследствии вы его оптимизируете.
def max_subset_sum(nums, k):
'''Maximum sum of any k elements (for comparison: O(n log n) alternative)'''
# Backtracking approach: enumerate all k-subsets
max_s = [float('-inf')]
def bt(start, path, curr_sum):
if len(path) == k:
max_s[0] = max(max_s[0], curr_sum)
return
remaining_spots = k - len(path)
for i in range(start, len(nums)):
if len(nums) - i < remaining_spots: break # prune
bt(i+1, path+[nums[i]], curr_sum+nums[i])
bt(0, [], 0)
return max_s[0]
# Much faster: just sort and take top k
def max_subset_sum_fast(nums, k):
return sum(sorted(nums, reverse=True)[:k])
nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(max_subset_sum(nums, 3)) # 20 (9+6+5)
print(max_subset_sum_fast(nums, 3)) # 20Проверка суммы подмножества
Задача о сумме подмножества ставит вопрос: существует ли подмножество массива с суммой, равной заданному целевому значению? Её можно решить с помощью поиска с возвратом (экспоненциальная сложность) или DP (полиномиальная сложность). Вариант с поиском с возвратом прост для понимания, но становится непрактичным для больших входных данных. Вариант с DP (логическая таблица dp[target+1]) обычно является предпочтительным подходом на собеседованиях. Понимание обоих подходов помогает объяснить компромисс: поиск с возвратом выдаёт все решения, а DP эффективно решает задачу принятия решения.
# Backtracking version: finds a subset if it exists
def subset_sum_bt(nums, target):
def bt(start, remaining):
if remaining == 0: return True
if remaining < 0 or start == len(nums): return False
# Include nums[start]
if bt(start + 1, remaining - nums[start]): return True
# Exclude nums[start]
return bt(start + 1, remaining)
return bt(0, target)
# DP version: O(n * target) time
def subset_sum_dp(nums, target):
dp = {0}
for num in nums:
dp |= {s + num for s in dp}
return target in dp
print(subset_sum_bt([3, 1, 4, 1, 5], 6)) # True (1+5 or 1+1+4)
print(subset_sum_dp([3, 1, 4, 1, 5], 6)) # TrueСложность перечисления подмножеств
Генерация всех подмножеств имеет неизбежную временную сложность O(n × 2ⁿ): существует 2ⁿ подмножеств, каждое из которых в среднем содержит n/2 элементов. Ни один алгоритм не может работать быстрее, если требуется получить все подмножества. Для задач, в которых требуется найти одно подмножество с заданным свойством (например, с максимальной суммой), следует предпочесть DP или жадный подход. Важный вывод для собеседования: всегда уточняйте, нужно ли вам перечислить все подмножества или только определить, удовлетворяет ли условию какое-либо подмножество. От этого зависит, допустима ли экспоненциальная сложность или требуется полиномиальная.
import time
def count_subsets(n):
nums = list(range(n))
result = []
def bt(start, path):
result.append(None) # count without storing
for i in range(start, len(nums)):
path.append(i); bt(i+1, path); path.pop()
bt(0, [])
return len(result)
for n in [10, 15, 20]:
start = time.time()
cnt = count_subsets(n)
elapsed = time.time() - start
print(f'n={n}: {cnt} subsets ({2**n} expected) in {elapsed:.3f}s')Сравнение всех трёх подходов
Для генерации всех подмножеств: поиск с возвратом лучше всего обобщается — его легко адаптировать к дубликатам и дополнительным ограничениям. Маскирование битами даёт компактное и быстрое решение, но ограничено значением n ≤ 30 из-за размера целого числа. Итеративный подход интуитивно понятен и не создаёт накладных расходов на рекурсию. Все три подхода формируют результат объёма O(n × 2ⁿ). На собеседовании поиск с возвратом демонстрирует понимание рекурсивного процесса принятия решений, который обобщается на более сложные задачи. Обсуждая подходы, упоминайте все три варианта.
# All three approaches for [1,2,3]
nums = [1, 2, 3]
# 1. Backtracking
def bt(start, path, res):
res.append(list(path))
for i in range(start, len(nums)):
path.append(nums[i]); bt(i+1, path, res); path.pop()
res1 = []; bt(0, [], res1)
# 2. Bit masking
res2 = [[nums[i] for i in range(len(nums)) if mask & (1<<i)]
for mask in range(1<<len(nums))]
# 3. Iterative
res3 = [[]]
for num in nums:
res3 += [s+[num] for s in res3]
print('All produce', len(nums)**2, '-ish subsets:',
len(res1), len(res2), len(res3)) # all 8Проверка знаний
Проверьте, насколько хорошо вы усвоили изученные в этом уроке концепции структур данных и алгоритмов — подготовки к техническим собеседованиям.
Итоги урока
В этом уроке вы узнали, что поиск с возвратом генерирует все подмножества, добавляя каждый частичный путь в результаты до дальнейшего исследования, дубликаты обрабатываются сортировкой и пропуском повторяющихся значений на одной глубине рекурсии с помощью условия i > start and nums[i] == nums[i-1], а маскирование битами даёт компактную итеративную альтернативу, где каждому подмножеству соответствует уникальная битовая маска. Далее мы рассмотрим перестановки и сочетания — родственные задачи перечисления с другими ограничениями.
Часто задаваемые вопросы
Урок «Подмножества и множество всех подмножеств» бесплатный?
Да — полный текст урока «Подмножества и множество всех подмножеств» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Подмножества и множество всех подмножеств»?
Сгенерируйте все подмножества множества с помощью перебора с возвратом и побитовых масок; для обработки дубликатов отсортируйте элементы и пропускайте повторяющиеся Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать DSA Interview Prep?
Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.
Сколько времени занимает урок «Подмножества и множество всех подмножеств»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке DSA Interview Prep?
Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
- Подмножества и множество всех подмножеств
- Перестановки и сочетания
- Задача о N ферзях и распространение ограничений