테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

문자열

글 속에서 패턴을 빠르게 찾고, 두 글이 얼마나 다른지 재는 알고리즘이다.

목차 (6)
  1. 단순 탐색: 모든 자리에 대 보기
  2. KMP의 실패 함수: 틀렸을 때 얼마나 되돌릴까
  3. KMP로 찾기
  4. 라빈-카프: 해시로 먼저 거르기
  5. 편집 거리: 두 글이 얼마나 다른가
  6. 접미사 배열: 한 번 정리해 두고 여러 번 찾기

KMP의 실패 함수: 틀렸을 때 얼마나 되돌릴까

단순 탐색의 낭비는 이미 맞춘 글자를 다시 비교하는 데 있다. 0번 자리에서 ABABA까지 맞췄다면, 본문의 그 다섯 글자는 이미 안다. 그 안에서 패턴의 앞부분과 겹칠 수 있는 곳은 패턴만 보고도 미리 계산할 수 있다. KMP(Knuth–Morris–Pratt) 알고리즘은 이 계산을 실패 함수(failure function, prefix function) 표에 담아 둔다.

π[i]는 패턴의 앞 i + 1글자에서 접두사이면서 동시에 접미사인 가장 긴 부분(자기 자신 전체는 빼고)의 길이다. 예를 들어 ABABA는 앞의 ABA와 뒤의 ABA가 같으므로 π[4] = 3이다. j글자를 맞춘 뒤 틀리면 패턴을 j − π[j − 1]칸 밀고, 겹친 π[j − 1]글자는 이미 맞은 것으로 치고 이어서 비교한다.

패턴 ABABAC의 실패 함수 0, 0, 1, 2, 3, 0. 예를 들어 ABABA까지는 앞의 ABA와 뒤의 ABA가 같아 값이 3이다위치 i패턴실패 함수 π[i]0A01B02A13B24A35C0예: π[4] = 3ABABA2칸 민 패턴ABABAABABACABABA의 끝 3글자(ABA)가 패턴의 앞 3글자와 같다.여기서 틀리면 패턴을 2칸 밀고, 겹친 3글자는 다시 비교하지 않는다.접두사 = 접미사로 겹치는 부분
패턴 ABABAC의 실패 함수. 아래는 π[4] = 3을 그림으로 본 것이다. ABABA를 맞춘 뒤 틀리면, 패턴을 두 칸 밀어도 앞의 ABA가 본문의 뒤쪽 ABA와 이미 맞는다는 것을 안다.
failure[0] = 0, k = 0
for i = 1 .. m-1:
  while k > 0 and p[i] ≠ p[k]: k = failure[k-1]
  if p[i] == p[k]: k += 1
  failure[i] = k

표를 만드는 일 자체가 패턴으로 패턴을 찾는 KMP다. k는 한 번에 1씩만 늘고 while에서 줄기만 하므로, 줄어드는 총량이 늘어난 총량을 넘을 수 없다. 그래서 이중 반복처럼 보여도 O(m)이다. 자세한 증명과 응용은 cp-algorithms의 Prefix function에 정리돼 있다.

라빈-카프: 해시로 먼저 거르기

라빈-카프(Rabin–Karp)는 비교를 해시로 대신한다. 패턴의 해시를 한 번 구해 두고, 본문의 길이 m짜리 창(window)마다 해시를 구해 같을 때만 글자를 비교한다. 핵심은 창을 한 칸 밀 때 해시를 처음부터 다시 계산하지 않는 것이다. 문자열을 B진법 수로 보면, 나가는 글자의 몫을 빼고 B를 곱한 뒤 들어오는 글자를 더하면 새 창의 해시가 O(1)에 나온다. 이것을 굴리는 해시(rolling hash)라 한다.

라빈-카프 단계 그림: 세 칸짜리 창을 한 칸씩 밀며 해시를 갱신한다. 3번 자리 897은 해시만 같은 가짜 일치이고, 5번 자리 793이 진짜 일치다패턴 793: 해시 = 793 mod 13 = 0본문창 해시05132538495769738293108114창 535: 해시 2해시가 달라 건너뜀창 358: 해시 7해시가 달라 건너뜀창 589: 해시 4해시가 달라 건너뜀창 897: 해시 0해시만 같음: 가짜 일치창 979: 해시 4해시가 달라 건너뜀창 793: 해시 0해시도 글자도 같음: 찾음창 932: 해시 9해시가 달라 건너뜀창 323: 해시 11해시가 달라 건너뜀창 238: 해시 4해시가 달라 건너뜀창 384: 해시 7해시가 달라 건너뜀27404091147갱신: 새 해시 = ((옛 해시 − 나가는 숫자 × 10²) × 10 + 들어오는 숫자) mod 13지금 창0가짜 일치0진짜 일치(창 시작 칸 아래에 해시를 적는다)
1 / 10

첫 창 535의 해시를 직접 계산한다. 2 ≠ 0: 글자를 보지 않고 넘어간다.

라빈-카프로 숫자열 535897932384에서 패턴 793의 자리를 찾는다. 해시는 숫자를 십진수로 읽어 13로 나눈 나머지다. 창을 밀 때마다 나가는 숫자를 빼고 들어오는 숫자를 더해 해시를 갱신한다. 해시가 패턴과 같은 두 창 중 하나는 글자가 달라 걸러진다.

해시가 같다고 글자가 같은 것은 아니다. 그림의 897처럼 해시만 같은 가짜 일치(spurious hit)가 있으므로 해시가 같을 때는 반드시 글자를 확인한다. 큰 소수를 법으로 쓰면 가짜 일치가 드물어 평균 O(n + m)이지만, 최악(모든 창이 가짜 일치)은 O(nm)이다. 법을 무작위로 고르면 공격자가 최악을 만들어 내기 어렵다.

라빈-카프가 빛나는 곳은 여러 패턴이나 긴 반복을 찾을 때다. 길이가 같은 패턴 k개의 해시를 해시 집합에 넣어 두면 창 하나당 집합 조회 한 번으로 k개를 함께 검사한다. 표절 검사가 글을 일정 길이 조각으로 잘라 해시를 비교하는 것(fingerprinting), rsync가 파일에서 바뀐 블록을 찾는 것도 굴리는 해시다. 해시 함수 자체는 해시 노트에서 다룬다. 굴리는 해시의 세부와 충돌 확률은 cp-algorithms의 Rabin-Karp가 자세하다.

편집 거리: 두 글이 얼마나 다른가

맞춤법 검사기의 "혹시 이 단어를 찾으셨나요?", diff 도구, DNA 서열 비교는 두 문자열이 얼마나 다른지 재야 한다. 편집 거리(edit distance, 레벤슈타인 거리 Levenshtein distance)는 한 글자 넣기, 지우기, 바꾸기를 각각 비용 1로 칠 때 한 문자열을 다른 문자열로 바꾸는 최소 비용이다.

경우의 수를 모두 해 보면 지수 시간이 들지만, 작은 문제의 답으로 큰 문제의 답을 만드는 동적 계획법(dynamic programming)으로 풀린다. D[i][j]를 앞 문자열의 앞 i글자를 뒤 문자열의 앞 j글자로 바꾸는 비용이라 하면, 마지막 글자를 어떻게 처리했느냐에 따라 세 경우뿐이다.

D[i][0] = i,  D[0][j] = j
D[i][j] = min( D[i-1][j] + 1,                 // a[i] 지우기
               D[i][j-1] + 1,                 // b[j] 넣기
               D[i-1][j-1] + (a[i] == b[j] ? 0 : 1) )   // 그대로 또는 바꾸기
편집 거리 단계 그림: KITTEN과 SITTING의 표를 한 줄씩 채우면 오른쪽 아래가 3이다. 되짚으면 K를 S로, E를 I로 바꾸고 G를 넣는 세 번의 편집이 나온다εSITTINGεKITTEN0123456701234567112345670123456711234567221234560123456711234567221234563321234501234567112345672212345633212345443212340123456711234567221234563321234544321234554322340123456711234567221234563321234544321234554322346654332301234567112345672212345633212345443212345543223466543323편집 3번K → S 바꾸기E → I 바꾸기G 넣기↖ 대각선 + 0/1(같으면 0, 다르면 바꾸기)↑ 위 + 1: 지우기← 왼쪽 + 1: 넣기방금 채운 줄되짚은 경로아직 비어 있음
1 / 8

0행과 0열은 빈 문자열에서 만들거나 빈 문자열로 지우는 비용이라 0부터 1씩 늘려 채운다.

KITTEN을 SITTING으로 바꾸는 편집 거리 표. 행은 KITTEN의 앞 i글자, 열은 SITTING의 앞 j글자다(ε는 빈 문자열). 한 줄씩 채우고, 답은 오른쪽 아래 칸의 값(3)이다. 마지막 단계의 진한 칸이 되짚은 경로다.

표는 (n + 1) × (m + 1)칸이고 칸마다 O(1)이니 시간과 공간이 O(nm)이다. 거리 값만 필요하면 바로 윗줄만 남겨 공간을 O(min(n, m))으로 줄일 수 있다. 실제 편집 순서까지 필요하면 표를 남겨 두고 오른쪽 아래에서 어느 이웃에서 왔는지 거꾸로 되짚는다. 그림의 KITTEN → SITTING은 K→S 바꾸기, E→I 바꾸기, G 넣기로 3이다.

변형이 많다. 바꾸기를 빼고 넣기·지우기만 허용하면 최장 공통 부분 수열(LCS)과 같은 문제가 되고, git diff 같은 줄 단위 비교 도구가 이 계열이다. 이웃한 두 글자 맞바꾸기까지 1로 치면 다메라우-레벤슈타인(Damerau–Levenshtein) 거리다. 같은 표 채우기 기법은 설계 기법 노트의 동적 계획법 절에서 더 다룬다. 정의와 변형은 위키백과의 Levenshtein distance에 정리돼 있다.

접미사 배열: 한 번 정리해 두고 여러 번 찾기

KMP와 라빈-카프는 패턴이 올 때마다 본문 전체를 훑는다. 본문은 거의 바뀌지 않고 질의가 많이 온다면(유전체, 책 전체, 로그 보관소), 본문을 미리 정리해 두는 편이 낫다. 접미사 배열(suffix array)은 본문의 모든 접미사를 사전순으로 정렬한 뒤 그 시작 위치만 배열로 적은 것이다.

banana의 접미사 배열: 사전순으로 a, ana, anana, banana, na, nana이고 시작 위치는 5, 3, 1, 0, 4, 2다. ana로 시작하는 접미사는 순위 1과 2에 이웃해 있다순위SA(시작 위치)접미사(사전순)LCP05a013ana121anana330banana044na052nana2"ana" 찾기이진 탐색으로순위 1~2→ 위치 1, 3LCP: 바로 윗줄과 앞부분이 같은 글자 수. 같은 접두사끼리 이웃에 모인다찾는 패턴과 겹친 앞부분윗줄과 같은 앞부분(LCP)
banana의 접미사 배열 [5, 3, 1, 0, 4, 2]. ana로 시작하는 접미사(ana, anana)는 정렬된 순서에서 이웃해 있으므로, 이진 탐색 두 번으로 그 구간을 찾으면 나오는 자리(3과 1)를 모두 얻는다. LCP는 바로 윗줄 접미사와 앞부분이 같은 글자 수다.

패턴이 나오는 모든 자리는 "패턴으로 시작하는 접미사"의 시작 위치이고, 그런 접미사는 정렬된 순서에서 한 구간에 모인다. 그래서 이진 탐색으로 구간 양 끝을 찾으면 된다. 비교 한 번에 최대 m글자를 보니 찾기는 O(m log n)이다. 메모리는 정수 n개뿐이라, 같은 일을 하는 접미사 트리(suffix tree)보다 몇 배 작다.

만들기는 접미사를 그냥 정렬하면 비교 하나가 O(n)이라 O(n² log n)이지만, 앞 1, 2, 4, 8…글자 순위를 두 배씩 늘려 가며 정렬하는 방법으로 O(n log n)에, SA-IS 같은 알고리즘으로 O(n)에 만든다. 이웃한 접미사의 공통 접두사 길이를 담은 LCP 배열을 함께 두면 서로 다른 부분 문자열의 개수, 가장 긴 반복 부분 문자열 같은 질문에도 답한다. bzip2 압축의 바탕인 버로스-휠러 변환(BWT)과, 유전체 정렬 도구가 쓰는 FM-인덱스도 접미사 배열에서 나온다(압축 이야기는 데이터 노트). 만드는 과정은 cp-algorithms의 Suffix Array와 VisuAlgo의 접미사 배열 시각화로 따라가 볼 수 있다.