이 주제에 대한 튜토리얼은 여기에서 확인할 수 있다.
목표: Foundation을 임포트하거나 NSString
의 rangeOfString()
메서드를 사용하지 않고 순수 Swift로 문자열 검색 알고리즘을 구현한다.
다시 말해, String
에 indexOf(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
^
세 가지 가능성이 있다:
두 문자가 일치한다. 가능한 일치를 찾았다.
문자가 일치하지 않지만, 소스 문자가 검색 패턴의 다른 곳에 나타난다.
소스 문자가 검색 패턴에 전혀 나타나지 않는다.
예제에서 o
와 d
는 일치하지 않지만, 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 알고리즘과 마찬가지로, 이 알고리즘도 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가 업데이트했다.