一、线性表初相识
在日常生活里,不知道大家有没有留意过排队的场景呢?比如在超市结账、银行办理业务或者餐厅取号排队的时候,人们一个挨着一个,形成了一条有序的队伍 。先来的人排在前面,后来的人依次排在后面,除了队首和队尾的人,每个人都有且仅有一个直接前驱和一个直接后继。其实,这种排队的方式就类似于数据结构中的线性表。
从专业角度来讲,线性表是一种最基本、最简单且常用的数据结构。它是 n 个具有相同特性的数据元素的有限序列,其中的数据元素之间存在一对一的关系。用数学语言来描述的话,线性表可以表示为:\(L = (a_1, a_2, ..., a_n)\),这里的\(L\)代表线性表,\(a_i\)(\(i = 1, 2, ..., n\))是表中的数据元素,\(n\)则是线性表的长度,当\(n = 0\)时,这个线性表就是空表。
再举个例子,英文字母表\((A, B, C, ..., Z)\)就是一个典型的线性表,每个字母都是表中的一个数据元素,它们按照固定的顺序排列,有着明确的先后次序。 又比如,我们记录一个班级学生的成绩,把每个学生的成绩依次记录下来,这也构成了一个线性表,每个成绩就是一个数据元素,按照学生的学号或者记录顺序排列。
二、线性表的定义与特点
2.1 定义
线性表是由相同数据类型的n(n≥0)个数据元素组成的有限序列。当 n = 0 时,线性表为空表;当 n > 0 时,记为\(L = (a_1, a_2, ..., a_i, a_{i + 1}, ..., a_n)\) ,其中\(a_1\)是表头元素,它没有直接前驱;\(a_n\)是表尾元素,它没有直接后继;对于其他元素\(a_i\)(\(2\leq i\leq n - 1\)) ,都有且仅有一个直接前驱\(a_{i - 1}\)和一个直接后继\(a_{i + 1}\) 。
2.2 特点
元素个数有限:线性表中数据元素的个数 n 是有限的,这符合计算机处理对象的实际情况。在实际应用中,无论是存储学生成绩、员工信息还是其他数据,数据量总是有限的。就像一个班级的学生人数是有限的,即使是规模很大的学校,其学生总数也是可以统计的有限数量,不可能无穷无尽。
逻辑顺序性:元素之间存在着明确的顺序关系,除了表头元素无前驱,表尾元素无后继外,其他每个元素都有唯一的直接前驱和直接后继。以一个公司员工的打卡顺序为例,第一个打卡的员工没有前驱,最后一个打卡的员工没有后继,而中间打卡的员工,他们的打卡顺序就决定了前驱和后继关系,排在前面打卡的同事就是直接前驱,后面打卡的就是直接后继。
元素单一性:每个元素都是单个的数据元素,而不是多个数据元素的组合。例如,在一个存储整数的线性表中,每个元素都是一个单独的整数,而不是一组整数。就好比一篮子苹果,每个苹果都是一个独立的个体,不存在半个苹果或者两个苹果粘在一起算一个元素的情况。
数据类型相同:线性表中所有元素的数据类型是一致的。如果是存储整数的线性表,所有元素都必须是整数;如果是存储字符串的线性表,所有元素都得是字符串。比如一个班级学生的年龄记录,年龄都是整数类型,不可能混入字符串或者其他类型的数据 。
抽象性:线性表只关注元素之间的逻辑关系,而不关心元素的具体内容和实际意义。无论线性表中存储的是数字、字符还是其他复杂的数据结构,我们在讨论线性表时,主要考虑的是元素的顺序、插入、删除、查找等操作,而不涉及元素所代表的具体业务含义。就像一列火车,我们在研究车厢的排列顺序和车厢的添加、移除等操作时,不用关心每个车厢里装的是什么货物。
三、线性表的基本操作
了解了线性表的定义和特点后,我们来看看它都有哪些基本操作,这些操作就像是我们对一个列表进行的各种常见操作,比如添加元素、删除元素、查找元素等 。下面我们以 C 语言为例,来介绍这些基本操作。
3.1 初始化
初始化操作是创建一个空的线性表。在顺序表中,我们需要分配内存空间来存储线性表的元素,并初始化一些相关的变量。比如下面这段代码,InitList函数通过为L.data分配内存,设置L.length为 0,完成了线性表的初始化。
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SqList;
void InitList(SqList *L) {
L->length = 0;
}
这个过程就像是准备一个空的书架,虽然书架还没有放书(没有数据元素),但是已经为放书做好了准备(分配了内存空间)。
3.2 求表长
求表长操作是返回线性表中数据元素的个数。在顺序表中,我们可以直接通过length变量获取表长,因为length变量始终记录着线性表中元素的个数。
int Length(SqList L) {
return L.length;
}
这就好比数一数书架上放了多少本书,书架上的书的数量就是线性表的长度。
3.3 查找操作
查找操作分为按值查找和按位查找。
3.3.1 按值查找
按值查找是在表中查找具有给定关键字值的元素。下面的LocateElem函数,它从线性表的第一个元素开始,逐个比较元素的值与给定值e是否相等,如果找到相等的元素,就返回该元素在表中的位置;如果遍历完整个表都没有找到,就返回 0。
int LocateElem(SqList L, int e) {
for (int i = 0; i < L.length; i++) {
if (L.data[i] == e) {
return i + 1;
}
}
return 0;
}
就好像在书架上找一本特定书名的书,从第一本书开始找,找到这本书时,就知道它在书架上的位置(对应线性表中的位置),如果找遍整个书架都没有找到,就说明这本书不在这个书架上(返回 0 表示没找到) 。
3.3.2 按位查找
按位查找是获取表中第i个位置的元素的值。在顺序表中,由于元素是连续存储的,我们可以直接通过数组下标i - 1来获取第i个位置的元素值。
int GetElem(SqList L, int i) {
if (i < 1 || i > L.length) {
// 处理越界情况
return -1;
}
return L.data[i - 1];
}
这就如同从书架上指定位置拿一本书,直接根据书架上的位置编号(对应线性表中的位序),就能拿到相应位置的书(对应线性表中的元素值) 。
3.4 插入操作
插入操作是在表中的第i个位置上插入指定元素e。在顺序表中,插入操作需要将第i个位置及之后的元素向后移动一位,然后将新元素插入到第i个位置。如果插入位置超过了表长或者小于 1,就会提示插入位置不合法。
int ListInsert(SqList *L, int i, int e) {
if (i < 1 || i > L->length + 1) {
return 0;
}
if (L->length >= MAXSIZE) {
return 0;
}
for (int j = L->length; j >= i; j--) {
L->data[j] = L->data[j - 1];
}
L->data[i - 1] = e;
L->length++;
return 1;
}
这就好比在书架上的某个位置插入一本新书,需要先把这个位置及后面的书依次往后挪一格,然后把新书放在空出来的位置上。
3.5 删除操作
删除操作是删除表中第i个位置的元素,并用e返回删除元素的值。在顺序表中,删除操作需要将第i个位置之后的元素向前移动一位,同时表长减 1。如果删除位置超过了表长或者小于 1,就会提示删除位置不合法。
int ListDelete(SqList *L, int i, int *e) {
if (i < 1 || i > L->length) {
return 0;
}
*e = L->data[i - 1];
for (int j = i; j < L->length; j++) {
L->data[j - 1] = L->data[j];
}
L->length--;
return 1;
}
这就像是从书架上拿走一本书,拿走后需要把后面的书依次往前挪一格,同时书架上的书的总数也减少了一本。
3.6 判空与销毁
判空操作是判断线性表是否为空表,如果为空表返回true,否则返回false 。在顺序表中,我们可以通过判断length是否为 0 来确定表是否为空。
int Empty(SqList L) {
return L.length == 0;
}
销毁操作是销毁线性表,并释放线性表所占用的内存空间。在顺序表中,如果是动态分配的内存,需要使用free函数释放内存。
void DestroyList(SqList *L) {
// 如果是动态分配的内存,需要释放
L->length = 0;
}
判空就像是看看书架上有没有书,而销毁操作则像是把书架拆除(释放内存),让书架不存在了。
四、线性表的存储结构
线性表在计算机中主要有两种存储结构,分别是顺序存储结构和链式存储结构,它们各有特点,适用于不同的场景 。就好比我们存放物品,有的物品适合放在一排整齐的柜子里(顺序存储),有的物品则适合用挂钩一个个挂起来,通过挂钩之间的连接来找到对应的物品(链式存储)。下面我们来详细了解一下这两种存储结构。
4.1 顺序表
4.1.1 定义与实现
顺序表是线性表的顺序存储结构,它把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,用一段地址连续的存储单元依次存储线性表中的数据元素 。简单来说,就是像排队一样,数据元素一个挨着一个紧密排列,它们在内存中的位置也是连续的。在 C 语言中,通常使用数组来实现顺序表。下面是顺序表的结构定义代码:
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SqList;
在这段代码中,SqList表示顺序表的类型,data是一个数组,用来存储数据元素,MAXSIZE定义了数组的最大容量,也就是顺序表能存储的最多元素个数,length记录了当前顺序表中实际存储的元素个数 。
4.1.2 特点
随机访问效率高:由于顺序表中的元素在内存中是连续存储的,所以可以通过数组下标直接访问到任何一个元素,时间复杂度为\(O(1)\) 。这就好比书架上的书按照编号依次排列,我们要找某一本特定编号的书时,直接根据编号就能快速找到它在书架上的位置,而不需要一本本去查找 。
存储密度高:每个节点只存储数据元素,没有额外的指针等开销,所以存储密度高,空间利用率高。这就像一个装满货物的集装箱,没有多余的空间浪费,所有空间都用来存放货物 。
插入和删除操作效率低:在顺序表中插入和删除元素时,需要移动大量元素。比如在表头插入一个元素,需要将后面所有元素都向后移动一位;在表尾插入元素相对简单,不需要移动其他元素(前提是顺序表未满)。删除操作同理,在表头删除元素需要将后面的元素都向前移动一位,在表尾删除元素则只需将表长减 1 。插入和删除操作的时间复杂度在最坏情况下为\(O(n)\),平均时间复杂度也为\(O(n)\) 。这就好比在一排紧密排列的书架中插入或拿走一本书,为了保持书架的顺序,需要移动很多本书 。
扩容不方便:如果顺序表的初始容量设置得太小,当数据量增加时,可能需要进行扩容操作。在 C 语言中,动态顺序表一般通过realloc函数来重新分配内存,但扩容操作涉及到数据的复制,时间复杂度较高 。而且如果内存中没有足够的连续空间,扩容还可能失败 。这就像一个固定大小的房间,当物品越来越多时,想要扩大房间面积不是一件容易的事,不仅要找更大的空间,还得把原来的物品搬到新空间里 。
4.2 链表
4.2.1 定义与实现
链表是线性表的链式存储结构,它的数据元素在物理存储位置上是随机的、非连续的,通过指针将各个节点连接起来,从而体现元素之间的逻辑顺序 。链表中的每个节点包含数据域和指针域,数据域用来存储数据元素,指针域用来存放下一个节点的地址 。下面是单链表节点的定义代码:
typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct Node *LinkList;
在这段代码中,Node是链表节点的结构体类型,data是数据域,next是指针域,它指向Node类型的结构体,即指向下一个节点 。LinkList是指向Node类型结构体的指针,通常用来表示链表的头指针 。
4.2.2 特点
插入和删除操作简单:在链表中插入和删除节点时,只需要修改指针的指向,不需要移动大量元素,时间复杂度为\(O(1)\)(前提是已经找到了要插入或删除节点的位置) 。比如在链表中插入一个新节点,只需要找到插入位置的前一个节点,然后修改前一个节点的指针,让它指向新节点,新节点的指针再指向原来前一个节点的下一个节点即可 。删除节点同理,修改前一个节点的指针,让它跳过要删除的节点,直接指向下一个节点 。这就好比在一串用挂钩挂起来的物品中,插入或拿走一个物品,只需要调整挂钩之间的连接关系,不需要移动其他物品 。
查找效率低:由于链表的存储空间是不连续的,不能像顺序表那样通过下标直接访问元素,所以查找元素时需要从链表的头节点开始,逐个遍历节点,直到找到目标元素,时间复杂度为\(O(n)\) 。这就像在一个杂乱无章的仓库里找一件物品,没有固定的位置顺序,只能一个地方一个地方地去找 。
存储空间利用率低:每个节点除了存储数据元素外,还需要额外存储一个指针,用于指向下一个节点,所以相比于顺序表,链表的存储空间利用率较低 。这就好比每个物品除了本身占用的空间外,还需要额外占用一些空间来存放连接下一个物品的挂钩 。
动态性好:链表不需要预先分配固定大小的空间,可以根据实际需求动态地申请和释放内存空间,适合存储数据量不确定或经常变化的数据 。这就像一个可以随时扩建或缩小的仓库,根据物品的多少来调整仓库的大小 。
4.2.3 头结点与头指针
头结点和头指针是链表中两个重要的概念,它们既有联系又有区别 。
头指针:头指针是指向链表中第一个结点的指针变量。如果链表有头结点,那么头指针就是指向头结点的指针;如果链表没有头结点,头指针就直接指向首元结点(即第一个数据元素所在的节点) 。头指针具有标识作用,通过头指针可以找到整个链表,无论链表是否为空,头指针均不为空,它是链表的必要元素 。这就好比一把钥匙,通过它可以打开链表这个 “大门”,找到链表中的所有元素 。
头结点:头结点是在链表的第一个元素结点之前增设的一个节点,它的数据域一般不存放数据(但也可以用来存放链表的长度等附加信息) 。头结点的存在主要是为了操作的统一和方便,有了头结点,在第一元素结点前插入结点和删除第一结点的操作与其他结点的操作就统一了 。比如在没有头结点的链表中,插入或删除第一个节点时,需要单独处理头指针的变化;而有了头结点后,插入或删除第一个节点和其他节点的操作方式相同,都只需要修改指针指向 。头结点不一定是链表的必要元素,有些链表可以不带头结点 。这就像在一串钥匙前面加了一个没有实际用途的装饰扣,虽然它本身不代表任何具体的钥匙,但它可以让整串钥匙的管理和操作更加方便 。
五、线性表的应用案例
5.1 一元多项式运算
在数学中,一元多项式是非常常见的数学表达式,比如\(3x^2 + 2x + 1\) 。我们可以用线性表来表示一元多项式,将每一项的系数和指数看作一个数据元素,存放在线性表中。例如,对于多项式\(3x^2 + 2x + 1\) ,可以用线性表\(((3, 2), (2, 1), (1, 0))\)来表示 ,其中每个括号内的第一个数是系数,第二个数是指数 。
以两个多项式相加为例,假设我们有多项式\(P(x) = 3x^2 + 2x + 1\)和\(Q(x) = 2x^2 + 3x + 4\) ,用线性表表示分别为\(P = ((3, 2), (2, 1), (1, 0))\)和\(Q = ((2, 2), (3, 1), (4, 0))\) 。多项式相加的过程,其实就是合并同类项的过程,也就是对两个线性表中的元素进行比较和合并 。
5.1.1 顺序表实现多项式相加
#include <stdio.h>
#include <stdlib.h>
#define MAXSIZE 100
typedef struct {
int coef;
int exp;
} PolyTerm;
typedef struct {
PolyTerm data[MAXSIZE];
int length;
} SqPolyList;
// 初始化多项式
void InitPoly(SqPolyList *L) {
L->length = 0;
}
// 插入多项式项
int InsertPoly(SqPolyList *L, int coef, int exp) {
if (L->length >= MAXSIZE) {
return 0;
}
int i;
for (i = L->length - 1; i >= 0; i--) {
if (L->data[i].exp < exp) {
break;
} else if (L->data[i].exp == exp) {
L->data[i].coef += coef;
return 1;
}
}
for (int j = L->length