Aquí hay un problema: Tu empresa asigna a los contratistas para cumplir los contratos. Miras tus listas y decides qué contratistas están disponibles para un contrato de un mes y revisas tus contratos disponibles para ver cuáles son para tareas de un mes de duración. Dado que sabes con qué eficacia cada contratista puede cumplir cada contrato, ¿cómo asignar a los contratistas para maximizar la eficacia general para ese mes?
Este es un ejemplo del problema de asignación, y el problema se puede resolver con el clásico algoritmo húngaro.
El algoritmo húngaro (también conocido como el algoritmo de Kuhn-Munkres) es un algoritmo de tiempo polinomico, el cual maximiza la ponderación de peso en un gráfico bipartito ponderado. Aquí, los contratistas y los contratos pueden ser modelados como un gráfico bipartito, con su efectividad como el peso de los bordes entre el contratista y los nodos de contrato.
En este artículo, aprenderás sobre una implementación del algoritmo húngaro que utiliza el algoritmo Edmonds-Karp para resolver el problema de asignación lineal. También aprenderás cómo el algoritmo de Edmonds-Karp es una ligera modificación del Ford-Fulkerson y la importancia de esta modificación.
El Problema de Flujo Máximo
El problema de flujo máximo en sí mismo se puede describir informalmente como el problema de mover algo de fluido o gas, a través de una red de tuberías desde una única fuente hasta un único terminal. Esto se hace con la suposición de que la presión en la red es suficiente para asegurar que el fluido o gas no pueda permanecer en cualquier longitud de tubería o instalación de tuberías (los lugares donde se reúnen diferentes longitudes de tubería).
Al realizar ciertos cambios en el gráfico, el problema de asignación puede convertirse en un problema de flujo máximo.
Preliminares
Las ideas necesarias para resolver estos problemas surgen en muchas disciplinas matemáticas y de ingeniería, a menudo conceptos similares son conocidos por diferentes nombres y expresados de diferentes maneras (por ejemplo, matrices de adyacencia y listas de adyacencia). Dado que estas ideas son bastante esotéricas, se hacen elecciones sobre la forma en que estos conceptos se definirán generalmente para cualquier entorno determinado.
Este artículo no asumirá ningún conocimiento previo más allá de una pequeña teoría introductoria de conjuntos.
Las implementaciones en este post representan los problemas como grafos dirigidos (dígrafo).
Digrafo
Un digrafo tiene dos atributos, setOfNodes y setOfArcs. Ambos de estos atributos son conjuntos(colecciones desordenadas). En los bloques de código en este post, estoy usando Python frozenset, pero ese detalle no es particularmente importante.
DiGraph = namedtuple('DiGraph', ['setOfNodes','setOfArcs'])
(Nota: Este y todos los demás códigos de este artículo están escritos en Python 3.6.)
Nodos
Un nodo n
está compuesto de dos atributos:
n.uid
: Un identificador único.Esto significa eso para cualquier de los dos nodosx
yy
,
x.uid != y.uid
n.datum
: Esto representa cualquier objeto de datos.
Node = namedtuple('Node', ['uid','datum'])
Arco
Un arco a
está compuesto de tres atributos:
a.fromNode
: Éste es un nodo, como lo definimos arriba.a.toNode
: Éste es un nodo, como lo definimos arriba.a.datum
: Este representa cualquier objeto de data.
Arc = namedtuple('Arc', ['fromNode','toNode','datum'])
El set de arcos en el dígrafo representa una relación binaria en los nodos en el dígrafo. La existencia del arco a
implica que existe una relación entre a.fromNode
y a.toNode
.
En un gráfico dirigido (en oposición a un gráfico no dirigido), la existencia de una relación entre a.fromNode
y a.toNode
no implican que una relación similar entre a.toNode
y a.fromNode
existe.
Esto se debe a que en un gráfico no dirigido, la relación que se expresa no es necesariamente simétrica.
DíGrafos
Nodos y arcos pueden usarse para definir un dígrafo, pero por conveniencia, en los algoritmos siguientes, se representará un dígrafo usando como un diccionario.
Aquí hay un método que puede convertir la representación gráfica anterior en una representación de diccionario similar a ésta:
def digraph_to_dict(G):
G_as_dict = dict([])
for a in [/fusion_text][fusion_text]G.setOfArcs[/fusion_text][/fusion_builder_column][/fusion_builder_row][fusion_builder_row][/fusion_builder_row][/fusion_builder_container][/fusion_builder_row][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][fusion_builder_row][fusion_builder_column type="1_1" layout="1_1" background_position="left top" background_color="" border_size="" border_color="" border_style="solid" border_position="all" spacing="yes" background_image="" background_repeat="no-repeat" padding="" margin_top="0px" margin_bottom="0px" class="" id="" animation_type="" animation_speed="0.3" animation_direction="left" hide_on_mobile="small-visibility,medium-visibility,large-visibility" center_content="no" last="no" min_height="" hover_type="none" link=""][fusion_text]G.setOfNodes[/fusion_text][/fusion_builder_column][/fusion_builder_row][fusion_builder_row][/fusion_builder_row][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][fusion_builder_row][fusion_builder_column type="1_1" layout="1_1" background_position="left top" background_color="" border_size="" border_color="" border_style="solid" border_position="all" spacing="yes" background_image="" background_repeat="no-repeat" padding="" margin_top="0px" margin_bottom="0px" class="" id="" animation_type="" animation_speed="0.3" animation_direction="left" hide_on_mobile="small-visibility,medium-visibility,large-visibility" center_content="no" last="no" min_height="" hover_type="none" link=""][fusion_text]+[/fusion_text][/fusion_builder_column][/fusion_builder_row][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][fusion_builder_row][fusion_builder_column type="1_1" layout="1_1" background_position="left top" background_color="" border_size="" border_color="" border_style="solid" border_position="all" spacing="yes" background_image="" background_repeat="no-repeat" padding="" margin_top="0px" margin_bottom="0px" class="" id="" animation_type="" animation_speed="0.3" animation_direction="left" hide_on_mobile="small-visibility,medium-visibility,large-visibility" center_content="no" last="no" min_height="" hover_type="none" link=""][fusion_text]= frozenset({get['arcs'][from_node][to_node][arc]})
G = DiGraph(nodes, arcs)
mfps = MaxFlowProblemState(G, sourceNodeUid=sid, terminalNodeUid=tid, maxFlowProblemStateUid=mfps.maxFlowProblemStateUid)
return mfps
He aquí una visualización de cómo este algoritmo resuelve el ejemplo red de flujo desde arriba. La visualización muestra los pasos que se reflejan en el digrafo G
que representa la red de flujo más actualizada y como se reflejan en el gráfico residual de esa red de flujo . Los caminos de aumento en el gráfico residual se muestran como rutas rojas, y el digrafo que representa el problema el conjunto de nodos y arcos afectados por un dado la ruta de aumento se resalta en verde. En cada caso, resaltaré las partes del gráfico que se cambiarán (en rojo o verde) y luego mostraré el gráfico después de los cambios (sólo en negro).
Aquí hay otra visualización de cómo este algoritmo resolviendo un ejemplo diferente red de flujo. Observe que éste usa números reales y contiene varios arcos con los mismos valores fromNode
ytoNode
.
Observa también que debido a que los Arcos con un ‘pull’ ResidualDatum pueden ser parte de la Vía en Aumento, los nodos afectados en el DíGrafo de la Red de Flujo_no pueden estar en un camino en G!
.
Gráficos Bipartitos
Supongamos que tenemos un digrafo G
, G
es bipartito si es posible particionar los nodos en G.setOfNodes
en dos conjuntos (part_1
and part_2
) tal que para cualquier arco a
en G.setOfArcs
no puede ser verdad que a.fromNode
en part_1
y a.toNode
en part_1
. Tampoco puede ser verdad que a.fromNode
en part_2
y a.toNode
en part_2
.
En otras palabras, G
es bi-partición si puede dividirse en dos conjuntos de nodos de tal manera que cada arco debe conectar un nodo en un conjunto a un nodo en el otro conjunto.
Pruebas Bipartitas
Supongamos que tenemos un digrafo G
, queremos probar si es bi-partición. Podemos hacer esto en O([/fusion_text][/fusion_builder_column][/fusion_builder_row][/fusion_builder_container][fusion_builder_container hundred_percent="no" equal_height_columns="no" menu_anchor="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" class="" id="" background_color="" background_image="" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" parallax_speed="0.3" video_mp4="" video_webm="" video_ogv="" video_url="" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" overlay_color="" video_preview_image="" border_size="" border_color="" border_style="solid" padding_top="" padding_bottom="" padding_left="" padding_right=""][fusion_builder_row][fusion_builder_column type="1_1" layout="1_1" background_position="left top" background_color="" border_size="" border_color="" border_style="solid" border_position="all" spacing="yes" background_image="" background_repeat="no-repeat" padding="" margin_top="0px" margin_bottom="0px" class="" id="" animation_type="" animation_speed="0.3" animation_direction="left" hide_on_mobile="small-visibility,medium-visibility,large-visibility" center_content="no" last="no" min_height="" hover_type="none" link=""][fusion_text])
por codicioso color de la gráfica en dos colores.
Primero, necesitamos generar un nuevo digrafo H
. Este gráfico tendrá tendrá el mismo conjunto de nodos como G
, pero tendrá más arcos queG
. Cada arco a
enG
creará 2 arcos en H
; el primer arcoserá idéntico a a
, y el segundo arco invierte al director de a
( b = Arc(a.toNode,a.fromNode,a.datum)
).
Bipartition = coll.namedtuple('Bipartition',['firstPart', 'secondPart', 'G'])
def bipartition(G):
nodes = frozenset(G.setOfNodes
arcs = frozenset(G.setOfArcs)
arcs = arcs.union( frozenset( {Arc(a.toNode,a.fromNode,a.datum) for a in G.setOfArcs} ) )
H = DiGraph(nodes, arcs)
H_as_dict = digraph_to_dict(H)
color = dict([])
some_node = next(iter(H.setOfNodes))
deq = coll.deque([some_node])
color[some_node] = -1
while len(deq) > 0:
curr = deq.popleft()
for a in H_as_dict.get(curr):
if (a.toNode not in color):
color[a.toNode] = -1*color[curr]
deq.extend([a.toNode])
elif(color[curr] == color[a.toNode]):
print(curr,a.toNode)
raise Exception('Not Bipartite.')
firstPart = frozenset( {n for n in color if color[n] == -1 } )
secondPart = frozenset( {n for n in color if color[n] == 1 } )
if( firstPart.union(secondPart) != G.setOfNodes ):
raise Exception('Not Bipartite.')
return Bipartition(firstPart, secondPart, G)
Coincidencias y Coincidencias Máximas
Supongamos que tenemos un digrafo G
ymatching
es un subconjunto de arcos de G.setOfArcs
. matching
es un matching si para dos digrafo a
y b
en matching
: len(frozenset( {a.fromNode} ).union( {a.toNode} ).union( {b.fromNode} ).union( {b.toNode} )) == 4
. En otras palabras, ningún arco en una coincidencia comparte un nodo.
La coincidencia matching
, es una coincidencia máxima si no hay ninguna otra concordancia alt_matching
en G
tanto que len(matching) < len(alt_matching)
. En otras palabras, matching
es una coincidencia máxima Si es el set más largo de arcos en G.setOfArcs
que todavía satisface la definición de combinación (la adición de cualquier arco que no esté ya en la coincidencia romperá la combinacióndefinición).
Una combinación máxima matching
es una combinación perfecta si para cada nodo n
en G.setOfArcs
existe un arco a
en matching
donde a.fromNode == n or a.toNode == n
.
Combinación Bi-Particionada Máxima
Una combinación bi-particionada máxima es una combinación máxima en un digrafo G
que es bi-particionado.
Dado que G
es bi-particionado, el problema de encontrar una combinación máxima bipartita se puede transformar en un problema de flujo máximo solucionable con el algoritmo de Edmonds-Karp y entonces el acoplamiento bipartito máximo puede ser recuperado del solución al problema de flujo máximo.
Deja que bipartition
sea una bi-partición de G
.
Para ello, necesito generar un nuevo digrafo (H
) con algunos nuevos nodos (H.setOfNodes
) y algunos arcos nuevos (H.setOfArcs
). H.setOfNodes
contiene todos los nodos en G.setOfNodes
y dos nodosmás, s
(un nodo fuente) y t
(un nodo terminal).
H.setOfArcs
contendrá un arco por cada G.setOfArcs
. Si un arco a
es un G.setOfArcs
y a.fromNode
está en bipartition.firstPart
y a.toNode
está en bipartition.secondPart
y luego incluye a
en H
(agregando un FlowArcDatum(1,0)
).
Si a.fromNode
es una bipartition.secondPart
y a.toNode
es una bipartition.firstPart
, luego incluye Arc(a.toNode,a.fromNode,FlowArcDatum(1,0))
en H.setOfArcs
.
La definición de un gráfico bi-particionado asegura que ningún arco conecte cualquier nodo donde ambos nodos están en la misma partición. H.setOfArcs
también contiene un arco de nodo s
a cada nodo enbipartition.firstPart
. Por último, H.setOfArcs
contiene un arco cada node enbipartition.secondPart
un nodo t
. a.datum.capacity = 1
por todas las a
en H.setOfArcs
.
Primera partición de los nodos en G.setOfNodes
los dos sets (part1
and part2
) separados tanto que ningún arco en G.setOfArcs
se dirige desde un conjunto al mismo conjunto (esta partición es posible porque G
está bi-particionado). A continuación, agregue todos los arcos en G.setOfArcs
los cuales están dirigidos de la part1
a la part2
hacia H.setOfArcs
. A continuación, cree un único nodo fuente s
y un único nodo terminalt
y cree más arcos
Luego, construye un maxFlowProblemState
.
def solve_mbm( bipartition ):
s = Node(uuid.uuid4(), FlowNodeDatum(0,0))
t = Node(uuid.uuid4(), FlowNodeDatum(0,0))
translate = {}
arcs = frozenset([])
for a in bipartition.G.setOfArcs:
if ( (a.fromNode in bipartition.firstPart) and (a.toNode in bipartition.secondPart) ):
fark = Arc(a.fromNode,a.toNode,FlowArcDatum(1,0))
arcs = arcs.union({fark})
translate[frozenset({a.fromNode.uid,a.toNode.uid})] = a
elif ( (a.toNode in bipartition.firstPart) and (a.fromNode in bipartition.secondPart) ):
bark = Arc(a.toNode,a.fromNode,FlowArcDatum(1,0))
arcs = arcs.union({bark})
translate[frozenset({a.fromNode.uid,a.toNode.uid})] = a
arcs1 = frozenset( {Arc(s,n,FlowArcDatum(1,0)) for n in bipartition.firstPart } )
arcs2 = frozenset( {Arc(n,t,FlowArcDatum(1,0)) for n in bipartition.secondPart } )
arcs = arcs.union(arcs1.union(arcs2))
nodes = frozenset( {Node(n.uid,FlowNodeDatum(0,0)) for n in bipartition.G.setOfNodes} ).union({s}).union({t})
G = DiGraph(nodes, arcs)
mfp = MaxFlowProblemState(G, s.uid, t.uid, 'bipartite')
result = edmonds_karp(mfp)
lookup_set = {a for a in result.G.setOfArcs if (a.datum.flow > 0) and (a.fromNode.uid != s.uid) and (a.toNode.uid != t.uid)}
matching = {translate[frozenset({a.fromNode.uid,a.toNode.uid})] for a in lookup_set}
return matching
Cubierta Mínima de Nodo
Una cubierta de nodo en un dígrafo G
es un conjunto de nodos (cover
) proveniente de G.setOfNodes
tanto que para cualquier arco a
de G.setOfArcs
esto debe ser verdad: (a.fromNode en cubierta) o (a.toNode en cubierta)
.
Una cubierta de nodo mínima es el set más pequeño de nodos en la grífica que sigue siendo una cubierta de nodo. El teorema de König indica que en un gráfico bi-particionado, el tamaño de la coincidencia máxima en ese gráfico es igual al tamaño de la cubierta de nodo mínima, y sugiere cómo la cubierta del nodo puede recuperarse de una coincidencia máxima:
Supongamos que tenemos la bi-partición bipartition
y la coincidencia máxima matching
. Define un nuevo digrafo H
, H.setOfNodes=G.setOfNodes
, los arcos en H.setOfArcs
son la unión de dos sets.
El primer set de arcos a
en matching
, con el cambio que si a.fromNode in bipartition.firstPart
y a.toNode en bipartition.secondPart
entonces a.fromNode
y a.toNode
son intercambiados en el acro creado le dan a tales arcos un atributo a.datum.inMatching = True
para indicar que se derivaron de arcos en una coincidencia.
El segundo conjunto es arcos a
NO enmatching
, con el cambio que si a.fromNode en bipartition.secondPart
ya.toNode en bipartition.firstPart
entonces a.fromNode
ya.toNode
se intercambian en el arco creado (da a tal arco un atributo a.datum.inMatching = False
).
A continuación, ejecute una primera búsqueda de profundidad (DFS) a partir de cada nodo n
enbipartition.firstPart
que no es n == a.fromNode
nin == a.toNodes
para cualquier arco a
enmatching
. Durante el DFS, algunos nodos son visitados y otros no (almacene esta información en un campo n.datum.visited
). La cubierta de nodo mínima es la unión de los nodos {a.fromNode para a en H.setOfArcs si ((a.datum.inMatching) y (a.fromNode.datum.visited))}
y los nodos {a.fromNode para a en H.setOfArcs si (a.datum.inMatching) y (no a.toNode.datum.visited)}
.
Esto puede demostrarse que conduce desde una coincidencia máxima a una cubierta de nodo mínimapor una prueba por contradicción, tomar un arco a
que supuestamente no estaba cubierto y considerar todos los cuatro casos sobre si a.fromNode
y a.toNode
pertenecen (ya sea comotoNode
o fromNode
) a cualquier arco en coincidencia matching
. Cada caso lleva a una contradicción debido al orden en que DFS visita nodos y el hecho de que matching
es un matching máximo.
Supongamos que tenemos una función para ejecutar estos pasos y devolver el conjunto de nodos que comprende la cubierta de nodo mínima cuando se le da el digrafo G
, y la coincidencia máxima matching
:
ArcMatchingDatum = coll.namedtuple('ArcMatchingDatum', ['inMatching' ])
NodeMatchingDatum = coll.namedtuple('NodeMatchingDatum', ['visited'])
def dfs_helper(snodes, G):
sniter, do_stop = iter(snodes), False
visited, visited_uids = set(), set()
while(not do_stop):
try:
stack = [ next(sniter) ]
while len(stack) > 0:
curr = stack.pop()
if curr.uid not in visited_uids:
visited = visited.union( frozenset( {Node(curr.uid, NodeMatchingDatum(False))} ) )
visited_uids = visited.union(frozenset({curr.uid}))
succ = frozenset({a.toNode for a in G.setOfArcs if a.fromNode == curr})
stack.extend( succ.difference( frozenset(visited) ) )
except StopIteration as e:
stack, do_stop = [], True
return visited
def get_min_node_cover(matching, bipartition):
nodes = frozenset( { Node(n.uid, NodeMatchingDatum(False)) for n in bipartition.G.setOfNodes} )
G = DiGraph(nodes, None)
charcs = frozenset( {a for a in matching if ( (a.fromNode in bipartition.firstPart) and (a.toNode in bipartition.secondPart) )} )
arcs0 = frozenset( { Arc(find_node_by_uid(a.toNode.uid,G), find_node_by_uid(a.fromNode.uid,G), ArcMatchingDatum(True) ) for a in charcs } )
arcs1 = frozenset( { Arc(find_node_by_uid(a.fromNode.uid,G), find_node_by_uid(a.toNode.uid,G), ArcMatchingDatum(True) ) for a in matching.difference(charcs) } )
not_matching = bipartition.G.setOfArcs.difference( matching )
charcs = frozenset( {a for a in not_matching if ( (a.fromNode in bipartition.secondPart) and (a.toNode in bipartition.firstPart) )} )
arcs2 = frozenset( { Arc(find_node_by_uid(a.toNode.uid,G), find_node_by_uid(a.fromNode.uid,G), ArcMatchingDatum(False) ) for a in charcs } )
arcs3 = frozenset( { Arc(find_node_by_uid(a.fromNode.uid,G), find_node_by_uid(a.toNode.uid,G), ArcMatchingDatum(False) ) for a in not_matching.difference(charcs) } )
arcs = arcs0.union(arcs1.union(arcs2.union(arcs3)))
G = DiGraph(nodes, arcs)
bip = Bipartition({find_node_by_uid(n.uid,G) for n in bipartition.firstPart},{find_node_by_uid(n.uid,G) for n in bipartition.secondPart},G)
match_from_nodes = frozenset({find_node_by_uid(a.fromNode.uid,G) for a in matching})
match_to_nodes = frozenset({find_node_by_uid(a.toNode.uid,G) for a in matching})
snodes = bip.firstPart.difference(match_from_nodes).difference(match_to_nodes)
visited_nodes = dfs_helper(snodes, bip.G)
not_visited_nodes = bip.G.setOfNodes.difference(visited_nodes)
H = DiGraph(visited_nodes.union(not_visited_nodes), arcs)
cover1 = frozenset( {a.fromNode for a in H.setOfArcs if ( (a.datum.inMatching) and (a.fromNode.datum.visited) ) } )
cover2 = frozenset( {a.fromNode for a in H.setOfArcs if ( (a.datum.inMatching) and (not a.toNode.datum.visited) ) } )
min_cover_nodes = cover1.union(cover2)
true_min_cover_nodes = frozenset({find_node_by_uid(n.uid, bipartition.G) for n in min_cover_nodes})
return min_cover_nodes
El Problema de Asignación Lineal
El problema de asignación lineal consiste en encontrar una máxima coincidencia de pesos en un gráfico bipartito ponderado.
Problemas como el que aparece al comienzo de este post pueden expresarse como un problema de asignación lineal. Dado un conjunto de trabajadores, un conjunto de tareas, y una función que indica la rentabilidad de una asignación de un trabajador a una tarea, queremos maximizar la suma de todas las asignaciones que hacemos; esto es un problema de asignación lineal.
Supongamos que el número de tareas y los trabajadores son iguales, aunque voy a demostrar que esta suposición es fácil de quitar. En la implementación, represento pesos de arco con un atributo a.datum.weight
para un arco a
.
WeightArcDatum = namedtuple('WeightArcDatum', [weight])
Algoritmo Kuhn-Munkres
El Algoritmo de Kuhn-Munkres resuelve el problema de asignación lineal. Una buena implementación puede tomar tiempo O(N^{4})
, (donde N
es el número de nodos en el digrafo que representa el problema). Una implementación que es más fácil de explicar toma O (N ^ {5})
(para una versión que regenera digrafos) y O (N ^ {4})
para (para una versión que mantiene los digrafos). Esto es similar a las dos implementaciones diferentes del algoritmo Edmonds-Karp.
Para esta descripción, sólo estoy trabajando con gráficos bipartitos completos (aquellos donde la mitad de los nodos están en una parte de la bi-partición y la otra mitad en la segunda parte). En el trabajador, la motivación de la tarea significa que hay tantos trabajadores como tareas.
Esto parece una condición significativa (¿qué sucede si estos conjuntos no son iguales?), Pero es fácil solucionar este problema; Hablo de cómo hacerlo en la última sección.
La versión del algoritmo descrito aquí utiliza el concepto útil de arcos de peso cero. Desafortunadamente, este concepto sólo tiene sentido cuando estamos resolviendo una minimización (si en vez de maximizar los beneficios de nuestras asignaciones de tareas de trabajadores, en vez de eso minimizamos el costo de tales asignaciones).
Afortunadamente, es fácil convertir un problema de asignación lineal máximo en un problema de asignación lineal mínimo estableciendo cada uno de los pesos de arco a
enM-a.datum.weight
donde M=max({a.datum.weight for a in G.setOfArcs})
. La solución al problema de maximización original será idéntica a la solución minimizando el problema después de que se cambien los pesos de arco. Así que para el resto, supongamos que hacemos este cambio.
El algoritmo Kuhn-Munkres resuelve ponderación de peso mínima en un gráfico bi-particionado ponderado por una secuencia de concordancias máximas en gráficos no ponderados bipartitos. Si a encontramos una concordancia perfecta en la representación del digrafo del problema de asignación lineal y si el peso de cada arco en la coincidencia es cero, entonces hemos encontrado la ponderación de peso mínimo ya que esta coincidencia sugiere que todos nodos en el digrafos han sido emparejadopor un arco con el menor costo posible ( ningún coste puede ser inferior a 0, basado en definiciones anteriores).
Ningún otro arcos se puede agregar a la coincidencia (ya que todos nodos ya están emparejados) y no arcos debe ser eliminado de la coincidente porque cualquier posible reemplazo arco tendrá al menos un valor de peso tan grande.
Si encontramos una coincidencia máxima del subgrafo de G
que contiene sólo arcos de peso cero, y no es una concordancia perfecta, no tenemos una solución completa (ya que la combinación no es perfecta). Sin embargo, podemos producir un nuevo digrafo H
cambiando los pesos de arcos enG.setOfArcs
de manera que aparezcan nuevos 0-peso arcos y la solución óptima de «H» es la misma que la solución óptima de «G». Dado que garantizamos que al menos un arco de peso cero se produce en cada iteración, garantizamos que llegaremos a un coincidencia perfecta en no más de [/fusion_text][/fusion_builder_column][/fusion_builder_row][/fusion_builder_container][fusion_builder_container hundred_percent=»no» equal_height_columns=»no» menu_anchor=»» hide_on_mobile=»small-visibility,medium-visibility,large-visibility» class=»» id=»» background_color=»» background_image=»» background_position=»center center» background_repeat=»no-repeat» fade=»no» background_parallax=»none» parallax_speed=»0.3″ video_mp4=»» video_webm=»» video_ogv=»» video_url=»» video_aspect_ratio=»16:9″ video_loop=»yes» video_mute=»yes» overlay_color=»» video_preview_image=»» border_size=»» border_color=»» border_style=»solid» padding_top=»» padding_bottom=»» padding_left=»» padding_right=»»][fusion_builder_container hundred_percent=»no» equal_height_columns=»no» menu_anchor=»» hide_on_mobile=»small-visibility,medium-visibility,large-visibility» class=»» id=»» background_color=»» background_image=»» background_position=»center center» background_repeat=»no-repeat» fade=»no» background_parallax=»none» parallax_speed=»0.3″ video_mp4=»» video_webm=»» video_ogv=»» video_url=»» video_aspect_ratio=»16:9″ video_loop=»yes» video_mute=»yes» overlay_color=»» overlay_opacity=»0.5″ video_preview_image=»» border_size=»» border_color=»» border_style=»solid» padding_top=»» padding_bottom=»» padding_left=»» padding_right=»»][fusion_builder_row][fusion_builder_row][fusion_builder_column type=»1_1″ layout=»1_1″ background_position=»left top» background_color=»» border_size=»» border_color=»» border_style=»solid» border_position=»all» spacing=»yes» background_image=»» background_repeat=»no-repeat» padding=»» margin_top=»0px» margin_bottom=»0px» class=»» id=»» animation_type=»» animation_speed=»0.3″ animation_direction=»left» hide_on_mobile=»small-visibility,medium-visibility,large-visibility» center_content=»no» last=»no» min_height=»» hover_type=»none» link=»»][fusion_builder_column type=»1_1″ background_position=»left top» background_color=»» border_size=»» border_color=»» border_style=»solid» border_position=»all» spacing=»yes» background_image=»» background_repeat=»no-repeat» padding=»» margin_top=»0px» margin_bottom=»0px» class=»» id=»» animation_type=»» animation_speed=»0.3″ animation_direction=»left» hide_on_mobile=»small-visibility,medium-visibility,large-visibility» center_content=»no» last=»no» min_height=»» hover_type=»none» link=»»][fusion_text][fusion_text]^{2}=N^{2} en tales iteraciones.
Supongamos que en la bi-partición bipartition
, bipartition.firstPart
contiene nodos representando trabajadores, y bipartition.secondPart
representa nodos que al mismo tiempo representa pruebas.
El algoritmo comienza generando un nuevo digrafo H
. H.setOfNodes = G.setOfNodes
. Algunos arcos en H
son nodos n
generados en bipartition.firstPart
. Cada nodo n
genera un arco b
en H.setOfArcs
por cada arco a
en bipartition.G.setOfArcs
donde a.fromNode = n
or a.toNode = n
, b=Arc(a.fromNode, a.toNode, a.datum.weight - z)
donde z=min(x.datum.weight for x in G.setOfArcs if ( (x.fromNode == n) or (x.toNode == n) ))
.
Más arcos en H
se generan por nodos n
en bipartition.secondPart
. Cada uno de estos nodos n
genera un arco b
en H.setOfArcs
por cada arco a
en bipartition.G.setOfArcs
donde a.fromNode = n
o a.toNode = n
, b=Arc(a.fromNode, a.toNode, ArcWeightDatum(a.datum.weight - z))
donde z=min(x.datum.weight for x in G.setOfArcs if ( (x.fromNode == n) or (x.toNode == n) ))
.
KMA: Luego, forma un nuevo digrafo K
compuesto sólo de los arcos de peso cero de H
, y los nodosincidente en esos arcos. Forma una bipartition
en los nodos en K
, luego usa solve_mbm( bipartition )
para obtener una combinación máxima (matching
) en K
. Si matching
es una combinación perfecta en H
(los arcos en matching
son incidente en todos los nodos en H.setOfNodes
) entonces matching
es una solución óptima para problema de asignación lineal.
De lo contrario, si matching
no es perfecto, genera la cubierta de nodo mínima deK
usando node_cover = get_min_node_cover(matching, bipartition(K))
. Luego, define z=min({a.datum.weight for a in H.setOfArcs if a not in node_cover})
. Define nodes = H.setOfNodes
, arcs1 = {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh-z)) for a in H.setOfArcs if ( (a.fromNode not in node_cover) and (a.toNode not in node_cover)}
, arcs2 = {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh)) for a in H.setOfArcs if ( (a.fromNode not in node_cover) != (a.toNode not in node_cover)}
, arcs3 = {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh+z)) for a in H.setOfArcs if ( (a.fromNode in node_cover) and (a.toNode in node_cover)}
. El !=
símbolo en la expresión anterior actúa como un operador XOR. Luego arcs = arcs1.union(arcs2.union(arcs3))
. Luego, H=DiGraph(nodes,arcs)
. Regresa a la marca KMA. El algoritmo continúa hasta que se produce una combinación perfecta. Esta combinación es también la solución al problema de asignación lineal.
def kuhn_munkres( bipartition ):
nodes = bipartition.G.setOfNodes
arcs = frozenset({})
for n in bipartition.firstPart:
z = min( {x.datum.weight for x in bipartition.G.setOfArcs if ( (x.fromNode == n) or (x.toNode == n) )} )
arcs = arcs.union( frozenset({Arc(a.fromNode, a.toNode, ArcWeightDatum(a.datum.weight - z)) }) )
for n in bipartition.secondPart:
z = min( {x.datum.weight for x in bipartition.G.setOfArcs if ( (x.fromNode == n) or (x.toNode == n) )} )
arcs = arcs.union( frozenset({Arc(a.fromNode, a.toNode, ArcWeightDatum(a.datum.weight - z)) }) )
H = DiGraph(nodes, arcs)
assignment, value = dict({}), 0
not_done = True
while( not_done ):
zwarcs = frozenset( {a for a in H.setOfArcs if a.datum.weight == 0} )
znodes = frozenset( {n.fromNode for n in zwarcs} ).union( frozenset( {n.toNode for n in zwarcs} ) )
K = DiGraph(znodes, zwarcs)
k_bipartition = bipartition(K)
matching = solve_mbm( k_bipartition )
mnodes = frozenset({a.fromNode for a in matching}).union(frozenset({a.toNode for a in matching}))
if( len(mnodes) == len(H.setOfNodes) ):
for a in matching:
assignment[ a.fromNode.uid ] = a.toNode.uid
value = sum({a.datum.weight for a in matching})
not_done = False
else:
node_cover = get_min_node_cover(matching, bipartition(K))
z = min( frozenset( {a.datum.weight for a in H.setOfArcs if a not in node_cover} ) )
nodes = H.setOfNodes
arcs1 = frozenset( {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh-z)) for a in H.setOfArcs if ( (a.fromNode not in node_cover) and (a.toNode not in node_cover)} )
arcs2 = frozenset( {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh)) for a in H.setOfArcs if ( (a.fromNode not in node_cover) != (a.toNode not in node_cover)} )
arcs3 = frozenset( {Arc(a.fromNode,a.toNode,ArcWeightDatum(a.datum.weigh+z)) for a in H.setOfArcs if ( (a.fromNode in node_cover) and (a.toNode in node_cover)} )
arcs = arcs1.union(arcs2.union(arcs3))
H = DiGraph(nodes,arcs)
return value, assignment
Esta implementación es O(N^{5})
porque genera una nueva combinación máxima matching
en cada iteración; similar a las dos implementaciones anteriores de Edmonds-Karp este algoritmo puede ser modificado para que siga la pista de la coincidencia y la adapte inteligentemente a cada iteración. Cuando esto se hace, la complejidad se convierte en O(N^{4})
. Una versión más avanzada y más reciente de este algoritmo (que requiere algunas estructuras de datos más avanzadas) puede ejecutarse en O (N ^ {3})
. Detalles de la implementación más sencilla anterior y la implementación más avanzada se puede encontrar en esta publicación que motivó este blog.
Ninguna de las operaciones sobre los pesos del arco modifica la asignación final devuelta por el algoritmo. Por eso: Dado que nuestros gráficos de entrada son siempre gráficos completos bi-particionados, una solución debe asignar cada nodo en una partición a otro nodo en la segunda partición, a través del arcoentre estos dos nodos. Observe que las operaciones realizadas en los pesos de arco nunca cambian el orden (ordenado por peso) de los arcos incidentes en ningún nodo particular.
Por lo tanto, cuando el algoritmo termina en un perfecta y completa bi-partición coincidente, cada nodo se le asigna un peso cero al arco, ya que el orden relativo de los arcos de que nodo no ha cambiado durante el algoritmo y dado que un arco de peso cero es el arco más barato posible y el complemento bipartito completo perfecto garantiza que existe un arco para cada nodo. Esto significa que la solución generada es de hecho la misma que la solución del problema de asignación lineal original sin ninguna modificación de pesos de arco.
Asignaciones no Balanceadas
Parece que el algoritmo es bastante limitado, ya que como se describe, opera sólo en gráficos bipartitos completos (aquellos donde la mitad de los nodos están en una parte de la bi-partición y la otra mitad en el segundo parte). En el trabajador, la motivación de la tarea significa que hay tantos trabajadores como tareas (parece bastante limitante).
Sin embargo, hay una transformación fácil que elimina esta restricción. Supongamos que hay menos trabajadores que tareas, agregamos algunos trabajadores ficticios (lo suficiente para hacer que el gráfico resultante sea un gráfico bipartito completo). Cada trabajador ficticio tiene un arco dirigido del trabajador a cada una de las tareas. Cada uno de estos arco tiene peso 0 (colocándolo en una coincidencia no da ningún beneficio adicional). Después de este cambio el gráfico es un gráfico bipartito completo que podemos resolver. No se inicia ninguna tarea asignada a un trabajador ficticio.
Supongamos que hay más tareas que los trabajadores. Añadimos algunas tareas ficticias (suficiente para hacer que el gráfico resultante sea un gráfico bi-particionado completo). Cada tarea ficticia tiene un arco dirigido de cada trabajador a la tarea ficticia. Cada uno de estos arco tiene un peso de 0 (colocándolo en una coincidencia no da ningún beneficio adicional). Después de este cambio el gráfico es un gráfico bi-particionado completo que podemos resolver. Cualquier trabajador asignado a la tarea ficticia no es empleado durante el período.
Ejemplo de asignación lineal
Por último, vamos a hacer un ejemplo con el código que he estado usando. Voy a modificar el ejemplo de problema de aquí. Tenemos 3 tareas: necesitamos limpiar el baño, barrer el piso, y lavar las ventanas.
Los trabajadores disponibles son Alice, Bob, Charlie y Diane. Cada uno de los trabajadores nos da el salario que requieren por tarea. Aquí están los salarios por trabajador:
Clean the Bathroom | Sweep the Floor | Wash the Windows | |
---|---|---|---|
Alice | $2 | $3 | $3 |
Bob | $3 | $2 | $3 |
Charlie | $3 | $3 | $2 |
Diane | $9 | $9 | $1 |
Si queremos pagar la menor cantidad de dinero, pero todavía obtener todas las tareas realizadas, ¿quién debe hacer qué tarea? Comienza introduciendo una tarea ficticia para que el digrafo que representa el problema bipartito.
Clean the Bathroom | Sweep the Floor | Wash the Windows | Do Nothing | |
---|---|---|---|---|
Alice | $2 | $3 | $3 | $0 |
Bob | $3 | $2 | $3 | $0 |
Charlie | $3 | $3 | $2 | $0 |
Diane | $9 | $9 | $1 | $0 |
Suponiendo que el problema está codificado en un digrafo, entonces kuhn_munkres( bipartition(G) )
resolverá el problema y devolverá la asignación. Es fácil verificar que la asignación óptima (costo más bajo) costará $ 5.
He aquí una visualización de la solución que el código anterior genera:
Eso es todo . Ya sabes todo lo que necesitas saber sobre el problema de asignación lineal.
Puedes encontrar todo el código de este artículo en GitHub.