线性表的链式表示和实现
上节提到,由于顺序表的特点是逻辑关系上相邻的两个元素在物理位置上也相邻,因此可以随机存取表中任一元素。然而,这也导致了顺序表在执行插入或删除操作时,需要移动大量元素。本节来讨论线性表的另一种存储方式——链式存储结构。
1 线性表的链式表示
1.1 单链表的定义 Single Linked List
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这些存储单元可以连续也可以不连续。因此,为了表示每个数据元素与其后继的逻辑关系,除了存储其信息本身外,还需要存储一个指示其后继的信息(即其后继的存储地址)。这两部分信息共同构成一个数据元素的存储映像,称为结点(Node)。其中,存储数据元素信息的域称为数据域;存储后继存储位置的域称为指针域,指针域中存储的信息称为指针或链。
因为此类链表的每个结点中只包含一个指针域,故又称为单链表或线性链表。
1.2 单链表的特点
换句话说,单链表中的数据元素之间的逻辑关系是由结点中的指针指示的,所以逻辑上相邻的两个元素不需要在物理地址上也相邻,这种存储结构为非顺序映像或链式映像。
在单链表中,只要知道前一个元素,通过指针就可以找到后一个元素。但是链表中的第一个数据元素就不是很好表达,因此我们需要引入“头结点”(如下图中的 head)。头结点的作用是指向表中的第一个元素结点,头结点的数据域中可以不存储任何信息,也可以存储表长等描述线性表的附加信息。若线性表为空表,则头结点的指针域为空。
头指针和头结点
上图中的单链表在第一个数据结点(即保存 a 1 a_1 a1 的结点)之前,还有一个和普通结点结构一致的头结点,包含数据域和指针域。还有一些链表没有完整的头结点,只有一个指针指向第一个数据结点,这个指针被称为头指针(可以看成是只包含指针域的头结点)。
这两种表示方法没有区别,不管有没有头结点,头指针都始终指向链表的第一个结点。但是为了操作方便,一般都加上头结点。
总结
- 单链表不需要大量连续的存储单元,可以更好地利用非连续内存。
- 同时,由于单链表除了存储数据元素本身,还存储了指向下一个元素的指针,因此也需要浪费更多的存储空间。
- 由于单链表的各元素离散地分布在存储空间中,所以只能顺序存取。如果要获取第 n n n 个元素,必须先遍历前 n − 1 n-1 n−1 个元素,因此单链表的查找操作效率很低。
2 单链表的实现
2.1 结点的定义
要实现链表,首先要定义结点,按照定义,每一个结点包含存储数据的数据域和存储后继位置的指针域:
/********** 单链表的定义 **********/
typedef int elemType;
typedef struct LNode{
elemType data; // 数据域
struct LNode *next; // 指针域
} LNode, *LinkedList;
其中我们给 LNode 定义了一个别名 LinkedList,这个类型别名是一个指针类型。实际上使用 LNode 和 LinkedList 会得到一样的效果,但是使用合适的别名可以让我们更好地理解代码。当我们要使用的变量的功能侧重于标识位置时,就声明这个对象为 LinkedList ;当我们要使用的变量是结点时,就声明这个对象为 LNode 。例如:
// 头指针,尾指针和临时指针
LinkedList head, tail, tmp;
// 新结点n
LNode *n;
2.2 主要操作的实现
2.2.1 头插法建立单链表
单链表的生成可以有两种方式,从头部插入和从尾部插入。其中头插法从空表开始,每生成一个新结点,就插入当前链表的表头,即头结点之后。
/*
* Function: 头插法建立单链表
* ----------------------------
* 从空表开始生成新结点,并将读取到的数据存放到新结点的数据域中,
* 然后将新结点插入到头结点之后。
*/
LinkedList headInsert(LinkedList &L){
LNode *n; // 先声明一个结点指针n,用来指向将来生成的新结点
elemType e; // 元素e,用来接收输入的元素数据
L = new LNode; // 创建头结点
L->next = NULL; // 初始为空链表
scanf("%d", &e);
while(e!=-1){
n = new LNode; // 创建新结点
n->data = e;
n->next = L->next; // 将新结点插入头结点之后
L->next = n;
scanf("%d", &e);
}
return L; // 返回头结点
}
时间复杂度分析
假设采用头插法建立单链表,一共插入
n
n
n 个结点。对于单次插入操作来说,插入位置固定,因此操作时间复杂度和链表总长无关,时间复杂度为
O
(
1
)
O(1)
O(1) 。总共插入
n
n
n 个结点,则总的时间复杂度为
O
(
n
)
O(n)
O(n) 。
2.2.2 尾插法建立单链表
头插法每一次都是在头结点之后插入新结点,因此会导致元素的输入顺序和在链表中的存储顺序相反,有些不符合逻辑,尾插法可解决这一问题。尾插法是从空表开始,每一次将新结点插入到表尾。但是不同于头结点,没有一个特殊的指针指向表尾,所以我们需要建立一个尾指针并让它始终指向表尾。
/*
* Function: 尾插法建立单链表
* ----------------------------
* 从空表开始生成新结点,并将读取到的数据存放到新结点的数据域中,
* 然后将新结点插入到链表的表尾。
*/
LinkedList tailInsert(LinkedList &L){
LNode *n; // 声明新的结点n
LinkedList t; // 声明尾指针t
elemType e;
L = new LNode; // 创建头结点
L->next = NULL;
t = L; // 将指针t指向表尾,此时链表为空,表尾就是头结点
scanf("%d", &e);
while (e!=-1){
n = new LNode; // 创建新结点
n->data = e;
t->next = n; // 将新结点n加入链表
t = n; // 将尾指针指向新结点n
scanf("%d", &e);
}
t->next = NULL; // 将新的链表的尾结点指针置空
return L;
}
时间复杂度分析
由于我们加入了尾指针,每一次插入操作只需要将新结点插入尾结点之后,单次插入操作的时间复杂度依然和链表总长度无关,时间复杂度为
O
(
1
)
O(1)
O(1) 。假设总共插入
n
n
n 个结点,那么总的时间复杂度为
O
(
n
)
O(n)
O(n) 。
2.2.3 按位查找结点操作
在单链表中查找第 i i i 个结点,若找到则返回该结点,否则返回 NULL 。
/*
* Function: 按位查找结点
* ----------------------------
* 在单链表中从第一个结点出发,直到找到第i个结点为止,
* 否则返回最后一个结点指针域NULL。
*/
LNode *getElem(LinkedList L, int i){
if (i<0){ // 检查序号值是否合法
return NULL;
}
LinkedList tmp=L; // 创建临时指针,并指向头结点
for (int j=0;j<i;j++){
if (!tmp->next){ // 如果临时指针的指针域为空, 则代表
return NULL; // 临时指针已位于表尾且仍未查到,返回NULL
}
tmp = tmp->next;
}
return tmp; // 如果for循环正常退出,
// 则代表找到,返回临时指针
}
时间复杂度分析
基本操作为判断结点是否为目标结点。
- 最优情况:目标结点在表头(即序号值 i = 1 i=1 i=1),只需要判断 1 次,因此时间复杂度为 O ( 1 ) O(1) O(1) 。
- 最差情况:目标结点在表尾(即序号值 i = n i = n i=n),共需要判断 n n n 次,时间复杂度为 O ( n ) O(n) O(n) 。
- 平均情况:令
p
i
p_i
pi 为目标元素在第
i
i
i 个位置上的概率,假设目标元素出现在任意一个位置上的概率是相等的,那么
p
i
=
1
(
n
)
p_i = \frac{1}{(n)}
pi=(n)1。在长度为
n
n
n 的链表中查找一个元素时,所需移动元素的平均次数为
∑ i = 1 n p i ∗ i = n + 1 2 \sum_{i=1}^{n} p_i*i = \frac{n+1}{2} i=1∑npi∗i=2n+1
所以平均时间复杂度为 O ( n ) O(n) O(n) 。
2.2.4 按值查找结点操作
在单链表中查找第一个数据域等于给定值 e e e 的结点并返回,若未找到则返回 NULL 。
/*
* Function: 按值查找结点
* ----------------------------
* 在单链表中从第一个结点出发,直到找到某个结点的数据域
* 等于目标值为止,否则返回返回NULL。
*/
LNode *locateElem(LinkedList L, elemType e, int &count){
LinkedList tmp=L->next; // 创建临时指针,指向头结点指针域
count=1; // 记录查找次数
while (tmp!=NULL && tmp->data!=e){ // 如果临时指针不为空且还未找到目标值,
tmp = tmp->next; // 则继续while循环
count++;
}
return tmp;
}
时间复杂度分析
和按位查找一致。
2.2.5 插入结点操作
在指定位置 i i i 插入新结点,如果 i i i 的值不合法则返回 NULL 。
插入结点的操作可分为几步:
- 确认 i i i 的值是否合法,如不合法返回 NULL 。
- 找到插入位置的前驱结点(即 i − 1 i-1 i−1)。在单链表中前驱结点记录了对后继结点的关系,所以想要对某个结点修改,就必须找到它的前驱结点。
- 在位置 i i i 插入新结点。
因为第 1 步和第 2 步恰好是按位查找结点操作所做的事,所以可以借用按位查找来实现插入操作。
/*
* Function: 插入操作
* ----------------------------
* 在指定位置i插入结点。
* 借用getElem()方法可以将整个过程简化为三步:
* 1. tmp = getElem(L, i-1);
* 2. n->next = tmp->next;
* 3. tmp->next = n;
*/
LNode *listInsert(LinkedList &L, elemType e, int i){
LinkedList tmp = getElem(L, i-1); // 首先使用按位查找检测第i-1个结点是否存在,
if (tmp==NULL){ // 即检查插入位置的前驱结点是否存在
return NULL;
}
LNode *n = new LNode; // 创建新结点
n->data = e;
n->next = tmp->next; // 将新结点插入第i-1个结点之后
tmp->next = n;
return tmp;
}
时间复杂度分析
根据前面分析,插入操作共分三步:
- 确认 i i i 的值是否合法。
- 找到插入位置的前驱结点(即 i − 1 i-1 i−1)。
- 在位置 i i i 插入新结点。
结合按位查找可简化为:
- 查找第 i − 1 i-1 i−1 个结点。
- 在位置 i i i 插入新结点。
因为按位查找操作的时间复杂度为 O ( n ) O(n) O(n) ,单次插入操作的复杂度为 O ( 1 ) O(1) O(1) ,所以总的时间复杂度为 O ( m a x { O ( n ) , O ( 1 ) } ) = O ( n ) O(max\{O(n), O(1)\}) = O(n) O(max{O(n),O(1)})=O(n) 。
2.2.5 删除结点操作
删除单链表的第 i i i 个结点,如果 i i i 的值不合法则返回 NULL 。分析同插入操作一致,重点是找到删除结点的前驱结点。
/*
* Function: 删除操作
* ----------------------------
* 删除在指定位置i的结点。
*/
elemType listDelete(LinkedList &L, int i){
LinkedList tmp = getElem(L, i-1); // 检查删除位置的前驱结点是否存在
if (tmp==NULL || tmp->next==NULL){ // tmp不存在或者tmp就是最后一个结点,
return -1; // 则不需要执行删除操作,直接返回
}
LinkedList q = tmp->next; // 指针q指向待删除结点
elemType e = q->data; // e用来保存删除结点的值
tmp->next = q->next; // 断开q其他结点在链表中连接关系
delete q; // 释放结点的存储空间
return e;
}
时间复杂度分析
与插入操作一致。
3 其他常用操作
3.1 反转链表操作
将链表所有元素的逻辑关系反转(即前驱变为后继)。
3.1.1 遍历法实现反转操作
基本思路是遍历原链表,将每一个遍历的元素指针反转。但在过程中可能会出现链表断裂的问题,如下图所示:
当第二个结点指向改变时,链表在第二、三结点处断裂,无法再获取第三、四结点。因此,我们需要用一个变量来保存当前结点的后继结点。一般需要三个指针,一个指向当前结点的前驱结点,定义为 pre ;一个指向当前结点,定义为 cur ;一个指向当前结点的后继结点,定义为 next 。
- 首先将 pre 和 next 初始化为 null, 将 cur 指向第一个结点(头结点的下一位)
- 保存 cur 的下一结点至 next
- 将 cur 指针反转,即指向前一位(pre)
- 将 pre 向后移动一位
- 将 cur 向后移动一位
- 重复 2-5步,直到 cur = null
- 最后返回 pre,需要注意这时的pre并不是头结点,还需要额外将 L->next 指向 pre,才是一个完整的反转链表
/*
* Function: 反转链表操作
* ----------------------------
* 将链表的所有元素逻辑关系反转。
*/
LinkedList listReverse(LinkedList &L){
// 如果链表为空,或是只有一个元素,则直接返回头结点
if (L->next==NULL || L->next->next==NULL){
return L;
}
// 第1步
LinkedList pre=NULL, next=NULL;
LinkedList cur=L->next;
while (cur!=NULL){
next = cur->next; // 第2步
cur->next = pre; // 第3步
pre = cur; // 第4步
cur = next; // 第5步
}
L->next = pre; // 将头结点指向pre
return L;
}
时间复杂度分析
整个反转过程可看成是遍历
n
n
n 个元素以及依次反转单个元素。反转单个元素的时间复杂度为
O
(
1
)
O(1)
O(1),遍历的时间复杂度为
O
(
n
)
O(n)
O(n) ,因此总的时间复杂度为
O
(
n
)
O(n)
O(n) 。
3.1.2 递归法实现反转操作
待补充。
3.2 合并有序链表操作
将有两个有序链表合并为一个有序链表。
基本思路是分别用两个指针遍历两个链表的元素,比较指针所指向结点的元素大小,将较小的先加入合并链表。
/*
* Function: 合并链表操作
* ----------------------------
* 将两个链表的元素按大小关系
*/
LinkedList listMerge(LinkedList L1, LinkedList L2){
LinkedList LM = new LNode; // 新的合并链表的头结点
LM->next = NULL;
LinkedList tmp = LM;
L1 = L1->next;
L2 = L2->next;
// 如果L1和L2都为空,那么直接返回空的LM
if (L1==NULL && L2==NULL){
return LM;
}
// 如果L1,L2其一不为空,或是都不为空,则直接开始比较
while (true){
// 当一方没有剩余结点时,退出循环
if (L1==NULL || L2==NULL){
break;
}
// 比较两个链表中的元素大小,较小的先加入合并链表
if (L1->data <= L2->data){
tmp->next = L1;
tmp = tmp->next;
L1 = L1->next;
} else {
tmp->next = L2;
tmp = tmp->next;
L2 = L2->next;
}
}
// 当一方已经完成遍历时,将另一方的剩余结点全部加入合并链表
if (L1==NULL){
tmp->next = L2;
} else {
tmp->next = L1;
}
return LM;
}
上述实现方法会破坏原链表,在确定不会再使用原链表后才可使用。例如:
// L1 = 1 3;
// L2 = 2 4;
LinkedList LM = listMerge(L1, L2);
listPrint(L1);
listPrint(L2);
listPrint(LM);
输出:
L1: 1 2 3 4
L2: 2 3 4
LM: 1 2 3 4
时间复杂度分析
合并有序链表的基本操作是两个链表内元素的比较,假设链表的长度分别为
L
a
La
La 和
L
b
Lb
Lb ,那么总的比较次数最多为
L
a
+
L
b
−
1
La +Lb -1
La+Lb−1 。例如:
a
=
{
1
,
3
,
5
}
,
b
=
{
2
,
4
,
6
}
a = \{1, 3, 5\}, b = \{2, 4, 6\}
a={1,3,5},b={2,4,6} ,总的比较次数为 5 。
因此最差时间复杂度为 O ( L a + L b ) O(La + Lb) O(La+Lb) 。
相关章节
第一节 【绪论】数据结构的基本概念
第二节 【绪论】算法和算法评价
第三节 【线性表】线性表概述
第四节 【线性表】线性表的顺序表示和实现
第五节 【线性表】线性表的链式表示和实现
第六节 【线性表】双向链表、循环链表和静态链表
第七节 【栈和队列】栈
第八节 【栈和队列】栈的应用
第九节 【栈和队列】栈和递归
第十节 【栈和队列】队列
附录
单链表实现的完整代码
/*
* File name: LinkedList.h
* -----------------------
* Using struct to implement LinkedList
*/
#ifndef _SINGLE_LINKED_LIST_h_
#define _SINGLE_LINKED_LIST_h_
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
/********** 单链表的定义 **********/
typedef int elemType;
typedef struct LNode{
elemType data; // 数据域
struct LNode *next; // 指针域
} LNode, *LinkedList;
/********** 基本操作的实现 **********/
/*
* Function: 头插法建立单链表
* ----------------------------
* 从空表开始生成新结点,并将读取到的数据存放到新结点的数据域中,
* 然后将新结点插入到头结点之后。
*/
LinkedList headInsert(LinkedList &L){ // L是一个指针
LNode *n; // 先声明一个结点指针n,用来指向将来生成的新结点
elemType e; // 元素e,用来接收输入的元素数据
L = new LNode; // 创建头结点
L->next = NULL; // 初始为空链表
scanf("%d", &e);
while(e!=-1){
n = new LNode; // 创建新结点
n->data = e;
n->next = L->next; // 将新结点插入头结点之后
L->next = n;
scanf("%d", &e);
}
return L; // 返回头结点
}
/*
* Function: 尾插法建立单链表
* ----------------------------
* 从空表开始生成新结点,并将读取到的数据存放到新结点的数据域中,
* 然后将新结点插入到链表的表尾。
*/
LinkedList tailInsert(LinkedList &L){
LNode *n; // 声明新的结点n
LinkedList t; // 声明尾指针t
elemType e;
L = new LNode; // 创建头结点
L->next = NULL;
t = L; // 将指针t指向表尾,此时链表为空,表尾就是头结点
scanf("%d", &e);
while (e!=-1){
n = new LNode; // 创建新结点
n->data = e;
t->next = n; // 将新结点n加入链表
t = n; // 将尾结点指向新结点n
scanf("%d", &e);
}
t->next = NULL; // 将新的链表的尾结点指针置空
return L;
}
/*
* Function: 判空操作
* ----------------------------
* 判断链表是否为空。
*/
bool listEmpty(LinkedList L){
return !L->next; // 如果只有头结点,则链表为空
}
/*
* Function: 求表长操作
* ----------------------------
* 计算单链表中结点的个数,不包括头结点。
*/
int listLength(LinkedList L){
int count=0; // 用来计算结点个数
LinkedList tmp=L;
while (tmp->next!=NULL){
tmp = tmp->next;
count++;
}
return count;
}
/*
* Function: 按位查找结点
* ----------------------------
* 在单链表中从第一个结点出发,直到找到第i个结点为止,
* 否则返回最后一个结点指针域NULL。
*/
LNode *getElem(LinkedList L, int i){
if (i<0){ // 检查序号值是否合法
return NULL;
}
LinkedList tmp=L; // 创建临时指针,并指向头结点
for (int j=0;j<i;j++){
if (!tmp->next){ // 如果临时指针的指针域为空, 则代表
return NULL; // 临时指针已位于表尾且仍未查到,返回NULL
}
tmp = tmp->next;
}
return tmp; // 如果for循环正常退出,
// 则代表找到,返回临时指针
}
/*
* Function: 按值查找结点
* ----------------------------
* 在单链表中从第一个结点出发,直到找到某个结点的数据域
* 等于目标值为止,否则返回返回NULL。
*/
LNode *locateElem(LinkedList L, elemType e, int &count){
LinkedList tmp=L->next; // 创建临时指针,指向头结点指针域
count=1; // 记录查找次数
while (tmp!=NULL && tmp->data!=e){ // 如果临时指针不为空且还未找到目标值,
tmp = tmp->next; // 则继续while循环
count++;
}
return tmp;
}
/*
* Function: 插入操作
* ----------------------------
* 在指定位置i插入结点。
* 借用getElem()方法可以将整个过程简化为三步:
* 1. tmp = getElem(L, i-1);
* 2. n->next = tmp->next;
* 3. tmp->next = n;
*/
LNode *listInsert(LinkedList &L, elemType e, int i){
LinkedList tmp = getElem(L, i-1); // 首先使用按位查找检测第i-1个结点是否存在,
if (tmp==NULL){ // 即检查插入位置的前驱结点是否存在
return NULL;
}
LNode *n = new LNode; // 创建新结点
n->data = e;
n->next = tmp->next; // 将新结点插入第i-1个结点之后
tmp->next = n;
return tmp;
}
/*
* Function: 删除操作
* ----------------------------
* 删除在指定位置i的结点。
*/
elemType listDelete(LinkedList &L, int i){
LinkedList tmp = getElem(L, i-1); // 检查删除位置的前驱结点是否存在
if (tmp==NULL || tmp->next==NULL){ // tmp不存在或者tmp就是最后一个结点,
return -1; // 则不需要执行删除操作,直接返回
}
LinkedList q = tmp->next; // 指针q指向待删除结点
elemType e = q->data; // e用来保存删除结点的值
tmp->next = q->next; // 断开q其他结点在链表中连接关系
delete q; // 释放结点的存储空间
return e;
}
/*
* Function: 输出操作
* ----------------------------
* 按顺序从头到尾输出单链表的元素
*/
void listPrint(LinkedList L){
LinkedList tmp=L;
while (tmp->next!=NULL){
tmp = tmp->next;
printf("%d ", tmp->data);
}
printf("\n");
}
#endif // _SINGLE_LINKED_LIST_h
单链表检测程序
/*
* File name: LinkedList.cpp
* -----------------------
* Using struct to implement LinkedList
*/
#include "LinkedList.h"
int main() {
LinkedList L; // 头结点
int n, i, res;
elemType e;
LinkedList node;
char helpInfo[] =
"*****************************\n"
"Linked list check: \n"
"\t1-Create linked list by head insertion\n"
"\t2-Create linked list by tail insertion\n"
"\t3-Insert element\n"
"\t4-Delete element\n"
"\t5-Print\n"
"\t6-Empty check\n"
"\t7-Get Length\n"
"\t8-Search by value\n"
"\t9-Search by location\n"
"\t10-Quit\n"
"*****************************\n";
while (n != 10) {
printf(helpInfo);
scanf("%d", &n);
switch (n) {
case 1:
printf("Head insertion:\n");
printf("Enter -1 to quit.\n");
headInsert(L);
break;
case 2:
printf("Tail insertion:\n");
printf("Enter -1 to quit.\n");
tailInsert(L);
break;
case 3:
printf("Please enter the location: \n");
scanf("%d", &i);
printf("Please enter the element: \n");
scanf("%d", &e);
listInsert(L, e, i);
break;
case 4:
printf("Please enter the location: \n");
scanf("%d", &i);
res = listDelete(L, i);
if (res == -1) {
printf("Didn't find the target node.\n");
} else {
printf(
"Target node in location %d with value %d is "
"deleted.\n",
i, res);
}
break;
case 5:
printf("List: ");
listPrint(L);
break;
case 6:
if (listEmpty(L)) {
printf("This list is empty.\n");
} else {
printf("This list is not empty.\n");
}
break;
case 7:
printf("Length of list is: %d\n", listLength(L));
break;
case 8:
printf("Please enter the target value: ");
scanf("%d", &e);
node = locateElem(L, e, i);
if (!node) {
printf("Didn't find the target node.\n");
} else {
printf("The index of target is: %d\n", i);
}
break;
case 9:
printf("Please enter the target index: ");
scanf("%d", &i);
node = getElem(L, i);
if (!node) {
printf("Didn't find the target node.\n");
} else {
printf("The value of target is %d\n", node->data);
}
break;
}
}
return 0;
}