这是本节的多页打印视图。
点击此处打印 .
返回本页常规视图 .
数据结构 数据结构在 408 试卷中占据 45 分,和计算机组成原理一样,是分数占比最大的科目,包含 11 道选择题以及两道大题。大题包含算法设计题和概念问答题,算法设计题可能是基于线性表、树或图的某个问题,让你设计算法并且给出时间和空间复杂度的分析,选择题也会均匀地涉及到各个章节的内容。
这门课理解性的内容居多,在知识掌握牢固的情况下不容易遗忘,建议大家优先复习。在复习的过程中可以与实践结合,可以去用代码实现一下一些算法和数据结构,这样可以对知识点的理解更加深刻。总的来说,数据结构中各个板块的内容都比较重要,建议大家将这些内容都理解透彻。
数据结构的考察目标包含如下内容(来自 408 考研大纲):
掌握数据结构的基本概念、基本原理和基本方法。 掌握数据的逻辑结构、存储结构及基本操作的实现,能够对算法进行基本的时间复杂度和空间复杂度的分析。 能够运用数据结构的基本原理和方法进行问题的分析与求解,具备采用 C 和 C++ 语言设计与算法实现算法的能力。 1 - 绪论 本章在考研中一般不直接考察,需要了解时间复杂度和空间复杂度的概念,并对算法进行相关分析。
1.1 - 数据结构基本概念 不会直接考察类型,这里作为一个总览,帮助大家理清考题中会涉及到哪些数据结构。
数据结构 数据结构(Data Structure)是 存储、组织和管理数据的方式 ,它不仅决定了数据的存储形式,也影响数据访问和操作的效率。
分类 mindmap
root((数据结构<br/>Data Structure))
线性结构
数组 Array
连续内存
随机访问快
插入删除慢
链表 Linked List
节点指针连接
插入删除快
访问慢
栈 Stack
后进先出 LIFO
函数调用
表达式求值
队列 Queue
先进先出 FIFO
任务调度
双端队列 Deque
两端可操作
树形结构
二叉树<br/>Binary Tree
二叉搜索树<br/>BST
堆 Heap
优先队列实现
平衡树
AVL树
红黑树
图结构
有向图
无向图
存储方式
邻接矩阵
邻接表
应用场景
路径搜索
社交网络建模
散列表<br/>Hash Table
哈希函数
快速查找
应用
字典
缓存 考试中涉及的数据结构可以分为以下几种:
线性结构 (元素之间有顺序关系)数组(Array) :连续内存,随机访问快,插入删除慢链表(Linked List) :节点通过指针连接,插入删除快,访问慢栈(Stack) :后进先出(LIFO),用于函数调用、表达式求值队列(Queue) :先进先出(FIFO),用于任务调度双端队列(Deque) :两端都可以操作树形结构 (元素之间有层级关系)二叉树(Binary Tree) 二叉搜索树(BST) 堆(Heap) :优先队列实现平衡树(AVL、红黑树) 图结构 (元素之间有网络关系)有向图 / 无向图 邻接矩阵 / 邻接表存储 常用于路径搜索、社交网络建模 散列表(Hash Table) 算法 算法(Algorithm)是 解决问题的一系列步骤或方法 ,用来操作数据结构,实现具体功能。
数据结构和算法的关系
数据结构提供“容器” 算法提供“操作方法” 好的数据结构可以让算法更高效 算法的设计也依赖数据结构特性 💡 举例:
用数组实现栈 → 可以快速访问栈顶 用链表实现队列 → 插入删除都很快 用堆实现优先队列 → 可以快速找最大/最小值 用图 + BFS → 可以找最短路径 1.2 - 算法基本概念 每年都会考察一道 复杂度分析 的选择题,重点掌握时间复杂度和空间复杂度的分析方法。
什么是算法 算法 (Algorithm) 是一个明确规定了操作步骤的有限指令集,用于计算函数、处理数据、解决一个特定问题或执行某个任务。
算法 的基本属性:
明确性 :每一步骤必须有明确且不含糊的定义。有输入和输出 :算法 应有 0 个或更多的 输入 和 1 个或更多的 输出 。输入 是在 算法 开始之前提供的,而 输出 是在 算法 结束时产生的。有限性 :如果 算法 在执行完有限步骤后终止,那么它就是 有限的 。换句话说,一个 算法 必须总是在执行有限次操作后结束。可行性 :算法 中的每一步都应该是简单且基本的,这样它们可以在有限的时间内完成并由计算机执行。独立性 :算法 的指令应该有普适性,也就是说,它们不应依赖于任何特定的编程语言或模型。相反,算法 应该足够通用,可以在任何编程环境中实现。效率度量 时间复杂度 时间复杂度(Time Complexity)是计算机科学中衡量一个 算法运行效率 的重要指标。它主要描述了当输入规模(通常记为
n
)不断增大时,算法执行所需的 基本操作次数 的增长趋势。
在算法的时间复杂度分析中,
基本操作次数
指的是一个算法中执行时间固定的 最小操作单元 。你可以把它理解为计算机执行一次最基础、最原始的指令所花费的时间。
这些基本操作通常包括:
算术运算 :例如,加、减、乘、除、取模等。比较运算 :例如,小于、大于、等于等。赋值操作 :将一个值赋给一个变量。逻辑运算 :例如,与、或、非等。计算方法 设输入规模为
n
,算法执行所需的 基本操作次数 记为
T ( n )
。
为了衡量算法效率,我们关心
T ( n )
随
n
增大的增长趋势,而不是精确的操作次数。
时间复杂度就是
T ( n )
的 渐近上界 ,常用 大
O
符号 表示为:
T ( n ) = O ( f ( n ))
其中
f ( n )
是能反映
T ( n )
增长速度的函数。
换句话说,时间复杂度刻画的是 当
n
趋于无穷大时,算法执行时间随输入规模增长的量级 。
常见时间复杂度 常见的 时间复杂度 (按增长速度排序)有:
O ( 1 )
:常数时间 。无论 输入 数据有多大,算法 都在恒定的时间内完成。O ( log n )
:对数时间 。例如:二分搜索。O ( n )
:线性时间 。例如:简单的搜索 算法 。O ( n log n )
:线性对数时间 。例如:高效的排序 算法 ,如归并排序。O ( n 2 )
、
O ( n 3 )
…:多项式时间 。例如:冒泡排序、插入排序和选择排序是
O ( n 2 )
。O ( 2 n )
:指数时间 。例如:计算斐波那契数列的递归实现。O ( n !)
:阶乘时间 。例如:旅行商问题的暴力解决方法。空间复杂度 空间复杂度 (Space Complexity)是衡量 算法执行过程中所需存储空间 随输入数据量增长而变化的指标。它不仅包括算法中用来存放 输入数据 的空间,还包括 辅助变量、递归调用栈 等临时空间。与时间复杂度类似,我们关注的是 增长趋势 ,通常只关注最高阶的量级,用大
O
符号表示。
组成 固定部分 包括常量、程序代码本身、简单变量、常量数组等。 这一部分的空间大小与输入数据规模无关,通常记为
O ( 1 )
。 可变部分 随着输入数据量的变化而变化的空间,主要包括:输入数据本身 (如数组、链表)辅助空间 (如临时数组、栈、队列等)递归调用栈 (递归深度对空间占用有直接影响) 常见空间复杂度 常见的 空间复杂度 (按增长速度排序)有:
空间复杂度 描述 示例 O ( 1 ) 使用常量额外空间 交换两个变量、求最大值/最小值 O ( n ) 需要与输入规模成线性关系的额外空间 复制数组、链表逆序 O ( n 2 ) 二维数组或矩阵存储 Floyd-Warshall 算法的距离矩阵 O ( log n ) 递归调用栈空间 二分查找的递归实现、平衡二叉树的遍历
2 - 线性表 本章是后序内容的基础,可能会涉及到在选择题中的概念考察。除此外,需要能够手写代码实现基于数组或链表的相关操作。
学习思维导图:
# 线性表
## 线性表的基本概念
## 线性表的实现
- 顺序存储
- 链式存储
## 线性表的应用
2.1 - 定义和基本操作 了解线性表的概念,不会直接考察。还是会考察 顺序表 和 链表 这两种具体实现方案。
线性表定义 线性表 Linear List L = (a₁, a₂, a₃, ..., aᵢ, aᵢ₊₁, ..., aₙ) a₁ a₂ a₃ ... aᵢ aᵢ₊₁ ... aₙ 当前元素 前驱 后继 线性表特点: 个数有限: 表中元素个数是确定的有限值类型相同: 所有元素数据类型一致,占用相同存储空间逻辑有序: 元素间存在先后顺序,有前驱和后继关系线性表 (Linear List)是 L 数据结构 中的一种基本结构。它是 零个或多个数据元素的有限序列 。通常,线性表 中的 数据元素 之间是 有序的 ,它们之间存在着 前驱和后继的关系 。
L = ( a 1 , a 2 , ⋯ , a i , a i + 1 , ⋯ , a n )
特点:
个数有限 表中元素数据类型都相同 ,每个元素占有 相同大小的存储空间 仅讨论 元素间的逻辑关系 ,表中元素有 先后顺序 顺序表和链表 线性表分为顺序存储结构(又称 顺序表 )和链式存储结构(又称为 链表 )。
顺序表中的元素存储地址是 连续的 (存储在栈中),而链表的元素存储地址是 非连续的 (存储在堆中),元素节点中除了存储数据元素之外还存储相邻元素的地址信息。顺序表中数据之间的 逻辑关系 和 物理关系 是一致的,链表中数据元素的逻辑关系和物理关系并不一定一致。
上图中左边为顺序表,右边为链表。顺序表在内存中的地址是连续的,各个元素的下标之间是有规律可循的,通过一个已知下标的元素可以找到顺序表中任意一个其它元素。但是链式表中的元素在内存中的地址是不连续的,所以链式表中的每个元素除了要保存数据信息外,还要保存下一个元素的内存地址,以便形成线性关系。
操作 线性表中的操作包含以下类型,顺序表 和 链表 会各自使用不同的方案实现这些操作:
初始化 (InitList): 创建一个 空的线性表 。插入 (Insert): 在线性表的 指定位置 插入一个 新的元素 。删除 (Delete): 删除线性表中的 指定位置的元素 。查找 (LocateElem): 根据 给定的条件 查找线性表中的 元素 。获取元素 (GetElem): 获取线性表中 指定位置的元素 。设置元素 (SetElem): 修改线性表中 指定位置的元素 的 值 。长度 (Length): 返回线性表中的 元素数量 。判空 (IsEmpty): 判断线性表是否 为空 。清空 (ClearList): 清除 线性表中的 所有元素 。遍历 (Traverse): 对线性表中的 每个元素 执行 某种操作 。2.2 - 顺序表示 关于顺序表,一般不会直接考察本节中介绍的这些操作,因为太简单了。更多的是考察基于顺序表(数组)的 算法设计 ,但是这些操作是更高级算法设计的基础。
顺序表定义 顺序表(Sequential List) 是一种常见的数据结构,用于存储一组元素,并按照它们在内存中的物理顺序来排列和访问这些元素。顺序表通常由一个 数组 或 列表 构成,其中每个元素都占据一个连续的内存位置,并且可以通过索引值来访问。
假设线性表 A 存储的其实位置为 LOC(A),每个元素占用的存储空间的大小为 sizeof(Elem),则 A 所对应的顺序存储结构为:
顺序表 优点 :随机访问性强 :顺序表支持通过索引直接访问元素,访问速度快,时间复杂度为 O(1) 。空间使用连续 :顺序表中的元素存储在连续的内存块中,这有助于提高缓存的局部性,从而提高访问速度。操作简单 :无需处理复杂的指针操作。 顺序表 缺点 :固定大小或调整大小的开销 :对于静态数组,大小是固定的,如果预分配的空间不足或过大,会导致内存浪费或数组溢出。动态数组可以重新分配大小,但这会增加时间和空间开销。插入和删除的时间开销 :如果要在顺序表的中间插入或删除元素,可能需要移动大量的元素,时间复杂度为 O(n) 。 操作 数据结构定义 以下代码定义了一个顺序表 SeqList,它使用 固定大小的数组 data 来存储元素,length 记录当前表的长度。InitList 函数初始化顺序表,设置 长度为 0 ,表示空表。
#define MAXSIZE 100
typedef struct {
ElementType data [ MAXSIZE ];
int length ;
} SeqList ;
void InitList ( SeqList * L ) {
L -> length = 0 ;
}
顺序表 SeqList 数据结构 结构体定义 typedef struct { ElementType data[MAXSIZE]; int length; } SeqList; MAXSIZE = 100 初始化: L->length = 0 内存布局 data[MAXSIZE] 数组 索引: ... 0 1 2 3 4 99 空 空 空 空 空 空 length 长度字段 0 (初始值) 初始化过程说明 1 定义顺序表结构:包含固定大小数组 data[MAXSIZE] 和长度字段 length 2 MAXSIZE = 100,表示数组最多可存储100个元素 3 InitList(L) 函数:将 length 设置为 0,表示当前表为空表 4 初始化后:数组预分配了空间,但 length=0 表示没有有效元素 特点: • 固定大小:数组大小在编译时确定,不能动态改变 • 顺序存储:元素在内存中连续存放 初始化后 length = 0 插入 顺序表插入过程图解:在位置 3 插入元素 99 步骤1:初始状态 10 20 30 40 50 1 2 3 4 5 6 7 length = 5 插入位置 pos=3 步骤2:从尾部开始,向后移动元素 10 20 30 40 50 50 移动 10 20 30 40 40 50 移动 10 20 30 30 40 50 移动 步骤3:插入新元素并更新长度 10 20 99 30 40 50 新元素 99 length = 6 关键要点: 1. 检查插入位置合法性:1 ≤ pos ≤ length+1 2. 检查数组是否已满:length < MAXSIZE 3. 从后往前移动元素,避免覆盖 4. 循环:for (i = length; i ≥ pos; i--) 5. 移动操作:data[i] = data[i-1] 6. 插入新元素:data[pos-1] = e 7. 更新长度:length++ 时间复杂度:O(n) 在第 pos 个位置 插入新元素 e。首先检查 插入位置合法性 和 空间是否已满 ,然后从尾部向后移动元素,为插入留出位置,最后将元素插入并更新长度。
bool Insert ( SeqList * L , int pos , ElementType e ) {
if ( L -> length == MAXSIZE || pos < 1 || pos > L -> length + 1 ) {
return false ;
}
for ( int i = L -> length ; i >= pos ; i -- ) {
L -> data [ i ] = L -> data [ i - 1 ];
}
L -> data [ pos - 1 ] = e ;
L -> length ++ ;
return true ;
}
删除 顺序表删除操作 Delete(L, pos, e) 删除前: 10 20 30 40 50 1 2 3 4 5 6 7 pos = 3 length = 5 删除元素: *e = 30 操作步骤: 1. 保存要删除的元素: *e = L->data[pos-1] = L->data[2] = 30 2. 后续元素前移: 10 20 被删除 40 50 i=4: data[2]=data[3] i=5: data[3]=data[4] 删除后: 10 20 40 50 1 2 3 4 5 6 7 length = 4 删除成功: return true 代码关键点: • 位置检查: if (pos < 1 || pos > L->length) return false; • 保存元素: *e = L->data[pos - 1]; // 数组下标从0开始 • 前移操作: for (int i = pos; i < L->length; i++) L->data[i-1] = L->data[i]; 删除第 pos 个元素 ,并通过指针返回删除的元素值。删除后将该位置后的所有元素 前移 ,最后更新长度。
bool Delete ( SeqList * L , int pos , ElementType * e ) {
if ( pos < 1 || pos > L -> length ) {
return false ;
}
* e = L -> data [ pos - 1 ];
for ( int i = pos ; i < L -> length ; i ++ ) {
L -> data [ i - 1 ] = L -> data [ i ];
}
L -> length -- ;
return true ;
}
查找操作 在线性表中 顺序查找 第一个值等于 e 的元素,返回其逻辑位置 (从 1 开始);若未找到,返回 0。
int LocateElem ( SeqList L , ElementType e ) {
for ( int i = 0 ; i < L . length ; i ++ ) {
if ( L . data [ i ] == e ) {
return i + 1 ;
}
}
return 0 ;
}
获取元素 获取顺序表中第 pos 个元素 ,返回值通过指针 *e 输出。若位置非法则返回 false 。
bool GetElem ( SeqList L , int pos , ElementType * e ) {
if ( pos < 1 || pos > L . length ) {
return false ;
}
* e = L . data [ pos - 1 ];
return true ;
}
判空 判断顺序表是否为空 ,直接判断 length 是否为 0 。
bool IsEmpty ( SeqList L ) {
return L . length == 0 ;
}
清空 清空顺序表 ,只需将 length 置 0 ,无需实际删除元素,等价于逻辑上的清空。
void ClearList ( SeqList * L ) {
L -> length = 0 ;
}
长度 返回当前顺序表的长度 ,即有效元素个数。
int Length ( SeqList L ) {
return L . length ;
}
2.3 - 链式表示 链表的基础操作 (插入、删除这种)是选择题的常客,解答题中的算法设计也会涉及到链表的操作。
单链表定义 线性表的 链式表示 通常指的是使用 链表 来实现线性表。链表 是由一系列 结点 组成的,每个 结点 都包含一个 数据元素 和一个指向下一个 结点 的 指针 。这种结构允许我们 动态地插入 和 删除元素 ,而不需要移动其他元素。
与顺序表不同,单链表中每个 结点 的存储空间都是 动态分配 的,即使用 c 语言中的 malloc() 函数或者 c++ 中的 new 操作符。动态分配 的空间存储在 进程的堆 中,这些 结点 占用的存储空间不是连续的,而是离散的,如下图所示:
由于堆的这种 动态内存分配 特性,单链表 具备如下优点:
链表大小可以动态变化 :链表的 结点 是在需要时 动态分配 的,因此在使用过程中不需要提前分配固定大小的存储空间,可以随时 插入 或 删除 结点 。插入和删除方便 :只要知道了某个 结点 的位置,就可以在 O(1) 的时间复杂度内 插入 或 删除 该 结点 。而顺序表可能需要移动大量的元素。无需预估数据大小 :链表可以灵活地增长,而顺序表需要预先定义一个固定大小,或者使用 动态数组 实现,但重新分配和拷贝的开销可能会很大。数组(顺序表)与 链表 不同,由于数组一般是直接作为函数的局部变量使用 int a[N] 定义的,所以数组存储在 进程的栈 中,数组中的相邻元素是 连续 地存储在栈上,如下图所示:
由于数组在内存中的存储是 连续 的,这有利于程序的 空间局部性 ,当访问一个元素时,相邻的元素也会被加载到 CPU 缓存 中,这提高了访问速度。 此外,通过数组的起始地址和一个偏移,我们可以快速地定位到某个数组元素在内存中的地址,实现 随机访问 。
相比而言,链表 则不具备以上特性,链表 的缺点如下:
随机访问较慢 :链表 不支持直接通过索引进行 随机访问 ,必须从头部开始逐个遍历 结点 ,直到找到所需的 结点 ,所以访问 链表 中的元素需要 O(n) 的时间复杂度。空间开销较大 :除了 数据元素 的存储外,每个 结点 还需要额外的空间存储一个 指针 ,这增加了 链表 的存储开销。操作 链表 的操作经常考察,需要熟练掌握,并且能够手写代码。
数据结构定义 可以使用如下结构体来描述 单链表 中的 结点 :
// 链表定义
typedef struct Node {
int data ;
struct Node * next ;
} Node ;
其中结构体中包含两个元素,一个是 数据 ,另一个 指向下一个结点的指针 。 通过这种方式,链表 可以在内存中以非连续的方式存储数据,每个 结点 通过 指针 连接起来。
单链表结构示意图 data next → Node 1 data 20 next → Node 2 data 30 next NULL Node 3 typedef struct Node { int data; // 存储数据 struct Node *next; // 指向下一个节点 } Node; 特点说明: • 每个节点包含数据和指针两部分 • 通过指针连接各个节点 • 最后一个节点的指针指向NULL • 在内存中可以非连续存储 head 在这个例子中,data 的类型是 int,意味着这个 链表 用于存储整数。但是,你可以根据需要更改 data 的类型,例如 float、char 或者自定义的结构体类型,以存储不同类型的数据。
next 指针的作用是 连接链表中的各个结点 。它存储了下一个结点的内存地址。通过 next 指针,我们可以从一个结点访问到下一个结点,从而遍历整个链表。
初始化链表 在 初始化 链表时我们需要为其创建一个 头结点 ,并将 头结点 的 next 设置为空。
// 初始化
Node * init_linkedlist () {
Node * head = ( Node * ) malloc ( sizeof ( Node )); // 创建头结点
if ( head == NULL ) exit ( 1 ); // 内存分配失败
head -> next = NULL ; // 初始为空链表
return head ;
}
头结点 的目的在于 简化空链表的处理 和 统一链表的操作 :
引入 头结点 后,即使链表为空,头指针 也始终指向 头结点 。 通过引入 头结点 ,可以使链表的第一个 结点 (实际数据 结点 )的操作与其他 结点 的操作保持一致。 判空 如果 头指针 的 next 为空的话,说明 单链表 为空:
bool is_empty ( Node * head ) {
return head -> next == NULL ;
}
插入 对于在 链表 的一个 结点 p 之后插入一个新 结点 n 可以抽象如下操作:
void insert_node_after ( Node * p , Node * n ) {
Node * q = p -> next ;
n -> next = q ;
p -> next = n ;
} void insert_value_after ( Node * n , int value ) {
Node * p = malloc ( sizeof ( Node ));
p -> value = value ;
Node * q = n -> next ;
n -> next = p ;
p -> next = q ;
} 当然,如果我们想插入一个实际值 value 的话,你需要通过 Node *p = malloc(sizeof(Node)); p->data = value; 来创建一个新 结点 ,然后再将新创建的 结点 通过以上函数插入。
在实际考察 插入 操作的过程中,可能会涉及到三种情况:在 链表 头部插入、在 链表 尾部插入 或 在任意位置插入。
void insert_after_head ( Node * head , int value ) {
// 调用上文定义的插入函数
insert_value_after ( head , value );
} void insert_after_tail ( Node * head , int value ) {
// 找到链表的尾部结点
Node * tail = head ;
while ( tail -> next != NULL ) {
tail = tail -> next ;
}
// 在该位置插入
insert_value_after ( tail , value );
} // 在链表的第 pos 个位置插入(位置从 0 开始计数),返回值 bool 表示插入是否成功
bool insert_at_pos ( Node * head , int pos , int value ) {
Node * p = head ;
int i = 0 ;
// 找到第 pos-1 个结点
while ( p && i < pos - 1 ) {
p = p -> next ;
i ++ ;
}
// 如果该位置不存在的话,返回 false
if ( ! p || i > pos - 1 ) return false ;
insert_value_after ( p , value );
return true ;
} 删除 假设指针 p 指向 链表 中的某个 结点 ,如果我们想删除 p 的下一个 结点 的话,可以通过如下代码:
void delete_node_after ( Node * p ) {
Node * q = p -> next ;
// 需要判断 q 是否存在
if ( q ) {
// 设置 p 的下一个结点跳过 q
p -> next = q -> next ;
// 释放 q 的空间
free ( q );
}
}
如果我们想删除 单链表 中的第 pos 个 结点 的话,可以通过如下代码:
// 返回 true 表示删除成功,返回 false 表示删除失败。
bool delete ( Node * head , int pos ) {
Node * p = head ;
int i = 0 ;
while ( p -> next && i < pos - 1 ) { // 找到第 pos-1 个结点
p = p -> next ;
i ++ ;
}
// 如果 p 是链表中最后一个结点,或者 pos-1 的结点不存在的话,返回 false
if ( ! ( p -> next ) || i > pos - 1 ) return false ;
// 删除下一个结点
Node * q = p -> next ;
p -> next = q -> next ;
free ( q );
return true ;
}
查找 如果要查找 链表 中是否有 结点 存储有 value 的值的话,可以通过如下函数:
// 返回 0 表示未找到
int Find ( Node * head , int value ) {
Node * p = head -> next ;
int i = 1 ;
// 遍历链表判断是否有结点值域 value 相同
while ( p ) {
if ( p -> data == value ) return i ;
p = p -> next ;
i ++ ;
}
return 0 ;
}
清空 清空 链表 需要删除 链表 中的每一个 结点 ,并且将 链表 设置为空,可以通过如下函数:
void ClearList ( Node * head ) {
Node * p = head -> next , * q ;
head -> next = NULL ;
// 遍历每个结点,并且 free 结点
while ( p ) {
q = p -> next ; // 暂存下一个结点
free ( p );
p = q ;
}
}
其他链式实现 双向链表 在 双向链表 中,每个节点包含三个部分:数据 、前驱指针 prev、后继指针 next。
typedef struct DNode {
ElementType data ;
struct DNode * prev ; // 指向前驱节点
struct DNode * next ; // 指向后继节点
} DNode ;
双向链表 主要具备以下优势:
可以 双向遍历 ,支持从任意节点向前或向后查找。 插入和删除 某个节点时,不需要再查找其前一个节点(与单链表相比更方便)。插入操作
假设插入 s 到 p 前:
原来:
... ⟷ pre ⟷ p ⟷ ...
插入后:
... ⟷ pre ⟷ s ⟷ p ⟷ ...
具体操作步骤如下:
// 假设 p 是链表中的某个节点,s 是新建节点
s -> prev = p -> prev ; // 步骤①:新节点 s 的前驱是 p 的前驱
s -> next = p ; // 步骤②:新节点 s 的后继是 p
p -> prev -> next = s ; // 步骤③:p 原前驱节点的 next 改为 s
p -> prev = s ; // 步骤④:p 的前驱改为 s
删除操作
假设删除节点 p:
... ⟷ prev ⟷ p ⟷ next ⟷ ...
↓ 删除 p
... ⟷ prev ⟷ next ⟷ ...
具体操作步骤如下:
p -> prev -> next = p -> next ; // 步骤①:前驱的 next 指向 p 的后继
p -> next -> prev = p -> prev ; // 步骤②:后继的 prev 指向 p 的前驱
free ( p ); // 步骤③:释放 p 节点
静态链表 静态链表 实际上就是用 顺序表 (一个结构体数组)来模拟 链表 。结构体中包含两个元素:data 和 next,其中 data 存储数据,next 存储下一个元素的下标(相当于指针的作用):
#define MAXSIZE 100
typedef struct {
ElementType data ;
int next ;
} SNode ;
SNode list [ MAXSIZE ];
静态链表 使用 next == -1 作为其结束的标志。静态链表 的各种操作和 动态链表 基本一致,只需要修改指针,不需要移动元素。
循环链表 循环链表 (Circular Linked List)是一种特殊的 链表 结构,其 最后一个节点的指针指向头节点 ,使得整个 链表 形成一个 环状结构 。因此,从任何一个节点开始遍历,只要不断沿着指针走,就一定会回到起点。
循环链表 可分为两种类型:
类型 说明 单向循环链表 每个节点只有一个 next 指针,最后一个节点的 next 指向头节点 双向循环链表 每个节点有 prev 和 next,首尾相连,前后都可以循环遍历
3 - 线性数据结构 本章以选择题形式考察,需要熟练掌握栈和队列的操作以及应用,另外还需要了解如何用数组实现栈和队列,可能会在代码题中考察。
学习思维导图:
# 栈、队列和数组
## 栈和队列基本概念
## 栈和队列的顺序存储结构
## 栈和队列的链式存储结构
## 多维数组的存储
## 特殊矩阵的压缩矩阵
## 栈、队列和数据的应用
3.1 - 栈 关于栈直接考查其定义和概念比较少,更多的是考查应用 ,但是这一节还是 作为基础 ,需要熟悉下。
定义 栈 是一个元素的集合,加入元素的操作叫做 “压栈”(push) ,而移除元素的操作叫做 “出栈”(pop) 。它遵循 后进先出(LIFO, Last In First Out) 的原则。这意味着最后被压入栈的元素是第一个被弹出的元素 。
基本操作 push(element): 将元素添加到 栈 的顶部。pop(): 移除并返回 栈顶 的元素。如果 栈 为空,这个操作可能会抛出一个错误或返回特定的值(例如 null 或 undefined),具体取决于实现。peek() 或 top(): 返回 栈顶 的元素但不移除它。这只是一个查看操作,栈 的内容不会改变。如果 栈 为空,这个操作可能会抛出一个错误或返回特定的值。isEmpty(): 判断 栈 是否为空。如果 栈 为空,返回 true ;否则返回 false 。size() 或 length(): 返回 栈 中元素的数量。实现 栈 的实现方式有两种,顺序栈 和 链式栈 。顺序栈 是在 顺序表 的基础上实现 栈结构 ,链式栈 是在 链表 的基础上实现 栈结构 。
顺序栈 顺序栈 是使用 数组 来实现的 栈 ,利用数组的索引来模拟 栈 的操作。 与普通的线性表不同,栈 的操作被限制在表的一端进行,这一端被称为 栈顶 (Top) ,另一端被称为 栈底 (Bottom) 。
顺序栈 的关键在于 栈顶指针 ,栈顶指针 是一个整数变量,用于指示 栈顶 元素在 数组 中的位置。其值的变化直接反映了栈中元素的变化。
当 栈空 时,一般将 栈顶指针 设置为 1 ,当 栈满 时,栈顶指针 指向 数组 中的最后一个元素。
当元素 入栈 时,将栈顶指针 向后 移动一个位置、然后放置新元素即可。当元素 出栈 时,需要将栈顶指针 向前 移动一个位置。
当然,入栈需要保证栈不满,出栈需要保证栈不空。
栈的定义
初始化
入栈
出栈
获取栈顶元素
判断栈空 #define MAX_SIZE 100 // 定义栈的最大容量
// 定义顺序栈的结构
typedef struct {
int data [ MAX_SIZE ]; // 使用数组存储数据
int top ; // 栈顶指针
} SeqStack ; // 初始化栈
SeqStack * initStack () {
SeqStack * stack = ( SeqStack * ) malloc ( sizeof ( SeqStack ));
if ( ! stack ) {
printf ( "Failed to allocate memory for stack \n " );
exit ( 1 );
}
// 栈顶指针初始化为 -1,表示栈为空
stack -> top = - 1 ;
return stack ;
} // 入栈操作
bool push ( SeqStack * stack , int value ) {
if ( isFull ( stack )) {
printf ( "Stack is full! \n " );
return false ;
}
// 先移动栈顶指针,再存放元素
stack -> data [ ++ stack -> top ] = value ;
return true ;
} // 出栈操作
bool pop ( SeqStack * stack , int * value ) {
if ( isEmpty ( stack )) {
printf ( "Stack is empty! \n " );
return false ;
}
// 先取出元素,再移动栈顶指针
* value = stack -> data [ stack -> top -- ];
return true ;
} // 获取栈顶元素
bool peek ( SeqStack * stack , int * value ) {
if ( isEmpty ( stack )) {
printf ( "Stack is empty! \n " );
return false ;
}
* value = stack -> data [ stack -> top ];
return true ;
} // 判断栈是否为空
// 当栈中没有元素时,栈顶指针通常指向一个特殊的位置,例如 -1。
bool isEmpty ( SeqStack * stack ) {
return stack -> top == - 1 ;
}
// 判断栈是否已满
// 当栈顶指针指向数组中的最后一个元素时,说明栈已经满了
bool isFull ( SeqStack * stack ) {
return stack -> top == MAX_SIZE - 1 ;
} 链式栈 栈的 链式存储结构 利用 单链表 来实现栈的功能。
在 链式栈 实现中,一般 头结点不存放数据 ,仅作为链表的固定起点。栈顶元素始终为 head->next。这样依然可以保证 入栈 和 出栈 的时间复杂度为
O ( 1 )
。
链式栈具备以下 重要特性 :
head 始终存在,不存储实际数据,只作为哨兵节点。栈顶元素 始终位于 head->next。空栈 时,head->next == NULL。入栈时在 head->next 前插入新节点;出栈时删除 head->next。
栈的定义
初始化
入栈
出栈
获取栈顶元素
判断栈空 // 定义链式栈的节点结构
typedef struct Node {
int data ;
struct Node * next ;
} Node ;
// 定义链式栈(带头结点)
typedef struct {
Node * head ; // 指向头结点
} LinkedStack ; // 初始化栈(带头结点)
LinkedStack * initStack () {
LinkedStack * stack = ( LinkedStack * ) malloc ( sizeof ( LinkedStack ));
if ( ! stack ) {
printf ( "Failed to allocate memory for stack \n " );
exit ( 1 );
}
stack -> head = ( Node * ) malloc ( sizeof ( Node )); // 创建头结点
if ( ! stack -> head ) {
printf ( "Failed to allocate memory for head node \n " );
exit ( 1 );
}
stack -> head -> next = NULL ; // 初始为空栈
return stack ;
} // 入栈操作(头插法)
void push ( LinkedStack * stack , int value ) {
Node * newNode = ( Node * ) malloc ( sizeof ( Node ));
if ( ! newNode ) {
printf ( "Failed to allocate memory for new node \n " );
exit ( 1 );
}
newNode -> data = value ;
newNode -> next = stack -> head -> next ;
stack -> head -> next = newNode ;
} // 出栈操作
bool pop ( LinkedStack * stack , int * value ) {
if ( isEmpty ( stack )) {
printf ( "Stack is empty! \n " );
return false ;
}
Node * topNode = stack -> head -> next ;
* value = topNode -> data ;
stack -> head -> next = topNode -> next ;
free ( topNode );
return true ;
} // 获取栈顶元素
bool peek ( LinkedStack * stack , int * value ) {
if ( isEmpty ( stack )) {
printf ( "Stack is empty! \n " );
return false ;
}
* value = stack -> head -> next -> data ;
return true ;
} // 判断栈是否为空
bool isEmpty ( LinkedStack * stack ) {
return stack -> head -> next == NULL ;
} 3.2 - 队列 直接考察不是很多,偶尔在选择题考察相关概念,另外要留意 循环队列 往年在解答题中考察过。
定义 队列是一种遵循 先入先出 (FIFO, First In First Out) 原则的线性数据结构。元素在 队尾 添加,在 队首 删除。
基本操作 enqueue(element): 将元素添加到 队列 的尾部。dequeue(): 从 队列 的头部移除并返回元素。如果 队列 为空,此操作可能会返回特定的值或引发错误。front() 或 peek(): 返回 队首 的元素但不移除它。isEmpty(): 判断 队列 是否为空。size(): 返回 队列 中元素的数量。实现 顺序队列 顺序队列通常是指使用固定大小的数组来存储 队列 中的元素。在顺序队列中,通常有两个指标:一个是 队头 (front),另一个是 队尾 (rear)。当插入(入队)或删除(出队)元素时,这两个指标会移动。
顺序队列有一个明显的问题:随着时间的推移,队列 中的元素可能向数组的末尾移动,即使 队列 并不满,也可能无法再插入新的元素,因为 队尾 已经达到了数组的末尾。这种现象称为 假溢出 。
循环队列 为了解决上述问题,可以使用 循环队列 (也称为环形队列)。循环队列 是顺序队列的一个变种,它把数组视为一个循环的结构。当 队尾 指标达到数组的最后一个位置并且还需要进一步移动时,它会回到数组的起始位置。
需要注意的是,在 循环队列 中需要牺牲一个存储单元以区分 队空 和 队满 的情况。
当 front == rear 时,队列为空 当 (rear + 1) % size == front 时,队列为满 #define MAX_SIZE 100
typedef struct {
int data [ MAX_SIZE ];
int front , rear ;
} CircularQueue ;
// 初始化队列
void initQueue ( CircularQueue * q ) {
q -> front = q -> rear = 0 ;
} // 入队操作
bool enqueue ( CircularQueue * q , int value ) {
if ( isFull ( q )) return false ;
q -> data [ q -> rear ] = value ;
q -> rear = ( q -> rear + 1 ) % MAX_SIZE ;
return true ;
} // 出队操作
bool dequeue ( CircularQueue * q , int * value ) {
if ( isEmpty ( q )) return false ;
* value = q -> data [ q -> front ];
q -> front = ( q -> front + 1 ) % MAX_SIZE ;
return true ;
} // 获取队首元素
bool front ( CircularQueue * q , int * value ) {
if ( isEmpty ( q )) return false ;
* value = q -> data [ q -> front ];
return true ;
}
// 获取队尾元素
bool rear ( CircularQueue * q , int * value ) {
if ( isEmpty ( q )) return false ;
* value = q -> data [( q -> rear - 1 + MAX_SIZE ) % MAX_SIZE ];
return true ;
} // 判断队列是否为空
bool isEmpty ( CircularQueue * q ) {
return q -> front == q -> rear ;
}
// 判断队列是否满
bool isFull ( CircularQueue * q ) {
return ( q -> rear + 1 ) % MAX_SIZE == q -> front ;
} 链式队列 链式队列 是使用 链表结构 来实现的 队列 。它充分利用了链表的动态性质,允许队列在运行时 动态增长或缩小 ,不存在顺序存储中需要预先分配空间的问题。
链式队列通常包含三个指针:
头结点(head) 始终存在 ,不存放有效数据,只是一个哨兵结点。主要作用:简化出队操作(避免删除第一个结点时单独处理)。 队头指针(front) 固定指向头结点 。注意:front 不直接指向第一个有效结点,而是 指向头结点 。 因此,真正的队头元素在 front->next。 队尾指针(rear) 始终指向最后一个有效结点。 如果队列为空,rear == front == head。 链式队列一般使用 单链表 来实现,入队和出队操作可以基于队头和队尾指针实现:
入队(enqueue) :在队尾插入新元素。由于维护了 尾指针 ,因此只需 O(1) 时间。出队(dequeue) :在队头删除元素。由于维护了 头指针 ,因此只需 O(1) 时间。这比用数组实现的队列在需要移动元素时效率更高。
// 结点定义
typedef struct QNode {
int data ; // 数据域
struct QNode * next ; // 指针域
} QNode ;
// 链式队列结构
typedef struct {
QNode * front ; // 队头指针 (指向头结点)
QNode * rear ; // 队尾指针 (指向队尾结点)
} LinkQueue ;
// 初始化队列(带头结点)
void InitQueue ( LinkQueue * Q ) {
QNode * head = ( QNode * ) malloc ( sizeof ( QNode )); // 申请头结点
head -> next = NULL ;
Q -> front = Q -> rear = head ; // front、rear 都指向头结点
} // 入队操作
void EnQueue ( LinkQueue * Q , int x ) {
QNode * node = ( QNode * ) malloc ( sizeof ( QNode ));
node -> data = x ;
node -> next = NULL ;
Q -> rear -> next = node ; // 新结点挂在队尾
Q -> rear = node ; // 更新队尾指针
} // 出队操作
int DeQueue ( LinkQueue * Q , int * x ) {
if ( IsEmpty ()) return 0 ; // 队空
QNode * p = Q -> front -> next ; // 队头第一个有效结点
* x = p -> data ;
Q -> front -> next = p -> next ; // 删除结点
if ( Q -> rear == p ) { // 如果队尾被删空了
Q -> rear = Q -> front ; // rear 重新指向头结点
}
free ( p );
return 1 ;
} // 判断队列是否为空
int IsEmpty ( LinkQueue Q ) {
return Q . front == Q . rear ;
} 3.3 - 栈和队列的应用 本节的几个知识点经常在选择题考察:中序表达式求值、中序转后序、后序表达式求值、入栈出栈序列。
二叉表达式树 二叉表达式树 是一种特殊的二叉树,用于表示算术或逻辑表达式。它的每个 叶子节点 通常存放 操作数 (operand,如数字或变量),每个 非叶子节点 存放 操作符 (operator,如 +, -, *, /)。
ExpressionTree n1 / n2 * n1->n2 n3 7 n1->n3 n4 + n2->n4 n5 - n2->n5 n6 3 n4->n6 n7 4 n4->n7 n8 5 n5->n8 n9 2 n5->n9 表达式种类 前序表达式(Preorder,波兰表示法)、中序表达式(Infix)和后序表达式(Postorder,逆波兰表示法)是根据二叉表达式树的不同遍历方式得到的表达式结果。具体来说:
这三种表达式的 操作符 和 操作数 间的相对位置有所不同:
表达式类型 描述 示例表达式 对应的公式 前序 操作符位于其操作数之前 * + A B C(A + B) * C中序 操作符位于其两个操作数之间,最常见的表示法 (A + B) * C(A + B) * C后序 操作符位于其操作数之后 A B + C *(A + B) * C
ExpressionTypes title 表达式种类 (Expression Types) tree_title 二叉表达式树 title->tree_title tree_example * / \ + C / \ A B tree_title->tree_example preorder 前序遍历 (Preorder) tree_example->preorder 前序遍历 inorder 中序遍历 (Inorder) tree_example->inorder 中序遍历 postorder 后序遍历 (Postorder) tree_example->postorder 后序遍历 prefix 前序表达式 (波兰表示法) 操作符在操作数之前 preorder->prefix 得到 infix 中序表达式 (普通表示法) 操作符在操作数之间 inorder->infix 得到 postfix 后序表达式 (逆波兰表示法) 操作符在操作数之后 postorder->postfix 得到 prefix_example * + A B C prefix->prefix_example prefix_features 特点: • 无需括号 • 从左到右求值 • 操作符优先 prefix->prefix_features infix_example (A + B) * C infix->infix_example infix_features 特点: • 需要括号 • 符合人类习惯 • 最常见形式 infix->infix_features postfix_example A B + C * postfix->postfix_example postfix_features 特点: • 无需括号 • 便于计算机处理 • 栈式求值 postfix->postfix_features prefix_app 应用: • 函数式编程 • LISP语言 prefix_features->prefix_app infix_app 应用: • 数学公式 • 编程语言 infix_features->infix_app postfix_app 应用: • 计算器 • 编译器 • 虚拟机 postfix_features->postfix_app legend 图例说明: ━━ 遍历关系 ┅┅ 特点说明 ⋯⋯ 应用场景 中序表达式求值 中序表达式求值涉及到两个主要的数据结构:操作符栈 与 操作数栈 。其核心思路是逐个读取中序表达式的字符,根据字符的类型(操作数 、操作符 、括号 )进行不同的处理,最终得到表达式的值。
初始化两个栈 :操作数栈 :用来存储数字。操作符栈 :用来存储操作符和左括号。逐个读取中序表达式的字符 :如果是 操作数 (数字),则直接压入 操作数栈 。 如果是 左括号 ,则直接压入 操作符栈 。 如果是 右括号 ,则:从 操作符栈 中弹出一个操作符。 从 操作数栈 中弹出所需的操作数。 执行该操作符对应的运算。 将结果压入 操作数栈 。 重复以上步骤,直到从 操作符栈 中弹出一个左括号。 如果是操作符 (如+、-、*、/等):如果 操作符栈 为空,或栈顶是左括号,或当前操作符 优先级 高于栈顶操作符的 优先级 ,则直接压入 操作符栈 。 否则:从 操作符栈 中弹出一个操作符。 从 操作数栈 中弹出所需的操作数。 执行该操作符对应的运算。 将结果压入 操作数栈 。 重复以上步骤,直到满足上述的压栈条件。 处理完所有字符后 :如果 操作符栈 不为空,则:从 操作符栈 中弹出一个操作符。 从 操作数栈 中弹出所需的操作数。 执行该操作符对应的运算。 将结果压入 操作数栈 。 重复以上步骤,直到 操作符栈 为空。 得到结果 :此时,操作数栈 中仅存一个数字,这就是整个中序表达式的值。 中序表达式求值的过程可以参照以下流程图进行理解:
InfixEvaluation start 开始 init 初始化两个栈: 操作数栈 (存储数字) 操作符栈 (存储操作符和左括号) start->init read_char 读取下一个字符 init->read_char char_type 判断字符类型 read_char->char_type operand 操作数 (数字) 直接压入操作数栈 char_type->operand 数字 left_paren 左括号 '(' 直接压入操作符栈 char_type->left_paren 左括号 right_paren 右括号 ')' char_type->right_paren 右括号 operator 操作符 (+, -, *, /) char_type->operator 操作符 more_chars 还有字符? operand->more_chars left_paren->more_chars right_paren_process 从操作符栈弹出操作符 从操作数栈弹出操作数 执行运算 结果压入操作数栈 right_paren->right_paren_process right_paren_check 栈顶是左括号? right_paren_process->right_paren_check right_paren_check->right_paren_process 否 pop_left_paren 弹出左括号 right_paren_check->pop_left_paren 是 pop_left_paren->more_chars op_condition 操作符栈为空 OR 栈顶是左括号 OR 当前优先级 > 栈顶优先级? operator->op_condition push_operator 压入操作符栈 op_condition->push_operator 是 pop_and_calc 从操作符栈弹出操作符 从操作数栈弹出操作数 执行运算 结果压入操作数栈 op_condition->pop_and_calc 否 push_operator->more_chars pop_and_calc->op_condition more_chars->read_char 是 final_check 操作符栈为空? more_chars->final_check 否 final_calc 从操作符栈弹出操作符 从操作数栈弹出操作数 执行运算 结果压入操作数栈 final_check->final_calc 否 result 操作数栈中的唯一数字 就是表达式的值 final_check->result 是 final_calc->final_check end 结束 result->end priority 操作符优先级: * / 高优先级 + - 低优先级 ( ) 括号优先级最高 以表达式 3 + (5 * 2 - 8) 说明计算过程:
步骤 输入字符 操作 操作符栈 数据栈 1 3压入数据栈 32 +压入 操作符栈 +33 (压入 操作符栈 +, (34 5压入数据栈 +, (3, 55 *压入 操作符栈 +, (, *3, 56 2压入数据栈 +, (, *3, 5, 27 -* 优先级 高于 -,* 出栈计算后,将 - 入栈+, (, -3, 108 8压入数据栈 +, (, -3, 10, 89 )处理到 ( 之前的所有操作符 +3, 210 用 + 运算符计算 5
后序表达式求值 后序表达式求值主要依赖一个 操作数栈 。其核心思路是逐个读取后序表达式的元素,并根据元素的类型(操作数 或操作符 )进行处理。
初始化一个 操作数栈 :逐个读取后序表达式的元素 :如果是操作数 ,则直接压入 操作数栈 。 如果是操作符 (如+、-、*、/等):从 操作数栈 中弹出所需的操作数。注意,因为操作符是后置的,所以先弹出的数字是第二操作数,后弹出的是第一操作数。 根据操作符执行相应的运算。 将计算结果压入 操作数栈 。 完成后序表达式的读取 :后序表达式求值的过程可以参照以下流程图进行理解:
flowchart TD
A[开始: 初始化操作数栈] --> B[读取后序表达式的下一个元素]
B --> C{是否还有元素?}
C -->|否| H[栈顶元素即为最终结果]
H --> I[结束]
C -->|是| D{当前元素类型?}
D -->|操作数| E[将操作数压入栈中]
E --> B
D -->|操作符| F[从栈中弹出两个操作数<br/>注意: 先弹出的是第二操作数<br/>后弹出的是第一操作数]
F --> G[执行运算并将结果压入栈中]
G --> B
style A fill:#e1f5fe
style I fill:#c8e6c9
style D fill:#fff3e0
style E fill:#f3e5f5
style F fill:#ffebee
style G fill:#ffebee
style H fill:#e8f5e8 以表达式 3 5 2 * 8 - + 说明计算过程:
步骤 输入字符 操作 操作数栈 1 3压入 操作数栈 32 5压入 操作数栈 3, 53 2压入 操作数栈 3, 5, 24 *弹出两个操作数并相乘,结果压入 操作数栈 3, 105 8压入 操作数栈 3, 10, 86 -弹出两个操作数并相减,结果压入 操作数栈 3, 27 +弹出两个操作数并相加,结果压入 操作数栈 5
中序转后序 将中缀表达式转换为后缀表达式的算法思想如下:
初始化一个 操作符栈 ,从左向右开始扫描中缀表达式;
遇到数字时,加入后缀表达式; 遇到运算符时:若为 (,入栈; 若为 ),则依次把栈中的运算符加入后缀表达式中,直到出现 (,从栈中删除 (; 若为除括号外的其他运算符当其 优先级 高于除 ( 以外的栈顶运算符时,直接入栈; 否则从栈顶开始,依次弹出 优先级 高于或等于当前运算符的运算符,直到遇到 优先级 更低的运算符或左括号为止。 中序转后序的过程可以参照以下流程图进行理解:
flowchart TD
A[开始:初始化操作符栈] --> B[从左向右扫描中缀表达式]
B --> C{读取下一个字符}
C -->|数字| D[直接添加到后缀表达式]
C -->|左括号| E[直接入栈]
C -->|右括号| F[弹出栈中运算符到后缀表达式]
C -->|运算符| G{当前运算符优先级 > 栈顶运算符优先级?}
D --> H{是否扫描完成?}
E --> H
F --> I{栈顶是否为左括号?}
I -->|否| F
I -->|是| J[弹出左括号但不加入后缀表达式]
J --> H
G -->|是| K[直接入栈]
G -->|否| L[弹出栈顶运算符到后缀表达式]
L --> M{栈空或栈顶为左括号或优先级更低?}
M -->|否| L
M -->|是| N[当前运算符入栈]
K --> H
N --> H
H -->|否| C
H -->|是| O[弹出栈中所有剩余运算符]
O --> P[结束:得到后缀表达式]
style A fill:#e1f5fe,font-size:24px
style P fill:#c8e6c9,font-size:24px
style D fill:#fff3e0,font-size:24px
style E fill:#fce4ec,font-size:24px
style F fill:#fce4ec,font-size:24px
style G fill:#f3e5f5,font-size:24px
classDef default font-size:18px
classDef largeText font-size:24px
class A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P largeText
linkStyle default font-size:22px 以中序表达式 a/b+(c*d-e*f)/g 为例,可以通过如下步骤将其转换为后续表达式:
待处理序列 栈 后缀表达式 当前扫描元素 动作 a/b+(c*d-e*f)/gaa 加入后缀表达式/b+(c*d-e*t)/ga// 入栈b+(c*d-e*f)/g/abb 加入后缀表达式+(c*d-e*f)/g/ab++ 优先级 低于栈顶的 /,弹出 /+(c*d-e*f)/gab/++ 入栈(c*d-e*f)/g+ab/(( 入栈c*d-e*f)/g+(ab/cc 加入后缀表达式*d-e*f)/g+(ab/c*栈顶为 (,* 入栈 d-e*t)/g+(*ab/cdd 加入后缀表达式-e*f)/g+(*ab/cd-- 先级低于栈顶的 *,弹出 *-e*f)/g+(ab/cd*-栈顶为 (,- 入栈 e*t)/g+(-ab/cd*ee 加入后缀表达式*f)/g+(-ab/cd*e** 优先级 高于栈顶的 -, * 入栈f)/g+(-*ab/cd*eff 加入后缀表达式)/g+(-*ab/cd*ef)把栈中 ( 之前的符号加入表达式 /g+ab/cd*ef*-// 优先级 高于栈顶的 +,/ 入栈g+/ab/cd*ef*-gg 加入后缀表达式+/ab/cd*ef*-g扫描完毕,运算符依次退栈加入表达式 ab/cd*ef*-g/+完成
入栈出栈序列 入栈出栈是常考的问题,这类问题的核心可以被总结为以下两种范式:
给定一个入栈序列,判断哪些出栈序列是不可能的? 给定一个入栈序列,其出栈序列序列有多少种? 这里给出这两个常见问题的解答。
不可能的出栈序列 给定一个入栈序列,其出栈序列可能有很多种,因为一个元素的出栈时间比较灵活,比如它可以一入栈马上出栈,也可以等一会再出栈。要辨别不可能的出栈系列,需要 抓住关键规则 :只有 栈顶元素 能够最先出栈。
也就是说,对于下图这种情况,A 被 B 压住了,在出栈序列中,A 一定在 B 的后面。如果某个出栈序列中 A 在 B 的前面,则这种出栈序列是不可能出现的。
卡特兰数 考虑这样一个问题:有
n
个不同的元素(例如
1 , 2 , ⋯ , n
),我们按照某种顺序依次将它们 入栈 ,并且在任意时刻可以选择 出栈 。每个元素都必须恰好入栈一次、出栈一次。我们想知道,所有可能的出栈序列共有多少种。
这个问题的答案可以用 卡特兰数(Catalan number) 来表示。卡特兰数的公式如下:
C n = n + 1 1 ( n 2 n )
也就是说,上述问题中 \(n\) 个元素的所有可能出栈序列数量就是 \(C_n\)。
那么为什么入栈出栈序列总数可以用卡特兰数来表示呢?
我们可以把每一次操作用数字表示:
整个过程总共有 \(2n\) 次操作,其中有 \(n\) 次入栈,\(n\) 次出栈。为了保证操作合法(不能在栈为空时出栈),必须满足:在任意时刻,出栈次数 不能超过 入栈次数。
这恰好 与卡特兰数的经典定义相对应 :卡特兰数计数的就是符合类似限制的序列数量。因此,入栈出栈序列数量正好是 \(C_n\)。
卡特兰数不仅出现在入栈出栈序列中,还常 出现在很多等价问题中 ,例如:
二叉树计数
给定 \(n\) 个不同节点,可以构造的不同形态二叉树的数量为 \(C_n\)。
括号匹配
\(n\) 对括号的合法匹配序列数量也为 \(C_n\)。
例如,当 \(n=2\) 时,合法的序列有:
()() (())
3.4 - 数组和特殊矩阵 在选择题偶尔会考察,多维数组的存储是 和 矩阵的几种压缩存储方式 要了解一下。
多维数组的存储 在计算机内存中,数组元素是 连续存放 的。对于一个二维数组来说,它实际上只是对一维数组的一种逻辑抽象。理解其存储方式的关键在于:如何将二维坐标 \((i, j)\) 映射到一维的线性内存地址 。
假设:
数组的第一个元素起始地址为 \(A\); 每个元素占用 \(B\) 个字节; 数组一共有 \(R\) 行、\(C\) 列; 那么数组中元素 \(a[i][j]\) 的存储地址为:
Addr ( a [ i ] [ j ]) = A + ( i × C + j ) × B
其中:
\(i \times C\) 表示从第 0 行到第 \(i-1\) 行总共有多少个元素; 再加上 \(j\),就得到了在一维展开后对应的下标; 乘以 \(B\) 后,加上基址 \(A\),就得到了该元素在内存中的实际地址。 举个 C 语言的示例进行说明,如果我们定义一个二维数组:
int a [ 2 ][ 3 ] = {{ 1 , 2 , 3 }, { 4 , 5 , 6 }};
这个数组的内存布局可以视为一个一维数组,如下所示:
+---------+---------+---------+---------+---------+---------+
| a[0][0] | a[0][1] | a[0][2] | a[1][0] | a[1][1] | a[1][2] |
+---------+---------+---------+---------+---------+---------+
0x100 0x104 0x108 0x10C 0x110 0x114
行主序和列主序 需要注意的是,不同语言对多维数组的存储顺序可能不同:
C / C++ 等主流编程语言:采用 行主序(Row-major order) ,即先存满一行,再存下一行。Fortran / MATLAB :采用 列主序(Column-major order) ,即先存满一列,再存下一列。如果按列主序存储,上面例子 a[2][3] 的内存布局会变为:
+---------+---------+---------+---------+---------+---------+
| a[0][0] | a[1][0] | a[0][1] | a[1][1] | a[0][2] | a[1][2] |
+---------+---------+---------+---------+---------+---------+
特殊矩阵的压缩存储 对称矩阵 什么是 对称矩阵 :对于矩阵
A
中的任意一个元素
a i , j
都有
a i , j = a j , i
,
Produced by GNUPLOT 4.4 patchlevel 0 所以为了 节省存储空间 ,可以使用一位数组
B
进行存储
如何计算对称矩阵中元素的下标
A
中的元素
a i , j
在数组
B
的下标
k = 1 + 2 + ⋯ + ( i − 1 ) + j − 1 = i ( i − 1 ) /2 + j − 1
元素为
$$a_{i, j} = \begin{cases}
\frac{i(i-1)}{2}+j-1,\text{ } i \ge j \\
\frac{j(j-1)}{2}+i-1,\text{ } i \lt j
\end{cases}$$
三角矩阵
{\displaystyle \mathbf {L} ={\begin{bmatrix}l_{1,1}&&\cdots &&0\\l_{2,1}&l_{2,2}&&(0)&\\l_{3,1}&l_{3,2}&\ddots &&\vdots \\\vdots &\vdots &\ddots &\ddots &\\l_{n,1}&l_{n,2}&\ldots &l_{n,n-1}&l_{n,n}\end{bmatrix}}}
{\displaystyle \mathbf {U} ={\begin{bmatrix}u_{1,1}&u_{1,2}&u_{1,3}&\ldots &u_{1,n}\\&u_{2,2}&u_{2,3}&\ldots &u_{2,n}\\\vdots &&\ddots &\ddots &\vdots \\&(0)&&\ddots &u_{n-1,n}\\0&&\cdots &&u_{n,n}\end{bmatrix}}} 下三角矩阵
是一个方阵,其主对角线及其以下(右下部分)的所有元素都不为零,而主对角线以上的所有元素都为零。上三角矩阵
是一个方阵,其主对角线及其以上(左上部分)的所有元素都不为零,而主对角线以下的所有元素都为常数。三角矩阵下标计算
以上三角矩阵
A
为例,上半部分元素首先按序存储在一位数组
B
中,在最后一个位置添加一个元素,用于存储下三角位置对应的元素
A
中的元素
a i , j
在数组
B
的下标
k = n + ( n − 1 ) ⋯ + ( n − i + 2 ) + ( j − i + 1 ) − 1 = ( i − 1 ) ( 2 n − i + 2 ) /2 + ( j − i )
元素为
$$a_{i, j} = \begin{cases}
\frac{(i-1)(2n-i+2)}{2}+(j-i),\text{ } i \le j(\text{\small 上三角区和对角线元素}) \\
\frac{n(n+1)}{2},\text{ } i \gt j(\text{\small 下三角区元素})
\end{cases}$$
稀疏矩阵 稀疏矩阵 (Sparse Matrix)是指在矩阵中大部分元素为零的矩阵。与之相对的是 稠密矩阵 (Dense Matrix),即大部分元素非零。
A 6 × 7 = 0 0 3 0 0 0 0 2 0 0 0 0 1 0 0 0 0 0 0 0 0 5 0 0 0 0 0 0 6 0 0 0 0 0 0 7 0 0 0 0 0 4 由于 稀疏矩阵 的非零元素远少于零元素,存储整个矩阵(包括所有零元素)会浪费大量空间。因此,稀疏矩阵通常使用特定的数据结构(三元组表 和 十字链表 )来高效存储和操作,只保存非零元素及其位置信息。
三元组表 三元组表 (Triple Table) 是一种稀疏矩阵的顺序存储方法。它利用三个一维数组(或一个结构体数组)分别存放非零元素的 行号 、列号 和 数值 ,从而节省内存空间。
对于一个 \(m \times n\) 的稀疏矩阵 \(A\),若其中只有 \(t\) 个非零元素,则三元组表的长度就是 \(t\)。存储时,一般约定按照 行序优先 (先按行号从小到大排序,行号相同时再按列号从小到大)存放,便于后续矩阵运算和查找。
在程序实现时,常见的两种方式是:
分开存储法 用三个等长的一维数组 row[]、col[] 和 val[] 分别保存行号、列号和数值。结构体存储法 定义一个结构体 Triple,包含 (row, col, value) 三个字段,再用一个一维数组 Triple data[t] 来存储。#define MAXSIZE 100 // 最大非零元素个数
// 稀疏矩阵三元组表(分开存储)
typedef struct {
int m , n , t ; // 矩阵的行数、列数、非零元素个数
int row [ MAXSIZE ]; // 行号数组
int col [ MAXSIZE ]; // 列号数组
int val [ MAXSIZE ]; // 数值数组
} TSMatrix ; #define MAXSIZE 100 // 最大非零元素个数
// 三元组
typedef struct {
int row , col ; // 行号、列号
int val ; // 数值
} Triple ;
// 稀疏矩阵三元组表(结构体存储)
typedef struct {
int m , n , t ; // 矩阵的行数、列数、非零元素个数
Triple data [ MAXSIZE ]; // 非零元素数组
} TSMatrix ; 三元组表的优点是 存储结构简单、节省空间 ,但缺点是 随机访问代价较高 —— 若要访问某个元素,需要顺序扫描查找对应行列下标,适合用于矩阵转置、稀疏矩阵相加、输出等操作。
在实际应用中,如果需要频繁地进行按行、按列的运算,就会引入 十字链表 等更复杂的存储方式。
十字链表 十字链表 (Cross List) 是一种用于稀疏矩阵的存储结构,它的核心思想是利用行方向和列方向两个链表 同时组织非零元素,从而使得既能按行遍历,也能按列遍历,访问效率都较高。相比于普通的顺序存储或单一链表结构,十字链表在需要频繁进行矩阵运算、转置或按行按列混合访问的场景下具有明显优势。
下图给出了一个十字链表的实例:
在十字链表中,每一个非零元素都会建立一个结点。该结点需要记录行号(i)、列号(j)、元素值(value) ,同时还要保存两个方向的指针:right 指针 指向同一行中的下一个非零元素,down 指针 指向同一列中的下一个非零元素。借助这两个指针,矩阵可以在行链和列链之间灵活切换,实现双向高效的访问。
为了快速定位某一行或某一列的首个非零元素,还需要额外设置行头指针数组(rhead)和 列头指针数组(chead) 。每一行和每一列都对应一个头指针,这些头指针并不存储具体数值,而是起到索引的作用,使得从某一行或某一列开始的遍历可以在常数时间内定位到第一个非零元素。
// 十字链表结点定义
typedef struct CrossListNode {
int row ; // 行号
int col ; // 列号
int value ; // 元素值
struct CrossListNode * right ; // 指向同一行下一个非零元素
struct CrossListNode * down ; // 指向同一列下一个非零元素
} CrossListNode ; // 十字链表结构
typedef struct {
CrossListNode ** rhead ; // 行头指针数组
CrossListNode ** chead ; // 列头指针数组
int rows ; // 矩阵行数
int cols ; // 矩阵列数
int nums ; // 非零元素个数
} CrossList ; 十字链表的优势主要体现在三个方面:其一,空间效率高 ,仅存储非零元素及必要的指针;其二,支持按行或按列的双向遍历 ,在算法实现中非常灵活;其三,插入与删除操作方便 ,只需在相应行和列的链表中调整指针即可,而不必像顺序存储那样整体移动元素。因此,十字链表常被用于稀疏矩阵运算 和图的邻接矩阵存储 ,是数据结构与图论中重要的一种存储方式。
三对角矩阵 三对角矩阵 是一种特殊的稀疏矩阵,它除了主对角线外,只在其上下相邻的两条对角线上有非零元素,其余元素均为零。
$$\begin{vmatrix}
a_{1,1} & a_{1,2} & & & & \\
a_{2,1} & a_{2,2} & a_{2,3} & & & \\
& a_{3,2} & a_{3,3} & a_{3,4} & & \\
& & \ddots & \ddots & \ddots & \\
& & & a_{n-1,n-2} & a_{n-1,n-1} & a_{n-1,n}\\
& & & & a_{n,n-1} & a_{n,n}
\end{vmatrix}$$
由于仅有三条对角线是非零的,我们可以只存储这三条线来 节省空间 (从
n 2
降为 3 n − 2
个数字)。
一种常用存储方式是使用三个长度为 n 的数组:
a[2...n]:下对角线 (从第 2 行开始有值)b[1...n]:主对角线 c[1...n-1]:上对角线 (到第 n-1 行)4 - 字符串 本章可能在选择题中出现,掌握 KMP 算法的思想,能够手工模拟 KMP 过程即可。
4.1 - 定义和实现 基本不会不会直接考查,了解下字符串的基于栈和堆的 存储结构 就可以。
串的定义 字符串是计算机科学中的一个基本概念,通常被定义为 有限序列 中 字符 的有序集合。
形式定义:
字符串
S
是 字符集
Σ
上的一个 有限序列 :
S = a 1 a 2 ⋯ a n
,其中
a i ∈ Σ
,且 长度
n
为字符串的长度, 当 n = 0
时,称为 空字符串 。
串的存储结构 定长顺序存储表示 在 定长顺序存储表示 中,每个字符串都有固定的长度,通常用一个预定义的最大大小来表示。这种表示方法使用一个一维数组,数组的大小等于预定的最大长度。
在某些语境中(例如 C 语言),使用特殊字符(如 '\0')来标识字符串结束。
#define MAX_SIZE 100 // 预定义的最大大小
typedef struct {
char data [ MAX_SIZE ]; // 字符数组
int length ; // 当前字符串的长度
} FixedString ;
堆分配存储表示 在 堆分配存储表示 中,每个字符串具有其自己的 长度 ,因此可以动态地分配存储空间。字符串在堆中分配空间,只需要那么多的空间来存储实际的字符以及结束字符或 长度 信息。
typedef struct {
char * data ; // 指向动态分配空间的指针
int length ; // 字符串的长度
} HeapString ;
// 创建一个新的字符串
void initHeapString ( HeapString * s , const char * str ) {
s -> length = strlen ( str );
s -> data = ( char * ) malloc (( s -> length + 1 ) * sizeof ( char ));
if ( s -> data == NULL ) {
exit ( 1 ); // 分配失败
}
strcpy ( s -> data , str );
}
// 释放字符串
void freeHeapString ( HeapString * s ) {
free ( s -> data );
s -> length = 0 ;
}
块链存储表示 块链存储表示 是结合了 链表 与 块 存储的思想。串分为较小的 块 ,每个块存储一部分 字符 。块之间使用 指针 链接。这样,整个字符串就变成了一个字符块的链表。
#define BLOCK_SIZE 4 // 为了简单起见,设块大小为 4
typedef struct StringBlock {
char data [ BLOCK_SIZE ]; // 块中的字符数据
struct StringBlock * next ; // 指向下一个块的指针
} StringBlock ;
typedef struct {
StringBlock * head ; // 指向第一个块的指针
int length ; // 字符串的总长度
} BlockString ;
// 初始化块链字符串
void initBlockString ( BlockString * s ) {
s -> head = NULL ;
s -> length = 0 ;
}
// 为了简化,这里只展示了数据结构定义和初始化函数。实际应用中还需要考虑其他函数,如插入、删除、销毁等。
串的基本操作 赋值 (Assign):比较 (Compare):根据字符的字典顺序比较两个串。可以判断两个串是否相等,或者一个串是否小于或大于另一个串。 长度 (Length):连接 (Concat):将两个串连接成一个新的串。例如,将串"Hello"和串"World"连接为"HelloWorld"。 子串提取 (Substr):从串中提取某个指定位置开始的指定 长度 的字符序列作为一个新的串。例如,从串"HelloWorld"中提取从位置 1 开始的 5 个字符,得到"Hello"。 插入 (Insert):在串的指定位置插入另一个串。例如,在串"HelloWorld"的第 6 个位置插入串"Beautiful", 得到"HelloBeautifulWorld"。 删除 (Delete):替换 (Subsititude):将串中某个子串的所有出现替换为另一个子串。例如,将串"apple, banana, apple"中的"apple"替换为"orange",得到"orange, banana, orange"。 模式匹配 (Index):在串中查找另一个子串的位置。这通常使用算法如 KMP、BM 或 Sunday 来加速查找。 清空 (Clear):4.2 - 模式匹配 这一节要考的话其实就是 kmp 算法,会考察下 next 数组的构建 和 调整位置的方式 。虽然历年考查得其实也不是很多,但是这种涉及算法的还是得多留个心眼,因为有可能在今年的大题就考查了。
字符串模式匹配 是计算机科学中的基础问题,主要是在一个 主字符串 中查找一个 子字符串 模式。
串概念 模式匹配算法核心概念 主串 (Str): 待搜索的完整字符串 A 0 B 1 A 2 B 3 C 4 A 5 B 6 D 7 模式串 (Pattern): 要在主串中查找的目标字符串 C A B 匹配结果: A 0 B 1 A 2 B 3 C 4 A 5 B 6 D 7 匹配位置: 4 核心概念: ■ 主串 (Str) 完整的待搜索字符串 示例: "ABABCABD" ■ 子串 (Substr) 主串中的任意连续片段 示例: "CAB" (位置4-6) ■ 模式串 (Pattern) 要查找的目标字符串 示例: "CAB" 匹配术语: ✓ 匹配 找到与模式串完全相同的子串 ⚡ 匹配位置 模式串在主串中的起始索引 本例中匹配位置为 4 ✗ 失配 当前比较的字符不相同 算法目标: 在主串中找到所有与模式串完全匹配的子串, 并返回它们的起始位置。 这就是模式匹配算法要解决的核心问题。 在介绍模式匹配算法之前,需要能够区分关于串的几个概念:
主串 (Str / 主字符串):待搜索的那一整段字符串。子串 (Substr / 子字符串):主串中的某一段字符串模式串 (Pattern / 模式字符串):要在主串中查找的目标。以及以下几个关于 模式匹配 的术语:
匹配 :在主串中找到与模式串完全相同的子串匹配位置 :模式串在主串中出现的起始位置失配 :当前字符不匹配模式匹配算法 就是将 模式串 与 主串 相匹配,企图找到主串中的某个和 子串 和 模式串 完全相同。
简单模式匹配算法 简单模式匹配算法 (即暴力匹配 / 朴素匹配)的 基本思想 是:逐个检查 主串 的每一个位置,判断 从该位置开始的子串 是否与 模式串 完全相同。
算法描述:
从 主串 的第一个字符开始,尝试与 模式串 匹配。 如果当前字符匹配,则继续比较下一个字符,依此类推。 如果在某一位置发生不匹配,则将 模式串 移动到 主串 的下一个起始位置,重新匹配。 如果某一段 子串 与 模式串 完全相同,则返回该 子串在主串中的起始位置 。 如果扫描完整个 主串 都没有找到匹配的 子串 ,则返回 -1。 int simplePatternMatching ( const char * mainStr , const char * pattern ) {
int m = strlen ( mainStr );
int n = strlen ( pattern );
// 如果主字符串的长度小于模式字符串的长度,直接返回 -1
if ( m < n ) return - 1 ;
for ( int i = 0 ; i <= m - n ; i ++ ) {
int j ;
for ( j = 0 ; j < n ; j ++ ) {
if ( mainStr [ i + j ] != pattern [ j ]) {
break ;
}
}
// 如果 j 等于模式串的长度,说明已经找到匹配
if ( j == n ) return i ;
}
return - 1 ; // 没有找到匹配
}
简单模式匹配算法的 核心思路 其实就是将主串中的每个字串和模式串进行对比。举个例子,主串 abaaabc 和模式串 abc 进行简单模式匹配的过程如下:
这个简单模式匹配算法的 时间复杂度 是
O ( mn )
,其中
m
是 主字符串 的长度,
n
是 模式字符串 的长度。
KMP 算法 与 简单模式匹配算法 不同, KMP 算法 在发现不匹配的字符时能够避免不必要的比较,从而提高效率。
前后缀 简单模式匹配 时间复杂度过高,因为每一次匹配失败都得从下一个位置重新开始。这就没有充分应用 模式串 的特性,比如对于字符串 abcdabc,我们可以发现:
其 前缀 包含a, ab, abc, abcd 等 起 后缀 包含c, bc, abc, dabc 等 abc 是在其 前缀 和 后缀 中都包含的部分,在字符串匹配时,我们可以充分利用该信息,匹配失败时,我们可以不用从下一个位置开始,而是从 模式串内部的某个位置 开始。
前后缀概念图解 - 字符串 "abcdabc" a b c d a b c 0 1 2 3 4 5 6 前缀 (Prefix): 从字符串开头开始的子串 a "a" (长度1) a b "ab" (长度2) a b c "abc" (长度3) a b c d "abcd" (长度4) 后缀 (Suffix): 到字符串末尾结束的子串 c "c" (长度1) b c "bc" (长度2) a b c "abc" (长度3) d a b c "dabc" (长度4) 关键发现:相同的前后缀 a b c 前缀 "abc" a b c 后缀 "abc" 在 "abcdabc" 中,"abc" 既是前缀也是后缀 这就是 KMP 算法的核心思想! 当匹配失败时,我们可以利用这个信息跳过一些不必要的比较 KMP 算法 就是基于这个思想,其核心是一个称为 “部分匹配表 ” 或 “前缀函数 ” 的辅助数组(通常称为 next 数组 ),该数组用于确定当 模式串 在 主串 不匹配时应该如何有效地移动 模式串 。
算法描述 构建 部分匹配表 (next 数组 ):next[i] 表示:当 模式串 在第 i 位 匹配失败 时,下一次 应该跳到的模式串位置 。 不断进行 模式匹配 ,直到匹配成功或 主串 结束。 next 数组计算 如果 模式串 为 pattern,用通俗的话来说,next[k] 就是第 k 个字符的 前缀字符串 pattern[0:k-1](pattern 的前 k 个字符构成的子串,不包含当前字符)的最长相同 前缀 和 后缀 的长度。
以 ababac 字符串为例,可以得到以下的 next 数组:
模式串(pattern) a b a b a c 下标(index) 0 1 2 3 4 5 pattern[0:index] 的相同前后缀长度 0 0 1 2 3 0 next -1 0 0 1 2 3
一般而言,next 数组 中的第一个元素被设置为 -1(因为第一个字符不存在 前缀子串 )。
以 c 字符为例,我们需要计算 next[5],所以需要统计 c 的 前缀字符串 ababa 的最长相同 前缀 和 后缀 的长度,可以观察到,最长的相同 前缀 和 后缀 为 aba,长度为 3,所以 next[5] = 3。
使用 next 调整位置 用 i 表示 主串 当前下标,用 j 表示 模式串 当前下标:
如果 main[i] == pattern[j],将 i 和 j 向后向后移动一位。 如果 main[i] != pattern[j]:如果 j == 0,将 i 向后移动一位。 如果 j != 0,将 j 移动到 next[j]。 以下图为例,说明 主串 “ababcabcabababd” 和 模式串 “ababd” 的匹配过程。
在 i = 4, j = 4 时 匹配失败 , next[j] = next[4] = 2, 移动 j = 2
在 i = 4, j = 2 时 匹配失败 , next[j] = next[2] = 0, 移动 j = 0
在 i = 4, j = 0 时 匹配失败 , 移动 i++, i = 5, j = 0
在 i = 5,j = 0 时 匹配成功 , 移动 i++, j++ i = 6, j = 1
算法实现 算法实现了解即可,考试基本不会考察 kmp 算法 实现,只要能够熟练地在纸上模拟 kmp 算法 流程即可。
void computeNextArray ( const char * pattern , int m , int * next ) {
int len = 0 ;
next [ 0 ] = 0 ;
int i = 1 ;
while ( i < m ) {
if ( pattern [ i ] == pattern [ len ]) {
len ++ ;
next [ i ] = len ;
i ++ ;
} else {
if ( len != 0 ) {
len = next [ len - 1 ];
} else {
next [ i ] = 0 ;
i ++ ;
}
}
}
}
int KMP ( const char * mainStr , const char * pattern ) {
int m = strlen ( mainStr );
int n = strlen ( pattern );
int next [ n ];
computeNextArray ( pattern , n , next );
int i = 0 , j = 0 ;
while ( i < m ) {
if ( pattern [ j ] == mainStr [ i ]) {
i ++ ;
j ++ ;
}
if ( j == n ) {
return i - j ;
} else if ( i < m && pattern [ j ] != mainStr [ i ]) {
if ( j != 0 )
j = next [ j - 1 ];
else
i ++ ;
}
}
return - 1 ; // 没有找到匹配
}
修正后的 next 数组 其实上述内容就是 kmp 算法 的核心了,当然有时候也会涉及到这样一个概念: 修正后的 next 数组 。
这个概念其实来源于早期教材(特别是严蔚敏的《数据结构》), 修正后的 next 数组 常称作 nextval 数组 ,其目的是避免某些情况下的冗余匹配。
nextval 数组是为了修正 next 数组存在的以下问题:当 pattern[i] == pattern[next[i]] 时,如果直接使用 next[i],会导致重复比较已经失败过的字符。
为了解决这个问题,定义了 nextval:其核心思想是:避免跳转到与当前位置字符相同的地方,减少无意义的重复比较。
如果 pattern[i] == pattern[next[i]] → 则 nextval[i] = nextval[next[i]] 否则 → nextval[i] = next[i] nextval 的构建规则如下:
if (next[i] == -1) {
nextval[i] = -1;
} else {
int k = next[i];
while (k != -1 && pattern[i] == pattern[k]) {
k = nextval[k]; // 向前继续跳
}
nextval[i] = k;
}
需要注意的,nextval 的计算过程是迭代向前的,因为我们希望减少无意义的比较,所以要争取找到一个不同的字符。
KMP算法 - 修正后的next数组 (nextval) 问题: 当 pattern[i] == pattern[next[i]] 时,会导致重复比较相同字符 解决: nextval数组避免跳转到相同字符位置 示例:pattern = "ababaa" 字符 索引 next nextval a 0 -1 -1 b 1 0 0 a 2 0 0 b 3 1 0 a 4 2 0 a 5 3 3 优化过程说明: • nextval[3]: pattern[3]='b' == pattern[next[3]]='b' → nextval[3] = nextval[1] = 0 • nextval[4]: pattern[4]='a' == pattern[next[4]]='a' → nextval[4] = nextval[2] = 0 • nextval[5]: pattern[5]='a' != pattern[3]='b' → 保持3 nextval 计算规则: if (pattern[i] == pattern[next[i]]) nextval[i] = nextval[next[i]] // 继续向前跳,避免重复比较 else nextval[i] = next[i] // 保持原值 nextval 的优势: • 避免在匹配失败时跳转到相同字符位置 • 减少无意义的重复比较,提高匹配效率 字符相同,优化 字符相同,优化 以字符串 ababaa 为例,我们首先可以计算出其 next 数组 :
pattern a b a b a a index 0 1 2 3 4 5 next -1 0 0 1 2 3
然后计算出 nextval 数组 :
pattern a b a b a a index 0 1 2 3 4 5 nextval -1 0 0 0 0 3
nextval[0] = -1nextval[1] = 0nextval[2] = 0pattern[3] = b, pattern[next[3]] = pattern[1] = b, 相等,优化:nextval[3] = nextval[1] = 0pattern[4] = a, pattern[next[4]] = pattern[2] = a, 相等,优化:nextval[4] = nextval[2] = 0nextval[5] = 3(pattern[5] = a, pattern[next[5]] = pattern[3] = b,不相等,保留原值)其实 kmp 的现代实现中一般使用 next 数组 就足够了,nextval 主要是优化了极少数情况下的性能,特别是在大量重复字符的 模式串 里效果明显。
5 - 树与二叉树 本章在选择题中考察,需熟练掌握树的各种概念,并且能够手工模拟基于树的各种算法流程。
学习思维导图
# 树和二叉树
## 树的基本概念
## 二叉树
- 定义和主要特性
- 顺序存储结构和链式存储结构
- 遍历
- 线索二叉树的基本概念和构造
## 树、森林
- 树的存储结构
- 森林和二叉树的转化
- 树和森林的遍历
## 树和二叉树的应用
- 哈夫曼树和哈夫曼编码
- 并查集及其应用
5.1 - 树 树的基本概念 结点属性 双亲 (parent):如果一个结点包含子结点,则该结点被称为其子结点的双亲。兄弟 (sibling):具有相同双亲结点的结点互称为兄弟结点。孩子 (child):一个结点直接连接到另一个结点,并且位于较低的层级,则该结点被称为子结点或孩子。度 结点的度 :结点的孩子数量树的度 :等于树中所有结点度的最大值分支结点 (非终端结点):度大于 0 的结点叶子结点 (终端结点):度等于 0 的结点深度 树的深度 (Depth):从根结点到最远叶子结点的 结点总数 。结点的深度 :是指从根结点到该结点的结点总数。深度定义是从上往下的,高度定义是从下往上的。
对于树而言,一般不用在意这个,因为树的高度和深度是相同的,但是结点的高度和深度可能会不同。
高度 树的高度 (Height):从根结点到最远叶子结点的 结点总数 。结点的高度 :从该结点到其最远叶子结点的 结点总数。树的高度定义常常有两种方式,这个需要区分一下:
定义一:从某节点到最远叶子节点的 结点总数 。 定义二:从某节点到最远叶子节点的 边数 。 定义一在算法竞赛和教材中更加常用,408 真题也是按照这种方式考查的(2020 年第 3 题 ),在学习和考试中需要按照按照定义一来记忆。
路径 树的带权路径长度 = 10 + 6 + 16 + 14 + 33 + 45 + 6 = 130
路径 :在一棵树中,从一个结点到另一个结点所经过的所有结点,被称为这两个结点之间的路径。结点的权 :每个结点被赋予的一个数值,通常表示该结点的重要性或频率。结点的带权路径长度 :从根结点到该结点的路径长度与该结点权值的乘积。树的带权路径长度 :所有叶子结点的带权路径长度之和。树的存储结构 树的 存储结构 是指在计算机中如何表示和存储树这种数据结构,这里主要了解 双亲表示法 、孩子表示法 和 孩子兄弟表示法 即可。
双亲表示法 双亲表示法 主要是使用一个数组,其中每个结点都有一个指示其双亲结点在数组中位置的索引。
#define MAXSIZE 100
typedef struct {
int data ; // 结点数据
int parent ; // 双亲的位置
} PTNode ;
typedef struct {
PTNode nodes [ MAXSIZE ]; // 结点数组
int n ; // 结点数
} PTree ;
孩子表示法 孩子表示法 将每个结点的孩子结点排列起来,以单链表作为存储结构。然后再用一个数组与之相配合。
#define MAXSIZE 100
// 孩子结点
typedef struct ChildNode {
int child ; // 孩子结点在数组中的位置
struct ChildNode * next ; // 下一个孩子
} * ChildPtr ;
// 表头结构
typedef struct {
int data ; // 结点数据
ChildPtr firstchild ; // 第一个孩子的指针
} CTBox ;
typedef struct {
CTBox nodes [ MAXSIZE ]; // 结点数组
int n ; // 结点数
} CTree ;
孩子兄弟表示法 孩子兄弟表示法 是将树转化为 二叉树 的形式来存储。每个结点有两个指针,一个指向它的第一个孩子,另一个指向它的右兄弟。
typedef struct CSNode {
int data ; // 结点数据
struct CSNode * firstchild ; // 第一个孩子
struct CSNode * rightsib ; // 右兄弟
} CSNode , * CSTree ;
森林的基本概念 森林 (Forest)是一组互不相交的树的集合。换句话说,森林由若干棵树组成,每棵树都是一个独立的层次结构,且这些树之间没有连接关系。
森林与树的区别 :一棵树只有一个 根结点 ,而森林可以有多个 根结点 (每棵树一个)。 森林可以看作是多个树的并集,树是森林的一个特例(森林中只有一棵树)。 森林的深度 :森林中所有树的最大 高度 (从根到最远叶结点的路径长度)。树、森林和二叉树的转换 树转化为二叉树 若树的根结点有孩子,那么第一个孩子是 二叉树 的左孩子,其他的孩子结点依次作为前一个孩子结点的右孩子。 对每个孩子执行上述步骤。 森林转二叉树 把森林中的每一棵树转换为 二叉树 。 第一棵二叉树不动,从第二棵二叉树开始,依次将后一棵二叉树的根作为前一棵二叉树的右孩子。其结果是一个 二叉树 。 树和森林的遍历 树的遍历 对于一个给定的树,通常有以下两种遍历方式:
先根遍历 (类似于二叉树的前序遍历):访问树的根结点。 递归地 先根遍历 根的每一棵子树。 后根遍历 (类似于二叉树的后序遍历):递归地 后根遍历 根的每一棵子树。 访问树的根结点。 注意树是没有 中根遍历 的,除非这棵树是二叉树。
#define MAXCHILD 20
typedef struct TreeNode {
int value ;
int numChildren ; // 子结点的数量
struct TreeNode * children [ MAXCHILD ]; // 子结点指针数组
} TreeNode ; void preOrderTraversal ( TreeNode * root ) {
if ( root == NULL ) {
return ;
}
printf ( "%d " , root -> value ); // 先访问根结点
// 然后遍历子结点
for ( int i = 0 ; i < root -> numChildren ; ++ i ) {
preOrderTraversal ( root -> children [ i ]);
}
} void postOrderTraversal ( TreeNode * root ) {
if ( root == NULL ) {
return ;
}
// 先遍历子结点
for ( int i = 0 ; i < root -> numChildren ; ++ i ) {
postOrderTraversal ( root -> children [ i ]);
}
printf ( "%d " , root -> value ); // 再访问根结点
} 对于 上图 所示的树:其 先根遍历 为 A, B, E, F, C, D, G,后根遍历 为E, F, B, C, G, D, A。
观察可以得到如下结论:
树的 先根遍历 和其对应的二叉树的 先序遍历 相同 树的 后根遍历 和其对应的二叉树的 中序遍历 相同 森林的遍历 对于一个给定的森林,遍历方式如下:
先根遍历 (与树的先根遍历相似):依次 先根遍历 森林中的每一棵树。后根遍历 (与树的后根遍历相似):依次 后根遍历 森林中的每一棵树。中根遍历 (普通的树构成的森林是不存在中序遍历的,这里的中序遍历指代的是二叉树森林):依次 中根遍历 森林中的每一棵二叉树。对于 上图 所示的森林:其 先根遍历 为 A, B, C, D, E, F, G, H, I,后根遍历 为 B, C, D, A, F, E, H, I, G,中根遍历 为 B, C, D, A, F, E, H, I, G
观察可以得到如下结论:
森林的 先根遍历 和其对应的二叉树的 先序遍历 相同 森林的 中根遍历 和其对应的二叉树的 中序遍历 相同 5.2 - 二叉树 二叉树存储结构 链式存储结构 :链式存储是最常见的二叉树存储方式。在链式存储中,每个节点都包含一个数据元素以及指向其左子树和右子树的指针。 链式存储结构适用于表示任意形状和大小的二叉树,并且对于树的动态操作(插入、删除节点)非常方便。 顺序存储结构 (数组表示):顺序存储结构使用数组来表示二叉树。通常,数组的索引与二叉树的节点之间存在特定的关系,例如,对于索引
i
的节点,其左子节点在索引
2 i + 1
处,右子节点在索引
2 i + 2
处,父节点在索引
( i − 1 )
/
2
处。 顺序存储结构通常用于堆数据结构(如二叉堆)的实现,其中对于堆的性质要求,使得数组表示变得非常有效。 链接存储 在 链接存储 中,我们需要在结构体中定义两个指针,分别指向二叉树左边的结点和右边的结点。
typedef struct TreeNode {
ElementType data ;
struct TreeNode * left ;
struct TreeNode * right ;
} TreeNode ;
顺序存储 顺序存储表示用数组存储一颗树,如下图所示。
理解顺序存储的关键在于理解其 结点的隐式链接关系 以及 空指针的定义 这两点。
在顺序存储中,结点的链接关系由其下标指定:数组的下标从 1 开始的话,如果当前结点的下标为
i
,那么其左结点的下标为
2 i
,右结点的下标为
2 i + 1
。 数组的下标从 0 开始的话,如果当前结点的下标为
i
,那么其左结点的下标为
2 i + 1
,右结点的下标为
2 i + 2
。 通常采用一个特殊值来表示空指针,上图中采用 -1 表示空指针。 #define MAX_SIZE 100
int tree [ MAX_SIZE ];
int lchild ( int i ) {
return tree [ 2 * i + 1 ];
})
int rchild ( int i ) {
return tree [ 2 * i + 2 ];
}
顺序存储最常见于 堆排序 中。因为 堆是一棵完全二叉树 。
对完全二叉树而言,顺序存储不会造成空间浪费; 父结点与子结点之间的关系可以直接通过下标计算得到(无需额外指针); 堆排序过程中需要频繁地比较和交换父结点与子结点,数组下标运算能显著提升效率。 因此,堆排序天然适合采用顺序存储结构来实现。
特殊二叉树 特殊的二叉树 包含满二叉树、完全二叉树两种。
简单来说,满二叉树 必须每一层结点数都是满的,
完全二叉树 允许最后一层的最后几个结点为空。
满二叉树 满二叉树(Full Binary Tree)具备如下特点:
每个节点要么没有子节点,要么有两个子节点。 每一层都被完全填满。 第
n
层的结点数量为
2 n
(这里假设根结点是第
0
层)。 高度 为
h
的满二叉树节点数目为
2 h − 1
。结点数量为
n
的满二叉树的高度为
log 2 ( n + 1 )
。 完全二叉树 完全二叉树(Complete Binary Tree)具备如下特点:
由满二叉树通过删除最后一层的一些节点而得到的。 除了最后一层外,所有其他层都是完全填满的,而且最后一层的节点都集中在左侧。 高度 为
h
的完全二叉树的结点数量范围为 [
2 h − 1
,
2 h − 1
]。结点数量为
n
的完全二叉树的高度为
⌈ log 2 ( n + 1 )⌉
。 遍历方式 DFS 深度优先搜索 (DFS,Depth-First Search)是一种用于遍历或搜索树或图的算法。
当使用 DFS 遍历二叉树中的结点时,算法会优先探索树的深度,直到达到最深的节点。
当达到叶子节点或无法继续深入时,算法会回溯返回到上一个节点,探索其他分支。
二叉树的深度优先(DFS)遍历方式包含前序、中序、后序遍历三种。
理解这三种遍历方式的关键在于理解其递归过程,不同方式的递归顺序不一样。
具体不同点如下表所示:
访问方式 递归顺序 前序 根左右(NLR) 中序 左根右(LNR) 后序 左右根(LRN)
在表述递归顺序时,通常用 N 表示根节点,L 表示左孩子,R 表示右孩子。
前序遍历 前序遍历序列 = [1, 2, 4, 5, 8, 3, 6, 7, 9, 10]
访问顺序:根节点 -> 左子树 -> 右子树 步骤:访问根节点。 递归地进行前序遍历左子树。 递归地进行前序遍历右子树。 void preorderTraversal ( struct TreeNode * root ) {
if ( root == NULL ) {
return ;
}
// operation here
preorderTraversal ( root -> left ); // 遍历左子树
preorderTraversal ( root -> right ); // 遍历右子树
}
中序遍历 中序遍历序列 = [4, 2, 8, 5, 1, 6, 3, 9, 7, 10]
访问顺序:左子树 -> 根节点 -> 右子树 步骤:递归地进行中序遍历左子树。 访问根节点。 递归地进行中序遍历右子树。 void inorderTraversal ( struct TreeNode * root ) {
if ( root == NULL ) {
return ;
}
inorderTraversal ( root -> left ); // 遍历左子树
// operation here
inorderTraversal ( root -> right ); // 遍历右子树
}
后序遍历 后序遍历序列 = [4, 8, 5, 2, 6, 9, 10, 7, 3, 1]
访问顺序:左子树 -> 右子树 -> 根节点 步骤:递归地进行后序遍历左子树。 递归地进行后序遍历右子树。 访问根节点。 void postorderTraversal ( struct TreeNode * root ) {
if ( root == NULL ) {
return ;
}
postorderTraversal ( root -> left ); // 遍历左子树
postorderTraversal ( root -> right ); // 遍历右子树
// operation here
}
BFS 层次遍历序列 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
二叉树的层次遍历(Level Order Traversal),也称为 广度优先搜索 (BFS),是一种从上到下、从左到右逐层访问二叉树节点的方法。
BFS 算法从根节点开始,逐层访问节点 。同一层级的节点按照从左到右的顺序访问。
void levelOrderTraversal ( struct TreeNode * root ) {
if ( root == NULL ) {
return ;
}
struct Queue * queue = createQueue ();
enqueue ( queue , root );
while ( queue -> front != NULL ) {
struct TreeNode * current = dequeue ( queue );
// operation here
if ( current -> left != NULL ) {
enqueue ( queue , current -> left );
}
if ( current -> right != NULL ) {
enqueue ( queue , current -> right );
}
}
}
线索二叉树 线索二叉树 (Threaded Binary Tree)是一种为了提高二叉树遍历效率而设计的数据结构。它通过利用二叉树中原本为空的指针域来存储指向前驱或后继结点的指针(称为“线索”),从而省去递归或栈的遍历方式。
普通二叉树 的结点有两个指针:left 和 right,但在一棵 n 个结点的二叉树中,实际的左右子树为空的指针数有很多(约 2n 个)。线索二叉树的思想就是把 这些空指针利用起来 ,指向结点在某种遍历序列中的前驱或后继。
数据结构 // ltag = 1 时表示 lchild 域指向节点的前驱
// rtag = 1 时表示 rchild 域指向节点的后继
typedef struct ThreadNode {
ElementType data ;
struct ThreadNode * lchild , * rchild ;
int ltag , rtag ;
} ThreadNode ; // 以中序线索二叉树为例
// 中序遍历,找到中序遍历的第一个节点,然后遍历 rchild 即可
void InOrder ( ThreadNode * n ) {
ThreadNode * p = n ;
while ( p -> lchild ) {
p = p -> lchild ;
}
for (; p != NULL ; p = p -> rchild ) {
visit ( p );
}
} 线索二叉树中的每个节点都可以包含以下线索信息:
前驱线索 如果 p 的左子指针 left 原本为空,那么将其改为指向 p 的“遍历前驱结点”。 同时 ltag = 1,表示这个指针是线索,而不是左孩子。 后继线索 如果 p 的右子指针 right 原本为空,那么将其改为指向 p 的“遍历后继结点”。 同时 rtag = 1,表示这个指针是线索,而不是右孩子。 类型 📌 按不同的遍历方式,线索可分为:
线索类型 前驱 / 后继定义 前序线索 指向前序遍历的前驱 / 后继结点 中序线索 指向中序遍历的前驱 / 后继结点 后序线索 指向后序遍历的前驱 / 后继结点
在实际应用中,最广泛使用的是 中序线索二叉树 。因为通过中序线索,可以用如下方式访问整棵树:
从最左下角的结点开始 通过右线索一步步访问中序后继 直到结束,不需要递归或辅助栈 这种遍历方法最简单、最直接。
二叉树构建 已知二叉树,可以获得其前序、中序和后序遍历序列。
反之,给定二叉树的遍历序列,可以通过以下两种方式重建其二叉树结构:
带空指针的单序列重建 :给定包含空指针信息的单一遍历序列(如带空指针的前序遍历),可以唯一确定二叉树的结构。双序列组合重建 :给定两种不同的遍历序列组合,可以唯一确定二叉树的结构。带空指针的单序列 在二叉树的遍历序列中,明确标记空节点 (例如用 # 或 NULL),可以保证仅通过 一个前序遍历序列 就能唯一确定一棵二叉树的结构。
如果只用常规的前序、中序或后序遍历而不标记空节点,那么遇到某些情况时会出现歧义。例如,一棵根节点为 1 的树,如果它只有一个左孩子或只有一个右孩子,两种情况的普通前序序列都是 1 X,无法区分。
但是如果在遍历时显式地写出空指针(#),序列就变得 无歧义 。
BinaryTreePreorder 前序遍历序列(带空指针) 1 2 4 # # # 3 # # ① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨ 1 1 2 2 1->2 L 3 3 1->3 R 4 4 2->4 L null1 # 2->null1 R null4 # 3->null4 L null5 # 3->null5 R null2 # 4->null2 L null3 # 4->null3 R 举个例子,对于以上二叉树,我们可以使用以下方法构建二叉树:
从序列的第一个元素开始:如果是 #,说明该子树为空,返回 NULL。 否则,创建一个新节点作为当前子树的根。 递归处理下一个元素:按前序规则,先为当前节点构建 左子树 。 如果遇到 #,左子树为空,返回。 再递归处理下一个元素: 重复步骤 1–3,直到序列完全读完。 TreeNode * createTree ( char a [], int * i ) {
if ( a [ * i ] == '#' ) { // 空指针标记
( * i ) ++ ; // 别忘了推进下标
return NULL ;
}
// 创建当前节点
TreeNode * cur = ( TreeNode * ) malloc ( sizeof ( TreeNode ));
cur -> val = a [ * i ];
( * i ) ++ ; // 消费当前字符
// 按前序顺序构建左右子树
cur -> left = createTree ( a , i );
cur -> right = createTree ( a , i );
return cur ;
}
双序列组合重建 从前序、中序、后序序列中选两个构造二叉树,有两种方式:
前序 + 中序 :这是最常见的组合,因为前序确定了根节点的位置,而中序则可以区分哪些节点在左子树,哪些在右子树。有了这两个信息,就可以唯一确定一棵二叉树。后序 + 中序 :后序确定了根节点的位置(它是后序遍历的最后一个节点),而中序同样可以帮助我们区分左、右子树。因此,这两者也可以唯一确定一棵二叉树。重点在于选取的两种遍历方式可以提供关于这颗二叉树的结构信息,以 前序 和 中序 为例: