0Pricing
Coding Interview Prep · Урок

Разбиение на подмножества с равной суммой

Переформулируйте задачу о разбиении как рюкзак 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 — локальная установка не требуется.

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

  1. Рюкзак 0/1 и оптимизация памяти
  2. Неограниченный рюкзак и размен монет II
  3. Разбиение на подмножества с равной суммой
  4. Целевая сумма с положительными и отрицательными знаками
← Назад к Coding Interview Prep