에드먼즈-카프 알고리즘은 다음과 같이 진행했다.
최단 경로를 하나 찾아 흐름을 보내는 데에 의 시간이 걸리고, 최단 경로의 개수는 개 이므로 전체 시간복잡도는 이다.
그런데 굳이 증가 경로를 하나씩 찾지 않고, 한 번에 여러 개를 찾아 흐름을 보낼 수 있다면 조금 더 빠르게 최대 유량을 찾아낼 수도 있지 않을까? 우리는 다음의 사실을 알고 있다.
에드먼즈-카프 알고리즘을 수행하는 경우(즉 최단 경로를 증가 경로 로 두고, 를 추가로 흘려보내는 경우), 잔여 네트워크 에서의 최단 거리 는 단조 증가한다.
경로 의 길이는 길어봐야 이기 때문에, 경로의 길이가 같은 것들을 묶어 한 번에 처리할 수 있다면 번만 반복해도 최대 유량 문제를 해결할 수 있다.
디닉 알고리즘이 바로 가 같은 것들을 묶어 한 번에 처리하는 알고리즘으로, 의 시간복잡도를 가진다. 는 적어도 O()에 많으면 이니, 에드먼즈-카프 알고리즘보다는 좀 더 빠르다.
우리에게는 "디닉 알고리즘"으로 잘 알려져 있지만, 사실은 이 알고리즘을 고안한 디니츠(Dinitz)의 이름이 잘못 알려져서라고 한다.
임의의 유향 그래프 와 시작 정점 를 생각해보자. 에서 시작해 BFS를 진행하면 와 다른 모든 정점 사이의 최단 거리를 구할 수 있고, 이를 기준으로 와 를 몇 개의 부분 집합으로 나눌 수 있다. 는 에서 로의 최단 거리다.
정점 레이어(vertex layer)
번째 정점 레이어 는 로부터의 최단 거리가 인 모든 정점들의 집합이다.
간선 레이어(edge layer)
번째 간선 레이어 는 에서 로 이어지는 모든 간선들의 집합이다.
이렇게 의 부분 집합들을 가지고 새롭게 유향 그래프 를 정의해보자.
레이어드 네트워크(layered network)
유향 그래프 와 시작 정점 가 주어졌을 때, 레벨 그래프 는 로부터 도달할 수 있는 의 모든 정점들과 해당 정점들로의 최단 경로 상에 있는 모든 간선들로 이루어진 유향 그래프다.
어떤 정점 가 있다고 해보자. 를 이용하면 모든 최단 경로들도 찾을 수 있다. 에서 시작해 로 끝나는 최단 경로들에 포함된 모든 정점들과 간선들로 이루어진 그래프를 라 하자. 는 를 구성한 후, 에서 역방향으로 BFS를 수행하면 얻을 수 있다.
를 소스, 를 싱크로 갖는 유량 네트워크 와 흐름 가 주어졌을 때, 잔여 네트워크 를 가지고 레이어드 네트워크 를 만들었다고 해보자(이후 생략).
에드먼즈-카프 알고리즘에서 그랬듯 의 최단 경로를 하나 골라 증가 경로 로 선택하자. 이 경로는 당연히 에서 찾을 수 있다. 이 경로를 따라 의 유량을 추가로 흘렸을 때 유량의 값은 가 된다고 해보자.
이제 에서 다음 증가 경로 를 찾아야 한다. 증가 경로의 길이는 단조 증가하므로 의 길이는 보다 길거나 같은데, 두 경로의 길이가 서로 같은 경우를 생각해보자.
만약 에드먼즈-카프 알고리즘을 사용했다면 또 의 시간을 들여 증가 경로를 찾아야 했겠지만, 다행히 우리는 이미 찾아놓았던 를 활용할 수 있다.
의 레이어드 네트워크 에서 의 핵심 간선들을 제거한 것을 라 하자. 에 의 흐름을 추가로 보낸 후의 잔여 네트워크를 라 할 때 다음이 성립한다.
위 정리를 이용하면 에서 찾은 경로를 의 증가 경로로도 사용할 수 있다.
에 에서 찾은 최단 경로 를 따라 의 유량을 추가로 흘려보냈을 때 어떤 일이 일어나는지를 생각해보자. 가 위의 간선일 때,
가 와 달라지는 경우는 위의 두 경우 밖에 없다. 즉 에서 의 핵심 간선들을 삭제하고, 위의 간선 몇 개의 역방향 간선이 추가하면 를 만들 수 있다. 한편 는 의 부분집합이며, 의 핵심 간선들을 삭제하기만 한 것이기 때문에 의 부분집합이 된다.
에서 에드먼즈-카프 알고리즘을 실행할 때, 모든 정점 에 대해 매 유량 증가마다 잔여 네트워크 에서의 와 는 단조 증가한다.
이전 글에서 매 유량 증가마다 가 단조 증가함은 보인 적이 있다. 마찬가지의 방법으로 매 유량 증가마다 또한 단조 증가함을 보일 수 있다.
의 레이어드 네트워크를 라 할 때, 라면 다.
이제 를 가지고 레이어드 네트워크 를 만든다고 해보자. 의 모든 최단 경로는 를 통해 찾을 수 있다. 만약 이면서 에서 찾을 수 있는 모든 임의의 최단 경로를 에서도 찾을 수 있다면, 만으로도 의 증가 경로를 찾을 수 있게 될 것이다.
에서는 찾을 수 있지만, 에서는 찾을 수 없는 최단 경로가 있다고 가정하자. 만약 그렇다면 이 경로에는 에서는 찾을 수 있지만 에서는 찾을 수 없는 간선이 하나 이상 포함되어 있다. 그 중 하나를 라 한다면 다음의 두 경우가 가능하다.
만약 였다면 이 간선은 에서 찾았던 증가 경로 를 따라 흐름을 보내면서 삭제된 간선이어야 한다. 하지만 이렇게 삭제된 간선은 에서도 여전히 삭제된 상태이므로 에 포함될 수 없다.
그렇다면 라 해보자. 이는 에서 찾을 수 있는 최단 경로들에 간선이 포함되지 않는다는 것이다. 를 포함하는 경로 중 가장 짧은 것은 최단 경로, , 최단 경로를 거치는 것이고, 그 길이는 이다.
한편 이므로 이 간선은 에서의 최단 경로들 중 적어도 하나에는 포함되고, 그 길이는 이다.
그런데 위 Lemma에 따르면 이고 여야 한다. 이는 라는 가정과 모순이다.
따라서 는 의 원소일 수 없다.
는 의 원소이므로 다. 였다가 유량 증가와 함께 가 됐을 때, 가능한 경우는 인 간선 가 경로 위에 존재하다, 유량이 증가하면서 인 간선 가 잔여 네트워크에 추가되는 경우 밖에 없다.
가 에 포함되는 간선이므로 다음과 같이 쓸 수 있다.
또한 로부터 다음과 같이 쓸 수 있다.
이 둘을 더하면 아래와 같다(를 가정했으므로).
위 Lemma에 따라 이고, 이므로, 다음과 같은 모순이 발생한다.
따라서 도 있을 수 없다.
어떠한 경우에도 모순이므로 이면서 인 간선은 존재할 수 없다. 따라서 다.
지금까지 알아낸 사실들을 정리해보자.
중간 정리
1. 잔여 네트워크 가 주어진다면 레이어드 네트워크 를 만든다. 에서 최단 경로 를 찾을 수 있다면 이는 의 증가 경로다.
2. 를 따라 의 흐름을 보냈을 때, 유량이 로 증가했다고 하자. 잔여 네트워크 에 존재하는 최단 증가 경로 의 길이가 와 같다면, 그 도 (정확히는 에서 의 핵심 간선들이 제거된 )에서 찾아낼 수 있다(Theorem 1과 2).
어떠한 흐름도 없는 초기 상태에서부터 더 이상 유량을 증가시킬 수 없을 때까지 잔여 그래프의 최단 경로를 따라 유량을 증가시키기를 반복할 때, 그 증가 경로의 길이는 단조 증가한다.
이때 증가 경로의 길이가 같은 것들을 묶은 것을 페이즈(phase)라고 해보자. 증가 경로의 길이가 인 페이즈의 맨 첫 번째 잔여 그래프를 이라 하고, 그 레이어드 네트워크는 라 해보자. 다음의 과정을 통해 길이 의 증가 경로를 따라 보낼 수 있는 유량을 모두 보낼 수 있다.
하나 짚고 넘어가야 할 점은 에서 경로의 핵심 간선을 제거한 는 레이어드 네트워크가 아닐 수 있다는 것이다. 레이어드 네트워크는 데드엔드(dead-end)가 없다는 성질을 가져야 한다.
데드엔드(dead-end)
가 아닌 시작점이거나 가 아닌 종료점인 정점. 다시 말해 가 아니면서 자신으로 들어오는 간선이 없는 정점, 혹은 가 아니면서 자신으로부터 나가는 간선이 없는 정점.
의 경우 의 최단 경로를 모아 만들기 때문에 가 아닌 시작점이나 가 아닌 종료점이 존재하지 않지만, 이와 달리 는 레이어드 네트워크인 에서 어떤 경로의 핵심 간선들을 삭제해 만들기 때문에, 핵심 간선들을 삭제하면서 몇 개의 데드엔드가 발생할 수 있다.
아래와 같은 가 있다고 하자. 간단하게, 를 제외한 각 레이어의 정점들은 말 그대로 바로 옆에 있는 한 정점으로의 간선만을 가진다고 해보자.
DFS를 통해 증가 경로를 찾았을 때, 간선 가 핵심 간선이 되어 사라졌다고 하자. 정점 는 데드엔드가 되었다.
이제 다음 증가 경로를 찾아보자. DFS를 통해 최단 경로를 따라 가다, 가 데드엔드임을 확인한다. 쭉 돌아와 경로를 최단 경로로 찾는다고 하자. 이 경로의 핵심 간선은 였고, 흐름이 추가됨과 함께 사라진다. 이제는 도 데드엔드다.
또 증가 경로를 찾으면 경로 와 를 탐색한 후, 의 경로를 찾게 된다.
만약 데드엔드로 향하는 경로를 적절히 사라지게 했다면 어땠을까? 가 데드엔드가 됐을 때, 로 향하는 경로를 없애보자.
의 최단 경로를 곧바로 찾을 수 있다. 마찬가지로 가 핵심 간선이라 가 데드엔드가 됐다고 하자. 로 향하는 경로를 없애면,
의 경로를 곧바로 찾을 수 있다.
사실 데드엔드가 남아있는 를 그대로 사용하더라도 최단 경로는 (존재한다면) 반드시 찾을 수 있다. 하지만 데드엔드로의 경로를 매번 탐색하게 되면 시간복잡도가 어마무시하게 높아져버릴지도
모른다.
데드엔드를 삭제하는 과정은 다음과 같다. 유량 증가와 함께 간선 가 삭제된다고 하자.
이렇게 로부터 발생할 수 있는 데드엔드로의 경로를 모두 삭제하면, 최단 경로들에 포함되는 간선들만이 남게 되어 의 레이어드 네트워크 이 된다.
이제 최대 유량을 찾아보자. 최대 유량을 반환할 때까지 아래를 반복한다.
디닉 알고리즘의 각 페이즈를 종료하는 데 걸리는 시간은 이다.
각 페이즈에서,
위 분석대로라면, 2번을 한 번 수행하는 데에는 최대 의 시간이 걸린다. 한 페이즈에서 증가 경로는 많아야 핵심 간선의 수만큼 있고, 핵심 간선은 많아야 개 있으므로, 2번은 번 반복될 수 있다. 따라서
디닉 알고리즘의 한 페이즈가 종료되는 데 걸리는 시간은 다.
가 아니라, 사실 2-3에서 사라진 간선들은 이후 같은 페이즈의 반복에서는 다시 나타나지 않는다. 때문에 2-3은 한 페이즈 전체에서 많아봐야 번 수행될 수 있다. 그러므로 사실 한 페이즈를 종료하는 데 걸리는 시간은 가 아니라 다.
디닉 알고리즘의 총 시간복잡도는 다.
한 페이즈는 최단 경로의 길이가 같은 것들을 묶은 것이다. 이 경로의 길이는 길어봐야 이므로, 서로 다른 페이즈의 개수도 다.
한 페이즈가 종료되는 데 의 시간이 걸리니, 개의 페이즈가 종료되기 위한 총 시간복잡도는 다.
Shimon Even과 Alon Itai의 버전의 디닉 알고리즘은, 지금까지 이야기 해온 디니츠의 오리지널 버전과는 조금 차이가 있다. 가장 큰 차이는 를 사용하지 않고 를 사용한다는 점이다.
오리지널 버전에서는 에서 BFS를 수행해 를 만들고, 다시 에서 BFS를 수행해 의 최단 경로만을 남겼다.
그런데 사실 꼭 를 어떤 자료구조로 구현해야 할 필요는 없다. 의 최단 경로 탐색이 가능하기만 하면 되고, 이는 만으로도 충분하다.
이 구현에서는 이미 증가 경로가 아님이 확인된 경로를 재탐색하지 않기 위한 백트래킹 테크닉을 하나 사용한다. 이를 이용하면 에 있는, 가 아닌 종점들로 향하는 경로를 탐색하는 일을 막을 수 있고, 또한 간선 삭제로 인해 발생하는 데드 엔드 경로도 굳이 삭제할 필요가 없어진다.
이번에는 2367번 파티를 디닉 알고리즘을 이용해 풀어보자. Shimon Even과 Alon Itai의 구현을 따른다.
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
#define fastio cin.tie(NULL); cout.tie(NULL); ios_base::sync_with_stdio(false)
#define all(v) v.begin(), v.end()
const int INF = 1e9;
struct Flow_Network{
struct Edge {//간선
int to;//어디로 가는 간선인지
int capacity;//용량
int flow;//흐름
Edge(int _to, int _cap) : to(_to), capacity(_cap), flow(0) {}
Edge *reverse;//역방향 간선
void push(int f) {//(u,v)로 흐름 증가. 역방향 간선인 (v,u)에는 흐름 감소
flow += f;
reverse->flow -= f;
}
};
vector<vector<Edge*>> edges;//한 정점으로부터 시작하는 간선들의 리스트
vector<int> layer, work;//정점들이 속한 레이어, 가장 최근에 본 간선 인덱스
int source = 0, sink = 0;
Flow_Network(int N): source(0), sink(N + 1), edges(N + 2), layer(N + 2), work(N + 2) {}
void add_edge(int u, int v, int c) {//용량이 c인 간선 (u, v)
Edge *edge = new Edge(v, c);//v로 향하는 용량 c의 간선
Edge *reverse_edge = new Edge(u, 0);//역방향 간선 (v, u)
//두 간선의 역방향 관계 설정
edge->reverse = reverse_edge;
reverse_edge->reverse = edge;
edges[u].emplace_back(edge);//정점 u에 간선을 추가
edges[v].emplace_back(reverse_edge);//정점 v에 간선을 추가
}
bool find_vertex_layer() {//레이어드 네트워크 L(s)의 정점 레이어 구성
queue<int> q;
q.push(source);
fill(all(layer), -1);//미방문한 정점 표시
layer[source] = 0;//소스는 V_0에 속함.
//BFS를 통한 레이어드 네트워크 구성
while (!q.empty()) {
int from = q.front();
q.pop();
for (auto &edge: edges[from]) {//연결된 간선들을 보면서
int to = edge->to;
if (layer[to] != -1) continue;//방문한 정점인 경우는 제외
if (edge->capacity - edge->flow > 0) {//잔여 용량이 있는 경우
q.push(to);//큐에 추가
layer[edge->to] = layer[from] + 1;//d(v) = d(u) + 1
}
}
}
return layer[sink] != -1;//싱크에 방문하지 못했다면 더 이상 증가 경로를 찾을 수 없음.
}
int augment_flow(int cur, int flow) {//증가 경로를 찾고 해당 경로로 보낼 수 있는 흐름을 찾아 보냄
if (cur == sink) return flow;
//현재 정점과 연결된 간선들을 보되, 이미 지나간 건 다시 볼 필요가 없음.
for (int &i = work[cur]; i < edges[cur].size(); i++) {
Edge *edge = edges[cur][i];
int to = edge->to;
int residual_capacity = edge->capacity - edge->flow;
if (layer[to] != layer[cur] + 1) continue;//L_f에 포함된 간선이 아니면 제외
if (residual_capacity <= 0) continue;//잔여 용량이 없으면 제외
//이후 t로의 경로에 흘려보낼 수 있는 흐름을 찾기
int f_p = augment_flow(to, min(flow, residual_capacity));
if (f_p > 0) {//보낼 수 있다면
edge->push(f_p);//보냄
return f_p;
}
}
return 0;
}
int dinic() {
int max_flow = 0;//최대 유량
while (find_vertex_layer()) {//s->t 최단 경로를 찾을 수 있나?
//최단 경로가 존재한다면
fill(all(work), 0);//각 정점에서 최근에 본 간선 인덱스를 0으로 설정
while (true) {
//증가 경로를 찾고, 해당 경로로 보낼 수 있는 흐름을 찾아 보냄.
int f = augment_flow(source, INF);
//최대 유량 추가
max_flow += f;
//더 이상 보낼 수 없다면 정지
if (!f) break;
}
}
return max_flow;
}
};
int main() {
fastio;
int N, K, D;
cin >> N >> K >> D;
Flow_Network network(N + D);
for (int i = 1; i <= D; i++) {
int M;
cin >> M;
network.add_edge(N + i, network.sink, M);
}
for (int i = 1; i <= N; i++) {
network.add_edge(network.source, i, K);
int Z;
cin >> Z;
for (int j = 0; j < Z; j++) {
int food;
cin >> food;
network.add_edge(i, N + food, 1);
}
}
cout << network.dinic();
}
main
은 이전과 동일하다. struct Flow_Network
내부만 보자.
struct Edge
struct Edge {//간선
int to;//어디로 가는 간선인지
int capacity;//용량
int flow;//흐름
Edge(int _to, int _cap) : to(_to), capacity(_cap), flow(0) {}
Edge *reverse;//역방향 간선
void push(int f) {//(u,v)로 흐름 증가. 역방향 간선인 (v,u)에는 흐름 감소
flow += f;
reverse->flow -= f;
}
};
Edge
는 간선을 담기 위한 구조체다. 간선이라고 한다면, 이 간선은 edges[u]
에 to =
v인 간선을 추가한다. push()
를 통해 간선을 따라 흐름을 보낸다.
add_edge()
void add_edge(int u, int v, int c) {//용량이 c인 간선 (u, v)
Edge *edge = new Edge(v, c);//v로 향하는 용량 c의 간선
Edge *reverse_edge = new Edge(u, 0);//역방향 간선 (v, u)
//두 간선의 역방향 관계 설정
edge->reverse = reverse_edge;
reverse_edge->reverse = edge;
edges[u].emplace_back(edge);//정점 u에 간선을 추가
edges[v].emplace_back(reverse_edge);//정점 v에 간선을 추가
}
크게 볼 것은 없다.
dinic()
int dinic() {
int max_flow = 0;//최대 유량
while (find_vertex_layer()) {//정점 레이어를 구성
fill(all(work), 0);//각 정점에서 최근에 본 간선 인덱스를 0으로 설정
while (true) {
//증가 경로를 찾아 해당 경로로 보낼 수 있는 흐름을 찾아 보낸다.
int f = augment_flow(source, INF);
//최대 유량 추가
max_flow += f;
//더 이상 보낼 수 없다면 정지
if (!f) break;
}
}
return max_flow;
}
find_vertex_layer()
bool find_vertex_layer() {//레이어드 네트워크 L(s)의 정점 레이어 구성
queue<int> q;
q.push(source);
fill(all(layer), -1);//미방문한 정점 표시
layer[source] = 0;//소스는 V_0에 속함.
//BFS를 통한 레이어드 네트워크 구성
while (!q.empty()) {
int from = q.front();
q.pop();
for (auto &edge: edges[from]) {//연결된 간선들을 보면서
int to = edge->to;
if (layer[to] != -1) continue;//방문한 정점인 경우는 제외
if (edge->capacity - edge->flow > 0) {//잔여 용량이 있는 경우
q.push(to);//큐에 추가
layer[edge->to] = layer[from] + 1;//d(v) = d(u) + 1
}
}
}
return layer[sink] != -1;//싱크에 방문하지 못했다면 더 이상 증가 경로를 찾을 수 없음.
}
BFS를 통해 레이어드 네트워크 의 정점 레이어를 구성한다. 소스부터 시작해 연결된 각 간선들을 본다. 미방문한 정점인 경우, 잔여 용량이 있는지 확인한다. 잔여 용량이 있다면 큐에 추가하고, 해당 정점이 어떤 레이어에 속하는지를 기록한다.
만약 layer[sink] == -1
이라면 더 이상 싱크로의 경로가 존재하지 않는다는 것, 즉 더 이상의 증가 경로가 없다는 말이다. 여기서 디닉 알고리즘은 종료된다.
의 최단 경로들을 담은 가 아니라, 에서 모든 정점으로의 최단 경로를 담은 다.
augment_flow()
int augment_flow(int cur, int flow) {//증가 경로를 찾고 해당 경로로 보낼 수 있는 흐름을 찾아 보냄
if (cur == sink) return flow;//싱크인 경우는 종료한다.
//현재 정점과 연결된 간선들을 보되, 이미 지나간 건 다시 볼 필요가 없음.
for (int &i = work[cur]; i < edges[cur].size(); i++) {
Edge *edge = edges[cur][i];
int to = edge->to;
int residual_capacity = edge->capacity - edge->flow;
if (layer[to] != layer[cur] + 1) continue;//L_f(s)에 포함된 간선이 아니면 제외
if (residual_capacity <= 0) continue;//잔여 용량이 없으면 제외
//이후 t로의 경로에 흘려보낼 수 있는 흐름을 찾기
int f_p = augment_flow(to, min(flow, residual_capacity));
if (f_p > 0) {//보낼 수 있다면
edge->push(f_p);//보냄
return f_p;
}
}
return 0;
}
증가 경로를 찾고 해당 경로로 흐름을 보내는 함수다. 파라미터 flow
는 현재 정점 cur
까지의 경로에서 흐를 수 있는 흐름으로, 초기값은 INF
로 둔다.
현재 보고 있는 정점과 연결된 간선들을 보자. 해당 간선을 라 할 때, 이 에 포함된 간선인지는 layer[to] = layer[cur] + 1
인지, 그리고 잔여 용량이 남아있는지를 통해 확인할 수 있다. 만약 그렇다면 flow
와 간선의 잔여 용량 중 작은 것을 이후 경로로 흘려보내기를 시도할 수 있다.
DFS가 가 아닌 위에서 수행되므로, 가 아닌 다른 정점이 DFS의 종점이 되는 경우가 발생할 수 있다. 이 경우에는 아래에서 이 반환되기에 걸러진다.
눈여겨 볼 것은 vector<int> work
를 통해 이미 본 탐색했던 간선들을 스킵하는 테크닉이다. 위 for
문에서 i
가 증가하는 경우는 edges[cur][i]
에 해당하는 간선을 지났을 때 로의 증가 경로를 찾지 못하는 경우 밖에 없다. 이 정보를 work[cur]
에 저장해둔다면, 로의 증가 경로가 존재하지 않는 것이 확실한 간선들에 대한 탐색을 걸러낼 수 있기에 실행 시간을 훨씬 줄일 수 있다.
References
- Dinitz, Yefim. (2006). Dinitz’ Algorithm: The Original Version and Even’s Version. 218-240. 10.1007/11685654_10.
- https://gazelle-and-cs.tistory.com/84