LeetCode 392. 判断子序列:基础解法与海量输入优化策略

30 阅读6分钟

在字符串处理的基础题型中,“判断子序列”是一道高频面试题,不仅考察对字符串遍历的基本理解,其进阶问题更能检验对海量数据场景的优化能力。本文将从题目本质出发,详解基础解法的逻辑的逻辑,再针对进阶场景提供高效优化方案。

一、题目核心解析

题目要求判断字符串 s 是否为 t 的子序列。核心定义是:子序列是通过删除原始字符串部分字符(可删除0个),且不改变剩余字符相对位置形成的新字符串。例如 "ace""abcde" 的子序列,但 "aec" 因顺序错乱不满足条件。

这里需要区分“子序列”与“子串”:子串要求字符连续,而子序列仅要求顺序一致,字符可间隔。这一差异决定了遍历策略的核心——无需追求连续匹配,只需按顺序逐个定位 s 的字符在 t 中的位置。

二、基础解法:双指针遍历

针对单组输入(即一次判断一个 s 是否为 t 的子序列),双指针是最直观、高效的解法,时间复杂度和空间复杂度均处于最优水平。

1. 解法逻辑

定义两个指针sPointtPoint,分别指向 st 的起始位置,通过遍历 t 来匹配 s 的字符:

  • 当两个指针指向的字符相等时,说明匹配成功,两个指针同时后移(继续匹配 s 的下一个字符);

  • 当字符不相等时,仅移动 t 的指针(跳过 t 中不匹配的字符);

  • 遍历结束后,若 sPoint 已遍历完 s(即 sPoint === s.length),说明 s 所有字符都按顺序在 t 中找到,返回 true,否则返回 false

2. 代码实现与解析


function isSubsequence(s: string, t: string): boolean {
  const sL = s.length;
  const tL = t.length;
  let sPoint = 0; // 指向s当前待匹配的字符
  let tPoint = 0; // 指向t当前遍历的字符

  // 当两个指针都未遍历完对应字符串时,继续循环
  while (sPoint < sL && tPoint < tL) {
    // 字符匹配成功,双指针后移
    if (s.charCodeAt(sPoint) === t.charCodeAt(tPoint)) {
      sPoint++;
      tPoint++;
    } else {
      // 匹配失败,仅移动t的指针
      tPoint++;
    }
  }

  // 若s的所有字符都匹配完成,返回true
  return sPoint === sL;
};

代码优化点:最终判断可直接简化为 return sPoint === sL,无需额外的 if-else,逻辑更简洁。

3. 复杂度分析

  • 时间复杂度:O(m + n),其中 ms 的长度,nt 的长度。最坏情况下需遍历完 ts,但实际遍历长度不超过 n(因 t 遍历完后循环终止)。

  • 空间复杂度:O(1),仅使用常数个变量存储指针和字符串长度,无额外空间开销。

三、进阶问题:海量输入场景优化

题目进阶要求:当存在 k ≥ 10亿 个输入字符串 S1, S2, ..., Sk 时,如何依次判断它们是否为 T 的子序列?

若沿用基础解法,对每个Si 都遍历一次 T,时间复杂度将达到 O(k * n)nT 的长度)。当 k = 10亿 时,该方案完全不可行,需通过“预处理T”来降低单次查询的时间复杂度。

1. 优化核心思路

核心在于“空间换时间”:提前对 T 进行预处理,构建一个字典(或数组),存储每个字符在 T 中出现的所有位置。之后对每个 Si,通过二分查找快速定位字符在 T 中的位置,避免重复遍历 T

2. 预处理步骤

假设字符集为 ASCII 字符(可扩展至 Unicode),构建一个二维数组 preprocess,其中 preprocess[c] 存储字符 cT中出现的索引列表(按顺序排列)。

示例:若 T = "abcde",则:

  • preprocess['a'] = [0]

  • preprocess['b'] = [1]

  • preprocess['c'] = [2]

  • preprocess['d'] = [3]

  • preprocess['e'] = [4]

3. 单次查询逻辑(二分查找)

对每个待查询的 Si,遍历其每个字符,通过二分查找在 preprocess 中找到该字符在 T 中“大于当前位置”的最小索引,逐步推进匹配位置:

  1. 初始化当前匹配位置 pos = -1(表示上一个字符在 T 中匹配到的索引);

  2. Si 的每个字符 c

    • preprocess[c] 为空,说明 T 中无该字符,Si 不是子序列,返回false

    • preprocess[c] 中二分查找大于 pos 的最小索引 newPos

    • 若找不到这样的 newPos,返回 false;否则更新 pos = newPos,继续匹配下一个字符;

  3. 遍历完 Si 所有字符,返回 true

4. 代码实现(预处理+二分查找)


// 预处理T,构建字符位置字典
function preprocessT(T: string): Map<string, number[]> {
  const map = new Map<string, number[]>();
  for (let i = 0; i < T.length; i++) {
    const c = T[i];
    if (!map.has(c)) {
      map.set(c, []);
    }
    map.get(c)!.push(i);
  }
  return map;
}

// 二分查找:在arr中找到大于target的最小元素索引
function binarySearch(arr: number[], target: number): number {
  let left = 0;
  let right = arr.length - 1;
  let result = arr.length; // 若所有元素都小于等于target,返回length(表示找不到)
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] > target) {
      result = mid;
      right = mid - 1;
    } else {
      left = mid + 1;
    }
  }
  return result;
}

// 海量输入场景下的单次查询
function isSubsequenceBatch(s: string, preprocessedMap: Map<string, number[]>): boolean {
  let pos = -1; // 上一个字符在T中的匹配位置
  for (const c of s) {
    const indices = preprocessedMap.get(c);
    if (!indices) return false; // T中无该字符
    const idx = binarySearch(indices, pos);
    if (idx === indices.length) return false; // 找不到比pos大的索引,匹配失败
    pos = indices[idx]; // 更新当前匹配位置
  }
  return true;
}

5. 进阶方案复杂度分析

  • 预处理阶段:时间复杂度 O(n)nT 长度),空间复杂度 O(n)(存储所有字符的位置列表);

  • 单次查询阶段:时间复杂度 O(m * log k)mSi 长度,k 为对应字符在 T 中的出现次数)。

k ≥ 10亿 时,预处理仅需执行一次,后续单次查询的时间复杂度被大幅降低,整体效率远优于基础解法。

四、两种方案的适用场景

  1. 基础双指针解法:适用于单组输入或少量输入场景,无需预处理,实现简单,空间开销小;

  2. 预处理+二分查找解法:适用于海量输入场景,预处理一次后可支持高频查询,通过空间换时间提升整体效率。

五、总结

LeetCode 392 题的核心是对“子序列”定义的理解,基础解法通过双指针遍历高效解决单组查询问题,而进阶场景则需要跳出“逐一遍历”的思维,通过预处理和二分查找优化海量查询的效率。在实际面试中,不仅要能写出基础解法,还需理解进阶优化的逻辑,结合场景选择合适的方案,这也是考察工程师问题分析和优化能力的关键。