Skip to Content
WebJavaScriptJavaScript 코딩테스트16. 문자열 고급 알고리즘

이번 편의 결과물: coding-test/16-advanced-strings/에 패턴 매칭·트라이·최장 팰린드롬 부분 문자열 3문제 풀이와 테스트를 작성해 node --test를 통과시킵니다. · 다루는 개념: 문자열 매칭(KMP 개념), 트라이(Trie) 직접 구현, 팰린드롬 판별

이 편에서 만드는 파일

coding-test/ └── 16-advanced-strings/ ├── 01-kmp-search/ │ ├── solution.js (+) │ └── solution.test.js (+) ├── 02-trie/ │ ├── solution.js (+) │ └── solution.test.js (+) └── 03-longest-palindrome/ ├── solution.js (+) └── solution.test.js (+)

개념 정리

왜 단순 비교보다 빠른 방법이 필요한가

04편에서는 Map/Set으로 조회를 O(1)에 가깝게 만들었습니다. 문자열 탐색도 마찬가지로, 텍스트 길이를 n, 패턴 길이를 m이라 할 때 모든 시작 위치에서 처음부터 다시 비교하면 최악의 경우 O(n·m)이 걸립니다. 이번 편은 이 비교 횟수를 줄이는 방법들을 다룹니다.

문제단순 방법의 비용이번 편 방법
패턴 찾기O(n·m), 일치하지 않을 때마다 패턴 처음으로 되돌아감KMP: 실패 테이블로 되돌아갈 위치를 미리 계산, O(n + m)
여러 단어의 접두사 검색단어마다 매번 문자열 비교트라이: 공통 접두사를 트리로 공유해 검색 O(단어 길이)
최장 팰린드롬 찾기모든 부분 문자열을 확인, O(n³)중심 확장: 각 중심에서 양쪽으로 넓혀가며 확인, O(n²)

KMP의 핵심 아이디어(실패 함수)

패턴 안에서 접두사와 접미사가 겹치는 부분을 미리 계산해 두면(실패 테이블), 문자가 일치하지 않을 때 패턴을 처음부터 다시 비교하지 않고 겹치는 부분만큼 건너뛸 수 있습니다. 이번 편은 이론 증명 대신 “실패 테이블을 만들고, 그 테이블로 건너뛰며 비교한다”는 절차 자체를 구현하는 데 집중합니다.

문제 1. 긴 텍스트에서 패턴의 첫 등장 위치 찾기

문제 설명

텍스트 문자열과 패턴 문자열이 주어질 때, 텍스트 안에서 패턴이 처음 나타나는 시작 인덱스를 구합니다. 패턴이 없으면 -1을 반환합니다.

제약
텍스트 길이1 이상 10만 이하
패턴 길이1 이상 텍스트 길이 이하

입출력 예

입력(텍스트, 패턴)출력설명
(“ababcabcabababd”, “ababd”)10인덱스 10부터 “ababd”가 시작됨
(“hello world”, “world”)6인덱스 6부터 “world”가 시작됨
(“abc”, “xyz”)-1패턴이 텍스트에 없음

접근

먼저 패턴 자신에 대해 “실패 테이블”(table[i]pattern[0..i]에서 접두사와 접미사가 겹치는 최대 길이)을 만듭니다. 텍스트를 한 번 순회하면서 문자가 일치하면 패턴 포인터를 함께 전진시키고, 불일치하면 실패 테이블을 이용해 패턴 포인터만 되돌립니다(텍스트 포인터는 되돌리지 않습니다). 이 방식이 KMP(Knuth-Morris-Pratt) 알고리즘입니다. 실패 테이블을 만드는 데 O(m), 텍스트를 순회하는 데 O(n)이 걸려 전체 시간 복잡도는 O(n + m)입니다.

풀이

// coding-test/16-advanced-strings/01-kmp-search/solution.js // 시간복잡도: O(n + m) — 실패 테이블 생성 O(m) + 텍스트 순회 O(n) // 공간복잡도: O(m) — 패턴 길이만큼의 실패 테이블을 저장한다 function buildFailureTable(pattern) { const table = new Array(pattern.length).fill(0); let prefixLength = 0; let i = 1; while (i < pattern.length) { if (pattern[i] === pattern[prefixLength]) { prefixLength += 1; table[i] = prefixLength; i += 1; } else if (prefixLength > 0) { prefixLength = table[prefixLength - 1]; } else { table[i] = 0; i += 1; } } return table; } export function solution(text, pattern) { if (pattern.length === 0) return 0; const table = buildFailureTable(pattern); let textIndex = 0; let patternIndex = 0; while (textIndex < text.length) { if (text[textIndex] === pattern[patternIndex]) { textIndex += 1; patternIndex += 1; if (patternIndex === pattern.length) { return textIndex - patternIndex; } } else if (patternIndex > 0) { patternIndex = table[patternIndex - 1]; } else { textIndex += 1; } } return -1; }

테스트

// coding-test/16-advanced-strings/01-kmp-search/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('반복되는 부분 문자열이 있어도 정확한 위치를 찾는다', () => { assert.strictEqual(solution('ababcabcabababd', 'ababd'), 10); }); test('일반적인 문자열에서 패턴 위치를 찾는다', () => { assert.strictEqual(solution('hello world', 'world'), 6); }); test('패턴이 없으면 -1을 반환한다', () => { assert.strictEqual(solution('abc', 'xyz'), -1); }); test('패턴이 빈 문자열이면 0을 반환한다', () => { assert.strictEqual(solution('abc', ''), 0); });
node --test coding-test/16-advanced-strings/01-kmp-search/solution.test.js

함정

  • 불일치가 발생했을 때 텍스트 포인터(textIndex)까지 되돌리면 단순 비교와 같아져 KMP의 장점이 사라집니다. 패턴 포인터만 되돌립니다.
  • 실패 테이블을 만들 때 prefixLength를 되돌리는 조건(else if (prefixLength > 0))을 빠뜨리면 반복되는 패턴에서 잘못된 테이블이 만들어집니다.

문제 2. 접두사 검색이 가능한 단어 저장 구조 만들기

문제 설명

여러 단어를 저장하고, 특정 문자열이 저장된 단어와 완전히 일치하는지(search), 저장된 어떤 단어의 접두사로 존재하는지(startsWith)를 빠르게 확인하는 자료구조를 직접 구현합니다.

제약
저장 단어 수1 이상 1000 이하
단어 길이1 이상 20 이하, 소문자 알파벳

입출력 예

연산결과설명
insert(“apple”), insert(“app”), insert(“application”)-세 단어 저장
search(“app”)true”app”을 그대로 저장한 적 있음
search(“appl”)false저장된 단어와 완전히 일치하지 않음
startsWith(“appl”)true”apple”과 “application”의 접두사임
startsWith(“banana”)false어떤 저장된 단어의 접두사도 아님

접근

각 노드가 다음 글자로 가는 자식 노드들의 맵(Map)과 “여기서 단어가 끝나는지” 표시(isEndOfWord)를 갖는 트리 구조(트라이)를 만듭니다. insert는 단어의 글자를 하나씩 따라가며 없는 노드는 새로 만들고, 마지막 노드에 isEndOfWord를 표시합니다. search는 같은 방식으로 노드를 따라간 뒤 마지막 노드가 isEndOfWord인지 확인하고, startsWith는 노드까지 도달할 수 있는지만 확인합니다. 여러 단어가 공통 접두사를 공유하는 노드를 재사용하므로, 단어 길이를 L이라 하면 각 연산은 O(L)입니다.

풀이

// coding-test/16-advanced-strings/02-trie/solution.js // 시간복잡도: O(L) — insert·search·startsWith 모두 단어 길이 L에 비례한다 // 공간복잡도: O(전체 단어 길이 합) — 공통 접두사는 노드를 공유해 저장한다 class TrieNode { children = new Map(); isEndOfWord = false; } export class Trie { #root = new TrieNode(); insert(word) { let node = this.#root; for (const char of word) { if (!node.children.has(char)) { node.children.set(char, new TrieNode()); } node = node.children.get(char); } node.isEndOfWord = true; } search(word) { const node = this.#findNode(word); return node !== null && node.isEndOfWord; } startsWith(prefix) { return this.#findNode(prefix) !== null; } #findNode(prefix) { let node = this.#root; for (const char of prefix) { if (!node.children.has(char)) return null; node = node.children.get(char); } return node; } }

테스트

// coding-test/16-advanced-strings/02-trie/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Trie } from './solution.js'; function makeTrie() { const trie = new Trie(); for (const word of ['apple', 'app', 'application']) { trie.insert(word); } return trie; } test('저장한 단어는 search에서 true를 반환한다', () => { assert.strictEqual(makeTrie().search('app'), true); }); test('저장하지 않은 문자열은 search에서 false를 반환한다', () => { assert.strictEqual(makeTrie().search('appl'), false); }); test('저장된 단어들의 접두사는 startsWith에서 true를 반환한다', () => { assert.strictEqual(makeTrie().startsWith('appl'), true); }); test('어떤 단어의 접두사도 아니면 startsWith에서 false를 반환한다', () => { assert.strictEqual(makeTrie().startsWith('banana'), false); });
node --test coding-test/16-advanced-strings/02-trie/solution.test.js

함정

  • search에서 isEndOfWord 확인을 빠뜨리면 다른 단어의 접두사일 뿐인데도 true를 반환하게 됩니다(“app”만 저장했는데 “appl”이 search에서 true가 되는 식의 오류).
  • children을 일반 객체({})로 만들면 constructor, toString 같은 프로토타입 속성과 이름이 겹치는 글자에서 예기치 못한 동작이 생길 수 있습니다. Map을 쓰면 이런 문제가 없습니다.

문제 3. 가장 긴 팰린드롬 부분 문자열 찾기

문제 설명

문자열이 주어질 때, 그 안에 있는 연속된 부분 문자열 중 앞뒤로 읽어도 같은(팰린드롬) 가장 긴 부분 문자열을 구합니다. 정답이 여러 개면 그중 하나만 반환합니다.

제약
문자열 길이1 이상 1000 이하

입출력 예

입력출력(예시)설명
”babad""bab""aba”도 정답으로 인정(길이가 같음)
“cbbd""bb”길이 2인 팰린드롬
”a""a”글자 하나는 항상 팰린드롬

접근

팰린드롬은 항상 어떤 “중심”을 기준으로 좌우 대칭입니다. 중심이 글자 하나인 경우(홀수 길이)와 글자 사이인 경우(짝수 길이) 두 가지를 모두 고려해, 각 중심에서 양쪽 문자가 같은 동안 바깥으로 넓혀갑니다(중심 확장). 문자열 길이를 n이라 하면 중심 후보가 2n - 1개이고 각 중심에서 최대 n번 넓힐 수 있으므로 시간 복잡도는 O(n²)입니다.

풀이

// coding-test/16-advanced-strings/03-longest-palindrome/solution.js // 시간복잡도: O(n^2) — 중심 후보 2n-1개에서 각각 최대 n번 확장한다 // 공간복잡도: O(1) — 시작 위치와 최대 길이만 변수로 유지한다 export function solution(text) { if (text.length === 0) return ''; let start = 0; let maxLength = 1; function expandAroundCenter(left, right) { while (left >= 0 && right < text.length && text[left] === text[right]) { const currentLength = right - left + 1; if (currentLength > maxLength) { maxLength = currentLength; start = left; } left -= 1; right += 1; } } for (let center = 0; center < text.length; center++) { expandAroundCenter(center, center); expandAroundCenter(center, center + 1); } return text.slice(start, start + maxLength); }

테스트

// coding-test/16-advanced-strings/03-longest-palindrome/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('홀수 길이 팰린드롬을 찾는다', () => { const result = solution('babad'); assert.ok(result === 'bab' || result === 'aba'); }); test('짝수 길이 팰린드롬을 찾는다', () => { assert.strictEqual(solution('cbbd'), 'bb'); }); test('글자 하나는 그대로 반환한다', () => { assert.strictEqual(solution('a'), 'a'); }); test('빈 문자열이면 빈 문자열을 반환한다', () => { assert.strictEqual(solution(''), ''); });
node --test coding-test/16-advanced-strings/03-longest-palindrome/solution.test.js

함정

  • 홀수 길이 중심(expandAroundCenter(center, center))만 확인하고 짝수 길이 중심(expandAroundCenter(center, center + 1))을 빠뜨리면 “cbbd” 같은 짝수 길이 팰린드롬을 놓칩니다.
  • maxLength의 초기값을 0으로 두면 길이 1인 문자열(“a”)에서 아무 갱신도 일어나지 않아 빈 문자열이 반환됩니다. 최소 길이 1을 기본값으로 둡니다.

직접 해보기

  1. 문제 1의 solution(findFirstMatchIndex)을 응용해 텍스트 안에서 패턴이 나타나는 모든 시작 인덱스를 배열로 반환하는 함수를 만들어봅니다.
  2. Trie에 저장된 단어 중 특정 접두사로 시작하는 단어를 모두 배열로 반환하는 findWordsWithPrefix 메서드를 추가해봅니다.

정답 보기(1번)

// coding-test/16-advanced-strings/01-kmp-search/solution.js (findAllMatchIndexes 추가) export function findAllMatchIndexes(text, pattern) { if (pattern.length === 0) return []; const table = buildFailureTable(pattern); const indexes = []; let textIndex = 0; let patternIndex = 0; while (textIndex < text.length) { if (text[textIndex] === pattern[patternIndex]) { textIndex += 1; patternIndex += 1; if (patternIndex === pattern.length) { indexes.push(textIndex - patternIndex); patternIndex = table[patternIndex - 1]; } } else if (patternIndex > 0) { patternIndex = table[patternIndex - 1]; } else { textIndex += 1; } } return indexes; }

한 번 일치를 찾은 뒤 바로 종료하지 않고, 패턴 포인터를 실패 테이블로 되돌려 다음 일치를 계속 찾습니다. buildFailureTable은 같은 파일 안의 비공개 함수를 그대로 재사용합니다.

자주 하는 실수

증상원인고치는 법
KMP 결과가 단순 비교와 다름불일치 시 텍스트 포인터까지 되돌림패턴 포인터만 실패 테이블 값으로 되돌린다
트라이에서 존재하지 않는 단어가 search에서 true로 나옴isEndOfWord 확인 없이 노드 도달 여부만 확인search는 반드시 isEndOfWord까지 확인한다
최장 팰린드롬에서 짝수 길이 답을 놓침홀수 길이 중심만 확장글자 사이를 중심으로 하는 짝수 길이 확장도 함께 수행한다
빈 문자열·길이 1 입력에서 예외 발생경계 조건(길이 0 또는 1)을 별도로 처리하지 않음함수 시작 부분에서 빈 입력을 먼저 처리한다

확인 문제

문제 14지선다
KMP 알고리즘이 단순 문자열 비교보다 빠른 이유는?
문제 24지선다
트라이(Trie)에서 여러 단어가 공통 접두사를 가질 때 얻는 이점은?
문제 34지선다
트라이의 search 메서드가 startsWith와 다르게 isEndOfWord를 추가로 확인해야 하는 이유는?
문제 44지선다
최장 팰린드롬 부분 문자열을 중심 확장 방식으로 풀 때 짝수 길이 중심까지 확인해야 하는 이유는?

참고 자료

Last updated on