questions M-R
This commit is contained in:
231
backend/data/questions/permutations-ii.yaml
Normal file
231
backend/data/questions/permutations-ii.yaml
Normal file
@@ -0,0 +1,231 @@
|
||||
title: Permutations II
|
||||
slug: permutations-ii
|
||||
difficulty: medium
|
||||
leetcode_id: 47
|
||||
leetcode_url: https://leetcode.com/problems/permutations-ii/
|
||||
categories:
|
||||
- arrays
|
||||
- sorting
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
**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`
|
||||
|
||||
|
||||
|
||||
**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
|
||||
|
||||
|
||||
|
||||
**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
|
||||
|
||||
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user