最长递增子序列
大家好我是🍚🐲,今天要图解的是求 最长递增子序列。 我们将从求子问题的候选者数组一步步优化到,反向链表+数组 的最优解法,最终算法时间复杂度为 O(nlogn)。
定义
在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大
注意这里序列指的是下标
例如 [1,2,7,8,3,4,5]
的最长递增子序列 不是 [1,2,3,4,5]
而是 [0,1,4,5,6]
但为理解方便我们先求 最长递增值数组 [1,2,3,4,5]
,再改造算法求最长递增子序列
下文将简称 最长递增值数组 为 maxInc
思路
求 n-1 长度数组的最长递增值数组(maxInc)
通常这种问题,需要转换为子问题求解,求 n-1 长度数组的 maxInc,
但会碰到如下情况:
以 [1,2,7,8,3,4,5]
为例
取前4项
[1,2,7,8]
的 maxInc 就是其本身;
取前5项
[1,2,7,8,3]
的 maxInc 还是 [1,2,7,8]
,但是 3
这一项数据却属于最终的 maxInc 中
因此除了要得到子问题 的 maxInc。还要保存新增项 3
的候选可能性
maxInc 的候选数组
由上面的问题可知只保存子问题的 maxInc 是不够的
那么 保存子问题 所有长度 的值递增数组,不就把 3
的候选可能性保留下来了
前 4 项候选数组 | 前 5 项候选数组 |
---|---|
[1] [1 , 2] [1 , 2 , 7] [1 , 2 , 7 , 8] | [1] [1 , 2] [1 , 2 , 3] [1 , 2 , 7 , 8] |
如上表所示,只要从上到下遍历候选者数组,找到最后一个尾部值小于
3
的候选者: [1,2]
构成新的候选者 [1,2,3]
,并替换下一个候选者 [1,2,7]
(因为尾部值越小越容易连接后续项)
这样就处理完了 前5项
子问题
思路总结
- 维护一个 maxInc 的候选数组,在求子问题的过程中保持每个长度的候选者的尾项值尽可能小
- 完成原数组的遍历后,最后一个候选者就是 macInc
接下来我们看看完整的候选数组构建流程
候选数组构建的逐流程图解
优化空间复杂度
目前这个算法使用 2 维度数组 存储候选者
意味着如果原数组
是一个单调递增数组,二维数组 空间复杂度就是 S(n²)
那么如何减少空间复杂度呢?
看图找公共部分
来看看在上面例子中,处理“前四项”子问题时二维数组中的公共部分(蓝色区域)
,显然这些 1
和 2
是非常多余的
使用节点来表示
提到“公共部分”,那么最容易想到的数据结构是 节点、链表、图
把上面的每个数据项换成节点,可得下图
当然你会问,为什么不是树,而是反向链表(图),
如果存的是一颗树,我们在最后找最长路径需要遍历整颗树
,增加了时间复杂度
而反向链表,只需用变量记录最长候选者的尾项
,在最后反向遍历一遍即可获取 maxInc
构建候选者尾项数组
在二维数组的实现当中,我们需要找到 最后一个尾项小于新增项的候选者
,使用了二分查找
因此在构建反向链表的同时,我们还需要一个数组来存储所有候选者的尾项
下图橙色部分就是尾项数组
用什么表示反向链表(图)
假定原数组
的值不重复,那么我们可以直接以值 构建一个 当前节点
->上一个节点
映射表来表示反向链表
代码
红蓝二分查找
因为算法中需要求 最后一个尾项小于目标值的候选者
和普通的等值的二分查找略有不同。我们采用红蓝二分查找。简单了解一下红蓝染色法的核心原则
二分过程中始终保持:
[0, start] 闭区间始终满足目标条件
[end, length - 1] 闭区间始终不满足条件
🌰图解:从 [1,2,7,8]
找 最后一个小于 3
的值: 2
红蓝二分代码
/**
* [0,start] 满足条件
* [end,length-1] 不满足条件
*/
function lastFit<T>(arr: T[], fit: (midV: T) => boolean) {
const len = arr.length;
let start = -1;
let end = len;
while (start + 1 < end) {
const mid = (start + end) >> 1;
const midV = arr[mid];
// 满足条件将, 且数组具有单调性,将 start 扩充至 mid
if (fit(midV)) {
start = mid;
}
// 不满足条件将,将 end 扩充至 mid
else {
end = mid;
}
}
return start;
}
// 2
console.log(lastFit([1,2,7,8], (midV) => midV < 3));
最长递增值数组
export function maxInc(arr: number[]) {
// 反向链表
const trainMap: Record<number, number> = {};
// 候选者尾项数组
const lastItems = [arr[0]];
for (let i = 1; i < arr.length; i++) {
const value = arr[i];
const maxValue = lastItems[lastItems.length - 1];
// 当前项比 记录的最大项值还大,直接放到末尾 [1←2←7] => [1←2←7 8]
if (maxValue < value) {
lastItems.push(value);
// 前←后 7←8,[1←2←7←8]
trainMap[value] = maxValue;
continue;
}
// 找最后一个尾项小于目标值的候选者,红蓝二分查找看上面实现,
// [1←2←7←8] value:3 => found:2
const lastSmallI = lastFit(lastItems, (midV) => midV < value);
if (lastSmallI !== -1) {
// value:3 插入到 found:2 之后
// [1 ← 2 ← 7 ← 8] [1 ← 2 3 8]
// => ↑ ↓
// ← 7 ←
lastItems[lastSmallI + 1] = value;
// 前←后 2←3
// [1 ← 2 ← 3 8]
// ↑ ↓
// ← 7 ←
const lastSmall = lastItems[lastSmallI];
trainMap[value] = lastSmall;
}
// 找不到任何一项比其小的值,则将第 0 项更新为这个更小的值
else {
lastItems[0] = value;
}
}
const len = lastItems.length;
// lastItems 尾项就是 maxInc 的最后一项,不需要动
// 根据 trainMap 挨个查找 最长递增值数组项,回填到 lastItems 中即可得到 maxInc
for (let i = len - 2; i >= 0; i--) {
const item = trainMap[lastItems[i + 1]];
lastItems[i] = item;
}
return lastItems;
}
// [1,2,3,4,5]
console.log(maxInc([1,2,7,8,3,4,5]));
最长递增子序列
我们只需要把 trainMap
换成 index 映射表,lastItems
替换为对应 index 数组即可
注意过程中的“值比较”部分要转换
export function maxIncSequence(arr: number[]) {
// 反向链表
const trainMap: Record<number, number> = {};
// 候选者尾项idx数组
const lastItems = [0];
for (let i = 1; i < arr.length; i++) {
const value = arr[i];
const maxValueI = lastItems[lastItems.length - 1]
const maxValue = arr[maxValueI];
// 当前项比 记录的最大项值还大,直接放到末尾 [1←2←7] => [1←2←7 8]
if (maxValue < value) {
lastItems.push(i);
// 前←后 7←8,[1←2←7←8]
trainMap[i] = maxValueI;
continue;
}
// 找最后一个尾项小于目标值的候选者,红蓝二分查找看上面实现,
// [1←2←7←8] value:3 => found:2
const lastSmallI = lastFit(lastItems, (index) => (arr[index] < value));
if (lastSmallI !== -1) {
// value:3 插入到 found:2 之后,这里插的是 index
// [1 ← 2 ← 7 ← 8] [1 ← 2 3 8]
// => ↑ ↓
// ← 7 ←
lastItems[lastSmallI + 1] = i;
// 前←后 2←3,构建 index 反向链表
// [1 ← 2 ← 3 8]
// ↑ ↓
// ← 7 ←
const lastSmall = lastItems[lastSmallI];
trainMap[i] = lastSmall;
}
// 找不到任何一项比其小的值,则第 0 项更新为这个更小的值
else {
lastItems[0] = i;
}
}
const len = lastItems.length;
// lastItems 尾项就是 maxInc 的最后一项,不需要动
// 根据 trainMap 挨个查找反向链表,回填到 lastItems 中即可得到最长递增子序列
for (let i = len - 2; i >= 0; i--) {
const item = trainMap[lastItems[i + 1]];
lastItems[i] = item;
}
return lastItems;
}
// [0,1,4,5,6]
console.log(maxIncSequence([1,2,7,8,3,4,5]));
时间复杂度计算
把代码循环框架单独拎出来看看时间复杂度
-
第一层求解子问题循环,遍历整个数组显然复杂度就是
O(n)
; -
第二层二分查找循环,每次遍历的范围是上一次的一半,
假设候选者数组长度是 8,则最多循环3次,
2^3 = 8
,把 8 换成 n,
2^x=n
->x=logn
(这里log指以2为底的对数) -
因此整个算法的时间复杂度为 O(nlogn)
function maxIncSequence(arr: number[]) {
const lastItems = [0];
// 求解 n-1 子问题,时间复杂度 O(n)
for(let i = 1; i < arr.length; i++) {
// 二分查找,时间复杂度 O(nlogn),找最后一项尾巴值小于新增项的候选者
let start = 0, end = lastItems.length-1;
while(start < end) {
...
}
...
}
}