呕心沥血的计算机算法思维神级教材大黑书:《算法导论》的概览、梳理、分析、思考总结、归纳、拓展、提炼 (1)
第一部分:基础篇 - 算法的基石与分析方法
1.1 算法的“灵魂”与“肉体”:究竟什么是算法?
兄弟们,咱们天天写代码,但你有没有停下来思考过,我们写的那些逻辑,到底是不是“算法”?或者说,一个好的“算法”到底长啥样?在《算法导论》里,开篇就给我们揭示了算法的本质。
1.1.1 算法的定义:解决问题的“食谱”
-
官方定义: 算法(Algorithm)是解决特定问题的一系列清晰、有限、有序的计算步骤。它就像一份精确的“食谱”,指导我们如何一步步地从输入(原材料)得到输出(美味佳肴)。
-
接地气的理解: 想象一下,你要把一堆乱七八糟的扑克牌按从小到大排好。你可能会想:
-
从剩下的牌里找到最小的那张。
-
把它放到新的一堆牌的末尾。
-
重复这个过程,直到所有牌都排好。 这就是一个算法!它有明确的步骤,最终会完成任务。
-
1.1.2 算法的五大特性:好算法的“DNA”
一个算法,要能称得上“算法”,必须满足以下五个基本特性:
-
输入(Input): 算法必须有零个或多个外部量作为输入。这些输入是算法处理的对象。
-
比如,排序算法的输入就是那堆乱七八糟的扑克牌。
-
-
输出(Output): 算法必须产生一个或多个量作为输出。输出是算法执行的结果,它与输入有着特定的关系。
-
排序算法的输出就是排好序的扑克牌。
-
-
确定性(Definiteness): 算法的每一步都必须是精确定义的,没有歧义。对于相同的输入,算法总是产生相同的输出。
-
不能说“随便找一张牌”,而是“找到剩下的牌中最小的那张”。
-
-
有限性(Finiteness): 算法必须在有限的步骤后终止,不能无限循环。每一步的执行时间也必须是有限的。
-
不能让算法一直找下去,必须在所有牌都排好后停止。
-
-
有效性/可行性(Effectiveness): 算法的每一步都必须是可行的,原则上可以通过纸笔计算完成。
-
每一步操作都必须是基本、可执行的。
-
思维导图:算法的五大特性
graph TD
A[算法] --> B[输入];
A --> C[输出];
A --> D[确定性];
A --> E[有限性];
A --> F[有效性/可行性];
1.1.3 算法与程序的区别:理论与实践
-
算法: 是独立于任何编程语言的、抽象的、解决问题的思想和步骤。它是一种数学概念。
-
程序: 是算法用特定编程语言(如C语言)的具体实现。程序必须在计算机上运行。
做题编程随想录: 面试时,面试官可能会让你描述一个算法,然后让你用代码实现。描述算法时,要注重逻辑和步骤;实现时,要考虑语言特性、边界条件和效率。
1.1.4 为什么算法很重要?
兄弟们,你可能会问,我一个C程序员,一个搞嵌入式的,天天写驱动、写业务逻辑,有必要把算法学得这么深吗?答案是:太有必要了!
-
效率的基石: 同样一个功能,用不同的算法实现,性能可能天差地别。一个高效的算法能让你的程序在处理海量数据时依然飞快,而一个糟糕的算法可能让你的程序卡死。在嵌入式领域,资源(CPU、内存)是极其宝贵的,高效算法更是生命线。
-
解决复杂问题: 很多看似无从下手的问题,一旦你掌握了算法思维,就能找到巧妙的解决方案。
-
大厂的“敲门砖”: 无论是Google、Meta、字节跳动还是华为、腾讯,算法和数据结构都是面试的必考项,而且是重中之重。它考察的是你的逻辑思维能力、问题解决能力和代码实现能力。
-
底层优化: 深入理解算法,能让你在编写底层代码、优化系统性能时,有更清晰的思路和更强大的武器。比如,你知道操作系统的调度算法、文件系统的索引结构,这些都离不开算法的支撑。
小结: 算法是解决问题的核心思想,它具备输入、输出、确定性、有限性和有效性五大特性。理解算法的本质,是开启《算法导论》修炼之路的第一步,也是你成为顶尖程序员的必备素质。
1.2 算法的“照妖镜”:时间与空间复杂度分析
兄弟们,算法写出来了,怎么知道它“好不好”?光能跑起来可不行!我们得用“照妖镜”——时间复杂度和空间复杂度分析,来衡量一个算法的效率。这是《算法导论》的核心,也是大厂面试的灵魂拷问!
1.2.1 为什么需要复杂度分析?
你可能会说,我直接跑一下程序,看看运行时间不就行了?这叫事后统计法。它有几个致命缺点:
-
依赖环境: 运行时间受计算机硬件、操作系统、编程语言、编译器等多种因素影响。
-
依赖数据: 不同的输入数据,运行时间可能不同。
-
难以比较: 在不同机器上运行,结果没有可比性。
所以,我们需要一种事前分析估算方法,它不依赖于具体的计算机,不依赖于具体的输入数据,只关注算法本身的执行效率,这也就是复杂度分析。
1.2.2 时间复杂度:算法的“执行时长”
-
基本思想: 衡量算法执行时间随输入规模增长而增长的趋势。我们不关注具体的秒数,而是关注操作次数的增长趋势。
-
如何计算:
-
找出基本操作: 算法中最耗时的、执行次数最多的操作。
-
计算操作次数: 用关于输入规模 n 的函数 T(n) 表示基本操作的执行次数。
-
使用渐近记号: 用大O表示法(Big O Notation)表示 T(n) 的渐近上界。
-
大O表示法(渐近上界):O(g(n))
-
定义: 如果存在正的常数 c 和 n_0,使得当 ngeqn_0 时,有 0leqf(n)leqccdotg(n),则称 f(n)=O(g(n))。
-
通俗理解: O(g(n)) 表示算法的运行时间或空间消耗最多与 g(n) 成正比。它给出了算法性能的上限。
-
计算规则:
-
常数项忽略: O(ccdotf(n))=O(f(n))。例如,O(2n)=O(n),O(100)=O(1)。
-
低阶项忽略: O(n2+n)=O(n2)。
-
只保留最高阶项: O(2n2+3n+5)=O(n2)。
-
加法法则: 多个并列的循环,取最大时间复杂度。
for (int i = 0; i < n; i++) { /* O(n) */ } for (int j = 0; j < m; j++) { /* O(m) */ } // 总时间复杂度 O(n + m)
-
乘法法则: 嵌套循环,时间复杂度相乘。
for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { /* O(1) */ } } // 总时间复杂度 O(n * n) = O(n^2)
-
常见时间复杂度及其效率(从低到高):
复杂度 |
名称 |
例子 |
增长趋势(n 增大时) |
---|---|---|---|
O(1) |
常数阶 |
数组元素访问,哈希表查找(理想情况) |
不变 |
O(logn) |
对数阶 |
二分查找 |
增长缓慢 |
O(n) |
线性阶 |
遍历数组 |
线性增长 |
O(nlogn) |
线性对数阶 |
归并排序,快速排序,堆排序 |
较快增长 |
O(n2) |
平方阶 |
冒泡排序,选择排序,插入排序 |
快速增长 |
O(n3) |
立方阶 |
矩阵乘法 |
飞速增长 |
O(2n) |
指数阶 |
递归计算斐波那契数列(无优化) |
爆炸式增长 |
O(n) |
阶乘阶 |
旅行商问题(暴力解法) |
极其快速增长 |
做题编程随想录: 在面试中,面试官最喜欢让你分析代码的时间复杂度。记住,分析时要关注最坏情况(Worst Case),因为它代表了算法性能的上限,是算法运行时间的保证。
1.2.3 空间复杂度:算法的“内存消耗”
-
基本思想: 衡量算法执行过程中临时占用存储空间随输入规模增长而增长的趋势。
-
如何计算:
-
关注额外空间: 除了输入数据本身占用的空间外,算法运行过程中额外申请的内存空间(如数组、栈、队列、递归栈)。
-
使用大O表示法: 表示额外空间消耗的渐近上界。
-
-
常见空间复杂度:
-
O(1):常数空间,不随输入规模变化。
-
O(logn):对数空间,如递归深度为对数级别。
-
O(n):线性空间,如创建与输入规模等大的数组。
-
O(n2):平方空间,如创建二维数组。
-
做题编程随想录: 空间复杂度在嵌入式领域尤为重要,因为嵌入式设备的内存资源通常非常有限。在算法设计时,往往需要在时间复杂度和空间复杂度之间进行权衡(Space-Time Trade-off)。
1.2.4 渐近记号的“家族成员”:O, Ω, Θ, o, ω
除了大O记号,还有其他几个渐近记号,它们更精确地描述函数的增长关系。
-
大O记号 (O):渐近上界
-
f(n)=O(g(n)):表示 f(n) 的增长率不大于 g(n) 的增长率。
-
通俗:f(n) “小于等于” g(n)。
-
-
大Ω记号 (Omega):渐近下界
-
f(n)=Omega(g(n)):表示 f(n) 的增长率不小于 g(n) 的增长率。
-
通俗:f(n) “大于等于” g(n)。
-
-
大$\Theta记号(\Theta$):渐近紧界
-
f(n)=Theta(g(n)):表示 f(n) 的增长率等于 g(n) 的增长率。
-
通俗:f(n) “等于” g(n)。当 f(n)=O(g(n)) 且 f(n)=Omega(g(n)) 时,则 f(n)=Theta(g(n))。
-
-
小o记号 (o):非渐近紧上界
-
f(n)=o(g(n)):表示 f(n) 的增长率严格小于 g(n) 的增长率。
-
通俗:f(n) “严格小于” g(n)。
-
-
小$\omega记号(\omega$):非渐近紧下界
-
f(n)=omega(g(n)):表示 f(n) 的增长率严格大于 g(n) 的增长率。
-
通俗:f(n) “严格大于” g(n)。
-
表格:渐近记号对比
记号 |
名称 |
含义 |
通俗理解 |
例子 |
---|---|---|---|---|
O |
大O |
渐近上界 |
小于等于 |
2n2+3n=O(n2) |
Omega |
大Omega |
渐近下界 |
大于等于 |
2n2+3n=Omega(n2) |
Theta |
大Theta |
渐近紧界 |
等于 |
2n2+3n=Theta(n2) |
o |
小o |
非渐近紧上界 |
严格小于 |
n=o(n2) |
omega |
小Omega |
非渐近紧下界 |
严格大于 |
n2=omega(n) |
做题编程随想录: 在实际面试中,通常只要求用大O记号来表示时间/空间复杂度。但理解其他记号能让你对算法的性能有更精确的把握,在阅读《算法导论》时也能更好地理解其严谨性。
小结: 复杂度分析是衡量算法优劣的“照妖镜”。时间复杂度关注执行次数,空间复杂度关注内存消耗。大O表示法是最常用的渐近上界表示,掌握其计算规则和常见复杂度等级至关重要。理解渐近记号的家族成员,能让你在算法的海洋中航行得更远。
1.3 算法的“数学基石”:那些年我们追过的数学
兄弟们,别一听到数学就头大!《算法导论》之所以被誉为神书,很大一部分原因就是它对算法的数学分析非常严谨。这些数学工具,是理解算法性能、推导正确性的“秘密武器”。你不需要成为数学家,但掌握这些基础,能让你看懂算法的“内涵”。
1.3.1 求和公式:循环的“速算器”
很多算法都包含循环,计算循环次数往往需要用到求和公式。
-
算术级数求和: 1+2+dots+n=fracn(n+1)2=O(n2)
-
应用: 嵌套循环中,内层循环次数递增的情况。
-
-
几何级数求和: a+ar+ar2+dots+arn−1=afracrn−1r−1 (当 rneq1)
-
应用: 某些分治算法的递归树求和。
-
-
重要结论:
-
sum_i=1nik=Theta(nk+1)
-
sum_i=0nri=Theta(rn) (当 r1)
-
sum_i=0nri=Theta(1) (当 0\<r\<1)
-
做题编程随想录: 看到循环,尤其是嵌套循环,条件是i*i < n
或者i = i*2
这种,就要条件反射地想到求和公式或者对数、指数。
1.3.2 对数与指数:算法的“加速器”与“炸弹”
-
对数(Logarithms):
-
定义: x=log_ba 当且仅当 bx=a。
-
性质:
-
log_b(xy)=log_bx+log_by
-
log_b(x/y)=log_bx−log_by
-
log_bxa=alog_bx
-
换底公式: log_ba=fraclog_calog_cb
-
重要: log_ba 和 log_ca 之间只差一个常数因子 frac1log_cb。所以在渐近分析中,对数的底通常不重要,我们直接写 O(logn)。
-
-
应用: 二分查找(每次将问题规模减半)、树形结构的高度(平衡二叉树)。
-
-
指数(Exponentials):
-
性质:
-
a0=1
-
a1=a
-
a−1=1/a
-
(am)n=amn
-
aman=am+n
-
fracaman=am−n
-
-
应用: 暴力搜索、递归无优化(如斐波那契数列)。指数级别的算法通常是“性能炸弹”,只能处理很小规模的问题。
-
做题编程随想录: 对数是算法效率的“加速器”,指数是“性能炸弹”。在设计算法时,我们总是追求将指数复杂度降低到多项式复杂度,甚至对数复杂度。
1.3.3 阶乘:排列组合的“计数器”
-
定义: n=ntimes(n−1)timesdotstimes2times1。
-
性质: 增长速度非常快,比指数函数还要快。
-
应用: 全排列、旅行商问题(TSP)的暴力解法。阶乘复杂度的算法基本无法用于实际问题。
1.3.4 增长率的比较:谁跑得更快?
理解不同函数增长率的相对速度,是判断算法优劣的关键。
-
基本顺序: O(1)\<O(logn)\<O(n)\<O(nlogn)\<O(n2)\<O(n3)\<O(2n)\<O(n)
-
重要推论:
-
任何多项式函数 nk (k0)的增长速度都比任何对数函数 (logn)c (c0)快。
-
任何指数函数 an (a1)的增长速度都比任何多项式函数 nk (k0)快。
-
阶乘函数 n 的增长速度比任何指数函数 an (a1)快。
-
表格:不同增长率函数对比(当 n 足够大时)
n |
1 |
log_2n |
n |
nlog_2n |
n2 |
2n |
n |
---|---|---|---|---|---|---|---|
10 |
1 |
3.32 |
10 |
33.2 |
100 |
1024 |
3,628,800 |
100 |
1 |
6.64 |
100 |
664 |
10,000 |
1.26times1030 |
9.33times10157 |
1000 |
1 |
9.96 |
1000 |
9,960 |
1,000,000 |
1.07times10301 |
无法表示 |
做题编程随想录: 这张表就是你选择算法的“指南针”。在面对大规模数据时,一个 O(n2) 的算法可能就已经无法接受了,我们必须追求 O(nlogn) 甚至 O(n) 的算法。
小结: 算法的数学基础是理解其性能和正确性的关键。求和公式帮助我们分析循环,对数和指数揭示了算法的效率等级,而增长率的比较则指导我们选择更优的算法。掌握这些数学工具,你就能更好地理解《算法导论》的精髓,并在算法设计中游刃有余。
1.4 递归与分治:“化整为零”的艺术
兄弟们,很多复杂的问题,如果直接去解决,可能会一团乱麻。但如果把它们“化整为零”,分解成一个个小问题,然后各个击破,是不是就简单多了?这就是**递归(Recursion)和分治(Divide and Conquer)**的思想!它们是算法设计中非常强大且常用的策略。
1.4.1 递归:自己调用自己
-
定义: 递归是一种函数或过程调用自身的技术。它通常用于解决可以分解为相同子问题的问题。
-
构成要素:
-
基本情况(Base Case): 递归终止的条件。当问题规模小到可以直接解决时,不再进行递归调用。这是防止无限递归的关键!
-
递归步(Recursive Step): 将原问题分解为更小的子问题,并递归地调用自身来解决这些子问题。
-
-
例子:计算阶乘 n
-
数学定义: n=ntimes(n−1) (当 n0),且 0=1。
-
基本情况: 当 n=0 时,返回 1。
-
递归步: 当 n0 时,返回 ntimestextfactorial(n−1)。
-
概念性C代码:递归计算阶乘
#include <stdio.h>
/**
* @brief 递归计算阶乘
* @param n 要计算阶乘的非负整数
* @return n的阶乘
* @note 这是一个经典的递归示例,展示了基本情况和递归步。
* 但需要注意,对于较大的n,可能会导致栈溢出。
*/
long long factorial(int n) {
// 基本情况:当n为0时,阶乘为1
if (n == 0) {
return 1;
}
// 递归步:n的阶乘等于n乘以(n-1)的阶乘
// 这里函数调用了自身,但参数n减小,最终会达到基本情况
return (long long)n * factorial(n - 1);
}
// 主函数用于测试
int main() {
int num = 5;
printf("%d的阶乘是: %lld\n", num, factorial(num)); // 输出 5的阶乘是: 120
num = 0;
printf("%d的阶乘是: %lld\n", num, factorial(num)); // 输出 0的阶乘是: 1
// 注意:对于较大的数,例如20,阶乘会非常大,超出long long范围
// 20! = 2432902008176640000,仍在long long范围内
// 21! = 51090942171709440000,超出long long范围,会溢出
num = 20;
printf("%d的阶乘是: %lld\n", num, factorial(num));
// num = 21; // 尝试计算21的阶乘会溢出
// printf("%d的阶乘是: %lld\n", num, factorial(num));
return 0;
}
代码分析与说明:
-
factorial(int n)
函数清晰地展示了递归的两个要素:-
if (n == 0)
是基本情况,它直接返回一个确定的值,不再进行递归。这是递归能够终止的关键。 -
return (long long)n * factorial(n - 1);
是递归步,它将问题n!
转化为更小的子问题(n-1)!
,并通过调用自身来解决这个子问题。
-
-
main
函数中进行了简单的测试,并特别提醒了long long
的范围限制,这在实际编程中非常重要,阶乘增长非常快,很容易溢出。 -
递归栈: 每次递归调用都会在内存栈上创建一个新的栈帧,保存当前函数的局部变量、参数和返回地址。当达到基本情况并开始返回时,栈帧会逐层弹出。理解递归栈的工作原理,对于调试递归程序和分析空间复杂度至关重要。
做题编程随想录: 递归的优点是代码简洁、逻辑清晰,符合人类思维。但缺点是可能导致栈溢出(Stack Overflow)和重复计算(效率低,如未优化的斐波那契数列)。在面试中,面试官可能会让你把递归改写成迭代(非递归)形式,或者分析递归的复杂度。
1.4.2 分治策略:大事化小,小事化了
-
定义: 分治(Divide and Conquer)是一种重要的算法设计范式,它将一个大的问题分解(Divide)成若干个相互独立的、与原问题形式相同但规模更小的子问题,然后递归地解决(Conquer)这些子问题,最后将子问题的解合并(Combine)起来得到原问题的解。
-
三步走战略:
-
分解(Divide): 将原问题分解成若干个规模更小、相互独立、与原问题形式相同的子问题。
-
解决(Conquer): 递归地解决这些子问题。如果子问题足够小,则直接解决(基本情况)。
-
合并(Combine): 将子问题的解合并成原问题的解。
-
-
典型应用:
-
排序算法: 归并排序、快速排序。
-
查找算法: 二分查找。
-
计算几何: 最近点对问题。
-
大整数乘法: Karatsuba算法。
-
矩阵乘法: Strassen算法。
-
做题编程随想录: 分治策略通常与递归紧密结合。在分析分治算法时,最关键的是写出并求解递归关系式(Recurrence Relation)。
1.4.3 递归关系式:分治算法的“数学模型”
-
定义: 递归关系式(或称递推关系式)是描述一个算法的运行时间(或空间)与输入规模之间关系的数学表达式。它通常由一个基本情况和一个递归步组成。
-
通用形式: T(n)=aT(n/b)+f(n)
-
T(n):解决规模为 n 的问题所需的时间。
-
a:子问题的数量。
-
n/b:每个子问题的规模(假设子问题规模相等)。
-
f(n):分解问题和合并子问题解所需的时间。
-
求解递归关系式的方法:
-
代换法(Substitution Method):
-
思想: 猜测一个解的形式,然后用数学归纳法证明这个猜测是正确的。
-
步骤:
-
猜测解的形式(通常根据经验或递归树法)。
-
用数学归纳法证明猜测的正确性。
-
确定常数 c 和 n_0。
-
-
优点: 严谨,能得到精确的渐近界。
-
缺点: 需要猜测,有时猜测困难。
-
-
递归树法(Recursion-Tree Method):
-
思想: 将递归关系式展开成一棵树,树的每个节点表示一个子问题的代价。然后对树的所有节点代价求和。
-
步骤:
-
画出递归树,表示每次递归调用的分解过程。
-
计算每层节点的代价之和。
-
计算树的层数。
-
将所有层的代价求和。
-
-
优点: 直观,易于理解,能帮助猜测解的形式。
-
缺点: 适用于简单的递归关系式,复杂的树可能难以求和。
-
-
主方法(Master Method):
-
思想: 针对形如 T(n)=aT(n/b)+f(n) 的递归关系式,直接给出渐近解。
-
三个情况(记住它,面试利器!): 设 age1,b1 为常数,f(n) 为渐近正函数。
-
如果 f(n)=O(nlog_ba−epsilon),其中 epsilon0 为常数,则 T(n)=Theta(nlog_ba)。
-
通俗理解: 如果分解/合并的代价 f(n) 比递归调用本身的代价 nlog_ba 小一个多项式因子,那么总代价由递归调用决定。
-
-
如果 f(n)=Theta(nlog_ba),则 T(n)=Theta(nlog_balogn)。
-
通俗理解: 如果分解/合并的代价 f(n) 与递归调用本身的代价 nlog_ba 渐近相等,那么总代价是 nlog_ba 乘以 logn。
-
-
如果 f(n)=Omega(nlog_ba+epsilon),其中 epsilon0 为常数,且对于某个常数 c\<1 和所有足够大的 n,有 af(n/b)leqcf(n) (正则条件),则 T(n)=Theta(f(n))。
-
通俗理解: 如果分解/合并的代价 f(n) 比递归调用本身的代价 nlog_ba 大一个多项式因子,那么总代价由分解/合并决定。
-
-
-
-
主方法应用举例:
-
归并排序: T(n)=2T(n/2)+O(n)
-
a=2,b=2,f(n)=O(n)
-
计算 nlog_ba=nlog_22=n1=n。
-
比较 f(n)=O(n) 与 nlog_ba=n。它们是 Theta 关系,符合主方法情况2。
-
所以,T(n)=Theta(nlogn)。
-
-
二分查找: T(n)=T(n/2)+O(1)
-
a=1,b=2,f(n)=O(1)
-
计算 nlog_ba=nlog_21=n0=1。
-
比较 f(n)=O(1) 与 nlog_ba=1。它们是 Theta 关系,符合主方法情况2。
-
所以,T(n)=Theta(1cdotlogn)=Theta(logn)。
-
-
做题编程随想录: 主方法是分析分治算法复杂度的“大杀器”,务必熟练掌握其三个情况。在面试中,直接用主方法给出归并排序、快速排序、二分查找等算法的复杂度,会显得你非常专业。
小结: 递归和分治是解决复杂问题的两大艺术。递归通过自身调用实现问题分解,分治则通过“分解-解决-合并”三步走策略。而递归关系式是分析分治算法性能的数学模型,主方法则是快速求解这类关系式的利器。掌握这些,你就掌握了算法设计的一把“金钥匙”。
1.5 排序算法:“整理大师”的十八般武艺
兄弟们,排序是计算机科学中最基本、最常用的操作之一。你每天用的手机、电脑,背后都在进行着大量的排序。理解各种排序算法的原理、优缺点和适用场景,是每个程序员的必修课,也是大厂面试的“送分题”(如果你真懂的话)!
本节我们将深入剖析几种基础且重要的排序算法:插入排序、归并排序、堆排序和快速排序。
1.5.1 插入排序(Insertion Sort):“摸牌”式整理
-
思想: 就像我们玩扑克牌时,一张一张地摸牌,每摸一张新牌,就把它插入到手中已排好序的牌的正确位置。
-
过程:
-
假设数组的第一个元素已经排好序。
-
从第二个元素开始,依次取出每个元素。
-
将取出的元素与前面已排好序的元素从后往前进行比较。
-
如果比它大,就往后移动一位,直到找到一个比它小或等于它的元素,或者到达数组开头。
-
将取出的元素插入到这个位置。
-
-
特性:
-
稳定性: 稳定(相等元素的相对顺序不变)。
-
原地排序: 只需要常数额外空间 O(1)。
-
时间复杂度:
-
最好情况: O(n) (数组已经有序)。
-
最坏情况: O(n2) (数组逆序)。
-
平均情况: O(n2)。
-
-
适用场景: 小规模数据,或基本有序的数据。
-
概念性C代码:插入排序
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <time.h> // For time
#include <stdbool.h> // For bool type
// 辅助函数:打印数组
void printArray(int arr[], int size) {
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
/**
* @brief 插入排序算法实现
* @param arr 待排序的整数数组
* @param n 数组的大小
* @note 插入排序是一种简单直观的排序算法。
* 它的工作原理是通过构建有序序列,对于未排序数据,
* 在已排序序列中从后向前扫描,找到相应位置并插入。
* 时间复杂度:最好O(n),平均O(n^2),最坏O(n^2)。
* 空间复杂度:O(1) (原地排序)。
* 稳定性:稳定。
* 适用于小规模数据或基本有序的数据。
*/
void insertionSort(int arr[], int n) {
// 外层循环从第二个元素开始,因为第一个元素默认已排序
for (int i = 1; i < n; i++) {
// current_element 存储当前要插入的元素
int current_element = arr[i];
// j 用于从已排序部分的末尾向前扫描
int j = i - 1;
// 内层循环:将 current_element 插入到已排序部分的正确位置
// 条件1: j >= 0 确保不越界
// 条件2: arr[j] > current_element 如果当前元素比已排序部分的元素小,则需要向后移动
while (j >= 0 && arr[j] > current_element) {
arr[j + 1] = arr[j]; // 将比 current_element 大的元素向后移动一位
j--; // 继续向前扫描
}
// 循环结束后,j+1 就是 current_element 应该插入的位置
arr[j + 1] = current_element;
// printf("Pass %d: ", i); // 每次外层循环结束后打印数组状态,方便理解
// printArray(arr, n);
}
}
// 主函数用于测试插入排序
int main_insertion_sort() {
printf("--- 插入排序示例 ---\n");
int arr1[] = {12, 11, 13, 5, 6};
int n1 = sizeof(arr1) / sizeof(arr1[0]);
printf("原始数组: ");
printArray(arr1, n1);
insertionSort(arr1, n1);
printf("排序后数组: ");
printArray(arr1, n1);
int arr2[] = {1, 2, 3, 4, 5}; // 最好情况
int n2 = sizeof(arr2) / sizeof(arr2[0]);
printf("\n原始数组 (最好情况): ");
printArray(arr2, n2);
insertionSort(arr2, n2);
printf("排序后数组 (最好情况): ");
printArray(arr2, n2);
int arr3[] = {5, 4, 3, 2, 1}; // 最坏情况
int n3 = sizeof(arr3) / sizeof(arr3[0]);
printf("\n原始数组 (最坏情况): ");
printArray(arr3, n3);
insertionSort(arr3, n3);
printf("排序后数组 (最坏情况): ");
printArray(arr3, n3);
printf("--- 插入排序示例结束 ---\n");
return 0;
}
代码分析与说明:
-
insertionSort
函数是插入排序的核心实现。 -
外层循环
for (int i = 1; i < n; i++)
: 负责遍历待排序的元素。i
从1开始,表示从第二个元素开始处理,因为我们认为第一个元素arr[0]
已经是一个长度为1的有序序列。 -
current_element = arr[i]
: 暂存当前要插入的元素。 -
j = i - 1
:j
指向已排序部分的最后一个元素。 -
内层循环
while (j >= 0 && arr[j] > current_element)
: 这是查找插入位置并进行元素后移的关键。-
j >= 0
:确保j
不会越界,即不会访问到数组的前面。 -
arr[j] > current_element
:如果已排序部分的当前元素arr[j]
比current_element
大,说明current_element
应该插入到arr[j]
的前面。 -
arr[j + 1] = arr[j]
:将arr[j]
向后移动一位,为current_element
腾出空间。 -
j--
:继续向前比较,直到找到current_element
的正确插入位置。
-
-
arr[j + 1] = current_element
: 当内层循环结束时,j+1
就是current_element
应该插入的位置。 -
时间复杂度分析:
-
外层循环执行 n−1 次。
-
内层
while
循环在最坏情况下(逆序数组)会执行 i 次比较和移动。 -
所以总操作次数大约是 1+2+dots+(n−1)=frac(n−1)n2,因此是 O(n2)。
-
最好情况(有序数组),内层
while
循环几乎不执行(arr[j] > current_element
不满足),总操作次数大约是 n−1 次,所以是 O(n)。
-
-
空间复杂度分析: 只使用了几个常数个额外变量 (
current_element
,j
),所以空间复杂度是 O(1)。 -
稳定性: 当
arr[j] > current_element
时才移动,如果arr[j] == current_element
,则current_element
不会移动到arr[j]
的前面,保持了相等元素的相对顺序,所以是稳定的。
做题编程随想录: 插入排序虽然简单,但它是理解其他更复杂排序算法(如希尔排序)的基础。在嵌入式中,如果数据量很小,或者数据大部分有序,插入排序由于其常数空间开销和简单实现,反而可能是个不错的选择。
1.5.2 归并排序(Merge Sort):“分而治之”的典范
-
思想: 归并排序是分治策略的典型应用。它将一个大问题(排序整个数组)分解成小问题(排序子数组),然后递归地解决这些小问题,最后将排好序的子数组合并成一个完整的有序数组。
-
过程:
-
分解(Divide): 将待排序的 n 个元素分解成两个各含 n/2 个元素的子序列。
-
解决(Conquer): 递归地对这两个子序列进行排序。
-
合并(Combine): 将两个已排序的子序列合并成一个已排序的序列。
-
-
特性:
-
稳定性: 稳定。
-
非原地排序: 需要 O(n) 的额外空间用于合并操作。
-
时间复杂度:
-
最好、最坏、平均情况: 都是 O(nlogn)。
-
-
适用场景: 对稳定性有要求,或数据量较大,或外部排序(数据不能一次性载入内存)。
-
概念性C代码:归并排序
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 辅助函数:将两个有序子数组合并成一个有序数组
// arr: 原始数组
// left: 第一个子数组的起始索引
// mid: 第一个子数组的结束索引,也是第二个子数组的起始索引-1
// right: 第二个子数组的结束索引
void merge(int arr[], int left, int mid, int right) {
// 计算左右子数组的长度
int n1 = mid - left + 1;
int n2 = right - mid;
// 创建临时数组来存储左右子数组的元素
// 注意:这里动态分配内存,确保在函数结束时释放
int *L = (int *)malloc(n1 * sizeof(int));
int *R = (int *)malloc(n2 * sizeof(int));
// 检查内存分配是否成功
if (L == NULL || R == NULL) {
perror("内存分配失败");
if (L) free(L); // 释放已分配的内存
if (R) free(R);
exit(EXIT_FAILURE); // 退出程序
}
// 将数据复制到临时数组L和R
for (int i = 0; i < n1; i++) {
L[i] = arr[left + i];
}
for (int j = 0; j < n2; j++) {
R[j] = arr[mid + 1 + j];
}
// 合并临时数组L和R到原始数组arr
int i = 0; // L的当前索引
int j = 0; // R的当前索引
int k = left; // arr的当前索引 (从原始子数组的起始位置开始填充)
// 当L和R中都有元素时,比较并选择较小的放入arr
while (i < n1 && j < n2) {
if (L[i] <= R[j]) { // 注意这里使用 <= 保证稳定性
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 将L中剩余的元素复制到arr (如果L还有剩余)
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 将R中剩余的元素复制到arr (如果R还有剩余)
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
// 释放临时数组的内存
free(L);
free(R);
}
/**
* @brief 归并排序算法实现
* @param arr 待排序的整数数组
* @param left 数组的起始索引
* @param right 数组的结束索引
* @note 归并排序是分治策略的典型应用。
* 它递归地将数组分成两半,直到每个子数组只有一个元素(有序),
* 然后将这些子数组两两合并,直到整个数组有序。
* 时间复杂度:O(n log n) (最好、最坏、平均)。
* 空间复杂度:O(n) (需要额外空间进行合并)。
* 稳定性:稳定。
* 适用于大规模数据排序,或需要稳定性的场景。
*/
void mergeSort(int arr[], int left, int right) {
// 基本情况:如果子数组只有一个元素或为空,则认为它已经有序
if (left < right) {
// 分解:找到中间点,将数组分成两半
int mid = left + (right - left) / 2; // 避免 left + right 溢出
// 解决:递归地对左右两半进行排序
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
// 合并:将两个已排序的子数组合并
merge(arr, left, mid, right);
}
}
// 主函数用于测试归并排序
int main_merge_sort() {
printf("--- 归并排序示例 ---\n");
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: ");
printArray(arr, n);
mergeSort(arr, 0, n - 1);
printf("排序后数组: ");
printArray(arr, n);
int arr2[] = {38, 27, 43, 3, 9, 82, 10};
int n2 = sizeof(arr2) / sizeof(arr2[0]);
printf("\n原始数组2: ");
printArray(arr2, n2);
mergeSort(arr2, 0, n2 - 1);
printf("排序后数组2: ");
printArray(arr2, n2);
printf("--- 归并排序示例结束 ---\n");
return 0;
}
代码分析与说明:
-
mergeSort(int arr[], int left, int right)
函数:-
基本情况
if (left < right)
: 当子数组的起始索引left
小于结束索引right
时,表示子数组至少有两个元素,需要继续分解。否则,子数组只有一个元素(或为空),已经有序,直接返回。 -
分解
int mid = left + (right - left) / 2;
: 计算中间索引,将数组分成两半。left + (right - left) / 2
这种写法比(left + right) / 2
更安全,可以避免left + right
溢出(尽管在大多数情况下不会)。 -
解决
mergeSort(arr, left, mid);
和mergeSort(arr, mid + 1, right);
: 递归地对左右两个子数组进行排序。 -
合并
merge(arr, left, mid, right);
: 当左右子数组都已排序完毕后,调用merge
函数将它们合并。
-
-
merge(int arr[], int left, int mid, int right)
函数:-
这是归并排序的核心,负责将两个已排序的子数组
arr[left...mid]
和arr[mid+1...right]
合并成一个有序数组。 -
创建临时数组
L
和R
: 这是归并排序需要额外 O(n) 空间的主要原因。它们用于存储左右子数组的元素。 -
复制数据: 将原始数组中的元素复制到临时数组
L
和R
中。 -
合并逻辑: 使用三个指针
i
,j
,k
。-
i
遍历L
数组,j
遍历R
数组。 -
k
遍历原始数组arr
,用于存放合并后的元素。 -
在
while (i < n1 && j < n2)
循环中,比较L[i]
和R[j]
,将较小的元素放入arr[k]
,并移动相应的指针。 -
稳定性保证:
if (L[i] <= R[j])
使用<=
而不是<
。这意味着如果L[i]
和R[j]
相等,L[i]
(来自左子数组,原始顺序靠前)会先被放入结果数组。这保证了相等元素的相对顺序不变,从而使归并排序是稳定的。
-
-
处理剩余元素: 当一个临时数组的元素全部被合并后,另一个临时数组可能还有剩余元素,直接将其全部复制到
arr
的末尾即可(因为它们本身就是有序的)。 -
释放内存:
free(L)
和free(R)
是非常重要的,防止内存泄漏。
-
-
时间复杂度分析:
-
分解阶段:
O(1)
。 -
解决阶段:两个子问题,每个规模为 n/2,所以是 2T(n/2)。
-
合并阶段:
merge
函数需要遍历所有 n 个元素,所以是 O(n)。 -
递归关系式: T(n)=2T(n/2)+O(n)。
-
根据主方法情况2,其解为 T(n)=Theta(nlogn)。
-
无论最好、最坏、平均情况,归并排序的比较次数和移动次数都大致相同,因此时间复杂度始终是 O(nlogn)。
-
-
空间复杂度分析:
-
merge
函数需要额外的L
和R
数组,总大小为 n1+n2approxn,所以是 O(n) 的额外空间。 -
递归调用栈的空间复杂度是 O(logn)(树的高度)。
-
因此,总空间复杂度是 O(n)。
-
做题编程随想录: 归并排序是理解分治策略的绝佳例子,其稳定性和 O(nlogn) 的时间复杂度使其成为非常优秀的排序算法。在面试中,归并排序常用于考察分治思想、递归实现和合并逻辑。由于其非原地排序的特性,在内存受限的嵌入式系统中可能需要谨慎使用,或者采用链表归并排序(虽然复杂但可以减少内存碎片)。
1.5.3 堆排序(Heap Sort):“大根堆”的魔法
-
思想: 堆排序利用了堆(Heap)这种数据结构的特性。堆是一个近似完全二叉树的结构,并满足堆的性质:父节点的值总是大于或等于(最大堆)或小于或等于(最小堆)其子节点的值。堆排序就是通过构建最大堆,然后反复将堆顶元素(最大值)与堆的最后一个元素交换,再调整堆,从而实现排序。
-
过程:
-
构建最大堆(Build Max Heap): 将待排序的数组构建成一个最大堆。这个过程通常从最后一个非叶子节点开始,向上进行“堆化”(heapify)操作。
-
排序(Sort):
-
将堆顶元素(最大值)与堆的最后一个元素交换。
-
将堆的大小减1(相当于将最大值从堆中移除,放入已排序区域)。
-
对新的堆顶元素进行“堆化”操作,使其满足最大堆的性质。
-
重复以上步骤,直到堆中只剩一个元素。
-
-
-
特性:
-
稳定性: 不稳定(因为交换操作可能改变相等元素的相对顺序)。
-
原地排序: 只需要常数额外空间 O(1)。
-
时间复杂度:
-
最好、最坏、平均情况: 都是 O(nlogn)。
-
-
适用场景: 对稳定性无要求,需要原地排序,且对性能有较高要求。
-
概念性C代码:堆排序
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 辅助函数:交换两个整数的值
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
/**
* @brief 堆化操作 (Max-Heapify)
* 确保以i为根的子树满足最大堆的性质。
* @param arr 数组
* @param n 堆的大小 (有效元素的数量)
* @param i 要堆化的子树的根节点索引
* @note 这个函数是堆排序的核心。它假设左右子树已经是最大堆,
* 然后通过将根节点与其最大的子节点交换(如果根节点小于子节点),
* 并递归地对受影响的子树进行堆化,来维护最大堆的性质。
*/
void heapify(int arr[], int n, int i) {
int largest = i; // 假设当前根节点是最大的
int left_child = 2 * i + 1; // 左子节点索引
int right_child = 2 * i + 2; // 右子节点索引
// 如果左子节点存在且大于当前最大值
if (left_child < n && arr[left_child] > arr[largest]) {
largest = left_child;
}
// 如果右子节点存在且大于当前最大值
if (right_child < n && arr[right_child] > arr[largest]) {
largest = right_child;
}
// 如果最大值不是根节点,则交换并递归堆化受影响的子树
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest); // 递归调用,确保交换后的子树也满足堆性质
}
}
/**
* @brief 堆排序算法实现
* @param arr 待排序的整数数组
* @param n 数组的大小
* @note 堆排序是一种基于比较的排序算法,利用了堆这种数据结构。
* 它分为两个主要阶段:
* 1. 构建最大堆:将无序数组构建成一个最大堆。
* 2. 排序:反复将堆顶元素(最大值)与堆的最后一个元素交换,
* 然后缩小堆的范围,并对新的堆顶元素进行堆化。
* 时间复杂度:O(n log n) (最好、最坏、平均)。
* 空间复杂度:O(1) (原地排序)。
* 稳定性:不稳定。
* 适用于对稳定性无要求,且需要原地排序的场景。
*/
void heapSort(int arr[], int n) {
// 阶段1: 构建最大堆 (Build Max Heap)
// 从最后一个非叶子节点开始向上堆化。
// 最后一个非叶子节点的索引是 (n/2) - 1
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// printf("构建最大堆后: "); // 打印构建堆后的数组状态
// printArray(arr, n);
// 阶段2: 排序 (Extract elements one by one)
// 每次循环,将当前堆的最大元素(根节点)放到数组的末尾
// 然后缩小堆的范围,并重新堆化根节点
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶元素 (最大值) 与堆的最后一个元素交换
swap(&arr[0], &arr[i]);
// 对剩余的堆 (大小为 i) 进行堆化,确保根节点仍然是最大值
// 注意:此时堆的有效大小为 i,因为 arr[i] 已经是有序部分
heapify(arr, i, 0);
// printf("第%d次交换并堆化后: ", n - i); // 打印每次迭代后的数组状态
// printArray(arr, n);
}
}
// 主函数用于测试堆排序
int main_heap_sort() {
printf("--- 堆排序示例 ---\n");
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: ");
printArray(arr, n);
heapSort(arr, n);
printf("排序后数组: ");
printArray(arr, n);
int arr2[] = {38, 27, 43, 3, 9, 82, 10};
int n2 = sizeof(arr2) / sizeof(arr2[0]);
printf("\n原始数组2: ");
printArray(arr2, n2);
heapSort(arr2, n2);
printf("排序后数组2: ");
printArray(arr2, n2);
printf("--- 堆排序示例结束 ---\n");
return 0;
}
代码分析与说明:
-
heapify(int arr[], int n, int i)
函数:-
这是维护堆性质的核心函数。它假设以
i
为根的左右子树已经是最大堆,然后通过比较arr[i]
和其子节点,将最大值“下沉”到正确的位置。 -
largest = i;
:初始化largest
为当前根节点。 -
left_child = 2 * i + 1;
和right_child = 2 * i + 2;
:计算左右子节点的索引(基于0索引的完全二叉树)。 -
两个
if
语句:检查左右子节点是否存在(left_child < n
)并且是否比当前的largest
大。如果更大,则更新largest
为子节点的索引。 -
if (largest != i)
:如果largest
发生了变化,说明根节点不是最大的,需要进行交换。 -
swap(&arr[i], &arr[largest]);
:交换根节点和最大子节点。 -
heapify(arr, n, largest);
:递归调用。因为交换后,原来的最大子节点(现在是根节点)可能破坏了其子树的堆性质,所以需要对受影响的子树继续进行堆化。
-
-
heapSort(int arr[], int n)
函数:-
阶段1: 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) { heapify(arr, n, i); }
:-
从最后一个非叶子节点开始(索引为
n/2 - 1
),向上遍历到根节点(索引0)。 -
对每个节点调用
heapify
。这个过程确保了整个数组被组织成一个最大堆。 -
时间复杂度: 构建堆的过程是 O(n)。虽然
heapify
是 O(logn),但由于大部分节点都在树的底部,所以总和是线性的。
-
-
阶段2: 排序
for (int i = n - 1; i > 0; i--) { ... }
:-
循环从数组的最后一个元素开始,每次循环将当前堆的最大值放到正确的位置。
-
swap(&arr[0], &arr[i]);
:将堆顶元素(当前最大值)与当前堆的最后一个元素交换。这样,最大值就被放到了数组的末尾(已排序区域)。 -
heapify(arr, i, 0);
:将堆的大小减1(n
变为i
),然后对新的堆顶元素进行堆化,使其满足最大堆性质。这个过程需要 O(logi) 的时间。 -
这个循环执行 n−1 次,每次操作都是 O(logn)。
-
时间复杂度: 排序阶段是 O(nlogn)。
-
-
-
总时间复杂度: 构建堆 O(n) + 排序 O(nlogn) = O(nlogn)。
-
空间复杂度: 只使用了常数个额外变量 (
largest
,left_child
,right_child
,temp
),所以是 O(1) 的原地排序。 -
稳定性: 堆排序是不稳定的。例如,在
swap(&arr[0], &arr[i])
操作中,相等元素的相对顺序可能会被改变。
做题编程随想录: 堆排序是少数几个能达到 O(nlogn) 时间复杂度且是原地排序的算法之一,这在内存受限的场景下非常有用。理解堆的性质和 heapify
操作是关键。在面试中,常考堆排序的实现细节、时间/空间复杂度以及稳定性。堆的应用远不止排序,它还是实现优先队列、Top K 问题等的重要数据结构。
1.5.4 快速排序(Quick Sort):“挖坑填数”的艺术
-
思想: 快速排序也是分治策略的典型应用。它通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据小,然后对这两部分数据再递归地进行快速排序。
-
过程:
-
分解(Divide): 从数组中选择一个元素作为“基准”(Pivot)。通过一趟排序(分区操作),将数组分为三部分:小于基准的元素、基准本身、大于基准的元素。
-
解决(Conquer): 递归地对“小于基准的元素”部分和“大于基准的元素”部分进行快速排序。
-
合并(Combine): 无需合并。当两个子问题都排序完成后,整个数组也就有序了。
-
-
特性:
-
稳定性: 不稳定(因为交换操作可能改变相等元素的相对顺序)。
-
原地排序: 只需要 O(logn) 的额外空间用于递归栈(最坏情况 O(n))。
-
时间复杂度:
-
最好、平均情况: O(nlogn)。
-
最坏情况: O(n2) (每次选择的基准都是最大或最小值,导致分区不平衡)。
-
-
适用场景: 大多数情况下性能优秀,是实践中最常用的排序算法之一。
-
概念性C代码:快速排序
#include <stdio.h>
#include <stdlib.h> // For srand, rand
#include <time.h> // For time
// 辅助函数:交换两个整数的值
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
/**
* @brief 分区操作 (Partition)
* 选择一个基准元素,将数组分为两部分:小于基准的元素和大于基准的元素。
* @param arr 数组
* @param low 待分区部分的起始索引
* @param high 待分区部分的结束索引
* @return 基准元素最终的索引
* @note 这是快速排序的核心。它将数组arr[low...high]进行重新排序,
* 使得arr[low...pivot_index-1]都小于等于arr[pivot_index],
* 而arr[pivot_index+1...high]都大于arr[pivot_index]。
* 这里使用Hoare分区方案或Lomuto分区方案,以下是Lomuto分区方案的实现。
* 为了避免最坏情况,通常会随机选择基准或使用三数取中法。
*/
int partition(int arr[], int low, int high) {
// 选择最右边的元素作为基准 (Lomuto 分区方案)
int pivot = arr[high];
// i 指向小于基准的元素的边界
int i = (low - 1);
// 遍历当前分区内的所有元素
for (int j = low; j <= high - 1; j++) {
// 如果当前元素小于或等于基准
if (arr[j] <= pivot) {
i++; // 移动i,扩展小于基准的区域
swap(&arr[i], &arr[j]); // 将当前元素放到小于基准的区域
}
}
// 将基准元素放到其最终的正确位置 (i+1)
swap(&arr[i + 1], &arr[high]);
return (i + 1); // 返回基准元素的最终索引
}
/**
* @brief 快速排序算法实现
* @param arr 待排序的整数数组
* @param low 数组的起始索引
* @param high 数组的结束索引
* @note 快速排序是分治策略的又一典范。
* 它通过分区操作将数组分成两部分,然后递归地对这两部分进行排序。
* 时间复杂度:平均O(n log n),最坏O(n^2)。
* 空间复杂度:O(log n) (平均,递归栈),O(n) (最坏,递归栈)。
* 稳定性:不稳定。
* 实践中性能通常最优,但对基准选择敏感。
*/
void quickSort(int arr[], int low, int high) {
// 基本情况:如果子数组只有一个元素或为空,则认为它已经有序
if (low < high) {
// 分解:执行分区操作,获取基准元素的最终位置
int pivot_index = partition(arr, low, high);
// 解决:递归地对基准左右两边的子数组进行排序
quickSort(arr, low, pivot_index - 1); // 排序左边部分
quickSort(arr, pivot_index + 1, high); // 排序右边部分
}
}
// 改进的分区函数:随机选择基准,避免最坏情况
int randomizedPartition(int arr[], int low, int high) {
// 生成一个随机索引作为基准
srand(time(NULL)); // 初始化随机数种子,只在程序开始时调用一次
int random_pivot_index = low + rand() % (high - low + 1);
// 将随机选择的基准与最后一个元素交换,然后使用Lomuto分区
swap(&arr[random_pivot_index], &arr[high]);
return partition(arr, low, high);
}
// 改进的快速排序函数:使用随机基准
void randomizedQuickSort(int arr[], int low, int high) {
if (low < high) {
int pivot_index = randomizedPartition(arr, low, high);
randomizedQuickSort(arr, low, pivot_index - 1);
randomizedQuickSort(arr, pivot_index + 1, high);
}
}
// 主函数用于测试快速排序
int main_quick_sort() {
printf("--- 快速排序示例 ---\n");
int arr[] = {10, 7, 8, 9, 1, 5};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: ");
printArray(arr, n);
quickSort(arr, 0, n - 1);
printf("排序后数组 (标准): ");
printArray(arr, n);
int arr2[] = {5, 4, 3, 2, 1}; // 最坏情况 (如果每次都选最右边作为基准)
int n2 = sizeof(arr2) / sizeof(arr2[0]);
printf("\n原始数组2 (最坏情况): ");
printArray(arr2, n2);
quickSort(arr2, 0, n2 - 1);
printf("排序后数组2 (标准): ");
printArray(arr2, n2);
int arr3[] = {10, 7, 8, 9, 1, 5};
int n3 = sizeof(arr3) / sizeof(arr3[0]);
printf("\n原始数组3 (随机基准): ");
printArray(arr3, n3);
randomizedQuickSort(arr3, 0, n3 - 1);
printf("排序后数组3 (随机): ");
printArray(arr3, n3);
printf("--- 快速排序示例结束 ---\n");
return 0;
}
代码分析与说明:
-
partition(int arr[], int low, int high)
函数:-
这是快速排序的核心,实现了Lomuto分区方案。
-
pivot = arr[high];
:选择最右边的元素作为基准。 -
i = (low - 1);
:i
指针用于标记小于或等于基准的元素的边界。初始时,这个区域是空的。 -
for (int j = low; j <= high - 1; j++)
:遍历从low
到high-1
的所有元素。 -
if (arr[j] <= pivot)
:如果当前元素arr[j]
小于或等于基准,则将其放到“小于等于基准”的区域。-
i++;
:i
向右移动一位,扩展“小于等于基准”的区域。 -
swap(&arr[i], &arr[j]);
:将arr[j]
交换到i
的位置。
-
-
循环结束后,
i
指向“小于等于基准”区域的最后一个元素。 -
swap(&arr[i + 1], &arr[high]);
:将基准元素(原来在arr[high]
)放到i+1
的位置,这样它就位于所有小于等于它的元素和所有大于它的元素之间。 -
return (i + 1);
:返回基准元素最终的索引。
-
-
quickSort(int arr[], int low, int high)
函数:-
基本情况
if (low < high)
: 如果子数组的起始索引low
小于结束索引high
,表示子数组至少有两个元素,需要继续排序。 -
分解
int pivot_index = partition(arr, low, high);
: 调用partition
函数进行分区,得到基准元素的最终位置。 -
解决
quickSort(arr, low, pivot_index - 1);
和quickSort(arr, pivot_index + 1, high);
: 递归地对基准左右两边的子数组进行排序。注意,基准元素本身已经就位,不需要再参与递归排序。
-
-
randomizedPartition
和randomizedQuickSort
:-
标准快速排序在处理有序或逆序数组时会退化到 O(n2) 的最坏情况,因为每次都选择最右边(或最左边)作为基准,导致分区极度不平衡。
-
随机化快速排序通过随机选择基准元素,可以大大降低遇到最坏情况的概率,使其平均时间复杂度保持在 O(nlogn)。
srand(time(NULL))
用于初始化随机数种子,确保每次运行的随机性。
-
-
时间复杂度分析:
-
分区操作:
partition
函数需要遍历一次数组,所以是 O(n)。 -
递归关系式:
-
最好/平均情况: 每次分区都非常平衡(接近 n/2),递归关系式类似于 T(n)=2T(n/2)+O(n),根据主方法,解为 O(nlogn)。
-
最坏情况: 每次分区都极度不平衡(一个子数组为 n−1,另一个为 0),递归关系式为 T(n)=T(n−1)+O(n)。展开后是 O(n2)。
-
-
随机化快速排序: 虽然最坏情况理论上仍然存在,但其出现的概率极低,平均时间复杂度是 O(nlogn)。
-
-
空间复杂度分析:
-
快速排序是原地排序,不需要额外数组。
-
但递归调用会使用栈空间。
-
平均情况: 递归深度为 O(logn),所以空间复杂度是 O(logn)。
-
最坏情况: 递归深度为 O(n)(例如,每次分区都只分出一个元素),所以空间复杂度是 O(n)。
-
-
稳定性: 快速排序是不稳定的。在分区过程中,当
arr[j] <= pivot
时,如果arr[j]
和pivot
相等,并且arr[j]
在pivot
的后面,它可能会被交换到pivot
的前面,从而改变了相等元素的相对顺序。
做题编程随想录: 快速排序因其平均情况下的优秀性能和原地排序的特性,在实践中被广泛使用。面试中,快速排序是常客,除了实现,还会问到基准选择策略(Lomuto、Hoare、随机化、三数取中)、最坏情况和如何避免。理解其不稳定性也很重要。
表格:基础排序算法对比
算法名称 |
时间复杂度(平均) |
时间复杂度(最坏) |
空间复杂度 |
稳定性 |
适用场景/特点 |
---|---|---|---|---|---|
插入排序 |
O(n2) |
O(n2) |
O(1) |
稳定 |
小规模数据,基本有序数据 |
归并排序 |
O(nlogn) |
O(nlogn) |
O(n) |
稳定 |
大规模数据,需要稳定性,外部排序 |
堆排序 |
O(nlogn) |
O(nlogn) |
O(1) |
不稳定 |
大规模数据,需要原地排序,Top K问题 |
快速排序 |
O(nlogn) |
O(n2) |
O(logn) (平均) |
不稳定 |
实践中最常用,性能优秀,对基准选择敏感 |
小结: 排序算法是算法学习的入门砖,也是衡量算法设计能力的重要指标。插入排序简单直观,适合小规模数据;归并排序稳定且性能优秀,但需要额外空间;堆排序原地且性能稳定,但不稳定;快速排序平均性能最佳,但最坏情况可能退化。理解这些算法的“十八般武艺”,能让你在面对各种排序需求时游刃有余。
第一部分总结与展望:算法的“内功心法”初窥
兄弟们,恭喜你完成了《算法导论》“大黑书”修炼的第一部分:基础篇 - 算法的基石与分析方法!
在这段旅程中,我们一起:
-
揭示了算法的本质: 理解了算法的定义、五大特性,以及它与程序的区别,明确了算法在编程中的核心地位。
-
掌握了算法的“照妖镜”: 深入学习了时间复杂度和空间复杂度分析,特别是大O表示法的计算规则和常见复杂度等级,让你能够科学地评估算法的效率。我们还了解了渐近记号家族的其他成员,让你对算法性能的描述更加精确。
-
巩固了算法的“数学基石”: 回顾了求和公式、对数、指数和阶乘等数学工具,并理解了它们在算法分析中的应用,以及不同函数增长率的相对速度,这为你后续理解更复杂的算法打下了坚实基础。
-
领略了“化整为零”的艺术: 深入剖析了递归和分治策略,理解了它们如何将大问题分解为小问题,并通过递归关系式和主方法,让你能够精确分析这类算法的性能。
-
学习了“整理大师”的十八般武艺: 详细探讨了插入排序、归并排序、堆排序和快速排序这四种基础且重要的排序算法,包括它们的思想、过程、特性、C语言实现、详细的代码分析、时间/空间复杂度以及稳定性。
现在,你已经对算法的“内功心法”有了初步的认识,掌握了分析算法效率、理解递归和分治思想,以及实现基础排序算法的能力。这些都是你未来深入学习更复杂算法的基石!
这仅仅是《算法导论》“大黑书”终极修炼的第一步!在接下来的第二部分,我们将深入探索算法的“骨架”与“血肉”——数据结构篇!我们将一起揭开各种数据结构的神秘面纱,理解它们如何组织数据,并为算法提供高效的支持。
准备好了吗?让我们继续深入,成为真正的算法高手!
如果你觉得这份“秘籍”对你有亿点点帮助,请点赞、收藏、转发!
---------------------------------------------------------------------------------------------------------------------更新于2025.6.25 下午5:19
呕心沥血的全网史上最强大黑书:计算机界神书《算法导论》的概览、梳理、分析、思考总结、归纳、拓展、提炼
第二部分:数据结构篇 - 算法的“骨架”与“血肉”
2.1 数组与链表:数据存储的“基本单位”
兄弟们,要存储一堆数据,最直观、最基础的方式是什么?没错,就是数组和链表!它们是所有复杂数据结构的基石,就像盖房子用的砖头和钢筋。别看它们简单,里面的学问可不小,而且在嵌入式领域,对内存的精细控制,更离不开对它们的深入理解。
2.1.1 数组:连续的“格子铺”
-
定义: 数组(Array)是一种线性的、连续的内存空间,用于存储相同类型的数据元素的集合。每个元素通过其索引(下标)进行访问。
-
你可以把它想象成一排排整齐的“格子铺”,每个格子大小一样,都有一个唯一的编号(索引),你可以通过编号直接找到对应的格子。
-
-
特点:
-
连续存储: 数组中的所有元素在内存中是紧挨着存放的。
-
同类型元素: 数组只能存储相同数据类型(如
int
、char
、struct
)的元素。 -
固定大小(通常): 一旦创建,数组的大小通常是固定的(静态数组)。动态数组可以在运行时改变大小,但其底层原理仍涉及重新分配连续内存。
-
-
优缺点:
-
优点:
-
随机访问(Random Access): 通过索引,可以在 O(1) 时间内直接访问任何元素。这是数组最大的优势!
-
缓存友好: 连续的内存布局使得CPU缓存能够更高效地预取数据,提高访问速度。
-
-
缺点:
-
插入/删除效率低: 在数组中间插入或删除元素时,需要移动后续所有元素,时间复杂度为 O(n)。
-
空间利用率: 如果数组大小固定且预留空间过多,可能造成内存浪费。动态数组虽然灵活,但扩容时可能需要重新分配和复制整个数组,开销较大。
-
-
-
C语言实现:静态数组与动态数组
在C语言中,数组是基本的数据结构。
-
静态数组: 在编译时确定大小,内存分配在栈区或全局/静态区。
int static_arr[10]; // 声明一个包含10个整数的静态数组
-
动态数组: 在运行时通过内存分配函数(如
malloc
)在堆区分配内存,可以灵活调整大小。// 声明一个指针,用于指向动态分配的内存 int *dynamic_arr; int size = 5; // 分配5个整数大小的内存 dynamic_arr = (int *)malloc(size * sizeof(int)); if (dynamic_arr == NULL) { perror("动态数组内存分配失败"); exit(EXIT_FAILURE); } // ... 使用 dynamic_arr ... free(dynamic_arr); // 使用完毕后释放内存
-
概念性C代码:数组的基本操作与动态数组扩容
#include <stdio.h>
#include <stdlib.h> // For malloc, realloc, free
// 辅助函数:打印数组
void print_array(const int arr[], int size) {
printf("Array elements: [");
for (int i = 0; i < size; i++) {
printf("%d", arr[i]);
if (i < size - 1) {
printf(", ");
}
}
printf("]\n");
}
/**
* @brief 在数组指定位置插入元素
* @param arr 数组指针的指针,因为可能需要重新分配内存
* @param size_ptr 数组当前大小的指针
* @param element 要插入的元素
* @param index 插入位置的索引
* @return true 插入成功,false 插入失败 (如内存不足或索引越界)
* @note 插入操作需要移动后续元素,时间复杂度为 O(n)。
* 如果数组空间不足,会尝试扩容。
*/
bool insert_into_array(int **arr, int *size_ptr, int element, int index) {
if (index < 0 || index > *size_ptr) {
printf("错误: 插入索引 %d 超出有效范围 [0, %d]\n", index, *size_ptr);
return false;
}
// 检查是否需要扩容。这里简单地在插入时扩容,实际应用中会预留更多空间
// 假设当前数组已满,需要扩容
int new_size = *size_ptr + 1;
int *temp_arr = (int *)realloc(*arr, new_size * sizeof(int));
if (temp_arr == NULL) {
perror("数组扩容失败");
return false;
}
*arr = temp_arr;
*size_ptr = new_size;
// 移动元素为新元素腾出空间
for (int i = *size_ptr - 1; i > index; i--) {
(*arr)[i] = (*arr)[i - 1];
}
// 插入新元素
(*arr)[index] = element;
printf("在索引 %d 处插入 %d。新大小: %d\n", index, element, *size_ptr);
return true;
}
/**
* @brief 从数组指定位置删除元素
* @param arr 数组指针的指针,因为可能需要重新分配内存
* @param size_ptr 数组当前大小的指针
* @param index 删除位置的索引
* @return true 删除成功,false 删除失败 (如索引越界)
* @note 删除操作需要移动后续元素,时间复杂度为 O(n)。
* 实际应用中,删除后可能不会立即缩容,以避免频繁的内存操作。
*/
bool delete_from_array(int **arr, int *size_ptr, int index) {
if (index < 0 || index >= *size_ptr) {
printf("错误: 删除索引 %d 超出有效范围 [0, %d)\n", index, *size_ptr);
return false;
}
printf("删除索引 %d 处的元素 (%d)。\n", index, (*arr)[index]);
// 移动元素覆盖被删除的元素
for (int i = index; i < *size_ptr - 1; i++) {
(*arr)[i] = (*arr)[i + 1];
}
// 缩小数组大小 (实际应用中可能不会立即缩容)
(*size_ptr)--;
// 尝试缩容,但如果频繁删除,可能导致性能问题
// int *temp_arr = (int *)realloc(*arr, (*size_ptr) * sizeof(int));
// if (temp_arr == NULL && *size_ptr > 0) { // 缩容失败但数组不为空,不认为是致命错误
// fprintf(stderr, "警告: 数组缩容失败,继续使用当前内存。\n");
// } else {
// *arr = temp_arr;
// }
printf("新大小: %d\n", *size_ptr);
return true;
}
// 主函数用于测试数组操作
int main_array_operations() {
printf("--- 数组基本操作与动态扩容示例 ---\n");
int initial_size = 5;
int *my_array = (int *)malloc(initial_size * sizeof(int));
if (my_array == NULL) {
perror("初始数组内存分配失败");
return EXIT_FAILURE;
}
int current_size = initial_size;
// 初始化数组元素
for (int i = 0; i < current_size; i++) {
my_array[i] = (i + 1) * 10;
}
printf("初始数组 (大小: %d): ", current_size);
print_array(my_array, current_size);
// 随机访问
printf("访问索引 2 的元素: %d\n", my_array[2]); // O(1)
// 在中间插入元素
insert_into_array(&my_array, ¤t_size, 55, 2);
print_array(my_array, current_size);
// 在末尾插入元素 (相当于追加)
insert_into_array(&my_array, ¤t_size, 99, current_size);
print_array(my_array, current_size);
// 在开头插入元素
insert_into_array(&my_array, ¤t_size, 5, 0);
print_array(my_array, current_size);
// 删除中间元素
delete_from_array(&my_array, ¤t_size, 3);
print_array(my_array, current_size);
// 删除末尾元素
delete_from_array(&my_array, ¤t_size, current_size - 1);
print_array(my_array, current_size);
// 删除开头元素
delete_from_array(&my_array, ¤t_size, 0);
print_array(my_array, current_size);
// 尝试无效操作
insert_into_array(&my_array, ¤t_size, 100, -1);
delete_from_array(&my_array, ¤t_size, current_size);
// 释放内存
free(my_array);
printf("--- 数组基本操作与动态扩容示例结束 ---\n");
return 0;
}
代码分析与说明:
-
print_array
: 辅助函数,用于打印数组内容。 -
insert_into_array
函数:-
接收
int **arr
和int *size_ptr
,这是因为realloc
可能会改变数组的起始地址,并且需要更新数组的大小。 -
索引检查:
if (index < 0 || index > *size_ptr)
确保插入位置合法。index > *size_ptr
允许在数组末尾插入(追加)。 -
扩容:
realloc
用于重新分配内存。如果当前数组已满,它会尝试分配一个更大的连续内存块。如果成功,temp_arr
将指向新内存块,旧内存块的数据会被自动复制到新内存块。 -
元素移动:
for (int i = *size_ptr - 1; i > index; i--) { (*arr)[i] = (*arr)[i - 1]; }
这段代码是插入操作效率低的核心原因。它需要将从插入位置开始的所有元素向后移动一位,为新元素腾出空间。这个操作的时间复杂度是 O(n)。 -
插入元素:
(*arr)[index] = element;
将新元素放入腾出的位置。
-
-
delete_from_array
函数:-
索引检查:
if (index < 0 || index >= *size_ptr)
确保删除位置合法。 -
元素移动:
for (int i = index; i < *size_ptr - 1; i++) { (*arr)[i] = (*arr)[i + 1]; }
这是删除操作效率低的核心原因。它需要将从删除位置的下一个元素开始的所有元素向前移动一位,覆盖被删除的元素。这个操作的时间复杂度也是 O(n)。 -
缩容: 代码中注释掉了
realloc
进行缩容的部分。在实际应用中,频繁的缩容操作可能导致性能下降,通常会等到数组占用空间远大于实际元素数量时才进行缩容,或者不缩容,而是通过一个逻辑大小来管理。
-
-
内存管理:
malloc
和free
成对出现,确保内存的正确分配和释放,防止内存泄漏。realloc
在扩容时可能返回NULL
,需要进行错误检查。 -
做题编程随想录: 数组的随机访问是其最大优势,但插入和删除的 O(n) 复杂度是其短板。在嵌入式中,如果数据量固定且需要高速随机访问,数组是首选。但如果数据量动态变化且频繁插入删除,就需要考虑链表了。
2.1.2 链表:灵活的“串珠子”
-
定义: 链表(Linked List)是一种线性的、非连续的内存空间,用于存储数据元素的集合。每个元素(节点)包含数据和指向下一个元素的指针(或引用)。
-
你可以把它想象成一串“珠子”,每颗珠子除了自己的颜色(数据),还带有一根线(指针)指向下一颗珠子。它们在内存中不一定挨着,但通过指针可以串联起来。
-
-
特点:
-
非连续存储: 链表中的元素在内存中可以分散存储,通过指针连接。
-
动态大小: 链表的长度可以在运行时动态变化,无需预先确定大小。
-
节点结构: 每个节点通常包含数据域和指针域。
-
-
优缺点:
-
优点:
-
插入/删除效率高: 在链表中间插入或删除元素时,只需要修改少量指针,时间复杂度为 O(1)(前提是已经找到插入/删除位置)。
-
空间利用率高: 按需分配内存,没有固定分区带来的内部碎片。
-
-
缺点:
-
随机访问效率低: 无法通过索引直接访问元素,必须从头节点开始,沿着指针逐个遍历,时间复杂度为 O(n)。
-
额外空间开销: 每个节点都需要额外的空间来存储指针。
-
缓存不友好: 非连续的内存布局可能导致CPU缓存命中率低。
-
-
-
链表的常见类型:
-
单向链表(Singly Linked List): 每个节点只包含一个指向下一个节点的指针。
-
双向链表(Doubly Linked List): 每个节点包含一个指向前一个节点的指针和一个指向后一个节点的指针。
-
循环链表(Circular Linked List): 链表的最后一个节点指向头节点,形成一个环。
-
概念性C代码:单向链表的创建、插入、删除、遍历
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 定义链表节点结构体
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个节点
} Node;
/**
* @brief 创建一个新的链表节点
* @param data 节点中存储的数据
* @return 指向新创建节点的指针,如果内存分配失败则返回NULL
*/
Node *create_node(int data) {
Node *new_node = (Node *)malloc(sizeof(Node));
if (new_node == NULL) {
perror("节点内存分配失败");
return NULL;
}
new_node->data = data;
new_node->next = NULL; // 新节点的next指针初始为NULL
return new_node;
}
/**
* @brief 在链表头部插入元素
* @param head_ptr 指向链表头指针的指针 (因为头指针可能改变)
* @param data 要插入的数据
* @return true 插入成功,false 插入失败
* @note 时间复杂度 O(1)。
*/
bool insert_at_head(Node **head_ptr, int data) {
Node *new_node = create_node(data);
if (new_node == NULL) {
return false;
}
new_node->next = *head_ptr; // 新节点指向原来的头节点
*head_ptr = new_node; // 头指针更新为新节点
printf("在头部插入 %d\n", data);
return true;
}
/**
* @brief 在链表尾部插入元素
* @param head_ptr 指向链表头指针的指针
* @param data 要插入的数据
* @return true 插入成功,false 插入失败
* @note 时间复杂度 O(n) (需要遍历到尾部)。
* 如果维护一个尾指针,则可优化为 O(1)。
*/
bool insert_at_tail(Node **head_ptr, int data) {
Node *new_node = create_node(data);
if (new_node == NULL) {
return false;
}
if (*head_ptr == NULL) { // 如果链表为空,新节点即为头节点
*head_ptr = new_node;
} else {
Node *current = *head_ptr;
while (current->next != NULL) { // 遍历到链表尾部
current = current->next;
}
current->next = new_node; // 尾部节点指向新节点
}
printf("在尾部插入 %d\n", data);
return true;
}
/**
* @brief 在链表指定位置(索引)插入元素
* @param head_ptr 指向链表头指针的指针
* @param data 要插入的数据
* @param index 插入位置的索引 (0-based)
* @return true 插入成功,false 插入失败 (如索引越界或内存不足)
* @note 时间复杂度 O(index),最坏 O(n)。
*/
bool insert_at_index(Node **head_ptr, int data, int index) {
if (index < 0) {
printf("错误: 插入索引 %d 无效 (必须非负)\n", index);
return false;
}
if (index == 0) { // 在头部插入的特殊情况
return insert_at_head(head_ptr, data);
}
Node *new_node = create_node(data);
if (new_node == NULL) {
return false;
}
Node *current = *head_ptr;
// 遍历到插入位置的前一个节点
for (int i = 0; current != NULL && i < index - 1; i++) {
current = current->next;
}
if (current == NULL) { // 索引超出链表长度
printf("错误: 插入索引 %d 超出链表范围\n", index);
free(new_node); // 释放未使用的节点
return false;
}
new_node->next = current->next; // 新节点指向当前节点的下一个节点
current->next = new_node; // 当前节点指向新节点
printf("在索引 %d 处插入 %d\n", index, data);
return true;
}
/**
* @brief 从链表头部删除元素
* @param head_ptr 指向链表头指针的指针
* @return true 删除成功,false 链表为空无法删除
* @note 时间复杂度 O(1)。
*/
bool delete_from_head(Node **head_ptr) {
if (*head_ptr == NULL) {
printf("错误: 链表为空,无法删除头部元素\n");
return false;
}
Node *temp = *head_ptr; // 暂存头节点
*head_ptr = (*head_ptr)->next; // 头指针指向下一个节点
printf("删除头部元素: %d\n", temp->data);
free(temp); // 释放原头节点内存
return true;
}
/**
* @brief 从链表尾部删除元素
* @param head_ptr 指向链表头指针的指针
* @return true 删除成功,false 链表为空无法删除
* @note 时间复杂度 O(n) (需要遍历到倒数第二个节点)。
*/
bool delete_from_tail(Node **head_ptr) {
if (*head_ptr == NULL) {
printf("错误: 链表为空,无法删除尾部元素\n");
return false;
}
if ((*head_ptr)->next == NULL) { // 只有一个节点的情况
printf("删除尾部元素: %d\n", (*head_ptr)->data);
free(*head_ptr);
*head_ptr = NULL;
return true;
}
Node *current = *head_ptr;
Node *prev = NULL;
while (current->next != NULL) { // 遍历到尾部节点
prev = current;
current = current->next;
}
prev->next = NULL; // 倒数第二个节点指向NULL
printf("删除尾部元素: %d\n", current->data);
free(current); // 释放原尾部节点内存
return true;
}
/**
* @brief 从链表指定位置(索引)删除元素
* @param head_ptr 指向链表头指针的指针
* @param index 删除位置的索引 (0-based)
* @return true 删除成功,false 删除失败 (如索引越界或链表为空)
* @note 时间复杂度 O(index),最坏 O(n)。
*/
bool delete_at_index(Node **head_ptr, int index) {
if (*head_ptr == NULL) {
printf("错误: 链表为空,无法删除\n");
return false;
}
if (index < 0) {
printf("错误: 删除索引 %d 无效 (必须非负)\n", index);
return false;
}
if (index == 0) { // 删除头节点的特殊情况
return delete_from_head(head_ptr);
}
Node *current = *head_ptr;
Node *prev = NULL;
// 遍历到删除位置的前一个节点
for (int i = 0; current != NULL && i < index; i++) {
prev = current;
current = current->next;
}
if (current == NULL) { // 索引超出链表长度
printf("错误: 删除索引 %d 超出链表范围\n", index);
return false;
}
prev->next = current->next; // 前一个节点指向当前节点的下一个节点
printf("删除索引 %d 处的元素: %d\n", index, current->data);
free(current); // 释放被删除节点内存
return true;
}
/**
* @brief 遍历并打印链表所有元素
* @param head 链表头指针
* @note 时间复杂度 O(n)。
*/
void traverse_list(Node *head) {
printf("链表元素: ");
if (head == NULL) {
printf("链表为空。\n");
return;
}
Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
/**
* @brief 查找链表中是否存在指定数据
* @param head 链表头指针
* @param data 要查找的数据
* @return true 找到,false 未找到
* @note 时间复杂度 O(n)。
*/
bool search_list(Node *head, int data) {
Node *current = head;
while (current != NULL) {
if (current->data == data) {
return true;
}
current = current->next;
}
return false;
}
/**
* @brief 释放链表所有节点的内存
* @param head_ptr 指向链表头指针的指针
* @note 避免内存泄漏,非常重要。
*/
void free_list(Node **head_ptr) {
Node *current = *head_ptr;
Node *next_node;
while (current != NULL) {
next_node = current->next; // 暂存下一个节点
free(current); // 释放当前节点
current = next_node; // 移动到下一个节点
}
*head_ptr = NULL; // 将头指针置为NULL,表示链表已空
printf("链表内存已释放。\n");
}
// 主函数用于测试单向链表操作
int main_linked_list_operations() {
printf("--- 单向链表操作示例 ---\n");
Node *head = NULL; // 初始化空链表
traverse_list(head);
// 插入操作
insert_at_tail(&head, 10);
insert_at_head(&head, 5);
insert_at_tail(&head, 20);
insert_at_index(&head, 15, 2); // 在索引2处插入15 (5 -> 10 -> 15 -> 20)
insert_at_index(&head, 2, 0); // 在索引0处插入2 (2 -> 5 -> 10 -> 15 -> 20)
traverse_list(head);
// 查找操作
printf("查找 15: %s\n", search_list(head, 15) ? "找到" : "未找到");
printf("查找 100: %s\n", search_list(head, 100) ? "找到" : "未找到");
// 删除操作
delete_from_head(&head); // 删除 2
traverse_list(head);
delete_from_tail(&head); // 删除 20
traverse_list(head);
delete_at_index(&head, 1); // 删除索引1处的元素 (原15,现在是10)
traverse_list(head);
delete_from_head(&head); // 删除 5
traverse_list(head);
delete_from_head(&head); // 删除 10
traverse_list(head);
delete_from_head(&head); // 尝试删除空链表
traverse_list(head);
// 释放所有内存
free_list(&head);
traverse_list(head);
printf("--- 单向链表操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
Node
结构体: 定义了链表节点的结构,包含data
(数据)和next
(指向下一个节点的指针)。 -
create_node
: 辅助函数,用于动态分配内存并初始化新节点。 -
insert_at_head
: 在链表头部插入元素。只需修改两个指针:新节点的next
指向原头节点,head_ptr
指向新节点。时间复杂度 O(1)。 -
insert_at_tail
: 在链表尾部插入元素。需要遍历链表找到尾部节点,然后修改其next
指针。时间复杂度 O(n)。如果链表结构中额外维护一个尾指针,则可优化为 O(1)。 -
insert_at_index
: 在指定索引处插入元素。需要遍历到插入位置的前一个节点。时间复杂度 O(index),最坏 O(n)。 -
delete_from_head
: 删除链表头部元素。只需修改head_ptr
指向下一个节点,并释放原头节点内存。时间复杂度 O(1)。 -
delete_from_tail
: 删除链表尾部元素。需要遍历到倒数第二个节点,然后修改其next
指针为NULL
,并释放原尾部节点内存。时间复杂度 O(n)。 -
delete_at_index
: 删除指定索引处的元素。需要遍历到删除位置的前一个节点。时间复杂度 O(index),最坏 O(n)。 -
traverse_list
: 遍历链表并打印所有元素。时间复杂度 O(n)。 -
search_list
: 查找链表中是否存在某个元素。需要遍历链表。时间复杂度 O(n)。 -
free_list
: 释放链表所有节点的内存。这是防止内存泄漏的关键!必须逐个节点释放。 -
指针的指针
Node **head_ptr
: 在需要修改函数外部的指针变量(如head
)时,必须传入指针的指针。例如,insert_at_head
和free_list
都需要修改main
函数中的head
变量,所以需要传入&head
。 -
做题编程随想录: 链表在内存中是非连续的,这使得它在插入和删除操作上具有 O(1) 的优势(一旦找到位置)。但随机访问和缓存性能是其劣势。在嵌入式中,链表常用于实现动态大小的队列、任务列表等,但要特别注意内存碎片和内存管理(
malloc
/free
)的开销和可靠性。
2.1.3 数组与链表对比
特性 |
数组(Array) |
链表(Linked List) |
---|---|---|
存储方式 |
连续的内存空间 |
非连续的内存空间,通过指针连接 |
大小 |
固定(静态数组),动态(动态数组) |
动态,按需分配 |
随机访问 |
O(1)(通过索引直接访问) |
O(n)(需要遍历) |
插入/删除 |
O(n)(需要移动元素) |
O(1)(修改指针,前提是找到位置) |
空间效率 |
可能有内部碎片(固定大小),无额外指针开销 |
无内部碎片,但每个节点有额外指针开销 |
缓存友好 |
友好(连续存储) |
不友好(非连续存储) |
实现难度 |
简单 |
相对复杂(指针操作,内存管理) |
典型应用 |
查找表,固定大小缓冲区 |
动态列表,队列,栈,内存管理 |
小结: 数组和链表是数据存储的“基本单位”,各有其独特的优缺点。数组擅长随机访问和缓存友好,但插入删除效率低;链表擅长动态插入删除,但随机访问效率低且有额外空间开销。在实际应用中,选择哪种数据结构,取决于你对访问模式、数据量变化、内存限制等因素的权衡。在嵌入式领域,对内存的精细控制和对性能的极致追求,使得理解这两种基本数据结构的底层原理和权衡变得尤为重要。
2.2 栈与队列:受限的“操作序列”
兄弟们,有了数组和链表这些“砖头”,我们就可以构建更高级的数据结构了。栈和队列就是两种非常常见且实用的线性数据结构,它们通过对数据操作施加特定的限制,从而实现特定的逻辑。它们就像生活中的“弹夹”和“排队”,有着严格的进出规则。
2.2.1 栈(Stack):后进先出(LIFO)的“弹夹”
-
定义: 栈(Stack)是一种特殊的线性表,它只允许在表的一端进行插入和删除操作。这一端被称为栈顶(Top),另一端被称为栈底(Bottom)。栈遵循**后进先出(Last In, First Out, LIFO)**的原则。
-
想象一下枪的弹夹,最后压入的子弹,总是最先被射出。
-
-
基本操作:
-
入栈(Push): 将一个新元素添加到栈顶。
-
出栈(Pop): 移除栈顶元素,并返回该元素的值。
-
查看栈顶元素(Peek/Top): 返回栈顶元素的值,但不移除。
-
判断栈是否为空(isEmpty): 检查栈中是否有元素。
-
判断栈是否已满(isFull): (基于数组实现时需要)检查栈是否达到最大容量。
-
-
实现方式:
-
基于数组实现: 简单,但需要预先确定大小,可能存在栈满问题。
-
基于链表实现: 动态大小,没有栈满问题(除非内存耗尽),但有额外指针开销。
-
概念性C代码:基于数组和基于链表的栈
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// --- 基于数组的栈实现 ---
#define MAX_STACK_SIZE 10 // 定义栈的最大容量
typedef struct ArrayStack {
int data[MAX_STACK_SIZE]; // 存储元素的数组
int top; // 栈顶指针,指向栈顶元素的索引
} ArrayStack;
/**
* @brief 初始化一个基于数组的栈
* @param stack 指向ArrayStack结构体的指针
*/
void init_array_stack(ArrayStack *stack) {
stack->top = -1; // 初始化栈顶指针为-1,表示栈为空
printf("基于数组的栈已初始化。\n");
}
/**
* @brief 判断基于数组的栈是否为空
* @param stack 指向ArrayStack结构体的指针
* @return true 如果栈为空,false 否则
*/
bool is_array_stack_empty(const ArrayStack *stack) {
return stack->top == -1;
}
/**
* @brief 判断基于数组的栈是否已满
* @param stack 指向ArrayStack结构体的指针
* @return true 如果栈已满,false 否则
*/
bool is_array_stack_full(const ArrayStack *stack) {
return stack->top == MAX_STACK_SIZE - 1;
}
/**
* @brief 基于数组的栈入栈操作
* @param stack 指向ArrayStack结构体的指针
* @param item 要入栈的元素
* @return true 入栈成功,false 栈已满
* @note 时间复杂度 O(1)。
*/
bool push_array_stack(ArrayStack *stack, int item) {
if (is_array_stack_full(stack)) {
printf("错误: 栈已满,无法入栈 %d\n", item);
return false;
}
stack->data[++stack->top] = item; // 栈顶指针先加1,再存入元素
printf("入栈: %d\n", item);
return true;
}
/**
* @brief 基于数组的栈出栈操作
* @param stack 指向ArrayStack结构体的指针
* @param item_ptr 用于存储出栈元素的指针
* @return true 出栈成功,false 栈为空
* @note 时间复杂度 O(1)。
*/
bool pop_array_stack(ArrayStack *stack, int *item_ptr) {
if (is_array_stack_empty(stack)) {
printf("错误: 栈为空,无法出栈\n");
return false;
}
*item_ptr = stack->data[stack->top--]; // 先取出元素,再将栈顶指针减1
printf("出栈: %d\n", *item_ptr);
return true;
}
/**
* @brief 查看基于数组的栈顶元素
* @param stack 指向ArrayStack结构体的指针
* @param item_ptr 用于存储栈顶元素的指针
* @return true 查看成功,false 栈为空
* @note 时间复杂度 O(1)。
*/
bool peek_array_stack(const ArrayStack *stack, int *item_ptr) {
if (is_array_stack_empty(stack)) {
printf("错误: 栈为空,无法查看栈顶元素\n");
return false;
}
*item_ptr = stack->data[stack->top];
printf("栈顶元素: %d\n", *item_ptr);
return true;
}
// --- 基于链表的栈实现 ---
// 链表节点结构体 (与单向链表相同)
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
typedef struct LinkedListStack {
StackNode *top; // 栈顶指针,指向链表的头节点
} LinkedListStack;
/**
* @brief 初始化一个基于链表的栈
* @param stack 指向LinkedListStack结构体的指针
*/
void init_linked_list_stack(LinkedListStack *stack) {
stack->top = NULL; // 栈顶指针初始为NULL,表示栈为空
printf("基于链表的栈已初始化。\n");
}
/**
* @brief 判断基于链表的栈是否为空
* @param stack 指向LinkedListStack结构体的指针
* @return true 如果栈为空,false 否则
*/
bool is_linked_list_stack_empty(const LinkedListStack *stack) {
return stack->top == NULL;
}
/**
* @brief 基于链表的栈入栈操作
* @param stack 指向LinkedListStack结构体的指针
* @param item 要入栈的元素
* @return true 入栈成功,false 内存分配失败
* @note 时间复杂度 O(1)。
*/
bool push_linked_list_stack(LinkedListStack *stack, int item) {
StackNode *new_node = (StackNode *)malloc(sizeof(StackNode));
if (new_node == NULL) {
perror("栈节点内存分配失败");
return false;
}
new_node->data = item;
new_node->next = stack->top; // 新节点指向当前栈顶
stack->top = new_node; // 栈顶更新为新节点
printf("入栈: %d\n", item);
return true;
}
/**
* @brief 基于链表的栈出栈操作
* @param stack 指向LinkedListStack结构体的指针
* @param item_ptr 用于存储出栈元素的指针
* @return true 出栈成功,false 栈为空
* @note 时间复杂度 O(1)。
*/
bool pop_linked_list_stack(LinkedListStack *stack, int *item_ptr) {
if (is_linked_list_stack_empty(stack)) {
printf("错误: 栈为空,无法出栈\n");
return false;
}
StackNode *temp = stack->top; // 暂存栈顶节点
*item_ptr = temp->data; // 取出数据
stack->top = temp->next; // 栈顶指向下一个节点
printf("出栈: %d\n", *item_ptr);
free(temp); // 释放原栈顶节点内存
return true;
}
/**
* @brief 查看基于链表的栈顶元素
* @param stack 指向LinkedListStack结构体的指针
* @param item_ptr 用于存储栈顶元素的指针
* @return true 查看成功,false 栈为空
* @note 时间复杂度 O(1)。
*/
bool peek_linked_list_stack(const LinkedListStack *stack, int *item_ptr) {
if (is_linked_list_stack_empty(stack)) {
printf("错误: 栈为空,无法查看栈顶元素\n");
return false;
}
*item_ptr = stack->top->data;
printf("栈顶元素: %d\n", *item_ptr);
return true;
}
/**
* @brief 释放基于链表的栈所有节点的内存
* @param stack 指向LinkedListStack结构体的指针
*/
void free_linked_list_stack(LinkedListStack *stack) {
StackNode *current = stack->top;
StackNode *next_node;
while (current != NULL) {
next_node = current->next;
free(current);
current = next_node;
}
stack->top = NULL;
printf("基于链表的栈内存已释放。\n");
}
// 主函数用于测试栈操作
int main_stack_operations() {
printf("--- 栈操作示例 ---\n\n");
// 测试基于数组的栈
printf("--- 测试基于数组的栈 ---\n");
ArrayStack arr_stack;
init_array_stack(&arr_stack);
int item;
peek_array_stack(&arr_stack, &item); // 尝试查看空栈
push_array_stack(&arr_stack, 10);
push_array_stack(&arr_stack, 20);
peek_array_stack(&arr_stack, &item);
push_array_stack(&arr_stack, 30);
pop_array_stack(&arr_stack, &item);
peek_array_stack(&arr_stack, &item);
// 填满并溢出
for (int i = 0; i < MAX_STACK_SIZE - 2; i++) {
push_array_stack(&arr_stack, 100 + i);
}
push_array_stack(&arr_stack, 999); // 最后一个成功入栈的元素
push_array_stack(&arr_stack, 1000); // 尝试溢出
while (!is_array_stack_empty(&arr_stack)) {
pop_array_stack(&arr_stack, &item);
}
pop_array_stack(&arr_stack, &item); // 尝试从空栈出栈
printf("\n");
// 测试基于链表的栈
printf("--- 测试基于链表的栈 ---\n");
LinkedListStack list_stack;
init_linked_list_stack(&list_stack);
peek_linked_list_stack(&list_stack, &item); // 尝试查看空栈
push_linked_list_stack(&list_stack, 100);
push_linked_list_stack(&list_stack, 200);
peek_linked_list_stack(&list_stack, &item);
push_linked_list_stack(&list_stack, 300);
pop_linked_list_stack(&list_stack, &item);
peek_linked_list_stack(&list_stack, &item);
// 连续出栈直到空
while (!is_linked_list_stack_empty(&list_stack)) {
pop_linked_list_stack(&list_stack, &item);
}
pop_linked_list_stack(&list_stack, &item); // 尝试从空栈出栈
free_linked_list_stack(&list_stack); // 释放内存
printf("--- 栈操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
基于数组的栈:
-
data
数组存储元素,top
变量作为栈顶指针,指向栈顶元素的索引。初始化为-1
表示空栈。 -
push
操作:stack->data[++stack->top] = item;
先递增top
,再存入元素。 -
pop
操作:*item_ptr = stack->data[stack->top--];
先取出元素,再递减top
。 -
is_array_stack_full
:判断top
是否达到MAX_STACK_SIZE - 1
。 -
优点: 实现简单,访问速度快(数组的随机访问优势),无额外指针开销。
-
缺点: 容量固定,可能发生栈溢出(Stack Overflow)问题,需要手动处理扩容(如果允许动态扩容)。
-
-
基于链表的栈:
-
top
指针直接指向链表的头节点(即栈顶元素)。 -
push
操作:创建新节点,新节点的next
指向当前top
,然后更新top
为新节点。这相当于在链表头部插入,时间复杂度 O(1)。 -
pop
操作:暂存top
指向的节点,top
移动到下一个节点,然后释放原top
指向的节点。这相当于删除链表头部节点,时间复杂度 O(1)。 -
is_linked_list_stack_empty
:判断top
是否为NULL
。 -
优点: 动态大小,理论上没有栈满问题(除非系统内存耗尽)。
-
缺点: 有额外指针开销,内存分配/释放可能带来性能损耗。
-
-
做题编程随想录: 栈在计算机科学中应用非常广泛。
-
函数调用栈: 每次函数调用都会在栈上创建一个新的栈帧,保存局部变量、参数和返回地址。递归调用尤其依赖栈。
-
表达式求值: 逆波兰表达式(后缀表达式)求值,中缀表达式转后缀表达式。
-
括号匹配: 检查表达式中的括号是否正确匹配。
-
深度优先搜索(DFS): 递归实现DFS的本质就是利用了系统栈,非递归实现则需要显式使用栈。
-
回溯: 记录状态,当不满足条件时回溯到上一个状态。
-
嵌入式中: 栈通常用于管理函数调用、局部变量,对栈溢出需要特别关注,因为嵌入式设备的栈空间通常有限。
-
2.2.2 队列(Queue):先进先出(FIFO)的“排队”
-
定义: 队列(Queue)是一种特殊的线性表,它只允许在表的一端进行插入操作,在另一端进行删除操作。进行插入操作的一端称为队尾(Rear/Tail),进行删除操作的一端称为队头(Front/Head)。队列遵循**先进先出(First In, First Out, FIFO)**的原则。
-
想象一下排队买票,先排队的人总是先买到票。
-
-
基本操作:
-
入队(Enqueue): 将一个新元素添加到队尾。
-
出队(Dequeue): 移除队头元素,并返回该元素的值。
-
查看队头元素(Front/Peek): 返回队头元素的值,但不移除。
-
判断队列是否为空(isEmpty): 检查队列中是否有元素。
-
判断队列是否已满(isFull): (基于数组实现时需要)检查队列是否达到最大容量。
-
-
实现方式:
-
基于数组实现(循环队列): 数组首尾相连,避免假溢出,但需要预先确定大小。
-
基于链表实现: 动态大小,没有队满问题,但有额外指针开销。
-
概念性C代码:基于数组的循环队列和基于链表的队列
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// --- 基于数组的循环队列实现 ---
#define MAX_QUEUE_SIZE 5 // 定义队列的最大容量
typedef struct CircularArrayQueue {
int data[MAX_QUEUE_SIZE]; // 存储元素的数组
int front; // 队头指针
int rear; // 队尾指针
int count; // 队列中元素的数量
} CircularArrayQueue;
/**
* @brief 初始化一个基于数组的循环队列
* @param queue 指向CircularArrayQueue结构体的指针
*/
void init_circular_array_queue(CircularArrayQueue *queue) {
queue->front = 0; // 队头指针指向第一个元素
queue->rear = -1; // 队尾指针指向最后一个元素的前一个位置
queue->count = 0; // 初始元素数量为0
printf("基于数组的循环队列已初始化。\n");
}
/**
* @brief 判断基于数组的循环队列是否为空
* @param queue 指向CircularArrayQueue结构体的指针
* @return true 如果队列为空,false 否则
*/
bool is_circular_array_queue_empty(const CircularArrayQueue *queue) {
return queue->count == 0;
}
/**
* @brief 判断基于数组的循环队列是否已满
* @param queue 指向CircularArrayQueue结构体的指针
* @return true 如果队列已满,false 否则
*/
bool is_circular_array_queue_full(const CircularArrayQueue *queue) {
return queue->count == MAX_QUEUE_SIZE;
}
/**
* @brief 基于数组的循环队列入队操作
* @param queue 指向CircularArrayQueue结构体的指针
* @param item 要入队的元素
* @return true 入队成功,false 队列已满
* @note 时间复杂度 O(1)。
*/
bool enqueue_circular_array_queue(CircularArrayQueue *queue, int item) {
if (is_circular_array_queue_full(queue)) {
printf("错误: 队列已满,无法入队 %d\n", item);
return false;
}
queue->rear = (queue->rear + 1) % MAX_QUEUE_SIZE; // 队尾指针循环移动
queue->data[queue->rear] = item;
queue->count++;
printf("入队: %d\n", item);
return true;
}
/**
* @brief 基于数组的循环队列出队操作
* @param queue 指向CircularArrayQueue结构体的指针
* @param item_ptr 用于存储出队元素的指针
* @return true 出队成功,false 队列为空
* @note 时间复杂度 O(1)。
*/
bool dequeue_circular_array_queue(CircularArrayQueue *queue, int *item_ptr) {
if (is_circular_array_queue_empty(queue)) {
printf("错误: 队列为空,无法出队\n");
return false;
}
*item_ptr = queue->data[queue->front];
queue->front = (queue->front + 1) % MAX_QUEUE_SIZE; // 队头指针循环移动
queue->count--;
printf("出队: %d\n", *item_ptr);
return true;
}
/**
* @brief 查看基于数组的循环队列队头元素
* @param queue 指向CircularArrayQueue结构体的指针
* @param item_ptr 用于存储队头元素的指针
* @return true 查看成功,false 队列为空
* @note 时间复杂度 O(1)。
*/
bool front_circular_array_queue(const CircularArrayQueue *queue, int *item_ptr) {
if (is_circular_array_queue_empty(queue)) {
printf("错误: 队列为空,无法查看队头元素\n");
return false;
}
*item_ptr = queue->data[queue->front];
printf("队头元素: %d\n", *item_ptr);
return true;
}
// --- 基于链表的队列实现 ---
// 链表节点结构体 (与单向链表相同)
typedef struct QueueNode {
int data;
struct QueueNode *next;
} QueueNode;
typedef struct LinkedListQueue {
QueueNode *front; // 队头指针
QueueNode *rear; // 队尾指针
} LinkedListQueue;
/**
* @brief 初始化一个基于链表的队列
* @param queue 指向LinkedListQueue结构体的指针
*/
void init_linked_list_queue(LinkedListQueue *queue) {
queue->front = NULL;
queue->rear = NULL;
printf("基于链表的队列已初始化。\n");
}
/**
* @brief 判断基于链表的队列是否为空
* @param queue 指向LinkedListQueue结构体的指针
* @return true 如果队列为空,false 否则
*/
bool is_linked_list_queue_empty(const LinkedListQueue *queue) {
return queue->front == NULL;
}
/**
* @brief 基于链表的队列入队操作
* @param queue 指向LinkedListQueue结构体的指针
* @param item 要入队的元素
* @return true 入队成功,false 内存分配失败
* @note 时间复杂度 O(1)。
*/
bool enqueue_linked_list_queue(LinkedListQueue *queue, int item) {
QueueNode *new_node = (QueueNode *)malloc(sizeof(QueueNode));
if (new_node == NULL) {
perror("队列节点内存分配失败");
return false;
}
new_node->data = item;
new_node->next = NULL; // 新节点总是放在队尾,其next为NULL
if (is_linked_list_queue_empty(queue)) { // 如果队列为空,新节点既是队头也是队尾
queue->front = new_node;
queue->rear = new_node;
} else {
queue->rear->next = new_node; // 原队尾指向新节点
queue->rear = new_node; // 队尾指针更新为新节点
}
printf("入队: %d\n", item);
return true;
}
/**
* @brief 基于链表的队列出队操作
* @param queue 指向LinkedListQueue结构体的指针
* @param item_ptr 用于存储出队元素的指针
* @return true 出队成功,false 队列为空
* @note 时间复杂度 O(1)。
*/
bool dequeue_linked_list_queue(LinkedListQueue *queue, int *item_ptr) {
if (is_linked_list_queue_empty(queue)) {
printf("错误: 队列为空,无法出队\n");
return false;
}
QueueNode *temp = queue->front; // 暂存队头节点
*item_ptr = temp->data; // 取出数据
queue->front = temp->next; // 队头指针指向下一个节点
if (queue->front == NULL) { // 如果出队后队列变空,队尾指针也要置NULL
queue->rear = NULL;
}
printf("出队: %d\n", *item_ptr);
free(temp); // 释放原队头节点内存
return true;
}
/**
* @brief 查看基于链表的队列队头元素
* @param queue 指向LinkedListQueue结构体的指针
* @param item_ptr 用于存储队头元素的指针
* @return true 查看成功,false 队列为空
* @note 时间复杂度 O(1)。
*/
bool front_linked_list_queue(const LinkedListQueue *queue, int *item_ptr) {
if (is_linked_list_queue_empty(queue)) {
printf("错误: 队列为空,无法查看队头元素\n");
return false;
}
*item_ptr = queue->front->data;
printf("队头元素: %d\n", *item_ptr);
return true;
}
/**
* @brief 释放基于链表的队列所有节点的内存
* @param queue 指向LinkedListQueue结构体的指针
*/
void free_linked_list_queue(LinkedListQueue *queue) {
QueueNode *current = queue->front;
QueueNode *next_node;
while (current != NULL) {
next_node = current->next;
free(current);
current = next_node;
}
queue->front = NULL;
queue->rear = NULL;
printf("基于链表的队列内存已释放。\n");
}
// 主函数用于测试队列操作
int main_queue_operations() {
printf("--- 队列操作示例 ---\n\n");
// 测试基于数组的循环队列
printf("--- 测试基于数组的循环队列 ---\n");
CircularArrayQueue arr_queue;
init_circular_array_queue(&arr_queue);
int item;
front_circular_array_queue(&arr_queue, &item); // 尝试查看空队列
enqueue_circular_array_queue(&arr_queue, 10);
enqueue_circular_array_queue(&arr_queue, 20);
front_circular_array_queue(&arr_queue, &item);
enqueue_circular_array_queue(&arr_queue, 30);
enqueue_circular_array_queue(&arr_queue, 40);
enqueue_circular_array_queue(&arr_queue, 50); // 填满
enqueue_circular_array_queue(&arr_queue, 60); // 尝试溢出
dequeue_circular_array_queue(&arr_queue, &item);
front_circular_array_queue(&arr_queue, &item);
enqueue_circular_array_queue(&arr_queue, 60); // 再次入队,利用循环特性
front_circular_array_queue(&arr_queue, &item);
while (!is_circular_array_queue_empty(&arr_queue)) {
dequeue_circular_array_queue(&arr_queue, &item);
}
dequeue_circular_array_queue(&arr_queue, &item); // 尝试从空队列出队
printf("\n");
// 测试基于链表的队列
printf("--- 测试基于链表的队列 ---\n");
LinkedListQueue list_queue;
init_linked_list_queue(&list_queue);
front_linked_list_queue(&list_queue, &item); // 尝试查看空队列
enqueue_linked_list_queue(&list_queue, 100);
enqueue_linked_list_queue(&list_queue, 200);
front_linked_list_queue(&list_queue, &item);
enqueue_linked_list_queue(&list_queue, 300);
dequeue_linked_list_queue(&list_queue, &item);
front_linked_list_queue(&list_queue, &item);
// 连续出队直到空
while (!is_linked_list_queue_empty(&list_queue)) {
dequeue_linked_list_queue(&list_queue, &item);
}
dequeue_linked_list_queue(&list_queue, &item); // 尝试从空队列出队
free_linked_list_queue(&list_queue); // 释放内存
printf("--- 队列操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
基于数组的循环队列:
-
data
数组存储元素,front
指向队头元素的索引,rear
指向队尾元素的索引。count
记录元素数量,用于判断空/满。 -
循环特性:
(index + 1) % MAX_QUEUE_SIZE
用于实现队头和队尾指针的循环移动,避免“假溢出”(即数组后面有空位但无法利用)。 -
判断空/满: 通过
count
变量判断,当count == 0
为空,count == MAX_QUEUE_SIZE
为满。 -
优点: 实现相对简单,操作效率高(O(1)),无额外指针开销。
-
缺点: 容量固定,需要预先确定大小。
-
-
基于链表的队列:
-
front
指针指向队头节点,rear
指针指向队尾节点。 -
enqueue
操作:创建新节点,将其插入到链表尾部。如果队列为空,新节点同时是队头和队尾。否则,原队尾节点的next
指向新节点,然后更新rear
为新节点。时间复杂度 O(1)。 -
dequeue
操作:移除链表头部的节点。更新front
指针。如果移除后队列变空,rear
也要置为NULL
。时间复杂度 O(1)。 -
优点: 动态大小,没有队满问题。
-
缺点: 有额外指针开销,内存分配/释放可能带来性能损耗。
-
-
做题编程随想录: 队列在计算机系统中也有着广泛应用。
-
任务调度: 操作系统中的进程/线程调度,等待CPU的进程排队。
-
消息队列: 进程间通信(IPC),消息的发送和接收。
-
缓冲区: 数据流的缓冲,如网络数据包的接收队列。
-
广度优先搜索(BFS): 图的遍历算法,需要使用队列来存储待访问的节点。
-
打印队列: 多个打印任务排队等待打印机。
-
嵌入式中: 队列常用于处理中断数据、传感器数据、通信协议栈的数据包等,是实现异步通信和事件驱动的重要手段。
-
2.2.3 栈与队列对比
特性 |
栈(Stack) |
队列(Queue) |
---|---|---|
操作原则 |
后进先出(LIFO) |
先进先出(FIFO) |
操作端点 |
栈顶(Top) |
队头(Front)和队尾(Rear) |
基本操作 |
Push(入栈),Pop(出栈),Peek(查看栈顶) |
Enqueue(入队),Dequeue(出队),Front(查看队头) |
实现方式 |
数组(固定容量),链表(动态容量) |
数组(循环队列),链表(动态容量) |
时间复杂度 |
所有基本操作均为 O(1) |
所有基本操作均为 O(1) |
典型应用 |
函数调用,表达式求值,括号匹配,DFS,回溯 |
任务调度,消息队列,缓冲区,BFS |
小结: 栈和队列是两种重要的线性数据结构,它们通过限制数据操作的顺序,实现了独特的逻辑。栈的LIFO特性使其适用于需要“回溯”或“撤销”的场景,而队列的FIFO特性使其成为处理“排队”或“顺序执行”任务的理想选择。在嵌入式编程中,它们是构建高效、可靠的系统不可或缺的工具。
2.3 树:非线性的“家族图谱”
兄弟们,如果说数组和链表是线性的“一维”数据结构,那么树就是非线性的“多维”数据结构了!它能更好地表达数据之间的层级关系和父子关系,就像一个庞大的“家族图谱”。《算法导论》中,树是重头戏,特别是二叉树及其各种变体,它们是构建高效算法的“骨架”。
2.3.1 树的基本概念:家族成员的“称谓”
在深入各种树之前,我们先统一一下树的“家族称谓”:
-
节点(Node): 树的基本组成单位,存储数据。
-
根节点(Root Node): 树的最顶端节点,没有父节点。一棵树只有一个根节点。
-
父节点(Parent Node): 拥有子节点的节点。
-
子节点(Child Node): 拥有父节点的节点。
-
兄弟节点(Sibling Nodes): 拥有相同父节点的节点。
-
叶子节点(Leaf Node): 没有子节点的节点。
-
边(Edge): 连接两个节点的线。
-
路径(Path): 从一个节点到另一个节点所经过的边的序列。
-
深度(Depth): 从根节点到某个节点的路径长度(边的数量)。根节点的深度为0。
-
高度(Height): 从某个节点到其最远叶子节点的最长路径长度。叶子节点的高度为0。树的高度是根节点的高度。
-
度(Degree): 一个节点拥有的子节点的数量。树的度是所有节点度的最大值。
-
森林(Forest): 零棵或多棵不相交的树的集合。
思维导图:树的基本概念
graph TD
A[树] --> B[节点];
B --> B1[根节点];
B --> B2[父节点];
B --> B3[子节点];
B --> B4[兄弟节点];
B --> B5[叶子节点];
A --> C[边];
A --> D[路径];
A --> E[深度];
A --> F[高度];
A --> G[度];
A --> H[森林];
2.3.2 二叉树:最常见的“分叉树”
-
定义: 二叉树(Binary Tree)是一种特殊的树,每个节点最多只有两个子节点,分别称为左子节点(Left Child)和右子节点(Right Child)。
-
特殊二叉树类型:
-
满二叉树(Full Binary Tree): 除了叶子节点外,所有节点都有两个子节点。
-
完全二叉树(Complete Binary Tree): 除最后一层外,其他各层节点数都达到最大值;且最后一层节点都靠左排列。
-
平衡二叉树(Balanced Binary Tree): 左右子树的高度差的绝对值不超过1。
-
-
遍历方式: 遍历二叉树就是按照特定顺序访问树中的所有节点。这是面试和笔试的常考点!
-
前序遍历(Pre-order Traversal): 根 -> 左子树 -> 右子树
-
应用: 复制一棵树,表达式树的前缀表达式。
-
-
中序遍历(In-order Traversal): 左子树 -> 根 -> 右子树
-
应用: 对二叉查找树进行中序遍历可以得到有序序列。
-
-
后序遍历(Post-order Traversal): 左子树 -> 右子树 -> 根
-
应用: 删除树,表达式树的后缀表达式。
-
-
层序遍历(Level-order Traversal): 从上到下,从左到右,逐层访问节点。
-
应用: 查找最短路径(在无权图中),广度优先搜索(BFS)的特例。
-
-
概念性C代码:二叉树的递归和非递归遍历
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// --- 栈的辅助实现 (用于非递归遍历) ---
#define STACK_MAX_SIZE 100 // 假设树的最大节点数
typedef struct StackNode_Tree {
void *data; // 存储任意类型指针,这里是Node*
struct StackNode_Tree *next;
} StackNode_Tree;
typedef struct Stack_Tree {
StackNode_Tree *top;
} Stack_Tree;
void init_stack_tree(Stack_Tree *s) { s->top = NULL; }
bool is_stack_empty_tree(const Stack_Tree *s) { return s->top == NULL; }
bool push_stack_tree(Stack_Tree *s, void *item) {
StackNode_Tree *new_node = (StackNode_Tree *)malloc(sizeof(StackNode_Tree));
if (new_node == NULL) { perror("栈内存分配失败"); return false; }
new_node->data = item;
new_node->next = s->top;
s->top = new_node;
return true;
}
void *pop_stack_tree(Stack_Tree *s) {
if (is_stack_empty_tree(s)) { return NULL; }
StackNode_Tree *temp = s->top;
void *item = temp->data;
s->top = temp->next;
free(temp);
return item;
}
void free_stack_tree(Stack_Tree *s) {
while (!is_stack_empty_tree(s)) {
pop_stack_tree(s); // 仅释放节点内存,不释放数据
}
}
// --- 队列的辅助实现 (用于层序遍历) ---
typedef struct QueueNode_Tree {
void *data; // 存储任意类型指针,这里是Node*
struct QueueNode_Tree *next;
} QueueNode_Tree;
typedef struct Queue_Tree {
QueueNode_Tree *front;
QueueNode_Tree *rear;
} Queue_Tree;
void init_queue_tree(Queue_Tree *q) { q->front = NULL; q->rear = NULL; }
bool is_queue_empty_tree(const Queue_Tree *q) { return q->front == NULL; }
bool enqueue_queue_tree(Queue_Tree *q, void *item) {
QueueNode_Tree *new_node = (QueueNode_Tree *)malloc(sizeof(QueueNode_Tree));
if (new_node == NULL) { perror("队列内存分配失败"); return false; }
new_node->data = item;
new_node->next = NULL;
if (is_queue_empty_tree(q)) {
q->front = new_node;
q->rear = new_node;
} else {
q->rear->next = new_node;
q->rear = new_node;
}
return true;
}
void *dequeue_queue_tree(Queue_Tree *q) {
if (is_queue_empty_tree(q)) { return NULL; }
QueueNode_Tree *temp = q->front;
void *item = temp->data;
q->front = temp->next;
if (q->front == NULL) { q->rear = NULL; }
free(temp);
return item;
}
void free_queue_tree(Queue_Tree *q) {
while (!is_queue_empty_tree(q)) {
dequeue_queue_tree(q); // 仅释放节点内存,不释放数据
}
}
// --- 二叉树节点结构体 ---
typedef struct TreeNode {
int data;
struct TreeNode *left; // 左子节点
struct TreeNode *right; // 右子节点
} TreeNode;
/**
* @brief 创建一个新的二叉树节点
* @param data 节点中存储的数据
* @return 指向新创建节点的指针,如果内存分配失败则返回NULL
*/
TreeNode *create_tree_node(int data) {
TreeNode *new_node = (TreeNode *)malloc(sizeof(TreeNode));
if (new_node == NULL) {
perror("树节点内存分配失败");
return NULL;
}
new_node->data = data;
new_node->left = NULL;
new_node->right = NULL;
return new_node;
}
/**
* @brief 递归前序遍历 (根 -> 左 -> 右)
* @param root 树的根节点
*/
void preorder_traversal_recursive(TreeNode *root) {
if (root == NULL) {
return;
}
printf("%d ", root->data); // 访问根节点
preorder_traversal_recursive(root->left); // 递归遍历左子树
preorder_traversal_recursive(root->right); // 递归遍历右子树
}
/**
* @brief 递归中序遍历 (左 -> 根 -> 右)
* @param root 树的根节点
*/
void inorder_traversal_recursive(TreeNode *root) {
if (root == NULL) {
return;
}
inorder_traversal_recursive(root->left); // 递归遍历左子树
printf("%d ", root->data); // 访问根节点
inorder_traversal_recursive(root->right); // 递归遍历右子树
}
/**
* @brief 递归后序遍历 (左 -> 右 -> 根)
* @param root 树的根节点
*/
void postorder_traversal_recursive(TreeNode *root) {
if (root == NULL) {
return;
}
postorder_traversal_recursive(root->left); // 递归遍历左子树
postorder_traversal_recursive(root->right); // 递归遍历右子树
printf("%d ", root->data); // 访问根节点
}
/**
* @brief 非递归前序遍历 (使用栈)
* @param root 树的根节点
*/
void preorder_traversal_iterative(TreeNode *root) {
if (root == NULL) return;
Stack_Tree s;
init_stack_tree(&s);
push_stack_tree(&s, root);
while (!is_stack_empty_tree(&s)) {
TreeNode *current = (TreeNode *)pop_stack_tree(&s);
printf("%d ", current->data);
// 先压右子节点,再压左子节点,这样出栈时左子节点先出
if (current->right != NULL) {
push_stack_tree(&s, current->right);
}
if (current->left != NULL) {
push_stack_tree(&s, current->left);
}
}
free_stack_tree(&s);
}
/**
* @brief 非递归中序遍历 (使用栈)
* @param root 树的根节点
*/
void inorder_traversal_iterative(TreeNode *root) {
if (root == NULL) return;
Stack_Tree s;
init_stack_tree(&s);
TreeNode *current = root;
while (current != NULL || !is_stack_empty_tree(&s)) {
// 一直向左走,并将路径上的节点入栈
while (current != NULL) {
push_stack_tree(&s, current);
current = current->left;
}
// 左子树为空,出栈一个节点,访问它,然后转向右子树
current = (TreeNode *)pop_stack_tree(&s);
printf("%d ", current->data);
current = current->right;
}
free_stack_tree(&s);
}
/**
* @brief 非递归后序遍历 (使用两个栈)
* @param root 树的根节点
*/
void postorder_traversal_iterative(TreeNode *root) {
if (root == NULL) return;
Stack_Tree s1, s2;
init_stack_tree(&s1);
init_stack_tree(&s2);
push_stack_tree(&s1, root);
// 第一个栈s1用于模拟前序遍历 (根右左),将结果压入s2
while (!is_stack_empty_tree(&s1)) {
TreeNode *current = (TreeNode *)pop_stack_tree(&s1);
push_stack_tree(&s2, current); // 将根节点压入第二个栈
if (current->left != NULL) {
push_stack_tree(&s1, current->left);
}
if (current->right != NULL) {
push_stack_tree(&s1, current->right);
}
}
// 第二个栈s2的出栈顺序就是后序遍历的结果
while (!is_stack_empty_tree(&s2)) {
TreeNode *current = (TreeNode *)pop_stack_tree(&s2);
printf("%d ", current->data);
}
free_stack_tree(&s1);
free_stack_tree(&s2);
}
/**
* @brief 层序遍历 (使用队列)
* @param root 树的根节点
*/
void levelorder_traversal(TreeNode *root) {
if (root == NULL) return;
Queue_Tree q;
init_queue_tree(&q);
enqueue_queue_tree(&q, root);
while (!is_queue_empty_tree(&q)) {
TreeNode *current = (TreeNode *)dequeue_queue_tree(&q);
printf("%d ", current->data);
if (current->left != NULL) {
enqueue_queue_tree(&q, current->left);
}
if (current->right != NULL) {
enqueue_queue_tree(&q, current->right);
}
}
free_queue_tree(&q);
}
/**
* @brief 释放二叉树所有节点的内存 (后序遍历方式释放)
* @param root 树的根节点
*/
void free_tree(TreeNode *root) {
if (root == NULL) {
return;
}
free_tree(root->left);
free_tree(root->right);
// printf("释放节点: %d\n", root->data); // 调试用
free(root);
}
// 主函数用于测试二叉树遍历
int main_binary_tree_traversal() {
printf("--- 二叉树遍历示例 ---\n");
// 构建一个简单的二叉树
// 1
// / \
// 2 3
// / \
// 4 5
TreeNode *root = create_tree_node(1);
root->left = create_tree_node(2);
root->right = create_tree_node(3);
root->left->left = create_tree_node(4);
root->left->right = create_tree_node(5);
printf("递归前序遍历 (根->左->右): ");
preorder_traversal_recursive(root);
printf("\n");
printf("非递归前序遍历 (根->左->右): ");
preorder_traversal_iterative(root);
printf("\n");
printf("递归中序遍历 (左->根->右): ");
inorder_traversal_recursive(root);
printf("\n");
printf("非递归中序遍历 (左->根->右): ");
inorder_traversal_iterative(root);
printf("\n");
printf("递归后序遍历 (左->右->根): ");
postorder_traversal_recursive(root);
printf("\n");
printf("非递归后序遍历 (左->右->根): ");
postorder_traversal_iterative(root);
printf("\n");
printf("层序遍历 (从上到下,从左到右): ");
levelorder_traversal(root);
printf("\n");
// 释放树的内存
free_tree(root);
printf("二叉树内存已释放。\n");
printf("--- 二叉树遍历示例结束 ---\n");
return 0;
}
代码分析与说明:
-
TreeNode
结构体: 定义了二叉树节点,包含data
、left
(左子节点指针)和right
(右子节点指针)。 -
create_tree_node
: 辅助函数,用于创建新节点并分配内存。 -
递归遍历:
-
前序、中序、后序遍历的递归实现都非常简洁,直接按照“根-左-右”的顺序访问节点和递归调用。这是理解树遍历最直观的方式。
-
时间复杂度: 每次访问一个节点,每个节点访问一次,所以都是 O(n),其中 n 是节点数量。
-
空间复杂度: 递归调用会使用系统栈,最坏情况下(退化为链表的树)递归深度为 O(n),平均情况下为 O(logn)(平衡树)。
-
-
非递归遍历:
-
非递归前序遍历: 使用一个栈。先将根节点入栈。然后循环:出栈一个节点并访问,然后先将右子节点(如果存在)入栈,再将左子节点(如果存在)入栈。这样确保左子节点先于右子节点出栈。
-
非递归中序遍历: 使用一个栈。从根节点开始,一路向左,将沿途节点入栈。当到达最左边的节点(左子树为空)时,出栈一个节点并访问,然后转向其右子树。
-
非递归后序遍历: 相对复杂,通常使用两个栈。第一个栈用于模拟前序遍历的“根-右-左”顺序,将结果压入第二个栈。第二个栈的出栈顺序就是后序遍历的“左-右-根”顺序。
-
非递归遍历的优势: 避免递归深度过大导致的栈溢出问题,在某些内存受限或对栈空间有严格要求的嵌入式环境中可能更适用。
-
辅助栈/队列的实现: 代码中为了演示非递归遍历,单独实现了简单的栈和队列用于存储
TreeNode*
指针。在实际项目中,可以复用之前实现的栈和队列。
-
-
层序遍历:
-
使用一个队列。先将根节点入队。然后循环:出队一个节点并访问,然后将其左子节点和右子节点(如果存在)依次入队。
-
时间复杂度: O(n)。
-
空间复杂度: O(w),其中 w 是树的最大宽度。最坏情况下(满二叉树的最后一层)为 O(n)。
-
-
free_tree
: 递归地释放树的所有节点内存。通常采用后序遍历的方式释放,确保子节点先于父节点被释放。 -
做题编程随想录:
-
面试常考: 二叉树的四种遍历方式,尤其是非递归实现。
-
应用:
-
表达式树: 用于表示数学表达式,遍历可以得到前缀、中缀、后缀表达式。
-
文件系统: 目录结构可以看作一棵树。
-
堆: 一种特殊的完全二叉树。
-
二叉查找树: 后续会讲,用于高效查找。
-
-
嵌入式中: 树结构可以用于组织配置信息、数据索引、状态机等。但要特别注意递归深度和内存分配,非递归实现和内存池技术会更常用。
-
2.3.3 二叉查找树(BST):有序的“搜索利器”
-
定义: 二叉查找树(Binary Search Tree, BST),又称二叉搜索树、有序二叉树,是一种特殊的二叉树,它满足以下性质:
-
若任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。
-
若任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。
-
任意节点的左、右子树也分别为二叉查找树。
-
没有键值相等的节点(或规定相等的值放在左子树或右子树)。
-
-
特点: 使得查找、插入、删除操作非常高效。
-
性能分析:
-
查找、插入、删除操作的平均时间复杂度: O(logn) (当树平衡时)。
-
查找、插入、删除操作的最坏时间复杂度: O(n) (当树退化为链表时,例如插入一个有序序列)。
-
-
应用场景: 数据库索引、字典、集合、需要快速查找、插入、删除有序数据的场景。
概念性C代码:二叉查找树的插入、查找、删除
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// 二叉查找树节点结构体 (与普通二叉树节点相同)
typedef struct BSTNode {
int data;
struct BSTNode *left;
struct BSTNode *right;
} BSTNode;
/**
* @brief 创建一个新的二叉查找树节点
* @param data 节点中存储的数据
* @return 指向新创建节点的指针,如果内存分配失败则返回NULL
*/
BSTNode *create_bst_node(int data) {
BSTNode *new_node = (BSTNode *)malloc(sizeof(BSTNode));
if (new_node == NULL) {
perror("BST节点内存分配失败");
return NULL;
}
new_node->data = data;
new_node->left = NULL;
new_node->right = NULL;
return new_node;
}
/**
* @brief 在二叉查找树中插入元素 (递归实现)
* @param root 当前子树的根节点
* @param data 要插入的数据
* @return 插入新节点后的根节点 (可能不变或为新节点)
* @note 时间复杂度:平均O(log n),最坏O(n)。
*/
BSTNode *insert_bst_recursive(BSTNode *root, int data) {
// 基本情况:如果当前节点为空,则创建新节点并返回
if (root == NULL) {
return create_bst_node(data);
}
// 递归步:根据数据大小决定向左或向右子树插入
if (data < root->data) {
root->left = insert_bst_recursive(root->left, data);
} else if (data > root->data) {
root->right = insert_bst_recursive(root->right, data);
}
// 如果 data == root->data,通常不处理(不插入重复元素)或根据需求决定
// 这里我们假设不插入重复元素,或者如果允许重复,可以放在左子树或右子树
// 为了简化,我们这里假设不处理重复元素,直接返回原root
return root; // 返回当前子树的根节点
}
/**
* @brief 在二叉查找树中查找元素 (递归实现)
* @param root 当前子树的根节点
* @param data 要查找的数据
* @return 指向找到节点的指针,如果未找到则返回NULL
* @note 时间复杂度:平均O(log n),最坏O(n)。
*/
BSTNode *search_bst_recursive(BSTNode *root, int data) {
// 基本情况1: 树为空或找到目标数据
if (root == NULL || root->data == data) {
return root;
}
// 递归步:根据数据大小决定向左或向右子树查找
if (data < root->data) {
return search_bst_recursive(root->left, data);
} else {
return search_bst_recursive(root->right, data);
}
}
/**
* @brief 查找二叉查找树中最小值的节点
* @param node 当前子树的根节点
* @return 指向最小节点的指针
*/
BSTNode *find_min_node(BSTNode *node) {
BSTNode *current = node;
// 最小节点在最左边的叶子节点
while (current && current->left != NULL) {
current = current->left;
}
return current;
}
/**
* @brief 在二叉查找树中删除元素 (递归实现)
* @param root 当前子树的根节点
* @param data 要删除的数据
* @return 删除节点后的根节点 (可能改变)
* @note 时间复杂度:平均O(log n),最坏O(n)。
* 删除操作是BST中最复杂的操作,需要处理三种情况:
* 1. 节点没有子节点 (叶子节点)
* 2. 节点只有一个子节点
* 3. 节点有两个子节点
*/
BSTNode *delete_bst_recursive(BSTNode *root, int data) {
if (root == NULL) { // 基本情况:树为空或未找到要删除的节点
return root;
}
// 递归步:查找要删除的节点
if (data < root->data) {
root->left = delete_bst_recursive(root->left, data);
} else if (data > root->data) {
root->right = delete_bst_recursive(root->right, data);
} else { // 找到要删除的节点 (root 就是要删除的节点)
// 情况1: 节点没有子节点 或 只有一个子节点
if (root->left == NULL) { // 只有右子节点或没有子节点
BSTNode *temp = root->right;
free(root);
return temp; // 返回右子节点 (或NULL)
} else if (root->right == NULL) { // 只有左子节点
BSTNode *temp = root->left;
free(root);
return temp; // 返回左子节点
}
// 情况3: 节点有两个子节点
// 找到右子树中最小的节点 (即中序遍历的后继节点)
// 或者找到左子树中最大的节点 (即中序遍历的前驱节点)
BSTNode *temp = find_min_node(root->right); // 找到右子树的最小节点
// 将右子树最小节点的数据复制到当前要删除的节点
root->data = temp->data;
// 递归删除右子树中最小的节点 (因为它的数据已经移到root了)
root->right = delete_bst_recursive(root->right, temp->data);
}
return root; // 返回当前子树的根节点
}
/**
* @brief 递归中序遍历二叉查找树 (左 -> 根 -> 右)
* @param root 树的根节点
* @note 中序遍历BST会得到一个有序序列。
*/
void inorder_traverse_bst(BSTNode *root) {
if (root == NULL) {
return;
}
inorder_traverse_bst(root->left);
printf("%d ", root->data);
inorder_traverse_bst(root->right);
}
/**
* @brief 释放二叉查找树所有节点的内存
* @param root 树的根节点
*/
void free_bst(BSTNode *root) {
if (root == NULL) {
return;
}
free_bst(root->left);
free_bst(root->right);
free(root);
}
// 主函数用于测试二叉查找树操作
int main_bst_operations() {
printf("--- 二叉查找树 (BST) 操作示例 ---\n");
BSTNode *root = NULL; // 初始化空BST
printf("插入元素: 50, 30, 70, 20, 40, 60, 80\n");
root = insert_bst_recursive(root, 50);
root = insert_bst_recursive(root, 30);
root = insert_bst_recursive(root, 70);
root = insert_bst_recursive(root, 20);
root = insert_bst_recursive(root, 40);
root = insert_bst_recursive(root, 60);
root = insert_bst_recursive(root, 80);
printf("中序遍历 (应为有序): ");
inorder_traverse_bst(root); // 20 30 40 50 60 70 80
printf("\n\n");
// 查找元素
int search_val = 40;
BSTNode *found_node = search_bst_recursive(root, search_val);
if (found_node) {
printf("查找 %d: 找到\n", search_val);
} else {
printf("查找 %d: 未找到\n", search_val);
}
search_val = 90;
found_node = search_bst_recursive(root, search_val);
if (found_node) {
printf("查找 %d: 找到\n", search_val);
} else {
printf("查找 %d: 未找到\n", search_val);
}
printf("\n");
// 删除叶子节点 (20)
printf("删除叶子节点 20\n");
root = delete_bst_recursive(root, 20);
printf("中序遍历: ");
inorder_traverse_bst(root); // 30 40 50 60 70 80
printf("\n\n");
// 删除只有一个子节点的节点 (70,其右子节点是80)
printf("删除只有一个子节点的节点 70\n");
root = delete_bst_recursive(root, 70);
printf("中序遍历: ");
inorder_traverse_bst(root); // 30 40 50 60 80
printf("\n\n");
// 删除有两个子节点的节点 (50)
printf("删除有两个子节点的节点 50\n");
root = delete_bst_recursive(root, 50);
printf("中序遍历: ");
inorder_traverse_bst(root); // 30 40 60 80 (原来的50被60取代,然后60从右子树删除)
printf("\n\n");
// 再次删除,直到空
printf("删除 30, 40, 60, 80\n");
root = delete_bst_recursive(root, 30);
root = delete_bst_recursive(root, 40);
root = delete_bst_recursive(root, 60);
root = delete_bst_recursive(root, 80);
printf("中序遍历: ");
inorder_traverse_bst(root);
printf("\n");
// 释放内存 (如果上面没删完,这里确保释放)
free_bst(root);
printf("BST内存已释放。\n");
printf("--- 二叉查找树 (BST) 操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
BSTNode
结构体: 与普通二叉树节点相同,但其数据组织方式遵循BST的性质。 -
create_bst_node
: 辅助函数,创建新节点。 -
insert_bst_recursive(BSTNode *root, int data)
:-
递归实现: 简单直观。
-
基本情况: 如果
root
为NULL
,说明找到了插入位置,创建新节点并返回。 -
递归步: 如果
data
小于root->data
,则向左子树递归插入;如果data
大于root->data
,则向右子树递归插入。 -
重复元素: 代码中简单地忽略了重复元素。实际应用中,可以根据需求选择:允许重复(放在左子树或右子树),或更新现有节点,或报错。
-
-
search_bst_recursive(BSTNode *root, int data)
:-
递归实现: 同样简单直观。
-
基本情况:
root
为NULL
(未找到)或root->data == data
(找到)。 -
递归步: 根据
data
与root->data
的比较结果,向左或向右子树递归查找。
-
-
delete_bst_recursive(BSTNode *root, int data)
:-
这是BST操作中最复杂的部分。 需要处理三种情况:
-
删除叶子节点: 直接释放节点,并将其父节点的相应指针置为
NULL
。 -
删除只有一个子节点的节点: 将该节点的子节点直接连接到其父节点上,然后释放该节点。
-
删除有两个子节点的节点: 这是最复杂的情况。
-
策略: 找到其中序遍历的后继节点(即右子树中最小的节点),或者中序遍历的前驱节点(即左子树中最大的节点)。
-
代码中采用了找到右子树中最小的节点 (
find_min_node(root->right)
)。 -
将这个“后继节点”的数据复制到要删除的节点中。
-
然后,递归地从右子树中删除这个“后继节点”(因为它现在是重复的,且它必然是叶子节点或只有一个右子节点的节点,简化了删除)。
-
-
-
find_min_node
: 辅助函数,用于在子树中找到最小值的节点(即最左边的节点)。
-
-
inorder_traverse_bst
: 中序遍历BST会得到一个有序序列,这是BST的重要特性。 -
free_bst
: 递归释放所有节点内存。 -
做题编程随想录:
-
面试高频: BST的插入、查找、删除操作,特别是删除操作的细节。
-
性能瓶颈: BST的性能高度依赖于树的结构。如果插入的数据是严格有序的,BST会退化成一条链表,导致所有操作的时间复杂度都变为 O(n),失去了其优势。
-
引出平衡树: 正是因为BST可能退化,才引出了平衡二叉查找树的概念,如AVL树和红黑树,它们通过旋转等操作,在插入和删除时动态调整树的结构,保证树的高度保持在 O(logn),从而确保操作的平均时间复杂度为 O(logn)。
-
2.3.4 平衡二叉查找树(AVL/红黑树):自平衡的“搜索保证”
-
为什么需要平衡?
-
正如前面所说,普通的BST在最坏情况下会退化成链表,导致查找、插入、删除操作的效率从 O(logn) 降低到 O(n)。这在实际应用中是不可接受的。
-
平衡二叉查找树通过在插入和删除操作时,自动进行调整(如旋转),来维护树的平衡性,从而保证树的高度始终保持在 O(logn) 级别,进而确保所有基本操作的时间复杂度稳定在 O(logn)。
-
-
AVL树:
-
定义: 最早的自平衡二叉查找树。它要求任意节点的左右子树的高度差的绝对值不超过1。
-
平衡因子: 左子树高度减去右子树高度。平衡因子只能是 -1, 0, 1。
-
旋转操作: 当插入或删除导致平衡因子超出范围时,通过单旋转(LL, RR)或双旋转(LR, RL)来恢复平衡。
-
特点: 平衡性要求严格,旋转次数可能较多,但查找效率非常高。
-
-
红黑树(Red-Black Tree):
-
定义: 一种自平衡二叉查找树,它在每个节点上增加一个颜色属性(红色或黑色),并通过遵循以下五条性质来保持平衡:
-
每个节点要么是红色,要么是黑色。
-
根节点是黑色。
-
每个叶子节点(NIL节点,空节点)是黑色。
-
如果一个节点是红色,则它的子节点必须是黑色(不能有两个连续的红色节点)。
-
从任意节点到其每个叶子节点的所有路径都包含相同数量的黑色节点。
-
-
平衡维护: 通过颜色翻转和旋转操作来维护性质。
-
特点: 平衡性不如AVL树严格(高度差可能达到2倍),但插入和删除的平均旋转次数更少,因此在实际应用中(如C++ STL的
map
/set
,Java的TreeMap
/TreeSet
,Linux内核调度器)更常用。
-
-
应用场景:
-
数据库索引: B树/B+树是多叉平衡树,适用于磁盘存储。
-
文件系统: 某些文件系统内部使用树结构。
-
关联数组/字典/映射: C++
std::map
, JavaTreeMap
底层都是红黑树。 -
内存管理: 某些内存分配器使用平衡树来管理空闲内存块。
-
操作系统内核: Linux内核中广泛使用红黑树来管理进程调度、虚拟内存区域等。
-
做题编程随想录:
-
面试: 对于平衡树,通常不会要求你手写实现(太复杂),但会要求你理解其核心思想(为什么需要平衡?如何保持平衡?),以及AVL树和红黑树的区别和优缺点。
-
重点: 掌握红黑树的五条性质,理解它们如何保证树的高度在 O(logn)。知道红黑树在实际应用中比AVL树更受欢迎的原因(虽然平衡性稍弱,但插入删除的平均性能更好)。
-
拓展: 多叉平衡树(B树、B+树)是数据库索引的基石,理解它们如何减少磁盘I/O次数。
小结: 树是非线性数据结构,能够高效地表达层级关系。二叉树是最基础的树形结构,其遍历方式是必考点。二叉查找树通过有序性实现了高效的查找、插入、删除,但其性能依赖于树的平衡性。平衡二叉查找树(如AVL树和红黑树)则通过自平衡机制,保证了操作的稳定 O(logn) 性能,是高级算法和系统设计中不可或缺的利器。
2.4 堆:优先队列的“幕后英雄”
兄弟们,咱们在排序算法那一节已经见识过堆(Heap)的威力了,它可是堆排序的“核心武器”!但堆不仅仅是用来排序的,它更是一种非常重要的数据结构,尤其在实现**优先队列(Priority Queue)**时,它简直是“幕后英雄”!
2.4.1 堆的定义与性质:特殊的“完全二叉树”
-
定义: 堆是一个近似完全二叉树的结构,同时满足堆序性(Heap Property):
-
最大堆(Max-Heap): 任意父节点的值都大于或等于其子节点的值。堆顶(根节点)是所有元素中的最大值。
-
最小堆(Min-Heap): 任意父节点的值都小于或等于其子节点的值。堆顶(根节点)是所有元素中的最小值。
-
-
存储方式: 由于堆是完全二叉树,它通常使用数组来存储,非常节省空间。
-
对于索引为
i
的节点:-
其父节点索引为
(i - 1) / 2
-
其左子节点索引为
2 * i + 1
-
其右子节点索引为
2 * i + 2
-
-
-
特点: 堆只保证父节点和子节点之间的相对大小关系,不保证兄弟节点之间的关系,也不保证非父子节点之间的关系。
思维导图:堆的结构与性质
graph TD
A[堆] --> B[近似完全二叉树];
A --> C[堆序性];
C --> C1[最大堆];
C --> C2[最小堆];
B --> D[通常用数组实现];
D --> D1[父节点: (i-1)/2];
D --> D2[左子节点: 2i+1];
D --> D3[右子节点: 2i+2];
2.4.2 堆的基本操作:维护“堆序性”
堆的核心操作就是维护其堆序性,主要通过“上浮”和“下沉”来实现。
-
插入元素(Insert):
-
将新元素插入到堆的末尾(数组的下一个空闲位置),以保持完全二叉树的结构。
-
然后,将新元素与其父节点比较,如果违反堆序性,则进行**上浮(Swim / Sift-up)**操作:不断将该元素与父节点交换,直到满足堆序性或到达堆顶。
-
时间复杂度:O(logn) (堆的高度)。
-
-
删除最大/最小元素(Extract-Max / Extract-Min):
-
通常是删除堆顶元素(最大堆删除最大值,最小堆删除最小值)。
-
将堆顶元素取出。
-
将堆的最后一个元素移动到堆顶,以保持完全二叉树的结构。
-
然后,对新的堆顶元素进行**下沉(Sink / Sift-down)**操作:不断将该元素与其最大的子节点(最大堆)或最小的子节点(最小堆)交换,直到满足堆序性或到达叶子节点。
-
时间复杂度:O(logn) (堆的高度)。
-
-
构建堆(Build Heap):
-
将一个无序数组构建成一个堆。
-
从最后一个非叶子节点开始,向上遍历到根节点,对每个节点执行**下沉(Sift-down)**操作。
-
时间复杂度:O(n) (虽然有 n/2 个节点需要下沉,每个下沉操作是 O(logn),但由于大部分节点都在树的底部,总和是线性的)。
-
概念性C代码:最大堆的插入和删除最大元素
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// 辅助函数:交换两个整数的值
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
// --- 最大堆实现 ---
#define MAX_HEAP_CAPACITY 100 // 堆的最大容量
typedef struct MaxHeap {
int *arr; // 存储堆元素的数组
int size; // 堆中当前元素的数量
int capacity; // 堆的容量
} MaxHeap;
/**
* @brief 初始化一个最大堆
* @param heap 指向MaxHeap结构体的指针
* @param capacity 堆的容量
* @return true 初始化成功,false 内存分配失败
*/
bool init_max_heap(MaxHeap *heap, int capacity) {
heap->arr = (int *)malloc(capacity * sizeof(int));
if (heap->arr == NULL) {
perror("堆内存分配失败");
return false;
}
heap->size = 0;
heap->capacity = capacity;
printf("最大堆已初始化,容量: %d\n", capacity);
return true;
}
/**
* @brief 判断最大堆是否为空
* @param heap 指向MaxHeap结构体的指针
* @return true 如果堆为空,false 否则
*/
bool is_max_heap_empty(const MaxHeap *heap) {
return heap->size == 0;
}
/**
* @brief 判断最大堆是否已满
* @param heap 指向MaxHeap结构体的指针
* @return true 如果堆已满,false 否则
*/
bool is_max_heap_full(const MaxHeap *heap) {
return heap->size == heap->capacity;
}
/**
* @brief 堆化操作:元素上浮 (Swim / Sift-up)
* 当一个元素插入到堆的末尾后,通过上浮操作维护最大堆的性质。
* @param heap 指向MaxHeap结构体的指针
* @param index 要上浮的元素的当前索引
* @note 时间复杂度 O(log n)。
*/
void heap_swim(MaxHeap *heap, int index) {
// 当当前元素不是根节点 (index > 0) 且大于其父节点时,进行交换
// 父节点索引为 (index - 1) / 2
while (index > 0 && heap->arr[index] > heap->arr[(index - 1) / 2]) {
swap(&heap->arr[index], &heap->arr[(index - 1) / 2]);
index = (index - 1) / 2; // 更新索引到新的位置
}
}
/**
* @brief 堆化操作:元素下沉 (Sink / Sift-down)
* 当堆顶元素被替换或某个元素变小后,通过下沉操作维护最大堆的性质。
* @param heap 指向MaxHeap结构体的指针
* @param index 要下沉的元素的当前索引
* @note 时间复杂度 O(log n)。
*/
void heap_sink(MaxHeap *heap, int index) {
int largest = index; // 假设当前节点是最大的
int left_child = 2 * index + 1;
int right_child = 2 * index + 2;
// 找到当前节点、左子节点、右子节点中最大的那个
if (left_child < heap->size && heap->arr[left_child] > heap->arr[largest]) {
largest = left_child;
}
if (right_child < heap->size && heap->arr[right_child] > heap->arr[largest]) {
largest = right_child;
}
// 如果最大值不是当前节点,则交换并递归下沉
if (largest != index) {
swap(&heap->arr[index], &heap->arr[largest]);
heap_sink(heap, largest); // 递归调用,确保交换后的子树也满足堆性质
}
}
/**
* @brief 向最大堆中插入元素
* @param heap 指向MaxHeap结构体的指针
* @param item 要插入的元素
* @return true 插入成功,false 堆已满
* @note 时间复杂度 O(log n)。
*/
bool insert_max_heap(MaxHeap *heap, int item) {
if (is_max_heap_full(heap)) {
printf("错误: 堆已满,无法插入 %d\n", item);
return false;
}
heap->arr[heap->size] = item; // 将新元素放到数组末尾
heap_swim(heap, heap->size); // 对新元素进行上浮操作
heap->size++;
printf("插入堆: %d\n", item);
return true;
}
/**
* @brief 从最大堆中提取最大元素 (堆顶元素)
* @param heap 指向MaxHeap结构体的指针
* @param item_ptr 用于存储提取元素的指针
* @return true 提取成功,false 堆为空
* @note 时间复杂度 O(log n)。
*/
bool extract_max_heap(MaxHeap *heap, int *item_ptr) {
if (is_max_heap_empty(heap)) {
printf("错误: 堆为空,无法提取最大元素\n");
return false;
}
*item_ptr = heap->arr[0]; // 堆顶元素就是最大值
heap->size--; // 缩小堆的范围
if (heap->size > 0) {
heap->arr[0] = heap->arr[heap->size]; // 将堆的最后一个元素移到堆顶
heap_sink(heap, 0); // 对新的堆顶元素进行下沉操作
}
printf("提取最大元素: %d\n", *item_ptr);
return true;
}
/**
* @brief 查看最大堆的堆顶元素 (最大值)
* @param heap 指向MaxHeap结构体的指针
* @param item_ptr 用于存储堆顶元素的指针
* @return true 查看成功,false 堆为空
* @note 时间复杂度 O(1)。
*/
bool peek_max_heap(const MaxHeap *heap, int *item_ptr) {
if (is_max_heap_empty(heap)) {
printf("错误: 堆为空,无法查看堆顶元素\n");
return false;
}
*item_ptr = heap->arr[0];
printf("堆顶元素 (最大值): %d\n", *item_ptr);
return true;
}
/**
* @brief 释放最大堆的内存
* @param heap 指向MaxHeap结构体的指针
*/
void free_max_heap(MaxHeap *heap) {
if (heap->arr != NULL) {
free(heap->arr);
heap->arr = NULL;
}
heap->size = 0;
heap->capacity = 0;
printf("最大堆内存已释放。\n");
}
// 辅助函数:打印堆的数组表示
void print_heap_array(const MaxHeap *heap) {
printf("堆的数组表示 (size=%d): [", heap->size);
for (int i = 0; i < heap->size; i++) {
printf("%d", heap->arr[i]);
if (i < heap->size - 1) {
printf(", ");
}
}
printf("]\n");
}
// 主函数用于测试堆操作
int main_heap_operations() {
printf("--- 堆操作示例 ---\n\n");
MaxHeap my_heap;
if (!init_max_heap(&my_heap, MAX_HEAP_CAPACITY)) {
return EXIT_FAILURE;
}
int item;
peek_max_heap(&my_heap, &item); // 尝试查看空堆
insert_max_heap(&my_heap, 30);
print_heap_array(&my_heap);
insert_max_heap(&my_heap, 20);
print_heap_array(&my_heap);
insert_max_heap(&my_heap, 10);
print_heap_array(&my_heap);
insert_max_heap(&my_heap, 40); // 40会上浮到堆顶
print_heap_array(&my_heap);
peek_max_heap(&my_heap, &item);
insert_max_heap(&my_heap, 5);
print_heap_array(&my_heap);
insert_max_heap(&my_heap, 35); // 35会上浮到20的位置
print_heap_array(&my_heap);
peek_max_heap(&my_heap, &item);
extract_max_heap(&my_heap, &item); // 提取40
print_heap_array(&my_heap);
peek_max_heap(&my_heap, &item);
extract_max_heap(&my_heap, &item); // 提取35
print_heap_array(&my_heap);
peek_max_heap(&my_heap, &item);
// 连续提取直到空
while (!is_max_heap_empty(&my_heap)) {
extract_max_heap(&my_heap, &item);
print_heap_array(&my_heap);
}
extract_max_heap(&my_heap, &item); // 尝试从空堆提取
free_max_heap(&my_heap);
printf("--- 堆操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
MaxHeap
结构体: 包含一个int *arr
指向动态分配的数组,size
记录堆中当前元素数量,capacity
记录数组总容量。 -
init_max_heap
: 初始化堆,分配底层数组内存。 -
heap_swim(MaxHeap *heap, int index)
(上浮操作):-
当新元素插入到数组末尾时,它可能比其父节点大,从而违反最大堆性质。
-
while (index > 0 && heap->arr[index] > heap->arr[(index - 1) / 2])
:循环条件是当前节点不是根节点且大于其父节点。 -
在循环体内,将当前元素与父节点交换,并更新
index
到父节点原来的位置,继续向上比较。 -
这个过程确保了新插入的元素最终达到其正确的位置,维护了堆序性。时间复杂度是 O(logn),因为每次比较都向上移动一层。
-
-
heap_sink(MaxHeap *heap, int index)
(下沉操作):-
当堆顶元素被替换(如
extract_max_heap
)或某个元素变小(如decrease_key
操作)时,可能违反堆序性。 -
largest = index;
:假设当前节点最大。 -
通过比较当前节点与左右子节点,找到三者中最大的元素。
-
if (largest != index)
:如果最大值不是当前节点,说明需要下沉。 -
swap(&heap->arr[index], &heap->arr[largest]);
:交换当前节点与最大的子节点。 -
heap_sink(heap, largest);
:递归调用。因为交换后,原来的最大子节点(现在是当前节点)可能破坏了其子树的堆性质,所以需要对受影响的子树继续下沉。 -
时间复杂度是 O(logn),因为每次比较都向下移动一层。
-
-
insert_max_heap
:-
将新元素添加到数组末尾 (
heap->arr[heap->size] = item;
)。 -
调用
heap_swim
对新元素进行上浮操作。
-
-
extract_max_heap
:-
取出堆顶元素 (
*item_ptr = heap->arr[0];
)。 -
将堆的最后一个元素移到堆顶 (
heap->arr[0] = heap->arr[heap->size];
)。 -
调用
heap_sink
对新的堆顶元素进行下沉操作。
-
-
peek_max_heap
: 查看堆顶元素,时间复杂度 O(1)。 -
free_max_heap
: 释放底层数组内存。 -
做题编程随想录:
-
优先队列: 堆是实现优先队列的绝佳数据结构。优先队列是一种抽象数据类型,它支持插入元素和提取最大/最小元素的操作,并且提取操作总是返回优先级最高(最大或最小)的元素。
-
堆排序: 之前已经讲过,堆排序就是利用堆的
build_heap
和extract_max
操作实现的。 -
Top K 问题: 查找数据集中最大的K个元素或最小的K个元素。可以使用大小为K的最小堆(找最大的K个)或最大堆(找最小的K个)。
-
Dijkstra算法和Prim算法: 在实现这些图算法时,优先队列(通常用堆实现)可以优化其性能。
-
嵌入式中: 堆可以用于实时任务调度(优先级队列)、事件管理、传感器数据过滤(如中值滤波的滑动窗口)、内存管理中的一些高级分配策略等。由于其基于数组的特性,内存开销相对可控。
-
小结: 堆是一种特殊的完全二叉树,通过维护堆序性(最大堆或最小堆)来实现高效的插入和提取最值操作。它是实现优先队列的“幕后英雄”,在排序、Top K 问题和图算法中都有广泛应用。掌握堆的构建、上浮和下沉操作,你就掌握了处理动态最值问题的核心利器。
2.5 散列表(哈希表):快速查找的“魔法字典”
兄弟们,如果你要在一堆数据里快速找到某个东西,比如通过名字找电话号码,或者通过学号找学生信息,你会怎么做?遍历数组太慢,二叉查找树虽然快但还得维护结构。有没有一种“一键直达”的魔法?有!那就是散列表(Hash Table),也叫哈希表,它就像一本“魔法字典”,能让你以近乎 O(1) 的速度进行查找、插入和删除!
2.5.1 散列表的定义与基本思想:键值对的“映射”
-
定义: 散列表(Hash Table)是一种根据键(Key)直接访问数据(Value)的数据结构。它通过**散列函数(Hash Function)**将键映射到表中的一个位置来访问记录,从而实现快速查找。
-
基本思想:
-
键值对(Key-Value Pair): 散列表存储的是一系列键值对。
-
散列函数(Hash Function):
h(key)
。这是一个函数,它接收一个键作为输入,并返回一个整数(散列值/哈希值),这个整数通常是数组的索引。 -
散列地址(Hash Address): 散列函数计算出的索引,即数据在数组中应该存放的位置。
-
数组(Table/Bucket Array): 散列表的底层通常是一个数组,每个数组元素称为一个“桶”(Bucket)。
-
-
理想情况: 如果散列函数能够将不同的键均匀地映射到不同的散列地址,那么查找、插入、删除操作的时间复杂度都可以达到 O(1)。
思维导图:散列表的基本构成
graph TD
A[散列表] --> B[键值对];
A --> C[散列函数 h(key)];
A --> D[散列地址 (数组索引)];
A --> E[底层数组 (桶)];
C --> D;
D --> E;
2.5.2 哈希冲突与解决办法:当“魔法”失灵时
-
哈希冲突(Hash Collision): 不同的键通过散列函数计算出相同的散列地址。这是散列表无法避免的问题,因为键的数量通常远大于散列地址的数量。
-
想象一下,两个不同的名字,通过“首字母”哈希,都映射到了“A”这个位置。
-
-
解决哈希冲突的常用方法:
-
开放寻址法(Open Addressing):
-
当发生冲突时,不是将新元素存放到另一个位置,而是在散列表中寻找下一个空的槽位。
-
探测序列(Probe Sequence): 寻找下一个空槽位的策略。
-
线性探测(Linear Probing): 发生冲突时,顺序地查找下一个空槽位(
h(key) + 1, h(key) + 2, ...
)。-
缺点: 容易形成“聚集”(Clustering),即连续的空闲块被填满,导致后续查找效率下降。
-
-
二次探测(Quadratic Probing): 发生冲突时,按二次方序列查找空槽位(
h(key) + 1^2, h(key) + 2^2, ...
)。-
优点: 缓解了线性聚集。
-
缺点: 可能存在二次聚集(Secondary Clustering)。
-
-
双重散列(Double Hashing): 使用两个散列函数。第一个函数计算初始地址,第二个函数计算步长。
-
优点: 效果最好,能有效避免聚集。
-
-
-
缺点: 删除元素复杂(需要标记为“已删除”而不是直接清空),装载因子(Load Factor)不能太高。
-
-
链地址法(Separate Chaining / Chaining):
-
当发生冲突时,将所有映射到同一个散列地址的元素存储在一个链表中。散列表的每个“桶”不再直接存储元素,而是存储一个链表的头指针。
-
优点:
-
实现相对简单。
-
对装载因子不敏感,可以存储更多元素。
-
删除操作简单。
-
可以存储任意数量的冲突元素。
-
-
缺点:
-
需要额外空间存储链表指针。
-
链表操作(遍历)可能带来性能开销。
-
-
做题编程随想录: 这是最常用、最推荐的哈希冲突解决办法,也是面试中手写哈希表时通常要求实现的方式。
-
-
概念性C代码:基于链地址法的散列表
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <string.h> // For strcmp, strcpy
#include <stdbool.h> // For bool type
// --- 键值对结构体 ---
typedef struct KeyValuePair {
char *key; // 键 (字符串)
int value; // 值 (整数)
} KeyValuePair;
// --- 链表节点结构体 (用于解决哈希冲突) ---
typedef struct HashNode {
KeyValuePair pair;
struct HashNode *next;
} HashNode;
// --- 散列表结构体 ---
typedef struct HashTable {
HashNode **buckets; // 桶数组,每个元素是一个链表头指针
int capacity; // 桶数组的大小
int size; // 散列表中当前键值对的数量
} HashTable;
/**
* @brief 创建一个新的键值对节点
* @param key 键字符串
* @param value 值
* @return 指向新创建节点的指针,如果内存分配失败则返回NULL
*/
HashNode *create_hash_node(const char *key, int value) {
HashNode *new_node = (HashNode *)malloc(sizeof(HashNode));
if (new_node == NULL) {
perror("哈希节点内存分配失败");
return NULL;
}
new_node->pair.key = (char *)malloc(strlen(key) + 1); // 为键分配内存
if (new_node->pair.key == NULL) {
perror("键字符串内存分配失败");
free(new_node);
return NULL;
}
strcpy(new_node->pair.key, key); // 复制键字符串
new_node->pair.value = value;
new_node->next = NULL;
return new_node;
}
/**
* @brief 简单的哈希函数 (字符串转整数,再取模)
* @param key 键字符串
* @param capacity 散列表的容量 (桶的数量)
* @return 散列地址 (桶的索引)
* @note 这是一个非常简单的哈希函数,实际应用中需要更复杂的哈希函数来保证均匀分布。
*/
unsigned int hash_function(const char *key, int capacity) {
unsigned int hash_val = 0;
while (*key != '\0') {
hash_val = (hash_val << 5) + *key++; // 经典的DJB2哈希算法的变种
}
return hash_val % capacity;
}
/**
* @brief 初始化散列表
* @param table 指向HashTable结构体的指针
* @param capacity 散列表的容量 (桶的数量)
* @return true 初始化成功,false 内存分配失败
*/
bool init_hash_table(HashTable *table, int capacity) {
table->buckets = (HashNode **)calloc(capacity, sizeof(HashNode *)); // calloc会初始化为NULL
if (table->buckets == NULL) {
perror("哈希表桶数组内存分配失败");
return false;
}
table->capacity = capacity;
table->size = 0;
printf("散列表已初始化,容量: %d\n", capacity);
return true;
}
/**
* @brief 向散列表中插入键值对
* @param table 指向HashTable结构体的指针
* @param key 键字符串
* @param value 值
* @return true 插入成功,false 内存分配失败
* @note 如果键已存在,则更新其值。时间复杂度:平均O(1),最坏O(n) (链表过长)。
*/
bool hash_table_insert(HashTable *table, const char *key, int value) {
unsigned int index = hash_function(key, table->capacity);
HashNode *current = table->buckets[index];
// 检查键是否已存在 (更新操作)
while (current != NULL) {
if (strcmp(current->pair.key, key) == 0) {
printf("更新键 '%s' 的值从 %d 到 %d\n", key, current->pair.value, value);
current->pair.value = value;
return true;
}
current = current->next;
}
// 键不存在,创建新节点并插入到链表头部 (O(1) 插入到链表头)
HashNode *new_node = create_hash_node(key, value);
if (new_node == NULL) {
return false;
}
new_node->next = table->buckets[index]; // 新节点指向原链表头
table->buckets[index] = new_node; // 更新桶的头指针
table->size++;
printf("插入键值对: ('%s', %d)\n", key, value);
return true;
}
/**
* @brief 从散列表中查找键
* @param table 指向HashTable结构体的指针
* @param key 要查找的键字符串
* @param value_ptr 用于存储找到的值的指针
* @return true 找到键,false 未找到
* @note 时间复杂度:平均O(1),最坏O(n) (链表过长)。
*/
bool hash_table_search(const HashTable *table, const char *key, int *value_ptr) {
unsigned int index = hash_function(key, table->capacity);
HashNode *current = table->buckets[index];
// 遍历对应桶的链表查找键
while (current != NULL) {
if (strcmp(current->pair.key, key) == 0) {
*value_ptr = current->pair.value;
printf("查找键 '%s': 找到,值为 %d\n", key, *value_ptr);
return true;
}
current = current->next;
}
printf("查找键 '%s': 未找到\n", key);
return false;
}
/**
* @brief 从散列表中删除键值对
* @param table 指向HashTable结构体的指针
* @param key 要删除的键字符串
* @return true 删除成功,false 未找到键
* @note 时间复杂度:平均O(1),最坏O(n) (链表过长)。
*/
bool hash_table_delete(HashTable *table, const char *key) {
unsigned int index = hash_function(key, table->capacity);
HashNode *current = table->buckets[index];
HashNode *prev = NULL;
// 遍历对应桶的链表查找键
while (current != NULL) {
if (strcmp(current->pair.key, key) == 0) {
if (prev == NULL) { // 要删除的是链表头节点
table->buckets[index] = current->next;
} else { // 要删除的是链表中间或尾部节点
prev->next = current->next;
}
printf("删除键 '%s' (值为 %d)\n", key, current->pair.value);
free(current->pair.key); // 释放键字符串的内存
free(current); // 释放节点内存
table->size--;
return true;
}
prev = current;
current = current->next;
}
printf("删除键 '%s': 未找到\n", key);
return false;
}
/**
* @brief 打印散列表的当前状态 (包括桶和链表)
* @param table 指向HashTable结构体的指针
*/
void print_hash_table(const HashTable *table) {
printf("\n--- 散列表当前状态 (大小: %d, 容量: %d) ---\n", table->size, table->capacity);
for (int i = 0; i < table->capacity; i++) {
printf("桶 %d: ", i);
HashNode *current = table->buckets[i];
if (current == NULL) {
printf("空\n");
} else {
while (current != NULL) {
printf("('%s', %d) -> ", current->pair.key, current->pair.value);
current = current->next;
}
printf("NULL\n");
}
}
printf("----------------------------------------\n\n");
}
/**
* @brief 释放散列表所有内存
* @param table 指向HashTable结构体的指针
*/
void free_hash_table(HashTable *table) {
for (int i = 0; i < table->capacity; i++) {
HashNode *current = table->buckets[i];
HashNode *next_node;
while (current != NULL) {
next_node = current->next;
free(current->pair.key); // 释放键字符串内存
free(current); // 释放节点内存
current = next_node;
}
}
free(table->buckets); // 释放桶数组内存
table->buckets = NULL;
table->capacity = 0;
table->size = 0;
printf("散列表内存已释放。\n");
}
// 主函数用于测试散列表操作
int main_hash_table_operations() {
printf("--- 散列表 (哈希表) 操作示例 ---\n");
HashTable my_hash_table;
int initial_capacity = 10; // 初始桶的数量
if (!init_hash_table(&my_hash_table, initial_capacity)) {
return EXIT_FAILURE;
}
// 插入元素
hash_table_insert(&my_hash_table, "apple", 10);
hash_table_insert(&my_hash_table, "banana", 20);
hash_table_insert(&my_hash_table, "cherry", 30);
hash_table_insert(&my_hash_table, "date", 40);
hash_table_insert(&my_hash_table, "elderberry", 50);
hash_table_insert(&my_hash_table, "fig", 60);
hash_table_insert(&my_hash_table, "grape", 70);
hash_table_insert(&my_hash_table, "honeydew", 80);
hash_table_insert(&my_hash_table, "kiwi", 90);
hash_table_insert(&my_hash_table, "lemon", 100); // 可能会有冲突
print_hash_table(&my_hash_table);
// 更新元素
hash_table_insert(&my_hash_table, "apple", 15);
print_hash_table(&my_hash_table);
// 查找元素
int val;
hash_table_search(&my_hash_table, "banana", &val);
hash_table_search(&my_hash_table, "mango", &val); // 未找到
// 删除元素
hash_table_delete(&my_hash_table, "cherry");
print_hash_table(&my_hash_table);
hash_table_delete(&my_hash_table, "unknown"); // 尝试删除不存在的键
print_hash_table(&my_hash_table);
hash_table_delete(&my_hash_table, "apple");
print_hash_table(&my_hash_table);
// 释放内存
free_hash_table(&my_hash_table);
print_hash_table(&my_hash_table); // 此时应显示空表
printf("--- 散列表 (哈希表) 操作示例结束 ---\n");
return 0;
}
代码分析与说明:
-
KeyValuePair
和HashNode
:KeyValuePair
存储实际的键值对,HashNode
是链表节点,包含KeyValuePair
和next
指针。 -
HashTable
结构体: 核心结构,包含buckets
(一个指向HashNode*
数组的指针,即桶数组)、capacity
(桶的数量)和size
(实际存储的键值对数量)。 -
create_hash_node
: 创建新节点,注意为键字符串动态分配内存并复制,避免悬空指针或意外修改。 -
hash_function(const char *key, int capacity)
:-
这是一个非常简单的字符串哈希函数(DJB2算法的变种)。它将字符串中的每个字符累加并左移,最后对
capacity
取模,得到桶的索引。 -
重要性: 哈希函数的质量直接决定了散列表的性能。一个好的哈希函数应该能够将键均匀地分布到各个桶中,减少冲突。
-
-
init_hash_table
: 初始化散列表,使用calloc
分配桶数组,它会将所有内存初始化为零(即NULL
),方便后续操作。 -
hash_table_insert
:-
首先通过
hash_function
计算键的散列地址。 -
然后遍历对应桶的链表,检查键是否已存在。如果存在,则更新其值。
-
如果键不存在,则创建新节点,并将其插入到链表头部。这种插入方式是 O(1) 的,因为不需要遍历链表。
-
-
hash_table_search
:-
计算散列地址。
-
遍历对应桶的链表,通过
strcmp
比较键是否匹配。 -
时间复杂度: 平均情况下,每个桶的链表很短,所以查找接近 O(1)。最坏情况下(所有键都映射到同一个桶),链表会变得很长,退化为 O(n)。
-
-
hash_table_delete
:-
计算散列地址。
-
遍历对应桶的链表,找到要删除的节点及其前一个节点。
-
根据节点位置(头节点或非头节点)修改链表指针。
-
重要: 释放被删除节点的键字符串内存和节点本身的内存,防止内存泄漏。
-
-
print_hash_table
: 辅助函数,用于可视化散列表的内部结构,包括每个桶中的链表。 -
free_hash_table
: 释放散列表所有动态分配的内存,包括每个节点的键字符串、节点本身以及桶数组。这是防止内存泄漏的关键。 -
做题编程随想录:
-
面试高频: 哈希表是面试常客,会让你手写实现,或讨论哈希冲突的解决办法、哈希函数的选择。
-
装载因子(Load Factor):
alpha = size / capacity
。它衡量了散列表的“满”的程度。当装载因子过高时,冲突会增多,性能会下降。通常,当装载因子达到某个阈值(如0.7或0.75)时,需要进行扩容(Resizing),即创建一个更大的桶数组,并重新哈希所有旧元素到新数组中。这个过程是 O(n)。 -
应用:
-
字典/映射(Map/Dictionary): 存储键值对,实现快速查找。
-
缓存(Cache): 快速查找缓存数据。
-
数据库索引: 某些数据库系统可能使用哈希索引。
-
去重: 快速判断元素是否存在。
-
计数: 统计元素出现频率。
-
-
嵌入式中: 哈希表可以用于实现配置参数的快速查找、协议解析中的状态机映射、小型文件系统的元数据管理等。但需要权衡内存开销和哈希函数的计算复杂度。
-
小结: 散列表是一种通过哈希函数实现快速查找的“魔法字典”。它在平均情况下能够实现 O(1) 的查找、插入和删除操作,是空间换时间的典型。理解哈希冲突及其解决办法(特别是链地址法)是掌握散列表的关键。在实际编程中,哈希函数的选择和装载因子的管理直接影响其性能。
2.6 图:复杂关系的“网络图”
兄弟们,如果说树是表达层级关系的“家族图谱”,那么图(Graph)就是表达任意复杂关系的“网络图”了!从社交网络到城市交通,从电路板到网页链接,万事万物都可以抽象成图。在《算法导论》中,图是另一个重磅章节,它涉及的算法非常多,而且在实际工程中应用极其广泛。
2.6.1 图的基本概念:网络成员的“身份”
-
图(Graph): 由顶点(Vertex/Node)集合 V 和边(Edge)集合 E 组成,通常表示为 G=(V,E)。
-
顶点(Vertex / Node): 图中的基本元素,表示实体。
-
边(Edge): 连接两个顶点的线,表示顶点之间的关系。
-
有向边(Directed Edge): 边有方向,从一个顶点指向另一个顶点。表示单向关系。
-
无向边(Undirected Edge): 边没有方向,表示双向关系。
-
-
有向图(Directed Graph): 所有边都是有向边的图。
-
无向图(Undirected Graph): 所有边都是无向边的图。
-
带权图(Weighted Graph): 每条边都带有一个数值(权重/代价),表示某种成本、距离或强度。
-
路径(Path): 从一个顶点到另一个顶点所经过的顶点序列(或边序列)。
-
环(Cycle): 起点和终点相同的路径。
-
连通性(Connectivity):
-
连通图: 在无向图中,任意两个顶点之间都存在路径。
-
强连通图: 在有向图中,任意两个顶点之间都存在双向路径。
-
连通分量/强连通分量: 图中最大的连通/强连通子图。
-
-
度(Degree):
-
无向图: 顶点的度是与该顶点相连的边的数量。
-
有向图:
-
入度(In-degree): 指向该顶点的边的数量。
-
出度(Out-degree): 从该顶点指向其他顶点的边的数量。
-
-
思维导图:图的基本概念
graph TD
A[图] --> B[顶点 V];
A --> C[边 E];
C --> C1[有向边];
C --> C2[无向边];
A --> D[有向图];
A --> E[无向图];
A --> F[带权图];
A --> G[路径];
A --> H[环];
A --> I[连通性];
I --> I1[连通图];
I --> I2[强连通图];
I --> I3[连通分量];
A --> J[度];
J --> J1[入度];
J --> J2[出度];
2.6.2 图的表示方法:如何“画”出关系网
在计算机中表示图,主要有两种常用方法:
-
邻接矩阵(Adjacency Matrix):
-
思想: 使用一个 VtimesV 的二维数组(或矩阵)来表示图。
matrix[i][j]
的值表示顶点i
和顶点j
之间是否存在边。-
对于无权图,
matrix[i][j] = 1
表示有边,0
表示无边。 -
对于带权图,
matrix[i][j]
存储边的权重,0
或INF
表示无边。
-
-
存储空间: O(V2)。
-
优点:
-
判断任意两点之间是否存在边非常快,时间复杂度 O(1)。
-
查找顶点的度非常方便。
-
-
缺点:
-
对于稀疏图(边数量远小于 V2 的图),会浪费大量存储空间。
-
获取一个顶点的所有邻居需要遍历一行或一列,时间复杂度 O(V)。
-
-
适用场景: 稠密图(边数量接近 V2 的图)。
-
-
邻接表(Adjacency List):
-
思想: 使用一个数组(或哈希表),数组的每个元素是一个链表(或动态数组)。数组的索引代表顶点,链表存储与该顶点相邻的所有顶点。
-
adj_list[i]
存储一个链表,链表中的每个节点表示与顶点i
相邻的顶点。 -
对于带权图,链表节点中除了邻居顶点,还需要存储边的权重。
-
-
存储空间: O(V+E) (其中 V 是顶点数,E 是边数)。
-
优点:
-
对于稀疏图,存储效率高,节省空间。
-
获取一个顶点的所有邻居非常方便,时间复杂度 O(textdegree(V))。
-
-
缺点:
-
判断任意两点之间是否存在边需要遍历链表,时间复杂度 O(textdegree(V)),最坏 O(V)。
-
-
适用场景: 稀疏图(最常用)。
-
概念性C代码:图的邻接表表示
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// --- 邻接表节点结构体 ---
typedef struct AdjListNode {
int dest; // 边的目标顶点
int weight; // 边的权重 (如果图是带权的)
struct AdjListNode *next; // 指向下一个邻接节点
} AdjListNode;
// --- 邻接列表头结构体 (每个顶点对应一个列表头) ---
typedef struct AdjList {
AdjListNode *head; // 链表头指针
} AdjList;
// --- 图结构体 ---
typedef struct Graph {
int num_vertices; // 图中顶点的数量
AdjList *array; // 邻接表数组,每个元素是一个AdjList
} Graph;
/**
* @brief 创建一个新的邻接表节点
* @param dest 目标顶点
* @param weight 权重 (默认为1,如果无权图)
* @return 指向新创建节点的指针,如果内存分配失败则返回NULL
*/
AdjListNode *create_adj_list_node(int dest, int weight) {
AdjListNode *new_node = (AdjListNode *)malloc(sizeof(AdjListNode));
if (new_node == NULL) {
perror("邻接表节点内存分配失败");
return NULL;
}
new_node->dest = dest;
new_node->weight = weight;
new_node->next = NULL;
return new_node;
}
/**
* @brief 创建一个图 (使用邻接表表示)
* @param num_vertices 图中顶点的数量
* @return 指向新创建图的指针,如果内存分配失败则返回NULL
*/
Graph *create_graph(int num_vertices) {
Graph *graph = (Graph *)malloc(sizeof(Graph));
if (graph == NULL) {
perror("图结构内存分配失败");
return NULL;
}
graph->num_vertices = num_vertices;
graph->array = (AdjList *)malloc(num_vertices * sizeof(AdjList));
if (graph->array == NULL) {
perror("邻接表数组内存分配失败");
free(graph);
return NULL;
}
// 初始化所有邻接列表头为NULL
for (int i = 0; i < num_vertices; i++) {
graph->array[i].head = NULL;
}
printf("图已创建,包含 %d 个顶点。\n", num_vertices);
return graph;
}
/**
* @brief 向图中添加一条边 (无向图)
* @param graph 指向图结构体的指针
* @param src 边的起始顶点
* @param dest 边的目标顶点
* @param weight 边的权重 (默认为1)
* @note 对于无向图,需要添加两条有向边。
*/
void add_edge(Graph *graph, int src, int dest, int weight) {
// 检查顶点是否合法
if (src < 0 || src >= graph->num_vertices || dest < 0 || dest >= graph->num_vertices) {
printf("错误: 顶点 %d 或 %d 超出范围 [0, %d)\n", src, dest, graph->num_vertices);
return;
}
// 从 src 到 dest 添加边
AdjListNode *new_node = create_adj_list_node(dest, weight);
if (new_node == NULL) return;
new_node->next = graph->array[src].head; // 新节点插入到链表头部
graph->array[src].head = new_node;
// 对于无向图,还需要从 dest 到 src 添加反向边
new_node = create_adj_list_node(src, weight);
if (new_node == NULL) return;
new_node->next = graph->array[dest].head;
graph->array[dest].head = new_node;
printf("添加边: %d --(%d)--> %d\n", src, weight, dest);
}
/**
* @brief 打印图的邻接表表示
* @param graph 指向图结构体的指针
*/
void print_graph(const Graph *graph) {
printf("\n--- 图的邻接表表示 ---\n");
for (int v = 0; v < graph->num_vertices; v++) {
printf("顶点 %d 的邻接列表: ", v);
AdjListNode *current = graph->array[v].head;
while (current != NULL) {
printf("-> %d (权重: %d) ", current->dest, current->weight);
current = current->next;
}
printf("-> NULL\n");
}
printf("------------------------\n\n");
}
/**
* @brief 释放图的所有内存
* @param graph 指向图结构体的指针
*/
void free_graph(Graph *graph) {
if (graph == NULL) return;
for (int i = 0; i < graph->num_vertices; i++) {
AdjListNode *current = graph->array[i].head;
AdjListNode *next_node;
while (current != NULL) {
next_node = current->next;
free(current); // 释放邻接表节点
current = next_node;
}
graph->array[i].head = NULL; // 清空链表头
}
free(graph->array); // 释放邻接表数组
free(graph); // 释放图结构体
printf("图内存已释放。\n");
}
// 主函数用于测试图的邻接表表示
int main_graph_representation() {
printf("--- 图的邻接表表示示例 ---\n");
int num_vertices = 5;
Graph *graph = create_graph(num_vertices);
if (graph == NULL) {
return EXIT_FAILURE;
}
// 添加边 (无向图,带权重)
add_edge(graph, 0, 1, 10);
add_edge(graph, 0, 4, 20);
add_edge(graph, 1, 2, 30);
add_edge(graph, 1, 3, 40);
add_edge(graph, 1, 4, 50);
add_edge(graph, 2, 3, 60);
add_edge(graph, 3, 4, 70);
print_graph(graph);
// 释放图内存
free_graph(graph);
printf("--- 图的邻接表表示示例结束 ---\n");
return 0;
}
代码分析与说明:
-
AdjListNode
结构体: 定义了邻接表中的节点,包含dest
(目标顶点)、weight
(边的权重,如果无权图可以默认为1)和next
指针。 -
AdjList
结构体: 只是一个包装,包含一个head
指针,指向对应顶点的邻接链表的头部。 -
Graph
结构体: 包含num_vertices
(顶点数量)和array
(一个AdjList
类型的数组,这就是邻接表的核心)。 -
create_graph
: 初始化图,分配Graph
结构体和AdjList
数组的内存,并将所有链表头初始化为NULL
。 -
add_edge(Graph *graph, int src, int dest, int weight)
:-
向图中添加一条边。
-
对于无向图,需要添加两条有向边:一条从
src
到dest
,另一条从dest
到src
。 -
新节点总是插入到对应顶点的邻接链表的头部。这种插入是 O(1) 的,因为它不需要遍历链表。
-
-
print_graph
: 辅助函数,用于打印图的邻接表表示,方便可视化。 -
free_graph
: 释放图的所有内存。需要遍历每个顶点的邻接链表,逐个释放节点,然后释放AdjList
数组,最后释放Graph
结构体本身。 -
做题编程随想录:
-
邻接矩阵 vs 邻接表: 这是图表示方法的经典对比。
-
稠密图(边很多): 邻接矩阵更优,因为 O(V2) 的空间浪费不明显,且判断边是否存在是 O(1)。
-
稀疏图(边很少): 邻接表更优,因为 O(V+E) 的空间效率更高,且遍历邻居更高效。
-
-
嵌入式中: 在嵌入式系统中,图结构可能用于表示传感器网络、设备连接关系、状态机转换等。由于内存限制,邻接表通常是更优的选择,因为它能更有效地利用内存。
-
2.6.3 图的遍历:在网络中“漫游”
图的遍历是指系统地访问图中的每一个顶点和每一条边。最常用的两种遍历算法是广度优先搜索和深度优先搜索。
-
广度优先搜索(Breadth-First Search, BFS):
-
思想: 从起始顶点开始,逐层向外扩展,先访问所有距离起始顶点为1的顶点,然后访问所有距离为2的顶点,依此类推。
-
实现: 通常使用**队列(Queue)**来实现。
-
将起始顶点入队。
-
标记起始顶点为已访问。
-
当队列不为空时,出队一个顶点。
-
访问该顶点。
-
将其所有未访问过的邻居顶点入队,并标记为已访问。
-
-
特性:
-
最短路径: 在无权图中,BFS可以找到从起始顶点到所有其他顶点的最短路径(边的数量最少)。
-
时间复杂度: O(V+E) (使用邻接表),O(V2) (使用邻接矩阵)。
-
空间复杂度: O(V) (队列中最多存储所有顶点)。
-
-
应用: 查找无权图中的最短路径、社交网络中的好友关系、网络爬虫、迷宫寻路。
-
-
深度优先搜索(Depth-First Search, DFS):
-
思想: 从起始顶点开始,尽可能深地探索图的分支。当一个分支的路径走到尽头(没有未访问的邻居)时,回溯到上一个顶点,继续探索另一个分支。
-
实现: 通常使用递归(隐式利用系统栈)或**栈(Stack)**来实现。
-
将起始顶点入栈。
-
标记起始顶点为已访问。
-
当栈不为空时,出栈一个顶点。
-
访问该顶点。
-
将其所有未访问过的邻居顶点入栈,并标记为已访问。
-
-
特性:
-
连通性: 可以用来判断图的连通性、查找连通分量。
-
环检测: 可以用来检测图中是否存在环。
-
拓扑排序: 对有向无环图(DAG)进行拓扑排序。
-
时间复杂度: O(V+E) (使用邻接表),O(V2) (使用邻接矩阵)。
-
空间复杂度: O(V) (递归栈深度或显式栈大小)。
-
-
应用: 查找图中的路径、拓扑排序、迷宫寻路(找到一条路径)、组件分析。
-
概念性C代码:图的BFS和DFS遍历
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// --- 队列的辅助实现 (用于BFS) ---
// 队列节点结构体
typedef struct QueueNode_Graph {
int data; // 存储顶点索引
struct QueueNode_Graph *next;
} QueueNode_Graph;
// 队列结构体
typedef struct Queue_Graph {
QueueNode_Graph *front;
QueueNode_Graph *rear;
} Queue_Graph;
void init_queue_graph(Queue_Graph *q) { q->front = NULL; q->rear = NULL; }
bool is_queue_empty_graph(const Queue_Graph *q) { return q->front == NULL; }
bool enqueue_queue_graph(Queue_Graph *q, int item) {
QueueNode_Graph *new_node = (QueueNode_Graph *)malloc(sizeof(QueueNode_Graph));
if (new_node == NULL) { perror("队列内存分配失败"); return false; }
new_node->data = item;
new_node->next = NULL;
if (is_queue_empty_graph(q)) {
q->front = new_node;
q->rear = new_node;
} else {
q->rear->next = new_node;
q->rear = new_node;
}
return true;
}
int dequeue_queue_graph(Queue_Graph *q) {
if (is_queue_empty_graph(q)) { return -1; /* 错误或特殊值 */ }
QueueNode_Graph *temp = q->front;
int item = temp->data;
q->front = temp->next;
if (q->front == NULL) { q->rear = NULL; }
free(temp);
return item;
}
void free_queue_graph(Queue_Graph *q) {
while (!is_queue_empty_graph(q)) {
dequeue_queue_graph(q);
}
}
// --- 栈的辅助实现 (用于DFS非递归) ---
// 栈节点结构体
typedef struct StackNode_Graph {
int data; // 存储顶点索引
struct StackNode_Graph *next;
} StackNode_Graph;
// 栈结构体
typedef struct Stack_Graph {
StackNode_Graph *top;
} Stack_Graph;
void init_stack_graph(Stack_Graph *s) { s->top = NULL; }
bool is_stack_empty_graph(const Stack_Graph *s) { return s->top == NULL; }
bool push_stack_graph(Stack_Graph *s, int item) {
StackNode_Graph *new_node = (StackNode_Graph *)malloc(sizeof(StackNode_Graph));
if (new_node == NULL) { perror("栈内存分配失败"); return false; }
new_node->data = item;
new_node->next = s->top;
s->top = new_node;
return true;
}
int pop_stack_graph(Stack_Graph *s) {
if (is_stack_empty_graph(s)) { return -1; /* 错误或特殊值 */ }
StackNode_Graph *temp = s->top;
int item = temp->data;
s->top = temp->next;
free(temp);
return item;
}
void free_stack_graph(Stack_Graph *s) {
while (!is_stack_empty_graph(s)) {
pop_stack_graph(s);
}
}
// --- 图的邻接表表示 (与2.6.2相同,此处省略重复代码,只包含必要结构体) ---
typedef struct AdjListNode {
int dest;
int weight;
struct AdjListNode *next;
} AdjListNode;
typedef struct AdjList {
AdjListNode *head;
} AdjList;
typedef struct Graph {
int num_vertices;
AdjList *array;
} Graph;
// 假设 create_graph 和 add_edge 函数已存在并能正确工作
// 这里为了代码的独立性,将它们复制过来,但实际中可放在头文件或单独文件
AdjListNode *create_adj_list_node(int dest, int weight) {
AdjListNode *new_node = (AdjListNode *)malloc(sizeof(AdjListNode));
if (new_node == NULL) { perror("邻接表节点内存分配失败"); return NULL; }
new_node->dest = dest; new_node->weight = weight; new_node->next = NULL; return new_node;
}
Graph *create_graph(int num_vertices) {
Graph *graph = (Graph *)malloc(sizeof(Graph));
if (graph == NULL) { perror("图结构内存分配失败"); return NULL; }
graph->num_vertices = num_vertices;
graph->array = (AdjList *)malloc(num_vertices * sizeof(AdjList));
if (graph->array == NULL) { perror("邻接表数组内存分配失败"); free(graph); return NULL; }
for (int i = 0; i < num_vertices; i++) { graph->array[i].head = NULL; }
return graph;
}
void add_edge(Graph *graph, int src, int dest, int weight) {
if (src < 0 || src >= graph->num_vertices || dest < 0 || dest >= graph->num_vertices) { return; }
AdjListNode *new_node = create_adj_list_node(dest, weight);
if (new_node == NULL) return;
new_node->next = graph->array[src].head; graph->array[src].head = new_node;
new_node = create_adj_list_node(src, weight); // For undirected graph
if (new_node == NULL) return;
new_node->next = graph->array[dest].head; graph->array[dest].head = new_node;
}
void free_graph(Graph *graph) {
if (graph == NULL) return;
for (int i = 0; i < graph->num_vertices; i++) {
AdjListNode *current = graph->array[i].head;
AdjListNode *next_node;
while (current != NULL) { next_node = current->next; free(current); current = next_node; }
}
free(graph->array); free(graph);
}
/**
* @brief 广度优先搜索 (BFS)
* @param graph 指向图结构体的指针
* @param start_vertex BFS的起始顶点
* @note BFS使用队列来逐层遍历图。
* 时间复杂度:O(V+E) (邻接表),O(V^2) (邻接矩阵)。
* 空间复杂度:O(V) (队列和visited数组)。
*/
void bfs(Graph *graph, int start_vertex) {
if (start_vertex < 0 || start_vertex >= graph->num_vertices) {
printf("错误: 起始顶点 %d 超出范围。\n", start_vertex);
return;
}
printf("BFS 从顶点 %d 开始: ", start_vertex);
bool *visited = (bool *)calloc(graph->num_vertices, sizeof(bool)); // 记录顶点是否已访问
if (visited == NULL) { perror("visited数组内存分配失败"); return; }
Queue_Graph q;
init_queue_graph(&q);
visited[start_vertex] = true;
enqueue_queue_graph(&q, start_vertex);
while (!is_queue_empty_graph(&q)) {
int current_vertex = dequeue_queue_graph(&q);
printf("%d ", current_vertex);
// 遍历当前顶点的所有邻居
AdjListNode *current_node = graph->array[current_vertex].head;
while (current_node != NULL) {
int adj_vertex = current_node->dest;
if (!visited[adj_vertex]) { // 如果邻居未被访问
visited[adj_vertex] = true;
enqueue_queue_graph(&q, adj_vertex);
}
current_node = current_node->next;
}
}
printf("\n");
free(visited);
free_queue_graph(&q);
}
/**
* @brief 深度优先搜索 (DFS) 递归辅助函数
* @param graph 指向图结构体的指针
* @param vertex 当前访问的顶点
* @param visited 记录顶点是否已访问的数组
*/
void dfs_recursive_helper(Graph *graph, int vertex, bool visited[]) {
visited[vertex] = true;
printf("%d ", vertex);
AdjListNode *current_node = graph->array[vertex].head;
while (current_node != NULL) {
int adj_vertex = current_node->dest;
if (!visited[adj_vertex]) {
dfs_recursive_helper(graph, adj_vertex, visited); // 递归访问未访问的邻居
}
current_node = current_node->next;
}
}
/**
* @brief 深度优先搜索 (DFS) 递归实现
* @param graph 指向图结构体的指针
* @param start_vertex DFS的起始顶点
* @note DFS使用递归 (隐式栈) 来深入遍历图的分支。
* 时间复杂度:O(V+E) (邻接表),O(V^2) (邻接矩阵)。
* 空间复杂度:O(V) (递归栈深度)。
*/
void dfs_recursive(Graph *graph, int start_vertex) {
if (start_vertex < 0 || start_vertex >= graph->num_vertices) {
printf("错误: 起始顶点 %d 超出范围。\n", start_vertex);
return;
}
printf("DFS (递归) 从顶点 %d 开始: ", start_vertex);
bool *visited = (bool *)calloc(graph->num_vertices, sizeof(bool));
if (visited == NULL) { perror("visited数组内存分配失败"); return; }
dfs_recursive_helper(graph, start_vertex, visited);
printf("\n");
free(visited);
}
/**
* @brief 深度优先搜索 (DFS) 非递归实现
* @param graph 指向图结构体的指针
* @param start_vertex DFS的起始顶点
* @note DFS非递归实现使用显式栈来模拟递归过程。
* 时间复杂度:O(V+E) (邻接表),O(V^2) (邻接矩阵)。
* 空间复杂度:O(V) (显式栈大小)。
*/
void dfs_iterative(Graph *graph, int start_vertex) {
if (start_vertex < 0 || start_vertex >= graph->num_vertices) {
printf("错误: 起始顶点 %d 超出范围。\n", start_vertex);
return;
}
printf("DFS (非递归) 从顶点 %d 开始: ", start_vertex);
bool *visited = (bool *)calloc(graph->num_vertices, sizeof(bool));
if (visited == NULL) { perror("visited数组内存分配失败"); return; }
Stack_Graph s;
init_stack_graph(&s);
push_stack_graph(&s, start_vertex);
visited[start_vertex] = true; // 在入栈时就标记为已访问
while (!is_stack_empty_graph(&s)) {
int current_vertex = pop_stack_graph(&s);
printf("%d ", current_vertex);
// 遍历当前顶点的所有邻居
// 注意:这里遍历邻居的顺序会影响DFS的访问路径,
// 但最终会访问所有可达节点。
// 为了与递归DFS的输出顺序更接近,可能需要逆序遍历邻接链表,
// 或者在入栈时先压入右子节点,再压入左子节点(对于树)。
// 对于图,通常按邻接表顺序即可。
AdjListNode *current_node = graph->array[current_vertex].head;
while (current_node != NULL) {
int adj_vertex = current_node->dest;
if (!visited[adj_vertex]) {
visited[adj_vertex] = true; // 在入栈时标记为已访问
push_stack_graph(&s, adj_vertex);
}
current_node = current_node->next;
}
}
printf("\n");
free(visited);
free_stack_graph(&s);
}
// 主函数用于测试图的遍历
int main_graph_traversal() {
printf("--- 图的遍历示例 ---\n");
int num_vertices = 7; // 0-6
Graph *graph = create_graph(num_vertices);
if (graph == NULL) {
return EXIT_FAILURE;
}
// 构建一个图 (无向图)
// 0 -- 1 -- 2
// | | |
// 3 -- 4 -- 5
// |
// 6
add_edge(graph, 0, 1, 1);
add_edge(graph, 0, 3, 1);
add_edge(graph, 1, 2, 1);
add_edge(graph, 1, 4, 1);
add_edge(graph, 2, 5, 1);
add_edge(graph, 3, 4, 1);
add_edge(graph, 4, 5, 1);
add_edge(graph, 4, 6, 1); // 顶点6只与4相连
printf("\n");
bfs(graph, 0); // BFS 从顶点0开始
// 预期输出类似: 0 1 3 2 4 5 6 (顺序可能因邻接表内部顺序略有不同)
dfs_recursive(graph, 0); // DFS (递归) 从顶点0开始
// 预期输出类似: 0 1 2 5 4 3 6 (顺序可能因邻接表内部顺序略有不同)
dfs_iterative(graph, 0); // DFS (非递归) 从顶点0开始
// 预期输出类似: 0 3 4 6 5 2 1 (非递归DFS的访问顺序可能与递归不同,取决于邻居入栈顺序)
// 释放图内存
free_graph(graph);
printf("--- 图的遍历示例结束 ---\n");
return 0;
}
代码分析与说明:
-
辅助队列和栈: 为了实现BFS和非递归DFS,代码中再次提供了简化的队列和栈实现。在实际项目中,可以复用之前在2.2节实现的通用栈和队列。
-
bfs(Graph *graph, int start_vertex)
:-
visited
数组:bool *visited = (bool *)calloc(graph->num_vertices, sizeof(bool));
用于记录每个顶点是否已经被访问过,防止重复访问和陷入死循环(尤其是有环图)。calloc
会将所有元素初始化为false
。 -
队列的使用:
Queue_Graph q; init_queue_graph(&q);
-
核心逻辑:
-
起始顶点入队并标记为已访问。
-
循环直到队列为空:
-
出队当前顶点。
-
遍历其所有邻居:如果邻居未被访问,则标记为已访问并入队。
-
-
-
时间复杂度: 每个顶点最多入队一次、出队一次,每条边最多被检查两次(无向图)。所以,时间复杂度是 O(V+E)。
-
空间复杂度:
visited
数组 O(V),队列最多存储 O(V) 个顶点。总空间复杂度 O(V)。
-
-
dfs_recursive_helper(Graph *graph, int vertex, bool visited[])
(递归DFS辅助函数):-
visited
数组: 同样用于防止重复访问。 -
核心逻辑:
-
标记当前顶点为已访问并访问它。
-
遍历当前顶点的所有邻居:如果邻居未被访问,则递归调用
dfs_recursive_helper
对其进行DFS。
-
-
时间复杂度: 同BFS,每个顶点和每条边都只访问一次,所以是 O(V+E)。
-
空间复杂度: 递归调用会使用系统栈,最坏情况下(图是链式结构)栈深度为 O(V)。
-
-
dfs_iterative(Graph *graph, int start_vertex)
(非递归DFS):-
visited
数组和栈的使用:Stack_Graph s; init_stack_graph(&s);
-
核心逻辑:
-
起始顶点入栈并标记为已访问。
-
循环直到栈为空:
-
出栈当前顶点并访问。
-
遍历其所有邻居:如果邻居未被访问,则标记为已访问并入栈。
-
-
-
注意: 递归DFS的访问顺序通常是“一条路走到黑”,非递归DFS的访问顺序取决于邻接表(或邻接矩阵)中邻居的存储顺序,以及入栈的顺序。通常,为了模拟递归DFS的“先左后右”特性,在非递归实现中,如果邻接表是按升序存储邻居,那么入栈时应该先压入右边的邻居,再压入左边的邻居,这样出栈时左边的邻居会先被处理。但对于无向图,通常按邻接表顺序即可。
-
时间复杂度: O(V+E)。
-
空间复杂度: 显式栈最多存储 O(V) 个顶点。总空间复杂度 O(V)。
-
-
做题编程随想录:
-
面试高频: BFS和DFS是图算法的基石,必考!会让你手写实现,或讨论它们的应用场景和区别。
-
BFS vs DFS:
-
BFS: 适用于查找最短路径(无权图)、层序遍历、连通分量(所有节点到起始节点的距离)。
-
DFS: 适用于判断连通性、检测环、拓扑排序、生成树(DFS树)、路径查找(找到一条即可)。
-
-
visited
数组的重要性: 无论是BFS还是DFS,visited
数组都至关重要,它防止了重复访问节点,避免了死循环,并确保每个节点只被处理一次。 -
嵌入式中: 图算法可以用于传感器网络路由、设备依赖管理、状态机跳转逻辑等。在资源受限的环境下,选择合适的图表示方法(通常是邻接表)和优化遍历算法的内存使用是关键。
-
2.6.4 图的应用:复杂世界的“映射”
图作为一种强大的数据结构,其应用无处不在:
-
社交网络: 用户是顶点,好友关系是边。
-
导航系统: 城市是顶点,道路是边(带权)。
-
电路分析: 元件是顶点,连接线是边。
-
任务依赖: 任务是顶点,依赖关系是边(有向)。
-
网页链接: 网页是顶点,超链接是边(有向)。
-
生物信息学: 蛋白质相互作用网络。
-
编译器: 控制流图、调用图。
-
人工智能: 搜索问题(八皇后、数独)。
小结: 图是表达复杂关系网络的强大数据结构。掌握其两种主要表示方法(邻接矩阵和邻接表)的优缺点和适用场景,以及两种核心遍历算法(BFS和DFS)的原理、实现和应用,是解决图相关问题的基础。在实际工程中,图算法无处不在,是衡量你算法功底的重要标准。
2.7 其他高级数据结构(简要介绍)
兄弟们,除了上面这些“基础款”数据结构,算法世界里还有一些更高级、更专业的“神器”,它们在特定场景下能发挥出惊人的效率。虽然《算法导论》会深入讲解,但咱们这里先做个“入门介绍”,让你心里有数。
-
B树/B+树:数据库索引的“定海神针”
-
思想: B树和B+树是多叉平衡查找树,每个节点可以有多个子节点。它们被设计用于磁盘等外部存储。
-
特点: 相比二叉树,B树/B+树的“矮胖”结构能大大减少磁盘I/O次数(因为磁盘I/O是瓶颈),从而提高数据库查询效率。B+树通常是数据库索引的首选,因为它所有数据都存储在叶子节点,且叶子节点之间通过链表连接,方便范围查询。
-
应用: 数据库索引(MySQL InnoDB使用B+树)、文件系统索引。
-
-
Trie树(字典树/前缀树):字符串查找的“导航仪”
-
思想: 一种用于存储字符串集合的树形结构。每个节点代表一个字符,从根节点到任意节点的路径都代表一个字符串。
-
特点: 能够高效地进行字符串查找、前缀匹配、统计字符串频率等操作。
-
应用: 搜索引擎的关键词匹配、自动补全、拼写检查、IP路由表。
-
-
并查集(Disjoint Set / Union-Find Set):集合合并与查询的“管家”
-
思想: 用于管理一组不相交的集合,并支持两种操作:
-
合并(Union): 将两个集合合并成一个。
-
查找(Find): 确定某个元素属于哪个集合(通常返回集合的代表元素)。
-
-
优化: 路径压缩(Path Compression)和按秩合并(Union by Rank/Size)可以大大提高操作效率,使其平均时间复杂度接近 O(alpha(n))(反阿克曼函数,增长极其缓慢,可视为常数)。
-
应用: 连通分量检测(判断两个节点是否在同一个连通分量)、最小生成树算法(Kruskal)。
-
-
跳表(Skip List):有序链表的“升级版”
-
思想: 一种随机化的数据结构,它在多层有序链表的基础上,通过随机化的方式构建多级索引,从而实现 O(logn) 的查找、插入和删除操作。
-
特点: 相比平衡二叉查找树,实现更简单,但性能同样优秀。
-
应用: Redis等内存数据库的有序集合(Sorted Set)底层实现。
-
做题编程随想录:
-
这些高级数据结构在力扣、牛客等平台的高级题目中会经常出现。
-
对于它们,首先要理解核心思想和解决的问题,其次是操作的平均时间复杂度,最后是典型的应用场景。
-
在面试中,通常不会让你手写这些复杂数据结构,但会考察你的理解和选择能力:在特定场景下,你会选择哪种数据结构?为什么?
小结: 除了基础数据结构,还有许多针对特定问题优化的高级数据结构。B树/B+树用于外部存储优化,Trie树擅长字符串操作,并查集用于集合管理,跳表则提供了链表式的简单性和对数级的性能。了解这些“神器”,能让你在算法的世界里如虎添翼,解决更复杂、更实际的问题。
第二部分总结与展望:数据结构是算法的“骨架”与“血肉”
兄弟们,恭喜你完成了《算法导论》“大黑书”修炼的第二部分:数据结构篇 - 算法的“骨架”与“血肉”!
在这段硬核的旅程中,我们一起:
-
掌握了数据存储的“基本单位”: 深入剖析了数组和链表这两种最基础的数据结构,理解了它们在内存中的存储方式、各自的优缺点,并通过详细的C语言代码,让你彻底掌握了它们的创建、插入、删除、遍历等核心操作,并学会了在不同场景下进行权衡选择。
-
理解了受限的“操作序列”: 学习了栈和队列这两种特殊的线性数据结构,它们通过LIFO和FIFO的原则,在函数调用、任务调度、表达式求值等场景中发挥着不可替代的作用。我们用C语言实现了它们基于数组和链表的版本,并分析了各自的优缺点。
-
领略了非线性的“家族图谱”: 深入探讨了树的基本概念,特别是二叉树的遍历方式(递归与非递归),以及二叉查找树的插入、查找、删除操作。我们理解了BST可能退化的问题,并引出了平衡二叉查找树(AVL树、红黑树)的重要性,让你明白它们如何保证高效的搜索性能。
-
认识了优先队列的“幕后英雄”: 详细讲解了堆的定义、性质,并通过C语言实现了最大堆的插入和提取最大元素操作,理解了上浮和下沉的原理,以及堆在优先队列、Top K问题中的广泛应用。
-
学会了快速查找的“魔法字典”: 深入剖析了散列表(哈希表)的基本思想、哈希函数,以及哈希冲突的解决办法(特别是链地址法)。我们通过C语言代码实现了基于链地址法的散列表,并讨论了其性能分析和应用场景。
-
探索了复杂关系的“网络图”: 学习了图的基本概念、两种主要表示方法(邻接矩阵和邻接表),并通过C语言实现了邻接表。最重要的是,我们详细讲解了图的两种核心遍历算法——广度优先搜索(BFS)和深度优先搜索(DFS),并提供了C语言实现,让你能够在复杂网络中“漫游”。
-
初步接触了其他高级数据结构: 简要介绍了B树/B+树、Trie树、并查集和跳表等高级数据结构,让你对算法世界的广度有了更深的认识。
现在,你已经掌握了算法的“骨架”与“血肉”——各种数据结构的精髓!你不仅能够理解它们的设计思想,还能用C语言亲手实现它们,并分析它们的性能和适用场景。这些知识,是你在力扣、牛客等刷题平台披荆斩棘的利器,更是你在嵌入式、系统开发等领域构建高效、稳定系统的基石!
这仅仅是《算法导论》“大黑书”终极修炼的第二步!在接下来的第三部分,我们将进入算法的“策略”与“技巧”——算法设计范式篇!我们将一起学习贪心、动态规划、回溯、分支限界等高级算法设计思想,让你能够从容应对各种复杂问题。
如果你觉得这份“秘籍”对你有亿点点帮助,请点赞、收藏、转发!