2024-04-05-10. Stack & Queue, 미로 찾기
Stack
- Insert/Delete 만 제공한다. Search는 제공하지 않는다.
- Insert/Delete를 Push/Pop이라고 부른다.
- Last in, First out. 나중에 들어간 데이터가 먼저 나간다.
- 성능
Queue
- Insert/Delete 만 제공한다.
- First in, First out. 먼저 들어간 데이터가 먼저 나간다.
- Insert는 O(1), Delete는 O(1) 시간이 걸린다.
Stack 구현
stack은 배열과 Stack Pointer로 구현할 수 있다.
배열의 인덱스 1부터 i까지만 데이터가 차 있으면 Stack Pointer는 인덱스 i+1을 가리킨다. 즉, 처음으로 비어 있는 인덱스를 가리킨다.
Stack Pointer는 배열에서 Push와 Pop을 수행할 위치를 가리킨다.
int Stack[N];
SP = 0;
void Push(int x) {
    Stack[SP++] = x;
}
int Pop() {
    return Stack[--SP];
}
Queue 구현
Queue는 Insert가 일어나는 위치를 가리키는 Head와 Delete가 일어나는 위치를 가리키는 Tail을 가진다.
Insert와 Delete를 반복하다 보면, Tail 밑의 공간은 사용하지 못하게 되는 문제가 발생한다. 이를 해결하기 위해, Head, Tail이 배열 끝까지 도달한 뒤 Insert, Delete가 발생하면, Head, Tail이 배열 맨 처음을 가리키도록 한다.
int Queue[N];
int Head = 0, Tail = 0;
int isEmpty() {
    return Head == Tail;
}
void insert(int x) {
    Queue[Head] = x;
    Head = (Head + 1) % N;
}
int delete() {
    int RetVal;
    RetVal = Queue[Tail];
    Tail = (Tail + 1) % N;
    return RetVal;
}
Stack, Queue의 응용 - 미로 찾기
위 그림과 같은 미로가 M x N 2차원 배열 형태로 주어진다. 빈 공간은 ‘0’으로 표시되어 있고, 벽은 ‘1’로 표시되어 있다.
(0, 0)에서 출발하여 상하좌우 빈 공간을 따라 (M-1, N-1)에 도달하는 경로는 어떻게 찾을 수 있을까?
그냥 근처의 빈 공간으로 이동하기만 한다면 같은 위치를 계속 다시 방문하게 될 수 있다. 이를 방지하기 위해 이미 방문한 위치를 기록해야 한다.
방문한 위치의 ‘0’을 ‘*’로 바꿔서 기록한다고 하자. ‘1’이나 ‘*’로는 이동하지 않고, ‘0’로 표시된 곳으로만 이동한다면 갔던 곳을 또 방문하는 것을 방지할 수 있다.
만약 막다른 길에 도달한다면, 주위에 ‘0’로 표시된 공간이 나올 때까지 ‘*’로 표시된 곳을 따라 되돌아가면 된다. ‘*’를 ‘X’로 고쳐 후진했음을 표시할 수도 있을 것이다.
그런데 위 그림과 같이 이동한 경우에는, 막다른 길에서 어느 방향으로 되돌아가야 할지 알 수 없다. ‘*’로 표시된 공간이 주위에 2개이기 때문이다.
따라서 각 칸을 방문한 순서를 기억할 다른 방법이 필요하다.
DFS 명시적 Stack
우리가 바라는 것은 가장 마지막에 방문한 칸에 가장 먼저 되돌아가는 것이다. 방문을 push, 되돌아가는 것을 pop이라 한다면, 이는 Last in, First out 구조이다. 따라서 이동 경로를 기억하고 되돌아가는 데 Stack을 사용할 수 있다.
새로운 위치를 방문할 때마다, 이전 위치의 좌표를 stack에 push 하면 된다.
막다른 길에서 되돌아갈 때는 stack에서 pop하여 나온 좌표로 가면 된다.
char Map[M][N] = {...};
int i = 0, j = 0;  // 현재 방문 위치. (0, 0)에서 시작
int[][] Find() {
    while (i != M-1 || j != N-1) {  // 미로 끝에 도달할 때 까지 반복
        int ip = -1, jp = -1;
        인접한 위치에 빈 공간('0')이 있으면 좌표를 ip, jp에 대입;
        if (ip != -1) {  // 근처에 빈 공간이 있다면
            push(i);
            push(j);
            i = ip;
            j = jp;
            Map[i][j] = '*';
        }
        else {  // 근처에 빈 공간이 없다면
            Map[i][j] = 'X';  // 후진한 위치는 'X'로 표시
            j = Pop();
            i = Pop();
        }
    }
    return Map;
}
한 칸마다 후진해서 돌아올 수 있는 횟수가 최대 3번이므로, 한 칸을 최대 4번까지만 중복 방문할 수 있다. 따라서 while루프는 4*M*N번 이상 반복될 순 없고, 알고리즘 수행 시간은 O(4MN) = O(MN) 이다.
DFS 미로 찾기 알고리즘 정확성
알고리즘의 정확성을 증명하기 위해선, 다음을 증명하면 된다.
- (0, 0)에서 (s, t)로 가는 길이 존재할 때, 알고리즘은 그 길을 따라 도착 지점 (s, t)에 도달할 수 있다.
증명
- (0, 0)에서 (s, t)로 가는 길이 존재한다는 것은 다음과 같은 의미이다.
- (0, 0), (a1, b1), (a2, b2), ..., (s, t)라는 좌표 배열은, 인접한 두 원소의 좌표 차이가 1이며, 초기에 ‘0’이 적혀 있다.
 
- Base : (0, 0)은 루프 시작때부터 이미 도달해 있다.
- Step : (0, 0) 부터 (ak, bk)까지 도달했다고 가정한다.
- 만약 (ak, bk)에 인접한 ‘0’인 위치가 (ak+1, bk+1) 뿐이면, 알고리즘이 (ak+1, bk+1)를 방문함이 자명하다.
- 만약 (ak, bk)에 인접한 ‘0’인 위치로 (ak+1, bk+1) 외에 (x, y)가 존재하고, (ak+1, bk+1)가 아닌(x, y) 를 방문했다면
 
- (x, y)를 방문하기 직전에 (ak, bk)의 좌표를 스택에 push했을 것이다.
- while문의 각 루프마다 push 또는 pop이 발생하므로
- 결국 (ak, bk)의 좌표가 pop되어 (ak, bk)로 돌아오게 될 것이다. 또다시 (ak+1, bk+1)가 아닌 (x’, y’) 를 방문해도 마찬가지 이유로 (ak, bk)로 돌아오게 되고, 결국 (ak+1, bk+1)을 방문하게 된다.
- push와 pop이 반복되어 (ak, bk)로 돌아오지 않고 무한 루프에 빠지는 일은 없다. 오직 ‘0’인 위치의 좌표만 push할 수 있고, push한 뒤에는 ‘*’로 바뀌므로, push가 일어날 수 있는 횟수에 한계가 있기 때문이다.
- (ak, bk)의 좌표가 pop되기 전에 while루프가 끝나 버릴 수는 있는데, 이는 (ak, bk)로 돌아오지 않고 (s, t) 에 도달한 경우이므로 문제가 없다.
 
- 따라서 다른 길을 통해 (s, t)에 도달한 경우를 제외하면, 알고리즘은 (0, 0), (a1, b1), (a2, b2), ..., (s, t)를 방문한다.
BFS 명시적 Queue
bfs와 queue를 사용하면, 미로를 탈출하는 최단 경로를 찾을 수 있다.
미로의 빈 공간은 0, 벽은 -1로 주어진다고 하자. (0, 0)에서 출발하여 최단 경로 내의 공간을 차례대로 방문하고, 방문한 공간마다 그 위치까지의 최단 경로를 적도록 하자. 결과는 오른쪽 그림과 비슷할 것이다.
알고리즘은 다음과 같이 동작한다.
- 시작 좌표 (0, 0)을 큐에 넣고, (0, 0)에 적힌 수를 1로 고친다.
- 큐에서 좌표 하나를 delete하고, delete된 좌표로 방문한다.
- 방문하고 있는 칸에 적힌 수가 k라 하자. 방문하고 있는 칸에 인접한 아직 방문하지 않은 칸들의 숫자를 k+1로 고치고, 큐에 넣는다.
- (M-1, N-1) 을 방문할 때까지 2, 3을 반복한다.
int Map[M][N];
int[][] Find() {
    // 시작 좌표를 큐에 삽입
    insert(0);
    insert(0);
    // 시작 위치까지의 최단 경로 길이 1을 적음
    Map[0][0] = 1;
    while (true) {
        int i = delete(), j = delete();  // 큐에서 좌표를 꺼냄
        if(i == M-1 && j == N-1) 
            break; // 미로 끝에 도달하면 루프 종료
        int k = Map[i][j];  // 좌표에 적힌 값을 읽음
        for 0이 적혀 있고 (i, j)에 인접한 모든 좌표 (pi, pj) 에 대해 {
            Map[pi][pj] = k + 1;
            insert(pi);
            insert(pj);
        }    
    }
    return Map;
}
BFS 미로 찾기 알고리즘 정확성
위 알고리즘의 정확성을 증명하기 위해, 변수 k값이 감소하지 않는다는 것을 증명해야 하고, 또 그 전에 queue에 저장된 값들의 특징에 대한 증명을 해야 한다.
알고리즘 실행 과정에서 다음이 성립한다.
- 큐에 저장된 좌표값이 [ x1, y1, x2, y2, ..., xr, yr ] 이고, x1쪽에서 삭제 연산, yr쪽에서 삽입 연산이 일어날 때, 
- 1 <= i < r 인 i에 대해, Map[xi][yi] <= Map[xi+1][yi+1] 가 성립하고
- Map[xr][yr] <= Map[x1][y1] + 1 이 성립한다.
 
- 예를 들어, 큐에 적힌 좌표가 가리키는 숫자를 순서대로 나열하면
- 2, 2, 2, 2, 3, 3, 3
- 12, 12, 12, 13, 13
- 5, 5, 5
- k, k, k, k, k+1, k+1
- k, k, k
증명
- Base : 큐에 0, 0 만이 저장되어 있고, 이는 1을 가리키므로 성립
- Step : n 번째 while 루프가 끝날 때 성립한다고 가정한다.
- n+1번째 while루프가 시작될 때, Map[x1][y1] = k라 하면, Map[xr][yr]은 k또는 k+1이다.
- x1, y1은 delete되고, Map에 적힌 숫자가 k+1인 좌표들이 insert 된다.
- 왼쪽에서 k가 지워지고, 오른쪽에서 k+1이 삽입되므로, n+1번째 루프가 끝날 때도 성립한다.
위 증명을 통해, 큐에서 delete되는 좌표가 가리키는 숫자는 1또는 0만큼 증가한다는 것을 알 수 있다.
따라서, while 루프의 변수 k는 다음 루프에서 1또는 0만큼 증가한다. 감소하지 않는다.
마지막으로 다음 두 명제를 증명함으로써, 알고리즘의 정확성을 증명할 수 있다.
미로 탈출구까지의 최단 경로의 길이가 L이라 하자. n <= L인 자연수 n에 대해,
- 임의의 칸까지의 최단 경로 길이가 n 이라면, 알고리즘은 그 칸을 방문하고 n을 적는다.
- 알고리즘이 어떤 칸에 n을 적었다면, 그 칸까지의 최단 경로 길이는 n이다.
증명
- Base
- 최단 경로가 1인 칸은 (0, 0)뿐이다. 알고리즘 시작 시 (0, 0)을 방문하고, 그 위치에 1을 올바르게 적으므로 명제1 이 증명된다.
- while 루프 안에서 k는 1 이상이고, k+1이 Map에 적히므로, 1은 오직 (0, 0)칸에만 적힌다. 따라서 명제 2가 증명된다.
- Step : 1~n에 대해 명제 1, 2가 참이라고 가정한다.
- 명제 1 증명 :
- 최단 경로 길이가 n+1인 임의의 칸 b에 대해, 최단 경로 길이가 n인 칸 a가 적어도 하나 인접해 있다. 가정에 따라 a에는 n이 적혀 있을 것이고, a를 방문할 때 for 루프에서 b에 n+1을 적게 된다.
- b가 a에 인접해 있음에도 for루프에서 n+1 을 적지 못했다면, b에 이미 0이 아닌 숫자가 적혀 있다는 것이다.
- 그 숫자가 n+1보다 클 수는 없다. 변수 k의 값이 감소하지 않기 때문이다.
- 이미 n+1이 적혀 있었다면, 문제가 없다.
- n+1보다 작다면, 명제 2가 n이하에서 참이라고 가정했으므로 실제 최단 경로 길이가 n 이하가 되고, 이는 b의 최단 경로 길이가 n+1이라는 가정에 모순이다.
- 따라서 b에는 n+1이 적히고, n+1에서도 명제 1이 참이다.
- 명제 2 증명 :
- 알고리즘이 어떤 a라는 칸에 방문하고 있을 때, for 루프에서 b라는 칸에 n+1을 적었다고 하자.
- b에 n+1을 적기 위해선 a와 b가 인접해야 하고, a에 적힌 숫자가 n이여야 한다.
- 즉, a까지의 최단 경로는 n이고, 시작지점에서 a를 거쳐 b로 가는 길이가 n+1인 경로가 존재하게 된다.
- 그런데 b로 가는 n+1보다 더 짧은 경로는 존재할 수 없다. n이하에서 명제가 참이라고 가정했으므로, 최단 경로 길이가 n 이하라면 그 값이 n+1 대신 적혀야 하기 때문이다.
- 따라서 b로 가는 최단 경로의 길이는 n+1이다.
위 증명은 사실 두 가지 예외 경우를 놓치고 있는데, 첫째는 break문이 실행되어 while문이 종료되는 경우이고, 둘째는 delete()연산을 할 때 큐가 비어 있어 런타임 에러로 프로그램이 종료되는 경우이다.
break문은 k=L일 때, 즉 Map에 L+1이 적히게 되는 시점에서 실행되므로, L까지는 다 적혀 첫째 경우는 문제가 없다.
둘째 경우는 발생하지 않는다. 이를 다음을 증명함으로써 보인다.
- 미로 내 임의의 좌표 (s, t)에 대해, (0, 0), (a1, b1), (a2, b2), ..., (s, t)  이라는 길이가 L이하인 경로가 존재한다면, 좌표 (s, t) 는 큐에 삽입된다.
증명
- Base : 알고리즘 시작 시 (0, 0)은 큐에 삽입된다.
- Step : (an, bn)은 큐에 삽입되었다고 가정하자
- 그러면 (an, bn)은 언젠가 큐에서 삭제되고, 그 좌표로 방문하게 된다.
- (an, bn)을 방문한 시점에 (an+1, bn+1)에 적힌 숫자가 0이라면, (an+1, bn+1)은 큐에 들어가게 된다.
 
- 만약 (an+1, bn+1)에 0이 아닌 숫자가 적혀 있었다면, 이미 (an+1, bn+1)가 한번 큐에 들어갔다 나갔다는 뜻이다.
- 수학적 귀납법에 의해, 결국 (s, t)도 큐에 삽입된다.
최단 경로가 L이하인 모든 좌표는 큐에 삽입되므로, 해당 좌표들을 전부 방문하기 전까지는 비어 있는 큐에서 삭제 연산이 발생하지 않는다.