数据结构学习笔记
内容是后续所有学习的最基础
tag: #ComputerScience/basic
本总结图片大部分来自Uc berkely CS61B课程课件 ——在此感谢professor Hug和其他课任老师。
绪论
基本概念
数据
保存信息的载体
数据元素
数据对象的基本单位。
数据项
数据元素的最小单位。
数据对象
具有相同性质的数据元素。
数据类型
原子类型
其值不可再分
结构类型
其值可再分
抽象数据类型
抽象数据和其对应的操作所组成
数据结构的定义
其数据元素之间具有一种或多种特定关系的集合。且数据元素之间的关系称之为结构。
数据结构的定义由逻辑、存储结构和数据的运算三部分组成,所以其数据类型表示最好是抽象数据类型。
注意:
一个算法的设计基于数据的逻辑结构;算法的实现基于数据的存储结构。
数据结构三要素
数据的逻辑结构
线性结构
线性表:普通顺序表、栈和队列、数组、串。
非线性结构
非线性表:集合、树形结构、图的结构。
从逻辑上来说:线性结构就是一个萝卜一个坑;非线性结构就是不同品种萝卜的种在不同的田。
数据的存储结构
顺序存储结构
数据对象的数据元素在逻辑上和物理上的存储是连续的。
链式存储结构
数据元素之间逻辑上连续,物理上不连续。
索引存储结构
利用索引表,构建的索引项(由数据的关键字和地址组成)
散列存储结构
又称Hash存储,使用关键字确定数据元素的存储地址。
数据的运算
在数据上的运算的定义
作用于逻辑结构,指出运算的功能。
在数据上的运算的实现
作用于存储结构,指出具体的操作步骤。
算法
五个特征:有穷性(算法有在有限时间内执行和退出、可行性(算法的操作可理解性、确定性(步骤明确、输入、输出
五个目标:正确性、可读性、健壮性、效率和低存储量要求。
时间复杂度
一个语句的频度:指一个算法中一条语句被重复执行的次数。
\(T(n)\):指的算法中所有语句的频度之和。
通常来说,我们指算法复杂度,是指算法中,依赖问题规模n(输入量),增长速度最快的函数\(f(n)\)的频度\(O(f(n))\),且与问题规模n成正比,所以说\(T(n)=O(f(n))\)
例子:递归调用
对于线性递归函数,其\(f(n)= f(n-1)+c\),其\(O(f(n+c)=O(n)\)
对于非线性递归函数,其\(f(n)= f(n-1)+f(n-2)\),其调用结构更类似于执行树,那么久类似于完全二叉树计算结点数,当树具有n层,其结点总数=2n-1,故其时间复杂度为\(O(2n-1)\)
空间复杂度
算法所需耗费的存储空间\(S(n)=O(g(n))\)
其包含了算法中的变量、常量、指令和输入数据外,还包含了对数据进行操作时所需的辅助空间。
摊销运行时间
摊销运行时间(Amortized Time)是一种平均性能度量,用于评估算法或数据结构在一系列操作中的性能表现。它不仅考虑单个操作的最坏情况,还考虑一系列操作的总体性能。
摊销运行时间通过将总操作次数分摊到每个操作上来计算平均性能。这意味着一些操作可能会花费更多时间,但在后续操作中会花费更少时间,以平衡总体性能。
通常,摊销分析的步骤如下:
- 首先,确定一系列操作的序列。例如,对于数据结构,可能有一系列的插入、删除或查询操作。
- 然后,对于这些操作,计算它们的总运行时间,即执行所有操作所需的总时间。
- 最后,将总运行时间分摊到每个操作上,得到摊销时间。
摊销运行时间的好处在于它提供了对算法或数据结构在实际使用中的整体性能的更准确估计。它通常用于分析动态数据结构,例如动态数组、栈、队列以及各种平衡树(如红黑树和伸展树)等。通过摊销分析,我们可以更好地理解这些数据结构的平均性能,而不仅仅关注单个操作的最坏情况。
线性表
顺序表
是相同数据类型的有限序列。
存储结构为逻辑和物理上元素都是相邻。
位序和数组下标区别
位序:是指数组索引从1开始。
数组下标:是指数组索引从0开始。
静态分配
使用数组进行建立。
缺点:
数组大小不可更改。
动态分配
使用指针,malloc开辟连续堆空间存储,不使用时需要使用free释放空间。
缺点:
增加拓展顺序表时,需要使用辅助空间,导致空间复杂度高。
特点
随机访问:访问元素的时间复杂为O(1)。
存储密度高:动态分配中不仅有data还有该data指针(存储的地址)。
拓展容量不方便。
插入、删除元素不方便。
操作
创建表并初始化、删除表
createlits
initatelist
destroylist
插入、删除某元素
insertlist
deletelist
查找
按位查找
按值查找
总代码
#include<iostream>
using namespace std;
#define Maxsize 10
//创建动态分配顺序表
typedef struct {
int* data;
int length;
}sqlist;
//创建静态分配顺序表
typedef struct {
int data[Maxsize];
int length;
}matrix_sqlist;
//初始化顺序表
//静态类型
template<typename T> void InitialList(T& l,int Init_len)
{
for (int i = 0; i < Init_len; i++) {
l.data[i] = 0;
}
l.length = Init_len;
cout << "Initial matrix_sqlist is ok!" << endl;
}
//销毁动态顺序表
template < class T > bool DestroyList(T& l) {
free(l.data);
return true;
}
//求表长
template<class T> int LenList(T l) {
return l.length;
}
//查询表是否为空
template < class T > bool isEmptyList(T l) {
if (l.length == 0) {
return true;
}
return false;
}
//插入新元素于顺序表中,使用从尾部移动,腾出位序i位置的方法
template <class T> bool InsertElem(T& l, int index, int e) {
//如果插入元素的位序小于1或大于数组最大长度,返回false
if (index < 1 || index > Maxsize - 1) {
return false;
}
//如果顺序表的长度已经达到最大,则无法插入新元素
if (l.length == Maxsize) {
return false;
}
for (int j = l.length; j > index; j--) {
l.data[j] = l.data[j - 1];
}
l.data[index] = e;
++l.length;
return true;
}
//删除元素
template <class T> bool DeleteElem(T& l, int index) {
if (index < 1 || index > Maxsize - 1) {
return false;
}
for (int j = index; j < l.length - 1; ++j) {
l.data[j] = l.data[j + 1];
}
--l.length;
return true;
}
//按位查找
template < class T> int GetElem(T l, int index) {
if (index < 1 || index > Maxsize - 1) {
return 0;
}
return l.data[index-1];
}
//按值查找
template <class T > auto LocateElem(T l, int value) -> decltype(value) {
;//return -- > index
for (int i = 0; i < l.length; ++i) {
if (l.data[i] == value) {
return i;
}
}
}
//打印顺序表中已有元素
template<class T> void PrintList(T l) {
for (int i = 0; i < l.length ; ++i) {
cout << l.data[i];
}
cout << endl;
}
int main() {
//测试静态顺序表
matrix_sqlist mq;
InitialList(mq,3);
InsertElem(mq, 1, 1);
InsertElem(mq, 2, 2);
cout <<"顺序表长度:"<< LenList(mq) << endl;
PrintList(mq);
DeleteElem(mq, 1);
PrintList(mq);
cout <<"Find ELemt index:"<< LocateElem(mq, 2) << endl;;
cout <<"The index is value in the sqlist:"<< GetElem(mq, 2) << endl;;
//测试动态顺序表
sqlist ql;
ql.data = (int*)malloc(sizeof(int) * Maxsize);
InitialList(ql,3);
PrintList(ql);
}
树
术语
术语 | 解释 | 注明 |
---|---|---|
层次 | 从根结点开始数到结点所经历的层数 | 根结点为第一层 |
树的深度 | 树中结点的最大层次称为树的深度或高度 | 数的方向:root->node |
树的高度 | 树中结点的最大层次称为树的深度或高度 | 数的方向:root <- node |
average depth | 树的平均深度是每个节点深度的平均值。\(\frac{每个节点深度之和}{结点总数}\) | |
结点的深度 | 从根到指定结点的深度 | |
结点的高度 | 指定结点到根所经历的路径数 |
树的深度和高度,与结点的深度和高度的概念不一样!!!
区分:
m叉树和度为m的树:
度为m的树 | m叉树 |
---|---|
其至少有一个结点的度为m,任意结点的度\(\le\)m | 任意节点的度\(\le\)m |
其高度h时,至少有m+h-1个结点。 | 允许所有结点的度都小于\(<\)m |
一定是非空树,至少有m+1个结点 | 可以为空树 |
高度为h的m叉树,至少有h个结点。
高度为h、度为m的树,至少有h+m-1个结点
树常考性质
- 结点数的总度数=结点数-1
- 度为m的树种第i层上至多有\(m^{i-1}\)个结点。
- 高度为h的m叉树至多有\(\frac{m^h-1}{m-1}\)个结点(完全m叉树)
- 具有n个结点的m叉树的最小高度\(h=\log_m({n(m-1)}+1)\)
二叉树
完全二叉树
满二叉树可以是完全二叉树。
其特点是,若有度为1的结点,其只有左子树,没有右子树。
当前结点编号i=偶数时,父结点编号为\(\frac{i}{2}\),左子树为\(2i\),右子树为\(2i+1\),当前结点为i/2的左孩子;
当前结点编号i=奇数时,父结点编号为\(\frac{i-1}{2}\),左子树为\(2i\),右子树为\(2i+1\),当前结点为\(\frac{i-1}{2}\)的右孩子;
满二叉树
其所有结点的度为2,总结点个数为\(2^h-1\)(h为高度)。
二叉树常考性质
- 在非空的二叉树的前提下,叶子结点个数=度为2的结点数+1。\(n_0=n_2+1\)
- 非空二叉树第k层上至多有\(2^{k-1}\)个结点。
- 高度为h的二叉树至多有\(2^h -1\)个结点。
- 结点数为n的完全二叉树的高度\(h=log_2(n+1)或者log_2n +1\).
性质4,前一个从二叉树的总结点数角度看(当前结点应比h-1层结点总数多,比h层结点总数小或等于(\(2^{h-1}-1 < n \le 2^h-1\)) ),后者从二叉树高度当前h的结点数看(\(2^{h-1}\le n < 2^h\))。
二叉树访问节点方式
对二叉树的结点进行先中后序遍历。
使用递归分支法
从根开始使用遍历法,对有子树的结点进行同方法展开。
使用递归调用时访问根结点次数法检测遍历方法
对根节点记录的第一次访问是,前序遍历;
对根节点记录的第二次访问是,中序遍历;
对根节点记录的第三次访问是,后序遍历;
前序遍历
如上方法所示:前序遍历为DLR,则遍历打印的节点应为 [父结点] [左孩子] [右孩子]这样的组合重复出现
中序遍历
如上方法所示:中序遍历为LDR,则遍历打印的节点应为 [左孩子] [父结点] [右孩子]这样的组合重复出现
后序遍历
如上方法所示:后序遍历为LRD,则遍历打印的节点应为 [左孩子] [右孩子] [父结点]这样的组合重复出现
层序遍历
利用队列,保存每次访问结点的左右子树,从而保证根结点对子树是横向的节点访问。
while(! IsEmpty(p)){
Dequeue(Q,p);//Q为队列,p为当前结点;
operater(p);//对当前结点进行的操作
if(p->lchild != NULL){
EnQueue(Q,p->lchild);//将左孩子加入队列
}
if(p->rchild != NULL){
EnQueue(Q,p->rchild);//将右孩子加入队列
}
}
由二叉树的遍历序列构造二叉树
仅有一个遍历序列,是无法构造二叉树。且每个遍历序列组合应都有中序。
前序+中序遍历序列
由前序确定根结点(从左往右,中序确定左右子树结点集合
后序+中序遍历序列
由后序确定根结点(从右往左,中序确定左右子树结点集合
层序+中序遍历序列
由层序遍历(从左往右确定第一层的根结点和子树),依靠中序确定左右子树。
前序、后序、层序序列两两组合无法唯一确定二叉树。
线索二叉树
根据前、中、后序某一个序列,将二叉树n个结点的n+1个空指针域连其前驱和后继。
利用ltag和rtag标志,标识其左右子树是否为前驱或后继。
土方法寻找前驱:
使用pre树节点变量,存储当前面访问节点q的前驱。当访问节点q与所求节点p相等时,此时的pre为p的前驱。
void LDR(BiTree T){
if(T != NULL){
LDR(T->lchild);//遍历左子树
visit(T);//访问根结点
LDR(T->rchild);//遍历右子树
}
}
void visit(BiTree *q){
if(q == p)//是否递归到需要查找的结点
final = pre;//是,此时的前驱为pre
else
pre = q;//否,将q作为前驱
}
线索化建立
//当前根结点处理 , pre 保存当前结点前驱
void visit(BiTree* p,BiTree &pre){
if(p->lchild == NULL){//左子树线索处理
p->lchild = pre;
p->ltag = 1;
}
if(pre != NULL && pre->rchild != NULL){//右子树线索处理
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
}
二叉搜索树BST
其特点在于左孩子<父结点<右孩子
常见的操作为:查找、插入、删除
基本操作
判读根结点是否为空
传递的key与当前根结点T.value比较,小于左子树递归,大于右子树递归
需要注意的操作——删除
删除有三种情况:
- 无孩子,此时直接删除即可
- 有一个孩子,把该孩子节点替换掉删除的父结点即可
- 左右孩子都存在,则此时看左子树,查找左子树中最大的一个节点,或者,右子树中查找最小的一个节点。用其中一个与删除的父结点替换即可。
例如:
此时我要删除根结点k,则此时我在左子树中找到它的最大节点g,在右子树中找到它的最小节点m,则此时可以选择其中一个去替换根结点。
启发
我们可以用二叉搜索树建立set和map这样的数据结构。
性质
高度和平均深度决定了 BST 操作的运行时间。
高度决定了优化节点的最坏情况运行时间,而平均深度决定了搜索操作的平均情况运行时间。
需要注意的地方
当我们去检测一个树结构是否为BST时,我们应该使用min,max分别代表左子树最小节点和右子树最大节点,与根进行比较,防止局部满足BST要求而全局没有满足。
更改前:
此时我们检测这个数组,会发现该检测BST的函数只体现了局部性满足。
更改为如下图:
不相交集
定义
不相交集(也被称为"不相交集数据结构"、"并查集"或简称"DSU")用于表示一组不相交(互不重叠)的集合,其中每个元素属于一个集合。不相交集主要用于解决与将元素分成集合、检查元素之间的连接性以及在集合上执行高效的合并和查找操作相关的问题。
个人理解的话:就是将数组看作以树为逻辑结构进行处理元素。
不相交集数据结构关联的关键操作包括:
- MakeSet(x):创建一个包含元素
x
的新集合。 - Find(x):返回包含
x
的集合的代表(根)元素。此操作有助于确定两个元素是否属于同一个集合。 - Union(x, y):将包含元素
x
和y
的集合合并为一个单一的集合。此操作确保两个集合中的所有元素都连接在一起。
这些操作的效率对于各种算法和应用非常关键。不相交集数据结构的常见用途包括:
- 检测和处理图中的连通分量。
- 实现算法,例如Kruskal的最小生成树算法和Tarjan的强连通分量算法。
- 解决涉及分割和聚类的问题,例如图像分割。
不相交集的数组格式
在数组中索引就对应哪个节点,而该节点下在数组中的元素,则表示为它的父结点(当为正时,其有父结点。当为负数时,其没有父结点,表示自身为根结点,且数的绝对值表示子结点的个数)
a[i]={1,-1},如:a[0]表示节点0,且有父结点。a[1]表示为根节点,其子节点有一个。
是否为不相交集的判断
不相交集总是用树进行构建,故其结点不能含有循环。
路径压缩方法
QuickFind(快速查找)
使用整数数组来跟踪每个元素属于哪个集合。
QuickFind 使用一个整数数组来跟踪每个元素属于哪个集合。它通过维护一个数组,使得数组中的每个索引位置代表一个元素,其对应的值表示该元素所属的集合。合并(Union)操作在 QuickFind 中是比较耗时的,因为需要遍历整个数组来更新元素的集合信息。
QuickUnion(快速联合)
QuickUnion 存储每个节点的父节点,而不是直接存储元素所属的集合。合并操作通过将一个根节点的父节点指向另一个根节点来实现。这种方式比 QuickFind 更高效,但在某些情况下可能会导致树的不平衡。
WeightedQuickUnion(WQU:加权快速联合)
WeightedQuickUnion 与 QuickUnion 类似,但它通过集合的大小(节点数)来决定哪个集合合并到哪个集合。具体来说,它会将小的集合合并到大的集合中,以减少快速联合中出现connect树的高度的情况,从而降低了查找的时间复杂度。
源至UCB CS61B:初始connect的时候,使用较小数作为两个数的连接集合子树的根。
使用inverse Ackerman进行路径压缩
当我们纯在一个链表为1->2->3->4时,我们使用了该方法后,其变为以1为根,子节点为2,3,4的三叉树
代码:
public int find(int val){
int p = parent(val);
if(p == val){
return val;
}else{
int root = find(p);
setParent(val,root);
return root;
}
}
判断加权快速联合
-
在本方法中,我们始终用子节点数多的作为根。也就是说子节点数少的根结点去连接子结点数多的根结点。
-
用方法构成的不相交集树,其树高只能小于等于\(logN(N=SumOfItems)\)。
优化:WeightedQuickUnion with Path Compression(WQU with path compression )
这是 WeightedQuickUnion 的改进版本。它在查找操作中执行路径压缩,即将查找路径上的每个节点的父节点直接设置为集合的根节点。这样做可以进一步降低树的高度,从而提高了查找操作的效率。
Implementation | isConnected |
connect |
---|---|---|
Quick Find 快速查找 | Θ(1) | Θ(N) |
Quick Union 快联 | O(N) | O(N) |
Weighted Quick Union (WQU) 加权快速联合 (WQU) | O(log N) | O(log N) |
WQU with Path Compression 带路径压缩的 WQU | O(α(N))* | O(α(N))* |
B数
定义
前提:B数是建立在BST规则之上,加了一个平衡树的条件。
有时也称为2-3-4树或2-4树,即一个节点可以有2、3或4个子结点。而每个节点设置为两个元素设置为2-3树。
2-3树也就是指某个子树的根结点保存有2个元素,而叶子结点为3个元素。
2-3-4树同理。
B数的构造方式不变量:(重要)
由于 B 树的构造方式,它们有两个不变量:
- 所有叶子到根的距离都相同。
- 具有 k 个元素的非叶节点必须恰好有 k + 1 个子节点。
当插入新节点发生不平衡时,如下:
调整方法
当新添加的节点导致叶子结点数大于父结点里的元素个数+1时,我们把新添加元素所在的叶子结点元素列中,把第二个大的元素提到父结点,然后把被提元素的左边元素作为一个新的叶子结点连接该父结点。如上图变化所示。
注意:其中的也有提高高度问题。
B数结点数设置理解
有时候我们会对B树结点中的元素个数设置为L。(常见L=2、3)
例如下图中:
-
2-3-4树指结点元素最多3个,孩子节点最多4个。
-
2-3树指结点元素最多2个,孩子节点最多3个。
红黑树
旋转树
也就是将节点进行旋转
左旋
左上旋步骤:
在上图中,当我们想进行左上旋转时
- 选择需要旋转的节点G。
- 拆离G的右子树为x。
- 将G的右子树的左孩子的子树重新作为G的右孩子。
- 最后将G树作为x的左子树。
代码:
private Node rotateLeft(Node G){
Node x = G.right; //将旋转的节点G的右子树拆离,作为后续返回的新树x
G.right = x.left; //将该拆离的右子树P的左孩子k所在的子树,重新作为G的右孩子
x.left = G; //而G作为新树左孩子
return x;
}
右旋
右下旋步骤:
在上图中:我们将刚才左上旋后的结果进行右下旋,将树形状还原。
- 选择要旋转的节点P
- 与左旋同理,将要旋转的部分进行拆离,此处拆离P的左子树G作为新树x的根
- 拆离树的右子树k,重新接入指旋转节点的左子树
- 最后将重组的P子树接入到G的右子树完成右下旋。
代码
private Node rotateRight(Node P){
Node x = P.left;//P的左子树进行拆分,准备重组
P.left = x.right;//将左子树的右孩子所处的子树,重新接入到旋转节点P的左子树
x.left = P;//将旋转节点重新接入到新树的右子树,完成旋转
return x;
}
理解
左上旋是将旋转节点作为右子树的左孩子。左旋提的是右孩子
右下旋是将旋转节点作为左子树的右孩子。右旋提的是左孩子
步骤简记:左(右)旋拆右(左)子树,右(左)子树的左(右)孩子加在旋转点的右(左)孩子,旋转点作拆的子树左(右)子树.
作用
通过左右旋的轮换执行,使树变成平衡树。
树经过左右旋转后,仍保持着节点规则:
创建LLRB树(Left-leaning:左倾红黑树)
其本质就是将2-3树改为普通的BST树。
思想转变过程
- 创建“粘合(glue)”节点,将原本为结点中的两个元素拆分为该粘合节点的左右孩子,而该粘合节点也就表示其左右孩子为同一个节点的元素。但这样会占用更多的空间以及代码不易读。
- 此时我们想到,若将该“粘合”节点进行左上旋,那么就把上图中f节点提到粘合结点的位置,而左孩子d为其f的左子树,并且f原本的左子树在d的右子树上,如图:
这样,不就使原本的2-3树变成了BST树。同时保证了BST树节点构成的规则。以及“原本的粘合节点”变成了“红色的左链接线”。
红色的链接线表示两元素为2-3树中的属于同一个节点的元素意思。
性质(重要)
-
在B树的限制要求基础上
-
没有右红色链接,也没有一个节点有两个红色链接!
-
每一个LLRB树与一个2-3树一一对应,通过红色左链接线判断(顺便检测2-3树个数)。(而2-3-4树与标准红黑树保存对应)
-
从根到叶子的每条路径都有相同数量的黑色链接(因为 2-3 棵树到每个叶子的链接数量相同)。
-
LLRBT高度不超过对应2-3树的\(2\times height +1\)。(height=2-3树的高度)
解释:
LLRB树插入节点后的情况
我们在插入新的节点时,都是使用红色的链接线与新节点相连。插入方式与BST相同。
1.插入新的节点导致树变得右倾
如上图所示,在左倾红黑树中不可能含有右红链接。
解决方法:E使用左旋将右红链接变为到左红链接即可(新插入节点的父结点作为新节点的左孩子)。
在cs61b中,所说的3节点是指转为2-3树后,叶子结点的元素由三个。同理,4个结点是指转2-3树后的叶子结点有4个元素。
2.插入两个新节点且都作为左链接
如上图所示:我们有两个新添加的左红链接的节点
解决方法:将该两个左红链接的节点都变为同一个父结点的叶子结点。Z进行右旋
3.颜色翻转
即刚才2情况我们构建完成后的新添加的节点都为叶子后。
我们将其父结点向上推一级。使其它与它的父结点在同一节点内。
子链接变黑色,父链接变红色。(其实也就是在2-3树中出现不平衡情况的节点调整:将处于BST父结点的节点上提至更高一级的父结点,而原同级元素作为左右孩子节点)
如下图:也就是更改个红色链接
注意
我们需要注意的是,我们插入结点后通常需要经过几个转换,才能完成转换。
例如:
当我们经过颜色翻转后,我们发现树仍为右倾,此时我们只需要把右倾进行左上旋即可。
插入LLRB的代码
private Node put(Node h, Key key, Value val) {
if (h == null) { return new Node(key, val, RED); }
//本部分为BST插入结点的比较
int cmp = key.compareTo(h.key);
if (cmp < 0) { h.left = put(h.left, key, val); }
else if (cmp > 0) { h.right = put(h.right, key, val); }
else { h.val = val; }
//后部分才为LLRB的插入点情况的解决
if (isRed(h.right) && !isRed(h.left)) { h = rotateLeft(h); }
if (isRed(h.left) && isRed(h.left.left)) { h = rotateRight(h); }
if (isRed(h.left) && isRed(h.right)) { flipColors(h); }
return h;
}
例题
按BST规则,顺序插入7-1构建左倾红黑树。
结果:
复杂度
由于左倾红黑树与 2-3 树具有 1-1 对应关系,并且始终保持在 2-3 树高度的 2 倍以内,因此操作的运行时间将花费\(logN\)时间。
总结
二叉搜索树很简单,但它们容易不平衡,从而导致运行时很糟糕。 2-3 树(B 树)是平衡的,但实现起来很繁琐。
-
使用三个基本操作来保持平衡结构,即向左旋转、向右旋转和颜色翻转。
-
LLRB 与 2-3 树保持对应,标准红黑树与 2-3-4 树保持对应。
-
Java的 TreeMap 树形图 是一棵红黑树,对应2-3-4树。
-
2-3-4 树允许在任一侧进行粘合链接(请参阅 Red-Black Tree 红黑树)。
注意:
如果旋转不影响最底层的叶子,则树的高度保持不变。
如果旋转影响最底层的叶子,则树的高度可能会增加或减少。
旋转只是改变树的结构而非修改结点个数。
当旋转节点为树的根时,可能改变根。
哈希
哈希码定义
能够将对象转换为数字。其函数值的应均匀分布在所有整数的集合中。计算速度理想状况为O(1).
特点
确定性:
对于有效的哈希码必能进行查找到。
一致性:
对于同一个元素对象的哈希码一定是相同的。
哈希表
定义
哈希表是一种将哈希函数与数组可以在常数时间内建立索引的事实相结合的数据结构。使用哈希表和映射抽象数据类型,我们可以构建一个 HashMap
,只要我们知道键属于哪个存储桶,它就允许对任何键值对进行摊销常数时间访问。
发生冲突处理
线性探测
外部链接
对于不同元素的同一哈希码,我们另一个解决方法,是将这类元素放在一个集合之中,相当于另建一个元素的查找表,把该表的地址存放在哈希表中。当需要查找时,我们可先比较哈希码,再通过哈希码对应保存的数组首地址。前往实际保存元素的数组中查找。
而另保存同一个哈希码却不同元素的数据结构有以下类型:
1.链表;2.ArrayList数组;3.Arrayset集合4.其他自己想的
实际元素转哈希码,并保存在哈希表中的例子
如下图所示:我们先将汉字转为哈希码,再将哈希码的有效部分利用除余计算得出。再根据计算结果在哈希表中查找位置,并根据该元素所利用存储元素的结构进行再遍历查找即可(一般在哈希表中,保存同一个元素的结构为链表,其哈希表元素保存着对应链表的首地址)
哈希表大小调整
当我们设定的哈希表所遇到同一哈希码的冲突越来越多时,其对应的链表元素也越来越多。导致实际元素分布不均匀。影响查询速度。
负载因子
负载因子(load factor)=插入的元素数量除以数组总长度
通过负载因子的设定,当负载因子大于设定的最大负载因子值时,我们将根据规则调整哈希表的大小,以及所有元素将重新定位。
一般Java中的最大负载因子为0.75,以及调整时数组长度加倍设定。
哈希表性能
检测x是否存在 | 添加x | |
---|---|---|
不具有负载因子重组,仅使用取余 | \(\Theta(N)\) | \(\Theta(N)\) |
具有负载因子进行重组条件时: | \(\Theta(1)^+\) | \(\Theta(1)^{*+}\) |
\(^+\) | 表示元素分布均匀 | |
\(^*\) | 表示平均情况下 |
在cs61b中给出的复杂度:
Text | contains(x) | add(x) |
---|---|---|
Bushy BSTs 满BST | Θ(log N) | Θ(log N) |
Separate Chaining Hash Table with NO resizing 单独的链接哈希表,无需调整大小 | Θ(N) | Θ(N) |
Separate Chaining Hash Table with resizing 可调整大小的单独链接哈希表 | Θ(1) | Θ(1) |
为什么要自定义哈希码
假设我们使用java的默认哈希函数,则其哈希码生成根据随机的地址码得到的。当我们去查找或添加时,有可能会产生副作用。比如:在另一个哈希索引的bucket中添加,但其该元素已经在另一个bucket当中,这样就重复了。
主要就是避免在哈希表中重复!!!
当时用可变元素做bucket存储的对象时,我们不应该改变它,这样会导致我们在求完hashcode后,无法索引到正确位置。
cs61b的老师有介绍Java对于哈希映射当项目数量过多时,用红黑树对bucket进行优化。
优先级队列
能够查看所有节点,但永远只能移除最小属性节点。
堆
小根堆
cs61b中讲述的是小根堆(大根堆也条件就相反)
条件:
最小堆:
每个节点要小于等于其两个子结点。
完整性:
在堆树中,仅允许最底层部分的缺少节点,并且节点应靠左(类似于满二叉树)
对操作
插入
永远是在堆的底部插入新节点,然后再对该节点仅进行检测是否符合最小堆的条件,若不符合使其上浮到正确的位置。
删除
我们永远是删除最小节点,并将底层最靠右的叶子结点进行补充,然后也是进行最小堆条件检测,使其下沉到正确的位置。
注意
父结点在其交换位置时,永远是与最小孩子交换位置。
树的表示种类
BSTMap类型(使用链表)
固定宽度节点
public class Tree1A<Key> {
Key k;
Tree1A left;
Tree1A middle;
Tree1A right;
...
}
可变宽度节点
childern数组保存的孩子节点的地址
public class Tree1B<Key> {
Key k;
Tree1B[] children;
...
兄弟树
节点具有指向兄弟节点地址变量sibling
public class Tree1C<Key> {
Key k;
Tree1C favoredChild;
Tree1C sibling;
...
}
使用数组
使用数组进行表示树,需要树是尽可能完全的,否则会浪费空间。
含有父母数组
其中父母数组保存节点的父母节点索引,而key数组保存的是各个节点的key值。
数组的保存是层级遍历顺序保存。
public class Tree2<Key> {
Key[] keys;
int[] parents;
...
}
仅含节点值的数组
也就是上一个方法没有父母数组,而key数组相同,使用层级顺序保存节点元素。
对于一个完整的二叉树使用数组进行层级顺序保存节点,当我们进行节点查找时:
以下运算都是结果向下取整。
leftChild(k)
= \(k*2\)rightChild(k)
= \(k*2+1\)parent(k)
= \(k/2\)
复杂度
Methods 方法 | Ordered Array 有序数组 | Bushy BST 布希 BST | Hash Table 哈希表 | Heap 堆 |
---|---|---|---|---|
add |
Θ(N) | Θ(logN) | Θ(1) | Θ(logN) |
getSmallest |
Θ(1) | Θ(logN) | Θ(N) | Θ(1) |
removeSmallest |
Θ(N) | Θ(logN) | Θ(N) | Θ(logN) |
树和森林
树的先根遍历
类似于二叉树的先序遍历,先访问根结点,再类似于递归分支法访问左子树和右子树的根结点,循环往复。
树的后根遍历
类似于二叉树的后序遍历,先访问左子树和右子树的根结点,再根类似于递归分支法访问根结点结点,循环往复。
树的层序遍历
类似于二叉树的层序遍历
树边二叉树:
树的兄弟连线,并放在右孩子处。长子在左边。
口诀:左长子右兄弟
森林变二叉树:
先将每个树变为二叉树
哈夫曼树
又称最优二叉树。
是二叉树的应用:它的具有编码场景,将元素集的元素按权值排序,依次从权值集合中选择权值最小的两个.
在构造树时的口诀:左小右大。
在对已经构造好的哈夫曼树下进行编码标注:左0右1.
带权路径长度WPL
1.通过叶子结点计算
\(WPL_总=\sum叶子结点权值*所在层数\)
2.通过数学思维,将所有子树节点相加
\(WPL_总=\sum除层数1的根结点外,其余所有结点权值新加\)
哈夫曼编码长度
将每个字符的编码位数X权值的和。
等长编码长度:\(2^n\)是二进制能表示所有字符数,其长度为nx总权值,
图
一定是非空集合。线性表和树可以为空。
概念:
子图:普通子图:可以不包含全部的顶点和边。
生成子图:必须包含所有顶点,边可以不全部包含。
极大连通子图:指无向图的连通分量。
强连通子图:指有向图的强连通分量。
生成树:包含全部顶点的极小连通子图。
树
常考点:
n个顶点,|E|>n-1,则该图必有回路。
动态查找和静态查找的区别是:
动态查找会添加删除结点。而静态查找只会对原表仅查找操作。
Dijkstra
算法思想:
启发式Dijkstra算法——A*
将所有顶点插入边pq队列中,按d(源节点,v)+h(v,到目标结点的distance)的顺序存储顶点。重复:从pq中移除最佳顶点v,并放宽v中指向的所有边。
例如:
上图中h(v,goal)启发式函数初始为每个节点前往下一个节点的最短路径.。
算法过程
根据上图所示:每次使用出队访问下一个节点前,我们要先计算更新当前结点在Dijkstra数组中的各个值(distTo,edgeTo等等),并且更新队列中(vertex, distance)并排序,在前往下一个weight最小的节点前去除distance最小的元素对,终止条件为遇到节点6(finally distinction)。
结果优化:
- 利用预测去探索。
- 并不是每个顶点都被访问过。
- 结果不是顶点0的最短路径树(3的路径是次优的!),但这没关系,因为我们只关心节点6的路径。
最小生成树
破圈法:也就是将一个些节点连接含有圈的权值较大的边去除,留下权值较小的边。
构建的公共特性:
不能有环。
prime
归并顶点集,每次在已连接的顶点下一个权重最小的边寻找。
kruskal
归并边集,每次在边中寻找最小边。
渐进复杂度分析
当已经给出算法的渐进复杂度后,我们根据代码已有部分和复杂度进行联合分析,得出代码缺少部分的实际代码。
例1
此题,我们需要知道的是,当条件循环变量为2的倍数时,若条件停止为N,则表明条件为\(2^i<N\),则执行时间就能反推为\(\Theta (log_2N)\),也就是循环这么多次。
例2
排序
对于堆排序,其朴素堆排序,是每次寻找最大根将其从堆中删除,添加到输出数组的末尾进入。
而就地堆排序,是在原数组上,逻辑上先将元素看作堆树,自下而上的根据堆条件进行调节元素位置——根据父结点和孩子节点在数组中的索引特点(当前结点为k,其父结点为k/2,孩子节点分为为2k,2k+1),将其比较,设置为大根堆。
合并排序和堆排序,需要先将数据进行划分组合,才会再排序。
在拆分少于15个元素时,进行插入排序,快于合并和堆排序。
选择排序
每次将j找到的最小或最大的元素放到i处。
插入排序
外层设定为i(选取未定元素),内层为int j =i;i>0;i--。外层未定元素跟内层的已排序元素比较,确定所在位置。为就地排序。
注意
在反向数组中,选择排序仅进行 \(O(N)\) 次总交换(找到最大值,然后交换到前面),而插入排序则按照 \(O(N^2)\)次交换的顺序进行(将每个项目交换到前面),因此插入排序实际上会慢一个常数因子。
堆排序
朴素堆排序
也就是利用大根堆或小根堆的规则(最小堆,根为最大或最小;每次添加节点是添加到叶子结点,且位置从左往右)
选择排序的一种变体是使用基于堆的 PQ 对项目进行排序。为此,请将所有项目插入 MaxPQ,然后将它们一一删除。第一个被删除的项目被放置在数组的末尾,下一个项目就在末尾之前,依此类推,直到最后删除的项目被放置在数组的位置 0 为止。每次插入和删除都需要 O(log N) 时间,并且有 N 次插入和删除,导致运行时间为 O(N log N)。通过更多的工作,我们可以证明在最坏的情况下堆排序是 θ(N log N)。这个朴素版本的堆排序使用 θ(N) 作为 PQ。创建 MaxPQ 需要 O(N) 内存。也可以使用 MinPQ 代替。
就地堆排序
将数组根据大根堆或小根堆的规则和父结点和孩子节点在数组索引特点,进行调节元素位置,使其得以为数组形式存储的堆树。
对数组进行排序时,我们可以通过将数组本身视为堆来避免 θ(N) 内存成本。为此,我们首先使用自下而上的堆构造来堆化数组(花费 θ(N) 时间)。然后,我们重复删除最大项,将其与堆中的最后一项交换。随着时间的推移,堆从 N 个项目缩小到 0 个项目,排序列表从 0 个项目缩小到 N 个项目。得到的版本也是 θ(N log N)。
合并排序
递归拆分元素,最后递归合并元素。
伪代码:
left = sorted(left,mid);
right = sorted(mid,right);
merge(left,right);
merge( []left, []right){
int i,j,k=0,0,0;
[] output;
//定义比较停止条件
while(i<left.lenght || j<right.lenght)
if(left[i]>right[j]){//依次比较拆分的两部分数组取出数的大小,选择小的元素存放在输出数组中
output[k] = right[j];
++k;
++j;
}else{
output[k] = left[i];
++k;
++i;
}
//最后将剩余的数组存存到输出数组中
ouput.append(i for i in right[j:]);
ouput.append(i for i in left[i:]);
return output;
}
总结
- 选择排序:找到最小的项,将其放在前面
- 堆排序:使用MaxPQ找到最大元素,放在最前面
- 归并排序:将两个已排序的半部分合并为一个已排序的整体
- 插入排序:确定当前项插入到哪里
快速排序
每次选定一个主元,将除主元的元素外的元素到上一个主元的索引位,其主元应是小于主元的元素,而后边是大于主元的元素。
最好情况
整体运行时间为\(O(N\ H),H=层数=\Theta(logN)\).即快排最好的情况为\(\Theta(NlogN)\)
最坏的情况
当每个分区选择的主元位于数组的开头时,运行时最坏的情况就会发生。
在这种情况下,每一层仍然需要执行 O(N) 工作,但现在 H = 层数 = N 层,因为每个元素都必须以轴为中心。因此,最坏情况的运行时间是 \(\Theta(N^2)\)。
快速排序与合并排序性能
Theoretical Analysis 理论分析 | Quicksort 快速排序 | Mergesort 归并排序 |
---|---|---|
Best Case | \(\Theta(N log N)\) | \(\Theta(N log N)\) |
Worst Case | \(\Theta(N^2)\) | \(\Theta(N log N)\) |
如何避免快排在最坏情况
因为快排受主元位置影响。如何围绕主元进行分区和添加以加快速度的优化。
比如:
- 已排序好的数组,左侧没有任何元素可划分,主元始终为最小值。
- 逆序排序的数组,右侧没任何元素可划分,主元始终为最大值。
- 所有元素相等,所有元素都讲被划分到左侧或右侧。
快排的性能论据
10%的情况
根据经验假设,主元总是距离任一边至少为10%。
当选择到一个足够靠近中间的主元,则至少有一个距离边缘10%的主元。
快排是BST排序
从快排分区的规则来看,是每次选取一个根结点,其划分小于和大于的区域,其类似于构建BST树。
快排总结
- 随机性:选择一个随机枢轴点,在排序之前对项目进行洗牌
- 更智能的枢轴选择:恒定时间枢轴选择(例如:选择几个,然后从中选择最好的),线性时间枢轴选择(例如:中位数)
- 内省:如果当前排序达到递归深度阈值,则切换到不同的排序
快速排序vs合并排序
- 合并排序比 Quicksort L3S(最左主元,3 扫描分区)和 QuickSort PickTH(中位主元,Tony Hoare 分区)更快
- 快速排序 LTHS(最左主元,Tony Hoare 分区)比合并排序更快!(该排序用两个指针,对主元的两边元素进行交换。当两个指针交叉时停止)
- TH的划分:
- 两个指针,一个位于 items 数组的两端,彼此走向对方
- 左指针讨厌较大或相同的项目,右指针讨厌较小或相同的项目
- 交换他们不喜欢的任何东西;当两个指针交叉时停止
- 新的枢轴 = 右指针处的项目。
Quick Select 快速选择
Quick select helps us quickly find the median with partitioning. Median of an length n array will be around index n /2.
快速选择可以帮助我们快速找到分区的中位数。长度为 n 的数组的中位数将在索引 n /2 左右。
- 以最左边的项作为基准来初始化数组。
- 围绕枢轴进行分区。
- 划分子问题。重复该过程。
- 当枢轴位于中值索引时停止。
预期运行时间: θ(N) 。最坏情况运行时(当数组按排序顺序时): θ(N^2) 。
排序的理论界限
我们已知的排序算法中,最快的排序所花时间复杂度为\(\Theta(N \log N)\) ,
注意
最优排序算法最多需要 \(\Theta(N \log N)\) 时间。我们知道有几种排序(例如,合并排序)在最坏的情况下需要 \(\Theta(N \log N)\)时间。因此,假设的“最佳”排序算法不可能比这更糟糕。
不可能找到一种基于比较的排序算法,其比较次数少于\(\Theta(N \log N)\) 次。这就是讲座中证明的比较排序界限。
不可能找到一种花费少于\(\Theta(N \log N)\) 时间的基于比较的排序算法。每次比较必须至少花费常数时间,因此基于比较的排序的时间复杂度与比较次数具有相同的下限。
对于类似于排列的问题,我们需要将其原子项进行比较的最少次数为\(log_2(N!)\)
排序的稳定性
即经过排序后,元素的位置不会受到改变。
稳定的排序
计数排序、归并排序
排序的不稳定性
就是经过排序后,元素位置会改变。
不稳定的排序有
快速排序
基数排序
基数排序是指在\(\Theta(N+R)\)时间内对,N键内数字或字符(0~R-1之间的整数)进行排序。通过避免任何二进制比较来证明比线性算术要好。
比如二进制的基数为0,1.
LSD排序
在 LSD 算法中,我们按每个数字从右到左排序。需要检查 \(\Theta(WN)\)位,其中 \(W\)是最长密钥的长度。运行时是 \(\Theta(WN+WR)\) ,尽管我们通常将 \(R\) 视为常量,只是说 \(\Theta(WN)\)。运行时的\(\Theta(WR)\) 部分是由于创建了用于计数排序的长度 $R $箭头。
MSD排序
在 MSD 排序中,我们从左到右排序,第一次的排序进行分区,然后并独立解决每个每个分区进行排序。
同样的,对于每个问题通常由\(R\)个子问题。最坏的运行时间和LSD排序的完全相同\(\Theta(WN+WR)\)。
最好的情况为:我们仅需查看顶部字符\((R>N)\Theta(N+R)\)。
计数排序与快排相比的优点
- 对于足够大的N,它比快排排序速度快,(前者为N,后者为\(NlogN\))
- 基数排序是稳定的,因为我们从第一个元素扫描到最后一个元素。
- 基数排序不使用元素之间的比较。
基数排序比合并排序效率高的情况:
- 字符串彼此间过于相似时
- 当排序元素过大时,即N过大,后者为\(O(NlogN)\),比前者慢。
在具有JIT(即时编译器)的情况下合并排序的执行效率比基数排序要高。基数排序的执行效率趋近于O(N),但是需要自己去进行计算基数怎样才最高效。
压缩
无前缀代码
香农-法诺码
霍夫曼编码
霍夫曼编码对无前缀码采用自下而上的方法,而不是香农-法诺码采用自上而下的方法。算法如下:
- 计算相对频率。
- 将每个符号分配给一个节点,权重=相对频率。
- 取两个最小的节点并将它们合并为一个超级节点,其权重等于权重之和。
- 重复直到所有东西都是树的一部分。
大多数压缩技术背后的总体思想是利用序列中任何现有的冗余或顺序来减小数据的大小。但是,如果序列没有现有的冗余或顺序,则可能无法进行压缩。
LZW编码
该算法从一个简单的码字表开始,其中每个码字对应一个符号。每当使用编码时,都会通过将前一个编码与下一个符号连接来创建新的代码字。
总结
时空限制压缩
附录
ucb CS61B学习
Java基础
Java数据类型使用类型
除了基本的八种基本数据类型,其余的数据类型如:数组、对象等都是引用类型。当我们在读变量进行操作时,需要注意到这一点。
数据结构文件规范
当我们创建某个数据结构的源文件并完成代码后,我们应使用一个间接类对该数据结构类进行间接调用,例如:
- 间接调用类SLList对IntNode的调用语句改为
private
,这样使用户调用无法知该数据结构的细节。
//file: SLList
public class SLList{
private IntNode first;
}
//file: Intnode
public class IntNode {
public int item;
public IntNode next;
public IntNode(int i, IntNode n) {
item = i;
next = n;
}
}
- 将该数据结构作为调用类的嵌套类
public class SLList {
public static class IntNode {
public int item;
public IntNode next;
public IntNode(int i, IntNode n) {
item = i;
next = n;
}
}
private IntNode first;
public SLList(int x) {
first = new IntNode(x, null);
}
...
此处细节:将嵌套类声明为 static
意味着静态类中的方法无法访问封闭类的任何成员。即无法访问外层类中的first变量等等。
- 在需要使用IntNode进行计算时,我们可以在间接类部编写一个私有的调用函数,而公开一个调用函数就行(有点类似与接口)
//实际执行者
private static int size(IntNode p) {
if (p.next == null) {
return 1;
}
return 1 + size(p.next);
}
//调用者
public int size() {
return size(first);
}
2.List
在本章节中,我们先后创建了IntNode,SSList
其中使用的细节:
使用间接调用类,对数据结构进行调用和处理,比如,间接调用该数据结构产生新的对象,对该数据结构对象进行处理,也私有后,使用public函数调用private处理函数,起到隔离效果。(比如:使用到哨兵节点,类似头结点)。
对于创建的列表,我们使用头尾结点(哨兵),以至于我们访问头尾元素,以及插入删除头尾结点为常量时间。
其中我们需要注意的是,头结点的next连接首元结点。而尾结点的pre才是连接的真正尾结点。
在创建的节点成员变量中,加上一个pre节点变量。使其list变为双向链表。
在Java中,将类声明加上class DList<T>{},使其该类在实例化时,可指定某些成员变量的数据类型。也就是我们常用的arraylist<>中设定各种数据类型进行自定义元素类型相同。
注意:
在基本类型上实例化泛型,请使用 Integer
、 Double
、 Character
、 Boolean
、 Long
、 Byte
或 Float
而不是它们的原始等效项。
3.数组
反射
我们在Java中使用反射来指定所需字段。
例如:如下所示我们想要的操作,但是以下操作编译器无法通过。我们使用Planet::getFieldOfInterest.
String fieldOfInterest = "mass";
Planet p = new Planet(6e24, "earth");
double mass = p[fieldOfInterest];
//or
String fieldOfInterest = "mass";
Planet p = new Planet(6e24, "earth");
double mass = p.fieldOfInterest;
新学函数
复制数组System.arraycopy
System.arraycopy(b, 0,x, 3, 2)` 相当于Python中的 `x[3:5] = b[0:2]
System.arraycopy
有五个参数:
- The array to use as a source
用作源的数组 - Where to start in the source array
从源数组的哪里开始 - The array to use as a destination
用作目标的数组 - Where to start in the destination array
目标数组中从哪里开始 - How many items to copy
要复制多少项目