플로이드·워셜의 메모리 줄이기 — N³칸에서 N²칸으로

모든 쌍 최단 거리에서 점화식을 세웠다. 답은 나오지만 N3N^3칸을 들고 있다. NN이 1000이면 10억 칸이다. 정말 다 필요한가.

이 포스트에서 다루는 내용
  • kk층은 k1k-1층만 참조한다 → 2층으로
  • 덮어써도 되는 이유 → 1층으로
  • 밀집 그래프에서 다익스트라 NN번과 표기가 비기는데도 이걸 쓰는 이유

2층이면 충분하다

Dk[i][j]D^k[i][j]ii에서 jj로 가되 중간에 거치는 노드를 1번부터 kk번까지로 제한했을 때의 최단 거리다. 위첨자 kk가 그 제한을 가리키므로, kk를 하나 올릴 때마다 층이 한 장 쌓인다고 보면 된다.

점화식은 Dk[i][j]=min(Dk1[i][j], Dk1[i][k]+Dk1[k][j])D^k[i][j] = \min(D^{k-1}[i][j],\ D^{k-1}[i][k] + D^{k-1}[k][j])였다. 오른쪽에 나오는 층은 셋 다 k1k-1이다.

kk층을 채우는 동안 읽는 것은 k1k-1층뿐이고, Dk2D^{k-2} 이하는 한 칸도 읽히지 않는다. 다음 단계인 k+1k+1층을 채울 때 읽는 것도 kk층뿐이다.

한 층을 다 채우고 나면 그 아래 층은 두 번 다시 쓰이지 않는다.

층을 두 장만 잡고 kk의 홀짝에 따라 번갈아 쓰면 된다. 홀수 kk에서는 0번 장을 읽어 1번 장에 쓰고, 짝수 kk에서는 반대로 한다.

이 글의 코드는 본편 의사코드에서 INF 검사만 덜어냈다. 한쪽이라도 INF면 덧셈을 건너뛴다는 규칙은 층을 줄여도 그대로 필요하고, 층을 어떻게 잡느냐와는 무관하다. 여기서 볼 것은 층 인덱스뿐이라 그 검사는 접어 둔다.

int D[2][n + 1][n + 1];

void floydWarshall(int W[][n + 1], int n) {
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            D[0][i][j] = W[i][j];

    for (int k = 1; k <= n; k++) {
        int prev = (k - 1) % 2, curr = k % 2;
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                D[curr][i][j] = min(D[prev][i][j],
                                    D[prev][i][k] + D[prev][k][j]);
    }
}

마지막 반복까지 마치면 답 DND^ND[n % 2]에 들어 있다.

배열이 잡는 칸을 세어 보면 한 층에 N2N^2개씩 두 층, 곧 2N22N^2개다. O()O(\cdot) 표기는 상수배를 세지 않으므로 공간은 O(N3)O(N^3)에서 O(N2)O(N^2)로 내려간다. 시간은 세 겹 반복 그대로라 O(N3)O(N^3)이다.

N³칸을 두 층으로, 다시 한 층으로 줄인다.
N³칸을 두 층으로, 다시 한 층으로 줄인다.

한 층으로 줄이면 무엇이 위험한가

층을 하나 더 덜어낼 수 있을까. 층 구분을 아예 없애면 코드는 이렇게 짧아진다.

int D[n + 1][n + 1];

void floydWarshall(int W[][n + 1], int n) {
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            D[i][j] = W[i][j];

    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                D[i][j] = min(D[i][j], D[i][k] + D[k][j]);
}

위험은 분명하다. D[i][k]D[i][k]를 읽을 때 그것이 아직 Dk1[i][k]D^{k-1}[i][k]인지 이미 Dk[i][k]D^k[i][k]로 덮인 것인지 알 수 없다. 같은 kk 반복 안에서 (i,k)(i,k) 칸이 먼저 갱신될 수 있기 때문이다.

jj 루프가 kk를 지나는 순간이 그 자리다. j=kj = k에서 D[i][k]D[i][k]kk층 값이 쓰이고, 같은 ii의 그 뒤 jj들은 덮인 값을 읽는다.

D[k][j]D[k][j]ii 루프가 kk를 지나면 같은 일을 겪는다.

점화식이 요구한 것은 k1k-1층인데 코드가 읽는 것은 뒤섞인 두 층이다.

칸을 하나씩 짚어 본다. 안쪽 문장이 읽는 칸은 D[i][j]D[i][j], D[i][k]D[i][k], D[k][j]D[k][j] 셋이다.

이 중 D[i][j]D[i][j]는 걱정할 것이 없다. 하나의 kk 반복에서 (i,j)(i,j) 칸에 값을 쓰는 곳은 iijj가 그 값을 가리키는 그 한 번뿐이고, 읽기는 그 쓰기와 같은 문장에서 먼저 일어난다. 읽는 시점의 D[i][j]D[i][j]는 아직 아무도 건드리지 않은 Dk1[i][j]D^{k-1}[i][j]다.

위험이 남는 자리는 D[i][k]D[i][k]D[k][j]D[k][j] 두 칸이다. 두 칸이 kk 단계에서 어떻게 변하는지만 확인하면 된다.

덮어써도 값이 같다

두 칸이 kk 단계 동안 값을 바꾸지 않는다면 어느 층을 읽든 결과가 같으니, 그것만 보이면 끝난다. 근거는 두 갈래로 댄다. 하나는 정의를 그대로 따지는 길이고, 하나는 점화식에 j=kj=ki=ki=k를 대입해 확인하는 길이다.

정리 1k행과 k열은 k단계에서 변하지 않는다

모든 ii에 대해 Dk1[i][k]=Dk[i][k]D^{k-1}[i][k] = D^k[i][k]이고, 모든 jj에 대해 Dk1[k][j]=Dk[k][j]D^{k-1}[k][j] = D^k[k][j]이다.

증명. 정의로 보는 논증. 두 값의 차이는 중간에 kk를 쓸 수 있느냐뿐이다. iki \to k로 가는 길에서 kk는 끝점이다. 본편 정리 1은 제한을 지키는 아무 길이나 같은 노드를 두 번 지나지 않는 길로, 길이를 늘리지 않고 바꿀 수 있다고 말한다. 두 제한 어느 쪽에서든 그런 길만 겨루어도 최솟값은 그대로다. 그런 길에서 kk는 끝점으로 이미 한 번 나왔으니 중간 노드로 쓰일 수 없다. 허용 여부가 결과를 바꾸지 못하므로 두 값은 같다. kk가 출발점인 kjk \to j 쪽도 같은 논증이다.

점화식에 넣어 보는 확인. 대각선 값 Dk1[k][k]D^{k-1}[k][k]부터 확정한다. 기저가 D0[k][k]=0D^0[k][k] = 0이고, 허용 집합이 넓어져도 후보가 사라지지 않아 층이 올라갈수록 값이 늘지 않으므로(Dk1[k][k]D0[k][k]D^{k-1}[k][k] \le D^0[k][k]) 이 값은 00 이하다. 가중치가 모두 00 이상이라 kk에서 출발해 kk로 돌아오는 길의 길이가 음수일 수 없으니 00 이상이기도 하다. 곧 어느 층에서도 Dk1[k][k]=0D^{k-1}[k][k] = 0이다.

j=kj = k를 대입하면

Dk[i][k]=min ⁣(Dk1[i][k], Dk1[i][k]+Dk1[k][k])D^k[i][k] = \min\!\left(D^{k-1}[i][k],\ D^{k-1}[i][k] + D^{k-1}[k][k]\right)

이고 방금 확정한 Dk1[k][k]=0D^{k-1}[k][k] = 0 때문에 두 인자가 같다. 곧 Dk[i][k]=Dk1[i][k]D^k[i][k] = D^{k-1}[i][k]다. i=ki = k를 대입하면 kk행에 대해 같은 식을 얻는다.

결론. 한 층짜리 코드가 읽는 위험한 자리는 D[i][k]D[i][k]D[k][j]D[k][j] 두 칸이었다. 정리 1은 그 두 칸이 kk 단계 내내 값을 바꾸지 않는다고 말한다. 읽는 값이 어느 층이든 같으므로 덮어쓰기는 답을 바꾸지 않는다. 한 층이면 된다.

층을 오가는 인덱스도 함께 사라진다. 공간은 O(N2)O(N^2)이고, 남는 것은 세 겹 formin 하나다.

k단계에서 k행과 k열은 값이 바뀌지 않으므로 덮어써도 안전하다.
k단계에서 k행과 k열은 값이 바뀌지 않으므로 덮어써도 안전하다.

그런데 왜 이걸 쓰는가

비교 대상을 분명히 해 둔다. 모든 노드가 서로 이어질 만큼 간선이 많은 밀집 그래프, 곧 간선 수 MM이 대략 N2N^2개인 경우다.

이 자리에서 다익스트라는 구현이 둘로 갈린다.

우선순위 큐를 이진 힙으로 두면 한 번이 O(MlogN)O(M\log N)이다. MM 자리에 N2N^2을 넣으면 O(N2logN)O(N^2\log N)이고, NN번 돌리면 O(N3logN)O(N^3\log N)이다.

다익스트라 2에서 짚었듯 밀집 그래프에서는 힙을 걷어내고 배열을 그대로 훑어 최솟값을 찾는 쪽이 낫다. 그러면 한 번이 O(N2)O(N^2)이고, NN번이면 O(N3)O(N^3)이다.

플로이드·워셜도 O(N3)O(N^3)이다. 힙 구현을 상대로는 logN\log N 하나를 앞서지만, 밀집 그래프에서 실제로 골라야 할 배열 구현을 상대로는 표기가 같다. 표기는 둘을 갈라 주지 못한다.

갈림은 표기에 드러나지 않는 자리, 곧 상수와 메모리 접근 패턴에 있다.

다익스트라는 어느 구현이든 시작점마다 거리 배열과 방문 표시를 새로 세우고, 반복마다 아직 확정하지 않은 노드 중 최솟값을 골라야 한다. 힙을 쓰면 큐 연산 하나하나가 O(logN)O(\log N)번의 비교와 원소 교환을 부르고, 힙을 오르내리며 짚는 자리는 배열 위에서 절반씩 건너뛰므로 접근이 흩어진다.

플로이드·워셜에는 그런 자료구조도, 시작점마다 다시 세우는 준비도 없다. 배열 세 겹 반복만 돌고, 안쪽 jj 루프가 훑는 D[i][j]D[i][j]D[k][j]D[k][j]는 각각 한 줄을 순서대로 지나가므로 메모리 접근이 규칙적이다.

같은 O(N3)O(N^3) 안에서 어느 쪽이 실제로 빠른지는 그래프와 구현과 기계에 따라 달라진다. 여기서는 표기가 비긴다는 것과, 갈림이 표기 바깥에 있다는 것까지만 말해 둔다.


핵심 정리
  • 점화식의 오른쪽은 전부 k1k-1층이다. kk층을 다 채우면 그 아래 층은 다시 읽히지 않으므로, 층 두 장을 kk의 홀짝으로 번갈아 쓰면 된다.
  • 한 층으로 줄일 때 위험한 자리는 D[i][k]D[i][k]D[k][j]D[k][j] 두 칸이다. 두 칸은 kk 단계에서 값이 변하지 않으므로 덮인 값을 읽어도 답이 같다.
  • 근거는 두 갈래다. iki \to k 길에서 kk는 끝점이라 중간 노드로 쓰일 수 없고, 점화식에 j=kj = k를 넣으면 Dk1[k][k]=0D^{k-1}[k][k] = 0 때문에 두 인자가 같아진다.
  • 공간은 O(N3)O(N^3)에서 O(N2)O(N^2)로 내려가고, 시간은 O(N3)O(N^3) 그대로다.
이어지는 글

여기까지 구한 것은 최단 거리다. 그 거리를 내는 길이 어떤 노드를 어떤 순서로 밟는지 복원하는 문제는 배열을 한 장 더 두고 갱신할 때마다 경유지를 적어 두는 별도의 이야기다.

© 2026 XsQuare01. Powered by GitHub Pages. · 방문자