for textmining

Augmented Data Structure

|

이번 글에서는 Augmented Data Structure에 대해 살펴보도록 하겠습니다. 이 글은 고려대 김선욱 교수님 강의와 위키피디아를 참고해 정리하였음을 먼저 밝힙니다. 그럼 시작하겠습니다.

concept

Augmented Data Stucture란 기존 자료구조에 추가적인 정보를 저장해, 이를 바탕으로 계산효율성을 높이려는 자료구조의 일종입니다. 이진탐색트리(binary search tree), RB 트리 등 노드에 서브트리 노드의 개수 정보를 추가해 중위탐색(inorder traverse) 성능을 높이는 사례가 바로 여기에 해당합니다. 이진탐색트리, RB 트리에서 중위탐색에 소요되는 계산복잡성은 $O(n)$인데, Augmented Data Structure를 써서 $O(\log{n})$의 계산복잡성을 달성할 수 있습니다.

OS-Select

OS-Select는 주어진 이진탐색트리에서 $i$번째 작은 값을 찾는 문제입니다(1번째 작은 값=최소값). 아래 그림과 같이 기존 이진탐색트리 노드에 자기 자신을 포함한 자식노드의 개수를 별도로 저장해 둡니다. 예컨대 아래 이진탐색트리에서 잎새노드는 자식노드가 없으므로 그 값이 1이 됩니다. $F$에 해당하는 노드는 자식노드 2개에 자기 자신까지 해서 그 값이 3이 됩니다. 마찬가지로 $C$ 노드는 왼쪽 서브트리의 노드 1개, 오른쪽 서브트리의 노드 3개, 자기 자신, 이렇게 총 5가 됩니다.

예컨대 위 트리에서 6번째 작은 값을 찾는다고 칩시다. 이진탐색트리는 각 노드에 해당하는 값이 왼쪽 자식노드보다는 크고 오른쪽 자식노드보다는 작다는 성질을 가지고 있습니다. 주어진 이진탐색트리 루트노드의 왼쪽 서브트리 노드 수가 5개이므로, 위 트리에서 6번째 작은 값은 루트노드 $M$이 됩니다(case 1).

이번엔 4번째 작은 값을 찾아 봅시다. 루트노드의 왼쪽 서브트리 노드 수가 5개이므로, 우리가 찾고자 하는 값은 루트노드의 왼쪽 서브트리에 속해 있다는 걸 알 수 있습니다. 다시 말해 루트노드의 왼쪽 자식노드를 루트로 하는 새로운 서브트리 가운데 4번째 작은 값이 우리가 찾는 값이 됩니다(case 2이고 OS-Select($C$, 4) 재귀 호출).

이번엔 8번째 작은 값을 찾아 봅시다. 루트노드의 왼쪽 서브트리와 루트노드를 포함한 노드 수가 6개이므로, 우리가 찾고자 하는 값은 루트노드의 오른쪽 자식노드를 루트로 하는 새로운 서브트리 가운데 2번째(8-6=2) 작은 값이 됩니다(case 3이고 OS-Select($P$, 8-6) 재귀 호출).

이를 의사코드로 표현하면 다음과 같습니다.

# x: 주어진 이진탐색트리의 루트노드
# i: i번째 작은값의 i
OS-Select(x, i):
	r = x.left.size + 1
	# case 1
    if i == r:
		return x
    # case 2
	elif i < r:
		return OS-Select(x.left, i)
	# case 3
    else:
		return OS-Select(x.right, i-r)

# 최초 시작은 루트노드에서
# 이후 재귀적으로 함수 호출
OS-Select(T.root, i)

OS-Select는 최악의 경우에도 루트노드에서 잎새노드까지의 경로만을 탐색하게 됩니다. OS-Select의 계산복잡성은 트리 노드수가 전체 $n$개일 때 트리의 높이($\log{n}$)에 비례한다는 얘기입니다. 따라서 전체적으로 계산복잡성은 $O(\log{n})$이 됩니다.

OS-Rank

OS-Rank는 주어진 이진탐색트리에서 $x$가 몇 번째로 큰 값인지 알아내는 문제입니다. OS-Select와 마찬가지로 기존 이진탐색트리에 자기 자신을 포함한 자식노드의 개수를 별도로 저장해 둡니다. 아래 그림은 OS-Select 때 예시로 든 것과 동일합니다.

이진탐색트리에서 각 노드의 rank는 왼쪽 서브트리의 노드 수가 중요합니다. 예컨대 몇 번째 큰 값인지 알고자 하는 노드가 $F$라고 칩시다. $F$의 rank는 다음과 같이 분리해서 생각해볼 수 있습니다.

  • F와 F의 왼쪽 서브트리 : 1+D의 값(1)
  • C와 C의 왼쪽 서브트리 : 1+A의 값(1)
  • F의 rank = 2+2 = 4

이를 의사코드로 표현하면 다음과 같습니다. 아래 코드에서 y.p.left.sizey 노드 부모의 왼쪽 서브트리 노드의 수를 가리킵니다.

# T : 이진탐색트리
# x : 몇 번째 큰 값인지 알고자 하는 노드
OS-Rank(T, x):
	r = x.left.size + 1
	y = x
	while y != T.root:
      	# 위 그림 기준으로 C, F가 
        # 각각 부모노드, 오른쪽 자식노드인지 확인
		if y == y.p.right:
          	 # 기존 rank에 더해
             # 왼쪽 서브트리 + 자기 자신 반영
			r = r + y.p.left.size + 1
		y = y.p
	return r

OS-Rank는 최악의 경우 잎새노드에서 시작해 루트노드까지 올라가며 랭크를 계산합니다. OS-Rank 역시 계산복잡성은 $O(\log{n})$이 됩니다.

subtree 크기 업데이트

지금까지 설명해드린 OS-Select, OS-Rank를 무리없이 구현하려면 각 노드별로 서브트리 노드 수를 반영해 주어야 합니다. 트리를 build할 때는 물론 새 노드를 insert, 기존 노드를 delete할 때마다 서브트리의 노드 수가 바뀌게 됩니다.

우선 insert를 기준으로 생각해보면 이진탐색트리의 노드 삽입은 잎새에서 이루어지고, 이 잎새에서 루트에 이르는 경로에만 subtree 숫자들이 바뀌기 때문에 최대 트리의 높이에 해당하는 노드들에 대해서만 노드 수 업데이트를 해주면 됩니다($O(\log{n})$). 물론 RB 트리와 같이 삽입 과정에서 rotation이 이뤄지는 경우도 상정해볼 수는 있겠지만 아래처럼 상수시간 내 연산이 가능하기 때문에 전체적인 계산복잡성을 지배하지 않습니다.

한편 delete로 인한 subtree 크기 업데이트에 대한 계산복잡성도 $O(\log{n})$이라고 합니다.

interval tree

interval tree란 각 노드의 값이 구간(interval)인 이진탐색트리인 자료구조를 가리킵니다. 여기에 (1) 해당 노드 구간의 상한 (2) 해당 노드 왼쪽 서브트리의 값 가운데 최대값 (3) 해당 노드 오른쪽 서브트리의 값 가운데 최대값 등 세 가지 가운데 최대값을 각 노드에 구간 정보와 별도로 저장해 놓습니다. 예컨대 다음 그림과 같습니다.

우리가 알고 싶은 값은 특정 구간이 주어졌을 때 위 interval tree에서 겹치는 구간이 있는지, 있다면 어느 구간이 겹치는지 정보입니다. 예컨대 위와 같은 트리에서 [14, 16]이 겹치는지 알아보고 싶다고 칩시다. 그 과정은 다음과 같습니다.

  • 알고 싶은 구간의 하한 14와 루트노드의 왼쪽 자식노드(18)를 비교합니다. 작으므로 루트노드의 왼쪽 자식노드로 갑니다.
  • 알고 싶은 구간의 하한 14와 루트노드의 왼쪽 자식노드의 왼쪽 자식노드(8)를 비교합니다. 크므로 루트노드의 왼쪽 자식노드의 오른쪽 자식노드로 갑니다.
  • 알고 싶은 구간의 하한 14와 루트노드의 왼쪽 자식노드의 오른쪽 자식노드의 왼쪽 자식노드(10)를 비교합니다. 크므로 루트노드의 왼쪽 자식노드의 오른쪽 자식노드의 오른쪽 자식노드로 가야 합니다. 그런데 [15, 18] 이 노드는 자식노드를 가지지 않고, [14, 16]과 그 구간이 겹치므로 [15, 18]을 결과값으로 반환합니다.

interval-search의 의사코드는 다음과 같습니다.

Interval-Search(T, i):
	x = T.root
	while x != T.nil and i does not overlap x.int:
		if x.left != T.nil and x.left.max >= i.low:
			x = x.left
		else:
			x = x.right
	return x

원하는 결과를 찾기 위해 최악의 경우 루트노드에서 잎새노드에 이르기까지 탐색하게 됩니다. interval-search의 계산복잡성 또한 $O(\log{n})$이 됩니다.

Comment  Read more

다이내믹 프로그래밍

|

이번 글에서는 다이내믹 프로그래밍(Dynamic Programming)에 대해 살펴보도록 하겠습니다. 이 글은 고려대 김선욱 교수님 강의와 위키피디아를 참고해 정리하였음을 먼저 밝힙니다. 그럼 시작하겠습니다.

concept

다이내믹 프로그래밍이란 계산 결과를 저장(Memoization)해 두었다가 재활용하는 기법입니다. 본질적으로는 모든 경우의 수를 다 계산해보는 전역 탐색(exhaustive search) 기법입니다만, 이미 저장해 둔 계산 결과를 다시 써먹는 방식으로 반복되는 계산을 줄입니다.

다이내믹 프로그래밍은 원 문제를 작은 부분문제로 쪼개어 푼 뒤 그 결과를 합치는 분할정복(divide and conquer)과는 차이가 있습니다. 분할정복 문제는 부분문제가 서로 독립적일 때 적용하는 기법입니다. 분할정복은 부분문제의 해를 재사용하지 않고 그저 합치기만 합니다. 하지만 다이내믹 프로그래밍은 부분문제가 서로 겹칠 때 씁니다. 덕분에 부분문제의 해(solution)를 재사용할 수 있습니다.

다이내믹 프로그래밍은 optimal valueoptimal solution을 찾는 데 관심이 있습니다. 따로 설명드리겠지만, 행렬 스칼라 곱 연산을 다이내믹 프로그래밍으로 풀 경우 스칼라 곱 최소 횟수가 optimal value, 이 횟수에 대응하는 행렬 곱 순서가 optimal solution이 됩니다. 행렬 스칼라 곱 연산과 관련해서는 이따가 자세히 살펴보겠습니다.

이 글에서는 다이내믹 프로그래밍을 적용할 수 있는 몇 가지 예시를 살펴보도록 하겠습니다. 다만 다이내믹 프로그래밍 기법의 일종인 Assembly-Line Scheduling과 비터비 알고리즘(viterbi algorithm)은 은닉마코프모델, 최대엔트로피마코프모델, Conditional Random Fields 등 시퀀스 예측 모델에 많이 쓰이기 때문에 이 글에서 별도로 다루었습니다.

Rod Cutting

Rod cutting 문제는 우리가 가지고 있는 통나무를 어떻게 쪼개서 팔아야 최대 수익을 낼 수 있는지를 따지는 겁니다. 예컨대 통나무 길이에 따라 다음과 같이 시장 가격이 매겨졌다고 칩시다. (길이의 단위는 미터, 가격은 만원)

길이 $i$ 1 2 3 4 5 6 7 8
가격 $p_i$ 1 5 8 9 10 17 17 20

고려해야 할 경우의 수는 꽤 많습니다. 가령 길이가 4미터인 통나무를 자르는 가짓수는 아래와 같이 8가지나 됩니다. 여기서 최대 이익을 내는 optimal solution은 길이가 2미터인 통나무 두개로 쪼개 파는 경우입니다. 이 때 optimal value는 10만원이 됩니다. ($n$미터 통나무라면 고려해야할 경우의 수는 $2^{n-1}$가지)

그런데 문제를 자세히 살펴보면 부분문제가 겹친다는 걸 알 수 있습니다. 가령 1미터짜리 통나무의 optimal value는 1입니다(문제 정의상 더 잘게 자를 수 없으므로). 2미터의 optimal value는 다음과 같이 구합니다.

  • 1미터짜리 통나무+1미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 1 + 직전 계산결과(1미터짜리의 optimal value) 1 = 2
  • 자르지 않고 2미터짜리 통나무 통째로 파는 경우 : 가격표에서 가져온 5
  • 가장 큰 값 선택 : $max(2, 5)=5$

3미터의 optimal value는 다음과 같이 구합니다.

  • 1미터짜리 통나무+2미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 1 + 직전 계산결과(2미터짜리의 optimal value) 5 = 6
  • 2미터짜리 통나무+1미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 5 + 직전 계산결과(1미터짜리의 optimal value) 1 = 6
  • 자르지 않고 3미터짜리 통나무 통째로 파는 경우 : 가격표에서 가져온 8
  • 가장 큰 값 선택 : $max(6,6,8)=8$

우리의 관심인 4미터의 optimal value는 다음과 같이 구합니다.

  • 1미터짜리 통나무+3미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 1 + 직전 계산결과(3미터짜리의 optimal value) 8 = 9
  • 2미터짜리 통나무+2미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 5 + 직전 계산결과(2미터짜리의 optimal value) 5 = 10
  • 3미터짜리 통나무+1미터짜리 통나무를 자르는 모든 경우의 수 가운데 최적 solution : 가격표에서 가져온 8 + 직전 계산결과(1미터짜리의 optimal value) 1 = 9
  • 자르지 않고 4미터짜리 통나무 통째로 파는 경우 : 가격표에서 가져온 9
  • 가장 큰 값 선택 : $max(9,10,9,9)=10$

이를 파이썬으로 구현한 코드는 다음과 같습니다(출처). 약간 손질하였습니다.

INT_MIN = -32767
def cutRod(price, n):
  	# val : optimal value
    # 0으로 초기화 
    val = [0 for x in range(n+1)]
    for i in range(1, n+1):
        max_val = INT_MIN
        for j in range(i):
          	 if max_val < price[j] + val[i-j-1]:
             	max_val = price[j] + val[i-j-1]
        val[i] = max_val
    return val[n]
 
arr = [1, 5, 8, 9, 10, 17, 17, 20]
size = len(arr)
print("Maximum Obtainable Value is " + str(cutRod(arr, size)))

Longest Common Subsequence

최장공통부분수열(Longest Common Subsequence) 문제 또한 다이내믹 프로그래밍으로 풀 수 있습니다. 이를 풀려면 먼저 공통수열의 길이를 구해야 합니다. 3가지 경우가 있을 수 있습니다.

  • case1 수열 $A$와 $B$의 마지막 원소가 공통부분 수열인 경우 : abcdad의 LCS 길이는 2이다. 이는 abca의 LCS 길이에 1을 더한 것과 같다.
  • case2 수열 $A$의 마지막 원소가 공통부분 수열이 아닌 경우 : abcdac의 길이는 2이다. 이는 abcac의 LCS 길이와 같다.
  • case3 수열 $B$의 마지막 원소가 공통부분 수열이 아닌 경우 : abcdade의 길이는 2이다. 이는 abcdad의 LCS 길이와 같다.

수열 $A$와 $B$의 마지막 원소가 서로 같다면 case1에 해당하고, 다르다면 case2이거나 case3에 해당합니다. 그런데 우리는 제일 긴 수열에 관심이 있으므로 case2, case3 가운데 최대값을 취합니다. 입력 문자열 길이에 해당하는 행렬(0으로 초기화)을 만들어 놓고 행렬을 위와 같은 규칙을 바탕으로 업데이트합니다. 이를 파이썬으로 구현한 코드는 다음과 같습니다.

def lcs(a, b):
    lengths = [[0 for j in range(len(b)+1)] for i in range(len(a)+1)]
    # row 0 and column 0 are initialized to 0 already
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            if x == y:
                lengths[i+1][j+1] = lengths[i][j] + 1
            else:
                lengths[i+1][j+1] = max(lengths[i+1][j], lengths[i][j+1])

optimal value를 찾았으니 이제는 optimal solution을 찾을 차례입니다. 위 코드를 바탕으로 ABCBDAB, BDCABA 두 수열의 LCS 길이를 아래 그림처럼 구했다고 칩시다.

위 행렬 계산은 우측 하단 모서리인 (7, 6)에서 시작합니다. 대상 칸의 값과 바로 위 칸의 값(4)이 같으면 한 칸 위로 옮깁니다. 다르다면 대상 칸의 값과 바로 왼쪽 칸의 값을 비교해 같으면 한 칸 왼쪽으로 옮깁니다. 바로 위 칸과 왼쪽 칸 모두 대상 칸의 값과 다르다면 해당 위치의 원소가 공통수열의 원소에 해당하므로 결과 result 변수에 저장하고, 대각선으로 한 칸 옮깁니다. 이를 구현한 파이썬 코드는 다음과 같습니다.

    # read the substring out from the matrix
    result = ""
    x, y = len(a), len(b)
    while x != 0 and y != 0:
        if lengths[x][y] == lengths[x-1][y]:
            x -= 1
        elif lengths[x][y] == lengths[x][y-1]:
            y -= 1
        else:
            result = a[x-1] + result
            x -= 1
            y -= 1
    return result

Matrix chain multiplication

행렬끼리의 곱셈은 곱셈 순서에 따라 스칼라 곱 횟수에 큰 차이가 날 수 있습니다. 예컨대 행렬 $A$의 차원수가 2×3, $B$는 3×4, $C$는 4×5이고 셋을 곱한다고 가정해 봅시다. $(AB)C$의 경우 2×3×4+2×4×5, 총 64회의 스칼라곱 연산을 수행해야 합니다. 그런데 $A(BC)$의 경우 3×4×5+2×3×5, 총 90회의 스칼라곱 연산을 수행해야 합니다. 행렬 곱을 수행하기 전에 스칼라 곱 횟수를 미리 가늠해서 전체적인 계산량을 줄이려는 것이 이 문제의 관심이 되겠습니다.

그런데 행렬 곱셈은 다음과 같이 부분문제가 서로 겹치기 때문에 다이내믹 프로그래밍을 적용할 수 있습니다.

  • $ABC$ : $(AB)C$, $A(BC)$
  • $ABCD$ : $(AB)(CD)$, $A(BC)D$, …

이를 파이썬으로 구현한 코드는 다음과 같습니다(출처). 약간 손질하였습니다.

def MatrixChainOrder(p):
    n = len(p)
    m = [[0 for x in range(n)] for x in range(n)]
    for L in range(2, n):
        for i in range(1, n - L + 1):
            j = i + L - 1
            m[i][j] = float('inf')
            for k in range(i, j):
                # q = cost/scalar multiplications
                q = m[i][k] + m[k + 1][j] + \
                	p[i - 1] * p[k] * p[j]
                if q < m[i][j]:
                    m[i][j] = q
    return m[1][n - 1]

위 코드에서 $p$는 행렬 크기를 나타냅니다. 예컨대 [1,2,3,4]라면 행렬 $A$의 차원수가 1×2, $B$는 2×3, $C$는 3×4이고 셋을 곱한다는 뜻입니다.

Comment  Read more

비터비 알고리즘

|

이번 글에서는 비터비 알고리즘(Viterbi Algorithm)에 대해 살펴보도록 하겠습니다. 이 글은 고려대 김선욱 교수님 강의와 위키피디아를 참고해 정리하였음을 먼저 밝힙니다. 그럼 시작하겠습니다.

concept

비터비 알고리즘이란 히든 스테이트의 최적 시퀀스(the most likely sequence of hidden states)를 찾기 위한 다이내믹 프로그래밍(dynamic programming) 기법의 일종입니다. 여기서 히든 스테이트란 은닉마코프모델 등의 추정 대상인 품사, 개체명 등을 가리킵니다. 은닉마코프모델에 적용된 비터비 알고리즘을 파이썬 코드로 구현한 내용은 이곳을 참고하시면 좋을 것 같습니다.

어쨌든 비터비 알고리즘을 이해하기 위해서는 은닉마코프모델 등의 아키텍처뿐 아니라 다이내믹 프로그래밍 개념(반복되는 계산 결과를 저장해 두었다가 풀기)을 익히고 있어야 하는데요. 이 글에서는 다이내믹 프로그래밍의 대표적 예시 가운데 하나이자 비터비 알고리즘과 유사한 기법인 Assembly-Line Scheduling을 중심으로 살펴 보겠습니다.

Assembly-Line Scheduling

Assembly-Line Scheduling 문제는 2개(혹은 그 이상)의 조립라인 사이에서 생산비용을 최소화하는 최적 경로(path)를 찾는 문제입니다. 다음 그림과 같습니다.

위 그림에서 동그라미는 비용(cost)을 나타냅니다. 제품은 1번 내지 2번 라인을 자유롭게 오갈 수 있습니다. 제품이 첫번째 공정에서 1번 라인에 보내진다면 그 비용은 2+7, 즉 9가 됩니다. 제품이 첫번째 공정에서 2번 라인에 보내진다면 비용은 4+8, 즉 12가 됩니다. 이를 식으로 쓰면 다음과 같습니다.

  • $f_1[1]=2+7=9$
  • $f_1[2]=4+8=12$

그러면 두번째 공정에서 제품이 1번 라인에 있다면 그 최소 비용(=$f_2[1]$)은 얼마일까요? 두 가지 경우의 수를 생각해 볼 수 있습니다. 첫번째 공정에서 1번 라인, 두번째 공정도 1번 라인인 case입니다. 또 첫번째 공정에서 2번 라인, 두번째 공정은 1번 라인인 case입니다. 두 경우 중 작은 값을 취해 구합니다.

[\begin{align} { f }_{ 2 }\left[ 1 \right] &=\min { \left( 2+7+9,4+8+2+9 \right) } \ &=\min { \left( { f }_{ 1 }\left[ 1 \right] +9,{ f }_{ 1 }\left[ 2 \right] +2+9 \right) } \ &=\min { \left( 18,23 \right) } =18 \end{align}]

그렇다면 두번째 공정의 1번 라인을 기준으로 보면, 1번 라인을 유지하는게 나을까요, 아니면 1번에서 2번으로 라인을 바꾸는 것이 좋을까요? 각각의 비용은 18, 23이므로 유지하는 것이 좋습니다. 제품이 두번째 공정에서 1번 라인에 있다면 생산비용을 줄이기 위한 제품의 최적 경로는 1번 라인(직전 공정) → 1번 라인(현재 공정)입니다.

이를 식으로 나타내면 다음과 같습니다.

  • $l_2[1]=1$

같은 방식으로 $f_2[2]$와 $l_2[2]$를 구하면 각각 다음과 같습니다. 다시 말해 제품이 두번째 공정에서 2번 라인에 있다면 제품의 최적 경로는 1번 라인 → 2번 라인입니다.

  • $f_2[2]=min(4+8+5,2+7+2+5)\=min(f_1[2]+5,f_1[1]+2+5)=min(17,16)=16$
  • $l_2[2]=1$

그런데 구하는 과정을 자세히 보면 처음에 구한 $f_1[1]$과 $f_1[2]$을 다음 공정에 소요되는 최소 비용을 구할 때 재활용한다는 사실을 알 수 있습니다. 이처럼 계산과정이 반복될 때 중간 계산과정을 저장해 두었다가 해당 계산결과가 필요할 때 다시 써먹는 기법을 다이내믹 프로그래밍이라고 합니다.

위와 같은 과정을 반복해 각 단계별 최소 생산비용을 구한 표는 다음과 같습니다.

$j$ 1 2 3 4 5 6
$f_j[1]$ 9 18 20 24 32 35
$f_j[2]$ 12 16 22 25 30 37

각 단계별 최적 경로는 다음과 같습니다.

$j$ 2 3 4 5 6
$l_j[1]$ 1 2 1 1 2
$l_j[2]$ 1 2 1 2 2

자, 이제는 제품이 라인을 빠져나가는 경우를 생각해 봅시다. 제품이 여섯번째 공정까지 왔고 1번 라인에 있는 경우의 최소 소요비용($f_6[1]$)은 35입니다. 1번 라인에서 출하하는 데 드는 비용은 위의 그림을 보니 3입니다. 둘을 더한 38이 총 생산비용이 됩니다. 마찬가지로 2번 라인에 대해 총 생산비용을 구하면 37+2로 39가 됩니다. 따라서 총 생산비용을 최소화하는 제품 생산 경로는 마지막 여섯번째 공정 때 1번 라인에 있어야 합니다.

각 단계별로 backtrace를 한 결과는 다음과 같습니다.

  • 총 생산비용을 최소화하려면, 제품은 다섯번째 공정에서 2번 라인에 있어야 한다. (∵ $l_6[1]=2$)
  • 제품은 네번째 공정에서 2번 라인에 있어야 한다. (∵ $l_5[2]=2$)
  • 제품은 세번째 공정에서 1번 라인에 있어야 한다. (∵ $l_4[2]=1$)
  • 제품은 두번째 공정에서 2번 라인에 있어야 한다. (∵ $l_3[1]=2$)
  • 제품은 첫번째 공정에서 1번 라인에 있어야 한다. (∵ $l_2[2]=1$)

이를 그림으로 나타내면 다음과 같습니다. 처음에 소개한 그림과 동일합니다.

Viterbi Algorithm

비터비 알고리즘을 애니메이션으로 만든 그림은 다음과 같습니다(출처). 현재 스테이트로 전이할 확률이 가장 큰 직전 스테이트를 모든 시점, 모든 스테이트에 대해 구합니다.

모든 시점, 모든 스테이트에 대해 구한 결과는 다음과 같습니다. (원래는 그물망처럼 촘촘하게 되어 있으나 경로가 끊어지지 않고 처음부터 끝까지 연결되어 있는 경로가 유효할 것이므로 그래프를 그린 사람이 이해를 돕기 위해 이들만 남겨 놓은 것 같습니다)

위 패스에서 만약 최대 확률을 내는 $k+2$번째 시점의 상태가 $θ_0$라면 backtrace 방식으로 구한 최적 상태열은 다음과 같습니다.

  • $[θ_0, θ_2, θ_2, θ_1, θ_0, θ_1]$

Comment  Read more

Disjoint Set

|

이번 글에서는 디스조인트 셋(Disjoint Set)에 대해 살펴보도록 하겠습니다. 이 글은 고려대 김황남 교수님 강의와 위키피디아, 그리고 이곳을 참고해 정리하였음을 먼저 밝힙니다. 그럼 시작하겠습니다.

concept

디스조인트 셋이란 서로 중복되지 않는 부분 집합들로 나눠진 원소들에 대한 정보를 저장하고 조작하는 자료 구조입니다. 디스조인트 셋은 전체 집합이 있을 때 구성 원소들이 겹치지 않도록 분할(partition)하는 데 자주 쓰입니다. 이와 관련해 몇 가지 용어를 살펴보겠습니다.

  • 셋(set)은 개체들의 집합입니다. (리스트 등과 달리 순서는 고려하지 않음)
  • 셋 $A$의 모든 원소가 셋 $B$에 포함될 때 $A$를 $B$의 부분집합(subset)이라 합니다. 이 때 $B$는 $A$의 초월집합(superset)이라 합니다.
  • 셋 $A$와 셋 $B$가 공유하는 원소가 하나도 없을 때 $A$와 $B$를 mutually disjoint하다고 합니다.
  • 임의의 셋을 분할(partition)한다는 건 각 부분집합이 다음 두 가지 속성을 만족하는 디스조인트 셋이 되도록 셋을 쪼개는 걸 뜻한다. (1) 파티션된 부분집합을 합치면 원래의 셋이 된다. (2) 파티션된 부분집합끼리는 mutually disjoint, 즉 겹치는 원소가 없다.

예컨대 $S={1,2,3,4}$이고, $A={1,2}$, $B={3,4}$, $C={2,3,4}$, $D={4}$라면 $A$와 $B$는 $S$의 분할입니다. 이 때 $A$와 $B$가 디스조인트 셋입니다. 하지만 $A$와 $C$는 $S$의 분할이 아닙니다. 겹치는 원소가 있기 때문입니다. $A$와 $D$ 또한 $S$의 분할이 아닙니다. 둘을 합쳐도 $S$가 되지 않기 때문입니다.

operations

디스조인트 셋은 세 가지 연산이 있는데요. make-set은 초기화 연산이므로 unionfind가 핵심이라고 할 수 있겠습니다.

  • make-set(x) : $x$를 유일한 원소로 하는 새로운 셋을 만듭니다.
  • union(x, y) : $x$가 속한 셋과 $y$가 속한 셋을 합칩니다.
  • find(x) : $x$가 속한 셋의 대표값(루트노드 값)을 반환합니다.

예를 들어 보겠습니다. $A={3,4}$인 디스조인트 셋이 이미 만들어져 있다고 칩시다. 이 경우 첫번째 들어간 원소인 3이 루트노드가 되며 3이 $A$를 대표하는 값이 됩니다. 다음과 같습니다.

find(4)는 4가 속한 셋의 대표값을 출력하라는 뜻입니다. 따라서 이 예에서는 3이 됩니다. 마찬가지로 find(3) 또한 출력 결과가 3입니다.

이번엔 다른 디스조인트 셋 $B={1,2}$가 있다고 치겠습니다. $B$의 대표값은 루트노드인 1입니다. $A$와 $B$를 합칠 때는 루트노드들끼리 이어줍니다. 다음과 같습니다.

새롭게 만든 디스조인트 셋의 루트노드는 1입니다. 따라서 예컨대 4가 속한 셋의 대표값을 출력하라는 $find(4)$를 수행하면 출력 결과가 1이 됩니다.

array로 구현

디스조인트 셋의 세 가지 기본연산을 배열(array)로 구현해보겠습니다. $A={3,4}$, $B={1,2}$ 이렇게 두 개 디스조인트 셋이 이미 만들어져 있다고 칩시다.

이를 배열로 구현하면 다음과 같습니다. $N$은 입력원소들을 가리킵니다. $S$는 입력원소가 루트노드인지 아닌지, 부모노드가 어떤 위치에 있는지 나타냅니다. 예컨대 31은 루트노드이기 때문에 이들에 해당하는 $S$의 값이 0입니다. 4의 부모노드는 $N$의 첫번째 요소, 즉 3이라고 표시가 되어 있네요. 마찬가지로 2의 부모노드는 $N$의 세번째 요소, 즉 1입니다.

  • $N=[3,4,1,2]$
  • $S=[0, 1, 0, 3]$

union 연산을 하면 $S$가 다음과 같이 바뀝니다.

  • $S=[0, 1, 1, 3]$

그렇다면 union(x,y) 연산의 계산 복잡성은 얼마나 될까요? 크게 세 가지 단계로 나눠 생각해볼 수 있습니다.

  • $x$가 속한 디스조인트 셋을 찾아야 합니다 : find(x)
  • $y$가 속한 셋을 찾아야 합니다 : find(y)
  • 찾은 셋을 합칩니다.

그런데 생각해보면 find 연산의 계산복잡성은 디스조인트 셋의 원소 수가 $n$개일 때 $O(\log{n})$입니다. 부모노드를 반복적으로 역추적해 루트노드를 찾습니다. 예컨대 잎새노드에서 루트에 이르기까지 일렬로 3-4-5-2-1 이렇게 구성돼 있는 디스조인트 셋이 있다고 할 때 find(3)은 엣지를 트리의 높이만큼 네 번 거슬러 올라가야 루트인 1을 찾을 수가 있습니다. find 연산을 좀 더 효율적으로 수행하기 위한 방법이 바로 path compression인데 조금 있다가 다루겠습니다.

find 연산을 두 번 수행해 합칠 셋을 찾았다고 칩시다. 그러면 이제 이 두 셋을 합칠 차례입니다. 합치는 방법에는 union-by-sizeunion-by-height가 있는데 바로 다음에서 다루겠습니다.

union

임의의 두 디스조인트 셋을 합칠 때는 원소수가 적은 셋을 많은 셋의 서브트리로 합치는 것이 효율적입니다(union-by-size). 마찬가지로 트리의 높이가 작은 셋을 큰 셋의 서브트리로 합쳐야 합니다(union-by-height). 다음 union 연산 때 반드시 find 연산을 수행해야 하는데 find 연산의 효율성을 높여주기 위해서입니다. 원소수와 트리의 높이는 비례하는 경향이 있고 find 연산의 계산복잡성은 이들에 매우 의존적입니다.

union-by-sizeunion-by-height를 구현하는 것은 간단합니다. 배열 $S$의 루트노드 정보를 바꾸면 됩니다. 루트노드일 때 0을 넣었던 기존과 달리 다음과 같이 바꿉니다.

  • union-by-size : $-$size of tree
  • union-by-height : $-$height of tree

요컨대 find 연산에서 찾은 두 개 디스조인트 셋의 원소수 혹은 높이를 비교해서 더 큰 쪽으로 합쳐줍니다. 이렇게 하면 이후 find 연산을 조금 더 효율적으로 수행할 수 있습니다.

union-by-sizeunion-by-height의 계산복잡성은 $O(1)$입니다. find 연산에서 이미 두 디스조인트 셋의 루트노드를 찾았기 때문에 $S$에서 이 두 루트노드 위치에 저장돼 있는 원소수 혹은 높이를 비교합니다. 둘 중 작은 쪽의 루트노드에 해당하는 $S$의 값을 큰 쪽 루트노드의 인덱스를 가리키도록 바꿉니다. 이 모든 연산이 $O(1)$에 해당합니다.

path compression

path compression은 다음과 같이 모든 노드가 루트를 가리키도록 만드는 것입니다. $S$에 부모노드 인덱스 대신 루트노드를 저장하는 방식입니다. find 연산을 수행할 때 트리의 높이만큼 거슬러 올라가야 루트노드를 찾을 수 있는데, 이러한 비효율성을 완화해보자는 취지입니다. path compression을 한번 수행해 놓으면 루트노드를 찾는 find 연산의 계산복잡성을 확 낮출 수 있습니다.

Comment  Read more

한국어 주절의 문법상

|

이번 글에서는 한국어 주절의 문법상(grammatical aspect)을 가장 잘 드러내면서 널리 쓰이고 있는 보조용언 구성인 -어 있--고 있-에 대해 살펴보도록 하겠습니다. 이번 글은 고려대 정연주 선생님 강의를 정리했음을 먼저 밝힙니다. 그럼 시작하겠습니다.

-어 있-

-어 있-은 크게 결과상(resultative)정태상(stative)을 나타냅니다. 차례대로 살펴 보겠습니다.

결과상

결과상이란 과거 사태의 결과가 지속되는 걸 가리킵니다. 동사가 나타내는 사태가 종결된 뒤 그 결과 상태가 주어에게 성립됨을 나타냅니다. 예문을 보겠습니다.

꽃이 피어 있다.

위 예문의 동사 피다가 나타내는 사태(開花)가 끝난 뒤, 그 결과 상태가 주어 에게 성립되고 있음을 알 수 있습니다. 다시 말해 꽃이 과거 어느 시점에 피었는데, 핀 상태가 현재도 계속되고 있다는 의미로 -어 있-이 쓰였다는 얘기입니다.

하지만 -어 있-이 아무 동사에나 결합하는 것은 아니고 결합 제약이 있습니다. -어 있-은 ‘놓이다’, ‘깔리다’, ‘숨다’, ‘앉다’, ‘눕다’와 같이 시간적 끝점을 갖는 자동사(목적어를 취하지 않는 동사)와 결합합니다. 예문을 보겠습니다.

(1) 진이가 *뛰어 있다.

(2) 진이가 예쁜 옷을 *입어 있다.

(1)의 뛰다는 에너지만 공급되면 계속 뛸 수 있다는 점에서 끝점이 없는 동사입니다. (2)의 입다는 끝이 있는 동사이지만 목적어를 갖는 타동사입니다. (1), (2) 모두 비문입니다. 다시 말해 서술어가 끝점이 없거나, 타동사라면 -어 있-이 결합할 수 없습니다.

물론 -어 있-과 결합할 수 있는 타동사가 일부 있기는 합니다. 이를 이해하기 위해선 우선 자동사와 타동사 개념을 살짝 살펴봐야 할 것 같습니다. 예문을 보겠습니다.

아이가 잔다.

자동사 자다가 가리키는 행위로 영향을 받는 대상은 주어 아이입니다. (1)과 (2) 예문에서도 서술어 동사가 가리키는 행위로 영향을 받는 대상은 주어 진이입니다.

반면 타동사는 해당 동사가 가리키는 행위로 영향을 받는 대상이 목적어입니다. 다음 예문과 같습니다.

철수가 친구를 때렸다.

자, 이제 원래 목적으로 돌아와서 -어 있-과 결합할 수 있는 타동사를 살펴보겠습니다. 다음 예문과 같습니다.

진이가 등대를 향해 있다.

위 예문의 타동사 향하다가 가리키는 행위로 영향을 받는 대상은 무엇일까요? 대충 봐서는 목적어인 등대일 것 같지만 사실 주어인 진이가 영향을 받는 의미로 전달됩니다.

요컨대 -어 있-과 결합할 수 있는 타동사는 타동사이면서도 목적어가 변화를 겪는 것이 아니라 주어가 변화를 겪는 의미로 사용되는 단어들입니다. 이러한 예로는 ‘향하다’, ‘떠나다’, ‘넘어서다’, ‘건너오다’, ‘초과하다’ 등이 있습니다.

정태상

과거 사건의 발생 여부에 대해서는 아무런 함축도 갖지 않고 단지 현재 상태만 나타내는 상 범주를 정태상이라고 합니다. 다음 예문과 같습니다.

우리 마을은 산으로 둘러싸여 있다.

이 길은 바닷가까지 뻗어 있다.

정태상은 의미적으로 결과상에서 파생된 용법이라고 합니다.

-고 있-

-고 있-은 크게 결과상(resultative), 정태상(stative), 연속상(continuous), 반복상(iterative)을 나타냅니다. 차례대로 살펴 보겠습니다.

결과상

-고 있-이 결과상의 의미로 쓰인 예문은 다음과 같습니다.

철수는 흰 셔츠를 입고 있다.

위 예문의 -고 있-은 철수가 흰 셔츠를 착용하는 동작을 완결한 뒤 그 결과 상태(옷을 입은 상태)가 지속되고 있음을 나타내고 있습니다. 결과 상태가 타동사의 주어에게 성립되고 있습니다. 다시 말해 입다라는 행위로 당장 영향을 받는 대상은 흰 셔츠이지만, 결과적으로 옷을 입은 행위(服着)의 결과로 주어인 철수가 옷을 입은 상태로 변화했다는 이야기입니다.

이처럼 타동사가 가리키는 행위로 영향을 받는 대상이 1차적으로는 목적어이지만, 결과적으로 주어에 영향을 미친다는 의미를 갖는 타동사를 재귀성 타동사라고 합니다. -어 있-은 재귀성 타동사와 결합할 때 결과상(결과 상태의 지속)의 의미를 갖습니다. 이러한 동사의 예로는 ‘(신발을)신다’, ‘(명찰을)달다’, ‘(이불을)덮다’, ‘(고개를)숙이다’ 등이 있습니다.

그러나 다음과 같은 경우도 있어 분석에 주의를 기울여야 합니다.

(가) 철수가 이불을 덮고 있다.

(나) 철수가 지붕에 짚을 덮고 있다.

(가)의 덮다는 결과적으로 주어인 철수를 변화시키기 때문에 재귀성을 가졌지만, (나)는 그렇지 않습니다. 재귀성 타동사가 쓰인 (가)의 -고 있-은 결과상, 그렇지 않은 (나)의 -고 있-은 연속상(진행상)의 의미로 쓰인 것을 확인할 수 있습니다.

이번엔 다른 사례를 보겠습니다.

관중들이 경기장을 가득 메우고 있다.

위 예문의 결과 상태가 타동사의 목적어에 성립된 경우여서 재귀성이 없습니다. 다만 경기장을 가득 메우는 데 주어 관중들의 적극적인 개입이 있어서 ` -어 있-`이 결과상의 의미를 갖습니다.

정태상

-고 있-의 정태상 관련 예문은 다음과 같습니다.

이 도시를 여러 산들이 병풍처럼 둘러싸고 있다.

연속상(진행상)

연속상이란 사태가 지속되고 있는 걸 가리킵니다. 예문을 보겠습니다.

철수는 흰 셔츠를 입고 있다.

위 예문은 결과상으로도, 연속상으로도 해석할 수 있는 중의적인 구문입니다. 연속상으로 해석할 경우 철수가 현재 흰 셔츠를 착용하는 동작을 수행하고 있음을 나타냅니다.

한국어의 연속상은 영어의 be+현재분사와 유사하지만 다른 점도 있습니다. 가장 다른 점은 -고 있-이 정적인 사태를 나타내는 동사와도 매우 자유롭게 결합할 수 있다는 점입니다. 다음 예문과 같습니다.

  • 알고 있다, 모르고 있다, 믿고 있다, 생각하고 있다, 사랑하고 있다…

이와 별개로 -ㄴ 중-은 정적인 사태를 나타내는 동사와 잘 결합하지 않습니다.

  • *아는 중이다, *모르는 중이다, *믿는 중이다, *생각 중이다, *사랑 중이다…

하지만 다음과 같은 예문이 자주 쓰이는데, 아래의 생각하다는 동적 사태(적극적으로 고민하고 있음)에 가까운 사태를 동사이기 때문인 것 같습니다.

나는 생각하는 중이야.

반복상

반복상이란 그 사태가 반복해서 발생함을 나타내는 상 범주입니다. 다음 예문과 같습니다.

최근 이와 비슷한 사건이 자주 발생하고 있다.

-어 있-, -고 있- 모두 결합할 수 있는 동사

-어 있--고 있-은 각각 특정 종류의 동사와만 결합하는 제약이 있으나 둘 모두 쓰이는 동사도 있습니다. 성장하다가 대표적인 사례입니다.

  • 성장해 있다(결과상) : 성장이라는 상태 변화가 어떤 완결점에 이미 도달해서 그 결과 상태가 지속됨을 나타냅니다.
  • 성장하고 있다(연속상) : 성장이라는 상태 변화가 아직 완결되지 않고 진행 중임을 나타냅니다.

동사가 다의어여서 둘 모두 결합하는 경우도 있습니다. 살다가 대표적인 사례입니다.

  • 살아 있다(결과상) : 살다alive 정도의 의미를 가질 때에는 시작점(태어난 순간), 끝점(죽는 순간)을 가지는 유계동사(telic verb)로서 생명력이 있는 상태가 지속됨을 나타냅니다.
  • 살고 있다(연속상) : 살다가 거주하다의 의미일 때는 무계동사(atelic verb)로서 해당 지역에 거주하는 중이다 정도의 의미를 나타냅니다.

Comment  Read more