跳转到内容

Lesson 25: 实现 my_strstr — 暴力字符串匹配

练习任务

难度:易

实现自己的 my_strstr(haystack, needle) 函数——在字符串 haystack(干草堆)中查找子串 needle(针)第一次出现的位置,返回指向该位置的指针;若未找到返回 NULL。如果 needle 是空字符串,返回 haystack。这是标准库 <string.h>strstr 的手写版本。

本课共有 4 组测试用例:

输入 "hello world" + "world" 输出 "found: world"
输入 "hello world" + "xyz" 输出 "not found"
输入 "aaaa" + "aa" 输出 "found: aaaa"
输入 "hello" + "" 输出 "found: hello"  (空 needle)

提示:暴力匹配的核心思路是「滑动窗口」——用一个宽度为 needle 长度为 m 的窗口,在 haystack 上从左向右滑动。每到一个位置 i,比较窗口内的 m 个字符是否与 needle 完全一致。外层循环变量 i 的取值范围是你需要思考的关键——i <= n - m 中的等号为什么不能丢?


核心知识点

  • C 字符串内存模型 — '\0'(NUL 终止符)标记字符串结束,strlen 返回不含 '\0' 的字符数
  • 双重循环滑动窗口 — 外层 i 控制窗口起点,内层 j 逐字符验证,两重循环各司其职
  • 边界条件 i <= n - m — 等号确保窗口恰好在末尾时仍被检测,丢掉会遗漏匹配
  • const 修饰符 — const char *haystack 承诺函数不修改源字符串内容
  • 空串处理时 *needle == '\0' 时返回 haystack,与 C 标准 strstr 行为一致
  • 指针返回值 — 找到时返回子串起始地址 &haystack[i],未找到返回 NULL
  • 最坏 O(n×m) 时间复杂度 — 每个位置都几乎比较完才失败时出现
  • KMP 算法思想 — next 数组避免 j 回退,将复杂度降至 O(n+m)

代码框架

25_my_strstr.c
c
#include <stdio.h>
#include <string.h>

char *my_strstr(const char *haystack, const char *needle)
{
    // 如果 needle 是空串,直接返回 haystack
    // 在这里实现空串检查:if (*needle == '\0') ...

    // 计算两个字符串的长度(用 strlen 函数)
    // size_t n = strlen(haystack);
    // size_t m = strlen(needle);

    // 如果 haystack 比 needle 短,直接返回 NULL
    // 在这里实现长度比较:if (n < m) ...

    // 外层循环:i 从 0 到 n - m(等号不能丢!)
    // 想一想为什么条件是 i <= n - m 而不是 i < n ?
    // 当 needle 刚好在 haystack 末尾时会发生什么?
    // 在这里实现外层循环:
    // for (size_t i = 0; i <= n - m; i++) { ... }

    // 内层循环:逐字符比较 haystack[i+j] 和 needle[j]
    // 不匹配则立即 break 退出内层循环
    // 在这里实现内层循环:
    // for (j = 0; j < m; j++) {
    //     if (haystack[i + j] != needle[j]) break;
    // }

    // 如果内层循环完整跑完(j == m),说明全部匹配
    // 返回匹配位置的指针:return (char *)(haystack + i);

    // 循环结束仍未找到,返回 NULL
}

int main(void)
{
    char haystack[256], needle[256];

    // 用 fgets 从标准输入读取两行:第一行是 haystack,第二行是 needle
    // 在这里读取输入:
    // fgets(haystack, sizeof(haystack), stdin);
    // fgets(needle, sizeof(needle), stdin);

    // 去掉每行末尾的换行符 '\n'
    // 提示:遍历字符串直到遇到 '\n',将其替换为 '\0'
    // 注意:空行(直接回车)读到的是 "\n",去掉后变成空串
    // 在这里去掉换行符:
    // int i = 0;
    // while (haystack[i] && haystack[i] != '\n') i++;
    // haystack[i] = '\0';
    // i = 0;
    // while (needle[i] && needle[i] != '\n') i++;
    // needle[i] = '\0';

    // 调用 my_strstr,根据返回值打印结果
    // 在这里完成调用和打印:
    // char *result = my_strstr(haystack, needle);
    // if (result)
    //     printf("found: %s\n", result);
    // else
    //     printf("not found\n");

    return 0;
}

阅读骨架后,尝试自己填充 // 在这里... 标记的部分。核心挑战在于:外层循环的终止条件是 i <= n - m 还是 i < n?内层循环什么时候退出、什么时候表示"完全匹配"?

TIP

先不要往下翻看参考解答。尝试理解滑动窗口的双重循环结构——外层控制窗口位置,内层逐字符验证。写完代码后用示例手动追踪一轮,确认逻辑正确。


深度讲解

1. C 字符串的内存模型

1.1 NUL 终止符——C 字符串的灵魂

回顾 Lesson 09 中学到的 C 字符串:C 语言没有内置的字符串类型,用'\0' 结尾的字符数组来表示字符串。'\0'(ASCII 码 0,也称 NUL 终止符)是不可见但至关重要的一个字节。

string_model.c
c
char s[] = "hello";

// "hello" 在内存中的实际占用是 6 个字节,不是 5 个:
// ┌───┬───┬───┬───┬───┬───┐
// │ h │ e │ l │ l │ o │ \0│
// └───┴───┴───┴───┴───┴───┘
//  0   1   2   3   4   5   ← 索引

printf("%zu\n", strlen(s));   // 5——strlen 不算 '\0'
printf("%zu\n", sizeof(s));   // 6——sizeof 算上 '\0'
C 字符串的核心约定:
┌──────────────────────────────────────────────────┐

  strlen 从头数到 '\0'(不含),即字符个数
  sizeof 返回数组总大小(含 '\0'),即字节数
  printf("%s", s) 从首地址打印,遇 '\0' 停止
 '\0' 继续读 = 越界,行为未定义

  "hello" 在内存中的六个字节:
    h    e    l    l    o   \0
  0x68 0x65 0x6C 0x6C 0x6F 0x00
  [0]  [1]  [2]  [3]  [4]  [5]                    │

  strlen("hello") = 5
  sizeof("hello") = 6

└──────────────────────────────────────────────────┘

1.2 字符串就是地址——指针视角

string_pointer.c
c
char *p = "hello";      // p 指向只读字符串 "hello" 的首字符
char *q = p;            // q 也指向同一地址

printf("%s\n", p);      // hello —— %s 从 p 所指向的地址开始输出
printf("%s\n", p + 2);  // llo   —— p+2 跳过两个字符
printf("%c\n", *p);     // h     —— 解引用得到首字符
printf("%c\n", p[1]);   // e     —— p[1] 等价于 *(p+1)

strstr 返回正是指向 haystack 内部某个位置的指针——这是 C 字符串函数的标准设计:不拷贝数据,只返回位置。

my_strstr("hello world", "world") 的返回值:
 返回指向这个 'w' 的指针
  h e l l o   w o r l d \0
  0 1 2 3 4 5 6 7 8 9 10

        返回后打印 "%s" 从这个位置开始 输出 "world"

2. 滑动窗口双重循环——算法核心

2.1 滑动窗口的直觉

想象你有一把"尺子",长度恰好等于 needle 的长度 m。把这把尺子放在 haystack 的最左端,比对这个位置上的 m 个字符和 needle 是否完全一致。如果一致——找到;如果不一致——尺子向右移一格,再比对。

haystack = "hello world" (n=11), needle = "world" (m=5)

位置 i=0:
  ┌─────────────┐
 h e l l o w o r l d "hello" vs "world"
  └─────────────┘

位置 i=1:
    ┌─────────────┐
  h e l l o   w o r l d "ello " vs "world"
    └─────────────┘

... 继续滑动 ...

位置 i=6:
              ┌─────────────┐
  h e l l o w o r l d "world" vs "world" 匹配!
              └─────────────┘
  返回 &haystack[6] 打印 "%s" "world"

2.2 双重循环的结构

暴力匹配的代码骨架是一个外-内双层循环

外层循环 i:从 0 n-m(窗口的起始位置)
  内层循环 j:从 0 m-1(窗口内逐字符比较)
 haystack[i+j] != needle[j] 不匹配,break
  内层循环退出时:
 j == m(完整走完,没有 break)→ 从位置 i 完全匹配!返回 &haystack[i]
 j < m(中途 break)→ 继续外层下一个 i
外层循环结束 未找到,返回 NULL

2.3 逐步跟踪——以 "hello world" 中找 "world" 为例

初始状态:
  haystack = "hello world" (n=11)
  needle   = "world"       (m=5)
  合法窗口起点: 0..6 (n-m = 6)

═══════════════════════════════════════════════════════════════
外层 i=0 窗口起点 0
═══════════════════════════════════════════════════════════════
内层比较 haystack[0+j] vs needle[j]:

  j=0: haystack[0]=h vs needle[0]=w 'h' 'w' break

窗口 "hello" 不匹配

═══════════════════════════════════════════════════════════════
外层 i=1 窗口起点 1
═══════════════════════════════════════════════════════════════
  j=0: haystack[1]=e vs needle[0]=w 'e' 'w' break

窗口 "ello " 不匹配

═══════════════════════════════════════════════════════════════
外层 i=2 窗口起点 2
═══════════════════════════════════════════════════════════════
  j=0: haystack[2]=l vs needle[0]=w 'l' 'w' break

窗口 "llo w" 不匹配

... i=3,4,5 同样首字符就不匹配 ...

═══════════════════════════════════════════════════════════════
外层 i=6 窗口起点 6(最后一个合法位置 n-m=6)
═══════════════════════════════════════════════════════════════

  j=0: haystack[6]=w vs needle[0]=w 'w' = 'w' 继续
  j=1: haystack[7]=o vs needle[1]=o 'o' = 'o' 继续
  j=2: haystack[8]=r vs needle[2]=r 'r' = 'r' 继续
  j=3: haystack[9]=l vs needle[3]=l 'l' = 'l' 继续
  j=4: haystack[10]=d vs needle[4]=d 'd' = 'd' j 走到 5

内层循环结束,j==5==m 全部匹配!
返回 &haystack[6] 指向 "world" 的起始地址

2.4 索引映射关系图解

双重循环中索引 i j 的含义:

    i=6 (窗口起点)

    h e l l o   w o r l d
    0 1 2 3 4 5 6 7 8 9 10 haystack 索引
           haystack[i] = haystack[6] = 'w'

    j=0: haystack[i+0] = haystack[6] = 'w'  vs needle[0] = 'w'
    j=1: haystack[i+1] = haystack[7] = 'o'  vs needle[1] = 'o'
    j=2: haystack[i+2] = haystack[8] = 'r'  vs needle[2] = 'r'
    j=3: haystack[i+3] = haystack[9] = 'l'  vs needle[3] = 'l'
    j=4: haystack[i+4] = haystack[10]= 'd'  vs needle[4] = 'd'

关键:haystack[i+j] 同时受外层的 i 和内层的 j 影响
      i 决定"站在哪里开始看",j 决定"站在当前位置后看第几个字符"

3. 边界条件:为什么是 i <= n - m 而不是 i < n

3.1 窗口大小与有效起点的物理限制

这是本课最重要的一个细节——如同学走路不能踩到悬崖边上,滑动窗口也不能滑出字符串边界。

haystack = "hello" (n=5), needle = "world" (m=5)

 i <= n-m:    i 的取值范围 0..0(只有 1 个位置,因为等长)
                 位置 i=0: 窗口 "hello",合法

 i < n:       i 的取值范围 0..4(共 5 个位置!)
                 位置 i=0: 窗口 "hello",合法
                 位置 i=1: 窗口 "ello?" 越界!✗
                 位置 i=2: 窗口 "llo??" 越界!✗
                 位置 i=3: 窗口 "lo???" 越界!✗
                 位置 i=4: 窗口 "o????" 越界!✗

i <= n - m 的等号保证:当窗口右边界恰好等于 n 时,窗口仍在字符串内部

窗口右边界计算:i + m
 i = n - m 时,右边界 = (n-m) + m = n
访问范围:haystack[i..i+m-1] = haystack[n-m..n-1] 全在字符串内

3.2 丢掉等号的代价

missing_equal.c
c
// ❌ 错误写法:用 i < n - m(丢了等号)
for (size_t i = 0; i < n - m; i++) { ... }

// 后果:当 needle 刚好在 haystack 末尾时
// i = n - m 时窗口对应 haystack[n-m..n-1],恰好匹配
// 但循环条件 i < n-m 在 i == n-m 时退出,永远不会检查这个位置!
//
// 例子:haystack="helloworld", needle="world"
// n=10, m=5, n-m=5
// i=0 窗口"hello", i=1 窗口"ellow", ... i=5 窗口"world" ← 但不会执行!
correct_equal.c
c
// ✅ 正确写法:用 i <= n - m(保留等号)
for (size_t i = 0; i <= n - m; i++) { ... }
// i 的取值范围:0, 1, 2, ..., n-m
// 最后一个合法的窗口起点 n-m 也会被检查到 ✓

3.3 全边界情形的系统分析

边界情形n-m 的值i 范围行为说明
needle 为空串 ""直接返回 haystack不进入循环,*needle=='\0' 提前处理
n < m(haystack 比 needle 短)负数返回 NULLn-m 为负(size_t 下溢为巨大值),需提前判 n<m
n == m(等长)0i=0只检查位置 0i<=0 恰好检查一次
needle 出现在开头≥0i=0 即命中返回 &haystack[0]首次即匹配
needle 出现在末尾≥0i=n-m 命中返回 &haystack[n-m]<= 的等号保证了末尾

WARNING

n < mn - m 在无符号算术(size_t)下会发生下溢——0 - 5 得到的是 SIZE_MAX - 4(约 2^64 - 4),导致外层循环范围异常。务必在循环前添加 if (n < m) return NULL; 检查,否则程序行为不可预测。

unsigned_underflow.c
c
// 演示无符号下溢的危险
#include <stdio.h>
#include <stddef.h>

int main(void)
{
    size_t n = 5, m = 10;
    size_t diff = n - m;     // 不是 -5!而是 SIZE_MAX - 4
    printf("n - m = %zu\n", diff);
    // 输出: n - m = 18446744073709551611(64 位系统)

    // 因此:
    // for (i = 0; i <= SIZE_MAX-4; i++) ← 循环天文数字次!→ 超时/越界

    return 0;
}

4. 时间复杂度分析

4.1 最好、最坏、平均

情况时间复杂度场景描述
最好O(n)每个位置的首字符就不匹配,外层走 n-m+1 次,每次内层只比较 1 次
最坏O(n×m)每个位置都几乎比较完 m 个字符才失败
平均O(n×m/k)k 与字符集大小和模式串结构有关

4.2 最坏场景:几乎匹配的陷阱

haystack = "AAAAAB"  (n=6)
needle   = "AAAAB"   (m=5)

i=0: AAAAA? j=0✓ j=1✓ j=2✓ j=3✓ j=4: A≠B break  (5 次比较)
i=1: AAAAB j=0✓ j=1✓ j=2✓ j=3: A≠B break       (4 次比较)
总比较次数 = 5+4 = 9 (n-m+1)×m/2

 haystack 全为 'A' needle "AA...AB" 时:
  比较次数 Σ(i=0..n-m) { min(m, 最后一个不同位置) }
 O(n×m)

这就是为什么在文本搜索(如浏览器 Ctrl+F)和DNA 序列匹配(4 种碱基,重复频繁)中,暴力匹配会慢得不可接受——需要更聪明的算法。这也是 KMP 等算法出现的动机。


5. 字符串匹配进阶:KMP 算法思想简介

5.1 暴力匹配的浪费在哪里?

回顾上一个"几乎匹配"的例子:当 i=0 的窗口比较到最后一个字符才失败时,我们已经读取了 "AAAA" 这 4 个匹配字符的信息——但暴力匹配在内层 break 后立刻丢弃所有信息,下一轮 i=1 回到窗口开头重新比对。

haystack = "AAAAAB", needle = "AAAAB"

i=0 窗口: A A A A B
 读了 5 个字符,知道前 4 个是 "AAAA"
              但这些信息在下一次 i=1 时完全被丢弃了!
i=1 窗口:   A A A A B
 又重新比对了 "AAA"...

KMP 的核心洞察:既然我们已经知道 haystack[0..3] = "AAAA"needle[0..3] = "AAAA",那 needle 的前缀已经和 haystack 的一段后缀匹配上了——我们可以直接跳过已经确认匹配的部分,不把 i 回退,只调整 j 的位置。

5.2 next 数组的直觉——"失败后该从哪继续?"

KMP 算法预处理 needle,计算一个 next[j] 数组,含义是:needle[j] 匹配失败时,下一次应该用 needle 的哪个位置继续比较

needle = "ABAAB"
索引:   0 1 2 3 4
字符:   A B A A B

next 数组(一种常见定义):
  next[0] = -1   // 第一个字符就失败,移动 haystack
  next[1] = 0    // B 失败,前缀 A 没有相等的真后缀,回到 0
  next[2] = 0    // A 失败,前缀 AB 没有相等的真后缀
  next[3] = 1    // A 失败,前缀 ABA 的相等真前缀/真后缀是 "A"(长度 1)
  next[4] = 1    // B 失败,前缀 ABAA 的相等最长真前缀/真后缀是 "A"(长度 1)

next 的核心是寻找 needle 的前缀中的最长相等真前缀和真后缀。这个概念的名字叫"前缀函数"(prefix function,π 数组)。

needle = "ABAAB"

前缀 "A":    无真后缀 next[1] = 0
前缀 "AB":   真前缀 A, B;真后缀 B, A 无相等 next[2] = 0
前缀 "ABA":  真前缀 A, AB;真后缀 A, BA "A" = "A" next[3] = 1
前缀 "ABAA": 真前缀 A,AB,ABA;真后缀 A,AA,BAA "A" = "A" next[4] = 1

next 数组有若干种编号约定,不同教材 +1/-1 可能不同,但思想一致。)

5.3 KMP 的核心:只前进,不后退

暴力匹配 vs KMP:

暴力匹配:
  i: 0 1 2 3 ...(i 可能回退-1 + 步进)
  j: 0 1 2 3 匹配失败 回退到 0 1 2 ...
  问题:i j 来回移动,做了大量无效比较

KMP:
  i: 0 1 2 3 4 ...(i 只在匹配成功时前进!)
  j: 0 1 2 失败!→ next[j] 箭头 继续比较(不回退到 0)
  关键:i 从不后退,j 通过 next 数组跳到"可能继续匹配"的位置
 KMP 处理 "AAAAAAB" "AAAAB":

i=0 j=0 A=A j=1
i=1 j=1 A=A j=2
i=2 j=2 A=A j=3
i=3 j=3 A=A j=4
i=4 j=4 B=A j = next[4] = 3(跳回 3,不回退 i)
i=4 j=3 A=A j=4
i=5 j=4 B=B j=5 j==m,匹配!

比较次数:暴力 25 次,KMP 10

TIP

KMP 在本单元中没有对应练习,但它的思想——预处理模式串以利用失败信息——是字符串算法设计中的经典范式。理解了 next 数组(前缀函数),你可以进一步学习 Boyer-Moore(从右端开始比较,跳得更远)和 Aho-Corasick(同时匹配多个模式串,KMP 扩展到 trie 上)等高级匹配算法。

KMP 时间复杂度为 O(n+m):预处理 needlenext 数组 O(m),扫描 haystack O(n)。这是线性时间字符串匹配的基准。


6. 指针返回值与函数契约

6.1 返回指针 vs 返回下标

return_comparison.c
c
// 方式 A: 返回下标(-1 表示未找到)
int strstr_index(const char *haystack, const char *needle);
// 调用方还需再做一次数组访问: haystack[index]
// 且 -1 作为"未找到"的标记不够类型安全

// 方式 B: 返回指针(NULL 表示未找到)← C 标准库选择
char *strstr(const char *haystack, const char *needle);
// 调用方直接得到指向子串的指针,可以直接传给 printf 等函数

返回指针的好处:

  • 调用方无需再关心原始 buffer——拿到指针直接就是子串的起点
  • NULL 是 C 语言中"无"的通用表达,语义清晰
  • 支持链式使用:printf("%s", my_strstr(s, "key"));

6.2 const 修饰符——只读承诺

const_contract.c
c
// strstr 的标准签名:
char *strstr(const char *haystack, const char *needle);
//                        ↑ const              ↑ const
//           haystack 和 needle 都是"只读输入"

// const 告诉调用者:
// 1. 我不会修改你传给我的字符串内容
// 2. 编译器会帮我检查——如果你在函数体内写了 *haystack='X',编译报错

const 是 API 设计中的"自文档"——只看函数签名就知道哪些参数是输入、哪些是输出。对于 strstr,两个参数都修饰为 const,返回值是 char*(指向 haystack 内部,拥有者仍是调用方)。


参考解答

练习: my_strstr 完整实现
solution_25_my_strstr.c
c
#include <stdio.h>
#include <string.h>

char *my_strstr(const char *haystack, const char *needle)
{
    /* 空串:C 标准规定返回 haystack */
    if (*needle == '\0')
        return (char *)haystack;

    size_t n = strlen(haystack);
    size_t m = strlen(needle);

    /* haystack 比 needle 短,不可能匹配;同时防止 n-m 无符号下溢 */
    if (n < m)
        return NULL;

    /* 外层循环:i 从 0 到 n-m(等号保证窗口末尾不会被遗漏) */
    for (size_t i = 0; i <= n - m; i++) {
        size_t j;

        /* 内层循环:逐字符比较窗口与 needle */
        for (j = 0; j < m; j++) {
            if (haystack[i + j] != needle[j])
                break;              /* 不匹配,退出内层 */
        }

        /* 内层完整跑完 = 全部匹配 */
        if (j == m)
            return (char *)(haystack + i);
    }

    return NULL;                     /* 未找到 */
}

int main(void)
{
    char haystack[256], needle[256];

    fgets(haystack, sizeof(haystack), stdin);
    fgets(needle, sizeof(needle), stdin);

    /* 去掉 haystack 末尾的 '\n' */
    int i = 0;
    while (haystack[i] && haystack[i] != '\n')
        i++;
    haystack[i] = '\0';

    /* 去掉 needle 末尾的 '\n'(空行变成空串 ✅) */
    i = 0;
    while (needle[i] && needle[i] != '\n')
        i++;
    needle[i] = '\0';

    char *result = my_strstr(haystack, needle);
    if (result)
        printf("found: %s\n", result);
    else
        printf("not found\n");

    return 0;
}

核心逻辑解析:

  1. 空串提前返回*needle == '\0' 检查避免了后续 strlen("") = 0 导致的 n-m 无符号运算问题。
  2. 长度保护if (n < m) 既排除了明显无匹配的场景,也防止了 n-msize_t 下的回绕(underflow)。
  3. 外层 i <= n-m:等号不能丢。当 needle 正好在 haystack 末尾时,i = n-m 是唯一需要检查的起点。
  4. 内层 j == m 判成功:如果内层 break 了,j 会小于 m;如果完整跑完,j 恰好等于 m(循环终止条件 j < m 失效)。

对照检查:外层循环条件写了 <= 吗?内层比较用 haystack[i+j] 了吗?空 needle 返回 haystack 了吗?n < m 提前判了吗?


课堂讨论

  1. 外层循环条件写成 for (i = 0; i <= n - m; i++) 中的 <= 可以改成 < 吗?什么情况下结果会错?举例说明。
  2. 如果 needle 是空字符串 "",标准库 strstr 会返回什么?为什么这么设计?
  3. 暴力匹配最坏情况下为什么达到了 O(n×m)?设计一个具体的 haystack 和 needle 让暴力匹配的比较次数最大化。
  4. 这个算法是大小写敏感的吗?如果要实现大小写不敏感的匹配,需要在哪里做修改?
  5. KMP 算法中 next 数组的物理含义是什么?为什么它可以避免 j 回退到 0?

讨论答案

Q1: i <= n-m 中的 <= 可以改成 < 吗?

不可以。丢掉等号会在 needle 恰好出现在 haystack 末尾时漏检。

具体例子:haystack = "hello world", needle = "world"n=11, m=5, n-m=6。窗口的合法起点是 i=0 到 i=6。如果用 i < 6(即 i < n-m),i 只取 0,1,2,3,4,5——永远不会检查 i=6(窗口 "world" 的位置)。结果明明存在匹配却返回了 NULL。

这是一个典型的一刀切错误(off-by-one bug):用 < 替代 <= 导致少检查一个位置。在循环边界设计时,画出字符串末尾的具体索引并验证"最后一个合法窗口的起点"的值是必要的好习惯。

Q2: 空字符串 needle 应该返回什么?

C 标准(man strstr)规定:如果 needle 是空串,返回 haystack。

理由是数学上的"空串约定"——空串被认为在任何字符串的任何位置都能匹配。这一定义使得很多字符串算法的边界条件更简洁(如 KMP 的 next[0] 通常设 -1)。

在实现中,这对应 if (*needle == '\0') return (char *)haystack; 的检查。如果不做这个检查,strlen(needle) 返回 0,后续 n-m 虽然等于 n,但无符号下溢风险在 n=0 时仍可能出现。

Q3: 设计一个最坏 O(n×m) 的场景

haystack = "AAAAAA" (n=6), needle = "AAAAB" (m=5)

这个场景的杀伤力在于:每次窗口几乎比到最后才失败——前 m-1 个字符都匹配,最后一个才不匹配。

i=0: AAAAA? j=0✓ j=1✓ j=2✓ j=3✓ j=4✗ (5 次比较)
i=1: AAAAB? j=0✓ j=1✓ j=2✓ j=3✗      (4 次比较)
 = 5+4 = 9

推广:haystack 全为相同字符,needle 仅在最后一个位置不同
比较次数 (n-m+1) × (m+1) / 2 ≈ O(n×m)

这在真实场景中常见:
- DNA 序列中的 GC 重复区
- 日志文件中的重复时间戳
- 二进制文件中的 0x00 填充区
Q4: 如何实现大小写不敏感匹配?

在内层比较时用 tolower() 转换后再比较。

case_insensitive.c
c
#include <ctype.h>

// 修改内层比较:
for (j = 0; j < m; j++) {
    if (tolower((unsigned char)haystack[i+j])
        != tolower((unsigned char)needle[j]))
        break;
}

要点:

  • tolower 需要 <ctype.h>,参数必须是 unsigned char 以避免负数传参的 UB
  • 大小写不敏感匹配比敏感匹配略慢(多两次函数调用/字符),但在文本搜索中更常用
  • 这实际上就是标准库 strcasestr(GNU 扩展)的核心逻辑
Q5: KMP 的 next 数组的物理含义

next[j] 表示:当 needle[j] 匹配失败时,模式串 needle 内部哪些前缀已经与当前 haystack 的后缀匹配上了——下一次从 needle[next[j]] 开始比较即可。

具体来说:next[j] = needle[0..j-1] 这个子串的最长相等真前缀和真后缀的长度。

c
// needle = "ABABC"
// 索引    =  0 1 2 3 4

// 当 j=4 ('C') 失败时:
//   已匹配的 "ABAB" 的最长相等真前缀/真后缀是 "AB"(长度 2)
//   这意味着下一轮可以从 needle[2] 开始比较
//   next[4] = 2

这种设计的核心优势是j 只跳转不归零,i 只前进不后退——每个字符被读一次、最多比较两次,总的线性复杂度 O(n+m) 就从这里来。


课后练习

  1. 简单优化:首字符快速跳过。在内层比较开始前,先检查 haystack[i]needle[0] 是否相等——如果不相等,直接跳过内层循环。这个优化在什么场景下效果最好?

    知识点提示:观察"首字符不匹配"时原始算法也需要进入内层循环才 break,增加了 1 次无意义比较。首字符预检可以省下这次开销。

    参考解答
    optimize_first_char.c
    c
    for (size_t i = 0; i <= n - m; i++) {
        /* 首字符预检——跳过明显不匹配的位置 */
        if (haystack[i] != needle[0])
            continue;
    
        size_t j;
        for (j = 1; j < m; j++) {       /* 从 j=1 开始!首字符已比较过 */
            if (haystack[i + j] != needle[j])
                break;
        }
        if (j == m)
            return (char *)(haystack + i);
    }

    效果最好的场景:字符集大(如 ASCII 通用文本),needle 的组成字符在 haystack 中不频繁出现。此时大量位置会被首字符预检直接跳过,比较次数近似 O(n)。

    效果最差的场景:haystack 全由 needle[0] 重复组成(如 DNA 序列 "AAAAA..."),首字符预检几乎全部"通过",与原始暴力匹配无异。

  2. 扩展:统计匹配次数。修改算法,不仅查找第一次出现,还要统计 needlehaystack 中出现的总次数——允许重叠匹配(即 haystack="aaaa", needle="aa" 应返回 3 次:位置 0、1、2)。

    知识点提示:找到一次后不立即返回,而是继续扫描下一个位置。注意 i 只能递增 1,不能跳跃——重叠匹配要求窗口每次只移动一格。

    参考解答
    count_matches.c
    c
    int count_matches(const char *haystack, const char *needle)
    {
        if (*needle == '\0')
            return 0;  /* 空串:按约定返回 0(有些教材按数学定义返回 strlen+1) */
    
        size_t n = strlen(haystack), m = strlen(needle);
        if (n < m)
            return 0;
    
        int count = 0;
        for (size_t i = 0; i <= n - m; i++) {
            size_t j;
            for (j = 0; j < m; j++) {
                if (haystack[i + j] != needle[j])
                    break;
            }
            if (j == m)
                count++;
            /* 注意:无论是否匹配,i 都递增 1(允许重叠) */
        }
        return count;
    }

    核心区别:找到匹配后 不 return 也不跳 i+=m(那会禁止重叠),而是正常让外层循环 i++ 进入下一个窗口起点。

  3. 性能实验:比较暴力匹配在不同数据规模下的耗时。生成 haystack = "AAA...AB"(n=100~10000)、needle = "AA...AB"(m=10),分别测量暴力匹配的执行时间,记录 O(n×m) 的实际增长趋势。

    知识点提示:用 clock() 函数测量时间。观察 m 固定时,运行时间和 n 是否大致成正比(线性×常数 = O(n×m) 中 m 为常数时近似 O(n)),以及 m 和 n 同比例增长时时间是否近似 O(n²)。

    参考解答
    benchmark_bruteforce.c
    c
    #include <stdio.h>
    #include <string.h>
    #include <time.h>
    #include <stdlib.h>
    
    int main(void)
    {
        for (int n = 100; n <= 10000; n += 500) {
            /* 构造 haystack: n-1 个 'A' + 1 个 'B' */
            char *haystack = malloc(n + 1);
            memset(haystack, 'A', n - 1);
            haystack[n - 1] = 'B';
            haystack[n] = '\0';
    
            /* 构造 needle: 9 个 'A' + 1 个 'B' */
            char *needle = malloc(11);
            memset(needle, 'A', 9);
            needle[9] = 'B';
            needle[10] = '\0';
    
            clock_t start = clock();
            volatile char *r = my_strstr(haystack, needle);
            clock_t end = clock();
    
            double elapsed = (double)(end - start) / CLOCKS_PER_SEC;
            printf("n=%5d  time=%.6f s  result=%s\n",
                   n, elapsed, r ? "found" : "miss");
    
            free(haystack);
            free(needle);
        }
        return 0;
    }

    预期观察:m=10 固定,n 增长时,时间应大致线性增长(常数 m 使 O(n×m) ≈ O(n))。如果同时增长 m,时间将呈二次增长。

  4. 阅读 material:man strstr 与 GNU C 库实现。用 man strstr 查看官方文档,了解标准库的行为契约。对比你的实现和 glibc 的 strstr 源码(可在线搜索),看看工业级实现用了什么算法优化(如两路算法 Two-Way Algorithm、SIMD 向量化比较)。

    知识点提示:glibc 的 strstr 实际上使用"两路算法"(Two-Way String Matching),兼顾 O(n) 时间复杂度和 O(1) 空间复杂度。你可以思考:为什么工业级实现选择两路算法而不是 KMP 或 BM?

  5. 理论延伸:Boyer-Moore 坏字符规则。Boyer-Moore 算法从模式串的右端开始比较(而非左端)。阅读资料简要理解其"坏字符启发式"——当发生不匹配时,根据"坏字符"在模式串中的最近出现位置决定跳跃距离。描述它与 KMP "只前进不后退"思想的相似与不同。

    知识点提示:KMP 利用"已匹配前缀"的信息决定 j 的跳转;Boyer-Moore 利用"坏字符"或"好后缀"的信息决定 i 的跳跃。BM 在实际中通常比 KMP 更快(尤其是大字母表场景),但最坏仍是 O(n×m)。


参考资料

  • man strstr — Linux 手册页,查看标准库 strstr 的函数签名与行为契约
  • 《算法导论》第 32 章 "字符串匹配" — 暴力匹配、Rabin-Karp、有限自动机、KMP 的严格算法描述
  • glibc strstr 源码 — 工业级 C 标准库实现,使用 Two-Way Algorithm
  • KMP Algorithm — GeeksforGeeks — KMP 的逐步图文讲解
  • K&R《C 程序设计语言》§5.5 字符指针与函数 — strlenstrstr 等字符串函数的指针实现范式

"If debugging is the process of removing software bugs, then programming must be the process of putting them in." — Edsger W. Dijkstra

Released under the MIT License.