Find the First Occurrence in a String
두 문자열 haystack과 needle이 주어집니다. 0부터 세어 needle이 처음 나타나는 haystack의 인덱스를 반환하세요. needle이 haystack에 나타나지 않으면 -1을 반환하세요. find나 indexOf와 같은 내장 부분 문자열 검색 함수를 호출하는 대신 직접 검색을 구현하세요.
함수
- haystackstring
- 검색할 텍스트
- needlestring
- 찾을 문자열
- 반환값integer
- needle의 첫 번째 복사본이 시작되는 인덱스이며, 없으면 -1
제약 조건
1 ≤ haystack.length ≤ 5 × 1041 ≤ needle.length ≤ 5 × 104- 두 문자열에는 소문자 영어 문자만 포함되어 있습니다.
needle은haystack보다 길 수 있습니다. 그러면 나타날 수 없으므로 답은-1입니다.
예제
- 입력
- haystack = "bananarama"needle = "ana"
- 출력
- 1
- 설명
- 인덱스 1, 2, 3의 문자는
ana를 만듭니다. 두 번째 문자열은 인덱스 3에서 시작해 첫 번째 문자열과 겹치지만, 정답은 첫 번째 문자열이므로 1입니다.
- 입력
- haystack = "pineapple"needle = "apples"
- 출력
- -1
- 설명
apple는 인덱스 4에서 시작하고, 그 직후에 haystack이 끝나므로 needle의 마지막s와 일치하는 문자가 없습니다.apples와 완전히 일치하는 부분이 없으므로 답은-1입니다.
- 입력
- haystack = "abcabcabd"needle = "abcabd"
- 출력
- 3
- 설명
- 인덱스 0에서의 시도는
abcab라는 다섯 글자와 일치한 다음, 찾는 문자열은d를 원하지만c를 만납니다. 일치하는 부분은 인덱스 3에서 시작해 마지막d에서 끝납니다.
제출 시 숨은 테스트 +16개
후속 질문
needle이 시작되는 모든 인덱스를 반환하되, 겹치는 항목도 포함하면서 여전히 O(n + m) 시간에 처리할 수 있나요?
힌트
하나씩 열어 보세요. 열 때마다 조금 더 알려 줍니다.
needle의 복사본은haystack안에 들어갈 수 있는 인덱스에서만 시작할 수 있습니다. 가능한 마지막 인덱스는 무엇인가요?긴 부분 일치가 실패하면 브루트 포스 방식은 다음 인덱스부터 다시 시작하고 거의 같은 문자들을 다시 읽습니다. 이미 일치한 문자들은
needle의 접두사이므로, haystack을 다시 살펴보지 않아도 그 문자들을 알 수 있습니다.needle의 모든 접두사에 대해, 접미사이기도 한 가장 긴 진접두사의 길이를 미리 계산합니다. 일치한 문자 수를 나타내는k를 사용해 검색 대상 문자열을 한 번만 훑습니다. 불일치가 발생하면 검색 대상 문자열에서 뒤로 이동하는 대신k를 미리 계산한 길이로 줄입니다.
풀이
모든 시작 위치에서 needle을 비교하는 방법은 정확하지만, 거의 일치할 때는 느립니다. 끝부분에서 실패하는 긴 부분 일치는 버려지고, 다음 시작 위치에서는 거의 같은 문자를 다시 읽습니다. Knuth-Morris-Pratt 알고리즘은 이 작업을 재활용합니다. needle만으로 만든 테이블은 실패한 부분 일치 중 어느 정도를 계속 활용할 수 있는지 알려 주므로, 검색은 haystack에서 뒤로 이동하지 않으며 O(n + m) 시간에 끝납니다.
모든 시작 위치를 확인하세요
정답이지만 가장 큰 테스트에서는 끝나지 않음
핵심 아이디어
haystack의 길이를 n, needle의 길이를 m이라고 하겠습니다. needle의 복사본은 0부터 n-m까지의 어떤 인덱스에서든 시작할 수 있습니다. 왼쪽에서 오른쪽으로 각 시작 위치를 확인하세요. 각 위치에서 needle과 haystack의 문자를 하나씩 비교하고, 처음 다른 문자가 나오면 멈춥니다. m개의 문자가 모두 일치하는 첫 시작 위치가 답이며, 왼쪽에서 오른쪽으로 확인하므로 첫 번째 복사본을 찾게 됩니다.
마지막 시작 위치가 n-m인 이유는 그보다 뒤에서 시작하는 복사본은 haystack의 끝을 넘어가기 때문입니다. 이 경계값은 needle이 haystack보다 긴 경우도 처리합니다. 시도할 시작 위치가 없으므로 루프가 끝까지 실행된 뒤 -1을 반환합니다.
대부분의 문자가 일치할 때 비용이 커집니다. a가 50,000개인 haystack과 a가 24,999개인 뒤에 b가 오는 needle을 생각해 보세요. 25,001개의 시작 위치마다 b에 도달하기 전에 25,000개의 문자를 비교하므로, -1이라는 답을 얻는 데 비교 횟수가 6 × 10^8회를 넘습니다.
알고리즘
n과m을 각각haystack과needle의 길이라고 합시다.- 0부터
n-m까지 각start에 대해j를 0으로 설정합니다. j < m이고haystack[start + j]가needle[j]와 같으면j를 증가시킵니다.j가m에 도달하면 모든 문자가 일치한 것입니다.start를 반환합니다.- 어떤 시작 위치에서도 일치하지 않으면
-1을 반환합니다.
def strStr(haystack, needle):
n, m = len(haystack), len(needle)
for start in range(n - m + 1):
j = 0
while j < m and haystack[start + j] == needle[j]:
j += 1
if j == m:
return start
return -1Knuth-Morris-Pratt
핵심 아이디어
브루트 포스가 무엇을 버리는지 살펴보세요. abcabcabd에서 abcabd를 검색할 때, 인덱스 0에서 시작한 시도는 abcab까지 일치한 뒤 실패합니다. 이 다섯 글자는 ab로 끝나고, ab는 니들의 시작 부분이기도 합니다. 따라서 불일치가 발생한 뒤에도 다음 유효한 시도에서 두 글자는 이미 일치한 상태이므로, 헤이스택의 같은 위치에서 계속 검색할 수 있습니다.
문자열의 보더란 접두사이면서 접미사이기도 한 더 짧은 부분 문자열을 말합니다. 예를 들어 abcab에서 ab가 보더입니다. 검색하기 전에 lps 테이블을 만드세요. 여기서 lps[i]는 needle[0..i]의 가장 긴 보더의 길이입니다. abcabd의 경우 [0, 0, 0, 1, 2, 0]입니다. 이 테이블은 니들에만 영향을 받으며, 니들을 자기 자신과 비교하면서 동일한 매칭 루프를 실행해 만듭니다.
그런 다음 헤이스택을 한 번 훑으면서 지금까지 일치한 니들 글자 수인 k를 유지합니다. 다음 글자가 needle[k]와 같으면 k를 1 증가시킵니다. 다르면 k를 lps[k-1]로 설정하고, 일치하거나 k가 0이 될 때까지 같은 글자를 다시 비교합니다. 보더로 되돌아가도 일치하는 문자열을 건너뛰지는 않습니다. 실패한 시도 안에서 시작하는 모든 일치 문자열은 지금까지 일치한 부분의 보더로 시작해야 하며, 가장 긴 보더를 먼저 시도하기 때문입니다. k가 m에 도달하면 일치 문자열은 i-m+1에서 시작한 것입니다.
시간 복잡도가 선형인 이유는 다음과 같습니다. k는 헤이스택의 각 글자마다 최대 1씩 증가하고, 되돌아갈 때마다 감소합니다. 따라서 증가한 횟수보다 더 많이 감소할 수 없으므로, 전체 검색은 최대 2n단계가 걸리고 테이블을 만드는 데는 최대 2m단계가 걸립니다.
알고리즘
lps를 구성합니다.k = 0으로 설정하고, 1부터m-1까지 각i에 대해k > 0이고needle[i]가needle[k]와 다를 동안k = lps[k-1]로 되돌립니다. 두 값이 일치하면k를 증가시키고,lps[i] = k를 저장합니다.k를 0으로 초기화하고 인덱스i로 haystack을 순회합니다.k > 0이고haystack[i]가needle[k]와 다를 동안k = lps[k-1]로 설정합니다.haystack[i]가needle[k]와 같으면k를 증가시킵니다.k가m과 같으면i-m+1을 반환합니다. 루프가 끝나면-1을 반환합니다.
def strStr(haystack, needle):
m = len(needle)
# lps[i]: length of the longest proper prefix of needle[0..i] that is also its suffix
lps = [0] * m
k = 0
for i in range(1, m):
while k > 0 and needle[i] != needle[k]:
k = lps[k - 1]
if needle[i] == needle[k]:
k += 1
lps[i] = k
k = 0 # how many letters of needle are matched so far
for i, ch in enumerate(haystack):
while k > 0 and ch != needle[k]:
k = lps[k - 1] # fall back to the longest border, never move i back
if ch == needle[k]:
k += 1
if k == m:
return i - m + 1
return -1
함정과 경계 사례
대부분의 버그는 haystack의 끝부분이나 폴백 루프 안에서 발생합니다.
- 시작 위치를
n-m대신n-1까지 이동시키는 경우입니다. haystack의 끝부분이 needle의 시작 부분과 일치하면, 비교 과정에서haystack의 끝을 넘어 읽게 되어 Python, Java, Rust, Swift에서는 인덱스 오류가 발생합니다. - needle이 haystack보다 길 수 있다는 점을 잊는 경우입니다. C++의
size_t나 Rust의usize처럼 부호 없는 길이에서는n-m이 음수가 될 수 없습니다. C++에서는 큰 수로 래핑되고 Rust에서는 디버그 빌드에서 패닉이 발생합니다. 먼저m > n을 확인하거나 부호 있는 정수로 계산하세요. - KMP 폴백을
while이 아니라if로 작성하는 경우입니다.aabaa에서aaa를 찾을 때,b에서는 2에서 1로, 다시 0으로 두 번 폴백해야 합니다. 한 번만 폴백하고 멈추면b가 아무것과도 일치하지 않는데도k가 1로 남아, 존재하지 않는 인덱스 2에서 일치한다고 보고하게 됩니다. - KMP에서 불일치가 발생한 뒤 haystack 인덱스를 뒤로 옮기는 경우입니다. 바뀌는 것은
k뿐입니다.i를 되돌리면 최악의 경우 시간 복잡도가O(n · m)으로 돌아갑니다. - 일치가 끝나는 위치나 1부터 세는 인덱스를 반환하는 경우입니다. 답은 0부터 세는 시작 위치입니다. Lua와 R의 문자열은 1부터 시작하므로 반환하기 전에 1을 빼세요.
- PHP에서 최상위 수준에
strStr을 선언하는 경우입니다. PHP 함수 이름은 대소문자를 구분하지 않으므로 내장 함수strstr과 충돌합니다. 그래서 PHP 시작 코드는 함수를 별도의 네임스페이스에 둡니다.
자주 묻는 질문4
문자열의 첫 번째 출현을 찾는 시간 복잡도는 얼마인가요?
모든 시작 위치를 확인하면 최악의 경우 O(n · m) 시간이 걸리며, 여기서 n과 m은 각각 haystack과 needle의 길이이고, 추가 공간은 O(1)입니다. Knuth-Morris-Pratt 알고리즘은 문자가 무엇이든 O(n + m) 시간과 테이블에 O(m) 공간을 사용합니다.
KMP 접두사 테이블은 어떻게 작동하나요?
needle의 각 접두사에 대해, 표에는 접미사이기도 한 가장 긴 진접두사의 길이가 저장됩니다. k개의 문자가 일치한 뒤 불일치가 발생하면, 그 k개의 문자는 needle의 접두사이며, lps[k-1]는 그중 다음에 올 수 있는 일치 부분의 시작으로 사용할 수 있는 문자의 수를 알려 줍니다. aabaaab의 표는 [0, 1, 0, 1, 2, 2, 3]입니다.
내장된 find 또는 indexOf를 사용하지 않는 이유는 무엇인가요?
프로덕션 코드에서는 그렇게 하는 것이 좋습니다. 테스트를 거쳤고 빠르기 때문입니다. 면접관은 올바른 경계를 사용해 일치하는 루프를 작성할 수 있는지 확인하려고 이 문제를 냅니다. 그리고 보통 이어지는 질문에서는 최악의 경우 O(n · m)이 되는 것을 어떻게 피할지 묻습니다. 내장 검색의 최악의 경우는 언어와 라이브러리 버전에 따라 달라지므로, 그 질문에 대한 답이 되지 않습니다.
해싱을 사용해 KMP 대신 이 문제를 풀 수 있나요?
네, Rabin-Karp 알고리즘을 사용하면 됩니다. needle의 해시와 haystack에서 m개의 문자로 이루어진 각 윈도우의 롤링 해시를 계산하고, 윈도우가 이동할 때마다 상수 시간에 해시를 갱신합니다. 해시가 일치할 때만 문자 하나씩 비교합니다. 평균적으로 O(n + m) 시간이 걸리지만, 해시 충돌이 많으면 다시 O(n · m)에 가까워질 수 있습니다.
비슷한 문제
같은 아이디어를 쓰는 문제입니다. 두세 개를 풀면 패턴이 몸에 익습니다.
Python
def strStr(haystack, needle):
# 여기에 코드를 작성하세요케이스 1
케이스 2
케이스 3
입력
haystack = "bananarama" needle = "ana"
기대값
1