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

Permutations排列

用 used 陣列或交換法

用在:排程順序、路徑列舉

時間複雜度O(n!·n)
空間複雜度O(n)
難度進階
前置知識Recursion、Subsets

01為什麼需要它

工作站的加工順序

一台機器要處理 6 個訂單,每個訂單之間切換模具的時間不同,順序不同總耗時就不同。要找最省時的順序。

為什麼用它順序問題和子集問題不同:同一批東西換個順序就是不同答案。6 個訂單有 6! = 720 種順序,全部列出來各算一次總耗時就好。這是排程問題在 n 小時最直接的解法,也是理解 TSP 這類問題的起點。

送貨路線列舉

外送員從店家出發要送 5 個地點再回來,哪個順序總距離最短?

為什麼用它5 個地點的所有拜訪順序就是 5 的全排列。用 used 陣列記住哪些地點已經排進路線,每一步從還沒排的裡面挑,走到底就是一條完整路線。

字謎與密碼變體

把「listen」的字母重新排列能拼出哪些字?測試帳號的密碼是幾個片段的某種順序,要把所有順序都試一遍。

為什麼用它字母的重新排列就是排列。有重複字母時要避免產生一樣的結果,排序後加一條「相同的值前一個沒用就跳過」的規則即可。

看到這些關鍵字就想到它:順序、排法、有幾種排法、每個元素恰好用一次、n!、字母重組、路線的拜訪順序。

02核心概念

子集是對每個元素問「選不選」,排列則是對每個位置問「放誰」。第一個位置有 n 個選擇,第二個位置剩 n−1 個,依此類推,葉節點共 n! 個。決策樹不再是二元的,每層的分支數等於「還沒用過的元素數」。

要知道誰還沒用過,最直接的做法是一個 used 陣列。每層 for 迴圈掃過所有元素,used[j] 為 true 就跳過,否則做選擇(標記 used、放進 path)、遞迴到下一個位置、撤銷選擇(拿掉、取消標記)。撤銷必須把兩樣東西都復原,少一個下個分支就會看到錯誤的狀態,這是排列最常見的 bug。

交換法省掉 used 和 path:位置 i 依序和 i 之後的每個元素交換,前 i 個就是已經決定的部分、後面就是還沒用的部分。遞迴回來時再換回去。它少一個陣列,但產生的順序和 used 版不同,遇到重複元素也比較難處理。

複雜度是 O(n!·n):n! 個排列各複製 O(n)。n = 10 是三百六十萬,n = 12 已經四億多,所以排列只能在 n 很小時完整列舉;n 大時題目通常要的是「最佳的一種」,那就得轉向 DP(例如狀態壓縮)或貪婪。重複元素的處理是先排序,同一層裡若 nums[j] == nums[j-1]used[j-1] 為 false,表示這個值在同一層已經當過開頭,跳過。

03演算法步驟

  1. 1準備 anspathused(全 false)。dfs() 表示「決定下一個位置放誰」。
  2. 2終止條件:len(path) == n,所有位置都填了,複製 path 放進 ans
  3. 3for 迴圈掃過每個 j:used[j] 為 true 就 continue。
  4. 4做選擇:used[j] = Truepath.append(nums[j]),遞迴 dfs()
  5. 5撤銷選擇:path.pop()used[j] = False,兩樣都要復原,然後試下一個 j。

04互動示範

[1, 2, 3] 的排列樹。每一層從 used 為 false 的數字裡挑,下方同時顯示 used 陣列和路徑。留意每次撤銷時 used 和路徑是一起復原的。

開始nums = [1, 2, 3] · 每層從沒用過的數字裡挑
112123131322212132323133131232321
nums
123
used
FFF
目前路徑
已收集的排列(0/6)
還沒有
步驟 0/37排列要決定「每個位置放誰」。每一層從還沒用過的數字裡挑一個放進路徑,used 陣列記錄誰已經在路徑上。

05程式碼

used 陣列版、交換法,以及含重複元素的版本。三者的骨架都是「做選擇、遞迴、撤銷」,差在怎麼記錄「誰還沒用」。

# 全排列(LeetCode 46):used 陣列記錄誰已經在路徑上
def permute(nums):
    ans = []
    path = []
    used = [False] * len(nums)

    def dfs():
        if len(path) == len(nums):        # 每個位置都填了
            ans.append(path[:])
            return
        for j in range(len(nums)):
            if used[j]:                   # 已經在路徑上,跳過
                continue
            used[j] = True                # 做選擇
            path.append(nums[j])
            dfs()
            path.pop()                    # 撤銷選擇
            used[j] = False

    dfs()
    return ans


# 交換法:第 i 個位置和 i 之後的每個元素交換,不用 used 也不用 path
def permute_swap(nums):
    ans = []

    def dfs(i):
        if i == len(nums):
            ans.append(nums[:])
            return
        for j in range(i, len(nums)):
            nums[i], nums[j] = nums[j], nums[i]   # 做選擇:nums[j] 放到位置 i
            dfs(i + 1)
            nums[i], nums[j] = nums[j], nums[i]   # 撤銷:換回來

    dfs(0)
    return ans


# 含重複元素的排列(LeetCode 47):排序後,相同的值必須「前一個用了才能用後一個」
def permute_unique(nums):
    nums = sorted(nums)                   # 排序出新串列,不改動呼叫者的輸入
    ans = []
    path = []
    used = [False] * len(nums)

    def dfs():
        if len(path) == len(nums):
            ans.append(path[:])
            return
        for j in range(len(nums)):
            if used[j]:
                continue
            if j > 0 and nums[j] == nums[j - 1] and not used[j - 1]:
                continue                  # 同樣的值已經在這個位置試過
            used[j] = True
            path.append(nums[j])
            dfs()
            path.pop()
            used[j] = False

    dfs()
    return ans


if __name__ == "__main__":
    print(permute([1, 2, 3]))
    # [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
    print(permute_swap([1, 2, 3]))       # 同樣 6 個,但最後兩個順序不同
    # [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 2, 1], [3, 1, 2]]
    print(permute_unique([1, 1, 2]))
    # [[1, 1, 2], [1, 2, 1], [2, 1, 1]]

06練習題

  • LeetCode 46PermutationsMedium
  • LeetCode 47Permutations II(排序加 used[j-1] 判斷)Medium
  • LeetCode 31Next Permutation(不用回溯,找下一個字典序)Medium
  • LeetCode 526Beautiful ArrangementMedium
  • LeetCode 60Permutation Sequence(用階乘直接算第 k 個)Hard
  • LeetCode 996Number of Squareful ArraysHard