P vs NP / NP-완전
P와 NP는 결정 문제(예/아니오로 답하는 문제)를 풀거나 검증하는 데 드는 시간으로 문제를 나눈 복잡도 클래스입니다. P는 다항 시간에 풀 수 있는 문제의 집합이고, NP는 답이 주어졌을 때 그 답이 맞는지 다항 시간에 검증할 수 있는 문제의 집합입니다. 풀 수 있으면 검증도 할 수 있으므로 P는 NP에 들어갑니다.
NP-완전(NP-complete)은 NP에 속하면서 NP의 모든 문제가 다항 시간에 환원되는, NP에서 가장 어려운 문제들입니다. 하나라도 다항 시간에 풀면 NP 전체가 풀려 P = NP가 되는데, 이 둘이 같은지는 아직 증명되지 않았습니다. 이 토픽에서는 찾기와 검증의 비용 차이, 환원이 문제들을 잇는 모습, 클래스 사이의 포함 관계를 확인합니다.
핵심 개념
P
입력 크기 n의 다항식 시간 안에 풀 수 있는 결정 문제의 집합입니다. "배열이 정렬되어 있는가", "거리가 k 이하인 경로가 있는가(음수 사이클이 없는 그래프)" 같은 결정 문제가 속합니다.
핵심 개념
NP
답(인증서)이 주어졌을 때 다항 시간에 맞는지 확인할 수 있는 결정 문제의 집합입니다. 답을 찾는 일이 어렵더라도 확인이 쉬우면 NP입니다.
핵심 개념
인증서와 검증
인증서는 "예"라는 답이 맞다는 증거입니다. 부분집합 합에서는 합이 목표가 되는 부분집합 자체가 인증서이고, 더해 보면 검증됩니다.
핵심 개념
다항 시간 환원
A ≤p B는 A의 입력을 다항 시간에 B의 입력으로 바꿔 B의 답이 A의 답이 되게 하는 변환입니다. B가 쉬우면 A도 쉽습니다.
핵심 개념
NP-난해와 NP-완전
NP-난해는 NP의 모든 문제가 환원되는 문제이고, NP에도 속하면 NP-완전입니다. NP-난해 문제는 NP 밖에 있을 수도 있습니다.
핵심 개념
P = NP 문제
P와 NP가 같은 집합인지를 묻는 문제입니다. 증명되지 않은 열린 문제이며 NP-완전 문제 하나가 다항 시간에 풀리면 P = NP가 됩니다.
NP-완전 이론의 배경
1971년 스티븐 쿡(Stephen Cook)은 "The Complexity of Theorem-Proving Procedures"에서 논리식의 만족 가능성 문제(SAT)가 NP의 모든 문제가 환원되는 문제임을 보였습니다. 레오니드 레빈(Leonid Levin)도 독립적으로 비슷한 결과를 얻었습니다. 이듬해 리처드 카프(Richard Karp)는 "Reducibility Among Combinatorial Problems"에서 환원으로 SAT를 포함한 21개의 조합 문제가 NP-완전임을 보였습니다.
P = NP 문제는 클레이 수학연구소(Clay Mathematics Institute)가 2000년에 발표한 밀레니엄 문제 7개 중 하나이고, 해결에는 100만 달러의 상금이 걸려 있습니다. 가렛(Garey)과 존슨(Johnson)의 책 "Computers and Intractability"는 NP-완전 문제를 모아 정리한 대표적인 참고서입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
P와 NP의 차이는?
P는 다항 시간에 풀 수 있는 결정 문제, NP는 답이 주어지면 다항 시간에 검증할 수 있는 결정 문제입니다. P는 NP에 포함됩니다.
면접 질문
NP-완전이란?
NP에 속하면서 NP의 모든 문제가 다항 시간에 환원되는 문제입니다. NP에서 가장 어려운 문제들입니다.
면접 질문
NP-완전 문제 하나를 다항 시간에 풀면?
NP의 모든 문제가 환원을 통해 다항 시간에 풀리므로 P = NP가 됩니다.
면접 질문
NP-완전 문제를 만나면 어떻게 하나요?
정확한 다항 시간 해법을 기대하기 어려우므로 입력이 작으면 완전 탐색, 다항 시간에 풀리는 특수한 경우의 활용, 근사 알고리즘을 고려합니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
Python — 부분집합 합: 찾기와 검증
from collections import Counter def verify(nums, target, cert): # 인증서 검증: 다항 시간 return not (Counter(cert) - Counter(nums)) and sum(cert) == target def find(nums, target): # 답 찾기: 부분집합 2^n개를 확인 n = len(nums) for mask in range(1 << n): sub = [nums[i] for i in range(n) if mask >> i & 1] if sum(sub) == target: return sub, mask + 1 # (부분집합, 확인한 개수) return None, 1 << n nums = [3, 34, 4, 12, 5, 2] print(find(nums, 9)) # ([4, 5], 21) print(verify(nums, 9, [4, 5]), verify(nums, 9, [3, 34])) # True False print(find(nums, 13)) # (None, 64)
NP의 정의를 코드로 옮긴 예입니다. NP는 답(인증서)이 주어졌을 때 그 답이 맞는지 다항 시간에 검증할 수 있는 결정 문제의 집합입니다. verify는 인증서가 nums의 원소로만 이루어졌는지 확인하고 합을 목표와 비교하는 것이 전부라 입력 크기의 다항식 시간이면 됩니다. find는 답을 직접 찾는 쪽으로, 원소가 n개이면 부분집합 2^n개를 하나씩 확인합니다(부분집합 하나를 만드는 O(n) 비용은 생략한 단순화입니다). 시각화의 순서로는 21번째에서 [4, 5]를 찾지만, 합이 13인 부분집합은 없어서 64개를 모두 확인하고서야 없다는 결론이 납니다. 여기서의 find는 가장 단순한 완전 탐색이며, 부분집합 합이 다항 시간에 풀리는지는 알려져 있지 않습니다. 시각화의 찾기와 검증 탭이 보여 주는 과정과 같습니다.
Python — 환원: 클리크를 정점 덮개로 바꾸기
from itertools import combinations V = {0, 1, 2, 3, 4} E = {(0, 1), (0, 2), (1, 2), (2, 3), (3, 4)} def is_clique(E, C): return all((a, b) in E or (b, a) in E for a, b in combinations(sorted(C), 2)) def complement(V, E): # 입력 변환: 보완 그래프를 만든다 return {(a, b) for a, b in combinations(sorted(V), 2) if (a, b) not in E and (b, a) not in E} def is_vertex_cover(E, S): return all(a in S or b in S for a, b in E) Ec = complement(V, E) C = {0, 1, 2} # 크기 3인 클리크 print(is_clique(E, C), sorted(Ec), is_vertex_cover(Ec, V - C), len(V) - len(C)) # True [(0, 3), (0, 4), (1, 3), (1, 4), (2, 4)] True 2 print(is_clique(E, {2, 3, 4}), is_vertex_cover(Ec, V - {2, 3, 4})) # False False
환원 A ≤p B는 A의 입력을 다항 시간에 B의 입력으로 바꾸어 B의 답이 곧 A의 답이 되게 하는 변환입니다. 여기서는 클리크를 정점 덮개로 바꿉니다. 그래프 G에서 정점 집합 C가 클리크라는 것은 보완 그래프(G에 없는 간선을 모은 그래프)에서 V - C가 정점 덮개라는 것과 같습니다. 5개 정점 그래프에서 C = {0, 1, 2}는 클리크이고 V - C = {3, 4}는 보완 그래프의 모든 간선을 덮으며, 크기도 |V| - |C| = 2입니다. {2, 3, 4}는 클리크가 아니고 V - C도 덮개가 아닙니다. 보완 그래프를 만드는 일은 정점 쌍을 한 번씩 보는 다항 시간이라 이 변환은 다항 시간입니다. 어느 한쪽이 다항 시간에 풀리면 다른 쪽도 풀리므로, 한쪽이 NP-완전이면 다른 쪽이 NP에 속하는 한 같이 NP-완전이 됩니다. 시각화의 환원 탭에서 클리크 다음에 정점 덮개가 나오는 화살표가 이 변환입니다.
Java — 부분집합 합 찾기와 검증
static boolean verify(int[] nums, int target, int[] cert) { List<Integer> pool = new ArrayList<>(); for (int x : nums) pool.add(x); int sum = 0; for (int c : cert) { if (!pool.remove((Integer) c)) return false; // 집합에 없는 원소(또는 중복 사용) sum += c; } return sum == target; } static int[] find(int[] nums, int target, int[] checked) { int n = nums.length; for (int mask = 0; mask < (1 << n); mask++) { int sum = 0; for (int i = 0; i < n; i++) if ((mask >> i & 1) == 1) sum += nums[i]; checked[0]++; if (sum == target) return pick(nums, mask); } return null; } static int[] pick(int[] nums, int mask) { // 켜진 비트의 원소만 모은다 return IntStream.range(0, nums.length).filter(i -> (mask >> i & 1) == 1).map(i -> nums[i]).toArray(); } // nums = {3, 34, 4, 12, 5, 2}: find(target 9) → [4, 5] (확인 21번), verify(nums, 9, {4, 5}) → true // find(target 13) → null (확인 64번)
같은 구조를 자바로 쓴 것입니다. 비트마스크의 각 비트가 원소를 넣을지 말지를 뜻하므로 1 << n개의 부분집합을 차례로 만들 수 있습니다. pick은 켜진 비트에 해당하는 원소를 배열로 모으는 보조 함수입니다. 검증 verify는 인증서 원소를 풀에서 하나씩 빼며 더해 비교하는 반복문이고, 찾기 find는 그 앞에 부분집합 개수만큼의 바깥 반복이 있습니다. n이 커질수록 find의 비용은 2^n으로 빠르게 늘지만 verify는 인증서 크기에 비례합니다. 이 차이가 NP 문제의 특징이며, 실무에서 NP-완전 문제를 만나면 근사 알고리즘, 휴리스틱, 입력이 작을 때의 완전 탐색 같은 방법을 고려합니다.
Q1
NP(Nondeterministic Polynomial) 문제의 정의는?
Q2
NP-완전(NP-Complete) 문제란?
Q3
어떤 NP-완전 문제 하나를 다항 시간에 풀 수 있음이 증명된다면 의미하는 것은?