मुख्य कंटेंट तक स्किप करें
Ajay Dhangar
EditReport

Longest Increasing Subsequence (LIS)

The Longest Increasing Subsequence (LIS) problem is a foundational paradigm in sequence optimization and dynamic programming. The objective is formalized as follows:

Given an integer array arr\text{arr} of length nn, determine the maximum length of a subsequence such that all elements are sorted in a strictly increasing order.

A subsequence is derived by deleting zero or more elements from the original array while preserving the relative spatial ordering of the remaining elements.

Formalized Example

Consider the sequence:

arr=[10,9,2,5,3,7,101,18]\text{arr} = [10, 9, 2, 5, 3, 7, 101, 18]

Valid strictly increasing subsequences include:

  • [2,5,7,101]    length=4[2, 5, 7, 101] \implies \text{length} = 4
  • [2,3,7,101]    length=4[2, 3, 7, 101] \implies \text{length} = 4
  • [2,5,7,18]    length=4[2, 5, 7, 18] \implies \text{length} = 4
  • [10,101]    length=2[10, 101] \implies \text{length} = 2

Optimal Evaluation: 44


Combinatorial Brute Force Intuition

A naive approach evaluates the entire state space of subsequences. At each index ii, a decision boundary emerges: either include arr[i]\text{arr}[i] (conditioned on it being strictly greater than the previously selected element) or exclude it.

This generates a state-space tree bounded by the power set of the array, yielding a total of 2n2^n possible subsets. The resulting time complexity is O(2n)\mathcal{O}(2^n), which is computationally intractable for inputs where n>30n > 30. This bottleneck motivates optimization via Dynamic Programming and Greedy/Binary Search strategies.


Approach 1: The Quadratic Dynamic Programming Model O(n2)\mathcal{O}(n^2)

Mathematical Formulation

Let dp[i]\text{dp}[i] represent the length of the LIS whose terminal element resides precisely at index ii.

For any given index ii, we scan all historical states jj where 0j<i0 \le j < i. If arr[j]<arr[i]\text{arr}[j] < \text{arr}[i], the element at ii can structurally extend the optimal subsequence terminating at jj.

Recurrence Relation

dp[i]=1+max0j<i,arr[j]<arr[i]{dp[j]}\text{dp}[i] = 1 + \max_{0 \le j < i, \, \text{arr}[j] < \text{arr}[i]} \{ \text{dp}[j] \}

Base Case:dp[i]=1i[0,n1]\text{Base Case:} \quad \text{dp}[i] = 1 \quad \forall \quad i \in [0, n-1]

Global Solution:LIS=max0i<n{dp[i]}\text{Global Solution:} \quad \text{LIS} = \max_{0 \le i < n} \{ \text{dp}[i] \}


Step-by-Step Execution Matrix

Input Instance: arr=[3,10,2,1,20]\text{arr} = [3, 10, 2, 1, 20]
Initialization: dp=[1,1,1,1,1]\text{dp} = [1, 1, 1, 1, 1]

Target Index (ii)Element (arr[i]\text{arr}[i])Valid Lookback Constraints Checked (j<ij < i)Candidate Evaluations (dp[j]+1\text{dp}[j] + 1)State Mutation (dp[i]\text{dp}[i])
03NoneNone1
110j=0:3<10 j=0: 3 < 10 \ \checkmarkdp[0]+1=2\text{dp}[0] + 1 = 22
22j=0:32; j=1:102j=0: 3 \not< 2; \ j=1: 10 \not< 2None1
31j=0,1,2:all elements1j=0, 1, 2: \text{all elements} \ge 1None1
420j=0:3<20 j=0: 3 < 20 \ \checkmark
j=1:10<20 j=1: 10 < 20 \ \checkmark
j=2:2<20 j=2: 2 < 20 \ \checkmark
j=3:1<20 j=3: 1 < 20 \ \checkmark
max(1+1,2+1,1+1,1+1)\max(1+1, 2+1, 1+1, 1+1)3

Final State Array: dp=[1,2,1,1,3]    max(dp)=3 (Subsequence: [3,10,20])\text{Final State Array: } \text{dp} = [1, 2, 1, 1, 3] \implies \max(\text{dp}) = 3 \, \text{ (Subsequence: } [3, 10, 20]\text{)}


Production Implementations

#include <iostream>
#include <vector>
#include <algorithm>

int lisQuadratic(const std::vector<int>& arr) {
const size_t n = arr.size();
if (n == 0) return 0;

std::vector<int> dp(n, 1);

for (size_t i = 1; i < n; ++i) {
for (size_t j = 0; j < i; ++j) {
if (arr[j] < arr[i]) {
dp[i] = std::max(dp[i], dp[j] + 1);
}
}
}
return *std::max_element(dp.begin(), dp.end());
}

int main() {
std::vector<int> arr = {10, 9, 2, 5, 3, 7, 101, 18};
std::cout << "LIS Length: " << lisQuadratic(arr) << "\n";
return 0;
}

import java.util.Arrays;

public class LongestIncreasingSubsequence {
public static int lisQuadratic(int[] arr) {
if (arr == null || arr.length == 0) return 0;

int n = arr.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int maxLIS = 1;

for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLIS = Math.max(maxLIS, dp[i]);
}
return maxLIS;
}

public static void main(String[] args) {
int[] arr = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println("LIS Length: " + lisQuadratic(arr));
}
}

from typing import List

def lis_quadratic(arr: List[int]) -> int:
if not arr:
return 0

n = len(arr)
dp = [1] * n

for i in range(1, n):
for j in range(i):
if arr[j] < arr[i]:
dp[i] = max(dp[i], dp[j] + 1)

return max(dp)

if __name__ == "__main__":
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"LIS Length: {lis_quadratic(arr)}")

function lisQuadratic(arr) {
if (!arr || arr.length === 0) return 0;

const n = arr.length;
const dp = new Array(n).fill(1);
let maxLIS = 1;

for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (arr[j] < arr[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLIS = Math.max(maxLIS, dp[i]);
}
return maxLIS;
}

const arr = [10, 9, 2, 5, 3, 7, 101, 18];
console.log(`LIS Length: ${lisQuadratic(arr)}`);


Approach 2: The Log-Linear Greedy Binary Search Paradigm O(nlogn)\mathcal{O}(n \log n)

Operational Core

To scale past quadratic limits, we optimize retrieval through a dynamic array, tails\text{tails}. Let tails[k]\text{tails}[k] be defined as the monotonically minimal tail value found among all encountered increasing subsequences of length k+1k + 1.

For each sequential element xarrx \in \text{arr}:

  1. Append Criterion: If xx is strictly greater than the current maximum value in tails\text{tails} (the final array position), append xx. This signals a structural increment to the global LIS length.
  2. Substitution Criterion: If xx is bounded within or below the sequence, find the lowest index mm such that tails[m]x\text{tails}[m] \ge x using binary search. Mutate this position to xx.

Design Invariant: Updating an entry to a smaller value does not immediately alter the verified max length of the LIS, but optimizing the boundary values lowers the threshold for upcoming elements to build upon the sequence.


Step-by-Step State Mutation Trace

Input Sequence: arr=[10,9,2,5,3,7,101,18]\text{arr} = [10, 9, 2, 5, 3, 7, 101, 18]

Initial State Matrix: tails=[]\text{Initial State Matrix: } \text{tails} = [\,]

Step (ii)Current Input (xx)Initial State Vector (tails\text{tails})Algorithmic Routing Engine DecisionsResulting Vector State (tails\text{tails})
110[]Vector empty     \implies push xx[10]
29[10]Lower-bound index 0    0 \implies overwrite 1010 with 99[9]
32[9]Lower-bound index 0    0 \implies overwrite 99 with 22[2]
45[2]5>2    5 > 2 \implies append to vector terminal[2, 5]
53[2, 5]Lower-bound index 1    1 \implies overwrite 55 with 33[2, 3]
67[2, 3]7>3    7 > 3 \implies append to vector terminal[2, 3, 7]
7101[2, 3, 7]101>7    101 > 7 \implies append to vector terminal[2, 3, 7, 101]
818[2, 3, 7, 101]Lower-bound index 3    3 \implies overwrite 101101 with 1818[2, 3, 7, 18]

Terminal Size Measurement: tails=4    LIS Length=4\text{Terminal Size Measurement: } |\text{tails}| = 4 \implies \text{LIS Length} = 4

Crucial Invariant Property: The structure tails=[2,3,7,18]\text{tails} = [2, 3, 7, 18] acts purely as a tracking mechanism for lengths; it does not track the actual sequence order. Here, the correct length 4 is found, but the true underlying sequence paths are [2,5,7,101][2, 5, 7, 101] or [2,3,7,101][2, 3, 7, 101].


Production Implementations

#include <iostream>
#include <vector>
#include <algorithm>

int lisLogLinear(const std::vector<int>& arr) {
std::vector<int> tails;
tails.reserve(arr.size());

for (const int x : arr) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) {
tails.push_back(x);
} else {
*it = x;
}
}
return static_cast<int>(tails.size());
}

int main() {
std::vector<int> arr = {10, 9, 2, 5, 3, 7, 101, 18};
std::cout << "LIS Length: " << lisLogLinear(arr) << "\n";
return 0;
}

import java.util.ArrayList;
import java.util.List;

public class LISOptimized {
private static int executeLowerBound(List<Integer> list, int target) {
int low = 0;
int high = list.size();

while (low < high) {
int mid = low + ((high - low) >>> 1);
if (list.get(mid) < target) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}

public static int lisLogLinear(int[] arr) {
if (arr == null || arr.length == 0) return 0;

List<Integer> tails = new ArrayList<>(arr.length);
for (int x : arr) {
int index = executeLowerBound(tails, x);
if (index == tails.size()) {
tails.add(x);
} else {
tails.set(index, x);
}
}
return tails.size();
}

public static void main(String[] args) {
int[] arr = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println("LIS Length: " + lisLogLinear(arr));
}
}

import bisect
from typing import List

def lis_log_linear(arr: List[int]) -> int:
if not arr:
return 0

tails: List[int] = []
for x in arr:
idx = bisect.bisect_left(tails, x)
if idx == len(tails):
tails.append(x)
else:
tails[idx] = x

return len(tails)

if __name__ == "__main__":
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"LIS Length: {lis_log_linear(arr)}")

function executeLowerBound(arr, target) {
let low = 0;
let high = arr.length;

while (low < high) {
const mid = low + ((high - low) >> 1);
if (arr[mid] < target) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}

function lisLogLinear(arr) {
if (!arr || arr.length === 0) return 0;

const tails = [];
for (const x of arr) {
const idx = executeLowerBound(tails, x);
if (idx === tails.length) {
tails.push(x);
} else {
tails[idx] = x;
}
}
return tails.length;
}

const arr = [10, 9, 2, 5, 3, 7, 101, 18];
console.log(`LIS Length: ${lisLogLinear(arr)}`);


Architectural Comparison Matrix

Dimensional CriteriaBrute Force PatternClassical DP ModelLog-Linear Binary Search
Time ComplexityO(2n)\mathcal{O}(2^n)O(n2)\mathcal{O}(n^2)O(nlogn)\mathcal{O}(n \log n)
Space ComplexityO(n)\mathcal{O}(n)O(n)\mathcal{O}(n)O(n)\mathcal{O}(n)
Sequence RecoveryTrivialBuilt-in via backtracking pointersComplex (requires secondary mapping trackers)
Application SuitabilityNon-viableIdeal for moderate array scales (n104n \le 10^4)Essential for large systems (n106n \le 10^6)

Reconstructing the Structural Subsequence Path

To retrieve the exact elements that form the sequence rather than just computing its scalar length, we introduce a tracking array parent[i]\text{parent}[i] into the classical dynamic programming engine. This acts as a reverse pointer system.

from typing import List, Tuple

def reconstruct_lis(arr: List[int]) -> Tuple[int, List[int]]:
if not arr:
return 0, []

n = len(arr)
dp = [1] * n
parent = [-1] * n

for i in range(1, n):
for j in range(i):
if arr[j] < arr[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
parent[i] = j

# Locate the peak of the tracking array
max_len = max(dp)
current_idx = dp.index(max_len)

# Reconstruct back to the initial node tracking points
sequence = []
while current_idx != -1:
sequence.append(arr[current_idx])
current_idx = parent[current_idx]

return max_len, sequence[::-1]

if __name__ == "__main__":
arr = [3, 10, 2, 1, 20]
length, path = reconstruct_lis(arr)
print(f"Verified Max Length: {length}") # Output: 3
print(f"Reconstructed Path: {path}") # Output: [3, 10, 20]


Boundary Edge Cases and Failure Modes

Strict vs. Non-Strict Monotonic Boundaries

  • Error: Applying a non-strict inequality operator (\le) instead of a strict less-than constraint (<<).
  • Fix: Ensure execution matches problem specifications. For strict tracking conditions, enforce arr[j]<arr[i]\text{arr}[j] < \text{arr}[i] and use lower_bound. For non-strict (non-decreasing) sequences, switch to arr[j]arr[i]\text{arr}[j] \le \text{arr}[i] and apply upper_bound.

Global Extrema Extraction Errors

  • Error: Assuming that the final entry (dp[n1]\text{dp}[n-1]) contains the total global maximum length.
  • Fix: The maximum length sequence can terminate at any point in the array. Always sweep the entire table (max0i<n{dp[i]}\max_{0 \le i < n} \{\text{dp}[i]\}) or track the running maximum inline.

Degenerate Uniform Sequences

  • Error: Code hangs, overflows, or incorrectly increments counts when arrays contain duplicate entries (e.g., [5,5,5,5][5, 5, 5, 5]).
  • Fix: Enforce clear invariant rules for matching values during binary search steps.

Technical Real-World Engineering Implementations

  • Patience Sorting Engines: The computation of minimum pile formations during sorting runs is structurally equivalent to solving the LIS problem.
  • Computational Biology (Gene Alignment): Used to isolate matching chromosomal strands or map variations by analyzing parallel long-chain patterns.
  • Multidimensional Spatial Layouts: Problems such as the Russian Doll Envelopes challenge (LeetCode #354) or Box Stacking sort data along one dimension (e.g., width) and then apply standard LIS calculations along the remaining dimensions (e.g., height) to find the optimal arrangement.
Finished reading? Mark this topic as complete.