for textmining

소인수분해

|

이번 글에서는 소인수분해 알고리즘에 살펴보도록 하겠습니다. 파이썬 코드는 이곳이곳을 참고하였습니다. 그럼 시작하겠습니다.

소수 찾기

소수란 1과 자기 자신만을 약수로 갖는 수를 가리킵니다. 어떤 숫자 num이 소수인지 아닌지 판별하는 가장 간단한 방법은 num까지의 모든 숫자를 나누어보는 것입니다. 다음과 같습니다.

def check_prime(num):
    # prime numbers are greater than 1
    if num > 1:
        # check for factors
        for i in range(2, num):
            if (num % i) == 0:
                print(num, "is not a prime number")
                print(i, "times", num // i, "is", num)
                break
        else:
            print(num, "is a prime number")

    # if input number is less than
    # or equal to 1, it is not prime
    else:
        print(num, "is not a prime number")

특정 범위 내 소수들 찾기

소수를 발견하면 그 소수의 배수인 모든 수들을 소수 리스트에서 지웁니다. 범위 내 숫자들 중 소수가 아닌 것들을 거르는 과정을 반복하다보면 결국 소수만 남게 됩니다. 이를 에라토스테네스의 체(Sieve of Eratosthenes)라고 합니다. 다음과 같습니다.

import math
def primeSieve(sieveSize):
    # creating Sieve (0~n까지의 slot)
    sieve = [True] * (sieveSize+1)
    # 0과 1은 소수가 아니므로 제외
    sieve[0] = False
    sieve[1] = False
    # 2부터 (루트 n) + 1까지의 숫자를 탐색
    for i in range(2,int(math.sqrt(sieveSize))+1):
        # i가 소수가 아니면 pass
        if sieve[i] == False:
            continue
        # i가 소수라면 i*i~n까지 숫자 가운데 i의 배수를
        # 소수에서 제외
        for pointer in range(i**2, sieveSize+1, i):
            sieve[pointer] = False
    primes = []
    # sieve 리스트에서 True인 것이 소수이므로
    # True인 값의 인덱스를 결과로 저장
    for i in range(sieveSize+1):
        if sieve[i] == True:
            primes.append(i)
    return primes

위 알고리즘에서 소수 탐색 범위가 $\sqrt{n}$까지인 이유는 약수가 존재하는 숫자의 반이 이 범위에 존재하기 때문에 이 범위를 탐색하는 것만으로도 전체 범위에서 약수의 존재 여부를 확신할 수 있습니다.

소인수분해

에라토스테네스의 체 알고리즘을 활용해 소인수분해를 하는 알고리즘은 다음과 같습니다.

# 소인수분해
def get_prime_factors(n):
    # n 범위 내의 소수를 구한다
    primelist = primeSieve(n)
    # 이 소수들 중 n으로 나누어 떨어지는
    # 소수를 구하고, 몇 번 나눌 수 있는지 계산
    # 예 : n = 8, factors = [(2, 3)]
    # 예 : n = 100, fcount = [(2: 2), (5: 2)]
    factors = []
    for p in primelist:
        count = 0
        while n % p == 0:
            n /= p
            count += 1
        if count > 0:
            factors.append((p, count))
    return factors

Comment  Read more

어텐션 매커니즘

|

이번 글에서는 딥러닝 모델이 특정 벡터에 주목하게 만들어 모델의 성능을 높이는 기법인 어텐션(attention) 매커니즘에 대해 살펴보도록 하겠습니다. 이 글은 미국 스탠포드 대학의 CS224d 강의와 원 논문을 정리하였음을 먼저 밝힙니다. 혹시 제가 잘못 알고 있는 점이나 보완할 점 있다면 댓글로 알려주시면 감사하겠습니다. 그럼 시작하겠습니다.

동기

어텐션 매커니즘은 기계번역(machine translation)을 위한 sequence-to-sequence 모델(S2S)에 처음 도입됐습니다. S2S 아키텍처를 간단히 나타낸 그림은 다음과 같습니다. 소스랭귀지($A,B,C$)를 입력으로 해서 벡터로 만드는 앞부분을 인코더(encoder), 인코더가 출력한 벡터를 입력으로 해서 타겟랭귀지($W,X,Y,Z$)를 출력하는 뒷부분을 디코더(decoder)라고 합니다.

그런데 여기에서 소스랭귀지와 타겟랭귀지의 길이가 길어질 수록 모델의 성능이 나빠집니다. $W$를 예측할 때 $A,B,C$ 모두에 집중해 보게 되면 정확도가 떨어질 수 있습니다. 모델로 하여금 ‘중요한 부분만 집중(attention)하게 만들자’가 어텐션 매커니즘의 핵심 아이디어가 되겠습니다.

핵심 아이디어

예컨대 독일어 “Ich mochte ein bier”를 영어 “I’d like a beer”로 번역하는 S2S 모델을 만든다고 칩시다. 모델이 네번째 단어인 ‘beer’를 예측할 때 ‘bier’에 주목하게 만들고자 합니다. 어텐션 매커니즘의 가정은 인코더가 ‘bier’를 받아서 벡터로 만든 결과(인코더 출력)는 디코더가 ‘beer’를 예측할 때 쓰는 벡터(디코더 입력)와 유사할 것이라는 점입니다.

인코더 계산과정

먼저 인코더 계산과정을 살펴보겠습니다. 인코더는 $i$번째 단어벡터 $x_i$를 받아서 그에 해당하는 히든스테이트 벡터 $h_i$를 만듭니다. 이후 $h_i$가 $i$번째 열벡터가 되도록 행렬 형태로 차곡차곡 쌓아놓습니다. 이 행렬을 $F$라고 정의합시다. 아래 그림은 양방향(bi-directional) 모델을 가정한 것입니다.

디코더 계산과정

$e_{ij}$는 디코더가 $i$번째 단어를 예측할 때 쓰는 직전 스텝의 히든스테이트 벡터 $s_{i-1}$이 인코더의 $j$번째 열벡터 $h_j$와 얼마나 유사한지를 나타내는 스코어(스칼라)값입니다. 예컨대 어텐션 매커니즘이 제대로 작동한다면 ‘bier’에 해당하는 디코더 출력 벡터와 ‘beer’를 예측할 때 쓰이는 인코더 입력벡터의 유사도가 높게 나타날 겁니다. 다음과 같이 정의됩니다.

[{ e }{ ij }=a\left( { s }{ i-1 },{ h }_{ j } \right)]

위 식에서 $a$는 원 논문에는 alignment model이라 소개돼 있습니다. $s_{i-1}$과 $h_j$ 간 유사도를 잘 뽑아낼 수 있다면 다양한 변형이 가능하다고 합니다. 실제로 $e_{ij}$를 구할 때 쓰이는 $a$는 (1) $F^TVs_{i-1}$ (2) $v^T\tanh{(WF+Vs_{i-1})}$ 등 다양하게 쓰입니다. 여기에서 $v, V, W$ 등은 어텐션을 적용하기 위한 학습 파라메터입니다.

$e_{ij}$에 소프트맥스 함수를 적용해 합이 1이 되도록 확률값으로 변환합니다. $T_x$는 인코더 입력 단어의 수를 가리킵니다.

[\alpha { ij }=\frac { exp\left( { e }{ ij } \right) }{ \sum { k=1 }^{ { T }{ x } }{ exp\left( { e }_{ ik } \right) } }]

디코더가 $i$번째 단어를 예측할 때 쓰이는 attention vector $a_i$는 다음과 같이 정의됩니다.

[\overrightarrow { \alpha { i } } =\left[ { \alpha }{ i1 },{ \alpha }{ i2 },…,{ \alpha }{ i{ T }_{ x } } \right]]

디코더가 $i$번째 단어를 예측할 때 쓰이는 context vector $c_i$는 다음과 같이 정의됩니다. 인코더의 $j$번째 열벡터를 어텐션 확률값으로 가중합을 한 것이라고 볼 수 있겠습니다.

[\overrightarrow { { c }{ i } } =\sum _{ j=1 }^{ { T }{ x } }{ { \alpha }{ ij }{ h }{ j } } =F \overrightarrow { { \alpha }_{ i } }]

디코더 계산 예시

디코더에서 계산되는 과정을 나타낸 그림은 다음과 같습니다. alignment model $a$는 디코더가 2번째 단어 ‘like’를 예측할 때 쓰이는 첫번째 히든스테이트 벡터 $s_1$과 가장 유사한 인코더의 열벡터가 $h_2$라고 판단했습니다. 디코더가 2번째 단어를 예측할 때 쓰이는 attention vector $α_2$를 보면 두번째 요소값이 가장 높기 때문입니다.

디코더가 2번째 단어를 예측할 때 쓰이는 context vector $c_2$는 인코더 출력벡터들로 구성된 행렬 $F$에 $α_2$를 내적해 구합니다. 인코더 모델은 타겟랭귀지 단어벡터(I’d)와 $c_2$를 concat해서 현시점의 히든스테이트 벡터 $s_i$를 만들어 냅니다.

Comment  Read more

Candidate Sampling

|

이번 글에서는 소프트맥스 확률을 구할 때 계산량을 줄이는 Candidate sampling 기법에 대해 살펴보도록 하겠습니다. 이번 글은 각 논문과 Quora를 정리하였습니다. 혹시 제가 잘못 알고 있거나 틀린 점 있으시면 댓글로 지적해주시면 고맙겠습니다. 그럼 시작하겠습니다.

목적

다범주 분류를 수행하는 딥러닝 모델의 말단에는 소프트맥스 확률과 크로스엔트로피 손실(loss)을 구하는 ‘Softmax-with-Loss’ 계층이 있습니다. 딥러닝 모델의 파라메터 업데이트를 하기 위해서는 손실에 대한 그래디언트를 구해야 하는데요. 3개 범주를 분류하는 모델을 구축한다고 했을 때 Softmax-with-Loss 계층의 손실에 대한 그래디언트는 다음 그림의 적색 화살표와 같습니다.

위 그림에서 $y_i$는 $i$번째 범주에 대한 소프트맥스 확률입니다. $t_j$는 $j$번째 범주의 실제 정답(1 혹은 0)입니다. 다시 말해 모든 범주에 대해 소프트맥스 확률을 구해야 손실에 대한 그래디언트를 계산할 수 있다는 이야기입니다.

그런데 여기서 범주 수가 10만개가 넘는다면 어떻게 될까요? 소프트맥스 확률값을 구할 때 계산량이 어마어마하게 많아질 겁니다. 특히 자연어의 경우 단어의 수가 적게는 수십만개에서 많게는 수백만개에 이르기 때문에 자연언어처리를 위한 딥러닝 모델을 만들 때 소프트맥스 확률값을 구할 때 계산량을 줄이려는 노력이 계속돼 왔습니다. Candidate sampling이 제안된 배경입니다.

몇 개만 뽑아서 계산하기

가장 간단한 아이디어로는 단어 몇 개만 뽑아서 소프트맥스 확률값을 구하고, 뽑힌 해당 단어들과 해당 단어들에 관계된 파라메터에 대해서만 업데이트하는 겁니다. On Using Very Large Target Vocabulary for Neural Machine Translation에서 제안된 방법입니다. 예컨대 문맥 $c$가 주어졌을 때 단어 $w$가 나타날 조건부확률은 다음과 같이 근사화합니다.

[\begin{align} p\left( w|c \right) =\frac { exp\left( { w }^{ T }c \right) }{ \sum _{ w’\in V }^{ }{ exp\left( { w’ }^{ T }c \right) } } \ \approx \frac { exp\left( { w }^{ T }c \right) }{ \sum _{ w’\in V’ }^{ }{ exp\left( { w’ }^{ T }c \right) } } \end{align}]

위 식에서 $V$는 말뭉치 전체의 단어로 이뤄진 집합입니다. $V’$는 $V$에서 $k$개 단어를 뽑은 $V$의 부분집합입니다. 소프트맥스 확률을 구할 때 말뭉치 전체 단어를 고려하지 않고 $k$개 일부 단어들만 고려하기 때문에 계산량을 상당히 많이 줄일 수 있습니다. 논문에 따르면 그 성능도 많이 떨어지지 않는다고 합니다.

Negative Sampling

Word2Vec에 쓰인 Negative Sampling은 Noise Contrasive Estimation의 단순화된 버전입니다. Negative Sampling은 소프트맥스 확률을 구할 때 계산량을 줄이기 위해 일부 샘플만 뽑아서 계산한다는 점에서는 위 방법과 공통점을 지닙니다. 가장 큰 차이점은 Noise Distribution 개념입니다.

Word2Vec의 Negative Sampling에서는 단어 벡터를 학습할 때 Noise(=Negative) 샘플인지 여부를 가리는 이진 분류 문제(binary classification problem)로 접근합니다. Word2Vec 모델에서 Negative 샘플은 사용자가 정한 window 내에 등장하지 않는 단어들(정답=0)입니다. 반면 Positive 샘플(window 내에 등장하는 단어들)은 정답을 1로 놓고 학습을 하는 방식입니다.

Word2Vec 학습과정에선 중심단어와 Positive 샘플의 단어벡터들끼리는 유사하게(내적값 상향), 중심 단어와 Negative 샘플의 단어벡터들끼리는 멀게(내적값 하향) 업데이트됩니다. 논문에 따르면 1회 학습시 사용하는 Negative 샘플의 수는 작은 말뭉치일 경우 5~20개, 큰 말뭉치일 경우 2~5개가 적당합니다. 다시 말해 1회 학습시 최대 20개 정도의 단어벡터를 업데이트하는 셈입니다.

Comment  Read more

머신러닝 기법 간 비교

|

이번 글에서는 다양한 머신러닝 모델을 서로 비교해 보면서 각 모델의 특징을 살펴보도록 하겠습니다. 이번 글은 An Introduction to Stastical Learning을 정리하였음을 먼저 밝힙니다. 그럼 시작하겠습니다.

선형회귀 vs K-NN

선형회귀는 모수적 기법(parametric method)입니다. 1차 선형식 모델을 가정하기 때문이죠. 명시적인 함수 형태의 모델을 가정하지 않는 비모수적 기법(non-parametric method)도 있습니다. 대표적인 것이 K-nearest Neighbors Regression 기법(K-NN)이 있습니다. 특정 데이터 포인트($x_0$)의 주변 $K$ 이웃의 $y$값 평균으로 회귀 문제를 풉니다. 선형회귀와 관련 자세한 내용은 이곳을, K-NN과 관련 자세한 내용은 이곳을 참고하시면 좋을 것 같습니다.

데이터의 분포 양상과 모수적 모델이 가정하는 모양이 일치할 경우, 모수적 기법의 성능은 비모수적 모델보다 좋은 경향이 있다고 합니다. 아래 그림을 보겠습니다.

위 그림 왼쪽의 검정색 실선은 $x$와 $y$의 실제 관계를 나타냅니다. 그 관계가 선형(linear)임을 확인할 수 있습니다. 오른쪽 그림에서 검정색 점선은 선형회귀 모델, 녹색 실선은 K-NN의 오차를 나타냅니다. $x$와 $y$가 선형관계를 이루고 있어서, 선형관계를 가정하고 구축된 선형회귀 모델의 오차가 K-NN의 오차보다 작은 것을 확인할 수 있습니다.

하지만 데이터의 분포 양상과 모수적 모델이 가정하는 모양이 불일치할 경우, 비모수적 모델의 성능이 좋을 수 있습니다. 이와 관련해 다음 그림을 보겠습니다.

위 그림 왼쪽의 검정색 실선은 $x$와 $y$의 실제 관계를 나타냅니다. 비선형(non linear)임을 확인할 수 있습니다. 파란색 선은 $K$가 1일 때 K-NN의 예측곡선, 빨간색 선은 $K$가 9일 때 예측곡선을 가리킵니다. $K$가 클 수록 더 많은 이웃데이터를 고려해 예측하므로 곡선의 모양이 평탄화(smoothing)되는 걸 확인할 수 있습니다. 오른쪽 그림에서 검정색 점선은 선형회귀 모델, 녹색 실선은 K-NN의 오차를 나타냅니다. $x$와 $y$가 비선형 관계여서, 선형관계를 가정하고 구축된 선형회귀 모델의 오차가 K-NN보다 큰 것을 확인할 수 있습니다.

실제 분석에서는 K-NN보다는 선형회귀가 자주 쓰입니다. K-NN에서는 데이터의 차원 수가 커질 수록, 즉 변수가 많아질 수록 차원의 저주(curse of dimensionality) 현상이 나타나기 때문입니다. K-NN은 예측시 $K$개 이웃을 고려하는데, 고차원 데이터 공간에서는 특정 데이터 포인트에서 가장 가까운 이웃이라 하더라도 실제로는 그 거리가 매우 먼 경우가 많다고 합니다. 이 때문에 변수의 숫자($p$)가 커질 수록 K-NN의 오차가 커지는 경향이 있습니다. 다음 그림과 같습니다.

LDA vs 로지스틱 회귀

범주 2개를 분류하는 선형판별분석(LDA)에서 데이터 $x$가 범주1일 확률을 $p_1(x)$, 범주2일 확률을 $p_2(x)$라고 두면 다변량 정규분포 확률함수로부터 다음과 같은 식을 유도할 수 있습니다. (LDA에서는 데이터가 정규분포를 따른다고 가정) LDA와 다음 식 유도와 관련해서는 이곳을 참고하시면 좋을 것 같습니다.

[\log { \left( \frac { { p }{ 1 }\left( x \right) }{ 1-{ p }{ 1 }\left( x \right) } \right) } =\log { \left( \frac { { p }{ 1 }\left( x \right) }{ { p }{ 2 }\left( x \right) } \right) } ={ c }{ 0 }+{ c }{ 1 }x]

위 식에서 $c_0$와 $c_1$은 다변량 정규분포 확률함수의 파라메터 $μ_1$(범주1인 데이터의 평균), $μ_2$(범주2인 데이터의 평균), $σ^2$(데이터의 분산, 등분산 가정, 범주1인 데이터의 분산=범주2인 데이터의 분산)로 계산된 고정된 스칼라값입니다.

로지스틱 회귀분석은 다음과 같이 정의됩니다. 로지스틱 회귀와 관련해서는 이곳을 참고하시면 좋을 것 같습니다.

[\log { \left( \frac { { p }{ 1 }\left( x \right) }{ 1-{ p }{ 1 }\left( x \right) } \right) } ={ \beta }{ 0 }+{ \beta }{ 1 }x]

LDA와 로지스틱 회귀 모두 $x$에 대해 1차 선형식(linear equation) 형태라는 점을 확인할 수 있습니다. LDA와 로지스틱 회귀의 결정경계(decision boundary)가 선형이라는 이야기입니다.

다른 점이 있다면 로지스틱 회귀의 $β_0$과 $β_1$은 최대우도추정(maximum likelihood estimation)에 의해 도출됐고, LDA의 $c_0$과 $c_1$은 다변량 정규분포 확률함수로부터 유도됐다는 점입니다. 이러한 성질은 $x$의 차원수가 2 이상일 때도 성립한다고 합니다.

다른 점은 또 있습니다. LDA는 각 관측치가 다변량 정규분포(등분산 가정)로부터 뽑혔다고 가정합니다. 이 때문에 이러한 가정이 들어맞는 데이터에 대해서는 로지스틱 회귀보다 성능이 좋습니다. 반대로 데이터의 분포에 대해 별다른 가정을 하지 않는 로지스틱 회귀는 데이터가 정규분포를 따르지 않을 때 LDA보다 좋은 성능을 냅니다.

SVM vs 로지스틱 회귀

서포트벡터머신(SVM)에서 쓰이는 힌지 로스(hinge loss)는 로지스틱 회귀와 깊은 관련을 맺고 있다고 합니다. 학습데이터의 범주가 2, 차원 수가 $p$, 개수가 $n$일 때 힌지 로스는 다음과 같이 정의됩니다.

[L\left( X,y,\beta \right) =\sum { i=1 }^{ n }{ \max { \left[ 0,1-{ y }{ i }\left( { \beta }{ 0 }+{ \beta }{ 1 }{ x }{ i1 }+…+{ \beta }{ p }{ x }_{ ip } \right) \right] } }]

로지스틱 회귀의 손실함수는 크로스 엔트로피입니다. 다음과 같이 정의됩니다.

[\begin{align} L\left( X,y,\beta \right) =&-\sum _{ i=1 }^{ n }{ { y }_{ i }\log { \left( { \beta }_{ 0 }+{ \beta }_{ 1 }{ x }_{ 1 }+…+{ \beta }_{ p }{ x }_{ p } \right) } } \ &-\sum _{ i=1 }^{ n }{ \left( 1-{ y }_{ i } \right) \log { \left( 1-{ \beta }_{ 0 }-{ \beta }_{ 1 }{ x }_{ 1 }-…-{ \beta }_{ p }{ x }_{ p } \right) } } \end{align}]

힌지 로스의 식을 살펴보면 $y_i(β_0+β_1x_{i1}+…+β_px_{ip})≥1$을 만족하는 데이터의 손실은 무시(=0)합니다. 하지만 로지스틱 회귀에서는 이를 만족하더라도 손실이 0에 가까워지기는 하지만, 완전히 0이 되지는 않습니다. 이를 나타낸 그림은 아래와 같습니다.

SVM과 로지스틱 회귀의 손실함수가 비슷하기 때문에 그 학습결과 또한 유사한 경향을 보인다고 합니다.

결정경계

로지스틱 회귀와 LDA는 선형 결정경계를, Quadratic Disciminant Analysis(QDA)는 비선형 결정경계를 만들어냅니다. K-Nearest Neighbor Regression(K-NN)은 비모수적 방법이라 결정경계 모양에 대한 가정이 전혀 없습니다. 다음은 LDA와 QDA 결정경계 모양을 나타낸 그림입니다.

다음은 K-NN의 결정경계 모양을 나타낸 그림입니다. 좌측 하단은 $K$가 1일 때, 우측 하단은 $K$가 9일 때 결정경계입니다. $K$가 커질 수록 예측시 고려하는 데이터가 많아지고 그 경계 또한 평탄화(smoothing)되는 걸 확인할 수 있습니다.

데이터의 범주가 선형 경계를 따라 분리될 수 있는 경우라면 LDA와 로지스틱 회귀의 성능이 좋습니다. 그 경계가 비선형적이라면 QDA가 좋은 성능을 낼 것입니다. 이도 저도 아니고 결정경계가 매우 복잡한 경우라면 K-NN이 좋은 선택이 될 겁니다. 하지만 K-NN의 성능은 $K$값에 상당히 민감하기 때문에 적절한 $K$값을 찾는 데 신중해야 한다고 합니다.

아래 그림은 의사결정나무(Decision Tree)의 결정경계를 나타냅니다. 의사결정나무는 한번에 하나의 설명변수를 기준으로 분기하기 때문에 축에 수직인 결정경계가 형성됩니다.

녹색과 노란색이 데이터의 실제 분포를 나타냅니다. 위 그림의 첫줄에 해당하는 데이터는 그 분포가 선형임을 확인할 수 있습니다. 이러한 데이터에는 축에 수직인 결정경계를 만들어내는 의사결정나무가 좋은 성능을 낼 수 없습니다. 반대로 두번째 줄에 해당하는 데이터는 그 분포가 의사결정나무의 결정경계와 잘 맞아서 좋은 성능을 낼 수 있습니다.

그런데 데이터가 첫줄에 해당하는 경우라도, 의사결정나무를 수백개 만들어서 그 결정경계를 실제 데이터 분포에 가깝게 만들 수도 있습니다(랜덤 포레스트). 아니면 데이터의 축을 회전하여 문제를 풀 수도 있습니다(로테이션 포레스트). 의사결정나무와 관련해 자세한 내용은 이곳, 랜덤 포레스트와 로테이션 포레스트와 관련한 내용은 이곳을 참고하시면 좋을 것 같습니다.

Comment  Read more

합병정렬(Merge Sort)

|

이번 글에서는 합병정렬(Merge Sort)에 대해 살펴보도록 하겠습니다. 이 글은 고려대 김선욱 교수님 강의를 정리했고, 코드는 이곳을 참고했음을 먼저 밝힙니다. 그럼 시작하겠습니다.

합병정렬

합병정렬은 다음과 같은 방식으로 동작합니다. (그림 출처)

우선 데이터를 잘게 쪼갭니다(divide). 위 예시에선 8개로 쪼갰습니다. 둘씩 크기를 비교해 정렬합니다(conquer). 이를 합칩니다(merge). 이를 더 이상 합칠 array가 없을 때까지 반복합니다. 데이터 개수가 홀수개여서 정확히 둘로 쪼갤 수 없을 때는 왼쪽 배열에 요소 하나를 더 포함시킵니다. 여기에서 $p$는 하위 배열의 시작점, $r$은 끝점입니다. $q$는 하위 배열을 가르는 기준점입니다.

파이썬 구현

합병정렬을 파이썬으로 구현한 코드는 다음과 같습니다. 우선 주어진 리스트를 중간 지점인 mid($q$)를 중심으로 왼쪽 리스트(leftList)와 오른쪽 리스트(rightList)로 쪼갭니다. leftListrightList 각각에 다시 이 작업을 재귀적으로 적용합니다. 분리된 리스트를 합치는 merge 함수는 주어진 두 개 리스트를 크기 순으로 정렬하는 역할을 합니다. 추후 다시 설명하겠습니다.

def merge_sort(list):
    if len(list) <= 1:
        return list
    mid = len(list) // 2
    leftList = list[:mid]
    rightList = list[mid:]
    leftList = merge_sort(leftList)
    rightList = merge_sort(rightList)
    return merge(leftList, rightList)

merge_sort 함수가 재귀적으로 수행되는 과정은 다음 그림과 같습니다.

14, 7, 3, 12, 9, 11, 6, 2가 주어졌을 때 merge_sort 함수가 돌면서 리스트를 분리하는 과정은 다음과 같습니다.

Iter 변수명 리스트
0 raw list 14, 7, 3, 12, 9, 11, 6, 2
1 leftList 14, 7, 3, 12
1 rightList 9, 11, 6, 2
2 leftList 14, 7
2 rightList 3, 12
3 leftList 14
3 rightList 7
4 leftList 3
4 rightList 12
5 leftList 9, 11
5 rightList 6, 2
6 leftList 9
6 rightList 11
7 leftList 6
7 rightList 2

분리된 리스트를 합치는 merge 함수는 다음과 같습니다. 위에서 분리한 왼쪽 리스트(left)와 오른쪽 리스트(right)의 첫번째 요소를 비교해 작은 값을 결과 리스트(result)에 저장해 놓고, 해당 값을 해당 리스트에서 지웁니다. 이를 leftright의 요소가 하나도 없을 때까지 반복합니다.

def merge(left, right):
    result = []
    while len(left) > 0 or len(right) > 0:
        if len(left) > 0 and len(right) > 0:
            if left[0] <= right[0]:
                result.append(left[0])
                left = left[1:]
            else:
                result.append(right[0])
                right = right[1:]
        elif len(left) > 0:
            result.append(left[0])
            left = left[1:]
        elif len(right) > 0:
            result.append(right[0])
            right = right[1:]
    return result

우선 merge 함수가 1회 돌 때 어떻게 작동하는지 살펴보겠습니다. 예컨대 [3, 7, 12, 14]가 left, [2, 6, 9, 11]이 right인 상황에서 merge 함수는 다음과 같이 동작합니다. 볼드 표시는 비교 대상입니다.

left right result
3, 7, 12, 14 2, 6, 9, 11  
3, 7, 12, 14 6, 9, 11 2
7, 12, 14 6, 9, 11 2, 3
7, 12, 14 9, 11 2, 3, 6
12, 14 9, 11 2, 3, 6, 7
12, 14 11 2, 3, 6, 7, 9
12, 14   2, 3, 6, 7, 9, 11
12   2, 3, 6, 7, 9, 11, 12
    2, 3, 6, 7, 9, 11, 12, 14

merge 함수가 여러 차례 호출되면서 연산하는 과정은 다음과 같습니다.

병합된 리스트
7, 14
3, 12
3, 7, 12, 14
9, 11
2, 6
2, 6, 9, 11
2, 3, 6, 7, 9, 11, 12, 14

합병정렬의 계산복잡성

데이터 개수가 $n$이라고 할 때 이를 정렬하는 데 $cn$의 시간이 걸린다고 칩시다. $c$는 컴퓨팅 파워 등과 관계 있는 어떤 상수를 나타냅니다. 우선 아래 그림을 봅시다.

예컨대 위에서부터 세 번째 층의 경우 원래 데이터를 4개로 쪼갰기 때문에 각각은 $cn/4$의 시간이 걸리지만, 데이터 덩어리 역시 4개이기 때문에 이에 해당하는 층의 계산시간은 $4×cn/4=cn$이 됩니다.

전체 층의 수가 $\log_2{n}$이 되는 이유는 $n$에 구체적인 수를 넣어보면 명확해집니다. 예컨대 데이터 개수($n$)가 8개라고 칩시다. 그러면 전체 층의 수는 3이 됩니다. 따라서 상수항($c$)을 무시하고 생각해보면 합병정렬의 계산복잡성은 $O(n\log{n})$(각 층의 계산시간 × 전체 층의 수)가 되는 것입니다.

한편 지금까지 설명해드린 합병정렬은 데이터를 한번 쪼갤 때 반씩 나누는 걸로 정했습니다만, 3개나 4개로 쪼개도 합병정렬을 구현할 수 있습니다.

가령 3개로 쪼갤 경우 전체 층의 수는 $\log_3{n}$이 되는데요. 이는 로그의 성질에 의해 $\log_2{3}×\log_2{n}$과 같습니다. 첫째 항은 상수이므로 매 분기마다 3개씩 쪼개도 합병정렬의 계산복잡성은 $O(n\log{n})$로 동일합니다. 3개로 분기하는 경우 절반씩 나누는 것보다 인덱스 등 관리 비용이 커지므로 알고리즘 실행 환경에 따라 유연하게 대처해야 할 것 같습니다

합병정렬의 특성

합병정렬은 수행 과정에서 리스트를 쪼갰다가 다시 합치는데, 같은 숫자의 경우에는 순서가 뒤바뀌지 않으므로 stable sort입니다. 별도 저장 공간이 필요한지 여부(in-place sort 여부)는 자료구조를 뭘 쓰느냐에 따라 달라집니다.

우선 연결리스트(linked list)를 쓰는 경우엔 in-place sort로 구현할 수 있습니다. 다음과 같이 기존 저장공간에서 포인터만 바꾸는 형식으로 합병정렬을 수행할 수 있는 덕분입니다. 예컨대 데이터를 쪼갤 경우 head 포인터를 더 두면 되고, 정렬하려는 경우 next 포인터가 가리키는 주소값을 바꿔주면 됩니다.

반면 배열을 쓰는 경우에는 다음 그림처럼 정렬된 리스트를 저장해 둘 별도 공간(buffer)이 필요해 in-place sort가 아니게 됩니다.

Comment  Read more