当前:LC28 · 找出字符串中第一个匹配项的下标 · 首次出现于 Day 15 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC28Easy字符串 · KMP / LPS

KMP:从 LPS 到主串不回退

从完整题目和暴力匹配出发,逐层理解 LPS 的定义、构建过程、失配回退机制, 再把六段动画与 Go 代码、正确性和复杂度连成一条完整推导。

题目是什么

在 haystack 中找到 needle 第一次完整出现的起始下标。

解决什么问题

避免失配后从模式串开头重新比较已经确认过的字符。

核心结论

j>0 失配时只执行 j=lps[j-1],主串指针 i 不回退。

内容来源:Notion 标杆文章动画已站内实现,不依赖 Notion Embed 或外部 SSO
01交互算法精讲

先说结论:KMP 到底解决了什么

LC28 的表面任务很简单:在长字符串haystack 中,找到短字符串needle 第一次完整出现的起始下标。

一次匹配已经成功了很多字符,最后却失配时,前面的成功信息能不能继续利用?

暴力匹配的回答是:不能。更换起点以后,从needle[0]重新开始。KMP 的回答是:能。它先分析模式串自身,把可复用的信息记录在lps中;失配时不让主串指针回头,只把模式串移动到下一个仍可能成功的位置。

  1. 1lps[i] 保存了什么信息?
  2. 2失配时为什么执行 j = lps[j-1],而主串指针 i 不动?
KMP 快,不是因为它从不失配,而是因为失配后仍会复用已经确认的信息。
02交互算法精讲

完整题目与题意拆解

给定两个字符串 haystackneedle,请在主串中找出模式串第一次完整出现的起始下标。 下标从 0 开始;如果不存在完整匹配,返回 -1。

本页主例中,第一次完整匹配从下标 6 开始;前面从下标 0 开始的尝试会在第六个字符处失配。
示例 1
haystack := "sadbutsad"
needle := "sad"
// 输出:0

"sad" 在下标 0 和 6 都出现,但题目只返回第一次出现的位置。

示例 2
haystack := "leetcode"
needle := "leeto"
// 输出:-1

没有任何起点能让 needle 全部匹配,因此返回 -1。

匹配阶段不断比较:

haystack[i] == needle[j]
  • i 指向主串当前待处理的字符。
  • j 指向模式串当前待匹配的字符,也表示已经连续匹配成功的字符数。
  • 当 j == len(needle) 时,整个模式串已经匹配完成。
  • 此时 i 在匹配区间后一位,所以起点是 i - j。
动画 1:题意扫描——寻找第一次完整匹配
支持开始、暂停、前进、后退、重置、进度条与倍速
haystack
0a
start=0
1a
2b
3a
4a
5c
6a
7a
8b
9a
10a
11f
needle
0a
j=0
1a
2b
3a
4a
5f
绿色只表示已经通过的字符;必须连续通过 6 个字符才是完整匹配。
已匹配失配当前/可复用
任务不是找相同字符,而是找 needle 第一次完整出现的起点。
当前动作建立第一个候选窗口
为什么只有窗口内六个字符全部相等,这个起点才能成为答案。
变量变化start = 0 · j = 0 · result = 未确定
对应代码寻找最小的 start,使 haystack[start:start+6] == needle
已暂停1 / 20
03交互算法精讲

第一层方案:暴力匹配

最直接的办法是枚举 haystack中每一个可能的起点。每选一个起点,就让needle 从下标 0 开始逐字符比较。

暴力解法 · Go
1func bruteStrStr(haystack, needle string) int {2    for start := 0; start+len(needle) <= len(haystack); start++ {3        j := 04        for j < len(needle) && haystack[start+j] == needle[j] {5            j++6        }7        if j == len(needle) {8            return start9        }10    }11    return -112}

这段代码完全正确,很适合确认题意。问题不是“会算错”,而是接近结尾才失配时, 下一轮会丢掉上一轮已经确认的前缀信息。

动画 2:暴力匹配为什么会重复比较
支持开始、暂停、前进、后退、重置、进度条与倍速
haystack
0a
start=0
1a
2b
3a
4a
5c
6a
7a
8b
9a
10a
11f
needle
0a
j=0
1a
2b
3a
4a
5f
下面不跳步,完整展示这个例子实际发生的 21 次字符比较。
暴力法从 start=0、j=0 开始,候选失败后 start 加一。
当前动作初始化双层循环
为什么最直接的方案是枚举每一个可能起点,再从 needle[0] 开始比较。
变量变化start = 0 · j = 0 · comparisons = 0
对应代码for start := 0; start+len(needle) <= len(haystack); start++
已暂停1 / 23
最坏情况下,每个候选起点都可能比较接近 m 次,所以暴力匹配达到 O(n·m)。KMP 的优化方向是:不要让一次成功匹配获得的信息全部作废。
04交互算法精讲

KMP 的整体地图:先预处理,再匹配

01
阶段一:预处理模式串

只分析 needle,构建长度相同的 lps 数组。它会回答:已经匹配 j 个字符后失配,j 应该回到哪里?

02
阶段二:扫描主串

i 扫描 haystack,j 扫描 needle;相等一起前进,失配时根据 j 决定回退模式串还是移动主串。

相等                    → i++,j++
失配且 j > 0          → j = lps[j-1]
失配且 j == 0         → i++
j == len(needle)       → return i - j

这四个分支里,真正抽象的是j = lps[j-1]。要理解它,必须先把 LPS 讲清楚。

05交互算法精讲

LPS 到底是什么

lps[i] 表示模式串前缀子串pattern[0:i+1]中,最长的、同时作为真前缀和真后缀出现的字符串长度。

什么是真前缀和真后缀

"aabaa" 为例。真前缀包括a / aa / aab / aaba,真后缀包括a / aa / baa / abaa。 两组中最长的相等项是 aa,长度为 2,所以:

// pattern[0:5] == "aabaa"
lps[4] = 2

“真”表示不能等于整个字符串本身。否则任何字符串都能把自己同时当作前缀和后缀, 这个信息没有意义。

动画 3:LPS 到底记录了什么
支持开始、暂停、前进、后退、重置、进度条与倍速
pattern
0a
1a
2b
3a
4a
5f
lps
0?
1?
2?
3?
4?
5?
“真”表示不能等于整个字符串本身;LPS 只分析 pattern。
LPS[i] 表示 pattern[0:i+1] 的最长相等真前后缀长度。
当前动作建立 LPS 的定义
为什么失配后可以复用的部分,必须同时是已匹配内容的后缀和模式串的前缀。
变量变化当前前缀长度 = 1 · 候选长度 = 0 · lps = [?, ?, ?, ?, ?, ?]
对应代码lps[i] = 最长相等真前后缀的长度
已暂停1 / 14

完整 LPS 示例

对于 pattern := "aabaaf",最终得到:

lps := []int{0, 1, 0, 1, 2, 0}
  • lps[0] = 0:"a" 没有非空真前后缀。
  • lps[1] = 1:"aa" 的最长相等真前后缀是 "a"。
  • lps[2] = 0:"aab" 不存在非空相等真前后缀。
  • lps[3] = 1:"aaba" 的最长相等真前后缀是 "a"。
  • lps[4] = 2:"aabaa" 的最长相等真前后缀是 "aa"。
  • lps[5] = 0:"aabaaf" 不存在非空相等真前后缀。
已经匹配成功的部分,其结尾可能和模式串开头相同;失配后,这一段可以直接复用。
06交互算法精讲

如何构建 LPS

构建阶段维护两个变量:i 表示当前正在确定lps[i]length 表示当前候选的最长相等前后缀长度。

情况一:当前字符相等

候选前后缀可以同时延长一位。lps[i] 已经确定,所以 i 前进。

length++
lps[i] = length
i++
情况二:失配,但 length > 0

当前候选太长,换一个更短但仍可能成立的候选。i 不动,因为 lps[i] 还没有确定。

length = lps[length-1]
情况三:失配,并且 length == 0

已经没有非空候选,当前位置只能记录 0,然后 i 才能前进。

lps[i] = 0
i++
动画 4:逐步构建 LPS 数组
支持开始、暂停、前进、后退、重置、进度条与倍速
pattern
0a
length=0
1a
i=1
2b
3a
4a
5f
lps
00
1?
2?
3?
4?
5?
初始化:lps[0] 固定为 0,从 i=1、length=0 开始。
当前动作建立构建 LPS 的两个指针
为什么单字符前缀的 LPS 必为 0,因此第一个待计算位置是 1。
变量变化i = 1 · length = 0 · lps = [0, ?, ?, ?, ?, ?]
对应代码length, i := 0, 1
已暂停1 / 17
构建阶段的记忆重点不是具体数组,而是:i 只有在 lps[i] 已经确定时才前进;候选长度回退时,当前字符还没有处理完。
07交互算法精讲

核心难点:为什么失配时 i 不回退

假设已经成功匹配了 needle[:j] == "aabaa", 接下来发生失配。这段已匹配内容的最长相等真前后缀是aa,长度为 2。

已匹配区间末尾的 aa 已经与主串确认相等, 又与模式串开头的 aa 相同。因此不需要让主串回去重新确认, 只需把模式串指针调整为:

j = lps[j-1] // 5 → 2

为什么读取 lps[j-1]

失配发生在 needle[j],真正成功的区间是needle[0:j],最后一个成功下标是j-1。失配字符不能被包含在可复用信息中。

为什么不会漏掉答案

KMP 跳过的起点不是凭猜测判定失败。若被跳过的起点可能成功, 已匹配区间末尾就必须等于模式串相应前缀;LPS 已经保留了其中最长的合法候选。 不满足该结构的起点已经能被证明不可能。

08交互算法精讲

KMP 完整匹配过程

haystack := "aabaacaabaaf"
needle := "aabaaf"
lps := []int{0, 1, 0, 1, 2, 0}
  1. 1前五个字符 "aabaa" 连续匹配成功。
  2. 2haystack[5]=='c' 与 needle[5]=='f' 失配。
  3. 3j 根据 LPS 依次从 5 回退到 2、1、0,i 始终保持 5。
  4. 4j==0 时仍失配,i 才从 5 前进到 6。
  5. 5从主串下标 6 开始完整匹配,返回 6。
动画 5:KMP 匹配全过程——为什么 i 不回退
支持开始、暂停、前进、后退、重置、进度条与倍速
haystack
0a
i=0
1a
2b
3a
4a
5c
6a
7a
8b
9a
10a
11f
needle
0a
j=0
1a
2b
3a
4a
5f
lps
00
11
20
31
42
50
下面严格把“比较”和“指针更新”拆成不同帧。
匹配阶段初始化 i=0、j=0;LPS 已经提前构建完成。
当前动作建立主串和模式串指针
为什么i 指向主串待处理字符,j 指向模式串待匹配字符,也表示连续匹配长度。
变量变化i = 0 · j = 0 · lps = [0,1,0,1,2,0]
对应代码i, j := 0, 0
已暂停1 / 32

逐帧观察失配后的三次回退:j: 5 → 2 → 1 → 0。在整个过程中i == 5,直到 j 已经为 0 且仍然失配,i 才前进。

09交互算法精讲

把动画和 Go 代码逐行对应

KMP 的匹配循环只有四个语义分支:

相等一起走

当前字符已经确认相等,两个指针都指向下一待处理字符。

i++
j++
失配且 j > 0,只回退 j

当前主串字符还要与回退后的模式串位置重新比较,不能 i++。

j = lps[j-1]
失配且 j == 0,主串前进

已经没有非空前缀可复用,当前主串字符可以安全排除。

i++
模式串匹配完成

i 在匹配区间后一位,减去匹配长度 j 得到起点。

return i - j
动画 6:动画步骤与 Go 代码分支对应
支持开始、暂停、前进、后退、重置、进度条与倍速
func strStr(haystack, needle string) int {    lps := buildLPS(needle)    i, j := 0, 0    for i < len(haystack) {        if haystack[i] == needle[j] {            i++            j++            if j == len(needle) {                return i - j            }        } else if j > 0 {            j = lps[j-1]        } else {            i++        }    }    return -1}
先构建 LPS;匹配开始前就准备好所有失配回退信息。
当前动作调用模式串预处理
为什么KMP 的匹配速度来自提前计算好的模式串结构。
变量变化needle = aabaaf · lps: 未构建 → [0,1,0,1,2,0]
对应代码lps := buildLPS(needle)
已暂停1 / 13
10交互算法精讲

LC28 完整 Go 提交代码

func strStr(haystack string, needle string) int {
    if len(needle) == 0 {
        return 0
    }

    lps := buildLPS(needle)
    i, j := 0, 0

    for i < len(haystack) {
        if haystack[i] == needle[j] {
            i++
            j++
            if j == len(needle) {
                return i - j
            }
        } else if j > 0 {
            j = lps[j-1]
        } else {
            i++
        }
    }

    return -1
}

func buildLPS(pattern string) []int {
    lps := make([]int, len(pattern))
    length := 0
    i := 1

    for i < len(pattern) {
        if pattern[i] == pattern[length] {
            length++
            lps[i] = length
            i++
        } else if length > 0 {
            length = lps[length-1]
        } else {
            lps[i] = 0
            i++
        }
    }

    return lps
}
查看纯文本代码
func strStr(haystack string, needle string) int {
    if len(needle) == 0 {
        return 0
    }

    lps := buildLPS(needle)
    i, j := 0, 0

    for i < len(haystack) {
        if haystack[i] == needle[j] {
            i++
            j++
            if j == len(needle) {
                return i - j
            }
        } else if j > 0 {
            j = lps[j-1]
        } else {
            i++
        }
    }

    return -1
}

func buildLPS(pattern string) []int {
    lps := make([]int, len(pattern))
    length := 0
    i := 1

    for i < len(pattern) {
        if pattern[i] == pattern[length] {
            length++
            lps[i] = length
            i++
        } else if length > 0 {
            length = lps[length-1]
        } else {
            lps[i] = 0
            i++
        }
    }

    return lps
}

一组最小测试

func main() {
    println(strStr("sadbutsad", "sad"))          // 0
    println(strStr("leetcode", "leeto"))        // -1
    println(strStr("aabaacaabaaf", "aabaaf"))  // 6
    println(strStr("hello", "ll"))              // 2
    println(strStr("aaaaa", "bba"))             // -1
}
11交互算法精讲

正确性与复杂度

匹配阶段的不变量

haystack[i-j:i] == needle[0:j]

进入每次循环时,主串中紧靠 i 左侧的 j 个字符,已经与模式串前 j 个字符相等。失配时,LPS 告诉我们哪个已匹配后缀仍可充当前缀; 回退 j 后不变量继续成立,所以 i 无需回退。

构建 LPS
O(m)
扫描主串
O(n)
总时间 / 空间
O(n+m) / O(m)

虽然 j 会回退,但每次回退都会缩短候选前缀;i 只向右移动,不会回头。

12交互算法精讲

最容易写错的地方

错误一:写成 j = lps[j]

已经成功匹配的最后一个位置是 j-1,needle[j] 本身已经失配,不能包含在查询范围中。

错误二:j > 0 失配时同时 i++

这会跳过当前主串字符与新 needle[j] 的比较,可能漏掉答案。

错误三:构建 LPS 回退 length 时让 i++

当前 lps[i] 还没有确定,同一个 pattern[i] 必须与更短候选重新比较。

错误四:完整匹配后返回 i

i 指向匹配区间后一位,正确起点是 i-j,也可以写 i-len(needle)。

错误五:混淆两阶段变量

构建 LPS 使用 i/length,主串匹配使用 i/j;两阶段都回退模式串候选,但变量含义不同。

13交互算法精讲

最后复盘

  1. 1题目要求寻找 needle 在 haystack 中第一次完整出现的位置。
  2. 2暴力法失配后换起点并从头比较,会丢失已经确认的信息。
  3. 3lps[i] 记录模式串前缀子串的最长相等真前后缀长度。
  4. 4失配且 j>0 时,已匹配区间的后缀可以复用,因此执行 j=lps[j-1]。
  5. 5主串指针 i 不回退,所以总复杂度从 O(n·m) 降为 O(n+m)。
相等一起走;失配看 j;j 大就回退;j 零主串走。
真正掌握的标准
  • 能解释 lps[4]=2 为什么成立。
  • 能解释 j=lps[j-1] 为什么不会漏答案。
  • 能解释为什么失配时主串指针 i 可以保持不动。