354. Russian Doll Envelopes
Problem Description
You have a collection of envelopes, each with a width and height. The goal is to find the maximum number of envelopes you can nest inside each other, like Russian dolls.
An envelope [w1, h1]
can fit inside another envelope [w2, h2]
only if both conditions are met:
w1 < w2
(the first envelope's width is strictly less than the second's)h1 < h2
(the first envelope's height is strictly less than the second's)
You need to determine the longest chain of envelopes where each envelope in the sequence can fit inside the next one. Envelopes cannot be rotated.
For example, if you have envelopes [[5,4], [6,4], [6,7], [2,3]]
, the maximum number you can nest is 3: [2,3] → [5,4] → [6,7]
.
The solution uses a clever approach by:
- Sorting envelopes by width (ascending) and height (descending for same width)
- Finding the longest increasing subsequence (LIS) based on heights only
The sorting strategy ensures that envelopes with the same width cannot be nested (due to descending height order), while the LIS on heights gives us the maximum nesting sequence. The algorithm uses binary search (bisect_left
) to efficiently maintain the LIS, achieving O(n log n)
time complexity.
Intuition
The key insight is recognizing this problem as a 2D version of the Longest Increasing Subsequence (LIS) problem. In standard LIS, we find the longest sequence where each element is greater than the previous one. Here, we need both dimensions (width and height) to be increasing.
The challenge is handling two dimensions simultaneously. We can't simply apply LIS on pairs because we need both width AND height to increase together. This is where the sorting strategy becomes crucial.
By sorting envelopes by width first, we ensure that as we traverse the array, we only need to worry about one dimension - the height. Once sorted by width, any valid nesting sequence must have increasing heights as well.
But there's a subtle issue: what if multiple envelopes have the same width? For example, [3,4]
and [3,5]
. They can't nest inside each other since their widths are equal, but if we only consider heights after sorting, we might incorrectly count both in our sequence.
The clever trick is to sort envelopes with the same width in descending order by height. Why? This prevents us from selecting multiple envelopes with the same width in our LIS. Since we're looking for an increasing sequence of heights, and envelopes with the same width are arranged in decreasing height order, we can only pick at most one envelope from each width group.
After this special sorting (x[0], -x[1])
, the problem reduces to finding LIS on the heights alone. The binary search optimization using bisect_left
maintains a list d
where d[i]
represents the smallest ending height of all increasing subsequences of length i+1
, allowing us to efficiently build the longest sequence.
Solution Approach
The implementation follows these steps:
Step 1: Sort the envelopes
envelopes.sort(key=lambda x: (x[0], -x[1]))
This sorts envelopes by width in ascending order. When widths are equal, it sorts by height in descending order (note the negative sign). This ensures envelopes with the same width won't both be selected in our subsequence.
Step 2: Initialize the LIS tracker
d = [envelopes[0][1]]
We create a list d
that will maintain the smallest ending heights for subsequences of different lengths. Initially, it contains just the height of the first envelope.
Step 3: Process remaining envelopes
for _, h in envelopes[1:]:
We iterate through the remaining envelopes, only caring about their heights (width is already handled by sorting).
Step 4: Update the LIS tracker
For each height h
:
-
If
h > d[-1]
: The current height is larger than all heights ind
, so we can extend our longest subsequence by appendingh
.d.append(h)
-
Otherwise: We find the leftmost position in
d
where we can placeh
to maintain the sorted order.idx = bisect_left(d, h) d[idx] = h
This replacement ensures
d[idx]
stores the smallest possible height for subsequences of lengthidx+1
.
Step 5: Return the result
return len(d)
The length of d
represents the maximum number of envelopes that can be nested.
The algorithm maintains the invariant that d[i]
is the minimum ending height among all increasing subsequences of length i+1
. By using binary search (bisect_left
), we achieve O(n log n)
time complexity instead of the O(n²)
dynamic programming approach.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through the solution with envelopes [[5,4], [6,4], [6,7], [2,3]]
.
Step 1: Sort the envelopes
-
Original:
[[5,4], [6,4], [6,7], [2,3]]
-
After sorting by
(width ascending, height descending for same width)
:[2,3]
(width=2)[5,4]
(width=5)[6,7]
(width=6, height=7)[6,4]
(width=6, height=4)
Note: The two envelopes with width=6 are sorted by height in descending order (7 before 4).
Sorted array: [[2,3], [5,4], [6,7], [6,4]]
Step 2: Extract heights and find LIS
Heights sequence: [3, 4, 7, 4]
Now we find the longest increasing subsequence on these heights:
-
Initialize
d = [3]
(first height) -
Process height 4:
- 4 > 3 (last element in d)
- Append 4:
d = [3, 4]
-
Process height 7:
- 7 > 4 (last element in d)
- Append 7:
d = [3, 4, 7]
-
Process height 4:
- 4 < 7 (last element in d)
- Find position using binary search:
bisect_left([3, 4, 7], 4) = 1
- Replace
d[1]
with 4:d = [3, 4, 7]
(no change since d[1] was already 4)
Result: len(d) = 3
The maximum nesting sequence has 3 envelopes. One valid sequence is:
[2,3]
→[5,4]
→[6,7]
The sorting strategy prevented us from incorrectly selecting both [6,7]
and [6,4]
(which have the same width and cannot nest). By placing [6,7]
before [6,4]
in our sorted array, the LIS algorithm naturally selected [6,7]
as part of the increasing sequence, while [6,4]
couldn't extend it further.
Solution Implementation
1from typing import List
2from bisect import bisect_left
3
4class Solution:
5 def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
6 # Sort envelopes by width ascending, and if widths are equal, by height descending
7 # This ensures we can't use two envelopes with the same width in our sequence
8 envelopes.sort(key=lambda envelope: (envelope[0], -envelope[1]))
9
10 # Initialize LIS (Longest Increasing Subsequence) array with the first envelope's height
11 # This array maintains the smallest tail value for each possible subsequence length
12 lis_heights = [envelopes[0][1]]
13
14 # Process remaining envelopes
15 for width, height in envelopes[1:]:
16 if height > lis_heights[-1]:
17 # If current height is larger than the largest height in LIS,
18 # we can extend the sequence
19 lis_heights.append(height)
20 else:
21 # Find the leftmost position where we can replace an existing height
22 # with the current height to maintain the LIS property
23 insertion_index = bisect_left(lis_heights, height)
24 lis_heights[insertion_index] = height
25
26 # The length of the LIS array represents the maximum number of envelopes
27 # that can be nested (Russian doll style)
28 return len(lis_heights)
29
1class Solution {
2 public int maxEnvelopes(int[][] envelopes) {
3 // Sort envelopes by width ascending, if width is same then by height descending
4 // This ensures we don't count multiple envelopes with same width
5 Arrays.sort(envelopes, (a, b) -> {
6 if (a[0] == b[0]) {
7 return b[1] - a[1]; // Same width: sort by height descending
8 }
9 return a[0] - b[0]; // Different width: sort by width ascending
10 });
11
12 int n = envelopes.length;
13
14 // dp array to store the smallest tail height for each subsequence length
15 // dp[i] represents the smallest ending height of all increasing subsequences of length i
16 int[] dp = new int[n + 1];
17 dp[1] = envelopes[0][1]; // Initialize with first envelope's height
18 int maxLength = 1; // Current maximum length of increasing subsequence
19
20 // Process remaining envelopes
21 for (int i = 1; i < n; i++) {
22 int currentHeight = envelopes[i][1];
23
24 if (currentHeight > dp[maxLength]) {
25 // Current height is larger than all existing subsequence tails
26 // Extend the longest subsequence
27 maxLength++;
28 dp[maxLength] = currentHeight;
29 } else {
30 // Find the position to replace using binary search
31 // We need to find the leftmost position where dp[pos] >= currentHeight
32 int left = 1;
33 int right = maxLength;
34
35 while (left < right) {
36 int mid = (left + right) >> 1; // Equivalent to (left + right) / 2
37 if (dp[mid] >= currentHeight) {
38 right = mid; // Search in left half including mid
39 } else {
40 left = mid + 1; // Search in right half
41 }
42 }
43
44 // After binary search, left is the position where dp[left] >= currentHeight
45 // Replace dp[left] with currentHeight to maintain the smallest tail property
46 int position = (dp[left] >= currentHeight) ? left : 1;
47 dp[position] = currentHeight;
48 }
49 }
50
51 return maxLength;
52 }
53}
54
1class Solution {
2public:
3 int maxEnvelopes(vector<vector<int>>& envelopes) {
4 // Sort envelopes by width ascending, and if widths are equal, by height descending
5 // The height descending ensures we don't mistakenly nest envelopes with same width
6 sort(envelopes.begin(), envelopes.end(), [](const auto& envelope1, const auto& envelope2) {
7 return envelope1[0] < envelope2[0] ||
8 (envelope1[0] == envelope2[0] && envelope1[1] > envelope2[1]);
9 });
10
11 int numEnvelopes = envelopes.size();
12
13 // LIS array to track the smallest ending height for each subsequence length
14 vector<int> lisHeights{envelopes[0][1]};
15
16 // Process remaining envelopes to find LIS of heights
17 for (int i = 1; i < numEnvelopes; ++i) {
18 int currentHeight = envelopes[i][1];
19
20 // If current height is larger than all previous, extend the sequence
21 if (currentHeight > lisHeights.back()) {
22 lisHeights.push_back(currentHeight);
23 }
24 else {
25 // Find the position to replace with current height using binary search
26 int replaceIndex = lower_bound(lisHeights.begin(), lisHeights.end(), currentHeight)
27 - lisHeights.begin();
28
29 // Update the height at found position
30 lisHeights[replaceIndex] = currentHeight;
31 }
32 }
33
34 // The size of LIS array represents the maximum number of nested envelopes
35 return lisHeights.size();
36 }
37};
38
1function maxEnvelopes(envelopes: number[][]): number {
2 // Sort envelopes by width ascending, and if widths are equal, by height descending
3 // The height descending ensures we don't mistakenly nest envelopes with same width
4 envelopes.sort((envelope1, envelope2) => {
5 if (envelope1[0] !== envelope2[0]) {
6 return envelope1[0] - envelope2[0];
7 }
8 return envelope2[1] - envelope1[1];
9 });
10
11 const numEnvelopes = envelopes.length;
12
13 // LIS array to track the smallest ending height for each subsequence length
14 const lisHeights: number[] = [envelopes[0][1]];
15
16 // Process remaining envelopes to find LIS of heights
17 for (let i = 1; i < numEnvelopes; i++) {
18 const currentHeight = envelopes[i][1];
19
20 // If current height is larger than all previous, extend the sequence
21 if (currentHeight > lisHeights[lisHeights.length - 1]) {
22 lisHeights.push(currentHeight);
23 } else {
24 // Find the position to replace with current height using binary search
25 const replaceIndex = lowerBound(lisHeights, currentHeight);
26
27 // Update the height at found position
28 lisHeights[replaceIndex] = currentHeight;
29 }
30 }
31
32 // The size of LIS array represents the maximum number of nested envelopes
33 return lisHeights.length;
34}
35
36// Helper function to find the leftmost position where target can be inserted
37function lowerBound(arr: number[], target: number): number {
38 let left = 0;
39 let right = arr.length;
40
41 while (left < right) {
42 const mid = Math.floor((left + right) / 2);
43 if (arr[mid] < target) {
44 left = mid + 1;
45 } else {
46 right = mid;
47 }
48 }
49
50 return left;
51}
52
Time and Space Complexity
Time Complexity: O(n log n)
The time complexity breaks down as follows:
- Sorting the envelopes takes
O(n log n)
wheren
is the number of envelopes - Iterating through the sorted envelopes takes
O(n)
time - For each envelope, we either:
- Append to the end of array
d
inO(1)
time, or - Perform binary search using
bisect_left
inO(log k)
time wherek
is the current size of arrayd
, and update the element at that index inO(1)
time
- Append to the end of array
- Since
k ≤ n
, each binary search operation is at mostO(log n)
- Overall:
O(n log n) + O(n) * O(log n) = O(n log n)
Space Complexity: O(n)
The space complexity analysis:
- The sorting operation may use
O(log n)
space for the recursion stack (depending on the sorting algorithm implementation) - Array
d
stores at mostn
elements in the worst case, requiringO(n)
space - No other significant auxiliary space is used
- Overall:
O(n)
space complexity
Common Pitfalls
Pitfall 1: Incorrect Sorting Strategy for Same Width
The Problem: A common mistake is sorting envelopes with the same width in ascending order of height instead of descending order. This would look like:
envelopes.sort(key=lambda x: (x[0], x[1])) # WRONG!
Why This Fails:
If you sort heights in ascending order for the same width, the LIS algorithm might incorrectly select multiple envelopes with the same width. For example, with envelopes [[1,2], [1,3], [1,4]]
, the algorithm would think all three can be nested, but they can't since they have the same width.
The Solution: Always sort by height in descending order when widths are equal:
envelopes.sort(key=lambda x: (x[0], -x[1])) # CORRECT
This ensures that among envelopes with the same width, only one can be selected in the increasing subsequence (since heights would be decreasing).
Pitfall 2: Using Standard LIS Without Proper Sorting
The Problem: Some might try to apply LIS directly on both dimensions or only sort by one dimension:
# Only sorting by width envelopes.sort(key=lambda x: x[0]) # Then trying to find LIS on (width, height) pairs
Why This Fails: This doesn't handle the strict inequality requirement for both dimensions. You might end up with envelopes that have equal heights or widths in your sequence.
The Solution: The two-step approach is essential:
- First, handle the width dimension through sorting
- Then, apply LIS only on the height dimension, with the special descending sort for equal widths
Pitfall 3: Misunderstanding the bisect_left
Replacement
The Problem:
Some might think we're building the actual sequence of envelopes in the lis_heights
array and be confused why we're replacing values:
# Misconception: lis_heights contains the actual sequence of envelopes
Why This Is Wrong:
The lis_heights
array doesn't store the actual subsequence. Instead, lis_heights[i]
stores the minimum possible ending height for all subsequences of length i+1
.
The Solution: Understand that this is a space-optimized approach where we only track the minimum ending values for each possible subsequence length. The actual sequence isn't preserved, but its length is, which is all we need for this problem.
How does merge sort divide the problem into subproblems?
Recommended Readings
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Sorting Summary Comparisons We presented quite a few sorting algorithms and it is essential to know the advantages and disadvantages of each one The basic algorithms are easy to visualize and easy to learn for beginner programmers because of their simplicity As such they will suffice if you don't know any advanced
Want a Structured Path to Master System Design Too? Don’t Miss This!