参考资料
《大话数据结构》
笔记说明
【块引用】内容为书中一些值得思考的“思想”
基本概念
程序设计 = 数据结构 + 算法
- 数据
- 数据元素(记录) → 建立数据模型
- 数据项:数据不可分割的最小单位
- 数据元素(记录) → 建立数据模型
- 数据结构
- 逻辑结构:面向问题
- 存储结构:面向计算机
- 数据类型:分类以满足不同需要
- 原子类型:不可分解(int、char)
- 结构类型:可以再分解(int数组)
抽象:抽取出事物具有的普遍性的本质;抽出问题的特征而忽略非本质的细节,是对具体事物的概括。隐藏繁杂的细节,只保留实现目标所必需的信息。
- 抽象数据类型(Abstract Data Type,ADT):定义数据对象、数据对象中各数据元素之间的关系及对数据元素的操作(问题分解、抽象和信息隐藏)
线性表
- 零个或多个数据元素的有限序列
“序列”说明元素之间有前驱和后继关系
直接前驱、直接后继
当传递一个参数给函数的时候,这个参数如果需要被改动,则需要传递指向这个参数的指针,如果不用被改动,可以直接传递这个参数。
顺序存储结构
数组
地址连续的存储单元,第 i 个数据元素 的存储位置可以由 推算得出:
,LOC表示获得存储地址的函数,假设每个数据元素占用 c 个存储单元
#define MAX_SIZE 20 // 最大存储容量
typedef int ElemType; // 元素类型
typedef struct{
ElemType data[MAX_SIZE]; // 数组
int lengh; // 线性表当前长度
} SqList;
随机存取(直接存取):当存储器中的数据被读取或写入时,所需要的时间与该数据所在的物理地址无关
插入与删除
- 异常处理
- 插入 / 删除
- 表长变化
时间复杂度
最好 | 最坏 | 平均 | |
---|---|---|---|
存入、读取 | |||
插入、删除 |
优缺点
适合元素个数不太变化、较多存取数据的应用
链式存储结构
- 结点:数据域、指针域
- 头结点、头指针:
// 单链表
typedef struct Node{
ElemType data; // 数据域
struct Node *next; // 指针
}Node;
typedef struct Node *LinkList;
for 循环需要事先知道循环次数,循环次数未知、终止条件已知时使用 while
单链表
创建
- 头插法:新结点始终插入到头结点后面(新结点始终在头结点后的第一个位置)
- 尾插法:新结点始终插入到终端结点后面
插入与删除
-
查找位置
- 插入 / 删除
- 整表删除
优缺点
插入或删除数据越频繁,单链表越适合
顺序存储与单链表对比
顺序存储 | 单链表 | ||
---|---|---|---|
存储分配方式 | 一段连续的存储单元依次存储数据元素 | 一组任意的存储单元 | |
时间性能 | 查找 | ||
插入和删除 |
平均移动一半表长的元素 |
找出指针的位置 | |
空间性能 | 需要预分配存储空间 | 动态分配 |
频繁查找用顺序存储,频繁插入/删除用单链表;元素个数变化较大用单链表。
静态链表(游标实现法)
给没有指针的高级语言设计的一种实现单链表能力的方法。
用数组描述单链表:
- 数据域 data
- 游标 cur(代替next指针):存放该元素的后继在数组中的下标
其中数组的第一个和最后一个元素作为特殊元素:
// 采用 struct 或并行数组(data和cur)实现
#define MAXSIZE 1000
typedef struct{
ElemType data;
int cur; // 游标(cursor)
} Component;
插入和删除
手动模拟动态申请和释放函数
优缺点
循环链表
头尾相接的单链表
- 头结点:使空链表与非空链表处理一致
- 尾指针:指向终端结点,其next指向头结点
双向链表
用空间换时间
- 增加指向前驱结点的指针域
typedef struct DulNode{
ElemType data;
struct DulNode *prior; // 指向前驱结点
struct DulNode *next; // 指向后继结点
}
p -> next ->prior = p = p -> prior -> next
插入和删除
更改指针变量(顺序):
- 待插入结点的前驱和后继
- 后结点前驱
- 前结点后继
双向循环链表
栈
限定仅在表尾进行插入和删除操作的线性表
- 栈顶(top):允许插入和删除
- 栈底(bottom)
- 后进先出(LIFO)
- 进栈 / 压栈 / 入栈(push):插入
- 出栈 / 弹栈(pop):删除
顺序存储结构(顺序栈)
- 栈顶指针
进栈和出栈
- 时间复杂度:
两栈共享空间
两个栈的栈顶指针由数组两端向中间靠拢
通常用于两个栈的空间需求有相反关系、数据类型相同
链式存储结构(链栈)
不需要头结点,栈顶相当于单链表的头部
进栈和出栈
- 时间复杂度:
栈的应用
递归
迭代使用的是循环结构,递归使用的是选择结构。大量的递归调用会建立函数的副本,会耗费大量的时间和内存,迭代则不需要反复调用函数和占用额外的内存。
编译器用栈实现递归(函数的局部变量、参数值和返回地址)
数学表达式求值
后缀表示法(逆波兰表示,RPN)
所有的符号都在数字后面出现
从左到右遍历表达式的每个数字和符号,数字进栈,若是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果
队列(简记,待补充)
只允许在一端进行插入操作,在另一端进行删除操作的线性表
- 先进先出(FIFO)
- 队头
- 队尾
- 入队
- 出队
循环队列(顺序存储结构)
头尾相接的顺序存储队列
- front指针:指向队头
- rear指针:指向队尾的下一个位置
空队列与队列满的判定
- 时间复杂度:
链式存储结构
队头指针指向头结点,队尾指针指向终端结点
入队和出队
- 入队:链表尾部插入
- 出队:头结点的后继结点出队(头结点无意义)
串(字符串)
由零个或多个字符组成的有限序列
- 空串
- 空格串
- 子串与主串
串的比较
通过组成串的字符之间的编码进行
- ASCII编码
- Unicode编码:前256个字符与ASCII码完全相同
顺序存储结构
用一组地址连续的存储单元来存储串中的字符序列(一般为定长数组)
存储空间可在程序执行过程中动态分配(堆)
链式存储结构
总的来说不如顺序存储
模式匹配算法
- 串的模式匹配:子串的定位操作
朴素的模式匹配算法(暴力法)非常低效
KMP算法
利用子串(模式串)本身特点,通过构建模式串的部分匹配表(失配函数 / next 数组)来更新要比较的子串下标,避免不必要的回溯
- next数组函数定义(为子串下标,主串与子串0下标位置存储字符串长度,无实际意义):
- 时间复杂度:, 是主串长度, 是模式串长度
KMP算法改进
next数组改进
Boyer-Moore 算法
通过预先计算模式串中每个字符最后出现的位置来实现快速的搜索
Rabin-Karp 算法
利用哈希函数来比较子串和模式串,需要额外的空间来存储哈希值
树
n(n≥0)个结点的有限集
n=0时称为空树
在任意一棵非空树中:(1)有且仅有一个特定的称为根(Root)的结点;(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集、、……、,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)
- 根结点唯一
- 子树互不相交
- 度:结点拥有的子树数 / 树内结点度的最大值
- 叶结点 / 终端结点:度为0
- 分支结点 / 内部结点 / 非终端结点:度非0
- 树的深度 / 高度
- 有序树和无序树
- 森林
存储结构
双亲表示法
- 数据域
- 指针域:存储该结点双亲位置(根结点指针域设为-1)
可以用的时间复杂度轻易找到双亲结点,但找孩子结点需要遍历整个结构
可以增加长子域、右兄弟域等
孩子表示法
多重链表表示法的两种方案:
- 指针域个数 = 树的度
- 专门取一个位置存储结点指针域的个数(数据域、度域、指针域)
孩子表示法:
- 表头数组的表头结点:把每个结点放到顺序存储的数组中
- 数据域
- firstchild指针域,存储孩子链表的头指针
- 孩子链表的孩子结点:对每个结点的孩子建立单链表
- 数据域
- next指针域
双亲孩子表示法
双亲表示法+孩子表示法
孩子兄弟表示法
设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟
把一棵复杂的树变成了一棵二叉树
二叉树
- 斜树:左斜树和右斜树
- 每一层只有一个结点,结点个数与二叉树深度相同
- 满二叉树:
- 非叶子结点的度一定是2
- 完全二叉树:
- 若结点度为1,则该结点只有左孩子
- 同样结点数的二叉树,完全二叉树的深度最小
性质
- 第 层至多有 个结点()
- 深度为 ,至多有 个结点()
- 结点总数 ,高度至少 ,至多 (完全二叉树)
- 叶子结点 ,度为 2 的结点数 ,则 边数 = = ,叶子结点
存储结构
顺序存储
二叉链表
遍历
- 前序遍历:根左右
- 中序遍历:左根右
- 后序遍历:左右根
- 层序遍历
- 前序 + 中序、后序 + 中序可以唯一确定二叉树
线索二叉树
用空指针域存放指向结点(在某种遍历次序下)的前驱和后继结点的地址
- 线索:指向前驱和后继的指针
- 线索链表:加上线索的二叉链表
- 线索化:在遍历过程中修改二叉链表中的空指针,将其改为指向前驱或后继的线索
树、森林与二叉树的转换
树转为二叉树
- 加线。在所有兄弟结点之间加一条连线
- 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线
- 层次调整。以树根结点为轴心顺时针旋转,第一个孩子是二叉树结点的左孩子,兄弟转换的孩子是右孩子
森林转为二叉树
- 把每个树转换为二叉树
二叉树转为树
二叉树转为森林
哈夫曼树
图
存储结构
邻接矩阵
用两个数组表示图:
- 顶点:一维数组
- 边 / 弧:二维数组
无向图:度为行和 = 列和
有向图:出度为行之和,入度为列之和
邻接表
数组与链表结合(类似上文的孩子表示法):
- 顶点:一维数组,数组中每个元素存储指向第一个邻接点的指针
- 每个顶点的所有邻接点:单链表(无向图:边表;有向图:顶点作为弧尾的出边表)
十字链表
邻接多重表
边集数组
遍历
深度优先遍历 DFS
递归思想:首先遍历到图的一个分支的最深处,无法再继续深入后回溯到前面的结点,继续探索其他分支
-
选择起始结点: 从图的某个结点开始遍历,将其标记为已访问
-
递归访问邻接点: 从当前结点开始,选择一个未被访问过的邻接点,标记为已访问,并递归地对该邻接点进行深度优先遍历
-
回溯: 当当前结点的所有邻接点都被访问过,或者当前结点没有未访问的邻接点时,回溯到上一个结点,继续寻找未访问过的邻接点进行深度优先遍历
-
重复步骤 2 和步骤 3,直到遍历完成
时间复杂度:,为顶点数,为边数
应用(简记,待完善)
- 检测图中是否存在环路
- 找出图中的连通分量
- 拓扑排序
- 解决迷宫问题等
广度优先遍历 BFS
先访问当前结点的所有邻接点,然后再依次访问这些邻接点的邻接点,以此类推,直到遍历完所有相邻的结点。
这种遍历方式类似于水波扩散,先访问与起始结点距离为 1 的结点,再访问与起始结点距离为 2 的结点,依次类推
-
选择起始结点: 从图的某个结点开始遍历,将其标记为已访问,并加入队列中
-
依次访问结点的邻接点: 从队列中取出一个结点,依次访问该结点的所有未被访问过的邻接点,并将这些邻接点标记为已访问,并加入队列中
-
重复步骤 2,直到队列为空
时间复杂度:,为顶点数,为边数
应用
- 查找两个节点之间的最短路径
- 拓扑排序
- 最小生成树算法(如 Prim 算法和 Kruskal 算法)的实现
- 寻找图的连通分量等
最小生成树
Prim算法适合稠密图,即边数非常多的情况;
Kruskal算法主要是针对边,边数少时效率非常高,适合稀疏图
普利姆 (Prim) 算法(由点选边)
贪心思想:通过不断选择权重最小的边来构建最小生成树,直到所有结点都被包含在最小生成树中
-
选择起始结点: 从图中选择任意一个结点作为起始结点,将其加入最小生成树中
-
寻找最小权重边: 从当前最小生成树的所有结点中,找到与其相连的边中权重最小的边,且该边的另一端结点不在最小生成树中。
-
加入最小生成树: 将找到的这条权重最小的边加入到最小生成树中,并将该边的另一端结点也加入到最小生成树中
-
重复步骤 2 和步骤 3,直到最小生成树包含了图中的所有结点
时间复杂度
- 稠密图(邻接矩阵):
- 稀疏图(优先队列):
克鲁斯卡尔 (Kruskal) 算法(由边选点)
通过不断选择权重最小的边,并确保加入这条边后不会形成环来构建最小生成树
-
初始化: 将图中的每个结点视为一个独立的连通分量
-
边的排序: 将图中的所有边按照权重从小到大进行排序
-
遍历边并检查: 依次遍历排好序的边,对于每条边,判断其连接的两个结点是否在不同的连通分量中(即判断是否会形成环)
-
加入最小生成树: 如果当前遍历到的边连接的两个结点在不同的连通分量中,则将这条边加入到最小生成树中,并将这两个结点所在的连通分量合并为一个连通分量。
-
重复步骤 3 和步骤 4,直到最小生成树中包含了图中的所有结点为止
时间复杂度
最短路径
迪杰斯特拉 (Dijkstra) 算法
解决从某个源点到其余各顶点的最短路径问题
贪心思想:基于已经求出的最短路径,求出更远顶点的最短路径
-
初始化: 将源点标记为已访问,并将其到自身的距离设置为 0,将所有其他结点到源点的距离设置为无穷大(或者一个足够大的值)
-
更新距离:
-
选择下一个结点:
-
重复步骤 2 和步骤 3,直到所有结点都被访问过
-
最终路径重建
时间复杂度
弗洛伊德 (Floyd) 算法 / Floyd-Warshall 算法
解决图中所有结点对之间的最短路径问题
动态规划思想:通过不断尝试以更多的中间节点来更新节点对之间的最短路径长度,直到所有最短路径都被找到
-
初始化: 构建一个二维数组
dist[][]
,其中dist[i][j]
表示从结点 i 到结点 j 的最短路径长度。初始时,dist[i][j]
的值为结点 i 到结点 j 的直接距离,如果两个结点之间没有直接的边相连,则距离为无穷大;同时,将对角线上的元素dist[i][i]
初始化为 0 -
动态规划更新距离: 对于每一对结点 i 和 j,以及每一个可能经过的中间结点 k,尝试更新结点 i 到结点 j 的最短路径长度。具体做法是,检查从结点 i 到结点 j 的当前最短路径长度
dist[i][j]
是否可以通过经过结点 k 而变得更短,即dist[i][j]
是否大于dist[i][k] + dist[k][j]
。如果是,则更新dist[i][j] = dist[i][k] + dist[k][j]
-
重复动态规划更新: 不断重复动态规划更新的过程,直到所有结点对之间的最短路径长度都不再变化为止。在每一次迭代中,都会利用更多的中间结点来尝试更新路径长度
-
最终结果: 当所有结点对之间的最短路径长度都不再变化后,
dist[][]
数组中存储的即为所有结点对之间的最短路径长度
时间复杂度:
拓扑排序
关键路径
算法
- 特性:输入、输出、有穷性、确定性、可行性
- 输出:打印输出 / 返回值
- 设计要求:正确性、可读性、健壮性、时间效率高、存储量低
- 健壮性:处理不合法输入数据
算法设计与分析
算法效率的度量方法
- 事后统计方法:通过设计好的测试程序和数据,利用计算机计时器对 不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低(缺陷大、不予考虑)
- 事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算
用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 算法采用用的策略、方法
- 编译产生的代码质量 → 软件性能
- 问题的输入规模
- 机器执行指令的速度 → 硬件性能