引言
在信息时代,算法早已不仅是计算机科学的学术话题,而是无数行业的核心支柱。它是什么?简单而言,算法是一组用来解决问题的步骤,是我们将问题转化为程序的一种方法。这种设计不仅能实现目标,更追求效率的极致,带来优雅、简洁且高效的解决方案。
算法的世界充满了挑战和乐趣。它让我们面对问题时,不只是去解决,而是去探索最佳的解决方案。从日常的排序和搜索,到复杂的数据压缩、图像处理,再到机器学习中的深度学习算法,算法无处不在。在计算机的底层,诸如快速傅里叶变换(FFT)加速信号处理、哈希算法实现高效数据存储和检索,甚至一些全球支付系统的加密技术,都依赖于精巧的算法。这些算法不仅影响着我们处理数据的效率,也直接决定了现代信息系统的安全性和稳定性。
探索算法,是体验分而治之、贪心、动态规划等设计思想的过程;是通过复杂性分析和优化,深入理解问题本质的过程。正是在这个过程中,你会不断发现更优雅的解法,不断打破自己对复杂问题的思维局限。当你设计出一个算法解决复杂的挑战,或发现某些问题其实共享着深层次的结构特性,这种成就感是无与伦比的。
数据结构与算法
当我们听到“算法”这个词时,很自然地会想到数学。然而实际上,许多算法并不涉及复杂数学,而是更多
地依赖基本逻辑,这些逻辑在我们的日常生活中处处可见。
例如,查字典。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查
找一个拼音首字母为𝑟的字,通常会按照以下方式实现:
- 翻开字典约一半的页数,查看该页的首字母是什么,假设首字母为𝑚。
- 由于在拼音字母表中𝑟位于𝑚之后,所以排除字典前半部分,查找范围缩小到后半部分。
- 不断重复步骤
- 和步骤2.,直至找到拼音首字母为𝑟的页码为止。
那么算法究竟是什么呢?而与算法密切相关的数据结构又是什么呢?
算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性。
- 问题是明确的,包含清晰的输入和输出定义。
- 具有可行性,能够在有限步骤、时间和内存空间下完成。
- 各步骤都有确定的含义,在相同的输入和运行条件下,输出始终相同。
数据结构(datastructure)是计算机中组织和存储数据的方式,具有以下设计目标。
- 空间占用尽量少,以节省计算机内存。
- 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
- 提供简洁的数据表示和逻辑信息,以便算法高效运行。
数据结构设计是一个充满权衡的过程。如果想在某方面取得提升,往往需要在另一方面作出妥协。(空间与时间的权衡)
而数据结构与算法高度相关、紧密结合!
- 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及操作数据的方法。
- 算法为数据结构赋能。数据结构本身只负责保存数据,而通过算法才能实现特定的功能和问题解决。
- 算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,选择合适的数据结构是关键。
算法分析
迭代
迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段
代码,直到这个条件不再满足。
for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。
func forLoop(n int) int {
res := 0
// 循环求和 1, 2, ..., n-1, n
for i := 1; i <= n; i++ {
res += i
}
return res
}
此求和函数的操作数量与输入数据大小𝑛成正比,或者说成“线性关系”。
与for循环类似,while 循环也是一种实现迭代的方法。在while循环中,每次循环都会检测条件是否为真,则继续执行,否则就结束循环。
func whileLoop(n int) int {
res := 0
// 初始化条件变量
i := 1
// 循环求和 1, 2, ..., n-1, n
for i <= n {
res += i
// 更新条件变量
i++
}
return res
}
while 循环比for 循环的自由度更高。在while 循环中,我们可以自由地设计条件变量的初始化和更新步骤。
我们可以在一个循环结构内嵌套另一个循环结构,
func nestedForLoop(n int) string {
res :=""
//循环i=1, 2, ..., n-1,n
for i :=1; i <=n;i++{
for j :=1; j <=n; j++{
//循环j= 1, 2, ...,n-1,n
res +=fmt.Sprintf("(%d,%d), ", i, j)
}
}
return res
}
在这种情况下,函数的操作数量与𝑛2成正比,或者说算法运行时间和输入数据大小𝑛成“平方关系”,以此类推每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”…
递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
func recur(n int) int {
// 终止条件
if n == 1 {
return 1
}
// 递:递归调用
res := recur(n - 1)
// 归:返回结果
return n + res
}
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范
式。
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以求和函数为例,设问题𝑓(𝑛)=1+2+⋯+𝑛。
- 迭代:在循环中模拟求和过程,从1遍历到𝑛,每轮执行求和操作,即可求得𝑓(𝑛)。
- 递归:将问题分解为子问题𝑓(𝑛)=𝑛+𝑓(𝑛−1),不断(递归地)分解下去,直至基本情况𝑓(1)=1时终止。
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归
通常比迭代更加耗费内存空间。 - 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
在实际中,编程语言允许的递归深度通常是有限的(例如python在windows上默认仅允许递归1000层),过深的递归可能导致栈溢出错误。
如果函数在返回前的最后一步才进行递归调用,则该函数可以被部分编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tailrecursion)。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
/* 普通递归 */
func recur(n int) int {
// 终止条件
if n == 1 {
return 1
}
// 递:递归调用
res := recur(n - 1)
// 归:返回结果
return n + res
}
/* 尾递归 */
func tailRecur(n int, res int) int {
// 终止条件
if n == 0 {
return res
}
// 尾递归调用
return tailRecur(n-1, res+n)
}
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。
- 从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。
数据结构
逻辑结构
集合结构:数据元素同属于一个集合,除此之外无其他关系。
集合结构中的数据元素是无序的,并且每个数据元素都是唯一的,集合中没有相同的数据元素。集合结构很像数学意义上的「集合」。
线性结构:数据元素之间是「一对一」关系。
线性结构中的数据元素(除了第一个和最后一个元素),左侧和右侧分别只有一个数据与其相邻。线性结构类型包括:数组、链表,以及由它们衍生出来的栈、队列、哈希表。
树形结构:数据元素之间是「一对多」的层次关系。
最简单的树形结构是二叉树。这种结构可以简单的表示为:根, 左子树, 右子树。 左子树和右子树又有自己的子树。当然除了二叉树,树形结构类型还包括:多叉树、字典树等。
图形结构:数据元素之间是「多对多」的关系。
图形结构是一种比树形结构更复杂的非线性结构,用于表示物件与物件之间的关系。一张图由一些小圆点(称为 「顶点」 或 「结点」)和连结这些圆点的直线或曲线(称为 「边」)组成。
在图形结构中,任意两个结点之间都可能相关,即结点之间的邻接关系可以是任意的。图形结构类型包括:无向图、有向图、连通图等。
物理结构
顺序存储结构(Sequential Storage Structure):将数据元素存放在一片地址连续的存储单元里,数据元素之间的逻辑关系通过数据元素的存储地址来直接反映。
在顺序存储结构中,逻辑上相邻的数据元素在物理地址上也必然相邻 。
这种结构的优点是:简单、易理解,且实际占用最少的存储空间。缺点是:需要占用一片地址连续的存储单元;并且存储分配要事先进行;另外对于一些操作的时间效率较低(移动、删除元素等操作)。
链式存储结构(Linked Storage Structure):将数据元素存放在任意的存储单元里,存储单元可以连续,也可以不连续。
链式存储结构中,逻辑上相邻的数据元素在物理地址上可能相邻,可也能不相邻。其在物理地址上的表现是随机的。链式存储结构中,一般将每个数据元素占用的若干单元的组合称为一个链结点。每个链结点不仅要存放一个数据元素的数据信息,还要存放一个指出这个数据元素在逻辑关系的直接后继元素所在链结点的地址,该地址被称为指针。换句话说,数据元素之间的逻辑关系是通过指针来间接反映的。
这种结构的优点是:存储空间不必事先分配,在需要存储空间的时候可以临时申请,不会造成空间的浪费;一些操作的时间效率远比顺序存储结构高(插入、移动、删除元素)。缺点是:不仅数据元素本身的数据信息要占用存储空间,指针也需要占用存储空间,链式存储结构比顺序存储结构的空间开销大。
算法复杂度
算法复杂度(Algorithm complexity):在问题的输入规模为 n 的条件下,程序的时间使用情况和空间使用情况。
在算法设计中,我们先后追求以下两个层面的目标。
- 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维
度。
- 时间效率:算法运行速度的快慢。
- 空间效率:算法占用内存空间的大小
简而言之,我们的目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有
这样,我们才能将各种算法进行对比,进而指导算法设计与优化过程。
「算法分析」的目的在于改进算法。正如上文中所提到的那样:算法所追求的就是 所需运行时间更少(时间复杂度更低)、占用内存空间更小(空间复杂度更低)。所以进行「算法分析」,就是从运行时间情况、空间使用情况两方面对算法进行分析。
比较两个算法的优劣通常有两种方法:
- 事后统计:将两个算法各编写一个可执行程序,交给计算机执行,记录下各自的运行时间和占用存储空间的实际大小,从中挑选出最好的算法。
- 预先估算:在算法设计出来之后,根据算法中包含的步骤,估算出算法的运行时间和占用空间。比较两个算法的估算值,从中挑选出最好的算法。
大多数情况下,我们会选择第 2 种方式。因为第 1 种方式的工作量实在太大,得不偿失。另外,即便是同一个算法,用不同的语言实现,在不同的计算机上运行,所需要的运行时间都不尽相同。所以我们一般采用预先估算的方法来衡量算法的好坏。
采用预先估算的方式下,编译语言、计算机运行速度都不是我们所考虑的对象。我们只关心随着问题规模 n 扩大时,时间开销、空间开销的增长情况。
这里的 「问题规模 n」 指的是:算法问题输入的数据量大小。对于不同的算法,定义也不相同。
- 排序算法中:n 表示需要排序的元素数量。
- 查找算法中:n 表示查找范围内的元素总数:比如数组大小、二维矩阵大小、字符串长度、二叉树节点数、图的节点数、图的边界点等。
- 二进制计算相关算法中:n 表示二进制的展开宽度。
时间复杂度
我们将 基本操作次数 作为时间复杂度的度量标准。换句话说,时间复杂度跟算法中基本操作次数的数量正相关。
基本操作 :算法执行中的每一条语句。每一次基本操作都可在常数时间内完成。
基本操作是一个运行时间不依赖于操作数的操作。
比如两个整数相加的操作,如果两个数的规模不大,运行时间不依赖于整数的位数,则相加操作就可以看做是基本操作。
func algorithm(n int) int {
fact := 1 // 执行1次
for i := 1; i < n+1; i++ { // 执行n次
fact *= i // 执行n次
}
return fact // 执行1次
}
把上述算法中所有语句的执行次数加起来:1 + n + n + 1 = 2n + 2,可以用一个函数f(n)来表达语句的执行次数:f(n)=2n+2。则时间复杂度的函数可以表示为:T(n) = O(f(n))。它表示的是随着问题规模 n 的增大,算法执行时间的增长趋势跟f(n)相同。O 是一种渐进符号,T(n)称作算法的 渐进时间复杂度(Asymptotic Time Complexity),简称为 时间复杂度。(经常用到的渐进符号有三种: Θ 渐进紧确界符号、O 渐进上界符号、Ω 渐进下界符号)。
所谓「算法执行时间的增长趋势」是一个模糊的概念,通常我们要借助像上边公式中 O 这样的「渐进符号」来表示时间复杂度。渐进符号(Asymptotic Symbol):专门用来刻画函数的增长速度的。简单来说,渐进符号只保留了 最高阶幂,忽略了一个函数中增长较慢的部分,比如 低阶幂、系数、常量。因为当问题规模变的很大时,这几部分并不能左右增长趋势,所以可以忽略掉。
我们将线性阶的时间复杂度记为𝑂(𝑛),这个数学符号称为大𝑂记号(big‑𝑂notation),表示函数𝑇(𝑛)的渐近上界(asymptoticupperbound)。
时间复杂度分析本质上是计算“操作数量𝑇(𝑛)”的渐近上界,它具有明确的数学定义。
若存在正实数𝑐和实数𝑛0,使得对于所有的𝑛>𝑛0,均有𝑇(𝑛)≤𝑐⋅𝑓(𝑛),则可认为𝑓(𝑛)给出了𝑇(𝑛)的一个渐近上界,记为𝑇(𝑛)=𝑂(𝑓(𝑛))。
计算渐近上界就是寻找一个函数𝑓(𝑛),使得当𝑛趋向于无穷大时,𝑇(𝑛)和𝑓(𝑛)处于相同的增长级别,仅相差一个常数项𝑐的倍数。
推算方法
在计算时间复杂度的时候,我们经常使用 O 渐进上界符号。因为我们关注的通常是算法用时的上界,而不用关心其用时的下界。
求解时间复杂度一般分为以下几个步骤:
- 找出算法中的基本操作(基本语句):算法中执行次数最多的语句就是基本语句,通常是最内层循环的循环体部分。
- 计算基本语句执行次数的数量级:只需要计算基本语句执行次数的数量级,即保证函数中的最高次幂正确即可。像最高次幂的系数和低次幂可以忽略。 (这是因为在𝑛趋于无穷大时,最高阶的项将发挥主导作用,其他
项的影响都可以忽略。) - 用大 O 表示法表示时间复杂度:将上一步中计算的数量级放入 O 渐进上界符号中。
同时,在求解时间复杂度还要注意一些原则:
- 加法原则:总的时间复杂度等于量级最大的基本语句的时间复杂度。
- 乘法原则:循环嵌套代码的复杂度等于嵌套内外基本语句的时间复杂度乘积。
常见时间复杂度
设输入数据大小为𝑛,常见的时间复杂度类型如下:
- 𝑂(1) < 𝑂(log𝑛) < 𝑂(𝑛) < 𝑂(𝑛log𝑛) < 𝑂(𝑛2) < 𝑂(2𝑛) < 𝑂(𝑛!)
- 常数阶<对数阶<线性阶<线性对数阶<平方阶<指数阶<阶乘阶
常数 O(1)
一般情况下,只要算法中不存在循环语句、递归语句,其时间复杂度都为 O(1)。
O(1) 只是常数阶时间复杂度的一种表示方式,并不是指只执行了一行代码。只要代码的执行时间不随着问题规模 n 的增大而增长,这样的算法时间复杂度都记为 O(1)。
func alg(n int) int {
a, b := 1, 2
return a*b + n
}
上述代码虽然有 2行代码,但时间复杂度也是 O(1),而不是 O(2)。
线性 O(n)
一般含有非嵌套循环,且单层循环下的语句执行次数为 n 的算法涉及线性时间复杂度。这类算法随着问题规模 n 的增大,对应计算次数呈线性增长。
func alg(n int) int {
sum := 0
for i := 1; i <= n; i++ {
sum += i
}
return sum
}
上述代码中 sum += 1 的执行次数为 n 次,所以这段代码的时间复杂度为 O(n)。
平方 O(n**2)
一般含有双层嵌套,且每层循环下的语句执行次数为 n 的算法涉及平方时间复杂度。这类算法随着问题规模 n 的增大,对应计算次数呈平方关系增长。
func alg(n int) int {
sum := 0
for i := 1; i <= n; i++ {
for j := 1; j <= n; j++ {
sum += 1
}
}
return sum
}
上述代码中,res += 1 在两重循环中,根据时间复杂度的乘法原理,这段代码的执行次数为 n2次,所以其时间复杂度为 O(n2)。
阶乘 O(n!)
阶乘时间复杂度一般出现在与「全排列」、「旅行商问题暴力解法」相关的算法中。这类算法随着问题规模 n 的增大,对应计算次数呈阶乘关系增长。
func permutations(sets []int, start, end int) {
if start == end {
fmt.Println(sets)
return
}
for i := start; i <= end; i++ {
sets[i], sets[start] = sets[start], sets[i]
permutations(sets, start+1, end)
sets[i], sets[start] = sets[start], sets[i]
}
}
在上述代码中,递归方法用于实现「全排列」。假设数组 arr
的长度为 n
,每一层递归对应一个 for
循环。
- 第一层
for
循环执行了n
次; - 第二层
for
循环执行了n-1
次; - 以此类推,直到最后一层
for
循环只执行 1 次。
因此,各层 for
循环的执行次数相乘,形成了一个阶乘计算:
n * (n - 1) * (n - 2) * ... * 2 * 1 = n!
所以,这个递归算法的基本语句执行次数为 n!
,其时间复杂度为 O(n!)
。
对数 O(logn)
对数时间复杂度一般出现在「二分查找」、「分治」这种一分为二的算法中。这类算法随着问题规模 n 的增大,对应的计算次数呈对数关系增长。
func alg(n int) int {
count := 1
for count < n {
count *= 2
}
return count
}
在上述代码中,cnt = 1
的时间复杂度为 O(1)
,可以忽略不计。while
循环中,变量 cnt
从 1 开始,每次循环都乘以 2。当 cnt
大于等于 n
时,循环结束。
变量 cnt
的取值是一个等比数列:2^0, 2^1, 2^2, ..., 2^x
,根据 2^x = n
可以得出,循环体的执行次数为 log2(n)
,因此这段代码的时间复杂度为 O(log2(n))
。
因为 log2(n) = k * log10(n)
,其中 k ≈ 3.322
是一个常数系数,log2(n)
和 log10(n)
之间的差距较小,可以忽略不计。因此,我们通常将对数时间复杂度写作 O(log n)
。
线性对数 O(n×logn)
线性对数一般出现在排序算法中,例如「快速排序」、「归并排序」、「堆排序」等。这类算法随着问题规模 n 的增大,对应的计算次数呈线性对数关系增长。
func alg(n int) int {
count, res := 1, 0
for count < n {
count *= 2
for i := 0; i < n; i++ {
res += 1
}
}
return res
}
在上述代码中,外层循环的时间复杂度为 O(log n)
,而内层循环的时间复杂度为 O(n)
。由于这两层循环是相互独立的,因此总的时间复杂度可以通过相乘得出:
总体时间复杂度为 O(n × log n)
。
指数𝑂(2**𝑛)
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为1个细胞,分裂一轮后变为2个,分裂两轮后
变为4个,以此类推,分裂𝑛轮后有2𝑛个细胞。
func exponential(n int) int {
count, base := 0, 1
//细胞每轮一分为二,形成数列1,2,4, 8, ...,2^(n-1)
for i := 0; i < n; i++ {
for j := 0; j < base; j++ {
count++
}
base *= 2
}
//count=1 +2 +4 +8 +..+2^(n-1) =2^n- 1
return count
}
最佳、最坏、平均时间复杂度
算法的时间效率往往不是固定的,而是与输入数据的分布有关。
时间复杂度是一个关于输入问题规模 n 的函数。但是因为输入问题的内容不同,习惯将「时间复杂度」分为「最佳」、「最坏」、「平均」三种情况。这三种情况的具体含义如下:
- 最佳时间复杂度:每个输入规模下用时最短的输入所对应的时间复杂度。
- 最坏时间复杂度:每个输入规模下用时最长的输入所对应的时间复杂度。
- 平均时间复杂度:每个输入规模下所有可能的输入所对应的平均用时复杂度(随机输入下期望用时的复杂度)。
假设输入一个长度为𝑛的数组nums ,其中nums 由从1至𝑛的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素1的索引。可以得出以下结论。
- 当nums = [?, ?, …, 1],即当末尾元素是1时,需要完整遍历数组,达到最差时间复杂度𝑂(𝑛)。
- 当nums = [1, ?, ?, …],即当首个元素为1时,无论数组多长都不需要继续遍历,达到最佳时间复杂度Ω(1)。
“最差时间复杂度”对应函数渐近上界,使用大𝑂记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用Ω记号表示。
func findOne(nums []int) int {
for i := 0; i < len(nums); i++ {
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if nums[i] == 1 {
return i
}
}
return -1
}
在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值。
最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用Θ记号来表示。
对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。
在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
可能由于𝑂符号过于朗朗上口,因此我们常常使用它来表示平均时间复杂度。但从严格意义上讲,这
种做法并不规范。
空间复杂度
空间复杂度(Space Complexity):在输入规模为 n
的情况下,算法所占用的空间大小,记作 S(n)
。通常使用算法的辅助空间作为衡量空间复杂度的标准。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”
算法在运行过程中使用的内存空间主要包括以下几种。
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数
返回后,栈帧空间会被释放。 - 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
- 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分:
相关代码如下:
/* 结构体 */
type node struct {
val int
next *node
}
/* 创建 node 结构体 */
func newNode(val int) *node {
return &node{val: val}
}
/* 函数 */
func function() int {
// 执行某些操作...
return 0
}
func algorithm(n int) int { // 输入数据
const a = 0 //暂存数据(常量)
b := 0 //暂存数据(变量)
newNode(0) //暂存数据(对象)
c := function() //栈帧空间(调用函数)
return a + b + c //输出数据
}
与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须
确保在所有输入数据下都有足够的内存空间预留。
以下代码为例,最差空间复杂度中的“最差”有两层含义。
- 以最差输入数据为准:当𝑛<10时,空间复杂度为𝑂(1);但当𝑛>10时,初始化的数组nums占
用𝑂(𝑛)空间,因此最差空间复杂度为𝑂(𝑛)。 - 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用𝑂(1)空间;当初始化数组nums
时,程序占用𝑂(𝑛)空间,因此最差空间复杂度为𝑂(𝑛)。
func algorithm(n int) {
a := 0 //O(1)
b := make([]int, 10000) //O(1)
var nums []int
if n > 10 {
nums = make([]int, n) //O(n)
}
fmt.Println(a, b, nums)
}
在递归函数中,需要注意统计栈帧空间。
func function() int {
//执行某些操作
return 0
}
/*循环的空间复杂度为O(1)*/
func loop(n int) {
for i := 0; i < n; i++ {
function()
}
}
/*递归的空间复杂度为O(n)*/
func recur(n int) {
if n == 1 {
return
}
recur(n- 1)
}
函数loop()和recur() 的时间复杂度都为𝑂(𝑛),但空间复杂度不同。
- 函数loop()在循环中调用了𝑛次function() ,每轮中的function() 都返回并释放了栈帧空间,因此
空间复杂度仍为𝑂(1)。 - 递归函数recur() 在运行过程中会同时存在𝑛个未返回的recur() ,从而占用𝑂(𝑛)的栈帧空间。
参考资料
- 【书籍】数据结构(C++ 语言版)
- 【书籍】算法导论 第三版(中文版)
- 【书籍】图解算法
数组和链表
数组
数组(array)是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的
位置称为该元素的索引(index)。
以整数数组为例,数组的存储方式如下图所示。
如上图所示,假设数据元素的个数为 n
,则数组中的每一个数据元素都有自己的下标索引,下标索引从 0
开始,到 n - 1
结束。数组中的每一个「下标索引」,都有一个与之相对应的「数据元素」。
从上图还可以看出,数组在计算机中的表示,就是一片连续的存储单元。数组中的每一个数据元素都占有一定的存储单元,每个存储单元都有自己的内存地址,并且元素之间是紧密排列的。
数组的一个最大特点是:可以进行随机访问
。即数组可以根据下标,直接定位到某一个元素存放的位置。
那么,计算机是如何实现根据下标随机访问数组元素的?
计算机给一个数组分配了一组连续的存储空间,其中第一个元素开始的地址被称为「首地址」。每个数据元素都有对应的下标索引和内存地址,计算机通过地址来访问数据元素。当计算机需要访问数组的某个元素时,会通过「寻址公式」计算出对应元素的内存地址,然后访问地址对应的数据元素。
寻址公式如下:
下标 i 对应的数据元素地址 = 数据首地址 + i × 单个数据元素所占内存大小。
上面介绍的数组只有一个维度,称为一维数组,其数据元素也是单下标变量。但是在实际问题中,很多信息是二维或者是多维的,一维数组已经满足不了我们的需求,所以就有了多维数组。
以二维数组为例,数组的形式如下图所示。
二维数组是一个由 m 行 n 列数据元素构成的特殊结构,其本质上是以数组作为数据元素的数组,即「数组的数组」。二维数组的第一维度表示行,第二维度表示列。
我们可以将二维数组看作是一个矩阵,并处理矩阵的相关问题,比如转置矩阵、矩阵相加、矩阵相乘等等。
数组的基本操作
数据结构的操作一般涉及到增、删、改、查共 4 种情况。
初始化数组
// 声明长度为5的int数组
var arr [5]int
// 声明并初始化
var arr = [5]int{1, 2, 3, 4, 5}
// 让编译器猜测数组长度
var arr = [...]int{1, 2, 3, 4, 5}
// 海牛运算符=声明并赋值
b := [...]int{1}
访问元素
从地址计算公式的角度看,索引本质上是内存地址的偏移量。首个元素的地址偏移量是0,因此它的索引为0是
合理的。
在数组中访问元素非常高效,我们可以在𝑂(1)时间内随机访问数组中的任意一个元素。
func randomAccess(nums [2]int) (randomNum int) {
// 在区间 [0, nums.length) 中随机抽取一个数字
randomIndex := rand.Intn(len(nums))
// 获取并返回随机元素
randomNum = nums[randomIndex]
return
}
func main() {
arr := [...]int{1,2}
randomAccess(arr)
}
插入元素
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如果想在数组中间插
入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。
func insert(nums [2]int, num int, index int) [2]int {
// 把索引 index 以及之后的所有元素向后移动一位
for i := len(nums) - 1; i > index; i-- {
nums[i] = nums[i-1]
}
// 将 num 赋给 index 处的元素
nums[index] = num
// 数组是值类型
// 函数内部的数组只是arr的一个完全COPY
// 因此要返回
return nums
}
func main() {
arr := [...]int{1, 2}
insert(arr, 0, 2)
}
删除元素
若想删除索引𝑖处的元素,则需要把索引𝑖之后的元素都向前移动一位。
删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无须特意去修改它。
/* 删除索引 index 处的元素 */
func remove(nums [2]int, index int) {
// 把索引 index 之后的所有元素向前移动一位
for i := index; i < len(nums)-1; i++ {
nums[i] = nums[i+1]
}
}
总的来看,数组的插入与删除操作有以下缺点。
- 时间复杂度高:数组的插入和删除的平均时间复杂度均为𝑂(𝑛),其中𝑛为数组长度。
- 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
- 内存浪费:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
遍历数组
func traverse(nums [2]int) {
count := 0
//通过索引遍历数组
for i := 0; i < len(nums); i++ {
count += nums[i]
}
count = 0
//直接遍历数组元素
for _, num := range nums {
count += num
}
//同时遍历数据索引和元素
for i, num := range nums {
count += nums[i]
count += num
}
}
查找元素
在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。
因为数组是线性数据结构,所以上述查找操作被称为“线性查找”。
func find(nums [2]int, target int) int {
index := -1
for i := 0; i < len(nums); i++ {
if nums[i] == target {
index = i
break
}
}
return index
}
扩容数组
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在
大多数编程语言中,数组的长度是不可变的。
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次复制到新数组。这是一个O(𝑛)的操作,在数组很大的情况下非常耗时。
go语言等多数编程语言提供了一个变长数组类型,如go的slice,python的list,这些变长数组已经将扩缩容等操作封装在底层,我们程序开发者则无需关心。
/* 扩展数组长度 */
func extend(nums []int, enlarge int) []int {
// 初始化一个扩展长度后的数组
// make用于初始化一个切片
// 切片是引用类型,底层引用的数组
res := make([]int, len(nums)+enlarge)
// 将原数组中的所有元素复制到新数组
for i, num := range nums {
res[i] = num
}
// 返回扩展后的新数组
return res
}
由于变长数组的使用场景远远大于普通数组(有些时候我们甚至难以写函数的返回值),因此像python这样的语言直接抛弃了普通数组的概念,因此python的list可以无限扩容。
数组的优点与局限性
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来
优化数据结构的操作效率。
- 空间效率高:数组为数据分配了连续的内存块,无须额外的结构开销。
- 支持随机访问:数组允许在𝑂(1)时间内访问任何元素。
- 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓
存来提升后续操作的执行速度。
连续空间存储是一把双刃剑,其存在以下局限性。
- 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
- 长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
- 空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
数组典型应用
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
- 随机访问:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现
随机抽样。 - 排序和搜索:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数
组上进行。 - 查找表:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。假如我们想实现字符到
ASCII 码的映射,则可以将字符的ASCII码值作为索引,对应的元素存放在数组中的对应位置。 - 机器学习:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式
构建的。数组是神经网络编程中最常使用的数据结构。 - 数据结构实现:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实
际上是一个二维数组。
链表
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
链表(linkedlist)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。
如以下代码所示,链表节点 ListNode 除了包含值,还需额外保存一个指针(我知道你家的地址!!)。因此在相同数据量下,链
表比数组占用更多的内存空间。
/* 链表节点结构体 */
type ListNode struct {
Val int
// 节点值
Next *ListNode // 指向下一节点的指针
}
// NewListNode 构造函数,创建一个新的链表
func NewListNode(val int) *ListNode {
return &ListNode{
Val: val,
Next: nil,
}
}
链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。尾节点指向的是“空”。
链表常用操作
初始化链表
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建节点之间的引用关系。
/* 初始化链表 1-> 3-> 2-> 5-> 4 */
// 初始化各个节点
n0 := NewListNode(1)
n1 := NewListNode(3)
n2 := NewListNode(2)
n3 := NewListNode(5)
n4 := NewListNode(4)
// 构建节点之间的引用
n0.Next = n1
n1.Next = n2
n2.Next = n3
n3.Next = n4
我们通常将头节点当作链表的代称,比如以上代码中的链表可记作链表n0。
查找结点
在链表中访问节点的效率较低。程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第𝑖个节点需要循环𝑖−1轮,时间复杂度为𝑂(𝑛)。
在链表中查找值为 val 的元素:从头节点 head 开始,沿着链表节点逐一进行查找。如果查找成功,返回被查找节点的地址;否则返回 None。
让指针变量 cur 指向链表的第一个链节点。顺着链节点的 next 指针遍历链表,如果遇到 cur.val == val,则返回当前指针变量 cur。如果 cur 指向为空时也未找到,则该链表中没有值为 val 的元素,则返回 None。
func findNode(head *ListNode, val int) *ListNode {
for cur := head; cur != nil; cur = cur.Next {
if cur.Val == val {
return cur
}
}
return nil
}
插入节点
链表中插入元素操作分为三种:
- 链表头部插入元素:在链表第一个链节点之前插入值为 val 的链节点。
- 链表尾部插入元素:在链表最后一个链节点之后插入值为 val 的链节点。
- 链表中间插入元素:在链表第 i 个链节点之前插入值为 val 的链节点。
链表头部插入元素
链表头部插入元素:在链表第一个链节点之前插入值为 val 的链节点。
首先,创建一个值为 val 的链节点 node。然后,将 node 的 next 指针指向链表的头节点 head。接着,将链表的头节点 head 指向 node。
func insertNode(head *ListNode, val int) *ListNode {
node := NewListNode(val)
node.Next = head
return node
}
链表头部插入元素的操作与链表的长度无关,因此,链表头部插入元素的时间复杂度为 O(1)。
链表尾部插入元素
链表尾部插入元素:在链表最后一个链节点之后插入值为 val 的链节点。
首先,创建一个值为 val 的链节点 node。然后,使用指针 cur 指向链表的头节点 head。通过链节点的 next 指针移动 cur 指针,从而遍历链表,直到 cur.next 为 None。最后,令 cur.next 指向新的链节点 node。
func insertNode(head *ListNode, val int) {
cur := head
for cur.Next != nil {
cur = cur.Next
}
node := NewListNode(val)
cur.Next = node
}
链表尾部插入元素的操作需要将 cur 从链表头部移动到尾部,操作次数是 n 次,因此,链表尾部插入元素的时间复杂度是 O(n)。
链表中间插入元素
链表中间插入元素:在链表值为target的节点后插入值为 val 的链节点。
先找到target节点的地址,然后创建一个值为 val 的链节点 node。将 node.next 指向 cur.next。然后令 cur.next 指向 node。
func insertNode(head *ListNode, target int, val int) error {
node := findNode(head, target)
if node == nil {
return fmt.Errorf("not found %d \n", target)
}
newNode := NewListNode(val)
newNode.Next = node.Next
node.Next = newNode
return nil
}
链表中间插入元素的操作需要将 cur 从链表头部移动到第 i 个链节点之前,操作的平均时间复杂度是 O(n),因此,链表中间插入元素的时间复杂度是 O(n)。
删除节点
在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可。
链表的删除元素操作与链表的查找元素操作一样,同样分为三种情况:
- 链表头部删除元素:删除链表的第一个链节点。
- 链表尾部删除元素:删除链表末尾的最后一个链节点。
- 链表中间删除元素:删除链表第 i 个链节点。
链表头部删除元素
链表头部删除元素:删除链表的第一个链节点。
直接将 self.head 沿着 next 指针向右移动一步即可。
func removeItem(head *ListNode) *ListNode {
next := head.Next
head.Next = nil
return next
}
链表头部删除元素只涉及到一步移动操作,因此,链表头部删除元素的时间复杂度为 O(1)。
链表尾部删除元素
链表尾部删除元素:删除链表末尾的最后一个链节点。
首先,使用指针变量 cur 沿着 next 指针移动到倒数第二个链节点。然后,将此节点的 next 指针指向 None 即可。
func removeItem(head *ListNode) {
cur := head
for cur.Next.Next != nil {
cur = cur.Next
}
cur.Next = nil
}
链表尾部删除元素的操作涉及到移动到链表尾部,操作次数为 n-2 次,因此,链表尾部删除元素的时间复杂度为 O(n)。
链表中间删除元素
链表中间删除元素:删除链表第 i 个链节点。
首先,使用指针变量 cur 移动到第 i-1 个位置的链节点。然后,将 cur 的 next 指针指向要删除的第 i 个元素的下一个节点即可。
func removeItem(head *ListNode, index int) {
cur := head
curIndex := 0
for cur.Next != nil && curIndex < index-1 {
curIndex++
cur = cur.Next
}
delNode := cur.Next
cur.Next = delNode.Next
}
链表中间删除元素的操作需要将 cur 从链表头部移动到第 i 个链节点之前,操作的平均时间复杂度是 O(n),因此,链表中间删除元素的时间复杂度是 O(n)。
常见链表类型
常见的链表类型包括三种。
-
单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空None 。
-
环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
-
双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间
单向链表通常用于实现栈、队列、哈希表和图等数据结构。
-
栈与队列:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
-
哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
-
图:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
双向链表常用于需要快速查找前一个和后一个元素的场景。
-
高级数据结构:比如在红黑树、B树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
-
浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
-
LRU算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。
-
时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的CPU调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU将切换到下一个进程。这种循环操作可以通过环形链表来实现。
-
数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。
数组vs链表
数组是最基础、最简单的数据结构。数组是实现线性表的顺序结构存储的基础。它使用一组连续的内存空间,来存储一组具有相同类型的数据。
数组的最大特点是支持随机访问。访问数组元素、改变数组元素的时间复杂度为 O(1),在数组尾部插入、删除元素的时间复杂度也是 O(1),普通情况下插入、删除元素的时间复杂度为 O(n)。
链表是最基础、最简单的数据结构。链表是实现线性表的链式存储结构的基础。它使用一组任意的存储单元(可以是连续的,也可以是不连续的),来存储一组具有相同类型的数据。
链表最大的优点在于可以灵活地添加和删除元素。
链表进行访问元素、改变元素操作的时间复杂度为 O(n)。
链表进行头部插入、头部删除元素操作的时间复杂度是 O(1)。
链表进行尾部插入、尾部删除操作的时间复杂度是 O(n)。
链表在普通情况下进行插入、删除元素操作的时间复杂度为 O(n)。
操作 | 数组 (Array) | 链表 (Linked List) |
---|---|---|
访问元素 | O(1) | O(n) |
改变元素 | O(1) | O(n) |
头部插入/删除元素 | O(n) | O(1) |
尾部插入/删除元素 | O(1) | O(n) |
普通插入/删除元素 | O(n) | O(n) |
栈和队列
栈
栈(stack)是一种线性表数据结构,是一种只允许在表的一端进行插入和删除操作的线性表。
可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将
盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。
简单来说,栈是一种 后进先出(Last In First Out) 的线性表,简称为 LIFO 结构。
我们可以从两个方面来解释栈的定义:
-
线性表:栈首先是一个线性表,栈中元素具有前驱后继的线性关系。栈中元素按照 a1, a2, …, an 的次序依次进栈。栈顶元素为 an。
-
后进先出原则:根据栈的定义,每次删除的总是栈中当前的栈顶元素,即最后进入栈的元素。而在进栈时,最先进入栈的元素一定在栈底,最后进入栈的元素一定在栈顶。也就是说,元素进入栈或退出栈是按照 后进先出(Last In First Out) 的原则进行的。
和线性表类似,栈有两种存储表示方法:顺序栈 和 链式栈。
- 顺序栈:顺序栈是堆栈的顺序存储结构,它利用一组地址连续的存储单元依次存放自栈底到栈顶的元素。为了指示栈顶元素在顺序栈中的位置,使用一个指针 top 来标识栈顶元素的位置。
- 优点:顺序栈实现简单,容易理解。
- 缺点:栈的容量固定,可能会发生栈溢出,或者空间浪费。
- 链式栈:链式栈是堆栈的链式存储结构,它利用单链表的方式来实现栈。栈中元素按照插入顺序依次插入到链表的第一个节点之前,并使用栈顶指针 top 指示栈顶元素。
- 优点:链式栈不需要预先定义栈的大小,可以动态扩展。
- 缺点:需要额外的指针存储,且每次访问栈顶元素的时间复杂度为 O(1),但是链表需要通过指针遍历,因此需要更多的内存操作。
- 在链式栈中,top 永远指向链表的头节点。
栈的基本操作
栈作为一种线性表,理论上应具备线性表的所有操作特性,但由于其「后进先出(LIFO)」的特殊性,栈的操作方式有所变化,主要体现在入栈(push)和出栈(pop)。
-
初始化空栈:创建一个空栈,定义栈的大小 size,以及栈顶元素指针 top。
-
判断栈是否为空:当栈为空时,返回 True;当栈不为空时,返回 False。此操作通常用于栈的删除操作和获取栈顶元素操作。
-
判断栈是否已满:当栈已满时,返回 True;当栈未满时,返回 False。该操作主要用于顺序栈中,判断是否可以插入新元素。
-
插入元素(进栈、入栈):相当于在线性表中最后一个元素后插入一个新的数据元素,并更新栈顶指针 top 的位置。
-
删除元素(出栈、退栈):相当于在线性表中删除最后一个数据元素,并更新栈顶指针 top 的位置。
-
获取栈顶元素:相当于获取线性表中最后一个数据元素。此操作不改变栈顶指针 top 的位置。
堆栈的顺序存储实现
使用数组实现栈时,可以将数组的尾部作为栈顶。入栈与出栈操作分别对应在数组尾部添加元素与删除元素,时间复杂度都为𝑂(1)
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。
/*基于数组实现的栈*/
type arrayStack struct {
data []int //数据
}
/*初始化栈*/
func newArrayStack() *arrayStack {
return &arrayStack{
//设置栈的长度为0,容量为16
data: make([]int, 0, 16),
}
}
/*栈的长度*/
func (s *arrayStack) size() int {
return len(s.data)
}
/*栈是否为空*/
func (s *arrayStack) isEmpty() bool {
return s.size() == 0
}
/*入栈*/
func (s *arrayStack) push(v int) {
//切片会自动扩容
s.data = append(s.data, v)
}
/*出栈*/
func (s *arrayStack) pop() any {
val := s.peek()
s.data = s.data[:len(s.data)-1]
return val
}
/* 获取栈顶元素 */
func (s *arrayStack) peek() any {
if s.isEmpty() {
return nil
}
val := s.data[len(s.data)-1]
return val
}
/* 获取 Slice 用于打印 */
func (s *arrayStack) toSlice() []int {
return s.data
}
堆栈的链式存储实现
使用链表实现栈时,可以将链表的头节点视为栈顶,尾节点视为栈底。
对于入栈操作,只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
/*基于链表实现的栈*/
type linkedListStack struct {
//使用内置包list来实现栈
data *list.List
}
/*初始化栈*/
func newLinkedListStack() *linkedListStack {
return &linkedListStack{
data: list.New(),
}
}
/*入栈*/
func (s *linkedListStack) push(value int) {
s.data.PushBack(value)
}
/*出栈*/
func (s *linkedListStack) pop() any {
if s.isEmpty() {
return nil
}
e := s.data.Back()
s.data.Remove(e)
return e.Value
}
/*访问栈顶元素*/
func (s *linkedListStack) peek() any {
if s.isEmpty() {
return nil
}
e := s.data.Back()
return e.Value
}
/*获取栈的长度*/
func (s *linkedListStack) size() int {
return s.data.Len()
}
/*判断栈是否为空*/
func (s *linkedListStack) isEmpty() bool {
return s.data.Len() == 0
}
/*获取List用于打印*/
func (s *linkedListStack) toList() *list.List {
return s.data
}
栈的典型应用
-
浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
-
程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。
队列
队列(queue)是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断
加入队列尾部,而位于队列头部的人逐个离开。
我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删
除队首元素的操作称为“出队”。
简单来说,队列是一种 「先进先出(First In First Out)」 的线性表,简称为 「FIFO 结构」。可以从两个方面来解释队列的定义:
第一个方面是 「线性表」:队列首先是一个线性表,队列中元素具有前驱后继的线性关系。队列中元素按照 a1, a2, …, an 的次序依次入队。队头元素为 a1,队尾元素为 an。
第二个方面是 「先进先出原则」:根据队列的定义,最先进入队列的元素在队头,最后进入队列的元素在队尾。每次从队列中删除的总是队头元素,即最先进入队列的元素。也就是说,元素进入队列或者退出队列是按照 「先进先出(First In First Out)」 的原则进行的。
和线性表类似,队列有两种存储表示方法:「顺序存储的队列」 和 「链式存储的队列」。
顺序存储的队列:利用一组地址连续的存储单元依次存放队列中从队头到队尾的元素,同时使用指针 front 指向队头元素在队列中的位置,使用指针 rear 指示队尾元素在队列中的位置。
链式存储的队列:利用单链表的方式来实现队列。队列中元素按照插入顺序依次插入到链表的第一个节点之后,并使用队头指针 front 指向链表头节点位置,也就是队头元素,rear 指向链表尾部位置,也就是队尾元素。
注意:
- front 和 rear 的指向位置并不完全固定。有时候为了算法设计的方便或者代码简洁,front 可能指向队头元素所在位置的前一个位置。
- rear 也可能指向队尾元素在队列位置的下一个位置。具体要根据算法的实现方式来决定。
队列的基本操作
-
初始化空队列:
创建一个空队列,定义队列的大小size
,以及队头元素指针front
,队尾指针rear
。 -
判断队列是否为空:
- 当队列为空时,返回
True
。 - 当队列不为空时,返回
False
。
一般只用于「出队操作」和「获取队头元素操作」中。
- 当队列为空时,返回
-
判断队列是否已满:
- 当队列已满时,返回
True
。 - 当队列未满时,返回
False
。
一般只用于顺序队列中插入元素操作中。
- 当队列已满时,返回
-
插入元素(入队):
相当于在线性表最后一个数据元素后面插入一个新的数据元素,并改变队尾指针rear
的指向位置。 -
删除元素(出队):
相当于在线性表中删除第一个数据元素,并改变队头指针front
的指向位置。 -
获取队头元素:
相当于获取线性表中第一个数据元素。
与插入元素(入队)、删除元素(出队)不同的是,该操作并不改变队头指针front
的指向位置。 -
获取队尾元素:
相当于获取线性表中最后一个数据元素。
与插入元素(入队)、删除元素(出队)不同的是,该操作并不改变队尾指针rear
的指向位置。
基于链表的实现
可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
/*基于链表实现的队列*/
type linkedListQueue struct {
//使用内置包list来实现队列
data *list.List
}
/*初始化队列*/
func newLinkedListQueue() *linkedListQueue {
return &linkedListQueue{
data: list.New(),
}
}
/*入队*/
func (s *linkedListQueue) push(value any) {
s.data.PushBack(value)
}
/*出队*/
func (s *linkedListQueue) pop() any {
if s.isEmpty() {
return nil
}
e := s.data.Front()
s.data.Remove(e)
return e.Value
}
/*访问队首元素*/
func (s *linkedListQueue) peek() any {
if s.isEmpty() {
return nil
}
e := s.data.Front()
return e.Value
}
/*获取队列的长度*/
func (s *linkedListQueue) size() int {
return s.data.Len()
}
/*判断队列是否为空*/
func (s *linkedListQueue) isEmpty() bool {
return s.data.Len() == 0
}
/*获取List用于打印*/
func (s *linkedListQueue) toList() *list.List {
return s.data
}
基于数组的实现
在数组中删除首元素的时间复杂度为𝑂(𝑛),这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
使用一个变量front指向队首元素的索引,并维护一个变量size用于记录队列长度。定义rear = front +size,这个公式计算出的rear指向队尾元素之后的下一个位置。
基于此设计,数组中包含元素的有效区间为[front,rear- 1]:
- 入队操作:将输入元素赋值给rear索引处,并将size增加1。
- 出队操作:只需将front增加1,并将size减少1。
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为𝑂(1)。
你可能会发现一个问题:在不断进行入队和出队的过程中,front 和rear 都在向右移动,当它们到达数组尾部时就无法继续移动了。
为了解决此问题,我们可以将数组视为首尾相接的“环形数组”。对于环形数组,我们需要让front 或rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示:
/* 基于环形数组实现的队列 */
type arrayQueue struct {
nums []int // 用于存储队列元素的数组
front, queSize int
// 队首指针,指向队首元素
// 队列长度
queCapacity int // 队列容量(即最大容纳元素数量)
}
/* 初始化队列 */
func newArrayQueue(queCapacity int) *arrayQueue {
return &arrayQueue{
nums: make([]int, queCapacity),
queCapacity: queCapacity,
front: 0,
queSize: 0,
}
}
/* 获取队列的长度 */
func (q *arrayQueue) size() int {
return q.queSize
}
/* 判断队列是否为空 */
func (q *arrayQueue) isEmpty() bool {
return q.queSize == 0
}
/* 入队 */
func (q *arrayQueue) push(num int) {
// 当 rear == queCapacity 表示队列已满
if q.queSize == q.queCapacity {
return
}
// 计算队尾指针,指向队尾索引 + 1
// 通过取余操作实现 rear 越过数组尾部后回到头部
rear := (q.front + q.queSize) % q.queCapacity
// 将 num 添加至队尾
q.nums[rear] = num
q.queSize++
}
/* 出队 */
func (q *arrayQueue) pop() any {
num := q.peek()
// 队首指针向后移动一位,若越过尾部,则返回到数组头部
q.front = (q.front + 1) % q.queCapacity
q.queSize--
return num
}
/* 访问队首元素 */
func (q *arrayQueue) peek() any {
if q.isEmpty() {
return nil
}
return q.nums[q.front]
}
/* 获取 Slice 用于打印 */
func (q *arrayQueue) toSlice() []int {
rear := (q.front + q.queSize)
if rear >= q.queCapacity {
rear %= q.queCapacity
return append(q.nums[q.front:], q.nums[:rear]...)
}
return q.nums[q.front:rear]
}
队列典型应用
-
淘宝订单。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
-
各类待办事项。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等,队列在这些场景中可以有效地维护处理顺序。
双向队列
在队列中,我们仅能删除头部元素或在尾部添加元素。双向队列(double‑endedqueue)提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
双向队列常用操作
方法名 | 描述 | 时间复杂度 |
---|---|---|
push_first() | 将元素添加至队首 | O(1) |
push_last() | 将元素添加至队尾 | O(1) |
pop_first() | 删除队首元素 | O(1) |
pop_last() | 删除队尾元素 | O(1) |
peek_first() | 访问队首元素 | O(1) |
peek_last() | 访问队尾元素 | O(1) |
/*初始化双向队列*/
//在Go中,将list作为双向队列使用
deque := list.New()
/*元素入队*/
deque.PushBack(2) //添加至队尾
deque.PushBack(5)
deque.PushBack(4)
deque.PushFront(3) //添加至队首
deque.PushFront(1)
/*访问元素*/
front := deque.Front() //队首元素
rear := deque.Back() //队尾元素
/*元素出队*/
deque.Remove(front) //队首元素出队
deque.Remove(rear) //队尾元素出队
/*获取双向队列的长度*/
size := deque.Len()
/*判断双向队列是否为空*/
isEmpty := deque.Len() == 0
双向队列实现
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
基于双向链表的实现
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。
我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
type linkedListDeque struct {
// 使用内置包 list
data *list.List
}
/* 初始化双端队列 */
func newLinkedListDeque() *linkedListDeque {
return &linkedListDeque{
data: list.New(),
}
}
/* 队首元素入队 */
func (s *linkedListDeque) pushFirst(value any) {
s.data.PushFront(value)
}
/* 队尾元素入队 */
func (s *linkedListDeque) pushLast(value any) {
s.data.PushBack(value)
}
/* 队首元素出队 */
func (s *linkedListDeque) popFirst() any {
if s.isEmpty() {
return nil
}
e := s.data.Front()
s.data.Remove(e)
return e.Value
}
/* 队尾元素出队 */
func (s *linkedListDeque) popLast() any {
if s.isEmpty() {
return nil
}
e := s.data.Back()
s.data.Remove(e)
return e.Value
}
/* 访问队首元素 */
func (s *linkedListDeque) peekFirst() any {
if s.isEmpty() {
return nil
}
e := s.data.Front()
return e.Value
}
/*访问队尾元素*/
func (s *linkedListDeque) peekLast() any {
if s.isEmpty() {
return nil
}
e := s.data.Back()
return e.Value
}
/*获取队列的长度*/
func (s *linkedListDeque) size() int {
return s.data.Len()
}
/*判断队列是否为空*/
func (s *linkedListDeque) isEmpty() bool {
return s.data.Len() == 0
}
/*获取List用于打印*/
func (s *linkedListDeque) toList() *list.List {
return s.data
}
基于数组的实现
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法:
/*基于环形数组实现的双向队列*/
type arrayDeque struct {
nums []int //用于存储双向队列元素的数组
front int //队首指针,指向队首元素
queSize int //双向队列长度
queCapacity int //队列容量(即最大容纳元素数量)
}
/*初始化队列*/
func newArrayDeque(queCapacity int) *arrayDeque {
return &arrayDeque{
nums: make([]int, queCapacity),
queCapacity: queCapacity,
front: 0,
queSize: 0,
}
}
/*获取双向队列的长度*/
func (q *arrayDeque) size() int {
return q.queSize
}
/* 判断双向队列是否为空 */
func (q *arrayDeque) isEmpty() bool {
return q.queSize == 0
}
/* 计算环形数组索引 */
func (q *arrayDeque) index(i int) int {
// 通过取余操作实现数组首尾相连
// 当 i 越过数组尾部后,回到头部
// 当 i 越过数组头部后,回到尾部
return (i + q.queCapacity) % q.queCapacity
}
/* 队首入队 */
func (q *arrayDeque) pushFirst(num int) {
if q.queSize == q.queCapacity {
fmt.Println(" 双向队列已满")
return
}
// 队首指针向左移动一位
// 通过取余操作实现 front 越过数组头部后回到尾部
q.front = q.index(q.front - 1)
// 将 num 添加至队首
q.nums[q.front] = num
q.queSize++
}
/* 队尾入队 */
func (q *arrayDeque) pushLast(num int) {
if q.queSize == q.queCapacity {
fmt.Println(" 双向队列已满")
return
}
// 计算队尾指针,指向队尾索引 + 1
rear := q.index(q.front + q.queSize)
// 将 num 添加至队尾
q.nums[rear] = num
q.queSize++
}
/* 队首出队 */
func (q *arrayDeque) popFirst() any {
num := q.peekFirst()
// 队首指针向后移动一位
q.front = q.index(q.front + 1)
q.queSize--
return num
}
/* 队尾出队 */
func (q *arrayDeque) popLast() any {
num := q.peekLast()
q.queSize--
return num
}
/* 访问队首元素 */
func (q *arrayDeque) peekFirst() any {
if q.isEmpty() {
return nil
}
return q.nums[q.front]
}
/* 访问队尾元素 */
func (q *arrayDeque) peekLast() any {
if q.isEmpty() {
return nil
}
// 计算尾元素索引
last := q.index(q.front + q.queSize - 1)
return q.nums[last]
}
/* 获取 Slice 用于打印 */
func (q *arrayDeque) toSlice() []int {
// 仅转换有效长度范围内的列表元素
res := make([]int, q.queSize)
for i, j := 0, q.front; i < q.queSize; i++ {
res[i] = q.nums[q.index(j)]
j++
}
return res
}
双向队列应用
双向队列兼具栈与队列的逻辑,因此它可以实现这两者的所有应用场景,同时提供更高的自由度。
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作push 到栈中,然后通过pop 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存50步)。当栈的长度超过50时,软件需要在栈底(队首)执行删除操作。但栈无法实现该功能,此时就需要使用双向队列来替代栈。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。
优先队列
优先队列(Priority Queue):一种特殊的队列。在优先队列中,元素被赋予优先级,当访问队列元素时,具有最高优先级的元素最先删除。
优先队列与普通队列最大的不同点在于出队顺序。
- 普通队列的出队顺序跟入队顺序相关,符合「先进先出(First in, First out)」的规则。
- 优先队列的出队顺序跟入队顺序无关,优先队列是按照元素的优先级来决定出队顺序的。优先级高的元素优先出队,优先级低的元素后出队。优先队列符合「最高级先出(First in, Largest out)」的规则。
优先队列的应用场景非常多,比如:
- 数据压缩:赫夫曼编码算法;
- 最短路径算法:Dijkstra 算法;
- 最小生成树算法:Prim 算法;
- 任务调度器:根据优先级执行系统任务;
- 事件驱动仿真:顾客排队算法;
- 排序问题:查找第 k 个最小元素。
很多语言都提供了优先级队列的实现。比如,Java 的 PriorityQueue
,C++ 的 priority_queue
等。Python 中也可以通过 heapq
来实现优先队列。
优先队列所涉及的基本操作跟普通队列差不多,主要是「入队操作」和「出队操作」。
而优先队列的实现方式也有很多种,除了使用「数组(顺序存储)实现」与「链表(链式存储)实现」之外,我们最常用的是使用 二叉堆结构实现优先队列。以下是三种方案的介绍和总结。
-
数组(顺序存储)实现优先队列:
- 入队操作:直接插入到数组队尾,时间复杂度为
O(1)
。 - 出队操作:需要遍历整个数组,找到优先级最高的元素,返回并删除该元素,时间复杂度为
O(n)
。
- 入队操作:直接插入到数组队尾,时间复杂度为
-
链表(链式存储)实现优先队列:
- 入队操作:需要为待插入元素创建节点,并在链表中找到合适的插入位置,时间复杂度为
O(n)
。 - 出队操作:直接返回链表队头元素,并删除队头元素,时间复杂度为
O(1)
。
- 入队操作:需要为待插入元素创建节点,并在链表中找到合适的插入位置,时间复杂度为
-
二叉堆结构实现优先队列:
- 构建一个二叉堆结构,二叉堆按照优先级进行排序。
- 入队操作:将元素插入到二叉堆中合适位置,时间复杂度为
O(log n)
。 - 出队操作:返回二叉堆中优先级最大节点并删除,时间复杂度也是
O(log n)
。
下面是三种结构实现的优先队列入队操作和出队操作的时间复杂度总结:
入队操作时间复杂度 | 出队操作(取出优先级最高的元素)时间复杂度 |
---|---|
堆 | O(log n) |
数组 | O(1) |
链表 | O(n) |
从上面的表格可以看出,使用「二叉堆」这种数据结构来实现优先队列是比较高效的。下面我们来讲解一下二叉堆实现的优先队列。
哈希表
哈希表(Hash Table):也叫做散列表。它通过建立键key与值value 之间的映射,实现高效的元素查询。
哈希表通过「键 key」和「映射函数 Hash(key)」计算出对应的「值 value」,把值映射到表中一个位置,以加快查找的速度。这个映射函数叫做「哈希函数(散列函数)」,存放记录的数组叫做「哈希表(散列表)」。
如图所示,给定𝑛个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个
学号,返回对应的姓名”的查询功能,则可以采用图示的哈希表来实现。
哈希表的关键思想是使用哈希函数,将键 key 映射到对应表的某个区块中。我们可以将算法思想分为两个部分:
-
向哈希表中插入一个键值对(key-value):哈希函数决定该key的对应值应该存放到表中的哪个区块,并将对应值存放到该区块中。
-
在哈希表中搜索一个键值对(key-value):使用相同的哈希函数从哈希表中查找对应的区块,并在特定的区块搜索该key对应的值。
我们使用 value = Hash(key) = key // 1000
作为哈希函数。//
符号代表整除。举个例子来说明一下哈希表的插入和查找策略。
向哈希表中插入一个键值对(key-value):通过哈希函数解析key,并将对应值存放到该区块中。
- 比如: 0138,通过哈希函数
Hash(key) = 0138 // 1000 = 0
,得出应将 0138 分配到 0 所在的区块中。
在哈希表中搜索一个键值对(key-value):通过哈希函数解析key,并在特定的区块搜索该关键字对应的值。
- 比如:查找 2321,通过哈希函数,得出 2321 应该在 2 所对应的区块中。然后我们从 2 对应的区块中继续搜索,并在 2 对应的区块中成功找到了 2321。
- 比如:查找 3214,通过哈希函数,得出 3214 应该在 3 所对应的区块中。然后我们从 3 对应的区块中继续搜索,但并没有找到对应值,则说明 3214 不在哈希表中。
哈希表在生活中的应用也很广泛,其中一个常见例子就是「查字典」。
比如为了查找「赞」这个字的具体意思,我们在字典中根据这个字的拼音索引 “zan”,查找到对应的页码为 599。然后我们就可以翻到字典的第 599 页查看「赞」字相关的解释了。
哈希表常用操作
哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等,
/*初始化哈希表*/
hmap := make(map[int]string)
/*添加操作*/
//在哈希表中添加键值对(key,value)
hmap[12836] = "小哈"
hmap[15937] = "小啰"
hmap[16750] = "小算"
hmap[13276] = "小法"
hmap[10583] = "小鸭"
/*查询操作*/
//向哈希表中输入键key,得到值value
name := hmap[15937]
fmt.Println(name)
/*删除操作*/
//在哈希表中删除键值对(key,value)
delete(hmap, 10583)
//遍历键值对key->value
for key, value := range hmap {
fmt.Println(key, "->", value)
}
//单独遍历键key
for key := range hmap {
fmt.Println(key)
}
//单独遍历值value
for _, value := range hmap {
fmt.Println(value)
}
在哈希表中进行增删查改的时间复杂度都是𝑂(1),非常高效,原因请看下文!
数组实现简单哈希表
在哈希表中,我们将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到key对应的桶,并在桶中获取value。
我们假设hash函数为:index = hash(key) % capacity
,随后就可以利用index 在哈希表中访问对应的桶,从而获取
value 。
设数组长度capacity = 100,得到如下:
/* 键值对 */
type pair struct {
key int
val string
}
/* 基于数组实现的哈希表 */
type arrayHashMap struct {
buckets []*pair
}
/* 初始化哈希表 */
func newArrayHashMap() *arrayHashMap {
// 初始化数组,包含 100 个桶
buckets := make([]*pair, 100)
return &arrayHashMap{buckets: buckets}
}
/* 哈希函数 */
func (a *arrayHashMap) hashFunc(key int) int {
index := key % 100
return index
}
/* 查询操作 */
func (a *arrayHashMap) get(key int) string {
index := a.hashFunc(key)
pair := a.buckets[index]
if pair == nil {
return "Not Found"
}
return pair.val
}
/* 添加操作 */
func (a *arrayHashMap) put(key int, val string) {
pair := &pair{key: key, val: val}
index := a.hashFunc(key)
a.buckets[index] = pair
}
/* 删除操作 */
func (a *arrayHashMap) remove(key int) {
index := a.hashFunc(key)
// 置为 nil ,代表删除
a.buckets[index] = nil
}
/* 获取所有键对 */
func (a *arrayHashMap) pairSet() []*pair {
var pairs []*pair
for _, pair := range a.buckets {
if pair != nil {
pairs = append(pairs, pair)
}
}
return pairs
}
/*获取所有键*/
func (a *arrayHashMap) keySet() []int {
var keys []int
for _, pair := range a.buckets {
if pair != nil {
keys = append(keys, pair.key)
}
}
return keys
}
/*获取所有值*/
func (a *arrayHashMap) valueSet() []string {
var values []string
for _, pair := range a.buckets {
if pair != nil {
values = append(values, pair.val)
}
}
return values
}
/*打印哈希表*/
func (a *arrayHashMap) print() {
for _, pair := range a.buckets {
if pair != nil {
fmt.Println(pair.key, "->", pair.val)
}
}
}
从本质上看,哈希函数的作用是将所有key构成的输入空间映射到数组所有索引构成的输出空间,而输入空
间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况。
例如,查询学号为12836和20336的两个学生时,我们得到
12836 % 100 = 36
20336 % 100 = 36
两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为哈希冲突(hashcollision)。
容易想到,哈希表容量 ( n ) 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 capacity
改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
负载因子(load factor)是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常作为哈希表扩容的触发条件。例如在 Java 中,当负载因子超过 0.75 时,系统会将哈希表扩容至原先的 2 倍。(python和Go 在哈希表容量超过当前容量的 2/3 时,会触发扩容。因此,python和Go 的默认负载因子大约为 0.67。)
哈希冲突
通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。
因此,设计再好的哈希函数也无法完全避免哈希冲突。所以就需要通过一定的方法来解决哈希冲突问题。常用的哈希冲突解决方法主要是两类:「开放寻址法(Open Addressing)」和「链地址法(Chaining)」。
这里的解决指的是:哈希表可以在出现哈希冲突时正常工作。
开放寻址法
开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
线性探测
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同:
-
插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1),直至找到空桶,将元素插入其中。
-
查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素并返回;如果遇到空桶,说明目标元素不在哈希表中,返回
None
。
例如,在长度为 11
的哈希表中已经填有关键字分别为 28
、49
、18
的记录(哈希函数为 Hash(key) = key mod 11
)。现在将插入关键字为 38
的新记录。根据哈希函数得到的哈希地址为 5
,产生冲突。
得到下一个地址
H(1) = (5 + 1) mod 11 = 6
仍然冲突;继续求出
H(2) = (5 + 2) mod 11 = 7
仍然冲突;继续求出
H(3) = (5 + 3) mod 11 = 8
地址 8
为空,处理冲突过程结束,记录填入哈希表中序号为 8
的位置。
然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None
,而当查询元素时,线性探测到该空桶就会返回。因此,在该空桶之后的元素都无法再被访问到,程序可能误判这些元素不存在。
为了解决该问题,我们可以采用懒删除(lazy deletion
)机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE
来标记这个桶。在该机制下,None
和 TOMBSTONE
都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE
时应该继续遍历,因为其之下可能还存在键值对。
然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着标记数量的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE
才能找到目标元素。
为此,可以在线性探测中记录遇到的首个 TOMBSTONE
的索引,并将搜索到的目标元素与该 TOMBSTONE
交换位置。这样做的好处是,每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
/* 键值对 */
type pair struct {
key int
val string
}
/* 开放寻址哈希表 */
type hashMapOpenAddressing struct {
// 键值对数量
size int
// 哈希表容量
capacity int
// 触发扩容的负载因子阈值
loadThres float64
// 扩容倍数
extendRatio int
// 桶数组
buckets []*pair
// 删除标记
TOMBSTONE *pair
}
/* 构造方法 */
func newHashMapOpenAddressing() *hashMapOpenAddressing {
return &hashMapOpenAddressing{
size: 0,
capacity: 4,
loadThres: 2.0 / 3.0,
extendRatio: 2,
buckets: make([]*pair, 4),
TOMBSTONE: &pair{-1, "-1"},
}
}
/* 哈希函数 */
func (h *hashMapOpenAddressing) hashFunc(key int) int {
return key % h.capacity // 根据键计算哈希值
}
/* 负载因子 */
func (h *hashMapOpenAddressing) loadFactor() float64 {
return float64(h.size) / float64(h.capacity) // 计算当前负载因子
}
/* 搜索 key 对应的桶索引 */
func (h *hashMapOpenAddressing) findBucket(key int) int {
index := h.hashFunc(key) // 获取初始索引
firstTombstone := -1
// 记录遇到的第一个 TOMBSTONE 的位置
for h.buckets[index] != nil {
if h.buckets[index].key == key {
if firstTombstone != -1 {
// 若之前遇到了删除标记,则将键值对移动至该索引处
h.buckets[firstTombstone] = h.buckets[index]
h.buckets[index] = h.TOMBSTONE
return firstTombstone // 返回移动后的桶索引
}
return index // 返回找到的索引
}
if firstTombstone == -1 && h.buckets[index] == h.TOMBSTONE {
firstTombstone = index // 记录遇到的首个删除标记的位置
}
index = (index + 1) % h.capacity // 线性探测,越过尾部则返回头部
}
// 若 key 不存在,则返回添加点的索引
if firstTombstone != -1 {
return firstTombstone
}
return index
}
/* 查询操作 */
func (h *hashMapOpenAddressing) get(key int) string {
index := h.findBucket(key) // 搜索 key 对应的桶索引
if h.buckets[index] != nil && h.buckets[index] != h.TOMBSTONE {
return h.buckets[index].val // 若找到键值对,则返回对应 val
}
return "" // 若键值对不存在,则返回 ""
}
/* 添加操作 */
func (h *hashMapOpenAddressing) put(key int, val string) {
if h.loadFactor() > h.loadThres {
h.extend() // 当负载因子超过阈值时,执行扩容
}
index := h.findBucket(key) // 搜索 key 对应的桶索引
if h.buckets[index] == nil || h.buckets[index] == h.TOMBSTONE {
h.buckets[index] = &pair{key, val} // 若键值对不存在,则添加该键值对
h.size++
} else {
h.buckets[index].val = val // 若找到键值对,则覆盖 val
}
}
/* 删除操作 */
func (h *hashMapOpenAddressing) remove(key int) {
index := h.findBucket(key) // 搜索 key 对应的桶索引
if h.buckets[index] != nil && h.buckets[index] != h.TOMBSTONE {
h.buckets[index] = h.TOMBSTONE // 若找到键值对,则用删除标记覆盖它
h.size--
}
}
/* 扩容哈希表 */
func (h *hashMapOpenAddressing) extend() {
oldBuckets := h.buckets
h.capacity *= h.extendRatio
// 暂存原哈希表
// 更新容量
h.buckets = make([]*pair, h.capacity) // 初始化扩容后的新哈希表
h.size = 0
// 重置大小
// 将键值对从原哈希表搬运至新哈希表
for _, pair := range oldBuckets {
if pair != nil && pair != h.TOMBSTONE {
h.put(pair.key, pair.val)
}
}
}
/* 打印哈希表 */
func (h *hashMapOpenAddressing) print() {
for _, pair := range h.buckets {
if pair == nil {
fmt.Println("nil")
} else if pair == h.TOMBSTONE {
fmt.Println("TOMBSTONE")
} else {
fmt.Printf("%d-> %s\n", pair.key, pair.val)
}
}
}
平方探测
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即 1, 4, 9, … 步。
平方探测主要具有以下优势:
- 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。
- 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
然而,平方探测并不是完美的:
- 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
- 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。
多次哈希
顾名思义,多次哈希方法使用多个哈希函数 f1(x)
, f2(x)
, f3(x)
, … 进行探测。
- 插入元素:若哈希函数
f1(x)
出现冲突,则尝试f2(x)
,以此类推,直到找到空位后插入元素。 - 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回
None
。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
二次探测法
F(i) = ±1², ±2², ±3², …, ±n² (n ≤ m / 2)
在这种方法中,当发生冲突时,探测的步长为探测次数的平方,即 1², -1², 2², -2² 等,这有助于缓解线性探测中的聚集效应。
例如,在长度为 11
的哈希表中已经填有关键字分别为 28
、49
、18
的记录(哈希函数为 Hash(key) = key mod 11
)。现在将插入关键字为 38
的新记录。根据哈希函数得到的哈希地址为 5
,产生冲突。
使用二次探测法处理哈希冲突的过程如下:
-
初始哈希地址:
- 给定的哈希函数计算出关键字 38 的哈希地址为 5,发生冲突。
-
第一个探测:
- 根据二次探测公式,H(1) = (5 + 1×1) mod 11 = 6,仍然发生冲突。
-
第二个探测:
- 继续计算,H(2) = (5 - 1×1) mod 11 = 4,4 对应的地址为空,处理冲突过程结束,记录插入哈希表中序号为 4 的位置。
因此,使用二次探测法解决冲突时,哈希表的地址为 4。
伪随机数序列
F(i) = 伪随机数序列
使用伪随机数序列来生成探测位置,这可以确保探测路径的随机性,减少冲突的聚集现象。
使用伪随机数序列处理哈希冲突的过程如下:
-
初始哈希地址:
- 给定的哈希函数计算出关键字 38 的哈希地址为 5,发生冲突。
-
第一个探测:
- 假设伪随机数为 9,计算新的哈希地址:H(1) = (9 + 5) mod 11 = 3。
-
结果:
- 地址 3 对应的桶为空,处理冲突过程结束,记录插入哈希表中序号为 3 的位置。
因此,使用伪随机数序列处理冲突时,哈希表的地址为 3。
链地址法
在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。
链地址法是一种更加常用的哈希冲突解决方法。相比于开放地址法,链地址法更加简单。
基于链式地址实现的哈希表的操作方法如下:
-
查询元素:
- 输入
key
,经过哈希函数计算得到桶的索引。接着访问链表的头节点,遍历链表并逐个对比节点,找到目标键值对。
- 输入
-
添加元素:
- 通过哈希函数计算得到桶的索引,访问对应的链表头节点,然后将新的节点(键值对)添加到链表中。
-
删除元素:
- 根据哈希函数计算得到桶的索引,访问链表的头节点,遍历链表以查找目标节点,然后将该节点从链表中删除。
链式地址存在以下局限性:
- 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
- 查询效率降低:因为需要线性遍历链表来查找对应元素。(此时时间复杂度退化为O(n))
- 严格来说,查询操作的时间复杂度与链表的长度 k 成正比,即 O(k)。对于哈希地址比较均匀的哈希函数来说,理论上, k = n // m 其中:n 为关键字的个数, m 为哈希表的表长。
以下代码给出了链式地址哈希表的简单实现,
/* 键值对 */
type pair struct {
key int
val string
}
/* 链式地址哈希表 */
type hashMapChaining struct {
size int // 键值对数量
capacity int // 哈希表容量
loadThres float64 // 触发扩容的负载因子阈值
extendRatio int // 扩容倍数
buckets [][]pair // 桶数组
}
/* 构造方法 */
func newHashMapChaining() *hashMapChaining {
buckets := make([][]pair, 4)
for i := 0; i < 4; i++ {
buckets[i] = make([]pair, 0)
}
return &hashMapChaining{
size: 0,
capacity: 4,
loadThres: 2.0 / 3.0,
extendRatio: 2,
buckets: buckets,
}
}
/* 哈希函数 */
func (m *hashMapChaining) hashFunc(key int) int {
return key % m.capacity
}
/* 负载因子 */
func (m *hashMapChaining) loadFactor() float64 {
return float64(m.size) / float64(m.capacity)
}
/* 查询操作 */
func (m *hashMapChaining) get(key int) string {
idx := m.hashFunc(key)
bucket := m.buckets[idx]
// 遍历桶,若找到 key ,则返回对应 val
for _, p := range bucket {
if p.key == key {
return p.val
}
}
// 若未找到 key ,则返回空字符串
return ""
}
/* 添加操作 */
func (m *hashMapChaining) put(key int, val string) {
// 当负载因子超过阈值时,执行扩容
if m.loadFactor() > m.loadThres {
m.extend()
}
idx := m.hashFunc(key)
// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
for i := range m.buckets[idx] {
if m.buckets[idx][i].key == key {
m.buckets[idx][i].val = val
return
}
}
// 若无该 key ,则将键值对添加至尾部
p := pair{
key: key,
val: val,
}
m.buckets[idx] = append(m.buckets[idx], p)
m.size += 1
}
/* 删除操作 */
func (m *hashMapChaining) remove(key int) {
idx := m.hashFunc(key)
// 遍历桶,从中删除键值对
for i, p := range m.buckets[idx] {
if p.key == key {
// 切片删除
m.buckets[idx] = append(m.buckets[idx][:i], m.buckets[idx][i+1:]...)
m.size -= 1
break
}
}
}
/* 扩容哈希表 */
func (m *hashMapChaining) extend() {
// 暂存原哈希表
tmpBuckets := make([][]pair, len(m.buckets))
for i := 0; i < len(m.buckets); i++ {
tmpBuckets[i] = make([]pair, len(m.buckets[i]))
copy(tmpBuckets[i], m.buckets[i])
}
// 初始化扩容后的新哈希表
m.capacity *= m.extendRatio
m.buckets = make([][]pair, m.capacity)
for i := 0; i < m.capacity; i++ {
m.buckets[i] = make([]pair, 0)
}
m.size = 0
// 将键值对从原哈希表搬运至新哈希表
for _, bucket := range tmpBuckets {
for _, p := range bucket {
m.put(p.key, p.val)
}
}
}
/* 打印哈希表 */
func (m *hashMapChaining) print() {
var builder strings.Builder
for _, bucket := range m.buckets {
builder.WriteString("[")
for _, p := range bucket {
builder.WriteString(strconv.Itoa(p.key) + "-> " + p.val + " ")
}
builder.WriteString("]")
fmt.Println(builder.String())
builder.Reset()
}
}
值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为 AVL树 或 红黑树,从而将查询操作的时间复杂度优化至 O(log n)。
哈希算法
各种编程语言采取了不同的哈希表实现策略:
- Python 采用开放寻址。字典
dict
使用伪随机数进行探测。 - Java 采用链式地址。自 JDK1.8 以来,当
HashMap
内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。 - Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶;当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
然而无论是开放寻址还是链式地址,它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生。
这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法的设计上。
为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点:
- 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
- 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
- 均匀分布:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
-
密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
-
数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。
-
单向性:无法通过哈希值反推出关于输入数据的任何信息。
-
抗碰撞性:应当极难找到两个不同的输入,使得它们的哈希值相同。
-
雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。
“均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机输入key下,哈希函数key % 100 可以产生均匀分布的输出。然而,该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key,从而破解密码。
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而,对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
- 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
- 乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
- 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
- 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
/* 加法哈希 */
func addHash(key string) int {
var hash int64
var modulus int64
modulus = 1000000007
for _, b := range []byte(key) {
hash = (hash + int64(b)) % modulus
}
return int(hash)
}
/* 乘法哈希 */
func mulHash(key string) int {
var hash int64
var modulus int64
modulus = 1000000007
for _, b := range []byte(key) {
hash = (31*hash + int64(b)) % modulus
}
return int(hash)
}
/*异或哈希*/
func xorHash(key string) int {
hash := 0
modulus := 1000000007
for _, b := range []byte(key) {
fmt.Println(int(b))
hash ^= int(b)
hash = (31*hash + int(b)) % modulus
}
return hash & modulus
}
/*旋转哈希*/
func rotHash(key string) int {
var hash int64
var modulus int64
modulus = 1000000007
for _, b := range []byte(key) {
hash = ((hash << 4) ^ (hash >> 28) ^ int64(b)) % modulus
}
return int(hash)
}
观察发现,每种哈希算法的最后一步都是对大质数1000000007取模,以确保哈希值在合适的范围内。先抛出结论:**使用大质数作为模数,可以最大化地保证哈希值的均匀分布。**因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
假设我们选择合数9作为模数,它可以被3整除,那么所有可以被3整除的key都会被映射到0、3、6这三个哈希值。这样就会导致哈希冲突,增加了哈希表的负担,影响了查找和插入操作的效率。
modulus = 9
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, …}
hash = {0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6, …}
如果输入 key 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。
现在,假设将 modulus 替换为质数 13,由于 key 和 modulus 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。
modulus = 13
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, …}
hash = {0, 3, 6, 9, 12, 2, 5, 8, 11, 1, 4, 7, …}
值得说明的是,如果能够保证 key 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 key 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。
由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如MD5、SHA‑1、SHA‑2和SHA‑3等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。
- MD5和SHA‑1已多次被成功攻击,因此它们被各类安全应用弃用。
- SHA‑2系列中的SHA‑256是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。
- SHA‑3相较SHA‑2的实现开销更低、计算效率更高,但目前使用覆盖度不如SHA‑2系列。
算法 | 推出时间 | 输出长度 | 哈希冲突 | 安全等级 | 应用 |
---|---|---|---|---|---|
MD5 | 1992 | 128 bit | 较多 | 低,已被成功攻击 | 已被弃用,仍用于数据完整性检查 |
SHA‑1 | 1995 | 160 bit | 较多 | 低,已被成功攻击 | 已被弃用 |
SHA‑2 | 2002 | 256/512 bit | 很少 | 高 | 加密货币交易验证、数字签名等 |
SHA‑3 | 2008 | 224/256/384/512 bit | 很少 | 高 | 可用于替代SHA‑2 |
树
树(Tree):由 n 个节点与节点之间的关系组成的有限集合。
- 当 ( n = 0 ) 时,称为空树。
- 当 ( n > 0 ) 时,称为非空树。
之所以把这种数据结构称为「树」,是因为这种数据结构看起来就像是一棵倒挂的树,也就是说数据结构中的「树」是根朝上,而叶朝下的。如下图所示。
「树」具有以下的特点:
- 有且仅有一个节点没有前驱节点,该节点被称为树的 根节点(Root)。
- 除了根节点以外,每个节点有且仅有一个直接前驱节点。
- 包括根节点在内,每个节点可以有多个后继节点。
- 当
n > 1
时,除了根节点之外的其他节点,可分为m (m > 0)
个互不相交的有限集合T1, T2, ..., Tm
,其中每一个集合本身又是一棵树,并且被称为根的 子树(SubTree)。
如下图所示,红色节点 A 是根节点,除了根节点之外,还有 3 棵互不相交的子树:
T1 (B, E, H, I, G)
T2 (C)
T3 (D, F, G, K)
树的节点由一个数据元素和若干个指向其子树的树的分支组成。节点所含有的子树个数称为 节点的度。
度为 0
的节点称为 叶子节点 或者 终端节点,度不为 0
的节点称为 分支节点 或者 非终端节点。
树中各节点的最大度数称为 树的度。
一个节点的子树的根节点称为该节点的 孩子节点,相应的,该节点称为孩子的 父亲节点。
同一个父亲节点的孩子节点之间互称为 兄弟节点。
节点的层次 是从根节点开始定义,将根节点作为第 1 层,根的孩子节点作为第 2 层,以此类推。如果某个节点在第 ( i ) 层,则其孩子节点在第 ( i+1 ) 层。
而父亲节点在同一层的节点互为 堂兄弟节点。
树中所有节点最大的层数称为 树的深度 或 树的高度。
树中,两个节点之间所经过的节点序列称为 路径,两个节点之间路径上经过的边数称为 路径长度。
二叉树
二叉树(binary tree) 是一种非线性数据结构,表示“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。
与链表类似,二叉树的基本单元是 节点,每个节点包含以下内容:
- 值:节点所存储的数据。
- 左子节点引用:指向左子树的引用。
- 右子节点引用:指向右子树的引用。
/* 二叉树节点结构体 */
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
/* 构造方法 */
func NewTreeNode(v int) *TreeNode {
return &TreeNode{
Left: nil, // 左子节点指针
Right: nil, // 右子节点指针
Val: v,
}
}
每个节点 都有两个引用(指针),分别指向:
- 左子节点(left‑child node)
- 右子节点(right‑child node)
该节点被称为这两个子节点的 父节点(parent node)。
当给定一个二叉树的节点时:
- 左子树(left subtree):该节点的左子节点及其以下节点形成的树。
- 右子树(right subtree):该节点的右子节点及其以下节点形成的树。
在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树。
例如,在下图中:
- 将“节点 2”视为父节点:
- 左子节点是“节点 4”,右子节点是“节点 5”。
- 左子树是“节点 4 及其以下节点形成的树”。
- 右子树是“节点 5 及其以下节点形成的树”。
二叉树的常用术语:
-
根节点(root node):
- 位于二叉树顶层的节点,没有父节点。
-
叶节点(leaf node):
- 没有子节点的节点,其两个指针均指向
None
。
- 没有子节点的节点,其两个指针均指向
-
边(edge):
- 连接两个节点的线段,即节点引用(指针)。
-
节点所在的层(level):
- 从顶至底递增,根节点所在层为 1。
-
节点的度(degree):
- 节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2。
-
二叉树的高度(height):
- 从根节点到最远叶节点所经过的边的数量。
-
节点的深度(depth):
- 从根节点到该节点所经过的边的数量。
-
节点的高度(height):
- 从距离该节点最远的叶节点到该节点所经过的边的数量。
二叉树基本操作
首先初始化节点,
/* 初始化二叉树 */
// 初始化节点
n1 := NewTreeNode(1)
n2 := NewTreeNode(2)
n3 := NewTreeNode(3)
n4 := NewTreeNode(4)
n5 := NewTreeNode(5)
// 构建节点之间的引用(指针)
n1.Left = n2
n1.Right = n3
n2.Left = n4
n2.Right = n5
与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。
/* 插入与删除节点 */
// 在 n1-> n2 中间插入节点 P
p := NewTreeNode(0)
n1.Left = p
p.Left = n2
// 删除节点 P
n1.Left = n2
插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除通常是由一套操作配合完成的,以实现有实际意义的操作。
常见二叉树类型
完美二叉树
完美二叉树(perfect binary tree):如果所有分支节点都存在左子树和右子树,并且所有叶子节点都在同一层上,则称该二叉树为满二叉树。
在满二叉树中,叶节点的度为 0,其余所有节点的度都为 2。若树的高度为 ( h ),则节点总数为 ( 2 h+1 - 1 ) ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
完全二叉树
完全二叉树(completebinarytree)只有最底层的节点未被填满,且最底层节点尽量靠左填充。
完满二叉树
完满二叉树(fullbinarytree)除了叶节点之外,其余所有节点都有两个子节点。
平衡二叉树
平衡二叉树(balancedbinarytree)中任意节点的左子树和右子树的高度之差的绝对值不超过1。
二叉搜索树
二叉搜索树(Binary Search Tree):也叫做二叉查找树、有序二叉树或者排序二叉树。它是指一棵空树或者具有下列性质的二叉树:
- 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。
- 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。
- 任意节点的左子树、右子树均为二叉搜索树。
平衡二叉搜索树
平衡二叉搜索树(Balanced Binary Tree):是一种结构平衡的二叉搜索树。即叶节点高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉搜索树。平衡二叉树可以在 ( O(\log n) ) 内完成插入、查找和删除操作。最早被发明的平衡二叉搜索树为 AVL 树(Adelson-Velsky and Landis Tree)。
AVL 树 满足以下性质:
- 空二叉树是一棵 AVL 树。
- 如果 ( T ) 是一棵 AVL 树,那么其左右子树也是 AVL 树,并且 ( |h(ls) - h(rs)| ≤ 1 ),其中 ( h(ls) ) 是左子树的高度,( h(rs) ) 是右子树的高度。
- AVL 树的高度为 ( O(log n) )。
二叉树的退化
当二叉树的每层节点都被填满时,称其为“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 (O(n))。
在最佳结构和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大值或极小值。
二叉树的存储结构
二叉树的存储结构分为两种:「顺序存储结构」和「链式存储结构」
二叉树的顺序存储结构
二叉树的顺序存储结构使用一维数组来存储二叉树中的节点。节点的存储位置按照完全二叉树的节点层次编号,从上到下、从左到右依次存放二叉树的数据元素。在进行顺序存储时,如果对应的二叉树节点不存在,则设置为「空节点」。
从图中我们也可以看出节点之间的逻辑关系:
-
如果某二叉树节点(非叶子节点)的下标为 i,那么:
- 其左孩子节点的下标为
2 * i + 1
- 其右孩子节点的下标为
2 * i + 2
- 其左孩子节点的下标为
-
如果某二叉树节点(非根节点)的下标为 i,那么其根节点的下标为
(i - 1) // 2
。//
表示整除
对于完全二叉树(尤其是完美二叉树)来说,采用顺序存储结构比较合适,因为它能充分利用存储空间。然而,对于一般二叉树,如果需要设置很多的「空节点」,顺序存储结构会浪费大量的存储空间。此外,顺序存储结构固有的一些缺陷会使二叉树的插入、删除等操作变得不方便,效率较低。
因此,当二叉树的形态和大小经常发生动态变化时,更适合采用链式存储结构。
二叉树的链式存储结构
二叉树采用链式存储结构时,每个链节点包含一个用于数据域 val,存储节点信息;还包含两个指针域 left 和 right,分别指向左右两个孩子节点。当左孩子或者右孩子不存在时,相应指针域值为空。
/* 二叉树节点结构体 */
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
二叉树的链表存储结构具有灵活、方便的特点,节点的最大数目只受系统最大可存储空间的限制。一般情况下,二叉树的链表存储结构比顺序存储结构更省空间(用于存储指针域的空间开销只是二叉树中节点数的线性函数),而且对于二叉树实施相关操作也很方便。因此,一般我们使用链式存储结构来存储二叉树。
二叉树遍历
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。
二叉树常见的遍历方式包括: 层序遍历 前序遍历 中序遍历 后序遍历
层序遍历
层序遍历(level-order traversal)是从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
层序遍历本质上属于广度优先遍历(breadth-first traversal),也称为广度优先搜索(breadth-first search, BFS)。它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”(FIFO, First In First Out)的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。通过使用队列,广度优先遍历能够按层次顺序逐个访问二叉树的节点。
func levelOrder(root *TreeNode) []any {
//初始化队列,加入根节点
queue := list.New()
queue.PushBack(root)
//初始化一个切片,用于保存遍历序列
nums := make([]any, 0)
for queue.Len() > 0 {
//队列出队
node := queue.Remove(queue.Front()).(*TreeNode)
//保存节点值
nums = append(nums, node.Val)
if node.Left != nil {
//左子节点入队
queue.PushBack(node.Left)
}
if node.Right != nil {
//右子节点入队
queue.PushBack(node.Right)
}
}
return nums
}
复杂度分析:
- 时间复杂度为 O(n):所有节点被访问一次,使用 O(n) 时间,其中 n 为节点数量。
- 空间复杂度为 O(n):在最差情况下,即完美二叉树时,遍历到最底层之前,队列中最多同时存在 (n+1)/2 个节点,占用 O(n) 空间。
前序、中序、后序遍历
相应地,前序、中序和后序遍历都属于深度优先遍历(depth-first traversal),也称深度优先搜索(depth-first search, DFS)。它体现了一种“先走到尽头,再回溯继续”的遍历方式。
深度优先遍历就像是绕着整棵二叉树的外围“走”一圈,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
深度优先搜索通常基于递归实现:
func preOrder(node *TreeNode) {
if node == nil {
return
}
//访问优先级:根节点->左子树->右子树
nums = append(nums, node.Val)
preOrder(node.Left)
preOrder(node.Right)
}
/*中序遍历*/
func inOrder(node *TreeNode) {
if node == nil {
return
}
//访问优先级:左子树->根节点->右子树
inOrder(node.Left)
nums = append(nums, node.Val)
inOrder(node.Right)
}
/* 后序遍历 */
func postOrder(node *TreeNode) {
if node == nil {
return
}
// 访问优先级:左子树-> 右子树-> 根节点
postOrder(node.Left)
postOrder(node.Right)
nums = append(nums, node.Val)
}
二叉树的前序遍历递归实现步骤为:
- 判断二叉树是否为空:如果为空,直接返回。
- 访问根节点:首先访问当前节点(根节点)。
- 递归遍历左子树:递归地遍历左子树。
- 递归遍历右子树:递归地遍历右子树。
前序遍历的核心思想是:根节点 → 左子树 → 右子树。
二叉树的中序遍历规则为:
- 如果二叉树为空,则返回。
- 如果二叉树不为空,则:
- 以中序遍历的方式遍历根节点的左子树。
- 访问根节点。
- 以中序遍历的方式遍历根节点的右子树。
中序遍历的顺序是:左子树 → 根节点 → 右子树。
二叉树的后序遍历递归实现步骤为:
- 判断二叉树是否为空:如果为空,直接返回。
- 递归遍历左子树:先递归遍历左子树。
- 递归遍历右子树:然后递归遍历右子树。
- 访问根节点:最后访问根节点。
后序遍历的顺序是:左子树 → 右子树 → 根节点。
复杂度分析:
- 时间复杂度为 ( O(n) ):所有节点都会被访问一次,因此遍历所有节点的时间复杂度为 ( O(n) ),其中 ( n ) 为节点的数量。
- 空间复杂度为 ( O(n) ):在最差情况下,即二叉树退化为链表时,递归的深度为 ( n ),因此系统的栈帧空间将占用 ( O(n) ) 的空间。
堆
堆(heap)是一种满足特定条件的完全二叉树,主要可分为两种类型:
- 小顶堆(min heap):任意节点的值 ≤ 其子节点的值。即根节点是最小值。
- 大顶堆(max heap):任意节点的值 ≥ 其子节点的值。即根节点是最大值。
堆作为完全二叉树的一个特例,具有以下特性:
- 最底层节点靠左填充,其他层的节点都被填满。
- 根节点被称为堆顶,底层最靠右的节点被称为堆底。
- 对于大顶堆(max heap),堆顶元素(根节点)的值是最大值;对于小顶堆(min heap),堆顶元素的值是最小值。
堆的常用操作
堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。
方法名 | 描述 | 时间复杂度 |
---|---|---|
push() | 元素入堆 | O(log n) |
pop() | 堆顶元素出堆 | O(log n) |
peek() | 访问堆顶元素(对于大/小顶堆分别为最大/小值) | O(1) |
size() | 获取堆的元素数量 | O(1) |
isEmpty() | 判断堆是否为空 | O(1) |
// Go语言中可以通过实现heap.Interface来构建整数大顶堆
// 实现heap.Interface需要同时实现sort.Interface
type intHeap []any
// Push heap.Interface的方法,实现推入元素到堆
func (h *intHeap) Push(x any) {
//Push和Pop使用pointerreceiver作为参数
//因为它们不仅会对切片的内容进行调整,还会修改切片的长度。
*h = append(*h, x.(int))
}
// Popheap.Interface的方法,实现弹出堆顶元素
func (h *intHeap) Pop() any {
//待出堆元素存放在最后
last := (*h)[len(*h)-1]
*h = (*h)[:len(*h)-1]
return last
}
// Lensort.Interface的方法
func (h *intHeap) Len() int {
return len(*h)
}
// Less sort.Interface的方法
func (h *intHeap) Less(i, j int) bool {
//如果实现小顶堆,则需要调整为小于号
return (*h)[i].(int) > (*h)[j].(int)
}
// Swap sort.Interface的方法
func (h *intHeap) Swap(i, j int) {
(*h)[i], (*h)[j] = (*h)[j], (*h)[i]
}
// Top 获取堆顶元素
func (h *intHeap) Top() any {
return (*h)[0]
}
/* Driver Code */
func TestHeap(t *testing.T) {
/* 初始化堆 */
// 初始化大顶堆
maxHeap := &intHeap{}
heap.Init(maxHeap)
/* 元素入堆 */
// 调用 heap.Interface 的方法,来添加元素
heap.Push(maxHeap, 1)
heap.Push(maxHeap, 3)
heap.Push(maxHeap, 2)
heap.Push(maxHeap, 4)
heap.Push(maxHeap, 5)
/* 获取堆顶元素 */
top := maxHeap.Top()
fmt.Printf(" 堆顶元素为 %d\n", top)
/* 堆顶元素出堆 */
// 调用 heap.Interface 的方法,来移除元素
heap.Pop(maxHeap) // 5
heap.Pop(maxHeap) // 4
heap.Pop(maxHeap) // 3
heap.Pop(maxHeap) // 2
heap.Pop(maxHeap) // 1
/* 获取堆大小 */
size := len(*maxHeap)
fmt.Printf(" 堆元素数量为 %d\n", size)
/* 判断堆是否为空 */
isEmpty := len(*maxHeap) == 0
fmt.Printf(" 堆是否为空 %t\n", isEmpty)
}
堆的实现
堆的存储与表示
完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,因此我们将采用数组来存储堆。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。
完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,因此我们将采用数组来存储堆。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。
如图所示,给定索引 (i),其左子节点的索引为 (2i + 1),右子节点的索引为 (2i + 2),父节点的索引为 (i - 1) / 2(向下整除)。当索引越界时,表示空节点或节点不存在。
我们可以将索引映射公式封装成函数,方便后续使用:
type maxHeap struct {
data []int
}
/* 获取左子节点的索引 */
func (h *maxHeap) left(i int) int {
return 2*i + 1
}
/* 获取右子节点的索引 */
func (h *maxHeap) right(i int) int {
return 2*i + 2
}
/* 获取父节点的索引 */
func (h *maxHeap) parent(i int) int {
// 向下整除
return (i - 1) / 2
}
访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素:
/* 访问堆顶元素 */
func (h *maxHeap) peek() int {
return h.data[0]
}
元素入堆
元素入堆:给定元素 val
,首先将其添加到堆底。添加之后,由于 val
可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为 堆化(heapify)。
考虑从入堆节点开始,从底至顶执行堆化。比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
设节点总数为𝑛,则树的高度为𝑂(log𝑛)。由此可知,堆化操作的循环轮数最多为𝑂(log𝑛),元素入堆操作的时间复杂度为𝑂(log𝑛)。
func (h *maxHeap) push(val int) {
//添加节点
h.data = append(h.data, val)
//从底至顶堆化
h.siftUp(len(h.data) - 1)
}
func (h *maxHeap) swap(i, j int) {
h.data[i], h.data[j] = h.data[j], h.data[i]
}
/*从节点i开始,从底至顶堆化*/
func (h *maxHeap) siftUp(i int) {
for {
//获取节点i的父节点
p := h.parent(i)
//当“越过根节点”或“节点无须修复”时,结束堆化
if p < 0 || h.data[i] <= h.data[p] {
break
}
//交换两节点
h.swap(i, p)
//循环向上堆化
i = p
}
}
堆顶元素出堆
堆顶元素出堆:堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤:
- 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
- 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
- 从根节点开始,从顶至底执行堆化。
“从顶至底堆化”的操作方向与“从底至顶堆化”相反。具体操作步骤如下:
- 将根节点的值与其两个子节点的值进行比较。
- 将最大的子节点与根节点交换。
- 然后继续比较交换后的节点与其子节点的值,重复上述操作。
- 直到越过叶节点或遇到无须交换的节点时,停止堆化过程。
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为𝑂(log𝑛)。
func (h *maxHeap) isEmpty() bool {
return len(h.data) == 0
}
func (h *maxHeap) size() int {
return len(h.data)
}
/* 元素出堆 */
func (h *maxHeap) pop() int {
// 判空处理
if h.isEmpty() {
fmt.Println("error")
return -1
}
// 交换根节点与最右叶节点(交换首元素与尾元素)
h.swap(0, h.size()-1)
// 删除节点
val := h.data[len(h.data)-1]
h.data = h.data[:len(h.data)-1]
// 从顶至底堆化
h.siftDown(0)
// 返回堆顶元素
return val
}
/* 从节点 i 开始,从顶至底堆化 */
func (h *maxHeap) siftDown(i int) {
for {
// 判断节点 i, l, r 中值最大的节点,记为 maxV
l, r, maxV := h.left(i), h.right(i), i
if l < h.size() && h.data[l] > h.data[maxV] {
maxV = l
}
if r < h.size() && h.data[r] > h.data[maxV]{
maxV = r
}
// 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if maxV == i {
break
}
// 交换两节点
h.swap(i, maxV)
// 循环向下堆化
i = maxV
}
}
堆的常见应用
堆的常见应用:
-
优先队列:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 ( O(\log n) ),而建队操作为 ( O(n) ),这些操作都非常高效。
-
堆排序:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
-
获取最大的 ( k ) 个元素:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。
建堆操作
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”。
借助入堆操作实现
我们首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为𝑛,每个元素的入堆操作使用𝑂(log𝑛)时间,因此该建堆方法的时间复杂度为𝑂(𝑛log𝑛)。
通过遍历堆化实现
实际上,我们可以实现一种更为高效的建堆方法,共分为两步。
- 将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
- 倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是“自下而上”构建的。
之所以选择倒序遍历,是因为这样能够保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的。
值得说明的是,由于叶节点没有子节点,因此它们天然就是合法的子堆,无须堆化。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化:
/* 构造函数,根据切片建堆 */
func newMaxHeap(nums []int) *MyHeap {
// 将列表元素原封不动添加进堆
h := MyHeap(nums)
for i := h.Parent(len(h) - 1); i >= 0; i-- {
// 堆化除叶节点以外的其他所有节点
h.siftDown(i)
}
return &h
}
假设给定一个节点数量为𝑛、高度为ℎ的“完美二叉树”:
节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,可以对各层的“节点数量×节点高度”求和,得到所有节点的堆化迭代次数的总和。
观察上式,发现𝑇(ℎ)是一个等比数列,可直接使用求和公式,得到时间复杂度为:
小结
- 堆是一棵完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素是最大(小)的。
- 优先队列的定义是具有出队优先级的队列,通常使用堆来实现。
- 堆的常用操作及其对应的时间复杂度包括:元素入堆𝑂(log𝑛)、堆顶元素出堆𝑂(log𝑛)和访问堆顶元素𝑂(1)等。
- 完全二叉树非常适合用数组表示,因此我们通常使用数组来存储堆。
- 堆化操作用于维护堆的性质,在入堆和出堆操作中都会用到。
- 输入𝑛个元素并建堆的时间复杂度可以优化至𝑂(𝑛),非常高效。
- Top‑k是一个经典算法问题,可以使用堆数据结构高效解决,时间复杂度为𝑂(𝑛log𝑘)。
数据结构的“堆”与内存管理的“堆”是同一个概念吗?
两者不是同一个概念,只是碰巧都叫“堆”。计算机系统内存中的堆是动态内存分配的一部分,程序在运行时
可以使用它来存储数据。程序可以请求一定量的堆内存,用于存储如对象和数组等复杂结构。当这些数据不再需要时,程序需要释放这些内存,以防止内存泄漏。相较于栈内存,堆内存的管理和使用需要更谨慎,使用不当可能会导致内存泄漏和野指针等问题。
图
图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。因此可以将图𝐺抽象地表示为一
组顶点𝑉 和一组边𝐸的集合。
V ={1,2,3,4,5}
E ={(1,2),(1,3),(1,5),(2,3),(2,4),(2,5),(4,5)}
G ={𝑉,𝐸}
如果将顶点看作节点,将边看作连接各个节点的引用(指针),就可以将图看作一种从链表拓展而来的数据结构。相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,因而更为复杂。
图的分类
无向图和有向图
根据边是否具有方向,可分为无向图(undirected graph)和有向图(directed graph):
-
无向图(Undirected Graph):如果图中的每条边都没有指向性,则称为无向图。例如朋友关系图、路线图都是无向图。
-
有向图(Directed Graph):如果图中的每条边都具有指向性,即𝐴→𝐵和𝐴←𝐵两个方向的边是相互独立的,,则称为有向图。例如流程图是有向图。
在无向图中,每条边都是由两个顶点组成的无序对。例如下图左侧中的顶点 v1 和顶点 v2 之间的边记为 (v1, v2) 或 (v2, v1)。
在有向图中,有向边也被称为弧,每条弧是由两个顶点组成的有序对,例如下图右侧中从顶点 v1 到顶点 v2 的弧,记为 ⟨v1, v2⟩,v1 被称为弧尾,v2 被称为弧头。
如果无向图中有 n 个顶点,则无向图中最多有 n × (n - 1) / 2 条边。而具有 n × (n - 1) / 2 条边的无向图称为「完全无向图(Complete Undirected Graph)」。
如果有向图中有 n 个顶点,则有向图中最多有 n × (n - 1) 条弧。而具有 n × (n - 1) 条弧的有向图称为「完全有向图(Complete Directed Graph)」。
如下图所示,左侧为包含 4 个顶点的完全无向图,右侧为包含 4 个顶点的完全有向图。
下面介绍一下无向图和有向图中一个重要概念 「顶点的度」。
顶点的度:
与该顶点vi 相关联的边的条数,记为 TD(vi)。
例如上图左侧的完全无向图中,顶点v3 的度为 3。
而对于有向图,我们可以将顶点的度分为 「顶点的出度」 和 「顶点的入度」。
-
顶点的出度:
以该顶点 vi为出发点的边的条数,记为 OD(vi)。 -
顶点的入度:
以该顶点vi 为终止点的边的条数,记为ID(vi)。
有向图中某顶点的度 = 该顶点的出度 + 该顶点的入度,即: TD(vi)=OD(vi)+ID(vi)
例如上图右侧的完全有向图中,顶点v3的出度为 3,入度为 3,顶点 v3的度为 (3 + 3 = 6)。
环形图和无环图
简单来说,如果顶点 v i0 可以通过一系列的顶点和边,到达顶点 v im,则称顶点 v i0 和顶点 v im 之间有一条路径,其中经过的顶点序列则称为两个顶点之间的路径。
环(Circle):如果一条路径的起始点和终止点相同,则称这条路径为「回路」或者「环」。
简单路径:顶点序列中顶点不重复出现的路径称为「简单路径」。
而根据图中是否有环,我们可以将图分为「环形图」和「无环图」。
- 环形图(Circular Graph):如果图中存在至少一条环路,则该图称为「环形图」。
- 无环图(Acyclic Graph):如果图中不存在环路,则该图称为「无环图」。
特别的,在有向图中,如果不存在环路,则将该图称为「有向无环图(Directed Acyclic Graph)」,缩写为 DAG。因为有向无环图拥有独特的拓扑结构,经常被用于处理动态规划、导航中寻求最短路径、数据压缩等多种算法场景。
连通图和非连通图
在无向图中,如果从顶点 v i 到顶点 v j 有路径,则称顶点 v i 和 v j 是连通的。
连通无向图:在无向图中,如果图中任意两个顶点之间都是连通的,则称该图为连通无向图。
非连通无向图:在无向图中,如果图中至少存在一对顶点之间不存在任何路径,则该图称为非连通无向图。
如上图所示,左侧图中 v1 与 v2, v3, v4, v5, v6 都是连通的,所以该图为连通无向图。右侧图中 v1 与 v2, v3, v4 都是连通的,但是 v1 和 v5, v6 之间不存在任何路径,则该图为非连通无向图。
无向图的「连通分量」概念:有些无向图可能不是连通无向图,但是其子图可能是连通的。这些子图称为原图的连通子图。而无向图的一个极大连通子图(不存在包含它的更大的连通子图)则称为该图的「连通分量」。
连通分量:无向图的一个极大连通子图(不存在包含它的更大的连通子图)称为该图的连通分量。
连通子图:如果无向图的子图是连通无向图,则该子图称为原图的连通子图。
连通分量:无向图中的一个极大连通子图(不存在包含它的更大的连通子图)称为该图的连通分量。
极大连通子图:无向图中的一个连通子图,并且不存在包含它的更大的连通子图。
例如上图中右侧的非连通无向图,其本身是非连通的。但顶点 v1, v2, v3, v4 与其相连的边构成的子图是连通的,并且不存在包含它的更大的连通子图了,所以该子图是原图的一个连通分量。同理,顶点 v5, v6 与其相连的边构成的子图也是原图的一个连通分量。
强连通有向图和强连通分量
连通性:在有向图中,如果从顶点 vi 到 vj 有路径,并且从顶点 vj 到 vi 也有路径,则称顶点 vi 和 vj 是连通的。
强连通有向图:如果图中任意两个顶点 vi 和 vj,从 vi 到 vj 和从 vj 到 vi 都有路径,则称该图为强连通有向图。
非强连通有向图:如果图中至少存在一对顶点之间不存在任何路径,则该图称为非强连通有向图。
如下图所示:
- 左侧图中任意两个顶点之间都有路径,因此左侧图为强连通有向图。
- 右侧图中顶点 v7 无法通过路径到达其他顶点,因此右侧图为非强连通有向图。
与无向图类似,有向图的一个极大强连通子图称为该图的 强连通分量。
- 强连通子图:如果有向图的子图是连通有向图,则该子图称为原图的强连通子图。
- 强连通分量:有向图中的一个极大强连通子图,称为该图的强连通分量。
- 极大强连通子图:有向图中的一个强连通子图,并且不存在包含它的更大的强连通子图。
例如上图中,右侧的非强连通有向图,其本身不是强连通的(顶点 v7 无法通过路径到达其他顶点)。但顶点 v1、v2、v3、v4、v5、v6 与其相连的边构成的子图(即上图的左侧图)是强连通的,并且不存在包含它的更大的强连通子图,因此该子图是原图的一个强连通分量(即上图中的左侧图是右侧图的强连通分量)。同理,顶点 v7 构成的子图也是原图的一个强连通分量。
带权图
有时,图不仅需要表示顶点之间是否存在某种关系,还需要表示这一关系的具体细节。这时候我们需要在边上带一些数据信息,这些数据信息被称为 权。在具体应用中,权值可以具有某种具体意义,比如权值可以代表距离、时间以及价格等不同属性。
- 带权图:如果图的每条边都被赋以⼀个权值,这种图称为带权图。
- 网络:带权的连通⽆向图称为⽹络。
图的表示
图的结构比较复杂,我们需要表示顶点和边。一个图可能有任意多个(有限个)顶点,而且任何两个顶点之间都可能存在边。我们在实现图的存储时,重点需要关注边与顶点之间的关联关系,这是图的存储的关键。
图的存储可以通过 顺序存储结构 和 链式存储结构 来实现。具体来说,常见的存储结构包括:
顺序存储结构:
-
邻接矩阵:
- 邻接矩阵使用一个二维数组来表示图的边。如果图是有向图,矩阵中的元素 A[i][j] 表示从顶点 vi 到顶点 vj 是否存在边。如果是无向图,矩阵是对称的。
-
边集数组:
- 边集数组将图中的所有边存储在一个数组中。每条边通过顶点对表示,例如 (vi, vj),表示从 vi 到 vj 的一条边。
链式存储结构:
-
邻接表:
- 邻接表为每个顶点维护一个链表,链表中的每个节点表示与该顶点相连的边。对于有向图,链表中存储的是从该顶点出发的边;对于无向图,则每个顶点的链表中包含与之相连的所有边。
-
链式前向星:
- 链式前向星将图的边按出度进行存储,每个顶点都有一个链表,表示从该顶点出发的所有边,并且链表节点中存储的是目标顶点的信息。
-
十字链表:
- 十字链表是邻接表的扩展,适用于有向图。它为每个顶点维护两个链表,一个表示出边(从该顶点出发的边),一个表示入边(指向该顶点的边)。
-
邻接多重表:
- 邻接多重表是一个更通用的链式存储结构,可以用于处理多重边(即两个顶点之间有多条边)和自环(顶点与自己之间的边)。它为每个顶点维护一个链表,并且链表节点中存储的是边的信息(包括源顶点、目标顶点以及边的其他信息)。
邻接矩阵
设图的顶点数量为𝑛,邻接矩阵(adjacencymatrix)使用一个𝑛×𝑛大小的矩阵来表示图,每一行(列)代
表一个顶点,矩阵元素代表边,用1或0表示两个顶点之间是否存在边。
设邻接矩阵为𝑀、顶点列表为𝑉 ,那么矩阵元素𝑀[𝑖,𝑗]=1表示顶点𝑉[𝑖]到顶点𝑉[𝑗]之间存在边,反之𝑀[𝑖,𝑗]=0表示两顶点之间无边。
邻接矩阵具有以下特性:
- 顶点不能与自身相连,因此邻接矩阵主对角线元素没有意义。
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
- 将邻接矩阵的元素从1和0替换为权重,则可表示有权图。
使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查改操作的效率很高,时间复杂度
均为𝑂(1)。然而,矩阵的空间复杂度为𝑂(𝑛2),内存占用较多。
邻接表
邻接表(adjacencylist)使用𝑛个链表来表示图,链表节点表示顶点。第𝑖个链表对应顶点𝑖,其中存储了该
顶点的所有邻接顶点(与该顶点相连的顶点)。
邻接表仅存储实际存在的边,而边的总数通常远小于𝑛²,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。比如当链表较长时,可以将链表转化为AVL树或红黑树,从而将时间效率从𝑂(𝑛)优化至𝑂(log𝑛);还可以把链表转换为哈希表,从而将时间复杂度降至𝑂(1)。
图的常见应用:
许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题,甚至spark用到了有向无环图,知识图谱的构建也用到了图。
图的基础操作
图的基础操作可分为对“边”的操作和对“顶点”的操作。在“邻接矩阵”和“邻接表”两种表示方法下,实现方式有所不同。
基于邻接矩阵的实现
给定一个顶点数量为𝑛的无向图:
-
添加或删除边:
- 直接在邻接矩阵中修改指定的边即可,操作时间为 O(1)。
- 由于是无向图,添加或删除时需要同时更新两个方向的边。
-
添加顶点:
- 在邻接矩阵的尾部添加一行一列,并全部填充0。操作时间为 O(n),因为需要更新矩阵的行和列。
-
删除顶点:
- 在邻接矩阵中删除一行一列。当删除首行首列时,最差情况下需要将 (n-1)² 个元素向左上移动,操作时间为 O(n²)。
-
初始化:
- 传入 n 个顶点,初始化长度为 n 的顶点列表。
- 初始化小的邻接矩阵 adjMat,操作时间为 O(n²)。
- 初始化顶点列表 vertices,操作时间为 O(n)。
/* 基于邻接矩阵实现的无向图类 */
type graphAdjMat struct {
// 顶点列表,元素代表“顶点值”,索引代表“顶点索引”
vertices []int
// 邻接矩阵,行列索引对应“顶点索引”
adjMat [][]int
}
/* 构造函数 */
func newGraphAdjMat(vertices []int, edges [][]int) *graphAdjMat {
// 添加顶点
n := len(vertices)
adjMat := make([][]int, n)
for i := range adjMat {
adjMat[i] = make([]int, n)
}
// 初始化图
g := &graphAdjMat{
vertices: vertices,
adjMat: adjMat,
}
// 添加边
// 请注意,edges 元素代表顶点索引,即对应 vertices 元素索引
for i := range edges {
g.addEdge(edges[i][0], edges[i][1])
}
return g
}
/* 获取顶点数量 */
func (g *graphAdjMat) size() int {
return len(g.vertices)
}
/* 添加顶点 */
func (g *graphAdjMat) addVertex(val int) {
n := g.size()
// 向顶点列表中添加新顶点的值
g.vertices = append(g.vertices, val)
// 在邻接矩阵中添加一行
newRow := make([]int, n)
g.adjMat = append(g.adjMat, newRow)
// 在邻接矩阵中添加一列
for i := range g.adjMat {
g.adjMat[i] = append(g.adjMat[i], 0)
}
}
/* 删除顶点 */
func (g *graphAdjMat) removeVertex(index int) {
if index >= g.size() {
return
}
// 在顶点列表中移除索引 index 的顶点
g.vertices = append(g.vertices[:index], g.vertices[index+1:]...)
// 在邻接矩阵中删除索引 index 的行
g.adjMat = append(g.adjMat[:index], g.adjMat[index+1:]...)
// 在邻接矩阵中删除索引 index 的列
for i := range g.adjMat {
g.adjMat[i] = append(g.adjMat[i][:index], g.adjMat[i][index+1:]...)
}
}
/* 添加边 */
// 参数 i, j 对应 vertices 元素索引
func (g *graphAdjMat) addEdge(i, j int) {
// 索引越界与相等处理
if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j {
fmt.Errorf("%s", "Index Out Of Bounds Exception")
}
// 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
g.adjMat[i][j] = 1
g.adjMat[j][i] = 1
}
/* 删除边 */
// 参数 i, j 对应 vertices 元素索引
func (g *graphAdjMat) removeEdge(i, j int) {
// 索引越界与相等处理
if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j {
fmt.Errorf("%s", "Index Out Of Bounds Exception")
}
g.adjMat[i][j] = 0
g.adjMat[j][i] = 0
}
/* 打印邻接矩阵 */
func (g *graphAdjMat) print() {
fmt.Printf("\t顶点列表 = %v\n", g.vertices)
fmt.Printf("\t邻接矩阵 = \n")
for i := range g.adjMat {
fmt.Printf("\t\t\t%v\n", g.adjMat[i])
}
}
基于邻接表的实现
设无向图的顶点总数为𝑛、边总数为𝑚,则可根据图9‑8所示的方法实现各种操作:
- 添加边:在顶点对应链表的末尾添加边即可,使用𝑂(1)时间。因为是无向图,所以需要同时添加两个方向的边。
- 删除边:在顶点对应链表中查找并删除指定边,使用𝑂(𝑚)时间。在无向图中,需要同时删除两个方向的边。
- 添加顶点:在邻接表中添加一个链表,并将新增顶点作为链表头节点,使用𝑂(1)时间。
- 删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用𝑂(𝑛+𝑚)时间。
- 初始化:在邻接表中创建𝑛个顶点和2𝑚条边,使用𝑂(𝑛+𝑚)时间。
为了方便添加与删除顶点,以及简化代码,使用列表(动态数组)来代替链表。
使用哈希表来存储邻接表,key 为顶点实例,value 为该顶点的邻接顶点列表(链表)。
type Vertex struct {
Val int
}
/* 基于邻接表实现的无向图类 */
type graphAdjList struct {
// 邻接表,key:顶点,value:该顶点的所有邻接顶点
adjList map[Vertex][]Vertex
}
/* 构造函数 */
func newGraphAdjList(edges [][]Vertex) *graphAdjList {
g := &graphAdjList{
adjList: make(map[Vertex][]Vertex),
}
// 添加所有顶点和边
for _, edge := range edges {
g.addVertex(edge[0])
g.addVertex(edge[1])
g.addEdge(edge[0], edge[1])
}
return g
}
/* 获取顶点数量 */
func (g *graphAdjList) size() int {
return len(g.adjList)
}
/* 添加边 */
func (g *graphAdjList) addEdge(vet1 Vertex, vet2 Vertex) {
_, ok1 := g.adjList[vet1]
_, ok2 := g.adjList[vet2]
if !ok1 || !ok2 || vet1 == vet2 {
panic("error")
}
// 添加边 vet1- vet2, 添加匿名 struct{},
g.adjList[vet1] = append(g.adjList[vet1], vet2)
g.adjList[vet2] = append(g.adjList[vet2], vet1)
}
func DeleteSliceElms(l []Vertex, val Vertex) []Vertex {
for i := 0; i < len(l); i++ {
if l[i] == val {
l = append(l[:i], l[i+1:]...)
}
}
return l
}
/* 删除边 */
func (g *graphAdjList) removeEdge(vet1 Vertex, vet2 Vertex) {
_, ok1 := g.adjList[vet1]
_, ok2 := g.adjList[vet2]
if !ok1 || !ok2 || vet1 == vet2 {
panic("error")
}
// 删除边 vet1- vet2
g.adjList[vet1] = DeleteSliceElms(g.adjList[vet1], vet2)
g.adjList[vet2] = DeleteSliceElms(g.adjList[vet2], vet1)
}
/* 添加顶点 */
func (g *graphAdjList) addVertex(vet Vertex) {
_, ok := g.adjList[vet]
if ok {
return
}
// 在邻接表中添加一个新链表
g.adjList[vet] = make([]Vertex, 0)
}
/* 删除顶点 */
func (g *graphAdjList) removeVertex(vet Vertex) {
_, ok := g.adjList[vet]
if !ok {
panic("error")
}
// 在邻接表中删除顶点 vet 对应的链表
delete(g.adjList, vet)
// 遍历其他顶点的链表,删除所有包含 vet 的边
for v, list := range g.adjList {
g.adjList[v] = DeleteSliceElms(list, vet)
}
}
/* 打印邻接表 */
func (g *graphAdjList) print() {
var builder strings.Builder
fmt.Printf(" 邻接表 = \n")
for k, v := range g.adjList {
builder.WriteString("\t\t" + strconv.Itoa(k.Val) + ": ")
for _, vet := range v {
builder.WriteString(strconv.Itoa(vet.Val) + " ")
}
fmt.Println(builder.String())
builder.Reset()
}
}
效率对比
似乎邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
图的遍历
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:广度优先遍历和深度优先遍历。
广度优先遍历
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。
从图的左上角顶点出发,首先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
BFS通常借助队列来实现,队列具有“先入先出”的性质,这与BFS的“由近及远”的思想异曲同工。
- 将遍历起始顶点
startVet
加入队列,并开启循环。 - 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。
- 循环步骤2,直到所有顶点被访问完毕后结束。
为了防止重复遍历顶点,需要借助一个哈希表visited来记录哪些节点已被访问。
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
func graphBFS(g *graphAdjList, startVet Vertex) []Vertex {
//顶点遍历序列
res := make([]Vertex, 0)
//哈希表,用于记录已被访问过的顶点
visited := make(map[Vertex]struct{})
visited[startVet] = struct{}{}
//队列用于实现BFS,使用切片模拟队列
queue := make([]Vertex, 0)
queue = append(queue, startVet)
//以顶点vet为起点,循环直至访问完所有顶点
for len(queue) > 0 {
//队首顶点出队
vet := queue[0]
queue = queue[1:]
//记录访问顶点
res = append(res, vet)
//遍历该顶点的所有邻接顶点
for _, adjVet := range g.adjList[vet] {
_, isExist := visited[adjVet]
// 只入队未访问的顶点
if !isExist {
queue = append(queue, adjVet)
visited[adjVet] = struct{}{}
}
}
}
// 返回顶点遍历序列
return res
}
广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序允许被任意打乱。
时间复杂度:所有顶点都会入队并出队一次,使用𝑂(|𝑉|)时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问2次,使用𝑂(2|𝐸|)时间;总体使用𝑂(|𝑉|+|𝐸|)时间。
空间复杂度:列表res,哈希表visited ,队列que中的顶点数量最多为|𝑉|,使用𝑂(|𝑉|)空间。
深度优先遍历
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。从图的左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中也需要借助一个哈希表visited 来记录已被访问的顶点,以避免重复访问顶点。
/* 深度优先遍历辅助函数 */
func dfs(g *graphAdjList, visited map[Vertex]struct{}, res *[]Vertex, vet Vertex) {
// append 操作会返回新的的引用,必须让原引用重新赋值为新 slice 的引用
*res = append(*res, vet)
visited[vet] = struct{}{}
// 遍历该顶点的所有邻接顶点
for _, adjVet := range g.adjList[vet] {
_, isExist := visited[adjVet]
// 递归访问邻接顶点
if !isExist {
dfs(g, visited, res, adjVet)
}
}
}
/* 深度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
func graphDFS(g *graphAdjList, startVet Vertex) []Vertex {
// 顶点遍历序列
res := make([]Vertex, 0)
// 哈希表,用于记录已被访问过的顶点
visited := make(map[Vertex]struct{})
dfs(g, visited, &res, startVet)
// 返回顶点遍历序列
return res
}
深度优先遍历的算法流程:
- 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
- 曲虚线代表向上回溯,表示此递归方法已经返回,回溯到了开启此方法的位置。
与广度优先遍历类似,深度优先遍历序列的顺序也不是唯一的。给定某顶点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。
以树的遍历为例,“根→左→右”“左→根→右”“左→右→根”分别对应前序、中序、后序遍历,它们展示了三种遍历优先级,然而这三者都属于深度优先遍历。
时间复杂度:所有顶点都会被访问1次,使用𝑂(|𝑉|)时间;所有边都会被访问2次,使用𝑂(2|𝐸|)时间;总体使用𝑂(|𝑉|+|𝐸|)时间。
空间复杂度:列表res ,哈希表visited 顶点数量最多为|𝑉|,递归深度最大为|𝑉|,因此使用𝑂(|𝑉|)空间。