= 10亿,你需要依次...">判断子序列 | 景真= 10亿,你需要依次...">
Skip to content

判断子序列

约 1421 字大约 5 分钟

贪心快慢指针二分查找ValToIndex

2025-02-27

392. 判断子序列

给定字符串 st ,判断 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
  • 两个字符串都只由小写字符组成。

贪心+快慢指针

class Solution {
    public boolean isSubsequence(String s, String t) {
        // 初始化两个指针 i 和 j,分别用于遍历字符串 s 和 t。判断 s 是否为 t 的子序列。
        // s为主,t为从。只有在t中找到s中对应的字符,才会移动s的指针。
        int i = 0, j = 0;
        
        // 使用 while 循环遍历字符串 t,同时检查字符串 s 中的字符
        while (i < s.length() && j < t.length()) {
            // 如果 s 和 t 中当前字符相等,s 的指针 i 向前移动。如果不相等,则s的指针不前进,t的指针继续前进。
            if (s.charAt(i) == t.charAt(j)) {
                i++;
            }
            // 不论字符是否相等,t 的指针 j 都向前移动。重点。
            j++;
        }
        
        // 判断是否遍历完 s 中的所有字符,如果是,说明 s 是 t 的子序列
        return i == s.length();
    }
}
  • 时间复杂度:O(n+m),其中 n 为 s 的长度,m 为 t 的长度。每次无论是匹配成功还是失败,都有至少一个指针发生右移,两指针能够位移的总距离为 n+m。
  • 空间复杂度:O(1)。

如果给你一系列字符串 s1,s2,... 和字符串 t,你需要判定每个串 s 是否是 t 的子序列(可以假定 s 较短,t 很长)。

boolean[] isSubsequence(String[] sn, String t);

你也许会问,这不是很简单吗,还是刚才的逻辑,加个 for 循环不就行了?

可以,但是此解法处理每个 s 时间复杂度仍然是 O(m+n),而如果巧妙运用二分查找,可以将时间复杂度降低,大约是 O(Klogm)。

哈希表,二分查找左边界,ValToIndex

利用哈希表直接定位数据,空间换时间。

img.png

比如对于这个情况,匹配了 "ab",应该匹配 "c" 了:

img.png

按照之前的解法,我们需要 j 线性前进扫描字符 "c",但借助 index 中记录的信息, 可以二分搜索 index[c] 中比 j 大的那个索引,在上图的例子中,就是在 [0,2,6] 中搜索比 4 大的那个索引:

img.png

这样就可以直接得到下一个 "c" 的索引。现在的问题就是,如何用二分查找计算那个恰好比 4 大的索引呢?答案是,寻找左侧边界的二分搜索就可以做到。

维护值到索引的映射。

对于搜索左侧边界的二分查找,有一个特殊性质:

val 不存在时,得到的索引恰好是比 val 大的最小元素索引

什么意思呢,就是说如果在数组 [0,1,3,4] 中搜索元素 2,算法会返回索引 2,也就是元素 3 的位置,元素 3 是数组中大于 2 的最小元素。 所以我们可以利用二分搜索避免线性扫描。

class Solution {
    boolean isSubsequence(String s, String t) {
        int m = s.length(), n = t.length();
        
        // 对 t 进行预处理
        ArrayList<Integer>[] index = new ArrayList[26]; // 用于存储字符在 t 中出现的位置列表
        for (int i = 0; i < n; i++) {
            char c = t.charAt(i);
            if (index[c] == null) // 如果字符 c 还没有出现过
                index[c] = new ArrayList<>(); // 初始化位置列表
            index[c].add(i); // 将字符 c 出现的位置添加到位置列表中
        }

        // 串 t 上的指针
        int j = 0;
        // 借助 index 查找 s[i]。遍历s
        for (int i = 0; i < m; i++) {
            char c = s.charAt(i);
            
            // 如果字符 c 在 t 中没有出现过,返回 false
            if (index[c] == null) return false;
            // 在 t 中查找字符 c 的位置,要求位置大于等于 j,
            // 即在index[c]中查找索引大于等于j的c的位置的左边界,注意pos是链表index[c]的索引,
            // 不是t的索引,index[c].get(pos)才是t的索引。
            int pos = left_bound(index[c], j);
            // 二分搜索区间中没有找到字符 c
            if (pos == -1) return false;
            
            // 向前移动指针 j
            j = index[c].get(pos) + 1;
        }
        
        return true; // 如果所有字符都找到合适的位置,返回 true
    }

    // 查找左侧边界的二分查找
    int left_bound(ArrayList<Integer> arr, int target) {
        int left = 0, right = arr.size();
        
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (target > arr.get(mid)) {
                left = mid + 1; // 目标在右半部分
            } else {
                // 开区间和闭区间只是一个符号,本质是指定了搜索范围,
                // 所以只要明白下一轮搜索的元素有哪些,就能熟练地使用开区间和闭区间。这就是第一性原理。
                right = mid; // 目标在左半部分
            } 
        }
        
        // 如果没有找到合适的位置,返回 -1。重点。
        if (left == arr.size()) {
            return -1;
        }
        return left; // 返回左侧边界的位置
    }
}