Files
codetutor/backend/data/questions/permutations-ii.yaml

253 lines
12 KiB
YAML

title: Permutations II
slug: permutations-ii
difficulty: medium
leetcode_id: 47
leetcode_url: https://leetcode.com/problems/permutations-ii/
categories:
- arrays
- sorting
- recursion
patterns:
- slug: backtracking
is_optimal: true
function_signature: "def permute_unique(nums: list[int]) -> list[list[int]]:"
test_cases:
visible:
- input: { nums: [1, 1, 2] }
expected: [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
- input: { nums: [1, 2, 3] }
expected: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
hidden:
- input: { nums: [1] }
expected: [[1]]
- input: { nums: [1, 1] }
expected: [[1, 1]]
- input: { nums: [1, 2] }
expected: [[1, 2], [2, 1]]
- input: { nums: [1, 1, 1] }
expected: [[1, 1, 1]]
- input: { nums: [0, 1, 0, 0, 9] }
expected: [[0, 0, 0, 1, 9], [0, 0, 0, 9, 1], [0, 0, 1, 0, 9], [0, 0, 1, 9, 0], [0, 0, 9, 0, 1], [0, 0, 9, 1, 0], [0, 1, 0, 0, 9], [0, 1, 0, 9, 0], [0, 1, 9, 0, 0], [0, 9, 0, 0, 1], [0, 9, 0, 1, 0], [0, 9, 1, 0, 0], [1, 0, 0, 0, 9], [1, 0, 0, 9, 0], [1, 0, 9, 0, 0], [1, 9, 0, 0, 0], [9, 0, 0, 0, 1], [9, 0, 0, 1, 0], [9, 0, 1, 0, 0], [9, 1, 0, 0, 0]]
description: |
Given a collection of numbers, `nums`, that might contain duplicates, return *all possible unique permutations **in any order***.
constraints: |
- `1 <= nums.length <= 8`
- `-10 <= nums[i] <= 10`
examples:
- input: "nums = [1,1,2]"
output: "[[1,1,2], [1,2,1], [2,1,1]]"
explanation: "The array contains a duplicate 1. We generate all unique permutations, avoiding duplicates like having two identical [1,1,2] arrangements."
- input: "nums = [1,2,3]"
output: "[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]"
explanation: "With all unique elements, we get the standard 3! = 6 permutations."
explanation:
intuition: |
Imagine you have a set of letter tiles, some of which are identical. You want to arrange them in every possible order, but you don't want to count the same arrangement twice just because you swapped two identical tiles.
The core insight is that **sorting brings duplicates together**, making them easy to detect and skip. Think of it like organising your tiles alphabetically before arranging them — when you see two identical tiles next to each other, you know to use one and skip the other in the same position.
The classic permutation approach uses backtracking: at each position, try placing each available element, then recursively fill the remaining positions. The challenge with duplicates is avoiding repeated work. If we've already tried placing a `1` at position 0, we shouldn't try placing the *other* `1` at position 0 — it would produce the same permutations.
By sorting first and then skipping duplicates that would lead to repeated branches, we prune the search tree efficiently. We only use a duplicate element if we've already used the one before it in our current path.
approach: |
We solve this using **Backtracking with Duplicate Skipping**:
**Step 1: Sort the input array**
- Sorting groups duplicates together: `[1, 2, 1]` becomes `[1, 1, 2]`
- This makes it easy to detect when we're about to make the same choice twice
&nbsp;
**Step 2: Set up backtracking state**
- `result`: List to store all unique permutations
- `current`: The permutation being built
- `used`: Boolean array tracking which elements are already in `current`
&nbsp;
**Step 3: Define the backtracking function**
- **Base case**: If `current` has `n` elements, we found a complete permutation — add a copy to `result`
- **Recursive case**: For each index `i` in `nums`:
- Skip if `used[i]` is `True` (element already in current permutation)
- Skip if `nums[i] == nums[i-1]` AND `used[i-1]` is `False` (duplicate pruning)
- Otherwise: mark `used[i] = True`, add `nums[i]` to `current`, recurse, then backtrack
&nbsp;
**Step 4: The duplicate pruning logic**
- The condition `nums[i] == nums[i-1] and not used[i-1]` means: "this element equals its predecessor, and the predecessor isn't currently in our path"
- This ensures we only use the *first* occurrence of a duplicate value at each decision point
- If the predecessor IS used, we're building a permutation that legitimately needs both duplicates
&nbsp;
**Step 5: Return all collected permutations**
- After backtracking completes, `result` contains all unique permutations
common_pitfalls:
- title: Forgetting to Sort
description: |
The duplicate-skipping logic relies on duplicates being adjacent. Without sorting, `[1, 2, 1]` won't have the duplicate `1`s next to each other, and the skip condition `nums[i] == nums[i-1]` won't work.
Always sort first: `nums.sort()` before starting backtracking.
wrong_approach: "Skip duplicates without sorting"
correct_approach: "Sort first, then skip adjacent duplicates"
- title: Wrong Duplicate Skip Condition
description: |
A common mistake is checking `nums[i] == nums[i-1]` without the `not used[i-1]` part. This would skip valid permutations that legitimately use both duplicate values.
For `[1, 1, 2]`, we *need* both `1`s in the permutation. The condition `not used[i-1]` ensures we only skip when the duplicate would create a *redundant branch*, not when we're building a permutation that requires both.
wrong_approach: "Skip whenever nums[i] == nums[i-1]"
correct_approach: "Skip only when nums[i] == nums[i-1] AND used[i-1] is False"
- title: Using a Set for Deduplication
description: |
You might think "just use a set to store results and remove duplicates". While this works, it's inefficient:
- Converting lists to tuples for set storage has overhead
- You generate duplicate permutations only to discard them
- With many duplicates, you waste significant computation
The sorting + skip approach prunes duplicates *before* generating them, which is far more efficient.
wrong_approach: "Generate all permutations, deduplicate with a set"
correct_approach: "Prune duplicate branches during backtracking"
- title: Forgetting to Copy the Current Permutation
description: |
When adding to results, use `result.append(current[:])` or `result.append(list(current))`, not `result.append(current)`.
The `current` list is mutated during backtracking. If you append the reference directly, all entries in `result` will point to the same (eventually empty) list.
key_takeaways:
- "**Sorting enables efficient duplicate handling**: Bringing duplicates together lets you detect and skip them with a simple adjacent comparison"
- "**The skip condition has two parts**: `nums[i] == nums[i-1]` detects duplicates, but `not used[i-1]` ensures we only skip redundant branches, not legitimate uses of both duplicates"
- "**Prune early, not late**: Avoiding duplicate work during generation is more efficient than deduplicating results afterward"
- "**Backtracking template**: This pattern (choose, explore, unchoose) with constraint checking applies to many combinatorial problems like N-Queens, Sudoku, and subset generation"
time_complexity: "O(n! * n). In the worst case (all unique elements), we generate n! permutations, each taking O(n) time to copy. With duplicates, the actual count is lower: n! / (k1! * k2! * ...) where ki is the count of each duplicate value."
space_complexity: "O(n). The recursion depth is at most n, and we use a `used` array of size n. The output space for storing permutations is O(n! * n) but is typically not counted as auxiliary space."
solutions:
- approach_name: Backtracking with Duplicate Skipping
is_optimal: true
code: |
def permute_unique(nums: list[int]) -> list[list[int]]:
result = []
nums.sort() # Sort to bring duplicates together
used = [False] * len(nums)
def backtrack(current: list[int]) -> None:
# Base case: found a complete permutation
if len(current) == len(nums):
result.append(current[:]) # Append a copy
return
for i in range(len(nums)):
# Skip if already used in current permutation
if used[i]:
continue
# Skip duplicate: same value as previous, but previous not used
# This means we'd be starting a redundant branch
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
# Choose: add nums[i] to current permutation
used[i] = True
current.append(nums[i])
# Explore: recurse to fill remaining positions
backtrack(current)
# Unchoose: backtrack to try other options
current.pop()
used[i] = False
backtrack([])
return result
explanation: |
**Time Complexity:** O(n! * n) — We generate up to n! permutations (fewer with duplicates), each requiring O(n) to copy.
**Space Complexity:** O(n) — Recursion stack depth is n, plus the `used` array of size n.
The key optimisation is the duplicate skip condition on line 17. By sorting first, duplicates are adjacent. When we encounter a duplicate that's the same as its unused predecessor, we skip it — the predecessor will handle all permutations starting with that value.
- approach_name: Backtracking with Counter
is_optimal: true
code: |
from collections import Counter
def permute_unique(nums: list[int]) -> list[list[int]]:
result = []
counter = Counter(nums) # Count occurrences of each number
def backtrack(current: list[int]) -> None:
# Base case: permutation complete
if len(current) == len(nums):
result.append(current[:])
return
# Try each unique number that still has remaining count
for num in counter:
if counter[num] > 0:
# Choose: use one instance of this number
current.append(num)
counter[num] -= 1
# Explore
backtrack(current)
# Unchoose: restore count
current.pop()
counter[num] += 1
backtrack([])
return result
explanation: |
**Time Complexity:** O(n! * n) — Same as the sorting approach.
**Space Complexity:** O(n) — Counter uses O(k) space where k is the number of unique elements, plus O(n) recursion depth.
This alternative approach uses a Counter to track available elements. By iterating over unique keys rather than indices, we naturally avoid considering the same value twice at the same decision point. Each unique value is tried exactly once per recursive level.
- approach_name: Brute Force with Set Deduplication
is_optimal: false
code: |
def permute_unique(nums: list[int]) -> list[list[int]]:
result_set = set()
def backtrack(current: list[int], remaining: list[int]) -> None:
if not remaining:
# Convert to tuple for set storage
result_set.add(tuple(current))
return
for i in range(len(remaining)):
# Choose element at index i
backtrack(
current + [remaining[i]],
remaining[:i] + remaining[i + 1:]
)
backtrack([], nums)
# Convert tuples back to lists
return [list(perm) for perm in result_set]
explanation: |
**Time Complexity:** O(n! * n) — Generates all n! permutations regardless of duplicates, plus set operations.
**Space Complexity:** O(n! * n) — Stores all permutations as tuples in a set.
This naive approach generates all permutations and relies on set deduplication. While correct, it wastes computation by generating duplicate permutations that are later discarded. For input like `[1,1,1,1,1,1,1,1]` (8 identical elements), this generates 8! = 40,320 permutations but keeps only 1. The optimised approaches generate only the unique ones.