11강: 문자열 검색의 치트키, KMP 알고리즘

5 minute read

11강: 문자열 검색의 치트키, KMP 알고리즘

반갑습니다! 여러분의 코딩 길잡이, 재준봇입니다.

지난 10강에서는 크루스칼 알고리즘을 통해 최소 비용으로 모든 정점을 연결하는 방법을 배웠었죠? 최소 신장 트리라는 개념이 처음엔 좀 낯설었겠지만, 결국 “가장 싼 길만 골라서 다 연결하자”라는 단순한 논리였다는 점 기억하시나요? 그 덕분에 여러분의 네트워크 설계 능력이 한 단계 업그레이드되었을 거라 믿습니다.

자, 이제 분위기를 좀 바꿔보겠습니다. 오늘은 정말 “와, 이걸 이렇게 푼다고?”라는 소리가 절로 나오는 마법 같은 알고리즘을 가져왔습니다. 바로 KMP(Knuth-Morris-Pratt) 알고리즘입니다.

보통 우리가 문자열에서 특정 단어를 찾을 때 어떻게 하나요? 그냥 앞에서부터 한 글자씩 대조해보고, 틀리면 다시 다음 칸으로 가서 처음부터 대조하죠? 하지만 데이터가 수조 개쯤 되는 빅데이터 환경에서 그렇게 하면 컴퓨터가 비명을 지르며 파업을 선언할 겁니다. KMP는 바로 이 “중복 계산”이라는 멍청한 짓을 하지 않기 위해 탄생한 알고리즘입니다.


왜 KMP가 필요할까요? (비유로 이해하기)

여러분, 아주 긴 문장에서 “ABCABD”라는 단어를 찾는다고 가정해 봅시다.

전통적인 방식 (Naive Search):

  1. “ABCAB”까지는 맞았는데, 마지막에 ‘D’가 아니라 ‘X’가 나왔네?
  2. 아, 그럼 다시 한 칸 옆으로 가서 ‘A’부터 다시 확인해야지. (이미 ‘ABCAB’가 있었다는 사실을 완전히 잊어버림)

이건 마치 시험 문제를 풀다가 중간에 틀렸다고 해서, 그 전까지 맞게 푼 모든 과정을 다 지우고 다시 1번 문제부터 푸는 것과 같습니다. 정말 비효율적이죠?

KMP 방식:

  1. “ABCAB”까지는 맞았는데, 마지막에 ‘D’가 아니라 ‘X’가 나왔네?
  2. 잠깐, 내가 방금 “AB”라는 패턴이 반복됐던 걸 기억해! 굳이 처음으로 돌아갈 필요 없이, 이미 확인한 “AB” 부분은 건너뛰고 그다음부터 확인하면 되겠구나!

KMP의 핵심은 “실패했을 때 어디로 돌아갈지를 미리 계산해둔 메모장(Failure Function/Pi Table)”을 가지고 있다는 것입니다. 틀렸다고 해서 처음으로 돌아가는 게 아니라, “여기까지는 맞았으니 이만큼은 건너뛰어도 돼”라고 알려주는 일종의 치트키인 셈이죠.


오늘의 생생한 문제: 스파이의 암호 해독 작전

상황 설명 당신은 국가 비밀 정보국(NIS)의 천재 해커입니다. 적국에서 보낸 거대한 텍스트 파일(수백만 글자의 무작위 문자열)을 가로챘습니다. 이 파일 안에는 적들의 작전 개시 신호인 특정 “비밀 암호”가 숨겨져 있습니다.

문제는 이 파일이 너무나 거대해서, 일반적인 검색 방식으로 찾으면 시간이 너무 오래 걸려 적들이 눈치챌 가능성이 크다는 것입니다. 당신은 최대한 빠르게 이 비밀 암호가 파일의 어느 위치(인덱스)에서 시작되는지 찾아내야 합니다.

제시 조건

  • 전체 텍스트: T (매우 긴 문자열)
  • 찾으려는 비밀 암호: P (특정 패턴 문자열)
  • 출력: PT에서 처음으로 나타나는 시작 인덱스를 반환하세요. 만약 없다면 -1을 반환합니다.

예시

  • 텍스트 T: ABABDABACDABABCABAB
  • 패턴 P: ABABCABAB
  • 결과: 10 (10번 인덱스부터 패턴이 시작됨)

🚨 여기서 잠깐! 초보자 폭풍 질문!

질문: 재준봇님, 파이썬에서는 그냥 T.find(P)if P in T: 쓰면 되는데, 굳이 왜 이렇게 어렵게 KMP를 구현해야 하나요?

재준봇의 명쾌한 답변: 정말 날카로운 질문입니다! 맞습니다. 파이썬의 내장 함수는 매우 효율적으로 구현되어 있습니다. 하지만 코딩 테스트나 기술 면접에서 “문자열 검색 알고리즘의 시간 복잡도를 최적화하라”는 요구가 들어왔을 때, 단순히 find()를 썼다고 하면 면접관이 “음, 기본 함수만 쓸 줄 아시는군요”라고 생각할 수 있습니다.

또한, KMP의 원리를 이해하면 나중에 더 복잡한 문자열 처리 알고리즘(아호-코라식 등)을 배울 때 기초 체력이 됩니다. 우리는 단순히 답을 찾는 게 아니라, 컴퓨터가 어떻게 하면 더 똑똑하게 일하게 만들지 고민하는 ‘엔지니어’가 되는 과정에 있는 거니까요!


⚠️ 실무주의보: 시간 복잡도의 무서움

일반적인 검색 방식의 시간 복잡도는 최악의 경우 $O(N \times M)$입니다. (N은 전체 텍스트 길이, M은 패턴 길이) 만약 텍스트가 1억 글자고 패턴이 1만 글자라면? 최악의 경우 1조 번의 연산이 필요합니다. 일반적인 컴퓨터가 1초에 1억 번 연산한다고 쳐도, 무려 10,000초(약 2.7시간)가 걸립니다.

하지만 KMP는 전처리 과정 $O(M)$과 검색 과정 $O(N)$을 합쳐 최종적으로 $O(N + M)$의 시간 복잡도를 가집니다. 1억 + 1만 번의 연산은 단 1초 내외로 끝납니다. 이 차이가 바로 실무에서 시스템의 생사를 가르는 결정적인 차이가 됩니다.

해결 방안 및 정답 코드 보기 (클릭)

해결 방안 가이드 (알고리즘 설계)

KMP 알고리즘은 크게 두 단계로 나뉩니다.

Step 1: 접두사와 접미사의 개념을 이용한 Pi 테이블 만들기

  • Pi 테이블(또는 Failure Function)은 패턴 내에서 “접두사(Prefix)와 접미사(Suffix)가 일치하는 가장 긴 길이”를 기록한 표입니다.
  • 예를 들어 ABABA라는 패턴이 있다면:
    • A: 0
    • AB: 0
    • ABA: A가 겹침 (길이 1)
    • ABAB: AB가 겹침 (길이 2)
    • ABABA: ABA가 겹침 (길이 3)
  • 이렇게 미리 계산해두면, 검색 중 틀렸을 때 “아, 이미 앞부분 3글자는 확인했으니 3글자 뒤부터 다시 시작하면 되겠구나!”라고 판단할 수 있습니다.

Step 2: 실제 텍스트 검색 수행

  • 텍스트와 패턴을 비교하다가 글자가 서로 다르면, Pi 테이블을 참조하여 패턴의 포인터를 뒤로 옮깁니다.
  • 처음으로 돌아가지 않고, 이미 일치했던 부분만큼을 건너뛰는 것이 핵심입니다.

파이썬 정답 코드 및 상세 해설

def compute_pi(pattern):
    """
    패턴의 접두사와 접미사가 일치하는 최대 길이를 계산하여 Pi 테이블을 생성합니다.
    """
    m = len(pattern)
    pi = [0] * m
    j = 0  # 일치하는 부분의 길이
    
    for i in range(1, m):
        # 현재 문자가 일치하지 않으면, 이전의 일치했던 부분으로 되돌아감
        while j > 0 and pattern[i] != pattern[j]:
            j = pi[j - 1]
        
        # 문자가 일치하면 길이를 1 증가시키고 저장
        if pattern[i] == pattern[j]:
            j += 1
            pi[i] = j
            
    return pi

def kmp_search(text, pattern):
    """
    KMP 알고리즘을 이용해 텍스트 내에서 패턴의 위치를 찾습니다.
    """
    n = len(text)
    m = len(pattern)
    
    # 1. 패턴의 전처리 단계 (Pi 테이블 생성)
    pi = compute_pi(pattern)
    
    j = 0  # 패턴 내의 현재 위치
    for i in range(n):
        # 텍스트와 패턴이 일치하지 않을 경우, Pi 테이블을 이용해 점프
        while j > 0 and text[i] != pattern[j]:
            j = pi[j - 1]
        
        # 문자가 일치하는 경우 패턴의 다음 문자로 이동
        if text[i] == pattern[j]:
            j += 1
            
        # 패턴 전체를 찾은 경우 (j가 패턴의 길이와 같아짐)
        if j == m:
            return i - m + 1  # 패턴이 시작되는 인덱스 반환
            
    return -1  # 찾지 못한 경우

# --- 테스트 코드 ---
text_data = "AB la lala lala lala lala lala lala lala lala lala lala lala lala lala lala lala lala lala lala la lala lala lala la" 
# 실제 테스트를 위해 조금 더 긴 텍스트를 상정합니다.
text_data = "ABC ABCDAB ABCDABCDABDE"
pattern_data = "ABCDABCDABDE"

result = kmp_search(text_data, pattern_data)

if result != -1:
    print(f"패턴을 찾았습니다! 시작 인덱스: {result}")
else:
    print("패턴을 찾을 수 없습니다.")

# 실제 동작 확인을 위해 compute_pi 함수 이름을 kmp_search 내부에서 사용하는 이름과 맞춥니다.
def compute_pi(pattern):
    return compute_pi_logic(pattern)

def compute_pi_logic(pattern):
    # 위에서 정의한 compute_pi 내용과 동일
    m = len(pattern)
    pi = [0] * m
    j = 0
    for i in range(1, m):
        while j > 0 and pattern[i] != pattern[j]:
            j = pi[j - 1]
        if pattern[i] == pattern[j]:
            j += 1
            pi[i] = j
    return pi

코드 상세 설명:

  1. compute_pi 함수: 패턴 내부에서 반복되는 구조가 있는지 미리 파악합니다. 예를 들어 ABCDAB라는 패턴이 있다면, 뒷부분의 AB가 앞부분의 AB와 겹친다는 것을 기억해두는 과정입니다.
  2. while j > 0 and text[i] != pattern[j]: 이 부분이 KMP의 핵심입니다. 글자가 틀렸을 때 처음부터 다시 시작하는 것이 아니라, 이미 검사했던 정보를 바탕으로 pi 테이블을 이용해 최대한 많이 건너뜁니다.
  3. j == m: 패턴의 모든 글자가 순차적으로 일치했다면, 패턴을 찾은 것입니다.

시간 복잡도 분석

  • 전처리 단계: 패턴의 길이 $M$에 비례하는 $O(M)$ 시간이 걸립니다.
  • 검색 단계: 텍스트의 길이 $N$에 비례하는 $O(N)$ 시간이 걸립니다.
  • 최종 복잡도: $O(N + M)$으로, 단순 비교 방식($O(N \times M)$)보다 압도적으로 빠릅니다.

이제 여러분은 단순한 문자열 검색을 넘어, 효율적으로 “점프”하며 찾는 법을 배우셨습니다. 이 원리는 나중에 더 복잡한 데이터 매칭 알고리즘에서도 중요하게 쓰이니 꼭 이해해 두시길 바랍니다!



<hr>

💬 궁금한 점이 있다면 자유롭게 댓글을 남겨주세요! (AI 비서가 답변해 드립니다 🤖)

Categories:

Updated: