광고
~ / posts / developer
smith@lab:~/posts$ cat developer/backtracking-input-size-pruning-timeout-checklist.md

백트래킹 시간 초과 판단법, n 크기별 경우의 수 표와 가지치기 점검

완전 탐색 코드를 짜기 전에 "이게 시간 안에 끝날까"를 먼저 계산하지 않으면, 예제는 통과하고 큰 입력에서 시간 초과가 나요. 결론부터 말하면 파이썬 기준으로 순열은 n=9, 부분집합은 n=20 안팎이 1초 안에 끝나는 한계였어요. 그 위라면 가지치기로 버틸 수 있는지(가지가 일찍 잘리는 구조인지)를 따지고, 아니면 비트마스크 DP나 반으로 나누어 탐색하기로 바꾸는 게 맞아요.

이 글은 동작 원리 대신 고를 때 필요한 숫자만 다뤄요. 크기별 경우의 수, 실제 처리 시간, 가지치기를 어디에 어떻게 넣어야 효과가 나는지, 시간 초과·오답이 났을 때 확인 순서를 Apple M1·파이썬 3.14.8로 직접 재서 정리했어요(2026-10-27). 절대 시간은 기기마다 다르니 배수로 읽어 주세요.

광고

백트래킹으로 될까, n 크기별 경우의 수 판단표

완전 탐색이 시간 안에 끝나는지는 잎(완성된 후보) 수 × 잎 하나 처리 비용으로 먼저 어림해요. 경우의 수는 파이썬 math 모듈 문서의 math.perm, math.comb 로 바로 계산할 수 있어요.

문제 모양잎 개수1초 안(파이썬)넘으면
순열 n개 전부n!n ≤ 9 (36만 2,880)비트마스크 DP, 가지치기
부분집합2ⁿn ≤ 20 (104만 8,576)반으로 나누기, 값 범위 DP
조합 n개 중 r개C(n, r)C(n, r) ≤ 100만 안팎r 이 크면 여집합으로
중복 순열nʳnʳ ≤ 100만 안팎DP
순서 있는 경로(외판원)(n−1)!n ≤ 10Held–Karp DP, O(2ⁿ·n²)

순열·부분집합 칸은 아래에서 직접 잰 값이고, 나머지는 "잎 100만 개 안팎"을 같은 기준으로 옮긴 어림이에요. 컴파일 언어는 이보다 여유가 있지만 언어·채점기마다 달라서, 경계 근처에서는 직접 재 보는 게 맞아요.

계산할 때 자주 빠뜨리는 게 중간 노드예요. 순열 트리는 잎이 n! 개지만 중간 노드까지 합치면 약 e·n! ≈ 2.7배예요. n=9에서 잎은 36만 2,880개인데 실제 재귀 호출은 98만 6,410번이었어요. 게다가 노드마다 for 루프가 n칸을 돌기 때문에, 순열은 실제 일이 n!·n 에 가까워요.

import math
print(math.factorial(10), math.comb(30, 15), 2 ** 25)
# 3628800 155117520 33554432

C(30, 15)가 1억 5,511만이라는 점은 자주 놓쳐요. "30개 중 절반 고르기"는 2³⁰의 15%나 되는 크기라, 조합이라고 작다고 보면 안 돼요.

출력 전체가 필요한지, 잎 개수가 100만 이하인지, 가지가 일찍 잘리는 구조인지, 상태를 집합 하나로 줄일 수 있는지를 차례로 물어 그대로 백트래킹·가지치기 백트래킹·비트마스크 DP·반으로 나누기 중 하나로 가는 판단 순서도
위에서부터 물어서 처음 '예'가 나온 칸의 방법을 써요

직접 재 본 처리 시간, 재귀와 itertools·비트마스크

같은 일을 세 방식으로 만들고 세 번 중 가장 빠른 값을 적었어요(부분집합 비트마스크와 순열 n=11은 한 번). 결과를 저장하지 않고 개수만 셌어요.

순열·부분집합 전부 만들기 처리 시간 (파이썬 3.14.8, Apple M1)
순열 n=9 재귀순열 n=9 재귀 · 0.77초0.77초순열 n=9 itertools순열 n=9 itertools · 0.1초0.1초순열 n=10 재귀순열 n=10 재귀 · 10.9초10.9초순열 n=10 itertools순열 n=10 itertools · 0.9초0.9초부분집합 n=20 재귀부분집합 n=20 재귀 · 0.66초0.66초부분집합 n=20 비트마스크부분집합 n=20 비트마스크 · 4.4초4.4초

비트마스크는 마스크마다 원소 목록을 새로 만든 경우예요. itertools 순열 n=11은 10.7초였어요

단위: 초 · 자료: 직접 측정 (2026-10-27)
표로 보기
구분시간
순열 n=9 재귀0.77
순열 n=9 itertools0.1
순열 n=10 재귀10.9
순열 n=10 itertools0.9
부분집합 n=20 재귀0.66
부분집합 n=20 비트마스크4.4

여기서 읽을 점은 셋이에요.

  1. 가지치기가 없으면 itertools 가 재귀보다 8~12배 빨라요. itertools 공식 문서의 permutations 는 C로 구현돼 있어서 파이썬 함수 호출 비용이 없어요. 조건 없이 전부 훑을 거라면 직접 짜지 말고 표준 라이브러리를 써요.
  2. n이 하나 늘 때 순열은 약 10배, 부분집합은 2배 늘어요. n=9에서 0.77초면 n=10은 10초를 넘어요. "조금 느린데 최적화하면 되겠지"가 통하지 않는 영역이에요.
  3. 비트마스크가 늘 빠른 건 아니에요. 마스크마다 [nums[i] for i in range(n) if mask >> i & 1] 로 목록을 다시 만들면 부분집합 하나에 O(n)이 들어서, 합을 한 칸씩 들고 내려가는 재귀(O(1))보다 6.7배 느렸어요. 비트마스크는 상태를 정수 하나로 저장하거나 DP 배열 인덱스로 쓸 때 이점이 커요. 비트 모양이 헷갈리면 진법 변환기로 마스크 값을 2진수로 풀어 확인해 보세요.

가지치기 효과, 목표값에 따라 뒤바뀌어요

가지치기는 "넣으면 무조건 빨라진다"가 아니에요. 어떤 조건으로 자르느냐가 입력 모양에 맞아야 효과가 나요. 1~100 사이 무작위 정수 22개에서 합이 목표값이 되는 부분집합을 모두 세는 문제로, 목표를 전체 합의 1/4·1/2·3/4로 바꿔 가며 다섯 번씩 평균을 냈어요.

부분집합 합 문제 원소 22개의 방문 노드 수 (가지치기 방식별)
가지치기 없음초과만 검사초과 + 남은 합 검사
0100200300400500목표 = 합의 1/4 · 가지치기 없음 419만 개목표 = 합의 1/4 · 초과만 검사 10만 개목표 = 합의 1/4 · 초과 + 남은 합 검사 9.1만 개목표 = 합의 1/4목표 = 합의 1/2 · 가지치기 없음 419만 개목표 = 합의 1/2 · 초과만 검사 210만 개목표 = 합의 1/2 · 초과 + 남은 합 검사 112만 개목표 = 합의 1/2목표 = 합의 3/4 · 가지치기 없음 419만 개목표 = 합의 3/4 · 초과만 검사 409만 개목표 = 합의 3/4 · 초과 + 남은 합 검사 32.9만 개목표 = 합의 3/4

세 방식 모두 찾은 해의 개수는 같았어요

단위: 만 개 · 자료: 직접 측정, 무작위 입력 5개 평균 (2026-10-27)
표로 보기
구분가지치기 없음초과만 검사초과 + 남은 합 검사
목표 = 합의 1/4419109.1
목표 = 합의 1/2419210112
목표 = 합의 3/441940932.9

목표가 작으면 "초과" 조건이 금방 걸려서 노드가 98% 줄어요(실행 3.2초 → 0.11초). 그런데 목표가 전체 합의 3/4이면 넘칠 일이 거의 없어서 2.4%밖에 안 줄고, 검사 비용 때문에 오히려 3.30초로 조금 더 느려졌어요. 이때 일을 한 건 반대쪽 조건이에요. "남은 걸 다 더해도 모자람"을 넣자 32만 9천 개(92% 감소, 0.43초)로 떨어졌어요.

정리하면 가지치기는 양쪽 경계를 다 넣어야 입력이 어느 쪽으로 치우쳐도 버텨요. 위키백과 분기 한정법(Branch and bound) 항목이 설명하는 상한·하한 비교가 같은 생각이고, 최적화 문제라면 "지금까지 찾은 최선보다 나아질 수 없으면 중단"이 세 번째 경계가 돼요.

정렬 후 break 는 노드보다 루프를 줄여요

오름차순 정렬을 해 두면 s + a[i] > T 인 순간 뒤쪽 원소도 전부 넘치니 continue 대신 break 로 루프를 끝낼 수 있어요. 실험에서 방문 노드 수는 같았고, 목표 1/4일 때 시간만 0.107초에서 0.095초로 약 11% 줄었어요. 노드를 줄이는 건 경계 조건이고, break 는 노드 안의 헛도는 루프를 줄이는 장치예요.

중복 원소는 만들기 전에 건너뛰어요

[1,1,1,1,2,2,2,2] 의 서로 다른 순열은 70개뿐인데 itertools.permutations 는 위치로 구분해서 4만 320개를 만들어요. set() 으로 거르면 0.0117초, 정렬한 뒤 "같은 값의 앞 원소를 안 썼으면 건너뛰기" 한 줄을 넣은 재귀는 노드 251개에 0.0004초였어요.

def unique_perms(nums):
    nums = sorted(nums)
    used, path, out = [False] * len(nums), [], []
    def go():
        if len(path) == len(nums):
            out.append(tuple(path)); return
        for i, v in enumerate(nums):
            if used[i] or (i and v == nums[i - 1] and not used[i - 1]):
                continue
            used[i] = True; path.append(v)
            go()
            path.pop(); used[i] = False
    go(); return out

N-Queen 으로 본 구현별 차이, 비트마스크와 대칭

가지치기를 넣은 뒤에도 같은 트리를 얼마나 싸게 도느냐에서 몇 배가 갈려요. 같은 가지치기 규칙으로 세 가지 구현을 n=10~13에서 한 번씩 쟀어요. 해의 개수는 모두 OEIS A000170 값(724, 2,680, 14,200, 73,712)과 같았어요.

N-Queen 해 전부 세기 처리 시간 (파이썬 3.14.8, Apple M1)
불리언 배열 3개비트마스크비트마스크 + 좌우 대칭
024681012n=10n=11n=12n=13n=10 · 불리언 배열 3개 0.053초n=11 · 불리언 배열 3개 0.329초n=12 · 불리언 배열 3개 1.9초n=13 · 불리언 배열 3개 10.5초10.5n=10 · 비트마스크 0.039초n=11 · 비트마스크 0.185초n=12 · 비트마스크 0.96초n=13 · 비트마스크 5.3초5.3n=10 · 비트마스크 + 좌우 대칭 0.02초n=11 · 비트마스크 + 좌우 대칭 0.105초n=12 · 비트마스크 + 좌우 대칭 0.5초n=13 · 비트마스크 + 좌우 대칭 2.9초2.9

n=13 배열 방식의 방문 노드는 467만 4,890개였어요

단위: 초 · 자료: 직접 측정, 한 번씩 (2026-10-27)
표로 보기
구분불리언 배열 3개비트마스크비트마스크 + 좌우 대칭
n=100.0530.0390.02
n=110.3290.1850.105
n=121.90.960.5
n=1310.55.32.9

n이 하나 늘 때마다 시간이 약 5~6배 늘어요. 그래서 구현을 4배 빠르게 다듬어도 버틸 수 있는 n은 하나 늘어날 뿐이에요. 구현 최적화는 경계에 걸린 n을 한 칸 넘기는 용도이고, 두 칸 이상 모자라면 방법을 바꿔야 해요.

백트래킹 대신 무엇을 쓰나, 비트마스크 DP와 반으로 나누기

입력이 표의 한계를 크게 넘으면 가지치기로는 안 돼요. 대표적인 두 가지 전환이 있어요.

외판원 문제(모든 도시를 한 번씩 도는 최단 경로): 16개 도시를 순열로 풀면 출발점을 고정해도 15! = 1조 3,077억 개예요. 위키백과 Held–Karp 알고리즘 항목대로 "방문한 도시 집합 + 마지막 도시"를 상태로 두는 DP는 Θ(2ⁿ·n²)이라 2¹⁶ × 16² = 1,677만 7,216 단위예요. 경로 순서가 아니라 집합만 같으면 이후가 같다는 점이 보이면 DP로 바꿀 수 있어요. 대신 메모리가 2ⁿ·n 칸 필요해서 n=20이면 2,097만 칸이에요.

부분집합 합 n=40: 2⁴⁰ = 1조 995억이라 그대로는 불가능해요. 위키백과 부분집합 합 문제 항목의 Horowitz–Sahni 방식처럼 원소를 20개씩 나눠 각각 2²⁰ = 104만 8,576개의 합을 만들고, 한쪽을 정렬한 뒤 이분 탐색으로 짝을 찾아요. 파이썬으로 답이 없는 최악의 경우 4.2초가 걸렸어요(원소 10⁹ 이하 무작위). 시간 제한이 빡빡하면 C++ 이나 정렬 두 개를 투 포인터로 맞추는 방식으로 줄여요. 이 방식은 2²⁰개 합을 다 들고 있어야 해서 메모리가 시간보다 먼저 문제가 될 수 있어요.

상황신호바꿀 방법비용
순서 경로, n 13~20지나온 순서 말고 집합만 중요비트마스크 DP시간 2ⁿ·n², 메모리 2ⁿ·n
부분집합, n 30~45합·개수만 묻고 답이 하나면 됨반으로 나누기시간 2^(n/2)·n, 메모리 2^(n/2)
부분집합, 값이 작음목표값·원소 값이 10⁵ 이하값 범위 DPO(n × 목표값)
최적화, 제약이 많음좋은 해를 빨리 찾으면 상한이 좁아짐분기 한정 + 좋은 초깃값입력마다 다름
광고
광고

운영에서 자주 나는 문제와 확인 순서, 체크리스트

코딩 테스트뿐 아니라 테스트 케이스 조합 생성, 설정값 조합 탐색, 배정·스케줄 검증 같은 서비스 코드에도 완전 탐색이 들어가요. 문제가 났을 때는 아래 순서로 보면 대부분 위쪽에서 걸려요.

최악 입력의 트리 크기 계산, 가지치기 위치와 양쪽 경계, 중복 원소 건너뛰기, 결과 저장 방식과 메모리, 재귀 깊이 한도를 차례로 확인하라는 다섯 단계 점검표
1번부터 차례로 보고, 고친 뒤엔 작은 입력에서 가지치기 없는 버전과 답을 대조해요
  1. 최악 입력의 트리 크기: 예제가 아니라 제약 최댓값으로 잎 개수를 계산해요. 위 표를 넘으면 코드를 다듬기 전에 방법부터 바꿔요.
  2. 가지치기 위치와 경계: 재귀 첫 줄에서 return 하지 말고 자식을 부르기 전 루프 안에서 걸러요. 한쪽 경계만 넣었다면 반대쪽 경계도 넣어요.
  3. 중복 원소: 입력에 같은 값이 있는데 결과가 "서로 다른" 것만 필요하면 정렬 + 건너뛰기를 넣어요. 결과를 set 으로 거르고 있으면 그 자리가 병목이에요.
  4. 결과 저장과 메모리: list(permutations(range(9))) 는 36만 개에 약 46.5MB를 썼어요(tracemalloc 측정). 개당 약 128바이트라 n=10이면 약 465MB, n=11이면 5GB를 넘어요(같은 비율로 계산). 다 모을 필요가 없으면 제너레이터(yield)로 하나씩 넘겨요.
  5. 재귀 깊이: 파이썬 기본 한도는 sys.getrecursionlimit() 으로 1,000이에요. 백트래킹은 깊이가 보통 n 이라 문제가 적지만, 문자열을 앞에서부터 한 글자씩 잘라 사전 단어로 나누는 탐색처럼 깊이가 입력 길이와 같은 재귀에 길이 2,000짜리 문자열이 들어오면 걸려요. sys 모듈 공식 문서는 한도를 너무 높이면 인터프리터가 죽을 수 있다고 경고하니, 깊이가 큰 경우는 반복문과 명시적 스택으로 바꾸는 편이 안전해요.

고친 뒤엔 n이 8 이하인 무작위 입력 수백 개로 가지치기 없는 버전과 답을 대조해요. 가지치기 버그는 "해를 조금 덜 찾는" 모양으로 나와서 예외가 없거든요. 같은 대조 방식은 그리디 반례 찾기 체크리스트에도 정리해 뒀어요.

서비스 코드라면 예산을 걸어요

입력 크기를 서비스가 통제하지 못하면 최악의 경우 요청 하나가 몇 분씩 CPU를 잡아요. 방문 노드 수에 상한(예: 100만)을 두고 넘으면 "부분 결과 + 탐색 중단" 상태를 돌려주게 만들면, 타임아웃보다 원인 파악이 쉬워요.

도입 전에 따질 것

탐색 순서 자체를 고르는 기준은 BFS DFS 판단표에 따로 정리해 뒀어요.

광고
광고

자주 묻는 질문

n이 몇이면 백트래킹으로 풀어도 되나요?

파이썬 1초 기준으로 순열은 n ≤ 9, 부분집합은 n ≤ 20이 직접 잰 한계였어요. 잎 개수가 100만 안팎을 넘으면 가지치기가 일찍 잘리는 구조인지 따져 보고, 아니면 비트마스크 DP나 반으로 나누기로 바꿔요.

itertools 를 쓰면 되는데 백트래킹을 직접 짜야 하나요?

조건 없이 전부 훑는다면 itertools 가 8~12배 빨라요. 다만 itertools 는 완성된 결과만 내기 때문에 중간에 가지를 칠 수 없어요. 앞부분만 보고 버릴 수 있는 조건이 있으면 직접 짠 재귀가 노드를 수십 배 줄여서 결국 더 빨라요.

가지치기를 넣었는데 오히려 느려졌어요.

조건이 거의 걸리지 않는 입력이면 검사 비용만 더해져요. 부분집합 합에서 목표가 전체 합의 3/4일 때 "초과" 검사만 넣으면 노드가 2.4%밖에 안 줄고 시간은 조금 늘었어요. 반대 방향 경계("남은 걸 다 더해도 모자람")를 같이 넣으면 92%가 줄었어요.

비트마스크가 재귀보다 항상 빠른가요?

아니에요. 마스크마다 원소 목록을 다시 만들면 부분집합 하나에 O(n)이 들어 재귀보다 6.7배 느렸어요. 비트마스크는 N-Queen 의 놓을 수 있는 열 계산이나 DP 상태 저장처럼 정수 연산 한 번으로 집합을 다룰 때 빨라져요.

RecursionError 가 나면 한도를 올리면 되나요?

깊이가 수천 정도면 sys.setrecursionlimit 으로 버틸 수 있지만, 공식 문서는 너무 높이면 인터프리터가 죽을 수 있다고 경고해요. 백트래킹에서 깊이가 그만큼 깊다면 트리 크기가 이미 감당할 수 없는 경우가 많으니, 방법부터 다시 보는 게 먼저예요.

모든 조합을 리스트에 담았더니 메모리가 부족해요.

순열 9개 전부를 리스트로 담으면 약 46.5MB였고, 한 개 늘 때마다 약 10배씩 늘어요. 하나씩 처리하고 버리도록 제너레이터로 바꾸거나, 정말 필요한 건 개수·최선값뿐인지 다시 확인해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 백트래킹 원리 — 순열·조합·부분집합 완전 탐색과 가지치기에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#백트래킹 시간 초과#완전 탐색 입력 크기#가지치기 효과#부분집합 합 백트래킹#N-Queen 비트마스크#중복 순열 제거#파이썬 재귀 한도#코딩테스트 완전 탐색
← 이전 글wordexp 쓰면 안 되는 이유, 물결표 경로 안전하게 펼치는 법다음 글 →임베딩 모델 고르기, API·오픈소스 비용과 차원별 저장 용량 비교
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →