213 lines
9.5 KiB
YAML
213 lines
9.5 KiB
YAML
title: Partition Labels
|
|
slug: partition-labels
|
|
difficulty: medium
|
|
leetcode_id: 763
|
|
leetcode_url: https://leetcode.com/problems/partition-labels/
|
|
categories:
|
|
- strings
|
|
- hash-tables
|
|
- two-pointers
|
|
patterns:
|
|
- slug: greedy
|
|
is_optimal: false
|
|
- slug: two-pointers
|
|
is_optimal: true
|
|
|
|
function_signature: "def partition_labels(s: str) -> list[int]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { s: "ababcbacadefegdehijhklij" }
|
|
expected: [9, 7, 8]
|
|
- input: { s: "eccbbbbdec" }
|
|
expected: [10]
|
|
- input: { s: "abc" }
|
|
expected: [1, 1, 1]
|
|
hidden:
|
|
- input: { s: "a" }
|
|
expected: [1]
|
|
- input: { s: "aaa" }
|
|
expected: [3]
|
|
- input: { s: "abab" }
|
|
expected: [4]
|
|
- input: { s: "caedbdedda" }
|
|
expected: [1, 9]
|
|
- input: { s: "qiejxqfnqceocmy" }
|
|
expected: [13, 1, 1]
|
|
- input: { s: "vhaagbqkaq" }
|
|
expected: [1, 8, 1]
|
|
|
|
description: |
|
|
You are given a string `s`. We want to partition the string into as many parts as possible so that each letter appears in **at most one part**.
|
|
|
|
For example, the string `"ababcc"` can be partitioned into `["abab", "cc"]`, but partitions such as `["aba", "bcc"]` or `["ab", "ab", "cc"]` are invalid.
|
|
|
|
Note that the partition is done so that after concatenating all the parts in order, the resultant string should be `s`.
|
|
|
|
Return *a list of integers representing the size of these parts*.
|
|
|
|
constraints: |
|
|
- `1 <= s.length <= 500`
|
|
- `s` consists of lowercase English letters.
|
|
|
|
examples:
|
|
- input: 's = "ababcbacadefegdehijhklij"'
|
|
output: "[9, 7, 8]"
|
|
explanation: 'The partition is "ababcbaca", "defegde", "hijhklij". This is a partition so that each letter appears in at most one part. A partition like "ababcbacadefegde", "hijhklij" is incorrect, because it splits s into fewer parts.'
|
|
- input: 's = "eccbbbbdec"'
|
|
output: "[10]"
|
|
explanation: "All characters overlap, so the entire string must be one partition."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're reading the string from left to right and trying to cut it into pieces. The challenge is that once you've seen a character, you can't cut until you've passed its **last occurrence** in the string.
|
|
|
|
Think of it like this: each character "claims" a range from its first to its last appearance. When you encounter a character, you're committing to include everything up to where it last appears. If along the way you see other characters, they might extend your commitment even further.
|
|
|
|
The key insight is that a partition can end only when you've reached a position where **all characters seen so far have had their last occurrence**. At that moment, you know no character from this segment will appear later, so it's safe to cut.
|
|
|
|
By tracking the "furthest reach" — the maximum last occurrence of any character seen — you know exactly when each partition can end.
|
|
|
|
approach: |
|
|
We solve this using a **Greedy Two-Pass Approach**:
|
|
|
|
**Step 1: Build a last occurrence map**
|
|
|
|
- Iterate through the string once
|
|
- For each character, record its last index in a hash map
|
|
- After this pass, `last[c]` tells us the rightmost position where character `c` appears
|
|
|
|
|
|
|
|
**Step 2: Initialise partition tracking variables**
|
|
|
|
- `start`: The beginning of the current partition (initially `0`)
|
|
- `end`: The furthest we must extend the current partition (initially `0`)
|
|
- `result`: A list to store partition sizes
|
|
|
|
|
|
|
|
**Step 3: Iterate through the string again**
|
|
|
|
- For each character at index `i`, update `end` to be the maximum of its current value and `last[s[i]]`
|
|
- This "extends" our partition boundary if the current character appears later
|
|
- When `i == end`, we've reached the end of a partition:
|
|
- The current partition spans from `start` to `end`
|
|
- Append its size (`end - start + 1`) to the result
|
|
- Set `start = i + 1` for the next partition
|
|
|
|
|
|
|
|
**Step 4: Return the result**
|
|
|
|
- After processing all characters, return the list of partition sizes
|
|
|
|
|
|
|
|
This greedy approach works because at each position we're making the minimum necessary extension to ensure no character spans multiple partitions.
|
|
|
|
common_pitfalls:
|
|
- title: Trying to Partition Greedily Without Lookahead
|
|
description: |
|
|
A common mistake is trying to determine partition boundaries on a single pass without knowing where characters last appear.
|
|
|
|
For example, with `s = "aba"`, if you try to end a partition after the first `'a'`, you'll incorrectly split it as `["a", "ba"]`. But `'a'` appears again at index 2, so the entire string must be one partition: `["aba"]`.
|
|
|
|
The solution is to **first build a map of last occurrences**, giving you the lookahead information you need.
|
|
wrong_approach: "Single pass without preprocessing"
|
|
correct_approach: "Two-pass: first map last occurrences, then greedily partition"
|
|
|
|
- title: Forgetting to Extend the Partition Boundary
|
|
description: |
|
|
When iterating through a partition, you must continuously update the `end` boundary as you encounter new characters.
|
|
|
|
Consider `s = "abc"` where `last['a'] = 0`, `last['b'] = 1`, `last['c'] = 2`. If you only set `end` once at the start of each partition, you'd incorrectly create three partitions. Instead, each character might extend `end` further.
|
|
wrong_approach: "Setting end only once per partition"
|
|
correct_approach: "Update end = max(end, last[s[i]]) at every index"
|
|
|
|
- title: Off-by-One Errors in Partition Size
|
|
description: |
|
|
The size of a partition from index `start` to `end` is `end - start + 1`, not `end - start`.
|
|
|
|
For example, if a partition spans indices `0` to `8` (inclusive), the size is `9`, not `8`. Forgetting the `+ 1` will produce incorrect sizes.
|
|
|
|
key_takeaways:
|
|
- "**Preprocessing enables greedy choices**: Building a last occurrence map gives you the lookahead information needed to make optimal decisions in a single pass"
|
|
- "**Interval extension pattern**: This technique of tracking a 'furthest reach' that extends as you encounter new elements applies to many interval and partition problems"
|
|
- "**Two-pass is often necessary**: When a greedy decision depends on future information, a preprocessing pass is a common and efficient solution"
|
|
- "**Similar problems**: This pattern appears in Jump Game, Merge Intervals, and other problems involving overlapping ranges"
|
|
|
|
time_complexity: "O(n). We traverse the string twice — once to build the last occurrence map, once to determine partitions."
|
|
space_complexity: "O(1). The hash map stores at most 26 entries (one per lowercase letter), which is constant regardless of input size."
|
|
|
|
solutions:
|
|
- approach_name: Greedy with Last Occurrence Map
|
|
is_optimal: true
|
|
code: |
|
|
def partition_labels(s: str) -> list[int]:
|
|
# First pass: record last occurrence of each character
|
|
last = {}
|
|
for i, c in enumerate(s):
|
|
last[c] = i
|
|
|
|
result = []
|
|
start = 0 # Start of current partition
|
|
end = 0 # Furthest we must extend current partition
|
|
|
|
# Second pass: greedily determine partitions
|
|
for i, c in enumerate(s):
|
|
# Extend partition to include last occurrence of current char
|
|
end = max(end, last[c])
|
|
|
|
# If we've reached the end of current partition
|
|
if i == end:
|
|
# Record partition size and start new partition
|
|
result.append(end - start + 1)
|
|
start = i + 1
|
|
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Two linear passes through the string.
|
|
|
|
**Space Complexity:** O(1) — The hash map holds at most 26 keys (one per lowercase letter).
|
|
|
|
The first pass builds a map of each character's last index. The second pass uses this information to greedily extend each partition until we reach a point where all characters in the current segment have had their last occurrence. At that moment, we record the partition and start a new one.
|
|
|
|
- approach_name: Interval Merging
|
|
is_optimal: false
|
|
code: |
|
|
def partition_labels(s: str) -> list[int]:
|
|
# Build intervals: [first_occurrence, last_occurrence] for each char
|
|
intervals = {}
|
|
for i, c in enumerate(s):
|
|
if c not in intervals:
|
|
intervals[c] = [i, i]
|
|
else:
|
|
intervals[c][1] = i
|
|
|
|
# Merge overlapping intervals in order of first occurrence
|
|
sorted_intervals = sorted(intervals.values())
|
|
|
|
result = []
|
|
start, end = sorted_intervals[0]
|
|
|
|
for first, last in sorted_intervals[1:]:
|
|
if first <= end:
|
|
# Overlapping - extend the current interval
|
|
end = max(end, last)
|
|
else:
|
|
# Non-overlapping - record previous and start new
|
|
result.append(end - start + 1)
|
|
start, end = first, last
|
|
|
|
# Don't forget the last interval
|
|
result.append(end - start + 1)
|
|
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(n + k log k) where k is the number of unique characters (at most 26).
|
|
|
|
**Space Complexity:** O(k) — Storage for intervals.
|
|
|
|
This approach explicitly models the problem as interval merging. Each character defines an interval from its first to last occurrence. We then merge overlapping intervals and return the sizes of merged intervals. While conceptually clear, the sorting step adds overhead compared to the greedy approach.
|