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로 부터 이러한 값들이 승부에 유리하다는 것을 탐색해서 항상 이러한 값에 가장 가까운 선택을 하려하는 것 처럼 보이는 약간의 지능적(?) 결과를 볼 수 있다.

[Tip] 포트 포워딩 설정 상태에서 VNC SSH Tunneling

VNC SSH Tunneling에 대해 다룬 적이 있었는데, macOS에서 기본 제공되는 Screen Sharing 같은 app으로 접속하려면 SSH 터널링을 설정하는 GUI가 없으므로 해당 포스팅의 “SSH Tunneling 설정이 없는 경우” 항목의 안내에 따라 다음과 같은 명령어를 터미널에 입력하고 localhost의 5999번 포트로 연결을 시도하면 된다고 했었다.

# SSH 터널링.
# 22번 SSH 포트를 통해 리모트의 5901번 VNC 포트를 로컬의 5999번 포트에 연결
ssh -L 5999:localhost:5901 <user_id>@<vnc_server_ip>

그럼 만약 22번이 아닌 포트 포워딩을 사용하고 있는 경우라면 어떻게 해야 할까?

사내에 있는 워크스테이션들에 각각 65530 부터 65531까지 포트로 access할 때 포워딩이 이루어 지도록 설정해 둔 상태라고 하면, 22번 포트가 아닌 특정한 포트로 포워딩을 수행하고 있으므로 이 값을 -p 옵션과 함께 작성해 주어야 한다. 예를 들어 포워딩하고 있는 포트의 번호가 65530이고 이를 통해 5901에서 돌고 있는 VNC를 내 localhost의 5999번 포트에 연결하고자 한다면 명령어는 다음과 같다.

# SSH 터널링.
# 65530번 SSH 포트를 통해 리모트의 5901번 VNC 포트를 로컬의 5999번 포트에 연결
ssh -L 5999:localhost:5901 -p 65530 <user_id>@<vnc_server_ip>

그리고 나서 macOS의 Screen Sharing에서는 다음과 같이 설정하고 VNC로 접속한다.

명령어가 너무 길다면

~/.ssh/config 환경 설정파일에 LocalForward를 다음과 같이 추가해 주면 매번 긴 명령어를 타이핑하지 않아도 된다.

# ~/.ssh/config
Host <host_name>
  HostName <vnc_server_ip>
  Port 65530
  User <user_id>
  LocalForward 5999 localhost:5901

이후 부터는 터미널에서 간단히 ssh <host_name> 명령어만 수행해도 Screen Sharing을 통해 VNC로 접속할 수 있다.

Rust 프로그래밍에서 map() 활용

Python이 그러하듯 Rust도 declarative programming(선언형 프로그래밍)을 지원한다. 그 중 map()은 이러한 코딩스타일의 대표처럼 사용되고는 하는데, 이것을 이용하면 길고 장황한 코드를 간결하게 나타낼 수 있다.

기본적 사용법

1 부터 10까지 1씩 증가하는 값을 가진 i32 형의 10칸짜리 배열 arr이 있다고 할 때, 그 안에 있는 각 원소들에 2를 곱하는 코드이다.

// 배열의 각 요소들에 x2를 수행하고 결과를 출력.
// 실행결과:
// Doubled array: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

fn main() {
    // i32 type 배열 선언.
    let arr: [i32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // map()을 사용해서 각 element들에 x2를 수행.
    let d_arr = arr.map(|x| x * 2);
    println!("Doubled array: {:?}", d_arr);
}

위의 코드는 Python과 같은 다른 언어에서도 사용하는 형식을 가지고 있어서 직관적이고 그 내용을 이해하기도 비교적 쉽다. 그렇다면 vector에 대해 같은 코드를 작성하면 어떨까? 10칸짜리 vector, vec를 만들고 동일한 동작을 수행해 보도록 하자.

// 벡터의 각 요소들에 x2를 수행하고 결괄르 출력.
// 실행결과:
// Doubled vector: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

fn main() {
    // Vector를 선언하고 1 부터 10까지 값들로 초기화.
    let vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // map()을 사용해서 각 element들에 x2를 수행.
    let d_vec = vec.iter().map(|x| x * 2).collect::<Vec<_>>();
    println!("Doubled vector: {:?}", d_vec);
}

앞에서의 배열에 대한 map() 연산과 달리 뭔가 복잡해 졌다. 10번째 줄을 보면, map()을 호출하기 전에 iter()를 통해서 먼저 iterator(반복자)를 받고, 그 다음에 map()의 내용을 lambda로 수행한 다음에 collect::<Vec<_>>()라는 기괴한 구문으로 끝을 맺고 있다.

The “Lazy Pipeline”

이와 같이 data soruce로 부터 반복자를 받아서 map()을 통해 형태를 변경하고 다시 collect()로 넘겨 최종 처리하는 패턴(Create – Transform – Consume)이 Rust에서 자주 사용되는데 이것을 “Iterator Bridge” 패턴 혹은 “Lazy Pipeline”이라고 부른다.

이렇게 복잡한 pipeline을 거치는 이유는 크게 두가지 정도를 생각해 볼 수 있는데, 첫번째는 원하는 목적에 따라 각 단계의 함수(메소드)들을 다른 종류로 대체할 수 있기 때문이고 두번째는 앞서 살펴본 array와 달리 컴파일 시간에 그 크기가 정해지지 않는 동적 타입에 대해 실행시간을 최대한 미루는 게으른 처리(lazy evaluation)을 이용하여 성능을 향상시키기 위함이다.

각 함수(메소드)들의 대체

위의 예제에서는 Vector type의 source에 대하여 x2라는 transformation을 수행하고, collect::<Vec<_>>()로 consume해서 새로운 vector를 생성해 냈다. 반복자를 호출하는 방법에도 소유권을 넘겨주는 지 혹은 빌려오는지에 따라 iter_into() 혹은 iter()와 같은 다양한 종류의 반복자 반환 메소드를 사용할 수 있고, transformation을 수행하는 메소드도 map() 뿐만 아니라 특정한 조건을 만족하는 원소를 걸러내는 filter()같은 메소드들을 사용할 수 있으며, 최종단의 consumer 역시도 새로운 container를 만들어 내는 collect()외에도 요소의 갯수를 반환하는 count() 나 요소의 합을 계산하는 sum() 같은 다양한 메소드들로 목적에 맞게 파이프라인을 구성할 수 있다.

동일한 패턴을 유지하면서 vector내부에 있는 짝수의 총 합을 계산하는 다음 코드를 한번 보자.

// 벡터안의 짝수만 걸러서(filter) 합을 구하기
// 실행결과:
// Sum of all even numbers: 30

fn main() {
    let vec = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let sum = vec.iter().filter(|x| *x % 2 == 0).sum::<i32>();
    println!("Sum of all even numbers: {}", sum);
}

게으른 처리 (Lazy Evaluation)

Adapter인 map() 혹은 filter()로 호출한 구문은 consumer(collect(), fold(), sum(), count(), for loop 등)을 만나기 전까지는 아무런 수행도 하지 않았다가 consumer를 만나고 나서야 동작을 수행하게 된다. 이러한 동작은 pipeline이 구성된 후에 수행되기 때문에 불필요하게 중간상태를 보관하기 위한 메모리를 할당할 필요 없이 최적화된 코드 수행을 할 수 있도록 해준다. 실행시간에 크기가 변화하는 Vector와 같은 container를 감안할 때, 코드 수행단계마다 처리해서 임시 메모리에 쌓아두는 것 보다, 필요 할 때까지 미루어 뒀다가 최종 상태를 감안해서 처리하는 게으른 처리는 메모리 소모와 성능면에서 좋은 전략이 될 수 있다.

Option과 Result에 대한 map() 사용

언뜻 보면 일관성이 없어 보이기도 하지만, Option과 Result에도 map()을 사용할 수 있는데, 이는 Option의 Some()이나 Result의 Ok() 상태 처럼 정상동작한 경우에 대해 수행할 동작을 정의하고자 할 때 사용할 수 있어서, if let 구문이나 match 구문을 대체하여 코드를 간결하게 유지할 수 있도록 해준다.

// 실행결과:
//the value is SOME: 'some value'
//the result is OK: Result was good

use std::io::{Error, ErrorKind};

fn main() {
    // Option, map()
    let opt = Some("some value");
    //let opt: Option<String> = None;
    opt.map(|x| println!("{}", format!("the value is SOME: '{x}'")));

    // Result, map()
    let res: Result<String, Error> = Ok("Result was good".to_string());
    //let res: Result<String, Error> = Err(Error::new(ErrorKind::Other, "Some error"));
    let _ = res.map(|x| println!("{}", format!("the result is OK: {x}")));
}

Conclusion

Rust에서 사용되는 map()의 활용에 대해 살펴 보았다. Container에 대해 map()을 사용하는 것은 비교적 우리에게 익숙하지만, Option / Result에 map()을 사용하는 개념은 상대적으로 그렇지 않은데, 많은 설명들에 따르면, 이것은 “상자 속에 들어 있는” 어떤 값에 대해 그 내부를 직접 들여다 보지 않고 적용한다는 점에서 container와 Option, Result 공통으로 적용할 수 있는 선언형 프로그래밍의 철학이 적용된 것이라고 한다.