What We're Solving
The Longest Increasing Subsequence (LIS) problem is a classic dynamic programming challenge frequently encountered in technical interviews. The goal is to determine the length of the longest subsequence of a given array of integers such that all elements of the subsequence are sorted in strictly increasing order.
It is important to distinguish a subsequence from a substring (or subarray). While a substring requires adjacent indices, a subsequence is derived by deleting zero or more elements from the array without changing the relative order of the remaining items.
For instance, given the array [10, 9, 2, 5, 3, 7, 101, 18], a valid increasing subsequence is [2, 3, 7, 101] or [2, 3, 7, 18], yielding a maximum length of 4. A basic dynamic programming solution solves this in O(n2) time. However, by combining dynamic programming with binary search (derived from the Patience Sorting algorithm), we can optimize this to a highly efficient O(n log n) time complexity.
To visualize the optimized binary search strategy, imagine playing a card game called Patience:
- Dealing Piles: You draw cards one by one from a deck. You place each drawn card onto the leftmost available pile whose top card is greater than or equal to the drawn card.
- Starting New Piles: If the drawn card is larger than the top card of all existing piles, you must start a new pile to the right of all active piles.
- The Magic Output: If you draw a 5, then a 3, the 3 is stacked on top of the 5. If you then draw a 7, it is larger than both, starting a new pile. By the time the entire deck is dealt, the number of piles you have created is exactly equal to the length of the longest increasing subsequence you can form from that sequence of cards.
The Approach
DP with Binary Search (O(n log n) Time, O(n) Space)
In our code, we maintain an active list (or array) sub representing the smallest tail elements of all increasing subsequences found so far. As we iterate through each number num in our input array:
- Search Position: We perform a binary search on
subto locate the insertion index ofnum. - Extension: If
numis larger than the largest tail insub, it means we can extend our longest increasing subsequence. We appendnumto the end ofsub. - Greedy Substitution: If
numfalls within the bounds ofsub, we locate the first element insubthat is greater than or equal tonumand replace it withnum.
sub array represents the length of the LIS. Note that the elements inside sub at the end of the run do not necessarily represent the actual LIS subsequence elements, but the total count is guaranteed to be correct.
Detailed Trace Walkthrough
Let's trace the execution on the input array nums = [10, 9, 2, 5, 3]:
- Step 1 (Initialization): Create an empty list:
sub = []. - Step 2 (Element: 10):
- Binary search for
10in[]returns insertion index0. - Since
index == sub.size(), we append 10 →sub = [10].
- Binary search for
- Step 3 (Element: 9):
- Binary search for
9in[10]returns insertion index0. - We replace the element at index 0 (10) with 9 →
sub = [9]. (An increasing subsequence of length 1 ending in 9 is more optimal than one ending in 10).
- Binary search for
- Step 4 (Element: 2):
- Binary search for
2in[9]returns insertion index0. - We replace 9 with 2 →
sub = [2].
- Binary search for
- Step 5 (Element: 5):
- Binary search for
5in[2]returns insertion index1. - Since
index == sub.size(), we append 5 →sub = [2, 5].
- Binary search for
- Step 6 (Element: 3):
- Binary search for
3in[2, 5]returns insertion index1. - We replace 5 with 3 →
sub = [2, 3]. (An increasing subsequence of length 2 ending in 3 is more optimal than one ending in 5).
- Binary search for
- Step 7 (Final Output):
- The loop terminates. The size of
subis2. We return2as the LIS length.
- The loop terminates. The size of
Code Breakdown
Understanding the Java implementation helper methods:
Collections.binarySearch(sub, num)returns the index of the search key if it is contained in the list. If it is not found, it returns-(insertion_point + 1).index = -(index + 1)converts a negative search result into the correct zero-indexed insertion point.sub.set(index, num)performs the greedy replacement, swapping out the larger tail value to optimize future compatibility.
Full Code Solution
Below is the complete Java implementation featuring the optimized binary search algorithm, along with a main method for trace logging.
package io.practise.dsa;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class LongestIncreasingSubsequence {
// DP with Binary Search: Time O(N log N), Space O(N)
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
List<Integer> sub = new ArrayList<>();
for (int num : nums) {
// Find the index of the first element >= num
int index = Collections.binarySearch(sub, num);
// If not found, binarySearch returns -(insertionPoint + 1)
if (index < 0) {
index = -(index + 1);
}
// If num is larger than all elements in sub, append it
if (index == sub.size()) {
sub.add(num);
} else {
// Otherwise, replace the first element that is >= num
sub.set(index, num);
}
}
return sub.size();
}
public static void main(String[] args) {
LongestIncreasingSubsequence solver = new LongestIncreasingSubsequence();
int[] nums = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println("--- Longest Increasing Subsequence Demonstration ---");
System.out.println("Input Array: " + Arrays.toString(nums));
int length = solver.lengthOfLIS(nums);
System.out.println("Length of LIS: " + length); // Expected: 4 (e.g., [2, 3, 7, 18])
}
}
Conclusion & Takeaways
Dynamic programming alone solves the LIS problem in quadratic time. By combining subproblem storage with binary search, we optimize search operations, achieving a highly efficient O(n log n) solution.