๐ŸŽ“ A1Learner
๐Ÿ”

DSA Permutations

A permutation is one ordering of a list of elements. Backtracking builds each ordering by picking an unused element for the next slot.

The problem

Permutations asks for every ordering of a list of values. An ordering uses every value exactly once. Two orderings differ when some position holds a different value.

Repeated values need care. Equal values are interchangeable, so swapping the two copies makes no new ordering. This article counts distinct orderings only, and its solution sorts the values before searching.

The cases used in this article

cases = [
    ([1, 2], [[1, 2], [2, 1]]),
    ([1, 2, 3], [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]),
    ([1, 1, 2], [[1, 1, 2], [1, 2, 1], [2, 1, 1]]),
    ([5], [[5]]),
]
for nums, expected in cases:
    print("nums", nums, "orderings:", expected)

Three distinct values give six orderings. The list [1, 1, 2] gives only three. The two copies of 1 are interchangeable, and that halves the count.

Intuition: fill one slot at a time, but guard each pick

The first idea fills one slot at a time. Any value could fill the first slot. Any value could fill the second slot too.

That freedom is the trap. It lets one value fill two slots, which no ordering may do. The next block counts the damage on a two-value list.

Unguarded slot filling on [1, 2]

nums = [1, 2]
built = []
for first in nums:
    for second in nums:
        built.append([first, second])
valid = [row for row in built if sorted(row) == nums]
invalid = [row for row in built if sorted(row) != nums]
print("sequences built:", len(built))
print("real orderings:", valid)
print("invalid:", invalid)

Half of the built sequences are waste. The search needs a memory of which values already sit in the path. That memory is the fix the optimal solution adds.

Brute force: build every sequence, then filter

The brute force builds every sequence of length n over the values, repeats allowed. It keeps a sequence only when the sorted sequence equals the sorted input. A counter records how many sequences were built.

The count of orderings of n distinct values is n factorial, written n!. It is the product 1 times 2 times every whole number up to n, so three values give 6 orderings.

The brute force on [1, 2, 3]

def permutations_brute(nums):
    results = []
    path = []
    built = [0]
    def search():
        if len(path) == len(nums):
            built[0] += 1
            if sorted(path) == sorted(nums):
                results.append(list(path))
            return
        for value in nums:
            path.append(value)
            search()
            path.pop()
    search()
    return results, built[0]

result, built = permutations_brute([1, 2, 3])
for row in result:
    print(row)
print("orderings kept:", len(result))
print("sequences built:", built)

The brute force built 27 sequences to keep 6. The waste grows as n to the power n. The surviving orderings grow only as n factorial, so the gap widens fast.

The optimal solution: a used list guards each slot

The state is the path built so far plus a used list that marks which positions already sit in the path, and the search picks only an unused position, clearing its mark when the branch returns. That one sentence is the core method.

One more rule handles repeats. The function sorts the values first, so equal values sit side by side. It then skips a value whose equal left neighbour is unused, because that ordering is built through the neighbour instead.

The guarded search on every case

def permutations(nums):
    nums = sorted(nums)
    results = []
    path = []
    used = [False] * len(nums)
    def search():
        if len(path) == len(nums):
            results.append(list(path))
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue
            used[i] = True
            path.append(nums[i])
            search()
            path.pop()
            used[i] = False
    search()
    return results

cases = [
    ([1, 2], [[1, 2], [2, 1]]),
    ([1, 2, 3], [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]),
    ([1, 1, 2], [[1, 1, 2], [1, 2, 1], [2, 1, 1]]),
    ([1, 2, 1], [[1, 1, 2], [1, 2, 1], [2, 1, 1]]),
    ([5], [[5]]),
]
for nums, expected in cases:
    result = permutations(nums)
    print("nums", nums, "->", result, "match:", result == expected)

Every case passes, including the unsorted [1, 2, 1]. Sorting inside the function is what makes that row work. The equal values land side by side before the skip rule ever sees them.

Walkthrough: building the orderings of [1, 2]

The walk picks one value at a time. Each pick prints the path and the used marks. Each release line is a backtrack step, where a value leaves the path and its mark clears.

Picks, records and releases for [1, 2]

nums = [1, 2]
path = []
used = [False] * len(nums)
def search():
    if len(path) == len(nums):
        print("record", list(path))
        return
    for i in range(len(nums)):
        if used[i]:
            continue
        used[i] = True
        path.append(nums[i])
        print("pick", nums[i], "- path:", list(path), "- used:", list(used))
        search()
        path.pop()
        used[i] = False
        print("release", nums[i], "- path:", list(path), "- used:", list(used))
search()

Read the two records in order. They are [1, 2] and [2, 1]. The release lines are what let the second record exist, because value 1 becomes pickable again for a later slot.

Complexity

Time is O(n times n!) for n distinct values. The search builds n! orderings, and copying each finished ordering costs n steps.

Extra space is O(n) besides the stored results. The path holds at most n values, and the used list holds one mark per value.

Brute sequences against real orderings

import math
for n in [2, 3, 4]:
    print("values", n, "- brute sequences:", n ** n, "- orderings:", math.factorial(n))

At four values the brute force builds 256 sequences for 24 orderings. The used list removes that waste before it happens, by refusing a taken value at pick time.

Edge cases and pitfalls

An empty list has one ordering, the empty ordering. A single value has one ordering too. Repeated values in any input order give each distinct ordering exactly once.

Edge cases, run

def permutations(nums):
    nums = sorted(nums)
    results = []
    path = []
    used = [False] * len(nums)
    def search():
        if len(path) == len(nums):
            results.append(list(path))
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue
            used[i] = True
            path.append(nums[i])
            search()
            path.pop()
            used[i] = False
    search()
    return results

print("empty list:", permutations([]))
print("one value:", permutations([5]))
result = permutations([1, 2, 1])
print("non-adjacent twins:", result)
print("count:", len(result), "- distinct:", len({tuple(row) for row in result}))

Forgetting to clear a used mark after a branch is the classic bug in this search. Later branches then see the value as taken forever. Orderings go missing silently, with no error to announce the loss.

The missing release, run

def permutations_buggy(nums):
    results = []
    path = []
    used = [False] * len(nums)
    def search():
        if len(path) == len(nums):
            results.append(list(path))
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            search()
            path.pop()
    search()
    return results

result = permutations_buggy([1, 2, 3])
print("orderings found:", result)
print("count:", len(result))

One ordering survives out of six. The count exposes the bug at once, which is why every block in this article prints its count beside its results.

Exercise

While permuting [1, 1, 2], the search reaches the second 1 while the first 1 is unused. What does the skip rule do?