0Pricing
DSA Interview Prep · Урок

Подмножества и множество всех подмножеств

Сгенерируйте все подмножества множества с помощью перебора с возвратом и побитовых масок; для обработки дубликатов отсортируйте элементы и пропускайте повторяющиеся

«Подмножества и множество всех подмножеств» — бесплатный урок 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 — локальная установка не требуется.

Все уроки этого курса

  1. Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
  2. Подмножества и множество всех подмножеств
  3. Перестановки и сочетания
  4. Задача о N ферзях и распространение ограничений
← Назад к DSA Interview Prep