基本概念

树是n(n大≥0)个结点的有限集合,n=0时称为空树,这是一种特殊情况。在任意一棵非空树中应满足:

  1. 有且仅有一个特定的称为根的结点;
  2. 当n>1时,其余结点可分为m(m>0)个互不相交的有限集合,其中每个集合本身又是一棵树,并且称为根节点的子树。

非空树的特性:

  • 有且仅有一个根节点;
  • 没有后继的结点称为“叶子结点”;
  • 有后继的结点称为“分支节点”;
  • 除了根节点外,任何一个结点都有且仅有一个前驱;
  • 每个结点可以有0个或多个后继;

结点和树的属性

  • 结点间的路径:只能从上往下数;
  • 路径长度:两个结点经过了几条边;
  • 结点的层次(深度):从上往下数;
  • 结点的高度:从下往上数;
  • 数的高度(深度):总共多少层
  • 结点的度:有几个孩子(分支);
  • 树的度:各个结点的度的最大值;

有序树和无序树

  • 有序树:逻辑上看,树中结点的各子树从左至右是有次序的,不能互换;
  • 无序树:逻辑上看,树中结点的各子树从左至右是无次序的,可以互换;

选择哪种树,具体看用树存什么,是否需要用结点的左右位置反映某些逻辑关系;

树和森林

森林是m(m≥0)棵互不相交的树的集合

常考性质

一、结点数=总度数+1

结点的度意思是结点有几个孩子,相加之后可以求出所有子节点个数,然后需要把根节点也计算上,所以+1;

二、区分度为m的树和m叉树

度为m的树 m叉树
树的度:各结点的度的最大值 m叉树:每个结点最多只能有m个孩子的树
任意结点的度≤m(最多m个孩子) 任意结点的度≤m(最多m个孩子)
至少有一个结点的度=m(要保证有一个结点的孩子数量为m) 允许所有结点的度都<m
一定是非空树,至少有m+1个结点 可以是空树

三、度为m的树的第i层至多有个结点

四、高度为h的m叉树至多有个结点

其实就是等比数列求和,首项为1;

五、高度为h的m叉树至少有h个结点;高度为h、度为m的树至少有h+m-1个结点

求至少,那么按照最少的来算就行;m叉树就让每一层的度都为1;规定树的度为m,那就只让尾结点的数量为m;

六、具有n个结点的m叉树的最小高度为

求m叉树的最小高度,那么就让树的每一层的度都为m。已知第h层最多有个结点,则

二叉树

二叉树是n(n大≥0)个结点的有限集合:

  1. n=0时为空二叉树;
  2. n>0时,由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树分别是一颗二叉树

特点: + 每个结点至多只有两棵子树; + 二叉树是有序树,左右子树不能颠倒; + 要注意区分度为2的有序树,度为2的有序树,至少需要有一个结点有2个孩子;

二叉树的五种状态:空树、只有左子树、只有右子树、只有根节点、左右子树都有;

几种特殊的二叉树

  • 满二叉树

一颗高度为h,且含有个结点的二叉树

  • 只有最后一层有叶子节点;
  • 不存在度为1的结点;
  • 按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为

  • 完全二叉树

当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应,称为完全二叉树。

满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。

  • 只有最后两层可能有叶子结点;
  • 最多只有一个度为1的结点;
  • 按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为
  • i≤为分支结点,i>为叶子节点;
  • 如果一个结点只有一个孩子,那么一定是左孩子;

  • 二叉排序树

一颗二叉树或者是空二叉树,或者是具有如下性质的二叉树:

  • 左子树上的所有节点的关键字均小于根结点的关键字;
  • 右子树上所有结点的关键字均大于根节点的关键字
  • 左右子树分别又是一棵二叉排序树;

二叉排序树可以用于元素的排序、搜索

  • 平衡二叉树

树上任意结点的左子树和右子树的深度之差不超过1;

二叉树常考性质

  • 一、设非空二叉树中度为0、1、2的结点个数分别为,则

推导:假设树中的结点总数为n,则可得:

  1. n = + +
  2. 已知数的结点数=总度数+1,则n=+2+1

两式联立可得

  • 二、二叉树第i层至多有个结点(i≥1)

  • 三、高度为h的二叉树至多有个结点(此时是满二叉树)

完全二叉树常考性质

  • 一、具有n个结点的完全二叉树的高度为h=

高为h的满二叉树共有个结点;

高为h-1的满二叉树共有个结点;

高为h的完全二叉树至少个结点,至多个结点;

  • 二、对于完全二叉树,可以由结点数n推出度为0、1、2的结点个数

突破点:完全二叉树最多只有一个度为1的结点,即要么是0,要么是1;

又由可得,,一定为奇数,则:

  1. 若完全二叉树有2k(偶数)个结点,则必有;
  2. 若完全二叉树有2k-1(奇数)个结点,则必有;

二叉树的存储结构

顺序存储

1
2
3
4
5
6
7
8
9
10
11
12
13
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}

//定义一个长度为MaxSize的数组t,按照从上至下,从左至右的顺序依次存储完全二叉树中的各个节点
TreeNode t[MaxSize];

for(int i = 0; i < MaxSize; i++)
{
t[i].isEmpty = true;
}

可以让数组的第一个位置空缺,保证数组下标和结点编号位置一致

基本操作:

  • i的左孩子——2i;
  • i的右孩子——2i+1;
  • i的父节点——

若完全二叉树共有n个结点,则:

  • 判断i是否有左孩子——2i≤n?
  • 判断i是否有右孩子——2i+1≤n?
  • 判断i是否是叶子/分支结点?——i>n/2

重要:二叉树的顺序存储中,一定要把二叉树的结点编号与完全二叉树对应起来,这样才能计算逻辑关系。所以顺序存储很浪费空间,最坏的情况,高度为h且每个结点都只有一个右孩子,那么也依旧需要个存储单元。所以二叉树的顺序存储结构只适合存储完全二叉树。

链式存储

二叉链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左右孩子指针
}BiTNode, *BiTree;

//定义一颗空树
BiTree root = NULL;

//插入根节点
root = (BiTree)malloc(sizeof(BiTNode));
root->data = {1};
root->lchild = NULL;
root->rchile = NULL;

//插入新结点
BiTNode *p = (BiTNode*)malloc(sizeof(BiTNode));
p->data = {2};
p->lchild = NULL;
p->rchile = NULL;
root->lchild = p; //作为根节点的左孩子

对于采用链式存储的二叉树,如果有n个结点,那么就会有2n个指针。其中除根节点外,每个结点都有指针指向自己,即n-1个指针指向具体结点,那么就会剩下n+1个结点指向空链域。可以利用这些空链域构造线索二叉树。

上面的链式存储找到指定结点的左右孩子的操作非常简单,但是如果想寻找父节点,那么就需要从头遍历。如果实际操作中需要频繁的寻找父节点,那么可以用三叉链表:

1
2
3
4
5
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左右孩子指针
struct BiTNode *parenty; //父节点指针
}BiTNode, *BiTree;

但考试中基本不涉及这种场景,需要针对二叉链表进行遍历。

二叉树的遍历

遍历就是按照某种次序把所有结点都访问一遍。对于树形结构来说,总体上有两种遍历顺序,一种是层序遍历:基于树的层次特性确定的次序规则;另一种是先:基于树的递归特性确定的次序规则。

先中后序遍历

  • 先序遍历:根左右(NLR)
  • 中序遍历:左根右(LNR)
  • 后序遍历:左右根(LRN)

上图的先序遍历:ABDECFG;中序遍历:DBEAFCG;后序遍历:DEBFCGA。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchlid;
}BiTNode, *BiTree;

//先序遍历
void PreOrder(BiTree T)
{
if(T != NULL){
visit(T); //访问根节点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}

//中序遍历
void InOrder(BiTree T)
{
if(T != NULL){
InOrder(T->lchild); //递归遍历左子树
visit(T); //访问根节点
InOrder(T->rchild); //递归遍历右子树
}
}

//后序遍历
void PostOrder(BiTree T)
{
if(T != NULL){
PostOrder(T->lchild); //递归遍历左子树
PostOrder(T->rchild); //递归遍历右子树
visit(T); //访问根节点
}
}

层序遍历

算法思想:

  1. 初始化一个辅助队列;
  2. 根节点入队
  3. 若队列非空,则队头结点出队,访问该节点,并将其左、右孩子插入队尾
  4. 重复3直到队列为空
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchlid;
}BiTNode, *BiTree;

typedef struct LinkNode{
BiTNode *data; //存指针即可,不需要存结点真实数据,节省空间
struct LinkNode *next;
}LinkNode;

typedef struct{
LinkNode *front, *rear; //队头队尾
}LinkQueue;

void LevelOrder(BiTree T)
{
LinkQueue Q;
InitQueue(Q); //初始化辅助队列,使用链队列方便扩展
BiTree p;
EnQueue(Q, T); //将根节点入队
while(!IsEmpty(Q)) //队列不空则循环
{
DeQueue(Q, p); //队头结点出队
visit(p); //访问出队结点
if(p->lchild != NULL)
EnQueue(Q, p->lchild); //左孩子入队
if(p->rchild != NULL)
EnQueue(Qm p->rchild); //右孩子入队
}
}

由遍历序列构造二叉树

不论遍历序列是前序、中序、后序还是层序,一个遍历序列都可以对应多种二叉树形态。如果只给出一颗二叉树的前/中/后/层序遍历中的一种,不能唯一确定一颗二叉树。

但只要给出中序遍历,那么任意结合前序、后序、层序遍历的一种,即可唯一确定一颗二叉树。

前序+中序:

后序+中序:

层序+中序:

核心就是找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点。

必须要结合中序遍历序列,否则无法确定。

线索二叉树

二叉树的遍历并不能向线性表一样从任意结点开始访问,如果要找某个结点的前驱或者后继,必须要从根节点开始。

由前面的结论可得,n个结点的二叉树,有n+1个空链域,可以用来记录前驱、后继的信息。我们根据中序遍历的顺序来指定前驱和后继。这样我们就可以方便的找到其前驱和后继,方便遍历操作。

线索二叉树中,只有线索指向的结点才能称为前驱(后继)

1
2
3
4
5
6
7
//线索二叉树结点
//左右线索标志tag==0表示指针指向孩子,tag==1表示指针是线索
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;

先序线索二叉树和后序线索二叉树和中序一样,只是前驱和后序不同而已。

二叉树的线索化

对二叉树进行线索化的过程:直接进行遍历,遍历的过程中进行线索化。用一个指针pre记录当前访问结点的前驱结点

要注意最后一个结点的rchile、rtag的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//中序线索化
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;

//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;

//中序遍历,一边遍历一边线索化
void InThread(ThreadTree T)
{
if(T != NULL){
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}

void visit(ThreadNode* q)
{
if(q->lchild == NULL) //左子树为空,建立前驱线索
{
q->lchild = pre;
q->ltag = 1;
}

if(pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}

pre = q;
}

//中序线索化二叉树T
void CreateInThread(ThreadTree T)
{
pre = NULL; //pre初始为空
if(T != NULL) //非空二叉树才能线索化
{
InThread(T); //中序线索化二叉树
if(pre->rchild == NULL)
{
pre->rtag = 1; //处理遍历的最后一个结点
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//先序线索化
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;

//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;

//先序遍历,一边遍历一边线索化
void PreThread(ThreadTree T)
{
if(T != NULL){
visit(T);
//这里必须判断一下lchild不是前驱线索
//先序遍历的顺序是根左右,如果一个结点没有左子树,那么线索化后左子树指针就会指向这个结点的根节点
//而此时pre指针指向的就是其根节点
//此时发生的事情就是结点的左子树指向pre(即根结点),然后pre指向自身
//根据先序遍历根左右的顺序,这个结点访问完后就要访问其左子树,这样又造成了循环访问,再也出不来了
if(T->ltag == 0){
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}

void visit(ThreadNode* q)
{
if(q->lchild == NULL) //左子树为空,建立前驱线索
{
q->lchild = pre;
q->ltag = 1;
}

if(pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}

pre = q;
}

//先序线索化二叉树T
void CreateInThread(ThreadTree T)
{
pre = NULL; //pre初始为空
if(T != NULL) //非空二叉树才能线索化
{
PreThread(T); //中序线索化二叉树
if(pre->rchild == NULL)
{
pre->rtag = 1; //处理遍历的最后一个结点
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//后序线索化
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;

//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;

//后序遍历,一边遍历一边线索化
void PostThread(ThreadTree T)
{
if(T != NULL){
PostThread(T->lchild);
PostThread(T->rchild);
visit(T);
}
}

void visit(ThreadNode* q)
{
if(q->lchild == NULL) //左子树为空,建立前驱线索
{
q->lchild = pre;
q->ltag = 1;
}

if(pre != NULL && pre->rchild == NULL)
{
pre->rchild = q; //建立前驱结点的后继线索
pre->rtag = 1;
}

pre = q;
}

//后序线索化二叉树T
void CreateInThread(ThreadTree T)
{
pre = NULL; //pre初始为空
if(T != NULL) //非空二叉树才能线索化
{
PostThread(T); //中序线索化二叉树
if(pre->rchild == NULL)
{
pre->rtag = 1; //处理遍历的最后一个结点
}
}
}

线索二叉树找前驱、后继

中序线索二叉树中找到指定结点*p的中序后继next:

  1. 如果p->rtag == 1 ,则next=p->rchild;
  2. 如果p->rtag==0,则p必有右孩子,next=p右子树中最左下结点;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//找到以P为根的子树中,第一个被中序遍历的结点
ThreadNode* FirtstNode(ThreadNode *p)
{
//循环找到最左下结点
while(p->ltag == 0){
p = p->lchild;
}
return p;
}

//在中序线索二叉树中找到结点P的后继结点
ThreadNode* NextNode(ThreadNode *p)
{
//右子树最左下结点
if(p->rtag == 0){
return FirstNode(p->rchild);
}
else{
return p->rchild;
}
}

//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
//不需要嵌套,空间复杂度O(1)
void Inorder(ThreadNode *T){
for(ThreadNode *p = FirstNode(T); p != NULL; p = NextNode(p))
{
visit(p);
}
}

在中序线索二叉树中找到指定结点*p的中序前驱pre

  1. 若p->ltag == 1,则pre = p->lchild
  2. 若p->ltag == 0,则p必有左孩子。pre=p的左子树中最右下的结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//找到以P为根的子树中,最后一个被中序遍历的结点
ThreadNode* LaststNode(ThreadNode *p)
{
//循环找到最左下结点
while(p->rtag == 0){
p = p->rchild;
}
return p;
}

//在中序线索二叉树中找到结点P的前驱结点
ThreadNode* PreNode(ThreadNode *p)
{
//左子树最右下结点
if(p->rtag == 0){
return LaststNode(p->lchild);
}
else{
return p->lchild;
}
}

//对中序线索二叉树进行逆向中序遍历(利用线索实现的非递归算法)
//不需要嵌套,空间复杂度O(1)
void RevInorder(ThreadNode *T){
for(ThreadNode *p = LaststNode(T); p != NULL; p = PreNode(p))
{
visit(p);
}
}

在先序线索二叉树中找到指定结点*p的先序后继next:

  1. 若p->rtag == 1,则next = p->rchild;
  2. 若p->rtag == 0,p必有右孩子
    1. 若p有左孩子,则先序后继为左孩子
    2. 若p没有作海周四,则先序后继为右孩子

在先序线索二叉树中找到指定节点*p的先序前驱pre

  1. 若p->ltag == 1,则next = p->lchild;
  2. 若p->ltah == 0,则只能用土办法从头开始先序遍历了。先序遍历中左右子树的结点只可能是根的后继,不可能是根的前驱

在后序线索二叉树中找到指定结点*p的后序前驱pre:

  1. 若p->ltag == 1,则pre=p->lchild;
  2. 若p->ltag == 0,则p必有左孩子
    1. 若p有右孩子,则后序前驱为右孩子;
    2. 若p没有右孩子,则后序前驱为左孩子

在后序线索二叉树中找到指定结点*p的后序后继next

  1. 若p->rtag == 1,则next = p->rchild;
  2. 若p->rtag == 0,则只能用土办法从头开始后序遍历,后序遍历中,左右子树中的结点只可能是根的前驱,不可能是后继

树的存储结构

二叉树的顺序存储,可以按照完全二叉树中的结点顺序,将各个结点存储到数组的对应位置。数组下标反映结点之间的逻辑关系。但对于普通的树,一个分支结点可以有多棵子树,没有完全树的概念。只依靠数组下标,无法反映结点之间的逻辑关系。

双亲表示法

顺序存储。

思路:用数组顺序存储各个结点。每个结点中保存数据元素、指向双亲结点的“指针”(数组下标)。非根节点的双亲指针=父节点在数组中的下标、根节点的双亲指针为-1

1
2
3
4
5
6
7
8
9
10
11
12
13
#define MAX_TREE_SIZE 100  

//结点定义
#define struct{
ElemType data; //数据元素
int parant; //双亲位置域
}PTNode;

//类型定义
typedef struct{
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;

森林是m棵互不相交的数的集合。双亲表示法也可以存储森林。

  • 优点:找父节点很方便,直接读取parant;
  • 缺点:找孩子不方便,只能从头到尾遍历整个数组;

适用于找父亲多,找孩子少的场景,比如“并查集”。

孩子表示法

既使用顺序存储,也使用链式存储。

思路:用数组顺序存储各个结点。每个结点中保存数据元素和孩子链表的头指针。用一个链表记录一个结点的所有孩子的编号,结点的指针域指向第一个孩子编号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define MAX_TREE_SIZE 100  

struct CTNode{
int child; //孩子节点在数组中的位置
struct CTNode *next; //下一个孩子
}

typedef struct {
ElemType data;
struct CTNode *firstChild; //第一个孩子
}CTBox;

typedef struct {
CTBox nodes[MAX_TREE_SIZE];
int n,r; //结点数和根结点位置
}

孩子表示法也可以存储森林。但需要记录多个根的位置。

  • 优点:找孩子很方便;
  • 缺点:找双亲不方便,只能遍历每个链表;

使用于找孩子多,找父亲少的场景,比如服务流程树。

孩子兄弟表示法

链式存储方式。

孩子兄弟表示法与二叉树类似,采用二叉链表实现。每个结点内保存数据元素和两个指针,但两个指针的含义和二叉树不同。

1
2
3
4
5
6
typedef struct CSNode{
ElemType data;
struct CSNode *firstChild; //指向第一个孩子
struct CSNode *nextsibling; //指向右兄弟
}

孩子兄弟表示法也可以存储森林。每棵树的树根都看成平级的关系,当作兄弟结点处理。

由于存储视角上看形态和二叉树雷系,所以是重要考点:树、森林和二叉树的转换。

树、森林转二叉树

树转二叉树

  1. 先在二叉树中,画出一个根结点;
  2. 按照树的“层序”依次处理每个结点:如果当前处理的结点在树中有孩子,就把所有孩子结点“用右指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方。

森林转二叉树

森林中各个树的根结点均视为平级的兄弟关系:

  1. 先把所有树的根结点画出来,在二叉树中用右指针串成糖葫芦
  2. 按照森林的“层序”依次处理每个结点:如果当前处理的结点在树中有孩子,就把所有孩子结点“用右指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方。

二叉树转树、森林

二叉树转树:

  1. 先画出树的根节点;
  2. 从树的根节点开始,按照树的“层序”恢复每个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦”拆下来,按顺序挂在当前结点的下方;

二叉树转森林:

  1. 先把二叉树的根节点和“一整串右指针糖葫芦”拆下来,作为多棵树的根节点;
  2. 按照森林的“层序”恢复每个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦”拆下来,按顺序挂在当前结点的下方;

树和森林的遍历

树的遍历分为三种:

  1. 先根遍历:若树非空,先访问根节点,再依次对每棵子树进行先根遍历(根左右);树的先根遍历序列与这棵树相应的二叉树的先序序列相同;
  2. 后根遍历:若树非空,先依次对每棵子树进行后根遍历,最后再访问根节点(左右根)。树的后根遍历序列与这棵树对应的二叉树的中序序列相同
  3. 层次遍历(用辅助队列实现):
    1. 若树非空,则根节点入队;、
    2. 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
    3. 重复2直到队列为空

先根遍历和后根遍历是深度优先,层次遍历是广度优先

森林的遍历分为两种:

  1. 先序遍历(效果等同于依次对各个树进行先根遍历,或者直接把森林转换成二叉树,看二叉树的先序遍历序列):
    1. 访问森林中第一棵树的根节点;
    2. 先序遍历第一棵树中根节点的子树森林
    3. 先序遍历除第一棵树之后剩余的树构成的森林。
  2. 中序遍历(效果上等同于依次对各个树进行后根遍历,或者看对应二叉树的中序遍历序列):
    1. 中序遍历森林中第一棵树的根节点的字数森林
    2. 访问森林中第一棵树的根节点
    3. 中序遍历除去第一棵树之后剩余的树

哈夫曼树

  • 结点的权:有某种现实含义的数值,比如表示结点的重要性等。

  • 结点的带权路径长度:从树的根到该节点的路径长度(经过的边数)与该结点上权值的乘积。
  • 树的带权路径长度:树中所有叶节点的带权路径长度之和。

在含有n个带权叶结点的二叉树中,其中带权路径长度最小的二叉树称为哈夫曼树,也称最优二叉树

哈夫曼树的构造

给定n个权值分别为的结点,构造哈夫曼树的算法描述如下: 1. 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F 2. 构造一个新的结点,从F中选取两棵根节点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根节点的权值之和; 3. 从F中删除刚才选出的两棵树,同时将新得到的树加入F中; 4. 重复2、3,直到F中只剩下一棵树为止;

  • 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大;
  • n个结点总共合并n-1次,每次合并都会导致增加一个结点,最终的结点总数为2n-1;
  • 哈夫曼树中不存在度为1的结点;
  • 哈夫曼树并不唯一,但每个哈夫曼树的带权路径长度必然相同且为最优。

哈夫曼编码

哈夫曼编码常用于数据压缩。

考虑以下场景,假设需要通过编码传递A、B、C、D的选择题答案,首先想到的是固定长度编码:每个字符用相等长度的二进制位表示。比如用ASCII编码,每个字符用8bit来表示A(01000001)、B(01000010)、C(01000011)、D(01000100).可见这种方式效率极低,如果有100道题目,总共要花费800bit。

首先想到的优化方式是,只有4个选项,那么每个字符可以只用长度为2的二进制表示:A(00)、B(01)、C(10)、D(11).这也是固定长度编码,100道题的二进制长度只有200bit.

假设已经知道了100题中有80个C,10个A,8个B,2个D,那么可以根据这个数字来构造哈夫曼树.

构造出来后,可以用0表示C,10表示A,111表示B,110表示D。这就是可变长度编码——允许使用不同字符用不等长的二进制位表示。这100道题最终只需要130bit。

哈夫曼编码是前缀编码:没有一个编码是另一个编码的前缀。前缀编码解码时无歧义。

哈夫曼编码:字符集中的每个字符作为一个叶子节点,各个字符出现的频度作为结点的权值,构造哈夫曼树。哈夫曼树不唯一,所以哈夫曼编码不唯一。

串的定义和实现

串,即字符串是由零个或多个字符组成的有限序列,一般记为。其中S是串命,n为串的长度

  • n=0时的串被称为空串(用表示)
  • 子串:串中任意个连续字符组成的子序列。空串也是字串;
  • 主串:包含子串的串;
  • 字符在主串中的位置称为字符在串中的序号。注意序号是从1开始的;
  • 子串在主串中的位置:子串第一个字符在主串中的位置;

串是一种特殊的线性表,数据元素之间呈现线性关系。串的数据对象限定为字符集。串的基本操作,通常以子串为操作对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//串的基本操作

//赋值操作,把串T赋值为chars
StrAssign(&T, chars);

//复制操作,由串S复制得到串T
StrCopy(&T, S);

//判空操作
StrEmpty(S);

//求串的长度,返回元素个数
StrLength(S);

//清空操作,将S清为空串
ClearString(&S);

//销毁串,回收存储空间
DestroyString(&S);

//串联接,用T返回由S1和S2联接成的新串
Concat(&t, S1, S2);

//求子串。用Sub返回串S的第pos个字符起长度为len的子串
SubString(&Sub, S, pos, len);

//定位操作。若主串s中存在与串T值相同的子串,则返回它在主串中第一次出现的位置,否则函数值为0
Index(S,T);

//比较操作,若S>T,则返回值>0,若S=T,则返回值=0;若S<T,则返回值<0,内部比较的是字符编码
StrCompare(S, T);

串的存储结构

串是特殊的线性表,所以参考线性表的存储方式来存储。

顺序存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define MAXLEN 255

//定长顺序存储
typedef struct{
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;


//堆分配存储
typedef struct{
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
HString S;
S.ch = (char*)malloc(MAXLEN * sizeof(char));
S.length = 0;

char ch[10]有四种存储方式

  1. 数组+变量length;
  2. 不设置length变量,ch[0]充当length,这样做可以保证字符的位序和数组下标相同
  3. 没有length变量,以字符'\0'表示结尾
  4. 设置变量length,并且ch[0]弃用。后续都用这个方式表示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
typedef struct{
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;

bool SubString(SString &Sub, SString S, int pos, int len)
{
//判断子串范围是否越界
if(pos + len - 1 > S.length)
return false;

for(int i = pos, i < pos+len; i++)
{
Sub.ch[i-pos+1] = S.ch[i];
}
Sub.length = len;
return true;
}

int StrCompare(SString S, SString T)
{
for(int i = 1; i <= S.length && i <= T.length; i++)
{
if(S.ch[i] != T.ch[i])
return S.ch[i] - T.ch[i];
}

//扫描过的所有字符都相同,则长度长的串更大
return S.length - T.length;
}

int Index(SString S, SString T)
{
int i = 1, n = StringLength(S), m = StrLength(T);
SString sub; //用于暂存子串
while(i <= n-m+1)
{
SubString(sub, S, i, m);
if(StrCompare(sub, T) != 0) ++i;
else return i; //返回子串在主串中的位置
}
return 0; //S中不存在与T相等的子串
}

链式存储

1
2
3
4
typedef struct StringNode{
char ch; //每个结点存一个字符
struct StringNode *next;
}StringNode, *String;

上面的做法存储密度很低,因为每个字符只占1B,但每个指针占用4B,有效信息占比太低,所以可以选择下面的方法存储:

1
2
3
4
typedef struct StringNode{
char ch[4]; //每个结点存多个字符
struct StringNode *next;
}StringNode, *String;

串的模式匹配

字符串模式匹配:在主串中找到与模式串相同的子串,并返回其所在的位置。

朴素模式匹配算法

朴素模式匹配算法其实就是暴力匹配。

主串长度为n,模式串长度为m;将主串中所有长度为m的子串依次与模式串对比,直到找到一个完全匹配的子串,或者所有子串都不匹配为止。

主串长度为n,模式串长度为m,则最多比较n-m+1个子串。

基础操作Index定位操作,其实就是朴素模式匹配算法:

1
2
3
4
5
6
7
8
9
10
11
12
int Index(SString S, SString T)
{
int i = 1, n = StringLength(S), m = StrLength(T);
SString sub; //用于暂存子串
while(i <= n-m+1)
{
SubString(sub, S, i, m);
if(StrCompare(sub, T) != 0) ++i;
else return i; //返回子串在主串中的位置
}
return 0; //S中不存在与T相等的子串
}

也可以不使用字符串的基本操作,直接通过数组下标实现朴素模式匹配算法:

  1. 变量i代表主串的数组下标,变量j代表模式串的数组下标
  2. 依次对比主串和子串对应的值,如果字符相等,i++,j++;
  3. 若当前子串匹配失败,则主串指针i指向下一个子串的第一个位置,模式串指针j回到模式串的第一个位置;
  4. 若j>T.length,则当前子串匹配成功,返回当前子串第一个字符的位置--i-T.length;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int Index(SString S, SString T)
{
int i = 1; j = 1;
while(i <= S.length && j <= T.length){
if(S.ch[i] == T.ch[i]){
//继续比较后续字符
++i;
++j;
}
else{
//指针退后重新匹配
i = i - j +2;
j = 1;
}
}
if(j > T.length)
return i - T.length;
else
return 0;
}

设主串长度n,子串长度m,则:

  • 最坏时间复杂度---模式串全部遍历完才能发现不匹配,遍历到主串末尾
  • 平均时间复杂度

KMP算法

基于朴素模式匹配算法优化得来的。朴素模式匹配算法中,依次遍历主串和模式串,一旦遍历失败,那么指针回溯,一切从头开始匹配。

但其实某一个子串哪怕匹配失败,但前面已经匹配过的字符是我们完全已知的,相当于模式串的子串。我们可以不用回溯主串指针。

以模式串"abaabc"为例:

上图主串S和模式串匹配到第6个元素的时候,由于前5个元素已经知道了,那么可以令主串指针不变,依旧指向第6位,模式串直接从第3位开始匹配。依次类推。

这种匹配方式和主串无关,只和模式串有关,例如针对模式串T="abaabc",有:

  • 当第6个元素匹配失败时,可令主串指针i不变,模式串指针j=3;
  • 当第5个元素匹配失败时,可令主串指针i不变,模式串指针j=2;
  • 当第4个元素匹配失败时,可令主串指针i不变,模式串指针j=2;
  • 当第3个元素匹配失败时,可令主串指针i不变,模式串指针j=1;
  • 当第2个元素匹配失败时,可令主串指针i不变,模式串指针j=1;
  • 当第1个元素匹配失败时,匹配下一个相邻子串,令j=0,i++,j++;

这样我们可以得到一个next数组:

next[0] next[1] next[2] next[3] next[4] next[5] next[6]
0 1 1 2 2 3

改造朴素模式匹配算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

KMP算法的最坏时间复杂度为;其中求next数组的时间复杂度,模式匹配过程最坏复杂度

next数组手算

next数组作用:当模式串第j个字符匹配失败时,从模式串的第next[j]开始继续往后匹配;

任何模式串都一样,第一个字符不匹配时,只能匹配下一个子串,因此next[1]无脑写0;

任何模式串都一样,第二个字符不匹配时,应尝试匹配模式串的第1个字符,因此next[2]无脑写1;

在不匹配的位置前画分界线,模式串一步步后退,直到分界线之前完全“能对上”,或者模式串完全跨过分界线为止。此时j指向哪里,next数组值就是多少

nextval数组

next数组的思路,其中中间有很多不必要的步骤,以模式串"abaabc"为例,next[3]=1,也就是当第三个字符不匹配时,模式串回溯到第一个位置去匹配,可是模式串第三个字符和第一个字符都是a,再次匹配依旧匹配不上,所以我们可以直接从头开始。以此类推next[5]=2,可是模式串第五个字符和第二个字符都是'b',从第二个字符开始依旧匹配不上,所以可以从next[2]开始匹配,即从1开始匹配。

手算解题:先求next数组,再由next数组求nextval数组:

1
2
3
4
5
6
7
8
nextval[1] = 0;

for(int j = 2; j <= T.length; j++){
if(T.ch[next[j]] == T.ch[j])
nextval[j] = nextval[next[j]];
else
nextval[j] = next[j];
}
序号j next[1] next[2] next[3] next[4] next[5] next[6]
模式串 a b a b a a
next[j] 0 1 1 2 3 4
nextval[j] 0 1 0 1 0 4

代码计算next

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void get_next(String T, int next[])
{
int i = 1,j = 0;
next[1] = 0;
while(i < T.length)
{
if(j == 0 || T.ch[i] == T.ch[j])
{
++i;
++j;
next[i] = j;
}
else
j = next[j];
}
}

计算机层次结构

硬件系统和软件系统共同构成了完整的计算机系统。

计算机硬件

冯诺依曼机

冯诺依曼提出了存储程序的概念:将指令以二进制代码的形式事先输入计算机的主存储器,计算机自动逐条执行指令直至程序结束。

冯诺依曼计算机特点:

  • 运算器、存储器、控制器、输入设备、输出设备5大部件组成;
  • 指令和数据以同等地位存储于存储器,可以按地址寻访;
  • 指令和数据用二进制表示;
  • 指令由操作码和地址码组成;
  • 存储程序
  • 以运算器为中心

现代计算机

冯诺依曼结构以运算器为中心,运算器来负责输入数据到输出数据的流程。现代计算机系统改进了这个方式,以存储器为核心,解放了运算器。

  • 计算机 = 主机 + IO设备
  • 主机 = CPU + 主存(内存)
  • CPU = 运算器 + 控制器

功能部件

存储器

存储器分为主存储器(内存)和辅助存储器(外存)。CPU能够直接访问的是主存,外存数据必须调入主存后才能被CPU访问。

主存由地址寄存器(Memory Address Register -- MAR)和数据计算器(Memory Data Register -- MDR)组成。数据在存储体内按照地址存储。不过在当代计算机系统中,MAR和MDR通常被集成到CPU中了。

  • 存储单元:每个存储单元存放一串二进制码;
  • 存储字(woed):存储单元中二进制代码的组合;
  • 存储字长:存储单元中二进制代码的位数;
  • 存储元:存储二进制的电子元件,每个存储单元可存1bit;
  • MAR的位数反应存储单元的个数,假设MAR为4位,那么总共有\(2^4\)个存储单元;
  • MDR的位数反应存储字长,假设MDR为16位,则每个存储单元可存放16bit,即一个字(word,注意不是byte)=16bit

运算器

运算器用于实现算数运算和逻辑运算

  • ACC(Accumulator):累加器,用于存放操作数,或者运算结果;
  • MQ(Multiple-Quotient Register):乘商寄存器,在乘、除运算时用于存放操作数或运算结果;
  • X:通用的操作数寄存器,用于存放操作数;
  • ALU(Arithmetic and Logic Unit):算数逻辑单元,通过内部复杂的电路实现算数运算和逻辑运算,是运算器的核心;

控制器

  • CU(Control Unit):控制单元,控制器的核心,分析指令,给出控制信号;
  • IR(Instruction Register):指令寄存器,存放当前正在执行的指令
  • PC(Program Counter):程序计数器,存放下一条指令的地址,有自动加1的功能。

程序执行过程

完成一条指令的步骤:取指令->分析指令->执行指令

  1. 根据PC取指令:(PC)->MAR
  2. 分析指令:M(MAR)->存储体->MDR,(MDR)->IR
  3. 取指令结束,PC自动+1指向下一条指令
  4. 分析指令:OP(IR)->CU,取IR中的操作码到CU
  5. 执行指令,不同的指令具体步骤不一样

计算机软件

软件分为应用软件和系统软件:

  • 应用软件是为了解决某个应用领域的问题而编制的程序;
  • 系统软件负责管理硬件资源,并向上层应用程序提供基础服务;

三个级别的语言:

  1. 机器语言
  2. 汇编语言
  3. 高级语言

计算机无法识别高级语言,对应的有三种翻译程序:

  1. 汇编程序:将汇编语言翻译为机器语言程序
  2. 解释程序:将源程序中的语句直接按照执行顺序翻译为机器指令
  3. 编译程序:将高级语言翻译为汇编或者机器语言程序

软件在硬件上的逻辑功能是等价的,同一个功能既可以用硬件实现,也可以用软件实现。硬件实现的性能高但成本高,软件实现的成本低但性能低。

指令集体系结构(ISA--Instruction Set Architecturn):软件和硬件之间的界面。设计计算机系统的ISA,就是要定义一台计算机可以支持哪些指令,以及每条指令的作用是什么,每条指令的用法是什么。

层次结构

  1. 微程序机器层,由硬件直接执行微指令
  2. 传统机器语言层,执行二进制机器指令
  3. 操作系统层,向上提供广义指令
  4. 汇编语言层,提供符号语言,用汇编程序翻译乘机器语言程序
  5. 高级语言层,面向用户,用编译程序翻译成汇编语言程序

下层是上层的基础,上层是下层的扩展。

源程序到可执行文件

  1. 预处理阶段:预处理器(cpp)对源程序中以#字符开头的命令进行处理
  2. 编译阶段:编译器(ccl)对预处理后的源程序进行编译,生成汇编语言源程序
  3. 汇编阶段:汇编器(as)将汇编语言源程序翻译为机器语言指令,并打包为可重定位目标文件
  4. 链接阶段:链接器(ld)将多个可重定位目标文件和标准库函数合并为一个可执行目标文件

计算机性能指标

存储器性能指标

  • 主存容量

主存容量表达主存储器能存储的最大容量,通常以字节(Byte)来衡量,也可以用字数\(\times\)字长(512K \(\times\) 16位)来表示。

总容量=\(存储单元个数\times存储字长bit = 存储单元个数\times存储字长\div 8 Bytes\)

主存中MAR位数反应存储单元的个数,MDR的位数就是存储字长,表示每个存储单元的大小。

假设MDR为32位,MDR为8位,则总容量为\(2^{32}\times8\)bit=4Gb.

存储容量常用单位:K(\(2^{10}\))、M(\(2^{20}\))、G(\(2^{30}\))、T(\(2^{40}\))

CPU性能指标

  • CPU主频:CPU内部数字脉冲信号震荡的频率

代表每秒执行多少个时钟周期数,值越大代表一个操作所需时间越少,CPU运行速度越快

计算方法:CPU主频=\(\frac{1}{CPU时钟周期}\)

时钟周期单位:微秒、纳秒;主频单位:Hz

  • CPI:执行一条指令所需要的时钟周期数

不同的指令,CPI不同;甚至相同的指令,CPI也可能发生变化。因此CPI是一个平均值。

\(CPI = \frac{时钟周期数量}{指令数量}\)

  • CPU执行时间:运行一个程序所花费的时间

\(CPU执行时间=\frac{CPU时钟周期数}{主频}=\frac{指令条数\times CPI}{主频}=时钟周期数\times时钟周期\)

  • MIPS:每秒执行多少百万条指令

\(MPIS=\frac{指令条数}{执行时间\times 10^6} = \frac{主频}{CPI\times 10^6}\)

  • FLOPS:每秒执行多少次浮点运算

常见单位:KFLOPS(\(10^3\))、MFLOPS(\(10^6\)、百万)、GFLOPS(\(10^9\)、十亿)、TFLOPS(\(10^{12}\)、万亿)、PFLOPS(\(10^{15}\))、EFLOPS(\(10^{18}\))、ZFLOPS(\(10^{21}\))

系统整体的性能指标

  • 数据通路宽带:数据总线依次所能并行传送信息的位数,指的是外部数据总线的宽度,不是CPU内部数据总线的宽度;

  • 吞吐量:系统在单位时间内处理请求的数量,主要取决于主存的存取周期

  • 响应时间:用户向计算机发送一个请求,到系统对该请求做出相应并获得结果所需要的等待时间

  • 基准程序:用来测量计算机处理速度的一种使用程序,就是常见的跑分软件

1
2
3
4
5
6
7
8
9
10
主频高的CPU一定比主频低的CPU快吗?
不一定,速度还和CPI有关


AB两个CPU的平均CPI相同,那么A一定更快吗?
不一定,还需要看指令系统,假如A不支持乘法指令B支持,那么A执行乘法时就需要多次加法来实现乘法操作


基准成宿执行的越快说明机器性能越好吗?
不一定,基准程序中的语句存在频度差异,运行结果不能完全说明问题。

逻辑结构

栈是只允许在一端进行插入或删除操作的线性表

几个术语:

  • 栈顶:线性表允许插入删除的那一端;
  • 栈底:固定的、不允许进行插入和删除的那一端;
  • 空栈:不含任何元素的空表;
  • 特点:后进先出(LIFO)

栈的常考题型:给定进栈顺序,判断有哪些合法的出栈顺序?n个不同元素进栈,出栈元素不同的排列顺序为,称为卡特兰数。

存储结构

顺序存储

顺序栈的实现

1
2
3
4
5
6
#define MaxSize 10   //栈的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈的元素
int top; //栈顶指针(数组下标)
}SqStack;

  • 栈顶指针:S.top,指向栈顶元素,可以理解为数组下标,初始时S.top=-1,栈顶元素表示为S.data[S.top]
  • 进栈操作:栈不满时,栈顶指针先加1,再送值到栈顶元素;
  • 出栈操作:栈非空时,先找栈顶元素值,再将栈顶指针减1;
  • 栈空条件:S.top==-1
  • 栈满条件:S.top == MaxSize-1;栈长S.top+1;

基本运算代码

栈的基本运算的时间复杂度都是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//初始化
void InitStack(SqStack &S)
{
S.top = -1;
}

//判空
bool StackEmpty(SqStack S)
{
if(S.top == -1)
return true;
else
return false;
}

//进栈
bool Push(SqStack &S, ElemType x)
{
if(S.top == MaxSize -1) //栈满了
return false;

S.top = S.top + 1; //指针先+1
S.data[S.top] = x; //新元素再入栈
return true;
}

//出栈
//注意,出栈操作只是逻辑上被删除出栈了,但数据依旧残留在内存中
bool Pop(SqStack &S, ElemType &x)
{
if(S.top == -1) //栈空
return false;

x = S.data[S.top]; //栈顶元素先出栈
S.top = S.top - 1; //指针再减1
return true;
}

//读栈顶元素
bool GetTop(SqStack S, ElemType &x)
{
if(S.top == -1)
return false;
x = S.data[S.top];
return true;
}

上述设计都是栈顶指针初始化为-1,即指向当前栈顶位置,此时入栈和出栈都需要先变更指针再操作数据;如果栈顶指针初始化为0,即指向下一个操作数的位置,那么正好相反,入栈和出栈需要先操作数据,再变更指针。

顺序栈的缺点:用静态数组存放元素,栈的大小不可改变。但如果我们一开始就分配非常大的地址空间,那么又会造成资源浪费。所以我们可以利用相对栈底位置不变的特性,让两个顺序栈公用一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延申。这个就是共享栈

1
2
3
4
5
6
7
8
9
10
11
12
13
#define Max 50
typedef struct{
ElemType data[MaxSize];
int top0; //0号栈顶指针
int top1; //1号栈顶指针
}

//初始化
void InitStack(ShStack &S){
S.top0 = -1;
S.top1 = MaxSize;
}

  • top0=-1时,0号栈为空;top1=MaxSize时,1号栈为空;
  • 栈满条件:top0 + 1 == top1
  • 0号栈进栈时,top0先加1再赋值;1号栈进栈时,top1先减1再赋值;

共享栈目的:

  • 更好的利用存储空间;
  • 两个栈互相调节,只有再整个存储空间都被占满时才发生上溢;

链式存储

链式存储方式完全可以类比链表操作,只是规定了只能在链表的一端(表头)进行操作。一般规定链栈没有头结点,LHead直接指向栈顶元素。

1
2
3
4
5
typedef struct Linknode
{
ElemType data; //数据域
struct Linknode* next; //指针域
}*LiStack;

基本操作

  • InitStack(&s) :初始化一个空栈;
  • StackEmpty(s):判断是否为空;
  • Push(&s,x):进栈;
  • Pop(&s,&x):出栈;
  • GetTop(s,&x):读栈顶元素
  • DestroyStack(&s):销毁栈并释放存储空间

栈的应用

括号匹配

计算机输入时,需要检查"()"、"[]"、"{}"这些括号是否都匹配出现。

由上图可知,连续输入左括号1、2、3,输入4的时候,3就被匹配了。即最后输入的左括号最先被匹配,正好符合后进先出的特性。可以用一个栈,遇到左括号就入栈,遇到右括号就弹出一个左括号。

算法思想:依次扫描所有字符,遇到左括号就入栈,遇到右括号则弹出栈顶元素检查是否匹配。

匹配失败的情况:

  1. 左括号单身
  2. 右括号单身
  3. 左右括号不匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//考试时用顺序栈即可,实际开发要用链栈
#define MaxSize 10
typedef struct{
char data[MaxSize];
int top;
}SqStack;

//考试时可以直接使用基本操作,简要说明下接口即可
void InitStack(SqStack &S); //初始化栈
bool StackEmpty(SqStack S); //判断是否为空
bool Push(SqStack &S, char x); //入栈
bool Pop(SqStack &S, char &x); //出栈

//括号检测,数组内为括号序列,length为数组长度
bool BracketCheck(char str[], int length)
{
SqStack S;
InitStack(S);

for(int i =0; i < length; i++){
if(str[i] == '(' || str[i] == '[' || str[i] == '{'){
Push(S, str[i]); //遇到左括号就入栈
}
else{
if(StackEmpty(S))
return false; //扫描到右括号时栈为空,则是非法输入

char topElem;
Pop(S, topElem); //栈顶元素出栈

//检测括号是否匹配
if(str[i] == ')' && topElem != '(')
return false;
if(str[i] == ']' && topElem != '{')
return false;
if(str[i] == '}' && topElem != '{')
return false;
}
}

return StackEmpty(S); //全部检索完成后,如果栈空才是匹配成功
}

表达式求值

上面的式子称为中缀表达式,由操作数、运算符、界限符组成。其中界限符(括号)是必不可少的,反映了计算的先后顺序,如果去掉界限符,那么整个式子的计算顺序就会发生改变。为了不用界限符也可以无歧义的表达运算顺序,产生了后缀表达式(逆波兰表达式)和前缀表达式(波兰表达式)。

  • 中缀表达式:运算符在两个操作数中间;
  • 后缀表达式:运算符在两个操作数后面;
  • 前缀表达式:运算符在两个操作数前面
中缀表达式 后缀表达式 前缀表达式
a+b ab+ +ab
a+b-c ab+c- -+abc

中缀转后缀的手算方法:

  1. 确定中缀表达式中各个运算符的运算顺序,由于运算顺序不唯一,所以后缀表达式也不是唯一的;
  2. 选择下一个运算符,按照【左操作数 右操作数 运算符】的方式组合成一个新的操作数
  3. 如果还有运算符没被处理,继续执行2

手算方法可能得到的后缀表达式不唯一,但对于计算机程序来说,算法的输出都是确定的。为了保证输出唯一,确定一个左优先原则:只要左边的运算符能先计算,那就优先计算左边的。

后缀表达式的手算方法:从左往右扫描,每遇到一个运算符,就让运算符前面最近的两个操作数执行对应的运算,然后合体为一个操作数继续扫描。

可以看到,最后出现的操作数最先被运算,和栈的特性相符。

  1. 从左往右扫描下一个元素,直到处理完所有的元素;
  2. 若扫描到操作数则压入栈,并回到1,否则执行3;
  3. 若扫描到运算符,则弹出两个栈顶元素,执行相应的运算,运算结果压回栈顶,回到1;
  4. 最后剩下的数就是最终的结果;

中缀转前缀的手算方法:

  1. 确定中缀表达式中各个运算符的运算顺序;
  2. 选择下一个运算符,按照【运算符 左操作数 右操作数】的方式组合成一个新的操作数;
  3. 如果还有运算符没被处理,就继续2;

右优先原则:只要右边的运算符能优先计算,就优先计算右边的。

用栈实现前缀表达式的计算:

  1. 从右往左扫描下一个元素,直到处理完所有元素;
  2. 若扫描到操作数则压入栈,并回到1;否则执行3;
  3. 若扫描到运算符,则弹出两个栈顶元素,执行相应运算,结果压回栈顶,回到1;

1
2
3
4
5
6
7
8
9
10
11
12
//中缀表达式转后缀表达式代码

/*
从左到右处理各个元素时可能遇到的三种情况:
1、遇到操作数:直接加入后缀表达式
2、遇到界限符:遇到“(”直接入栈,遇到")"则依次弹出栈内运算符并加入后缀表达式,直到弹出"("为止,注意,"("不加入后缀表达式
3、遇到运算符:依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到"("或栈空则停止。之后再把当前运算符入栈。

按照上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。
*/


1
2
3
4
5
6
7
8
//中缀表达式的计算

/*
初始化两个栈,代表操作数栈和运算符栈(中缀转后缀+后缀表达式求值 两个算法的结合)
若扫描到操作数,则压入操作数栈
若扫描到运算符或界限符,则按照中缀转后缀相同的逻辑压入运算符栈(期间也会弹出运算符,每弹出
一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应的运算,运算结果再压回操作数栈)
*/

递归

函数调用的过程本身就符合栈的特性:最后调用的函数总是最先执行结束。真实函数执行的时候,本身也是用一个栈来存储:1、调用返回地址;2、实参;3、局部变量;

面对问题时,如果我们可以把原始问题转换为属性相同,但规模较小的问题时,就可以使用递归算法。

递归调用时,函数调用栈称为“递归工作栈”。每进入一层递归,就会将递归调用所需要的信息压入栈顶;每退出一次递归,就从栈顶弹出相应信息。所以递归层数一旦变多就可能导致栈溢出。

递归的另一个缺点是效率不高,因为递归调用中可能包含很多重复的计算。

队列

逻辑结构

队列是只允许在一端进行插入,在另一端进行删除的线性表

  • 队头:允许删除的一端
  • 队尾:允许插入的一端
  • 特点:先进先出(FIFO)

存储结构

顺序存储结构

顺序存储实现

分配一块连续的存储单元存放队列中的元素,并设置两个指针指向队头和队尾的下一个位置;

1
2
3
4
5
6
7
8
9
10
11
#define MAXSzie 10
typedef struct{
ElemType data[MaxSize]; //静态数组存放队列元素
int front,rear; //队头指针和队尾指针
}SqQueue;

//初始化队列
void InitQueue(SqQueue &Q){
Q.rear = Q.front = 0; //初始化时,队头和队尾指针都指向0
}

上图指出了顺序队列的的操作:

  • 初始状态:front和rear都是0;
  • 入队操作:队列不满时,先将值送入队尾,然后队尾指针+1;
  • 出队操作:队不空时,先取头元素值,再将队头指针+1;
  • 判空条件:Q.front == Q.rear = 0;

注意,不能使用【Q.rear == MaxSize】来判断队列是否已满,可以看到上图虽然已经出队了,哪怕只剩最后一个元素,但此时Q.rear还是等于MaxSize,这种情况是假溢出(上溢出)。

循环队列

普通的顺序队列可能存在上溢出的情况,所以可以使用循环队列,把顺序队列臆造为一个环状空间。

  • 初始状态:Q.front = Q.rear = 0;
  • 入队操作,队尾+1取模:Q.rear = (Q.rear+1) % MaxSize
  • 出队操作,队头+1取模:Q.front = (Q.front + 1) % MaxSize
  • 获取队列长度:(rear + MaxSize - front) % MaxSize
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//队列判空
bool QueueEmpty(SqQueue Q){
if(Q.rear == Q.front){
return true;
}
else{
return false;
}
}

//入队操作
bool EnQueue(SqQueue &Q, ElemType x){
if((Q.rear + 1) % MaxSize == Q.front) //判断队满的条件:队尾指针的下一个位置就是队头
return false;
Q.data[Q.rear] = x; //新元素插入队尾
Q.rear = (Q.rear+1) % MaxSize; //队尾元素+1后取模,这样可以实现循环
return true;
}

//出队操作
bool DeQueue(SqQueue &Q, ElemType &x){
if(Q.rear == Q.front){ //队列为空
return false;
}

x = Q.data[Q.front]; //取队头
Q.front = (Q.front + 1) % MaxSize; //队头指针后移
return true;
}

//获取队头
bool GetHead(SqQueue Q, ElemType &x){
if(Q.rear == Q.front){
return false;
}
x = Q.data[Q.front];
return true;
}

上面的判断队是否满的操作,是通过牺牲一个存储单元,入队时少使用一个存储单元,判断队尾的下一个元素是否为队头,这个判断方法很常用,但也有其他的方法。

可以再定义一个元素size,来表示队列当前的长度,size初始化为0,每入队一次,size++,每出队一次,size--

1
2
3
4
5
6
#define MAXSzie 10
typedef struct{
ElemType data[MaxSize]; //静态数组存放队列元素
int front,rear; //队头指针和队尾指针
int size; //队列长度
}SqQueue;

也可以设置一个变量tag,标记上一次的操作,删除为0,插入为1。已知只有删除操作才可能导致队空,只有插入操作才可能导致队满

1
2
3
4
5
6
7
8
9
10
11
12
13
#define MAXSzie 10
typedef struct{
ElemType data[MaxSize]; //静态数组存放队列元素
int front,rear; //队头指针和队尾指针
int tag; //队列长度
}SqQueue;

//队满的条件:
front == rear && tag == 1; //头尾指针相遇且上次操作为插入

//队空的条件
front == rear && tag == 0; //头尾指针相遇且上次操作为删除

上述都是假定队尾元素指向的是下一个应该插入的位置,假设题目说的是队尾指针指向的就是尾元素,插入操作前应该先让队尾指针前移一位,再写入数据元素,初始化时,尾指针也应该指向MaxSize-1这个位置

1
2
3
4
5
6
7
8
9
10
11
12
13
//初始化
front = 0;
rear = MaxSize - 1;

//插入
Q.rear = (Q.rear + 1) % MaxSize;
Q.data[Q.rear] = x;

//判空:队尾下一个就是队头
(Q.rear + 1) % MaxSize == Q.front;

//判满,牺牲一个存储单元的方法
(Q.rear + 2) % MaxSize == Q.front;

链式存储

本质上就是一个同时带有队头指针和队尾指针的单链表。也有带头结点的和不带头结点的。

1
2
3
4
5
6
7
8
9
10
//链式队列结点
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;

//链式队列
typedef struct{
LinkNode *front,*rear; //队列的队头和队尾指针
}LinkQueue;

链式存储实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//带头结点

//初始化
void InitQueue(LinkQueue &Q){
Q.front = Q.rear = (LinkNode*)malloc(sizeof(LinkNode)); //初始化时,front和rear都指向头结点
Q.front->next = NULL;
}

//判空
bool IsEmpty(LinkQueue Q){
if(Q.front == Q.rear){
return true;
}
else{
return false;
}
}

//入队
void EnQueue(LinkQueue &Q, ElemType x){
LinkNode *s = (LinkNode*)malloc(sizeof(LinkNode));
s->data = x; //开辟空间,初始化值
s->next = NULL; //创造出来的其实就是链表的尾
Q.rear->next = s; //新结点插入到rear之后
Q.rear = s; //修改表尾指针
}

//出队
bool DeQueue(LinkQueue &Q, ElemType &x){
if(Q.front == Q.rear){ //队列判空
return false;
}

LinkNode* p = Q.front->next; //对于带头结点的队列来说,front指向的是头,要删除的其实是头结点的下一个元素
x = p->data;
Q.front->next = p->next; //修改头结点next指针
if(Q.rear == p){
//如果删除的是最后一个元素,还需要让尾结点也指向头结点
Q.rear = Q.front;
}
free(p);
return true;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//不带头结点

//初始化
void InitQueue(LinkQueue &Q){
Q.front = NULL;
Q.rear = NULL;
}

//判空
bool IsEmpty(LinkQueue Q){
if(Q.front == NULL){
return true;
}
else{
return false;
}
}

//入队
void EnQueue(LinkQueue &Q, ElemType x){
LinkNode *s = (LinkNode*)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;

if(Q.front == NULL){
//不带头结点的话,需要单独处理第一次插入的场景,因为初始化时头尾都是NULL
Q.front = s;
Q.rear = s;
}
else{
Q.rear->next = s; //新结点插入rear之后
Q.rear = s; //修改rear指针
}
}

//出队
bool DeQueue(LinkQueue &Q, ElemType &x){
if(Q.front == NULL)
return false; //空队
LinkNode* p = Q.front; //p指向出队的结点
x = p->data;
Q.front = p->next; //修改front指针
if(Q.rear == p){
//需要判断删除的是否为最后一个
Q.front = NULL;
Q.rear = NULL;
}
free(p);
return true;
}

链式存储操作除非内存不足,否则不存在队满的情况。

双端队列

双端队列是两端都可以进行入队和出队操作的线性表。

  • 输入受限的双端队列:只允许一端插入,两端删除的线性表,做题时输入固定,看输出;
  • 输出首先的双端队列:只允许两端插入,一端删除的线性表,做题时输出固定,看输入;

基本操作

  • InitQueue(&Q):初始化队列,构造一个空队列;
  • DestroyQueue(&Q):销毁队列,释放空间;
  • EnQueue(&Q,x):入队;
  • DeQueue(&Q,&x):出队;
  • GetHead(Q,&x):读队头元素,队列的使用场景中,大多数只访问队头元素
  • QueueEmpty(Q):判断队列是否为空

队列的应用

树的层次遍历

使用队列是为了保存下一步的处理顺序:

  1. 根节点入队
  2. 若队空,则结束遍历,否则重复3
  3. 队列中第一个结点出队并访问。若有左孩则左孩入队,若有右孩则右孩入队;返回2

操作系统中的应用

  1. 系统中多个进程争抢使用有限的系统资源时,FCFS(First Come First Service)是一种常用的策略,符合队列场景;
  2. 解决主机与外部设备之间速度不匹配的问题
  3. 页面替换算法

数组

数据结构

数组定义:由n个相同类型的数据元素构成的有限序列。

一维数组:数组元素a[i]的存放地址 = 起始地址+i*sizeof(ElemType);除非题目特别说明,否则数组下标默认从0开始。

二维数组:有两种地址映射方法:

  1. 行优先存储:连续地址空间内先行后列,先存储行号较小的元素,行号相等先存储列号较小的元素;M行N列数组中b[i][j]地址=起始地址+(i*N+j)*sizeof(ElemType)
  2. 列优先存储:连续地址空间内先列后行,先存储列号较小的元素,列号相等先存储行号较小的元素;M行N列数组中b[i][j]地址=起始地址+(j*M+i)*sizeof(ElemType)

矩阵存储

  • 普通矩阵 可用二维数组来存储普通的矩阵。注意描述矩阵的时候,行、列号通常从1开始,而数组下标通常从0开始。

  • 对称矩阵的压缩存储

压缩存储策略:只存储主对角线+下三角区。方法就是按照行优先原则将各个元素存入一维数组中。

一维数组分配的的大小为等差数列求和:

需要实现一个“映射”函数,把矩阵下标映射到一维数组下标中。核心思想:按照行优先原则,需要判断是第几个元素.

当ij时,可知前面有列,则它前面有个元素,即个元素,由于数组下标从0开始,则数组下标为

当i<j时,可以利用对称矩阵性质,把转化为.

  • 三角矩阵的压缩存储

例如下三角矩阵:除了对角线和下三角区外,其余元素都相同。也可以按照行优先原则,先将下三角区域的元素存入一维数组,并在最后一个位置存储常量c。

一维数组分配的的大小:

映射函数和对称矩阵相似,只是当ij时,直接取数组最后一个元素。

  • 三对角矩阵的压缩存储

三对角矩阵,又称带状矩阵:当时,有

压缩存储策略:按照行优先原则,只存储袋装部分即可。除了第一行和最后一行外,每一行只存3个元素,总共存储个元素。

对于来说,前行一共有个元素,是第i行第j-i+2个元素,则是第个元素

  • 稀疏矩阵的压缩存储

稀疏矩阵:非零元素个数远远少于矩阵元素个数。

方法1:三元组法。定义一个三元组<行,列,值>,保存矩阵中的非零元素。可以定义一个struct,然后用一维数组存储这些值。用这种方法存储后就失去了随机存取的特性。

方法2:十字链表法。定义两个数组,分别代表矩阵的行(向右域)和列(向下域)。数组中存放的是指针,向下域中每个数组项指向当前列的第一个元素,向右域中每个数组项指向当前行的第一个元素。每一个非零元素对应一个结点,结点值域中存<行,列,值>这样的三元组,同时结点中还有两个指针,分别指向当前行和当前列的下一个结点。

逻辑结构--线性表的定义

线性表是具有数据类型的n(n 0)个数据元素的有限序列。n为表长,n=0时称为空表。

  • 相同数据类型:每个元素所占空间一样大;
  • 有限:长度必须有限,比如所有整数就不是个线性表
  • 序列:必须有次序

  • 是线性表的”第i个“元素线性表中的位序;
  • 是表头元素,是表尾元素。注意线性表的位序是从1开始,数组下标是从0开始;
  • 除表头外,每个元素都有直接前驱;除表尾外,每个元素都有直接后继;

运算--线性表的基本操作

基本操作是指最核心、最基本的操作。其他复杂操作都可以通过调用基本操作来实现。

所有基本操作都可以归结为创建、销毁、增删改查

  • 创建和销毁:
    • InitList(&L):初始化表。构造一个空的线性表L,分配内存空间;
    • DestoryList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间;
  • 增删操作
    • ListInsert(&L, i, e):插入操作。在表L中的第i个位置插入指定元素e;
    • ListDelete(&L, i, &e):删除操作,删除表L中第i个位置的元素,并用e返回删除元素的值
  • 查询操作(改之前也需要查询)
    • LocateElem(L, e):按值查找操作。在表L中查找具有给定关键字值的元素;
    • GetElem(L, i):按位查找,获取表L中第i个位置的元素的值;
  • 其他常用操作:
    • Length(L):求表长。返回线性表L的长度,即L中数据元素的个数;
    • PrintList(L):输出操作。按照前后顺序输出线性表L的所有值;
    • Empty(L):判空操作。若L为空表,则返回true,否则返回false;

存储结构

顺序表

顺序表的定义

  用顺序存储方式实现线性表的顺序存储。把逻辑上相邻的元素存储在物理位置也相邻的存储单元中,元素之间的关系用存储单元的邻接关系来体现。

顺序表的特点:

  • 随机访问:因为在内存中连续存储,所以在时间内即可找到第i个元素,代码实现data[i-1];
  • 存储密度高,每个节点只需要存储数据元素本身
  • 拓展容量不方便,即使是动态存储,拓展长度的时间复杂度也很高,因为需要复制原来的元素;
  • 插入、删除操作不方便,需要移动大量的元素;

静态分配

静态分配就是用数组的方式来实现顺序表。

1
2
3
4
5
6
#define MaxSize 10    //定义最大长度
typedef struct {
ElemType data[MaxSize]; //用静态的“数组”存放数据元素
int length; //顺序表的当前长度
}SqList;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void InitList(SqList& L) {
for (int i = 0; i < MaxSize; i++) {
L.data[i] = 0; //将顺序表的所有元素设置为默认值
}
L.length = 0; //顺序表的初始长度为0
}

int main() {
//声明一个顺序表,在内存中分配存储空间
SqList L;

//初始化顺序表
InitList(L);
}

顺序表的局限性:顺序表的表长在刚开始确定后就无法更改。

动态分配

1
2
3
4
5
6
7
8
9
10
11
12
13
#define InitSize 10    //顺序表的初始长度

typedef struct {
ElemType *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList;

//C语言动态申请和释放内存空间--malloc、free
L.data=(ElemType*)malloc(sizeof(ElemType)*InitSize);

//C++动态申请和释放内存空间--new、delete
L.data=new ElemType[InitSize];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#define InitSize 10    //顺序表的初始长度

typedef struct {
int *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList;

void InitList(SeqList& L) {
L.data = (int*)malloc(InitSize * sizeof(int));
L.length = 0;
L.MaxSize = InitSize;
}

//增加动态数组长度
void IncreaseSize(SeqList& L, int len)
{
int* p = L.data; //p和L.data指向同一个地址位置
L.data = (int*)malloc((L.MaxSize + len) * sizeof(int)); //L.data指向新的地址空间
for (int i = 0; i < L.length; i++) {
L.data[i] = p[i]; //将数据复制到新区域
}
L.MaxSize = L.MaxSize + len; //顺序表最大长度增加len
free(p); //释放原来的内存空间
}

int main() {

SeqList L; //声明顺序表
InitList(L); //初始化顺序表

IncreaseSize(L, 5);
}

动态存储不是链表,物理结构没有发生变化,都是连续的地址空间。只是可以运行时动态决定分配的内存地址空间。

基本操作

插入

实现在顺序表L中的()位置插入元素。要注意代表线性表的位序,是从1开始的,而内部使用的数组下标是从0开始的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ListInsert(SqList& L, int i, Elemtype e)
{
//首先判断i的范围是否有效
if (i<1 || i>L.length + 1) //用i>length+1是要保证线性表内部数据必须连续,当前长度为length,最多只能在length+1处插入
return false;
if (L.length >= MaxSize) //存储空间已满
return false;

for (int j = L.length; j >= i; j--)
{
L.data[j] = L.data[j - 1]; //i位置后面的元素挨个后移。注意数组下标
}

L.data[i - 1] = e; //i位置插入e,注意数组下标
L.length++; //线性表长度++
return true;
}

时间复杂度计算最深层循环,即元素后移时的次数

  • 最好时间复杂度:表尾插入,i=n+1(length+1),不需要后移,时间复杂度;
  • 最坏时间复杂度:表头插入,i=1,原有的n个元素都需要向后移动,时间复杂度
  • 平均时间复杂度:新元素插入到任何一个位置的概率都相同,即i=1,2,3,……,length+1的概率都是p=1/(n+1),每个相加,等差数列求和,得n/2,所以时间复杂度;

删除

实现删除顺序表L中第)个位置的元素,用引用变量e返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool ListDelete(Sqlist &L, int i, ElemType& e)
{
//首先判断参数合法性
if(i < 1 || i > L.length)
return false;

e = L.data[i-1]; //将被删除的元素赋值给e,注意数组下标和顺序表次序

for(int j = i; j < L.length; j++)
{
L.data[j-1] = L.data[j]; //将第i个位置后的元素前移
}

L.length--;
return true;
}

时间复杂度的耗时和插入一样,在元素移动上:

  • 最好时间复杂度:表尾删除,i=n,无需移动元素,时间复杂度为
  • 最坏时间复杂度:表头删除,i=1,需要移动除了表头外的所有元素,时间复杂为
  • 平均时间复杂度:

按位查找

获取表L中第i个位置的元素的值。

如果顺序表是静态分配的,那么可以直接用数组下标来取。要注意数组下标和位序的区别。

1
2
3
4
5
6
7
8
9
10
11
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int length;
}Sqlist;

ElemType GetElem(Sqlite L, int i)
{
//这里可以补上对i合法性的判断
return L.data[i-1];
}

动态分配时,依旧可以用数组下标来取值。

1
2
3
4
5
6
7
8
9
10
11
12
#define InitSize 10
typedef struct{
ElemType *data;
int MaxSize;
int length;
}Seqlist;

ElemType GetElem(Seqlist L, int i)
{
//这里可以补上对i合法性的判断
return L.data[i-1];
}

按位查找的时间复杂度为。这就是顺序表随机存取的特性。顺序表在内存中都是连续存放的,可以根据起始地址和数据元素大小立即找到第个元素。

按值查找

在顺序表L中找到第一个元素等于的元素,并返回其位序。

1
2
3
4
5
6
7
8
9
int LocateElem(Sqlite L, ElemType e)
{
int i;
for(i = 0; i < L.length; i++)
if(L.data[i] == e)
return i+1; //返回的是位序

return 0;
}

注意:上面那个是伪代码,==只能用于基本数据类型,如果是类似于结构体那样的,C语言要自己挨个比较成员变量,C++需要重载==运算符。

时间复杂度:

  • 最好时间复杂度:查找的元素在表头,只需要比较一次,时间复杂度
  • 最坏时间复杂度:查找的元素在表尾,循环比较n次,时间复杂度
  • 平均时间复杂度:;

链式存储

顺序表的优点是可以随机存取,存储密度高,但顺序表的缺点是要求大片的连续存储空间,改变容量很不方便,且插入和删除操作需要移动大量的元素。

链式存储的线性表,每个需要使用连续地址单元,通过链建立逻辑关系。每个节点除了存放数据元素之外,还存放指向下一个节点的指针。优点是不要求大片连续地址空间,改变容量方便,但缺点是不可随机存取,而且要耗费一定空间存放指针。

单链表

单链表定义

线性表的链式存储又称为单链表。

1
2
3
4
5
6
7
8
9
10
11
//单链表的定义
typedef struct LNode{
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;

//表示一个单链表,只需要声明一个头指针L,指向单链表的第一个节点
LNode* L;
LinkLisk L;

//LNode*和LinkLisk一个意思,不过代码中一般用LNode*来强调这是一个结点,用LinkList来强调这是一个单链表。

单链表有两种实现方式,一种是带头节点,一种是不带头节点:

不带头节点的单链表初始化方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct LNode{
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;

bool InitList(LinkList &L)
{
L = NULL; //空表
return true;
}

void test()
{
LinkList L; //声明一个指向单链表的指针,注意此时并没有创建一个结点

InitList(L);
}

//判空逻辑:
bool Empty(LinkList L)
{
return (L == NULL);
}

头结点是在单链表的第一个结点之前附加一个结点,头结点不存储任何数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef struct LNode{
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;

bool InitList(LinkList &L)
{
L = (LNode*)malloc(sizeof(LNode)); //分配一个头结点
if(L == NULL) return false; //内存不足分配失败

L->next = NULL; //头结点之后暂时还没有结点
return true;
}

void test()
{
LinkList L; //声明一个指向单链表的指针,注意此时并没有创建一个结点

InitList(L);
}

//判空逻辑:
bool Empty(LinkList L)
{
if(L->next == NULL)
return true;
else
return false;
}

头指针和头结点的区别:

  • 头指针始终指向链表中的第一个结点;
  • 如果不带头结点,那头指针指向的就是存放数据的结点;如果带头结点,那么头指针指向的就是头结点,是不存放数据的;

之所以引入头结点,是为了写代码方便:

  1. 有头节点后,所有位置的操作算法统一,不再需要判断是否在第一个元素之前插入或删除第一个元素
  2. 不论链表是否为空,其头指针是指向头节点的非空指针,链表的头指针不变,因此空表和非空表的处理也就统一了

单链表基本操作

按位序插入

ListInsert(&L, i, e):在表L中的第i个位置插入指定元素e。

思路为找到待插入位置的前驱结点(第i-1),然后在其后面插入新结点。

带头结点的链表,不需要对第一个结点做特殊处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e)
{
if(i < 1) return false; //先判断插入位置是否合法

//第一步:找到第i-1个结点的位置
LNode *p; //指针p指向当前扫描到的结点
int j = 0; //当前p指向的结点的位置
p = L; //L指向头结点,头结点是第0个结点(不存数据)
while(p != NULL && j < i-1){
p = p->next;
j++;
}
if(p == NULL)
return false; //i值不合法,超出了线性表长度

//分配新结点,插入
LNode* s = (LNode*)malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s;
return true;
}

这个算法的时间开销在于找到前驱结点的耗时,平均时间复杂度为

如果链表没有头结点,那么需要单独处理插入第一个元素时候的情况,需要把头指针的位置变更。后续的结点操作都一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//在第i个位置插入元素e(不带头结点)
bool ListInsert(LinkList &L, int i, ElemType e)
{
if(i < 1) return false; //先判断插入位置是否合法

//单独处理第一个结点
if(i == 1)
{
LNode* s = (LNode*)malloc(sizeof(LNode));
s->data = e;
s->next = L;
L = s; //头指针指向新插入的结点
return true;
}

//第一步:找到第i-1个结点的位置
LNode *p; //指针p指向当前扫描到的结点
int j = 1; //当前p指向的结点的位置,此时从1开始
p = L; //p指向第一个结点,注意不是头结点
while(p != NULL && j < i-1){
p = p->next;
j++;
}
if(p == NULL)
return false; //i值不合法,超出了线性表长度

//分配新结点,插入
LNode* s = (LNode*)malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
按照结点插入

链表的插入操作分为前插操作后插操作,即给定结点p,在其之后或者之前插入一个结点。单链表的插入算法中,一般都选择后插操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
//在p结点之后插入元素e
bool InsertNextNode(LNode* p, ElemType e)
{
if(p == NULL)
return false;
LNode* s = (LNode*)malloc(sizeof(LNode));
if(s == NULL)
return false; //分配失败,内存不足
s->data = e; //新创建的结点s保存数据元素e
s->next = p->next;
p->next = s; //将s连接到p之后
return true;
}

正常来说,链表的每个结点都只能找到其后面的元素,那如何实现在某结点的前面插入元素呢?其实不需要考虑的那么复杂,链表插入后位置就固定了,我们依旧可以在p结点之后插入一个新结点s,然后交换p和s的数据域,达到偷天换日的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//在p结点之前插入元素e
bool InsertPriorNode(LNode *p, ElemType e)
{
if(p == NULL)
return false;

LNode* s = (LNode*)malloc(sizeof(LNode));
if(s == NULL)
return false;

s->next = p->next;
p->next = s; //依旧现在p结点之后插入s

//交换s和p的数据域:
s->data = p->data;
p->data = e;
return true;
}

//在p结点之前插入结点s
bool InsertPriorNode(LNode *p, LNode *s)
{
if(p == NULL || s == NULL)
return false;

s->next = p->next;
p->next = s; //依旧现在p结点之后插入s

//交换s和p的数据域:
ElemType temp = p->data;
p->data = s->data;
s->data=temp;
return true;
}
按位序删除

只分析带头结点的链表的删除操作。删除第i个位置的结点p,只需要找到第i-1个结点,让其指向第i+1的位置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool ListDelete(LinkList &l, int i, ElemType &e)
{
if(i < 1)
return false;

LNode* p; //p指向当前扫描到的结点
int j = 0; //j是结点的位序,从0开始(头结点开始)
p = L; //L指向头结点

while(p != NULL && j < i-1){ //找到第i-1个结点
p = p->next;
j++;
}
if(p == NULL)
return false; //i值不合法,超出线性表长度

if(p->next == NULL)
return false; //第i-1个结点之后已经没有其他结点

LNode* q = p->next; //q指向被删除结点
e = q->data; //返回被删除元素的值
p->next = q->next; //将*q结点从链中断开
free(q); //释放存储空间
return true;
}

时间消耗主要在寻找前驱结点上,平均时间复杂度是

删除指定结点

删除指定的结点p,可以参考前插操作的偷天换日方法。赋值p的后继结点给自身,这样p和p的后继,同时指向了p的后继的后继,然后删除p的后继结点:

1
2
3
4
5
6
7
8
9
10
11
12
//删除指定的结点p
bool DeleteNode(LNode *p)
{
if(p == NULL)
return false;

LNode* q = p->next; //临时结点q指向p的后继
p->data = p->next->data; //p和后继结点交换数据域
p->next = q->next; //p的指针指向后继的后继
free(q); //释放p的后继
return true;
}

注意上面的代码是有缺陷的,不能删除最后的结点。因为最后的结点,他的后继是空,没有数据域了,会崩溃。如果要删除最后一个结点,那只能函数传递链表头指针,挨个遍历寻找p的前驱结点,时间复杂度是。这也体现出了单链表的局限性:无法实现逆向检索。

按位查找

只考虑带头结点的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//按位查找,返回第i个元素,找不到返回NULL
LNode* GetElem(LinkList L, int i)
{
if(i < 0)
return NULL;

LNode *p; //指针p指向当前扫描到的结点
int j = 0; //从头结点开始
p = L; //p指向头结点
while(p != NULL && j < i)
{
p = p->next;
j++;
}
return p;
}

平均时间复杂度

按值查找

只考虑带头结点的情况

1
2
3
4
5
6
7
8
9
10
//按值查找,找到数据域==e的结点,没有返回NULL
LNode* LocateElem(LinkList L, ElemType e)
{
LNode* p = L->Next; //L是头结点,p要从第一个结点开始
//从第1个结点开始查找数据域为e的结点
while(p != NULL && p->data != e){
p = p->next;
}
return p;
}
求表长
1
2
3
4
5
6
7
8
9
10
11
int Length(LinkList L)
{
int len = 0;
LNode* p = L;
while(p->next != NULL)
{
p = p->next;
len++;
}
return len;
}

上面是带头结点的单链表求表长。如果不带头结点,需要单独处理一下空表的情况。

单链表的建立

给定指定数量的数据元素,要把他们存到一个单链表里面,操作步骤:

  1. 初始化一个单链表
  2. 每次取一个数据元素,插入到链表中

根据插入到链表表头还是表尾,可以分为头插法和尾插法。

尾插法,即新结点插入到链表的表尾,插入顺序和结点次序相同。插入不要用前面的按位插入,每次插入都要进行一次遍历,整体的时间复杂度会变成。所以新增一个尾指针。始终指向当前链表的尾结点,每次插入只需要针对尾指针进行插入即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//尾插法
//用户循环输入int值,插入链表,直到输出9999结束
LinkList List_TailInsert(LinkList& L)
{
int x;
L = (LinkList)malloc(sizeof(LNode)); //初始化空表,建立头结点
L->next = NULL;
LNode* s = L;
LNode* r = L; //r是尾指针
scanf("%d", &x);
while(x != 9999)
{
s = (LNode*)malloc(sizeof(LNode));
s->data = x;
r->next = s;
r = s; //尾指针指向新的表尾结点
scanf("%d", &x);
}
r->next = NULL; //尾结点指针置空
return L;
}

头插法:每次输入元素的时候,都插入到单链表的表头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LinkList List_HeadInsert(LinkList& L)
{
LNode* s;
int x;
L = (LinkList)malloc(sizeof(LNode)); //初始化空表,创建头结点
L->next = NULL;
scanf("%d", &x);
while(x != 9999)
{
s = (LNode*)malloc(sizeof(LNode)); //创建新结点
s->data = x;
s->next = L->next;
L->next = s; //L为头指针,将新结点插入表头
scanf("%d", &x);
}
return L;
}

双链表

单链表只能从前往后遍历。如果要访问某结点的前驱,只能再次从前往后遍历一次。所以引入双链表,内部两个指针,分别指向前驱结点和后继结点。

以下我们讨论双链表,默认带头结点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typedef struct DNode{
ElemType data;
struct DNode *prior,*next;
}DNode, *DLinkNode;

//初始化
bool InitDLinkList(DLinkList &L)
{
L = (DNode*)malloc(sizeof(DNode)); //分配一个头结点
if(L == NULL)
return false;
L->prior = NULL; //头结点的prior永远指向NULL
L->next = NULL; //初始化,头结点之后暂时还没有结点
return true;
}


//双链表判空,只需要看头结点之后是否有结点
bool Empty(DLinkList L)
{
if(L->next == NULL)
return true;
else
return false;
}

双向链表的插入,一定要注意指针操作的顺序,还要特别处理一下如果插入到最后一个结点的情况。以后插操作为例子,所有的插入操作都可以转化为后插。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//在p结点之后插入结点s
bool InsertNextDNode(DNode *p, DNode *s)
{
if(p == NULL || s == NULL)
return false;

//1、s后继指向p的后继
s->next = p->next;

//2、p的后继的前驱指向s,要注意p是否为尾结点
if(p->next != NULL)
p->next->prior = s;

//3、s的前驱指向p
s->prior = p;

//4、p的后继指向s
p->next = s;

return true;
}

删除操作一样,要特别处理一下删除的结点就是最后一个结点的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//删除p结点的后继结点
bool DeleteNextDNode(DNode* p)
{
if(p == NULL)
return false;

DNode *q = p->next; //q就是要删除的结点
if(q == NULL)
return false; //p没有后继结点,不用删除

//1、p的后继指向q的下一个
p->next = q->next;

//2、q的前驱指向p,要判断q是不是已经是最后的结点了
if(q->next != NULL)
q->next->prior = p;

free(q); //释放结点空间
return true;
}

销毁双链表,不断调用销毁结点的方法即可

1
2
3
4
5
6
7
void DestroyList(DLinkList &L)
{
while(L->next != NULL)
DeleteNextDNode(L);
free(L); //释放头结点
L = NULL; //头结点指向NULL
}

双链表不可随机存取,按位查找、按值查找都只能用遍历的方法实现,时间复杂度为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//后向遍历
while(p != NULL){
p = p->next;
}

//前向遍历
while(p != NULL){
p = p->prior;
}

//前向遍历跳过头结点
while(p->prior != NULL){
p = p->prior;
}

循环链表

循环链表就是在单链表和双链表的基础上,做一点改变,变为循环单链表和循环双链表。

循环单链表

普通单链表最后一个结点指针指向NULL,而循环单链表最后一个结点的指针指向头结点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;

//初始化循环单链表
bool InitList(LinkList &L){
L = (LNode*)malloc(sizeof(LNode)); //分配一个头结点
if(L == NULL)
return false;
L->next = L; //头结点的next指向头结点
return true;
}

//判断循环单链表是否为空(只有头结点)
bool Empty(LinkList L){
if(L->next == L)
return true;
else
return false;
}

//p判断结点p是否为表尾结点
bool IsTail(LinkList L, LNode *p){
if(p->next == L)
return true;
else
return false;
}

  • 单链表从一个结点出发,只能找到后续的各个结点;
  • 循环单链表从一个结点出发,可以找到其他任何一个结点;

链表的操作很多都是在头部或者尾部。循环单链表从头找到尾,时间复杂度为,但从尾找到头,时间复杂度仅为。所以很多时候循环单链表不设头指针,仅设尾指针,此时表头插入和表尾插入,时间复杂度都为

循环双链表

  • 双链表:表头结点的prior指向NULL,表尾结点的next指向NULL;
  • 循环双链表:表头结点的prior指向表尾结点,表尾结点的next指向表头结点;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef struct DNode{
ElemType data;
struct DNode *priot, *next;
}DNode, *DLinkList;

//初始化空的循环双链表
bool InitDLinkList(DLinkList &L){
L = (DNode*)malloc(sizeof(DNode)); //分配一个头结点
if(L == NULL)
return false;
L->prior = L; //头结点的prior指向头结点
L->next = L; //头结点的next指向头结点
}

//循环双链表判空
bool Empty(DLinkList L){
if(L->next == L)
return true;
else
return false;
}

//判断结点p是否为表尾结点
bool IsTail(DLinkLisk L, DNode* p){
if(p=>next == L)
return true;
else
return false;
}

普通双链表的插入和删除,都需要考虑尾结点,因为尾结点后继是NULL,但循环双链表的插入和删除就比较简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//在p后插入s
bool InsertNextDNode(DNode *p, DNode *s)
{
s->next = p->next;
p->next->prior = s; //此处不需要再考虑是否为尾结点了,一定不为NULL
s->prior = p;
p->next = s;
}

//删除同理
//删除p结点的后继结点
bool DeleteNextDNode(DNode* p)
{
if(p == NULL)
return false;

DNode *q = p->next; //q就是要删除的结点

p->next = q->next;
q->next->prior = p;

free(q); //释放结点空间
return true;
}

静态链表

对于普通单链表来说,各个结点在内存中星罗棋布,不连续;

静态链表:借助数组来描述链式存储结构,分配一整片连续的内存空间,各个节点集中安置。数组中分为数据域data和指针域next,这里的指针是指结点的数组下标--游标,next==-1时表示结束。

上图为例,假设每个结点共8B,数据元素占4B,游标占4B,则如果头结点的地址为addr,则存放e1的地址为addr+8*2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//定义
#define MaxSize 10 //静态链表的最大长度
typedef struct{
ElemType data;
int next;
}SLinkList[MaxSize];

//上面的写法等价于
#define MaxSize 10 //静态链表的最大长度
struct Node{
ElemType data;
int next;
};
typedef struct Node SLinkList[MaxSize];

基本操作很少考察,代码实现相对动态链表来说,只需要修改指针,而不需要移动元素:

查找操作:

  1. 从头结点出发挨个往后遍历结点,时间复杂度;

插入位序为i的结点:

  1. 找到一个空结点,存入数据元素(初始化的时候,可以让next的值为一个特殊值,如-2,这样就知道哪个下标的值为空);
  2. 从头结点出发找到位序为的结点;
  3. 修改新结点的next;
  4. 修改号结点的next;

删除某个结点:

  1. 从头结点出发找到前驱结点;
  2. 修改前驱结点的游标;
  3. 被删除结点的next设置为-2;

静态链表的优点:增删操作不需要大量移动元素

静态链表的缺点:不能随机存取,只能从头结点开始依次查询;容量固定不可变;

静态链表的适用场景:

  1. 不支持指针的低级语言;
  2. 数据元素数量固定不变的场景(如操作系统的文件分配表FAT)

顺序表和链表

逻辑结构

在逻辑结构上都同属于线性表,都是线性结构

存储结构

顺序表:

  • 优点:
    1. 支持随机存取
    2. 只需要存数据,存储密度高
  • 缺点:
    1. 需要分配大片连续空间;
    2. 改变容量麻烦

链表:

  • 优点:
    1. 离散的小空间,分配方便
    2. 改变容量方便
  • 缺点:
    1. 不可随机存取;
    2. 需要额外存储地址,存储密度低;

基本操作

基本操作分为创建销毁、增删改查

顺序表的创建需要预分配大片连续地址空间,若分配空间过小,则之后不方便扩展容量;若分配空间过大,则浪费内存资源。如果是静态分配,容量不可改;如果是动态分配,虽然可变容量,但需要移动大量元素,时间代价很高;

链表的创建只需要分配一个头结点,之后扩展很方便

顺序表的销毁操作只需要修改Length=0即可。对于静态分配的数据,等待系统自己回收空间;如果是动态分配的数组,那么需要手动free.

链表销毁时依次free结点

顺序表的插入和删除,需要将后续元素依次后移/前移,时间复杂度为,时间开销来自于移动元素。

链表的插入和删除只需要修改指针即可,时间复杂度也为,时间开销来自于查找目标元素。

虽然时间复杂度表示相同,但如果数据元素很大,则移动的时间代价很高,查找元素的时间代价更低。链表的效率要比顺序表高的多。

顺序表有随机存取特性,按位查找的时间复杂度是;链表只能一个一个遍历,时间复杂度为;

顺序表按值查找的时间复杂度为,如果元素有序,则可以利用一些算法在内找到;链表按值查找的时间复杂度为

顺序表的查找效率更高。

取舍

表长难以预估,经常需要增删元素-------使用链表

表长可预估,查询操作较多--------使用顺序表

试卷中出现开放式题目,回答使用链表还是顺序表更好,可以通过框架来答题:

顺序表和链表的逻辑结构都是线性结构,都属于线性表。但二者存储结构不同,(分别说特点和优缺点),由于采用的存储方式不同,导致基本操作的效率也不一样,(分别说基本操作)

计算机网络概念

计算机网络是一个将众多分散的、自治的计算机系统,通过通信设备与线路连接起来,由功能完善的软件实现资源共享和信息传递的系统。

  • 互联:互联互通;
  • 自治:无主从关系,互相连通但不能彼此控制;

几个概念:

  1. 计算机网络:由若干结点和连接这些结点的链路组成。结点可以是计算机、集线器、交换机、路由器等,链路可以是有线链路、无线链路;主要功能:数据通信、资源共享
  2. 互连网:多个计算机网络通过路由器互相连接而成;内部可以使用任何通信协议;
  3. 互联网:译为因特网。,特质全世界最大的计算机网络,由各大ISP和国际机构组建;使用TCP/IP协议族作为通信规则

ISP:互连网服务提供商(Internet Service Provider),如移动、电信、联通

集线器、交换机用于连接同一网络内的不同结点;路由器用于连接不同的网络;

家用路由器的概念不同于路由器,家用路由器=路由器+交换机+其他功能;

计算机网络组成

从组成部分看

  1. 硬件
    1. 主机,即端系统,如电脑、手机、物联网设备
    2. 通信设备:如集线器、交换机、路由器
    3. 通信链路:如网线、光纤、同轴电缆
  2. 软件:方便用户使用,实现资源共享。如Email、聊天软件、网盘
  3. 协议:规定计算机网络中的通信规则,由硬件、软件共同实现。如网络适配器+软件 实现网络通信协议

从逻辑功能看

  1. 资源子网:计算机网络中运行应用程序,向用户提供可共享的硬件、软件和信息资源的部分。主要由连接到互连网上的主机组成;
  2. 通信子网:计算机网络中负责计算机间信息传输的部分。即把计算机和其他用户装置连在遗器的所有通信设备和介质的总称。主要由 通信链路+通信设备+协议 组成。

注:主机北部实现信息传输的网络适配器、底层协议属于通信子网的范畴。

从工作方式看

  1. 边缘部分:直接为用户服务(通信、资源共享),主要由连接到互连网上的主机和其软件组成;
  2. 核心部分:为边缘部分提供服务(连通性、交换服务),由大量网络和连接这些网络的路由器组成

计算机网络的功能

  • 数据通信:实现计算机之间数据传输。是最基本,最重要的功能;
  • 资源共享:包括硬件、软件、数据资源;
  • 分布式处理:将某个复杂任务分配给网络中的多台计算机处理;
  • 提高可靠性:网络中各台计算机互为替代机;
  • 负载均衡:网络中各台计算机共同分担繁重工作;
  • 其他:满足社会需求和生活需求;

数据交换技术

现代计算机网络技术诞生于20世纪60年代,APRAnet项目。数据通信的发展史:5世纪起兴起邮政网络,1830s出现电报网络,1870s出现电话网络。现代计算机发展初始,需要分析电报网络和电话网络的优缺点。

电话网络

电话网络采用电路交换技术,电路交换的步骤如下:

  1. 建立连接(尝试占用通信资源)
  2. 通信(一直占用通信资源)
  3. 释放连接(归还通信资源)

电路交换的优点:通信前从主叫端到被叫端建立一条专用的物理通路,在通信的全部时间内,两个用户始终占用端到端的线路资源。数据直送,传输速率高;

电路交换的确缺点:建立/释放连接,需要额外的时间开销;通信过程中线路被双方独占,录用率低;线路分配的灵活性差;交换结点不支持“差错控制”(无法发现传输过程中发生的数据错误);

电路交换更适用于低频次、大量地传输数据。但计算机之间的数据传输往往是“突发式”的传输,往往是高频词,少量传输数据。

电报网络

电报网络采用报文交换技术,报文交换采用存储转发思想,把传送的数据单元先存储进中间结点,再根据目的地址转发至下一结点。

报文交换的优点:

  • 通信前无需建立连接;
  • 数据以“报文”为单位被交换结点间“存储转发”,通信线路可以灵活分配;
  • 在通信时间内,两个用户无需独占一整条物理线路,相比于电路交换,线路利用率高;
  • 交换结点支持“差错控制”;

报文交换的缺点:

  • 报文不定长,不方便存储转发管理;
  • 长报文的存储转发时间开销大、缓存开销大;
  • 长报文容易出错,重传代价高;

分组交换

计算机网络可以参考报文交换技术,但需要解决报文不定长的问题。所以出现了分组交换技术。把不定长的报文分成固定长度的组,包含:源地址、目的地址、分组号等。

在现代计算机网络技术中,路由器就是典型的分组交换机。

分组交换的优点:

  • 通信前无需建立连接;
  • 数据以“分组”为单位被交换结点间“存储转发”,通信线路可以灵活分配;
  • 在通信时间内,两个用户无需独占一整条物理线路,相比于电路交换,线路利用率高;
  • 交换结点支持“差错控制”;

相较于报文交换,分组交换改进了如下问题:

  1. 分组定长,方便存储转发管理;
  2. 分组的存储转发时间开销小、缓存开销小;
  3. 分组不易出错,重传代价低;

分组交换的缺点:

  • 相比于报文交换,分组交换的控制信息占比增加了;
  • 相比于电路交换,依然存在存储转发时延;
  • 报文被拆分为多个组,传输过程中可能出现失序、丢失等问题,增加处理的复杂度;

电路交换 报文交换 分组交换
完成传输所需时间 最少(不包括建立/释放连接耗时) 最多 较少
存储转发时延 较高 较低
通信前是否需要建立连接
缓存开销
是否支持差错控制 不支持 支持 支持
报文数据有序到达
是否需要额外的控制信息 是(控制信息占比大)
线路分配灵活性 不灵活 灵活 非常灵活
线路利用率 非常高

计算机网络分类

按分布范围分类:

  1. 广域网(WAN:Wide Area Network)
  2. 城域网(MAN:Metropolitan Area Network):并入局域网范围探讨
  3. 局域网(LAN:Local Area Network):采用以太网技术
  4. 个域网(PAN:Personal Area Network):通常是用无线技术将个人设备连接起来,因此也称为无线个域网(WPAN)

如今局域网几乎都是采用以太网技术实现,因此“以太网”已经成为“局域网”代名词。局域网通过路由器接入广域网。

按传输技术分类:

  • 广播式网络:当一台计算机发送数据分组时,广播范围内所有计算机都会收到该分组,并通过检查分组的目的地址决定是否接收该分组。所有的无线网络都是“广播式”;
  • 点对点网络:数据只会从发送方“点对点”发送到接收方,精准送达。比如路由器转发的数据分组;

按拓扑结构分类:

  • 总线形结构:数据“广播式”传输,存在“总线争用”的问题;比如集线器连接的设备;
  • 环形结构:数据“广播式”传输,通过“令牌”解决总线争用问题,令牌顺着环形依次传递,拿到令牌者可以使用总线。如流行于上个世纪的令牌环网局域网技术;
  • 星形结构:由中央设备实现数据的“点对点”传输,不存在“总线争用”问题,如以太网交换机连接的设备;
  • 网状结构:数据通过各中间结点逐一存储转发;属于“点到点”传输。如众多路由器构建的广域网。

按使用者分类:

  • 公用网———向公众开放的网络
  • 专用网——仅供某个组织内部使用的网络

按传输介质分类

  • 有线网络,如光纤、网线;
  • 无线网络:如5G、WiFi、卫星;

计算机网络性能指标

速率、带宽、吞吐量

信道:表示向某一方向传送信息的通道,信道通信线路。一条融信线路在逻辑上往往对应一条发送信道和一条接收信道。

速率:指连接到网络上的节点在信道上传输数据的速率。也称数据率、比特率、数据传输速率。

速率单位:bit/s、b/s、bps (每秒传输多少比特)

要注意B和b表示的不一样,1B=8b,有时候单位也会使用Bps、B/s;

要注意区分计网中的常用数量前缀和计组、操作系统中的数量前缀。

  • 计网:千:k()、兆:M()、吉:G()、太:T();
  • 计组、操作系统等表示容量:K()、M()、G()、T();

带宽是指某信道所能传送的最高数据率。单位也是bps;

在通信原理中,带宽也表示某信道允许通过的信号频带范围,单位Hz;

节点间通信实际所能到达的最高速率,由宽带、节点性能共同限制。不是带宽高了,传输速率就一定高。

吞吐量指单位时间内通过某个网络(或接口、信道)的实际数据率。单位也是b/s、B/s、MB/s;

吞吐量受带宽限制、受复杂的网络负载情况影响;

时延、时延带宽积、往返时延

时延:指数据(一个报文或分组、甚至比特)从网络(或链路)的一端传送到另一端所需的时间。也称延迟。

  • 总时延 = 发送时延 + 传播时延 + 处理时延 + 排队时延
    • 发送时延:又名传输时延,节点将数据推向信道所花的时间;
    • 传播时延:电磁波在信道中传播一定距离所花的时间;
    • 处理时延:被路由器处理所花的时间,比如分析首部、查找存储转发表;
    • 排队时延:数据排队进入、排队发出路由器所花的时间

要注意区分传播时延传输时延(发送时延)

时延带宽积 = 传播时延 带宽。单位是bit(s bit/s)

时延带宽积表达的含义是一条链路中,已经从发送端发出但是还没有到达接收端的最大比特数。表现类似容量的概念。

往返时延:表示从发送方发送完数据,到发送方收到来自接收方的确认总共经历的时间。

往返时延 = t2 + t3 + t4 + t5. 不包括t1

信道利用率

信道利用率表示某个信道有百分之多少的时间是有数据通过的。

信道利用率 =

信道利用率不能太低,浪费资源;但信道利用率也不能太高,容易导致网拥塞。

分层结构

分层设计思想:将庞大而复杂的问题,转化为若干较小的局部问题。分层设计并不唯一,可以根据实际需求增加或减少层次。

网络的体系结构是计算机网络的各层及其协议的集合,就是这个计算机网络及其构件所应该完成的功能的精确定义(不涉及实现)。实现是遵循这种体系结构的前提下,用何种硬件或软件完成这些功能的问题。体系结构是抽象的,实现的具体的。

计算机常见的有三种网络体系结构:OSI参考模型(法律上的标准)、TCP/IP模型(事实上的标准)、五层模型(教学用标准)。

在计算机网络的分层结构中,第n层中的活动元素(软件+硬件)通常称为第n层实体。不同机器上的同一层称为对等层,同一层的实体称为对等实体。

网络协议是控制对等实体之间进行通信协议的规则的集合,是水平的。

接口即同一节点内相邻两层实体交换信息的逻辑接口,又称为服务访问点(SAP)。服务是指下层为紧邻的上层提供的功能调用,是垂直的。

数据组成:

  • 协议数据单元(PDU):对等层次之间传送的数据单位。第n层的PDU记为n-PUD;
  • 服务数据单元(SDU):为完成上一层实体所要求的功能而传递的数据。第n层的SDU记为n-SDU;
  • 协议控制信息(PCI):控制协议操作的信息。第n层的PCI记为n-PCI;
  • 三者关系:n-SDU + n-PCI = n-PUD = (n+1)SDU

协议的三要素: 1. 语法:数据与控制信息的格式。例如协议控制信息占几个字节、每个字节什么含义; 2. 语义:即需要发出何种控制信息、完成各种动作及做出何种应答; 3. 同步(或时序):执行各种操作的条件、时序关系等,即事件实现顺序的详细说明。例如发送方发完数据后需要立即应答,如果10s内没收到传输成功的应答,需要再次发送数据;

OSI参考模型

(口诀:物联网叔会使用)

层级 定义 功能 传输单位
物理层 实现相邻节点之间比特的传输 1.需要定义电路接口参数(如形状、尺寸、引脚数)
2.需定义传输信号的含义、电气特征
比特
数据链路层 确保相邻节点之间的链路逻辑上无差错 1.差错控制:检查+纠错,或 检错+丢弃+重传;
2.流量控制:协调两个节点的速率;
网络层 把分组从源节点转发到目的节点 1.路由选择:构造并维护路由表,决定分组到达目的节点的最佳路径
2.分组转发:将"分组"从合适的端口转发出去;
3.拥塞控制:发现网络拥塞,并采取措施缓解拥塞
4.网际互联:实现异构网络互联
5.其他功能,包括差错控制、流量控制、连接建立与释放(确保分组有序不重复到达)、可靠传输管理(消息回应);
分组
传输层 实现端到端的通信,即进程到进程的通信,这里的端指端口 1.复用和分用:发送端几个高层实体复用一条低层的连接,在接收端进行分用;
2.其他功能:差错控制、流量控制、连接的建立于释放、可靠传输管理
报文段
会话层 管理进程间会话 会话管理:采用检查点机制,当通信失效时从检查点继续恢复通信(断点续传) 报文
表示层 解决两台主机上信息表示不一致的问题 数据格式转换,如编码转换、压缩/解压缩、加密/解密 报文
应用层 实现特定的网络应用 功能繁多,根据应用需求设计 报文

注意:数据链路层、网络层、传输层都有差错控制,这几个不一样。数据链路层传输数据的单位是"帧"、网络层传输数据的单位是"分组"、传输层传输数据的单位是报文段。一个分组由很多帧组成,报文段可以拆分成很多组。数据链路层确保帧的差错控制,网络层确保分组之间的差错控制,传输层确保报文段之间的差错控制。

TCP/IP模型

(口诀:接网叔用)

  • TCP/IP模型把表示层和会话层删去了,因为这两个层次并不是所有网络应用都需要的。如果某些应用需要数据格式转换、会话管理功能,就交给应用层的特定协议去实现。

  • TCP/IP在低层没有采用OSI中的物理层+数据链路层的模型,因为网络硬件种类繁多,不应该有过多的限制。TCP/IP模型低层为网络接口层,实现相邻节点间的数据传输(为网络层传输"分组")。但具体怎么传输不做规定。这使得T=CP/IP网络体系结构具有更强的灵活性、适应性。

  • TCP/IP模型的网络层去掉了OSI模型中网络层的差错控制、流量控制、可靠传输管理功能,传输层直接把报文段传递给网络层,由网络层拆分成不同分组,传递给网络接口层,再由网络接口层拆分为多个帧传输。因此网络接口层不完全可靠,因为它收到的分组有可能有差错。。这就是TCP/IP模型网络层特点:只保证尽最大能力交付数据,不保证数据可靠。网络层是网络的核心部分,这么做的优点是降低网络层功能复杂度,负载降低,造价降低。

  • TCP/IP模型中传输层来负责差错控制、流量控制、连接建立与释放、可靠传输管理,由它来保证传输的正确性、可靠性。TCP/IP模型把保证数据正确的功能全部交给传输层负责,压力给到网络的边缘部分(主机)。

OSI参考模型 TCP/IP模型
网络层 OIS网络层可以向上层提供:
1.有连接可靠的服务(虚电路);
2.无连接不可靠的服务(数据报)
TCP/IP网络层仅向上提供:无连接不可靠的服务(数据报)
传输层 OSI传输层仅可向上提供有连接的可靠的服务 TCPI/IP模型可向上层提供:
1.有连接可靠的服务(TCP协议);
2.无连接不可靠的服务(UDP协议)