草庐IT

数据结构笔记NO.1(绪论、线性表、栈队列和矩阵的压缩存储)

反方向的钟49 2025-05-12 原文

第一章、绪论

1、数据结构三要素:逻辑结构、存储结构(物理结构)、数据的运算。

(1)逻辑结构:是指数据元素之间的逻辑关系,即从逻辑关系上描述数据,它与数据的存储无关,是独立于计算机的。

(2)存储结构(物理结构):是指数据在计算机中的表示(又称映像),是用计算机语言实现的逻辑结构,它依赖于计算机语言。

  • 顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现(e.g.数组)。
    • 优点:①可以实现随机存取;② 每个元素占用最少的存储空间;
    • 缺点:只能使用相邻的一整块存储单元,因此可能产生较多的外部碎片;
  • 链式存储:不要求逻辑上相邻的元素在物理位置上也相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系(e.g.链表)。
    • 优点:不会出现碎片现象,能充分利用所有存储单元;
    • 缺点:①每个元素因存储指针而占用额外的存储空间;②只能实现顺序存取;
  • 索引存储:在存储元素信息的同时,还建立附加的索引表。索引表中的每项称为索引项,索引项的一般形式是(关键字,地址)。
    • 优点:检索速度快;
    • 缺点:①附加的索引表额外占用存储空间;②增加和删除数据时也要修改索引表,因而会花费较多的时间(对索引表进行维护的时间开销);
  • 散列存储:又称哈希(Hash)存储,根据元素的关键字直接计算出该元素的存储地址。
    • 优点:检索、增加和删除结点的操作都很快;
    • 缺点:若散列函数(也称哈希(Hash)函数)不好,则可能出现元素存储单元的冲突,而解决冲突会增加时间和空间的开销;

(3)数据的运算:包括运算的定义和实现。

  • 运算的定义:是针对逻辑结构的,指出运算的功能;
  • 运算的实现:是针对存储结构的,指出运算的具体操作步骤。

2、数据元素:是数据的基本单位,通常作为一个整体进行考虑和处理。一个数据元素可由若干数据项组成,数据项是构成数据元素的不可分割的最小单位。例如:学生记录就是一个数据元素,它由学号、姓名、性别等数据项组成。

3、数据对象:是具有相同性质的数据元素的集合,是数据的一个子集。

4、数据类型:是一个值的集合和定义在此集合上的一组操作的总称。

  • (1)原子类型:其值不可再分的数据类型;
  • (2)结构类型:其值可再分解为若干成分(分量)的数据类型;
  • (3)抽象数据类型(ADT(Abstract Data Type)):抽象数据组织及与之相关的操作。描述了数据的逻辑结构和抽象运算,通常用(数据对象,数据关系,基本操作集)这样的三元组来表示,从而构成一个完整的数据结构定义。

5、数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。

6、算法(Algorithm):是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。一个算法应具有5个特性:

  • (1)有穷性:一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成;
  • (2)确定性:算法中每条指令必须有确切的含义,对于相同的输入只能得出相同的输出;
  • (3)可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现;
  • (4)输入:一个算法有零个或多个输入,这些输入取自于某个特定的对象的集合;
  • (5)输出:一个算法有一个或多个输出,这些输出是与输入有着某种特定关系的量。

通常,设计一个“好”的算法应考虑达到以下目标:

  • (1)正确性:算法应能够正确地解决求解问题;
  • (2)可读性:算法应具有良好的可读性,以帮助人们理解;
  • (3)健壮性:输入非法数据时,算法能适当地做出反应或进行处理,而不会产生莫名其妙的输出结果;
  • (4)效率与低存储量需求:效率是指算法执行的时间,存储量需求是指算法执行过程中所需要的最大存储空间,这两者都与问题的规模有关。

7、时间复杂度

  • (1)一个语句的频度是指该语句在算法中被重复执行的次数;
  • (2)算法中所有语句的频度之和记为T(n),它是该算法问题规模n的函数,时间复杂度主要分析T(n)的数量级;
  • (3)算法中基本运算(最深层循环内的语句)的频度与T(n)同数量级,因此通常采用算法中基本运算的频度f(n)来分析算法的时间复杂度;
    • 取f(n)中随n增长最快的项,将其系数置为1作为时间复杂度的度量。例如,f(n) = an3 + bn2 + cn的时间复杂度为O(n3);
  • (4)算法的时间复杂度记为T(n) = O(f(n));
    • 式中O的含义是T(n)的数量级,其严格的数学定义是:若T(n)和f(n)是定义在正整数集合上的两个函数,则存在正常数C和n0,使得当n≥n0时,都满足0≤T(n)≤Cf(n)。
  • (5)算法的时间复杂度不仅依赖于问题的规模n,也取决于待输入数据的性质。
    • 最坏时间复杂度是指在最坏情况下,算法的时间复杂度;
    • 平均时间复杂度是指所有可能输入实例在等概率出现的情况下,算法的期望运行时间;
    • 最好时间复杂度是指在最好情况下,算法的时间复杂度;
    • 一般总是考虑在最坏情况下的时间复杂度,以保证算法的运行时间不会比它更长;
  • (6)在分析一个程序的时间复杂性时,有以下两条规则:
    • 加法规则:T(n) = T1(n) + T2(n) = O(f(n)) + O(g(n)) = O(max(f(n),g(n)));
    • 乘法规则:T(n) = T1(n) × T2(n) = O(f(n)) × O(g(n)) = O(f(n) × g(n));
  • (7)常见的渐近时间复杂度为:
    • O(1) < O(logn)(以2为底,后同) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn);

8、空间复杂度

  • (1)算法的空间复杂度S(n)定义为该算法所耗费的存储空间,它是问题规模n的函数,记为S(n) = O(g(n));
  • (2)一个程序在执行时除需要存储空间来存放本身所用的指令、常数、变量和输入数据外,还需要一些对数据进行操作的工作单元和存储一些为实现计算所需信息的辅助空间;
  • (3)若输入数据所占空间只取决于问题本身,和算法无关,则只需分析除输入和程序之外的额外空间;
  • (4)算法原地工作是指算法所需的辅助空间为常量,即O(1);

第二章、线性表

1、知识框架
2、线性表的定义

  • 线性表是具有相同数据类型的n(n ≥ 0)个数据元素的有限序列,其中n为表长,当n = 0时线性表是一个空表。
  • 若用L命名线性表,则其一般表示为:
    • L = (a1, a2, …, ai, ai+1, …, an);
    • a1是唯一的“第一个”数据元素,又称表头元素
    • an是唯一的“最后一个”数据元素,又称表尾元素
    • 除第一个元素外,每个元素有且仅有一个直接前驱
    • 除最后一个元素外,每个元素有且仅有一个直接后继

3、线性表的特点

  • 表中元素的个数有限;
  • 表中元素具有逻辑上的顺序性,表中元素有其先后次序;
  • 表中元素都是数据元素,每个元素都是单个元素;
  • 表中元素的数据类型相同,这意味着每个元素占有相同大小的存储空间;
  • 表中元素具有抽象性,即仅讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容;
  • 线性表是一种逻辑结构,表示元素之间一对一的相邻关系。顺序表和链表是线性表这一逻辑结构在内存中的不同组织形式(存储结构);

4、线性表的基本操作

  • InitList(&L);初始化表,构造一个空的线性表;
  • Length(L);求表长,返回线性表L的长度,即L中数据元素的个数;
  • LocateElem(L, e);按值查找操作,在表L中查找具有给定关键字值的元素;
  • GetElem(L, i);按位查找操作,获取表L中第i个位置的元素的值;
  • ListInsert(&L, i, e);插入操作,在表L中的第i个位置上插入指定元素e;
  • ListDelete(&L, i, &e);删除操作,删除表L中第i个位置的元素,并用e返回删除元素的值;
  • PrintList(L);输出操作,按前后顺序输出线性表L的所有元素值;
  • Empty(L);判空操作,若L为空表,则返回true,否则返回false
  • DestroyList(&L);销毁操作,销毁线性表,并释放线性表L所占用的内存空间;
  • 注意:基本操作的实现取决于采用哪种存储结构,存储结构不同,算法的实现也不同。

5、线性表的顺序表示—顺序表

  • (1)用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻;
  • (2)特点:表中元素的逻辑顺序与其物理顺序相同;
  • (3)随机存取(随机访问),即根据首地址和元素序号可在O(1)时间内找到指定的元素;
  • (4)假定线性表的元素类型为ElemType,则线性表的顺序存储类型描述为:
//数组空间静态分配,可能会产生溢出
#define MaxSize 50//定义线性表的最大长度
typedef struct {
	ElemType data[MaxSize];//顺序表的元素数组
	int length;//顺序表的当前长度
} SqList;//顺序表的类型定义
//数组空间动态申请
#define InitSize 100//表长度的初始定义
typedef struct {
	ElemType *data;//指示动态分配数组的指针
	int MaxSize, length;//数组的最大容量和当前个数
} SeqList;//动态分配数组的顺序表的类型定义

动态申请得到的内存是连续的,同样属于顺序存储结构。C的初始动态分配语句为:

SeqList L;
L.data = (ElemType *)malloc(sizeof(ElemType) * InitSize);

C++的初始动态分配语句为:

L.data = new ElemType[InitSize];
  • (5)顺序表的存储密度高,每个结点只存储数据元素;
  • (6)顺序表逻辑上相邻的元素物理上也相邻,所以插入和删除操作需要移动大量元素;

6、顺序表上基本操作的实现

(1)插入操作

  • 在顺序表L的第 i(1<= i <= L.length + 1)个位置插入新元素e。若 i 的输入不合法,则返回false,表示插入失败;否则,将第 i 个元素及其后的所有元素依次往后移动一个位置,腾出一个空位置插入新元素e,顺序表长度增加1,插入成功,返回true
bool ListInsert(SqList &L, int i, ElemType e) {
	if( i < 1 || i > L.length + 1 )//判断i的范围是否有效
		return false;
	if( L.length >= MaxSize )//当前存储空间已满,不能插入
		return false;
	for( int j = L.length; j >= i; j -- )//将第i个元素及之后的元素后移
		L.data[j] = L.data[j - 1];
	L.data[i - 1] = e;//在位置i处放e
	L.length ++;//线性表长度加1
	return true;
}
  • 时间复杂度分析(假设顺序表长度为n):
    • 最好情况:在表尾插入(即 i = n + 1),元素后移语句将不执行,时间复杂度为O(1);
    • 最坏情况:在表头插入(即 i = 1),元素后移语句将执行n次,时间复杂度为O(n);
    • 平均情况:在第 i 个位置插入元素的概率pi = 1/(n+1),元素后移语句要执行n-i+1次,所以移动结点的平均次数为 pi * (n-i+1)( i 从1到n+1求和)= n/2,所以平均时间复杂度为O(n)。

(2)删除操作

  • 删除顺序表L中第 i(1 <= i <= L.length)个位置的元素,用引用变量e返回。若 i 的输入不合法,则返回false;否则将被删除元素赋给引用变量e,并将第 i + 1个元素及其后的所有元素依次往前移动一个位置,返回true
bool ListDelete(SqList &L, int i, ElemType &e) {
	if( i < 1 || i > L.length )//判断i的范围是否有效
		return false;
	e = L.data[i-1];//将被删除的元素赋值给e
	for( int j = i - 1; j < L.length - 1; j ++)//将第i个位置后的元素前移
		L.data[j] = L.data[j + 1];
	L.length --;//线性表长度减1
	return ture;
}
  • 时间复杂度分析(假设顺序表长度为n):
    • 最好情况:删除表尾元素(即 i = n),无需移动元素,时间复杂度为O(1);
    • 最坏情况:删除表头元素(即 i = 1),需移动除表头元素外的所有元素,时间复杂度为O(n);
    • 平均情况:删除第 i 个元素的概率pi = 1/n,需移动n-i个元素,所以移动元素的平均个数为pi * (n - i)( i从1到n求和)= (n - 1)/2,所以平均时间复杂度为O(n)。

可见,顺序表中插入和删除操作的时间主要耗费在移动元素上,而移动元素的个数取决于插入和删除元素的位置。

(3)按值查找(顺序查找)

  • 在顺序表L中查找第一个元素值等于e的元素,并返回其位序。
int LocateElem(SqList L, ElemType e) {
	for( int i = 0; i < L.length; i ++ ) {
		if( L.data[i] == e )
			return i + 1;//下标为i的元素值等于e,返回其位序i + 1
	}
	return 0;//退出循环,说明查找失败
}
  • 时间复杂度分析(假设顺序表长度为n):
    • 最好情况:查找的元素就在表头,仅需比较一次,时间复杂度为O(1);
    • 最坏情况:查找的元素在表尾(或不存在)时,需要比较n次,时间复杂度为O(n);
    • 平均情况:要查找的元素在第 i 个位置上的概率pi = 1/n,需要比较 i 次,所以平均比较次数为pi * i( i从1到n求和)= (n + 1)/2,所以平均时间复杂度为O(n)。

7、线性表的链式表示–链表

  • 链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素在物理位置上也相邻,它通过“链”建立起数据元素之间的逻辑关系,因此插入和删除操作不需要移动元素,而只需修改指针,但也会失去顺序表可随机存取的优点。

8、单链表

  • 线性表的链式存储又称单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素;
  • 对每个链表结点,除存放元素自身的信息外,还需要存放一个指向其后继的指针;
  • data为数据域,存放数据元素;next为指针域,存放其后继结点的地址;
  • 单链表中结点类型的描述如下:
typedef struct LNode {//定义单链表结点类型
	ElemType data;
	struct LNode *next;
} LNode, *LinkList;
  • 单链表可以解决顺序表需要大量连续存储单元的缺点;
  • 单链表附加指针域,也存在浪费存储空间的缺点;
  • 单链表的元素离散地分布在存储空间中,所以单链表是非随机存取的存储结构,即不能直接找到表中某个特定的节点。
  • 查找某个特定的节点时,需要从表头开始遍历,依次查找;
  • 通常用头指针来标识一个单链表,如单链表L,头指针为NULL时表示一个空表;
  • 为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点头结点的数据域可以不设任何信息,也可以记录表长等信息。头结点的指针域指向线性表的第一个元素结点;
  • 引入头结点的优点
    • 使得在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理;
    • 无论链表是否为空,其头指针都是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也得到了统一。

9、单链表上基本操作的实现

(1)采用头插法建立单链表:从一个空表开始,生成新结点,并将读取到的数据放到新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后。

//头插法建立单链表
LinkList List_HeadInsert(LinkList &L) {//逆向建立单链表
	LNode *s;
	int x;
	L = (LinkList)malloc(sizeof(LNode));//创建头结点
	L->next = NULL;//初始为空链表
	scanf("%d", &x);//输入结点的值
	while( x != 9999 ) {//输入9999表示结束
		s = (LNode*)malloc(sizeof(LNode));//创建新结点
		s->data = x;
		s->next = L->next;
		L->next = s;//将新结点插入表中,L为头指针
		scanf("%d", &x);
	}
	return L;
}

采用头插法建立单链表时,读入数据的顺序与生成的链表中元素的顺序是相反的。每个结点插入的时间为O(1),设单链表长为n,则总时间复杂度为O(n)。

(2)采用尾插法建立单链表:将新结点插入到当前链表的表尾,为此必须增加一个尾指针r,使其始终指向当前链表的尾结点。

//尾插法建立单链表
LinkList List_TailInsert(LinkList &L) {//正向建立单链表
	int x;//设元素类型为整数
	L = (LinkList)malloc(sizeof(LNode));//创建头结点
	LNode *s, *r = L;//r为表尾指针
	scanf("%d", &x);//输入结点的值
	while( x != 9999 ) {//输入9999表示结束
		s = (LNode*)malloc(sizeof(LNode));//创建新结点
		s->data = x;
		r->next = s;
		r = s;//r指向新的表尾结点
		scanf("%d", &x);
	}
	r->next = NULL;//尾结点指针置空
	return L;
}

因为附设了一个指向表尾结点的指针,故时间复杂度和头插法的相同。

(3)按序号查找结点值:在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第 i 个结点为止,否则返回最后一个结点指针域NULL

//按序号查找结点值
LNode *GetElem(LinkList L, int i) {
	int j = 1;//计数,初始为1
	LNode *p = L->next;//第1个结点指针赋给p
	if( i == 0 )
		return L;//若i等于0,则返回头结点
	if( i < 1 )
		return NULL;//若i无效,则返回NULL
	while( p && j < i ) {//从第1个结点开始找,查找第i个结点
		p = p->next;
		j ++;
	}
	return p;//返回第i个结点的指针,若i大于表长,则返回NULL
}

按序号查找操作的时间复杂度为O(n)。

(4)按值查找表结点:从单链表的第一个结点开始,由前往后依次比较表中各结点数据域的值,若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL

//按值查找表结点
LNode *LocateElem(LinkList L, ElemType e) {
	LNode *p = L->next;
	while( p != NULL && p->data != e )//从第一个结点开始查找data域为e的结点
		p = p->next;
	return p;//找到后返回该结点指针,否则返回NULL
}

按值查找操作的时间复杂度为O(n)。

(5)插入结点操作:将值为x的新结点插入到单链表的第 i 个位置上。先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第 i-1 个结点,再在其后插入新结点。

  • 算法首先调用按序号查找算法GetElem(L, i - 1),查找第 i-1 个结点。假设返回的第i-1个结点为*p,然后令新结点*s的指针域指向*p的后继结点,再令结点*p的指针域指向新插入的结点*s
//实现插入结点的代码片段
p = GetElem(L, i - 1);
s->next = p->next;
p-next = s;

本算法主要的时间开销在于查找第i-1个元素,时间复杂度为O(n)。若在给定的结点后面插入新结点,则时间复杂度仅为O(1)。

扩展:对某一结点进行前插操作:

  • 前插操作是指在某结点的前面插入一个新结点,后插操作的定义刚好与之相反。在单链表算法中,通常都采用后插操作。
  • 以上面的算法为例,首先调用函数GetElem()找到第 i-1 个结点,即插入结点的前驱结点后,在对其执行后插操作。
  • 对结点的前插操作均可转化为后插操作,前提是从单链表的头结点开始顺序查找到其前驱结点,时间复杂度为O(n)。
  • 另一种方式:设待插入结点为*s,欲将*s插入到*p的前面,我们仍然将*s插入到*p的后面,然后将p->datas->data交换,这样既满足了逻辑关系,又能使得时间复杂度为O(1)。
//将*s结点插入到*p之前的主要代码片段
s->next = p->next;
p->next = s;
temp = p->data;
p->data = s->data;
s->data = temp;

(6)删除结点操作:将单链表的第 i 个结点删除。先检查删除位置的合法性,后查找表中第 i-1 个结点,即被删结点的前驱结点,再将其删除。

  • 假设结点*p为被找到的被删结点*q的前驱结点,为实现这一操作后的逻辑关系的变化,仅需修改*p的指针域,即将*p的指针域next指向*q的下一结点。
//实现删除结点的代码片段
p = GetElem(L, i - 1);
q = p->next;
p->next = q->next;
free(q);

该算法的主要时间也耗费在查找操作上,时间复杂度为O(n)。

扩展:删除结点*p

  • 要删除某个给定结点*p,通常做法是先从链表的头结点开始顺序找到其前驱结点,然后执行删除操作,时间复杂度为O(n)。
  • 删除结点*p的操作可用删除*p的后继结点操作来实现,实质就是将其后继结点的值赋予其自身,然后删除后继结点,也能使得时间复杂度为O(1)。
q = p->next;
p->data = q->data;
p->next = q->next;
free(q);

(7)求表长操作:计算单链表中数据结点(不含头结点)的个数,需要从第一个结点开始顺序依次访问表中的每个结点,为此需要设置一个计数器变量,每访问一个结点计数器加1,直到访问到空结点为止。算法的时间复杂度为O(n)。

  • 因为单链表的长度是不包括头结点的,因此不带头结点和带头结点的单链表在求表长操作上会略有不同。对不带头结点的单链表,当表为空时,要单独处理。

10、双链表

  • 单链表结点中只有一个指向其后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点(插入、删除操作时),只能从头开始遍历,访问后继结点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。
  • 双链表结点中有两个指针priornext,分别指向其前驱结点和后继结点。
  • 双链表中结点类型的描述如下:
typedef struct DNode {//定义双链表结点类型
	ElemType data;//数据域
	struct DNode *prior, *next;//前驱和后驱指针
} DNode, *DLinkList;

11、双链表上基本操作的实现

(1)双链表中的按值查找和按位查找的操作与单链表相同。但双链表在插入和删除操作的实现上,与单链表有着较大的不同。因为“链”变化时也需要对prior指针做出修改,其关键是保证在修改的过程中不断链。双链表可以很方便地找到其前驱结点,因此,插入、删除的时间复杂度仅为O(1)。

(2)双链表的插入操作:在双链表中p所指的结点之后插入结点*s,插入操作的代码片段如下:

s->next = p->next;
p->next->prior = s;
s->prior = p;
p->next = s;

(3)双链表的删除操作:删除双链表中结点*p的后继结点*q,删除操作的代码片段如下:

p->next = q->next;
q->next->prior = p;
free(q);

12、循环链表

(1)循环单链表

  • 循环单链表和单链表的区别在于,表中最后一个结点的指针不是NULL,而改为指向头结点,从而整个链表形成一个环。
  • 在循环单链表中,表尾结点*rnext域指向头结点L,故表中没有指针域为NULL的结点,因此,循环单链表的判空条件不是头结点的指针域是否为空,而是它是否等于头指针。
  • 循环单链表的插入、删除算法与单链表的几乎一样,所不同的是若操作是在表尾进行,则执行的操作不同,以让单链表继续保持循环的性质。
  • 正因为循环单链表是一个“环”,因此在任何一个位置上的插入和删除操作都是等价的,无需判断是否是表尾。
  • 在单链表中只能从表头结点开始往后顺序遍历整个链表,而循环单链表可以从表中的任意一个结点开始遍历整个链表。
  • 有时对单链表常做的操作是在表头和表尾进行的,此时对循环单链表不设头指针而仅设尾指针,从而使得操作效率更高。其原因是,若设的是头指针,对表尾进行操作需要O(n)的时间复杂度,而若设的是尾指针rr->next即为头指针,对表头与表尾进行操作都只需要O(1)的时间复杂度。

(2)循环双链表

  • 在循环双链表中,头结点的prior指针还要指向表尾结点。
  • 在循环双链表L中,某结点*p为尾结点时,p->next == L
  • 当循环双链表为空表时,其头结点的prior域和next域都等于L

13、静态链表

  • 静态链表借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next
  • 与前面所讲的链表中的指针不同的是,这里的指针是结点的相对地址(数组下标),又称游标;
  • 和顺序表一样,静态链表也要预先分配一块连续的内存空间。
  • 静态链表和单链表的对应关系如下图所示:

  • 静态链表结构类型的描述如下:
#define MaxSize 50//静态链表的最大长度
typedef struct {//静态链表结构类型的定义
	ElemType data;//存储数据元素
	int next;//下一个元素的数组下标
} SLinkList[MaxSize];
  • 静态链表以next == -1作为其结束的标志;
  • 静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素;
  • 总体来说,静态链表没有单链表使用起来方便,但在一些不支持指针的高级语言(如Basic)中,这是一种巧妙的设计方法。

14、顺序表和链表的比较

(1)存取(读写)方式:顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。例如在第 i 个位置上执行存或取的操作,顺序表仅需一次访问,而链表则需从表头开始依次访问 i 次。

(2)逻辑结构和物理结构:采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的。

(3)查找、插入和删除操作

  • 对于按值查找
    • 顺序表无序时,两者的时间复杂度均为O(n);
    • 顺序表有序时,可采用折半查找,此时的时间复杂度为O(logn)(以2为底)。
  • 对于按序号查找,顺序表支持随机访问,时间复杂度仅为O(1),而链表的平均时间复杂度为O(n)。
  • 顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。
  • 由于链表的每个结点都带有指针域,故而存储密度不够大。

(4)空间分配

  • 顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。
    • 预先分配过大,可能会导致顺序表后部大量闲置;
    • 预先分配过小,又会造成溢出。
  • 动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。
  • 链式存储的结点空间只在需要时申请分配,只要有内存空间就可以分配,操作灵活、高效。

在实际中应该怎样选取存储结构?

(1)基于存储的考虑

  • 难以估计线性表的长度或存储规模时,不宜采用顺序表;
  • 链表不用事先估计存储规模,但链表的存储密度低,显然链式存储结构的存储密度是小于1的。

(2)基于运算的考虑

  • 在顺序表中按序号访问ai的时间复杂度为O(1),而链表中按序号访问的时间复杂度为O(n),因此若经常做的运算是按序号访问数据元素,则显然顺序表优于链表。
  • 在顺序表中进行插入、删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,这一点是不应忽视的;在链表中进行插入、删除操作时,虽然也要找插入位置,但操作主要是比较操作,从这个角度考虑显然后者优于前者。

(3)基于环境的考虑

  • 顺序表容易实现,任何高级语言中都有数组类型;
  • 链表的操作是基于指针的,相对来讲,前者实现较为简单,这也是用户考虑的一个因素。

总之,两种存储结构各有长短,选择哪一种由实际问题的主要因素决定。

通常较稳定的线性表选择顺序存储,而频繁进行插入、删除操作的线性表(即动态性较强)宜选择链式存储。

第三章、栈、队列和数组

1、知识框架

2、栈的基本概念

  • 栈(Stack)是只允许在一端进行插入或删除操作的线性表。(栈是一种操作受限的线性表)
  • 栈顶(Top):线性表允许进行插入删除的那一端。
  • 栈底(Bottom):固定的,不允许进行插入和删除的另一端。
  • 空栈:不含任何元素的空表。
  • 栈的操作特性可以明显地概括为后进先出(Last In First Out, LIFO)
  • 栈的数学性质:n个不同元素进栈,出栈元素不同排列的个数为[C(n 2n)] / (n+1),上述公式称为卡特兰数

3、栈的基本操作

  • InitStack(&S):初始化一个空栈S
  • StackEmpty(S):判断一个栈是否为空,若栈S为空则返回true,否则返回false
  • Push(&S, x):进栈,若栈S未满,则将x加入使之成为新栈顶。
  • Pop(&S, &x):出栈,若栈S非空,则弹出栈顶元素,并用x返回。
  • GetTop(S, &x):读栈顶元素,若栈S非空,则用x返回栈顶元素。
  • DestroyStack(&S):销毁栈,并释放栈S所占用的存储空间。

在解答算法题时,若题干未做出限制,则可直接使用这些基本的操作函数。

4、栈的顺序存储结构

(1)顺序栈的实现

  • 采用顺序存储的栈称为顺序栈,它利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针top指示当前栈顶元素的位置。
  • 栈的顺序存储类型可描述为:
#define MaxSize 50//定义栈中元素的最大个数
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

由于顺序栈的入栈操作受数组上界的约束,当对栈的最大使用空间估计不足时,有可能发生栈上溢,此时应及时向用户报告消息,以便及时处理,避免出错。

注意:栈和队列的判空、判满条件,会因实际给的条件不同而变化,上面提到的方法以及下面的代码实现只是在栈顶指针设定的条件下的相应方法,而其他情况则需具体问题具体分析。(如有的教辅可能初始时将S.top定义为0,相当于规定top指向栈顶元素的下一个存储单元)

(2)顺序栈的基本运算

  • 初始化
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.data[++S.top] = x;//指针先加1,再入栈
		return true;
}
  • 出栈
bool Pop(SqStack &s, ElemType &x) {
	if( S.top == -1 )//栈空,报错
		return false;
	x = S.data[S.top--];
		return true;
}
  • 读栈顶元素
bool GetTop(SqStack S, ElemType &x) {
	if( S.top == -1 )//栈空,报错
		return false;
	x = S.data[S.top];//x记录栈顶元素
	return true;
}

(3)共享栈

利用栈底位置相对不变的特性,可以让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸。

  • 两个栈的栈顶指针都指向栈顶元素,top0 = -1 时0号栈为空,top1 = MaxSize时1号栈为空;
  • 仅当两个栈顶指针相邻(top1 - top0 = 1)时,判断为栈满;
  • 当0号栈进栈时top0先加1再赋值,1号栈进栈时top1先减1再赋值,出栈时则刚好相反。

共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。其存取数据的时间复杂度均为O(1),对存取效率没有什么影响。

5、栈的链式存储结构

  • 采用链式存储的栈称为链栈
  • 链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况;
  • 通常采用单链表实现,并规定所有操作都是在单链表的表头进行的。这里规定链栈没有头结点,Lhead指向栈顶元素。

  • 栈的链式存储类型可描述为:
typedef struct Linknode {
	ElemType data;//数据域
	struct Linknode *next;//指针域
} *LiStack;//栈类型定义

6、队列的基本概念

  • 队列(Queue)简称队,也是一种操作受限的线性表,只允许在表的一端进行插入,而在表的另一端进行删除。
  • 向队列中插入元素称为入队进队,删除元素称为出队离队
  • 其操作的特性是先进先出First In First Out,FIFO)。

  • 队头(Front):允许删除的一端,又称队首。
  • 队尾(Rear):允许插入的一端。
  • 空队列:不含任何元素的空表。

7、队列常见的基本操作

  • InitQueue(&Q):初始化队列,构造一个空队列Q
  • QueueEmpty(Q):判队列空,若队列Q为空返回true,否则返回false
  • EnQueue(&Q, x):入队,若队列Q未满,将x加入,使之成为新的队尾。
  • DeQueue(&Q, &x):出队,若队列Q非空,删除队头元素,并用x返回。
  • GetHead(Q, &x):读队头元素,若队列Q非空,则将队头元素赋值给x

需要注意的是,栈和队列是操作受限的线性表,因此不是任何对线性表的操作都可以作为栈和队列的操作。比如,不可以随便读取栈或队列中间的某个数据。

8、队列的顺序存储结构

(1)队列的顺序存储

  • 队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:
    • 队头指针front指向队头元素;
    • 队尾指针rear指向队尾元素的下一个位置。

注意:不同教材对frontrear的定义可能不同,例如,可以让front指向队头元素、rear指向队尾元素。对于不同的定义,出队入队的操作是不同的。

  • 队列的顺序存储类型可描述为:
#define MaxSize 50//定义队列中元素的最大个数
typedef struct {
	ElemType data[MaxSize];//存放队列元素
	int front, rear;//队头指针和队尾指针
} SqQueue;
  • 初始状态(队空条件)Q.front == Q.rear == 0
  • 进队操作:队不满时,先送值到队尾元素,再将队尾指针加1。
  • 出队操作:队不空时,先取队头元素值,再将队头指针加1。

  • 上图中(a)所示为队列的初始状态,有Q.front == Q.rear == 0成立,该条件可以作为队列判空的条件。
  • 不能用Q.rear == MaxSize作为队列满的条件,上图(d)中,队列中仅有一个元素,但仍满足该条件。这时入队出现“上溢出”,但这种溢出并不是真正的溢出,在data数组中依然存在可以存放元素的空位置,所以是一种“假溢出”。

(2)循环队列

  • 将顺序队列臆造为一个环状的空间,即把存储队列元素的表从逻辑上视为一个环,称为循环队列
  • 当队首指针Q.front = MaxSize - 1后,再前进一个位置就自动到0,这可以利用除法取余运算%来实现。
  • 初始时:Q.front = Q.rear = 0
  • 队首指针进1:Q.front = (Q.front + 1) % MaxSize
  • 队尾指针进1:Q.rear = (Q.rear + 1) % MaxSize
  • 队列长度:(Q.rear + MaxSize - Q.front) % MaxSize


那么,循环队列队空和队满的判断条件是什么呢?

  • 显然,队空的条件是Q.front == Q.rear
  • 若入队元素的速度快于出队元素的速度,则队尾指针很快就会赶上队首指针,如上图(d1)所示,此时可以看出队满时也有Q.front == Q.rear

为了区分是队空还是队满的情况,有三种处理方式:

( a ) 较为普遍的做法:牺牲一个单元来区分队空和队满,约定以“队头指针在队尾指针的下一位置作为队满的标志”,如上图(d2)所示。

  • 队满条件:(Q.rear + 1) % MaxSize == Q.front
  • 队空条件仍:Q.front == Q.rear
  • 队列中元素的个数:(Q.rear + MaxSize - Q.front) % MaxSize

( b )类型中增设表示元素个数的数据成员size。这样,队空的条件为Q.size == 0;队满的条件为Q.size == MaxSize。这两种情况都有Q.front == Q.rear

(c )类型中增设tag数据成员,以区分是队满还是队空。tag等于0时,表示因删除导致Q.front == Q.rear,则为队空;tag等于1时,表示因插入导致Q.front == Q.rear,则为队满。

(3)循环队列的操作( 基于上面的(a) )

  • 初始化
void InitQueue(SqQueue &Q) {
	Q.rear = Q.front = 0;//初始化队首、队尾指针
}
  • 判队空
bool isEmpty(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;//队头指针加1取模
	return true;
}

9、队列的链式存储结构

(1)队列的链式存储

  • 队列的链式表示称为链队列,它实际上是一个同时带有队头指针和队尾指针的单链表。
  • 头指针指向队头结点,尾指针指向队尾结点,即单链表的最后一个结点(注意与顺序存储的不同)。

  • 队列的链式存储类型可描述为:
typedef struct LinkNode {//链式队列结点
	ElemType data;
	struct LinkNode *next;
} LinkNode;

typedef struct {//链式队列
	LinkNode *front, *rear;//队列的队头和队尾指针
} LinkQueue;
  • Q.front == NULLQ.rear == NULL时,链式队列为空。
  • 出队时,首先判断队是否为空,若不空,则取出队头元素,将其从链表中摘除,并让Q.front指向下一个结点(若该结点为最后一个结点,则置Q.frontQ.rear都为NULL)。
  • 入队时,建立一个新结点,将新结点插入到链表的尾部,并改让Q.rear指向这个新插入的结点(若原队列为空队,则令Q.front也指向该结点)。

不难看出,不带头结点的链式队列在操作上往往比较麻烦,因此通常将链式队列设计成一个带头结点的单链表,这样插入和删除操作就统一了。

用单链表表示的链式队列特别适合于数据元素变动比较大的情形,而且不存在队列满而产生溢出的问题。

假如程序中要使用多个队列,与多个栈的情形一样,最好使用链式队列,这样就不会出现存储分配不合理和“溢出”的问题。

(2)链式队列的基本操作(带头结点)

  • 初始化
void InitQueue(LinkQueue &Q) {
	Q.front = Q.rear = (LinkNode *)malloc(sizeof(LinkNode));//建立头结点
	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;
	Q.rear = s;
}
  • 出队
bool DeQueue(LinkQueue &Q, ElemType &x) {
	if( Q.front == Q.rear )//空队
		return false;
	LinkNode *p = Q.front->next;
	x = p->data;
	Q.front->next = p->next;
	if( p == Q.rear )     //若原队列中只有一个结点,删除后变空
		Q.rear = Q.front;
	free(p);
	return true;
}

10、双端队列

  • 双端队列是指允许两端都可以进行入队和出队操作的队列;
  • 其元素的逻辑结构仍是线性结构;
  • 将队列的两端分别称为前端后端,两端都可以入队和出队。

  • 在双端队列进队时,前端进的元素排列在后端进的元素的前面,后端进的元素排列在前端进的元素的后面。
  • 在双端队列出队时,无论前端还是后端出队,先出的元素排列在后出的元素的前面。
  • 输出受限的双端队列:允许在一端进行插入和删除,但在另一端只允许插入的双端队列称为输出受限的双端队列。

  • 输入受限的双端队列:允许在一端进行插入和删除,但在另一端只允许删除的双端队列称为输入受限的双端队列。

  • 若限定双端队列从某个端点插入的元素只能从该端点删除,则该双端队列就蜕变为两个栈底相邻接的栈。

11、栈和队列的应用

(1)栈在括号匹配中的应用

  • 假设表达式中允许包含两种括号:圆括号和方括号,其嵌套的顺序任意,即([]())[([][])]等均为正确的格式,[(])([())(()]均为不正确的格式。
  • 考虑下列括号序列:

  • 计算机接收第一个括号“[”后,期待与之匹配的第8个括号“]”出现。
  • 获得了第2个括号“(”,此时第1个括号“[”暂时放在一边,而急迫期待与之匹配的第7个括号“)”出现。
  • 获得了第3个括号“[”,此时第2个括号“(”暂时放在一边,而急迫期待与之匹配的第4个括号“]”出现。第3个括号的期待得到满足,消解之后,第2个括号的期待匹配又称为当前最急迫的任务。
  • 以此类推,可见该处理过程与栈的思想吻合。

算法思想如下:

  • 1)初始设置一个空栈,顺序读入括号。
  • 2)若是右括号,则或者使置于栈顶的最急迫期待得以消解,或者是不合法的情况(括号序列不匹配,退出程序)。
  • 3)若是左括号,则作为一个新的更急迫的期待压入栈中,自然使原有的在栈中的所有未消解的期待的急迫性降了一级。算法结束时,栈为空,否则括号序列不匹配。

(2)栈在表达式求值中的应用

1 )三种表达式:

  • 中缀表达式:运算符在两个操作数中间(如:a+b),中缀表达式不仅依赖运算符的优先级,而且还要处理括号。
  • 后缀表达式:也叫逆波兰表达式(Reverse Polish notation),简称逆波兰式。运算符在两个操作数后面(如:ab+),后缀表达式已经考虑了运算符的优先级,没有括号,只有操作数和运算符。
  • 前缀表达式:也叫波兰表达式(Polish notation),简称波兰式。运算符在两个操作数前面(如:+ab),前缀表达式也考虑了运算符的优先级,没有括号,只有操作数和运算符。
e.g.
中缀表达式:a + b - c
对应的后缀表达式:ab+c-或abc-+(不唯一)
对应的前缀表达式:-+abc或+a-bc(不唯一)

2 )中缀转后缀的手算方法:

  • ①确定中缀表达式中各个运算符的运算顺序(“左优先”原则,以保证算法的确定性,具体见下);
  • ②选择下一个运算符,按照“ 左操作数 右操作数 运算符 ”的方式组合成一个新的操作数;
  • ③如果还有运算符没被处理,就继续②,直至处理完所有运算符。
  • “左优先”原则:只要左边的运算符能先计算,就优先算左边的。(保证手算和机算结果相同,即保证中缀表达式转化成的逆波兰式唯一,保证算法的确定性)

3 )后缀表达式的手算方法:

  • 从左往右扫描,每遇到一个运算符,就让运算符前面的两个操作数执行对应运算,合体为一个操作数。

4 )后缀表达式的机算(用栈实现):

  • ①从左往右扫描下一个元素,直到处理完所有元素;
  • ②若扫描到操作数则压入栈,并回到①,否则执行③;
  • ③若扫描到运算符,则弹出两个栈顶元素(注意:先弹出的是右操作数),执行相应运算,运算结果压回栈顶,回到①。

后缀表达式适用于基于栈的编程语言(stack–oriented programming language),如:Forth,PostScript。

5 )中缀转前缀的手算方法:

  • ①确定中缀表达式中各个运算符的优先顺序(“右优先”原则,以保证算法的确定性,具体见下);
  • ②选择下一个运算符,按照“ 运算符 左操作数 右操作数 ”的方式组合成一个新的操作数;
  • ③如果还有运算符没被处理,就继续②,直至处理完所有运算符。
  • “右优先”原则:只要右边的运算符能先计算,就优先算右边的。

6 )前缀表达式的机算(用栈实现):

  • ①从右往左扫描下一个元素,直到处理完所有元素;
  • ②若扫描到操作数则压入栈,并回到①,否则执行③;
  • ③若扫描到运算符,则弹出两个栈顶元素(注意:先弹出的是左操作数),执行相应运算,运算结果压回栈顶,回到①。

7 )中缀表达式转后缀表达式(机算)

初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:

  • ①遇到操作数:直接加入后缀表达式。
  • ②遇到界限符:遇到“(”直接入栈,遇到“ ) ”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“ ( ”不加入后缀表达式。
  • ③遇到运算符:依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止。之后再把当前运算符入栈。

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

8 )中缀表达式的计算(用栈实现)

是“中缀转后缀” + “后缀表达式求值”两个算法的结合:

  • ①初始化两个栈,操作数栈(用于存放当前暂时还不能确定运算次序的操作数)和运算符栈(用于存放当前暂时还不能确定运算次序的运算符)。
  • ②从左往右扫描中缀表达式,若扫描到操作数,压入操作数栈。
  • ③若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)。

(3)栈在递归中的应用

  • 若在一个函数、过程或数据结构的定义中又应用了它自身,则称这个函数、过程或数据结构是递归定义的,简称递归
  • 它通常把一个大型的复杂问题层层转化为一个与原问题相似的规模较小的问题来求解。
  • 递归策略只需少量的代码就可以描述出解题过程所需要的多次重复计算,大大减少了程序的代码量。但在通常情况下,它的效率并不是太高。
  • 递归效率不高的原因是递归调用过程中包含很多的重复计算。
  • 递归的效率低下,但优点是代码简单,容易理解。
  • 递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题
  • 可以将递归算法转换为非递归算法,通常需要借助栈来实现这种转换。

斐波那契数列就是递归的一个典型例子,用程序实现如下:

int Fib(int n) {//斐波那契数列的实现
	if( n == 0 )
		return 0;//边界条件
	else if( n == 1 )
		return 1;//边界条件
	else
		return Fib(n - 1) + Fib(n - 2);//递归表达式
}

必须注意递归模型不能是循环定义的,其必须满足下面的两个条件:

  • 递归表达式(递归体)
  • 边界条件(递归出口)

在递归调用的过程中,系统为每一层的返回点、局部变量、传入实参等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出。

(4)队列在层次遍历中的应用

在信息处理中有一大类问题需要逐层或逐行处理。这类问题的解决办法往往是在处理当前行时就对下一层或下一行做预处理,把处理顺序安排好,等到当前层或当前行处理完毕,就可以处理下一层或下一行。使用队列是为了保存下一步的处理顺序。

下面用二叉树的层序遍历的例子,说明队列的应用:

该过程简单描述如下:

  • ①根结点入队。
  • ②若队空(所有结点都已处理完毕),则结束遍历;否则重复③。
  • ③队列中第一个结点出队,并访问之。若其有左孩子,则将左孩子入队;若其有右孩子,则将右孩子入队,返回②。


(5)队列在计算机系统中的应用

解决主机与外部设备之间速度不匹配的问题(以主机和打印机为例):主机输出数据给打印机打印,输出数据的速度比打印数据的速度要快得多,由于速度不匹配,若直接把输出的数据送给打印机打印显然是不行的。解决的办法是设置一个打印数据缓冲区,主机把要打印输出的数据依次写入这个缓冲区,写满后就暂停输出,转去做其他的事情。打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求。主机接到请求后再向缓冲区写入打印数据。这样做既保证了打印数据的正确,又使主机提高了效率。由此可见,打印数据缓冲区中所存储的数据就是一个队列。

解决由多用户引起的资源竞争问题:CPU(即中央处理器,它包括运算器和控制器)资源的竞争就是一个典型的例子。在一个带有多终端的计算机系统上,有多个用户需要CPU运行自己的程序,它们分别通过各自的终端向操作系统提出占用CPU的请求。操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把CPU分配给队首请求的用户使用。当相应的程序运行结束或用完规定的时间间隔后,令其出队,再把CPU分配给新的队首请求的用户使用。这样既满足每个用户的请求,又使CPU能够正常运行。

12、数组和特殊矩阵

在数据结构中考虑的是如何用最小的内存空间来存储同样的一组数据。

所以,我们不研究矩阵及其运算等,而把精力放在如何将矩阵更有效地存储在内存中,并能方便地提取矩阵中的元素。

(1)数组的定义:数组是由 nn≥1 )个相同类型的数据元素构成的有限序列,每个数据元素称为一个数组元素,每个元素在n个线性关系中的序号称为该元素的下标,下标的取值范围称为数组的维界

数组与线性表的关系:数组是线性表的推广。一维数组可视为一个线性表;二维数组可视为其元素也是定长线性表的线性表,以此类推。

数组一旦被定义,其维数和维界就不再改变。除结构的初始化和销毁外,数组只会有存取元素和修改元素的操作。

(2)数组的存储结构

  • 大多数计算机语言都提供了数组数据类型,逻辑意义上的数组可采用计算机语言中的数组数据类型进行存储。
  • 一个数组的所有元素在内存中占用一段连续的存储空间。
  • 以一维数组A[ 0...n-1 ]为例,其存储结构关系式为:LOC(ai) = LOC(a0) + i × L 0 ≤ i < n ),其中,L 是每个数组元素所占的存储单元个数。
  • 对于多维数组,有两种映射方法按行优先按列优先
  • 以二维数组为例,按行优先存储的基本思想是:先行后列,先存储行号较小的元素,行号相等先存储列号较小的元素。
  • 设二维数组的行下标与列下标的范围分别为[0, h1][0, h2],则存储结构关系式为:
    • LOC(ai,j) = LOC(a0,0) + [ i × (h2 + 1) + j ] × L
  • 例如,对于数组A2×3,它按行优先方式在内存中的存储形式如下图所示:

  • 当以列优先方式存储时,得出存储结构关系式为:
    • LOC(ai,j) = LOC(a0,0) + [ j × (h1 + 1) + i ] × L
  • 例如,对于数组A2×3,它按列优先方式在内存中的存储形式如下图所示:

(3)特殊矩阵的压缩存储

压缩存储:指为多个值相同的元素只分配一个存储空间,对零元素不分配存储空间。其目的是节省存储空间。

特殊矩阵:指具有许多相同元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵。

常见的特殊矩阵有对称矩阵上(下)三角矩阵对角矩阵等。

特殊矩阵的压缩方法:找出特殊矩阵中值相同的矩阵元素的分布规律,把那些呈现规律性分布的、值相同的多个矩阵元素压缩存储到一个存储空间中。

1)对称矩阵

  • 若对于一个 n 阶方阵A[1...n] [1...n]中的任意一个元素ai,j都有ai,j = aj,i1≤ i, j ≤ n ),则称其为对称矩阵
  • 对于一个 n 阶方阵,其中的元素可以划分为3个部分,即上三角区主对角线下三角区,如下图所示:

  • 对于 n 阶对称矩阵,上三角区的所有元素和下三角区的对应元素相同,若仍采用二维数组存放则会浪费几乎一般的空间,为此将对称矩阵A[1...n] [1...n]存放在一维数组B[n(n+1)/2]中,即元素ai,j存放在bk中。只存放下三角部分(含主对角线)的元素。
  • 在数组B中,位于元素ai,ji ≥ j前面的元素个数为:
    • 第1行:1个元素(a1,1)。
    • 第2行:2个元素(a2,1a2,2)。
    • ……
    • i-1行:i - 1个元素(ai-1,1ai-1,2,…,ai-1,i-1)。
    • i行:j - 1个元素(ai,1ai,2,…,ai,j-1)。
  • 因此,元素ai,j在数组B中的下标k = 1 + 2 + ... + (i - 1) + (j - 1) = i(i - 1)/2 + j - 1数组下标从0开始)。
  • 因此,元素下标之间的对应关系如下:
    • k = i(i - 1)/2 + j - 1i ≥ j(下三角区和主对角线元素)
    • k = j(j - 1)/2 + i - 1i < j(上三角区元素ai,j = aj, i

当数组下标从1开始时,可以采用同样的推导方法。

2 )三角矩阵

  • 下三角矩阵中,上三角区的所有元素均为同一常量。其存储思想与对称矩阵类似,不同之处在于存储完下三角区和主对角线上的元素之后,紧接着存储对角线上方的常量一次,故可以将下三角矩阵A[1...n] [1...n]压缩存储在B[n(n+1)/2 + 1]中。
  • 元素下标之间的对应关系为:
    • k = i(i - 1)/2 + j - 1i ≥ j(下三角区和主对角线元素)
    • k = n(n + 1)/2i < j(上三角区元素)
  • 下三角矩阵在内存中的压缩存储形式如下图所示:

  • 上三角矩阵中,下三角区的所有元素均为同一常量。只需存储主对角线、上三角区的元素和下三角区的常量一次,可将其压缩存储在B[n(n+1)/2 + 1]中。
  • 在数组B中,位于元素ai,ji ≤ j前面的元素个数为:
    • 第1行:n个元素
    • 第2行:n - 1个元素
    • ……
    • i-1行:n - i + 2个元素
    • i行:j - i个元素
  • 因此,元素ai,j在数组B中的下标k = n + (n - 1) + ... + (n - i + 2) + (j - i) = (2n - i + 2)(i - 1)/2 + (j - i)
  • 因此,元素下标之间的对应关系如下:
    • k = (2n - i + 2)(i - 1)/2 + (j - i)i ≤ j(上三角区和主对角线元素)
    • k = n(n + 1)/2i > j(下三角区元素)
  • 上三角矩阵在内存中的压缩存储形式如下图所示:


注:以上推导均假设数组的下标从0开始,且矩阵按行优先原则存储,若题设有具体要求,则应该灵活应对。

3 )三对角矩阵

  • 对角矩阵也称带状矩阵
  • 对于 n 阶方阵A中的任一元素ai,j,当|i - j| > 1时,有ai,j = 01 ≤ i, j ≤ n),则称为三对角矩阵,如下图所示:

  • 在三对角矩阵中,所有非零元素都集中在以主对角线为中心的3条对角线的区域,其他区域的元素都为零。
  • 三对角矩阵A也可以采用压缩存储,将3条对角线上的元素按行优先方式存放在一维数组B中,且a1,1存放于B[0]中,其存储形式如下图所示:

  • 由此可以计算矩阵A中3条对角线上的元素ai,j1 ≤ i, j ≤ n,|i - j| ≤ 1)在一维数组B中存放的下标为k = 2i + j - 3
  • 反之,若已知三对角矩阵中某元素ai,j存放于一维数组B的下标为k的位置,则可得i = floor( (k + 1)/3 ) + 1j = k - 2i + 3
e.g.
当k = 0时,i = floor( (k + 1)/3 ) + 1 = 1, j = 0 - 2 x 1 + 3 = 1,存放的是a1,1
当k = 2时,i = floor( (k + 1)/3 ) + 1 = 2, j = 2 - 2 x 2 + 3 = 1,存放的是a2,1
当k = 4时,i = floor( (k + 1)/3 ) + 1 = 2, j = 4 - 2 x 2 + 3 = 3,存放的是a2,3

4 )稀疏矩阵

  • 矩阵中非零元素的个数t,相对矩阵元素的个数s来说非常少,即 s >> t 的矩阵称为稀疏矩阵
  • 例如,一个矩阵的阶为100 x 100,该矩阵中只有少于100个非零元素。
  • 若采用常规的方法存储稀疏矩阵,则相当浪费存储空间,因此仅存储非零元素。
  • 但仅存储非零元素的值是不够的,还要存储它所在的行和列。
  • 将非零元素及其相应的行和列构成一个三元组(行标,列标,值),然后按照某种规律存储这些三元组。
  • 稀疏矩阵的三元组既可以采用数组存储,也可以采用十字链表存储。
e.g.2017统考真题】适用于压缩存储稀疏矩阵的两种数据结构是(  )
A.三元组表和十字链表		B.三元组表和邻接矩阵
C.十字链表和二叉链表		D.邻接矩阵和十字链表
【答案】A
【分析】三元组表的结点存储了行row、列col、值value三种信息,是主要用来存储稀疏矩阵的一种数据结构。
十字链表将行单链表和列单链表结合起来存储稀疏矩阵。
邻接矩阵空间复杂度达O(n^2),不适合于存储稀疏矩阵。
二叉链表又名左孩子右兄弟表示法,可用于表示树和森林。
  • 稀疏矩阵压缩存储后便失去了随机存取特性

有关数据结构笔记NO.1(绪论、线性表、栈队列和矩阵的压缩存储)的更多相关文章

  1. ruby - 使用 RubyZip 生成 ZIP 文件时设置压缩级别 - 2

    我有一个Ruby程序,它使用rubyzip压缩XML文件的目录树。gem。我的问题是文件开始变得很重,我想提高压缩级别,因为压缩时间不是问题。我在rubyzipdocumentation中找不到一种为创建的ZIP文件指定压缩级别的方法。有人知道如何更改此设置吗?是否有另一个允许指定压缩级别的Ruby库? 最佳答案 这是我通过查看ruby​​zip内部创建的代码。level=Zlib::BEST_COMPRESSIONZip::ZipOutputStream.open(zip_file)do|zip|Dir.glob("**/*")d

  2. ruby - 使用 ruby​​ 将 HTML 转换为纯文本并维护结构/格式 - 2

    我想将html转换为纯文本。不过,我不想只删除标签,我想智能地保留尽可能多的格式。为插入换行符标签,检测段落并格式化它们等。输入非常简单,通常是格式良好的html(不是整个文档,只是一堆内容,通常没有anchor或图像)。我可以将几个正则表达式放在一起,让我达到80%,但我认为可能有一些现有的解决方案更智能。 最佳答案 首先,不要尝试为此使用正则表达式。很有可能你会想出一个脆弱/脆弱的解决方案,它会随着HTML的变化而崩溃,或者很难管理和维护。您可以使用Nokogiri快速解析HTML并提取文本:require'nokogiri'h

  3. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  4. ruby - Ruby 有 `Pair` 数据类型吗? - 2

    有时我需要处理键/值数据。我不喜欢使用数组,因为它们在大小上没有限制(很容易不小心添加超过2个项目,而且您最终需要稍后验证大小)。此外,0和1的索引变成了魔数(MagicNumber),并且在传达含义方面做得很差(“当我说0时,我的意思是head...”)。散列也不合适,因为可能会不小心添加额外的条目。我写了下面的类来解决这个问题:classPairattr_accessor:head,:taildefinitialize(h,t)@head,@tail=h,tendend它工作得很好并且解决了问题,但我很想知道:Ruby标准库是否已经带有这样一个类? 最佳

  5. ruby - 是否有用于序列化和反序列化各种格式的对象层次结构的模式? - 2

    给定一个复杂的对象层次结构,幸运的是它不包含循环引用,我如何实现支持各种格式的序列化?我不是来讨论实际实现的。相反,我正在寻找可能会派上用场的设计模式提示。更准确地说:我正在使用Ruby,我想解析XML和JSON数据以构建复杂的对象层次结构。此外,应该可以将该层次结构序列化为JSON、XML和可能的HTML。我可以为此使用Builder模式吗?在任何提到的情况下,我都有某种结构化数据-无论是在内存中还是文本中-我想用它来构建其他东西。我认为将序列化逻辑与实际业务逻辑分开会很好,这样我以后就可以轻松支持多种XML格式。 最佳答案 我最

  6. ruby - 我如何添加二进制数据来遏制 POST - 2

    我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_

  7. 世界前沿3D开发引擎HOOPS全面讲解——集3D数据读取、3D图形渲染、3D数据发布于一体的全新3D应用开发工具 - 2

    无论您是想搭建桌面端、WEB端或者移动端APP应用,HOOPSPlatform组件都可以为您提供弹性的3D集成架构,同时,由工业领域3D技术专家组成的HOOPS技术团队也能为您提供技术支持服务。如果您的客户期望有一种在多个平台(桌面/WEB/APP,而且某些客户端是“瘦”客户端)快速、方便地将数据接入到3D应用系统的解决方案,并且当访问数据时,在各个平台上的性能和用户体验保持一致,HOOPSPlatform将帮助您完成。利用HOOPSPlatform,您可以开发在任何环境下的3D基础应用架构。HOOPSPlatform可以帮您打造3D创新型产品,HOOPSSDK包含的技术有:快速且准确的CAD

  8. ruby - Rack:如何将 URL 存储为变量? - 2

    我正在编写一个简单的静态Rack应用程序。查看下面的config.ru代码:useRack::Static,:urls=>["/elements","/img","/pages","/users","/css","/js"],:root=>"archive"map'/'dorunProc.new{|env|[200,{'Content-Type'=>'text/html','Cache-Control'=>'public,max-age=6400'},File.open('archive/splash.html',File::RDONLY)]}endmap'/pages/search.

  9. 旋转矩阵的几何意义 - 2

    点向量坐标矩阵的几何意义介绍旋转矩阵的几何含义之前,先介绍一下点向量坐标矩阵的几何含义点:在一维空间下就是一个标量,如同一条直线上,以任意某一个位置为0点,以一定的尺度间隔为1,2,3...,相反方向为-1,-2,-3...;如此就形成了一维坐标系,这时候任何一个点都可以用一个数值表示,如点p1=5,即即从原点出发沿着x轴正方向移动5个尺度;点p2=-3,负方向移动3个尺度;     在一维坐标系上过原点做垂直于一维坐标系的直线,则形成了二维坐标系,此时描述一个点需要两个数值来表示点p3=(3,2),即从原点出发沿着x轴正方向移动3个尺度,在此基础上沿着y轴正方向移动两个尺度的位置就是点p3。

  10. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

随机推荐