力扣解题-392. 判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
进阶: 如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
致谢: 特别感谢 @pbrother 添加此问题并且创建所有测试用例。
示例 1: 输入:s = "abc", t = "ahbgdc" 输出:true
示例 2: 输入:s = "axc", t = "ahbgdc" 输出:false
提示: 0 <= s.length <= 100 0 <= t.length <= 10^4 两个字符串都只由小写字符组成。
Related Topics 双指针、字符串、动态规划
第一次解答
解题思路
核心方法:双指针贪心匹配法,用两个指针分别遍历s和t,逐个匹配字符且保持相对顺序,是本题最基础、空间效率最优的解法,时间复杂度O(n)(n为t的长度)、空间复杂度O(1)。
核心逻辑拆解
判断子序列的核心是“按顺序匹配,不回头”:
- 用
i指针遍历s(待匹配的子序列),j指针遍历t(原始字符串); - 遍历过程中,若
s[i] == t[j],说明s的第i个字符匹配成功,i指针右移(继续匹配下一个字符); - 无论是否匹配成功,
j指针都右移(t的字符只能向后走,不能回头); - 当
i遍历完s(i == s.length()),说明s的所有字符都按顺序匹配完成,返回true; - 若
j遍历完t但i未遍历完s,说明匹配失败,返回false。
具体步骤(以示例1 s="abc",t="ahbgdc"为例)
- 初始:i=0(指向'a'),j=0(指向'a');
- s[0] == t[0] → i=1(指向'b'),j=1(指向'h');
- s[1] != t[1] → j=2(指向'b');
- s[1] == t[2] → i=2(指向'c'),j=3(指向'g');
- s[2] != t[3] → j=4(指向'd');
- s[2] != t[4] → j=5(指向'c');
- s[2] == t[5] → i=3(等于s.length()=3),循环结束,返回true。
性能说明
- 时间复杂度:O(n)(仅遍历
t一次,s的遍历次数最多等于其长度,可忽略); - 空间复杂度:O(1)(仅使用两个指针变量,无额外内存开销),内存消耗41.9MB击败98.10%用户;
- 耗时说明:2ms击败54.35%用户,是因为该解法是基础实现,无预处理优化,但逻辑简单、稳定性高;
- 边界处理:
- 若
s为空字符串,直接返回true(空字符串是任何字符串的子序列); - 若
t为空但s非空,返回false(代码中j会快速遍历完t,i未遍历完s)。
- 若
public boolean isSubsequence(String s, String t) {
int i = 0, j = 0;
while (i < s.length() && j < t.length()) {
if (s.charAt(i) == t.charAt(j)) {
i++;
}
j++;
}
return i == s.length();
}
示例解答
解题思路
解法1:预处理+二分查找(进阶场景最优解)
核心方法:预处理t的字符位置 + 二分查找匹配,针对进阶问题(大量s需要匹配同一个t),先对t做一次预处理,后续每个s的匹配时间复杂度降为O(m×logn)(m为s长度,n为t长度),大幅提升批量匹配效率。
核心原理铺垫(针对进阶场景)
当有海量s需要匹配时,双指针法需要对每个s都遍历一次t(总时间O(k×n),k为s的数量),效率极低。预处理法的核心是:
- 提前为
t中每个字符(a-z)建立“出现位置的有序列表”; - 匹配
s时,对每个字符,用二分查找在对应列表中找“大于上一个匹配位置的最小位置”,确保相对顺序。
具体步骤
- 预处理
t:- 创建长度为26的列表数组
pos,pos[c]存储字符c('a'-'z')在t中出现的所有下标,且按升序排列; - 遍历
t,将每个字符的下标添加到对应列表中(如t="ahbgdc",pos['a'-'a'] = [0],pos['b'-'a'] = [2]);
- 创建长度为26的列表数组
- 匹配
s:- 初始化
prev = -1(记录上一个匹配字符在t中的位置,初始为-1); - 遍历
s的每个字符c:- 在
pos[c-'a']中用二分查找找“大于prev的最小下标”; - 若找不到,返回
false;若找到,更新prev为该下标;
- 在
- 遍历完成后返回
true。
- 初始化
代码实现
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
public boolean isSubsequence(String s, String t) {
// 预处理t:为每个字符建立位置列表
List<Integer>[] pos = new List[26];
for (int i = 0; i < 26; i++) {
pos[i] = new ArrayList<>();
}
for (int j = 0; j < t.length(); j++) {
char c = t.charAt(j);
pos[c - 'a'].add(j);
}
int prev = -1; // 上一个匹配字符的位置
for (char c : s.toCharArray()) {
List<Integer> list = pos[c - 'a'];
// 二分查找大于prev的最小下标
int idx = Collections.binarySearch(list, prev + 1);
if (idx < 0) {
idx = -idx - 1; // 转换为插入位置
}
// 找不到符合条件的下标,匹配失败
if (idx >= list.size()) {
return false;
}
// 更新上一个位置
prev = list.get(idx);
}
return true;
}
优势说明
- 预处理成本:O(n)(仅需处理一次
t); - 单条
s匹配成本:O(m×logn)(m≤100,logn≤14),远低于双指针的O(n); - 进阶场景适配:当k≥10亿时,预处理+二分的总时间O(n + k×m×logn),远优于双指针的O(k×n),是唯一可行的方案。
解法2:动态规划法(理解子序列匹配的另一种思路)
核心方法:预处理t的跳转表,dp[i][c]表示在t的第i个位置之后,字符c第一次出现的位置,通过动态规划构建跳转表,匹配时直接查表跳转。
代码实现
public boolean isSubsequence(String s, String t) {
int n = t.length();
int m = s.length();
// dp[i][c]:t中第i个位置之后,字符c('a'-'z')第一次出现的位置
int[][] dp = new int[n + 1][26];
// 初始化最后一个位置(n)的跳转表:所有字符都为n(表示不存在)
for (int c = 0; c < 26; c++) {
dp[n][c] = n;
}
// 从后往前构建跳转表
for (int i = n - 1; i >= 0; i--) {
for (int c = 0; c < 26; c++) {
if (t.charAt(i) == 'a' + c) {
dp[i][c] = i; // 当前字符就是c,跳转位置为i
} else {
dp[i][c] = dp[i + 1][c]; // 继承下一个位置的跳转结果
}
}
}
// 匹配s
int idx = 0; // 当前在t中的位置
for (char c : s.toCharArray()) {
int cIdx = c - 'a';
if (dp[idx][cIdx] == n) { // 找不到该字符
return false;
}
idx = dp[idx][cIdx] + 1; // 跳转到该字符的下一个位置
}
return true;
}
优势说明
- 匹配效率:O(m)(查表跳转,无需遍历
t),适合批量匹配场景; - 逻辑价值:动态规划的跳转表思路是字符串匹配的经典技巧,可扩展到更复杂的子序列问题;
- 空间开销:O(n×26)(n≤10⁴,总空间约26万),在可接受范围内。
总结
- 双指针法(第一次解答):基础解法,空间O(1),适合单条
s匹配,逻辑简单易实现; - 预处理+二分法:进阶场景最优解,预处理一次
t后,批量匹配海量s效率极高; - 动态规划法:另一种批量匹配思路,查表跳转更直观,空间开销略高于二分法;
- 关键选择技巧:
- 单条
s匹配:优先选双指针法(空间最优); - 海量
s匹配:优先选预处理+二分法(时间最优,空间开销更小); - 理解子序列匹配逻辑:可学习动态规划跳转表思路。
- 单条