第2章 线性表
本章内容
本章主要介绍线性表的相关知识,线性表主要包括顺序表和链表两种。我们将详细讨论这两种线性表的建立与增删改查的操作,同时分析它们的特点、相应时间复杂度以及应用。正常来说,数组下标从0开始访问。但为了解释清楚,我们之后写代码的时候默认从1位置开始访问
2.1 线性表的基本概念
线性表是具有相同特性数据元素的一个有限序列,该序列中元素个数叫做线性表的长度。我们想象一堆排队上车的人,这就是一个线性表,队伍中的都是人,这体现了相同特性元素;这些人可以按照高矮来排,体现其有序性。
线性表的逻辑特性:只有一个表头元素,只有一个表尾元素。表头元素没有前驱,表尾元素没有后继。除表头和表尾元素外,其他元素都只有一个直接前驱,一个直接后继。
线性表的存储结构:顺序表与链表
顺序表:顺序存储,内存中一块连续的存储空间,支持随机访问,静态分配,如数组。
链表:链式存储,分散存储,每个节点不仅存储元素信息还有元素之间的位置的逻辑关系、不支持随机访问、存储空间利用率较低,动态分配。
所谓的随机访问与不随机访问,我们可以这样考虑:
用QQ音乐播放电脑中本地歌曲,我们可以选择听任意一首,这就是随机访问;
用传统的磁带播放歌曲,我们只能按照磁带录音的内容去聆听,这就是不随机访问。
2.2 顺序表
前文已讲到,顺序表可以理解为内存中开辟一块连续的区域用于存放数据,我们根据它们连续的下标进行随机访问。
2.2.1 顺序表的定义与建立
#define maxSize 100 //定义顺序表最大长度为100
typedef struct Sqlist {
int data[maxSize]; //存放元素的数组
int length; //顺序表长度
}Sqlist;
2.2.2 顺序表的基本操作
【操作1】按照元素值查找
/*
* @fun return the position whose value matches x, return 0 if failed.
* @param L the list to be search
* @param x the value to be search
*/
int locateElem(Sqlist L, int x) {
int i;
for (i = 1; i <= L.length; i++) {
if (x == L.data[i]) {
return i; //找到,返回该位置
}
}
return 0; //如果表中不存在则返回0
}
【操作2】在第P个位置处插入新元素
/*
* @fun insert x into L at the position p, return 1 success, 0 failed
* @param L the list to be inserted, we should use call by reference
* @param p the position to insert
* @param x the element to be insert
*/
void listInsert(Sqlist &L, int p, int x) {
//如果位置p是非法的下标(小于1或者大于表长度),或者表已经满了,返回0,表示插入失败
if (p < 1 || p > L.length || L.length == maxSize - 1) {
return 0;
}
int i;
//依次向右挪动一位
for (i = L.length; i >= p; --i) {
L.data[i+1] = L.data[i];
}
//在p位置赋值为x
L.data[p] = x;
//表长加一
++(L.length);
//返回1代表成功插入
return 1;
}
【操作3】在第P个位置删除元素,并将该元素赋值给x
/*
* @fun delete the value at position p from L, and pass it to x.
* return 1 if success, 0 if failed
* @param L the list to be inserted, we should use call by reference
* @param p the position to delete
* @param x the element to be pass, we should use call by reference
*/
int listDelete(Sqlist &L, int p, int &x) {
int i;
//p的值非法,直接失败
if (p < 1 || P > L.length) {
return 0;
}
x = L.data[p];
for (i = p; i < L.length; ++i) {
//右边向左边移动一位
L.data[i] = L.data[i+1];
}
--(L.length);
return 1;
}
【分析】
总体来说,顺序表的增、删、改、查还是很好操作的,基本是就是对数组进行移动操作。我们在代码实现的时候注意判断边界情况即可。
对于n个元素的顺序表,插入或者删除一个元素所进行的平均移动次数为:
因此,顺序表的插入或者删除一个元素的时间复杂度为O(n)
顺序表可以直接通过数组下标访问元素,所以,访问一个元素的时间复杂度为O(1)
2.3 链表
链表是链式存储,每一个节点不仅存储元素的信息,还有存储节点之间的逻辑关系。
2.3.1 链表的结构
链表这种存储结构由很多个节点构成,每个节点包括存储元素的数据域和存储可直接访问其他节点地址的指针域。
2.3.2 链表的分类
我们访问链表的时候通常会获得指向第一个节点的指针,根据此指针指向的节点,我们才可以访问接下来相连的节点。有些时候,第一个节点只是访问的开始节点,不存储任何信息,我们称之为头节点(Head)。也有些情况,第一个节点就存储了元素信息。
所以,按照是否有头节点分为:带头节点的链表和不带头节点的链表
有的时候,有些链表的节点是有两个指向其他节点的指针域的,比如:每个节点既可以访问后一个节点又可以访问前一个节点、链表头节点可以直接访问链表尾节点等等。所以我们又可以分类为:单链表、循环单链表、双链表循环双链表以及静态链表。
【判断链表为空的条件】
单链表、双链表:
*带头节点:*head->next == null
*不带头节点:*head == null
循环单链表:
*带头节点:*head == head->next
*不带头节点:*head == null
循环双链表:
*带头节点:*head == head->next, head == head->prior
*不带头节点:*head == null
2.3.3 链表的建立算法
通常情况下,链表建立有两种常见的算法:头插法和尾插法,现在我们将仔细分析两种方法和建立过程。
【定义节点】
typedef struct LNode {
int data;
struct LNode* next;
} LNode;
【头插法】
/*
* @fun create list to store array a, using front method
* @param C the given head node of the list to be established, using call by reference
* @param a[] the array contains element to be stored in the list
* @param n the number of the elements to be stored in the list
*/
void createListF(LNode *&C, int a[], int n) {
LNode *S;
int i;
C = (LNode*)malloc(sizeof(LNode));
C->next = null;
for (i = 1; i <= n; ++i) {
S = (LNode*)malloc(sizeof(LNode));
S->data = a[i];
S->next = c->next;
c->next = S;
}
}
假设链表原有一个节点,存储元素1,若头插法依次插入2、3、4、5,则最后链表存储顺序:5、4、3、2、1
【尾插法】
/*
* @fun create list to store array a, using tail method
* @param C the given head node of the list to be established, using call by reference
* @param a[] the array contains element to be stored in the list
* @param b the number of the elements to be stored in the list
*/
void createListR(LNode *&C, int a[], int n) {
LNode *S, *r;
int i;
C = (LNode*)malloc(sizeof(LNode));
C->next = null;
r = C;
for (i = 1; i <= n; ++i) {
S = (LNode*)malloc(sizeof(LNode));
S->data = a[i];
r->next = S;
r = r->next;
}
r->next = null;
}
假设链表原有一个节点,存储元素1,若头插法依次插入2、3、4、5,则最后链表存储顺序:1、2、3、4、5
2.3.4 链表的基本操作
同样地,我们这里实现并分析一下链表的插入、删除等操作。
【操作1】查找带头节点的链表C中是否存在一个值为x的节点,若存在则返回1,否则返回0
int searchElem(LNode *C, int x) {
LNode *P;
P = C;
while(P->next != null) {
if (P->next->data == x) {
break;;
}
P = P->next;
}
if (P->next == null) {
return 0;
}
return 1;
}
【操作2】节点插入操作,在P指向的节点之后插入节点S
S->next = P->next;
P->next = S;
不能颠倒顺序,如果顺序颠倒,S连接P后,失去了P直接的后驱节点地址(P->next指针原本存储了P的直接后继节点地址,在没有被转存到其他地方的情况下被S覆盖)
【操作3】节点删除操作删除P指向的下一个节点
LNode *q = p->next;
p->next = p->next->next;
free(q);//释放内存空间
【操作4】综合运用:A和B是两个有序递增的单链表(带头节点)。设计一个算法,将A和B归并成一个按元素值非递减有序的链表C。
分析:使用尾插法,每次比较A、B哪个元素较小,插入C中
/*
* @fun merge A, B to C
* @param C the final list
* @param A list A
* @param B list B
*/
void merge(LNode *&C, LNode *A, LNode *B) {
LNode *p = A->next;
LNode *q = B->next;
LNode *r;
C = A;
C->next = null;
r = C;
free(B);
while(p != null && q != null) {
if (p->data <= q->data) {
r->next = p;
p = p->next;
r = r->next;
}
else {
r->next = q;
q = q->next;
r = r->next;
}
}
r->next = null;
if (p != null) {
r->next = p;
}
if (q != null) {
r->next = q;
}
}
【操作5】双链表插入操作:P后插入S
//定义双链表的节点
typedef struct DLNode {
int data;
DLNode* next;
DLNode* prior;
} DLNode;
S->next = P->next;
S->prior = P;
P->next = S;
S->next->prior = S;
【分析】
总体来说,链表的增、删、改、查是利用指针进行操作。我们在代码实现的时候注意不要随便调换代码顺序,否则有可能会遗失节点地址。
对于n个元素的顺序表,在指定节点后插入或者删除一个元素(直接给出指向链表中要操作的节点地址的指针,即不需要从头节点遍历到该节点的情况),时间复杂度为O(1)
链表需要从头开始才能访问特定节点,所以,访问一个元素的时间复杂度为O(n)