在字符串处理的基础题型中,“判断子序列”是一道高频面试题,不仅考察对字符串遍历的基本理解,其进阶问题更能检验对海量数据场景的优化能力。本文将从题目本质出发,详解基础解法的逻辑的逻辑,再针对进阶场景提供高效优化方案。
一、题目核心解析
题目要求判断字符串 s 是否为 t 的子序列。核心定义是:子序列是通过删除原始字符串部分字符(可删除0个),且不改变剩余字符相对位置形成的新字符串。例如 "ace" 是 "abcde" 的子序列,但 "aec" 因顺序错乱不满足条件。
这里需要区分“子序列”与“子串”:子串要求字符连续,而子序列仅要求顺序一致,字符可间隔。这一差异决定了遍历策略的核心——无需追求连续匹配,只需按顺序逐个定位 s 的字符在 t 中的位置。
二、基础解法:双指针遍历
针对单组输入(即一次判断一个 s 是否为 t 的子序列),双指针是最直观、高效的解法,时间复杂度和空间复杂度均处于最优水平。
1. 解法逻辑
定义两个指针sPoint 和 tPoint,分别指向 s 和 t 的起始位置,通过遍历 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),其中m是s的长度,n是t的长度。最坏情况下需遍历完t和s,但实际遍历长度不超过n(因t遍历完后循环终止)。 -
空间复杂度:
O(1),仅使用常数个变量存储指针和字符串长度,无额外空间开销。
三、进阶问题:海量输入场景优化
题目进阶要求:当存在 k ≥ 10亿 个输入字符串 S1, S2, ..., Sk 时,如何依次判断它们是否为 T 的子序列?
若沿用基础解法,对每个Si 都遍历一次 T,时间复杂度将达到 O(k * n)(n 为 T 的长度)。当 k = 10亿 时,该方案完全不可行,需通过“预处理T”来降低单次查询的时间复杂度。
1. 优化核心思路
核心在于“空间换时间”:提前对 T 进行预处理,构建一个字典(或数组),存储每个字符在 T 中出现的所有位置。之后对每个 Si,通过二分查找快速定位字符在 T 中的位置,避免重复遍历 T。
2. 预处理步骤
假设字符集为 ASCII 字符(可扩展至 Unicode),构建一个二维数组 preprocess,其中 preprocess[c] 存储字符 c 在 T中出现的索引列表(按顺序排列)。
示例:若 T = "abcde",则:
-
preprocess['a'] = [0] -
preprocess['b'] = [1] -
preprocess['c'] = [2] -
preprocess['d'] = [3] -
preprocess['e'] = [4]
3. 单次查询逻辑(二分查找)
对每个待查询的 Si,遍历其每个字符,通过二分查找在 preprocess 中找到该字符在 T 中“大于当前位置”的最小索引,逐步推进匹配位置:
-
初始化当前匹配位置
pos = -1(表示上一个字符在T中匹配到的索引); -
对
Si的每个字符c:-
若
preprocess[c]为空,说明T中无该字符,Si不是子序列,返回false; -
在
preprocess[c]中二分查找大于pos的最小索引newPos; -
若找不到这样的
newPos,返回false;否则更新pos = newPos,继续匹配下一个字符;
-
-
遍历完
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)(n为T长度),空间复杂度O(n)(存储所有字符的位置列表); -
单次查询阶段:时间复杂度
O(m * log k)(m为Si长度,k为对应字符在T中的出现次数)。
当 k ≥ 10亿 时,预处理仅需执行一次,后续单次查询的时间复杂度被大幅降低,整体效率远优于基础解法。
四、两种方案的适用场景
-
基础双指针解法:适用于单组输入或少量输入场景,无需预处理,实现简单,空间开销小;
-
预处理+二分查找解法:适用于海量输入场景,预处理一次后可支持高频查询,通过空间换时间提升整体效率。
五、总结
LeetCode 392 题的核心是对“子序列”定义的理解,基础解法通过双指针遍历高效解决单组查询问题,而进阶场景则需要跳出“逐一遍历”的思维,通过预处理和二分查找优化海量查询的效率。在实际面试中,不仅要能写出基础解法,还需理解进阶优化的逻辑,结合场景选择合适的方案,这也是考察工程师问题分析和优化能力的关键。