Algoritmo húngaro para resolver el problema de asignación
Enunciado del problema de asignación
Hay varias formulaciones estándar del problema de asignación (assignment problem) (todas esencialmente equivalentes). Aquí hay algunas de ellas:
-
Hay trabajos y trabajadores. Cada trabajador especifica la cantidad de dinero que espera por un trabajo particular. Cada trabajador puede asignarse a solo un trabajo. El objetivo es asignar trabajos a trabajadores de forma que se minimice el costo total.
-
Dada una matriz , la tarea es seleccionar un número de cada fila de modo que se elija exactamente un número de cada columna, y la suma de los números seleccionados se minimice.
-
Dada una matriz , la tarea es encontrar una permutación de longitud tal que el valor se minimice.
-
Consideremos un grafo bipartito completo con vértices por parte, donde a cada arista se le asigna un peso. El objetivo es encontrar un matching perfecto con el peso total mínimo.
Es importante notar que todos los escenarios de arriba son problemas “cuadrados”, lo que significa que ambas dimensiones son siempre iguales a . En la práctica, a menudo se encuentran formulaciones “rectangulares” similares, donde no es igual a , y la tarea es seleccionar elementos. Sin embargo, se puede observar que un problema “rectangular” siempre se puede transformar en un problema “cuadrado” agregando filas o columnas con valores cero o infinitos, respectivamente.
También notamos que, por analogía con la búsqueda de una solución mínima, también se puede plantear el problema de encontrar una solución máxima. Sin embargo, estos dos problemas son equivalentes entre sí: basta con multiplicar todos los pesos por .
Algoritmo húngaro
Referencia histórica
El algoritmo fue desarrollado y publicado por Harold Kuhn en 1955. El propio Kuhn le dio el nombre “húngaro” porque se basaba en el trabajo anterior de los matemáticos húngaros Dénes Kőnig y Jenő Egerváry.
En 1957, James Munkres mostró que este algoritmo corre en tiempo polinómico (estrictamente), independiente del costo.
Por lo tanto, en la literatura, este algoritmo se conoce no solo como el “húngaro”, sino también como el “algoritmo de Kuhn-Munkres” o “algoritmo de Munkres”.
Sin embargo, recientemente se descubrió en 2006 que el mismo algoritmo fue inventado un siglo antes que Kuhn por el matemático alemán Carl Gustav Jacobi. Su trabajo, About the research of the order of a system of arbitrary ordinary differential equations, que se publicó de forma póstuma en 1890, contenía, entre otros hallazgos, un algoritmo polinómico para resolver el problema de asignación. Desafortunadamente, como la publicación estaba en latín, pasó desapercibida entre los matemáticos.
También vale la pena notar que el algoritmo original de Kuhn tenía una complejidad asintótica de , y solo más tarde Jack Edmonds y Richard Karp (e independientemente Tomizawa) mostraron cómo mejorarlo a una complejidad asintótica de .
El algoritmo
Para evitar ambigüedad, notamos de inmediato que nos ocupamos principalmente del problema de asignación en una formulación matricial (es decir, dada una matriz , hay que seleccionar celdas de ella que estén en distintas filas y columnas). Indexamos los arreglos empezando en , es decir, por ejemplo, una matriz tiene índices .
También asumiremos que todos los números de la matriz A son no negativos (si no es el caso, siempre se puede hacer la matriz no negativa sumando alguna constante a todos los números).
Llamemos potencial a dos arreglos arbitrarios de números y , tales que se cumple la siguiente condición:
(Como se puede ver, corresponde a la -ésima fila, y corresponde a la -ésima columna de la matriz).
Llamemos valor del potencial a la suma de sus elementos:
Por un lado, es fácil ver que el costo de la solución deseada no es menor que el valor de cualquier potencial.
Información
Lema.
Demostración
La solución deseada del problema consiste de celdas de la matriz , así que para cada una de ellas. Como todos los elementos de están en distintas filas y columnas, al sumar estas desigualdades sobre todos los seleccionados, se obtiene en el lado izquierdo de la desigualdad, y en el lado derecho.
Por otro lado, resulta que siempre hay una solución y un potencial que convierte esta desigualdad en igualdad. El algoritmo húngaro descrito abajo será una demostración constructiva de este hecho. Por ahora, solo prestemos atención al hecho de que si cualquier solución tiene un costo igual a cualquier potencial, entonces esta solución es óptima.
Fijemos algún potencial. Llamemos a una arista rígida si
Recordemos una formulación alternativa del problema de asignación, usando un grafo bipartito. Denotemos con un grafo bipartito compuesto solo de aristas rígidas. El algoritmo húngaro mantendrá, para el potencial actual, el matching de máximo número de aristas del grafo . En cuanto contenga aristas, entonces la solución al problema será simplemente (después de todo, será una solución cuyo costo coincide con el valor de un potencial).
Pasemos directamente a la descripción del algoritmo.
Paso 1. Al principio, se asume que el potencial es cero ( para todo ), y se asume que el matching está vacío.
Paso 2. Luego, en cada paso del algoritmo, intentamos, sin cambiar el potencial, aumentar la cardinalidad del matching actual en uno (recordemos que el matching se busca en el grafo de aristas rígidas ). Para ello se usa el algoritmo de Kuhn usual para encontrar el matching máximo en grafos bipartitos. Recordemos el algoritmo aquí. Todas las aristas del matching se orientan en la dirección de la parte derecha a la izquierda, y todas las demás aristas del grafo se orientan en la dirección opuesta.
Recordemos (de la terminología de búsqueda de matchings) que un vértice se llama saturado si una arista del matching actual es adyacente a él. Un vértice que no es adyacente a ninguna arista del matching actual se llama no saturado. Un camino de longitud impar, en el que la primera arista no pertenece al matching, y para todas las aristas posteriores hay una pertenencia alternada al matching (pertenece/no pertenece) se llama camino aumentante. Desde todos los vértices no saturados de la parte izquierda se inicia un recorrido en profundidad o en anchura. Si, como resultado de la búsqueda, se pudo alcanzar un vértice no saturado de la parte derecha, hemos encontrado un camino aumentante de la parte izquierda a la derecha. Si incluimos las aristas impares del camino y quitamos las pares en el matching (es decir, incluimos la primera arista en el matching, excluimos la segunda, incluimos la tercera, etc.), entonces aumentaremos la cardinalidad del matching en uno.
Si no había camino aumentante, entonces el matching actual es maximal en el grafo .
Paso 3. Si en el paso actual no es posible aumentar la cardinalidad del matching actual, entonces se realiza un recálculo del potencial de tal forma que, en los siguientes pasos, habrá más oportunidades de aumentar el matching.
Denotemos por el conjunto de vértices de la parte izquierda que se visitaron durante el último recorrido del algoritmo de Kuhn, y por el conjunto de vértices visitados de la parte derecha.
Calculemos el valor :
Información
Lema.
Demostración
Supongamos . Entonces existe una arista rígida con y . Se sigue que la arista debe estar orientada de la parte derecha a la izquierda, es decir, debe estar incluida en el matching . Sin embargo, esto es imposible, porque no podríamos llegar al vértice saturado excepto yendo a lo largo de la arista de j a i. Así que .
Ahora recalculemos el potencial de esta forma:
-
para todos los vértices , hacer ,
-
para todos los vértices , hacer .
Información
Lema. El potencial resultante sigue siendo un potencial correcto.
Demostración
Mostraremos que, después del recálculo, para todo . Para todos los elementos de con y , la suma no cambia, así que la desigualdad sigue siendo cierta. Para todos los elementos con y , la suma disminuye en , así que la desigualdad sigue siendo cierta. Para los demás elementos cuyos y , la suma aumenta, pero la desigualdad se preserva, ya que el valor es, por definición, el aumento máximo que no cambia la desigualdad.
Información
Lema. El matching viejo de aristas rígidas es válido, es decir, todas las aristas del matching seguirán siendo rígidas.
Demostración
Para que alguna arista rígida deje de ser rígida como resultado de un cambio de potencial, es necesario que la igualdad se convierta en la desigualdad . Sin embargo, esto solo puede ocurrir cuando y . Pero implica que la arista no podría ser una arista del matching.
Información
Lema. Después de cada recálculo del potencial, el número de vértices alcanzables por el recorrido, es decir, , aumenta de forma estricta.
Demostración
Primero, notemos que cualquier vértice que era alcanzable antes del recálculo sigue siendo alcanzable. En efecto, si algún vértice es alcanzable, entonces hay algún camino de vértices alcanzables hasta él, empezando desde el vértice no saturado de la parte izquierda; como para aristas de la forma la suma no cambia, todo este camino se preservará después de cambiar el potencial. Segundo, mostramos que después de un recálculo, al menos un vértice nuevo será alcanzable. Esto se sigue de la definición de : la arista a la que se refiere se volverá rígida, así que el vértice será alcanzable desde el vértice .
Por el último lema, no pueden ocurrir más de recálculos de potencial antes de que se encuentre un camino aumentante y se aumente la cardinalidad del matching de . Así, tarde o temprano, se encontrará un potencial que corresponde a un matching perfecto , y será la respuesta al problema. Si hablamos de la complejidad del algoritmo, entonces es : en total debe haber a lo sumo aumentos del matching, antes de cada uno de los cuales hay no más de recálculos de potencial, cada uno de los cuales se realiza en tiempo .
No daremos aquí la implementación del algoritmo , ya que no resultará más corta que la implementación del de , descrita abajo.
El algoritmo
Ahora aprendamos cómo implementar el mismo algoritmo en (para problemas rectangulares , ).
La idea clave es considerar las filas de la matriz una por una, y no todas a la vez. Así, el algoritmo descrito arriba tomará la siguiente forma:
-
Considerar la siguiente fila de la matriz .
-
Mientras no haya un camino aumentante que empiece en esta fila, recalcular el potencial.
-
En cuanto se encuentre un camino aumentante, propagar el matching a lo largo de él (incluyendo así la última arista en el matching), y reiniciar desde el paso 1 (para considerar la siguiente línea).
Para alcanzar la complejidad requerida, es necesario implementar los pasos 2-3, que se realizan para cada fila de la matriz, en tiempo (para problemas rectangulares en ).
Para ello, recordemos dos hechos demostrados arriba:
-
Con un cambio en el potencial, los vértices que eran alcanzables por el recorrido de Kuhn seguirán siendo alcanzables.
-
En total, solo podían ocurrir recálculos del potencial antes de que se encontrara un camino aumentante.
De esto se siguen estas ideas clave que nos permiten alcanzar la complejidad requerida:
-
Para comprobar la presencia de un camino aumentante, no hay necesidad de iniciar el recorrido de Kuhn otra vez después de cada recálculo de potencial. En su lugar, se puede hacer el recorrido de Kuhn en forma iterativa: después de cada recálculo del potencial, mirar las aristas rígidas agregadas y, si sus extremos izquierdos eran alcanzables, marcar sus extremos derechos como alcanzables también y continuar el recorrido desde ellos.
-
Desarrollando esta idea más, podemos presentar el algoritmo de la siguiente forma: en cada paso del bucle, se recalcula el potencial. Posteriormente, se identifica una columna que se ha vuelto alcanzable (que siempre existirá ya que emergen vértices alcanzables nuevos después de cada recálculo de potencial). Si la columna no está saturada, se descubre una cadena aumentante. Por el contrario, si la columna está saturada, la fila del matching también se vuelve alcanzable.
-
Para recalcular el potencial de forma rápida (más rápido que la versión naive ), hay que mantener mínimos auxiliares para cada una de las columnas:
Es fácil ver que el valor deseado se expresa en términos de ellos de la siguiente forma:
Así, encontrar ahora se puede hacer en .
Es necesario actualizar el arreglo cuando aparecen filas visitadas nuevas. Esto se puede hacer en para la fila agregada (lo que suma sobre todas las filas a ). También es necesario actualizar el arreglo al recalcular el potencial, lo que también se hace en tiempo ( cambia solo para las columnas que aún no se han alcanzado: a saber, disminuye en ).
Así, el algoritmo toma la siguiente forma: en el bucle externo, consideramos las filas de la matriz una por una. Cada fila se procesa en tiempo , ya que solo podían ocurrir recálculos de potencial (cada uno en tiempo ), y el arreglo se mantiene en tiempo ; el algoritmo de Kuhn funcionará en tiempo (ya que se presenta en forma de iteraciones, cada una de las cuales visita una columna nueva).
La complejidad resultante es o, si el problema es rectangular, .
Implementación del algoritmo húngaro
La implementación de abajo fue desarrollada por Andrey Lopatin hace varios años. Se distingue por una concisión asombrosa: todo el algoritmo consiste de 30 líneas de código.
La implementación encuentra una solución para la matriz rectangular , donde . La matriz es 1-based por conveniencia y brevedad del código: esta implementación introduce una fila cero ficticia y una columna cero, lo que nos permite escribir muchos ciclos de forma general, sin comprobaciones adicionales.
Los arreglos y guardan el potencial. Inicialmente, se ponen en cero, lo cual es consistente con una matriz de cero filas (Nótese que para esta implementación no es importante si la matriz contiene o no números negativos).
El arreglo contiene un matching: para cada columna , guarda el número de la fila seleccionada (o si aún no se ha seleccionado nada). Por conveniencia de la implementación, se asume que es igual al número de la fila actual.
El arreglo contiene, para cada columna , los mínimos auxiliares necesarios para un recálculo rápido del potencial, como se describió arriba.
El arreglo contiene información sobre dónde se alcanzan estos mínimos para que luego podamos reconstruir el camino aumentante. Nótese que, para reconstruir el camino, basta con guardar solo valores de columna, ya que los números de fila se pueden tomar del matching (es decir, del arreglo ). Así, , para cada columna , contiene el número de la columna anterior en el camino (o si no hay ninguna).
El algoritmo mismo es un bucle externo a través de las filas de la matriz, dentro del cual se considera la -ésima fila de la matriz. El primer bucle do-while corre hasta que se encuentra una columna libre . Cada iteración del bucle marca como visitada una columna nueva con el número (calculado en la última iteración; e inicialmente igual a cero, es decir, empezamos desde una columna ficticia), así como una fila nueva adyacente a ella en el matching (es decir, ; e inicialmente cuando se toma la -ésima fila). Debido a la aparición de una fila visitada nueva , hay que recalcular el arreglo y en consecuencia. Si se actualiza, entonces la columna se vuelve el mínimo que se ha alcanzado (nótese que con tal implementación podría resultar igual a cero, lo que significa que el potencial no se puede cambiar en el paso actual: ya hay una columna alcanzable nueva). Después de eso, se recalculan el potencial y el arreglo . Al final del bucle “do-while”, encontramos un camino aumentante que termina en una columna que se puede “desenrollar” usando el arreglo de ancestros .
La constante INF es “infinito”, es decir, algún número, obviamente mayor que todos los números posibles de la matriz de entrada .
vector<int> u (n+1), v (m+1), p (m+1), way (m+1);
for (int i=1; i<=n; ++i) {
p[0] = i;
int j0 = 0;
vector<int> minv (m+1, INF);
vector<bool> used (m+1, false);
do {
used[j0] = true;
int i0 = p[j0], delta = INF, j1;
for (int j=1; j<=m; ++j)
if (!used[j]) {
int cur = A[i0][j]-u[i0]-v[j];
if (cur < minv[j])
minv[j] = cur, way[j] = j0;
if (minv[j] < delta)
delta = minv[j], j1 = j;
}
for (int j=0; j<=m; ++j)
if (used[j])
u[p[j]] += delta, v[j] -= delta;
else
minv[j] -= delta;
j0 = j1;
} while (p[j0] != 0);
do {
int j1 = way[j0];
p[j0] = p[j1];
j0 = j1;
} while (j0);
}Para restaurar la respuesta en una forma más familiar, es decir, encontrar para cada fila el número de la columna seleccionada en ella, se puede hacer de la siguiente forma:
vector<int> ans (n+1);
for (int j=1; j<=m; ++j)
ans[p[j]] = j;El costo del matching se puede tomar simplemente como el potencial de la columna cero (tomado con el signo opuesto). En efecto, como se puede ver del código, contiene la suma de todos los valores de , es decir, el cambio total en el potencial. Aunque varios valores de y podrían cambiar a la vez, el cambio total en el potencial es exactamente igual a , ya que hasta que no hay un camino aumentante, el número de filas alcanzables es exactamente uno más que el número de las columnas alcanzables (solo la fila actual no tiene un “par” en forma de una columna visitada):
int cost = -v[0];Conexión con el algoritmo de caminos más cortos sucesivos
El algoritmo húngaro se puede ver como el algoritmo de caminos más cortos sucesivos (Successive Shortest Path Algorithm), adaptado para el problema de asignación. Sin entrar en los detalles, demos una intuición respecto de la conexión entre ellos.
El algoritmo de caminos sucesivos usa una versión modificada del algoritmo de Johnson como técnica de reponderación. Esta se divide en cuatro pasos:
- Usar el algoritmo de Bellman-Ford, empezando desde el sumidero y, para cada nodo, encontrar el peso mínimo de un camino de a .
Para cada paso del algoritmo principal:
- Reponderar las aristas del grafo original de esta forma: .
- Usar el algoritmo de Dijkstra para encontrar el subgrafo de caminos más cortos de la red original.
- Actualizar los potenciales para la siguiente iteración.
Dada esta descripción, podemos observar que hay una analogía fuerte entre y los potenciales: se puede comprobar que son iguales salvo un desplazamiento constante. Además, se puede mostrar que, después de reponderar, el conjunto de todas las aristas de peso cero representa el subgrafo de caminos más cortos donde el algoritmo principal intenta aumentar el flujo. Esto también ocurre en el algoritmo húngaro: creamos un subgrafo hecho de aristas rígidas (aquellas para las que la cantidad es cero), e intentamos aumentar el tamaño del matching.
En el paso 4, todos los se actualizan: cada vez que modificamos la red de flujo, debemos garantizar que las distancias desde la fuente son correctas (de lo contrario, en la siguiente iteración, el algoritmo de Dijkstra podría fallar). Esto suena como la actualización realizada sobre los potenciales, pero en este caso, no se incrementan de forma igual.
Para profundizar la comprensión de los potenciales, consultar este artículo .
Ejemplos de tareas
Aquí hay unos pocos ejemplos relacionados con el problema de asignación, desde tareas muy triviales hasta menos obvias:
-
Dado un grafo bipartito, se requiere encontrar en él el matching máximo con el peso mínimo (es decir, en primer lugar se maximiza el tamaño del matching, y en segundo lugar se minimiza su costo).
Para resolverlo, simplemente construimos un problema de asignación, poniendo el número “infinito” en lugar de las aristas que faltan. Después de eso, resolvemos el problema con el algoritmo húngaro, y quitamos las aristas de peso infinito de la respuesta (podrían entrar en la respuesta si el problema no tiene una solución en forma de matching perfecto). -
Dado un grafo bipartito, se requiere encontrar en él el matching máximo con el peso máximo.
La solución es otra vez obvia: todos los pesos deben multiplicarse por menos uno. -
La tarea de detectar objetos en movimiento en imágenes: se tomaron dos imágenes, como resultado de lo cual se obtuvieron dos conjuntos de coordenadas. Se requiere correlacionar los objetos de la primera y la segunda imagen, es decir, determinar para cada punto de la segunda imagen a qué punto de la primera imagen correspondía. En este caso, se requiere minimizar la suma de distancias entre los puntos comparados (es decir, buscamos una solución en la que los objetos han tomado el camino más corto en total).
Para resolverlo, simplemente construimos y resolvemos un problema de asignación, donde los pesos de las aristas son las distancias euclidianas entre puntos. -
La tarea de detectar objetos en movimiento por localizadores: hay dos localizadores que no pueden determinar la posición de un objeto en el espacio, sino solo su dirección. Ambos localizadores (ubicados en puntos distintos) recibieron información en forma de de tales direcciones. Se requiere determinar la posición de los objetos, es decir, determinar las posiciones esperadas de los objetos y sus pares correspondientes de direcciones de tal forma que se minimice la suma de distancias de los objetos a los rayos de dirección.
Solución: otra vez, simplemente construimos y resolvemos el problema de asignación, donde los vértices de la parte izquierda son las direcciones del primer localizador, los vértices de la parte derecha son las direcciones del segundo localizador, y los pesos de las aristas son las distancias entre los rayos correspondientes. -
Cubrir un grafo dirigido acíclico con caminos: dado un grafo dirigido acíclico, se requiere encontrar el menor número de caminos (si hay empate, con el menor peso total) de modo que cada vértice del grafo yazca en exactamente un camino.
La solución es construir el grafo bipartito correspondiente a partir del grafo dado y encontrar el matching máximo de peso mínimo en él. Véase un artículo separado para más detalles. -
Libro para colorear un árbol. Dado un árbol en el que cada vértice, excepto las hojas, tiene exactamente hijos. Se requiere elegir para cada vértice uno de los colores disponibles de modo que no haya dos vértices adyacentes con el mismo color. Además, para cada vértice y cada color se conoce el costo de pintar este vértice con este color, y se requiere minimizar el costo total.
Para resolver este problema, usamos programación dinámica. A saber, aprendamos a calcular el valor , donde es el número de vértice, es el número de color, y el valor mismo es el costo mínimo necesario para colorear todos los vértices del subárbol enraizado en , y el vértice mismo con color . Para calcular tal valor , es necesario distribuir los colores restantes entre los hijos del vértice , y para ello es necesario construir y resolver el problema de asignación (en el que los vértices de la parte izquierda son colores, los vértices de la parte derecha son hijos, y los pesos de las aristas son los valores correspondientes de ).
Así, cada valor se calcula usando la solución del problema de asignación, lo que al final da la asintótica . -
Si, en el problema de asignación, los pesos no están en las aristas, sino en los vértices, y solo en los vértices de la misma parte, entonces no es necesario usar el algoritmo húngaro: solo hay que ordenar los vértices por peso y ejecutar el algoritmo de Kuhn usual (para más detalles, véase un artículo separado ).
-
Consideremos el siguiente caso especial. Sea que a cada vértice de la parte izquierda se le asigna algún número , y a cada vértice de la parte derecha . Sea el peso de cualquier arista igual a (los números y son conocidos). Resolver el problema de asignación.
Para resolverlo sin el algoritmo húngaro, primero consideramos el caso cuando ambas partes tienen dos vértices. En este caso, como se puede ver fácilmente, es mejor conectar los vértices en el orden inverso: conectar el vértice con el menor al vértice con el mayor . Esta regla se puede generalizar fácilmente a un número arbitrario de vértices: hay que ordenar los vértices de la primera parte en orden creciente de los valores , la segunda parte en orden decreciente de los valores , y conectar los vértices en pares en ese orden. Así, obtenemos una solución con complejidad . -
El problema de los potenciales. Dada una matriz , se requiere encontrar dos arreglos y tales que, para cualquier y , y la suma de elementos de los arreglos y sea máxima.
Conociendo el algoritmo húngaro, la solución a este problema no será difícil: el algoritmo húngaro justamente encuentra tal potencial que satisface la condición del problema. Por otro lado, sin conocimiento del algoritmo húngaro, parece casi imposible resolver tal problema.
Observación
Esta tarea también se llama el problema dual del problema de asignación: minimizar el costo total de la asignación es equivalente a maximizar la suma de los potenciales.
Literatura
-
Ravindra Ahuja, Thomas Magnanti, James Orlin. Network Flows [1993]
-
Harold Kuhn. The Hungarian Method for the Assignment Problem [1955]
-
James Munkres. Algorithms for Assignment and Transportation Problems [1957]