Partition Equal Subset Sum
Recibes un arreglo nums de enteros positivos. Decide si puedes dividir los valores en dos grupos cuyas sumas sean iguales. Cada valor va exactamente en un grupo, y un grupo puede tomar valores de cualquier posición. Devuelve true si existe una división así y false en caso contrario.
Función
- numsinteger-array
- los valores positivos que se deben dividir en dos grupos
- Devuelveboolean
- verdadero cuando los valores pueden formar dos grupos con sumas iguales; falso en caso contrario
Restricciones
1 ≤ nums.length ≤ 2001 ≤ nums[i] ≤ 100
Ejemplos
- Entrada
- nums = [6, 1, 4, 9, 2]
- Salida
- true
- Explicación
- El total es 22, así que cada grupo necesita 11. Los grupos 9 + 2 y 6 + 1 + 4 suman ambos 11, así que la respuesta es
true.
- Entrada
- nums = [4, 7, 2, 9, 6]
- Salida
- false
- Explicación
- El total es 28, así que cada grupo necesita 14. Al grupo que tiene 9 le faltan 5, y ninguna combinación de 4, 7, 2 y 6 suma 5, así que la respuesta es
falseaunque el total sea par.
- Entrada
- nums = [1, 2, 3, 5]
- Salida
- false
- Explicación
- El total es 11. Dos números enteros iguales siempre suman un número par, así que un total impar nunca se puede dividir en partes iguales y la respuesta es
false.
+18 pruebas ocultas al enviar
Para ir más allá
Cuando no existe una división en partes iguales, ¿puedes devolver la menor diferencia posible entre las sumas de los dos grupos?
Pistas
Ábrelas de una en una. Cada una revela un poco más.
Si los dos grupos tienen sumas iguales, ¿cuál debe ser cada suma en función del total de
nums? ¿Y qué te indica de inmediato un total impar?Solo necesitas encontrar un grupo cuya suma sea la mitad del total; los valores restantes forman el otro grupo. Piensa en el conjunto de sumas que pueden alcanzar los primeros valores y en cómo un valor más cambia ese conjunto.
Mantén un arreglo booleano
reach[0..target]con soloreach[0]en true. Para cada valornum, recorresdesdetargethacia abajo hastanumy marcareach[s]cuandoreach[s-num]esté marcado. Recorrer hacia abajo evita que cada valor se use dos veces.
Solución
Cada grupo debe contener exactamente la mitad del total, así que la verdadera pregunta es si algún subconjunto de nums suma target = total / 2. Probar todos los subconjuntos cuesta 2^n, lo cual es inviable para 200 valores. Sin embargo, las sumas son pequeñas: target es como máximo 200 × 100 / 2 = 10^4. Registrar qué sumas son alcanzables, un valor a la vez, convierte la búsqueda en una tabla de mochila 0/1 que se completa en O(n × sum) pasos.
Prueba cada subconjunto mediante recursión
Correcto, pero no termina con las pruebas más grandes
Intuición
Empieza con el total. Si es impar, no existe una división, porque dos números enteros iguales suman un número par. De lo contrario, cada grupo debe sumar exactamente target = total / 2. Cuando encuentres valores que sumen target, los valores que no elegiste formarán por sí solos la otra mitad. Así que basta con una pregunta: ¿hay algún subconjunto que sume target?
Recorre los valores en orden y toma una decisión para cada uno: ponlo en el primer grupo o déjalo para el segundo. Una función auxiliar reach(i, remaining) responde si los valores desde el índice i en adelante pueden sumar remaining. Devuelve true cuando remaining llega a 0, false cuando se queda sin valores o baja de 0 y, en los demás casos, prueba ambas opciones para nums[i].
Cada subconjunto corresponde a un camino de decisiones, así que la búsqueda no puede pasar por alto ninguna división y la respuesta es correcta. Es lenta porque hay 2^n caminos, y una entrada sin división obliga a probar casi todos. Toma 199 copias de 100 y un 98: el total es 19998, nunca se alcanza el objetivo 9999 y la búsqueda prueba todas las formas de elegir como máximo 99 de los cientos, alrededor de 4 × 10^59 caminos. Incluso 40 valores dan 2^40, alrededor de 10^12 caminos.
Algoritmo
- Suma los valores de
nums. Si el total es impar, devuelvefalse. - Establece
targeten la mitad del total. - Escribe
reach(i, remaining): devuelve true cuandoremaininges 0, y false cuandoisupera el último valor oremaininges menor que 0. - De lo contrario, devuelve
reach(i+1, remaining-nums[i])oreach(i+1, remaining): toma el valor o déjalo. - Devuelve
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)Completa una tabla con valores y suma
Intuición
La recursión plantea la misma pregunta una y otra vez. reach(i, remaining) depende únicamente de dos números: i, de 0 a n, y remaining, de 0 a target. Eso da como máximo (n+1) × (target+1) preguntas distintas, aproximadamente 201 × 10001 ≈ 2 × 10^6 en los límites, una cantidad lo bastante pequeña como para responderlas todas una vez.
Construye las respuestas hacia adelante en una tabla. can[i][s] indica si algunos de los primeros i valores suman s. Sin valores, solo es posible obtener la suma 0, así que la fila 0 es falsa excepto can[0][0]. El valor num = nums[i-1] ofrece dos maneras de alcanzar s: dejar fuera num, de modo que los valores anteriores ya sumen s, o incluirlo, de modo que los valores anteriores sumen s-num. Esa es toda la regla: can[i][s] = can[i-1][s] or can[i-1][s-num], donde la segunda parte solo cuenta cuando s ≥ num. Cada fila solo lee la fila anterior, así que cada valor se usa como máximo una vez.
Con [6, 1, 4, 9, 2] y el objetivo 11, las sumas alcanzables crecen de {0} a {0, 6}, luego a {0, 1, 6, 7} y después a {0, 1, 4, 5, 6, 7, 10, 11}. La suma 11 aparece después del 4 (6 + 1 + 4), y las filas posteriores la conservan. La respuesta es can[n][target]. Cada celda requiere un tiempo constante, así que tanto el tiempo como la memoria son O(n × target).
Algoritmo
- Devuelve
falsesi el total es impar y establecetargeten la mitad del total. - Crea una tabla con n+1 filas y target+1 columnas, todas con el valor false, y establece
can[0][0]en true. - Para cada fila
idesde 1 hasta n, tomanum = nums[i-1]. - Para cada suma
sdesde 0 hastatarget, establececan[i][s]encan[i-1][s]o, cuandos ≥ num, encan[i-1][s-num]. - Devuelve
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]Una fila de sumas, rellenada de arriba abajo
Intuición
Cada fila de la tabla solo lee la fila anterior, así que basta con una fila si la actualizas en el mismo sitio: reach[s] indica si algunos de los valores vistos hasta ahora suman s. El peligro está en el orden de las actualizaciones. Si recorres s en orden ascendente, es posible que reach[s-num] ya se haya activado con el mismo num. Con [3, 9] y un objetivo de 6, el 3 marca reach[3] y luego lo lee para marcar reach[6], como si tuvieras dos 3, y respondes que es verdadero para una división que no existe.
Recorre s en orden descendente, desde target hasta num. Entonces s-num es un índice menor que este valor aún no ha tocado, así que reach[s-num] todavía contiene la respuesta de antes de que llegara num. Eso es exactamente can[i-1][s-num] de la tabla, y la única fila hace el trabajo de toda la tabla.
También puedes detenerte en cuanto reach[target] se vuelva verdadero, porque los valores posteriores solo añaden sumas alcanzables y nunca eliminan ninguna. En el peor de los casos, siguen siendo O(n × target) pasos, unos 2 × 10^6, y la memoria se reduce a target + 1 valores booleanos.
Algoritmo
- Devuelve
falsesi el total es impar y establecetargeten la mitad de ese total. - Crea
reachcontarget + 1elementos, todos enfalseexceptoreach[0]. - Para cada valor
num, recorresdesdetargethastanumy establecereach[s]entruecuandoreach[s-num]seatrue. - Después de cada valor, devuelve
truesireach[target]estrue. - Si el bucle termina, devuelve
reach[target], que esfalse.
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]
Errores comunes y casos límite
Las respuestas incorrectas aquí se deben a confiar en una regla voraz, omitir la comprobación de paridad y reutilizar un valor en la tabla de una fila.
- Recorrer las sumas hacia arriba en la versión de una fila usa un valor más de una vez. Con
[3, 9], el objetivo es 6, el 3 marca la suma 3 y luego la suma 6, y respondes verdadero. - Omitir la comprobación de paridad: para
[1, 2], el total 3 se redondea hacia abajo a un objetivo de 1, el valor 1 lo alcanza y respondes verdadero para una partición que no puede existir. - El llenado voraz, como ordenar y siempre añadir al grupo más ligero, falla con
[3, 3, 2, 2, 2]: termina en 7 frente a 5, mientras que 3 + 3 = 2 + 2 + 2. - Un valor mayor que el objetivo, como en
[2, 2, 2, 10]. Un bucle descendente desdetargethastanumse ejecuta cero veces, lo cual es correcto, pero un rango como(num+1):(target+1)en R cuenta hacia atrás y rompe la tabla. Omite esos valores. - Un total par no es suficiente:
[4, 7, 2, 9, 6]suma 28 y aun así no tiene una partición. - En Lua y R, los arreglos empiezan en 1, así que la entrada para la suma
sestá en el índices + 1.
Preguntas frecuentes4
¿Por qué la partición en subconjuntos de suma igual es un problema de mochila 0/1?
Tienes una mochila de tamaño target = total / 2 y debes llenarla exactamente, usando cada valor como máximo una vez. Tomar un valor o dejarlo es la elección 0/1, y el tamaño de un valor es el propio valor. La tabla de sumas alcanzables de la mochila responde a esto en O(n × target) tiempo.
¿Cuál es la complejidad temporal de Partition Equal Subset Sum?
El enfoque de tabla tarda O(n × target), donde target es la mitad del total, y usa O(target) de memoria con una sola fila. Con 200 valores de como máximo 100, eso equivale a unos 2 × 10^6 pasos. El límite crece con el tamaño de los valores, no solo con su cantidad, por lo que se denomina seudopolinómico: con valores cercanos a 10^9 no cabría ninguna tabla, y el problema general es NP-completo.
¿Por qué el bucle interno va desde el objetivo hasta el valor?
Recorrer hacia abajo significa que se lee reach[s-num] antes de que este valor pueda modificarlo, así que sigue describiendo los valores anteriores a num. Recorrer hacia arriba permitiría extender de nuevo con num una suma construida con num, lo que cuenta un mismo valor muchas veces. El bucle ascendente es el adecuado para copias ilimitadas, como en Coin Change, y el incorrecto en este caso.
¿Se puede resolver Partition Equal Subset Sum con un conjunto de bits?
Sí. Almacena las sumas alcanzables como los bits de un único número grande, empezando con solo el bit 0 establecido. Para cada valor, bits |= bits << num añade ese valor a todas las sumas alcanzables a la vez, y la respuesta indica si el bit target está establecido. Es la misma tabla, pero cada palabra de máquina maneja 64 sumas a la vez, así que en la práctica se ejecuta mucho más rápido.
Problemas similares
Problemas que usan las mismas ideas. Resolver dos o tres es lo que fija un patrón.
Python
def canPartition(nums):
# Escribe el código aquíCaso 1
Caso 2
Caso 3
Entrada
nums = [6, 1, 4, 9, 2]
Esperado
true