Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions content/chapter-03-string-array/01-string-basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ order: 1
chapter: 3
chapterTitle: "字符串与数组"
updated: "2026-08-18"
contributors: ["Qing"]
contributors: ["Qing Ph1z"]
status: "draft"
---

# 3.1 字符串的定义、存储与编码

计算机上的非数值处理对象,很多是以字符串为单位的:源程序与目标程序、顾客的姓名地址、信息检索系统的关键词、文本编辑的内容,都是字符串。可计算机硬件天生是为数值计算设计的,于是串的存储和操作都得单独想办法。不同应用中串的特点差别很大——长度是否固定、拼接是否频繁、要不要随机访问、是不是流式输入——只有按实际情况选对存储结构,串处理才高效。这一节先把串的基本概念与抽象数据类型定下来,再说字符在计算机里怎么编码,最后讲三种存储表示。
> **为什么我们需要字符串捏?** 因为程序、姓名地址、文件内容、搜索关键词,很多真实世界的数据都不是一个孤零零的数字,而是一串有顺序的字符。计算机硬件虽然擅长数值计算,但面对这些字符时,仍然无从下手。

这一节先从“串到底是什么”讲起,再看字符如何编码、串如何存储,最后用文本编辑的例子把这些概念串起来。下文中带有“直觉理解”的段落可以帮助初学者建立图景;公式和复杂度分析则负责把这种图景说准确。

---

## 学习目标

Expand All @@ -22,7 +26,11 @@ status: "draft"
- 实现求长、取子串、定位、拼接等基本操作并分析复杂度;
- 通过文本编辑的例子理解"以串的整体为操作对象"的实际意义。

## 串的定义与基本术语
---

## 第一部分:串的定义与基本术语

> **为什么字符串不能只当作字符数组?** 因为“连续的一段字符”本身就是一个有意义的整体:我们要比较单词、截取句子、查找关键词,而不是每次只盯着一个字符。

**串(string,或字符串)** 是零个或多个字符组成的有限序列,一般记为

Expand All @@ -46,7 +54,11 @@ $$

两个串**相等**,当且仅当它们的长度相等且各个对应位置的字符都相同。串值必须用引号括起来以区别于变量名或数值常量,例如 `x = '123'` 表示把字符序列 `123` 赋给串变量 `x`;引号本身不属于串值。

## 串的逻辑结构与抽象数据类型
---

## 第二部分:串的逻辑结构与抽象数据类型

> **Ph1zの理解:** 串可以看成“数据对象被限制为字符的线性表”,真正特别的地方不在于它由字符组成,而在于它通常把**整个串或子串**作为操作对象。

串的逻辑结构和线性表极为相似,区别仅在于串的数据对象被约束为字符集。真正的差别体现在基本操作上:线性表大多以**单个元素**为操作对象(查找某个元素、求取某个元素、在某个位置插入/删除一个元素),而串通常以**串的整体**为操作对象(查找子串、求取子串、插入/删除子串、替换子串)。

Expand Down Expand Up @@ -91,7 +103,11 @@ ADT String {
`"ab" < "abc"`,因为短串是长串的前缀时短串更小;`"abc" < "abd"`,因为第一个不同字符 `c < d`。比较必须逐字符按字典序进行,而不是先比长度。
:::

## 字符、字符集与编码
---

## 第三部分:字符、字符集与编码

> **为什么一个汉字有时是 1 个字符,有时却占 2 或 3 个字节?** 因为“字符集”规定有哪些字符,“编码”规定这些字符如何落到字节上。算法如果不区分这两个层次,就很容易把字符边界切坏。

**字符**是组成字符串的基本单位。C/C++ 中 `char` 通常占 1 字节(8 bits),用 **ASCII 码**对 128 个符号编码——这 128 个符号构成的集合就是**字符集(charset)**:数字、大小写字母、标点与控制字符各有一个 0~127 的编号。

Expand Down Expand Up @@ -126,7 +142,11 @@ UTF-8 变长的秘密在于"前导字节"的二进制前缀:一个字符占几
多字节编码下字符数与字节数不相等。C 的 `strlen`、`std::string::size()` 都按字节计数;需要按字符处理时必须先解码(如按上面的规则逐字节判定字符边界),不能直接按下标截断。
:::

## 串的存储表示
---

## 第四部分:串的存储表示

> **选择哪种串结构才不亏?** 没有一种存储方式在所有场景都最好:定长顺序存储换来简单和随机访问,堆分配换来灵活,块链则更适合超长文本和流式处理。

串有 3 种机内表示方法:**定长顺序存储、堆分配存储、块链存储**。它们分别对应不同的取舍。

Expand Down Expand Up @@ -283,7 +303,11 @@ $$
| 空间开销 | 无指针开销 | 无指针开销 | 指针 + 块内填充 |
| 适用场景 | 长度已知、频繁随机访问 | 通用,拼接/比较为主 | 超长文本、流式批量处理 |

## 应用举例:文本编辑中的串
---

## 第五部分:应用举例——文本编辑中的串

> **一个文本编辑器为什么要维护页表和行表?** 因为修改“整行”时,往往只需要调整索引,不必搬动整篇文本。数据存储和索引结构分开,正是串处理从理论走向工程的关键一步。

文本编辑的实质是修改字符数据的形式或格式,基本操作是串的查找、插入和删除。可以把整个文本看成一个**文本串**,用换页符、换行符划分为若干页与行——页是文本串的子串,行是页的子串。进入编辑时,程序为文本串建立**页表**和**行表**(各子串的存储映像):页表记录页号与该页起始行号,行表记录每行的行号、起始地址和长度。例如某文本串只占一页,其行表如下:

Expand All @@ -299,6 +323,8 @@ $$

这个例子与三种存储结构是呼应的:正文按行存放,行内字符连续存放(可用堆分配;行内编辑频繁时也可用块链减少大段搬移);插入删除整行只改行表,代价与行内长度无关;而行内插入删除仍要搬移该行的字符。它同时说明了两点:一是串处理中"以串的整体为操作对象"(行是子串,文本是主串)是常态;二是存储结构与索引结构分离(字符存储 + 行表)可以大幅降低编辑代价。

---

## 小结

串是值域限定为字符集的线性表,操作粒度从“单个元素”转为“整体子串”(比较、拼接、定位),存储结构围绕这三类操作设计。字符编码(定长/变长)决定逻辑字符与物理字节的映射关系:定长编码支持O(1)随机偏移,变长编码(如UTF-8)下按字符索引需前缀解码,退化为O(n)。
Expand All @@ -313,6 +339,8 @@ $$

下一节将上述存储模型作为底层访问接口,讨论模式匹配问题。重点分析BF、KMP、BM算法在连续存储与链式存储下的实际效率差异,以及变长编码对“字符比较”带来的非预期开销。

---

## 练习

1. 空串与空格串有什么区别?它们的长度分别是多少?"判断串为空"应该用哪个?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ status: "draft"

3.1 解决了串"怎么存、基本操作怎么做"。这一节把串真正用起来:先看 C 语言给了哪些现成的串函数,再深入串最经典的问题——模式匹配,从朴素的 $O(n\cdot m)$ 改进到 KMP 的 $O(n+m)$,最后梳理串处理中的常见陷阱,并用"建立词索引表"说明串操作如何组装成真实应用。

---

## 学习目标

- 掌握 C 标准串函数 `strlen`、`strcpy`、`strcat`、`strcmp`、`strchr`、`strrchr` 的语义与风险;
Expand All @@ -21,6 +23,8 @@ status: "draft"
- 论证 KMP 的复杂度为 $O(n+m)$,并说明"主串只扫一遍"的工程价值;
- 识别串处理中的常见陷阱,理解串操作如何组装成词索引表等应用。

---

## C 语言的标准字符串函数库

C 语言没有内置串类型:字符串是"字符数组 + 结束标记",串值末尾放一个 `'\0'`(ASCII 中 8 位全 0 的 NULL 符),不计入串长。这种表示下串长是隐含的,求长必须扫描到结束标记——这正是 3.1 讲的串长表示方案中 C 语言的选择。注意这些函数按字节工作,多字节编码下 `strlen` 返回的是字节数而非字符数(3.1 的"串长≠字节数")。
Expand All @@ -42,6 +46,8 @@ C 语言没有内置串类型:字符串是"字符数组 + 结束标记",串
`strcpy`、`strcat` 只知道源串在哪结束,不知道目标数组能装多少,目标空间不够时照样写入,越过数组边界就是**缓冲区溢出**——C 程序最常见的安全漏洞之一。应改用 `strncpy`/`strncat` 并显式传长度,或直接使用能自动管理空间的 `std::string`。
:::

---

## 模式匹配的问题定义

**模式匹配**:在主串 `S`(长度为 `n`,又称目标串)中查找模式串 `T`(长度为 `m`,又称模式)首次出现的位置。这是文本编辑器、搜索引擎、信息检索与生物信息学中的基础操作,其效率直接决定这些系统的响应性能。
Expand Down Expand Up @@ -191,6 +197,8 @@ $$
这个结论的工程意义极其重大:**失配后模式如何滑动,完全由模式串自身预先计算好**,与主串无关——这就是 `next` 数组存在的根本依据。
:::

---

## 最大长度表与 next 数组

### 最大长度表
Expand Down Expand Up @@ -266,6 +274,8 @@ $$
`P[0..3] = "abab"`,按候选长度从大到小检查真前后缀:长度为 3 时前缀 `"aba"`、后缀 `"bab"`,不等;长度为 2 时前缀 `"ab"`、后缀 `"ab"`,相等。因此最长相等真前后缀长度为 2,即 `next[4] = 2`。失配于 `P[4]` 时,模式整体右移,直接用 `P[2]` 与主串当前字符继续比较。
:::

---

## next 数组的递推计算

手工查表只适合短模式,程序里要用递推。`build_next` 的思路:设进入循环时 `k = next[j]`,比较 `P[j]` 与 `P[k]`——这和匹配时失配回退是同一个模式:
Expand Down Expand Up @@ -307,6 +317,8 @@ std::vector<int> build_next(std::string_view p) {

构建 next 的时间复杂度为 $O(m)$:`k` 每次循环最多加 1(共 $m$ 次),沿 `next` 链回退的总量不超过前进总量,因此总工作量 $O(m)$。

---

## KMP 匹配算法

```cpp:line-numbers [kmp-match.cpp]
Expand Down Expand Up @@ -355,6 +367,8 @@ $$

KMP 最大的特点不是常数意义上的快,而是**主串指针单调递增、只需顺序扫描一遍**。处理从磁带、网络流或大文件读入的输入时,可以边读边匹配、读过的字节不必重读;朴素算法在主串上反复回退,若输入不能随机访问(磁带要倒带、网络流要重传),回退意味着无法承受的代价。这是 KMP"主串指针不回退"特性真正的工程价值。

---

## nextval:避免必然再次失配的比较

按原始 next 定义,某些失配后的回退比较是必然失败的。例如主串 `"aaaabcde"`、模式 `"aaaaax"`,其 next 为 `[-1, 0, 1, 2, 3, 4]`。若失配发生在 `j = 3`(即 $s_i \ne 'a'$),按 next 会依次尝试 `p_2`、`p_1`、`p_0`——它们都是 `'a'`,必然再次失配,白白比较 3 次。
Expand Down Expand Up @@ -384,10 +398,14 @@ std::vector<int> build_nextval(std::string_view p, const std::vector<int>& next)
模式中存在大量重复字符(如 `"aaaa…ab"`、`"abab…"`)时收益明显;字符几乎不重复的模式收益很小。nextval 常被视为 KMP 的标准配置,竞赛与面试中 next 与 nextval 两种写法都常见,关键是讲清约定。
:::

---

## 延伸阅读:其他匹配算法

朴素与 KMP 都是单模式、从左到右比较。文本编辑器的"查找"功能常用 **BM(Boyer–Moore)算法**:模式从右向左比较,失配时利用"坏字符"与"好后缀"规则跳过更多字符,平均性能优于 KMP;**AC(Aho–Corasick)算法**把 KMP 的"前缀自包含"思想扩展到多模式匹配:一次扫描同时在多个模式串中查找,复杂度 $O(n)$ 且与模式数量无关;**Wu-Manber** 算法则基于 BM 思想。理解 KMP 的 next 推导,是理解这些算法的基础。本节只引路不展开,后续章节再深入。

---

## 串处理中的常见陷阱(字符串的烦恼)

串操作看似简单,工程中却处处是坑,常称之为"字符串的烦恼"。应对它们有一个共同点:**先想清楚串的编码、长度、容量与边界,再动手**。
Expand All @@ -399,6 +417,8 @@ std::vector<int> build_nextval(std::string_view p, const std::vector<int>& next)
- **安全性问题**:把用户输入直接拼进 SQL 语句会引发 **SQL 注入**;不检查容量的 `strcpy`/`strcat` 会引发**缓冲区溢出**。
- **多语言支持**:中文、emoji、组合字符等复杂字符集与书写规则带来挑战,按字符处理还是按字节处理必须想清楚(3.1 的 UTF-8 字节结构)。

---

## 应用举例:建立词索引表

信息检索系统的主要操作是在大量信息中查询特定内容,提高查询效率的关键是建立好的索引。例如图书馆书目检索中,按书名直接检索并不方便,更实用的做法是建立"**书名关键词索引**":把书目文件中每本书的书名拆成关键词,按词典序组织成索引表,每个关键词后挂上该书号。
Expand All @@ -413,10 +433,14 @@ std::vector<int> build_nextval(std::string_view p, const std::vector<int>& next)

代价也可以量化:设一本书的书名有 $L$ 个字符、提取出 $k$ 个关键词,索引表已有 $K$ 个索引项。提取关键词 $O(L)$;每个关键词在有序索引表中折半定位 $O(\log K)$,若需插入还要搬移 $O(K)$;书号链表插入 $O(1)$。可见构建成本的大头在"有序插入",而索引一旦建好,查询就是折半查找 $O(\log K)$——这正是"建索引"与"查索引"的权衡。这个例子说明:串操作很少孤立出现,通常与线性表等结构组装在一起,而"以串的整体为操作对象"(提取、比较、复制整个词)正是串区别于一般线性表的本质。

---

## 小结

本节把串从"存"推进到"用":C 标准串函数库回答了"语言已经给了哪些现成操作";模式匹配是串最经典的问题——朴素匹配的指针回退暴露了重复比较的浪费,KMP 用最大长度表找出模式自身的相等真前后缀,让主串不回退,把最坏复杂度从 $O(n\cdot m)$ 压到 $O(n+m)$,nextval 再消除必然失配的比较;工程视角下,编码、性能、边界、安全等"字符串的烦恼"决定了一个串程序是否真正可用,词索引表则展示了串操作如何与线性表组装成真实系统。

---

## 练习

1. `strcpy(s1, s2)` 的"不检查容量"意味着什么风险?与 `strcat` 相比,安全使用要注意什么?
Expand Down
20 changes: 20 additions & 0 deletions content/chapter-03-string-array/03-array-and-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ status: "draft"

数组是"按下标随机存取"的数据结构:逻辑上它可能有多维,物理上它占据一段连续内存。理解**多维下标到一维地址的映射**,是掌握数组存储的关键;理解**特殊矩阵的压缩**,则是空间优化的典型练习。

---

## 学习目标

- 写出数组的 ADT,说明数组是线性表的推广;
- 由行优先/列优先推导二维与 n 维数组的寻址公式;
- 用压缩存储表示对称、三角、三对角矩阵,并推导下标换算;
- 用三元组与十字链表表示稀疏矩阵,比较转置算法的复杂度。

---

## 数组的定义与 ADT

数组是由 $n\ (n>1)$ 个相同类型元素组成的有序序列,是线性表的推广:二维数组可看作"元素是一维数组的一维数组",n 维数组可看作"元素是 $n-1$ 维数组的一维数组"。数组一旦建立,元素个数与元素间关系就不再变化,因此**一般只做存取、不做插入删除**,采用顺序存储。
Expand All @@ -36,6 +40,8 @@ ADT Array {
}
```

---

## 顺序存储与寻址

多维数组要映射到一维内存,有两种次序约定:
Expand Down Expand Up @@ -69,6 +75,8 @@ $$

其中空乘积($k=n-1$ 时)取 1。由于计算任一元素地址的时间相等,数组被称为**随机存取结构**。

---

## 对称矩阵压缩

若 `A` 是 $n \times n$ 对称矩阵(`A[i][j] = A[j][i]`),只需存储下三角(含对角线)共:
Expand All @@ -85,6 +93,8 @@ $$

当 `i < j` 时利用对称性交换下标即可。

---

## 三角矩阵

三角矩阵的主对角线一侧(不含对角线)的元素全为常数 `c`(常取 0)。除存三角区元素外,再额外用一个位置存 `c`。
Expand All @@ -104,6 +114,8 @@ $$
k = \frac{i(2n - i + 1)}{2} + (j - i).
$$

---

## 三对角矩阵

三对角矩阵的非零元集中在主对角线及其上、下各一条对角线上,即 $|i-j| \le 1$ 时 $A[i][j]$ 可能非零。第 0 行与第 $n-1$ 行各 2 个非零元,其余各行 3 个,共 $3n-2$ 个。按行优先压缩到一维数组时:
Expand All @@ -119,6 +131,8 @@ std::size_t tridiagonal_offset(std::size_t i, std::size_t j) {
}
```

---

## 稀疏矩阵

当非零元个数 `t` 远小于 $m \times n$(如稀疏因子 $\delta = t/(mn) \le 0.05$),直接存二维数组会浪费大量空间。稀疏矩阵只存每个非零元的三元组 `(row, col, value)` 及行列数。
Expand Down Expand Up @@ -167,6 +181,8 @@ struct OLNode {
压缩把空间换成时间:对称矩阵的下标换算保持 `O(1)` 访问,而稀疏矩阵三元组的访问依赖查找。不要只看空间收益,操作模式(随机访问 vs 批量处理)决定哪种方案合适。
:::

---

## 解题视角与易错点

遇到数组或特殊矩阵题,先不要急着套公式,可以按下面的顺序检查:
Expand All @@ -184,10 +200,14 @@ struct OLNode {

对应 Lab 的练习顺序是:先做地址换算,再做结构压缩,最后完成稀疏矩阵转置与运算。这样可以把“公式正确”和“数据结构操作正确”分开验证。

---

## 小结

数组的寻址公式是"连续内存 + 固定步长"的直接产物;特殊矩阵的压缩利用的是矩阵的结构规律;稀疏矩阵把空间换成查找时间。判断能否压缩、如何换算下标,比死记公式更重要——遇到新类型的结构矩阵时,方法是一样的。

---

## 练习

1. 推导列优先存储下 `A[i][j]` 的地址公式(下标从 0 开始)。
Expand Down
Loading
Loading