Разбиение на подмножества с равной суммой
Переформулируйте задачу о разбиении как рюкзак 0/1 с целью, равной половине общей суммы, и определите возможность решения с помощью булева массива DP
«Разбиение на подмножества с равной суммой» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 3 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Условие задачи
Дан непустой массив положительных целых чисел nums. Определите, можно ли разбить его на два подмножества с равными суммами. Например, массив [1, 5, 11, 5] можно разделить на [1, 5, 5] и [11] — сумма каждого подмножества равна 11. Если общая сумма нечётна, ответом сразу будет False. В противном случае нужно найти подмножество с суммой total_sum // 2 — это классическая задача о сумме подмножества.
Сведение к задаче о сумме подмножеств
Ключевое сведение: если общая сумма S чётна и существует подмножество с суммой S//2, оставшиеся элементы автоматически также дают сумму S//2. Итак, задача о равном разбиении на подмножества сводится к вопросу: существует ли подмножество чисел с суммой S//2? Это классическая NP-полная задача о сумме подмножеств, которую мы решаем с помощью динамического программирования по схеме задачи о рюкзаке 0/1 за время O(n × S).
def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False # odd sum: impossible
target = total // 2
# Now: does any subset of nums sum to target?Массив логических значений DP
Определим логический массив dp[c], где dp[c] = True означает, что существует подмножество с суммой ровно c. Инициализируйте dp[0] = True (сумма пустого подмножества равна 0), а все остальные элементы — значением False. Для каждого числа num перебирайте вместимость от target до num (обратный перебор в задаче о рюкзаке 0/1) и задавайте dp[c] = dp[c] or dp[c - num].
def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for c in range(target, num - 1, -1): # backward: 0/1 knapsack
dp[c] = dp[c] or dp[c - num]
return dp[target]
print(canPartition([1, 5, 11, 5])) # True
print(canPartition([1, 2, 3, 5])) # FalseПошаговый разбор примера
Для [1, 5, 11, 5] общая сумма = 22, целевая сумма = 11. Изначально dp[0]=True. После обработки числа 1: dp[1]=True. После обработки числа 5: dp[5]=True, dp[6]=True. После обработки числа 11: dp[11]=True (используется только само число 11). Мы уже получили dp[11]=True, но продолжаем обрабатывать все числа. Итоговый ответ: dp[11]=True, поэтому разбиение возможно.
Оптимизация с досрочным завершением
Можно досрочно завершить вычисления: если dp[target] в какой-то момент становится равным True, немедленно верните True. Это может существенно ускорить выполнение в наилучшем случае. Кроме того, если отдельный элемент равен target, можно сразу вернуть True. Если отдельный элемент превышает target, он не может входить в подмножество с целевой суммой, но остальные элементы всё равно нужно проверить.
def canPartition_fast(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
if max(nums) > target: # any element > target makes it impossible
return False
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for c in range(target, num - 1, -1):
dp[c] = dp[c] or dp[c - num]
if dp[target]:
return True # early exit
return dp[target]
print(canPartition_fast([1, 5, 11, 5])) # TrueИспользование множества Python вместо массива DP
Другой вариант — поддерживать множество достижимых сумм. Начните с {0}. Для каждого числа добавляйте его к каждой сумме из текущего множества: reachable = reachable | {s + num for s in reachable}. Оставляйте только суммы, не превышающие целевую. В конце проверьте, содержится ли target в множестве. Этот подход интуитивно понятен, но может потребовать больше памяти и на практике оказаться медленнее.
def canPartition_set(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
reachable = {0}
for num in nums:
reachable = {s + num for s in reachable if s + num <= target} | reachable
return target in reachable
print(canPartition_set([1, 5, 11, 5])) # TrueАнализ сложности
Подход с DP работает за время O(n × S), где S = sum(nums), и использует O(S) памяти для логического массива. При ограничениях LeetCode (n ≤ 200, сумма ≤ 20 000) это не более 4 000 000 операций — очень быстро. Подход с множеством имеет такую же асимптотическую сложность, но на практике может быть медленнее из-за затрат на создание множеств.
Обобщение: подсчёт подмножеств с заданной суммой
Рассмотрим связанную задачу: подсчитать количество подмножеств с суммой, равной целевой. Замените логическое значение в DP на целое число: dp[c] = number of ways to reach sum c. Используйте сложение вместо OR: dp[c] += dp[c - num]. Инициализируйте dp[0] = 1. Обратный перебор остаётся тем же. Это обобщение показывает, как шаблон задачи о рюкзаке адаптируется к разным вопросам о подмножествах.
def count_subsets(nums, target):
dp = [0] * (target + 1)
dp[0] = 1
for num in nums:
for c in range(target, num - 1, -1):
dp[c] += dp[c - num]
return dp[target]
print(count_subsets([1, 1, 1, 1, 1], 3)) # 10 (C(5,3))Частые дополнительные вопросы на собеседовании
Будьте готовы к дополнительным вопросам: (1) Что делать, если нужно вернуть само разбиение? — потребуется двумерный DP для восстановления решения. (2) Что делать, если элементы могут быть отрицательными? — сдвиньте целевую сумму или используйте словарь вместо массива. (3) Какова временная сложность? — O(n × сумма). (4) Можно ли улучшить решение, если многие числа одинаковы? — да, используйте подсчёт частот, чтобы уменьшить количество внешних итераций. Всегда заранее упоминайте эти компромиссы.
Связь с задачей о рюкзаке 0/1
Задача о равном разбиении на подмножества — это прямое применение задачи о рюкзаке 0/1: предметами являются числа, их веса равны значениям, а вместимость рюкзака равна целевой сумме. Мы проверяем, равна ли максимальная ценность целевой сумме (то есть проверяем существование решения), а не ищем саму максимальную ценность. Обратный перебор выполняется так же, меняется только операция: вместо max используется логическое or. Распознавание этой связи на собеседовании демонстрирует развитое умение находить закономерности.
Граничные случаи
Обработайте следующие граничные случаи: (1) массив длины 1 — один элемент нельзя разделить, поэтому результат всегда False; (2) все элементы одинаковы, а их количество чётно — решение может существовать или отсутствовать в зависимости от значений элементов; (3) очень большие суммы — проверьте ограничения перед выделением массива DP; (4) элементы, превышающие целевую сумму, можно пропустить, поскольку они никогда не войдут в подмножество с такой суммой. Проверка максимального элемента в качестве досрочного завершения эффективно обрабатывает случай (4).
Быстрая проверка
Проверьте, насколько вы усвоили концепции структур данных и алгоритмов — подготовки к собеседованию по программированию, рассмотренные в этом уроке.
Итоги урока
В этом уроке вы узнали: задача о равном разбиении на подмножества сводится к задаче о сумме подмножеств с целевой суммой, равной общей сумме // 2, одномерный логический DP dp[c] использует обратный перебор, как и задача о рюкзаке 0/1, а подход обобщается на подсчёт подмножеств заменой логического OR на сложение целых чисел. Далее мы перейдём к задаче о целевой сумме, преобразуя присваивание знаков в задачу о рюкзаке на разности сумм подмножеств.
Часто задаваемые вопросы
Урок «Разбиение на подмножества с равной суммой» бесплатный?
Да — полный текст урока «Разбиение на подмножества с равной суммой» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Разбиение на подмножества с равной суммой»?
Переформулируйте задачу о разбиении как рюкзак 0/1 с целью, равной половине общей суммы, и определите возможность решения с помощью булева массива DP Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 3 из 4.
Сколько времени занимает урок «Разбиение на подмножества с равной суммой»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Рюкзак 0/1 и оптимизация памяти
- Неограниченный рюкзак и размен монет II
- Разбиение на подмножества с равной суммой
- Целевая сумма с положительными и отрицательными знаками