2. 第二章:线性表
2.1 线性表的逻辑结构
- 定义:线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列。其中n为表长。当n=0时 线性表是一个空表
- 特点:线性表中第一个元素称为表头元素;最后一个元素称为表尾元素。除第一个元素外,每个元素有且仅有一个_直接前驱_。除最后一个元素外,每个元素有且仅有一个_直接后继_。
2.2 线性表的顺序存储结构
- 线性表的顺序存储又称为顺序表。它是用_一组地址连续的存储单元_(比如C语言里面的数组),依次存储线性表中的数据元素,从而使得_逻辑上相邻的两个元素在物理位置上也相邻_。
顺序表采用顺序存储方式,即逻辑上相邻的数据在计算机内的存储位置也是相邻的。顺序存储方式,元素存储是连续的,中间不允许有空,可以快速定位第几个元素,但是插入和删除时需要移动大量元素。根据分配空间方法不同,顺序表可以分为静态分配和动态分配两种方法。
2.2.1 静态分配
顺序表最简单的方法是使用一个定长数组data[]
存储数据,最大空间为Maxsize
,用length
记录实际的元素个数,即顺序表的长度。这种用定长数组存储的方法称为静态分配。静态顺序表如图所示。
顺序表的静态分配结构体定义,如图所示。采用静态分配方法,定长数组需要预先分配一段固定大小的连续空间,但是在运算的过程中,如合并、插入等操作,容易超过预分配的空间长度,出现溢出。解决静态分配的溢出问题,可以采用动态分配的方法。
2.2.2 动态分配
在程序运行过程中,根据需要动态分配一段连续的空间(大小为Maxsize),用elem记录该空间的基地址(首地址),用length记录实际的元素个数,即顺序表的长度。动态
顺序表如图2-4所示。采用动态存储方法,在运算过程中,如果发生溢出,可以另外开辟一块更大的存储空间,用以替换原来的存储空间,从而达到扩充存储空间的目的。
顺序表的动态分配结构体定义
1. 简化比较复杂的类型声明
给复杂的结构体类型起一个别名,这样就可以使用这个别名等价该结构体类型,在声明该类型变量时就方便多了。 例如,不使用typedef的顺序表定义:
struct SqList {
int *elem; //顺序表的基地址
int length; //顺序表的长度
};
如果需要定义一个顺序表,需要写:
struct SqList L; //定义时需要加上struct(c需要,C++不需要),L为顺序表的名字
使用typedef的顺序表定义:
typedef struct {
int *elem; //顺序表的基地址
int length; //顺序表的长度
}SqList;
如果需要定义一个顺序表,需要写:
SqList L; //不需要写struct,直接用别名定义
2. 提高程序的可移植性
例如,在程序中使用这样的语句:
typedef int ElemType; //给int起个别名ElemType
在程序中,假如有n个地方用到了ElemType类型,如果现在处理的数据变为字符型了,那么就可以将上面类型定义中的int直接改为char。
typedef char ElemType;
这样只需要修改类型定义,不需要改动程序中的代码。如果不使用typedef类型定义,就需要把程序中n个用到int类型的地方全部改为char类型。如果某处忘记修改,就会产生错误。 问题2:使用ElemType是为了让算法的通用性更好,因为使用线性表的结构体定义后,并不清楚具体问题处理的数据是什么类型,不能简单地写成某一种类型。结合typedef使用,可以提高算法的通用性和可移植性。 以int型元素为例,如果使用顺序表的动态分配结构体定义,就可以直接将ElemType写成int。
typedef struct{
int *elem; //顺序表的基地址
int length; //顺序表的长度
}SqList;
也可以使用类型定义,给int起个别名:
typedef int ElemType; //给int起个别名ElemType,两者等价
typedef struct{
ElemType *elem; //顺序表的基地址
int length; //顺序表的长度
}SqList;
显然,后一种定义的通用性和可移植性更好,当然第一种定义也没有错。
- 建立顺序表的三个属性:
- 1.存储空间的起始位置(数组名data)
- 2.顺序表最大存储容量(MaxSize)
- 3.顺序表当前的长度(length)
- 其实数组还可以动态分配空间,存储数组的空间是在程序执行过程中通过动态存储分配语句分配
- 总结:
- 1.顺序表最主要的特点是随机访问(C语言中基于数组),即通过首地址和元素序号可以在O(1)的时间内找到指定的元素。
- 2.顺序表的存储密度高,每个结点只存储数据元素。无需给表中元素花费空间建立它们之间的逻辑关系(因为物理位置相邻特性决定)
- 3.顺序表逻辑上相邻的元素物理上也相邻,所以插入和删除操作需要移动大量元素。
2.3 顺序表的操作
2.3.1.插入
- 算法思路:
- 1.判断
i
的值是否正确 - 2.判断表长是否超过数组长度
- 3.从后向前到第
i
个位置,分别将这些元素都向后移动一位 - 4.将该元素插入位置
i
并修改表长
- 1.判断
- 代码
- 分析:
- 最好情况:在表尾插入(即
i=n+1
),元素后移语句将不执行,时间复杂度为O(1)
。 - 最坏情况:在表头插入(即
i=1
),元素后移语句将执行n次,时间复杂度为O(n)
。 - 平均情况:假设pi(
pi=1/(n+1)
)是在第i个位置上插入一个结点的概率,则在长度为n的线性表中插入一个结点时所需移动结点的平均次数为
- 最好情况:在表尾插入(即
2.3.2.删除
- 算法思路:
- 1.判断i的值是否正确
- 2.取删除的元素
- 3.将被删元素后面的所有元素都依次向前移动一位
- 4.修改表长
- 代码
- 分析
- 最好情况:删除表尾元素(即
i=n
),无须移动元素,时间复杂度为O(1)
。 - 最坏情况:删除表头元素(即
i=1
),需要移动除第一个元素外的所有元素,时间复杂度为O(n)
。 - 平均情况:假设pi(
pi=1/n
)是删除第i个位置上结点的概率,则在长度为n的线性表中删除一个结点时所需移动结点的平均次数为
- 最好情况:删除表尾元素(即
2.4 线性表的链式存储结构
链表的优点:
链表是动态存储,不需要预先分配最大空间;插入删除不需要移动元素。
链表的缺点:
每次动态分配一个节点,每个节点的地址是不连续的,需要有指针域记录下一个节点的地址,指针域需要占用一个int的空间,因此存储密度低(数据所占空
间/节点所占总空间)。存取元素必须从头到尾按顺序查找,属于顺序存取。
- 线性表的链式存储是指通过一组任意的存储单元来存储线性表中的数据元素。
- 头结点和头指针的区别?
- 不管带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点链表中的第一个结点,结点内通常不存储信息
- 为什么要设置头结点?
- 1.处理操作起来方便 例如:对在第一元素结点前插入结点和删除第一结点起操作与其它结点的操作就统一了
- 2.无论链表是否为空,其头指针是指向头结点的非空指针,因此空表和非空表的处理也就统一了。
2.5 单链表的操作
2.5.1. 头插法建立单链表:
- 建立新的结点分配内存空间,将新结点插入到当前链表的表头
- 代码
2.5.2.尾插法建立单链表:
- 建立新的结点分配内存空间,将新结点插入到当前链表的表尾
- 代码
2.5.3.按序号查找结点
- 在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点指针域NULL。
- 代码
2.5.4.按值查找结点
- 从单链表第一个结点开始,由前往后依次比较表中各结点数据域的值,若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL。
- 代码
2.5.5.插入
- 插入操作是将值为x的新结点插入到单链表的第i个位置上。先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第i−1个结点,再在其后插入新结点。
- 算法思路:1.取指向插入位置的前驱结点的指针① p=GetElem(L,i-1);2.令新结点_s的指针域指向_p的后继结点② s->next=p->next;3.令结点_p的指针域指向新插入的结点_s③ p->next=s;
2.5.6.删除
- 删除操作是将单链表的第i个结点删除。先检查删除位置的合法性,然后查找表中第i−1个结点,即被删结点的前驱结点,再将其删除。
- 算法思路:1.取指向删除位置的前驱结点的指针 p=GetElem(L,i-1);2.取指向删除位置的指针 q=p->next;3.p指向结点的后继指向被删除结点的后继 p->next=q->next4.释放删除结点 free(q);
2.6. 双向链表
为了向前、向后操作方便,可以给每个元素附加两个指针域,一个存储前一个元素的地址,另一个存储下一个元素的地址。这种链表称为双向链表
定义
2.6.1.插入:(方法不唯一)
① s->next=p->next;
② p->next->prior=s;
③ s->prior=p;
④ p->next=s;
2.6.2.删除:
① p->next=q->next;
② q->next->prior=p;
③ free(q);
2.7 循环链表
单链表中,只能向后,不能向前。如果从当前节点开始,无法访问该节点前面的节点,而最后一个节点的指针指向头节点,形成一个环,就可以从任何一个节点出发,访问
所有的节点,这就是循环链表。循环链表和普通链表的区别就是最后一个节点的后继指向了头节点。
- 循环单链表:循环单链表和单链表的区别在于,表中最后一个结点的指针不是NULL,而改为指向头结点,从而整个链表形成一个环
- 循环双链表:类比循环单链表,循环双链表链表区别于双链表就是首尾结点构成环
- 当循环双链表为空表时,其头结点的
prior
域和next
域都等于Head
。
2.8 静态链表
- 静态链表:静态链表是用数组来描述线性表的链式存储结构。
- 数组第一个元素不存储数据,它的指针域存储第一个元素所在的数组下标。链表最后一个元素的指针域值为-1。
- 例子
顺序表和链表的比较
2.9 线性表的应用
合并有序顺序表
题目:将两个有序(非递减)顺序表La和Lb合并为一个新的有序(非递减)顺序表。 解题思路 1)首先创建一个顺序表Lc,其长度为La和Lb的长度之和。 2)然后从La和Lb中分别取数,比较其大小,将较小者放入Lc中,一直进行下去,直到其中一个顺序表La或Lb中的数取完为止。 3)把未取完的数再依次取出放入Lc中即可。
合并有序链表
题目:将两个有序(非递减)单链表La和Lb合并为一个新的有序(非递减)单链表。 解题思路 链表合并不需要再创建空间,只需要“穿针引线”,把两个单链表中的节点按非递减的顺序串联起来即可。 注意:单链表的头指针不可以移动,一旦头指针丢失,就找不到该单链表了,因此需要辅助指针。
就地逆置单链表
题目:将带有头节点的单链表就地逆置。即元素的顺序逆转,而辅助空间复杂度为O(1)。 解题思路 充分利用原有的存储空间,通过修改指针实现单链表的就地逆置。还记得吗?头插法 创建单链表得到的序列正好是逆序,那么我们就利用头插法建表的思路,实现就地逆置。
查找链表的中间节点
题目:带有头节点的单链表L,设计一个尽可能高效的算法求L中的中间节点。 解题思路 此类题型可以使用快慢指针来解决。一个快指针,一个慢指针,快指针走两步,慢指 针走一步。当快指针指向结尾的时候,慢指针刚好指向中间节点。
删除链表中的重复元素
题目:用单链表保存m个整数,节点的结构为(data,next),且|data| n(n为正整数)。现要求设计一个时间复杂度尽可能高效的算法,对于链表中data的绝对值相等的节点,仅保留第一次出现的节点而删除其余绝对值相等的节点。 解题思路 本题数据大小有范围限制,因此可以设置一个辅助数组记录该数据是否已出现,如果 已出现,则删除;如果未出现,则标记。一趟扫描即可完成。