<think>我们要求解最长递增子序列(Longest Increasing Subsequence, LIS)问题,并且要求时间复杂度为O(n log n)。
传统动态规划方法的时间复杂度是O(n^2),我们可以通过结合动态规划和二分查找的方法将时间复杂度优化到O(n log n)。
思路:
1. 我们维护一个数组`tail`,其中`tail[i]`表示长度为i+1的所有递增子序列中,结尾元素最小的那个子序列的结尾元素值。
注意:这个数组是单调递增的,因此我们可以使用二分查找。
2. 我们遍历原数组,对于每个元素,我们试图在`tail`数组中找到第一个大于等于它的元素的位置:
- 如果找到这样的位置(设为pos),说明我们可以用当前元素替换掉`tail[pos]`(因为当前元素比`tail[pos]`小,这样可以使得长度为pos+1的子序列的结尾更小,有利于后面得到更长的子序列)
- 如果没有找到(即当前元素比`tail`中所有元素都大),那么我们就在`tail`数组后面追加当前元素,这表示我们找到了一个更长的递增子序列。
3. 最终,`tail`数组的长度就是最长递增子序列的长度。
注意:这个算法只能得到最长递增子序列的长度,而不能直接得到具体的子序列(但可以通过额外记录来得到,不过题目只要求长度)。
步骤:
初始化一个数组`tail`,长度为0。
遍历原数组`nums`的每个元素`num`:
在`tail`数组中二分查找第一个大于等于`num`的元素的位置`pos`。
如果`pos`等于当前`tail`的长度(即在所有元素之后),则将`num`添加到`tail`末尾。
否则,将`tail[pos]`的值更新为`num`。
最后,`tail`的长度即为最长递增子序列的长度。
为什么是严格递增?因为我们在查找时使用“大于等于”,当遇到相等的元素时,我们也会进行替换(因为替换相等的元素并不会改变长度,但是会使得后续更有可能形成更长的序列,而且题目要求严格递增,所以相等元素不会出现在同一个子序列中,因此我们替换掉相等的元素也是安全的,实际上在严格递增的情况下,相等元素不会扩展序列长度,但替换可以保持`tail`数组的正确性)。
但是注意:严格递增的情况下,我们遇到相等的元素时不应该扩展序列长度,但是需要替换掉`tail`中第一个大于等于它的元素(也就是它自己或者比它大的第一个元素)。如果遇到相等的元素,我们替换掉相等元素,这样做的目的是为了在后续的遍历中,如果出现比当前元素稍大的数,可以形成更长的序列。例如,原序列为[1, 3, 5, 7, 3, 4]:
第一个3出现时,tail数组为[1,3,5,7];当遇到第二个3时,我们在tail中找到第一个大于等于3的元素就是3(位置1),然后将其替换为3(实际上没变,但逻辑上我们更新了该位置的最小结尾值)。然后接着遍历到4,我们在tail中找第一个大于等于4的是5(位置2),将5替换为4,此时tail变成[1,3,4,7],长度为4,但实际上的最长递增子序列可以是[1,3,5,7]或[1,3,4,7]都是长度为4。
然而,实际上第二个3出现时,我们不应该将3加入到tail中,因为严格递增要求后面的数必须大于前面的数。但是我们的做法是更新tail中与3相等的位置,这样并不会改变tail的长度,也不会影响后续的扩展(因为4大于3,所以可以替换5,从而在之后可能形成更长的序列)。
因此,算法如下(使用C语言实现):
注意:二分查找的实现,我们使用左闭右开区间。
例子:nums = [10, 9, 2, 5, 3, 7, 101, 18]
期望结果:4 ([2,5,7,101] 或 [2,3,7,101])
步骤:
初始化tail[0] = nums[0] -> tail = [10]
9: 在[10]中找第一个>=9 -> 10,替换10 -> tail=[9]
2: 在[9]中找第一个>=2 -> 9,替换9 -> tail=[2]
5: 在[2]中找第一个>=5 -> 没找到(5>2),所以添加到tail -> tail=[2,5]
3: 在[2,5]中找第一个>=3 -> 5,替换5 -> tail=[2,3]
7: 在[2,3]中找第一个>=7 -> 没找到(7>3),所以添加到tail -> tail=[2,3,7]
101: 添加到tail -> [2,3,7,101]
18: 在[2,3,7,101]中找第一个>=18 -> 101,替换101 -> [2,3,7,18]
最终长度为4。
所以算法正确。
C语言实现:
1. 使用动态数组tail,并记录其当前长度len。
2. 遍历数组,对每个元素进行二分查找。
二分查找函数:在数组tail[0..len-1]中查找第一个大于等于x的元素位置。
如果找到,返回下标;如果没找到(即x比所有元素都大),返回len(此时需要添加到末尾)。
注意:C语言中数组下标从0开始。
代码实现:
初始化:tail = (int *)malloc(n * sizeof(int)); len=0;
遍历每个元素:
如果len==0,则直接加入tail,len=1。
否则,二分查找tail[0..len-1]中第一个大于等于当前元素x的位置pos(即左边界)。
如果pos==len,则tail[len++]=x;
否则,tail[pos]=x;
最后,长度为len。
时间复杂度:O(n log n),因为每个元素进行一次二分查找(O(log n)),总共n个元素。
下面我们编写代码:
</think>
下面我将详细介绍如何使用 C 语言实现时间复杂度为 $O(n \log n)$ 的最长严格递增子序列(LIS)算法,并分析其原理和实现细节。
### 算法原理
该算法基于 **贪心 + 二分查找** 策略,核心思路是:
1. 维护一个数组 `tail`,其中 `tail[i]` 表示长度为 $i+1$ 的所有递增子序列中最小结尾元素的值
2. 通过二分查找确定每个新元素在 `tail` 数组中的位置
3. 不断更新 `tail` 数组使其保持最小结尾元素特性
4. 最终 `tail` 数组的长度即为 LIS 的长度
时间复杂度为 $O(n \log n)$:遍历数组 $O(n)$,每个元素二分查找 $O(\log n)$[^1][^2]。
### C 语言实现
```c
#include <stdio.h>
#include <stdlib.h>
int binary_search(int* arr, int len, int target) {
int left = 0, right = len;
while (left < right) {
int mid = left + (right - left) / 2;
if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
int lengthOfLIS(int* nums, int numsSize) {
if (numsSize == 0) return 0;
int* tail = (int*)malloc(numsSize * sizeof(int));
int len = 0; // 当前LIS长度
for (int i = 0; i < numsSize; i++) {
// 二分查找插入位置
int pos = binary_search(tail, len, nums[i]);
if (pos == len) {
// 当前元素比所有结尾都大,扩展序列
tail[len++] = nums[i];
} else {
// 替换现有位置的元素
tail[pos] = nums[i];
}
}
free(tail);
return len;
}
int main() {
int nums[] = {10, 9, 2, 5, 3, 7, 101, 18};
int n = sizeof(nums) / sizeof(nums[0]);
int result = lengthOfLIS(nums, n);
printf("最长递增子序列长度: %d\n", result); // 输出: 4
return 0;
}
```
### 关键步骤解析
1. **初始化**:
- `tail` 数组存储可能的递增序列结尾元素
- `len` 记录当前找到的 LIS 长度
2. **遍历处理每个元素**:
- 使用二分查找在 `tail[0..len-1]` 中找到第一个 $\geq$ 当前元素的位置
- 若位置 `pos == len`,说明当前元素可扩展序列
- 否则替换 `tail[pos]` 为当前元素,保持最小结尾特性
3. **示例分析**(输入 `[10,9,2,5,3,7,101,18]`):
| 元素 | tail 数组 | len | 操作 |
|------|---------------|-----|------------------|
| 10 | [10] | 1 | 直接添加 |
| 9 | [9] | 1 | 替换 10→9 |
| 2 | [2] | 1 | 替换 9→2 |
| 5 | [2,5] | 2 | 扩展 |
| 3 | [2,3] | 2 | 替换 5→3 |
| 7 | [2,3,7] | 3 | 扩展 |
| 101 | [2,3,7,101] | 4 | 扩展 |
| 18 | [2,3,7,18] | 4 | 替换 101→18 |
4. **为什么正确**:
- 维护的 `tail` 数组始终保持最小结尾元素
- 替换操作不影响已有序列长度,但为后续扩展创造条件
- 最终 `len` 即为 LIS 长度(实际序列可通过回溯获取)
### 复杂度分析
- **时间复杂度**:$O(n \log n)$
- $n$ 次循环,每次二分查找 $O(\log n)$
- **空间复杂度**:$O(n)$
- `tail` 数组额外空间[^1]
此算法效率远优于 $O(n^2)$ 的动态规划解法(当 $n=10^6$ 时,$n \log n \approx 2 \times 10^7$,而 $n^2=10^{12}$)[^3]。
### 扩展应用
该算法可扩展解决:
1. 最长非递减子序列(允许相等)
2. 二维偏序问题(如信封嵌套)
3. 最长递增子序列的计数问题