最长递增子序列(Longest Increasing Subsequence, LIS)问题是动态规划中的一个经典问题,目标是在给定序列中找到一个最长的子序列,该子序列中的元素是严格递增的。
例如:
- 对于序列
[10, 9, 2, 5, 3, 7, 101, 18]
,其最长递增子序列是[2, 3, 7, 101]
,长度为 4。
1. 问题定义
给定一个长度为 n
的整数序列 arr
,要找到其中的一个子序列 subsequence
,使得:
subsequence
是严格递增的。- 在所有可能的递增子序列中,
subsequence
的长度是最长的。
举例:
序列 arr = [10, 22, 9, 33, 21, 50, 41, 60, 80]
中,最长递增子序列是 [10, 22, 33, 50, 60, 80]
,长度为 6。
2. 动态规划思路
为了求解最长递增子序列问题,我们可以使用动态规划。核心思路是维护一个 dp[]
数组,其中 dp[i]
表示以 arr[i]
结尾的最长递增子序列的长度。
状态转移方程:
对于每个 i
:
-
遍历
dp[i]=max(dp[i],dp[j]+1)(j<i 且 arr[j]<arr[i])arr[0]
到arr[i-1]
,找到所有比arr[i]
小的元素arr[j]
,此时可以通过arr[j]
来扩展递增子序列。状态转移方程为:也就是说,如果
arr[i]
能接在arr[j]
之后形成更长的递增子序列,就更新dp[i]
。
初始条件:
每个位置上的元素自己都可以作为一个递增子序列的起点,因此初始化时 dp[i] = 1
。
最终结果:
最长递增子序列的长度就是 dp[]
数组中的最大值。
3. 动态规划代码实现
下面是使用动态规划求解最长递增子序列的 Java 代码:
public class LongestIncreasingSubsequence {
public static void main(String[] args) {
int[] arr = {10, 22, 9, 33, 21, 50, 41, 60, 80};
System.out.println("Length of Longest Increasing Subsequence is " + lis(arr));
}
// 动态规划方法求解LIS
public static int lis(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int n = arr.length;
// dp数组,dp[i]表示以arr[i]结尾的最长递增子序列的长度
int[] dp = new int[n];
// 初始化,所有元素自身都可以成为递增子序列
for (int i = 0; i < n; i++) {
dp[i] = 1;
}
// 填充dp数组
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[i] > arr[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
// 返回dp数组中的最大值,即LIS的长度
int maxLIS = 0;
for (int i = 0; i < n; i++) {
maxLIS = Math.max(maxLIS, dp[i]);
}
return maxLIS;
}
}
4. 详细解读代码
4.1 输入和输出
在 main
方法中,定义了一个整数数组 arr
,然后调用 lis()
方法来计算该数组的最长递增子序列的长度,并输出结果。
4.2 lis()
函数详解
public static int lis(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int n = arr.length;
int[] dp = new int[n];
4.3 初始化 dp[]
数组
for (int i = 0; i < n; i++) {
dp[i] = 1;
}
每个元素都可以自己作为一个递增子序列,所以 dp[]
数组的初始值全部为 1。
4.4 动态规划填表过程
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[i] > arr[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
使用嵌套的 for
循环:
- 外层循环变量
i
遍历整个数组,表示当前处理的元素是arr[i]
。 - 内层循环变量
j
遍历i
之前的所有元素,检查arr[j]
是否小于arr[i]
。如果arr[i]
可以接在arr[j]
之后构成递增子序列,则通过状态转移方程dp[i] = Math.max(dp[i], dp[j] + 1)
来更新dp[i]
。
4.5 返回结果
int maxLIS = 0;
for (int i = 0; i < n; i++) {
maxLIS = Math.max(maxLIS, dp[i]);
}
最后,遍历 dp[]
数组,找出其中的最大值,作为最长递增子序列的长度。
5. 优化方法
5.1 二分查找优化
上述动态规划解法的时间复杂度为 O(n^2)
,因为每个元素都需要和之前的所有元素比较。通过使用贪心算法结合二分查找,可以将时间复杂度优化到 O(n log n)
。
优化思路是维护一个数组 tail[]
,其中 tail[i]
表示长度为 i+1
的递增子序列的最小末尾元素。对于每个元素 arr[i]
,我们可以通过二分查找找到它应该插入到 tail[]
数组中的位置,从而更新递增子序列。
5.2 优化代码实现
import java.util.Arrays;
public class LongestIncreasingSubsequence {
public static void main(String[] args) {
int[] arr = {10, 22, 9, 33, 21, 50, 41, 60, 80};
System.out.println("Length of Longest Increasing Subsequence is " + lisOptimized(arr));
}
// 二分查找+贪心优化的LIS算法
public static int lisOptimized(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int n = arr.length;
int[] tail = new int[n];
int length = 0; // 当前LIS的长度
for (int num : arr) {
int i = Arrays.binarySearch(tail, 0, length, num);
if (i < 0) {
i = -(i + 1); // 如果没找到,返回的值是(-插入点 - 1)
}
tail[i] = num; // 更新tail数组
if (i == length) {
length++; // 如果插入点在tail末尾,则递增LIS长度
}
}
return length;
}
}
5.3 优化代码详解
- 使用
tail[]
数组来记录每个递增子序列的最小末尾元素。 - 对于每个新元素
num
,通过二分查找确定它应该插入到tail[]
的位置,从而保持tail[]
中递增子序列的最优结构。 - 最终
tail[]
数组的长度就是最长递增子序列的长度。
6. 复杂度分析
- 动态规划方法:
- 时间复杂度:
O(n^2)
,因为每个元素都需要与之前的所有元素比较。 - 空间复杂度:
O(n)
,需要一个大小为n
的dp[]
数组。
- 时间复杂度:
- 二分查找+贪心优化方法:
- 时间复杂度:
O(n log n)
,每个元素的插入操作通过二分查找完成。 - 空间复杂度:
O(n)
,需要一个tail[]
数组。
- 时间复杂度:
7. 总结
最长递增子序列问题可以通过动态规划和贪心结合二分查找两种方式解决。动态规划适合理解和入门,但时间复杂度较高;贪心+二分查找方法虽然较为复杂,但在时间效率上大幅提升。LIS 问题广泛应用于序列分析、排列组合等领域。