Partition Equal Subset Sum
Дан массив nums положительных целых чисел. Определите, можно ли разделить значения на две группы с равными суммами. Каждое значение должно входить ровно в одну группу, а в группу можно включать значения с любых позиций. Верните true, если такое разбиение существует, и false в противном случае.
Функция
- numsinteger-array
- положительные значения, чтобы разделить их на две группы
- Возвращаетboolean
- true, если значения можно разделить на две группы с равными суммами, иначе false
Ограничения
1 ≤ nums.length ≤ 2001 ≤ nums[i] ≤ 100
Примеры
- Ввод
- nums = [6, 1, 4, 9, 2]
- Вывод
- true
- Пояснение
- Итого получается 22, поэтому в каждой группе должно быть по 11. Группы 9 + 2 и 6 + 1 + 4 дают в сумме 11, поэтому ответ —
true.
- Ввод
- nums = [4, 7, 2, 9, 6]
- Вывод
- false
- Пояснение
- Сумма равна 28, поэтому каждой группе нужно по 14. Группе, в которой 9, нужно ещё 5, а из чисел 4, 7, 2 и 6 нельзя составить 5, поэтому ответ —
false, хотя сумма чётная.
- Ввод
- nums = [1, 2, 3, 5]
- Вывод
- false
- Пояснение
- Сумма равна 11. Два одинаковых целых числа всегда в сумме дают чётное число, поэтому нечётную сумму невозможно разделить поровну, и ответ —
false.
+18 скрытых тестов при отправке
Дополнительный вопрос
Если разделить на равные части не получается, можешь вернуть наименьшую возможную разницу между суммами двух групп?
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Если суммы двух групп равны, чему должна быть равна каждая сумма через общий итог
nums? И о чём сразу говорит нечётный общий итог?Тебе нужно найти только одну группу, сумма которой составляет половину от общей суммы; оставшиеся значения образуют другую группу. Подумай, каких сумм можно достичь, складывая первые несколько значений, и как добавление ещё одного значения меняет этот набор.
Поддерживай логический массив
reach[0..target], в котором толькоreach[0]имеет значение true. Для каждого значенияnumпроходи поsотtargetдоnumи отмечайreach[s], если отмеченreach[s-num]. Проход в обратном порядке не позволяет использовать каждое значение дважды.
Решение
Каждая группа должна содержать ровно половину общего количества, поэтому главный вопрос — найдётся ли подмножество nums, сумма которого равна target = total / 2. Перебор всех подмножеств требует 2^n операций, что нереально для 200 значений. Однако сами суммы невелики: target не превышает 200 × 100 / 2 = 10^4. Если записывать, какие суммы достижимы, добавляя по одному значению, поиск превращается в таблицу задачи о рюкзаке 0/1, которая заполняется за O(n × sum) шагов.
Попробуйте каждое подмножество с помощью рекурсии
Верно, но не успевает на самых больших тестах
Идея
Начните с общей суммы. Если она нечётная, разбить числа на две равные группы нельзя, потому что сумма двух одинаковых целых чисел чётная. Иначе сумма каждой группы должна быть ровно target = total / 2. Как только вы найдёте значения, сумма которых равна target, значения, которые вы не выбрали, сами составят вторую половину. Поэтому достаточно ответить на один вопрос: существует ли подмножество с суммой target?
Рассматривайте значения по порядку и выбирайте для каждого одно из двух: поместить его в первую группу или оставить для второй. Вспомогательная функция reach(i, remaining) проверяет, можно ли составить сумму remaining из значений, начиная с индекса i. Она возвращает true, когда remaining становится равным 0, false, когда значения заканчиваются или remaining становится меньше 0; в противном случае она пробует оба варианта для nums[i].
Каждое подмножество соответствует одному пути выбора, поэтому поиск не может пропустить разбиение и даёт правильный ответ. Он работает медленно, потому что существует 2^n путей, а для входных данных, которые нельзя разбить, приходится пробовать почти все из них. Возьмём 199 копий числа 100 и одно число 98: общая сумма равна 19998, сумма 9999 никогда не достигается, и поиск пробует все способы выбрать не более 99 чисел 100 — около 4 × 10^59 путей. Даже для 40 значений получается 2^40, то есть около 10^12 путей.
Алгоритм
- Сложи значения в
nums. Если сумма нечётная, верниfalse. - Задай
targetравным половине суммы. - Напиши
reach(i, remaining): возвращай true, когдаremainingравно 0, и false, когдаiбольше последнего значения илиremainingменьше 0. - Иначе возвращай
reach(i+1, remaining-nums[i])илиreach(i+1, remaining): возьми значение или оставь его. - Верни
reach(0, target).
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
# Can some of the values from index i on add up to exactly remaining?
def reach(i, remaining):
if remaining == 0:
return True
if i == len(nums) or remaining < 0:
return False
# Put nums[i] in the first group, or leave it for the second one
return reach(i + 1, remaining - nums[i]) or reach(i + 1, remaining)
return reach(0, total // 2)Заполните таблицу значением и суммой
Идея
Рекурсия снова и снова задаёт один и тот же вопрос. reach(i, remaining) зависит только от двух чисел: i от 0 до n и remaining от 0 до target. Значит, существует не более (n+1) × (target+1) разных вопросов — на граничных значениях это примерно 201 × 10001 ≈ 2 × 10^6, достаточно мало, чтобы ответить на каждый из них один раз.
Вычислим ответы вперёд, заполняя таблицу. can[i][s] показывает, можно ли получить сумму s из некоторых первых i значений. Если значений нет, возможна только сумма 0, поэтому в строке 0 везде false, кроме can[0][0]. Значение num = nums[i-1] даёт два способа получить s: не брать num, значит, предыдущие значения уже дают s, или взять его, значит, предыдущие значения дают s-num. Вот и всё правило: can[i][s] = can[i-1][s] or can[i-1][s-num], причём вторая часть учитывается только при s ≥ num. Каждая строка читает данные только из строки выше, поэтому каждое значение используется не более одного раза.
Для [6, 1, 4, 9, 2] при target 11 множество достижимых сумм растёт от {0} до {0, 6}, затем до {0, 1, 6, 7}, а затем до {0, 1, 4, 5, 6, 7, 10, 11}. Сумма 11 появляется после добавления 4 (6 + 1 + 4), и сохраняется в последующих строках. Ответ — can[n][target]. На каждую ячейку требуется постоянное количество операций, поэтому временная сложность и объём памяти составляют O(n × target).
Алгоритм
- Верните
falseдля нечётной суммы и установитеtargetравным половине этой суммы. - Создайте таблицу с n+1 строками и target+1 столбцами, заполненную значениями false, и установите
can[0][0]в true. - Для каждой строки
iот 1 до n возьмитеnum = nums[i-1]. - Для каждой суммы
sот 0 доtargetустановитеcan[i][s]равнымcan[i-1][s]или, еслиs ≥ num,can[i-1][s-num]. - Верните
can[n][target].
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
target = total // 2
n = len(nums)
# can[i][s] is True when some of the first i values add up to s
can = [[False] * (target + 1) for _ in range(n + 1)]
can[0][0] = True
for i in range(1, n + 1):
num = nums[i - 1]
for s in range(target + 1):
# Leave num out, or put it in and reach s - num with the values before it
can[i][s] = can[i - 1][s] or (s >= num and can[i - 1][s - num])
return can[n][target]Одна строка сумм, заполненная сверху вниз
Идея
Каждая строка таблицы считывает только строку над ней, поэтому достаточно одной строки, если обновлять её на месте: reach[s] показывает, дают ли некоторые из уже просмотренных значений в сумме s. Опасность заключается в порядке обновлений. Если перебирать s по возрастанию, значение reach[s-num] уже могло стать true из-за того же num. Для [3, 9] и целевого значения 6 число 3 отмечает reach[3], а затем считывает его и отмечает reach[6], как будто у тебя было две тройки, и алгоритм выдаёт true для несуществующего разбиения.
Перебирай s по убыванию, от target до num. Тогда s-num — меньший индекс, которого это значение ещё не затронуло, поэтому reach[s-num] по-прежнему хранит ответ, который был до появления num. Это в точности can[i-1][s-num] из таблицы, и одна строка выполняет работу всей таблицы.
Можно также остановиться, как только reach[target] станет true, потому что последующие значения только добавляют достижимые суммы и никогда не удаляют их. В худшем случае по-прежнему потребуется O(n × target) шагов, примерно 2 × 10^6, а объём памяти сократится до target + 1 булевых значений.
Алгоритм
- Верните
falseдля нечётной суммы и задайтеtargetзначение, равное её половине. - Создайте
reachсtarget + 1элементами, все со значением false, кромеreach[0]. - Для каждого значения
numпроходите поsотtargetдоnumи задавайтеreach[s]значение true, еслиreach[s-num]имеет значение true. - После каждого значения возвращайте
true, еслиreach[target]имеет значение true. - Если цикл завершится, верните
reach[target], значение которого равно false.
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
target = total // 2
# reach[s] is True when some of the values seen so far add up to s
reach = [False] * (target + 1)
reach[0] = True
for num in nums:
# Walk the sums downward so num is used at most once
for s in range(target, num - 1, -1):
if reach[s - num]:
reach[s] = True
if reach[target]:
return True
return reach[target]
Ловушки и крайние случаи
Неверные ответы возникают из-за доверия жадному правилу, пропуска проверки на нечётность и повторного использования значения в таблице с одной строкой.
- При переборе сумм по возрастанию в варианте с одной строкой одно и то же значение используется несколько раз. Для
[3, 9]целевая сумма равна 6, число 3 отмечает сначала сумму 3, а затем сумму 6, и вы отвечаете true. - Пропуск проверки на нечётность: для
[1, 2]сумма 3 округляется вниз до целевой суммы 1, значение 1 достигает её, и вы отвечаете true для разбиения, которого не может существовать. - Жадное заполнение, например сортировка и постоянное добавление к меньшей группе, не срабатывает для
[3, 3, 2, 2, 2]: в итоге получается 7 против 5, хотя 3 + 3 = 2 + 2 + 2. - Значение больше целевой суммы, как в
[2, 2, 2, 10]. Цикл с убыванием отtargetдоnumтогда выполняется ноль раз — это правильно, но диапазон вроде(num+1):(target+1)в R перебирается в обратном направлении и портит таблицу. Пропускайте такие значения. - Чётной суммы недостаточно: сумма
[4, 7, 2, 9, 6]равна 28, но разделить её на две равные части всё равно невозможно. - В массивах Lua и R индексация начинается с 1, поэтому запись для суммы
sнаходится по индексуs + 1.
Частые вопросы4
Почему задача о разбиении на равные подмножества является задачей о рюкзаке 0/1?
У вас есть рюкзак вместимостью target = total / 2, и его нужно заполнить точно, используя каждое значение не более одного раза. Взять значение или не брать его — это выбор 0/1, а размер значения равен самому значению. Таблица рюкзака с достижимыми суммами решает эту задачу за O(n × target).
Какова временная сложность задачи Partition Equal Subset Sum?
Табличный подход требует времени O(n × target), где target — половина общей суммы, и памяти O(target) при использовании одной строки. При 200 значениях не больше 100 это около 2 × 10^6 шагов. Оценка растёт с величиной значений, а не только с их количеством, поэтому её называют псевдополиномиальной: при значениях около 10^9 никакая таблица не поместится в память, а общая задача является NP-полной.
Почему внутренний цикл идёт от целевого значения вниз до значения?
Движение вниз означает, что reach[s-num] считывается до того, как это значение может его изменить, поэтому оно по-прежнему описывает значения до num. Движение вверх позволило бы снова расширить сумму, построенную с помощью num, на num, из-за чего одно значение учитывалось бы много раз. Цикл с движением вверх подходит для неограниченного количества копий, как в задаче Coin Change, но здесь он не подходит.
Можно ли решить задачу о разбиении на подмножества с равной суммой с помощью битового набора?
Да. Храни достижимые суммы в виде битов одного большого числа, начиная с установленного только бита 0. Для каждого значения bits |= bits << num одновременно прибавляет это значение ко всем достижимым суммам, а ответ определяется тем, установлен ли бит target. Это та же таблица, но каждое машинное слово обрабатывает сразу 64 суммы, поэтому на практике всё выполняется гораздо быстрее.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def canPartition(nums):
# Напишите код здесьСлучай 1
Случай 2
Случай 3
Ввод
nums = [6, 1, 4, 9, 2]
Ожидается
true