演算法圖鑑
Recursion & Backtracking · 03 / 05

Combinations & Combination Sum組合與剪枝

從 start 開始避免重複,排序後提前剪枝

用在:湊金額、選隊員

時間複雜度指數
空間複雜度O(n)
難度進階
前置知識Subsets

01為什麼需要它

用手上的面額湊出一筆金額

販賣機只收 5、10、50 元,要列出所有能湊出 65 元的投幣方式。10+5+50 和 50+10+5 是同一種,不能重複算。

為什麼用它這是「組合」不是「排列」:順序不重要。每一層只從目前位置往右挑,就天生不會產生順序不同的重複。剩餘金額變負時整條分支立刻放棄,這就是剪枝。

從 20 個人裡選 5 人的隊伍

社團要從 20 個報名者裡選 5 人參賽,每種名單都要評估一次適配度。C(20, 5) = 15,504 種,要有系統地列出來。

為什麼用它從 start 往右挑保證每個名單只出現一次。加一條剪枝:剩下的人不夠填滿 5 個位置就不用再往下試,可以砍掉大量沒用的遞迴。

預算內的採購清單

有一份零件價目表,要列出所有總價剛好等於預算的採購組合,每種零件可以買多份。

為什麼用它先把價目排序。當目前這個零件的價格已經超過剩餘預算,後面更貴的一定也超過,整個迴圈直接結束。排序加剪枝讓搜尋樹少掉一大半。

看到這些關鍵字就想到它:湊出總和、選 k 個、順序不重要、可以重複用或只能用一次、列出所有方案、n 小但暴力太慢。

02核心概念

組合是「不管順序的挑選」。[2, 3] 和 [3, 2] 算同一種,所以不能像排列那樣每層從頭掃。做法是帶一個 start每一層只從 start 往右挑,挑了第 i 個之後,下一層從 i(可重複用)或 i+1(只能用一次)開始。這樣每個組合只會以「由小到大的索引」這一種順序出現,重複自然消失。

剪枝是讓回溯能用的關鍵。回溯的樹是指數大的,能砍掉整棵子樹的判斷都值得做。Combination Sum 先把候選排序,for 迴圈裡一旦 candidates[i] > remain,右邊的更大、更不可能,直接 break,不是 continue。C(n, k) 則是「剩下的數不夠填滿 k 個」就不試。這些判斷都是 O(1),但砍掉的可能是幾千個節點。

做選擇、遞迴、撤銷選擇的三步和子集一樣,只是遞迴時多傳了 startremain。要分清兩個常混淆的地方:能否重複用同一個元素決定下一層的 start 是 i 還是 i+1;輸入本身有重複值時要先排序,同一層裡 candidates[i] == candidates[i-1]i > start 就跳過,因為以那個值開頭的分支這一層已經走過。

複雜度沒有乾淨的閉合式,依 target 和候選數而異,通常記作指數。可以確定的是:沒有排序與剪枝時每一層都會把所有候選試完;有了剪枝,樹的大小接近「真正可行的分支數」。如果題目只問「有幾種」或「最少幾個」而不要列出方案,那是 DP 的範圍(Coin Change),不要用回溯。

03演算法步驟

  1. 1先把候選排序,這是剪枝能用 break 的前提。
  2. 2dfs(start, remain)remain == 0 時把 path 複製進答案並 return。
  3. 3for 迴圈從 start 掃到底。若 candidates[i] > remainbreak(剪枝)。若輸入有重複且 i > start and candidates[i] == candidates[i-1]continue
  4. 4做選擇:path.append(candidates[i]),遞迴 dfs(i, remain - candidates[i])(可重複用)或 dfs(i + 1, …)(只能用一次)。
  5. 5撤銷選擇:path.pop(),繼續試下一個 i。

04互動示範

candidates = [2, 3, 6, 7]、target = 7。下方的遞迴堆疊顯示每一層的 start 與 remain,黃色的那一步就是剪枝發生的時刻:候選已經比 remain 大,整個迴圈直接結束。

開始candidates = [2, 3, 6, 7] · target = 7
candidates(start = 0
2367
灰色是 start 左邊,這一層不再考慮
目前路徑(remain = 7
已找到的組合
還沒有
遞迴堆疊(每一層的 start 與 remain,右邊是最深的一層)
步驟 0/28目標湊出 7。每層從 start 開始往右挑(不回頭挑左邊的,避免 [2,3] 和 [3,2] 算兩次),remain 記錄還差多少。

05程式碼

Combination Sum(可重複用)、C(n, k)(剩餘數量剪枝)與 Combination Sum II(只能用一次、輸入有重複)。三者只差 start 怎麼傳和剪枝條件。

# Combination Sum(LeetCode 39):每個數可重複使用,湊出 target
def combination_sum(candidates, target):
    candidates = sorted(candidates)         # 排序是剪枝的前提(排出新串列,不改動呼叫者的輸入)
    ans = []
    path = []

    def dfs(start, remain):
        if remain == 0:                     # 剛好湊到
            ans.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remain:      # 剪枝:右邊只會更大,整個迴圈結束
                break
            path.append(candidates[i])      # 做選擇
            dfs(i, remain - candidates[i])  # 可以重複用,所以下一層仍從 i 開始
            path.pop()                      # 撤銷選擇

    dfs(0, target)
    return ans


# 組合 C(n, k)(LeetCode 77):從 1..n 挑 k 個,剪枝:剩下的數不夠填滿就不用試
def combine(n, k):
    ans = []
    path = []

    def dfs(start):
        if len(path) == k:
            ans.append(path[:])
            return
        need = k - len(path)                # 還要挑幾個
        for i in range(start, n - need + 2):   # i 之後(含 i)至少要剩 need 個數
            path.append(i)
            dfs(i + 1)                      # 每個數只能用一次,下一層從 i+1 開始
            path.pop()

    dfs(1)
    return ans


# Combination Sum II(LeetCode 40):每個數只能用一次,且輸入有重複
def combination_sum2(candidates, target):
    candidates = sorted(candidates)
    ans = []
    path = []

    def dfs(start, remain):
        if remain == 0:
            ans.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remain:
                break
            if i > start and candidates[i] == candidates[i - 1]:
                continue                    # 同一層跳過相同的值
            path.append(candidates[i])
            dfs(i + 1, remain - candidates[i])
            path.pop()

    dfs(0, target)
    return ans


if __name__ == "__main__":
    print(combination_sum([2, 3, 6, 7], 7))         # [[2, 2, 3], [7]]
    print(combine(4, 2))                            # [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
    print(combination_sum2([10, 1, 2, 7, 6, 1, 5], 8))
    # [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]](兩個 1 只產生一次 [1, 7])

06練習題

  • LeetCode 39Combination SumMedium
  • LeetCode 40Combination Sum II(只用一次、跳過重複)Medium
  • LeetCode 77CombinationsMedium
  • LeetCode 216Combination Sum IIIMedium
  • LeetCode 17Letter Combinations of a Phone NumberMedium
  • LeetCode 131Palindrome Partitioning(切割位置的組合)Medium