Boyer-Moore 문자열 검색

이 주제에 대한 튜토리얼은 여기에서 확인할 수 있다.

목표: Foundation을 임포트하거나 NSStringrangeOfString() 메서드를 사용하지 않고 순수 Swift로 문자열 검색 알고리즘을 구현한다.

다시 말해, StringindexOf(pattern: String) 확장을 구현해 검색 패턴이 처음 나타나는 위치의 String.Index를 반환하거나, 패턴을 찾지 못하면 nil을 반환하는 것이다.

예를 들어:

// 입력:
let s = "Hello, World"
s.indexOf(pattern: "World")

// 출력:
<String.Index?> 7

// 입력:
let animals = "🐶🐔🐷🐮🐱"
animals.indexOf(pattern: "🐮")

// 출력:
<String.Index?> 6

참고: 소의 인덱스는 예상과 달리 3이 아니라 6이다. 이는 이모지가 문자당 더 많은 저장 공간을 사용하기 때문이다. String.Index의 실제 값은 중요하지 않으며, 단지 문자열에서 올바른 문자를 가리키는지가 중요하다.

브루트 포스 방식은 잘 동작하지만, 큰 텍스트에서는 효율적이지 않다. 사실 모든 문자를 확인할 필요는 없다. 여러 문자를 건너뛸 수 있다.

이렇게 건너뛰는 알고리즘을 Boyer-Moore라고 하며, 오랜 시간 동안 문자열 검색 알고리즘의 기준으로 여겨져 왔다.

Swift로 구현한 코드는 다음과 같다:

extension String {
    func index(of pattern: String) -> Index? {
        // 검색 패턴의 길이를 캐시한다. 여러 번 사용되며 계산 비용이 크기 때문이다.
        let patternLength = pattern.count
        guard patternLength > 0, patternLength <= count else { return nil }

        // 건너뛰기 테이블을 만든다. 이 테이블은 패턴의 문자가 발견되었을 때 얼마나 건너뛸지 결정한다.
        var skipTable = [Character: Int]()
        for (i, c) in pattern.enumerated() {
            skipTable[c] = patternLength - i - 1
        }

        // 패턴의 마지막 문자를 가리킨다.
        let p = pattern.index(before: pattern.endIndex)
        let lastChar = pattern[p]

        // 패턴을 오른쪽에서 왼쪽으로 스캔하므로, 문자열에서 패턴 길이만큼 건너뛴다.
        // (startIndex가 이미 문자열의 첫 문자를 가리키므로 1을 뺀다.)
        var i = index(startIndex, offsetBy: patternLength - 1)

        // 두 문자열을 뒤로 이동하며 일치하지 않는 문자를 찾거나 패턴의 시작에 도달할 때까지 이동하는 헬퍼 함수.
        func backwards() -> Index? {
            var q = p
            var j = i
            while q > pattern.startIndex {
                j = index(before: j)
                q = index(before: q)
                if self[j] != pattern[q] { return nil }
            }
            return j
        }

        // 메인 루프. 문자열의 끝에 도달할 때까지 계속 진행한다.
        while i < endIndex {
            let c = self[i]

            // 현재 문자가 패턴의 마지막 문자와 일치하는가?
            if c == lastChar {

                // 가능한 일치가 있다. 브루트 포스 검색을 뒤로 진행한다.
                if let k = backwards() { return k }

                // 일치하지 않으면 한 문자만 건너뛰는 것이 안전하다.
                i = index(after: i)
            } else {
                // 문자가 일치하지 않으면 건너뛴다. 건너뛰는 양은 건너뛰기 테이블에 의해 결정된다.
                // 문자가 패턴에 없으면 패턴 길이만큼 건너뛸 수 있다. 그러나 패턴에 문자가 있으면
                // 앞에 일치가 있을 수 있으므로 그렇게 많이 건너뛸 수 없다.
                i = index(i, offsetBy: skipTable[c] ?? patternLength, limitedBy: endIndex) ?? endIndex
            }
        }
        return nil
    }
}

알고리즘은 다음과 같이 동작한다. 검색 패턴을 소스 문자열과 맞춰보고, 문자열의 어떤 문자가 검색 패턴의 마지막 문자와 일치하는지 확인한다:

소스 문자열:  Hello, World
검색 패턴: World
                    ^

세 가지 가능성이 있다:

  1. 두 문자가 일치한다. 가능한 일치를 찾았다.

  2. 문자가 일치하지 않지만, 소스 문자가 검색 패턴의 다른 곳에 나타난다.

  3. 소스 문자가 검색 패턴에 전혀 나타나지 않는다.

예제에서 od는 일치하지 않지만, o는 검색 패턴에 나타난다. 이는 몇 칸을 건너뛸 수 있음을 의미한다:

소스 문자열:  Hello, World
검색 패턴:    World
                       ^

이제 두 o 문자가 맞춰졌다. 다시 검색 패턴의 마지막 문자와 소스 텍스트를 비교한다: W vs d. 이들은 일치하지 않지만 W는 패턴에 나타난다. 따라서 다시 건너뛰어 두 W를 맞춘다:

소스 문자열:  Hello, World
검색 패턴:        World
                           ^

이번에는 두 문자가 일치하며 가능한 일치가 있다. 일치를 확인하기 위해 검색 패턴의 끝부터 시작까지 브루트 포스 검색을 뒤로 진행한다. 이것이 전부다.

건너뛰는 양은 "건너뛰기 테이블"에 의해 결정된다. 이 테이블은 검색 패턴의 모든 문자와 건너뛰는 양을 담은 딕셔너리다. 예제의 건너뛰기 테이블은 다음과 같다:

W: 4
o: 3
r: 2
l: 1
d: 0

문자가 패턴의 끝에 가까울수록 건너뛰는 양이 작아진다. 패턴에서 문자가 여러 번 나타나면 패턴의 끝에서 가장 가까운 문자가 건너뛰는 양을 결정한다.

참고: 검색 패턴이 몇 문자로만 구성된 경우, 브루트 포스 검색이 더 빠르다. 건너뛰기 테이블을 만드는 시간과 짧은 패턴에 대한 브루트 포스 검색 사이에 트레이드오프가 있다.

크레딧: 이 코드는 Dr Dobb's 매거진(1989년 7월)의 Costas Menico의 글 "Faster String Searches"를 기반으로 한다. 가끔은 오래된 매거진도 유용하다!

추가 자료: 알고리즘의 상세 분석

Boyer-Moore-Horspool 알고리즘

위 알고리즘의 변형 중 하나는 Boyer-Moore-Horspool 알고리즘이다.

기존의 Boyer-Moore 알고리즘과 마찬가지로, 이 알고리즘도 skipTable을 사용해 여러 문자를 건너뛴다. 차이점은 부분 일치를 확인하는 방식에 있다. 위 버전에서는 부분 일치가 발견되지만 완전히 일치하지 않을 경우, 한 문자만 건너뛴다. 이 수정된 버전에서는 그런 상황에서도 skip 테이블을 활용한다.

다음은 Boyer-Moore-Horspool 알고리즘의 구현이다:

extension String {
    func index(of pattern: String) -> Index? {
        // 검색 패턴의 길이를 캐싱한다. 여러 번 사용될 예정이며 계산 비용이 크기 때문이다.
        let patternLength = pattern.count
        guard patternLength > 0, patternLength <= characters.count else { return nil }

        // skip 테이블을 만든다. 이 테이블은 패턴의 문자가 발견되었을 때 얼마나 건너뛸지 결정한다.
        var skipTable = [Character: Int]()
        for (i, c) in pattern.enumerated() {
            skipTable[c] = patternLength - i - 1
        }

        // 패턴의 마지막 문자를 가리킨다.
        let p = pattern.index(before: pattern.endIndex)
        let lastChar = pattern[p]

        // 패턴은 오른쪽에서 왼쪽으로 스캔되므로, 문자열에서 패턴 길이만큼 건너뛴다. 
        // (startIndex가 이미 소스 문자열의 첫 번째 문자를 가리키므로 1을 뺀다.)
        var i = index(startIndex, offsetBy: patternLength - 1)

        // 이 헬퍼 함수는 두 문자열을 뒤로 돌아가며 스캔한다.
        // 일치하지 않는 문자를 찾거나 패턴의 시작에 도달할 때까지 진행한다.
        func backwards() -> Index? {
            var q = p
            var j = i
            while q > pattern.startIndex {
                j = index(before: j)
                q = index(before: q)
                if self[j] != pattern[q] { return nil }
            }
            return j
        }

        // 메인 루프. 문자열의 끝에 도달할 때까지 계속 진행한다.
        while i < endIndex {
            let c = self[i]

            // 현재 문자가 패턴의 마지막 문자와 일치하는가?
            if c == lastChar {

                // 가능한 일치가 있다. 뒤로 돌아가며 브루트 포스 검색을 수행한다.
                if let k = backwards() { return k }

                // 최소 한 문자는 건너뛰도록 한다. (skipTable[lastChar] = 0이므로 필요하다.)
                let jumpOffset = max(skipTable[c] ?? patternLength, 1)
                i = index(i, offsetBy: jumpOffset, limitedBy: endIndex) ?? endIndex
            } else {
                // 문자가 일치하지 않으므로 skip 테이블에 따라 건너뛴다. 
                // 문자가 패턴에 없으면 전체 패턴 길이만큼 건너뛸 수 있다. 
                // 하지만 패턴에 문자가 존재한다면 앞쪽에서 일치가 있을 수 있으므로 더 적게 건너뛴다.
                i = index(i, offsetBy: skipTable[c] ?? patternLength, limitedBy: endIndex) ?? endIndex
            }
        }
        return nil
    }
}

실제로 Horspool 버전의 알고리즘은 원본보다 약간 더 나은 성능을 보인다. 하지만 이는 어떤 절충안을 선택하느냐에 따라 달라진다.

참고: 이 코드는 다음 논문을 기반으로 작성되었다: R. N. Horspool (1980). "Practical fast searching in strings". Software - Practice & Experience 10 (6): 501–506.

Swift Algorithm Club을 위해 Matthijs Hollemans가 작성하고, Andreas Neusüß와 Matías Mazzei가 업데이트했다.