《大话数据结构》笔记——第3章 线性表

文章目录

  • 3.1 开场白
  • 3.2 线性表的定义
  • 3.3 线性表的抽象数据类型
  • 3.4 线性表的顺序存储结构
    • 3.4.1 顺序存储的定义
    • 3.4.2 顺序存储方式
    • 3.4.3 数组长度与线性表长度区别
    • 3.4.4 地址计算方法
  • 3.5 顺序存储结构的插入与删除
    • 3.5.1 获得元素操作
    • 3.5.2 插入操作
    • 3.5.3 删除操作
    • 3.5.4 线性表顺序存储结构的优缺点
  • 3.6 线性表的链式存储结构
    • 3.6.1 顺序存储结构不足的解决办法
    • 3.6.2 线性表链式存储结构定义
    • 3.6.3 头指针与头结点的异同点
    • 3.6.4 线性表链式存储结构代码描述
  • 3.7 单链表的读取
  • 3.8 单链表的插入与删除
    • 3.8.1 单链表的插入
    • 3.8.2 单链表的删除
  • 3.9 单链表的整体创建
    • 3.9.1 头插法建立单链表
    • 3.9.2 尾插法
  • 3.10 单链表的整表删除
  • 3.11 单链表结构与顺序存储结构优缺点
  • 3.12 静态链表
    • 3.12.1 静态链表的插入操作
    • 3.12.2 静态链表的删除操作
    • 3.12.3 静态链表优缺点
  • 3.13 循环链表
  • 3.14 双向链表
  • 3.15 总结回顾
  • 3.16 结尾语

3.1 开场白

3.2 线性表的定义

线性表(List):零个或多个数据元素的有限序列

如果用数学语言来进行定义,可如下:

若将线性表记为(a1, …, ai-1, ai, ai+1, …, an),则表中 ai-1 领先于 ai,ai 领先于 ai+1,称 ai-1 是 ai 的直接前驱元素,ai+1 是 ai 的直接后继元素。当 i=1, 2,…, n-1 时,ai 有且仅有一个直接后继,当 i = 2, 3, …, n 时,ai 有且仅有一个直接前驱。

在这里插入图片描述

所以,线性表元素的个数 n(n>=0) 定义为线性表的长度,当 n = 0 时,称为空表。

3.3 线性表的抽象数据类型

线性表的抽象数据类型定义如下:

ADT 线性表
Data
	线性表的数据集合为{a1,...,ai-1,ai,ai+1,..,an},每个元素的类型均为 DataType。其中,除第一个元素 a1 外,每一个元素有且只有一个直接前驱元素,除了最后一个元素 an 外,每个元素有且只有一个直接后继元素。数据元素之间的关系是一对一的关系。
Operation
	InitList(*L):初始化操作,建立一个空的线性表 L
	ListEmpty(L):若线性表为空,返回 true,否则返回 false
	ClearList(*L):将线性表清空
	GetElem(L,i,*e):将线性表 L 中的第 i 个位置元素值返回给 e
	LocateElem(L,e):在线性表 L 中查找与给定值 e 相等的元素,如果查找成功,返回该元素在表中序号表示成功;否则返回 0 失败表示失败。
	ListInsert(*L,i,e):在线性表的第 i 个位置插入新元素 e
	ListDelete(*L,i,*e):删除线性表第 i 个元素,并用 e 返回其值
	ListLength(L):返回线性表 L 的元素个数
endADT

对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的。对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。

3.4 线性表的顺序存储结构

3.4.1 顺序存储的定义

线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。

3.4.2 顺序存储方式

线性表每个数据元素的类型都相同,所以可以使用一维数组来实现顺序存储结构。

/* 线性表的顺序存储结构 */  
#define MAXSIZE 20 //存储空间初始分配量为 20  
typedef int ElemType; //数据类型为 int  
typedef struct {  
    ElemType data[MAXSIZE]; //数组存储数据元素  
    int length; //线性表当前长度  
}SqList; 

这里可以看到,顺序存储结构需要三个属性:

  • 存储空间的起始位置:数组 data,它的存储位置就是存储空间的存储位置。
  • 线性表的最大存储容量:数组长度 MaxSize。
  • 线性表的当前长度:length。

3.4.3 数组长度与线性表长度区别

  • 数组的长度是存放线性表存储空间的长度
  • 线性表的长度是线性表中数据元素的个数
  • 在任意时刻,线性表的长度应该小于数组的长度

3.4.4 地址计算方法

线性表的第 i 个元素是要存储在数组下标为 i-1 的位置,即数据元素的序号和存放它的数组下标之间存在对应关系,如下图所示:

《大话数据结构》笔记——第3章 线性表_第1张图片

若每个存储元素占用 c 个存储单元,那么第 i 个数据元素 ai 的存储位置可以由 a1 推算得出:

LOC(ai) = LOC(a1)  + (i-1)*c

通过这个公式,可随时算出线性表中任意位置的地址,不管它是第一个还是最后一个,都是相同的时间。 那么我们对每个线性表位置的存入或者取出数据, 对于计算机来说都是相等的时间, 也就是一个常数,因此用我们算法中学到的时间复杂度的概念来说,它的存取时间性能为 O(1)。我们通常把具有这一特点的存储结构称为随机存取结构。

3.5 顺序存储结构的插入与删除

3.5.1 获得元素操作

如果我们要实现 GetElem 操作,即将线性表 L 中的第 i 个位置元素值返回。只要 i 的数值在数组下标范围内,就是把数组第 i-1 下标的值返回即可

实现代码如下:

#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0

typedef int Status;

/* 获取元素
	初始条件:顺序表 L 已存在,1 <= i <= ListLength(L) 
	操作结果:用 e 返回 L 中第 i 个数据元素的值 
*/
Status GetElem(SqList L, int i, ElemType *e) {
    if (L.length == 0 || i<1 || i>L.length) {
        return ERROR;
    }
    *e = L.data[i - 1];
    return OK;
}

3.5.2 插入操作

插入算法的思路:

  • 如果插入位置不合理,抛出异常
  • 如果线性表长度大于等于数组长度,则抛出异常或动态增加容量
  • 从最后一个元素开始向前遍历到第 i 个位置,分别将它们向后移动一个位置
  • 将要插入元素填入位置 i 处
  • 表长度加 1

实现代码如下:

/* 插入元素
	初始条件:顺序表 L 已存在,1 <= i <= ListLength(L) 
	操作结果:在 L 中第 i 个位置之前插入新的数据元素 e,L 的长度加 1 
*/ 
Status ListInsert(SqList *L, int i, ElemType e) {
    int k;
    if (L->length == MAXSIZE) { //顺序线性表已满
        return ERROR;
    }

    if (i< 1 || i > L->length + 1) { //当 i 不在范围内时
        return ERROR;
    }

    if (i <= L->length) { //若插入数据的位置不在表尾
        for (k = L->length - 1; k >= i - 1; k--) { //将要插入位置后数据元素向后移动一位 
            L->data[k + 1] = L->data[k];
        }
    }

    L->data[i - 1] = e; //将新元素插入
    L->length++;
    return OK;
}


3.5.3 删除操作

删除算法的思路:

  • 如果删除位置不合理,抛出异常
  • 取出删除元素
  • 从删除元素位置开始遍历到最后一个元素位置,分别将它们向前移动一个位置
  • 表长减 1

实现代码如下:

/* 删除操作
	初始条件:顺序表 L 已存在,1 <= i <= ListLength(L)
	操作结果:删除 L 的第 i 个数据元素,并用 e 返回其值,L 的长度减 1 
*/ 
Status ListDelete(SqList *L, int i, ElemType *e) {
    int k;

    if (L->length == 0) { //线性表为空
        return ERROR;
    }

    if (i<1 || i>L->length) { //删除位置不正确
        return ERROR;
    }

    *e = L->data[i - 1];

    if (i < L->length) { //如果删除不是最后位置
        for (k = i; k < L->length; k++) { //将删除位置的后继元素前移
            L->data[k - 1] = L->data[k];
        }
    }

    L->length--;
    return OK;
}



线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是 O(1);而插入或删除操作时,时间复杂度都是 O(n)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用。

3.5.4 线性表顺序存储结构的优缺点

优点:

  • 无须为表示表中元素之间的逻辑关系而增加额外的存储空间
  • 可以快速地存取表中任一位置的元素

缺点:

  • 插入和删除操作需要移动大量元素
  • 当线性表长度变化较大时,难以确定存储空间的容量
  • 造成存储空间的 “碎片”

3.6 线性表的链式存储结构

3.6.1 顺序存储结构不足的解决办法

  • 顺序存储结构的不足:

    线性表的顺序存储结构是有缺点的,最大的缺点就是插入和删除时需要移动大量的元素,这显然就需要耗费大量时间。

  • 为什么当插入和删除时,就要移动大量的元素?

    原因就在于相邻两元素的存储位置也具有邻居关系,它们在内存中的位置是紧挨着的,中间没有间隙,当然就无法快速插入和删除。

  • 解决思路:

    我们反正也是要让相邻元素间留有足够余地,那干脆所有的元素都不要考虑相邻位置了,哪有空位就到哪里,而只是让每个元素知道它下一个元素的位置在哪里,这样,我们可以在第一个元素时,就知道第二个元素的位置(内存地址) , 而找到它; 在第二个元素时,再找到第三个元素的位置(内存地址)。这样所有的元素我们就都可以通过遍历而找到。

3.6.2 线性表链式存储结构定义

线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存未被占用的任意位置。

在顺序结构中,每个数据元素只需要存数据元素信息就可以了。现在链式结构中,除了要存数据元素信息外, 还要存储它的后继元素的存储地址。

因此,为了表示每个数据元素 ai 与其直接后继数据元素 ai+1 之间的逻辑关系,对数据元素 ai 来说,除了存储其本身的信息之外,还需要存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素 ai 的存储映像。称为结点(Node)。

n 个结点(ai 的存储映像)链结成一个链表,即为线性表(a1,a2,…,an)的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。

《大话数据结构》笔记——第3章 线性表_第2张图片

对于线性表来说,总得有个头有个尾,链表也不例外。我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。

最后一个,当然就意味着直接后继不存在了,所以我们规定,线性链表的最后一个结点指针为"空"

《大话数据结构》笔记——第3章 线性表_第3张图片

有时,我们为了更加方便地对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,也可以存储如线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针

《大话数据结构》笔记——第3章 线性表_第4张图片

3.6.3 头指针与头结点的异同点

头指针:

  • 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
  • 头指针具有标示作用,所以常以头指针冠以链表的名字
  • 无论链表是否为空,头指针均不为空。头指针是链表的必要元素

头结点:

  • 头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可存放链表的长度)
  • 有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其他结点的操作就统一了
  • 头结点一般不是链表的必要要素

3.6.4 线性表链式存储结构代码描述

若线性表为空表,则头结点的指针域为 “空”

《大话数据结构》笔记——第3章 线性表_第5张图片

之前用的存储示意图很不方便,我们改用更方便的存储示意图来表示单链表:

《大话数据结构》笔记——第3章 线性表_第6张图片

若带有头结点的单链表:

在这里插入图片描述

空链表:

在这里插入图片描述

单链表中,我们在C语言中可用结构指针来描述:

#define OK 1
#define ERROR 0
typedef int ElemType; //数据类型为 int  
typedef int Status;

/* 线性表的单链表存储结构 */
typedef struct Node
{
    ElemType data;
    struct Node *next;
}Node;
typedef struct Node *LinkList; //定义 LinkList

从这个结构定义中,我们也就知道,结点由存放数据元素的数据域和存放后继结点地址的指针域组成

假设 p 是指向线性表第 i 个元素的指针,则该结点 ai 的数据域我们可以用 p->data 来表示,p->data 的值是一个数据元素,结点 ai 的指针域可以用 p->next 来表示,p->next 的值是一个指针。p->next 指向谁呢?当然是指向第 i+1 个元素,即指向 ai+1 的指针。也就是说,如果 p->data=ai,那么 p->next->data=ai+1

《大话数据结构》笔记——第3章 线性表_第7张图片

3.7 单链表的读取

在线性表的顺序存储结构中,我们要计算任意一个元素的存储位置使很容易的。但在单链表中,由于第 i 个元素到底在哪?没办法一开始就知道,必须从头开始找。因此,对于单链表实现获取第 i 个元素的操作 GetElem,在算法上,相对麻烦一些。

获得链表第 i 个数据的算法思路:

  1. 声明一个指针 p 指向链表第一个结点,初始化 j 从 1 开始
  2. 当 j
  3. 若链表末尾 p 为空,则说明第 i 个元素不存在
  4. 否则查找成功,返回结点 p 的数据

实现代码如下:

/* 获取元素 
	初始条件:顺序线性表 L 已存在,1 <= i <= ListLength(L) 
	操作结果:用 e 返回 L 中第 i 个数据元素的值
*/
Status GetElem(LinkList L,int i,ElemType *e) {
    int j;
    LinkList p; //声明一结点 p 
    p = L->next; //让 p 指向链表 L 的第一个结点
    j = 1; //j 为计数器
    while(p && j < i) { //p 不为空且计数器 j 还没有等于 i 时,循环继续
        p = p->next; //让 p 指向下一结点
        ++j;
    }
    if(!p || j > i) {
    	return ERROR; //第 i 个元素不存在
    }
    *e = p->data; //取第 i 个元素的数据
    return OK;
}

说白了,就是从头开始找,直到第 i 个结点为止。由于这个算法复杂度取决于 i 的位置,当 i=1 时,不需要变量,而当 i=n 时则遍历 n-1 次才可以。因此最坏情况的时间复杂度是 O(n)。

3.8 单链表的插入与删除

3.8.1 单链表的插入

先来看单链表的插入。假设存储元素 e 的结点为 s,要实现结点 p、p->next 和 s 之间逻辑关系的变化,只需将结点 s 插入到结点 p 和 p->next 之间即可。可如何插入呢?

根本用不着惊动其他结点,只需要让 s->next 和 p->next 的指针做一点改变即可。

s->next = p->next; 
p->next = s;

解读这两句代码,也就是说让 p 的后继结点改为 s 的后继结点,再把结点 s 变成 p 的后继结点

《大话数据结构》笔记——第3章 线性表_第8张图片

单链表第 i 个数据插入结点的算法思路:

  1. 声明一结点 p 指向链表第一个结点,初始化 j 从 1 开始;
  2. 当 j
  3. 若到链表末尾 p 为空,则说明第 i 个元素不存在;
  4. 否则查找成功,在系统中生成一个空结点 s;
  5. 将数据元素 e 赋值给 s->data;
  6. 单链表的插入标准语句 s->next=p->next;p->next=s;
  7. 返回成功

实现代码如下:

/* 插入元素 
	初始条件:顺序线性表 L 已存在,1≤i≤ListLength(L)
	操作结果:在 L 中第 i 个结点位置之前插入新的数据元素,L 的长度加 1
*/ 
Status ListInsert(LinkList *L , int i , ElemType e) {
    int j = 1;
    LinkList p,s;
    p = *L;
    while( p && j < i) { //寻找第 i 个结点
        p = p->next;
        ++j;
    }
    if( !p || j > i) {
        return ERROR; //第 i 个元素不存在
    }
    s = (LinkList)malloc(sizeof(Node)); //生成新结点
    s->data = e;
    s->next = p->next; //将 p 的后继结点赋值给 s 的后继
    p->next = s; //将 s 赋给 p 的后继
    return OK;
}

3.8.2 单链表的删除

现在我们再来看单链表的删除。设存储元素 ai 的结点为 q,要实现将结点 q 删除单链表的操作,其实就是将它的前继结点的指针绕过,指向它的后继结点即可。

《大话数据结构》笔记——第3章 线性表_第9张图片

我们所要做的,实际上就是一步,p->next=p->next->next,用 q 来取代 p->next,即是

q = p->next;
p->next = q->next;

解读这两句代码,也就是说把 p 的后继结点改成 p 的后继的后继结点。

单链表第 i 个数据删除结点的算法思路:

  1. 声明一结点 p 指向链表第一个结点,初始化 j 从 1 开始;
  2. 当 j
  3. 若到链表末尾 p 为空,则说明第 i 个元素不存在;
  4. 否则查找成功,将欲删除的结点 p->next 赋值给 q;
  5. 单链表的删除标准语句 p->next=q->next;
  6. 将 q 结点中的数据赋值给 e,作为返回;
  7. 释放 q 结点;
  8. 返回成功。

实现代码如下:

/* 删除元素 
	初始条件:顺序线性表 L 已存在,1≤i ≤ListLength(L)
	操作结果:删除 L 的 i 个结点,并用 e 返回其值,L 的长度减 1
*/
Status ListDelete(LinkList *L,int i,ElemType *e) {
    int j = 1;
    LinkList p,q;
    p = *L;
    while(p->next && j < i) { //遍历寻找第 i 个元素 
        p = p->next;
        ++j;
    }
    if(!(p->next) || j > i) { //第 i 个元素不存在 
        return ERROR;
	}
    q = p->next;
    p->next = q->next; //将 q 的后继赋给 p 的后继
    *e = q->data; //将 q 结点中的数据给 e
    free(q); //让系统回收此结点,释放内存
    return OK;
}

分析一下刚才我们讲解的单链表插入和删除算法,我们发现,它们其实就是由两部分组成:第一部分就是遍历查找第 i 个元素;第二部分就是插入和删除元素

从整个算法来说,我们很容易推导出:它们的时间复杂度都是 O(n)。如果在我们不知道第 i 个元素的指针位置,单链表数据结构在插入和删除操作上,与线性的顺序存储结构是没有太大优势的。但如果,我们希望从第 i 个位置,插入 10 个元素,对于顺序存储结构意味着,每一次插入都需要移动 n-i 个元素,每次都是 O(n)。而单链表,我们只需要在第一次时,找到第 i 个位置的指针,此时为 O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是 O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显

3.9 单链表的整体创建

顺序存储结构的创建,其实就是一个数组的初始化,即声明一个类型和大小的数组并赋值的过程。而单链表和顺序存储结构就不一样,它不像顺序存储结构这么集中,它可以很散,是一种动态结构。对于每个链表来说,它所占用空间的大小和位置是不需要预先分配划定的,可以根据系统的情况和实际的需求即时生成。

所以创建单链表的过程就是一个动态生成链表的过程。即从"空表"的初始状态起,依次建立各元素结点,并逐个插入链表。

3.9.1 头插法建立单链表

单链表整表创建的算法思路:

  1. 声明一结点 p 和计数器变量 i;
  2. 初始化一空链表 L;
  3. 让L的头结点的指针指向 NULL,即建立一个带头结点的单链表;
  4. 循环:
    • 生成一新结点赋值给 p;
    • 随机生成一数字赋值给 p 的数据域 p->data;
    • 将 p 插入到头结点与前一新结点之间。

实现代码如下:

/*随机产生 n个元素的值,建立带表头结点的单链表线性表 L(头插法)*/ 
void CreateListHead(LinkList *L,int n)
{
    LinkList p;
    int i;
    srand(time(0)); //初始化随机数种子
    *L = (LinkList)malloc(sizeof(Node));
    (*L)->next = NULL; //先建立一个带头结点的单链表
    for(i = 0;i < n;i++)
    {
        p = (LinkList)malloc(sizeof(Node)); //生成新的结点
        p->data = rand() % 100 + 1; //随机生成 100 以内的数字
        p->next = (*L)->next;
        (*L)->next = p; //插入到表头
    }
}

这段算法代码里,我们其实用的是插队的办法,就是始终让新结点在第一的位置。我也可以把这种算法简称为头插法,如图所示。

《大话数据结构》笔记——第3章 线性表_第10张图片

3.9.2 尾插法

可事实上,根据排队时的正常思维,我们还可以把新结点放在最后。我们每次新结点都插在终端结点的后面,这种算法称之为尾插法。
实现代码如下:

/*随机产生 n 个元素的值,建立带表头结点的单链线性表 L(尾插法)*/
void CreateListTail(LinkList *L,int n)
{
    LinkList p,r;
    int i;
    srand(time(0)); //初始化随机数种子
    *L = (LinkList)malloc(sizeof(Node)); //为整个线性表
    r = *L; //r为指向尾部的结点
    for(i = 0;i < n;i++)
    {
        p = (Node *)malloc(sizeof(Node)); //生成新结点
        p->data = rand() % 100 + 1; //随机生成100以内的数字
        r->next = p; //将表尾终端结点的指针指向新结点
        r = p; //就那个当前新结点定义为表尾终端结点
    }
    r->next = NULL; //表示当前链表结束
}

3.10 单链表的整表删除

当我们不打算使用这个单链表时,我们需要把它销毁,其实也就是在内存中将它释放掉,以便于留出空间给其他程序或软件使用。

单链表整表删除的算法思路如下:

  1. 声明一结点 p 和 q;
  2. 将第一个结点赋值给 p;
  3. 循环:
    • 将下一结点赋值给 q;
    • 释放 p;
    • 将 q 赋值给 p。

实现代码如下:

/*初始条件:顺序线性表 L 已经存在,操作结果:将 L 重置为空表*/
Status ClearList(LinkList *L)
{
    LinkList p,q;
    p = (*L)->next; //p指向第一个结点
    while(p) //没到表尾
    {
        q = p->next;
        free(p);
        p = q;
    }
    (*L)->next = NULL; //头结点指针域为空
    return OK;
}

3.11 单链表结构与顺序存储结构优缺点

简单地对单链表结构和顺序存储结构做对比:

《大话数据结构》笔记——第3章 线性表_第11张图片

通过上面的对比,我们可以得出一些经验性的结论:

  1. 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。
  2. 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,比如一年12个月,一周就是星期一至星期日共七天,这种用顺序存储结构效率会高很多。

3.12 静态链表

首先我们让数组的元素都是由两个数据域组成,data 和 cur。也就是说,数组的每个下标都对应一个 data 和一个 cur。数据域 data,用来存放数据元素,也就是通常我们要处理的数据;而游标 cur 相当于单链表中的 next 指针,存放该元素的后继在数组中的下标。

我们把这种用数组描述的链表叫做静态链表,这种描述方法还有起名叫做游标实现法。

为了我们方便插入数据,我们通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出。

/*线性表的静态链表存储结构*/
#define MAXSIZE 1000 //假设链表的最大长度 1000
typedef struct
{
    ElemType data;
    int cur; //游标(Cursor),为 0 时表示无指向
}Component,StaticLinkList[MAXSIZE];

另外我们对数组第一个和最后一个元素作为特殊元素处理,不存数据。我们通常把未被使用的数组元素称为备用链表。而数组第一个元素,即下标为 0 的元素的 cur 就存放备用链表的第一个结点的下标;而数组的最后一个元素的 cur 则存放第一个有数值的元素的下标,相当于单链表中的头结点作用,当整个链表为空时,则为 0。如图所示。

《大话数据结构》笔记——第3章 线性表_第12张图片

此时的图示相当于初始化的数组状态,见下面代码:

/*将一维数组 space 中个分量链成一备用链表*/
/*space[0].cur 为头指针,"0" 表示空指针*/
Status InitList(StaticLinkList space)
{
    int i;
    for(i = 0;i < MAXSIZE - 1;i++)
        space[i].cur = i + 1;
    space[MAXSIZE - 1].cur = 0; //目前静态链表为空
    return OK;
}

假设我们已经将数据存入静态链表,比如分别存放着"甲"、“乙”、“丁”、“戌”、“已”、"庚"等数据,则它将处于如图所示。

《大话数据结构》笔记——第3章 线性表_第13张图片

此时"甲"这里就存有下一元素"乙"的游标 2,"乙"则存有下一元素"丁"的下标 3。而"庚"是最后一个有值元素,所以它的 cur 设置为 0。而最后一个元素的 cur 则因"甲"是第一有值元素而存有它的下标为 1。而第一个元素则因空闲空间的第一个元素下标为 7,所以它的 cur 存有 7。

3.12.1 静态链表的插入操作

静态链表中要解决的是:如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放。

我们前面说过,在动态链表中,结点的申请和释放分别借用 malloc() 和 free() 两个函数来实现。在静态链表中,操作的是数组,不存在像动态链表的结点申请和释放问题,所以我们需要自己实现这两个函数,才可以做插入和删除的操作。

为了辨明数组中哪些分量未被使用,解决的办法是将所有未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新结点。

/*若备用空间链表为空,则返回分配的结点下标,否则返回 0*/
int Malloc_SLL(StaticLinkList space)
{
    int i = space[0].cur; //当前数组的第一个元素的 cur 存的值,
							//就是要返回第一个备用空闲的坐标 
    if(space[0].cur)
    {
        space[0].cur = space[i].cur; //由于要拿出一个分量来使用, 
                        //所以我们,就得把它的下一个分量用作备用
    }
    return i;
}

这段代码有意思,一方面它的作用就是返回一个下标值,这个值就是数组头元素的 cur 存的第一个空闲的下标。从上面的图示例子来看,其实就是返回 7。

那么既然下标为 7 的分量准备要使用了,就得有接替者,所以就把分量 7 的 cur 值赋值给头元素,也就是把 8 给 space[0].cur,之后就可以继续分配新的空闲分量,实现类似 malloc() 函数的作用

现在我们如果需要在"乙"和"丁"之间,插入一个值为"丙"的元素,按照以前顺序存储结构的做法,应该要把"丁"、“戌”、"庚"这些元素都往后移一位。但目前不需要,因为我们有了新的手段。

新元素"丙",想插队是吧?可以,你先悄悄地在队伍最后一排第 7 个游标位置待着。我一会就能帮你搞定。我接着找到了"乙",告诉他,你的 cur 不是游标为 3 的"丁"了,这点小钱,意思意思,你把你的下一位的游标改为 7 就可以了。"乙"叹了口气,收了钱把 cur 值改了。此时再回到"丙"那里,说你把你的 cur 改为 3。就这样,在绝大多数人都不知道的情况下,整个排队的次序发生了改变

实现代码如下:

/*在 L 中第 i 个元素之前插入新的数据元素 e*/
Status ListInsert(StaticLinkList L, int i,ElemType e)
{
    int j,k,l;
    k = MAXSIZE - 1; //注意 k 首先是最后一个元素的下标 
    if(i < 1 || i > ListLength(L) + 1)
        return ERROR;
    j = Malloc_SLL(L); //获得空闲分量的下标
    if(j)
    {
        L[j].data = e; //将数据赋值给此分量的 data 
        for(l = 1;l <= i - 1;l++) //找到第 i 个元素之前的位置 
        {
            k = L[k].cur;
        }
        L[j].cur = L[k].cur; //把第 i 个元素之前的 cur 赋值给新元素的 cur 
        L[k].cur = j; //把新元素的下标赋值给第 i 个元素之前元素的 cur 
        return OK;
    }
    return ERROR;
}

就这样,我们实现了在数组中,实现不移动元素,却插入了数据的操作,如图所示。

《大话数据结构》笔记——第3章 线性表_第14张图片

3.12.2 静态链表的删除操作

前面一样,删除元素时,原来是需要释放结点的函数 free()。现在我们也得自己实现它:

/*将下标为 k 的空闲结点回收到备用链表*/
void Free_SSL(StaticLinkList space,int k)
{
   space[k].cur = space[0].cur; //把第一个元素的 cur 值赋给要删除的分量 cur 
   space[0].cur = k; //把要删除的分量下标赋值给第一个元素的 cur 
}

意思就是"甲"现在要走,这个位置就空出来了,也就是,未来如果有新人来,最优先考虑这里,所以原来的第一个空位分量,即下标是 8 的分量,它降级了,把 8 给"甲"所在下标为 1 的分量的 cur,也就是 space[1].cur=space[0].cur=8,而 space[0].cur=k=1 其实就是让这个删除的位置成为第一个优先空位,把它存入第一个元素的 cur 中,如图所示。

在这里插入图片描述
删除操作代码如下:

/*删除在 L 中第 i 个数据元素 e*/
Status ListDelete(StaticLinkList L,int i)
{
    int j , k;
    if(i < 1 || i > ListLength(L))
        return ERROR;
    k = MAXSIZE - 1;
    for(j = 1;j <= i - 1;j++)
    {
        k = L[k].cur;
    }
    j = L[k].cur;
    L[k].cur = L[j].cur;
    Free_SSL(L,j);
    return OK;
}

有了刚才的基础,这段代码就很容易理解了。前面代码都一样,for 循环因为 i=1 而不操作,j=k[999].cur=1, L[k].cur=L[j].cur 也就是 L[999].cur=L[1].cur=2。这其实就是告诉计算机现在"甲"已经离开了,"乙"才是第一个元素。

当然,静态链表也有相应的其他操作的相关实现。比如我们代码中的 ListLength 就是一个,来看代码:

/*初始条件:静态链表 L 已存在。操作结果:返回 L 中数据元素个数*/
int ListLength(StaticLinkList L)
{
    int j = 0;
    int i = L[MAXSIZE - 1].cur;
    while(i)
    {
        i = L[i].cur;
        j++;
    }
    return j;
}

3.12.3 静态链表优缺点

总结一下静态链表的优缺点:

《大话数据结构》笔记——第3章 线性表_第15张图片

总的来说,静态链表其实是为了给没有指针的高级语言设计的一种实现单链表能力的方法。尽管大家不一定会用得上,但这样的思考方式是非常巧妙的,应该理解其思想,以备不时之需。

3.13 循环链表

将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)

循环链表解决了一个很麻烦的问题。如何从当中一个结点出发,访问到链表的全部结点

循环链表带有头结点的空链表如图所示:

《大话数据结构》笔记——第3章 线性表_第16张图片

对于非空的循环链表就如图所示:

在这里插入图片描述

其实循环链表和单链表的主要差异就在于循环的判断条件上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环未结束

在单链表中,我们有了头结点时,我们可以用 O(1) 的时间访问第一个结点,但对于要访问到最后一个结点,却需要 O(n) 时间,因为我们需要将单链表全部扫描一遍。

有没有可能用 O(1) 的时间由链表指针访问到最后一个结点呢?当然可以。

不过我们需要改造一下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表(如图所示),此时查找开始结点和终端结点都很方便了。

在这里插入图片描述

从上图中可以看到,终端结点用尾指针 rear 指示,则查找终端结点是 O(1),而开始结点,其实就是 rear->next->next,其时间复杂也为O(1)。

举个程序的例子,要将两个循环链表合并成一个表时,有了尾指针就非常简单了。比如下面的这两个循环链表,它们的尾指针分别是 rearA 和 rearB,如图所示。

《大话数据结构》笔记——第3章 线性表_第17张图片

要想把它们合并,只需要如下的操作即可,如图所示。

《大话数据结构》笔记——第3章 线性表_第18张图片

3.14 双向链表

我们在单链表中,有了 next 指针,这就使得我们要查找下一结点的时间复杂度为 O(1)。可以入股我们要查找的是上一结点的话,那最坏的时间复杂度就是 O(n) 了,因为我们每次都要从头开始遍历查找。

为了克服单向性这一缺点,我们的老科学家们,设计出了双向链表。双向链表(double linked list)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域所以在双向链表中的结点都有两个指针域,一个指向直接后继。另一个指向直接前驱

/*线性表的双向链表存储结构*/
typedef struct DulNode
{
  ElemType data;
  struct DulNode *prior;//直接前驱指针
  struct DulNode *next; //直接后继指针
}DulNode,*DuLinkList;

既然单链表也可以有循环链表,那么双向链表当然也可以是循环表。

双向链表的循环带头结点的空链表如图所示。

《大话数据结构》笔记——第3章 线性表_第19张图片

非空的循环的带头结点的双向链表如图所示。

《大话数据结构》笔记——第3章 线性表_第20张图片

插入操作时,其实并不复杂,不过顺序很重要,千万不能写反了。

我们现在假设存储元素 e 的结点为 s,要实现将结点 s 插入到结点 p 和 p->next 之间需要下面几步,如图所示。

《大话数据结构》笔记——第3章 线性表_第21张图片

s->prior = p;   //把p赋值给s的前驱,如图中1
s->next = p->next;  //把p->next赋值给s的后继,如图中2
p->next->prior = s;  //把s赋值给p->next的前驱,如图中3
p->next = s;  //把s赋值给p的后继,如图中4

若要删除结点 p,只需要下面两个步骤,如图所示。

《大话数据结构》笔记——第3章 线性表_第22张图片

p->prior->next = p->next;   //把p->next赋值给p->prior的后继,如图中①
p->next->prior = p->prior;  //把p->prior赋值给p->next的前驱,如图中②
free(p);  //释放结点

简单总结一下,双向链表相当于单链表来说,要更复杂一些,毕竟多了 prior 指针,对于插入和删除来说,要格外小心。另外,它的每个结点都需要记录两份指针,所以在空间上是要占用略多一些。不过,由于它良好的对称性,使得对某个结点的前后结点的操作,带来了方便,可以有效提高算法的时间性能,即是用空间来换时间。

3.15 总结回顾

这一章,我们主要讲的是线性表。

先谈了它的定义,线性表是零个或多个具有相同类型的数据元素的有限序列。然后谈了线性表的抽象数据类型,如它的一些基本操作。

之后我们就线性表的两大结构做了讲述。先讲的是比较容易的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。通常我们都是用数组来实现这一结构。

后来是我们的重点,有顺序存储结构的插入和删除操作不方便,引出了链式存储结构。它具有不受固定的存储空间限制,可以比较快捷的插入和删除操作的特点。然后我们分别就链式存储结构的不同形式,比如单链表、循环链表和双向链表做了讲解,另外我们还讲了若不使用指针如何处理链表结构的静态链表方法。

总的来说,线性表的这两种结构(如图所示)其实是后面其他数据结构的基础,把它们学明白了,对后面的学习有着至关重要的作用。

《大话数据结构》笔记——第3章 线性表_第23张图片

3.16 结尾语

你可能感兴趣的:(#,《大话数据结构》笔记)