def comparator(a: str, b: str) -> bool:
if len(a) != len(b):
return False
for i in range(len(a)):
if a[i] != b[i]:
return False
return True
Ex. 문자열
"ABCD"에 대해 각 A, B, C, D 는 ascii 숫자로 변환했다고 가정한다. 또한 X는 임의 크기의 적당한 소수이다.
예를 들어 문자열 "ABCD"의 해시값이 다음과 같다고 해보자.
여기서 맨 앞의 A를 제거하고, 뒤에 E를 추가해서 "BCDE"의 해시값을 만들고 싶다면?
원래대로라면 "BCDE"에 대해 다시
를 처음부터 계산해야 한다.
하지만 기존 해시값을 잘 이용하면 이렇게 계산할수 있다.
A * X^3을 제거한다.X배 해서 자리수를 하나 올린다.E를 더한다.즉,
를 한 뒤,
를 하면 "BCDE"의 해시값이 된다.
슬라이딩 윈도우처럼 문자열의 일정 구간을 계속 이동하며 비교해야 하는 상황에서 롤링 해시가 강력하다.문자열 "ABCDE"에서 길이 3짜리 부분 문자열을 비교한다고 해보자.
부분 문자열은 다음과 같다.
ABC
BCD
CDE
각 문자열을 매번 직접 비교하거나 해시를 새로 계산하면, 길이 3만큼의 시간이 계속 든다.
하지만 롤링 해시를 사용하면
hash(ABC) -> hash(BCD) -> hash(CDE)
처럼 이전 해시값을 이용해 다음 해시값을 O(1)에 얻을수 있다.
예를 들어 "ABCD"의 해시값은
정도지만, 문자열의 길이가 수십만이 된다면 X의 거듭제곱 값이 말도 안되게 커질 것이다.
그래서 보통은 MOD 연산을 같이 사용한다.
MOD = 10 ** 9 + 7
BASE = 31
그리고 해시값을 계산할때마다 다음처럼 MOD를 취해준다.
hash_value %= MOD
즉, 실제로는 다음과 같은 형태로 계산한다.
이렇게 하면 값이 너무 커지는 것을 막을수 있다.
이를 해시 충돌이라고 한다.
예를 들어 두 문자열이 서로 다르더라도, MOD 연산을 거친 결과가 같을수 있다.
hash(A) == hash(B)
라고 해서 반드시
A == B
라고 보장할수는 없는 것이다.
그래서 롤링 해시는 보통 다음과 같은 방식으로 사용한다.
코딩테스트에서는 보통 적절한 BASE, MOD를 사용하면 충돌 가능성이 매우 낮기 때문에 해시값 비교만으로 처리하는 경우도 많다.
길이 k짜리 부분 문자열의 해시값을 롤링하면서 계산하는 예시는 다음과 같다.
def rolling_hash(s: str, k: int):
MOD = 10 ** 9 + 7
BASE = 31
def to_int(ch: str) -> int:
return ord(ch) - ord('a') + 1
n = len(s)
if n < k:
return []
hash_value = 0
for i in range(k):
hash_value = (hash_value * BASE + to_int(s[i])) % MOD
result = [hash_value]
power = pow(BASE, k - 1, MOD)
for i in range(k, n):
left = to_int(s[i - k])
right = to_int(s[i])
hash_value = (hash_value - left * power) % MOD
hash_value = (hash_value * BASE + right) % MOD
result.append(hash_value)
return result
예를 들어
rolling_hash("abcde", 3)
를 수행하면 내부적으로는 다음과 같이 진행된다.
abc
bcd
cde
처음 abc의 해시값은 직접 계산한다.
그 다음 bcd의 해시값은 abc의 해시값에서 a를 제거하고, d를 추가해서 계산한다.
그 다음 cde의 해시값은 bcd의 해시값에서 b를 제거하고, e를 추가해서 계산한다.
즉, 매번 길이 3만큼 다시 계산하는게 아니라, 이전 해시값을 이용해 다음 해시값을 빠르게 만드는 것이다.
롤링 해시의 사용 이유는 다음과 같이 볼수 있다.
즉, 단순히 문자열 하나를 해싱하는 것만으로는 큰 의미가 없다.
진짜 핵심은
이전 해시값을 이용해 다음 문자열의 해시값을 빠르게 계산할수 있다.
는 점이다.
예를 들어 긴 문자열 text 안에서 pattern이 존재하는지 찾는다고 해보자.
일반적인 방식이라면 text의 각 위치마다 pattern과 직접 비교해야 한다.
def find_pattern(text: str, pattern: str) -> bool:
n = len(text)
m = len(pattern)
for i in range(n - m + 1):
if text[i:i + m] == pattern:
return True
return False
이 방식은 최악의 경우 각 위치마다 길이 m만큼 비교해야 하므로 시간복잡도는 O(NM)이 될수 있다.
하지만 롤링 해시를 사용하면 다음과 같이 생각할수 있다.
pattern의 해시값을 구한다.text에서 길이가 len(pattern)인 부분 문자열들의 해시값을 구한다.즉,
text = "ababcabc"
pattern = "abc"
라면 text에서 길이 3짜리 부분 문자열들을 확인한다.
aba
bab
abc
bca
cab
abc
이때 각 부분 문자열의 해시값을 매번 새로 계산하지 않고, 롤링 해시로 이전 해시값을 이용해 다음 해시값을 O(1)에 계산한다.
def rabin_karp(text: str, pattern: str) -> list[int]:
MOD = 10 ** 9 + 7
BASE = 31
def to_int(ch: str) -> int:
return ord(ch) - ord('a') + 1
n = len(text)
m = len(pattern)
if n < m:
return []
pattern_hash = 0
window_hash = 0
for i in range(m):
pattern_hash = (pattern_hash * BASE + to_int(pattern[i])) % MOD
window_hash = (window_hash * BASE + to_int(text[i])) % MOD
result = []
if pattern_hash == window_hash:
result.append(0)
power = pow(BASE, m - 1, MOD)
for i in range(m, n):
left = to_int(text[i - m])
right = to_int(text[i])
window_hash = (window_hash - left * power) % MOD
window_hash = (window_hash * BASE + right) % MOD
if window_hash == pattern_hash:
result.append(i - m + 1)
return result
사용 예시는 다음과 같다.
text = "ababcabc"
pattern = "abc"
print(rabin_karp(text, pattern))
결과는 다음과 같다.
[2, 5]
즉, "abc"는 text의 2번 인덱스와 5번 인덱스에서 등장한다.
다만 앞에서 말했듯이 해시 충돌이 발생할수 있다.
그래서 안전하게 구현하려면 해시값이 같은 경우 실제 문자열도 한번 더 비교해주는 방식이 좋다.
def rabin_karp_safe(text: str, pattern: str) -> list[int]:
MOD = 10 ** 9 + 7
BASE = 31
def to_int(ch: str) -> int:
return ord(ch) - ord('a') + 1
n = len(text)
m = len(pattern)
if n < m:
return []
pattern_hash = 0
window_hash = 0
for i in range(m):
pattern_hash = (pattern_hash * BASE + to_int(pattern[i])) % MOD
window_hash = (window_hash * BASE + to_int(text[i])) % MOD
result = []
if pattern_hash == window_hash and text[0:m] == pattern:
result.append(0)
power = pow(BASE, m - 1, MOD)
for i in range(m, n):
left = to_int(text[i - m])
right = to_int(text[i])
window_hash = (window_hash - left * power) % MOD
window_hash = (window_hash * BASE + right) % MOD
start = i - m + 1
if window_hash == pattern_hash and text[start:start + m] == pattern:
result.append(start)
return result
이렇게 하면 해시값이 같은 경우에만 실제 문자열 비교를 수행한다.
즉, 대부분의 경우에는 O(1) 해시 비교만 수행하고, 해시값이 같은 경우에만 추가 비교를 하게 된다.
패턴의 길이를 M, 전체 문자열의 길이를 N이라고 하면
pattern의 해시값 계산: O(M)text의 첫 윈도우 해시값 계산: O(M)따라서 평균적으로 O(N + M)에 패턴 매칭을 수행할수 있다.
물론 해시 충돌이 많이 발생해서 매번 실제 문자열 비교가 일어나면 최악의 경우 O(NM)이 될수 있다.
하지만 적절한 BASE, MOD를 사용하면 실제로는 충돌 가능성이 매우 낮기 때문에 빠르게 동작한다.
롤링 해시를 이용한 패턴 매칭은 다음과 같은 흐름이다.
pattern의 해시값을 구한다.text에서 pattern과 같은 길이의 구간 해시값을 구한다.pattern_hash == window_hash인 위치를 찾는다.즉, 롤링 해시를 이용하면 긴 문자열 안에서 특정 문자열을 찾는 작업을 매우 빠르게 처리할수 있다.