博客起源:本博客记录了个人学习数据结构期间做的一些笔记,其中含有PPT的截图或者个人的一些见解与思考,多有不足还望包含与指出,祝各位学习愉快
参考教材:《数据结构 C++语言描述》高等教育出版社
相关代码:DataStructure
笔记范围:全书
对于当前的数据之间的关系进行分析,进而思考应该如何存储
设计一定的方法存储到程序中
设计算法计算实现
约束:值集 + 运算集
数据类型 D a t a ? T y p e Data\ Type Data?Type(DT):一般编程语言已经实现好了
抽象数据类型 A b s t r a c t ? D a t a ? T y p e Abstract\ Data\ Type Abstract?Data?Type(ADT):数据结构+算法操作
正确性
健壮性(鲁棒性):对于不合法、异常的输入也有处理能力
可读性
可扩展性
高效率
空间复杂度
时间复杂度 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n)),其中有三种表示时间复杂度的公式
upper bound
:最坏的时间复杂度lower bound
:最好的时间复杂度average bound
:平均时间复杂度有一个头结点,尾结点,且每一个结点只有一个前驱结点和一个后继结点。
存储在一维地址连续的存储单元里
特点:逻辑位置相邻,物理位置也相邻
数据结构:一个一个一维数组 + 一个长度变量 n
template<class T, int MaxSize>
class SeqList
{
T data[MazSize];
int length;
public:
...
}
顺序表可以直接存储元素与关系
链表的元素存储也是可以直接实现的,但是关系要通过指针域来实现
单链表:默认有一个头结点,不存储数据
循环链表
双向链表
顺序表初始化构造
求顺序表长度
按位查找
按值查找
遍历顺序表
插入
删除
单链表初始化构造
head = new Node<T>;
head->next = nullptr;
头插法
head = new Node<T>;
head->next = nullptr;
尾插法
head = new Node<T>;
rear = head;
求单链表长度
按位查找
按值查找
遍历单链表
插入
删除
单链表的析构函数
其他操作
双向链表操作
插入
// 插入当前结点 s
s->prior = p;
s->next = p->next;
p->next->prior = s;
p->next = s;
删除
// 删除当前结点 p
p->next->prior = p->prior;
p->prior->next = p->next;
卡特兰数:假设
f
(
k
)
f(k)
f(k) 表示第 k 个数最后一个出栈的总个数,则
f
(
k
)
=
f
(
k
?
1
)
f
(
n
?
k
)
f(k)=f(k-1)f(n-k)
f(k)=f(k?1)f(n?k)
f
(
n
)
=
∑
k
=
1
n
f
(
k
?
1
)
f
(
n
?
k
)
=
1
n
+
1
C
2
n
n
f(n) = \sum_{k=1}^{n} f(k-1) f(n-k)=\frac{1}{n+1} C_{2n}^{n}
f(n)=k=1∑n?f(k?1)f(n?k)=n+11?C2nn?
顺序存储
链式存储
括号匹配
算数表达式求值
中缀表达式求值
双栈思路,算符优先法
遇到数字,直接入数栈
遇到符号
- 如果是括号,左括号直接入栈,右括号进行运算直到遇到左括号
- 如果是算符,在入算符栈之前,需要进行运算操作直到算符栈顶元素等级小于当前算符等级
中缀表达式转后缀表达式
算符栈即可
后缀先遇到就直接计算的运算符 → \to → 中缀表达式需要先算的运算符,于是转化思路就是:
- 遇到数字,直接构造后缀表达式
- 遇到算符
- 如果是括号,左括号直接入栈,右括号进行后缀表达式构造直到遇到左括号
- 如果是算符,在入算符栈之前,需要进行后缀表达式构造操作直到算符栈顶元素等级小于当前算符等级
后缀表达式求值
数栈即可
遇到数字直接入数栈,遇到算符直接进行运算
栈与递归
递归工作栈
先进先出
顺序存储
链式存储
循环队列的操作
循环队列的三个注意点
- 解决假溢出:采用循环队列,即在入队的时候不是单纯的指针 +1,而是+1后 % MaxSize
- 解决队空队满的冲突(真溢出):
- 浪费一个元素空间:测试rear+1是否等于head,
- 设置一个辅助标志变量
- 设置一个计数器
链队列的操作
报数问题:报到 0 的出队,报到 1 的重新入队,求解出队顺序
迷宫最短路问题:开一个记忆数组 d [ i ] [ j ] d[i][j] d[i][j] 表示从起点 ( 0 , 0 ) (0,0) (0,0) 到终点 ( i , j ) (i,j) (i,j) 点的最短路径的长度。可以将求最短路看做一个波心扩散的物理场景,队列中的每一个点都可以作为一个波心,从而实现“两点之间线段最短”的物理场景
由字符组成的串
使用固定长度的数组来存储,3种存储字符串长度的方法如下:
存储密度 = 串值所占的内存 一个结点的总内存 存储密度 = \frac {串值所占的内存}{一个结点的总内存} 存储密度=一个结点的总内存串值所占的内存?
非压缩形式:一个结点存一个字符
// 存储密度为:1/9 (64位操作系统)
struct String {
char data;
String* next;
};
压缩形式(块链):一个结点存储指定长度的字符
// 存储密度为:4/12 (64位操作系统)
const int MaxSize = 4;
struct String {
char data[MaxSize];
String* next;
}
BF算法(Brute - Force)
// 返回匹配上的所有位置下标(下标从0开始)
vector<int> BF(string& s, string& t) {
vector<int> res;
int i = 0, j = 0, n = s.size(), m = t.size();
while (i < n && j < m) {
if (s[i] == t[j]) i++, j++;
else i = i - j + 1, j = 0;
if (j == m) {
res.emplace_back(i - j);
j = 0;
}
}
return res;
}
KMP算法
优化思想:
先看暴力思想,我们需要每次将模式串 t 后移一位重新进行比较,其中浪费了已匹配的串数据,优化就是从这块已匹配的串数据入手。而已匹配的串数据就是模式串本身的串数据,因为我们可以直接从模式串本身入手。
初步猜想:
根据模式串的性质,构造一个数表
next
,存储模式串应该后移的指针数k
算法实现:
- 递推求
next
数组- KMP 中
i
指针不回溯,j
回溯到next[j]
// 求 next 数组 下标从1开始
for (int i = 2, j = 0; i <= m; i++) {
while (j && t[i] != t[j + 1])
// 未匹配上则不断回溯
j = ne[j];
if (t[i] == t[j + 1])
// 匹配上了则j指针后移一位
j++;
ne[i] = j;
}
// KMP 匹配 下标从1开始
for (int i = 1, j = 0; i <= n; i++) {
while (j && news[i] != newt[j + 1])
// 未匹配上则不断回溯
j = ne[j];
if (news[i] == newt[j + 1])
// 匹配上了则j指针后移一位
j++;
if (j == m) {
// 匹配完全,则统计并且回溯
cnt++;
j = ne[j];
}
}
typedef int arr[m][n];
// 等价于
typedef int arr1[n];
typedef arr1 arr2[m];
可以按照下标的关系,只需要知道第一个元素的地址,通过矩阵的大小关系即可直接计算出 a i j ? a_{ij\cdots} aij?? 的地址
对于多个相同的非零元素只分配一个存储空间,对零元素不分配空间
假设现在有一个 n*n 的对称矩阵
存:行优先存储 data[n * (n + 1) / 2]
取:我们如果要取 data[i][j]
对于上三角
i >= j
:data[i * (i + 1) / 2 + j]
i < j
:data[j * (j + 1) / 2 + i]
对于下三角
i >= j
:data[i * (i + 1) / 2 + j]
i < j
:data[j * (j + 1) / 2 + i]
假设现在有一个 n*n 的三角矩阵(上三角或下三角为常数c)
存:行优先存储,常数 c 存储到最后 data[n * (n + 1) / 2 + 1]
假设现在有一个 n*n 的对角矩阵(围绕主对角线有数据,其余数据均为0)
假设现在有一个 n*m 的稀疏矩阵(很多零的一个矩阵)
三元组顺序表
按行存储两个信息,一个是非零元素的数值,还有一个是具体的坐标 (i, j)
十字链表
定义两个指针数组,定义两个指针数组,存储行列的头指针即可 vector<CrossNode<T>*> cheads, rheads
采用联合结构体存储结点类型
根是第一层。第 i i i 层最多有 2 i ? 1 2^{i - 1} 2i?1 个结点
树中叶子结点的个数为
n
0
n_0
n0?,度数为2的结点的个数为
n
2
n_2
n2?。已知树的点数为
n
n
n,边数为
m
m
m,则
n
=
m
+
1
n = m + 1
n=m+1。而
n
=
n
0
+
n
1
+
n
2
n=n_0+n_1+n_2
n=n0?+n1?+n2?,
m
=
n
1
+
2
n
2
m=n_1+2n_2
m=n1?+2n2?,则
n
0
+
n
1
+
n
2
=
n
1
+
2
n
2
+
1
n_0+n_1+n_2 = n_1+2n_2 +1
n0?+n1?+n2?=n1?+2n2?+1,则
n
0
=
n
2
+
1
n_0=n_2 + 1
n0?=n2?+1
满二叉树:每一层都是满结点
完全二叉树:对于一个 k k k 层的二叉树, 1 → k ? 1 1\to k-1 1→k?1 都是满的,第 k k k 层从左到右连接叶子结点
结点数固定,则完全二叉树的形状唯一
若 i i i 为奇数,且 i ≠ 1 i\neq1 i=1,则左兄弟就是 i ? 1 i-1 i?1
若 i i i 为偶数,则右兄弟就是 i + 1 i+1 i+1
由含空指针标记的单个遍历序列构造二叉树
可以从遍历的逻辑进行逆推。在遍历到空指针的时候输出一个编制符号,然后在构造的时候按照遍历序列进行递归构造即可,如图
先序序列进行构造:
按照遍历的思路来,对于先序序列而言,第一个元素一定是根元素,因此首先根据“当前局面”的第一个元素创建根结点,接着递归创建左子树和右子树即可。注意传递的序列起始下标是引用类型的变量
中序序列进行构造:
不可以,因为不能确定根节点以及左子树和右子树的部分
后序序列进行构造:
与上述先序序列进行构建的逻辑一致,只不过有一个小 trick,即我们从后序序列的最后一个元素开始创建,那么得到的第一个元素就是根结点的值,然后首先递归创建右子树,再递归创建左子树即可。同样需要注意的是传递参数时,序列起始下标是引用类型的变量与先序序列构造逻辑相同,只是递归的顺序需要调整一下
由两个遍历序列构造二叉树
pre[ipre]
post[ipost+n-1]
由顺序结构构造链式结构
拷贝构造
析构
将空指针域用前驱 or 后继结点的地址进行覆盖
依旧是链式存储,只不过增加了结点域中的指针类型,分为链接类型Link与线索类型Thread
中序线索化的二叉树
线索化算法
设置一个全局变量 pre,为了简化思维,我们可以将一个中序遍历的过程想象成一个线性结构。前驱为pre,当前为p。
求后继结点和前驱结点的算法
遍历算法
求父结点的算法
多叉链表表示法
将每一个结点的子结点都预设置为一个定值(树的最大度数):浪费空间
孩子链表表示法
自顶向下存储边的信息
template<class T>
struct CTBox {
T data;
CTNode* firstchild;
};
struct CTNode {
int child;
CTNode* next;
};
双亲表示法
自下向上存储边的信息
孩子兄弟表示法
左结点存储孩子,右结点存储兄弟
树的路径长度:叶子结点到根结点的路径之和
G r a p h = ( V , E ) Graph = (V,E) Graph=(V,E)
完全无向图: e d g e = n ( n ? 1 ) / 2 edge=n(n-1)/2 edge=n(n?1)/2
完全有向图: e d g e = n ( n ? 1 ) edge=n(n-1) edge=n(n?1)
带权图称为网
连通图和连通分量:
无向图
连通图:每一个顶点之间都有路径可达
连通分量:极大连通子图
强连通图和强连通分量:
有向图
强连通图:每一个顶点之间都有路径可达
强连通分量:极大强连通子图
教材中的点编号统一从 0 0 0 开始
无向图的度:第 i i i 行(列)的非标记数的个数
有向图的度:
类定义:
存储出边表(邻接表)
存储入编表(逆邻接表)
类定义:
每个结点只能访问一次,故需要开启标记数组用来记录是否访问的情况
邻接矩阵:
时间复杂度: O ( n 2 ) O(n^2) O(n2)
针对邻接矩阵的一个无向连通图的搜索代码示例
邻接表:
时间复杂度: O ( n + e ) O(n+e) O(n+e)
针对邻接表的一个无向连通图的搜索代码示例
template<class T>
void ALGraph::DFS(int v, bool* visited) {
cout << vexs[v];
visited[v] = true;
// 遍历所有的边
}
求 (u,v)
的所有简单路径
dfs+回溯法的简单应用
染色法求二部图
bfs的简单应用
当然dfs也是可以的,只需要在染色之后判断是否有相同颜色的邻接点即可
M i n i m u m ? S p a n n i n g ? T r e e ( M S T ) Minimum\ Spanning\ Tree(MST) Minimum?Spanning?Tree(MST)
证明:
对于上述的一个割,选择其中权值最小的交叉边。从而对于所有的状态,每次选择最小交叉边即可。
算法标签: g r e e d y greedy greedy
构造 n ? 1 n-1 n?1 个割的状态
起始状态为:顶点集合 U U U 含 1 1 1 个顶点,顶点集合 V ? U V-U V?U 含 n ? 1 n-1 n?1 个顶点
状态转移为:
时间复杂度: O ( n 2 ) O(n^2) O(n2)
算法流标签: g r e e d y 、 d s u greedy、dsu greedy、dsu
单源最短路
D i j k s t r a Dijkstra Dijkstra 算法无法求解含负边权的单源最短路
B e l l m a n ? F o r d Bellman-Ford Bellman?Ford 算法支持负边权的单源最短路求解
S p f a Spfa Spfa 算法同样支持负边权的单元最短路,属于 B e l l m a n ? F o r d Bellman-Ford Bellman?Ford 算法的优化
多源最短路
F l o y d Floyd Floyd 适用于求解含负边权的多源最短路
算法标签: g r e e d y greedy greedy d p dp dp
其实就是 P r i m Prim Prim 的另一种应用
- P r i m Prim Prim 是只存储交叉边的最小值
- D i j k s t r a Dijkstra Dijkstra 是存储交叉边的最小值 + + + 这条边在集合 S 中的点已经记录的值
朴素版:
邻接矩阵
定义 d [ i ] d[i] d[i] 表示从起点到当前i号点的最短路径的长度
将顶点分为两个集合, S S S 和 V ? S V-S V?S,其中 S S S 表示已经更新了最短路径长度的顶点集合
迭代更新过程:依次更新每一个结点,对于当前结点
v
i
v_i
vi?,在集合
S
S
S 中的所有结点中,选择其中到当前结点路径最短的顶点
v
j
v_j
vj?,则 d[i]=d[j]+edges[j][i]
时间复杂度: O ( n 2 ) O(n^2) O(n2)
堆优化:
邻接表
时间复杂度: O ( e log ? e ) O(e \log e) O(eloge)
算法标签: d p dp dp
多阶段决策共 n n n 个阶段,
dp[i][j]
表示每一个阶段 k k k,从 i i i 到 j j j 的选择前 k k k 个顶点后的最短路径的长度
对于当前阶段 k k k,我们利用阶段 k ? 1 k-1 k?1 的状态进行转移更新,其实就是对于新增加的顶点 v k v_k vk? 是否选择的过程
dp[i][j] = dp[i][k] + dp[k][j]
dp[i][j]
就是
k
?
1
k-1
k?1 状态下的 dp[i][j]
定义:只支持查询和修改,不支持删除与插入
结合顺序查找与分块查找的一种方法
定义:根结点比左子树所有结点的值都大,比右子树所有结点的值都小。关键字唯一
操作:查找、插入、删除
判定:想要判定一棵二叉树是否为二叉排序树,只需要判断中序遍历的结果是不是递增的即可,可以采取中序遍历序列比对的方法,也可以在递归遍历二叉树的过程中通过记录前驱结点的值直接进行比较判断。时间复杂度: O ( n ) O(n) O(n)
定义:平衡因子为左子树的高度 - 右子树的高度,平衡二叉树的平衡因子绝对值 <= 1
构建:当插入结点进行构建时出现了有结点平衡因子的绝对值超过了1,则进行“旋转”调整,旋转共分为4种
定义:装填因子 α = n m \alpha=\frac{n}{m} α=mn?,其中 n n n 表示待填入表中的结点数, m m m 表示哈希表的空间大小
哈希函数应该满足以下两点:第一、映射出来的地址不会越界;第二、映射出来的地址是唯一的
常用的哈希函数
直接地址法 - 线性函数一对一映射
优点。计算简单且不可能产生冲突
缺点。对于空间的要求极高,如果数据过于离散,则会造成很大的空间浪费
数字分析法 - 按照数位中的数值分布情况进行哈希
缺点。需要预先知道数据的数字分布情况
平方取中法 - 对于 1 0 m 10^m 10m 的哈希空间,可以将数字平方后取中间 m m m 位进行哈希存储
折叠法
移位法:将一个数字按照数位拆分为几个部分,然后将几个部分的数值累加出一个数即可,高位抹去不用
间隔法:与移位法几乎一致,只不过将其中的部分意义间隔的进行数值反转,最后累计即可,高位抹去不用
除留余数法 - 按照数值 mod? p \text{mod}\ p mod?p 后的数值进行哈希,假设哈希表空间大小为 m m m ,则 p p p 一般取 ≤ m \le m ≤m 的质数
处理冲突
按照构造相同的逻辑进行查找即可
关键字:
稳定性:
内外排序:
基于交换的思路进行
稳定的
选择第 1 小的数放在第一个位置,…,选择第 i 小的数放在第 i 个位置
共选择 n-1 次
[1,i]
等价于先排好 [1,i-1]
,然后插入当前 num[i]
即可稳定的
基于插入直接排序的优点:
于是希尔(Shell)就得出来以下的希尔排序算法:
不稳定
分治法三步骤:divide、conquer and combine
每次选择一个 pivot 进行 partition,递归两个 partition
void Sort(int l, int r) {
if (l >= r) return;
int i = l - 1, j = r + 1, x = a[l + r >> 1];
while (i < j) {
while (a[++i] < x);
while (a[--j] > x);
if (i < j) swap(a[i], a[j]);
}
Sort(l, j), Sort(j + 1, r);
}
不稳定
堆与堆排序的定义
首先我们得知道什么是堆结构。堆是具有下面性质(对于任意的 1 ≤ i ≤ n / 2 1\le i \le n/2 1≤i≤n/2 )的完全二叉树
k i ≤ k 2 i 、 k i ≤ k 2 i + 1 k_i \le k_{2i}、k_i \le k_{2i+1} ki?≤k2i?、ki?≤k2i+1? 叫做 小顶堆
k i ≥ k 2 i 、 k i ≥ k 2 i + 1 k_i \ge k_{2i}、k_i \ge k_{2i+1} ki?≥k2i?、ki?≥k2i+1? 叫做 大顶堆
因此一个堆结构可以采用线性的单元进行存储与维护
而堆排序利用堆顶是最值这一性质,通过不断的取堆顶,调整堆的方式获得最终的排好序的序列
建立初始堆
由于完全二叉树中,每一个叶子结点都已经是堆结构,因此直接从第一个非叶子结点开始建堆即可。对每一个元素与左孩子、 右孩子进行比较
- 如果当前结点的值比左右孩子都大,那么无需修改,当前位置就是堆顶
- 如果当前结点的值比左孩子或者右孩子中的最大值小,则将最大的孩子作为堆顶,并将当前值不断的“下沉”即可
交换堆顶与记录位置后重新建堆
交换记录值获取当前堆中最值以后,需要将除了已记录的值的结点以外的所有结点重新调整为堆结构
- 调整为堆结构的过程与上述初始建堆的过程完全一致,只是结点数每次 -1
时间复杂度 O ( n log ? n ) O(n \log n) O(nlogn)
不稳定
递归
同样采用分治法,我们按照分治法的三个步骤进行讨论
- divide: 将当前序列划分为左右两部分
- conquer: 递归处理上述划分出来的两部分
- combine: 归并上述递归完的两部分
时间复杂度 O ( n log ? n ) ← T ( n ) = 2 T ( n 2 ) + O ( n ) O(n \log n)\leftarrow T(n)=2T(\frac{n}{2}) + O(n) O(nlogn)←T(n)=2T(2n?)+O(n)
非递归
就是模拟上述递归的过程,可以拆分为三步
- 归并
- 按照指定的长度处理整个序列
- 划分局部排序的长度
稳定的