字符串基础与正则

共 18 题
📑 题目列表 18 题
#
★★★

1. KMP 与朴素匹配的复杂度对比中朴素算法最坏 O(n·m)(如 'aaaa...b' 对 'aaaa...a'),KMP 如何通过 next 数组避免回溯降到 O(n+m)?

请对比朴素字符串匹配算法与 KMP 算法的时间复杂度,说明为什么朴素算法最坏情况是 O(n·m),并解释 KMP 是如何通过 next 数组避免指针回溯从而将复杂度降到 O(n+m) 的?

  • 朴素匹配的失配定位与指针回溯机制
  • KMP 中主串指针不回退、模式串指针由 next 数组决定跳跃的核心思想
  • next 数组的自匹配(最长公共前后缀)构造

朴素算法在文本串与模式串的每个位置都进行一次完整匹配尝试,当文本形如 'aaaa...b'、模式形如 'aaaa...a'(n 与 m 都很大时)的极端情况下,每次在最后一个字符才失配,共尝试约 n 次、每次比对 m 个字符,总时间 O(n·m)。KMP 的核心改进是:失配时主串指针 i 不回退,而是利用 next 数组把模式串指针 j 跳到下一个可能匹配的位置,继续比较。由于模式串在 j 之前的字符已经与文本匹配,next[j] 表示前缀中与已匹配后缀相同的最大长度,因此跳跃后无需重新比较这些公共部分。这样主串指针 i 单调递增(最多前进 n 次),模式串指针 j 累计最多回退 n 次,总复杂度 O(n+m)。

朴素算法的浪费在于失配后把主串指针和模式串指针同时回退,导致同一位置被反复比较。KMP 的关键洞察是"我们已经知道模式串中已匹配的部分",通过 next 数组把这个信息保存下来,从而主串指针永不回退。复杂度 O(n+m) 的证明基于摊还分析:j 每次增加源于主串前进,j 每次回退都对应之前一次前进,因此总回退次数 ≤ 总前进次数 = n。

public static int kmp(String text, String pattern) {
    int n = text.length(), m = pattern.length();
    int[] next = buildNext(pattern);
    for (int i = 0, j = 0; i < n; i++) {
        while (j > 0 && text.charAt(i) != pattern.charAt(j)) j = next[j - 1];
        if (text.charAt(i) == pattern.charAt(j)) j++;
        if (j == m) return i - m + 1; // 找到
    }
    return -1;
}
private static int[] buildNext(String p) {
    int m = p.length();
    int[] next = new int[m];
    for (int i = 1, j = 0; i < m; i++) {
        while (j > 0 && p.charAt(i) != p.charAt(j)) j = next[j - 1];
        if (p.charAt(i) == p.charAt(j)) j++;
        next[i] = j;
    }
    return next;
}
#
★★★

2. 字符串的基本操作集合(查找、比较、子串、拼接)与复杂度?

请列举字符串的基本操作集合(查找、比较、子串、拼接等),并分析每种操作在 Java 中对应的实现与时间复杂度?

  • JVM 中 String 的不可变性与 char[] 存储
  • 拼接、比较、子串、查找各操作的复杂度
  • 不同实现(String/StringBuilder/StringBuffer)的选型

Java 的 String 是不可变对象,底层用 char[](Java 9+ 用 byte[])存储。基本操作:查找(indexOf/contains)在一般实现下为 O(n·m),JDK 使用改进算法在常见情况接近线性;比较(equals/compareTo)平均 O(min(n,m));子串(substring)在 Java 7 之前是 O(1) 共享底层数组,Java 7 之后改为复制,复杂度 O(k)(k 为子串长度);拼接(+ 运算符)在循环中每次创建新对象,N 次拼接为 O(n²),应使用 StringBuilder 使其变为 O(n) 摊还。字符串哈希(hashCode)缓存于对象中,首次计算 O(n),之后 O(1)。

不可变性的设计带来线程安全与缓存(hashCode)的好处,但代价是任何修改都需重新创建对象。因此频繁修改场景必须用 StringBuilder/StringBuffer(前者非线程安全但更快,后者同步)。复杂度分析要区分"每次操作"与"累计操作"两种视角,循环拼接这类累计场景才是正确选型的依据。

// 错误的拼接:O(n²)
String s = "";
for (int i = 0; i < n; i++) s += "x";
// 正确的拼接:O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append("x");
String s = sb.toString();
#
★★★

3. KMP 的 next 数组为什么能保证 O(n+m),失配时最长公共前后缀如何避免重复比较,给出构建示例?

请解释 KMP 的 next 数组为什么能保证 O(n+m) 的复杂度,说明失配时如何利用最长公共前后缀避免重复比较,并给出一个具体的构建示例?

  • next 数组的语义(最长公共前后缀长度)
  • 失配时指针跳跃的机制与指针移动次数的摊还分析
  • next 数组的线性构造过程

next[i] 表示模式串前缀 P[0..i] 中"既是前缀又是后缀"的最长长度(不含自身)。当匹配到位置 j 失配时,由于 P[0..j-1] 已与文本匹配,其后缀与 P 的前缀相同,因此不必从 0 重新开始,只需令 j = next[j-1] 继续比较。因为每次失配 j 回退到 next 值,而回退次数不超过之前的匹配推进次数,主串指针 i 单调递增,所以总复杂度 O(n+m)。构建示例:模式串 "ababaca",next 数组为 [0,0,1,2,3,0,1]。例如 next[3]=2 表示前缀 "abab" 的最长公共前后缀是 "ab"(长度 2)。

关键在于"已匹配的信息不浪费"。文本中已匹配的后缀与模式串前缀相同,是 next 数组跳跃成立的前提。复杂度保证来自摊还分析:i 只前进不回退,j 的回退量累计不超过 i 的前进量,由此线性。

// 构建 next 的过程实际上就是模式串与自身匹配
private static int[] buildNext(String p) {
    int m = p.length();
    int[] next = new int[m];
    for (int i = 1, j = 0; i < m; i++) {
        while (j > 0 && p.charAt(i) != p.charAt(j)) j = next[j - 1];
        if (p.charAt(i) == p.charAt(j)) j++;
        next[i] = j;
    }
    return next;
}
#
★★★

4. Z 算法(Z-function)如何在线性时间内求每个位置与前缀的最长匹配,与 KMP 的应用场景差异?

请解释 Z 算法(Z-function)如何在线性时间内求出每个位置与字符串前缀的最长匹配长度,并对比它与 KMP 的应用场景差异?

  • Z 数组的定义与 Z-box 的维护
  • Z 算法 O(n) 构造的证明思路
  • 与 KMP 各自的适用场景(Z 数组天然适合"前缀匹配"统计)

Z 数组 z[i] 表示从位置 i 开始与字符串前缀能匹配的最长长度,即 s[0..z[i]-1] == s[i..i+z[i]-1]。Z 算法维护一个 [l, r] 的 Z-box(当前覆盖到的最右区间),当 i ≤ r 时可以利用 z[i-l] 直接加速初始化,再暴力扩展;当 i > r 时重新从暴力匹配开始。由于每次扩展 r 只增不减,r 最多前进 n 次,故总复杂度 O(n)。Z 算法常用于"在某串中找子串"(把模式串与文本拼接,中间加分隔符),以及统计每个位置与自身前缀的匹配长度。KMP 用于"单模式匹配整个文本",Z 算法则天然给出"每个位置与全局前缀的最长匹配",在需要前缀匹配信息的场景(如重复子串检测、最长回文相关)更直接。

Z 算法与 KMP 都用"已匹配信息避免重复比较",但角度不同:Z 数组直接给出"每个位置与全局前缀的最长匹配",是前缀匹配的天然统计;KMP 的 next 则聚焦"模式串内部前缀与后缀"。Z 算法同样线性是因为 Z-box 的右端点 r 单调递增,保证了总的扩展次数为 O(n)。需要前缀匹配长度的场景用 Z 更直接,纯粹的"整串匹配"两者皆可。

public static int[] zFunction(String s) {
    int n = s.length();
    int[] z = new int[n];
    for (int i = 1, l = 0, r = 0; i < n; i++) {
        if (i <= r) z[i] = Math.min(r - i + 1, z[i - l]);
        while (i + z[i] < n && s.charAt(z[i]) == s.charAt(i + z[i])) z[i]++;
        if (i + z[i] - 1 > r) { l = i; r = i + z[i] - 1; }
    }
    return z;
}
#
★★★

5. 字符串匹配的朴素算法与 KMP 的 next 数组中失配时如何利用已匹配前缀避免回溯?

在字符串匹配场景中,请说明朴素算法与 KMP 在失配时的处理差异,KMP 如何利用已匹配的前缀避免回溯?

  • 朴素算法失配时指针双回退的浪费
  • KMP 利用 next 数组让主串指针不回退
  • 已匹配前缀信息如何复用

朴素算法失配时把主串指针和模式串指针都回退,重新开始比较,导致已匹配的部分被重复比较。KMP 在失配时,主串指针 i 保持不变,模式串指针 j 跳到 next[j-1](即已匹配前缀的最长公共前后缀长度),因为模式串在 j 之前的字符已经与文本匹配,其最长公共前后缀后缀与文本中刚匹配的部分重叠,无需再比较。这样就完全避免了主串回溯,也避免了重复比较已知相等的部分,从而保证线性复杂度。

朴素算法丢掉的信息是"文本中已经与模式串匹配的那一段",KMP 用 next 数组把它保存下来。失配时已知的匹配长度 k = j,其最长公共前后缀长度是 next[k-1],于是模式串只需从该重叠位置之后继续比较即可。

#
★★

6. 字符串与字符数组/字节流的边界差异(不可变、编码、索引)?

请分析字符串与字符数组、字节流之间的边界差异,包括不可变性、编码方式与索引语义?

  • String 不可变 vs char[] 可变
  • 字符(char)与字节(byte)的对应关系
  • 索引是"字符位置"还是"字节位置"的区别

String 是不可变对象,char[] 可变;因此 String 天然线程安全、可缓存 hashCode,但任何修改都要重新创建对象。字符串以"字符"为逻辑单位,但底层存储是字节(UTF-16 下 Java 的 char 是 2 字节,UTF-8 下每个码点占 1-4 字节),因此"索引"的语义取决于编码:Java 的 charAt 按 UTF-16 code unit 索引,遇到 BMP 外的码点(如 emoji)会占两个 char,遍历时可能分割代理对;C 语言字符串按字节索引,索引到多字节字符中间会出错。字节流是原始字节,需要解码器才能变成字符串,直接按字节索引没有字符语义。

边界差异的核心是"逻辑字符"与"物理存储"的映射。跨语言交互时,同一字符串在不同编码下的字节数不同,索引、截断、长度都必须以统一编码(如 UTF-8)为准,否则会损坏数据。

#
★★

7. 字符串基础操作(拼接、反转、子串)在算法题与工程中的典型应用?

请说明字符串拼接、反转、子串等基础操作在算法题与工程实践中的典型应用场景?

  • 拼接(归并、构建结果)
  • 反转(双指针、回文)
  • 子串(滑动窗口、字符串匹配)

在算法题中,拼接常用于双指针归并字符串、构建最终结果、以及巧妙题型(如"两数相加"的字符串大数);反转常通过双指针原地交换实现,用于回文判断与倒序场景;子串操作(substring、滑动窗口)是字符串匹配、最长无重复子串、异位词等核心题型的基石。工程中,拼接用于日志、SQL、路径构建(用 StringBuilder);反转用于 Base64、URL 反转、加密辅助;子串用于文本解析、字段提取、正则式匹配结果的截取。

这些基础操作是更复杂字符串问题的原子步骤。理解它们各自的时间复杂度(如拼接的 O(n²) 陷阱、子串的复制成本)能在算法优化与工程性能之间做出正确取舍。

#
★★

8. 字符编码在序列化、I/O 与跨语言交互中的典型应用?

请说明字符编码在序列化、I/O 操作与跨语言交互中的典型应用场景与注意事项?

  • 序列化时如何指定并使用统一编码
  • I/O 输入输出流的字节与字符转换
  • 跨语言/跨平台编码不一致的坑

序列化时需显式指定编码(如 JSON/XML 用 UTF-8),否则默认平台编码(如 Windows 的 GBK)会导致数据在另一平台无法解析。I/O 中,字节流(InputStream/OutputStream)负责读写原始字节,字符流(Reader/Writer)通过指定 Charset 在字节与字符间转换,常用 InputStreamReader 与 new String(bytes, charset) 显式传参。跨语言交互时,网络协议通常约定 UTF-8,遇到底层字节需按约定解码;比较字符串或哈希时,不同编码下同一内容字节不同,需先统一编码再比较。

编码问题的根源是"同一个字符串在不同编码下字节表示不同"。最佳实践是全程统一 UTF-8,在 I/O 边界显式指定编码,避免依赖平台默认编码,从而消除乱码与数据损坏。

#
★★

9. 正则表达式与字符串匹配算法(KMP、多模式)的适用边界?

请说明正则表达式与字符串匹配算法(KMP、多模式匹配)各自的适用边界与使用场景?

  • 正则表达式表达能力 vs 性能
  • KMP 固定单模式精确匹配
  • AC 自动机等多模式匹配

正则表达式适合"模式本身是动态/复杂规则"的场景,如输入校验、日志提取、替换,但可能因回溯导致性能灾难;KMP 适合"单个固定模式、需要最坏情况线性的精确匹配",在性能敏感或对抗性输入下更可靠;AC 自动机适合"多模式精确匹配"(如敏感词过滤、字典匹配),一次扫描文本即可命中所有模式。当模式可以预编译且匹配是固定字符串时,用字符串算法更快更稳;当模式需要通配符、分组、逻辑或等复杂语义时,用正则。

选择的核心权衡是"表达力 vs 复杂度与确定性"。正则引擎(尤其回溯实现)在表达上最灵活但最不可控;KMP/AC 牺牲表达力换取最坏情况线性与确定性。二者是互补而非替代的关系。

#
★★

10. 正则表达式使用有哪些常见误区(贪婪回溯、锚点、转义)?

请列举正则表达式使用的常见误区,包括贪婪回溯、锚点使用、转义等问题?

  • 贪婪量词引起的回溯/灾难性回溯
  • 锚点(^ $)的误用
  • 转义与特殊字符处理

常见误区包括:1)默认贪婪匹配导致回溯过多,如 .* 在嵌套结构上可能灾难性回溯,应改用非贪婪 .*?、原子组或限定死匹配范围;2)忘记锚点导致匹配位置错误,如只写 abc 会匹配"xabcx"中的子串,需要 ^abc$\b 界定;3)转义不当,正则里的 . * ? + ( [ \ 等是元字符,要匹配字面量需转义,而在 Java 字符串和正则中需双重转义(\\);4)字符类内特殊字符处理,以及忽略大小写/多行等 flag 的误配。

这些误区大多源于"正则的语义与直觉不同"。正则既是语言也是引擎,理解引擎的匹配/回溯过程、元字符与转义规则,才能写出既正确又高效的表达式。

#
★★

11. 正则引擎的两种实现中 NFA 模拟(Thompson)与回溯(backtracking)在性能与表达能力上的差异,灾难性回溯如何产生?

请对比正则引擎两种实现(NFA 模拟 Thompson 与回溯 backtracking)在性能与表达能力上的差异,并说明灾难性回溯如何产生?

  • Thompson NFA 模拟的线性复杂度与确定性
  • 回溯引擎的灵活性与指数级最坏情况
  • 灾难性回溯(catastrophic backtracking)的成因

Thompson NFA 构造把正则编译为 NFA,用模拟所有状态集合的方式匹配,保证 O(n·m)(n 文本长度、m 模式大小)的线性时间,且不支持回溯(如反向引用、某些回溯引擎保留的扩展),表达力有限但性能确定性高。回溯引擎采用贪婪尝试+回溯的方式,支持反向引用、条件、前瞻等更丰富的特性,但最坏情况可能指数级:当多个量词(如 (a+)+)彼此嵌套、输入能部分匹配又反复失败时,引擎会指数级重试同一子串,造成灾难性回溯(ReDoS)。防御手段是限制递归深度、使用原子组/占有量词、禁止嵌套量词或改用 NFA 引擎。

这是"表达力 vs 可预测性"的根本权衡。Thompson 用状态集合模拟牺牲了某些扩展特性换取线性时间;回溯引擎为了完整特性付出指数级最坏代价。理解二者才能在安全与功能间取舍。

#
★★

12. 正则匹配的核心概念中字符类、量词、分组与回溯?

请解释正则匹配的核心概念:字符类、量词、分组与回溯,并说明它们如何共同工作?

  • 字符类 [..] 与常用转义元字符
  • 量词 * + ? {n,m} 的匹配语义
  • 分组 () 与捕获、回溯的交互

字符类(如 [a-z]\d\w)定义"匹配哪个字符集合";量词(*+?{n,m})定义"匹配多少次",默认贪婪(尽可能多匹配);分组 () 把若干 token 组合成一个单元,可配合量词或捕获内容,(?:...) 是非捕获分组。回溯是引擎在贪婪匹配失败后逐步回退减少重复次数、尝试其他分支的过程,它把量词、分组、分支结合在一起,保证最终能匹配或明确失败。理解回溯就能理解为什么某些正则会慢或快。

这四个概念是正则的语法基础。回溯是"匹配引擎"的执行机制,它让前三个概念能够组合出复杂模式,但也是性能问题的来源。掌握它们能写出正确、高效的正则。

#

13. 字符串操作有哪些常见误区(+=性能、索引越界、编码混淆)?

请列举字符串操作的常见误区,包括 += 拼接的性能、索引越界与编码混淆等问题?

  • += 循环拼接的 O(n²) 性能陷阱
  • 索引越界与边界处理
  • 编码混淆(字节/字符/码点)

常见误区:1)循环中用 s += "x" 拼接,每次创建新对象,总代价 O(n²),应改用 StringBuilder;2)索引越界,Java 的 charAt 索引范围是 [0, length),越界抛 StringIndexOutOfBoundsException,substring 的边界也要注意;3)编码混淆,把"字符数"当成"字节数",或没考虑 UTF-16 的代理对(emoji 占 2 个 char),导致遍历或截断出错;4)误用 == 比较字符串内容(应比较引用,用 equals 比较内容)。

这些误区源于对 String 不可变、底层存储、字符编码的细节理解不足。掌握这些细节能避免最常见的正确性与性能问题。

#

14. 字符编码(ASCII/UTF-8/UTF-16)的差异与选型边界?

请说明 ASCII、UTF-8、UTF-16 三种字符编码的差异,以及各自的选型边界?

  • 各编码的码点范围与字节宽度
  • 兼容性与字节序
  • 选型(网络/存储/内存)考量

ASCII 只覆盖 0-127,每字符 1 字节,是基础子集;UTF-8 是变长编码,1-4 字节,ASCII 兼容(ASCII 字符在 UTF-8 下仍是 1 字节),无字节序问题,适合网络传输与文件存储,是互联网事实标准;UTF-16 是变长编码(2 或 4 字节),BMP 内字符固定 2 字节,有字节序(BOM)问题,Java 内部 char 用 UTF-16 表示,适合内存中处理含大量 BMP 字符的文本。选型上,存储/传输用 UTF-8(节省空间、ASCII 兼容、无 BOM 包袱),内存索引如 Java 内部用 UTF-16,纯 ASCII 场景用 ASCII 即可。

关键是"变长 vs 定长"与"字节序"的权衡。UTF-8 在 ASCII 文本占空间最优且无字节序问题,成为网络/文件标准;UTF-16 在 CJK 等 BMP 字符上常为 2 字节,利于内存索引但引入 BOM。

#

15. 字符编码处理有哪些常见误区(BOM、截断、非法字节)?

请列举字符编码处理的常见误区,包括 BOM 处理、字节截断与非法字节等问题?

  • BOM 的读写与去除
  • 编码转换时截断多字节字符
  • 非法字节的容错与替换

常见误区:1)BOM:UTF-8 BOM(EF BB BF)写入文件后读回时未去除,导致首字符出现乱码或字符串比较失败,需在解码时跳过或使用带 BOM 的读取方式;2)截断:按字节数硬截断子串时可能切断一个多字节字符的中间字节,产生非法 UTF-8,需按字符边界截断;3)非法字节:遇到无效字节序列时,若直接解码会抛异常或产生替换字符,需设置容错策略(如替换为 U+FFFD 或忽略);4)编码转换时直接按字节拼接,未统一字符集。

这些误区都源于"字节与字符的映射不总是 1:1"。正确处理需要理解编码边界、在解码/编码层面显式处理 BOM 与错误,才能保证数据完整。

#

16. UTF-8 编码的核心概念中码点、字节序列、变长编码?

请解释 UTF-8 编码的核心概念:码点、字节序列与变长编码规则?

  • Unicode 码点(code point)的定义
  • UTF-8 的变长编码规则(1-4 字节)
  • 前缀位与连续字节的区分

Unicode 码点(code point)是抽象的字符编号,范围 U+0000 到 U+10FFFF。UTF-8 最重要的设计是变长编码:0-127 的 ASCII 码点用 1 字节(0xxxxxxx);128-2047 用 2 字节(110xxxxx 10xxxxxx);2048-65535 用 3 字节(1110xxxx 10xxxxxx 10xxxxxx);更大用 4 字节(11110xxx 10xxxxxx 10xxxxxx 10xxxxxx)。前导字节的"1"的个数标明后续字节数,所有后续字节都以 "10" 开头,从而连续字节可被无歧义识别,且不含 BOM 的字节序列可自同步定位。

变长编码的巧妙之处在于"前缀码"特性:每个字节都可通过前缀位判断是首字节还是后续字节,因此无需 BOM 即可自描述,且与 ASCII 完全兼容。这是 UTF-8 成为互联网标准的核心原因。

public static int utf8Length(int codePoint) {
    if (codePoint < 0x80) return 1;
    else if (codePoint < 0x800) return 2;
    else if (codePoint < 0x10000) return 3;
    else return 4;
}
#

17. 正则匹配在日志解析、输入校验、文本提取中的典型应用?

请说明正则匹配在日志解析、输入校验、文本提取中的典型应用场景?

  • 日志解析:提取时间戳、级别、关键字段
  • 输入校验:邮箱、手机号、格式验证
  • 文本提取:抽取 URL、数字、关键信息

日志解析中,正则用于从日志行提取时间戳、日志级别(INFO/WARN/ERROR)、异常堆栈、关键字段,配合分组捕获直接得到结构化数据。输入校验中,正则用于验证邮箱、手机号、密码强度、URL 等格式,常在表单与接口入参做前置校验。文本提取中,正则用于从大段文本抽取 URL、数字、日期、标签等,常配合 replaceAll 批量替换或 Matcher 遍历提取。

这些场景的共同点是"模式相对固定、但输入量大或格式多样"。正则把"格式规则"压缩成表达式,配合捕获组与迭代匹配(Matcher.find())高效完成结构化提取,是文本处理的标准工具。

#

18. 正则表达式到 NFA 的 Thompson 构造中每个语法构造只引入常数个新状态,ε-转移如何合并,NFA 模拟为何能保证线性时间?

请解释 Thompson 构造如何把正则表达式编译成 NFA,说明为什么每个语法构造只引入常数个新状态、ε-转移如何合并,以及 NFA 模拟为何能保证线性时间?

  • Thompson 构造的规则(字符、连接、并、闭包)
  • 每个构造引入常数个状态的正确性
  • ε-闭包与 NFA 状态集合模拟的线性时间

Thompson 构造为每个正则构造定义 NFA 片段:单个字符引入 2 个状态(1 条转移);连接两个子片段时把前者的末尾接后者的开头;并集 a|b 引入 2 个新状态,用 ε-转移并联两个子片段;闭包 a* 引入 2 个新状态,用 ε-转移实现循环。每个构造都只增加常数个状态,因此整体状态数 O(m)(m 是正则长度)。ε-转移用于把子片段"拼接/并联"而不消费字符,匹配时先计算 ε-闭包(可达且不消费字符的状态集合)。模拟时维护当前活跃状态集合,对每个文本字符做一次转移+ε-闭包,因为状态数为 O(m),单字符处理 O(m),总 O(n·m) 线性于文本长度(对固定模式而言)。

关键是把"分支与回溯"替换为"同时追踪所有活跃状态"。ε-闭包让并联与闭包结构统一处理,状态集合模拟保证每个字符只处理一遍,从而避免回溯引擎的指数级退化。这是 NFA 引擎线性时间的基础。