图解最长递增子序列

189 阅读7分钟

最长递增子序列

  大家好我是🍚🐲,今天要图解的是求 最长递增子序列。 我们将从求子问题的候选者数组一步步优化到,反向链表+数组 的最优解法,最终算法时间复杂度为 O(nlogn)。

这算法太棒辣.jpg

定义

在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大

注意这里序列指的是下标

例如 [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] 该项将被上一项优化
[1 , 2 , 7 , 8]

如上表所示,只要从上到下遍历候选者数组,找到最后一个尾部值小于 3 的候选者: [1,2]

构成新的候选者 [1,2,3],并替换下一个候选者 [1,2,7](因为尾部值越小越容易连接后续项)

这样就处理完了 前5项 子问题

思路总结

  1. 维护一个 maxInc 的候选数组,在求子问题的过程中保持每个长度的候选者的尾项值尽可能小
  2. 完成原数组的遍历后,最后一个候选者就是 macInc

接下来我们看看完整的候选数组构建流程

候选数组构建的逐流程图解

CleanShot 2024-11-06 at 22.59.32@2x.png

优化空间复杂度

目前这个算法使用 2 维度数组 存储候选者

意味着如果原数组是一个单调递增数组,二维数组 空间复杂度就是 S(n²)

那么如何减少空间复杂度呢?

看图找公共部分

来看看在上面例子中,处理“前四项”子问题时二维数组中的公共部分(蓝色区域) ,显然这些 12 是非常多余的

CleanShot 2024-11-07 at 00.01.10@2x.png

使用节点来表示

提到“公共部分”,那么最容易想到的数据结构是 节点、链表、图

把上面的每个数据项换成节点,可得下图

CleanShot 2024-11-07 at 00.13.35@2x.png

当然你会问,为什么不是树,而是反向链表(图),

如果存的是一颗树,我们在最后找最长路径需要遍历整颗树,增加了时间复杂度

而反向链表,只需用变量记录最长候选者的尾项,在最后反向遍历一遍即可获取 maxInc

构建候选者尾项数组

在二维数组的实现当中,我们需要找到 最后一个尾项小于新增项的候选者,使用了二分查找

因此在构建反向链表的同时,我们还需要一个数组来存储所有候选者的尾项

下图橙色部分就是尾项数组

CleanShot 2024-11-07 at 01.01.35@2x.png

用什么表示反向链表(图)

假定原数组的值不重复,那么我们可以直接以 构建一个 当前节点->上一个节点 映射表来表示反向链表

CleanShot 2024-11-07 at 00.35.39@2x.png

代码

红蓝二分查找

因为算法中需要求 最后一个尾项小于目标值的候选者 和普通的等值的二分查找略有不同。我们采用红蓝二分查找。简单了解一下红蓝染色法的核心原则

二分过程中始终保持:
[0, start] 闭区间始终满足目标条件
[end, length - 1] 闭区间始终不满足条件

🌰图解:从 [1,2,7,8] 找 最后一个小于 3 的值: 2

CleanShot 2025-01-07 at 17.37.19.png

红蓝二分代码
/**
 * [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]));

时间复杂度计算

把代码循环框架单独拎出来看看时间复杂度

  1. 第一层求解子问题循环,遍历整个数组显然复杂度就是 O(n);

  2. 第二层二分查找循环,每次遍历的范围是上一次的一半,

    假设候选者数组长度是 8,则最多循环3次,2^3 = 8

    把 8 换成 n,2^x=n -> x=logn (这里log指以2为底的对数)

  3. 因此整个算法的时间复杂度为 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) {
      ...
    }
    ...
  }
}