JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현

··설명 약 18분·작성: 해비·블로그 소개

읽기 전에 알아두면 좋아요

아래 개념이 익숙하면 본문을 바로 읽으세요. 낯선 개념만 링크에서 복습하면 됩니다.

개념과 예제는 실행하지 않아도 읽을 수 있습니다. 내 컴퓨터·터미널에서 직접 실행하는 과정은 선택 실습입니다.

과정 위치와 선택 복습 안내

과정 위치: 심화 선택 실습 · 자료구조 개념

과정 위치는 이 글이 속한 묶음입니다. 이 단계의 모든 개념을 먼저 읽을 필요는 없습니다. 위에 연결한 개념 중 낯선 내용만 복습하세요. 문제의 조건·상태·갱신 순서는 익숙한 언어로 설명하면 됩니다.

JavaScript로 문법·실행 복습하기 (선택)

개념 글의 코드는 JavaScript 예시입니다. 문제 화면에서는 JavaScript·Python·Java·C++·C# 중 익숙한 언어의 함수를 작성하세요. 본문의 Node.js 입출력 예시는 로컬 실행을 위한 별도 안내입니다.

Unicode 문자열을 저장한 Trie에서 접두사를 찾고 단어를 안전하게 삭제하려면 어떻게 하나요?

Trie는 공통으로 시작하는 글자를 같은 경로에 저장하는 트리입니다. 단어 끝과 경로별 개수를 구분해 접두사를 찾고, 삭제할 때는 다른 단어가 쓰는 가지를 남깁니다.

적용 조건·범위: Node.js 24의 CommonJS 환경을 기준으로 합니다. 아래 전체 코드를 trie.cjs로 저장하고 파일이 있는 폴더에서 node trie.cjs를 실행하세요. 외부 패키지는 필요하지 않습니다. module.exports는 다른 파일에서 가져올 때 사용하며 require.main 조건 안의 호출은 직접 실행할 때만 작동합니다.

본문 근거: MDN 문자열 반복자: 코드포인트 순회 · MDN Map: 접근 성능과 키 의미

Trie: 같은 앞부분은 같은 길로 읽습니다

작은 단어 목록으로 Trie의 공유 경로와 단어 끝을 배웁니다. 접두사 개수와 삭제 때 보존할 연결을 상태 변화로 따라갑니다.

가·가방·가방끈이 시작점에서 가·방·끈으로 이어지는 한 경로와 세 단어 끝을 공유하는 Trie
가로 가는 길과 방으로 가는 길을 이름마다 복제하지 않습니다. 같은 경로를 공유하면서 각 이름이 끝나는 위치는 따로 표시합니다. 원본 보기 (새 탭)

먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

트라이의 개념을 표현한 표지 일러스트
주제를 표현한 개념 표지입니다. 정확한 동작과 값의 변화는 아래 코드와 표에서 확인하세요.

선수 지식: JavaScript 변수·배열·조건문을 먼저 익힙니다. 객체 참조나 함수가 낯설면 아래 단계별 예제부터 시작하고, class 전체 구현은 해당 동작을 손으로 추적한 뒤 읽습니다. 각각의 코드는 독립 실행합니다.

1. “가방”으로 시작하는 이름을 어떻게 모을까요?

전시관에 “가”, “가방”, “가방끈”이라는 별칭이 있다고 해 보겠습니다. “가방”이라는 이름이 있는지 묻는 것과 “가방으로 시작하는 이름이 몇 개인지” 묻는 것은 다릅니다. 전자는 “가방” 하나의 등록 여부이고, 후자는 “가방”과 “가방끈”을 함께 세는 질문입니다.

가방의 정확한 등록 여부와 가방·가방끈 두 이름을 세는 접두사 개수 비교
가방의 등록 여부는 그 위치의 단어 끝을 확인합니다. 가방으로 시작하는 이름은 가방과 가방끈 두 개이며, 더 짧은 가는 포함하지 않습니다. 원본 보기 (새 탭)

이름들을 글자별 갈림길에 놓으면 공통 앞부분을 같이 사용할 수 있습니다. 시작점에서 가 → 방으로 두 번 이동하면 “가방으로 시작하는 이름”의 위치에 도착합니다. 이처럼 앞부분을 공유하는 길을 만드는 구조가 Trie(트라이)입니다. 처음부터 모든 메서드를 외우지 말고 길을 따라가는 모습부터 보겠습니다.

등록할 이름 시작점에서 따라갈 길 정확히 끝나는 위치
가 시작 → 가 가 뒤
가방 시작 → 가 → 방 방 뒤
가방끈 시작 → 가 → 방 → 끈 끈 뒤

“가”로 가는 길은 세 벌 만들지 않습니다. 시작점의 같은 갈림길을 공유합니다. “가방”의 방 역시 같은 위치를 쓰며, 더 긴 이름만 끈이라는 다음 길을 추가합니다.

짧은 예제는 각각 독립적으로 실행할 수 있습니다. 브라우저 개발자 도구의 Console에 한 블록 전체를 붙여 넣거나, 파일로 저장해 Node.js로 실행하세요. 다른 예제를 먼저 실행할 필요는 없습니다. 콘솔에서 같은 변수 이름을 다시 선언했다는 오류가 나오면 새로고침한 뒤 해당 예제를 실행하세요. 바로 아래 상자는 console.log로 출력되는 값입니다.

2. Map 두 개로 갈림길만 만들어 봅니다

Map은 이름표로 값을 찾는 도구였습니다. 여기서는 글자를 이름표로 사용하고 그 글자를 지난 다음 갈림길을 값에 저장합니다. 첫 예제는 길이 어떻게 연결되는지만 보여 주며 아직 “단어의 끝”이나 개수는 저장하지 않습니다.

start의 가 키가 afterGa Map을, afterGa의 방 키가 새 Map 객체를 가리키는 참조 연결
Map의 값에는 다음 글자 문자열이 아니라 다음 Map 객체를 저장합니다. 두 true 출력은 갈림길이 존재한다는 뜻이며, 아직 단어 끝이나 이름 수를 저장한 것은 아닙니다. 원본 보기 (새 탭)
const start = new Map();
const afterGa = new Map();
start.set("가", afterGa);
afterGa.set("방", new Map());
console.log(start.has("가"));
console.log(start.get("가").has("방"));
true
true

첫 두 줄에서 빈 갈림길 두 개를 만듭니다. start.set(“가”, afterGa)는 시작점에서 가를 따라가면 afterGa로 가라는 연결입니다. 다음 줄은 그 위치에서 방을 따라갈 길을 추가합니다. Map 안에 문자열 다음 글자를 적는 것이 아니라 다음 Map 객체를 연결한다는 점이 중요합니다.

start.get(“가”).has(“방”)은 한 번에 읽지 말고 둘로 나누세요. 먼저 get(“가”)로 가를 지난 위치에 도착하고, 그곳에서 has(“방”)으로 방이라는 다음 길이 있는지 묻습니다. 두 출력은 길의 존재만 말합니다. “가”라는 이름 자체가 등록되어 있는지는 아직 알 수 없습니다.

3. 길의 존재와 단어의 끝은 다릅니다

“가방”만 등록했어도 중간의 가 길은 존재합니다. 그렇다고 “가”도 등록된 이름이라고 답하면 틀립니다. 그래서 각 위치에 end라는 끝 표시를 붙입니다. end=true는 “여기까지가 등록된 이름 하나”라는 뜻입니다. false여도 더 긴 이름으로 이어지는 길은 남아 있을 수 있습니다.

가와 가방이 등록된 Trie에서 root·가·방 위치의 end와 pass 값 비교
root와 가 위치는 두 이름을 세고 방 위치는 가방 하나를 셉니다. end는 해당 위치에서 정확히 끝나는 이름이 있는지를 나타내며, 이 작은 코드는 표의 상태를 직접 만든 관찰 예제입니다. 원본 보기 (새 탭)

이제 “가”와 “가방” 두 이름을 등록한 상태를 보겠습니다. pass는 이 위치를 거쳐 등록된 이름의 수입니다. 해당 위치에서 끝나는 이름도 포함합니다. root는 아무 글자도 읽기 전인 시작점이며 모든 이름이 여기서 출발하므로 root.pass는 전체 이름 수입니다.

위치 end pass 무엇을 세었나
root: 시작점 false 2 가, 가방
가를 지난 위치 true 2 가, 가방
방까지 지난 위치 true 1 가방
const bang = { end: true, pass: 1, children: new Map() };
const ga = { end: true, pass: 2, children: new Map([["방", bang]]) };
const root = { end: false, pass: 2, children: new Map([["가", ga]]) };
console.log(root.pass);
console.log(ga.end);
console.log(ga.pass);
console.log(bang.pass);
2
true
2
1

이 코드는 표에 적힌 완성 상태를 작은 객체 세 개로 직접 만든 관찰용 예제입니다. 아직 insert 함수가 알아서 개수를 센 것은 아닙니다. children에는 글자별 다음 객체, end에는 정확한 이름의 끝, pass에는 위 표의 개수를 넣었습니다. new Map([[“방”, bang]])은 방이라는 이름표에 bang 객체를 미리 넣어 Map을 만드는 문법입니다.

출력 2, true, 2, 1은 각각 전체 이름 두 개, “가” 자체도 등록됨, 가로 시작하는 이름 두 개, 가방으로 시작하는 이름 한 개를 뜻합니다. 같은 pass 숫자를 보더라도 어느 위치에서 읽었는지에 따라 질문이 달라집니다.

새 이름 “가방끈”을 넣는다면 root → 가 → 방 → 끈까지 지난 모든 위치의 pass를 하나씩 올려야 합니다. 결과는 3, 3, 2, 1입니다. 반면 “가”를 다시 등록하면 이미 ga.end가 true이므로 아무 개수도 늘리지 않습니다. 이 글은 등장 횟수가 아니라 서로 다른 이름의 수를 저장합니다.

4. 짧은 이름을 지워도 긴 이름의 길은 남깁니다

이번에는 “가”, “가방” 상태에서 “가”만 지웁니다. 가 위치의 end를 false로 바꾸고, 지운 이름이 지나간 root와 가의 pass를 하나씩 줄입니다. 가방은 방 위치까지 가는 별개 이름이므로 방의 end와 pass는 그대로 둡니다.

가만 삭제할 때 root와 가의 pass가 2에서 1로 줄고 가의 end만 false로 바뀌며 방 경로가 남는 상태
가 삭제는 그 이름이 지난 root와 가의 pass만 줄입니다. 가방은 여전히 등록되어 있으므로 방의 end·pass와 공유 경로를 유지해야 합니다. 원본 보기 (새 탭)
상태 root.pass 가의 end / pass 방의 end / pass
삭제 전 2 true / 2 true / 1
가 하나 삭제 후 1 false / 1 true / 1
이어서 가방도 삭제 후 0 false / 0 → 길 제거 false / 0 → 길 제거
const bang = { end: true, pass: 1, children: new Map() };
const ga = { end: true, pass: 2, children: new Map([["방", bang]]) };
const root = { end: false, pass: 2, children: new Map([["가", ga]]) };
ga.end = false;
for (const node of [root, ga]) {
  node.pass -= 1;
}
if (ga.pass === 0) root.children.delete("가");
console.log(root.pass);
console.log(ga.end);
console.log(root.children.get("가").children.has("방"));
1
false
true

ga.end = false는 이름의 끝 표시만 없앱니다. for문은 [root, ga]의 두 객체를 하나씩 꺼내 pass를 줄입니다. 방은 이 배열에 넣지 않았으므로 바뀌지 않습니다. 마지막 if는 가 경로를 쓰는 이름이 하나도 없을 때에만 시작점에서 그 길을 지웁니다.

여기서는 ga.pass가 1이어서 길을 지우지 않습니다. 그래서 출력은 전체 이름 수 1, “가” 등록 여부 false, 방으로 이어지는 길 true입니다. root.children.delete(“가”)를 조건 없이 실행하면 아직 필요한 “가방”까지 찾지 못하게 됩니다.

나중에 “가방”도 지우면 방과 가의 pass가 모두 0이 됩니다. 그때는 끝쪽 방부터 거꾸로 올라오며 안 쓰는 길만 정리합니다. 중간에 pass가 남은 위치를 만나면 더 올라가 지울 필요가 없습니다. 다른 이름이 그 공통 길을 쓰고 있기 때문입니다.

5. 빈 이름과 중복도 같은 원리로 생각합니다

“가”, “가방”이 있는 상태에서 “가방”을 한 번 더 넣고, 그다음 “가”를 지웠습니다. 전체 개수와 가로 시작하는 개수는 얼마일까요?

가와 가방이 등록된 초기 Trie에 가방 중복 삽입과 가 삭제를 순서대로 요청하고 개수를 묻는 연습
초기 end·pass 값만 제시했습니다. 가방을 다시 등록하고 가를 지운 뒤 전체 이름 수와 가로 시작하는 수를 직접 계산해 보세요. 원본 보기 (새 탭)
답과 이유 확인하기

둘 다 1입니다. 중복 등록은 아무것도 바꾸지 않습니다. “가” 삭제 뒤에는 “가방” 하나만 남으므로 root.pass와 가 위치의 pass가 모두 1입니다. 가 위치의 end만 false가 됩니다.

빈 문자열 “”은 글자를 하나도 따라가지 않는 이름입니다. 따라서 그 끝은 root 자신입니다. 빈 이름을 새로 넣으면 root.end를 true로 바꾸고 root.pass만 하나 올립니다. 빈 접두사로 조회하면 시작점의 pass, 즉 등록된 모든 이름의 수가 답입니다. 등록된 이름이 전혀 없을 때는 그 수도 0입니다.

바로 실행해 보는 핵심 예제

코드 직접 실행

본문 예제를 선택하고 실제 계산 결과를 확인합니다.

예제 크게 보기 (새 탭)

독자 질문: 단어를 지울 때 같은 접두사를 공유하는 다른 단어는 어떻게 남기나요?

개발 삭제 후 개발자만 등록된 Trie에서 없는말 삭제의 반환값을 묻는 초기 상태
독립 예제 실행 뒤 개발의 끝 표시는 false이고 개발자의 끝 표시는 true입니다. 현재 상태에서 없는말 삭제를 요청할 때 반환값을 직접 예상해 보세요. 원본 보기 (새 탭)

왜 쓰나요? 완전한 단어 끝 표지만 지우고 다른 단어가 쓰지 않는 가지부터 정리합니다.

아래 바닐라 JavaScript 코드는 독립 실행 예제입니다. 본문에서 배운 개념을 새 데이터로 확인합니다. 다른 예제와 같은 콘솔에 이어 붙이지 말고 새 콘솔 또는 새 .cjs 파일에서 실행하세요.

class Trie {
  root = { end: false, next: new Map() };
  chars(word) { return [...word]; }
  insert(word) {
    let node = this.root;
    for (const ch of this.chars(word)) {
      if (!node.next.has(ch)) node.next.set(ch, { end: false, next: new Map() });
      node = node.next.get(ch);
    }
    node.end = true;
  }
  search(word) {
    let node = this.root;
    for (const ch of this.chars(word)) {
      node = node.next.get(ch);
      if (!node) return false;
    }
    return node.end;
  }
  delete(word) {
    const chars = this.chars(word);
    if (!this.search(word)) return false;
    const prune = (node, i) => {
      if (i === chars.length) node.end = false;
      else {
        const ch = chars[i], child = node.next.get(ch);
        if (prune(child, i + 1)) node.next.delete(ch);
      }
      return !node.end && node.next.size === 0;
    };
    prune(this.root, 0); return true;
  }
}
const trie = new Trie();
trie.insert("개발"); trie.insert("개발자"); trie.delete("개발");
console.log(trie.search("개발"), trie.search("개발자"));

실행 결과와 비용

이번 검토에서는 Node.js v24.18.0에서 위 독립 예제를 실행해 다음 출력과 일치하는지 확인했습니다.

false true

시간·공간 복잡도: 단어 길이 L의 삽입·정확 검색·삭제는 O(L)입니다. 접두사 검색은 prefix 길이에 비례하고 결과 목록 수집은 출력 크기만큼 추가됩니다.

직접 풀 문제

trie.delete('없는말')은 무엇을 반환하나요?

풀이와 기대 결과

false입니다. 없는 단어 삭제는 공유 경로를 바꾸면 안 됩니다.

원자료의 다른 학습 순서 (선택)

자료구조 과정 원래 순서의 다음 글: 15 Fenwick Tree로 증가 갱신과 구간 합

검증 범위: 위 독립 예제의 출력만 로컬에서 확인했습니다. 이 글은 독립 자료구조 실습입니다. 실제 브라우저 표시와 모든 입력·플랫폼의 실행은 검증하지 않았습니다.

짧은 예제도 아래 전체 구현처럼 문자열을 자동 정규화하지 않습니다. 시각적으로 같은 문자라도 코드포인트가 다르면 다른 단어입니다. 전체 구현의 접두사 개수 기능과 입력 검사는 이 축약 예제에는 없습니다.

6. 이제 전체 구현에서 어느 부분을 읽을까요?

아래 전체 구현은 손으로 만든 객체를 TrieNode로 만들고, 임의 길이 문자열에도 같은 작업을 반복합니다. walk는 글자를 따라 이동하기, insert는 새 길과 끝 표시 만들기, countPrefix는 도착 위치의 pass 읽기, delete는 사용하지 않는 길 정리하기입니다. 먼저 walk와 has부터 읽고, 그다음 insert와 delete를 읽어 보세요.

walk·has·insert·delete를 읽는 순서와 root에서 가·방까지의 노드 참조 path
상단은 전체 코드를 읽는 순서입니다. walk의 경로 탐색과 has의 끝 확인을 이해한 뒤, 중복·등록 여부를 확인하고 pass를 바꾸는 삽입과 삭제를 읽어 보세요. 원본 보기 (새 탭)

전체 코드의 path에는 root부터 도착 위치까지의 객체를 담습니다. 위에서 [root, ga]라고 직접 적은 것을 반복문이 만들어 주는 것입니다. 중복 등록이나 없는 이름 삭제는 pass를 바꾸기 전에 확인합니다. 바로 이 순서가 다른 이름의 개수를 지키는 장치입니다.

앞의 첫 실험은 Map만으로 길을 보였고 나머지는 두 이름의 상태를 직접 적었습니다. 모두 범용 Trie의 대체 구현은 아닙니다. 전체 버전은 문자열을 코드 포인트 단위로 나누고 자동 정규화하지 않습니다. 눈에 비슷하게 보여도 완성형 é와 e 뒤에 결합 악센트를 붙인 표현은 별개 키가 될 수 있습니다. 자동완성 목록 나열이나 인기순 정렬은 구현하지 않습니다. 우선 길·끝·개수를 구별한 뒤 아래의 자세한 계약을 읽으면 됩니다.

관련 코딩테스트 문제로 이어서 연습합니다

이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.

전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기

여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.

문제 상황과 API 계약

별칭 “가”, “가방”, “가방끈”을 Set에 저장하면 정확한 이름 조회는 간단합니다. 하지만 “가방으로 시작하는 별칭이 몇 개인가?”를 매번 묻는다면 모든 문자열을 훑어야 합니다. Trie는 공통 접두사를 한 경로로 묶어 접두사 끝 노드에서 답을 얻습니다. 빈번한 접두사 질의가 이 구조를 선택하는 이유입니다.

완성형 é 한 코드포인트 경로와 e 및 결합 악센트 두 코드포인트 경로의 차이
같아 보이는 é도 U+00E9 한 개로 구성되거나 U+0065와 U+0301로 구성될 수 있습니다. 이 구현은 코드포인트로 순회하고 자동 정규화하지 않으므로 두 경로를 별개 키로 다룹니다. 원본 보기 (새 탭)

이 구현은 중복을 저장하지 않는 문자열 집합입니다. insert는 새 단어일 때 true, delete는 실제 삭제했을 때 true입니다. has는 단어 전체가 등록되었는지, countPrefix는 해당 접두사로 시작하는 서로 다른 단어 수를 반환합니다. startsWith는 그 수가 0보다 큰지 확인합니다. 비어 있는 Trie에서 startsWith("")는 false라는 계약을 정했습니다.

문자열은 […word]로 순회합니다. 이는 UTF-16 코드 유닛 인덱싱 대신 JavaScript 문자열 반복자의 코드포인트 단위를 사용합니다. 😀는 한 간선이지만 가족 이모지나 결합 문자는 여러 간선일 수 있습니다. 사용자에게 보이는 한 글자와 동일하지 않으며 NFC 정규화도 하지 않습니다. 완성형 é와 e 뒤에 결합 악센트를 붙인 표현은 별개 키이고 대소문자도 구분합니다. 고립된 surrogate가 들어온 비정상 문자열을 별도로 거부하는 기능은 없습니다.

정확성을 지키는 불변식

각 노드의 children은 다음 코드포인트에서 자식 노드로 가는 Map입니다. end는 “여기에서 정확히 끝나는 단어가 있는가”를 나타냅니다. pass는 이 노드 아래에서 끝나는 서로 다른 단어 수이며, 해당 노드의 end가 true인 경우 자신도 포함합니다. root.pass는 전체 단어 수입니다.

가 위치의 pass 2가 그 위치에서 끝나는 가 한 개와 방 자식에서 끝나는 가방 한 개를 함께 세는 관계
pass는 자식 쪽 단어만 세는 값이 아닙니다. 가 위치에서 끝나는 가도 포함하므로, 가와 가방이 등록되면 가 위치의 pass는 1+1=2입니다. 원본 보기 (새 탭)

insert는 먼저 경로를 만들고 마지막 노드의 end를 확인합니다. 이미 true이면 어떤 pass도 증가시키지 않습니다. 새 단어이면 root부터 끝 노드까지 pass를 1 올립니다. 빈 문자열은 간선을 만들지 않고 root.end만 바꾸므로 별도의 특수 노드를 만들 필요가 없습니다.

delete는 끝까지 경로가 있고 end도 true인 경우에만 변경합니다. end를 끄고 경로의 pass를 1 줄인 뒤 아래에서 위로 pass===0인 자식만 제거합니다. 짧은 단어를 지웠다고 children 전체를 지우면 “가” 삭제가 “가방”까지 날리는 오류가 생깁니다. 반대로 빈 경로를 계속 남기면 등록·삭제가 반복될수록 쓸모없는 노드가 쌓입니다.

전체 구현과 실행

Node.js 24의 CommonJS 환경을 기준으로 합니다. 아래 전체 코드를 trie.cjs로 저장하고 파일이 있는 폴더에서 node trie.cjs를 실행하세요. 외부 패키지는 필요하지 않습니다. module.exports는 다른 파일에서 가져올 때 사용하며 require.main 조건 안의 호출은 직접 실행할 때만 작동합니다.

가 삭제 뒤 가방과 U+1F600 이모지 뒤 빛의 두 경로가 남고 exact false·prefix 1·emoji true가 출력되는 Trie
전체 구현의 입력에서 가 중복 등록은 개수를 늘리지 않습니다. 가만 지운 뒤 가방과 😀빛은 남으며, 그림의 U+1F600은 실제 입력의 😀 코드포인트를 뜻합니다. 원본 보기 (새 탭)
class TrieNode {
  constructor() {
    this.children = new Map();
    this.end = false;
    this.pass = 0;
  }
}
class Trie {
  constructor() { this.root = new TrieNode(); }
  chars(word) {
    if (typeof word !== 'string') throw new TypeError('문자열이 필요합니다.');
    return [...word];
  }
  walk(word) {
    let node = this.root;
    for (const ch of this.chars(word)) {
      node = node.children.get(ch);
      if (!node) return null;
    }
    return node;
  }
  insert(word) {
    let node = this.root;
    const path = [node];
    for (const ch of this.chars(word)) {
      if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
      node = node.children.get(ch);
      path.push(node);
    }
    if (node.end) return false;
    node.end = true;
    for (const item of path) item.pass++;
    return true;
  }
  has(word) { return this.walk(word)?.end ?? false; }
  countPrefix(prefix) { return this.walk(prefix)?.pass ?? 0; }
  startsWith(prefix) { return this.countPrefix(prefix) > 0; }
  delete(word) {
    const chars = this.chars(word);
    let node = this.root;
    const path = [node];
    for (const ch of chars) {
      node = node.children.get(ch);
      if (!node) return false;
      path.push(node);
    }
    if (!node.end) return false;
    node.end = false;
    for (const item of path) item.pass--;
    for (let i = chars.length - 1; i >= 0; i--) {
      if (path[i + 1].pass !== 0) break;
      path[i].children.delete(chars[i]);
    }
    return true;
  }
}

module.exports = { Trie };

if (require.main === module) {
  const t = new Trie();
  ['가', '가방', '가', '😀빛'].forEach(word => t.insert(word));
  t.delete('가');
  console.log(JSON.stringify({ exact: t.has('가'), prefix: t.countPrefix('가'), emoji: t.has('😀빛') }));
}

직접 실행 출력:

{"exact":false,"prefix":1,"emoji":true}

walk는 정확 검색과 접두사 집계가 공유하는 경로 탐색입니다. 중간 자식이 없으면 null을 반환합니다. 경로가 있다고 단어가 등록된 것은 아니므로 has는 반드시 end를 읽고, countPrefix는 pass를 읽습니다.

삽입과 삭제에서 path 배열을 유지하면 재귀 없이 조상의 집계값을 한 번씩 갱신할 수 있습니다. 삭제 때 chars[i]는 path[i]가 가진 간선 이름이고 path[i+1]이 그 간선의 자식입니다. 인덱스를 뒤집어 부모의 pass로 제거 여부를 판단하면 다른 단어의 경로를 지울 수 있습니다.

예제에서 “가”를 지워도 “가방”은 남으므로 exact는 false, prefix는 1입니다. 이모지로 시작하는 문자열도 같은 순회 규칙으로 들어갔으므로 emoji는 true입니다. 접두사 결과 나열이나 자동완성 순위 정렬은 구현 범위에 포함하지 않았습니다.

상태 변화 따라가기

가방 마지막 단어를 지우면 가·방 노드가 제거되고, 빈 문자열을 넣으면 간선 없이 root의 end와 pass가 바뀌는 상태
등록된 마지막 이름 가방을 지운 뒤에는 root만 남습니다. 빈 문자열을 새로 넣는 경우에는 새 간선을 만들지 않고 root.end와 root.pass를 바꿉니다. 원본 보기 (새 탭)
작업 가 노드 end / pass 방 노드 end / pass 의미
insert("가") true / 1 없음 가 자체가 단어
insert("가방") true / 2 true / 1 공유 경로에 두 단어
insert("가") 재요청 true / 2 true / 1 중복은 증가하지 않음
delete("가") false / 1 true / 1 가방 경로 보존
delete("가방") 노드 제거 노드 제거 pass 0인 경로만 정리
insert("") root.end=true 해당 없음 빈 단어도 집합 원소

복잡도와 적용하지 말아야 할 경우

L개 코드포인트 경로에서 Map 접근 M을 반복하는 O(L·M) 시간과 chars·path O(L), 전체 저장 O(S+1) 공간
통상 O(L)은 Map 접근을 기대 O(1)로 보는 가정이 필요합니다. S는 저장한 단어 수가 아니라 단어들의 코드포인트 길이 합의 상한입니다. 원본 보기 (새 탭)
작업 통상적 시간 공간 / 엄밀한 전제
insert / has / countPrefix / delete O(L) L은 코드포인트 수, Map 접근 기대 O(1) 가정
chars와 path 작업 공간 O(L) 문자 배열을 먼저 만드는 이 구현의 비용
전체 저장 O(S+1) S는 저장 단어들의 코드포인트 길이 합의 상한
Map 최악 시간 ECMAScript는 최악 O(1) 보장 안 함 노드별 Map 구현 비용이 경로 비용에 곱해짐

Map 접근이 노드당 M 시간이라면 경로 연산은 O(L·M)입니다. 해시 테이블을 기대하고 쓰는 O(L)을 JavaScript 명세의 최악 시간 보장으로 바꾸면 안 됩니다. 알파벳이 고정되어 작다면 고정 배열 자식으로 더 명확한 시간 상한을 얻을 수 있지만 메모리 사용은 달라집니다.

정확한 문자열 포함 여부만 필요하면 Set이 더 간단합니다. 접두사 목록이 정적이라면 정렬 배열에서 접두사 범위를 이진 탐색하는 대안도 있습니다. 문자열이 길고 공통 접두사가 드물면 Trie의 노드 객체와 Map 오버헤드가 커집니다.

텍스트 정규화, 검색 언어의 대소문자 규칙, 사용자가 보는 글자 단위 분할이 요구되면 입력 정책을 먼저 정해야 합니다. 문자열을 일부에서만 normalize하면 삽입한 키를 삭제하지 못합니다. 이 코드의 pass는 빈 문자열도 포함한 집합 개수이며 출현 빈도 카운터가 아닙니다.

연습으로 확인하기

등록과 삭제가 섞인 별칭 목록에서 접두사별 서로 다른 항목 수를 반환해 보세요. 전체 목록을 나열하지 않고 pass만 읽는 이유를 확인할 수 있습니다.

가와 가방이 등록된 Trie에서 countPrefix(가)가 가 위치의 pass 2를 읽는 흐름
접두사 가를 따라 도착한 위치의 pass를 읽으면 됩니다. 등록된 전체 이름 목록을 매번 펼치는 작업과 접두사 개수를 읽는 작업을 구분해 보세요. 원본 보기 (새 탭)

[창작 문제] 전시 별칭 관리대장의 접두사 수

LeetCode 208 – Implement Trie (Prefix Tree) — 기본 삽입·정확 검색·접두사 검색을 연습하는 공식 문제입니다. 여기의 빈 문자열·Unicode·삭제 계약은 그 문제와 별도로 정한 확장입니다.

공식 자료

공식 자료 확인일: 2026년 9월 10일. 구현은 이 글의 JavaScript 계약에 맞춰 독립적으로 작성했습니다. 다른 언어 라이브러리의 API와 숫자 범위가 그대로 적용되는 것은 아닙니다.

직접 실습: 연산 비용으로 구조를 설명합니다

실습 주제: JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현

가와 가방이 등록된 초기 Trie에서 가 삭제 후 상태와 유지 규칙 및 다른 구조의 비용을 직접 적는 실습
현재 공유 경로와 end·pass만 주어졌습니다. 가를 지운 뒤 바뀌는 필드와 남길 경로를 직접 그린 다음, 유지할 규칙과 대안 구조의 비용을 적어 보세요. 원본 보기 (새 탭)
  1. 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
  2. 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
  3. 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
  4. 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과

메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.

테스트 체크리스트

빈 Trie, 가 하나의 마지막 삭제, 가 중복 등록, L 코드포인트 입력의 비용을 확인하는 네 입력 시나리오
각 시나리오에서 조회·삭제 반환값, 중복 등록 뒤 pass 변화, 입력 길이에 따른 비용을 직접 기록해 보세요. 그림은 검사할 입력을 제시하며 통과 결과를 대신하지 않습니다. 원본 보기 (새 탭)
  • 빈 구조에 대한 조회·삭제 처리
  • 첫 원소와 마지막 원소 변경
  • 중복값 또는 동일 우선순위 처리
  • 입력 크기가 커졌을 때 예상 복잡도 유지

이 글이 도움이 되었나요?

조회 중

이어서 공부하기

체크포인트: 예제의 상태 변화를 손으로 추적하고 아래 문제에 적용하세요. 값이 없는 경우와 원소 하나인 경우도 설명해 보세요. 표본 하나에 더해 경계 입력과 작은 기준 풀이를 비교하고 왜 이 구조를 고르는지 설명하세요.

이 개념으로 더 풀어보기

다음 추천 · 자료구조 개념: Trie 자동완성: 접두사 아래 단어를 정해진 순서로 모으기

같은 주제에서 다음 개념과 예제로 이해를 넓힙니다.

권장 순서는 학습 가이드와 같습니다. 다른 순서는 접힌 참고 목록에서 확인할 수 있습니다. 읽음 표시는 숙달이나 채점 결과가 아닙니다.

원래 과정 목록 순서 (선택)

읽음 기록

읽음 — / 전체 23개 · 필수 23개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기