← 문제 풀이 목록

백준 17412: 도시 왕복하기 1

/ 16분 분량 / 문제 풀이

백준의 "도시 왕복하기 1" 문제를 풀어보았습니다. 이 문제는 주어진 도시들을 연결하는 경로를 최대한 많이 찾는 문제로, 최대 유량(Maximum Flow) 알고리즘을 활용해야 하는 문제입니다.

백준 17412: 도시 왕복하기 1

백준의 "도시 왕복하기 1" 문제를 풀어보았습니다. 이 문제는 주어진 도시들을 연결하는 경로를 최대한 많이 찾는 문제로, 최대 유량(Maximum Flow) 알고리즘을 활용해야 하는 문제입니다.

1. 문제 소개

  • 문제 번호: 17412
  • 문제명: 도시 왕복하기 1
  • 난이도 (티어): Platinum IV
  • 사용 언어: C++
  • 실행 시간: 4 ms
  • 메모리: 3556 KB

문제 요약:
N개의 도시와 P개의 양방향 도로가 주어집니다. 각 도로는 한 번만 사용할 수 있다는 제약이 있습니다. 1번 도시에서 2번 도시로 가는 서로 다른 경로의 최대 개수를 구하는 문제입니다. "서로 다른 경로"란, 경로를 구성하는 도로의 집합이 서로 다른 경우를 의미합니다.

2. 접근 방법

이 문제는 최대 유량(Maximum Flow) 문제로 접근해야 합니다. 특히, 각 간선(도로)의 용량이 1인 경우에 해당하며, 에드몬즈-카프(Edmonds-Karp) 알고리즘과 같은 최대 유량 알고리즘을 적용할 수 있습니다.

  • 알고리즘/자료구조: 최대 유량 알고리즘 (에드몬즈-카프), BFS(너비 우선 탐색), 인접 리스트, 간선 용량 및 유량 관리 배열.
  • 선택 이유:
    • 문제에서 "각 간선은 한 번만 사용할 수 있다"는 제약은 각 간선의 용량을 1로 설정하는 것과 같습니다.
    • "서로 다른 경로의 최대 개수"는 출발지에서 도착지로 흘려보낼 수 있는 최대 유량과 같습니다. 에드몬즈-카프 알고리즘은 BFS를 사용하여 증가 경로(augmenting path)를 찾고 유량을 흘려보내면서 최대 유량을 계산합니다. 각 증가 경로 탐색 시 BFS를 사용하므로, 이 알고리즘이 문제의 요구사항에 부합합니다.

3. 풀이 과정

에드몬즈-카프 알고리즘을 이용하여 최대 유량을 구하는 과정은 다음과 같습니다.

  1. 그래프 초기화:

    • 각 도시를 노드로, 도로는 간선으로 표현합니다.
    • 각 간선의 **용량(capacity)**을 1로 설정합니다. 이는 각 도로를 한 번만 사용할 수 있다는 제약 때문입니다.
    • **유량(flow)**은 현재 흐르는 양을 나타내며, 초기에는 모두 0입니다.
    • **인접 리스트(adj)**를 사용하여 도시 간의 연결 관계를 저장합니다. 양방향 도로이므로, u에서 v로 가는 도로가 있다면 adj[u]에 v를, adj[v]에 u를 추가합니다.
  2. BFS를 이용한 증가 경로 탐색 (반복):

    • 출발지(1번 도시)에서 도착지(2번 도시)까지 잔여 용량이 1 이상인 간선들로만 구성된 경로를 찾습니다. 이 경로를 **증가 경로(augmenting path)**라고 합니다.
    • BFS를 사용하여 이 경로를 탐색합니다. BFS 과정에서 parent 배열을 사용하여 경로를 역추적할 수 있도록 합니다.
    • 잔여 용량: capacity[u][v] - flow[u][v]로 계산됩니다. 이 값이 0보다 커야 해당 간선을 지날 수 있습니다.
    • 만약 BFS를 통해 도착지(2번 도시)에 도달할 수 있다면, 증가 경로를 찾은 것입니다.
    • 만약 BFS가 종료되었는데 도착지에 도달하지 못했다면, 더 이상 보낼 수 있는 경로가 없으므로 알고리즘을 종료합니다.
  3. 유량 업데이트:

    • 증가 경로를 찾았다면, 해당 경로를 따라 유량을 1만큼 흘려보냅니다.
    • 경로 상의 각 간선 (u, v)에 대해:
      • 정방향 유량 flow[u][v]를 1 증가시킵니다. (즉, capacity[u][v] - flow[u][v]가 1 감소합니다. 이제 이 간선은 정방향으로는 더 이상 지나갈 수 없습니다.)
      • 역방향 유량 flow[v][u]를 1 감소시킵니다. (이것은 잔차 그래프(residual graph) 개념으로, 나중에 다른 경로에서 이 간선을 역방향으로 사용하여 유량을 상쇄하거나 재배치할 기회를 제공합니다.)
  4. 총 유량 누적:

    • 하나의 증가 경로를 성공적으로 찾고 유량을 흘려보낼 때마다, 총 유량을 1 증가시킵니다.
  5. 종료:

    • 더 이상 증가 경로를 찾을 수 없을 때까지 2~4 단계를 반복합니다.
    • 최종적으로 계산된 총 유량이 문제에서 요구하는 서로 다른 경로의 최대 개수입니다.

핵심 아이디어

  • 간선 용량 1: 각 도로는 한 번만 사용할 수 있으므로, 용량을 1로 설정합니다.
  • BFS로 증가 경로 찾기: 잔여 용량이 있는 경로를 BFS로 탐색하여 가장 짧은 증가 경로를 찾습니다.
  • 역방향 간선 활용: flow[v][u] -= 1; 연산을 통해 이미 사용된 간선도 다른 경로에서 역방향으로 활용할 수 있게 하여 최대 유량을 찾는 데 기여합니다.

주의할 점

  • 인접 리스트 초기화: 양방향 도로이므로 adj[u].push_back(v);와 adj[v].push_back(u);를 모두 해주어야 합니다.
  • 용량과 유량: capacity는 고정된 값이고, flow는 동적으로 변하는 값입니다. capacity - flow > 0 조건을 통해 잔여 용량을 확인해야 합니다.
  • fill(parent, parent + MAX, -1): BFS를 시작할 때마다 parent 배열을 초기화하여 새로운 경로 탐색이 가능하도록 해야 합니다.

4. 코드 설명

아래는 위에서 설명한 에드몬즈-카프 알고리즘을 C++로 구현한 코드입니다. JSON의 '풀이_코드' 필드 내용을 그대로 가져왔으며, 수정 없이 사용했습니다.

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

using namespace std;

// 도시의 수는 최대 400개 (1번~N번 도시)
const int MAX = 401;

// 장부 기록용 배열들
int capacity[MAX][MAX]; // 통로의 한계치 (용량)
int flow[MAX][MAX];     // 현재 실제로 흐르는 물의 양 (유량)
int parent[MAX];        // 이번 BFS에서 어떤 도시를 거쳐왔는지 기록 (경로 역추적용)
vector<int> adj[MAX];   // 도시들 간의 연결 관계 (인접 리스트)

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int N, P;
    cin >> N >> P;

    for (int i = 0; i < P; i++) {
        int u, v;
        cin >> u >> v;
        
        // 1. 정방향과 역방향 모두 길을 열어줌
        // 양방향 도로이므로, 연결 리스트에 양쪽으로 추가합니다.
        adj[u].push_back(v);
        adj[v].push_back(u); 
        
        // 2. 문제 조건: 각 간선은 한 번만 쓸 수 있으므로 용량은 1
        // capacity[u][v] = 1; 는 capacity[v][u] = 1; 와 독립적으로 설정됩니다.
        // (이는 최대 유량의 원리에 따라, 역방향 간선은 사실상 존재하지 않는다고 보지만,
        //  잔차 그래프에서의 유량을 조절하기 위해 필요합니다.)
        capacity[u][v] = 1; 
    }

    int totalFlow = 0;
    int Start = 1;  // 출발지: 1번 도시
    int Target = 2; // 도착지: 2번 도시

    // [BFS 무한 반복] 가능한 최대한 많은 서로 다른 경로를 찾기 위해!
    // 에드몬즈-카프 알고리즘의 핵심으로, 새로운 증가 경로를 계속 찾습니다.
    while (true) {
        // 매 pass마다 방문 체크(부모 기록)를 초기화하여 새로운 경로를 발굴할 준비를 함
        // parent[i] = -1 이면 아직 방문하지 않았다는 뜻입니다.
        fill(parent, parent + MAX, -1);
        queue<int> q;
        
        q.push(Start);
        parent[Start] = Start; // 출발지는 자기 자신을 부모로 하여 BFS 시작 표시

        // BFS 수행: 도착지(Target)에 도달할 때까지 큐를 탐색합니다.
        while (!q.empty() && parent[Target] == -1) {
            int curr = q.front();
            q.pop();

            // 현재 도시(curr)에서 갈 수 있는 모든 인접 도시(next)를 탐색합니다.
            for (int next : adj[curr]) {
                // 논리 3: 정방향이든 역방향이든 '잔여 유량'이 있어야만 갈 수 있는 길로 간주함
                // (capacity[curr][next] - flow[curr][next]) > 0 조건이 중요합니다.
                // 이 조건이 정방향 간선의 잔여 용량을 확인하고,
                // 만약 flow[next][curr]가 음수라면 (즉, 역방향 유량이 존재한다면)
                // capacity[curr][next] - flow[curr][next] 값이 커지게 되어 역방향 통과 기회를 판별합니다.
                if (capacity[curr][next] - flow[curr][next] > 0 && parent[next] == -1) {
                    q.push(next);
                    parent[next] = curr; // next 도시의 부모를 curr로 기록하여 경로를 저장합니다.
                }
            }
        }

        // 논리 1: 큐가 빌 때까지 쑤셔봤는데도 Target에 도달 못 했다면?
        // 즉, parent[Target]이 여전히 -1이라면, 더 이상 보낼 수 있는 경로가 없다는 뜻입니다.
        if (parent[Target] == -1) break; // 알고리즘 종료

        // 논리 2: 경로를 찾았으므로 '역전파' 느낌으로 장부를 업데이트함
        // 찾은 경로를 역추적하면서 유량을 1만큼 흘려보냅니다.
        for (int i = Target; i != Start; i = parent[i]) {
            int u = parent[i]; // 현재 도시 (경로의 이전 도시)
            int v = i;         // 다음 도시 (현재 도시로부터 도달한 도시)

            // 정방향 유량 증가: u -> v 방향으로 1만큼 흘려보냄.
            // 이로 인해 capacity[u][v] - flow[u][v] 값이 1 감소하게 됩니다.
            flow[u][v] += 1; 
            
            // 역방향 유량 감소: v -> u 방향으로 -1 만큼 흘려보냄.
            // 이는 잔차 그래프에서 v->u 간선의 용량을 1 늘리는 효과와 같습니다.
            // 즉, 미래의 BFS에서 v->u 경로를 통해 유량을 '돌려보낼' 기회를 제공합니다.
            flow[v][u] -= 1; 
        }
        
        // 한 번의 BFS 성공 = 하나의 경로를 완벽히 찾아내 물을 흘림
        // 즉, 하나의 서로 다른 경로를 사용했으므로 총 유량을 1 증가시킵니다.
        totalFlow++;
    }

    // 최종적으로 쥐어짜낸 모든 경로의 합 출력
    // 이것이 1번 도시에서 2번 도시로 갈 수 있는 최대 서로 다른 경로의 개수입니다.
    cout << totalFlow << endl;

    return 0;
}

주요 부분 설명

  • capacity[MAX][MAX], flow[MAX][MAX]: 각 도시 쌍 간의 통로 용량과 현재 흐르는 유량을 저장하는 2차원 배열입니다.
  • parent[MAX]: BFS 탐색 시 경로를 기록하는 배열입니다. parent[v] = u는 u에서 v로 왔음을 의미합니다.
  • adj[MAX]: 각 도시에서 연결된 다른 도시들을 저장하는 인접 리스트입니다.
  • while (true) 루프: 에드몬즈-카프 알고리즘의 핵심으로, 새로운 증가 경로를 찾을 때까지 반복합니다.
  • fill(parent, parent + MAX, -1): 각 BFS 탐색 전에 parent 배열을 초기화합니다.
  • capacity[curr][next] - flow[curr][next] > 0: 간선을 따라 이동할 수 있는지 (잔여 용량이 있는지) 확인하는 조건입니다.
  • flow[u][v] += 1; flow[v][u] -= 1;: 증가 경로를 따라 유량을 업데이트하는 부분입니다. 정방향 유량을 늘리고 역방향 유량을 줄이는 것이 중요합니다.
  • totalFlow++: 유효한 증가 경로를 하나 찾을 때마다 총 유량을 1 증가시킵니다.

5. 복잡도 분석

  • 시간 복잡도:
    에드몬즈-카프 알고리즘의 시간 복잡도는 일반적으로 O(V * E^2) 입니다. 여기서 V는 정점(도시)의 수, E는 간선(도로)의 수입니다.
    하지만 이 문제에서는 각 간선의 용량이 1이고, 최대 유량 F가 V보다 크지 않으므로, 더 좋은 복잡도인 O(F * E) 또는 O(V * E) 로 분석될 수 있습니다.
    문제에서 N은 최대 400이고, P는 그보다 작으므로 O(N * P) 또는 O(N^2) 정도로 볼 수 있으며, 이 정도의 크기에서는 충분히 시간 내에 실행됩니다.

  • 공간 복잡도:

    • capacity, flow 배열: O(V^2) (여기서 V는 최대 도시 수 401)
    • adj 리스트: O(V + E) (여기서 E는 도로 수)
    • parent 배열: O(V)
    • queue: O(V)
      따라서 전체 공간 복잡도는 O(V^2) 입니다.

6. 배운 점

이 문제를 풀면서 최대 유량 알고리즘, 특히 에드몬즈-카프 알고리즘의 작동 원리를 깊이 이해할 수 있었습니다.

  • 최대 유량 문제의 일반화: "서로 다른 경로의 최대 개수"와 같은 문제를 "최대 유량" 문제로 모델링하는 방법을 배웠습니다.
  • 잔차 그래프의 중요성: 정방향 간선의 용량을 줄이는 것뿐만 아니라, 역방향 간선의 유량을 조절하여 더 나은 경로를 탐색할 수 있도록 하는 잔차 그래프의 개념을 익혔습니다.
  • BFS의 활용: 최대 유량 문제에서 증가 경로를 효율적으로 찾는 데 BFS가 어떻게 사용되는지 알게 되었습니다.

이 문제는 최대 유량 알고리즘을 처음 접하는 사람에게는 다소 어렵게 느껴질 수 있지만, 알고리즘의 흐름을 차근차근 따라가면서 이해한다면 큰 도움이 될 것입니다. 다음에도 더 흥미로운 알고리즘 문제로 돌아오겠습니다!