16916번: 부분 문자열
첫째 줄에 문자열 S, 둘째 줄에 문자열 P가 주어진다. 두 문자열은 빈 문자열이 아니며, 길이는 100만을 넘지 않는다. 또, 알파벳 소문자로만 이루어져 있다.
www.acmicpc.net
이 문제를 접하기 전까지는 Java로 문자열(String)을 다룰 때,
indexOf()와 charAt()만 잘 쓸 수 있다면 천하무적일 것이라 생각했다.
한창 프로젝트 기획 단계인 터라 바쁜 시기이지만, 점심시간에 문제의 지문을 얼핏 보았을 때에는 매우 간단해 보였다.
점심시간이 끝나기 전에 마쳐야겠다는 마음으로 아래와 같이 중첩 for문을 작성하여 가볍게 예제를 모두 패스하였다.
// 일부 발췌
String S = br.readLine();
String P = br.readLine();
br.close();
int L = S.length();
int l = P.length();
boolean flag = false;
outer:
for(int i=0;i<L-l+1;i++) {
for(int j=0;j<l;j++) {
char now = P.charAt(j);
if(S.charAt(i+j)==now) {
flag = true;
} else {
flag = false;
break;
}
if(j==l-1)
break outer;
}
}
그렇게 신속하게 제출 버튼을 눌러 버렸는데.....
나의 가뿐하고 후련하던 미소는, 28%에서 갑작스럽게 마주한 시간 초과 판정으로 인해 모두 무너져 버리고 말았다.
아니.. 대체 왜??
갑작스런 슬픔을 뒤로하고 프로젝트에 전념한 나는 집으로 돌아온 뒤
이 문제가 나를 거부한 이유가 무엇일지에 대해 다시 처음부터 생각하기 시작했다.
시간 초과가 뜬다면 항상 데이터 크기나 범위를 체크하는 것이 기본.
그래서 다시 확인해보니.... 무려 문자열의 최대 길이가 백만 단위이다.
내 기존 코드로 백만 단위 문자열 두 개의 일치 여부를 구하려 했다면 연산 횟수는 총 백만x백만...?
대략 연산 1억회에 순수하게 1초 정도의 시간이 소요된다고 하는데, 이 경우 무려 1조회의 연산이 필요하다.
아무 생각 없이 시간복잡도 O(n*m)짜리 알고리즘부터 사용해버린 나 자신을 반성하는 시간을 가졌다.
그렇다면 저렇게 어마무시한 규모의 문자열은 어떻게 다루어야 하는 걸까? 하는 마음으로 잠시 구글신께 기도하니,
KMP Algorithm이라는 어마어마한 가르침을 얻게 되었다.
K, M, P는 각각 어느 위대한 과학자들 이름에서 첫글자를 따온 것이라고 한다.
무려 세명이 머리를 맞대어 만든 알고리즘이란 과연 어떤 느낌일까? 아니나 다를까 역시 어려운 내용이었다.
하지만 감사히도, 나와 같은 중생들을 위해 친절한 가르침을 주시는 분들이 여럿 계셨다.
가장 큰 가르침을 얻은 분의 포스팅 링크를 아래와 같이 남긴다.
[알고리즘] 문자열 매칭 알고리즘 KMP (Java)
문자열 매칭 알고리즘 특정한 글이 있을 때 그 글 안에서 하나의 문자열을 찾는 알고리즘이다. 워드파일 또는 웹 브라우저 DB에서 문자열을 검색할 때 패턴 매칭 알고리즘을 사용하여 검색 결과
loosie.tistory.com
기존에 내가 사용한 방식처럼 중첩 for문으로, 길이가 백만인 두 문자열의 일치 여부를 무식하게 검증했다면
시간복잡도가 무려 O(백만x백만)=1조나 된다는 것은 이미 한번 언급한 바 있다.
KMP 알고리즘의 효용성은, 두 문자열을 각각 별도의 for문만 사용하여 검증할 수 있다는 데에 있다.
시간복잡도로 따진다면 O(n+m)=O(백만+백만)=2백만으로, 연산 횟수를 무려 50만배(!!)나 줄일 수 있다.
어떻게 이런 매직이 가능한 것일까.
처음 이해하기까지는 상당히 어려움이 있었지만, 직접 코드에 적용해서 돌려보며 생각하니
조금씩 깨달음을 얻을 수 있었다.
문제에서 주어진 것은 특정한 문자열 패턴 P와, 그 패턴의 존재 여부를 찾을 문자열 S이다.
(당연히 S는 P에 비해 길이가 같거나 길어야 한다.)
KMP 알고리즘에서는 문자열 P의 맨 앞 문자 n개와, 맨 뒤 문자 n개가 같아질 수 있는 최대 n값을 먼저 구한다.
처음에는, 이것도 결국 문자 하나하나 검색해야 하는 것이 아닐까?
이것으로 어떻게 S를 검증할 수 있다는 것일까? 하는 생각이 들었다.
하지만 실제로 코드를 작성해 보며 차근차근 과정을 검토해 보니 아래와 같았다.
(S의 길이 : L, P의 길이 : l)
int[] table = new int[l];
int index=0;
for(int i=1;i<l;i++) {
if(index>0 && P.charAt(i)!=P.charAt(index)) { // (1)
index = table[index-1];
}
if(P.charAt(i)==P.charAt(index)) { // (2)
index++;
table[i]=index;
}
}
위 코드에서의 index는 결과적으로 현재 검증 진행 중인 P의 접두사 길이를 의미한다.
예를 들어 현재 index=2이라면, for문의 이전 원소(i-1)부터 앞쪽으로 길이 2만큼의 문자열이
P의 가장 앞쪽에 있는 문자 2개와 동일하다는 것이다.
(ex : abcdeabc에서 i=5인 경우 나타나는 a는 해당 문자열 맨 앞(i=0)의 문자 a와 동일하다.
i=6인 경우의 문자 b는 해당 문자열 두번째(i=1)에 위치하는 문자 b와 동일하다.
그런데 i=5의 문자 a도 i=0의 문자 a와 동일하므로, i=5~6의 문자열은 i=0~1의 문자열과 같다.
따라서 i=6 시점에서 확인할 수 있는 해당 문자의 가장 긴 접두사의 길이는 2이다.)
따라서 P의 앞쪽에서부터 하나씩 세어간 문자들과 현재 조사중인 문자들의 순서가 같다면,
접두사의 길이가 길어지고 있다고 판정할 수 있다.
단, 중간에 순서가 서로 다르다는 것을 찾게 된다면 접두사의 길이를 더는 늘릴 수 없다.
따라서 접두사의 길이가 index-1일 때 접두사의 가장 끝에 있는 문자가 현재 문자와 같은지를 대신 검증한다.
같다면 패턴의 처음부터 다시 일치 여부를 검증할 필요 없이,
해당 지점(index-1)에서의 index 값, 즉 접두사의 길이를 다시 도출하여 이어 검증하면 된다.
(ex: index=7일 때, table[6]=3이라면 index=3부터 검증)
만일 일치하지 않는다면, 그냥 처음부터 검증을 다시 시작해야 한다.
이 경우 접두사의 길이는 0이 되어 버리므로, 다시 index=0으로 초기화하는 수밖에 없다.
이 과정을 계속하여 패턴의 '진짜' 접미사가 되는 지점과 처음 마주친다면, 이 지점부터 index는 다시 1이 될 것이다.
만일 문자열 검색이 모두 끝날 때까지 index가 초기화되지 않고 일정 값을 가진다면,
해당 문자열 P는 유효한 접두사-접미사가 존재한다고 볼 수 있다.
(+ 만일 for문의 i가 0부터 시작한다면 똑같은 문자열 두개를 처음부터 비교하는 모양새가 된다.
따라서 index값은 P의 길이인 l과 항상 같아진다.)
같은 얘기를 계속 반복하는 것 같지만...
P 맨 앞 문자열과, P의 첫 번째 문자열을 제외한 모든 지점부터
앞쪽으로 최대 몇 칸까지의 문자열이 겹칠 수 있는지를 판정하여 table array에 기록하는 것이 핵심이라 보면 된다.
여기까지만 확인하여도, S와 P를 비교하는 과정을 자연스럽게 유추할 수 있다.
boolean flag=false;
index=0;
for(int i=0;i<L;i++) {
if(index>0 && S.charAt(i)!=P.charAt(index)) {
index = table[index-1];
}
if(S.charAt(i)==P.charAt(index)) {
if(index==l-1) {
index = table[index];
flag=true;
break;
} else {
index++;
}
}
}
문자열 S의 특정 지점(어디든 무관하다)의 문자가 P의 index번째 문자와 같다면
S와 P 사이에 겹치는 문자열, 즉 P의 접두사 길이를 1씩 늘릴 수 있다.
만일 어느 지점에서 P의 '접두사' 길이가 P 자신과 같아진다면?
해당 지점으로부터 P의 길이 l 만큼의 문자열이 모두 P 전체 내용과 동일하다는 의미이다.
처음 table array 만드는 과정에서 여러차례의 멘붕을 겪은 결과,
그나마 S에서 P를 찾는 과정에 대해서는 빠르게 이해할 수 있었다.
S에서 현재 검색 중인 문자가 P의 현재 index 값과는 일치하지 않아도, 다른 위치의 값과 일치한다면
다음 문자는 P의 그 다른 위치의 값 다음 문자와 같은지 검증하면 된다.
이 과정에서 무의미한 반복문 사용이 획기적으로 줄어들 수 있게 된 것이다.
스스로도 쉽지 않은 개념이었기 때문에, 복습을 하는 과정에서 정리하고자 이 글을 작성한 것도 있다.
다행히 위와 같은 고난과 역경을 뚫고, 결국 맞았습니다!를 쟁취할 수 있었다.
나 역시도 며칠 후 다시 보면 이게 뭔 소리인가.. 싶을 것 같은 생각이 드는 탓에..ㅋㅋ
잊어버리기 전에 KMP 관련 문제들을 몇 개 더 도전해야겠다.
당신이 부족한게 아니에요.
이게 진짜 어려운 겁니다.
'Study > Algorithm' 카테고리의 다른 글
[백준 #9095] 1, 2, 3 더하기 (0) | 2021.09.20 |
---|---|
소수 구하기 알고리즘 관련 잡설 (코딩 초보의 좌충우돌 후기) (0) | 2021.08.25 |