很喜欢各种巧妙的数据结构,这次从头开始系统的学习一下!
浅谈数据结构
结构 \(=\) 实体 \(+\) 关系
数据结构是按照逻辑结构组织起来的一批数据,按一定的存储方法存储,并在这些数据上定义了相关运算的集合
- 数据结构的逻辑组织
线性:表,栈,队列
非线性:树,图,堆 - 数据结构的存储结构
顺序,链接,索引,散列 - 抽象数据类型 Abstract Data Type
定义了一组运算的数学模型,与物理存储结构无关,使软件系统建立在数据之上(面向对象思想)
代码复用,隐藏实现细节提供接口(模块化思想)
再提问题求解
- 实际的应用问题:问题抽象 -> 数据抽象 -> 算法抽象
于是选择合适的数据结构与算法解决问题 - 例子(很有趣!):农夫过河问题
可以将初始岸边的 "人羊狼菜" 抽象成一个状态,农夫带物过河表示状态的转移
具体来说,可以用一个长度为 \(4\) 的 \(01\) 串来描述状态,为\(1\)在起始岸,为\(0\)在目标岸
问题抽象为由初始状态("人羊狼菜" \((1111)_2=15\))转移到目标状态(空\((0000)_2=0\))
数据抽象为图结构
算法抽象为最短路问题
算法效率与度量
\(T(n)\):确定时间复杂度
- 大 \(O\) 表示法:
\(T(n) = O(f(n))\) 表达函数增长率的上界
具体定义:一定存在两个参数 \(c\),\(n_0\),使得对于所有 \(n>n_0\),都有\(T(n)<cf(n)\) - 大 \(\Omega\) 表示法:表达函数增长率的下界,与 大 \(O\) 表示法的唯一区别是不等式的方向
- 大 \(\Theta\) 表示法:当函数增长率上下界相等时,使用大 \(\Theta\) 表示法
线性结构
线性表:前驱,后继关系;开始节点;终止节点以及内部节点
- 按操作划分:线性表,栈,队列
线性表:不限制操作
栈:限制插入,删除元素在同一端,先入后出
队列:插入,删除不在同一端,先入先出 - 按存储结构划分:顺序表(即数组),链表
- 顺序表:按索引值从小到大放在一片相邻的连续区域,存储密度可以达到 \(1\)
- 链表:需要指针,存储密度更低;单链表,双链表,循环链表
单链表带表头:带表头的单链表的首元素为head->nxt
,不带表头的单链表首元素为head
表头实际上是一个head
虚节点,能够简化许多操作,使得空表与非空表的操作一致
带表头单链表插入元素(在位置 \(i\) 插入):
cur = head; // 不考虑不合法的插入
i -= 1;
while (i--) cur = cur->nxt;
newPoint->nxt = cur->nxt;
cur->nxt = newPoint;
普通单链表:
if (i == 1) { // 讨论插入元素的位置是否为首位
if (head == NULL) head = newPoint; // 讨论表是否为空
else {
newPoint->nxt = *head;
head = newPoint;
}
} else {
i -= 1;
cur = head;
while (--i) cur = cur->nxt;
newPoint->nxt = cur->nxt;
cur->nxt = newPoint;
}
可以发现带表头的单链表的插入操作比普通单链表方便很多
- 顺序表 \(Vs\) 链表
- 顺序表访问速度\(O(1)\),插入或删除\(O(n)\)
- 链表访问速度\(O(n)\),插入或删除\(O(1)\);然而找到被插入或删除的元素的位置仍然需要 \(O(n)\) 的时间
- 一般来说,顺序表所需空间比链表小(指针占用空间)
- 顺序表的节点数目需要大致估计,而链表不需要
(因为顺序表预先申请固定长度的连续空间,而链表由于使用指针可以根据需要动态的申请或释放空间)
栈与队列
- 栈 (FILO First in Last out)
顺序栈与链式栈
解决后缀表达式:遇到操作数则压入栈,遇到运算符则弹出两个操作数进行对应运算,再将结果压入栈
解决前缀表达式:反向处理前缀表达式,处理方法同上 - 队列 (FIFO First in First out)
顺序队列(实现采用环形队列)与链式队列(单链)
顺序队列判满的条件:\((rear + 1) % maxSize = front\) 注意,此处的front
,rear
都是实指 - 关于前缀,后缀,中缀表达式的补充
- 根据前缀表达式建立表达式树:遇见操作符创建根节点,遇见操作数创建子节点,递归创建左右节点
对该表达式树前序遍历获得前缀表达式;中序遍历获得中缀表达式(注意,没有括号意味着可能出错);后序遍历获得后缀表达式
前序遍历:根左右;中序遍历:左根右;后序遍历:左右根 - 中缀表达式转后缀表达式:解决数学式的计算,先将中缀表达式转为后缀表达式,再用栈对后缀表达式进行计算
使用一个栈用来储存运算符,从左到右扫描中缀表达式
数字,变量:直接输出至后缀表达式
运算符:若当前运算符比栈顶运算符优先级高或栈空,则入栈;否则,弹栈直至当前运算符比栈顶运算符优先级高或栈空,将当前运算符入栈并将弹出的运算符全部输出至后缀表达式
左括号:直接入栈
右括号:弹栈并将弹出的运算符全部输出至后缀表达式,遇见左括号则将其弹出并停止弹栈
若扫描完后栈中仍有剩余的运算符,则逐个弹出至栈空并将弹出的运算符全部输出至后缀表达式
字符串基本概念
字符串是一种以字符集为数据对象的线性表(通常以顺序表实现)
字符之间有特殊的偏序编码规则来对字符的大小关系进行定义
子串,真字串,字符串常量的概念
- 字符串的存储结构
顺序存储,以\0
作为结束符
字符串复制不能简单的采用s1=s2
,而应该是strcpy(s1, s2)
第一种方法只是简单的将s2
的存储地址赋给s1
,相当于创建了一个 \(alias\),s1
的原有存储地址消失了 - 字符串的模式匹配
- 朴素枚举匹配(\(BruteForce\))
\(O(nm)\)
朴素算法匹配较慢的原因是存在大量的冗余比较 - 快速模式匹配算法(\(KMP\))
// 洛谷 P3375 代码,我自己写的很好理解
#include <bits/stdc++.h>
using namespace std;
const int N = 2e6 + 10;
int la, lb;
int fail[N];
char a[N], b[N];
int main() {
scanf("%s%s", a + 1, b + 1);
la = strlen(a + 1), lb = strlen(b + 1);
fail[0] = -1;
for (int i = 1; i <= lb; ++i) {
int j = fail[i - 1];
while (~j && b[j + 1] != b[i]) j = fail[j];
fail[i] = j + 1;
}
for (int i = 1, j = 0; i <= la; ++i) {
while (j && a[i] != b[j + 1]) j = fail[j];
if (a[i] == b[j + 1]) ++j;
if (j == lb) printf("%d\n", i - lb + 1), j = fail[j];
}
for (int i = 1; i <= lb; ++i) printf("%d%c", fail[i], " \n"[i == lb]);
return 0;
}
记住失配指针 fail
或 next
是针对模式串而言的,不是文本串
fail[i]
的含义是前 \(i\) 个字符构成的模式串子串最长公共前后缀的长度,循环体中的 j
是当前能够完全匹配的字串长度
不断跳fail
直到j+1
能与文本串的第 i
位匹配,此时完全匹配字串长度加一,于是有j++
形象的想象一下,还是比较好懂的
时间复杂度 \(O(m+n)\)
失配数组next
还有一个经典的运用就是求某个串的最短循环节 \(k\) 的长度
若某个串 \(s=kkk...kkk\)(由循环节 \(k\) 重复 \(n\) 次组成) 那么其next
数组与 \(k\) 的长度 \(len_k\) 必定满足如下关系
\(next[s]=(n-1) \times len_k\)
关于二叉树
- 满二叉树:除叶子外的节点必有两棵非空子树
- 完全二叉树:最多只有最下面两层结点度数可以小于 \(2\),且最下一层的节点集中在最左边
(上面这样说很抽象,想象建立一颗二叉树是一层层下去从左至右添加节点的,这样建立的一颗二叉树一定是完全二叉树) - 满二叉树定理:非空满二叉树的叶子节点个数是分支节点个数 \(+1\)
- 在二叉树的三种遍历之中,中序遍历 较为重要,这是因为:
前序遍历 \(+\) 中序遍历或后序遍历 \(+\) 中序遍历都可以还原出原二叉树的结构,而前序遍历 \(+\) 后序遍历不行
这是因为当二叉树某个节点只有一个子节点的时候,无论这个节点是左子节点还是右子节点,对于前序和后序遍历的结果是没有影响的
- 二叉搜索树 \((Binary Search Tree)\)
每个结点的左子树的值都小于该结点的值,右子树的值都大于该结点的值,因此其 \(BST\) 的 中序遍历序列 即为一个递增序列
\(BST\) 的删除操作比较麻烦,参考这个博客 BST 的删除
为了保持 \(BST\) 稳定的 \(logN\) 树高,可以采用红黑树 \(RBTree\) 或伸展树 \(Splay\) 来维护
- 堆与优先序列
堆总是一颗完全二叉树,其某个节点的值总是不大于或者不小于其父节点的值
首先介绍两个操作:\(shiftup\) 与 \(shiftdown\)。这两个操作都是为了维护堆的有序性
shiftup
:自顶向上的,将当前元素不断与其父节点的值进行比较,若不满足堆定义即进行交换,可一直交换至根节点
shiftdown
:自上而下的,将当前元素不断与其子节点中较小(若为大根堆,则大)的结点的值进行比较,若不满足堆定义即进行交换,可一直交换至叶子结点
靠这两个操作可以实现很多功能:
- 添加元素:将新元素添加至堆的末尾,并不断进行
shiftup
更新 - 删除元素:一般是删除根元素,将根与末尾元素进行交换并直接删除,再对新的根元素不断进行
shiftdown
更新 - 建堆:有两种方式,一种是一个一个向堆中添加新元素,复杂度 \(O(nlogn)\)
另一种是将原数据直接建成一颗完全二叉树,并从后往前对每一个非叶子结点(只有 \(\frac{n}{2}\) 个)进行shiftdown
更新(不断生成新子堆),复杂度 \(O(n)\)
建堆操作可参考博客
- \(Huffman\) 树 (最优二叉树)
- 定义某结点的带权路径长度为根节点到该结点的路径长度与该结点的权值之积,树的带权路径长度为所有叶子节点的带权路径长度之和
给定若干个带权叶子节点,建立 \(Huffman\) 树,可以证明该树的带权路径长度是最小的 - \(Huffman\) 树的建立基于贪心:每次从叶子结点中挑选两个权值最小的结点并且为其创建一个相同的父节点形成一颗三个结点的森林,父节点的权值为两子节点的权值之和
再将父节点添加进叶子节点序列中参与进一步的构建。每一次操作叶子节点序列的长度都会减一,最后所有森林合并形成的树即为 \(Huffman\) 树 - 可以发现,权值越小的结点深度越大,这样的贪心策略使得最终所得的带权路径长度最小
- \(Huffman\) 树的应用:编码问题
一般来讲,字符是以编码形式存储的:为了节省空间,一般不采用等长编码,而是出现频率越高的字符编码长度越短
并且字符采用的都是前缀编码,即任何字符编码的前缀都不会是另一个字符的编码
我们将字符视为一个个叶子节点,权值为其出现的频率进行 \(Huffman\) 树的构建:将左分支边标上 \(0\),右为 \(1\)
那么每个字符的编码即为根到其所在结点的标号连接起来得到的 \(01\) 串。这样的编码方式称为 哈夫曼编码
由于 \(Huffman\) 树的性质,满足了出现频率越高的字符编码长度越短(离根越近)的性质
且一定是前缀编码:没有一片树叶是另一片树叶的祖先,每个叶节点编码的前缀不可能是另一个叶节点编码
- 森林
森林:多颗树的集合(一棵树也是森林)
森林中的每棵树的根节点连向同一根结点就成了树,树去掉根节点剩余的子树就成了森林
森林转二叉树:连接森林中所有根节点,并连接所有兄弟(具有相同父节点的结点)结点 (森林中所有根节点可以视为有一个相同的虚父节点)
接着对每个节点,去除除了第一个孩子之外向所有孩子的连线,整理形态即可得到一颗二叉树
- 树的中心:到最远结点距离最小的点称为树的中心
树形 DP 解决,先一遍 \(dfs1\) 存储每个结点向下到其所有子树内叶子结点的最远距离 \(dw[x]\) 与次远距离 \(dw2[x]\),以及最远距离所对应的子树根结点 \(dwp[x]\)
第二遍 \(dfs2\) 求出所有结点向上(不经过子树)到最远点的距离 \(up[x]\)
最后扫描所有点,所有点的 \(up[x]\) 与 \(dw[x]\) 取最大值即可得到点 \(x\) 到最远结点的距离
void dfs2(int x) {
for (int i = head[x]; i; i = nxt[i]) {
int son = to[i];
if (son == dwp[x])
up[son] = max(up[x], dw2[x]) + w[i];
else
up[son] = max(up[x], dw1[x]) + w[i];
dfs(son);
}
}
并查集 Union-Find set
自己觉得很巧妙的一个数据结构,是一种树形数据结构,用来处理一些不相交集合的合并与查询问题
原理很简单,需要讲的一点是路径压缩:因为对于连通块,我们并不在乎兄弟关系,于是可以在查询时将一条链上所有元素的父亲设为根以减小树高,提高查询效率
贴个自己的路径压缩代码:
inline int get(int x) {
return x == fa[x] ? x : fa[x] = get(fa[x]);
}
图 Graph
图的类型
- 有向图,无向图
- 完全图(每两个顶点之间都有边),完全有向图(双边),完全无向图,稀疏图,稠密图
- 回路 (环),无环图,有向无环图 (DAG, \(Directed Acyclic Graph\))
- 连通分量,强连通分量
若顶点 \(V_1\) 与 \(V_2\) 之间,存在一条从 \(V_1\) 到 \(V_2\) 的有向路径,同时存在一条从 \(V_2\) 到 \(V_1\) 的有向路径,则称 \(V1, V2\) 强连通
非强连通有向图的 极大强连通子图 即为强连通分量 - 网络:边带权连通图
图的存储结构
- 邻接矩阵
- 邻接表 (链式前向星)
图的遍历
- 深度优先搜索 (栈,\(vis\) 数组)
- 广度优先搜索 (队列)
- 拓扑排序
拓扑序列:若在 DAG 中顶点 \(v_i\) 到 \(v_j\) 中有一条路径,则在序列中顶点 \(v_i\) 必在顶点 \(v_j\) 之前
拓扑排序:入度为 \(0\) 的点入队,相邻的结点入度 \(-1\):重复以上过程直至 DAG 每个结点均被访问。若还有未被访问到的结点,说明这个图有环
\(dfs\) 实现拓扑排序:根据顶点编号深搜,当某个点没有后继或其后继结点均被访问过后,该点入栈;最后将栈中元素倒序即可得到拓扑序列
当某个点被访问过且是当前结点的祖先结点,说明出现环:具体实现可以将 \(vis\) 数组扩展至 \(-1\) 域
最短路算法
- 单源最短路 Dijkstra 算法:基于贪心,不支持负权图,堆优化后复杂度 \(O(n+m)log_n\)
初始化 \(dis_{start}\) 为 \(0\),其余 \(dis\) 为无穷大,\({start,0}\) 点入小根堆,小根堆按照 \(dis\) 排序
每次取堆顶并更新相邻结点的 \(dis\),设当前结点为 \(x\),其相邻结点为 \(y\),\(edge{x, y}\) 权值为 \(v\)
当满足 \(dis_x + v < dis_y\) 时,更新 \(dis_y\) 为 \(dis_x + v\) 并将 \({y,dis[y]}\) 入堆
每次从堆顶取出的点一定是到源点最短距离已经确定的点
memset(dis, 127, sizeof dis);
dis[0] = 0;
q.push(node(0, 0));
while (!q.empty()) {
int cur = q.top().x;
q.pop();
if (vis[cur]) continue;
vis[cur] = true;
for (int i = head[cur]; i; i = nxt[i]) {
if (dis[cur] + w[i] < dis[to[i]]) {
dis[to[i]] = dis[cur] + w[i];
q.push(node(to[i], dis[to[i]]));
}
}
}
- 多源最短路 Floyd 算法:基于动态规划,复杂度 \(O(n^3)\),支持负权边
\(dis[x][y]\) 代表由 \(x\) 至 \(y\) 的最短路,枚举中间节点 \(k\) 转移
初始化 \(dis[x][y]\) 为 \(x\) 到 \(y\) 的直接边距离,无边连接的两点初始为无穷大,\(dis[x][x]\) 初始为 \(0\)
枚举中间点 \(1\) 至 \(n\),每完成一个循环,\(dis[x][y]\) 的含义变为,在允许经过 \(1...i\) 点的情况下 \(x\) 到 \(y\) 的最短路径长度
那么 \(n\) 个循环完成后 \(dis[x][y]\) 即为 \(x\) 到 \(y\) 的最短路
以下是核心代码:
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
for (int k = 1; j <= n; ++k) {
if (dis[j][i] + dis[i][k] < dis[j][k])
dis[j][k] = dis[j][i] + dis[i][k];
}
- 单源最短路 SPFA 算法:基于改良的 bellmanford 算法,核心为深搜,复杂度 \(O(nm)\),易被卡
图的最小生成树
一个有 \(n\) 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 \(n\) 个结点,并且有保持图连通的最少的边 (\(n-1\)条)
最小生成树不唯一
-
Prim 算法:算法类似 Dijkstra,通过顶点构建,复杂度 \(O(NlogN)\)
定义一个点到某个点集的距离时该点到点集中所有点距离的最小值
先将任意一个点加入空点集进行初始化
每次在点集的邻接点中选择一个距离最小的点加入点集,并更新该点的邻接点到点集的距离
重复以上操作直到所有点都加入点集,这样就得到了一颗最小生成树 -
Kruskal 算法:同样基于贪心,通过边构建,复杂度 \(O(ElogE)\)
将所有边按权值从小到大排序
依次访问每一条边,若该边连接的两点并未联通,则选择这条边加入最小生成树并将该两点联通
(对点连通块的维护操作可以利用并查集)
若成功选到了 \(n-1\) 条边则成功构建一颗最小生成树
Prim 在稠密图中比 Kruskal 优,在稀疏图中比 Kruskal 劣。Prim 是以更新过的节点的连边找最小值,Kruskal 是直接将边排序