KMP:主串从不后退,调整子串位置
KMP算法(Knuth-Morris-Pratt算法)是一种高效的字符串匹配算法,由 Donald Knuth、Vaughan Pratt 和 James H. Morris 于 1977 年联合发表,它能在主文本字符串中快速查找模式字符串的所有出现位置,其核心优势在于通过预处理模式串来避免主串指针的回溯,将时间复杂度优化至线性级别 💡。
📊 1. 核心思想
KMP 算法的核心在于利用匹配失败时已经获得的信息,通过一个称为 next 数组(也称为部分匹配表或失败函数)的辅助数据结构,记录模式串自身的部分匹配信息。当发生不匹配时,算法能根据 next 数组知道模式串可以向右滑动多远,从而避免重复比较主串中已匹配的字符。
next 数组的含义:对于模式串 P的每个位置 i,next[i]表示子串 P[0:i]中 最长相等真前缀和真后缀的长度(即不包括字符串本身的最长相同前后缀)。
🔧 2. Next 数组的构建
构建 next 数组是 KMP 算法的预处理步骤,其过程本身也运用了类似 KMP 的思想。
算法步骤(以模式串 P 下标从 0 开始为例):
- 初始化:
next[0] = 0(或-1,实现有差异,但思想相通)。设两个指针i(后缀末尾)和j(前缀末尾,也代表当前最长相等前后缀的长度)。 - 遍历
i从 1 到len(P)-1:- 若
P[i] == P[j],则j++,next[i] = j,i++。 - 若
P[i] != P[j],则令j = next[j-1](或j = next[j],取决于实现)进行回溯,直到j回溯到 0 或匹配成功。 - 若
j已回溯到 0 且仍不匹配,则next[i] = 0,i++。
- 若
示例:模式串 P = “ABABCABAB” 的 next 数组
| 索引 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 字符 P[i] | A | B | A | B | C | A | B | A | B |
| next[i] | 0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 |
next 数组的构建代码(Python):
def build_next(pattern):
next_arr = [0] * len(pattern)
j = 0
for i in range(1, len(pattern)):
while j > 0 and pattern[i] != pattern[j]:
j = next_arr[j-1]
if pattern[i] == pattern[j]:
j += 1
next_arr[i] = j
return next_arr
print(build_next("ABABCABAB")) # 输出: [0, 0, 1, 2, 0, 1, 2, 3, 4]
🔍 3. KMP 匹配过程
有了 next 数组后,就可以进行主串 S和模式串 P的匹配。
算法步骤:
- 初始化:主串指针
i=0,模式串指针j=0。 - 遍历主串
S:- 若
S[i] == P[j],则i++,j++。 - 若
j == len(P),表示匹配成功,记录起始位置i - j。然后根据 next 数组调整j = next[j-1]以继续寻找下一个匹配。 - 若
S[i] != P[j]:- 若
j > 0,则利用 next 数组回溯模式串指针j = next[j-1]。 - 若
j == 0,则无法回溯,主串指针i++。
- 若
- 若
KMP 搜索代码(Python):
def kmp_search(text, pattern):
next_arr = build_next(pattern)
j = 0
for i in range(len(text)):
while j > 0 and text[i] != pattern[j]:
j = next_arr[j-1]
if text[i] == pattern[j]:
j += 1
if j == len(pattern):
return i - j + 1 # 返回首次匹配的起始位置
return -1
text = "ABABABCABABABD"
pattern = "ABABCABAB"
print(kmp_search(text, pattern)) # 输出: 2
⚙️ 4. 时间复杂度与优势
- 时间复杂度:
- 构建 next 数组:
O(m),其中m为模式串长度。 - 匹配过程:
O(n),其中n为主串长度。 - 总时间复杂度为
O(n + m),是线性的。
- 构建 next 数组:
- 优势:
- 避免了主串指针的回溯:这是与暴力匹配(时间复杂度
O(n*m))最根本的区别,KMP 算法的主串指针i只会前进或不变(当j>0且不匹配时i不变,j回溯),不会后退。 - 特别适合处理大文本流或实时数据,因为不需要回溯主串,可以边读入边匹配。
- 避免了主串指针的回溯:这是与暴力匹配(时间复杂度
🚀 5. 应用场景
KMP 算法的高效性使其在许多领域有广泛应用:
- 文本编辑器中的查找功能(如 Ctrl+F)
- 搜索引擎中的关键词定位和网页索引
- 生物信息学中的 DNA/RNA 序列匹配(基因序列通常很长)
- 数据压缩、网络入侵检测、拼写检查等
📝 6. 与其他算法的简要对比
| 算法 | 时间复杂度 (最坏) | 特点 | 适用场景 |
|---|---|---|---|
| 暴力匹配 | O(n*m) | 实现简单,但效率低 | 小规模数据 |
| KMP | O(n+m) | 避免主串回溯,稳定高效 | 通用,尤其是大规模文本 |
| Boyer-Moore | O(n/m) (平均) | 坏字符规则,跳跃式匹配 | 模式串较长,字符集较大 |
| Rabin-Karp | O(n+m) | 基于哈希,可同时检测多个模式 | 多模式匹配, plagiarism检测 |
💎 总结
KMP 算法通过巧妙地构建 next 数组,记录了模式串自身的“记忆”,在匹配失败时能智能地滑动模式串,避免了主串指针的回溯,从而实现了高效的字符串匹配。虽然理解和实现起来比暴力匹配稍复杂,但其线性时间复杂度的优势在处理大规模字符串时非常显著。
希望以上介绍能帮助你透彻理解 KMP 算法。
BM: 坏字符和好后缀,模式串从右向左匹配
BM算法(Boyer-Moore算法)是一种非常高效的单模式字符串匹配算法,由Robert S. Boyer和J Strother Moore于1977年提出。它采用从右向左的比较方式和独特的启发式规则,能在许多情况下实现亚线性的匹配速度,平均性能优异,通常比KMP算法快3-5倍。
BM算法的核心思想
BM算法的核心在于利用预处理信息在匹配失败时跳过尽可能多的无效位置。它通过 “坏字符规则” (Bad Character Rule) 和 “好后缀规则” (Good Suffix Rule) 来计算模式串的安全移动距离,并且匹配时从模式串的末尾开始向前比较。
1. 坏字符规则 (Bad Character Rule)
当发现文本串 T中的某个字符与模式串 P不匹配时,该文本串中的字符被称为 “坏字符”。
- 情况1:坏字符在模式串中存在
- 将模式串向右移动,使其最右边出现的这个坏字符与文本串中的坏字符对齐。
- 移动位数 = 坏字符在模式串中的失配位置索引 - 该坏字符在模式串中最后一次出现的位置索引。
- 若计算值为负,则可能产生回退,因此实际中会取该规则与好后缀规则计算值的最大值。
- 情况2:坏字符在模式串中不存在
- 直接将整个模式串向右移动到坏字符的下一位。
- 移动位数 = 坏字符在模式串中的失配位置索引 + 1 (通常可理解为模式串长度,但需根据具体位置计算)。
坏字符表预处理:创建一个数组 bc_table(大小依字符集而定,如256 for ASCII),记录每个字符在模式串中最后一次出现的位置(索引)。如果字符不在模式串中,则记为 -1。
2. 好后缀规则 (Good Suffix Rule)
当发生失配时,模式串末尾已经匹配成功的部分子串称为 “好后缀” 。
- 情况1:模式串的前面部分存在与好后缀完全匹配的子串
- 将模式串向右移动,使前面最靠右的那个匹配子串与文本串中的好后缀对齐。
- 情况2:模式串中不存在与好后缀完全匹配的子串,但存在一个最长前缀与好后缀的某个后缀相匹配
- 将模式串向右移动,使这个最长前缀与文本串中好后缀的相应后缀对齐。
- 情况3:模式串中既不存在与好后缀匹配的子串,也不存在与其后缀匹配的前缀
- 将整个模式串向右移动模式串的长度。
好后缀表预处理:构建一个数组 gs_table,其计算通常借助一个后缀数组 suffix,suffix[i]表示模式串中以 i位置结尾的子串与模式串后缀的最大匹配长度。根据 suffix数组的信息来填充 gs_table,以确定在各种失配位置下根据好后缀规则应移动的距离。
BM算法的工作流程
- 预处理阶段:
- 根据模式串
P构建坏字符表bc_table。 - 根据模式串
P构建好后缀表gs_table。
- 根据模式串
- 匹配阶段:
- 将模式串
P与文本串T对齐,初始时i = 0(i表示文本串中与模式串首字符对齐的位置)。 - 从模式串的末尾开始(即从右向左)比较字符。
- 若所有字符都匹配,则找到一个有效匹配,输出位置
i。随后,通常根据好后缀规则移动模式串以继续寻找下一个匹配。 - 若遇到坏字符,设其在模式串中的位置为
j:- 根据坏字符规则计算移动距离
bad_shift = j - bc_table[T[i+j]](需注意边界和不存在的情况)。 - 根据好后缀规则计算移动距离
good_shift = gs_table[j]。 - 模式串的实际移动距离为
max(bad_shift, good_shift)。取最大值是为了保证不漏过可能的匹配,同时实现最大的跳跃。 - 令
i = i + max(bad_shift, good_shift),重新开始下一轮从右向左的比较。
- 根据坏字符规则计算移动距离
- 重复直到模式串移出文本串的末尾。
- 将模式串
BM算法示例
文本串 T: “ABABABCABABABD”
模式串 P: “ABABCABAB”
(此示例可结合前述规则逐步推演,由于篇幅限制,此处不展开逐步过程,但概念上遵循上述流程。)一个常见的匹配结果是模式串在文本串的索引位置 2 处找到(0-based索引)。
BM算法的性能分析
- 时间复杂度:
- 预处理阶段:构建坏字符表
O(m + |Σ|)(|Σ| 为字符集大小),构建好后缀表最高可达O(m)。 - 匹配阶段:
- 最坏情况为
O(n * m),例如文本串和模式串都是相同字符时。 - 最佳情况可达到
O(n / m),当模式串始终不在文本中出现时,每次都能跳跃整个模式串长度。 - 平均性能非常优异,尤其在实际应用和字符集较大时,远超朴素算法,也通常优于KMP算法。
- 最坏情况为
- 预处理阶段:构建坏字符表
- 空间复杂度:主要来自存储
bc_table(O(|Σ|)) 和gs_table(O(m))。
BM算法的优缺点
- 优点:
- 实际应用中效率高,尤其适合模式串较长、字符集较大的场景。
- 采用了从右向左比较和启发式跳跃,跳过大量不可能匹配的位置,减少了比较次数。
- 缺点:
- 预处理好后缀表相对复杂,实现起来比KMP等算法稍麻烦。
- 最坏时间复杂度不如KMP算法稳定(KMP最坏为
O(n+m))。
BM算法的应用
BM算法因其高效性被广泛应用于:
- 文本编辑器中的查找功能(Ctrl+F)。
- 搜索引擎和数据过滤中的关键词匹配。
- 网络安全领域,如入侵检测系统(IDS)中的特征码匹配。
- 生物信息学中DNA序列匹配等。
总结
BM算法通过巧妙的坏字符规则和好后缀规则,以及从右向左的比较顺序,实现了在大多数情况下的高效字符串匹配。它虽然预处理阶段稍显复杂,且最坏时间复杂度理论不佳,但其优异的平均性能使其成为实践中非常受欢迎的字符串匹配算法之一。理解并掌握BM算法,对于深入理解字符串匹配问题的优化思路具有重要意义。
RP 算法
RK 算法通常指 Rabin-Karp 字符串匹配算法。它是一种使用哈希技术来高效查找主串中模式串出现位置的算法。以下是其核心原理、步骤和特点的总结:
| 特性维度 | Rabin-Karp 算法 (RK算法) | 暴力匹配算法 (BF算法) |
|---|---|---|
| 核心思想 | 利用哈希值快速比较主串子串和模式串,避免逐个字符对比 | 逐个字符比较主串和模式串 |
| 关键操作 | 滚动哈希 (Rolling Hash) | 无特殊操作,简单遍历 |
| 平均时间复杂度 | O(n + m) (n为主串长度, m为模式串长度) | O(n * m) |
| 最坏时间复杂度 | O(n * m) (当哈希冲突频繁时退化) | O(n * m) |
| 空间复杂度 | O(1) (通常只需存储哈希值等少量变量) | O(1) |
| 优势 | 平均情况下比BF算法快得多,尤其适用于多模式匹配 | 实现简单,无需额外预处理,小规模字符串或模式串短时可能更快 |
| 劣势 | 需要处理哈希冲突(可能需二次验证),最坏情况下效率不如优化算法 | 效率低下,尤其当主串和模式串很长时 |
🧠 RK 算法核心思想
RK 算法通过比较哈希值来快速判断主串中的子串是否与模式串匹配,从而避免每次都进行昂贵的逐个字符比较。其核心在于“滚动哈希”(Rolling Hash)技术,它允许在常数时间内计算下一个子串的哈希值,而不是每次都重新计算整个子串的哈希值。
🔄 RK 算法的工作步骤
- 计算模式串哈希值:首先计算模式串的哈希值,例如
hash_pat。 - 计算主串前 m 个字符的哈希值:计算主串前
m(模式串长度)个字符的子串哈希值,例如hash_sub。 - 比较哈希值并滑动窗口:
- 如果
hash_sub == hash_pat,则逐个字符比较该子串与模式串(以防止哈希冲突)。 - 如果匹配,返回当前起始位置。
- 无论是否匹配,算法都会使用滚动哈希技巧,根据当前子串的哈希值
hash_sub快速计算下一个子串(窗口向右滑动一位)的哈希值。
- 如果
- 重复直到遍历完成:重复步骤 3,直到主串中所有可能的子串都被检查过。
🔢 哈希函数设计
RK 算法通常将字符串视为一个 d进制数(d是字符集的大小,例如 ASCII 256 或小写字母 26)。一个常见的滚动哈希函数是:
hash(s[i+1:i+m+1]) = d * (hash(s[i:i+m]) - d^(m-1) * s[i]) + s[i+m]
然后,为了防止数值过大,常对一个大素数 q取模:hash_value = hash_value % q。
📊 复杂度分析
- 时间复杂度:
- 平均情况:O(n + m)。预处理模式串哈希和主串前 m 个字符的哈希需要 O(m),主串滑动窗口处理需要 O(n)。
- 最坏情况:O(n * m)。当哈希冲突频繁发生时,每次哈希值相等都需要进行逐个字符比较。
- 空间复杂度:O(1)。仅需存储几个变量(如哈希值、进制数、模数等)。
⚠️ 注意事项
- 哈希冲突:不同的字符串可能产生相同的哈希值。因此,当哈希值匹配时,必须进行逐个字符的验证以确保不是误报。选择一个好的哈希函数(大进制数
d和大素数q)对于减少冲突至关重要。 - 模数选择:模数
q应足够大,以尽量减少冲突,但也要考虑计算效率,避免溢出。
🌐 主要应用
RK 算法适用于需要快速进行多模式匹配(稍加修改)或在多个输入中查找重复子串的场景,例如:
- 文本编辑器中的查找功能(虽不常见,因有更优算法如KMP)
- 检测文档中的抄袭(查找长字符串中的重复片段)
- DNA序列匹配(生物信息学)
🔍 简单例子
假设主串 txt = "123456",模式串 pat = "34",字符集为数字 0-9(d=10)。
- 计算
pat的哈希值:hash_pat = '3'*10 + '4' = 30+4=34。 - 计算
txt前 2 个字符 “12” 的哈希值:hash_sub = '1'*10 + '2' = 10+2=12。 12 != 34,不匹配。- 使用滚动哈希计算下一个子串 “23”:
hash_sub_new = 10 * (12 - 10^(2-1)*'1') + '3' = 10*(12-10 * 1) + 3 = 10*(2) + 3 = 23。 23 != 34,不匹配。- 计算下一个子串 “34”:
hash_sub_new = 10 * (23 - 10^(2-1)*'2') + '4' = 10*(23-10 * 2) + 4 = 10*(3) + 4 = 34。 34 == 34,哈希匹配,再逐个字符验证,“34” 确实等于 “34”,匹配成功。
💡 总结
Rabin-Karp 算法通过巧妙的滚动哈希技术,将字符串匹配的平均时间复杂度优化到了线性级别。虽然最坏情况下的性能并不理想,但其思路清晰,实现相对简单,并且在处理多模式匹配等特定问题时非常有用。理解其原理和哈希函数的设计是掌握该算法的关键。
广义表
广义表(Generalized List),也称为列表(Lists),是一种非常灵活的数据结构,它可以很好地表示层次性和递归性的数据。下面我将为你详细介绍广义表的核心概念、特性、存储表示、基本操作以及应用场景。
📚 广义表详解
1 核心概念与定义
广义表是线性表的一种推广,是一种非线性的数据结构。它可以存储多种类型的数据,并且支持嵌套结构。广义表是 n(n≥0)个元素 a₁, a₂, ..., aᵢ, ..., aₙ的有限序列。广义表通常记作 Ls = (a₁, a₂, ..., aᵢ, ..., aₙ)。其中:
- Ls 是广义表的名字。
- n 是它的长度。
- 每个元素
aᵢ可以是原子(一个不可再分的单个数据项,用小写字母表示),也可以是另一个广义表(称为子表,用大写字母表示)。
广义表通过递归进行定义,这使得它可以表示非常复杂的数据结构。
1.1 表头与表尾
任何非空广义表(n ≥ 1)都可以分解为表头(Head) 和表尾(Tail) 两部分:
- 表头 Head(Ls):非空广义表的第一个元素
a₁。它可以是原子,也可以是子表。 - 表尾 Tail(Ls):非空广义表除去表头后,由其余所有元素构成的子表
(a₂, a₃, ..., aₙ)。关键的是,表尾本身必然是一个广义表(即使只剩一个元素)。
例如,对于广义表 L = (a, b, c):
Head(L) = a(原子)Tail(L) = (b, c)(子表)
1.2 重要示例与概念辨析
以下是一些帮助理解广义表的例子:
| 广义表表示 | 表名 | 长度 | 深度 | 表头 | 表尾 | 说明 |
|---|---|---|---|---|---|---|
E = () | E | 0 | 1 | - | - | 空表 |
L = (a, b) | L | 2 | 1 | a | (b) | 元素均为原子,等价于线性表 |
A = (x, L) = (x, (a, b)) | A | 2 | 2 | x | ((a, b)) | 第二个元素是子表L |
B = (A, y) = ((x, (a, b)), y) | B | 2 | 3 | A | (y) | 第一个元素是子表A |
C = (A, B) | C | 2 | 4 | A | (B) | 两个元素都是子表 |
D = (a, D) = (a, (a, (a, ...))) | D | 2 | ∞ | a | (D) | 递归表,深度无穷 |
特别注意:
(())不是空表:它是一个长度为1的非空广义表,其唯一的元素是空表()。对其取表头和表尾都会得到空表()。- 表尾永远是子表:
Tail(L) = (b, c)是一个子表,而不是单个元素b或c。
1.3 长度与深度
长度:指广义表最外层的元素个数。例如,
A = (x, (a, b))的长度是2。深度:指广义表展开后所含括号的最大层数(递归定义的最大嵌套次数)。
原子的深度为 0。
空表
()的深度为 1。非空广义表的深度是其所有元素深度的最大值加 1。
例如:
B = ((x, (a, b)), y)的深度计算过程如下:元素1是子表
(x, (a, b)):- 元素1.1是原子
x(深度0) - 元素1.2是子表
(a, b)(深度1) - 所以子表
(x, (a, b))的深度为max(0, 1) + 1 = 2
- 元素1.1是原子
元素2是原子
y(深度0)因此,B的深度为
max(2, 0) + 1 = 3
2 广义表的性质
广义表具有以下几个重要性质:
- 有序性:广义表中的数据元素有相对次序。
- 层次性与多层次结构:广义表中的元素可以是子表,子表的元素还可以是子表,形成一种多层次的结构。
- 共享性:一个广义表可以被其他多个广义表共享。例如,表
A可以同时是表B和表C的元素。 - 递归性:广义表可以是递归表,即广义表本身可以作为自己的一个子表(如上面的例子
D = (a, D))。递归表的深度是无穷的,但其长度是有限的(如D的长度为2)。 - 通用性:广义表可以兼容和表示其他多种数据结构。
- 当所有元素都是原子时,它就是线性表。
- 可以表示树形结构(如
(A, B, C)可表示一棵树,其中A,B,C是子树)。 - 可以表示图形结构(尤其是递归表可以表示有环图)。
3 存储表示
由于广义表的元素可以是原子或子表,类型不统一,并且长度和深度经常变化,因此顺序存储结构难以实现,通常采用链式存储结构。主要有两种存储方式:
3.1 头尾链表存储结构
这种结构中,每个结点使用一个标志位(tag) 来区分原子结点和表结点。
typedef enum { ATOM, LIST } ElemTag; // ATOM=0:原子, LIST=1:子表
typedef struct GLNode {
ElemTag tag; // 标志域,用于区分原子结点和表结点
union {
AtomType atom; // 原子结点的值域
struct {
struct GLNode* hp; // 指向表头的指针
struct GLNode* tp; // 指向表尾的指针
} ptr;
};
} *GList;
这种存储方式能清晰地反映广义表的层次结构,表头指针 hp和表尾指针 tp构成了一个递归的结构。
3.2 扩展线性链表存储结构
这种结构也更常用,每个结点也包含一个标志位 tag,但用不同的指针域来组织:
typedef enum { ATOM, LIST } ElemTag;
typedef struct GLNode {
ElemTag tag; // 标志域
union {
AtomType atom; // 原子结点的值域
struct GLNode* hp; // 指向子表的指针
};
struct GLNode* next; // 指向下一个元素的指针
} *GList;
这种结构更像一个普通的链表,hp指向该元素代表的子表(如果该元素是子表),next指向同级的下一个元素。这种表示法在遍历时可能更直观。
4 基本操作
广义表的基本操作大多需要递归实现。
| 操作名 | 功能描述 |
|---|---|
InitGList(&L) | 初始化一个空的广义表L。 |
CreateGList(&L, S) | 根据字符串S所描述的广义表结构(如 “(a, (b, c))”)创建广义表L。 |
DestroyGList(&L) | 销毁广义表L,释放其占用的存储空间。 |
CopyGList(&T, L) | 复制广义表L,得到广义表T。 |
GListLength(L) | 返回广义表L的长度(最外层元素个数)。 |
GListDepth(L) | 返回广义表L的深度。 |
GListEmpty(L) | 判断广义表L是否为空表(长度是否为0)。 |
GetHead(L) | 返回广义表L的表头。 |
GetTail(L) | 返回广义表L的表尾。 |
TraverseGList(L, Visit()) | 遍历广义表L,对每个元素调用函数Visit进行操作。 |
操作示例:
对于广义表 L = ((a, b), c, d):
GetHead(L) = (a, b)(子表)GetTail(L) = (c, d)(子表)GetHead(GetTail(L)) = c(原子)GetTail(GetTail(L)) = (d)(子表)
5 广义表与线性表的对比
广义表是线性表的推广,两者有显著区别:
| 特性 | 线性表 | 广义表 |
|---|---|---|
| 元素类型 | 必须是同一类型的原子 | 可以是原子,也可以是子表 |
| 结构 | 严格的线性顺序结构 | 递归、嵌套的非线性结构 |
| 操作复杂度 | 插入、删除等操作复杂度通常为O(n) | 操作多需递归实现,复杂度通常更高 |
| 灵活性 | 相对固定,难以直接表示复杂层次关系 | 高度灵活,能表示树、图等复杂结构 |
| 表头/表尾 | 无此特定概念或概念不同 | 核心概念,且表尾必定是一个子表 |
6 应用场景
广义表的递归和层次特性使其在多个领域有广泛应用:
- 表示多层次、递归的数据结构:如文件目录结构(目录中包含文件和子目录)、HTML/XML文档的嵌套标签、组织结构图等。
- 表示数学表达式:例如,多项式
P(x, y) = x² + 2xy + y²可以用广义表表示为( +, (^, x, 2), ( *, ( *, 2, x), y), (^, y, 2) ),这种表示便于递归求值。 - 人工智能与符号处理:LISP等函数式编程语言将程序和数据都表示为广义表(称为S-表达式),极大地简化了符号处理和元编程。
- 数据库系统:用于表示复杂的关系模式或对象关系映射。
- 作为其他数据结构的基础:由于其通用性,可用于实现树、图等更复杂的数据结构。
希望以上详细的介绍能帮助你全面理解广义表这一重要的数据结构。
线性表存储
线性表是最常用且最基本的数据结构之一,其存取方式直接影响了操作的效率。下面我将为你详细介绍线性表的几种主要存取方式,包括顺序存储和链式存储,以及它们的各种变体和特点。
📊 线性表存取方式详解
1️⃣ 线性表的基本概念
线性表(Linear List)是由 n(n≥0)个具有相同类型的数据元素组成的有限序列。若线性表非空,则:
- 存在唯一的一个被称为"第一个"的元素(表头元素)
- 存在唯一的一个被称为"最后一个"的元素(表尾元素)
- 除第一个元素外,每个元素都有且仅有一个直接前驱
- 除最后一个元素外,每个元素都有且仅有一个直接后继
数据元素之间的关系是线性的,逻辑结构可以表示为:(a₁, a₂, a₃, …, aₙ)
2️⃣ 顺序存储结构(顺序表)
顺序存储结构使用一段地址连续的存储单元依次存储线性表中的数据元素,通常使用数组来实现。
存储特点
- 逻辑相邻,物理也相邻:逻辑上相邻的元素在物理存储位置上也相邻
- 随机存取:可以通过首地址和元素序号直接计算出任一元素的存储位置,存取时间为O(1)
- 存储位置计算公式:
LOC(aᵢ) = LOC(a₁) + (i-1)×L,其中L是每个元素占用的存储单元数
基本操作效率
- 按索引查找/取值:O(1) - 直接通过数组下标访问
- 插入操作:平均需要移动n/2个元素,时间复杂度O(n)
- 删除操作:平均需要移动(n-1)/2个元素,时间复杂度O(n)
顺序存储的优缺点
优点:
- 存储密度高(100%),无需为表示逻辑关系增加额外空间
- 随机存取速度快,通过索引可直接访问任一元素
缺点:
- 需要预先分配固定大小的存储空间,可能造成空间浪费或溢出
- 插入和删除操作需要移动大量元素,效率较低
3️⃣ 链式存储结构(链表)
链式存储结构使用一组任意的存储单元存储线性表的数据元素,这些存储单元可以是连续的,也可以是不连续的。每个节点不仅包含数据本身,还包含表示逻辑关系的指针域。
单链表(Singly Linked List)
最基本的链表形式,每个节点包含:
- 数据域:存储数据元素
- 指针域:存储指向下一个节点的指针
单链表又分为:
- 带头节点的单链表:头节点不存储数据,其指针域指向第一个实际数据节点
- 不带头节点的单链表:头指针直接指向第一个数据节点
双向链表(Doubly Linked List)
每个节点包含两个指针域:
- 指向直接前驱节点的指针(prior)
- 指向直接后继节点的指针(next)
这使得链表可以在两个方向上遍历,但每个节点需要更多的存储空间。
循环链表(Circular Linked List)
- 循环单链表:表尾节点的指针指向头节点(或第一个数据节点),形成环状
- 循环双链表:表尾节点的next指针指向头节点,头节点的prior指针指向表尾节点
循环链表可以从任意节点开始遍历整个链表。
静态链表(Static Linked List)
使用数组来描述链式存储结构,数组元素为结构体,包含:
- 数据域
- 游标(cur) - 指示后继元素在数组中的下标
静态链表在不支持指针的程序设计语言中特别有用。
链式存储的操作特点
- 查找操作:需要从头节点开始顺序查找,平均时间复杂度O(n)
- 插入操作:只需修改相关指针,时间复杂度O(1)(不考虑查找插入位置的时间)
- 删除操作:只需修改相关指针,时间复杂度O(1)(不考虑查找删除位置的时间)
链式存储的优缺点
优点:
- 不需要预先分配固定存储空间,可以动态扩展
- 插入和删除操作效率高,只需修改指针
缺点:
- 存储密度较低,需要额外空间存储指针
- 不支持随机存取,必须顺序访问
4️⃣ 两种主要存储方式的对比
下表总结了顺序存储和链式存储的主要特点对比:
| 特性 | 顺序存储结构 | 链式存储结构 |
|---|---|---|
| 存储空间 | 预先分配,地址连续 | 动态分配,地址可不连续 |
| 存储密度 | 高(100%) | 较低(需存储指针) |
| 存取方式 | 随机存取,O(1)时间 | 顺序存取,O(n)时间 |
| 查找操作 | 按索引快速,O(1)时间 | 需要遍历,平均O(n)时间 |
| 插入操作 | 平均需要移动n/2个元素,O(n)时间 | 只需修改指针,O(1)时间 |
| 删除操作 | 平均需要移动(n-1)/2个元素,O(n)时间 | 只需修改指针,O(1)时间 |
| 空间分配 | 固定大小,难以扩展 | 动态分配,灵活扩展 |
5️⃣ 存取方式的选择策略
选择线性表的存取方式时,需要考虑以下因素:
适合顺序存储的场景
- 数据量相对稳定,变化不大
- 需要频繁按索引访问元素
- 插入和删除操作较少
- 对存储密度要求较高
适合链式存储的场景
- 数据量变化较大,难以预估规模
- 需要频繁进行插入和删除操作
- 不需要频繁按索引随机访问元素
6️⃣ 其他存储方式
除了上述两种主要存储方式外,还有:
索引存储
在存储元素信息的同时,建立附加的索引表。索引项的一般形式是:(关键字,地址),通过索引可以快速定位元素。
散列存储
根据数据元素的关键字直接计算出该元素的存储地址,也称为哈希存储。
💎 总结
线性表的存取方式主要分为顺序存储和链式存储两大类,它们各有优缺点和适用场景:
- 顺序存储(数组实现)适合数据量固定、需要频繁随机访问的场景
- 链式存储(指针实现)适合数据量变化大、需要频繁插入删除的场景
在实际应用中,需要根据具体需求、操作特点和性能要求选择合适的存储方式。有时还可以结合多种存储方式的优点,设计更复杂的数据结构以满足特定需求。
多叉树转 2 叉树
。将多叉树转换为二叉树主要采用 “孩子兄弟表示法”(Left-Child Right-Sibling Representation)。这种方法能保持树原有的层次结构,便于利用二叉树的算法进行处理。
下面的表格总结了多叉树节点与转换后二叉树节点间的核心关系,帮助你直观理解转换规则:
| 多叉树中的关系 | 转换后二叉树中的关系 |
|---|---|
| 节点的第一个子节点 | 变为该节点对应的二叉树节点的左子节点 (left child) |
| 节点的下一个兄弟节点 | 变为该节点对应的二叉树节点的右子节点 (right child) |
🔄 转换步骤
转换过程遵循以下步骤,通常通过递归算法实现:
- 处理根节点:创建二叉树的根节点,其值与多叉树根节点相同。
- 处理子节点:
- 将多叉树节点的第一个子节点递归转换为二叉树节点,并设为当前二叉树节点的左子节点。
- 将多叉树节点的其他子节点(兄弟节点)递归转换为二叉树节点,并依次设置为前一个兄弟节点对应的二叉树节点的右子节点,从而形成一条右链。
- 递归进行:对每个子节点重复上述过程。
📝 代码示例 (C++)
以下是 C++ 的实现示例,参考了 C 语言的实现思路:
#include <iostream>
#include <vector>
using namespace std;
// 多叉树节点定义
struct MultiTreeNode {
int data;
MultiTreeNode* firstChild; // 指向第一个子节点
MultiTreeNode* nextSibling; // 指向下一个兄弟节点
MultiTreeNode(int val) : data(val), firstChild(nullptr), nextSibling(nullptr) {}
};
// 二叉树节点定义
struct BinaryTreeNode {
int data;
BinaryTreeNode* left; // 左孩子
BinaryTreeNode* right; // 右孩子
BinaryTreeNode(int val) : data(val), left(nullptr), right(nullptr) {}
};
// 转换函数
BinaryTreeNode* convertToBinaryTree(MultiTreeNode* multiRoot) {
if (multiRoot == nullptr) {
return nullptr;
}
// 创建对应的二叉树根节点
BinaryTreeNode* binaryRoot = new BinaryTreeNode(multiRoot->data);
// 递归转换第一个子节点作为左孩子
if (multiRoot->firstChild != nullptr) {
binaryRoot->left = convertToBinaryTree(multiRoot->firstChild);
}
// 递归转换下一个兄弟节点作为右孩子
if (multiRoot->nextSibling != nullptr) {
binaryRoot->right = convertToBinaryTree(multiRoot->nextSibling);
}
return binaryRoot;
}
// 示例:打印二叉树(先序遍历)
void printBinaryTreePreOrder(BinaryTreeNode* root) {
if (root == nullptr) return;
cout << root->data << " ";
printBinaryTreePreOrder(root->left);
printBinaryTreePreOrder(root->right);
}
// 示例:创建多叉树并进行转换
int main() {
/* 假设有多叉树结构:
1
/|\
2 3 4
|
5
*/
MultiTreeNode* root = new MultiTreeNode(1);
root->firstChild = new MultiTreeNode(2);
root->firstChild->nextSibling = new MultiTreeNode(3);
root->firstChild->nextSibling->nextSibling = new MultiTreeNode(4);
root->firstChild->firstChild = new MultiTreeNode(5);
BinaryTreeNode* binaryRoot = convertToBinaryTree(root);
cout << "转换后的二叉树先序遍历结果: ";
printBinaryTreePreOrder(binaryRoot); // 输出:1 2 5 3 4
cout << endl;
// ... (实际应用中需记得释放内存)
return 0;
}
⚠️ 注意事项
- 节点结构定义:多叉树节点需包含指向其第一个子节点 (
firstChild) 和下一个兄弟节点 (nextSibling) 的指针。 - 递归思想:理解递归在转换过程中的作用,它负责深入到每个子树进行转换。
- 内存管理:在实际项目中,如果树节点是动态创建的,需要注意内存的释放,避免泄漏。示例代码省略了这部分。
- 遍历顺序:转换后对二叉树进行先序遍历,可以访问到与原多叉树结构相对应的节点顺序。
- 森林转换:此方法也适用于将多棵树组成的森林转换为二叉树,通常可将森林中各树的根节点视为兄弟节点进行转换。
🧠 深入理解
- 优点:孩子兄弟表示法保持了树的原始结构,并能利用二叉树的性质进行遍历和操作,在许多应用场景中非常有用,例如文件系统的目录结构表示。
- 逆向转换:从转换后的二叉树恢复多叉树也是可能的,过程基本上是上述转换的逆过程。
希望这些信息能帮助你理解多叉树到二叉树的转换。
堆排序
堆排序是一种非常高效且值得掌握的排序算法。它利用“堆”这种数据结构来实现排序,兼具了时间效率和高空间效率。下面我会为你详细解析堆排序的原理、步骤、性能,并提供代码实现。
✨ 堆的基本概念
堆(Heap)是一种特殊的完全二叉树,它满足以下性质之一:
- 大顶堆:每个节点的值都大于或等于其子节点的值。因此,堆顶(根节点)是整个堆中的最大值。
- 小顶堆:每个节点的值都小于或等于其子节点的值。因此,堆顶是整个堆中的最小值。
堆通常使用数组来存储。对于数组中索引为 i(从0开始计数)的元素:
- 其左子节点的索引为
2*i + 1 - 其右子节点的索引为
2*i + 2 - 其父节点的索引为
floor((i-1)/2)
🔧 堆排序的工作原理
堆排序(以升序排序为例)主要分为两个大的阶段:构建大顶堆和反复提取最大值并调整。
1. 构建大顶堆 (Build Max Heap)
从一个无序的数组开始,我们需要将其调整为一个大顶堆。策略是从最后一个非叶子节点开始,依次向前,对每个节点执行“下沉”操作(Heapify),确保以该节点为根的子树满足大顶堆的性质。最后一个非叶子节点的索引通常是 n/2 - 1(n为数组长度)。
“下沉”操作 (Heapify / Sift Down) 的核心步骤:
- 针对当前节点、其左子节点和右子节点,找出值最大的那个节点。
- 如果最大值不是当前节点,就将当前节点与这个最大值的子节点交换位置。
- 由于交换可能会破坏下一级子树的堆性质,因此需要递归地或迭代地对交换后的子树继续进行下沉操作,直到当前节点大于等于其所有子节点,或者到达了叶子节点。
2. 排序 (Extract and Heapify)
在将整个数组构造成大顶堆后,堆顶元素(数组的第一个元素)就是当前最大值。
- 交换:将堆顶元素与当前堆的最后一个元素交换。此时,最大值就被放置到了数组的最终正确位置上。
- 缩小堆:将堆的大小减一(排除刚刚交换到末尾的最大值),最后一个元素不再视为堆的一部分。
- 调整:由于新的堆顶元素可能破坏堆的性质,因此需要对新的堆顶执行下沉操作,使剩余元素重新构成一个大顶堆。
- 重复:重复上述步骤,直到堆中只剩一个元素。此时,数组就已经排好序了。
📊 堆排序的步骤摘要
下表总结了堆排序算法的关键步骤,以升序排序为例:
| 步骤 | 操作描述 | 说明 |
|---|---|---|
| 1 | 构建大顶堆:从最后一个非叶子节点开始,自底向上、自右向左地对每个节点执行下沉操作。 | 确保每个节点的值都大于或等于其子节点的值,堆顶元素为最大值。 |
| 2 | 交换堆顶与堆尾:将堆顶元素(当前最大值)与当前堆的最后一个元素交换。 | 将最大值放置到数组的末尾,这是其最终有序位置。 |
| 3 | 缩小堆范围:将堆的大小减一,排除已排序的最大值。 | 接下来只需对剩余未排序部分进行操作。 |
| 4 | 调整堆:对新的堆顶元素执行下沉操作,使剩余部分重新满足大顶堆性质。 | 为下一次提取最大值做准备。 |
| 5 | 重复步骤2-4:直到堆的大小变为1。 | 此时所有元素都已排好序。 |
⚙️ 复杂度分析
| 指标 | 复杂度 | 解释 |
|---|---|---|
| 时间复杂度 | O(n log n) | • 建堆阶段:直观上感觉需要O(n log n),但通过更精细的分析可以得出其时间复杂度为O(n)。 • 排序阶段:需要进行n-1次下沉操作,每次下沉的时间复杂度为O(log n),因此为O(n log n)。 |
| 空间复杂度 | O(1) | 堆排序是原地排序算法,只需要常数级别的额外空间用于交换元素等临时操作。 |
| 稳定性 | 不稳定 | 在交换堆顶和堆尾元素的过程中,可能会改变相同值元素的相对顺序。 |
👍 优点和缺点
优点:
- 高效的时间复杂度:最坏、平均、最好情况下的时间复杂度均为 O(n log n),性能稳定。
- 空间效率高:是原地排序算法,不需要额外的存储空间,空间复杂度为 O(1)。
缺点:
- 不稳定:对于相等键值的元素,排序后它们的相对位置可能会改变。
- 缓存不友好:堆排序的访问模式是跳跃式的(例如访问
2*i+1,2*i+2),而不是顺序访问,这对CPU缓存不友好,在某些实际应用中可能比同样时间复杂度但缓存友好的算法(如快速排序)稍慢。
🚀 应用场景
- 需要保证最坏情况性能:快速排序在最坏情况下会退化为O(n²),而堆排序的O(n log n)最坏情况复杂度更有优势。
- 内存空间受限:当可用内存空间非常宝贵时,堆排序的原地排序特性使其成为一个很好的选择。
- 获取前k个最大/最小元素:例如,要从1亿个数字中找出前10个最大的数,可以维护一个大小为10的小顶堆,扫描完所有数据后,堆中的元素就是前10个最大的数。这种方法的时间复杂度是O(n log k),非常高效。
🖥️ 代码实现
以下是使用不同编程语言实现堆排序(升序)的示例代码:
Python 实现
def heap_sort(arr):
def heapify(n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] > arr[largest]:
largest = left
if right < n and arr[right] > arr[largest]:
largest = right
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(n, largest)
n = len(arr)
# 构建最大堆
for i in range(n // 2 - 1, -1, -1):
heapify(n, i)
# 逐个提取元素
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0] # 交换
heapify(i, 0) # 调整剩余堆
# 调用示例
arr = [12, 11, 13, 5, 6, 7]
heap_sort(arr)
print("Sorted array:", arr) # 输出: [5, 6, 7, 11, 12, 13]
代码来源于,并进行了适当修改和注释。
Java 实现
public class HeapSort {
public static void heapSort(int[] arr) {
int n = arr.length;
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 逐个从堆中提取元素
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶(最大值)移动到数组末尾
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 对减小后的堆调用heapify
heapify(arr, i, 0);
}
}
private static void heapify(int[] arr, int n, int i) {
int largest = i; // 初始化最大值为根
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
// 递归地堆化受影响的子树
heapify(arr, n, largest);
}
}
// 测试
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
System.out.print("Sorted array: ");
for (int num : arr) {
System.out.print(num + " "); // 输出: 5 6 7 11 12 13
}
}
}
代码来源于,并进行了适当修改和注释。
C++ 实现
#include <iostream>
#include <vector>
using namespace std;
void heapify(vector<int>& arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
void heapSort(vector<int>& arr) {
int n = arr.size();
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 从堆中逐个提取元素
for (int i = n - 1; i > 0; i--) {
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
int main() {
vector<int> arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
cout << "Sorted array: ";
for (int num : arr) {
cout << num << " "; // 输出: 5 6 7 11 12 13
}
return 0;
}
代码来源于,并进行了适当修改和注释。
希望这份详细的介绍能帮助你彻底理解堆排序!如果你有任何疑问,欢迎随时提出。
建堆
堆排序是一种非常高效且值得掌握的排序算法。它利用“堆”这种数据结构来实现排序,兼具了时间效率和高空间效率。下面我会为你详细解析堆排序的原理、步骤、性能,并提供代码实现。
🧠 堆排序算法详解
✨ 堆的基本概念
堆(Heap)是一种特殊的完全二叉树,它满足以下性质之一:
- 大顶堆:每个节点的值都大于或等于其子节点的值。因此,堆顶(根节点)是整个堆中的最大值。
- 小顶堆:每个节点的值都小于或等于其子节点的值。因此,堆顶是整个堆中的最小值。
堆通常使用数组来存储。对于数组中索引为 i(从0开始计数)的元素:
- 其左子节点的索引为
2*i + 1 - 其右子节点的索引为
2*i + 2 - 其父节点的索引为
floor((i-1)/2)
🔧 堆排序的工作原理
堆排序(以升序排序为例)主要分为两个大的阶段:构建大顶堆和反复提取最大值并调整。
1. 构建大顶堆 (Build Max Heap)
从一个无序的数组开始,我们需要将其调整为一个大顶堆。策略是从最后一个非叶子节点开始,依次向前,对每个节点执行“下沉”操作(Heapify),确保以该节点为根的子树满足大顶堆的性质。最后一个非叶子节点的索引通常是 n/2 - 1(n为数组长度)。
“下沉”操作 (Heapify / Sift Down) 的核心步骤:
- 针对当前节点、其左子节点和右子节点,找出值最大的那个节点。
- 如果最大值不是当前节点,就将当前节点与这个最大值的子节点交换位置。
- 由于交换可能会破坏下一级子树的堆性质,因此需要递归地或迭代地对交换后的子树继续进行下沉操作,直到当前节点大于等于其所有子节点,或者到达了叶子节点。
2. 排序 (Extract and Heapify)
在将整个数组构造成大顶堆后,堆顶元素(数组的第一个元素)就是当前最大值。
- 交换:将堆顶元素与当前堆的最后一个元素交换。此时,最大值就被放置到了数组的最终正确位置上。
- 缩小堆:将堆的大小减一(排除刚刚交换到末尾的最大值),最后一个元素不再视为堆的一部分。
- 调整:由于新的堆顶元素可能破坏堆的性质,因此需要对新的堆顶执行下沉操作,使剩余元素重新构成一个大顶堆。
- 重复:重复上述步骤,直到堆中只剩一个元素。此时,数组就已经排好序了。
📊 堆排序的步骤摘要
下表总结了堆排序算法的关键步骤,以升序排序为例:
| 步骤 | 操作描述 | 说明 |
|---|---|---|
| 1 | 构建大顶堆:从最后一个非叶子节点开始,自底向上、自右向左地对每个节点执行下沉操作。 | 确保每个节点的值都大于或等于其子节点的值,堆顶元素为最大值。 |
| 2 | 交换堆顶与堆尾:将堆顶元素(当前最大值)与当前堆的最后一个元素交换。 | 将最大值放置到数组的末尾,这是其最终有序位置。 |
| 3 | 缩小堆范围:将堆的大小减一,排除已排序的最大值。 | 接下来只需对剩余未排序部分进行操作。 |
| 4 | 调整堆:对新的堆顶元素执行下沉操作,使剩余部分重新满足大顶堆性质。 | 为下一次提取最大值做准备。 |
| 5 | 重复步骤2-4:直到堆的大小变为1。 | 此时所有元素都已排好序。 |
⚙️ 复杂度分析
| 指标 | 复杂度 | 解释 |
|---|---|---|
| 时间复杂度 | O(n log n) | • 建堆阶段:直观上感觉需要O(n log n),但通过更精细的分析可以得出其时间复杂度为O(n)。 • 排序阶段:需要进行n-1次下沉操作,每次下沉的时间复杂度为O(log n),因此为O(n log n)。 |
| 空间复杂度 | O(1) | 堆排序是原地排序算法,只需要常数级别的额外空间用于交换元素等临时操作。 |
| 稳定性 | 不稳定 | 在交换堆顶和堆尾元素的过程中,可能会改变相同值元素的相对顺序。 |
👍 优点和缺点
优点:
- 高效的时间复杂度:最坏、平均、最好情况下的时间复杂度均为 O(n log n),性能稳定。
- 空间效率高:是原地排序算法,不需要额外的存储空间,空间复杂度为 O(1)。
缺点:
- 不稳定:对于相等键值的元素,排序后它们的相对位置可能会改变。
- 缓存不友好:堆排序的访问模式是跳跃式的(例如访问
2*i+1,2*i+2),而不是顺序访问,这对CPU缓存不友好,在某些实际应用中可能比同样时间复杂度但缓存友好的算法(如快速排序)稍慢。
🚀 应用场景
- 需要保证最坏情况性能:快速排序在最坏情况下会退化为O(n²),而堆排序的O(n log n)最坏情况复杂度更有优势。
- 内存空间受限:当可用内存空间非常宝贵时,堆排序的原地排序特性使其成为一个很好的选择。
- 获取前k个最大/最小元素:例如,要从1亿个数字中找出前10个最大的数,可以维护一个大小为10的小顶堆,扫描完所有数据后,堆中的元素就是前10个最大的数。这种方法的时间复杂度是O(n log k),非常高效。
🖥️ 代码实现
以下是使用不同编程语言实现堆排序(升序)的示例代码:
Python 实现
def heap_sort(arr):
def heapify(n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] > arr[largest]:
largest = left
if right < n and arr[right] > arr[largest]:
largest = right
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(n, largest)
n = len(arr)
# 构建最大堆
for i in range(n // 2 - 1, -1, -1):
heapify(n, i)
# 逐个提取元素
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0] # 交换
heapify(i, 0) # 调整剩余堆
# 调用示例
arr = [12, 11, 13, 5, 6, 7]
heap_sort(arr)
print("Sorted array:", arr) # 输出: [5, 6, 7, 11, 12, 13]
代码来源于,并进行了适当修改和注释。
Java 实现
public class HeapSort {
public static void heapSort(int[] arr) {
int n = arr.length;
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 逐个从堆中提取元素
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶(最大值)移动到数组末尾
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 对减小后的堆调用heapify
heapify(arr, i, 0);
}
}
private static void heapify(int[] arr, int n, int i) {
int largest = i; // 初始化最大值为根
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
// 递归地堆化受影响的子树
heapify(arr, n, largest);
}
}
// 测试
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
System.out.print("Sorted array: ");
for (int num : arr) {
System.out.print(num + " "); // 输出: 5 6 7 11 12 13
}
}
}
代码来源于,并进行了适当修改和注释。
C++ 实现
#include <iostream>
#include <vector>
using namespace std;
void heapify(vector<int>& arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
void heapSort(vector<int>& arr) {
int n = arr.size();
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 从堆中逐个提取元素
for (int i = n - 1; i > 0; i--) {
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
int main() {
vector<int> arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
cout << "Sorted array: ";
for (int num : arr) {
cout << num << " "; // 输出: 5 6 7 11 12 13
}
return 0;
}
代码来源于,并进行了适当修改和注释。
希望这份详细的介绍能帮助你彻底理解堆排序!如果你有任何疑问,欢迎随时提出。
自下而上复杂度
自下而上建堆(Heapify)的时间复杂度确实是 O(n)。这是一个非常重要且可能有些反直觉的结论,因为单个元素的调整操作(下沉,Sift-Down)在最坏情况下是 O(log n),但整体建堆过程却能达到线性复杂度。
为了帮助你快速理解这两种建堆方式的本质区别,我先用一个表格来总结它们的核心特性:
| 特性 | 自下而上建堆 (Heapify) | 自上而下建堆 (逐个插入) |
|---|---|---|
| 核心过程 | 从最后一个非叶节点开始,向前遍历并对每个节点执行下沉操作 | 从空堆开始,逐个将元素插入堆尾并执行上浮操作 |
| 起始点 | 最后一个非叶节点 (索引 ≈ n/2) | 第一个元素 |
| 核心操作 | 下沉 (Sift-Down) | 上浮 (Sift-Up) |
| 时间复杂度 | O(n) (线性) | O(n log n) (线性对数) |
| 适用场景 | 批量建堆,所有元素已知且一次性给定时,效率更高 | 动态插入,元素逐个到达时需要维持堆结构时使用 |
🧠 为什么自下而上建堆是 O(n)?
关键在于大部分需要调整的节点都位于树的底层,它们需要下沉的步骤(代价)非常少;而需要较多调整步骤的节点数量非常少。
数学推导(求和公式):
假设一个包含 n 个节点的完全二叉树,高度为 h (h ≈ log₂n)。
- 叶子节点(最底层)大约有 n/2 个,它们的高度为 0,不需要下沉操作。
- 高度为 1 的节点大约有 n/4 个,它们最多只需要下沉 1 步。
- 高度为 2 的节点大约有 n/8 个,它们最多只需要下沉 2 步。
- …
- 根节点(高度为 h)只有 1 个,它最多需要下沉 h 步。
总时间代价 T(n) 可以表示为:
T(n) = n/4 * 1 + n/8 * 2 + n/16 * 3 + … + 1 * h
这是一个等差数列和等比数列的乘积,通过错位相减法等数学方法求解后,可以证明 T(n) < 2n。因此,时间复杂度是 O(n)。
直观理解:
- 工作量分布:树中需要调整的节点数量大约是 n/2(非叶节点),但大部分工作量是由很少量的高层节点贡献的。大量的底层节点几乎不贡献工作量(因为调整的步数少)。
- 与自上而下对比:自上而下建堆时,每个新插入的元素都可能从最底层一路上浮到根节点,路径很长。而自下而上建堆时,一个节点的下沉操作只会发生在它的子树中,不会波及到整个树,且越底层的节点涉及的子树越小,调整得越快。
⚡ 重要提示
虽然自下而上建堆的整体时间复杂度是 O(n),但这并不意味着每个节点的调整操作都是 O(1)。根节点的调整仍然需要 O(log n) 的时间,只是由于这种节点数量极少,在求和时被“均摊”掉了。
💡 如何选择
- 当你已经拥有一个完整的无序数组,并希望将其构建成一个堆时(例如堆排序的第一步),应优先选择自下而上建堆,因为其效率更高 (O(n))。
- 当你需要动态维护一个堆,即元素是一个一个到来并需要随时保证堆结构时,则使用自上而下(插入)的方法,尽管其建堆复杂度是 O(n log n),但这是唯一的选择。
希望以上的解释能帮助你理解自下而上建堆的时间复杂度。这是一个非常精妙的算法设计案例,展示了如何通过优化整体流程来降低复杂度。
交换数字
交换数字
交换数字是编程中的常见操作,有多种实现方法,各有其适用场景和特点。下面我用一个表格为你汇总主要方法,然后详细解释,最后给你一些选择建议。
| 方法 | 核心思路 | 优点 | 缺点 | 典型应用场景 |
|---|---|---|---|---|
| 临时变量法 | 引入第三个变量暂存值 | 直观易懂,可读性强,稳定可靠 | 需要额外的内存空间 | 通用场景,教学示例,可读性要求高的代码 |
| 算术运算法 | 通过加法减法或乘法除法操作交换值 | 无需额外变量 | 有溢出风险(加减),除数不能为零(乘除) | 内存受限环境,不推荐常规使用 |
| 位异或运算法 | 利用异或运算的性质交换值 | 无需额外变量,效率通常比算术法高 | 代码可读性稍差 | 嵌入式开发,内存极度受限或追求极致性能 |
| 函数封装法 | 将交换操作封装成函数 | 代码复用,模块化,减少重复代码 | 引入函数调用开销(通常可忽略) | 需要多次交换操作的项目 |
| 宏定义法 | 使用预处理器宏进行文本替换 | 免去函数调用开销,灵活 | 可能产生副作用(如对重复求值),调试稍复杂 | C语言中追求性能的频繁交换操作 |
下面是这些方法的详细说明和代码示例(以C语言为例)。
📝 详细方法说明与代码示例
1. 使用临时变量
这是最直接、最常用的方法。思路是引入一个临时变量 (temp) 来暂时保存其中一个变量的值。
#include <stdio.h>
int main() {
int a = 5, b = 10, temp;
printf("交换前: a = %d, b = %d\n", a, b);
temp = a; // 将 a 的值暂存到 temp
a = b; // 将 b 的值赋给 a
b = temp; // 将 temp (原a的值) 赋给 b
printf("交换后: a = %d, b = %d\n", a, b);
return 0;
}
优点:逻辑清晰,易于理解和维护,适用于所有数据类型。
缺点:需要消耗一个额外变量的内存空间(通常这微不足道)。
2. 使用算术运算
这种方法通过加法和减法来实现交换,不需要临时变量。
#include <stdio.h>
int main() {
int a = 5, b = 10;
printf("交换前: a = %d, b = %d\n", a, b);
a = a + b; // a 变为 a 与 b 的和
b = a - b; // b 的值变为原来的 a (因为 (a+b)-b = a)
a = a - b; // a 的值变为原来的 b (因为 (a+b)-a = b)
printf("交换后: a = %d, b = %d\n", a, b);
return 0;
}
你也可以使用乘除法,但务必注意除数不能为零:
a = a * b;
b = a / b;
a = a / b;
优点:节省了一个临时变量。
缺点:
加减法:当
a和b的值非常大时,a + b可能会超出整型数据的表示范围,导致溢出,这是潜在的风险。乘除法:同样有溢出风险,并且如果
b为0,除法会导致运行时错误。不推荐在重要项目或通用场景中使用,除非你能确保不会溢出且除数非零。
3. 使用位异或运算
利用异或操作符 ^ 的性质(相同为0,不同为1;一个数与自己异或结果为0;一个数与0异或结果为自己)来交换值,也无需临时变量。
#include <stdio.h>
int main() {
int a = 5, b = 10; // 5: 0101, 10: 1010
printf("交换前: a = %d, b = %d\n", a, b);
a = a ^ b; // a 现在是一个"中间值",不同位为1
b = a ^ b; // 相当于 (a^b)^b = a,b 得到了原 a 的值
a = a ^ b; // 相当于 (a^b)^a = b,a 得到了原 b 的值
printf("交换后: a = %d, b = %d\n", a, b);
return 0;
}
优点:避免了算术运算的溢出问题,在某些底层系统或对性能有极致要求的场合效率较高。
缺点:代码可读性较差,不了解位操作的人可能难以理解。
注意:操作数必须是整数类型。
4. 函数封装
当程序中需要多次进行交换操作时,将其封装成函数是非常好的实践,能提高代码的复用性和整洁度。
#include <stdio.h>
// 通过指针来修改主函数中变量的值
void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
int main() {
int a = 5, b = 10;
printf("交换前: a = %d, b = %d\n", a, b);
swap(&a, &b); // 传递变量的地址
printf("交换后: a = %d, b = %d\n", a, b);
return 0;
}
优点:模块化,一次编写多处使用,使主逻辑更清晰。
缺点:有极小的函数调用开销(现代编译器优化下通常可忽略)。
5. 宏定义
使用预处理器宏来“定义”一个交换操作,它在编译前进行文本替换。
#include <stdio.h>
// 定义交换宏
#define SWAP(x, y) { int temp = (x); (x) = (y); (y) = temp; }
int main() {
int a = 5, b = 10;
printf("交换前: a = %d, b = %d\n", a, b);
SWAP(a, b); // 编译器预处理时会替换为 { int temp = a; a = b; b = temp; }
printf("交换后: a = %d, b = %d\n", a, b);
return 0;
}
优点:免去了函数调用的开销(虽然通常很小),更灵活(不限于int类型,但需小心)。
缺点:宏只是简单替换,如果参数是复杂表达式(如 SWAP(i++, j--)),可能会产生不可预期的副作用(多次求值)。调试时也可能更复杂。
🔄 在其他编程语言中
许多现代高级语言提供了更简便的交换方式,通常基于元组(Tuple)解包或包装类:
Python:
a, b = 5, 10 a, b = b, a # 直接交换,最简洁 print(a, b) # 输出: 10 5Java (使用临时变量或封装方法,因为基本类型是值传递):
// 常用方式还是临时变量 int temp = a; a = b; b = temp; // 如果是包装类(Integer等)或对象,可通过方法封装JavaScript:
let a = 5, b = 10; [a, b] = [b, a]; // 使用解构赋值 console.log(a, b); // 输出: 10, 5
💡 如何选择方法?
- 通用首选:对于绝大多数情况,尤其是初学者和注重代码可读性、可维护性的场景,使用临时变量是最安全、最清晰的选择。那一点额外的内存开销几乎总是值得的。
- 面试与炫技:位异或运算常被用作面试题考察对位运算的理解,但在实际项目中使用时需加上注释说明意图。
- 极端限制:只有在内存极度受限的嵌入式环境或确知数据范围不可能溢出时,才考虑算术法或位运算法。
- 模块化与复用:如果代码中需要多次交换,封装成函数是专业的表现。
- 性能临界:在C/C++中,如果处在性能关键的循环内部,且编译器优化后仍希望避免函数调用开销,可以考虑使用宏,但必须确保传入的宏参数没有副作用。
希望这些详细解释能帮助你全面理解交换数字的各种方法!
堆
堆是计算机科学中一种非常重要且高效的数据结构,它巧妙地通过数组来模拟一棵完全二叉树,并满足特定的堆序性质。下面我们通过一个核心性质表格和详细解释来帮助你全面理解它。
| 性质类别 | 核心要点 | 详细说明 |
|---|---|---|
| 🧱 结构性质 | 完全二叉树 | 堆总是一棵完全二叉树。这意味着除了最后一层外,其他层都是满的,并且最后一层的节点都靠左排列。这个特性决定了它可以用数组来紧凑、高效地存储,无需指针。 |
| 📊 堆序性质 | 父节点主导 | 在大顶堆中,每个节点的值都大于或等于其子节点的值,根节点(堆顶)是最大值。在小顶堆中,每个节点的值都小于或等于其子节点的值,根节点(堆顶)是最小值。 |
| 🧮 数组表示 | 索引关系 | 若数组下标从 0开始,对于下标为 i的节点: - 其父节点下标为 (i-1)/2(向下取整)。 - 其左孩子下标为 2*i+1。 - 其右孩子下标为 2*i+2。 |
| ⚡ 操作复杂度 | 高效 | - 插入元素 (heappush): O(log n) - 删除堆顶 (heappop): O(log n) - 构建堆 (heapify): O(n) - 获取极值 (peek): O(1) |
| 🎯 核心应用 | 依赖极值 | - 优先队列 - 堆排序 (升序用大顶堆,降序用小顶堆) - 求 Top K 问题 - 图算法 (如Dijkstra算法、Prim算法) |
📌 堆序性质的深入理解
堆序性质是堆的灵魂,但它只规定了父子节点之间的大小关系,而并不要求兄弟节点之间或不同分支的节点之间有任何大小关系。
- 大顶堆示例:在一个包含
[16, 14, 10, 8, 7, 9, 3, 2, 4, 1]的大顶堆中,根节点16是最大的。节点14和10是兄弟,14>10;但节点8(14的左孩子)和节点9(10的左孩子)之间没有大小要求,此处8<9。 - 小顶堆示例:小顶堆则相反,根节点最小,且每个节点都比它的子节点小。
🛠️ 维护堆性质的操作
当插入或删除元素后,堆的性质可能被破坏,需要通过上浮(Sift Up) 或下沉(Sift Down/Heapify) 操作来修复。
- 插入与上浮(Sift Up):新元素被添加到数组末尾,然后与其父节点比较。如果违反堆序(在大顶堆中比父大,在小顶堆中比父小),就与父节点交换,并重复此过程直至满足条件。
- 删除与下沉(Sift Down):通常指删除堆顶元素。方法是将其与数组末尾元素交换并移除,然后新的堆顶元素与其最大的孩子(大顶堆) 或最小的孩子(小顶堆) 比较,如果违反堆序就交换,并重复此过程直至满足条件。
🧩 构建堆(Heapify)
将一个无序数组构建成堆,有两种方式:
- 自底向上(Down-top):从最后一个非叶子节点(下标为
n/2 - 1)开始,向前遍历并对每个节点执行下沉(Sift Down) 操作。这是一种高效的方法,时间复杂度为O(n)。 - 自顶向下(Top-down):将数组视为空堆,然后逐个插入(Push) 元素。每次插入都伴随一次上浮,时间复杂度为O(n log n),效率较低,通常不推荐。
💡 堆的典型应用
优先队列(Priority Queue):这是堆最直接的应用。无论是操作系统中的进程调度,还是网络中的数据包管理,都需要快速处理优先级最高的元素,堆的O(1)取极值和O(log n)插入删除特性完美契合此需求。
堆排序(Heap Sort):算法分为两步:
- 建堆:将无序数组构建成大顶堆(升序)或小顶堆(降序)。
- 排序:反复将堆顶元素(当前极值)与当前无序区末尾元素交换,然后对新的堆顶执行下沉操作以重新使无序区满足堆性质。堆排序的时间复杂度为O(n log n)。
Top-K 问题:在海量数据中找出最大(或最小)的K个元素。
求前K个最大元素:维护一个大小为K的小顶堆。新元素若比堆顶大,则替换堆顶并下沉。
求前K个最小元素:维护一个大小为K的大顶堆。新元素若比堆顶小,则替换堆顶并下沉。
此方法的时间复杂度约为O(n log K),非常高效。
⚠️ 重要注意事项
- 内存管理中的“堆”:请注意,数据结构中的“堆”与内存管理中的“堆”是两个完全不同的概念,切勿混淆。
- 相同值的处理:堆不保证具有相同值的元素之间的任何顺序(稳定性)。
- 多线程环境:标准的堆操作不是线程安全的,在多线程环境下使用需要额外的同步机制。
希望以上解释能帮助你建立起对堆的全面认识。