Partition Equal Subset Sum
Você recebe um array nums de números inteiros positivos. Determine se é possível dividir os valores em dois grupos cujas somas sejam iguais. Cada valor deve pertencer a exatamente um grupo, e um grupo pode receber valores de quaisquer posições. Retorne true se essa divisão for possível e false caso contrário.
Função
- numsinteger-array
- os valores positivos para dividir em dois grupos
- Retornaboolean
- verdadeiro quando os valores podem formar dois grupos com somas iguais, falso caso contrário
Restrições
1 ≤ nums.length ≤ 2001 ≤ nums[i] ≤ 100
Exemplos
- Entrada
- nums = [6, 1, 4, 9, 2]
- Saída
- true
- Explicação
- O total é 22, então cada grupo precisa de 11. Os grupos 9 + 2 e 6 + 1 + 4 somam 11, então a resposta é
true.
- Entrada
- nums = [4, 7, 2, 9, 6]
- Saída
- false
- Explicação
- O total é 28, então cada grupo precisa de 14. O grupo que tem 9 precisa de mais 5, e nenhuma combinação de 4, 7, 2 e 6 resulta em 5, então a resposta é
false, embora o total seja par.
- Entrada
- nums = [1, 2, 3, 5]
- Saída
- false
- Explicação
- O total é 11. Dois números inteiros iguais sempre somam um número par, então um total ímpar nunca pode ser dividido igualmente e a resposta é
false.
+18 testes ocultos ao enviar
Para ir além
Quando não houver uma divisão igual, você consegue retornar a menor diferença possível entre as somas dos dois grupos?
Dicas
Abra uma de cada vez. Cada uma revela um pouco mais.
Se os dois grupos tiverem somas iguais, qual deve ser o valor de cada soma em relação ao total de
nums? E o que um total ímpar indica imediatamente?Você só precisa encontrar um grupo cuja soma seja metade do total; os valores restantes formam o outro grupo. Pense no conjunto de somas que os primeiros valores podem alcançar e em como mais um valor altera esse conjunto.
Mantenha um array booleano
reach[0..target]com apenasreach[0]verdadeiro. Para cada valornum, percorrasdetargetaténume marquereach[s]quandoreach[s-num]estiver marcado. Percorrer em ordem decrescente impede que cada valor seja usado duas vezes.
Solução
Cada grupo deve conter exatamente metade do total, então a verdadeira questão é se algum subconjunto de nums soma target = total / 2. Tentar todos os subconjuntos custa 2^n, o que é inviável para 200 valores. As somas em si são pequenas, porém: target é no máximo 200 × 100 / 2 = 10^4. Registrar quais somas são alcançáveis, um valor por vez, transforma a busca em uma tabela de mochila 0/1 que é preenchida em O(n × sum) etapas.
Experimente cada subconjunto usando recursão
Correta, mas não termina nos maiores testes
Intuição
Comece pelo total. Se ele for ímpar, não existe divisão, porque a soma de dois números inteiros iguais é sempre par. Caso contrário, cada grupo deve somar exatamente target = total / 2. Depois que você encontrar valores que somem target, os valores que não escolheu formam a outra metade por conta própria. Portanto, basta responder a uma pergunta: existe algum subconjunto que some target?
Percorra os valores em ordem e faça uma escolha para cada um: coloque-o no primeiro grupo ou deixe-o para o segundo. Uma função auxiliar reach(i, remaining) responde se os valores a partir do índice i conseguem somar remaining. Ela retorna true quando remaining chega a 0, false quando não há mais valores ou quando o valor fica abaixo de 0 e, caso contrário, tenta as duas opções para nums[i].
Cada subconjunto corresponde a um caminho de escolhas, então a busca não pode deixar passar nenhuma divisão, e a resposta está correta. Ela é lenta porque há 2^n caminhos, e uma entrada sem divisão faz com que ela tente quase todos eles. Considere 199 cópias de 100 e uma de 98: o total é 19998, o alvo 9999 nunca é atingido, e a busca tenta todas as maneiras de escolher no máximo 99 dos valores 100, cerca de 4 × 10^59 caminhos. Até mesmo 40 valores resultam em 2^40, cerca de 10^12 caminhos.
Algoritmo
- Some error
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)Preencha uma tabela por valor e soma
Intuição
A recursão faz a mesma pergunta várias vezes. reach(i, remaining) depende apenas de dois números: i, de 0 a n, e remaining, de 0 a target. Isso resulta em, no máximo, (n+1) × (target+1) perguntas diferentes, cerca de 201 × 10001 ≈ 2 × 10^6 nos limites, poucas o suficiente para responder a cada uma delas uma única vez.
Construa as respostas em uma tabela, avançando. can[i][s] indica se alguns dos primeiros i valores somam s. Sem valores, apenas a soma 0 é possível, então a linha 0 é falsa, exceto can[0][0]. O valor num = nums[i-1] oferece duas maneiras de chegar a s: deixar num de fora, então os valores anteriores já somam s, ou incluí-lo, então os valores anteriores somam s-num. Essa é toda a regra: can[i][s] = can[i-1][s] or can[i-1][s-num], em que a segunda parte só conta quando s ≥ num. Cada linha lê apenas a linha acima, então cada valor é usado no máximo uma vez.
Em [6, 1, 4, 9, 2] com alvo 11, as somas alcançáveis aumentam de {0} para {0, 6}, depois para {0, 1, 6, 7} e, então, para {0, 1, 4, 5, 6, 7, 10, 11}. A soma 11 aparece após o 4 (6 + 1 + 4), e as linhas posteriores a mantêm. A resposta é can[n][target]. Cada célula exige trabalho constante, então tanto o tempo quanto a memória são O(n × target).
Algoritmo
- Retorne
falsepara um total ímpar e definatargetcomo a metade dele. - Crie uma tabela com n+1 linhas e target+1 colunas, todas com false, e defina
can[0][0]como true. - Para cada linha
ide 1 a n, usenum = nums[i-1]. - Para cada soma
sde 0 atarget, definacan[i][s]comocan[i-1][s]ou, quandos ≥ num,can[i-1][s-num]. - Retorne
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]Uma linha de somas, preenchida de cima para baixo
Intuição
Cada linha da tabela lê apenas a linha acima dela, então uma única linha basta se você a atualizar no próprio lugar: reach[s] indica se alguns dos valores vistos até agora somam s. O perigo está na ordem das atualizações. Se você percorrer s em ordem crescente, reach[s-num] talvez já tenha sido ativado pelo mesmo num. Com [3, 9] e alvo 6, o 3 marca reach[3] e depois lê esse valor para marcar reach[6], como se você tivesse dois 3s, e a resposta será verdadeira para uma divisão que não existe.
Percorra s em ordem decrescente, de target até num. Assim, s-num é um índice menor que esse valor ainda não alterou, então reach[s-num] ainda contém a resposta de antes de num chegar. Isso é exatamente can[i-1][s-num] da tabela, e a única linha dá conta do trabalho da tabela inteira.
Você também pode parar assim que reach[target] se tornar verdadeiro, porque os valores posteriores apenas acrescentam somas alcançáveis e nunca removem nenhuma. No pior caso, ainda são O(n × target) etapas, cerca de 2 × 10^6, e a memória cai para target + 1 valores booleanos.
Algoritmo
- Retorne
falsese o total for ímpar e definatargetcomo a metade dele. - Crie
reachcomtarget + 1entradas, todas falsas, excetoreach[0]. - Para cada valor
num, percorrasdetargetaténume definareach[s]como true quandoreach[s-num]for true. - Após cada valor, retorne
truesereach[target]for true. - Se o loop terminar, retorne
reach[target], que é 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]
Armadilhas e casos extremos
Respostas erradas aqui vêm de confiar em uma regra gulosa, pular a verificação de paridade e reutilizar um valor na tabela de uma linha.
- Percorrer as somas em ordem crescente na versão de uma linha usa um valor mais de uma vez. Com
[3, 9], o alvo é 6, o 3 marca a soma 3 e depois a soma 6, e você responde verdadeiro. - Pular a verificação de paridade: para
[1, 2], o total 3 é arredondado para baixo até um alvo de 1, o valor 1 o alcança, e você responde verdadeiro para uma divisão que não pode existir. - Preenchimento guloso, como ordenar e sempre adicionar ao grupo mais leve, falha em
[3, 3, 2, 2, 2]: termina em 7 contra 5, enquanto 3 + 3 = 2 + 2 + 2. - Um valor maior que o alvo, como em
[2, 2, 2, 10]. Um loop decrescente detargetaténumentão é executado zero vezes, o que está correto, mas um intervalo como(num+1):(target+1)em R conta para trás e quebra a tabela. Ignore esses valores. - Um total par não é suficiente:
[4, 7, 2, 9, 6]soma 28 e ainda assim não tem divisão. - Em Lua e R, os arrays começam em 1, então a entrada para a soma
sfica no índices + 1.
Perguntas frequentes4
Por que o problema Partition Equal Subset Sum é um problema da mochila 0/1?
Você tem uma mochila de tamanho target = total / 2 e deve enchê-la exatamente, usando cada valor no máximo uma vez. Pegar um valor ou deixá-lo de fora é a escolha 0/1, e o tamanho de um valor é o próprio valor. A tabela da mochila com as somas alcançáveis responde a isso em O(n × target) tempo.
Qual é a complexidade de tempo de Partition Equal Subset Sum?
A abordagem com tabela leva tempo O(n × target), em que target é metade do total, e usa O(target) de memória com uma linha. Com 200 valores de no máximo 100, isso dá cerca de 2 × 10^6 etapas. O limite cresce com o tamanho dos valores, não apenas com sua quantidade, por isso é chamado de pseudopolinomial: com valores próximos de 10^9, nenhuma tabela caberia, e o problema geral é NP-completo.
Por que o loop interno vai do alvo até o valor?
Percorrer em ordem decrescente significa que reach[s-num] é lido antes que esse valor possa alterá-lo, então ele ainda descreve os valores antes de num. Percorrer em ordem crescente permitiria que uma soma formada com num fosse ampliada com num novamente, contando um mesmo valor várias vezes. O loop em ordem crescente é o correto para cópias ilimitadas, como no Coin Change, e o incorreto neste caso.
É possível resolver Partition Equal Subset Sum com um conjunto de bits?
Sim. Armazene as somas alcançáveis como os bits de um único número grande, começando com apenas o bit 0 definido. Para cada valor, bits |= bits << num adiciona esse valor a todas as somas alcançáveis de uma só vez, e a resposta é se o bit target está definido. É a mesma tabela, mas cada palavra da máquina processa 64 somas de cada vez, então, na prática, é muito mais rápido.
Problemas parecidos
Problemas que usam as mesmas ideias. Resolver dois ou três é o que fixa um padrão.
Python
def canPartition(nums):
# Escreva o código aquiCaso 1
Caso 2
Caso 3
Entrada
nums = [6, 1, 4, 9, 2]
Esperado
true