쇼핑몰 상품에 옵션이 붙어 있다. 사이즈(S, M)와 색상(검정, 흰색)이면 고를 수 있는 조합이 4개, 사이즈 5 × 색상 8 × 소재 4면 160개다. 이 조합을 화면에 모두 깔아 주려고 하나씩 만들어 담는 코드를 짰다. 같은 코드로 "필터 체크박스 30개를 각각 켜거나 끈 모든 경우"를 만들면 몇 개일까? 답은 약 10억 개이고, 묶음을 하나씩 만들어 담는 코드는 메모리가 먼저 모자라 끝나지 못한다. 옵션 선택지는 5 + 8 + 4 = 17개, 체크박스는 30개로 둘 다 작은 수인데, 경우의 수는 왜 이렇게 차이 날까?
이 Unit은 그 수를 세는 법, 곧 경우의 수(counting)를 다룬다. 모든 경우를 하나씩 만드는 코드는 흔히 "하나 고르고 → 다음 자리로 들어가고 → 돌아오면 고른 것을 되돌리는" 모양으로 짠다. 이런 짜임을 백트래킹(backtracking)이라고 부른다(알고리즘으로는 『한입 알고리즘』 5장에서 다룬다). 이름은 몰라도 된다. 공식을 외우지 않고, 이 코드가 갈래를 몇 개 만드는지 한 줄씩 따라가며 센다. 그러면 "n이 20을 넘으면 위험하다" 같은 실무 감각이 어디서 나오는지 보인다.
곱의 법칙: 고르는 일이 이어지면 곱한다
윗옷이 3벌(흰, 검정, 파랑), 바지가 2벌(청, 면)이다. 윗옷을 하나 고른 다음 바지를 하나 고르면, 입는 방법은 몇 가지일까? 표로 그려 보면 바로 보인다.
윗옷 \ 바지
청
면
흰
흰 + 청
흰 + 면
검정
검정 + 청
검정 + 면
파랑
파랑 + 청
파랑 + 면
윗옷 하나마다 바지 2가지가 붙으니 행 3개 × 열 2개, 6칸이다. 이렇게 단계별 가짓수를 곱해 세는 규칙을 곱의 법칙(rule of product)이라고 한다. 고르는 일이 차례로 이어지면(윗옷을 고르고, 그다음 바지를 고르고) 각 단계의 가짓수를 곱한다. 신발 2켤레를 더하면 6칸 하나하나에 신발 2가지가 또 붙어 3 × 2 × 2 = 12가지다.
더하기가 맞는 경우도 있다. "윗옷 3벌 중 하나 또는 바지 2벌 중 하나, 아무거나 하나만 고른다"면 3 + 2 = 5가지다. 구별하는 법은 간단하다. 둘 다 고르면(그리고) 곱하고, 둘 중 하나만 고르면(또는) 더한다.
같은 규칙이 비밀번호에도 쓰인다. 0~9 숫자 4자리 PIN은 자리마다 10가지라 10 × 10 × 10 × 10 = 10⁴ = 10,000가지다. 10⁴처럼 같은 수를 여러 번 곱한 것을 거듭제곱(power)이라 하고, 위에 작게 쓴 4는 몇 번 곱했는지를 나타내는 지수(exponent)다.
줄 세우기: 순열과 팩토리얼
A, B, C 세 명을 한 줄로 세운다. 첫 자리에는 셋 중 누구나 올 수 있다. 둘째 자리에는 첫 자리에 선 사람을 빼고 두 명이, 마지막 자리에는 남은 한 명이 온다. 곱의 법칙으로 3 × 2 × 1 = 6가지다. 고를 때마다 후보가 하나씩 준다는 점이 옷 고르기와 다르다.
예측먼저 답해 보세요
A, B, C 세 명을 한 줄로 세우는 모든 순서를 백트래킹으로 만든다(아직 안 세운 사람 중 하나를 다음 자리에 세우고, 들어갔다가, 돌아오면 뺀다). 끝까지 실행하면 줄은 몇 가지 나올까?
선택지와 해설 보기
실험 세 명을 줄 세우는 모든 순서
실험세 명을 줄 세우는 모든 순서
아직 세우지 않은 사람 중 하나를 다음 자리에 세우고(push), 남은 자리를 채우러 들어갔다가, 돌아오면 빼냅니다(pop). 완성된 줄은 result에 쌓입니다.
A, B, C 입력을 끝까지 실행하면 ABC ACB BAC BCA CAB CBA, 6가지가 찍힌다. 코드는 첫 자리에 A를 세운 채로 둘째 자리에 후보 B, C를 차례로 세워 보고, 돌아와서 A를 빼고 B를 세운다. push는 모두 15번 일어난다. 첫 자리 3번, 둘째 자리 3 × 2 = 6번, 셋째 자리 3 × 2 × 1 = 6번이다. A, B 입력은 AB BA, 2 × 1 = 2가지다.
이렇게 순서를 따져 늘어놓는 것을 순열(permutation)이라 하고, n부터 1까지 모두 곱한 수를 팩토리얼(factorial)이라 부르며 n!로 적는다.
n!=n×(n−1)×⋯×2×1
n부터 하나씩 줄여 가며 1까지 곱하라는 뜻이다. 가운데 점 세 개는 "그 사이 수도 다 곱한다"는 표시다. 3! = 3 × 2 × 1 = 6, 4! = 4 × 3! = 24다. 한 명이 늘 때마다 앞 답에 그 사람 수를 곱하므로 빠르게 커진다.
사람 수 n
1
2
3
4
5
6
8
10
줄 세우는 법 n!
1
2
6
24
120
720
40,320
3,628,800
모두가 아니라 몇 명만 세울 수도 있다. 5명 중 1등·2등·3등을 정하면 자리가 셋뿐이라 5 × 4 × 3 = 60가지다.
5P3=5×4×3=60
"5명에서 3명을 골라 순서대로 세우는 수"라고 읽는다. P는 순열의 영어 첫 글자다. 5에서 시작해 하나씩 줄이며 3개만 곱한다.
팀 뽑기: 조합은 겹치는 순서로 나눈다
이번에는 5명(A~E) 중 3명으로 팀을 만든다. 팀에는 순서가 없다. A, B, C를 뽑든 C, A, B를 뽑든 같은 팀이다.
앞 절의 1등·2등·3등 60가지에서 출발해 보자. 그 60가지 안에서 팀 {A, B, C}는 ABC, ACB, BAC, BCA, CAB, CBA로 여섯 번 나온다. 세 명을 줄 세우는 순서가 3! = 6가지라서다. 다른 팀도 모두 정확히 6번씩 나온다. 그러니 팀 수는 60 ÷ 6 = 10이다.
5C3=3!5P3=3×2×15×4×3=10
"5명에서 3명을 고르는 조합"이라고 읽는다. 분자는 순서까지 따져 뽑은 수(60)이고, 분모는 한 팀이 몇 번씩 겹쳐 세어졌는지(6)다. 이렇게 순서를 따지지 않고 고르는 것을 조합(combination)이라고 한다.
무엇을 세나
순서
5명 중 3명
식
1·2·3등 정하기
따진다(순열)
60
5 × 4 × 3
팀 만들기
따지지 않는다(조합)
10
60 ÷ 3!
셋 다 줄 세우기
따진다
6
3!
조합은 실무에서 자주 나온다. 상품 6개 중 2개를 골라 비교하는 화면이라면 짝은 6 × 5 ÷ 2 = 15가지다. 모든 쌍을 비교하는 이중 루프가 n(n − 1) ÷ 2번 도는 것도 같은 계산이다.
부분집합: 원소마다 두 갈래, 2ⁿ
필터 체크박스가 a, b, c 세 개다. 각각 켜거나 끌 수 있으면 켜진 것들의 묶음은 몇 가지일까? 원소를 골라 만든 묶음을 부분집합(subset)이라고 한다. 아무것도 안 켠 빈 묶음 {}, 전부 켠 {a, b, c}도 센다.
체크박스 하나마다 "켠다/끈다" 두 가지이고, 세 개를 차례로 정하니 곱의 법칙으로 2 × 2 × 2 = 2³ = 8가지다. 개수를 정하지 않고 고른다는 점이 앞 절의 팀 뽑기와 다르다.
예측먼저 답해 보세요
원소 a, b, c의 부분집합을 모두 만드는 백트래킹 함수 pick(i)가 있다. i번 원소를 넣고 pick(i + 1), 빼고 다시 pick(i + 1)을 부르고, i가 원소 수에 닿으면 묶음 하나를 적는다. pick(0)부터 끝까지 실행하면 pick은 모두 몇 번 불릴까? (첫 호출도 센다. 층마다 호출이 두 배가 된다)
선택지와 해설 보기
실험 원소마다 넣는다·뺀다 두 갈래
실험원소마다 넣는다·뺀다 두 갈래
pick(i)는 i번 원소를 넣는 갈래로 먼저 들어갔다가, 돌아오면 빼는 갈래로 들어갑니다. 호출될 때마다 order에 i를 적어 호출 수를 셉니다.
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번, 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번, 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번, 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 0번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 1번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 1번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 2번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 2번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 3번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 3번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 4번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 4번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 4번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 5번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 5번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 5번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 6번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 6번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 7번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 7번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 7번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 8번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 8번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 8번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 9번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 9번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 10번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 10번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 11번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 11번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 11번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 12번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 12번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 12번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 13번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 13번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 14번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 14번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 14번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 15번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 15번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 15번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 15번), 맨 아래 줄 8칸이 부분집합
시작
pick(0)
1번
a 정함
a·
2번
b 정함
b·b·
4번
c · 끝
c·c·c·c·
8번
지금 들어가 있는 갈래끝난 갈래
한 줄 = 그 깊이의 pick 호출. 글자 = 넣음, · = 뺌. 호출 1 + 2 + 4 + 8 = 15번 (지금까지 15번), 맨 아래 줄 8칸이 부분집합
1/44단계.
2/44단계.
3/44단계.
4/44단계.
5/44단계: a를 넣을지 뺄지 정할 차례다.
6/44단계: 넣는 갈래: a를 넣고 다음 원소로 들어간다.
7/44단계: b를 넣을지 뺄지 정할 차례다.
8/44단계: 넣는 갈래: b를 넣고 다음 원소로 들어간다.
9/44단계: c를 넣을지 뺄지 정할 차례다.
10/44단계: 넣는 갈래: c를 넣고 다음 원소로 들어간다.
11/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
12/44단계: {a,b,c} 하나를 적었다.
13/44단계: 빼는 갈래: c를 빼고 다음 원소로 들어간다.
14/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
15/44단계: {a,b} 하나를 적었다.
16/44단계: 빼는 갈래: b를 빼고 다음 원소로 들어간다.
17/44단계: c를 넣을지 뺄지 정할 차례다.
18/44단계: 넣는 갈래: c를 넣고 다음 원소로 들어간다.
19/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
20/44단계: {a,c} 하나를 적었다.
21/44단계: 빼는 갈래: c를 빼고 다음 원소로 들어간다.
22/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
23/44단계: {a} 하나를 적었다.
24/44단계: 빼는 갈래: a를 빼고 다음 원소로 들어간다.
25/44단계: b를 넣을지 뺄지 정할 차례다.
26/44단계: 넣는 갈래: b를 넣고 다음 원소로 들어간다.
27/44단계: c를 넣을지 뺄지 정할 차례다.
28/44단계: 넣는 갈래: c를 넣고 다음 원소로 들어간다.
29/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
30/44단계: {b,c} 하나를 적었다.
31/44단계: 빼는 갈래: c를 빼고 다음 원소로 들어간다.
32/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
33/44단계: {b} 하나를 적었다.
34/44단계: 빼는 갈래: b를 빼고 다음 원소로 들어간다.
35/44단계: c를 넣을지 뺄지 정할 차례다.
36/44단계: 넣는 갈래: c를 넣고 다음 원소로 들어간다.
37/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
38/44단계: {c} 하나를 적었다.
39/44단계: 빼는 갈래: c를 빼고 다음 원소로 들어간다.
40/44단계: 원소를 모두 정했다. 지금 path가 부분집합 하나다.
41/44단계: {} 하나를 적었다.
42/44단계.
43/44단계.
44/44단계(실행 끝).
1/44단계.
▶ 2 const path: string[] = []
해볼 것
원소 3개 입력을 끝까지 실행해 부분집합 수와 호출 수를 본다
원소 4개 입력으로 바꿔 두 수가 어떻게 늘었는지 견줘 본다
원소 3개 입력은 부분집합 8개, 호출 15번이다. 원소 4개 입력으로 바꾸면 부분집합 16개, 호출 1 + 2 + 4 + 8 + 16 = 31번이다.
원소 3개일 때 호출을 층으로 나누면 pick(0) 1번, pick(1) 2번, pick(2) 4번, pick(3) 8번, 모두 15번이다. 층을 내려갈 때마다 갈래가 둘로 갈라져 두 배가 된다. 맨 아래 8번이 부분집합 8개다. 원소 하나가 늘면 부분집합도 호출도 약 두 배가 된다.
원소가 n개면 부분집합은 2를 n번 곱한 2ⁿ개다. 원소가 하나 늘 때마다 두 배가 되는 이 증가 속도(growth rate)를 Big-O 표기법(Big-O notation)으로 적으면 O(2ⁿ)이다(Unit 3 표의 2ⁿ 줄, 빅오 표기법 노트). 호출 수 2ⁿ⁺¹ − 1(2를 n + 1번 곱한 수에서 1을 뺀 것. 원소 3개면 2⁴ − 1 = 15, 앞에서 센 호출 수)도 2ⁿ의 약 두 배라 모양은 같다.
부분집합은 조합을 크기별로 모은 것이기도 하다. 원소 4개라면 0개를 고르는 법 1, 1개 4, 2개 6, 3개 4, 4개 1을 모두 더해 16 = 2⁴이다.
고르는 개수
0
1
2
3
4
합
원소 4개에서
1
4
6
4
1
16 = 2⁴
숫자 감각: n이 20을 넘으면
세 가지 경우의 수가 n에 따라 어떻게 자라는지 나란히 놓으면 이렇다.
n
n²(모든 쌍의 어림, 실제는 그 절반쯤)
2ⁿ(부분집합)
n!(줄 세우기)
10
100
1,024
3,628,800 (약 363만)
12
144
4,096
479,001,600 (약 4.8억)
20
400
1,048,576 (약 100만)
약 2.4 × 10¹⁸
30
900
1,073,741,824 (약 10억)
약 2.7 × 10³²
40
1,600
약 1.1조
약 8.2 × 10⁴⁷
표의 "약 2.4 × 10¹⁸" 같은 표기는 2.4 뒤에 0이 18개쯤 붙는 수라는 뜻이다(약 a × 10ᵏ은 a 뒤에 0이 k개쯤 붙는 수). 2¹⁰ = 1,024는 약 1,000이다. 그래서 2²⁰은 1,000 × 1,000 = 약 100만, 2³⁰은 약 10억, 2⁴⁰은 약 1조로 어림할 수 있다. 10이 늘 때마다 1,000배다.
시간으로 바꿔 보자. Unit 3처럼 연산 한 번에 1나노초, 곧 1초에 10억 번 계산한다고 친다. 잰 값이 아니라 어림이다. 기기·언어마다 다르고, 묶음을 하나씩 만들어 담는 백트래킹은 경우 하나에 연산을 여러 번 하니 이보다 느리다. 그러면 이렇게 정리된다.
2ⁿ 탐색: n이 20이면 약 100만이라 1밀리초쯤, 순식간이다. 30이면 약 10억이라 경우 하나에 연산 한 번씩만 해도 1초가 넘고, 10억 개 묶음을 실제로 만들어 담으면 메모리가 먼저 모자란다. 40이면 약 1.1조라 18분이 넘는다. n이 20을 넘으면 모든 부분집합을 훑는 코드는 위험하다고 보고, 가지치기(pruning, 답이 될 수 없는 갈래를 미리 자르기)나 다른 방법을 찾는다.
n! 탐색: 10!이 약 363만이라 4밀리초쯤, n이 10 안팎이면 괜찮다. 12!(약 4.8억)은 0.5초쯤이지만 13!은 약 62억이라 6초, 15!은 약 1.3조라 20분이 넘는다. n이 12를 넘으면 모든 순서를 훑기 어렵다.
n²(모든 쌍의 어림): n이 1만이어도 1억이라 0.1초다.
수가 이렇게 커지면 다른 문제도 생긴다. JavaScript의 number는 2⁵³(약 9천조)까지는 모든 정수를 정확히 담지만, 2⁵³ + 1은 담을 수 없어 2⁵³으로 반올림된다. 그래서 바로 옆 정수와 구별된다고 보장되는 안전한 정수(safe integer)는 Number.MAX_SAFE_INTEGER, 곧 2⁵³ − 1 = 9007199254740991까지다(MDN). 18!은 약 6.4 × 10¹⁵라 그 안에 있지만, 19!은 약 1.2 × 10¹⁷로 넘는다.
실험 팩토리얼은 언제 2⁵³을 넘을까
실험팩토리얼은 언제 2⁵³을 넘을까
17!부터 값을 찍고, 그 값에 1을 더해도 같은 수로 남는지 함께 찍습니다. 언어 버튼으로 TypeScript와 Python의 출력을 견줘 보세요. 아래 막대는 f를 2진수로 썼을 때의 자리 수입니다.
언어
입력
▶ 다음에 실행할 줄(디버거가 멈춘 줄)
방금 실행한 줄
변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
카드 안에서 ← → 키로도 넘길 수 있습니다.
1const n = 202let f = 13for (let i = 1; i <= n; i++) {4 f *= i5if (i >= 17) console.log(`${i}! = ${f}, +1 하면 ${f + 1 === f ? '그대로' : '달라짐'}`)6}
변수
n
20
n
20
f
1
n
20
i
1
f
1
n
20
i
1
f
1
n
20
i
2
f
1
n
20
i
2
f
2
n
20
i
3
f
2
n
20
i
3
f
6
n
20
i
4
f
6
n
20
i
4
f
24
n
20
i
5
f
24
n
20
i
5
f
120
n
20
i
6
f
120
n
20
i
6
f
720
n
20
i
7
f
720
n
20
i
7
f
5040
n
20
i
8
f
5040
n
20
i
8
f
40320
n
20
i
9
f
40320
n
20
i
9
f
362880
n
20
i
10
f
362880
n
20
i
10
f
3628800
n
20
i
11
f
3628800
n
20
i
11
f
39916800
n
20
i
12
f
39916800
n
20
i
12
f
479001600
n
20
i
13
f
479001600
n
20
i
13
f
6227020800
n
20
i
14
f
6227020800
n
20
i
14
f
87178291200
n
20
i
15
f
87178291200
n
20
i
15
f
1307674368000
n
20
i
16
f
1307674368000
n
20
i
16
f
20922789888000
n
20
i
17
f
20922789888000
n
20
i
17
f
355687428096000
n
20
i
17
f
355687428096000
n
20
i
18
f
355687428096000
n
20
i
18
f
6402373705728000
n
20
i
18
f
6402373705728000
n
20
i
19
f
6402373705728000
n
20
i
19
f
121645100408832000
n
20
i
19
f
121645100408832000
n
20
i
20
f
121645100408832000
n
20
i
20
f
2432902008176640000
n
20
i
20
f
2432902008176640000
출력
17! = 355687428096000, +1 하면 달라짐
18! = 6402373705728000, +1 하면 달라짐
19! = 121645100408832000, +1 하면 그대로
20! = 2432902008176640000, +1 하면 그대로
1!은 2진수로 1자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
1!은 2진수로 1자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
1!은 2진수로 1자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
1!은 2진수로 1자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
2!은 2진수로 2자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
2!은 2진수로 2자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
3!은 2진수로 3자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
3!은 2진수로 3자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
4!은 2진수로 5자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
4!은 2진수로 5자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
5!은 2진수로 7자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
5!은 2진수로 7자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
6!은 2진수로 10자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
6!은 2진수로 10자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
7!은 2진수로 13자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
7!은 2진수로 13자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
8!은 2진수로 16자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
8!은 2진수로 16자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
9!은 2진수로 19자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
9!은 2진수로 19자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
10!은 2진수로 22자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
10!은 2진수로 22자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
11!은 2진수로 26자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
11!은 2진수로 26자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
12!은 2진수로 29자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
12!은 2진수로 29자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
13!은 2진수로 33자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
13!은 2진수로 33자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
14!은 2진수로 37자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
14!은 2진수로 37자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
15!은 2진수로 41자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
15!은 2진수로 41자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
16!은 2진수로 45자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
16!은 2진수로 45자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
17!은 2진수로 49자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
17!은 2진수로 49자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
17!은 2진수로 49자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
18!은 2진수로 53자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
18!은 2진수로 53자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
18!은 2진수로 53자리
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
19!은 2진수로 57자리 → number는 위 53자리만 기억하고 아래 4자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
19!은 2진수로 57자리 → number는 위 53자리만 기억하고 아래 4자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
19!은 2진수로 57자리 → number는 위 53자리만 기억하고 아래 4자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
20!은 2진수로 62자리 → number는 위 53자리만 기억하고 아래 9자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
20!은 2진수로 62자리 → number는 위 53자리만 기억하고 아래 9자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
20!은 2진수로 62자리 → number는 위 53자리만 기억하고 아래 9자리는 0으로 친다
+1↓
위에서 53자리 ┘
위 53자리: number도 담는다53자리 아래: number는 0으로 쳐서 맨 끝에 더한 +1이 사라진다(TS). Python 정수는 다 담는다
1/46단계.
2/46단계.
3/46단계.
4/46단계.
5/46단계.
6/46단계.
7/46단계.
8/46단계.
9/46단계.
10/46단계.
11/46단계.
12/46단계.
13/46단계.
14/46단계.
15/46단계.
16/46단계.
17/46단계.
18/46단계.
19/46단계.
20/46단계.
21/46단계.
22/46단계.
23/46단계.
24/46단계.
25/46단계.
26/46단계.
27/46단계.
28/46단계.
29/46단계.
30/46단계.
31/46단계.
32/46단계.
33/46단계.
34/46단계.
35/46단계.
36/46단계.
37/46단계.
38/46단계.
39/46단계.
40/46단계.
41/46단계.
42/46단계: f가 2⁵³을 넘었다. TS number로는 이제 f와 f + 1을 구별하지 못한다.
43/46단계.
44/46단계.
45/46단계.
46/46단계(실행 끝).
1/46단계.
▶ 2 let f = 1
해볼 것
number로 20!까지 실행해 TypeScript에서 "그대로"가 처음 나오는 줄을 찾는다
BigInt로 25!까지 실행해 본다
number로 20!까지 실행하면 TypeScript는 18!까지 "+1 하면 달라짐"을 찍다가 19!부터 "그대로"를 찍는다. Python은 같은 코드에서 끝까지 "달라짐"을 찍는다.
2⁵³을 넘으면 number가 담을 수 있는 정수 사이에 빈틈이 생겨서, 19! + 1이 가장 가까운 담을 수 있는 수인 19!로 반올림된다. 그래서 19!과 19! + 1을 구별하지 못한다(number는 수를 담는 자리가 53비트라 2진수 54자리부터는 끝자리가 잘린다. 『한입 CS』 0장). 그렇다고 19!~22!의 저장된 값이 근삿값인 것은 아니다. 2⁵³ 위에서는 담을 수 있는 수 사이 간격이 2, 4, …로 벌어질 뿐, 간격 위에 놓인 수는 정확히 저장된다. 22!은 1부터 22까지 곱하는 동안 2가 19번 곱해진 수(1~22에 2의 배수 11개, 그중 4의 배수 5개, 8의 배수 2개, 16의 배수 1개라 11 + 5 + 2 + 1 = 19번)라, 2¹⁹를 묶어 내면 남는 수가 53비트 안에 들어가 간격 위에 정확히 놓인다. 23!은 23이 홀수라 2가 더 늘지 않는데 수는 23배 커져서, 남는 수가 53비트를 넘는다. 그래서 23!부터는 저장된 값 자체도 정확하지 않다.
Python의 정수(int)는 크기 제한 없이 정확하다(Python 문서). JavaScript에서 큰 정수를 정확히 세려면 BigInt를 쓴다. 숫자 뒤에 n을 붙여(1n) 만들고, BigInt끼리만 계산한다(f *= BigInt(i)). number와 섞어 쓰면 TypeError가 난다(MDN: BigInt). BigInt로 곱하면 25!도 두 언어가 같은 값을 낸다.
수학 다리n!과 코드의 곱셈
수학에서n! = n × (n − 1) × … × 1. 수는 얼마든지 커질 수 있고 늘 정확합니다.
코드에서for 문으로 1부터 n까지 곱합니다. TypeScript number는 2⁵³을 넘으면 옆 정수와 구별하지 못하고, BigInt와 Python int는 정확합니다.
같은 점한 명 늘 때마다 앞 답에 그 수를 곱합니다.
다른 점코드는 담을 그릇의 크기가 정해져 있습니다. 경우의 수가 2⁵³(약 9천조)을 넘을 수 있으면 BigInt를 쓰거나, 정확한 수 대신 어림(2.4 × 10¹⁸)으로 판단합니다.
실무 코드에서 찾아보기
경우의 수 코드는 "순서를 따지나?"(3절)와 "n이 최대일 때 이 수가 number가 정확히 담는 2⁵³ 안인가?"(5절)를 먼저 묻는다. 비트 연산이 보이면 32비트 한계(Unit 1 6절)도 확인한다.
함정 찾기 AI가 짠 경우의 수 함수, 어느 줄이 틀릴까?
함정 찾기AI가 짠 경우의 수 함수, 어느 줄이 틀릴까?
내가 AI에게 한 요청 “팀원 n명 중 r명을 뽑아 팀을 만드는 경우의 수를 세는 함수와, 필터 체크박스를 각각 켜고 끈 모든 묶음을 만드는 함수를 짜 줘. 팀원은 최대 30명이다.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
확인 문제 연습
확인 문제연습
1. 0~9 숫자로 만드는 4자리 PIN(같은 숫자를 다시 써도 된다)의 경우의 수는?
2. 6명 중 3명을 뽑아 팀을 만드는 경우의 수는? (팀 안의 순서는 따지지 않는다)
3. 필터 체크박스 20개를 각각 켜거나 끈 모든 경우를 테스트하면 경우는 대략 몇 가지일까?
4. TypeScript number로 계산한 19!(약 1.2 × 10¹⁷)에 1을 더해 원래 값과 ===로 비교하면?
이 Unit이 쓰이는 곳
『한입 알고리즘』 5장 DFS와 백트래킹: 옵션 조합 수가 "그룹별 선택지 수의 곱"(사이즈 5 × 색상 8 × 소재 4 = 160)이라는 문장이 곱의 법칙이다. 가지치기 절의 "경우의 수는 그룹이 늘수록 곱으로 커진다"도 같은 계산이다.
『한입 알고리즘』 10장 동적 계획법: 줄이 1,000개인 두 파일에서 공통으로 남길 줄을 고르는 조합이 "2의 1,000제곱에 가깝다"는 문장이 부분집합 2ⁿ이다. 줄마다 남긴다/뺀다 두 갈래다.
『한입 CS』 0장 숫자는 비트다: "안전한 정수는 2⁵³까지" 절이 이 Unit 5절의 19!과 같은 한계를 다룬다. 큰 id에 BigInt나 문자열을 쓰는 이유다.
『한입 CS』 10장 해시, 암호화, 인코딩: 보너스 문제에서 16진수 서명 L글자의 모든 조합이 16ᴸ가지(16을 L번 곱한 수)라는 것이 곱의 법칙이다. 한 자리씩 맞히면 16을 L번 곱하던 것(16ᴸ)이 16을 L번 더한 것(16 × L)으로 바뀐다. 곱이 합으로 바뀐 것이다.
더 풀어 보기(선택) · 팩토리얼 없이 조합 수 세기
보너스 문제팩토리얼 없이 조합 수 세기
30명 중 15명을 뽑는 조합 수(정답 155,117,520)를 TypeScript number로 factorial(30) / (factorial(15) * factorial(15))처럼 계산하면 155117519.99999997이 나온다. 30!이 2⁵³을 훨씬 넘어 근삿값이기 때문이다. 팩토리얼을 거치지 않고, 계산하는 동안 늘 정수만 다루면서 ₙCᵣ를 구하는 반복문을 짜 보자.