网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
规定所有的操作都是在单链表的表头进行的,毕竟栈只能在一端进行操作
Q:是否需要另外设置尾指针?
A:否,尾结点的next------>NULL;可以判断尾结点在哪里,不需要另设;
Q:是否需要另设头结点?
A:否,此时栈顶指针top指向的是栈顶元素,入栈出栈操作可以在O(1)时间内完成,不需要设置头结点;
Q:建立链栈适合采用哪种插入法?
A:头插法;(头插法逆序)
typedef struct LinkStack{
ElemType data;//数据域
struct LinkStack \*next;//指针域
}\*LinkStack;
注意链栈元素出栈是e=top→data;
(三)栈的应用
主要是满足后进先出的特性
1.数制转换
43(10) = 101011(2)
思想:先求出来的余数放在后边
2.括号匹配
思想:自左至右扫描表达式,若遇左括号,则将左括号入栈,若遇右括号,则将其与栈顶的左括号进行匹配,若配对,则栈顶的左括号出栈,否则出现括号不匹配错误。
3.表达式求值(中缀表达式求值)
#优先级最低
思想:例如:4+2×3-10/5
按照运算法则,我们应当先算2×3然后算10/5 ,再算加法,最后算减法。
我们两个栈,一个用于存储运算符称之为运算符栈,另一个用于存储操作数称之为操作数栈。
(1)首先置操作数栈为空,表达式起始符“#”为运算符栈的栈低元素。
(2)依次读入表达式中每个字符,若是操作数则进操作数栈,若是运算符则和运算符栈栈顶元素比较优先级,若栈顶元素优先级高于即将入栈的元素,则栈顶元素出栈(优先级高的先出栈,再把优先级低的放进来),操作数栈pop出两个操作数和运算符一起进行运算,将运算后的结果放入操作数栈,直至整个表达式求值完毕(即运算符栈顶元素和要放入元素均为“#”)
4.迷宫问题
思想:以栈S记录当前路径,则栈顶中存放的是“当前路径上最后一个位置信息”
- 若当前位置“可通”,则纳入路径(入栈),继续前进;
- 若当前位置“不可通”,则后退(出栈),换方向继续探索;
- 若四周“均无通路”,则将当前位置从路径中删除出去。
5.递归调用
5.程序运行时的函数调用
二、队列
队列:
队列是仅限定在表尾进行插入和表头进行删除操作的线性表;
先进先出(FIFIO)
(一)链队列
队列的链式存储结构
默认带头结点;
实际上是一个同时带有队头指针和队尾指针的单链表;
适合于数据元素变动比较大的情况,不存在队满溢出的问题;
插入元素只动rear,删除元素只动front;
#include<iostream>
#include<cstdio>
#include<cstdlib>
using namespace std;
//队列的链式存储类型
//先定义链式队列结点,再定义链式队列
//先搞一颗珍珠
typedef struct QNode{
QElemType data;
struct QNode \*next;
}QNode,\*QueuePtr;
//把珍珠串起来
typedef struct{
QueuePtr front;//队头指针
QueuePtr rear;//队尾指针
}LinkQueue;
//初始化;构造一个空队列Q ;带头结点哦~
bool InitQueue(LinkQueue &Q){
Q.front=Q.rear=(QueuePtr)malloc(sizeof(QNode));
if(!Q.front)
return false;
Q.front->next=NULL;
return true;
}
//判空
bool IsEmpty(LinkQueue Q){
if(Q.front==Q.rear)
return true;
return false;
}
//入队
bool EnQueue(LinkQueue &Q,QElemType e){
//表面上插的是元素,实际上插入的是结点
QueuePtr p=(QueuePtr)malloc(sizeof(QNode));
if(!p)
return false;
p->data=e;
p->next=NULL;
Q.rear->next=p;
Q.rear=p;
return true;
}
//出队
//若队列非空,对头元素出队并用e返回其值
bool DeQueue(LinkQueue &Q,QElemType &e){
if(Q.front==Q.rear)
return false;
QueuePtr p=Q.front->next;
Q.front->next = p->next;
e=p->data;
//若原队列中只有一个结点,删除后变空
if(Q.rear==p)
Q.rear=Q.front;
//Q:这里写成Q.rear=NULL;也行吧
//A:不行!!!队列空的条件是front和rear都指向头结点
return true;
}
int main(){
return 0;
}
(二)顺序队列
分配一块连续的存储单元存放队列中的元素,并附设两个指针,队头指针和队尾指针,队头指针指向队头元素,队尾指针指向队尾元素的下一个位置;
空队列的条件:Q.front == Q.rear==0;
队满的条件:Q.rear==MaxSize
队不满但元素插入完毕:
队列长度:Q.rear-Q.front
对头元素:Q.base[Q.front]
队尾元素:Q.base[Q.rear-1]
顺序队列的假溢出:
队列中有空闲单元,但新元素进入队列无法在O(1)时间复杂度完成(需要移动元素)
解决办法:循环队列
(三)循环队列
循环队列:队列的顺序存储结构;
把存储队列元素的表从逻辑上视为一个环;
当Q.rear== Q.front时,如何区分队列空和队列满:
默认处理方式:
令队列空间中的一个单元闲置,使得在任何时刻,保持Q.rear和Q.front之间至少间隔一个空闲单元;实际上就是让判满的公式改变了一下,Q.rear+1 == Q.front;而队空是Q.rear==Q.front;
队列满: (Q.rear+1)%MAXSIZE == Q.front
队列空: Q.rear==Q.front
队列长度: (Q.rear - Q.front+ MAXSIZE)% MAXSIZE
循环队列
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MAXSIZE 100
using namespace std;
//循环队列的存储结构
typedef struct{
QElemType \*base;
int front;
int rear;
}SqQueue;
//初始化循环队列
bool InitQueue(SqQueue &Q){
Q.base=(QElemType \*)malloc(MAXSIZE\*sizeof(QElemType));
if(!Q.base)
return false;
Q.front=Q.rear=0;
return true;
}
//入队
bool EnQueue(SqQueue &Q,QElemType e){
//将元素e插入队列Q的队尾
if((Q.rear+1)%MAXSIZE==Q.front)
return false;
Q.base[Q.rear]=e;
Q.rear=(Q.rear+1)%MAXSIZE;
return true;
}
//出队
bool DeQueue(SqQueue &Q,QElemType &e){
//删除队列Q的队头元素并用e带回
if(Q.front==Q.rear)
return false;
e=Q.base[Q.front];
Q.front=(Q.front+1)%MAXSIZE;
return true;
}
int main(){
return 0;
}
(四)队列的应用
1.层次遍历二叉树
2.解决主机与外设之间速度不匹配的问题:缓冲区
3.多用户资源竞争问题:CPU的分时
CHAPTER3 串
串:零个或多个字符组成的有限序列;
空串:长度为零的串;
空白串:仅由一个或多个空格组成的串;
空串是任意串的子串,任意串是其自身的子串;
子串的定位运算成为模式匹配或串匹配;
串默认用顺序存储;
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MaxStrLen 256; //预定义最大串长
using namespace std;
//串的定长顺序存储表示
typedef struct{
char ch[MaxStrLen];//每个分量存储一个字符
int length;//串的实际长度
}Sstring;
//顺序串,求子串
//求串S从第POS个位置起,长度为len的子串sub
bool SubString(Sstring &sub,Sstring s,int pos,int len){
//健壮性
if(pos<1||pos>s.length-len+1||len<0)
return false;
//把主串的值赋给子串
for(int i=pos;i<(pos+2);i++){
sub.ch[i]=s.ch[i];
}
return true;
}
int main(){
return 0;
}
顺序串存储存在的问题:
空间大小固定,运算结果截断;
子串的定位运算又称为模式匹配或串匹配;
一、朴素模式匹配算法
思想:从主串、模式串(子串)的第一个位置开始比较(i=1,j=1),若相等,则 i,j 各自+1,然后比较下一个字符。若不等,主串指针回溯到上一轮比较位置的下一个位置,子串回溯到1,再进行下一次比较。(i=i-(j-1)+1)
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MaxStrLen 256; //预定义最大串长
using namespace std;
//串的定长顺序存储表示
typedef struct{
char ch[MaxStrLen];//每个分量存储一个字符
int length;//串的实际长度
}Sstring;
//朴素模式匹配 S:主串 T:子串
int Index(Sstring S,Sstring T){
int i=j=0;
while(i<=S.length&&j<=T.length){//在主、子串有效长度内
if(S.ch[i]==T.ch[j]){
i++;
j++;//继续比较后续字符
}
else{//指针回溯
i=i-j+2;
j=1;
}
}
if(j>T.length)
return i-T.length;
//为啥是i-T.length而不是i-T.length+1;
//因为最后一个相等之后,i,j还会执行一次自增操作
else return 0;//匹配失败
}
int main(){
return 0;
}
匹配成功的最好时间复杂度:O(m)
- 刚好第一个就匹配上了,总对比次数为子串长度
匹配失败的最好时间复杂度:O(n-m+1)=O(n-m)=O(n)
- 匹配成功之前,每一个与第一个字符都匹配失败;
最坏时间复杂度:O(nm-m^2+m)= O(nm)
- 子串除了最后一个对不上,其余的都能对上,则每次遍历完一边后,又要走回头路;
- 直到匹配成功/失败一共需要比较
m*(n-m+1)
m:每次需要移动m次
i需要移动n-m+1次
二、KMP算法
思想:失配时,只有模式串指针回溯,主串指针不变;
next数组求法(手动模拟):
- 前1~j-1个组成串s
- next[j]=s的最长相等前后缀长度+1
- next[1]=0;
- 若位序从0开始,next[j[整体-1
next[j]的含义:实际上是子串的下一个需要比较的位置;
//KMP算法
int Index\_KMP(Sstring S,Sstring T,int next[]){
int i=1,j=1;
while(i<=S.length&&j<=T.length){
if(j==0||S.ch[i]==T.ch[j]){
i++;
j++;
}else{
j=next[j];//发生失配时,模式串指针回溯
}
if(j>T.length)
return i-T.length;
else return 0;
}
}
求next[]的算法
//求next数组
//求模式串T的函数值,并存入数组next
int Get\_Index(Sstring P,int next[]){
int i=1,j=0;
next[1]=0;
while(i<=P.length){
if((j==0)||P[i]==P[j]){
i++;
j++;
if(P[i]!=P[j])
next[i]=j;
else
next[i]=next[j];
}
else
j=next[j];
}
}
CHAPTER4 数组和广义表
一、数组
这部分看王道就行
(一)数组
- 数组是有n(>=1)个相同类型的数据元素构成的有限序列; 是线性表的推广;
- 一维数组可以看作一个线性表,二维数组可以看作“数据元素是一维数组”的一维数组;
- 三维数组可以看作“数据元素是二维数组”的一维数组;
(二)数组的顺序表示
(三)矩阵的压缩存储
1.特殊矩阵
2.稀疏矩阵
找规律算就行
二、广义表(重点)
(一)广义表的定义
广义表是线性表的推广。
L=(a1,a2,…,an ),n≥0,ai可以是单元素,也可以是一个表。
例如:
A = ( ):A是一个空表。
B = (e):B只有一个原子。
C = (a, (b,c,d) ):C有一个原子和一个子表。
D = (A, B, C):D有3个子表。
E = (a, E) = (a, (a, (a,…… , ) ):E是一个递归的表。
广义表 LS = ( a1, a2, …, an )的结构特点:
广义表中的数据元素有相对次序;
广义表的长度定义为表中的元素个数;
广义表的深度定义为表的嵌套层数;
注意:“原子”的深度为 0 ;
“空表”的深度为 1 。
广义表可以共享;
广义表可以是一个递归的表;
递归表的深度是无穷值,长度是有限值。
A = ( ):长度为0,深度为1。
B = (e):长度为1,深度为1 。
C = (a, (b,c,d) ):长度为2,深度为2 。
D = (A, B, C):长度为3,深度为3 。
E = (a, E) = (a, (a, (a, …… , ) ):长度为2,深度为∞ 。
任何一个非空广义表 LS = ( a1, a2, …, an) 均可分解表头和表尾两部分:
表头(Head):第一个元素
Head(LS) = a1
表尾(Tail):除第一个元素外其余元素构成的表
Tail(LS) = ( a2, …, an)
D = ( E, F ) = ((a, (b, c)), F )
Head( D ) = E ; Tail( D ) = ( F )
Head( E ) = a ; Tail( E ) = ( ( b, c) )
Head( (( b, c)) ) = ( b, c) ; Tail( (( b, c)) ) = ( )
Head( ( b, c) ) = b ; Tail( ( b, c) ) = ( c )
(二)广义表的存储结构
CHAPTER5 树
一、基本概念
(一)定义
- 树:n个结点的有限集(树是一种递归的数据结构,适合于表示具有层次的数据结构)
- 结点的度:一个结点的孩子个数
- 树的度:树中节点的最大度数
- 两结点之间的路径:由两个结点之间所经过的结点序列构成
- 两结点之间的路径长度:路径上所经过的边的个数
- 树的路径长度是指树根到每个结点的路径长的总和,根到每个结点的路径长度的最大值是树的高度减1
二、二叉树(必考)
(一)定义
每个结点至多有两棵子树,且二叉树的子树有左右之分,顺序不能颠倒;
(二)性质
- 一个有n个结点的完全二叉树的高度H=[log(n)]+1
(三)存储结构
1.顺序存储
非完全二叉树不适合顺序存储
2.链式存储
二叉链表: 每个结点两个指针域;
三叉链表: 就多了个指向双亲结点的指针域;
(四)用二叉树表示表达式
按运算顺序构造二叉树,然后进行先中后序遍历即可;
用栈对后缀表达式求值:遇到操作数就进栈,遇到操作符就从栈顶弹出两个操作数进行运算,最后运算结果入栈,循环至栈空;
三、遍历二叉树和线索二叉树
(一)层序遍历—队列
先根,后子树,先左子树,后右子树;某一结点出队后,将该结点的子树的根节点入队后,再将队头元素出队;
(二)先中后序遍历(必考)
#include<iosstream>
#include<cstdio>
#include<cstdlib>
using namespace std;
//二叉树链式存储
typedef struct BiTNode{
ElemType data;
struct BiTNode \*Lchild,\*Rchild;
}BiTNode,\*BiTree;//BiTree指向结构体的指针 BiTNode指向结构体的变量
//层序遍历
void Cengxu(BiTNode \*root){
InitQueue(Q);
EnQueue(Q,root);
while(!EmptyQueue){
DeQueue(Q,p);//队头元素出栈
visit(p);
EnQueue(Q,p->lChild);
EnQueue(Q,p->rChild);
}
}
//二叉树先序遍历(递归)
void PreOrder(BiTNode \*root){
if(root != NULL){
cout<<root->data;
PreOrder(root->Lchild);
PreOrder(root->Rchild);
}
}
//中序遍历二叉树(递归)
void InOrder(BiTNode \*root){//root指向根
if(root!=NULL){
InOrder(root->Lchild);
cout<<root->data;
InOrder(root->Rchild);
}
}
//后序遍历二叉树(递归)
void PostOrder(BiTNode \*root){
if(root!=NULL){
PostOrder(root->Lchild);
PostOrder(root->Rchild);
cout<<root->data;
}
}
//先序遍历(非递归) 这里的node就是BiTNode 懒得改了 非重点
void preOrder(Node root) {
if(root==NULL) return null;
Node\* p=root;
Stack<Node\* > s; //建立一个栈 存储node类型
while(!s.empty() || p){
if(p){
cout<<p->data;
s.push(p);
p=p->lchild;
}
else{
p=s.top();
s.pop();
p=p->rchild;
}
}
}
//中序遍历(非递归)
void InOrder\_1(BiTNode \*root){
InitStack(S);//初始化栈
BiTNode \*p;
Push(&S,root);//根指针入栈
While(!StackEmpty(S)){
while(GetTop(S,p)&&p){
Push(S,p->Lchild);//向左走到头
}
Pop(S,p);//空指针退栈
if(!StackEmpty(S)){
Pop(S,p);
cout<<p->data;//访问结点
Push(S,p->Rchild);//向右
}
}
}
int main(){
return 0;
}
(三)线索二叉树(低概率考点)
tag == 0:指向子树的根节点;
tag == 1:指向前驱或后继;
1.中序线索二叉树(非重点)
思路:在有左子树的情况下一直往左走,直到走到最左下的结点;
//中序线索二叉树上找指定结点的后继:
BiThrTree inordernext(BiThrTree p)
{
if (p->rtag==1) return(p->Rchild);
else {
q=p->Rchild;
while (q->ltag==0) q=q->Lchild;
return(q);
}
}
2.后序线索二叉树(掌握算法)
思路:
- 若p所指结点是整棵二叉树的根结点,则无后继结点;
- 若p->Rtag=1,则p->Rchild指向其后继结点;
- 若p->Rtag=0://P所指结点有右子树
1.若p所指结点是其父结点f的右孩子,则其父结点f是其后继;
2.若p所指结点是其父结点f的左孩子:
ⅰ 若p所指结点没有右兄弟,则其父结点f是其后继;
ⅱ 若P有右兄弟,则其后继结点是其父的右子树上后序遍历得到的第一个结点。
//在后序线索二叉树上查找指定结点的后继;
BiThrTree postorder\_next(BiThrTree p)
{
if (p->Rtag == 1) return(p->Rchild);
else {
查找p所指节点的父结点f;
if (p == f->Rchild) return f;
if (p == f->Lchild && f->Rtag == 1) return f;
q = f->Rchild;
while (q->Ltag == 0 || q ->Rtag == 0) {
if (q->Ltag == 0)
q = q->Lchild;
else
q = q->Rchild;
}
return(q);
}
}
四、树、森林
(一)树的存储结构
1.双亲表示法:
采用一组地址连续的存储单元存储树的结点,通过保存每个结点的双亲结点的位置,表示树中结点之间的结构关系。(eg.a为根节点)
data | parent |
---|---|
a | 0 |
2.孩子表示法
通过保存每个结点的孩子结点的位置,表示树中结点之间的结构关系。
(类似于链表)
3.孩子兄弟表示法
用二叉链表存储树。链表的两个指针域分别指向该结点的第一个孩子结点和其右边的下一个兄弟结点。(左孩子,右兄弟)
(二)树、森林、二叉树的相互转换(必考)
工具:孩子兄弟表示法
(三)树、森林遍历
树的先根遍历等同于对转换所得的二叉树进行先序遍历;
树的后根遍历等同于对转换所得的二叉树进行中序遍历;
森林的先序遍历等于对转换所得的二叉树进行先序遍历;
森林的中序遍历等于对转换所得的二叉树进行中序遍历;
五、哈夫曼树及应用
(一)定义
- 哈夫曼树(最优二叉树):带权路径长度最短的树;
- 路径和路径长度:从树中的一个结点到另一个结点之间的分支构成这两个结点之间的路径,路径上的分支数目称作路径长度;
- 结点的带权路径长度:从根到该结点的路径长度与该结点权的乘积称为结点的带权路径长度;
- 树的带权路径长度:树中所有叶子的带权路径长度之和称为树的带权路径长度(WPL);
note:构建哈夫曼树时,都是两个两个合在一起的,所以没有度为一的结点,即n1=0;
(二)哈夫曼算法构造最优二叉树
- 在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左、右子树上根结点的权值之和;
- 在F中删除这两棵树,同时将新得到的二叉树加入F中;
- 重复1和2,直到F中只含一棵树为止。这棵树便是最优二叉树;
哈夫曼树适合采用顺序结构:已知叶子结点数n0,且n1=0,则总结点数为2n2+1(或2n0-1),且哈夫曼树构造过程需要不停地修改指针,用链式存储的话很容易造成指针偏移;
构造哈弗曼树的算法,会填空就行;
CHAPTER6 图
一、定义
- 图:顶点集和边集构成的二元组
- 分为无向图和有向图
- 无向完全图: 把能连起来的边都连起来:1+2+3+·····+n-1=n(n-1)/2
- 有向完全图:有来有回:n(n-1)
- 邻接点:边的两个顶点互为领接点
- 顶点V的度=与V相关联的边的数目(有向图中:度=入度+出度);图的所有顶点度数之和:2*e(e为边数)
- 路径:从一个点到另一个点所经过的顶点序列
- 网络(网):若图中的每条边都有权,这个带权图被称为网;
- 长度(无权图):沿路径所经过的边数成为该路径的长度;
- 长度(有权图):取沿路径各边的权之和作为此路径的长度;
- 简单路径:路径中的顶点不重复出现;
- 简单回路:由简单路径组成的回路;
- 连通图:无向图(有向图)中任意两个顶点之间都是连通的,称为连通图(强连通图);
- 连通分量:无向图G中的极大连通子图称为G的连通分量;对任何连通图而言,连通分量就是其自身;
- 强连通分量:针对于有向图;
- 生成树:一个连通图的生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的n-1条边;(多加一条边就会形成一个环)
重要区分:
极大连通子图:无向图的连通分量,极大即要求该连通子图包含其所有的边;非连通图(无向图)有多个极大连通子图,即多个连通分量;连通图(无向图)只有一个极大连通子图,即他本身;
极小连通子图:既要保持图连通,又要使得边数最少的子图;例如,生成树;
二、图的存储结构
图的存储结构至少要保存两类信息:顶点的数据和顶点间的关系;
(一)图的数组表示法
在数组表示法中,用邻接矩阵表示顶点间的关系
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MaxVnum 50
using namespace std;
//图的数组表示法定义
//定义一个二维数组来表示邻接矩阵
typedef double AdjMatrix[MaxVnum] [MaxVnum];
typedef struct{
int vexnum,arcnum;//顶点数和边数
AdjMatrix arcs;//邻接矩阵
}Graph;
int main(){
Graph G;
return 0;
}
数组表示法的特点:
//无向图
- 无向图的邻接矩阵是对称矩阵,同一条边表示了两次;
- 顶点v的度:等于二维数组对应行(或列)中值为1的元素个数;
- 判断两顶点v、u是否为邻接点:只需判二维数组对应分量是否为1;
- 顶点不变,在图中增加、删除边:只需对二维数组对应分量赋值1或清0;
- 设图的顶点数为 n ,用有n个元素的一维数组存储图的顶点,用邻接矩阵表示边,则G占用的存储空间为:n+n2;图的存储空间占用量只与它的顶点数有关,与边数无关;适用于边稠密的图;
//有向图
- 有向图的邻接矩阵不一定是对称的;
- 顶点v的出度:等于二维数组对应行中值为1的元素个数;
- 顶点v的入度:等于二维数组对应列中值为1的元素个数;
(二)邻接表
顶点:通常按编号顺序将顶点数据存储在一维数组中
关联同一顶点的边:用线性链表存储
网的邻接表表示:
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MaxVnum 50
using namespace std;
//表结点结构
typedef struct ArcNode{
int adjvex;
double weight;
struct ArcNode \*nextarc;
}ArcNode;
//头结点结构
typedef struct{
VertexType data;
ArcNode \*firstarc;
}AdjList[MaxVnum];
//图
typedef struct{
int vexnum,arcnum;
AdjList vertexes;
}AGraph;
int main(){
AGraph G;
return 0;
}
三、图的遍历(重要)
图的遍历:从图的某个顶点出发,访问图中的所有顶点,且使每个顶点仅被访问一次;
深度优先搜索遍历(DFS)、广度优先搜索遍历(BFS);
(一)DFS
key points:递归、栈;类似于树的先序遍历
基本思想:
- 访问顶点v;
- 依次从v的未被访问的邻接点出发,对图进行深度优先遍历;直至图中和v有路径相通的顶点都被访问;
- 若此时图中尚有顶点未被访问,则从一个未被访问的顶点出发,重新进行深度优先遍历,直到图中所有顶点均被访问过为止。
手动模拟:
伪代码:
#include<iostream>
#include<cstdio>
#include<cstdlib>
using namespace std;
bool visited[MaxVnum];//访问标记数组
//邻接表或者邻接矩阵存储图
//DFS
void DFSTraverse(Graph G){
for(int v=0;v<G.vexnum;++v)//vexnum:顶点数
visited[v]==false;//第一个for循环,初始化访问标记数组
for(int v=0;v<vexnum;++v)//第二个for循环,从v=0开始对图进行DFS
if(visited[v]==false)//此条件判断语句可计算图的连通分量个数
DFS(G,v);
}
void DFS(Graph G,int v){
visit(v);//访问顶点v;
visited[v]=true;//设已访问标记
for(w为v的第一个领接点;w存在;w取v的下一个领接点)
if(visited[w]==false)
DFS(G,w);
}
int main(){
return 0;
}
算法执行过程图解:
复杂度分析:
遍历图的过程实质上是对每个顶点查找其邻接顶点的过程,所以耗费的时间取决于所采用的存储结构;
邻接链表表示:查找每个顶点的邻接点所需时间为O(e),e为边(弧)数,算法时间复杂度为O(n+e);
数组表示:查找每个顶点的邻接点所需时间为O(n2),n为顶点数,算法时间复杂度为O(n2);
(二)BFS
key points:队列,类似于树的层次遍历;
基本思想:从图中某顶点vi出发:
- 访问顶点vi ;
- 访问vi 的所有未被访问的邻接点w1 ,w2 , …wk ;
- 依次从这些邻接点(在步骤②中访问的顶点)出发,访问它们的所有未被访问的邻接点; 依此类推,直到图中所有访问过的顶点的邻接点都被访问;
tips:
为实现3,需要保存在步骤2中访问的顶点,而且访问这些顶点的邻接点的顺序为:先保存的顶点,其邻接点先被访问。
手动模拟:
首先从v1开始,v1入队,访问v1,visited[v1]=T;v1出队,v1的邻接点v2、v3入队,对v2、v3进行同等操作;
伪代码:
#include<iostream>
using namespace std;
void BFSTraverse(Graph G){
for(v=0;v<G.vexnum;v++)
visited[v]=false;//初始化标记数组
InitQueue(Q);//初始化队列
for(v=0;v<G.vexnum;v++){
if(visited[v]==false){
EnQueue(Q,v);
visited[v]=true;
while(!Empty(Q)){
DeQueue(Q,u);//队头元素出队给Q
for(w取u的第一个邻接点;w存在;w取u的下一个邻接点){
if(visited[w]==false){
EnQueue(Q,w);
visited[w]=true;
}
}
}
}
}
}
int main(){
return 0;
}
复杂度分析:
同DFS
四、图的应用(手动模拟)
迪杰斯特拉、弗洛伊德掌握算法
prim、克鲁斯卡尔、破圈法,都是贪心的思想
(一)最小代价生成树-手动模拟
最小生成树的形式不是唯一的,但权值的和总是相同的;
为啥要求最小生成树:最小生成树是代价最小的,例如要在多个村庄之间修路,怎样使路径想通且代价最小,就应该考虑最小生成树;
求最小生成树所用到的性质:
最小生成树的MST性质:
假设G=(V,E)是一个连通网络,U是V中的一个真子集,若存在顶点u∈U和顶点v∈V-U 的边(u,v)是一条具有最小权的边,则必存在G的一棵最小生成树包括这条边(u,v)。
1.Prim算法求最小代价生成树——不断加点的过程
算法思想:
普里姆算法构造最小生成树的过程是从一个顶点U={u0}作初态,不断寻找与U中顶点相邻且代价最小的边的另一个顶点,扩充到U集合直至U=V为止。
注:
1.“与U之外的顶点 ”就保证了在构造最小生成树的过程中,不会有环形成;
- “扩充到U集合直至U=V为止”就保证了图中的所有顶点均被包含进来了;
手动模拟:
算法实现(顶多出填空题)
要解决的问题: - 顶点集合如何表示?–closedge
- 最小边如何选择?–lowcost里边非0 的最小边
- 一个顶点加入U集合如何表示?–令lowcost=0
//定义数组
/\*
1.adjvex:V-U中的顶点,也是i的邻接点;
2.lowcost:当前顶点相连的最小代价的边
3.closedge[i].adjvex=k:表示从U和V-U中各选一个点,组成边(i,k)
4.顶点i加入U集合时:closedge[i].lowcost=0
\*/
struct {
int adjvex;
double lowcost;
}closedge[MAX_VERTEX_NUM];
void MiniSpanTree_PRIM (Graph G, VertexType u){
//用普里姆算法从顶点u出发构造G的最小生成树
for(j = 0; j < G.vexnum; ++j) //辅助数组初始化
if ( j != u ) closedge[j] = {u, G.arcs[u][j]};
closedge[u].lowcost = 0; //初始,U={u}
for(i = 1; i < G.vexnum; ++i) {
k = minimum(closedge); //求生成树的下一个顶点k
cout << closedge[k].adjvex << G.vexs[k]; //输出生成树的边
closedge[k].lowcost = 0; //顶点k并入U集合
for(j = 0; j < G.vexnum; ++j)
if (G.arcs[k][j] < closedge[j].lowcost)
closedge[j] = {k, G.arcs[k][j]};
}
时间复杂度O(n2)
2.克鲁斯卡尔求解最小生成树:——不断加边的过程
算法思想:
- 假设连通网N=(V,E),则令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V,{}),图中每个顶点自成一个连通分量
- 在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。
手动模拟:
3.破圈法求解最小生成树
——思想简单,实现复杂
算法思想:每次选择最长的边进行删除,删除之后,保证图依然是连通的;
补:
AOV网 | AOE网 |
---|---|
顶点——活动 | 顶点——事件 |
有向边——活动的先后关系 | 有向边——活动,边的权值表示完成该活动的开销 |
侧重于表示活动的前后次序 | 除表示活动的先后次序外,还表示活动的持续时间 |
求解工程流程是否合理 | 解决工程所需最短时间及哪些子工程拖延会影响整个工程按时完成等问题 |
(二)拓扑排序
1.AOV网
AOV网: 用顶点表示活动,边表示活动的顺序关系的有向图称为AOV网;
特点:若在有向图中有弧<v,u>,则称顶点v是顶点u 的前趋,那么施工计划中顶点v 也排在u之前。也称u是v的后继。
一个AOV网不应该存在环,因为存在环意味着某项活动的进行应该以本活动的完成作为先决条件,会死锁。
手动快速输出拓扑序列:
每次都输出入度为0 的点,然后去掉这个点继续按这个规则输出;
2.拓扑排序-手动模拟
- 拓扑排序:将有向图中的顶点排成一个序列。
- 拓扑序列:有向图D的一个顶点序列称作一个拓扑序列。如果该序列中任两顶点v 、u ,若在D中v是u前趋,则在序列中v也是u前趋。
拓扑排序方法:
- 在有向图中选一个无前趋的顶点v,输出之;
- 从有向图中删除v及以v为尾的弧;
- 重复1、2,直接全部输出全部顶点或有向图中不存在无前趋的结点时为止;
算法不必掌握
(三)关键路径-手动模拟
AOE网:顶点表示事件,有向边表示活动,边上权值表示完成该活动的开销,称为用边表示活动的网络,即AOE网;
注:
事件Vi的最早发生时间是是源事件V1到Vi的最长路径长度;
活动的最早开始时间=弧尾事件的最早发生时间
活动的最晚开始时间=弧头事件的最晚发生时间-边的权值
边的权值即活动的持续时间
手动模拟: 掌握表格
(四)最短路径-算法
路径长度:路径上的边数、路径上边的权值之和
最短路径:两结点间权值之和最小的路径
求解最短路径的算法所依赖的性质:
两点之间的最短路径,也包含了路径上其他顶点间的最短路径;
(毕竟每一段最短,加起来才最短)
1.单源最短路径
即求图中某顶点到其他各顶点的最短路径:DJIKSTRA算法
算法思想:按路径长度递增顺序求解最短路径;本质:贪心
算法步骤:设V0是起始源点,S是已求得最短路径的终点集合
- V-S = 未确定最短路径的顶点的集合, 初始时 S={V0},长度最短的路径是边数为1且权值最小的路径
- 下一条长度最短的路径:
① Vi ∈ V - S ,先求出V0 到Vi 中间只经 S 中顶点的最短路径;
② 上述最短路径中长度最小者即为下一条长度最短的路径;
③ 将所求最短路径的终点加入S 中; - 重复2直到求出所有终点的最短路径;
手动模拟: 会写表格
算法:——尽量掌握
2.每对顶点间的最短路径
方法一:每次以一个顶点为源点,重复执行迪杰斯特拉算法n次,求得每一对顶点之间的最短路径。
方法二:FLOYD算法——以邻接矩阵作为图的存储结构
手动模拟:
算法:——尽量掌握
CHAPTER7查找
(一)静态查找
平均查找长度ASL
p:查找到该元素的概率
c:比较次数
1.顺序查找
一般选择从后往前查找
1.1无序表的静态查找
将0号单元设置为“监视哨” 的目的是使得代码内的循环不必判断数组是否会越界,因为满足i==0时,循环一定会跳出,从而减少不必要的循环语句,进而提高程序效率;
#include<iostream>
#include<cstdio>
#include<cstdlib>
using namespace std;
typedef struct{
KeyType key;
OtherInfoType info;
}ElemType;
typedef struct{
ElemType \*elem;//数据元素存储空间基址,建表时,按实际长度分配,0号单元留空
int length;//表长
}SSTable;
//在无序表中查找元素key所在的位置,查找成功则返回元素在表中的位置,否则返回0
int Sq\_search(SSTable ST,KeyType key){
int i=ST.length;
ST.elem[0].key=key;//监视哨:下标为0的位置存放待查找的元素
while(ST.elem[i].key!=key) i--;
return i;
}
int main(){
return 0;
}
——查找失败只有一种可能性:走到监视哨了;
1.2有序表的静态查找
#include<iostream>
#include<cstdio>
#include<cstdlib>
using namespace std;
typedef struct{
KeyType key;
OtherInfoType info;
}ElemType;
typedef struct{
ElemType \*elem;//数据元素存储空间基址,建表时,按实际长度分配,0号单元留空
int length;//表长
}SSTable;
//假设表中元素按递增排序;查找成功时返回下标;失败时返回0
int Sq\_search(SSTable ST,KeyType key){
int i=n;
ST.elem[0].key=key;//监视哨:下标为0的位置存放待查找的元素
while(ST.elem[i].key>key) i--;
if(ST.elem[i]==key){
return i;
}
return 0;
}
int main(){
return 0;
}
ASL(成功)和无序表一样;
查找失败:n个元素,就由n+1个空隙,即n+1种出错的可能;这里从代码就可看出,若key大于最大值或小于最小值,都是查找失败,所以就有n+1种出错的可能;在n+1处查找失败要比较1次,在第n个和第n-1个元素之间失败要比较两次;
2.折半查找 —算法
折半查找的前提:一定得有序;
mid=[low+high]/2(向下取整)
算法思想:
先确定待查记录所在的范围(区间),若待查记录等于表中间位置上的记录,则查找成功;否则,缩小查找范围,即若待查记录小于中间位置上的元素,则下一次到前半区间进行查找,若待查记录大于中间位置上的元素,则下一次到后半区间进行查找。
#include<iostream>
#include<cstdio>
#include<cstdlib>
using namespace std;
typedef struct{
KeyType key;
OtherInfoType info;
}ElemType;
typedef struct{
ElemType \*elem;//数据元素存储空间基址,建表时,按实际长度分配,0号单元留空
int length;//表长
}SSTable;
//在有序表中查找元素e,查找成功时返回下标;失败时返回0
//若是该数组中没有这个元素,最后都会产生low>high;这就是循环退出条件;
int B\_search(SSTable ST,KeyType key){
int low=1,high=ST.length;
while(low<=high){
mid=(low+high)/2;// “/”本就是向下取整
if(ST.elem[mid].key==key) return mid;
else if(ST.elem[mid].key<key) low=mid+1;
else high=mid-1;
}
return 0;//查找失败
}
int main(){
return 0;
}
note:链表只能用顺序查找,毕竟这玩意不能随机存储;
折半查找判定树:
树中每个圆形结点表示一个记录,结点中的值为该记录关键字的值,树中最下面的结点都是方形的,表示查找不成功的情况
结点在第几层,查找成功就需要比较几次;
查找成功时的查找长度为从根节点到目的结点的路径上的结点数;
查找失败时的查找长度为从根节点到对应失败结点的父结点的路径上的结点数;
每个根节点都是mid
在失败的情况下,最后的mid,high,low都指向同一个结点,如果mid已经不等的话就已经失败了,即到a1,a3,a5,a7.a8的位置就知道失败了,不会再继续向下比较了;所以失败时的比较次数应该是叶子结点的父结点的比较次数;然后,n个结点有n+1种失败的可能;
补充:比较到方框时,就一定有low>high,不会再继续while循环了;
查找成功和失败的平均查找长度与有n个结点的完全二叉树的高度相同。
3.分块查找(索引查找) —算法填空
- 先在索引表中确定元素所在的块;
- 再在块中顺序查找;
ASL(分块查找)=ASL(索引表内的查找)+ASL(块内查找)
(二)动态查找
就是可以对表进行增删改查
1.二叉排序树的定义(算法)
注:二叉排序树出手动模拟的概率比较大!
具有以下性质的二叉树:
- 若其左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若其右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 其左、右子树也分别为二叉排序树;
即:左小右大
2.二叉排序树上的查找运算及特点(算法)
BiTree SearchBST(BiTree T,keyType key) {
//在T指向根的二叉排序树中递归地查找关键字等于key的数据元素,
//若找到,返回指向该结点的指针,否则返回NULL
if (T==NULL) return NULL;
else if (T->data.key==key) return T;
else if (key < T->data.key)
return SearchBST(T->lchild,key);
else return SearchBST(T->rchild,key);
}
3.二叉排序树的创建构造(算法)
步骤:
- 若二叉排序树为空树,则插入元素作为树根结点;
- 若根结点的键值等于key,则插入失败;
- 若key小于根结点的键值,则插入到根的左子树上;否则,插入到根的右子树上;
新插入的结点一定是个叶子结点;
二叉排序树结点的值一定是唯一的;且插入的新值,一定是插入在为空的位置上;
算法如下:
二叉排序树的特点:
- 将一个无序序列的元素依次插入到一棵初始为空的二叉排序树上,然后进行中序遍历,可得到一个有序序列。(默认是升序)
- 在二叉排序树上插入元素时,总是将新元素作为叶子结点插入,插入过程中无须移动其他元素,仅需将一个空指针修改为非空。
- 在二叉排序树上删除元素时,应保持其中序遍历序列有序的特点。
4.二叉排序树的删除(不咋考-手动模拟即可)
删除时的三种情况:
- 待删除的结点p是个叶子结点
若p是f的左孩子将p的父结点f的左孩子指针置空,若p是f的右孩子,则将f的右孩子指针置空;(即直接删)
- 待删除的结点p是仅有一个非空子树
(那么它中序遍历的前驱(后继)就是其左孩子(右孩子);
用前驱或后继覆盖;)
则将p的左孩子(或右孩子)接为p的父结点f的左孩子(若p是f的左孩子),或者将p的左孩子(或右孩子)接为p的父结点f的右孩子(若p是*f的右孩子) ;
- 待删除的结点p有两个非空子树
(p的直接前驱就是左子树的最右边(即该结点是没有右子树的单枝树),直接后继就是右子树的最左边(即该结点是没有左子树的单枝树;不然咋叫最左边、最右边呢);这样删除两个子树都不为空的结点就转化为了删除只有一个子树或叶子结点的情况;
本质上就是按二叉排序树的性质,把另一部分拼上去;
)
a.令p的直接前驱(或直接后继)代替p,然后再删除其直接前驱(或直接后继)
b.令p的左子树为f的左子树(若p是f的左孩子),而p的右子树为s的右子树(s是对p的左子树进行中序遍历的最后一个结点);
或令p的右子树为f的右子树(若p是f的右孩子),而p的左子树为*s的左子树(s是对p的右子树进行中序遍历的第一个结点);
二叉排序树的查找性能:
求ASL和二分的查找树一样;
若查找成功,则走了一条从根结点到某结点的路径,若查找失败,则走到一棵空的子树时为止。因此,最坏情况下,其平均查找长度不会超过树的高度。
高度越高平均查找长度就越大;
5.二叉平衡树的定义和构造(主要会旋转即可)
如果根据关键字的输入序列构造的二叉树为单枝树,则其平均查找长度与顺序查找相同,因此在构造二叉排序树的过程中需要进行处理,使得树中结点的分布比较均匀。因此就有了平衡二叉树的概念;
定义:平衡二叉树或者是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左、右子树的深度之差的绝对值不超过1。
平衡因子:定义二叉树中结点的平衡因子bf等于结点左子树的高度减去右子树的高度;平衡二叉树中结点的平衡因子为0、1或-1。
四种平衡处理:
- RR型左旋平衡处理
- LL型右旋平衡处理
- LR型先左后右旋转平衡处理
- RL型先右后左旋转平衡处理
补充:文件系统底层用的B树
(三)哈希表(重中之重)
1.定义
散列:也称为杂凑或哈希。它既是一种查找方法,又是一种存储方法,称为散列存储,其内存存放形式也称为哈希表或散列表。
处理冲突:
一般情况下,设计出的散列函数很难是单射的,即不同的关键字对应到同一个存储位置,这样就造成了冲突(碰撞)。此时,发生冲突的关键字互为同义词。
2.散列函数的构造方法
直接定址法、数字分析法、平方取中法、折叠法、随机数法、除留余数法
几乎只考 除留余数法
- 直接定址法
可表示为H(key)=a*key+b,其中a、b均为常数;
这种方法计算特别简单,并且不会发生冲突,但当关键字分布不连续时,会出现很多空闲单元,会将造成大量存贮单元的浪费; - 数字分析法
分析关键字的各个位的构成,截取其中若干位作为散列函数值,尽可能使关键字具有大的敏感度;
- 平方取中法
这种方法是先求关键字的平方值,然后在平方值中取中间几位为散列函数的值。因为一个数平方后的中间几位和原数的每一位都相关,因此,使用随机分布的关键字得到的记录的存储位置也是随机的。
- 折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列函数的值,称为折叠法;
例如,假设关键字为某人身份证号码430104681015355,则可以用4位为一组进行叠加,即有5355+8101+1046+430=14932,舍去高位,则有H(430104681015355)=4932。 - 随机数法
给随机数呗 - 除留余数法
Hash(key) = key % p
P一般取小于表长的最大质数;
除留余数法的关键是选取较理想的p值,使得每一个关键字通过该函数转换后映射到散列空间上任一地址的概率都相等,从而尽可能减少发生冲突的可能性。一般情形下,取p为一个素数较理想。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上软件测试知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
e_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQyNjU4NTE1,size_16,color_FFFFFF,t_70#pic_center)
处理冲突:
一般情况下,设计出的散列函数很难是单射的,即不同的关键字对应到同一个存储位置,这样就造成了冲突(碰撞)。此时,发生冲突的关键字互为同义词。
2.散列函数的构造方法
直接定址法、数字分析法、平方取中法、折叠法、随机数法、除留余数法
几乎只考 除留余数法
- 直接定址法
可表示为H(key)=a*key+b,其中a、b均为常数;
这种方法计算特别简单,并且不会发生冲突,但当关键字分布不连续时,会出现很多空闲单元,会将造成大量存贮单元的浪费; - 数字分析法
分析关键字的各个位的构成,截取其中若干位作为散列函数值,尽可能使关键字具有大的敏感度;
- 平方取中法
这种方法是先求关键字的平方值,然后在平方值中取中间几位为散列函数的值。因为一个数平方后的中间几位和原数的每一位都相关,因此,使用随机分布的关键字得到的记录的存储位置也是随机的。
- 折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列函数的值,称为折叠法;
例如,假设关键字为某人身份证号码430104681015355,则可以用4位为一组进行叠加,即有5355+8101+1046+430=14932,舍去高位,则有H(430104681015355)=4932。 - 随机数法
给随机数呗 - 除留余数法
Hash(key) = key % p
P一般取小于表长的最大质数;
除留余数法的关键是选取较理想的p值,使得每一个关键字通过该函数转换后映射到散列空间上任一地址的概率都相等,从而尽可能减少发生冲突的可能性。一般情形下,取p为一个素数较理想。
[外链图片转存中…(img-7DXaGmh6-1715367078686)]
[外链图片转存中…(img-8TKFRtfs-1715367078687)]
[外链图片转存中…(img-bsza5POo-1715367078687)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上软件测试知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新