휴대폰에서 영단어를 입력할 때 버튼을 누르는 횟수를 최소화하기 위해 자동완성 기능을 활용하는 문제이다. 이 문제는 Trie(트라이) 자료 구조 또는 재귀적인 접근 방식을 사용하여 효율적으로 해결할 수 있다.
백준 5670번 문제이다. 원문 페이지는 BOJ 서비스 종료로 더 이상 열리지 않아 아래에 문제 규칙을 직접 요약했다.
학습 목표
이 글을 읽고 나면 다음을 설명할 수 있어야 한다.
- Trie의 각 노드에서 “버튼을 눌러야 하는 지점"이 정확히 어디인지(자식이 둘 이상이거나 단어가 끝나는 노드)와 그 이유.
- Trie를 만드는 풀이와 Trie 없이 단어 집합을 재귀적으로 쪼개는 풀이가 같은 값을 계산하는 이유, 그리고 메모리 사용 방식의 차이.
- 평균 출력에서 부동소수점 반올림이 어긋나는 상황과 처리 방법.
문제 설명
휴대폰에서 길이가 $P$인 영단어를 입력하려면 일반적으로 버튼을 $P$번 눌러야 한다. 그러나 입력 속도를 높이기 위해, 사전에 기반한 자동완성 기능을 사용하는 자판 모듈이 있다. 이 모듈은 다음과 같은 규칙으로 동작한다.
- 첫 글자는 자동완성되지 않는다. 사용자는 첫 번째 글자를 반드시 직접 입력해야 한다.
- 자동입력 조건: 현재까지 입력된 문자열 $c_1c_2…c_n$이 있을 때, 사전에서 이 문자열로 시작하는 단어들이 모두 다음 글자 $c$를 공유한다면, 모듈은 $c$를 자동으로 입력해준다.
예를 들어, 사전에 “hello”, “hell”, “heaven”, “goodbye"가 있을 때, 사용자가 ‘h’를 입력하면 모듈은 자동으로 ’e’를 입력해준다. 이는 ‘h’로 시작하는 모든 단어의 다음 글자가 ’e’이기 때문이다. 그러나 ‘he’ 다음에는 ’l’과 ‘a’가 올 수 있으므로, 모듈은 다음 입력을 기다린다.
사용자가 ’l’을 입력하면, 모듈은 다시 ’l’을 자동으로 입력해준다. 여기서 “hell"이 단어의 끝이지만, “hello"는 아직 끝나지 않았으므로, 모듈은 다시 입력을 기다린다. 따라서 “hello"는 h, l, o 세 번(3회), “hell"은 h, l 두 번(2회), “heaven"은 h, a 두 번(2회), “goodbye"는 g 한 번(1회)이므로 평균은 $(3+2+2+1)/4 = 2.00$이다.
문제에서 입력은 여러 개의 테스트 케이스로 이루어져 있다. 각 테스트 케이스는 사전에 속한 단어의 개수 $N$이 주어지며, 이어서 $N$개의 단어가 주어진다. 각 단어를 입력하기 위해 버튼을 눌러야 하는 횟수의 평균을 소수점 둘째 자리까지 반올림하여 출력해야 한다. 원문 페이지가 닫혀 입력 크기와 메모리·시간 제한의 정확한 수치는 이 글에서 확인하지 않았다.
접근 방식
왜 분기 노드와 단어 끝 노드에서만 입력하는가
사전의 모든 단어를 Trie에 넣으면, 어떤 접두사에 해당하는 노드의 자식 수는 “사전에서 그 접두사 다음에 올 수 있는 글자의 종류 수"와 같다. 자식이 하나뿐이면 다음 글자가 유일하게 정해지므로 모듈이 자동으로 채운다. 자식이 둘 이상이면 어느 쪽인지 사용자가 정해 주어야 하므로 버튼을 눌러야 한다. 또 현재 노드가 어떤 단어의 끝이라면, 자식이 하나뿐이어도 그 단어에서 멈출 수 있는 선택지가 있어 모듈은 자동입력을 하지 않는다. 그래서 “hell” 다음의 ‘o’는 자식이 하나지만 is_end가 참이어서 직접 눌러야 한다. 첫 글자는 루트의 자식 수와 무관하게 항상 직접 입력하므로, 횟수를 1로 시작하고 두 번째 글자부터 노드 조건을 검사한다.
flowchart LR
R["root"] --> H["h"]
R --> G["g"]
H --> E
E["e (분기점)"] --> L1["l"]
E --> A["a"]
L1 --> L2["l (끝)"]
L2 --> O["o (끝)"]
A --> V["ven (끝)"]
G --> GB["oodbye (끝)"]
위 그림에서 “e” 노드는 자식이 ’l’과 ‘a’ 두 개이므로 여기서 한 번 더 눌러야 하고, “l (끝)” 노드는 단어 “hell"이 끝나는 곳이어서 “hello"를 쓸 때 ‘o’를 눌러야 한다. 각 단어의 답은 루트에서 그 단어까지 내려가는 경로에서 이런 노드를 지난 횟수에 첫 글자 1회를 더한 값이다.
Trie 없이 재귀로 푸는 방법
같은 값을 Trie 없이도 구할 수 있다. 현재 위치의 글자로 단어들을 그룹화하고, 한 그룹 안에서 모든 단어가 같은 글자를 공유하는 동안은 자동입력이므로 위치만 앞으로 건너뛴다. 그룹 안의 단어가 갈라지거나 어느 단어가 끝나는 지점에서 한 번 눌렀다고 보고 depth를 1 늘린 뒤, 남은 단어들에 대해 같은 과정을 재귀적으로 반복한다. 이 방식은 Trie 노드를 26개 포인터 배열로 잡지 않고 인덱스 리스트만 넘기므로, 노드 수가 많은 입력에서 메모리 사용 패턴이 다르다. 다만 어느 쪽이 얼마나 적게 쓰는지는 문제의 실제 제한과 측정이 필요하며, 이 글은 측정값을 제시하지 않는다. Python에서 26칸 자식 배열을 가진 노드 객체를 대량으로 만들면 객체 오버헤드가 커지기 쉬워, 이 글의 Python 풀이는 Trie 대신 재귀 분할을 택했다(측정값은 없다).
복잡도
| 풀이 | 시간 | 공간 | 비고 |
|---|---|---|---|
| C++ Trie | $O(S)$ | $O(S \cdot 26)$ 포인터 (최악) | $S$는 모든 단어 길이의 합. 삽입과 질의가 각각 단어 길이에 비례 |
| Python 재귀 분할 | 최악 $O(S)$에 가까움, 공통 접두사 비교 비용 포함 | 단어 저장 + 인덱스 리스트 | 그룹마다 dict를 만들어 상수 비용이 큼 |
C++ 코드와 설명
| |
Trie 노드는 is_end(단어의 끝 여부), child_count(자식 수), 26칸 자식 포인터 배열로 구성된다. insert는 새 노드를 만들 때마다 부모의 child_count를 올려서, 질의 단계에서 “이 지점이 분기점인가"를 O(1)로 판단하게 한다. count_key_presses는 첫 글자 1회에서 시작해 두 번째 글자부터 현재 노드가 단어 끝이거나 자식이 둘 이상일 때만 횟수를 올린다. 총합을 $N$으로 나눈 평균에 1e-8을 더하는 것은 정확히 .xx5로 끝나는 값이 부동소수점 오차로 내림되는 것을 막기 위한 보정이다. 테스트 케이스마다 delete_trie로 노드를 해제한다.
Python 코드와 설명
| |
solve는 단어 길이 합을 한 번씩만 훑도록 init(indices, i, depth)를 재귀 호출한다. indices는 아직 처리 중인 단어들이고 i는 검사할 글자 위치, depth는 지금까지 누른 횟수다. 같은 위치의 글자로 wordbook 딕셔너리에 그룹화한 뒤, 각 그룹에서 모든 단어가 다음 글자를 공유하는 동안 j를 늘려 공통 접두사를 한 번에 건너뛴다. 어느 단어가 끝나거나 글자가 갈라지는 위치에서 멈추고, 그 그룹에는 depth + 1을 부여한다. 끝에 도달한 단어는 depths에 횟수를 저장하고 나머지는 같은 함수를 다시 호출한다. 마지막으로 decimal의 ROUND_HALF_UP으로 평균을 소수점 둘째 자리까지 반올림한다. 정수 합 Decimal(sum(depths))를 바로 N으로 나눈 뒤 quantize로 맞추므로 float 오차가 끼지 않는다. (round()는 컨텍스트와 무관하게 짝수 반올림을 쓰기 때문에 quantize를 쓴다.) 입력이 끝나면 readline()이 빈 문자열을 돌려주어 int()가 ValueError를 내므로 이 예외로 반복을 종료한다.
결론
시간은 두 풀이 모두 입력 문자 총수에 비례하므로 선택 기준은 메모리와 구현 길이다. 노드 객체를 싸게 만들 수 있는 C++에서는 child_count와 is_end를 저장하는 Trie가 단순하고, 노드 객체 비용이 큰 Python에서는 Trie 없이 그룹이 갈라지는 지점에서 즉석으로 판단하는 재귀 분할이 대안이 된다.
코너 케이스 및 실수 포인트
| 케이스 | 설명 | 처리 방법 |
|---|---|---|
| 단어 1개 | $N=1$이면 첫 글자만 누르고 나머지는 자동완성 | 횟수 1, 출력 1.00 |
| 다른 단어의 접두사인 단어 | “hell"과 “hello"처럼 자식이 하나여도 is_end이면 멈춤 | is_end 조건을 반드시 포함 |
| 평균 반올림 | .xx5 경계에서 부동소수점 오차로 내림 | +1e-8 보정 또는 decimal ROUND_HALF_UP |
| 다중 테스트 케이스 | EOF까지 반복 입력 | while (cin >> N) / EOFError 처리 |
| 합계 크기 | 합계는 입력 문자 총수 이하라 int로 충분 | 코드의 int total_presses 유지 |
참고 자료
- Trie — Wikipedia: 접두사 트리의 정의와 연산 복잡도.
![Featured image of post [Algorithm] C++/Python 백준 5670번: 휴대폰 자판](/post/algorithm/2024-09-25-boj-5670/wordcloud_hu_94848fea87d93539.webp)
![[Algorithm] C++/Python 백준 3653번 : 영화 수집](/post/algorithm/2024-09-25-boj-3653/wordcloud_hu_9c7e86fbd0974d56.webp)
![[Algorithm] C++/Python 백준 3679번 : 단순 다각형](/post/algorithm/2024-09-25-boj-3679/wordcloud_hu_6b3b2fbdb74918fe.webp)
![[Algorithm] C++/Python 백준 5670번: 휴대폰 자판](/post/algorithm/2024-09-25-boj-5670/wordcloud_hu_520ec3c3904044ff.webp)
![[Algorithm] C++ 백준 1014번: 컨닝](/post/algorithm/2024-09-23-boj-1014/wordcloud_hu_b3c868c2d5a7f65c.webp)
![[Algorithm] C++/Python 백준 2618번 : 경찰차](/post/algorithm/2024-09-23-boj-2618/wordcloud_hu_b516fba37f0a7dd4.webp)
![[Algorithm] C++/Python 백준 14725번 : 개미굴](/post/algorithm/2024-10-10-boj-14725/wordcloud_hu_e21974d8aca9ccdc.webp)
![[Algorithm] C++/Python 백준 11400 : 단절선(Bridge) 문제 풀이 및 해설](/post/algorithm/2024-12-26-boj-11400/wordcloud_hu_e833b3afc73e3c7f.webp)
![[Algorithm] Two Pointers Algorithm](/post/2024-10-16-two-pointers/wordcloud_hu_2ae002fe9438c7e6.webp)
![[Algorithm] C++/Python 백준 11505번 : 구간 곱 구하기](/post/algorithm/2024-10-10-boj-11505/wordcloud_hu_83182463fd8f24b4.webp)
![[Algorithm] C++ / Python 백준 11238번: Fibo](/post/algorithm/2026-03-10-boj-11238-fibo-cpp-python-solution/wordcloud_hu_becd9f0d5bbc5cc4.webp)