Flujo Máximo y el Problema de Asignación Lineal
Publicado: 2022-03-11Aquí hay un problema: su empresa asigna contratistas para cumplir con los contratos. Revisa sus listas y decide qué contratistas están disponibles para un compromiso de un mes y revisa sus contratos disponibles para ver cuáles de ellos son para tareas de un mes. Dado que sabe con qué eficacia cada contratista puede cumplir con cada contrato, ¿cómo asigna contratistas para maximizar la eficacia general de ese mes?
Este es un ejemplo del problema de asignación, y el problema se puede resolver con el algoritmo húngaro clásico.
El algoritmo húngaro (también conocido como algoritmo de Kuhn-Munkres) es un algoritmo de tiempo polinomial que maximiza la coincidencia de peso en un gráfico bipartito ponderado. Aquí, los contratistas y los contratos se pueden modelar como un gráfico bipartito, con su efectividad como los pesos de los bordes entre el contratista y los nodos del contrato.
En este artículo, aprenderá sobre una implementación del algoritmo húngaro que utiliza el algoritmo de Edmonds-Karp para resolver el problema de asignación lineal. También aprenderá cómo el algoritmo de Edmonds-Karp es una ligera modificación del método Ford-Fulkerson y cómo esta modificación es importante.
El problema del flujo máximo
El problema del flujo máximo en sí puede describirse de manera informal como el problema de mover algún fluido o gas a través de una red de tuberías desde una sola fuente a una sola terminal. Esto se hace asumiendo que la presión en la red es suficiente para garantizar que el fluido o el gas no permanezcan en ningún tramo de tubería o accesorio de tubería (aquellos lugares donde se encuentran diferentes longitudes de tubería).
Al hacer ciertos cambios en el gráfico, el problema de asignación se puede convertir 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 se conocen con diferentes nombres y se expresan de diferentes maneras (por ejemplo, matrices de adyacencia y listas de adyacencia). Dado que estas ideas son bastante esotéricas, se eligen con respecto a qué tan generalmente se definirán estos conceptos para cualquier entorno dado.
Este artículo no asumirá ningún conocimiento previo más allá de una pequeña introducción a la teoría de conjuntos.
Las implementaciones en esta publicación representan los problemas como gráficos dirigidos (dígrafo).
dígrafos
Un dígrafo tiene dos atributos, setOfNodes y setOfArcs . Ambos atributos son conjuntos (colecciones desordenadas). En los bloques de código de esta publicación, en realidad estoy usando el conjunto congelado de Python, 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
se compone de dos atributos:
n.uid
: Un identificador único.Esto significa que para cualesquiera dos nodos
x
ey
,
x.uid != y.uid
-
n.datum
: Esto representa cualquier objeto de datos.
Node = namedtuple('Node', ['uid','datum'])
arcos
Un arco a
se compone de tres atributos:
a.fromNode
: Este es un nodo , como se define arriba.a.toNode
: Este es un nodo , como se define arriba.a.datum
: Esto representa cualquier objeto de datos.
Arc = namedtuple('Arc', ['fromNode','toNode','datum'])
El conjunto de arcos en el dígrafo representa una relación binaria en los nodos del dígrafo . La existencia de arc a
implica que existe una relación entre a.fromNode
y a.toNode
.
En un gráfico dirigido (a diferencia de un gráfico no dirigido), la existencia de una relación entre a.fromNode
y a.toNode
no implica que exista una relación similar entre a.toNode
y a.fromNode
.
Esto se debe a que en un gráfico no dirigido, la relación que se expresa no es necesariamente simétrica.
dígrafos
Los nodos y los arcos se pueden usar para definir un dígrafo , pero por conveniencia, en los algoritmos a continuación, un dígrafo se representará 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 esta:
def digraph_to_dict(G): G_as_dict = dict([]) for a in G.setOfArcs: if(a.fromNode not in G.setOfNodes): err_msg = 'There is no Node {a.fromNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) pdg(G) raise KeyError(err_msg) if(a.toNode not in G.setOfNodes): err_msg = 'There is no Node {a.toNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) pdg(G) raise KeyError(err_msg) G_as_dict[a.fromNode] = (G_as_dict[a.fromNode].union(frozenset([a]))) if (a.fromNode in G_as_dict) else frozenset([a]) for a in G.setOfArcs: if(a.fromNode not in G.setOfNodes): err_msg = 'There is no Node {a.fromNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) if a.toNode not in G_as_dict: G_as_dict[a.toNode] = frozenset([]) return G_as_dict
Y aquí hay otro que lo convierte en un diccionario de diccionarios, otra operación que te será útil:
def digraph_to_double_dict(G): G_as_dict = dict([]) for a in G.setOfArcs: if(a.fromNode not in G.setOfNodes): err_msg = 'There is no Node {a.fromNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) if(a.toNode not in G.setOfNodes): err_msg = 'There is no Node {a.toNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) if(a.fromNode not in G_as_dict): G_as_dict[a.fromNode] = dict({a.toNode : frozenset([a])}) else: if(a.toNode not in G_as_dict[a.fromNode]): G_as_dict[a.fromNode][a.toNode] = frozenset([a]) else: G_as_dict[a.fromNode][a.toNode] = G_as_dict[a.fromNode][a.toNode].union(frozenset([a])) for a in G.setOfArcs: if(a.fromNode not in G.setOfNodes): err_msg = 'There is no Node {a.fromNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) if a.toNode not in G_as_dict: G_as_dict[a.toNode] = dict({}) return G_as_dict
Cuando el artículo habla de un dígrafo representado por un diccionario, mencionará G_as_dict
para referirse a él.
A veces es útil obtener un nodo de un dígrafo G
a través de su uid
(identificador único):
def find_node_by_uid(find_uid, G): nodes = {n for n in G.setOfNodes if n.uid == find_uid} if(len(nodes) != 1): err_msg = 'Node with uid {find_uid!s} is not unique.'.format(**locals()) raise KeyError(err_msg) return nodes.pop()
Al definir gráficos, las personas a veces usan los términos nodo y vértice para referirse al mismo concepto; lo mismo ocurre con los términos arco y arista.
Dos representaciones gráficas populares en Python son esta que usa diccionarios y otra que usa objetos para representar gráficos. La representación en esta publicación está en algún lugar entre estas dos representaciones de uso común.
Esta es mi representación digráfica . Hay muchos como este, pero este es el mío.
Paseos y Senderos
Sea S_Arcs
una secuencia finita (colección ordenada) de arcos en un dígrafo G
tal que si a
es cualquier arco en S_Arcs
excepto el último, y b
sigue a
en la secuencia, entonces debe haber un nodo n = a.fromNode
en G.setOfNodes
tal que a.toNode = b.fromNode
.
Comenzando desde el primer arco en S_Arcs
y continuando hasta el último arco en S_Arcs
, recopile (en el orden en que se encuentran) todos los nodos n
como se definió anteriormente entre cada dos arcos consecutivos en S_Arcs
. Etiquete la colección ordenada de nodos recopilados durante esta operación S_{Nodes}
.
def path_arcs_to_nodes(s_arcs): s_nodes = list([]) arc_it = iter(s_arcs) step_count = 0 last = None try: at_end = False last = a1 = next(arc_it) while (not at_end): s_nodes += [a1.fromNode] last = a2 = next(arc_it) step_count += 1 if(a1.toNode != a2.fromNode): err_msg = "Error at index {step_count!s} of Arc sequence.".format(**locals()) raise ValueError(err_msg) a1 = a2 except StopIteration as e: at_end = True if(last is not None): s_nodes += [last.toNode] return s_nodes
Si algún nodo aparece más de una vez en la secuencia
S_Nodes
, llame aS_Arcs
a Walk en el dígrafoG
De lo contrario, llama a
S_Arcs
una ruta desdelist(S_Nodes)[0]
alist(S_Nodes)[-1]
en el dígrafoG
.
Nodo de origen
Llame al nodo s
un nodo de origen en el dígrafo G
si s
está en G.setOfNodes
y G.setOfArcs
no contiene a
arco tal que a.toNode = s
.
def source_nodes(G): to_nodes = frozenset({a.toNode for a in G.setOfArcs}) sources = G.setOfNodes.difference(to_nodes) return sources
Nodo terminal
Llame al nodo t
un nodo terminal en el dígrafo G
si t
está en G.setOfNodes
y G.setOfArcs
no contiene a
arco tal que a.fromNode = t
.
def terminal_nodes(G): from_nodes = frozenset({a.fromNode for a in G.setOfArcs}) terminals = G.setOfNodes.difference(from_nodes) return terminals
Cortes y cortes de st
Un corte cut
de un dígrafo conectado G
es un subconjunto de arcos de G.setOfArcs
que divide el conjunto de nodos G.setOfNodes
en G
. G
está conectado si cada nodo n
en G.setOfNodes
y tiene al menos un arco a
en G.setOfArcs
tal que n = a.fromNode
o n = a.toNode
, pero a.fromNode != a.toNode
.
Cut = namedtuple('Cut', ['setOfCutArcs'])
La definición anterior se refiere a un subconjunto de arcos , pero también puede definir una partición de los nodos de G.setOfNodes
.
Para las funciones predecessors_of
y successors_of
, n
es un nodo en el conjunto G.setOfNodes del dígrafo G
, y cut
es un corte de G
:
def cut_predecessors_of(n, cut, G): allowed_arcs = G.setOfArcs.difference(frozenset(cut.setOfCutArcs)) predecessors = frozenset({}) previous_count = len(predecessors) reach_fringe = frozenset({n}) keep_going = True while( keep_going ): reachable_from = frozenset({a.fromNode for a in allowed_arcs if (a.toNode in reach_fringe)}) reach_fringe = reachable_from predecessors = predecessors.union(reach_fringe) current_count = len(predecessors) keep_going = current_count - previous_count > 0 previous_count = current_count return predecessors def cut_successors_of(n, cut, G): allowed_arcs = G.setOfArcs.difference(frozenset(cut.setOfCutArcs)) successors = frozenset({}) previous_count = len(successors) reach_fringe = frozenset({n}) keep_going = True while( keep_going ): reachable_from = frozenset({a.toNode for a in allowed_arcs if (a.fromNode in reach_fringe)}) reach_fringe = reachable_from successors = successors.union(reach_fringe) current_count = len(successors) keep_going = current_count - previous_count > 0 previous_count = current_count return successors
Sea cut
un corte del dígrafo G
def get_first_part(cut, G): firstPartFringe = frozenset({a.fromNode for a in cut.setOfCutArcs}) firstPart = firstPartFringe for n in firstPart: preds = cut_predecessors_of(n,cut,G) firstPart = firstPart.union(preds) return firstPart def get_second_part(cut, G): secondPartFringe = frozenset({a.toNode for a in cut.setOfCutArcs}) secondPart = secondPartFringe for n in secondPart: succs= cut_successors_of(n,cut,G) secondPart = secondPart.union(succs) return secondPart
cut
es un corte del dígrafo G
si: (get_first_part(cut, G).union(get_second_part(cut, G)) == G.setOfNodes) and (len(get_first_part(cut, G).intersect(get_second_part(cut, G))) == 0)
cut
se llama corte xy si (x in get_first_part(cut, G)) and (y in get_second_part(cut, G) ) and (x != y)
. Cuando el nodo x
en un cut
xy es un nodo fuente y el nodo y
en el corte xy es un nodo terminal , entonces este corte se denomina corte st .
STCut = namedtuple('STCut', ['s','t','cut'])
Redes de flujo
Puede usar un dígrafo G
para representar una red de flujo.
Asigne a cada nodo n
, donde n
está en G.setOfNodes
un n.datum
que es un FlowNodeDatum
:
FlowNodeDatum = namedtuple('FlowNodeDatum', ['flowIn','flowOut'])
Asigne a cada arco a
, donde a
está en G.setOfArcs
y a.datum
que es un FlowArcDatum
.
FlowArcDatum = namedtuple('FlowArcDatum', ['capacity','flow'])
flowNodeDatum.flowIn
y flowNodeDatum.flowOut
son números reales positivos.
flowArcDatum.capacity
y flowArcDatum.flow
también son números reales positivos.
Para cada nodo nodo n
en G.setOfNodes
:
n.datum.flowIn = sum({a.datum.flow for a in G.Arcs if a.toNode == n}) n.datum.flowOut = sum({a.datum.flow for a in G.Arcs if a.fromNode == n})
El dígrafo G
ahora representa una red de flujo .
El flujo de G
se refiere al a.flow
a para todos los arcos a
en G
Flujos factibles
Sea el dígrafo G
una red de flujo .
La red de flujo representada por G
tiene flujos factibles si:
Para cada nodo
n
enG.setOfNodes
excepto para los nodos de origen y los nodos terminales :n.datum.flowIn = n.datum.flowOut
.Para cada arco
a
enG.setOfNodes
:a.datum.capacity <= a.datum.flow
.
La condición 1 se llama restricción de conservación.
La condición 2 se denomina restricción de capacidad.
Capacidad de corte
La capacidad de corte de un st corte stCut
con nodo fuente s
y nodo terminal t
de una red de flujo representada por un dígrafo G
es:
def cut_capacity(stCut, G): part_1 = get_first_part(stCut.cut,G) part_2 = get_second_part(stCut.cut,G) s_part = part_1 if stCut.s in part_1 else part_2 t_part = part_1 if stCut.t in part_1 else part_2 cut_capacity = sum({a.datum.capacity for a in stCut.cut.setOfCutArcs if ( (a.fromNode in s_part) and (a.toNode in t_part) )}) return cut_capacity
Corte de capacidad mínima
Sea stCut = stCut(s,t,cut)
un st cut de una red de flujo representada por un dígrafo G
.
stCut
es el corte de capacidad mínima de la red de flujo representada por G
si no hay otro st cut stCutCompetitor
en esta red de flujo tal que:
cut_capacity(stCut, G) < cut_capacity(stCutCompetitor, G)
Eliminando los flujos
Me gustaría referirme al dígrafo que sería el resultado de tomar un dígrafo G
y eliminar todos los datos de flujo de todos los nodos en G.setOfNodes
y también todos los arcos en G.setOfArcs
.
def strip_flows(G): new_nodes= frozenset( (Node(n.uid, FlowNodeDatum(0.0,0.0)) for n in G.setOfNodes) ) new_arcs = frozenset([]) for a in G.setOfArcs: new_fromNode = Node(a.fromNode.uid, FlowNodeDatum(0.0,0.0)) new_toNode = Node(a.toNode.uid, FlowNodeDatum(0.0,0.0)) new_arc = Arc(new_fromNode, new_toNode, FlowArcDatum(a.datum.capacity, 0.0)) new_arcs = new_arcs.union(frozenset([new_arc])) return DiGraph(new_nodes, new_arcs)
Problema de flujo máximo
Una red de flujo representada como un dígrafo G
, un nodo fuente s
en G.setOfNodes
y un nodo terminal t
en G.setOfNodes
, G
puede representar un problema de flujo máximo si:
(len(list(source_nodes(G))) == 1) and (next(iter(source_nodes(G))) == s) and (len(list(terminal_nodes(G))) == 1) and (next(iter(terminal_nodes(G))) == t)
Etiqueta esta representación:
MaxFlowProblemState = namedtuple('MaxFlowProblemState', ['G','sourceNodeUid','terminalNodeUid','maxFlowProblemStateUid'])
Donde sourceNodeUid = s.uid
, terminalNodeUid = t.uid
y maxFlowProblemStateUid
es un identificador para la instancia del problema.
Solución de flujo máximo
Sea maxFlowProblem
un problema de flujo máximo . La solución a maxFlowProblem
se puede representar mediante una red de flujo representada como un dígrafo H
.
Digraph H
es una solución factible para el problema de flujo máximo en la entrada python maxFlowProblem
si:
strip_flows(maxFlowProblem.G) == strip_flows(H)
.H
es una red de flujo y tiene flujos factibles .
Si además de 1 y 2:
- No puede haber otra red de flujo representada por el dígrafo
K
tal questrip_flows(G) == strip_flows(K)
yfind_node_by_uid(t.uid,G).flowIn < find_node_by_uid(t.uid,K).flowIn
.
Entonces H
también es una solución óptima para maxFlowProblem
.
En otras palabras, una solución de flujo máximo factible se puede representar mediante un dígrafo , que:
Es idéntico al dígrafo
G
del problema de flujo máximo correspondiente con la excepción de que eln.datum.flowIn
,n.datum.flowOut
y ela.datum.flow
de cualquiera de los nodos y arcos pueden ser diferentes.Representa una red de flujo que tiene flujos factibles .
Y, puede representar una solución óptima de flujo máximo si además:
- El
flowIn
de entrada para el nodo correspondiente al nodo terminal en el problema de flujo máximo es tan grande como sea posible (cuando las condiciones 1 y 2 aún se cumplen).
Si el dígrafo H
representa una solución de flujo máximo factible : find_node_by_uid(s.uid,H).flowOut = find_node_by_uid(t.uid,H).flowIn
esto se deduce del flujo máximo, el teorema de corte mínimo (discutido a continuación). De manera informal, dado que se supone que H
tiene flujos factibles, esto significa que el flujo no se puede 'crear' (excepto en el nodo de origen s
) ni 'destruir' (excepto en el nodo terminal t
) al cruzar cualquier (otro) nodo ( restricciones de conservación ).
Dado que un problema de flujo máximo contiene solo un nodo fuente único s
y un nodo terminal único t
, todo el flujo 'creado' en s
debe ser 'destruido' en t
o la red de flujo no tiene flujos factibles (se habría violado la restricción de conservación ).
Deje que el dígrafo H
represente una solución de flujo máximo factible ; el valor anterior se denomina valor de flujo st de H
.
Dejar:
mfps=MaxFlowProblemState(H, maxFlowProblem.sourceNodeUid, maxFlowProblem.terminalNodeUid, maxFlowProblem.maxFlowProblemStateUid)
Esto significa que mfps
es un estado sucesor de maxFlowProblem
, lo que significa que mfps
es exactamente como maxFlowProblem
con la excepción de que los valores de a.flow
para arcos a
en maxFlowProblem.setOfArcs
pueden ser diferentes de a.flow
para arcos a
en mfps.setOfArcs
.
def get_mfps_flow(mfps): flow_from_s = find_node_by_uid(mfps.sourceNodeUid,mfps.G).datum.flowOut flow_to_t = find_node_by_uid(mfps.terminalNodeUid,mfps.G).datum.flowIn if( (flow_to_t - flow_from_s) > 0): raise Exception('Infeasible st flow') return flow_to_t
Aquí hay una visualización de un mfps
junto con su maxFlowProb
asociado. Cada arco a
en la imagen tiene una etiqueta, estas etiquetas son a.datum.flowFrom / a.datum.flowTo
, cada nodo n
en la imagen tiene una etiqueta, y estas etiquetas son n.uid {n.datum.flowIn / a.datum.flowOut}
.
Flujo de corte st
Deje que mfps
represente un MaxFlowProblemState
y deje que stCut
represente un corte de mfps.G
El flujo de corte de stCut
se define:
def get_stcut_flow(stCut,mfps): s = find_node_by_uid(mfps.sourceNodeUid, mfps.G) t = find_node_by_uid(mfps.terminalNodeUid, mfps.G) part_1 = get_first_part(stCut.cut,mfps.G) part_2 = get_second_part(stCut.cut,mfps.G) s_part = part_1 if s in part_1 else part_2 t_part = part_1 if t in part_1 else part_2 s_t_sum = sum(a.datum.flow for a in mfps.G.setOfArcs if (a in stCut.cut.setOfCutArcs) and (a.fromNode in s_part) ) t_s_sum = sum(a.datum.flow for a in mfps.G.setOfArcs if (a in stCut.cut.setOfCutArcs) and (a.fromNode in t_part) ) cut_flow = s_t_sum - t_s_sum return cut_flow
El primer flujo de corte es la suma de los flujos desde la partición que contiene el nodo de origen hasta la partición que contiene el nodo terminal menos la suma de los flujos desde la partición que contiene el nodo terminal hasta la partición que contiene el nodo de origen .
Flujo máximo, corte mínimo
Deje que maxFlowProblem
represente un problema de flujo máximo y deje que la solución de maxFlowProblem
se represente mediante una red de flujo representada como dígrafo H
.
Sea minStCut
el corte de capacidad mínima de la red de flujo representada por maxFlowProblem.G
.
Debido a que en el problema de flujo máximo, el flujo se origina en un solo nodo de origen y termina en un solo nodo terminal y, debido a las restricciones de capacidad y las restricciones de conservación , sabemos que todo el flujo que ingresa a maxFlowProblem.terminalNodeUid
debe cruzar cualquier corte de st . , en particular debe cruzar minStCut
. Esto significa:
get_flow_value(H, maxFlowProblem) <= cut_capacity(minStCut, maxFlowProblem.G)
Resolviendo el Problema de Flujo Máximo
La idea básica para resolver un problema de flujo máximo maxFlowProblem
es comenzar con una solución de flujo máximo representada por el dígrafo H
. Tal punto de partida puede ser H = strip_flows(maxFlowProblem.G)
. La tarea es entonces usar H
y mediante alguna modificación codiciosa de los valores de a.datum.flow
de algunos arcos a
en H.setOfArcs
para producir otra solución de flujo máximo representada por el dígrafo K
tal que K
aún no puede representar una red de flujo con flujos factibles y get_flow_value(H, maxFlowProblem) < get_flow_value(K, maxFlowProblem)
. Mientras este proceso continúe, la calidad ( get_flow_value(K, maxFlowProblem)
) de la solución de flujo máximo encontrada más recientemente ( K
) es mejor que cualquier otra solución de flujo máximo que se haya encontrado. Si el proceso llega a un punto en el que sabe que no es posible ninguna otra mejora, el proceso puede terminar y devolverá la solución óptima de flujo máximo .
La descripción anterior es general y omite muchas pruebas, como si dicho proceso es posible o cuánto tiempo puede llevar. Daré algunos detalles más y el algoritmo.
El flujo máximo, teorema de corte mínimo
Del libro Flows in Networks de Ford y Fulkerson, la declaración del flujo máximo, teorema de corte mínimo (Teorema 5.1) es:
Para cualquier red, el valor de flujo máximo de
s
at
es igual a la capacidad de corte mínima de todos los cortes que separans
yt
.
Usando las definiciones en esta publicación, eso se traduce en:
La solución a un maxFlowProblem
representado por una red de flujo representada como dígrafo H
es óptima si:
get_flow_value(H, maxFlowProblem) == cut_capacity(minStCut, maxFlowProblem.G)
Me gusta esta prueba del teorema y Wikipedia tiene otra.
El teorema de flujo máximo, corte mínimo se utiliza para probar la corrección y la integridad del método de Ford-Fulkerson .
También daré una prueba del teorema en la sección después de aumentar caminos .
El método de Ford-Fulkerson y el algoritmo de Edmonds-Karp
CLRS define el método Ford-Fulkerson así (sección 26.2):
FORD-FULKERSON-METHOD(G, s, t): initialize flow f to 0 while there exists an augmenting path p in the residual graph G_f augment flow f along
Gráfico de residuos
El gráfico residual de una red de flujo representada como el dígrafo G
se puede representar como un dígrafo G_f
:
ResidualDatum = namedtuple('ResidualDatum', ['residualCapacity','action']) def agg_n_to_u_cap(n,u,G_as_dict): arcs_out = G_as_dict[n] return sum([a.datum.capacity for a in arcs_out if( (a.fromNode == n) and (a.toNode == u) ) ]) def agg_n_to_u_flow(n,u,G_as_dict): arcs_out = G_as_dict[n] return sum([a.datum.flow for a in arcs_out if( (a.fromNode == n) and (a.toNode == u) ) ]) def get_residual_graph_of(G): G_as_dict = digraph_to_dict(G) residual_nodes = G.setOfNodes residual_arcs = frozenset([]) for n in G_as_dict: arcs_from = G_as_dict[n] nodes_to = frozenset([find_node_by_uid(a.toNode.uid,G) for a in arcs_from]) for u in nodes_to: n_to_u_cap_sum = agg_n_to_u_cap(n,u,G_as_dict) n_to_u_flow_sum = agg_n_to_u_flow(n,u,G_as_dict) if(n_to_u_cap_sum > n_to_u_flow_sum): flow = round(n_to_u_cap_sum - n_to_u_flow_sum, TOL) residual_arcs = residual_arcs.union( frozenset([Arc(n,u,datum=ResidualDatum(flow, 'push'))]) ) if(n_to_u_flow_sum > 0.0): flow = round(n_to_u_flow_sum, TOL) residual_arcs = residual_arcs.union( frozenset([Arc(u,n,datum=ResidualDatum(flow, 'pull'))]) ) return DiGraph(residual_nodes, residual_arcs)
agg_n_to_u_cap(n,u,G_as_dict)
devuelve la suma dea.datum.capacity
para todos los arcos en el subconjunto deG.setOfArcs
donde arca
está en el subconjunto sia.fromNode = n
ya.toNode = u
.agg_n_to_u_cap(n,u,G_as_dict)
devuelve la suma dea.datum.flow
para todos los arcos en el subconjunto deG.setOfArcs
donde arca
está en el subconjunto sia.fromNode = n
ya.toNode = u
.
Brevemente, el gráfico residual G_f
representa ciertas acciones que se pueden realizar en el dígrafo G
Cada par de nodos n,u
en G.setOfNodes
de la red de flujo representada por el dígrafo G
puede generar 0, 1 o 2 arcos en el gráfico residual G_f
de G
.
El par
n,u
no genera ningún arco enG_f
si no hay un arcoa
enG.setOfArcs
tal quea.fromNode = n
ya.toNode = u
.El par
n,u
genera el arcoa
enG_f.setOfArcs
dondea
representa un arco etiquetado como arco de flujo de empuje den
au
a = Arc(n,u,datum=ResidualNode(n_to_u_cap_sum - n_to_u_flow_sum))
ifn_to_u_cap_sum > n_to_u_flow_sum
.El par
n,u
genera el arcoa
enG_f.setOfArcs
dondea
representa un arco etiquetado como arco de flujo de extracción den
au
a = Arc(n,u,datum=ResidualNode(n_to_u_cap_sum - n_to_u_flow_sum))
sin_to_u_flow_sum > 0.0
.
Cada arco de flujo de inserción en
G_f.setOfArcs
representa la acción de agregar un total dex <= n_to_u_cap_sum - n_to_u_flow_sum
flujo a los arcos en el subconjunto deG.setOfArcs
donde arca
está en el subconjunto sia.fromNode = n
ya.toNode = u
Cada arco de flujo de extracción en
G_f.setOfArcs
representa la acción de restar un total de flujox <= n_to_u_flow_sum
a arcos en el subconjunto deG.setOfArcs
donde arca
está en el subconjunto sia.fromNode = n
ya.toNode = u
.
Realizar una acción individual de empujar o jalar desde G_f
en los arcos aplicables en G
podría generar una red de flujo sin flujos factibles porque las restricciones de capacidad o las restricciones de conservación podrían violarse en la red de flujo generada.
Aquí hay una visualización del gráfico residual de la visualización de ejemplo anterior de una solución de flujo máximo , en la visualización, cada arco a
representa una a.residualCapacity
.
Ruta de aumento
Sea maxFlowProblem
un problema de flujo máximo y sea G_f = get_residual_graph_of(G)
el gráfico residual de maxFlowProblem.G
.
Una ruta de augmentingPath
AugmentingPath para maxFlowProblem
es cualquier ruta desde find_node_by_uid(maxFlowProblem.sourceNode,G_f)
hasta find_node_by_uid(maxFlowProblem.terminalNode,G_f)
.
Resulta que se puede aplicar una ruta de augmentingPath
a una solución de flujo máximo representada por el dígrafo H
generando otra solución de flujo máximo representada por el dígrafo K
donde get_flow_value(H, maxFlowProblem) < get_flow_value(K, maxFlowProblem)
si H
no es óptimo .
Así es cómo:
def augment(augmentingPath, H): augmentingPath = list(augmentingPath) H_as_dict = digraph_to_dict(H) new_nodes = frozenset({}) new_arcs = frozenset({}) visited_nodes = frozenset({}) visited_arcs = frozenset({}) bottleneck_residualCapacity = min( augmentingPath, key=lambda a: a.datum.residualCapacity ).datum.residualCapacity for x in augmentingPath: from_node_uid = x.fromNode.uid if x.datum.action == 'push' else x.toNode.uid to_node_uid = x.toNode.uid if x.datum.action == 'push' else x.fromNode.uid from_node = find_node_by_uid( from_node_uid, H ) to_node = find_node_by_uid( to_node_uid, H ) load = bottleneck_residualCapacity if x.datum.action == 'push' else -bottleneck_residualCapacity for a in H_as_dict[from_node]: if(a.toNode == to_node): test_sum = a.datum.flow + load new_flow = a.datum.flow new_from_node_flow_out = a.fromNode.datum.flowOut new_to_node_flow_in = a.toNode.datum.flowIn new_from_look = {n for n in new_nodes if (n.uid == a.fromNode.uid)} new_to_look = {n for n in new_nodes if (n.uid == a.toNode.uid) } prev_from_node = new_from_look.pop() if (len(new_from_look)>0) else a.fromNode prev_to_node = new_to_look.pop() if (len(new_to_look)>0) else a.toNode new_nodes = new_nodes.difference(frozenset({prev_from_node})) new_nodes = new_nodes.difference(frozenset({prev_to_node})) if(test_sum > a.datum.capacity): new_flow = a.datum.capacity new_from_node_flow_out = prev_from_node.datum.flowOut - a.datum.flow + a.datum.capacity new_to_node_flow_in = prev_to_node.datum.flowIn - a.datum.flow + a.datum.capacity load = test_sum - a.datum.capacity elif(test_sum < 0.0): new_flow = 0.0 new_from_node_flow_out = prev_from_node.datum.flowOut - a.datum.flow new_to_node_flow_in = prev_to_node.datum.flowIn - a.datum.flow load = test_sum else: new_flow = test_sum new_from_node_flow_out = prev_from_node.datum.flowOut - a.datum.flow + new_flow new_to_node_flow_in = prev_to_node.datum.flowIn - a.datum.flow + new_flow load = 0.0 new_from_node_flow_out = round(new_from_node_flow_out, TOL) new_to_node_flow_in = round(new_to_node_flow_in, TOL) new_flow = round(new_flow, TOL) new_from_node = Node(prev_from_node.uid, FlowNodeDatum(prev_from_node.datum.flowIn, new_from_node_flow_out)) new_to_node = Node(prev_to_node.uid, FlowNodeDatum(new_to_node_flow_in, prev_to_node.datum.flowOut)) new_arc = Arc(new_from_node, new_to_node, FlowArcDatum(a.datum.capacity, new_flow)) visited_nodes = visited_nodes.union(frozenset({a.fromNode,a.toNode})) visited_arcs = visited_arcs.union(frozenset({a})) new_nodes = new_nodes.union(frozenset({new_from_node, new_to_node})) new_arcs = new_arcs.union(frozenset({new_arc})) not_visited_nodes = H.setOfNodes.difference(visited_nodes) not_visited_arcs = H.setOfArcs.difference(visited_arcs) full_new_nodes = new_nodes.union(not_visited_nodes) full_new_arcs = new_arcs.union(not_visited_arcs) G = DiGraph(full_new_nodes, full_new_arcs) full_new_arcs_update = frozenset([]) for a in full_new_arcs: new_from_node = a.fromNode new_to_node = a.toNode new_from_node = find_node_by_uid( a.fromNode.uid, G ) new_to_node = find_node_by_uid( a.toNode.uid, G ) full_new_arcs_update = full_new_arcs_update.union( {Arc(new_from_node, new_to_node, FlowArcDatum(a.datum.capacity, a.datum.flow))} ) G = DiGraph(full_new_nodes, full_new_arcs_update) return G
En lo anterior, TOL
es un valor de tolerancia para redondear los valores de flujo en la red. Esto es para evitar la imprecisión en cascada de los cálculos de coma flotante. Entonces, por ejemplo, usé TOL = 10
para significar redondear a 10 dígitos significativos.
Sea K = augment(augmentingPath, H)
, entonces K
representa una solución factible de flujo máximo para maxFlowProblem
. Para que la declaración sea verdadera, la red de flujo representada por K
debe tener flujos factibles (no violar la restricción de capacidad o la restricción de conservación) .
He aquí por qué: en el método anterior, cada nodo agregado a la nueva red de flujo representada por el dígrafo K
es una copia exacta de un nodo del dígrafo H
o un nodo n
al que se le ha agregado el mismo número a su n.datum.flowIn
como es n.datum.flowOut
. Esto significa que la restricción de conservación se cumple en K
siempre que se satisfaga en H
. La restricción de conservación se cumple porque verificamos explícitamente que cualquier nuevo arco a
en la red tenga a.datum.flow <= a.datum.capacity
; por lo tanto, siempre que los arcos del conjunto H.setOfArcs
que se copiaron sin modificar en K.setOfArcs
no violen la restricción de capacidad , entonces K
no viola la restricción de capacidad .
También es cierto que get_flow_value(H, maxFlowProblem) < get_flow_value(K, maxFlowProblem)
si H
no es óptimo .
Este es el motivo: para que exista una ruta de augmentingPath
en la representación de dígrafo del gráfico residual G_f
de un problema de flujo máximo maxFlowProblem
, entonces el último arco a
en la ruta de augmentingPath
debe ser un arco de "empuje" y debe tener a.toNode == maxFlowProblem.terminalNode
. Una ruta de aumento se define como aquella que termina en el nodo terminal del problema de flujo máximo para el cual es una ruta de aumento. A partir de la definición del gráfico residual , está claro que el último arco en una ruta de aumento en ese gráfico residual debe ser un arco de "empuje" porque cualquier arco b
de "tracción" en la ruta de aumento tendrá b.toNode == maxFlowProblem.terminalNode
y b.fromNode != maxFlowProblem.terminalNode
de la definición de ruta . Además, a partir de la definición de ruta , está claro que el nodo terminal solo se modifica una vez mediante el método de augment
. Por lo tanto, el maxFlowProblem.terminalNode.flowIn
augment
una vez y aumenta el valor de maxFlowProblem.terminalNode.flowIn
porque el último arco en la ruta de augmentingPath
debe ser el arco que provoca la modificación en maxFlowProblem.terminalNode.flowIn
durante el augment
. A partir de la definición de augment
tal como se aplica a los arcos 'push', maxFlowProblem.terminalNode.flowIn
solo se puede aumentar, no disminuir.
Algunas pruebas de Sedgewick y Wayne
El libro Algorithms, cuarta edición de Robert Sedgewich y Kevin Wayne tiene algunas pruebas cortas y maravillosas (páginas 892-894) que serán útiles. Los recrearé aquí, aunque usaré un lenguaje que encaje con las definiciones anteriores. Mis etiquetas para las pruebas son las mismas que en el libro de Sedgewick.
Proposición E: para cualquier dígrafo H
que represente una solución factible de flujo máximo para un problema de flujo máximo maxFlowProblem
, para cualquier stCut
get_stcut_flow(stCut,H,maxFlowProblem) = get_flow_value(H, maxFlowProblem)
.
Prueba: Let stCut=stCut(maxFlowProblem.sourceNode,maxFlowProblem.terminalNode,set([a for a in H.setOfArcs if a.toNode == maxFlowProblem.terminalNode]))
. La proposición E se cumple para stCut
directamente de la definición de st flow value . Supongamos que allí deseamos mover algún nodo n
de la partición s ( get_first_part(stCut.cut, G)
) y dentro de la partición t (get_second_part(stCut.cut, G))
, para hacerlo necesitamos cambiar stCut.cut
, que podría cambiar stcut_flow = get_stcut_flow(stCut,H,maxFlowProblem)
e invalidar la proposición E . Sin embargo, veamos cómo cambiará el valor de stcut_flow
a medida que hagamos este cambio. el nodo n
está en equilibrio, lo que significa que la suma del flujo hacia el nodo n
es igual a la suma del flujo que sale (esto es necesario para que H
represente una solución factible ). Tenga en cuenta que todo el flujo que forma parte del nodo de entrada n
de stcut_flow
ingresa desde la partición s (el flujo que ingresa al nodo n
desde la partición t, ya sea directa o indirectamente, no se habría contado en el valor de stcut_flow
porque se dirige en la dirección incorrecta según la definición). Además, todo el flujo que sale de n
eventualmente (directa o indirectamente) fluirá hacia el nodo terminal (probado anteriormente). Cuando movemos el nodo n
a la partición t, todo el flujo que ingresa a n
desde la partición s debe agregarse al nuevo valor de stcut_flow
; sin embargo, todo el flujo que sale de n
debe restarse del nuevo valor de stcut_flow
; la parte del flujo que se dirige directamente a la partición t se resta porque este flujo ahora es interno a la nueva partición t y no se cuenta como stcut_flow
. La parte del flujo de n
que se dirige a los nodos en la partición s también se debe restar de stcut_flow
: después de que n
se mueva a la partición t, estos flujos se dirigirán desde la partición t a la partición s y así no debe tenerse en cuenta en stcut_flow
, ya que estos flujos se eliminan del flujo de entrada a la partición s y deben reducirse por la suma de estos flujos, y el flujo de salida de la partición s a la partición t (donde todos los flujos de st debe terminar) debe reducirse en la misma cantidad. Como el nodo n
estaba en equilibrio antes del proceso, la actualización habrá agregado el mismo valor al nuevo valor de stcut_flow
que restó, dejando así la proposición E verdadera después de la actualización. La validez de la proposición E se sigue entonces de la inducción sobre el tamaño de la partición t.
Aquí hay algunos ejemplos de redes de flujo para ayudar a visualizar los casos menos obvios donde se cumple la proposición E ; en la imagen, las áreas rojas indican la partición s, las áreas azules representan la partición t y los arcos verdes indican un corte st . En la segunda imagen, el flujo entre el nodo A y el nodo B aumenta mientras que el flujo hacia el nodo terminal t no cambia:

Corolario: Ningún valor de flujo de corte de st puede exceder la capacidad de cualquier corte de st .
Proposición F. (flujo máximo, teorema de corte mínimo): Sea f
un flujo st . Las siguientes 3 condiciones son equivalentes:
Existe un st corte cuya capacidad es igual al valor del caudal
f
.f
es un flujo máximo .No hay camino de aumento con respecto a
f
.
La condición 1 implica la condición 2 por el corolario. La condición 2 implica la condición 3 porque la existencia de un camino creciente implica la existencia de un flujo con valores mayores, contradiciendo la maximalidad de f
. La condición 3 implica la condición 1: Sea C_s
el conjunto de todos los nodos a los que se puede llegar desde s
con un camino creciente en el grafo residual . Sean C_t
los arcos restantes, entonces t
debe estar en C_t
(según nuestra suposición). Los arcos que cruzan de C_s
a C_t
luego forman un corte st que contiene solo arcos a
donde a.datum.capacity = a.datum.flow
o a.datum.flow = 0.0
. Si esto fuera de otro modo, los nodos conectados por un arco con capacidad residual remanente a C_s
estarían en el conjunto C_s
ya que entonces habría un camino creciente desde s
hasta dicho nodo . El flujo a través del st corte es igual a la capacidad del st corte (ya que los arcos de C_s
a C_t
tienen un flujo igual a la capacidad) y también al valor del st flujo (por la proposición E ).
Esta afirmación del teorema de flujo máximo, corte mínimo implica la afirmación anterior de Flows in Networks.
Corolario (propiedad de integralidad): cuando las capacidades son números enteros, existe un flujo máximo de valor entero y el algoritmo de Ford-Fulkerson lo encuentra.
Prueba: cada ruta de aumento aumenta el flujo en un número entero positivo, el mínimo de las capacidades no utilizadas en los arcos de 'empuje' y los flujos en los arcos de 'tirón', todos los cuales son siempre números enteros positivos.
Esto justifica la descripción del método Ford-Fulkerson de CLRS . El método consiste en seguir encontrando rutas de aumento y aplicar el maxFlowSolution
augment
encontrar mejores soluciones, hasta que no haya más rutas de aumento, lo que significa que la última solución de flujo máximo es óptima .
De Ford-Fulkerson a Edmonds-Karp
Las preguntas restantes con respecto a la solución de problemas de flujo máximo son:
¿Cómo deben construirse los caminos de aumento ?
¿Terminará el método si usamos números reales y no enteros?
¿Cuánto tiempo llevará terminar (si lo hace)?
El algoritmo de Edmonds-Karp especifica que cada ruta de aumento se construye mediante una búsqueda en amplitud (BFS) del gráfico residual ; resulta que esta decisión del punto 1 anterior también forzará la terminación del algoritmo (punto 2) y permitirá determinar la complejidad asintótica de tiempo y espacio.
Primero, aquí hay una implementación de BFS :
def bfs(sourceNode, terminalNode, G_f): G_f_as_dict = digraph_to_dict(G_f) parent_arcs = dict([]) visited = frozenset([]) deq = deque([sourceNode]) while len(deq) > 0: curr = deq.popleft() if curr == terminalNode: break for a in G_f_as_dict.get(curr): if (a.toNode not in visited): visited = visited.union(frozenset([a.toNode])) parent_arcs[a.toNode] = a deq.extend([a.toNode]) path = list([]) curr = terminalNode while(curr != sourceNode): if (curr not in parent_arcs): err_msg = 'No augmenting path from {} to {}.'.format(sourceNode.uid, terminalNode.uid) raise StopIteration(err_msg) path.extend([parent_arcs[curr]]) curr = parent_arcs[curr].fromNode path.reverse() test = deque([path[0].fromNode]) for a in path: if(test[-1] != a.fromNode): err_msg = 'Bad path: {}'.format(path) raise ValueError(err_msg) test.extend([a.toNode]) return path
Usé un deque del módulo de colecciones de python.
Para responder a la pregunta 2 anterior, parafrasearé otra prueba de Sedgewick y Wayne: Proposición G. La cantidad de rutas de aumento necesarias en el algoritmo de Edmonds-Karp con N
nodos y A
arcos es como máximo NA/2
. Prueba: cada ruta de aumento tiene un arco de cuello de botella , un arco que se elimina del gráfico residual porque corresponde a un arco de "empuje" que se llena al máximo o a un arco de "tirón" a través del cual el flujo se convierte en 0. Cada vez que un arco se convierte en un arco de cuello de botella, la longitud de cualquier ruta de aumento a través de él debe aumentar en un factor de 2. Esto se debe a que cada nodo en una ruta puede aparecer solo una vez o no aparecer en absoluto (según la definición de ruta ) ya que las rutas están siendo explorado del camino más corto al más largo, lo que significa que al menos un nodo más debe ser visitado por el siguiente camino que pasa por el nodo de cuello de botella en particular, lo que significa 2 arcos adicionales en el camino antes de que lleguemos al nodo . Dado que el trayecto de aumento tiene una longitud máxima de N
, cada arco puede estar en un máximo de N/2
trayectos de aumento, y el número total de trayectos de aumento es como máximo NA/2
.
El algoritmo de Edmonds-Karp se ejecuta en O(NA^2)
. Si se explorarán como máximo NA/2
rutas durante el algoritmo y explorar cada ruta con BFS es N+A
, entonces el término más significativo del producto y, por lo tanto, la complejidad asintótica es O(NA^2)
.
Sea mfp
un maxFlowProblemState
.
def edmonds_karp(mfp): sid, tid = mfp.sourceNodeUid, mfp.terminalNodeUid no_more_paths = False loop_count = 0 while(not no_more_paths): residual_digraph = get_residual_graph_of(mfp.G) try: asource = find_node_by_uid(mfp.sourceNodeUid, residual_digraph) aterm = find_node_by_uid(mfp.terminalNodeUid, residual_digraph) apath = bfs(asource, aterm, residual_digraph) paint_mfp_path(mfp, loop_count, apath) G = augment(apath, mfp.G) s = find_node_by_uid(sid, G) t = find_node_by_uid(tid, G) mfp = MaxFlowProblemState(G, s.uid, t.uid, mfp.maxFlowProblemStateUid) loop_count += 1 except StopIteration as e: no_more_paths = True return mfp
La versión anterior es ineficiente y tiene peor complejidad que O(NA^2)
ya que construye una nueva solución de flujo máximo y un nuevo gráfico residual cada vez (en lugar de modificar los dígrafos existentes a medida que avanza el algoritmo). Para llegar a una verdadera solución O(NA^2)
, el algoritmo debe mantener tanto el dígrafo que representa el estado del problema de flujo máximo como su gráfico residual asociado. Por lo tanto, el algoritmo debe evitar iterar sobre arcos y nodos innecesariamente y actualizar sus valores y valores asociados en el gráfico residual solo cuando sea necesario.
Para escribir un algoritmo de Edmonds Karp más rápido, reescribí varios fragmentos de código del anterior. Espero que revisar el código que generó un nuevo dígrafo haya sido útil para comprender lo que está sucediendo. En el algoritmo rápido, uso algunos trucos nuevos y estructuras de datos de Python que no quiero analizar en detalle. Diré que a.fromNode
y a.toNode
ahora se tratan como cadenas y uids a nodos . Para este código, deje que mfps
sea un maxFlowProblemState
import uuid def fast_get_mfps_flow(mfps): flow_from_s = {n for n in mfps.G.setOfNodes if n.uid == mfps.sourceNodeUid}.pop().datum.flowOut flow_to_t = {n for n in mfps.G.setOfNodes if n.uid == mfps.terminalNodeUid}.pop().datum.flowIn if( (flow_to_t - flow_from_s) > 0.00): raise Exception('Infeasible st flow') return flow_to_t def fast_e_k_preprocess(G): G = strip_flows(G) get = dict({}) get['nodes'] = dict({}) get['node_to_node_capacity'] = dict({}) get['node_to_node_flow'] = dict({}) get['arcs'] = dict({}) get['residual_arcs'] = dict({}) for a in G.setOfArcs: if(a.fromNode not in G.setOfNodes): err_msg = 'There is no Node {a.fromNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) if(a.toNode not in G.setOfNodes): err_msg = 'There is no Node {a.toNode.uid!s} to match the Arc from {a.fromNode.uid!s} to {a.toNode.uid!s}'.format(**locals()) raise KeyError(err_msg) get['nodes'][a.fromNode.uid] = a.fromNode get['nodes'][a.toNode.uid] = a.toNode lark = Arc(a.fromNode.uid, a.toNode.uid, FlowArcDatumWithUid(a.datum.capacity, a.datum.flow, uuid.uuid4())) if(a.fromNode.uid not in get['arcs']): get['arcs'][a.fromNode.uid] = dict({a.toNode.uid : dict({lark.datum.uid : lark})}) else: if(a.toNode.uid not in get['arcs'][a.fromNode.uid]): get['arcs'][a.fromNode.uid][a.toNode.uid] = dict({lark.datum.uid : lark}) else: get['arcs'][a.fromNode.uid][a.toNode.uid][lark.datum.uid] = lark for a in G.setOfArcs: if a.toNode.uid not in get['arcs']: get['arcs'][a.toNode.uid] = dict({}) for n in get['nodes']: get['residual_arcs'][n] = dict() get['node_to_node_capacity'][n] = dict() get['node_to_node_flow'][n] = dict() for u in get['nodes']: n_to_u_cap_sum = sum(a.datum.capacity for a in G.setOfArcs if (a.fromNode.uid == n) and (a.toNode.uid == u) ) n_to_u_flow_sum = sum(a.datum.flow for a in G.setOfArcs if (a.fromNode.uid == n) and (a.toNode.uid == u) ) if(n_to_u_cap_sum > n_to_u_flow_sum): flow = round(n_to_u_cap_sum - n_to_u_flow_sum, TOL) get['residual_arcs'][n][u] = Arc(n,u,ResidualDatum(flow, 'push')) if(n_to_u_flow_sum > 0.0): flow = round(n_to_u_flow_sum, TOL) get['residual_arcs'][u][n] = Arc(u,n,ResidualDatum(flow, 'pull')) get['node_to_node_capacity'][n][u] = n_to_u_cap_sum get['node_to_node_flow'][n][u] = n_to_u_flow_sum return get def fast_bfs(sid, tid, get): parent_of = dict([]) visited = frozenset([]) deq = coll.deque([sid]) while len(deq) > 0: n = deq.popleft() if n == tid: break for u in get['residual_arcs'][n]: if (u not in visited): visited = visited.union(frozenset({u})) parent_of[u] = get['residual_arcs'][n][u] deq.extend([u]) path = list([]) curr = tid while(curr != sid): if (curr not in parent_of): err_msg = 'No augmenting path from {} to {}.'.format(sid, curr) raise StopIteration(err_msg) path.extend([parent_of[curr]]) curr = parent_of[curr].fromNode path.reverse() return path def fast_edmonds_karp(mfps): sid, tid = mfps.sourceNodeUid, mfps.terminalNodeUid get = fast_e_k_preprocess(mfps.G) no_more_paths, loop_count = False, 0 while(not no_more_paths): try: apath = fast_bfs(sid, tid, get) get = fast_augment(apath, get) loop_count += 1 except StopIteration as e: no_more_paths = True nodes = frozenset(get['nodes'].values()) arcs = frozenset({}) for from_node in get['arcs']: for to_node in get['arcs'][from_node]: for arc in get['arcs'][from_node][to_node]: arcs |= frozenset({get['arcs'][from_node][to_node][arc]}) G = DiGraph(nodes, arcs) mfps = MaxFlowProblemState(G, sourceNodeUid=sid, terminalNodeUid=tid, maxFlowProblemStateUid=mfps.maxFlowProblemStateUid) return mfps
Aquí hay una visualización de cómo este algoritmo resuelve la red de flujo de ejemplo de arriba. La visualización muestra los pasos tal como se reflejan en el dígrafo G
que representa la red de flujo más actualizada y tal como se reflejan en el gráfico residual de esa red de flujo. Las rutas de aumento en el gráfico residual se muestran como rutas rojas, y el dígrafo que representa el problema, el conjunto de nodos y arcos afectados por una ruta de aumento dada, 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 (solo en negro).
Aquí hay otra visualización de cómo este algoritmo resuelve una red de flujo de ejemplo diferente. Tenga en cuenta que este usa números reales y contiene varios arcos con los mismos valores fromNode
y toNode
.
**También tenga en cuenta que debido a que los arcos con un ResidualDatum 'pull' pueden ser parte de la ruta de aumento, los nodos afectados en el DiGraph de la red volada _pueden no estar en una ruta en G!
.
Gráficos bipartitos
Supongamos que tenemos un dígrafo G
, G
es bipartito si es posible dividir los nodos en G.setOfNodes
en dos conjuntos ( part_1
y part_2
) de modo que para cualquier arco a
en G.setOfArcs
no puede ser cierto que a.fromNode
en part_1
y a.toNode
en part_1
. Tampoco puede ser cierto que a.fromNode
en part_2
y a.toNode
en part_2
.
En otras palabras, G
es bipartito si se puede dividir en dos conjuntos de nodos de modo que cada arco debe conectar un nodo de un conjunto con un nodo del otro conjunto.
Prueba bipartita
Supongamos que tenemos un dígrafo G
, queremos probar si es bipartito . Podemos hacer esto en O(|G.setOfNodes|+|G.setOfArcs|)
coloreando con avidez el gráfico en dos colores.
Primero, necesitamos generar un nuevo dígrafo H
. Este gráfico tendrá el mismo conjunto de nodos que G
, pero tendrá más arcos que G
. Cada arco a
en G
creará 2 arcos en H
; el primer arco será idéntico a a
, y el segundo arco invierte el 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 Máximo de Coincidencias
Supongamos que tenemos un dígrafo G
y matching
es un subconjunto de arcos de G.setOfArcs
. matching
es una coincidencia si para cualquiera de los dos arcos a
y b
matching
: len(frozenset( {a.fromNode} ).union( {a.toNode} ).union( {b.fromNode} ).union( {b.toNode} )) == 4
. En otras palabras, no hay dos arcos en una coincidencia que compartan un nodo .
Matching matching
, es una coincidencia máxima si no hay otra coincidencia alt_matching
en G
tal que len(matching) < len(alt_matching)
. En otras palabras, matching
es una coincidencia máxima si es el conjunto de arcos más grande de G.setOfArcs
que todavía satisface la definición de coincidencia (la adición de cualquier arco que no esté ya en la coincidencia romperá la definición de coincidencia ).
Una matching
máxima es una coincidencia perfecta si cada nodo n
en G.setOfArcs
existe un arco a
en matching
donde a.fromNode == n or a.toNode == n
.
Coincidencia bipartita máxima
Una coincidencia bipartita máxima es una coincidencia máxima en un dígrafo G
que es bipartito .
Dado que G
es bipartita , el problema de encontrar una coincidencia bipartita máxima se puede transformar en un problema de flujo máximo que se pueda resolver con el algoritmo de Edmonds-Karp y luego se puede recuperar la coincidencia bipartita máxima de la solución al problema de flujo máximo .
Sea bipartition
una bipartición de G
.
Para hacer esto, necesito generar un nuevo dígrafo ( H
) con algunos nodos nuevos ( H.setOfNodes
) y algunos arcos nuevos ( H.setOfArcs
). H.setOfNodes
contiene todos los nodos en G.setOfNodes
y dos nodos más, s
(un nodo fuente ) y t
(un nodo terminal ).
H.setOfArcs
contendrá un arco para cada G.setOfArcs
. Si un arco a
está en G.setOfArcs
y a.fromNode
está en bipartition.firstPart
y a.toNode
está en bipartition.secondPart
, entonces incluya a
en H
(agregando un FlowArcDatum(1,0)
).
Si a.fromNode
está en bipartition.secondPart
y a.toNode
está en bipartition.firstPart
, incluya Arc(a.toNode,a.fromNode,FlowArcDatum(1,0))
en H.setOfArcs
.
La definición de un gráfico bipartito asegura que ningún arco conecte ningún nodo donde ambos nodos estén en la misma partición. H.setOfArcs
también contiene un arco desde el nodo s
hasta cada nodo en bipartition.firstPart
. Finalmente, H.setOfArcs
contiene un arco de cada nodo en bipartition.secondPart
al nodo t
. a.datum.capacity = 1
para todo a
en H.setOfArcs
.
Primero divida los nodos en G.setOfNodes
los dos conjuntos disjuntos ( part1
y part2
) de modo que ningún arco en G.setOfArcs
se dirija de un conjunto al mismo conjunto (esta partición es posible porque G
es bipartito ). A continuación, agregue todos los arcos en G.setOfArcs
que se dirigen desde part1
a part2
en H.setOfArcs
. Luego cree un solo nodo de origen s
y un solo nodo terminal t
y cree algunos arcos más
Luego, construya 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
Cobertura de nodo mínimo
Una cubierta de nodo en un dígrafo G
es un conjunto de nodos ( cover
) de G.setOfNodes
tal que para cualquier arco a
de G.setOfArcs
esto debe ser cierto: (a.fromNode in cover) or (a.toNode in cover)
.
Una cobertura mínima de nodos es el conjunto más pequeño posible de nodos en el gráfico que sigue siendo una cobertura de nodos . El teorema de Konig establece que en un gráfico bipartito , el tamaño de la coincidencia máxima en ese gráfico es igual al tamaño de la cobertura de nodo mínima , y sugiere cómo se puede recuperar la cobertura de nodo de una coincidencia máxima :
Supongamos que tenemos la bipartición bipartition
y la máxima coincidencia matching
. Defina un nuevo dígrafo H
, H.setOfNodes=G.setOfNodes
, los arcos en H.setOfArcs
son la unión de dos conjuntos.
El primer conjunto son los arcos a
en matching
, con el cambio de que si a.fromNode in bipartition.firstPart
y a.toNode in bipartition.secondPart
, entonces a.fromNode
y a.toNode
se intercambian en el arco creado, dan a dichos arcos un a.datum.inMatching=True
atributo para indicar que se derivaron de arcos en una coincidencia .
El segundo conjunto son arcos a
NOT en matching
, con el cambio de que si a.fromNode in bipartition.secondPart
y a.toNode in bipartition.firstPart
entonces a.fromNode
y a.toNode
se intercambian en el arco creado (da a tales arcos a.datum.inMatching=False
).
A continuación, ejecute una búsqueda en profundidad (DFS) a partir de cada nodo n
en bipartition.firstPart
que no es ni n == a.fromNode
ni n == a.toNodes
para ningún arco a
en matching
. Durante el DFS, se visitan algunos nodos y otros no (almacene esta información en un campo n.datum.visited
). La cobertura mínima de nodos es la unión de los nodos {a.fromNode for a in H.setOfArcs if ( (a.datum.inMatching) and (a.fromNode.datum.visited) ) }
y los nodos {a.fromNode for a in H.setOfArcs if (a.datum.inMatching) and (not a.toNode.datum.visited)}
.
Se puede demostrar que esto lleva de una coincidencia máxima a una cobertura de nodo mínima mediante una prueba por contradicción, tome un arco a
que supuestamente no estaba cubierto y considere los cuatro casos con respecto a si a.fromNode
y a.toNode
pertenecen (ya sea como toNode
o fromNode
) a cualquier arco en coincidencia matching
. Cada caso conduce a una contradicción debido al orden en que DFS visita los nodos y al hecho de que la matching
es una coincidencia máxima .
Supongamos que tenemos una función para ejecutar estos pasos y devolver el conjunto de nodos que comprende la cobertura mínima de nodos cuando se le da el dígrafo G
y la matching
máxima :
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 coincidencia de peso máximo en un gráfico bipartito ponderado.
Problemas como el que se encuentra al comienzo de esta publicación se pueden expresar 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 la asignación de un trabajador a una tarea, queremos maximizar la suma de todas las asignaciones que hacemos; este es un problema de asignación lineal .
Suponga que el número de tareas y trabajadores es igual, aunque mostraré que esta suposición es fácil de eliminar. En la implementación, represento pesos de arco con un atributo a.datum.weight
para un arco a
.
WeightArcDatum = namedtuple('WeightArcDatum', [weight])
Algoritmo de Kuhn-Munkres
El Algoritmo de Kuhn-Munkres resuelve el problema de asignación lineal . Una buena implementación puede llevar O(N^{4})
tiempo (donde N
es el número de nodos en el dígrafo 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 DiGraphs ) y O(N^{4})
para (para una versión que mantiene DiGraphs ). Esto es similar a las dos implementaciones diferentes del algoritmo de Edmonds-Karp .
Para esta descripción, solo estoy trabajando con grafos bipartitos completos (aquellos donde la mitad de los nodos están en una parte de la bipartición y la otra mitad en la segunda parte). En el trabajador, la motivación por tareas esto significa que hay tantos trabajadores como tareas.
Esto parece una condición importante (¡qué pasa 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 descrita aquí utiliza el concepto útil de arcos de peso cero . Desafortunadamente, este concepto solo tiene sentido cuando estamos resolviendo una minimización (si en lugar de maximizar las ganancias de nuestras asignaciones de tareas de trabajadores, en cambio, minimicáramos el costo de tales asignaciones).
Afortunadamente, es fácil convertir un problema de asignación lineal máxima en un problema de asignación lineal mínima configurando cada arco a
pesos a Ma.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 del problema de minimización después de cambiar los pesos del arco . Entonces, para el resto, supongamos que hacemos este cambio.
El algoritmo de Kuhn-Munkres resuelve la coincidencia de peso mínimo en un gráfico bipartito ponderado mediante una secuencia de coincidencias máximas en gráficos bipartitos no ponderados. Si a encontramos una coincidencia perfecta en la representación del dígrafo del problema de asignación lineal , y si el peso de cada arco en la coincidencia es cero, entonces hemos encontrado la coincidencia de peso mínimo ya que esta coincidencia sugiere que todos los nodos en el dígrafo han sido emparejado por un arco con el costo más bajo posible (ningún costo puede ser menor que 0, según definiciones anteriores).
No se pueden agregar otros arcos a la coincidencia (porque todos los nodos ya están emparejados) y no se debe eliminar ningún arco de la coincidencia porque cualquier posible arco de reemplazo tendrá al menos un valor de peso igual de grande.
Si encontramos una coincidencia máxima del subgrafo de G
que contiene solo arcos de peso cero , y no es una coincidencia perfecta , no tenemos una solución completa (ya que la coincidencia no es perfecta ). Sin embargo, podemos producir un nuevo dígrafo H
cambiando los pesos de los arcos en G.setOfArcs
de manera que aparezcan nuevos arcos de peso 0 y la solución óptima de H
sea la misma que la solución óptima de G
. Como garantizamos que se produce al menos un arco de peso cero en cada iteración, garantizamos que llegaremos a una coincidencia perfecta en no más de |G.setOfNodes|^{2}=N^{2} iteraciones.
Supongamos que en bipartition bipartition
, bipartition.firstPart
contiene nodos que representan trabajadores y bipartition.secondPart
representa nodos que representan tareas.
El algoritmo comienza generando un nuevo dígrafo H
. H.setOfNodes = G.setOfNodes
. Algunos arcos en H
se generan a partir de nodos n
en bipartition.firstPart
. Cada nodo n
genera un arco b
en H.setOfArcs
para cada arco a
en bipartition.G.setOfArcs
donde a.fromNode = n
o 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) ))
.
Se generan más arcos en H
a partir de los nodos n
en bipartition.secondPart
. Cada nodo n
genera un arco b
en H.setOfArcs
para 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: A continuación, forme un nuevo dígrafo K
compuesto solo por los arcos de peso cero de H
y los nodos incidentes en esos arcos . Forme una bipartition
en los nodos en K
, luego use solve_mbm( bipartition )
para obtener una coincidencia máxima ( matching
) en K
. Si matching
es un emparejamiento perfecto en H
(los arcos en el matching
inciden en todos los nodos en H.setOfNodes
), entonces el matching
es una solución óptima para el problema de asignación lineal .
De lo contrario, si matching
no es perfecta , genere la cobertura de nodo mínima de K
usando node_cover = get_min_node_cover(matching, bipartition(K))
. A continuación, defina z=min({a.datum.weight for a in H.setOfArcs if a not in node_cover})
. Defina 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. Entonces arcs = arcs1.union(arcs2.union(arcs3))
. A continuación, H=DiGraph(nodes,arcs)
. Vuelva a la etiqueta KMA . El algoritmo continúa hasta que se produce una coincidencia perfecta . Esta coincidencia 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 matching
máxima en cada iteración; Al igual que las dos implementaciones anteriores de Edmonds-Karp, este algoritmo se puede modificar para que realice un seguimiento de la coincidencia y lo adapte de manera inteligente a cada iteración. Cuando se hace esto, 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})
. Los detalles tanto de la implementación más simple anterior como de la implementación más avanzada se pueden encontrar en esta publicación que motivó esta publicación de blog.
Ninguna de las operaciones sobre los pesos del arco modifica la asignación final devuelta por el algoritmo. He aquí por qué: dado que nuestros gráficos de entrada son siempre gráficos bipartitos completos, una solución debe asignar cada nodo en una partición a otro nodo en la segunda partición, a través del arco entre estos dos nodos . Tenga en cuenta que las operaciones realizadas en los pesos de los arcos nunca cambian el orden (ordenado por peso) de los arcos que inciden en un nodo en particular.
Por lo tanto, cuando el algoritmo termina en una coincidencia bipartita completa perfecta , a cada nodo se le asigna un arco de peso cero , ya que el orden relativo de los arcos de ese nodo no ha cambiado durante el algoritmo, y dado que un arco de peso cero es el arco más barato posible y el emparejamiento bipartito completo perfecto garantiza que existe uno de esos arcos 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 los pesos de los arcos .
Asignaciones desequilibradas
Parece que el algoritmo es bastante limitado ya que, como se describe, opera solo en grafos bipartitos completos (aquellos en los que la mitad de los nodos están en una parte de la bipartición y la otra mitad en la segunda parte). En el trabajador, la motivación por tareas esto significa que hay tantos trabajadores como tareas (parece bastante limitante).
Sin embargo, existe una transformación fácil que elimina esta restricción. Supongamos que hay menos trabajadores que tareas, agregamos algunos trabajadores ficticios (suficientes para hacer que el gráfico resultante sea un gráfico bipartito completo ). Each dummy worker has an arc directed from the worker to each of the tasks. Each such arc has weight 0 (placing it in a matching gives no added profit). After this change the graph is a complete bipartite graph which we can solve for. Any task assigned a dummy worker is not initiated.
Suppose that there are more tasks than workers. We add some dummy tasks (enough to make the resulting graph a complete bipartite graph ). Each dummy task has an arc directed from each worker to the dummy task. Each such arc has a weight of 0 (placing it in a matching gives no added profit). After this change the graph is a complete bipartite graph which we can solve for. Any worker assigned to dummy task is not employed during the period.
A Linear Assignment Example
Finally, let's do an example with the code I've been using. I'm going to modify the example problem from here. We have 3 tasks: we need to clean the bathroom , sweep the floor , and wash the windows .
The workers available to use are Alice , Bob , Charlie , and Diane . Each of the workers gives us the wage they require per task. Here are the wages per worker:
Clean the Bathroom | Sweep the Floor | Wash the Windows | |
---|---|---|---|
Alicia | $2 | $3 | $3 |
Beto | $3 | $2 | $3 |
charlie | $3 | $3 | $2 |
Diane | $9 | $9 | $1 |
If we want to pay the least amount of money, but still get all the tasks done, who should do what task? Start by introducing a dummy task to make the digraph representing the problem bipartite.
Clean the Bathroom | Sweep the Floor | Wash the Windows | Do Nothing | |
---|---|---|---|---|
Alicia | $2 | $3 | $3 | $0 |
Beto | $3 | $2 | $3 | $0 |
charlie | $3 | $3 | $2 | $0 |
Diane | $9 | $9 | $1 | $0 |
Supposing that the problem is encoded in a digraph , then kuhn_munkres( bipartition(G) )
will solve the problem and return the assignment. It's easy to verify that the optimal (lowest cost) assignment will cost $5.
Here's a visualization of the solution the code above generates:
That is it. You now know everything you need to know about the linear assignment problem.
You can find all of the code from this article on GitHub.
Lecturas adicionales en el blog de ingeniería de Toptal:
- Grafique la ciencia de datos con Python/NetworkX