目录
知识结构
线性表基本概念与实现
1 定义
线性表是具有相同数据类型的有限、有序序列。序列中所含数据元素的个数叫做线性表的长度,用 n(n ≧ 0) 表示,n = 0 时表示线性表是一个空表。
2 逻辑特性
线性表只有一个表头元素和表尾元素。表头元素没有前驱,表尾元素没有后继,其他所有元素都有前驱和后继。
总结一下线性表的特点:
元素个数有限;
元素具有逻辑上的顺序性,元素的排序有先后次序;
元素的数据类型相同,每个元素占有相同大小的存储空间;
元素具有抽象性,即只讨论它们之间的逻辑关系而不考虑元素的内容;
线性表是一种逻辑结构,表示元素之间一对一的相邻关系,也就是线性关系;
而具体到某一个表,比如顺序表和链表则是存储结构,两者属于不同层面的概念。
3 存储结构:顺序存储结构(顺序表)和链式存储结构(链表)。
(1)顺序表
把线性表中所有的元素按照其逻辑顺序,依次存储到指定存储位置(第一个元素存储的位置)开始的一块连续的存储空间中,这样的线性表称为顺序表。
(2)链表
把所有元素当成独立的结点并依次链接起来的表称为链表。链表存储中,每个结点不仅包含所存元素的信息,还包含元素之间逻辑关系的信息。其结点的空间可以来自于整个内存。
随机存取:随机存取就是直接存取,即通过下标直接访问元素的数据结构,与存储位置无关。
顺序存取:顺序存取就是存取第 N 个数据时,必须先访问前面的 (N - 1) 个数据。
存取读写方式不是存储方式。顺序表是一种随机存取的存储结构,而链表是一种顺序存取的存储结构。
(3)两者的区别
1)存储结构上
① 线性表:顺序表采用数组存储,具有随机访问特性,只需通过数组下标即可快速的找到元素;数组是一段连续的地址空间,因此顺序表占用连续的存储空间。
② 链表:链表不具有随机访问特性,在链表中要想找到某个元素必须遍历前面所有元素。链表中每个结点都需要划分一个指针域,因此链表结点的存储空间利用率较顺序表稍低一些。但也因此链表的空间分配非常灵活,支持存储空间的动态分配。
存储密度:数据域所占总空间的比例越多,存储密度越大,因此顺序表的存储密度要大于链表。
由此可知,顺序表通过物理位置上的相邻来反映数据元素之间的逻辑关系,链表则是通过指针链接来反映逻辑关系。
2)基本操作
顺序表插入/删除操作平均需要移动近一半的元素;链表不需要移动元素,只需要修改指针。
3)空间分配
顺序表一次性分配存储空间,链表多次分配;顺序表存储密度为1,链表小于1。
(4)复杂度的计算:
1)顺序表的插入
① 分析一:插入第一个位置需要移动后 n 个元素,插入第二个位置需要移动后 n - 1 个,以此类推,总共移动次数为 n + (n - 1) + (n - 2) + ... + 2 + 1 + 0 = (n +1) * n / 2。共有 n + 1 个插入位置,所以插入平均需要移动次数为 n / 2 次。
注意:插入的位置是元素之间,而不是插入到元素中。
② 分析二:假设 pi(pi = 1/(n + 1)) 是在第 i 个位置上插入一个结点的概率,则在长度为 n 的线性表中插入一个结点时,所需移动结点的平均次数为
2)顺序表的删除
① 分析一:删除第一个元素要移动后 n - 1 个元素,删除第二个元素要移动后 n - 2 个元素,第三个要 n - 3 个。总共移动次数为 ( n - 1) + (n - 2) + ... + 2 + 1 + 0 = n * (n - 1) / 2。共有 n 个可删除的元素,所以删除平均需要移动次数为 (n - 1) / 2。
② 分析二:假设 pi(pi = 1/(n)) 是删除第 i 个位置上结点的概率,则在长度为 n 的线性表中删除一个结点时,所需移动结点的平均次数为
因此顺序表的插入/删除都需要要移动近一半的元素,其平均时间复杂度为 O(n)。
3)链表的插入/删除操作的时间复杂度为 O(1),只需要操作指针而不需要移动元素。
4)对于按值查找,顺序表无序时两者时间复杂度都为 O(n);有序时,采用折半查找,顺序表时间复杂度为 。
4 如何选取存储结构
(1)基于存储的考虑
对线性表的长度或存储规模难以估计时,不宜采用顺序表而应选择链表。
(2)基于运算的考虑
在顺序表中按序号访问元素的时间复杂度为 O(1),而链表中按序号访问的时间复杂度为 O(n),所以如果经常做的运算是按序号访问数据元素,显然顺序表优于链表。
在顺序表中做插入、删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,这一点是不应忽视的;在链表中做插入、删除操作时,虽然也要找插入位置,但是操作要简单很多,从这个角度考虑显然链表优于顺序表。
(3)基于环境的考虑
顺序表容易实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现较为简单,这也是用户考虑的一个因素。
总之,两种存储结构各有长短,选择哪一种由实际问题的主要因素决定。通常较稳定的线性表选择顺序存储,而频繁做插入、删除操作的线性表(即动态性较强)宜选择链式存储。
线性表的结构体定义和基本操作
线性表的基本操作:
InitList(&L): // 初始化表,构造一个空的线性表
Length(L): // 返回线性表的的长度
LocateElem(L, e): // 按 e 值查找表中元素
GetElem(L, i): // 按 i 位查找表中元素
ListInsert(&L, i, e): // 在表中第 i 个位置上插入元素 e
ListDelete(&L, i, &e): // 删除表中第 i 个位置的元素,用 e 返回值
PrintList(L): // 按前后顺序输出线性表的元素
Empty(L): // 判空,L 为空返回 true,否则 false
DestoryList(&L): // 销毁并释放线性表所占用的内存空间
“&” 表示 C++ 中的引用,若传入指针型变量,且在函数体内要对传入的指针进行改变(即改变指针指向的内存)就会用到指针变量的引用,在 C 中用指针的指针来达到同样的效果。
线性表的顺序表示
顺序表
1 定义
ElemType是广义上的类型定义。
# define MaxSize 100
typedef struct
{
ElemType data[MaxSize]; // 顺序表最大长度
int length; // 顺序表当前长度
}Sqlist; // 顺序表类型的定义
顺序表本质是结构体数组,考试中更常用更快捷的定义如下:
int L[MaxSize]; // 考试多为 int 类型
int n;
一维数组可以静态分配(数组空间和大小都是事先固定),也可以动态分配(在程序执行过程中通过动态分配语句分配)。
#define InitSize 100
typedef struct
{
Elemtype* data; // 指示动态分配数组的指针
int MaxSize, length; // 数组的最大容量和当前个数
}Seqlist; // 动态分配数组顺序表的类型定义
C 和 C++ 的分配语句分别为:
L.data = (ElemType*)malloc(sizeof(ElemType) * InitSize)
L.data = new ElemType[InitSize];
ElemType* 指定分配的类型,sizeof(ElemType) 用来指明分配 ElemType 大小的单个结点空间,InitSize 指明分配的结点数量,不写默认1。
动态分配并不是链式存储,它同样是属于顺序存储结构的,物理结构没有变化,依然是随机存取方式,只是分配的空间大小可以在运行时决定。
2 基本操作
插入
// 将元素 e 插入到顺序表的第 i 个位置,对应下标 i - 1
bool ListInsert(SqList &L, int i, ElemType e)
{
if (i < 1 || i > L.length + 1) // i 的值应该满足 1 <= i <= L.length + 1;
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 ture; // 插入成功返回 true
}
删除
// 删除表中第 i 个位置的元素,并将元素 e 返回,对应下标 i - 1
bool ListDelete(SqList &L, int i, ElemType &e) // e 的值要改变,所以用引用
{
if (i < 1 || i > L.length) // i 的值应该满足 1 <= i <= L.length
return false; // 只有 length 个元素可删除
e = L.data[i - 1]; // 被删除的元素值赋给 e
if (int j = i; j < L.length; j++) // 将第 i 个之后的元素前移
L.data[j - 1] = L.data[j];
L.length --; // 表长 + 1
return ture; // 删除成功返回 ture
}
插入/删除表尾元素不需要移动元素,所以不写入插入/删除函数内。
按值查找
// 查找顺序表中值为 e 的元素,查找成功返回位序,位序是实际上的位置,因此 + 1
int localeElem(Sqlist L, ElemType e)
{
for (inr i = 0; i = L.length; i++) // i 的值应该满足 1 <= i <= L.length
{
if (L.data[i] == e)
return i + 1;
return 0;
}
}
线性表的链式表示
顺序表的插入删除需要移动大量的元素,影响了运行效率。链式存储不要求逻辑上相邻的两个元素在物理位置上也相邻,解决了顺序表需要大量连续存储空间的缺点;但链表的缺点在于其指针域会浪费存储空间,且由于其元素离散的分布在存储空间中,当链表中的某一个结点丢失时,该结点后的所有结点也随之丢失。
我们通常用头指针(head)来标志一个链表,头指针为 NULL 时链表为空。有时为了方便会在链表的第一个有效结点之前附加一个结点,称为头结点。头结点的数据域通常不含任何的信息,但也可以存储一些描述链表属性的信息如链表长度。头结点的后继结点称为开始结点。
结点是内存中一片由用户分配的存储空间,只有一个地址来表示它的存在,并没有显式的名称。因此在分配链表结点空间的时候,同时定义一个指针来指向这个结点,并以该指针作为该结点的名称。
头指针永远指向链表的第一个结点(因此它有可能指向头结点),而头结点是带头结点的链表中的第一个结点。
头指针的意义:
不管链表是否存在头结点,我们总能通过头指针去访问链表中的每一个元素,因此它是必须存在的。
头结点的作用:
① 统一操作。开始结点的位置被存放在头结点的指针域中,因此相当于一个“中间结点”,对它的操作和其他结点没有区别,无须添加特殊代码,保证了操作的统一性。
② 统一处理。链表加上头结点之后,无论链表是否为空,头指针始终指向头结点,因此空表和非空表的处理也统一了,方便了链表的操作同时减少了程序的复杂性和出现 bug 的机会。
③ 检测空表。头指针始终指向链表的第一个元素,当链表为空的时候,带头结点的链表中,头指针就指向头结点;不带头结点头指针就为 NULL。
④ 减少值变。不带头结点时,如果删除第一个结点或在开始结点前插入元素时就需要改变头指针的值,使它指向新的结点。因此在算法的函数形参表中头指针一般使用指针的指针(C++ 中使用引用 &);而带头结点的单链表不需改变头指针的值,函数参数表中头结点使用指针变量即可。
单链表
1 定义
(1)单链表结点定义
typedef struct LNode
{
ElemType data;
struct LNode* next;
}LNode, *LinkList;
每个结点只有一个数据域和指针域,指针域指向后继结点。带头结点时,当 head -> next = NULL 时,链表为空;不带头结点时,head 直接指向开始结点,当 head == NULL 时链表为空。
LNode* 和 LinkList 的作用是等价的,只不过是这个结点结构体定义的两个别名。但要注意 LNode *p, q; 和 LinkList p, q; 的区别。
(2)单链表构造
头插法:从一个空表开始,每次都将结点插入到头结点和开始结点之间,逆序建立单链表。
LinkList List_HeadInsert(LinkList &L)
{
int x;
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
LNode *s;
L -> next = NULL; // 初始化为空链表
scanf("%d", &x); // 输入结点的值
while (x != 9999)
{ // 输入 9999 表示结束
s = (LNode*)malloc(sizeof(LNode)); // 创建新结点
s -> data = x; // 让 s 指向它
s -> next = L -> next;
L -> next = s;
scanf("%d", &x);
}
return L;
}
尾插法:从一个空表开始,每次都将结点插入到表尾,正向建立单链表。
LinkList List_TailInsert(LinkList &L)
{
int x;
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
LNode *s, *r = L; // 尾指针 r 始终指向表尾元素
L -> next = NULL; // 初始化为空链表
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;
}
建立新的链表和新结点时一定要先申请分配内存空间。
2 基本操作
按序查找
// 查找并返回链表中第 i 个位置的结点指针
LNode *GetElem(LinkList L, int i)
{
int j = 1; // 计数
LNode *p = L -> next; // 头结点指针赋给 p
if (i == 0)
return L; // i 为0,返回头结点
if (i < 1)
return NULL; // i 不合法,返回 NULL
while (p && j < i) // 从第一个结点开始查找
{
p = p -> next;
j ++;
}
return p; // 返回第 i 个结点的指针,如果 i 大于表长,p = NULL,直接返回 p 即可
}
按值查找
// 查找并返回链表中数据域值为 e 的结点指针
LNode *LocateElem(LinkList L, ElemType e)
{
LNode *p = L ->next; // 等于 L 等于 L -> next 都是一样的
while (p != NULL && p -> data != e)
p = p -> next;
return p;
}
插入结点
将新结点插入单链表的第 i 个位置上,要先检查插入位置的合法性,然后找到第 i - 1 个结点,将结点插于其后。假设调用 GetElem 后返回的指针为 p,新结点为 *s。
p = GetElem(L, i - 1); // 查找插入位置的前驱结点指针
s -> next = p -> next;
p -> next = s;
该插入语句顺序不能颠倒,否则会丢失掉后继结点的位置。
删除结点
删除链表的第 i 个结点,先检查删除位置的合法性,然后找到第 i - 1 个结点,再将第 i 个结点删除。假设调用 GetElem 后返回的指针为 p,q 指向待删除结点。
p = GetElem(L, i - 1); // 查找删除结点的前驱结点指针
q = p -> next;
p -> next = q -> next;
free(q); // 释放结点的存储空间
指针变量自身也占用存储空间,它的存储空间是由系统分配的,不需要用户来释放。只有用户申请分配的存储空间才需要用户自己来释放。考试中肯定是要删除的同时释放该结点所占用的内存空间,因此插入/删除操作的前提是必须知道其前驱结点的位置。
插入/删除中最耗时的操作来自于查找,所以其时间复杂度为 O(1)。另外还能通过交换结点的数据域的值来实现结点的插入和删除,请读者自行去实现。
由于链表的长度是可以自由分配的,所以求链表长度也需要遍历算法,设置一个计数器,随着指针的移动而自增,直到指针为空。需要注意的是,单链表的长度是不包括头结点的,因此求表长的操作有所不同。
双链表
1 定义
结点定义
typedef struct DLNode
{
ElemType data;
struct DLNode *prior, *next;
}DLNode, *DlinkList;
每个结点都有两个指针,一个指向前驱一个指向后继。其判空条件和单链表一样,带头结点时,当 head -> next = NULL 时,链表为空;不带头结点时,head 直接指向开始结点,当 head == NULL 时链表为空。双链表的建立同样分头插法和尾插法,请自行参考资料写出。
2 基本操作
双链表仅在每个结点上加入了一个前驱指针,所以它的按位查找和按值查找与单链表是一样的(用不到前驱指针),但是插入和删除因为涉及到前驱指针的变动,所以和单链表有所不同。
插入
插入和删除最重要的一点就是不能断链。如下是将结点 *s 插入到 结点*p 之后的代码:
s -> next = p -> next;
p -> next -> prior = p;
s -> prior = p;
p -> next = s;
该段代码可以任意组织顺序,唯一要求便是1、2两步必须在4之前,否则会导致 *p 的后继结点的地址丢失从而断链。自行画图能更好的理解,前插同理,加深理解。
删除
如下是删除结点 *p 后得结点 *q 的代码:
p -> next = q -> next;
q -> next -> prior = p;
free(q);
循环链表
1 循环单链表:单链表中最后一个结点的指针域指向第一个结点 L 就构成了循环单链表。循环单链表可以实现从任何一个节点出发访问链表中的任何结点,单链表则只能访问出发结点后的所有结点。循环链表中没有空的指针域,因此它的判空条件为:带头结点时,头指针所指结点的指针域指向本身时为空,即head = head -> next ;不带头结点时,head == NULL 时表示链表为空。
循环单链表的插入/删除操作与单链表几乎一样,仅表尾的操作有所不用,需要保持循环的状态。涉及循环单链表的操作通常是在表头和表尾进行的,此时对循环单链表不仅要设置一个头指针,还要设置一个尾指针。若只设置了头指针,对表尾进行操作需要 O(n) 的时间复杂度;设置了表尾指针时,尾指针的 next 即为头指针,对表头表尾的操作都只需要 O(1) 的时间复杂度。
2 循环双链表:循环双链表的所有结点在循环单链表的基础上,每一结点都增添了一个指向其前驱结点的指针,从而就能从任一结点出发然后为所欲为。不带头结点时,head == NULL 时链表为空。带头结点时,其空状态下,head 所指结点的指针域必然都等于 head。所以判断循环双链表是否为空只需检查其两个指针的任意一个是否等于 head 即可。以下四条语句都能快速判断:
head -> next == head; // head 结点后继为 head 结点
head -> prior == head; // head 结点前驱为 head 结点
head -> next == head && head -> prior == head; // 一二的与
head -> next == head || head -> prior == head; // 一二的或
静态链表
静态链表借助一维结构体数组来表示,因此需要分配较大的连续空间。其中每一个结点含有两个分量:一个数据域 data,一个指指针域 next。与一般链表不同的是,这个指针是结点的相对地址,是一个存储数组下标的整型变量,又称游标。它直接指示了当前节点的直接后继结点在数组中的位置,因此静态链表的插入和删除不需要移动元素。其定义描述如下:
#define MaxSize 50
typedef struct
{
ElemType data;
int next;
}SLinkLins[MaxSize];
静态链表以 next == -1 作为其结束的标志,静态链表出现的比较少,因此考察的也少。
小小的练习
1 将顺序表中的所有元素逆置
尽量放在一个循环里面解决。注意这里写 i < j 是最好也最简洁的,不要弄什么 n / 2 或者 n / 2 + 1 这些花里胡哨的。。。不能写成 i != j,这在偶数序列时会导致 i,j 直接彼此越过而循环不结束。
void reverse (Sqlist &L) // L 要改变,故用引用型
{
int i, j;
int temp;
for (i = 0, j = L.length - 1; i < j; i++, j--
{
temp = L.data[i];
L.data[i] = L.data[j];
L.data[j] = temp;
}
}
2 从一给定的顺序表 L 中删除下标 i ~ j (i <= j,包括 i、j)的所有元素,假定 i,j 都是合法的
这里的处理是将 j + 1 后的元素都往前移 j + 1 - i个,所以设置一个 delta 为该值,后面的元素下标依次减去 delta 即可实现往前的覆盖。如果 j + 1后的元素不够 j - i + 1 个怎么办?没关系,因为表长减去了一个 delta,那样在表外的元素都会被直接删除掉。
void delete (Sqlist &L, int i, int j) // L 要改变,故用引用型
{
int k, delta;
delta = j - i + 1; // 元素要移动的距离
for (k = j + 1; k < L.length; k++)
L.data[k - delta] = L.data[k]; // 用第 k 个元素去覆盖它前边的第 delta 个元素
L.length -= delta; // 表长发生变化
}
3 设计一个算法删除单链表 L (有头结点)中的一个最小值结点
用 p 从头至尾扫描链表,pre 指向 *p 结点的前驱,用 minp 保存值最小的结点的指针,minpre 指向 minp 的前驱。一边扫描,一边比较,将最小值结点放到 minp 中。这类题目一般一个指针是不够的,需要额外的指针来记录中间过程。
void delminnode (LNode *L)
{
LNode *pre = L, *p = pre -> next, *minp = p, *minpre = pre;
while (p != NULL) // 查找最小值结点 minp 以及前驱结点 minpre
{
if (p -> data < minp ->data) // 发现更小的值,用 minp 和 minpre 来标记
{
minp = p;
minpre = pre;
}
pre = p;
p = p -> next;
}
minpre -> next = minp -> next;
free (minp);
}
4 有一个线性表,采用带头结点的单链表 L 来存储。设计一个算法将其逆置。要求不能建立新的结点,只能通过表中已有结点的重新组合来完成
前面提到过关于逆序的问题能很好地解决这一题,就是链表建立的头插法。头插法完成后,链表中的元素顺序恰好和原数组中元素的顺序相反。这里可以将原 L 链表当做数组,将 L -> next 设置为空,将头结点后的一串结点用头插法逐个插入 L 中即可实现逆序。简单讲一下代码的原理,整个的基本过程就是先将头结点和开始结点先从链表中脱离出来,然后依次从旧链表中逐个取下结点并链接到头结点和开始结点之中。通过 p 来取结点链接结点,通过 q 来标记 p 该从哪取起。
void reversel (LNode *L) // 并没有新的结点
{
LNode *p = L -> next, *q;
L -> next = NULL;
while (p != NULL) // p 结点始终指向旧链表的
{
q = p -> next; // q 结点作为辅助结点来记录 p 的直接后继结点的位置
p -> next = L -> next; // 将 p 所指的结点插入新的链表中
L -> next = p;
p = q; // 因为后继结点以及存入 q 中,所以 p 仍然可以找到后继
}
}
5 设计一个算法,将一个头结点为 A 的单链表(数据域为整数)分解成两个单链表 A 和 B ,使得 A 链表只含有原来链表中 data 域为奇数的结点,而 B 链表只含有原链表中 data 域为偶数的结点,且保持原来的相对顺序
每申请一个新结点的时候,要将其指针域 next 设置为 NULL,这样可以避免很多因链表的终端结点忘记设置 NULL 而产生的错误。p 始终指向当前被判断结点的前驱结点,这删除结点是会类似的,因取下一个结点就是删除一个结点,只是不释放这个结点的内存空间而已。没必要让 q 也沿着链表移动,因为它始终指向的是要从 A 链表中移除的结点,所以让它待在一个表上是不合适的。仅仅让它需要的时候指向 p 之后,然后带着移除结点出去,再回来指向下一个就行了。这也是 while 循环用p做条件的原因,q 是一个经常跳的指针,判断它为空是没有意义的。
指针不赋值相当于野指针,它会随即指向系统中的一个位置,一旦操作失误,将造成不可估量的后果,所以申请节点后要将其赋值为 NULL;赋值为 NULL 也有利于判断链表是否为空。
void sortSingal (LNode *A)
{
LNode *B = (LNode*)malloc(sizeof(LNode)); // 申请链表 B 的头结点
B -> next = NULL;
LNode *p = A, *q, *r = B;
while (p -> next != NULL)
{
if (q -> data % 2 == 0)
{
q = p -> next; // q 指向要从链表中取下的结点
p -next = q -> next; // 从链表中取下这个结点
q -> next = NULL;
r -> next = q;
r = q;
}
else
p = p -> next;
}
}
总结下来,关于线性表的题目其实都是从其一些基本操作复合或变形而来的,所以牢牢地打好基础,将所有的顺序表、链表的查找插入删除的基操工作做好就不会丢分了。