안녕하세요, 여행벌입니다.

오늘은 대표적인 DP 문제 LIS에 대해서 포스팅해보겠습니다.


LIS ( Longest Increasing Subset )

 LIS는 이름 그대로 증가 부분 수열 중에서 가장 긴 부분 수열을 찾아내는 문제입니다.

 예를 들어, { 1 4 2 3 5 7 9 10 } 이 있다고 해봅시다.

 부분 수열이란 { 4 3 9 10 } 과 같이 수열에서 0개 이상의 숫자를 지우고 남은 수열을 말합니다.

 그 중, 증가 부분 수열이란 부분 수열의 원소들이 증가하고 있는 상태를 유지하는 것을 말합니다.

 { 1 2 3 5 7 9 } , { 4 5 7 9 10 } 등이 부분 증가 수열입니다.

  어떻게 증가 부분 수열 중 가장 길이가 긴 수열을 찾을 수 있을까요??

1) 완전 탐색

 가장 쉬운 방법은 모든 부분 증가 수열을 찾아보고, 그 중 가장 길이가 긴 수열을 찾는 방법입니다. 수열의 모든 부분에서 시작해서 모든 증가 수열을 DFS 탐색을 하듯이 탐색할 수 있습니다.

#include<vector>
#include<algorithm>

using namespace std;

int LIS(vector<int> A) {
	if (A.size() == 0) return 0;
	int ans = 0;
	for (int i = 0; i < A.size(); i++) {
		vector<int> B;
		// 시작점인 i보다 큰 애들을 다 입력하고 이어서 진행해본다.
		for (int j = i + 1; j < A.size(); j++)
			if (A[i] < A[j])
				B.push_back(A[j]);
		ans = max(ans, 1 + LIS(B));
	}
}

 다음과 같이 재귀적으로 코드를 구현할 수 있습니다.

 예를 들어 { 3, 1, 2, 5, 4 } 수열이 있다면, 다음과 같은 증가 부분 수열이 있습니다.

 { 3, 5 } / { 3, 4 } / { 1, 2 } / { 1, 2, 5 } / { 1, 2, 4 } / { 2, 5 } / { 2, 4 } / { 5 } / { 4 }

 이런 모든 경우의 수를 다 계산해서 가장 큰 값을 return 해주면 LIS 를 구할 수 있습니다. 완전 탐색이므로 당연히 굉장히 좋지 않은 시간 복잡도를 가지게 됩니다.

2) O(N^2) 시간 복잡도를 가지는 알고리즘

 메모리제이션을 이용하면 더 괜찮은 알고리즘을 구현할 수 있습니다.

 길이가 N인 수열이 있을 때,

 Dp[K] := (0 ~ K번 째 원소) 부분 수열 중 가장 큰 증가 부분 수열의 길이 라고 해보겠습니다

int LIS(vector<int> A) {
	int N = A.size();
	vector<int> Dp(N);
	Dp[0] = 1; 
	for (int i = 1; i < N; i++) {
		int m = 0;
		for (int j = 0; j < i; j++)
			if (A[j] < A[i])
				m = max(m, Dp[j]);
		Dp[i] = m + 1;
	}
	int ans = 0;
	for (int i = 0; i < N; i++)
		ans = max(ans, Dp[i]);
	return ans;
}

list =  { 3, 1, 2, 5, 4 } 수열을 생각해보겠습니다.

Dp[0] := { 3 } 에서 얻을 수 있는 가장 큰 증가 부분 수열의 길이이므로 1이 됩니다.

Dp[1] := { 3, 1 } 에서 가장 큰 증가 부분 수열의 길이이므로 1이 됩니다.

Dp[2] := { 3, 1, 2 } 에서 얻을 수 있는 가장 큰 증가 부분 수열의 길이이므로 2가 됩니다.

Dp[3] := { 3, 1, 2, 5 } 에서얻을 수 있는 가장 큰 증가 부분 수열의 길이이므로 3이 됩니다.

Dp[4] : = { 3, 1, 2, 5, 4 } 에서  얻을 수 있는 가장 큰 증가 부분 수열의 길이이므로 2가 됩니다.

 

 Dp 배열은 정의에 의해 다음과 같은 점화식이 성립합니다.

 Dp[K] := max(Dp[i], where 0 <= i < K and list[i] < list[K] ) + 1

 Dp[K]는 기존에 구한 가장 긴 증가 부분 수열에 list[K]를 뒤에 이어서 만들 수 있습니다. 따라서, list[K] 보다 값이 작아서 list[K]를 뒤에 이을 수 있는 증가 부분 수열 중에서 Dp 값이 가장 큰 값( 가장 길이가 긴 수열 )을 찾으면 Dp[K] 를 구할 수 있습니다.

 

 이중포문으로 Dp 배열을 채울 수 있으므로 O(N^2) 복잡도로 해결할 수 있습니다. 완전탐색보다는 많이 개선되었지만, 아직 N이 조금만 커져도 계산이 어렵다는 것을 알 수 있습니다.

3) O(NlogN) 시간 복잡도를 가지는 알고리즘

 Dp 배열의 정의를 조금 바꾸고, Binary Search를 이용해 Dp 배열을 채워나가면 훨씬 더 좋은 알고리즘을 구현할 수 있습니다.

 

 Dp[ x ] := 길이가 x 인 증가 부분 수열 중 가장 작은 마지막 값 이라고 정의하겠습니다.

 

 예를 들어, [ 4 2 6 3 1 5 ] 에서 길이가 2인 증가 부분 수열은 4 - 6 / 4 - 5 / 2 - 6 / 2 - 3 / 2 - 5 / 3 - 5 / 1 - 5 처럼 여러가지가 있지만, 이 중 가장 작은 마지막 값은 3이므로 Dp[2] = 3 이 된다.

 

 이때, Dp 배열은 항상 증가 상태를 유지합니다. 왜냐하면, Dp[1] 은 길이가 1인 가장 작은 마지막 값이고, Dp[2] 는 Dp[1] 의 원소에 더 큰 원소를 이어서 얻은 값이고, Dp[3] 은 Dp[2] 의 원소에 더 큰 원소를 이은 값이기 때문입니다. 따라서, 우리는 Dp 배열에 대해서 Binary_Search 를 이용할 수 있고, 시간 복잡도를 O(NlogN) 으로 줄일 수 있습니다.

 

 지금 알고 있는 가장 큰 증가 부분 수열의 길이가 k이고 Dp[k] 까지 알고 있다고 해보겠습니다. 다음에 순회하는 원소가 Dp[k] 보다 크다면, 지금 구한 가장 큰 증가 부분 수열의 뒤에 원소를 이을 수 있으므로, Dp[k + 1] 을 갱신할 수 있습니다. 아니라면, 순회하는 원소를 Binary_Search를 이용해 Dp[1] ~ Dp[k] 중에 적절한 위치에 갱신을 하면 됩니다. 

int LIS(vector<int> v) {
	int N = v.size();
	vector<int> Dp(N + 1, INF);
	int longest = 1;
	Dp[0] = -INF;
	Dp[1] = v[0];
	for (int cur : v) {
		if (Dp[longest] < cur) {
			longest++;
			Dp[longest] = cur;
		}
		else {
			vector<int>::iterator it = lower_bound(Dp.begin(), Dp.end(), cur);
			*it = cur;
		}
	}
	int ans = 0;
	for (int i = 1; i <= N; i++)
		if (Dp[i] != INF) ans = i;
	return ans;
}

 배열의 길이가 N일 때, 최대 증가 부분 수열의 길이는 N이므로 Dp 배열의 크기는 (N + 1) 이 되고, 초기화는 INF 로 초기화해둡니다. Dp[ 0 ] 은 길이가 0인 증가 부분 수열이므로 존재하지 않고, 이분 탐색을 위해 Dp[0] = -INF 로 초기화해두겠습니다. Dp[1] 은 길이가 1인 증가 부분 수열이므로 일단은 v[0] 으로 채워둡니다. longest는 현재 찾은 가장 긴 증가 부분 수열의 길이라고 하겠습니다.

 

 배열의 원소들을 앞에서부터 순회하며 현재 찾은 가장 긴 증가 부분 수열의 마지막 값인 Dp[longest] 보다 큰 값이라면 가장 긴 증가 부분 수열의 뒤에 값을 이어서 LIS 를 만들 수 있으므로, longest는 1 증가하고, Dp 값을 갱신할 수 있습니다. 아니라면, 현재 순회 중인 원소를 Binary_Search를 통해서 Dp 배열을 갱신하면 됩니다.

 

 예를 들어, { 3, 1, 4, 2, 5, 6 } 이 있다고 하겠습니다.

순서 DP[0] DP[1] DP[2] DP[3] DP[4] DP[5] DP[6]
초기값 -INF 3 INF INF INF INF INF
1) 3을 순회 -INF 3 INF INF INF INF INF
2) 1을 순회 -INF 1 INF INF INF INF INF
3) 4를 순회 -INF 1 4 INF INF INF INF
4) 2를 순회 -INF 1 2 INF INF INF INF
5) 5를 순회 -INF 1 2 5 INF INF INF
6) 6을 순회 -INF 1 2 5 6 INF INF

 N개의 원소를 순회하면서, Binary_Search를 진행하므로 O(NlogN) 시간 복잡도를 가지는 것을 확인할 수 있습니다.


 

안녕하세요, 여행벌입니다.

오늘은 2진수 표기법의 특징을 활용한 비트마스킹 알고리즘에 대해서 포스팅해보겠습니다.


[ 비트마스킹 ]

 컴퓨터는 내부적으로 모든 자료를 이진수로 표현합니다. 이와 같은 특성을 이용해 정수의 이진수 표현을 자료구조로 쓰는 기법을 비트 마스크라고 합니다.

 비트 마스크를 이용하면 더 빠른 수행 시간, 더 간결한 코드, 더 적은 메모리 사용이라는 효과를 얻을 수 있습니다.

비트 연산자

 비트마스킹은 기본적으로 비트를 다뤄야 하므로, 비트 연산자에 대해서 먼저 간단하게 알아보겠습니다.

a & b a의 모든 비트와 b의 모든 비트를 AND연산한다.
둘다 1이라면 1, 아니면 0
ex)
a = 4 = 100(2)
b = 7 = 111(2)
a & b = 100(2) = 4
a | b a의 모든 비트와 b의 모든 비트를 OR연산한다.
둘다 0이라면 0, 아니면 1
ex) 
a = 2 = 010(2)
b = 5 = 101(2)
a | b = 111(2) = 7
a ^ b a의 모든 비트와 b의 모든 비트를 XOR연산한다. 둘이 다르다면 1, 아니면 0 ex)
a = 3 = 011(2)
b = 5 = 101(2)
a ^ b = 110(2) = 6
~a a의 모든 비트에 NOT 연산을 한다.
0이면 1, 1이면 0
ex) 3비트라고 가정
a = 3 = 011(2)
~a  = 100(2) = 4
a << b a를 b비트 만큼 왼쪽으로 시프트 ex) 
a = 1 = 001(2)
a << 2 = 100(2) = 4
a >> b a를 b비트 만큼 오른쪽으로 시프트 ex)
a = 4 = 100(2)
a >> 2 =  001(2) = 1

집합의 표현

 비트마스크를 이용하면 집합을 쉽게 표현할 수 있습니다. 또, 집합에 원소를 추가, 삭제하는 등 다양한 연산을 굉장히 빠르게 수행할 수 있습니다.

 

 그럼 비트를 이용해서 어떻게 집합을 표현할 수 있을까요? 원소의 개수가 N인 집합이 있다고 하면, 각각의 원소를 0번부터 (N-1)번 까지 번호를 부여하고, 번호에 해당하는 비트가 1이면 원소가 포함, 0이면 원소가 불포함이라고 한다면 집합을 비트를 이용해 표현할 수 있습니다.

 

 예를 들어, { A, B, C, D, E, F, G } 집합이 있다고 하겠습니다.

총 7개의 원소가 존재하므로 우리는 7개의 비트로 이 집합을 표현할 수 있습니다. 즉, 각 원소마다 비트를 하나씩 대응시켜서 원소가 존재한다면 1, 존재하지 않다면 0으로 표현해보겠습니다.

예를 들어, { A } 라는 부분집합은 64 = 1000000(2) 로 표현하고 { C, F } 는 18 = 0010010(2) 로 표현할 것입니다.

원소 추가

 현재 상태 cur이 있을 때, p번 원소를 추가한다고 해보겠습니다. 그럼, 현재 상태 cur에서 p번 비트를 1로 바꿔줘야 됩니다. a | b 비트연산자를 활용하면 쉽게 해결할 수 있습니다.

cur = cur | (1 << p)

 1 << p를 통해서 p번 비트만 1, 나머지 비트는 0인 값을 만들고 | 연산을 진행한다면 cur의 p번 비트는 1로 바뀌게 되고, 나머지 비트는 원래 상태를 유지하게 됩니다.

원소 삭제

 원소를 삭제하는 연산도 쉽게 구현할 수 있습니다. 현재 상태 cur에서 p번 원소를 삭제한다고 생각해보겠습니다. 그러면, p번 비트를 0으로 바꿔줘야됩니다.

cur = cur & ~(1 << p);

 1 << p 를 통해서 p번 비트만 1, 나머지 비트는 0으로 만듭니다. 그 후, ~ 연산을 통해 p번 비트만 0, 나머지 비트는 1로 만들고 & 연산을 진행한다면 p번 비트만 0으로 바뀌고 나머지는 현재 상태 cur과 동일하게 유지할 수 있습니다.

원소 토글

 p번 비트가 1이면 0, 0이면 1로 바꾸는 토글 연산도 쉽게 구현할 수 있습니다. 현재 상태 cur에서 p번 원소가 있다면 삭제하고, 없다면 추가해보겠습니다.

cur = cur ^ (1 << p);

 1 << p 를 통해서 p번 비트만 1, 나머지 비트는 0으로 만듭니다. cur의 나머지 비트들은 0과 XOR 연산을 진행하므로 0이면 0, 1이면 1로 현재 상태를 유지하게 되고, p번 비트는 1과 XOR 연산을 진행하므로 1이면 0, 0이면 1로 토글이 됩니다.

집합 연산(합집합)

 비트마스킹을 이용하면 원소를 추가, 삭제, 토글 하는 연산 외에도 합집합, 교집합, 차집합 등등을 쉽게 구할 수 있습니다.

a | b; // a 와 b의 합집합
a & b; // a 와 b의 교집합
a & ~b; // a 에서 b를 뺀 차집합
a ^ b; // a와 b 중 하나에만 포함된 원소들의 집합

 A집합을 나타내는 a와 B집합을 나타내는 b가 있을 때, 둘이 | 연산을 하게 된다면 존재하는 원소들의 비트는 모두 1로 켜지게 되고, 두 집합에 모두 없는 원소들만 비트가 0이 됩니다. 따라서, 합집합 연산이 됩니다.

 마찬가지로 & 연산을 하게 되면, 두 집합에 모두 존재하는 원소들의 비트만 1과 1을 AND 연산하게 되므로 1로 살아남고, 나머지는 0이 됩니다. 따라서 교집합 연산이 됩니다.

 a & ~b 연산을 하게 되면 a 집합과 b의 여집합과 & 연산을 하게 됩니다. 즉, A - B 인 차집합 연산이 됩니다.

 마지막으로, ^ 연산을 하게 되면 둘 중 하나에만 포함된 원소들만 1로 살아남게 됩니다.

집합의 크기 구하기

 집합의 크기를 구하려면, 현재 1로 켜져 있는 비트의 수를 count 해야 됩니다. 따라서, 모든 비트를 순회해야 되고 재귀적으로 다음과 같이 구현할 수 있습니다.

int bitCount(int x){
	if(x == 0) return 0;
	return x % 2 + bitCount(x / 2);
}

 x % 2 를 하면 마지막 비트를 얻게 되고, x / 2 를 하게 되면 마지막 비트를 삭제할 수 있습니다. 따라서, 모든 비트를 재귀적으로 순회할 수 있습니다.

모든 부분 집합 순회하기

 어떤 집합의 모든 부분 집합을 순회하는 과정도 정말 간단하게 구현할 수 있습니다.

for(int subset = set; subset; subset = (subset - 1) & set){
	// subset은 set의 부분집합 중 하나.
}

 예를 들어, { A, B, D } 를 포함한 집합을 생각해보겠습니다.

 모든 부분 집합은 공집합을 제외하고 { A }, { B }, { D }, { A, B }, { A, D }, { B, D }, { A, B, D } 가 존재합니다.

 비트로 표현하면 다음과 같습니다.

{ A, B, D } 1101(2)
{ A, B } 1100(2)
{ A, D } 1001(2)
{ B, D } 0101(2)
{ A } 1000(2)
{ B }  0100(2)
{ D } 0001(2)

 위에서 구현한 코드를 한 번 따라가 보겠습니다. set = 1101(2) = { A, B, D } 입니다.

subset (subset - 1) (subset - 1) & set
1101(2) // { A B D } 1100(2) 1100(2)
1100(2) // { A B } 1011(2) 1001(2)
1001(2) // { A D } 1000(2) 1000(2)
1000(2) // { A } 0111(2) 0101(2)
0101(2) // { B D } 0100(2) 0100(2)
0100(2) // { B }  0011(2) 0001(2)
0001(2) // { D } 0000(2) 0000(2) // 종료

 for문을 통해 모든 부분 집합을 다 순회하는 것을 확인할 수 있습니다.


 위처럼 비트마스크를 이용하면 집합의 표현, 원소 추가, 원소 삭제, 원소 토글, 합집합, 교집합, 차집합 등등 다양한 연산들을 어떤 자료구조보다 빠르게 구현할 수 있습니다.

안녕하세요, 여행벌입니다.

오늘은 문자열을 검색할 때 사용하는 강력한 알고리즘인 KMP 알고리즘에 대해서 포스팅해보겠습니다.


[ 문자열 검색 ]

 문자열 검색이란 주어진 긴 문자열(H)에서 문자열(N)을 부분 문자열로 포함하는지 확인하고, 포함한다면 N과 일치하는 부분 문자열의 시작 위치를 찾는 문제를 문자열 검색 문제라고 합니다.

 예를 들어, "ABCDABCABACBABC" 에서 "ABC" 문자열을 찾는 경우를 생각해보겠습니다.

 가장 간단하고 확실한 방법은 H의 모든 지점에서 N과 하나씩 비교해나가면서 같다면 저장하고, 아니라면 다시 다음 칸으로 이동해서 같은 과정을 반복하는 방법입니다.

 하지만, 이 방법은 H의 모든 지점에 대해서 N개 씩 문자를 비교해야되므로 O(|H| * |N|) 의 복잡도를 가지게 됩니다! 보통 N은 길이가 작지만, H는 길이가 크므로 그리 좋은 복잡도는 아닌 것을 알 수 있습니다.

 KMP 알고리즘을 이용하면 O(|H| * |N|) 복잡도를 O(|H| + |N|) 으로 획기적으로 줄일 수 있습니다.

[ 사전 지식 ]

 KMP 알고리즘을 다루기 전에 자주 쓰이는 용어부터 정의하고 넘어가겠습니다.

● 접두사(prefix) : 어떤 문자열 S의 0번 글자부터 a번 글자까지로 구성된 부분 문자열을 S의 접두사(prefix)라고 합니다. 

● 접미사(suffix) : 어떤 문자열 S의 b번 글자부터 마지막 글자까지로 구성된 부분 문자열을 S의 접미사(prefix)라고 합니다.

 

ex ) 문자열 "abcde"

접두사 : a / ab / abc / abcd / abcde

접미사 : e / de / cde / bcde / abcde

 

부분 일치 테이블(partial match table)

 어떤 문자열 N에 대해서,  pi[ i ] := N[0 - i] 의 접두사도 되고 접미사도 되는 문자열의 최대 길이

ex ) "AABAABAC"

pi[0] = "A" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = 0

pi[1] = "AA" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = "A" = 1

pi[2] = "AAB" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = 0

pi[3] = "AABA" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = "A" = 1

pi[4] = "AABAA" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = "AA" = 2

pi[5] = "AABAAB" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = "AAB" = 3

pi[6] = "AABAABA" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = "AABA" = 4

pi[7] = "AABAABAC" 의 접두사도 되고 접미사도 되는 문자열의 최대 길이 = 0

[ KMP 알고리즘 ]

 KMP 알고리즘은 검색 과정에서 얻는 정보를 버리지 않고 활용해서 시간을 절약합니다. 즉, 불일치가 일어났을 때 지금까지 일치한 글자의 수를 이용해 다음으로 시도해야 할 시작 위치를 빠르게 찾아냅니다.

  다음과 같은 경우를 생각해보겠습니다. "ABCAABAABADAABC" 에서 "AABAABAC" 를 찾는데, H[3번]에서 탐색을 진행하다 다음과 같이 마지막 부분에서만 다르다는 것을 알았다고 합시다.

 이때, 일치한 부분인 S = "AABAABA"에 집중을 해봅시다.

 우리는 다음과 같은 사실을 알 수 있습니다. "AABAABA"에서 접두사와 접미사가 최대한 같은 부분은 접두사 S[0번~3번글자] "AABA" 와 S[3번~6번글자] "AABA" 입니다. 이를 이용해 다음과 같은 2가지 사실을 알아낼 수 있습니다.

● "AABAABA" 에서 접두사와 접미사가 같은 부분이 S[3]부터 진행되므로 우리는 S[1], S[2] 번에 대해서 추가로 탐색할 진행할 필요 없이 그냥 3번으로 이동해서 탐색을 진행하면 됩니다. 즉, 접두사 AABA가 다시 반복되는 부분부터 탐색을 진행하면 되므로 H[4번], H[5]번에서 탐색을 진행하는 것이 아니라 H[6번]에서 탐색을 진행하면 됩니다.

 위의 그림과 같은 2가지 경우는 탐색할 필요도 없이 당연히 다르다는 것을 알 수 있습니다.

 

"AABAABA" 에서 3번으로 이동 후, "AABA" 4글자는 이미 같다는 것을 알고 있으므로 5번 째부터 비교를 진행해나가면 됩니다.

 KMP알고리즘은 위와 같이 불일치가 일어났을 때, 지금까지 일치한 부분으로부터 정보를 끌어내고 그 정보를 이용해서 탐색을 더 효율적으로 진행합니다. 따라서, KMP 코드는 다음과 같습니다.

vector<int> kmp(string H, string N) {
	int n = H.size(), m = N.size();
	vector<int> ans;
	vector<int> pi = getPartialMatch(N);
	int begin = 0, matched = 0;
	while (begin + m <= n) {
		if (matched < m && H[begin + matched] == N[matched]) {
			matched++;
			if (matched == m) // 끝까지 다 일치했습니다.
				ans.push_back(begin);
		}
		else {
			if (matched == 0) begin++; // 일치한 부분이 하나도 없음 -> 시작포인트를 한 칸 앞으로
			else {
				begin += matched - pi[matched - 1]; // pi 배열을 이용해 다음 시작 지점으로 점프
				matched = pi[matched - 1]; // pi 배열을 이용해 이미 일치하는 부분을 건너뛰고 탐색 시작
			}
		}
	}
	return ans;
}

 예시를 통해서 다시 한 번 KMP 작동 원리를 이해해보겠습니다.

 H = "ABCABABDABABC" 에서 N ="ABABC" 를 찾는 과정을 차례대로 진행해보겠습니다.

Step 1)

begin = 0, matched = 0 에서 시작합니다.

H[0 ~ 1] 와 N[0 ~ 1] 가 "AB" 로 같으므로, matched = 2이 됩니다. ( 일치하는 문자가 2개 )

이때, "AB" 의 Pi 배열 값이 0이므로 우리는 "AB"에서 겹치는 접두사, 접미사가 하나도 없다는 것을 알 수 있습니다. 우리는 다음 탐색이 H[1] = "B" 에서 시작될건데, "AB"까지는 match가 됐으므로, H[1]="B"에서 탐색을 진행해봐야 N[0] = "A"와 match되지않는 것을 알 수 있습니다.

 따라서, 다음 탐색을 굳이 H[1] 에서 진행할 필요가 없으므로 begin 을 2로 Jump 합니다.

 

Step 2)

begin = 2, matched = 0 에서 시작합니다.

H[2] 과 N[0] 은 시작부터 다르므로 matched = 0 이 됩니다. 일치하는 문자가 하나도 없으므로, 활용할 수 있는 정보가 없고 바로 다음 칸으로 넘어가서 탐색을 진행합니다.

 

Step 3)

begin = 3, matched = 0 에서 시작합니다.

 이번에는 "ABAB" 4글자가 일치하고, 이때 접두사와 접미사가 같은 "AB" 문자열이 존재합니다!! 

 어렵게 생각하지 말고, 간단하게 생각해봅시다. 일단 우리는 N 문자열을 다음 칸 부터 탐색할 필요가 없습니다. 왜냐하면 "ABAB" 이므로 접두사 "AB"를 건너뛰고 다시 시작해도 "AB"가 반복되는 것을 알 수 있습니다. 따라서, 다음 탐색을 아래 그림과 같이 2칸 건너뛰고 시작해도 됩니다!

 또, 우리는 2칸 건너뛰어서 탐색을 진행해도 이미 앞의 "AB"는 겹치는 것을 알고 있습니다. 따라서, 이미 matched = 2를 만족한다는 것을 알 수 있습니다! 즉, 2칸 더 건너뛰어서 탐색을 진행해도 됩니다.

 

 이제 KMP 알고리즘이 이해가 가시나요??!

 불일치가 발생할 시, 그 정보를 이용해서 위와 같이 탐색을 안해도되는 부분을 다 건너뛰고 탐색을 진행할 수 있습니다.

 

 그렇다면, 부분 일치 테이블 pi 배열은 어떻게 구할까요?

 간단한 방법은 O(|N| * |N|) 으로 접미사도 되고 접두사도 되는 문자열의 최대 길이를 구합니다.

vector<int> getPartialMatch(string N) {
	int m = N.size();
	vector<int> pi(m, 0);
	for (int begin = 1; begin < m; begin++) {
		for (int i = 0; i < m - begin; i++){
			if (N[begin + i] != N[i]) break;
			pi[begin + 1] = max(pi[begin + i], i + 1);
		}
	}
	return pi
}

 하지만, pi 배열도 KMP 알고리즘을 이용해서 훨씬 빠르게 구할 수 있습니다. 코드는 다음과 같습니다.

vector<int> getPartialMatch(string N) {
	int m = N.size();
	vector<int> pi(m, 0);
	int begin = 1, matched = 0;
	while (begin + matched < m) {
		if (N[begin + matched] == N[matched]) {
			matched++;
			pi[begin + matched - 1] = matched;
		}
		else {
			if (matched == 0) begin++;
			else {
				begin += matched - pi[matched - 1];
				matched = pi[matched - 1];
			}
		}
	}
	return pi;
}

 pi 배열

Step 1)

begin = 1, matched = 0 에서 시작하겠습니다.

N[1] 과 N[0] 은 다르고 일치한 부분이 하나도 없으므로 begin 은 다음 칸으로 이동합니다.

Step 2)

 begin = 2, matched = 0 에서 시작하겠습니다.

N[2] 과 N[0] 은 "A"로 일치하므로, matched를 1 증가시키고, pi[2] = 1이 됩니다.

N[3] 과 N[1] 은 "B"로 일치하므로, matched를 1 증가시키고, pi[3] = 2가 됩니다.

N[4] 와 N[2] 는 "A"로 일치하므로, matched를 1 증가시키고, pi[4] = 3가 됩니다.

N[5] 와 N[3] 은 "C" 와 "B"로 일치하지않습니다.

이때, 우리는 KMP 알고리즘처럼 matched 가 3이고 pi[2] 는 "ABA" 로 1이므로 begin 을 2칸 Jump하고,

matched도 1로 바로 증가시켜버려도 됩니다.

 최종적으로 위 과정을 거치면 pi 배열을 O(|N| * |N|) 보다 효율적으로 얻을 수 있습니다.  

 즉, KMP 알고리즘을 이용해서 pi 배열을 구하면 O(|N|) 으로 시간 복잡도를 줄일 수 있습니다.

[ 최종 코드 ]

 KMP 알고리즘을 이용한다면 문자열 탐색 과정의 시간 복잡도를

O(|N| * |H| + |N| * |N| ) → O(|N| + |H| + |N|) 으로 줄일 수 있습니다.

 ( Pi 배열을 구하는 복잡도 포함 ! )

vector<int> getPartialMatch(string N) {
	int m = N.size();
	vector<int> pi(m, 0);
	int begin = 1, matched = 0;
	while (begin + matched < m) {
		if (N[begin + matched] == N[matched]) {
			matched++;
			pi[begin + matched - 1] = matched;
		}
		else {
			if (matched == 0) begin++;
			else {
				begin += matched - pi[matched - 1];
				matched = pi[matched - 1];
			}
		}
	}
	return pi;
}

vector<int> kmp(string H, string N) {
	int n = H.size(), m = N.size();
	vector<int> ans;
	vector<int> pi = getPartialMatch(N);
	int begin = 0, matched = 0;
	while (begin + m <= n) {
		if (matched < m && H[begin + matched] == N[matched]) {
			matched++;
			if (matched == m) // 끝까지 다 일치했습니다.
				ans.push_back(begin);
		}
		else {
			if (matched == 0) begin++; // 일치한 부분이 하나도 없음 -> 시작포인트를 한 칸 앞으로
			else {
				begin += matched - pi[matched - 1]; // pi 배열을 이용해 다음 시작 지점으로 점프
				matched = pi[matched - 1]; // pi 배열을 이용해 이미 일치하는 부분을 건너뛰고 탐색 시작
			}
		}
	}
	return ans;
}

 

안녕하세요, 여행벌입니다.

오늘은 가장 대표적인 최단 경로 알고리즘인 다익스트라(Dijkstra) 알고리즘에 대해서 포스팅해보겠습니다.


최단 경로 알고리즘

 최단 경로 알고리즘은 크게 단일 시작점 알고리즘과 모든 쌍 알고리즘으로 나뉩니다. 단일 시작점 알고리즘은 지난 포스팅에서 다뤘던 BFS 탐색과 비슷하게 하나의 시작점에서 다른 모든 정점까지 가는 최단 거리를 구해줍니다. 반면에 모든 쌍 알고리즘은 모든 정점의 쌍에 대해 최단 거리를 계산해줍니다. 단일 시작점 알고리즘으로 가장 유명하고 대표적인 알고리즘이 다익스트라 알고리즘입니다. 즉, 다익스트라 알고리즘을 통해 우리는 시작 정점에서 다른 모든 정점까지 가는 최단 거리를 구할 수 있습니다.

다익스트라 알고리즘

 다익스트라 알고리즘의 작동 원리는 간단합니다. 시작 정점에서부터 아직 방문하지 않은 정점 중 거리가 가장 가까운 정점을 찾습니다. 이때, 거리가 가장 가까운 정점 B를 찾는 방법은 현재까지 방문한 정점 집합에서 거리가 가장 가까운 정점을 찾습니다. 그 후, 다른 모든 정점에 대해서 현재 시작 정점과의 최단 거리와 새롭게 찾은 정점 B를 거쳐서 가는 거리를 비교해서 새롭게 최단 거리를 갱신합니다.

 쉽게 표현하면 A -> C 로 가는 현재 최단 거리와 A -> B -> C 로 가는 거리를 비교해서 최단 거리를 갱신해나갑니다.

다익스트라 알고리즘은 시작 정점에서 다른 모든 정점까지의 최단 거리를 저장하는 dist 배열과 방문한 정점에 대해서 기록하는 visited 배열이 필요합니다.

● 간선이 존재하지않는 정점과 정점은 가중치를 INF(매우 큰 값)으로 초기에 설정해놓아야합니다.

● 음수 간선이 존재하는 경우, 최단 거리를 구할 수 없습니다.

 

 그럼, 예시를 통해 다익스트라 알고리즘의 작동 원리를 알아보겠습니다.

다익스트라 알고리즘 코드 구현 (C)

// graph에는 간선이 존재하면 가중치가, 간선이 존재하지않으면 INF가 저장되어있다.
int graph[MAX_VERTICES][MAX_VERTICES];
int distance[MAX_VERTICES];
bool visited[MAX_VERTICES];

void dijkstra(int start) {
	// 초기화 작업 
	for (int i = 0; i < MAX_VERTICES; i++)
		distance[i] = graph[start][i], found[i] = false;
	found[start] = true;
	distance[start] = 0;
	// 가장 가까운 정점부터 탐색을 진행하자.
	for (int i = 0; i < MAX_VERTICES - 1; i++) {
		// 현재 시점에서 방문하지 않은 정점 중 가장 가까운 정점을 찾는다.
		int u = 0, minC = INF;
		for (int j = 0; j < MAX_VERTICES; j++) {
			if (!found[j] && distance[j] < minC)
				u = j, minC = distance[j];
		}
		found[u] = true;
		// 정점을 추가해서 dist 배열을 갱신하자.
		for (int j = 0; j < MAX_VERTICES; j++) {
			if (!found[j] && distance[u] + graph[u][j] < distance[j])
				distance[j] = distance[i] + graph[i][j];
		}
	}
}

 먼저, 현재 그래프의 상태를 담고 있는 graph 배열은 간선이 존재하면 해당 가중치 값을, 아니라면 INF 값으로 셋팅이 되어있어야합니다. 그 후, 시작 정점(S)에서 탐색하지 않은 정점 중 가장 가까운 정점(U)을 찾고, 나머지 모든 정점(W)에 대해서 U를 거쳐서 가는 것이 더 작다면 distance 를 갱신해줍니다.

 즉, distance[W] = min(distance[W], distance[U] + graph[U][W]); 가 성립합니다.

다익스트라 알고리즘 코드 구현 (C++)

vector<pair<int, int>> graph[MAX_VERTICES];

void dijkstra(int start) {
	// distance 배열을 선언하고 INF로 초기화합니다.
	vector<int> distance(MAX_VERTICES, INF);
	vector<bool> visited(MAX_VERTICES, false);

	distance[start] = 0;
	visited[start] = true;
	while (1) {
		int closest = INF, here;
		for(int i = 0; i < MAX_VERTICES; i++)
			if (!visited[i] && distance[i] < closest) {
				here = i;
				distance[i] = closest;
			}
		if (closest == INF) break;  
		// 가장 가까운 점 방문
		for (int i = 0; i < graph[here].size(); i++) {
			int next = graph[here][i].first;
			if (visited[next]) continue;
			int nextDist = distance[here] + graph[here][i].second;
			distance[next] = min(distance[next], nextDist);
		}
	}
}

우선순위 큐를 이용한 다익스트라

 자료구조 우선순위 큐를 이용하면 위에서 구현한 다익스트라 알고리즘보다 효율적인 다익스트라 알고리즘을 구현할 수 있습니다. 우선순위 큐를 이용한 다익스트라 알고리즘은 BFS 탐색과 굉장히 유사합니다.

 우선순위 큐에 정점 번호와 함께 지금까지 찾아낸 해당 정점까지의 최단 거리를 쌍으로 넣습니다. 우선순위 큐는 정점까지의 최단 거리를 기준으로 정점을 배열함으로써, 아직 방문하지 않은 정점 중 시작점으로부터의 거리가 가장 가까운 점을 찾는 과정을 간단하게 해 줍니다.

 따라서, 우리는 우선순위 큐에서 현재 가장 가까운 정점을 뽑을 수 있습니다. 이때, 현재 해당 정점까지 구해놓은 최단 거리가 우선순위 큐에서 뽑은 값보다 더 작다면 이 경우는 갱신할 필요가 없으므로 무시합니다. 이렇게 구현하면 visited 배열을 따로 선언하지 않아도 됩니다.

 그 후, 현재 정점에서 연결된 다른 정점들로 탐색을 진행하가며 dist 배열을 갱신하고, dist 배열이 갱신된 경우는 새로운 최단 거리를 찾았다는 뜻이므로 다시 우선순위 큐에 넣어줍니다. 

우선순위 큐는 기본적으로 내림차순 기준인 Max Heap으로 구현되므로 가중치를 음수로 바꾸어 진행하면 됩니다. 나머지 과정은 따로 진행하지 않겠습니다.

우선순위 큐를 이용한 다익스트라 알고리즘 코드 구현 (C++)

 C++ STL 에서 지원해주는 우선순위 큐를 이용하면 손쉽게 구현할 수 있습니다. 이때, 우리는 거리가 짧은 정점을 큐에서 뽑아내고 싶으므로, 큐에 ( -최단거리, 정점 번호 ) 를 쌍으로 넣습니다.

#include<vector>
#include<queue>
using namespace std;

#define MAX_VERTICES 100
#define INF 987654321
vector<pair<int, int>> graph[MAX_VERTICES];

void dijkstra(int start) {
	// distance 배열을 선언하고 INF로 초기화합니다.
	vector<int> distance(MAX_VERTICES, INF);
	distance[start] = 0;
	priority_queue<pair<int, int>> pq;
	pq.push({ 0, start }); 
	while (!pq.empty()) {
		int cost = -pq.top().first;
		int here = pq.top().second;
		pq.pop();
		// 지금 꺼낸 것보다 더 짧은 경로를 알고 있다면 무시한다.
		if (distance[here] < cost) continue;
		// 인접한 정점들을 검사하며 경로 갱신!
		for (int i = 0; i < graph[here].size(); i++) {
			int next = graph[here][i].first;
			int nextDist = cost +  graph[here][i].second;
			// 현재 정점을 거쳐서 이동하면 최단 거리가 갱신되는 경우
			if (nextDist < distance[next]){
				distance[next] = nextDist;
				pq.push({ -nextDist, next });
			}
		}
	}
}

다익스트라 알고리즘 시간 복잡도

* 정점의 개수 V , 간선의 개수 E *

 우선순위 큐를 이용하지 않은 다익스트라 알고리즘은 정점이 V개 있을 때, 시작 정점을 제외한 모든 정점에 대해서, 가장 가까운 점을 찾고, 최단 거리를 갱신해주는 과정을 거치므로 O(V^2 + E) 의 시간 복잡도를 가지게 됩니다.

 하지만, 우선순위 큐를 이용해 다익스트라 알고리즘을 구현하게 된다면 시간 복잡도를 줄일 수 있습니다.

 먼저 우선순위 큐에 추가되는 원소의 수는 최대 E(간선의개수)개 이므로 O(logE) 의 시간이 걸립니다. 또, E개의 원소에 대해 이 작업을 진행해야하므로 O(E * logE)의 시간 복잡도를 가지는 것을 알 수 있습니다.

 보통, E는 V^2 보다 작기 때문에 O(E * logV) 로 시간 복잡도를 표기할 수 있습니다.


 

+ Recent posts