📝 문제 설명
https://school.programmers.co.kr/learn/courses/30/lessons/42839
프로그래머스
SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프
programmers.co.kr
목표: 한 자리 숫자가 적힌 종이들이 주어졌을 때(문자열 numbers) 이 한자리 수들로 만들 수 있는 소수가 몇 개인지 반환해야한다.
핵심 조건:
- numbers는 “17” 이러한 형태로 주어지며, 이는 숫자 ‘1’, ‘7’ 으로 봐야한다.
- numbers의 길이는 1이상 7이하의 문자열. 즉, 한자리 숫자가 1~7개 주어진다.
- numbers는 0~9까지 숫자로만 이루어져있다.
🔍 풀이 과정 (나만의 풀이 전략)
이 문제는 총 두 단계로 구성된다.
- 주어진 numbers로 만들 수 있는 숫자 조합을 먼저 파악
- 이게 소수인지 판별하는 방식으로 구현하고자 한다.
주어진 numbers의에서 만들 수 있는 모든 자리 수 (1 ~ N자리)를 구해야 하므로, DFS 알고리즘을 사용한다.
숫자들을 하나씩 붙여가면서 가장 깊은 자릿수에 방문하면 이전으로 백트래킹한 후 다시 다른 조각들을 이어붙이는 수직적인 탐색 방식의 DFS를 통해 구현한다.
DFS 구현을 위해 이미 방문한 숫자는 다음 탐색에서 제외해야 하므로 visited 배열을 통해 방문 체크를 한다.
다음과 같은 흐름으로 탐색을 진행한다.
[0층] "" (시작)
│
├── [1층] "1" ─── [2층] "17" ─── [3층] "173" (➡️ 복귀)
│ │
│ └── [2층] "1"로 복귀 후 다음 조각 '3' 선택 ➡️ [2층] "13" ─── [3층] "137" (➡️ 복귀)
│
├── [1층] "1" 갈래 완전 종료 후 ""로 복귀 ➡️ 다음 조각 '7' 선택 ➡️ [1층] "7" ... (반복)
const permutation = (current, depth) => {
if (depth > 0) set.add(parseInt(current));
if (depth === arr.length) return;
for (let i=0; i < arr.length; i++) {
if (visited[i] === false) {
visited[i] = true;
permutation(current + arr[i], depth + 1);
visited[i] = false;
}
}
}
permutation("", 0);
“173”를 예시로 상세한 실행 흐름을 추적하면 아래 표와 같다.
| 현재 함수 | i (루프 인덱스) | visited 상태 | set 상태 | 진행 상황 |
| permutation(’’, 0) | 0 | [T, F, F] | [] | visited[0] = true 체크 후 permutation(’1’, 1) 호출 |
| permutation(’1’, 1) | 0 | [T, F, F] | [1] | set에 ‘1’ 추가, |
| visited[0]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | ||||
| 1 | [T, T, F] | [1] | visited[1] = true 체크 후 permutation(’17’, 2) 호출 | |
| permutation(’17’, 2) | 0 | [T, T, F] | [1, 17] | set에 ‘17’ 추가, |
| visited[0]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | ||||
| 1 | [T, T, F] | [1, 17] | visited[1]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | |
| 2 | [T, T, T] | [1, 17] | visited[2] = true 체크 후 permutation(’173’, 3) 호출 | |
| permutation(’173’, 3) | 0 | [T, T, T] | [1, 17, 173] | set에 ‘173’ 추가, |
| visited[0]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | ||||
| 1 | [T, T, T] | [1, 17, 173] | visited[1]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | |
| 2 | [T, T, T] | [1, 17, 173] | visited[2]이 true 즉, 이미 방문한 상태. 루프 종료 후 | |
| permutation(’17’, 2) | 2 | [T, T, F] | [1, 17, 173] | visited[2] = false로 복구. |
| 루프 종료 | ||||
| permutation(’1’, 1) | 1 | [T, F, F] | [1, 17, 173] | visited[1] = false복구 후 다음 인덱스로 |
| permutation(’1’, 1) | 2 | [T, F, T] | [1, 17, 173] | visited[2] = true 체크 후 permutation(’13’, 2) 호출 |
| permutation(’13’, 2) | 0 | [T, F, T] | [1, 17, 173, 13] | set에 ‘13’ 추가. |
| visited[0]이 true 즉, 이미 방문한 상태이므로 다음 인덱스로 넘어감 | ||||
| 1 | [T, T, T] | [1, 17, 173, 13] | visited[1] = true 체크 후 permutation(’137’, 3) 호출 | |
| permutation(’137’, 3) | 0~2 | [T, T, T] | [1, 17, 173, 13, 137] | set에 ‘137’ 추가. |
| 이후 visited[0~2]가 true 이미 방문한 상태이므로 루프 종료 | ||||
| permutation(’13’, 2) | 2 | [T, T, F] | [1, 17, 173, 13, 137] | visited[2] = false복구 후 루프 종료 |
| permutation(’1’, 1) | 2 | [T, F, F] | [1, 17, 173, 13, 137] | visited[1] = false복구 후 루프 종료 |
| permutation(’’, 0) | 0 | [F, F, F] | [1, 17, 173, 13, 137] | visited[0] = false복구 후 다음 인덱스로 |
| permutation(’’, 0) | 1 | [F, T, F] | [1, 17, 173, 13, 137] | visited[1] = true 체크 후 permutation(’7’, 1) 호출 |
| permutation(’7’, 1) | 0 | [T, T, F] | [1, 17, 173, 13, 137, 7] | set에 ‘7’ 추가. |
| 이후 | ||||
| visited[0] = true 체크 후 permutation(’71’, 2) 호출 | ||||
| … 이후 반복 |
이 후 소수 판별 함수의 로직은 다음과 같다.
- number가 1 이하인 경우 제외
- 2부터 시작해서 주어진 number의 제곱근까지 루프문을 돌면서 i로 나누었을 때 나머지가 존재하지 않는다면. (딱 나누어진다면) 소수가 아니라고 판별한다.
💻 코드 (Code)
function isPrime(number) {
if (number <= 1) return false;
for (let i = 2; i<=Math.sqrt(number); i++) {
if (number % i === 0) return false;
}
return true;
}
function solution(numbers) {
var answer = 0;
const set = new Set(); // 만들 수 있는 숫자 조합을 저장할 Set 집합 (중복을 허용 X)
const arr = numbers.split("") // 숫자의 각 자리를 분해하여 배열로 변환
const visited = new Array(arr.length).fill(false);
const permutation = (current, depth) => {
if (depth > 0) set.add(parseInt(current));
if (depth === arr.length) return;
for (let i=0; i < arr.length; i++) {
if (visited[i] === false) {
visited[i] = true;
permutation(current + arr[i], depth + 1);
visited[i] = false;
}
}
}
permutation("", 0);
set.forEach((num) => {
if (isPrime(num)) {
answer++;
}
})
return answer;
}
최적화된 코드
function isPrime(number) {
if (number <= 1) return false;
if (number === 2) return true;
if (number % 2 === 0) return false; // 짝수인 경우 제외
for (let i = 3; i<=Math.sqrt(number); i+=2) {
if (number % i === 0) return false;
}
return true;
}
function solution(numbers) {
var answer = 0;
const set = new Set(); // 만들 수 있는 숫자 조합을 저장할 Set 집합 (중복을 허용 X)
const arr = numbers.split("") // 숫자의 각 자리를 분해하여 배열로 변환
const visited = new Array(arr.length).fill(false);
const permutation = (current, depth) => {
if (depth > 0) set.add(parseInt(current));
if (depth === arr.length) return;
for (let i=0; i < arr.length; i++) {
if (visited[i] === false) {
visited[i] = true;
permutation(current + arr[i], depth + 1);
visited[i] = false;
}
}
}
permutation("", 0);
set.forEach((num) => {
if (isPrime(num)) {
answer++;
}
})
return answer;
}
소수 판별 함수에서 짝수인 경우를 제외하면 소수 판별 속도를 더 빠르게 할 수 있다.
❓ 꼬리 질문 및 복습 (Self-Q&A)
복습
완전 탐색
완전 탐색은 문제의 해를 찾기 위해 가능한 모든 케이스를 빠짐없이 탐색하는 알고리즘으로
100%의 정답률을 보장하지만, 입력 데이터의 크기에 따라 연산량이 무지막지하게 늘어나는 문제가 존재한다. 따라서 대용량 데이터 처리에는 적합하지 않고 주로 탐색 범위가 좁을 때 최적의 선택이다.
완전 탐색의 종류로는
- 단순 Brute Force: 반복문과 조건문만으로 처음부터 끝까지 다 뒤지는 방식
- 비트마스크(Bitmask): 이진수 비트 연산을 이용해 모든 경우의 수를 표현하고 탐색하는 방식
- 재귀 함수를 이용한 순열/조합: DFS 등을 활용해 모든 가능한 순서를 조합하는 방식
- BFS/DFS: 그래프나 트리 형태의 상태 공간을 탐색하는 방식
완전 탐색의 시간 복잡도는 경우의 수에 따라 O(2^n), O(N!)(순열) 등
이번 문제에서는 주어진 종이 조각의 개수(numbers)가 최대 7개로 제한되었기 때문에 완전 탐색을 사용하는 것이 적합한 문제였다.
DFS (완전 탐색)
DFS는 그래프나 트리 자료구조에서 시작 정점으로부터 연결된 한 갈래를 선택해서 최대한 깊숙히 바닥 노드까지 파고들어 탐색한 뒤, 더 이상 갈 곳이 없으면 내려온 길을 되돌아가 다음 갈래를 선택하는 알고리즘이다. 이는 Call Stack이나 LIFO를 이용해 작동한다.
시간 복잡도는 일반적으로 그래프 탐색 시 정점(V)과 간선(E)를 모두 확인하므로 O(V+E)이다. 이번 문제에서는 조각을 뽑는 가짓수와 순열의 가짓수를 고려해 O(N*N!)의 시간 복잡도를 가진다.
DFS에서 주요 관련 개념이 있는데
- 백트래킹: 해를 찾는 도중에 막힐 경우(바닥 노드에 도달하는 등), 이전 상태로 되돌아가서 다른 경로를 다시 시도하는 알고리즘이다.
- 가지치기(Pruning): 백트래킹 과정에서 이 경로는 더 들어가봤자 어차피 해를 구할 수 없다라고 판단되는 갈래를 미리 차단하여 연산량을 줄이는 최적화 기법이다.
그렇다면 이번 문제에서 DFS를 사용하는 이유는
- permutation("") 으로 시작하면서 빈 문자열에서 조각 하나를 고를 때마다 트리의 아래 depth로 내려간다. “1”에서 “17”이 되는 것은 트리의 자식 노드로 이동하는 셈이다.
- DFS는 Call Stack을 이용해서 내가 지금 어떤 조각들을 거쳐서 이 깊이까지 내려왔는지를 기억한다. “173” 이라는 바닥 노드에 도달했을 때 메모리 스택에는 [”1”, “7”, “3”]이라는 경로가 수직으로 쌓여있다.
- 만약 BFS(넓이 우선)으로 구현하려 했다면, ‘1’고르고 ‘7’ 고르고 ‘3’ 고른 뒤에 다시 위로 올라가서 ‘17’, ‘13’을 고르는 식으로 가로로 움직여야 한다. 이는 이전 층의 상태를 모두 큐에 저장하여야 하므로 메모리 낭비가 심하다. DFS는 한 놈을 끝까지 파고들어 숫자를 완성하고 돌아오는 방식이기 때문에 이번 문제와 같이 순열 조립에 최적화된 형태라고 할 수 있다.
그렇다면 이번 문제를 떠나서 앞으로 어떤 문제를 풀이하려 할 때 아 이 때는 DFS가 적합하다! 라고 판단하는 기준은 어떨까?
- 모든 경우의 수를 조합하거나 나열해야하는 상황 (순열, 조합, 부분집합)
- 이전의 선택이 다음의 선택에 영향을 주며 상태가 누적되는 경우
- 이번 문제에서 visited 배열을 사용한 것 처럼 과거 선택에 대한 기록을 남기는 경우
- 가장 먼 곳이나 바닥을 확인해야 결론이 나는 경우
- 경로 탐색 문제에서 ‘미로의 출구까지 갈 수 있는가?’, ‘트리의 리프 노드(끝)까지 도달했을 때 총합이 얼마인가?’ 와 같이 중간 과정보다 끝까지 가봐야 정답 조건을 충족하는 경우
- 제약 조건 N의 크기가 매우 작을 때 (완전 탐색 기반의 경우!)
- 완전 탐색 기반의 DFS는 시간 복잡도가 O(2^N) 이나 O(N!)으로 큰 편이다. 간혹 문제에서 N의 조건이 10 이하, 15이하와 같이 범위가 극단적으로 작은 경우 완전 탐색으로 구현하는 문제라는 힌트라고 볼 수 있다.
- 일반적인 DFS (그래프/트리 탐색)는
- "미로가 주어졌을 때 출구까지 갈 수 있는가?"처럼 정해진 지도(그래프)가 이미 존재할 때 사용한다. 이는 중복 방문을 막는 것이 목적으로 따라서 visited[] 를 true에서 false로 탐색이 완전 끝날 때 까지 바꾸지 않는다.
- 이는 정점만 훑으면 되므로 N이 비교적 크다. ****(N ≥ 100,000)
꼬리질문
Q. DFS를 재귀 함수(Recursion) 대신 다르게 구현하는 방법이 있나요?
재귀 함수는 기본적으로 컴퓨터 내부의 Call Stack을 사용하는 방식이다. 따라서 개발자가 명시적으로 스택을 선언하고 직접 반복문을 돌리면서 데이터를 Push/Pop 하는 방식으로도 동일하게 DFS를 구현할 수 있다.
Q. 이 문제에서 '백트래킹(Backtracking)'과 'DFS'의 차이점은 무엇인가요?
- DFS는 트리나 그래프의 모든 노드를 빠짐없이 방문하는 탐색 알고리즘 자체를 의미한다.
- 백트래킹은 탐색을 진행하다가 조건을 만족하지 않으면 더 깊이 들어가지 않고 이전 상태로 되돌아가 나오는 알고리즘 설계 패러다임이다.
- 이번 풀이에서 모든 숫자를 다 만들기 때문에 가지치기를 하지는 않았지만, 하나의 조각을 쓰고 난 뒤 복구하는 과정 (visited[i] = false)에 백트래킹의 핵심 메커니즘이 녹아들어 있다.
💬 마무리하며
알고리즘 공부를 시작하면서 처음으로 완전 탐색 문제 풀이를 시도해보면서 어려움이 있었던 것 같습니다. 스스로 문제를 해결하려고 고민했을 때 결국 해결책을 찾지 못하고 AI의 도움을 받아 학습하면서 문제를 해결했습니다. 이번 풀이를 통해 DFS 개념에 대해 복습하게 되었고, 여기서는 완전 탐색을 위해 DFS 알고리즘을 활용해보았는데, 이 경험을 통해 앞으로 비슷한 유형에서는 직접 더 빠르게 해결할 수 있으면 좋겠습니다!
파이팅!
'알고리즘 > 프로그래머스' 카테고리의 다른 글
| [프로그래머스] 프로세스 (Lv. 2 JavaScript) (0) | 2026.06.11 |
|---|---|
| [프로그래머스] 타겟 넘버 (Lv. 2 JavaScript) (0) | 2026.06.07 |
| [프로그래머스] 더 맵게 (Lv. 2 JavaScript) (0) | 2026.06.02 |
| [프로그래머스] 가장 큰 수 (Lv. 2 JavaScript) (0) | 2026.06.01 |
| [프로그래머스] 올바른 괄호 (Lv. 2, JavaScript) (0) | 2026.05.29 |
