본문으로 건너뛰기
Life Saver Wiki

다이나믹 프로그래밍 - 여러 번 주사위 굴려서 정해진 숫자 맞추기

제출 버튼 누르고 TLE 보신 분들 주목. 순수 재귀와 Coin Change 2 함정에서 벗어나, 브루트포스 → 메모이제이션 → 타뷸레이션까지 함께 올라가는 LeetCode 1155 완전 정복 가이드입니다.

운영자
Life Saver Wiki

Number of Dice Rolls With Target Sum (LeetCode 1155)

면이 k개인 주사위 n개를 굴려서 합이 target이 되는 경우의 수를 10^9 + 7로 나눈 나머지를 구하는 문제입니다.

제가 처음 이 문제를 봤을 때의 솔직한 심정은 “어? 그냥 조합 문제 아닌가?”였습니다. 그리고 실제로 Reddit r/leetcode에서도 “Coin Change 2랑 똑같은 거 아니에요?”라는 질문이 심심찮게 올라오더라고요. 결론부터 말하면, 아닙니다. Coin Change 2는 동전 개수에 제한이 없지만, 이 문제는 정확히 n개의 주사위를 전부 사용해야 합니다. 이 차이 하나 때문에 Wrong Answer가 쏟아지는 걸 실제로 LeetCode Discuss에서 수도 없이 봤습니다.


문제 설명 — 초등학생도 이해할 수 있게

여러분 앞에 면이 6개인 주사위가 2개 있습니다. (1, 2, 3, 4, 5, 6이 그려져 있죠.)

이 두 개를 굴려서 합이 7이 나오는 경우는 몇 가지일까요?

세어봅시다:

주사위 A주사위 B
167
257
347
437
527
617

총 6가지입니다. 여기서 중요한 포인트 하나: (1, 6)과 (6, 1)은 다른 경우라는 겁니다. 주사위가 똑같이 생겼어도, 어떤 주사위에서 어떤 숫자가 나왔는지가 중요해요. 이걸 헷갈려 하시는 분들이 실제로 꽤 많더라고요.

이제 일반화하면: 면이 k개인 주사위 n개의 합이 target이 되는 경우의 수를 구하는 문제입니다. 단, 답이 너무 커질 수 있으니 10^9 + 7로 나눈 나머지를 반환합니다.


”브루트포스로 하면 왜 안 돼요?” — 누구나 처음엔 이렇게 생각합니다

솔직히 말해서, 저도 처음엔 “모든 경우를 다 세면 되지 않나?”라고 생각했습니다. 주사위 n개, 각각 k개의 선택지 → k^n 가지니까… n=30, k=30이면 30^30입니다.

이 숫자가 얼마나 큰지 체감이 안 되시는 분들을 위해: 우주에 존재하는 원자의 개수보다 훨씬 큽니다. 당연히 TLE(Time Limit Exceeded)가 나고, 실제로 LeetCode GitHub Issue #18932에서 한 유저가 “brute force로 통과해야 하는 거 아닌가요?”라고 올렸다가 바로 반려된 사례도 있습니다.

그래도 브루트포스에서 시작하는 건 좋은 접근입니다

“완전 탐색으로는 안 된다”는 걸 확인하는 것 자체가 DP로 넘어가는 첫걸음이에요. 재귀로 상태 공간 트리를 그려보면 어디서 중복 계산이 발생하는지 눈에 들어옵니다.

                          (n=3, target=7)
                         /       |      \
           주사위1=1    /  주사위1=2 | ...  \ 주사위1=k
                     /           |           \
            (n=2, target=6)  (n=2, target=5)  (n=2, target=7-k)
               /  |  \          /  |  \
        (n=1,5) (n=1,4) ...  (n=1,4) (n=1,3) ...

보다시피 (n=1, target=4) 같은 상태가 여러 경로에서 등장합니다. Reddit의 한 유저가 딱 이걸 깨닫고 한 말이 인상적이었습니다:

“아! 첫 번째 주사위를 하나 고정하고 나면, 나머지 n-1개의 주사위로 target - 첫주사위값을 만드는 하위 문제가 되는구나.”


패턴 발견 — 여기서부터가 진짜 시작

주사위 3개, 목표 합이 7이라고 생각해봅시다.

한 개의 주사위 값이 1이면 → 주사위 2개로 7-1=6 을 만들어야 한다. 한 개의 주사위 값이 2이면 → 주사위 2개로 7-2=5 를 만들어야 한다. 한 개의 주사위 값이 3이면 → 주사위 2개로 7-3=4 를 만들어야 한다. …

패턴이 보이시나요? dp[n][target] = dp[n-1][target-1] + dp[n-1][target-2] + ... + dp[n-1][target-k]

이걸 점화식으로 표현하면:

dp[i][j] = Σ (dp[i-1][j - face])   (face = 1, 2, ..., k)

단, j - face >= 0 일 때만 유효합니다 (합이 음수가 될 수는 없으니까요).


실전에서 사람들이 가장 많이 하는 실수 3가지

실수 1: Modulo를 마지막에만 적용

LeetCode Discuss에서 정말 자주 보이는 패턴입니다. C++이나 Java에서 int는 약 21억까지밖에 표현하지 못하는데, 중간 합산에서 이걸 넘겨버리면 오버플로가 발생해서 음수로 래핑됩니다. 결과는 당연히 Wrong Answer.

매 덧셈마다 % MOD를 적용하세요. Python은 arbitrary precision 정수라 오버플로는 없지만, mod를 안 하면 숫자가 천문학적으로 커져서 메모리와 속도가 폭발합니다.

// 나쁜 예 ❌
int sum = a + b + c + d;
return sum % MOD;

// 좋은 예 ✅
int sum = ((a + b) % MOD + c) % MOD;
sum = (sum + d) % MOD;
return sum;

실수 2: Base Case — dp[0][0] = 1을 빼먹음

“주사위 0개로 합 0을 만드는 방법”은 1가지(아무것도 안 하는 것)입니다. 이걸 0으로 초기화하면 모든 값이 0으로 수렴하는 참사가 발생합니다. 반대로 n > 0일 때 dp[n][0]은 말이 안 되니 0이어야 합니다.

실수 3: Pruning (가지치기) 없이 DP 돌리기

target < n (최소 합보다 작음) 또는 target > n*k (최대 합보다 큼)일 경우, 답은 무조건 0입니다. 이걸 확인하지 않고 DP 테이블을 채우면 불필요한 연산이 발생하고, 특히 k가 클 때 낭비가 심합니다.


Top-Down (메모이제이션) — 직관적으로 접근하기

초보자에게 더 직관적인 Top-Down 방식부터 시작합시다. 재귀에 캐시를 붙이는 거죠.

from functools import lru_cache

class Solution:
    def numRollsToTarget(self, n: int, k: int, target: int) -> int:
        MOD = 10**9 + 7

        # 가지치기: 불가능한 경우 즉시 0 반환
        if target < n or target > n * k:
            return 0

        @lru_cache(maxsize=None)
        def dp(i: int, j: int) -> int:
            # base case
            if i == 0:
                return 1 if j == 0 else 0

            total = 0
            for face in range(1, k + 1):
                if j - face >= 0:
                    total = (total + dp(i - 1, j - face)) % MOD
            return total

        return dp(n, target)

여기서 실제 유저 경험 팁 하나: Python에서 @lru_cache는 일반 dict 기반 메모이제이션보다 2~3배 빠릅니다. StackOverflow 질문(69597327)에서도 이 차이로 고생한 사례가 올라와 있으니 참고하세요.


Bottom-Up (타뷸레이션) — 더 빠르고 면접에서 빛을 발하는 방식

메모이제이션으로 감을 잡았으면, 타뷸레이션으로 최적화합시다. 2D 테이블을 머릿속에 그려보세요.

i\j01234567
010000000
101111110
200123456
3

dp[2][7] = 6 → 주사위 2개로 합 7이 되는 경우가 6가지. 위에서 우리가 세어본 그 숫자죠?

class Solution:
    def numRollsToTarget(self, n: int, k: int, target: int) -> int:
        MOD = 10**9 + 7

        # 가지치기
        if target < n or target > n * k:
            return 0

        dp = [[0] * (target + 1) for _ in range(n + 1)]
        dp[0][0] = 1

        for i in range(1, n + 1):
            for j in range(1, target + 1):
                for face in range(1, k + 1):
                    if j - face >= 0:
                        dp[i][j] = (dp[i][j] + dp[i-1][j - face]) % MOD

        return dp[n][target]

시간 복잡도: O(n * target * k)n≤30, k≤30, target≤1000이면 약 90만 연산으로 충분합니다.


공간 최적화 — Rolling Array로 O(target)까지 줄이기

dp[i][j]를 계산할 때 필요한 건 오직 dp[i-1][...] 뿐입니다. 즉, 직전 행만 기억하면 됩니다.

class Solution:
    def numRollsToTarget(self, n: int, k: int, target: int) -> int:
        MOD = 10**9 + 7

        if target < n or target > n * k:
            return 0

        prev = [0] * (target + 1)
        prev[0] = 1

        for i in range(1, n + 1):
            curr = [0] * (target + 1)
            for j in range(1, target + 1):
                for face in range(1, k + 1):
                    if j - face >= 0:
                        curr[j] = (curr[j] + prev[j - face]) % MOD
            prev = curr

        return prev[target]

공간 복잡도가 O(n * target)에서 O(target)으로 줄었습니다. 면접관 앞에서 이 최적화까지 보여주면 분명히 플러스 점수입니다.


면접관이 보는 포인트 — 이걸 물어보면 어떻게 답해야 할까?

면접관이 이 문제를 내는 진짜 이유는 “Knapsack 패턴을 인식하는가”를 보기 위함입니다. LeetCode Discuss의 DP Patterns 가이드에서도 1155번이 “Given a target, find number of distinct ways to reach the target” 패턴의 대표 예시로 분류되어 있습니다.

면접관 앞에서 이렇게 말할 수 있다면 합격점입니다:

  1. “이 문제는 0/1 Knapsack의 변형입니다. 각 주사위가 하나의 아이템이고, 1~k 값을 ‘선택’합니다.”
  2. “점화식은 dp[i][j] = Σ dp[i-1][j-face] 입니다.”
  3. “중간 합산마다 mod를 적용해야 오버플로를 방지할 수 있습니다.”
  4. “공간 최적화로 O(target)까지 줄일 수 있는데, rolling array를 사용합니다.”

마무리 — 여러분의 TLE를 응원합니다

마지막으로, 이 글을 읽고 계신 분들 중에 “와, 나 아직도 DP 어렵다”라고 느끼신다면 — 그게 정상입니다. 바바라 오클리 교수가 Learn How to Learn에서 말했듯이, 뇌는 한 번 생각한 패턴을 반복해서 떠올립니다. 잘못된 접근을 먼저 익히면, 나중에 다시 풀 때도 그 잘못된 길이 먼저 생각나는 거죠.

DP는 근육과 같습니다. 반복적인 연습만이 뇌에 새겨진 패턴을 바꿀 수 있어요. 이 글에서 소개한 커뮤니티의 시행착오를 여러분의 경험으로 삼고, 한 문제를 Top-Down → Bottom-Up → 공간 최적화까지 세 가지 방식으로 풀어보는 연습을 해보시길 강력히 추천합니다.

여러분의 첫 TLE를 응원합니다. 그게 진짜 DP의 시작이니까요.