全栈开发知识库

26.判断子序列

题目

给定字符串 s 和 t ,判断 s 是否为 t 的子序列。

字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。

进阶:

如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?

示例 1:

  • 输入:s = "abc", t = "ahbgdc"
  • 输出:true

示例 2:

  • 输入:s = "axc", t = "ahbgdc"
  • 输出:false

提示:

  • 0 <= s.length <= 100
  • 0 <= t.length <= 10^4
  • 两个字符串都只由小写字符组成。

思路

首先,如果 s 是空串,直接返回 true,因为空串是任何字符串的子序列。
设置双指针 i , j 分别指向字符串 s , t 的首个字符,遍历字符串 t:

  • 当 s[i] == t[j] 时,代表匹配成功,此时同时 i++ , j++ ;
    • 进而,若 i 已走过 s 尾部,代表 s 是 t 的子序列,此时应提前返回 true ;
  • 当 s[i] != t[j] 时,代表匹配失败,此时仅 j++ ;

若遍历完字符串 t 后,字符串 s 仍未遍历完,代表 s 不是 t 的子序列,此时返回 false 。


class Solution {
    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();
    }
}

class Solution {
    fun isSubsequence(s: String, t: String): Boolean {
        var sIndex = 0
        var tIndex = 0
        var sLength = s.length
        var tLength = t.length
        while(sIndex < sLength && tIndex < tLength) {
            if (s[sIndex] == t[tIndex]) {
                sIndex ++
            }
            tIndex ++
        }

        return sIndex == sLength
    }
}

复杂度分析:

  • 时间复杂度 O(N) : 其中 N 为字符串 t 的长度。最差情况下需完整遍历 t 。
  • 空间复杂度 O(1) : i , j 变量使用常数大小空间。
进阶问题解法

如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?

这种类似对同一个长字符串做很多次匹配的 ,可以像 KMP 算法一样,先用一些时间将长字符串中的数据 提取出来,磨刀不误砍柴功。有了提取好的数据,就可以快速的进行匹配。

  • 先看上面双指针的问题慢在哪里? 判断 s = "abc" 是不是 t = "ahbgdc" 的子序列。双指针是这样走的:
t:  a  h  b  g  d  c
    0  1  2  3  4  5
  • 找 'a':从 t[0] 开始,t[0]='a' 命中!下次从 t[1] 开始
  • 找 'b':从 t[1] 开始,t[1]='h' 不是,往后挪 → t[2]='b' 命中!下次从 t[3] 开始
  • 找 'c':从 t[3] 开始,t[3]='g' 不是 → t[4]='d' 不是 → t[5]='c' 命中!
  • 注意"找 'b'"和"找 'c'"那两步:指针在 t 里一格一格地往后挪,挪过的 'h'、'g'、'd' 都是白挪的。

  • 如果有 10 亿个 s,每个 s 都要这样在 t 里一格格挪,重复劳动太多了。

核心问题就一句话: 我站在 t 的某个位置,想知道"往右边最近的 'b' 在哪",双指针只能一步步找。能不能提前算好、一步跳过去?

所以我希望有这么一张表,能直接回答这个问题:

  • "站在 t 的位置 i,往右边(包括 i 自己)最近的要查找的字符 x 在第几格?"

  • 如果有了这张表,"找 'b'"就不用一格格挪了,直接查表,一步到位。

我把这张表叫 dp,它有两个维度:

  • 第一维 i:我现在站在 t 的哪个位置(0 ~ m)。
  • 第二维 c:我要找哪个字符('a''z',用 0 ~ 25 表示)。

dp[i][c] = 站在位置 i,往右找字符 c,第一次遇到它的下标

找不到怎么办? 用一个特殊值 m(t 的长度,一个不可能是真实下标的值)表示"右边没有这个字符了"。

第三步:手工填出 t = "ahbgdc" 的表(只看 b、d、c 三列)

先别管代码,我们用脑子想,从最右边往左填。为什么从右往左,待会就明白了。

t:   a    h    b    g    d    c
i:   0    1    2    3    4    5      6(哨兵,啥都没有)

先填哨兵行 i=6(站在 t 的末尾之外,右边什么都没有):

dp[6][任何字符] = 6   (都找不到)
  • 填 i=5(t[5]='c'):站在位置 5,往右(含自己)找各字符。

    • 找 'c':我脚下就是 'c'!→ dp[5]['c'] = 5
    • 找 'b':脚下不是 b,那就看"位置 6 往右找 b"的答案 = dp[6]['b'] = 6 → dp[5]['b'] = 6
    • 找 'd':脚下不是 d,看 dp[6]['d'] = 6 → dp[5]['d'] = 6
  • 填 i=4(t[4]='d'):

    • 找 'd':脚下就是 'd'!→ dp[4]['d'] = 4
    • 找 'c':脚下不是 c,看右边 dp[5]['c'] = 5 → dp[4]['c'] = 5
    • 找 'b':脚下不是 b,看右边 dp[5]['b'] = 6 → dp[4]['b'] = 6
  • 填 i=3(t[3]='g'):

    • 找 'b':脚下是 g,不是 b,看右边 dp[4]['b'] = 6 → dp[3]['b'] = 6
    • 找 'c':脚下不是 c,看右边 dp[4]['c'] = 5 → dp[3]['c'] = 5
    • 找 'd':脚下不是 d,看右边 dp[4]['d'] = 4 → dp[3]['d'] = 4
  • 填 i=2(t[2]='b'):

    • 找 'b':脚下就是 'b'!→ dp[2]['b'] = 2
    • 找 'c':脚下不是,看右边 dp[3]['c'] = 5 → dp[2]['c'] = 5
  • 填 i=1(t[1]='h')、i=0(t[0]='a') 同理沿用右边。

填完后这三列长这样:

i:           0    1    2    3    4    5    6
dp[i]['b']:  2    2    2    6    6    6    6
dp[i]['c']:  5    5    5    5    5    5    6
dp[i]['d']:  4    4    4    4    4    6    6

你看上面每一格,无非两种情况:

  • 规则 1:我脚下的字符 t[i] 正好就是要找的 c → 那不用往右看了,答案就是我自己:dp[i][c] = i

  • 规则 2:我脚下的字符不是 c → 那"从我这往右找 c"和"从我右边一格往右找 c"是一样的,直接抄右边邻居的答案:dp[i][c] = dp[i+1][c]

if (t[i] - 'a' == c) {
    dp[i][c] = i            // 规则1:脚下就是
} else {
    dp[i][c] = dp[i + 1][c] // 规则2:抄右邻居
}

为什么必须从右往左填? 因为规则 2 要用到 dp[i+1](右边邻居)的结果。我得先把右边算好,左边才能抄。所以从最右的哨兵行开始,一路往左推。

这就像接力:最右边的人先确定答案,然后每个人看一眼右手边的人,要么"我自己就是",要么"跟右手边一样"。

用这张表判断 s = "abc"(看跳跃的过程)

准备一个变量 pos,表示"我现在站在 t 的哪个位置开始找",初始 pos = 0。

  • 要找 'a':查 dp[0]['a']

    • (这列没列出来,但 t[0]='a',所以 dp[0]['a']=0)
    • 结果 0,不是哨兵 6 → 命中位置 0
    • 下次从 0 的下一格开始:pos = 0 + 1 = 1
  • 要找 'b':查 dp[1]['b'] = 2

    • 结果 2,不是 6 → 命中位置 2(注意!直接从 1 跳到 2,没有经过 h)
    • 下次从:pos = 2 + 1 = 3
  • 要找 'c':查 dp[3]['c'] = 5

    • 结果 5,不是 6 → 命中位置 5(直接从 3 跳到 5,跳过了 g、d)
    • 下次从:pos = 5 + 1 = 6
  • s 的字符全部找完了 → "abc" 是子序列 ✅

看到了吗?找 'b' 和找 'c' 时,指针是"跳"过去的,不是一格格挪的。 跳过的 h、g、d 一次都没碰,这就是提速的关键。

再看一个失败的例子 s = "bd"(b 在前 d 在后,但 t 里 d 在 b... 不对,t 里 b 在位置 2,d 在位置 4,b 在 d 前面,所以 "bd" 应该是子序列)。换 s = "db":

  • pos = 0
  • 找 'd':dp[0]['d'] = 4 → 命中 4 → pos = 5
  • 找 'b':dp[5]['b'] = 6 == 哨兵!→ 位置 5 往右没有 b 了 → 返回 false ✅
  • "db" 不是子序列(t 里 d 在 b 后面,没法先 d 后 b),结果正确。
查询时的两条规则

判断 s 时,对每个字符 ch,查 dp[pos][ch]:

val c = ch - 'a'
if (dp[pos][c] == tLen) {   // 等于哨兵值,说明右边没这个字符了
    return false
}
pos = dp[pos][c] + 1        // 跳到命中位置的下一格,准备找下一个字符
  • 查出来是哨兵 m → 右边找不到了 → s 不是子序列。

  • 否则 → 跳到 命中位置 + 1,继续找 s 的下一个字符。

  • s 全部字符顺利找完 → 返回 true。

一句话总结整个方法
  • 预处理: 给 t 建一张表,记下"从每个位置往右,每个字母最近在哪",从右往左填(脚下是就填自己,不是就抄右邻居)。

  • 查询: 来一个 s,就拿着 pos 在表里一路"跳",跳到哨兵就失败,全跳完就成功。

因为查询时完全不碰 t 本身、只查表跳跃,所以每个 s 只花 O(|s|) 时间,跟 t 多长没关系——这正是 10 亿个 s 场景下能扛住的原因。

class Solution {
    fun isSubsequence(s: String, t: String): Boolean {
        val m = t.length

        // ===== 预处理:构建 dp 表 =====
        // dp[i][c] = 从 t 的位置 i(含 i)往右,字符 c 首次出现的下标;找不到记为 m
        // 多开一行 dp[m] 作哨兵行,全部初始化为 m
        val dp = Array(m + 1) { IntArray(26) { m } }

        // 从右往左递推(情况 B 需要用到右邻居 dp[i+1])
        for (i in m - 1 downTo 0) {
            for (c in 0 until 26) {
                dp[i][c] = if (t[i] - 'a' == c) {
                    // 情况 A:脚下就是字符 c
                    i
                } else {
                    // 情况 B:抄右邻居的结果
                    dp[i + 1][c]
                }
            }
        }

        // ===== 查询:拿着 pos 在表里跳 =====
        var pos = 0  // 接下来从 t 的哪个位置开始找
        for (ch in s) {
            val c = ch - 'a'
            if (dp[pos][c] == m) {
                // 触发哨兵:右边再无此字符
                return false
            }
            // 跳到命中位置的下一格
            pos = dp[pos][c] + 1
        }
        // s 全部字符匹配成功
        return true
    }
}

//进阶问题的解决 
public boolean isSubsequence(String s, String t) {

    //考虑到  对第一个字符的处理 ,在t 之前一个空字符
    t=' '+t;

    //对t长字符串 做预处理
    //存储每一个位置上  a--z的下一个字符出现的位置
    int[][] dp = new int[t.length()][26];
    //依次对每个字符作处理
    for (char c = 'a'; c <= 'z'; c++) {
        //表示接下来不会在出现该字符
        int nextPos = -1;

        //从最后一位开始处理
        for (int i = t.length() - 1; i >= 0; i--) {
            //dp[i][c-'a']  加上外层循环  就是对每一个位置的a---z字符的处理了
            dp[i][c - 'a'] = nextPos;
            if (t.charAt(i) == c) {//表示当前位置有该字符  那么指向下一个该字符出现的位置就要被更新  为i
                nextPos = i;
            }
        }
    }

    //数据的利用 ,开始匹配
    int index=0;
    for (char c:s.toCharArray()){
        index=dp[index][c-'a'];//因为加了' ',所以之后在处理第一个字符的时候  如果是在第一行,就会去第一行,不影响之后字符的判断
        if(index==-1){
            return false;
        }
    }
    return true;
}

哨兵值 m 到底干嘛的

m(也就是 t.length)是一个永远不可能是真实下标的值(真实下标是 0 ~ m-1)。我们借它表示一个特殊状态:"右边再也找不到这个字符了"。

它有两个作用:

  • 作用 A:作为"找不到"的标记,让查询能判断失败,查询时:

    • if (dp[pos][c] == tLen) return false // tLen 就是 m,查到哨兵 = 找不到 = 失败

    • 如果 dp[pos][c] 是个真实下标(0 ~ m-1),说明右边有这个字符,能继续;如果等于 m,说明右边没了,直接判定 s 不是子序列。

  • 作用 B:作为哨兵行,让填表的"抄右邻居"不越界

    • 填表时情况 B 要抄 dp[i+1]。当 i = m-1(最后一个真实字符)时,dp[i+1] = dp[m],已经超出真实范围了。

    • 我们多开一行 dp[m],整行填成 m,专门接住这个边界。这样 i = m-1 抄 dp[m] 时,抄到的就是 m(表示"末尾右边啥都没有"),逻辑自然成立,不用写特殊的 if 判断。

    • dp[m][所有字符] = m ← 哨兵行,初始化时全填 m



  • 邮箱 :charon.chui@gmail.com
  • Good Luck!