태그 보관물: algorithm

Alpha-Beta Pruning으로 Minimax 탐색시간 개선

Minimax Algorithm과 간단한 예제 포스팅에서 사용했던 예제에서 main()함수에 있는 total_coins 변수의 값을 12에서 32이로 증가시켜서 실행해 보면 컴퓨터가 답을 내는 시간이 기하 급수적으로 늘어나서 첫 대답을 탐색하는데 대략 30초 정도 시간이 걸리는 것을 볼 수 있다(Macbook Pro M5 기준).

def main():
    total_coins = 32 # 원래는 12임.
    ...

이는 각 노드들의 선택가능한 조건이 3개씩이어서 32개의 코인을 가정하는 경우 이론상 대략 1000조가지가 넘는 노드들을 탐색해야 하기 때문이다.

Pruning(가지치기)

하지만, 실제로는 모든 노드들을 탐색해야 하는 것은 아니다. 개중에는 어차피 탐색해 들어가 봤자 선택되지 않을 값을 가진 것들이 있기 때문에 이러한 것들을 미리 알 수만 있다면 탐색 할 필요가 없는 경로들에 대해 탐색을 수행하지 않음으로써 소요되는 시간을 아끼고 효율적인 탐색을 수행할 수 있다. 이러한 동작을 “가지치기”라고 하는데, alpha와 beta라는 두개의 경계값을 유지하는 것으로 이루어진다.

  • α(alpha): 현재까지 Max(컴퓨터)가 확보한 가장 높은 점수
  • β(beta): 현재까지 Min(사용자)가 확보한 가장 낮은 점수

탐색 도중에 α >= β가 되는 순간이 있다면, 이러한 경로는 선택될 리가 없다고 가정하고 탐색을 중단한다.

구현 예제

다음의 코드는 “변형 nim game” 예제에 Alpha-Beta purining을 적용한 것이다.

실행결과 및 결론

이 프로그램을 실행하고 컴퓨터의 턴으로 게임을 시작하면 기존에 30초 가량 걸리던 탐색시간이 2초 정도로 줄어드는 것을 확인할 수 있다.

Minimax Algorithm과 간단한 예제

Minimax algorithm은 체스나 장기, 틱택토 혹은 오목 처럼 두 명의 플레이어가 번갈아 가면서 턴을 주고받는 유한 제로섬 게임(Finite zero-sum game)에서 최적의 선택을 내리기 위해 사용 할 수 있는 결정 트리 탐색 알고 리즘이다. 다시말해, 내가 얻는 이득이 상대의 손실이 되고, 내가 보는 손실이 상대의 이득이 되는 구조에서, “상대방은 항상 자신에게 유리한 선택을 하고 나에게는 불리한 선택을 한다”는 가정하에 자기 자신의 손실을 최소화(minimize)하고 이득을 최대화(maximize)하는 전략을 찾는 것이다.

이 포스팅에서는 minimax algorithm의 개념을 이해하고 간단한 게임에 적용시켜 최적의 해법을 찾아내는 과정을 확인해 본다.

Max Player와 Min Player

Minimax 알고리즘에서는 두 플레이어를 각각 Max와 Min으로 정의한다.

  • Max Player (컴퓨터): 자신의 점수를 maximize하려는 플레이어.
  • Min Player (상대방): Max의 점수를 minimize하려는 플레이어.

이 그림은 Max와 Min 두 플레이어가 서로 턴을 바꿔가며 결정하는 예제를 단순화 해서 트리로 그린 것이다. 각 선택 들의 최종 결과 값이 3, 5, 2, 9로 귀결된다고 할 때, Min은 Max의 이득을 최소화 하는 결정을 선택을 하게 될 것이므로, 왼쪽 노드의 3과 5중에서는 3, 오른쪽 노드의 2와 9중에서는 2를 선택할 것이다. 그렇다면 Max 입장에서는 Min이 결정 할 3과 2중에 이득을 최대화 하는 3을 고르게 되는 것이다.

전체 트리 상에는 Max가 이 게임에서 얻을 수 있는 더 큰 이득인 5와 9가 있지만 이들은 Min에 의해 버려지게 될 것이므로, Max 입장에서는 3을 얻기위한 결정을 수행하는 것이 최적이라고 할 수 있다.

변형 Nim 게임에 적용

Nim game은 “베스킨라빈스31” 게임과 유사한 것인데 판에 올려진 물체(코인)을 1개, 2개 혹은 3개씩 돌아가면서 가져가서 결국 마지막 남은 하나를 가져가는 사람이 지게되는 게임이다.

이것을 약간 변형해서 7개의 코인을 가정하고 컴퓨터와 번갈아 가면서 1개, 2개 혹은 3개씩 가져가서 최종적으로 남은 코인을 0개로 만드는 쪽이 이기는 게임을 “변형 Nim 게임”이라고 하고 여기에 minimax를 적용해 보도록 하자.

결정 트리의 모든 노드를 그리면 공간이 부족하니 Max(컴퓨터)가 3개를 가져가는 경우를 탐색하는 상황을 살펴보자. Max와 Min은 각각 번갈아 가면서 코인을 1개, 2개 혹은 3개를 가져가는 상황에 대하여 남은 coin이 0이 되는 상황을 탐색한다.

Max 입장에서 남은 코인이 0개가 되도록 만드는 node가 Min이 되도록 하는 조건이 유리하다고 판단하고, 이 때 큰 점수 +1을 매겨서 결정을 유도하고, 그 반대의 경우에는 -1 점수를 매겨서 이 상황을 피하도록 한다.

구현코드

이러한 트리 탐색 과정을 재귀함수로 나타내면 다음과 같다.

실행 결과 및 결론

다음은 전체 코인의 갯수를 설정하는 total_coins의 값을 12로 해서 수행한 결과이다.

결과를 보면 12 / 8 / 4개를 남기도록 하는 규칙을 명시적으로 주지 않았음에도 컴퓨터는 minimax tree로 부터 이러한 값들이 승부에 유리하다는 것을 탐색해서 항상 이러한 값에 가장 가까운 선택을 하려하는 것 처럼 보이는 약간의 지능적(?) 결과를 볼 수 있다.

Heap tree 만들기

Heap tree는 완전이진트리 형태로 모든 부모 노드의 값이 자식 노드 보다 크거나 (max heap) 모든 부모 노드의 값이 자식 노드 보다 작은 (min heap) tree를 말한다. 이 글에서는 max heap tree를 만드는 방법을 알아본다.

Heap tree를 생성하는 buildHeapTree()이 동작하는 절차는 다음과 같다. N은 원소의 갯수.

부모_노드 := 소숫점_버림(N / 2) - 1 부터 0까지
모든 부모_노드들에 대하여

    hepify(부모_노드):
        큰_자식 := max(왼쪽_자식, 오른쪽_자식)
        if(큰_자식 > 부모_노드)
            swap(큰_자식, 부모_노드)
            heapify(큰_자식)

재귀로 구현된 heapify()가 등장하는데, 이 함수가 하는 역할은 주어진 node를 부모로 시작해서 단말 node로 내려가면서 max heap 조건에 맞도록 변경해 주는 역할을 한다. heapify()는 큰 자식과 부모를 swap() 하는 경우 재귀하므로 시간 복잡도는 O(트리높이)가 된다.

Heap tree를 생성하는 방법을 고민해 본 적이 있다면, 부모 node들을 순회 할 때 역순으로 순회하는게 얼마나 영리한 방법인지 무릎을 쳤을 것이다. 내 얘기다;;

예제

임의의 정렬되지 않은 배열 { 2, 7, 5, 4, 1, 6, 10, 3, 9, 8}을 예로들어 보자. 10개의 node를 가지는 tree를 배열로 구현 할 때, 부모 node들의 index는 소숫점_버림(N/2) – 1로 계산할 수 있다. 따라서 10개의 node를 가지는 이 tree의 부모 node들의 위치는 [0], [1], [2], [3], [4]이다. (C/C++로 구현 할때는 N/2한 값을 그냥 int형 변수에 넣으면 된다)

각 부모 node들과 자식 node들을 subtree라고 하면 이 예제는 다음과 같이 5개의 subtree들로 구분될 수 있다.

Heapify – Subtree A

부모인 4번째 index의 값과 하나 밖에 없는 자식인 9번째 index의 값을 비교한다. 자식의 값이 더 크므로 부모와 swap()한다.

Heapify – Subtree B

부모인 3번째 index와 왼쪽 자식인 7번째, 오른쪽 자식인 8번째를 비교해서 가장 큰 오른쪽 자식과 부모 node를 swap()한다.

Heapify – Subtree C

오른쪽 자식인 index 6번과 부모인 index 2번을 교체한다.

Heapify – Subtree D

1번 index의 왼쪽 자식이 더 크므로 부모와 swap()한다.

Heapify – Subtree E

2번 index의 값이 더 커서 부모인 0번 index와 swap()하고 보니 2번 index를 부모로 하는 subtree가 더이상 max heap이 아니게 된다.

그래서 다시 한번 heapify()를 호출해서 max heap을 만든다.

Code

/*
  Name: buildHeapTree
  Desc.: Makes sure all sub-trees to be max heapified.
  Args.:
    - arr : Pointer to array to be sorted.
    - arrSize : Size of the array.
*/
static void buildHeapTree(int* arr, int arrSize)
{
    int parentIdx = (arrSize / 2) - 1;
    int arrayLastIdx = arrSize - 1;
 
    for(; parentIdx >= 0; parentIdx--)
    {
        maxHeapify(arr, arrayLastIdx, parentIdx);
    }
}
/*
  Name: swap
  Desc.: Exchanges two elements of the array 'arr' that two given indices indicates.
  Args.:
    - arr: Pointer to array to be sorted.
    - idx1, idx2 : Two indices to be swapped.
*/
static void swap(int* arr, int idx1, int idx2)
{
    int t = std::move(arr[idx1]);
    arr[idx1] = std::move(arr[idx2]);
    arr[idx2] = std::move(t);
}

 
/*
  Name: maxHeapify
  Desc.: Makes given tree and it's sub-trees to be max heap trees.
  Args:
    - arr : Pointer to array to be sorted.
    - arrayLastIdx : Last index of the given array.
    - parentIdx : Index of the parent node of the tree.
*/
static void maxHeapify(int* arr, int arrayLastIdx, int parentIdx)
{
    int lcIdx = (parentIdx * 2) + 1;  // Index for left child node.
    int rcIdx = lcIdx + 1;            // Index for right child node.
    int biggerChildIdx = lcIdx;       // Index for child that has bigger value.
 
    // Stop recursion if left child index exceeds last index of the array.
    if(lcIdx > arrayLastIdx)
        return;
 
    // Left child would be the only one if there's no right child. 
    if(rcIdx > arrayLastIdx)
        biggerChildIdx = lcIdx;
    else   // Which of left/right is bigger if both are exits?
        biggerChildIdx = (arr[lcIdx] < arr[rcIdx]) ? rcIdx : lcIdx;
 
    // Swap and heapify if child is bigger than parent.
    if(arr[parentIdx] < arr[biggerChildIdx])
    {
        swap(arr, parentIdx, biggerChildIdx);
        maxHeapify(arr, arrayLastIdx, biggerChildIdx);
    }
 
    return;
}