《数据结构(C语言版)》学习笔记02 线性表

目录

一、 定义

二、 逻辑结构

2.1 特性

2.2 基本操作

三、 顺序表

3.1 创建和销毁

3.2 插入和删除

3.3 查找

3.4 补充  

四、链表

4.1 单链表(有头结点)

4.1.1 定义

4.1.2 判空、求长

4.1.3 初始化、销毁、清空

4.1.4 查找

4.1.5 前驱、后继

4.1.6 插入

4.1.7 删除

4.1.8 头插法、尾插法

4.1.9 遍历

4.2 单链表(无头结点)

4.2.1 定义

4.2.2 判空、求长

4.2.3 初始化、销毁、清空

4.2.4 查找

4.2.5 前驱、后继

4.2.6 插入

4.2.7 删除

4.2.8 头插法、尾插法

4.2.9 遍历

4.3 双向链表

4.3.1 定义

4.3.2 判空、求长

4.3.3 初始化、销毁、清空

4.3.4 查找

4.3.5 前驱、后继

4.3.6 插入

4.3.7 删除

4.3.8 头插法、尾插法

4.3.9 遍历

4.4 单循环链表

4.4.1 定义

4.4.2 判空、求长

4.4.3 创建、销毁、清空

4.4.4 查找

4.4.5 前驱、后继

4.4.6 插入

4.4.7 删除

4.4.8 头插法、尾插法

4.4.9 遍历

4.5 静态链表、双循环链表 

五、对比


一、 定义

线性表是具有相同数据类型的n(n>=0)个数据元素的有限列。

同一线性表中的元素必定具有相同特性,即属于同一数据对象,相邻数据元素之间存在着序偶(ordered pair)关系

二、 逻辑结构

线性表是一种逻辑结构,表示元素之间一对一的相邻关系。

2.1 特性

  1. 存在唯一一个表头元素(第一个元素);
  2. 存在唯一一个表尾元素(最后一个元素);
  3. 除了表头元素外,每个数据元素有且仅有一个前驱;除了表尾元素外,每个数据元素有且仅有一个后继。

2.2 基本操作

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

ADT List{

InitList(&L)
    操作结果:构造一个空的线性表L。
DestroyList(&L)
    初始条件:线性表L已存在。
    操作结果:销毁线性表L。
ClearList(&L)
    初始条件:线性表L已存在。
    操作结果:将L重置为空表。
ListEmpty(L)
    初始条件:线性表L已存在。
    操作结果:若L为空表,则返回true,否则返回false。
ListLength(L)
    初始条件:线性表L已存在。
    操作结果:返回L中数据元素个数。
GetElem(L, i, &e)
    初始条件:线性表L已存在,1<=i<=L.length。
    操作结果:用e返回L中第i个数据元素的值。
LocateElem(L, e, compare())
    初始条件:线性表L已存在,compare()是数据元素判定函数。
    操作结果:返回L藏第1个与e满足关系compare()的数据元素的位序。若这样的元素不存在,则返回值为0。
PriorElem(L, cur_e, &pre_e)
    初始条件:线性表L已存在。
    操作结果:若cur_e是L的数据元素,且不是第一个,则用pre_e返回它的前驱,否则操作失败,pre_e无定义。
NextElem(L, cur_e, &next_e)
    初始条件:线性表L已存在。
    操作结果:若cur_e是L的数据元素,且不是最后一个,则用next_e返回它的后继,否则操作失败,next_e无定义。
ListInsert(&L, i, e)
    初始条件:线性表L已存在,1<=i<=L.length+1。
    操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加一。
ListDelete(&L, i, &e)
    初始条件:线性表L已存在且非空,1<=i<=L.length。
    操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减一。
ListTraverse(L, visit())
    初始条件:线性表L已存在。
    操作结果:依次对L的每个数据元素调用函数visit()。一旦visit()失败,则操作失败。

}ADT List

三、 顺序表

3.1 创建和销毁

①静态分配内存

//static allocation
typedef struct{
    ElemType data[LIST_MAX_SIZE];
    int length;
}SqList;

bool InitList(SqList &L){
    //for(int i=0; i<LIST_MAX_SIZE; i++) L.data[i] = 0;
    L.length = 0;
    return true;
}

结构体有两个成员变量:

  1. 最大长度为LIST_MAX_SIZE(宏定义的数)的自定义类型静态数组data。
  2. 指示当前顺序表长度的整型变量length。

InitList:

  1. 函数为bool类型变量,用于指示函数运行是否成功。
  2. 将顺序表当前长度length设为0。

        关于注释中,给每个数据元素赋初始值,关注到以下问题:

        (1)官方教材中,并无静态分配的参考代码。

        (2)辅助教材中,静态分配赋初始值,动态分配无这一动作。

        (3)在对线性表的任何操作中,都会受到L.length的限制,即不会访问到没有数据的地方。

        (4)在L.length的限制下,只要在长度内,就必然有数据,不可能访问到内存中的脏数据。

        所以,个人认为赋初始值的操作没有必要,即使有,也不应该像辅助教材中那样写,若非数值型变量,仅仅用等于号赋值是不够的(函数赋值或重载等于号)。后续在清空线性表操作中,也有类似问题。仁者见仁智者见智,欢迎讨论。

②动态分配内存

//dynamic allocation
typedef struct{
    ElemType *data;
    int length;
    int listsize;
}SqList;

bool InitList(SqList &L){
    L.data = (ElemType *)malloc(LIST_INIT_SIZE*sizeof(ElemType));
    if(!L.data) return false;
    L.length = 0;
    L.listsize = LIST_INIT_SIZE;
    return true;
}

bool DestroyList(SqList &L){
    free(L.data);
    return true;
}

结构体有三个成员变量:

  1. 指向动态分配返回首地址的指针data。
  2. 指示当前顺序表长度的整型变量length。
  3. 指示当前顺序表最大长度的整型变量listsize,类似静态的LIST_MAX_SIZE,只是这个变量会随着需求不断变大。

InitList(创建、初始化):

  1. void *malloc(unsigned int size),所以返回的指针需要强转。
  2. 若内存分配失败(没有那么长的连续内存),则返回NULL,!L.data为true。
  3. 将顺序表当前长度length设为0。
  4. 将当前顺序表最大长度listsize设为LIST_INIT_SIZE(宏定义的数),指定顺序表初始的最大长度,在1.3.2的ListIncrease函数中有更详细的调用。

DestroyList(销毁):

  1. 动态分配需要销毁的详细原因见注中红色部分,当静态内存(数组)不用时,编译器会自动收回空间;但使用malloc分配的动态内存必须要手动free。

  2. free只需连续动态内存的首地址。

*注:关于为什么叫做动态和静态,以及各自的优缺点,这里引用一篇文章

  1. 静态内存的分配是在程序开始编译时完成的,不占用CPU资源而动态内存的分配是在程序运行时完成的;动态内存的分配与释放都是占用CPU资源的。
  2. 静态内存是在栈上分配的;动态内存是在堆上分配的。
  3. 动态内存分配需要指针和引用数据类型的支持;静态内存不需要。
  4. 静态内存分配是在编译前就已经确定了内存块的大小,属于按计划分配内存而动态内存区;动态分配是在程序运行过程中,根据需要随时分配的,属于按需分配。
  5. 静态内存的控制权是交给编译器的;而动态内存的控制权是由程序员决定的。

动态内存和静态内存 - 知乎动态内存与静态内存是两种不同的分配内存的方式,那么它们在分配方式上存在什么样的区别呢? (1)静态内存的分配是在程序开始编译时完成的,不占用CPU资源而动态内存的分配是在程序运行时完成的,动态内存的分配与释…https://zhuanlan.zhihu.com/p/54776045

3.2 插入和删除

这里还得扯一下静态分配和动态分配

在插入一个元素时,线性表的长度会加一,自然就可能超出最大长度的限制。

而此时,静态分配唯一能采取的办法是,①重新定义一个更长的数组,②将原有数组的所有元素拷贝到新数组中,③把新元素插入新数组。

对于动态分配来说,处理方法不唯一,比如可以通过malloc函数,重新找一块更大的连续空间,执行和静态分配同样的动作。另外,个人认为比较方便的一点是,可以通过realloc函数直接操作。

*注:realloc函数的原理

ElemType *q =(ElemType *)realloc(p, ...)

  1. realloc() 扩展内存时,如果当前连续内存块足够 realloc 的话,只是将p所指向的空间扩大,并返回p的指针地址。 这个时候 q 和 p 指向的地址是一样的。
  2. 如果当前连续内存块不够长度,再找一个足够长的地方,分配一块新的内存q,并将 p指向的内容拷贝到 q,返回 q。并将p所指向的内存空间删除。

c函数之realloc工作原理及其正确用法_archerLea的博客-CSDN博客_realloc原理直接给例子,围绕例子来谈:https://blog.csdn.net/deggfg/article/details/23515319

后续内容将不再讨论静态分配方式

bool ListIncrease(SqList &L){
    L.listsize += LIST_INCREMENT;
    ElemType *newbase = (ElemType *)realloc(L.data, L.listsize*sizeof(ElemType));
    if(!newbase) return false;
    L.data = newbase;
    return true;
}

bool ListInsert(SqList &L, int i, ElemType e){
    if(i<1||i>L.length+1)return false;
    if(L.length>=L.listsize){
        if(!ListIncrease(L)) return false;
    }

    //normal
    for(int j=L.length-1; j>=i-1; j--) L.data[j+1] = L.data[j];
    L.data[i-1] = e;

    L.length++;
    return true;
}

bool ListDelete(SqList &L, int i, ElemType &e){
    if(i<1||i>L.length)return false; //not empty

    //normal
    e = L.data[i-1];
    for(int j=i; j<=L.length-1; j++) L.data[j-1] = L.data[j];

    L.length--;
    return true;
}

 ListIncrease(扩容):

  1. 顺序表的最大长度在原有基础上增加一个LIST_INCREMENT(宏定义的数)。
  2. 用的是realloc函数,返回值和malloc一样,都需要进行强转。
  3. realloc函数有两个参数,第一个是原先malloc返回的首地址,第二个是需要的长度。
  4. 因为realloc的结果可以指向一块新的内存区,所以需要对data进行重新赋值。

ListInsert(插入):

  1. i代表位序,位序和数组下标不是同一个概念,我们一般说“数组的第i个元素”,其中的i代表位序,对应的数组下标应为[i-1]。
  2. 插入的时候,位序的范围是从1到L.length+1,1就是第1个位置,第L.length+1个是最后一个元素后面的位置(不是最后一个元素,最后一个元素的位序是L.length)。
  3. 如果数据个数等于(书上写的大于等于)最大长度,那么需要调用ListIncrease扩容。
  4. 新来的数据元素会插入在第i个位置上(对应下标[i-1])。
  5. 从第i个变量开始,后面的每个元素依次后移,共L.length-i+1个元素,这个操作是从后向前进行的。
  6. 添加完了之后顺序表长度再加一。

ListDelete(删除):

  1. 删除的时候,位序的范围是从1到L.length,比插入的时候少一个,插入可以插在最后一个空位上,删除不可以,最多也只能删除到最后一个(位序为L.length)。
  2. 先回传变量,再删除。
  3. 从第i+1个变量开始,后面的每个元素依次前移,共L.length-(i+1)+1=L.length-i个元素,这个操作是从前向后进行的。
  4. 删除完了之后顺序表长度再减一。
  5. 注意到官方教材中,除了位序的限制外,还应要求顺序表非空。i>L.length其实隐含了这一条件,当顺序表为空表时,L.length==0,位序i>=1>L.length,会直接返回false。

图自 《数据结构(C语言版)》

注意到前文代码中有个//normal,既然有normal,就有不normal。

指针写法参考官方教材代码;

普通写法参考辅助教材代码。

bool ListInsert(SqList &L, int i, ElemType e){
    if(i<1||i>L.length+1)return false;
    if(L.length>=L.listsize){
        if(!ListIncrease(L)) return false;
    }

    //pointer
    ElemType *q = &(L.data[i-1]);
    for(ElemType *p=&(L.data[L.length-1]); p>=q; p--) *(p+1) = *p;
    *q = e;

    L.length++;
    return true;
}

bool ListDelete(SqList &L, int i, ElemType &e){
    if(i<1||i>L.length)return false; //not empty

    //pointer v1
    ElemType *p = &(L.data[i-1]);
    e = *p;
    ElemType *q = L.data+L.length-1;
    for(p++; p<=q; p++) *(p-1) = *p;
    
    L.length--;
    return true;
}

3.3 查找

①按位查找(随机存取特性)

bool GetElem(SqList L, int i, ElemType &e){
    if(i<1||i>L.length) return false;
    e = L.data[i-1];
    return true;
}

 GetElem(按位查找):

  1. 边界条件,只能按位序查找到第1个到第L.length个这个范围内的元素。
  2. 下标等于位序减一。

假设线性表的每个元素需要占用l个存储单元,并以所占的第一个单元的存储地址作为数据元素的存储位置…一般来说,线性表的第i个数据元素ai的存储位置为

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

式中LOC(a1)是线性表第一个数据元素a1的存储位置,通常称作线性表的起始位置或基地址…通常称这种存储结构的顺线性表为顺序表…每一个数据元素的存储位置都和线性表的起始位置相差一个和数据元素再线性表中的位序成正比的常数。由此,只要确定了存储线性表的起始位置,线性表中任意数据元素都可随机存取,所以线性表的顺序存储结构是一种随机存取的存储结构。

 图自 《数据结构(C语言版)》

②按值查找

bool compare(ElemType a, ElemType b);

int LocateElem(SqList L, ElemType e, bool (* compare)(ElemType, ElemType)){
    for(int j=0; j<L.length; j++){
        if(compare(e, L.data[j])) return j+1;
    }
    return 0;
}

bool compare(ElemType a, ElemType b){
    return a==b?true:false;
}

LocateElem(按值查找):

  1. 这里采用了官方教材的写法,会给函数传递一个函数指针,这里我自己也一知半解,无法形成什么系统性的语言。
  2. 个人认为,针对不同的数据类型,compare的写法可易可难,但LocateElem的写法无需改变。
  3. 个人认为,可以定义不同的compare函数,针对不同的使用场景,调用LocateElem的时候传入不同的compare函数。
  4. 同样的传入指针的操作可在1.3.4的ListTraverse函数中找到。
  5. 函数返回的是位序,但是函数中的j是下标,所以返回时需要加一。

3.4 补充  

bool ClearList(SqList &L){
    //for(int j=0; j<L.length; j++) L.data[j] = 0;
    L.length=0;
    return true;
}

ClearList(置空) :

  1. 是否需要将元素置为初始值仍存疑。
  2. 必须将长度修改为0。
bool ListEmpty(SqList L){
    return L.length==0?true:false;
}

int ListLength(SqList L){
    return L.length;
}

ListEmpty(判空):若L为空表,则返回true,否则返回false。

ListLength(判长):返回L中数据元素个数。

bool PriorElem(SqList L, ElemType cur_e, ElemType &pre_e){
    int i = LocateElem(L, cur_e, compare);
    if(!i||i==1) return false;
    pre_e = L.data[i-2];
    return true;
}

bool NextElem(SqList L, ElemType cur_e, ElemType &next_e){
    int i = LocateElem(L, cur_e, compare);
    if(!i||i==L.length) return false;
    next_e = L.data[i];
    return true;
}

 PriorElem(前驱):若cur_e在L中且不是第一个元素,用pre_e返回cur_e的前驱。注意第i个元素的前驱的下标为[i-2]。

NextElem(后继):若cur_e在L中且不是最后一个元素,用next_e返回cue_e的后继。注意第i个元素的后继的下标为[i]。

bool visit(ElemType e);

bool ListTraverse(SqList L, bool (*visit)(ElemType)){
    for(int i=0; i<L.length; i++){
        if(!(*visit)(L.data[i])) return false;
    }
    return true;
}

bool visit(ElemType e){
    printf("%d", e);
    return true;
}

ListTraverse(遍历):对L的每个元素都调用visit函数。 对此处的函数指针,我的理解和前文的按值查找一样。

四、链表

后面实现的操作有以下部分,仅针对单链表进行详细介绍,其他几种链表(除静态链表和双循环链表外)均只针对特殊处理部分进行说明。

ListEmpty()
//判空

ListLength()
//求表长

InitList()
//初始化链表

DestroyList()
//销毁链表

ClearList()
//清空链表

GetElem()
//获取第i个位置的结点数据

LocateElem()
//获取值为e的结点

PriorNode()
//返回指定节点的前驱

NextNode()
//返回指定节点的后继

ListInsert()
//在第i个位置前插入结点

NodeInsertNext()
//在指定结点后插入结点

NodeInsertPrior()
//在指定结点前插入结点

ListDelete()
//删除第i个位置的结点

NodeDelete()
//删除指定结点

ListInsertHead()
//头插法建立链表

ListInsertTail()
//尾插法建立链表

ListTraverse()
//遍历链表

4.1 单链表(有头结点)

 首先需要明确头结点和头指针的定义,

头指针指的是表头元素指针,用来标识一个链表;

头结点是人为定义的在链表最前端,不存储数据,只为了操作方便的一个结点。 

更多内容请参阅 1.4.2 单链表(无头结点)。

4.1.1 定义

typedef struct LNode{
    ElemType data;
    struct LNode *next;
}LNode, *LinkList;

在学习的时候曾产生过一个疑问,如果链表中的结点不用指针类型会发生什么?

也就是把上面代码第三行的 *next 直接用 next 代替,这样的话,“链表”中的结点就会产生嵌套的情况。

比如有三个结点的链表:

当用指针类型时,第一个结点的 next 存储的是第二个结点的地址,第二个结点的 next 存储的是第三个结点的地址 ;

当用非指针类型时,第一个结点的 next 存储的是第二个结点,也就是说第二个结点是第一个结点的一部分,同理第三个结点在第二个结点中(也在第一个结点内部),这样就失去了链表的特性。

4.1.2 判空、求长

bool ListEmpty(LinkList L){
    if(L->next) return false;
    return true;
}

int ListLength(LinkList L){
    int len = 0;
    LNode *p = L->next;
    while(p){
        len ++;
        p = p->next;
    }
    return len;
}

ListEmpty(判空):

对于有头结点的单链表来说,链表是否为空的判断依据为头结点指向的下一个节点是否为空,如果是,认为头结点就是尾结点,自然为空。

在有头结点的链表中,头结点不充当链表中的结点,仅为方便操作使用(这也是为什么不允许查找和删除头结点,当然,规矩比较灵活,也可以强行定义允许删除)。

ListLength(求长):

注意这里是先把指针p指向了头结点的下一个结点,也即单链表中的第一个结点。

如果该结点为空,那么整个表是空的,直接返回 len = 0;

如果该结点不为空,那么需要顺着链表不断向后遍历,同时 len++,直到 p 指向表尾结点的下一个结点(空结点)。

4.1.3 初始化、销毁、清空

bool InitList(LinkList &L){
    L = (LinkList)malloc(sizeof(LNode));
    if(!L) return false;
    //L->data = EOI;
    L->next = NULL;
    return true;
}

InitList(初始化):

这里要做的事情有两件:

  1. 给单链表L分配地址。使用 Linklist L 声明一个链表后,L指向的空间不可预知,直接访问可能会造成错误,而 malloc 之后 L 会指向内存中一块可用的地址。事实上,在对链表初始化之前,调试窗口内查看 *L ,会提示无法访问这块内存。
  2. 将 L->next 置空,这也是带头结点的链表所必须的操作,灵魂所在。
bool DestroyList(LinkList &L){
    if(!L) return false;
    LNode *p;
    while(L){
        p = L->next;
        free(L);
        L = p;
    }
    return true;
}

 DestroyList(销毁):

销毁(Destroy)和清空(Clear)的区别:

销毁是销毁整个链表,销毁之后这个链表就不存在了,不允许访问和进行任何操作;

清空是删除链表内的所有结点,链表还在,还可以访问和进行操作(在允许的范围内)。

所以,在销毁的过程中,需要进行两件事:

  1. 判断传入的链表是否存在,如果链表不存在,就没有销毁的必要了。这里其实很好理解,作为判断条件的时候,L 就是链表存在,!L 就是链表不存在。
  2. 循环操作,每次循环都把 L 指向第二个结点,然后 free 第一个结点。也很好理解,就是每次都把头结点摘掉,但是直接摘会丢失链表的头指针(L),所以需要先记住第二个结点的地址,删除完第一个,再把第二个结点当表头。
bool ClearList(LinkList &L){
//case1
    if(!L) return false;
    while(!ListEmpty(L)){
        int e;
        if(!ListDelete(L, 1, e)) return false;
    }
    return true;
//eo case1

//case2
    if(!L) return false;
    LNode *p = L->next;
    while(p){
        L->next = p->next;
        free(p);
        p = L->next;
    }
    return true;
//eo case2

//case3
    if(!L) return false;
    LNode *q, *p = L->next;
    while(p){
        q = p->next;
        free(p);
        p = q;
    }
    L->next = NULL;
    return true;
//eo case3
}

 ClearList(清空):

这里我写了三种写法,其实大致都差不多,(不重要:三种写法保留一种就行了,这里的eo是end of的意思)

第一种写法:如果链表非空就删除第一个结点;

第二种写法:每次都删除第二个结点,然后把后面的结点链到头结点上(头结点的 next 指向第三个结点),直到第二个结点不存在;

第三种写法:从第二个结点开始,挨个往后删,最后把 L->next 置空。

4.1.4 查找

①按位查找

bool GetElem(LinkList L, int i, ElemType &e){
    if(!L) return false;
    LNode *p = L->next;
    int j = 1;
    while(p && j<i){
        p = p->next;
        j ++;
    }
    if(!p || j>i) return false;
    e = p->data;
    return true;
}

由于链表失去了随机存取的特性(因为存储位置不连续,如果用代码输出地址其实还是连续的,因为电脑里连续空闲空间太多了,当链表长度足够大的时候,总会不连续的),所以想要获取某一个结点的数值,就需要从头开始,挨个往下找。

需要注意,用来遍历链表的指针 p 一开始便指向了 L->next ,并且 j = 1,这是因为头结点是不允许查找的(当然也可以查找,但是位序为0,“第0个位置”,好像没有这么说的,那或许可以把头结点当作第1个结点,但是这样头结点的特性就体现不出来了,头结点本身是不存数据的…当然也可以存…不杠)

这里注意几个边界条件:

  1. 当输入 i 为 0 的时候,不满足 while 的判断条件,满足 if j>i 的判断条件,返回 false。
  2. 当输入 i 为 1 的时候,不满足 while 的判断条件,也不满足 if 的判断条件,此时 p 是 L->next ,恰好是第一个结点,返回true。
  3. 当输入 i 等于 表长 的时候,不妨带个数进去看看,假设表长为2,i 也等于2,第一次循环结束后,p 指向第二个结点,j 等于2,不满足 j 小于 i 的条件,退出循环,返回true。
  4. 当输入 i 等于 表长+1 的时候,不妨带个数进去看看,假设表长为2,i 等于3,第一次循环结束后,p指向第二个结点,j 等于2,小于3,还会再进一次循环,第二次循环结束后,p指向第三个结点(实际上是 NULL ),j等于3,while 的两个条件都不满足,退出循环。此时 p 为NULL ,满足 if 判断条件,返回false。
  5. 同理可知,当 i 更大的时候,会因为 p == NULL 而退出循环,再因为 p == NULL 而返回false。

其实逻辑上也很好理解,最后一次循环退出的条件,要么没有 p( p 为 NULL ),要么 j 等于 i(p 已经在第 i 个位置了),编写的时候可以尝试带一下几个边界条件试试,后面就不写这么多了…

②按值查找

LNode * LocateElem(LinkList L, ElemType e){
    if(!L) return NULL;
    LNode *p = L->next;
    while(p && p->data!=e){
        p = p->next;
    }
    return p;
}

按值查找相较顺序表,修改了返回值,因为在链表中弱化了位序的概念,返回符合条件的结点更便于操作。

按值查找相较按位查找,好理解很多,从 L->next 开始查,原因也是在于不允许查头结点,即使输入的值恰好等于头结点的值,也认为这是不合理的。

查找的过程,就是从第二个节点开始,挨个与条件比较,如果符合,就返回这个结点。

需要注意的是,这里只能返回第一个符合条件的结点。 

4.1.5 前驱、后继

LNode * PriorNode(LinkList L, LNode *cur){
    if(!L || !cur) return NULL;
    LNode *p = L;
    while(p && p->next!=cur){
        p = p->next;
    }
    return p;
}

 PriorNode(返回指定结点的前驱):

这里的查找思路比较特殊,是从头结点开始的,原因在于个人认为,需要前驱结点的时候一般是需要对结点进行一些操作(比如后插),这个时候是允许返回头结点的。

查找的过程,就是从头结点开始,把每个结点的 next 指针与传入的 结点地址 比较,如果相等,那么当前 p 所指的位置就是传入结点的前驱。

需要注意的是,这里隐含了一个条件,cur 结点并不一定在链表 L 中,但因为单链表表尾指向 NULL ,所以会存在查不到退出循环的情况。而对于循环链表而言,是没有这种条件的,需要进行特殊的结束判断。

LNode * NextNode(LNode *cur){
    if(!cur) return NULL;
    return cur->next;
}

NextNode(返回指定结点的后继):

这是单链表里最简单的操作了吧… 

硬要说的话,存在一个表尾结点的临界条件,但是表尾结点的 next 指向 NULL,感性上理解也就是没有的意思。

注:

PriorNode 函数传入了链表 L,但是 NextNode 函数不需要,原因在于一个结点中只有其后继的信息,并没有前驱的信息,所以找前驱必须要回到原本的链表中去。

4.1.6 插入

①按位插入

bool ListInsert(LinkList &L, int i, ElemType e){
    if(!L) return false;
    LNode *p = L;
    int j = 0;
    while(p && j<i-1){
        p = p->next;
        j ++;
    }
    if(!p || j>i-1) return false;
    LNode *s = (LNode *)malloc(sizeof(LNode));
    if(!s) return false;
    s->data = e;
    s->next = p->next;
    p->next = s;
    return true;
}

其实从循环开始和结束条件也能看出来,这里的思路是找第 i-1 个结点,然后在它后面插入结点。 

插入的方式为:

  1. 创建一个新的结点 s,将其值修改 e;
  2. s 的 next 指针指向原来 p 的 next 指向的位置;
  3. p 的 next 指针指向 s。

图自 《王道考研数据结构》

顺序不能乱,如果 p->next 先指向 s,此时就没有任何东西可以指向原本 p 结点后面的结点了,导致断链无法访问。

另外,这里想说明几点:

  1. 可以注意到,起始条件Ⅰp = L->next 和 j = 1 是成对出现的,起始条件Ⅱ p = L 和 j = 0 也是成对出现的,他们分别对应着循环条件Ⅰ while(p && j<i) 和 Ⅱ while(p && j<i-1),以及错误条件Ⅰ if(!p || j>i) 和 Ⅱ if(!p || j>i-1);
  2. 为什么循环条件都是 p 不为空?因为如果 p 为空了还进入循环,那么 p->next 是无法访问的,百分百报错;
  3. 为什么错误条件都是p不为空?理由同上,因为还会访问到 p->data;
  4. i-1 和 i 的区别是什么?j<i时,是允许访问到表尾结点的;j<i-1时,是访问不到表尾结点的(一般都是用来找前驱);
  5. 那什么时候用条件Ⅰ,什么时候用条件Ⅱ?不需要头指针,但是需要表尾结点,或者说需要访问第 i 个位置的结点,就从第一个结点开始,对应着条件Ⅰ;需要头指针,但是不需要表尾结点,或者说需要访问第 i 个位置的前驱结点,就从头指针开始,对应着条件Ⅱ。

②指定节点后插

bool NodeInsertNext(LNode *cur, ElemType e){
    if(!cur) return false;
    LNode *s = (LNode *)malloc(sizeof(LNode));
    if(!s) return false;
    s->data = e;
    s->next = cur->next;
    cur->next = s;
    return true;
}

这里的思路和按位插入是一模一样的,如果有了这个函数,可以修改一下按位插入:

bool ListInsert(LinkList &L, int i, ElemType e){
    if(!L) return false;
    //找前驱
    LNode *p = L;
    int j = 0;
    while(p && j<i-1){
        p = p->next;
        j ++;
    }
    if(!p || j>i-1) return false;
    //后插
    if(!NodeInsertNext(p, e)) return false;
    return true;
}

本质上按位插入就是一个查找前驱+后插的过程,如果把按位查找也写成返回结点的形式,这里会更简单。

③指定节点前插

bool NodeInsertPrior(LNode *cur, ElemType e){
    if(!cur) return false;
    LNode *s = (LNode *)malloc(sizeof(LNode));
    if(!s) return false;
    s->next = cur->next;
    s->data = cur->data;
    cur->data = e;
    cur->next = s;
    return true;
}

前插和后插的思路不同,正常来说,需要从头结点开始,遍历到指定结点的前驱,再在这个前驱后面进行后插,但是这样查找的时间成本很高。所以可以用一个好办法: 如果要在 p 结点前插入结点,可以把结点先插到 p 结点后面,然后调换 p 结点和新结点的数据,实现偷天换日。

需要注意,上面形容的是逻辑上的顺序,但是实际实现上,是按照以下步骤进行的:

  1. 创建一个新的结点 s,将其值修改为现有节点 cur 的 data;
  2. s 的 next 指向 cur 的 next,这时候的 s 已经和 cur 一模一样了;
  3. 把 cur 当成 s 的前驱,把其值修改为要插入的数值 e;
  4. 由于 cur 本来就在链上,所以把以 cur 为表尾的前半段链表和以 s 为表头的后半段链表连接起来,也即 cur 的 next 指向 s。

这么操作的时间复杂度只有O(1),而通过找前驱再后插的办法复杂度有O(n),故不再赘述(其实和按位插入一个思路,已经赘述完了)。

4.1.7 删除

①按位删除

bool ListDelete(LinkList &L, int i, ElemType &e){
    if(!L) return false;
    LNode *p = L;
    int j = 0;
    while(p->next && j<i-1){
        p = p->next;
        j ++;
    }
    if(!p->next || j>i-1) return false;
    LNode *q = p->next;
    e = q->data;
    p->next = q->next;
    free(q);
    return true;
}

 这里可以注意到,循环和错误的判断条件变成了 p->next 不为空,这和插入的时候说的判断 p 不为空还有什么和什么成对出现,不是矛盾了吗?其实没有。

首先,明确插入和删除操作的不同:

在第 i 个位置插入,需要寻找第 i-1 个结点,并在这个结点后面插入(最多用到第 i 个结点)。

在第 i 个位置删除,需要寻找第 i-1 个结点,并将第 i-1 个结点和第 i+1 个结点链接起来(最多用到第 i+1 个结点)。

删除的方式为:

  1. 找到指定位置的前驱 p
  2. 标记指定位置 q = p->next
  3. 把指定位置的前驱链接到后继上 p->next = q->next
  4. 删除指定位置 free(q)

被删除的结点是 p->next 。

图自 《王道考研数据结构》

其次,明确什么时候会退出循环:

插入的时候,退出循环有两个条件,p 为空或者 j>=i-1,那么有以下几种可能(红色代表插入和删除条件矛盾的地方):

第一,p==NULL,j<i-1(正常退出循环且报错)。这种情况说明的是,p 已经走到最后一个结点后面的空位置了,但还是没到第 i-1 个位置,是因为输入的 i 太大了。此时 p->next 不存在,不允许删除不存在的结点。

第二,p!=NULL,j=i-1(正常退出循环但不报错)。这种情况说明的是,p 还没走到最后,但已经到了第 i-1 个位置,是要在表中插入。此时 p->next 存在,允许删除表中结点。或者,当 i==表长,此时 p 在 i-1个位置,此时p->next 存在,指向表尾结点,是允许删除的。或者,j==0 且 i==1,不进入循环,是在表空的时候插入第一个位置。此时 p->next 不存在,不允许删除不存在的结点。又或者,i==表长+1,p 停在表尾结点的位置,是要在表尾插。此时 p->next 不存在,不允许删除不存在的结点。

第三,p!=NULL,j>i-1(不进循环且报错)。这种情况说明的是,要在第0个位置插入。(如果进了循环,要么是 j==i-1 要么是 j<i-1。此时 p->next 可能存在,也可能不存在

第四,p==NULL,j>=i-1。暂时想不到怎么会发生,正如第三点提到的,如果进了循环,出来的时候 j<=i-1,如果不满足,就说明没进循环,但此时 p=L,不可能为空。

最后,明确输入条件的约束范围:

插入的时候,假如表长为 len,能够插入的合理位置在 1<=i<=len+1

删除的时候,假如表长为 len,能够删除的合理位置在 1<=i<=len

所以…删除能访问的范围其实比插入要少一个,毕竟链表空的时候还得要在第一个位置插,但是链表空的时候不能在第一个位置删吧。

那么,如果保持 p 的判断条件不变,把 j 的判断条件修改为 j<i-2 呢?其实仔细想一下,j 从 0 开始,也就是说 i-1>=0 都是合法的,但是条件修改为 j<j-2 之后,i 的合法范围就从2开始了,第一个结点是无法删除的。

那么,怎么改才对呢?我的建议是……别改。

②指定节点删除

bool NodeDelete(LNode *cur, ElemType &e){
    //if(cur==L || !cur || !cur->next) return false;
    if(!cur) return false;
    LNode *q = cur->next;
    e = cur->data;
    cur->data = q->data;
    cur->next = q->next;
    free(q);
    return true;
}

按照指定结点前插的思路,指定结点的删除也可以用偷天换日来完成,要删除指定位置的 cur 结点,那么就把 cur 结点的后继复制到 cur 节点里来,然后删除 cur 结点的后继即可。

需要注意的是,删除的条件:

  1. 头结点不能删除;
  2. 因为要把后一个结点的数据拷贝到前一个结点中,所以后一个结点不能空。 

辅导教材中并没有提到这些,只是默认了不会删头结点,同时被删除结点一定存在后继,个人认为这种假设存在一定合理性,但并不完全合理。

其一,合理性在于,指定结点删除一般是和其他函数结合使用的,例如按位删除,在大条件下,就保证了找到的这个结点不是头结点,也不可能是最后一个结点(最多是最后一个结点的前驱,然后通过前驱链接后继删除)

其二,不合理性在于,如果把按位删除函数中的删除部分代码替换为指定结点删除函数,那么最后一个结点是删除不了的,实际上无论怎么修改循环条件,都不可能通过指定结点删除函数来处理表尾结点,因此,需要对表尾元素做特殊处理。 

学识浅薄,欢迎指正。

4.1.8 头插法、尾插法

bool ListInsertHead(LinkList &L){ // presumed the list has been initiated
    if(!L) return false; //unavailable if not empty
    ElemType e;
    scanf("%d", &e);
    while(e!=EOI){
        LNode *s = (LNode *)malloc(sizeof(LNode));
        if(!s) return false;
        s->data = e;
        s->next = L->next;
        L->next = s;
        scanf("%d", &e);
    }
    return true;
}

bool ListInsertTail(LinkList &L){ // presumed the list has been initiated
    if(!L) return false; //unavailable if not empty
    LNode *p = L;

    //make t point to the last node
    //while(p->next) p = p->next;

    ElemType e;
    scanf("%d", &e);
    while(e!=EOI){
        LNode *s = (LNode *)malloc(sizeof(LNode));
        if(!s) return false;
        s->data = e;
        s->next = p->next;
        p->next = s;
        p = s;
        scanf("%d", &e);
    }
    return true;
}

有了前面的基础,看这两段代码根本不在话下了。

ListInsertHead(头插法建立链表):

顾名思义,每次插入都在表头进行,所以输入的顺序和实际建立链表的顺序其实是相反的。这里不需要第二个指针,因为每次插入都是在 L->next (第一个位置)进行的。

ListInsertTail(尾插法建立链表):

顾名思义,每次插入都在表尾进行,所以需要一个指针始终指向表尾。

需要注意,这里的代码运行的前提有两个,第一是链表已经进行初始化,即 L 不为空,如果没有初始化,初始化即可;第二是链表为空(建立链表,从零开始的意思),如果不为空,之前是头插还好,否则顺序会乱,尾插法则需要另作处理把指针先挪到表尾。 

4.1.9 遍历

bool ListTraverse(LinkList L){
    if(!L) return false;
    LNode *p = L->next;
    int i = 1;
    while(p){
        printf("%d: %d\n", i, p->data);
        i ++;
        p = p->next;
    }
    return true;
}

又出现了,p = L->next 配合 i = 1。 

4.2 单链表(无头结点)

接下来的介绍顺序和前面一样,但是仅针对特殊处理的部分进行详细说明。

首先,说明一下有头结点和无头结点的区别:

有头结点,头结点(L)作为第0个结点,不存储数据,不允许对其进行操作(后插结点和销毁除外),其存在只是为了方便操作;

无头结点,头指针(L)作为第1个结点,存储数据,允许进行任何操作,就是实际的一个结点。

也是因为二者特性的不同,所以在操作实现的处理上会有差异,主要体现在循环开始和终止的条件判断上

4.2.1 定义

定义和有头结点的单链表定义是一样的,都是包含一个数据域 data 和一个指针域 next。

虽然在 typedef 的时候把 LNode 给更名为了 *Linklist,但这里仅仅是做了语义的区分,需要明白 Linklist 类型就是 LNode * 类型,实际上在创建链表 Linklist L 的时候,只是用一个结点 L 来标识整个链表而已,本质还是个结点。

4.2.2 判空、求长

bool ListEmpty(LinkList L){
    if(!L) return true;
    return false;
}

int ListLength(LinkList L){
    LNode *p = L;
    int len = 0;
    while(p){
        p = p->next;
        len ++;
    }
    return len;
}

有头结点的时候,判空的依据是 L->next (第一个结点)是否为空,求长从 L->next (第一个结点)开始;

无头结点的时候,判断的依据是 L (第一个节点)是否为空,求长从 L (第一个结点)开始。 

4.2.3 初始化、销毁、清空

bool InitList(LinkList &L){
    L = NULL;
    return true;
}

在初始化的时候,因为头指针作为一个结点,暂无数据,所以需要把头指针本身置空(有头结点的时候,是给 L 分配内存,然后 L->next 置空,这里为什么不这样做呢?因为在 malloc 分配空间后,L 的数据域和指针域不为空,这显然不是一个无头结点的空表该有的特性)。 

销毁和清空的操作和之前本质上无异,但是需要注意,从语义上来说,销毁的目的是让链表无法访问,清空的目的是让链表没有结点。在有头结点的时候,这二者是区分开的,但是在没有头结点的时候,销毁的结果是 L 为空,清空的结果也是 L 为空。但是销毁的时候,L 被 free 掉了,清空的时候只是手动将 L 置空。实际上,被 free 后的指针是不允许访问的,虽然结果一样,但感性理解上还是有所区别的。

其实,针对无头结点的链表来说,在插入的时候需要对头指针进行特殊处理,即为它分配空间并传入数据,也就是说,即使传进来的是个空表(L==NULL),也需要允许进行操作,这就导致不论是清空后的链表还是销毁后的链表,都可以进行在第一个位置插入结点的操作(但是认识上不允许销毁后还插入)。

4.2.4 查找

bool GetElem(LinkList L, int i, ElemType &e){
    LNode *p = L;
    int j = 1;
    while(p && j<i){
        p = p->next;
        j ++;
    }
    if(!p || j>i) return false;
    e = p->data;
    return true;
}

LNode * LocateElem(LinkList L, ElemType e){
    LNode *p = L;
    while(p && p->data!=e){
        p = p->next;
    }
    return p;

前面提过,

当 p 指向头结点(有头结点)的时候,j 从 0 开始自增;

当 p 指向 p->next(有头结点时的第一个结点)的时候,j 从1 开始自增。

但是在无头结点的链表中,p 指向头指针 L(第一个结点)的时候,j 从 1 开始自增。 

4.2.5 前驱、后继

这里也是很前面一样的,但是默认了不能传回第一个结点的前驱。

4.2.6 插入

①按位插入

bool ListInsert(LinkList &L, int i, ElemType e){
    //if(!L) return false;
    LNode *s = (LNode *)malloc(sizeof(LNode));
    if(!s) return false;
    s->data = e;
    if(i==1){
        s->next = L;
        L = s;
    }
    else{
        LNode *p = L;
        int j = 2;
        while(p && j<i){
            p = p->next;
            j ++;
        }
        if(!p || j>i) return false;
        s->next = p->next;
        p->next = s;
    }
    return true;
}

可以看到,代码和之前有了很大的出入,这是因为没有头结点的时候,即使初始化了 L 仍然为空,这也是为什么第二行的判空条件要注释掉。

需要进行的特殊处理如下:

  1. 当在第1个位置插入时,可以把插入的新结点当作新链表的第一个结点,让新结点指向原本的链表表头,再把第一个结点的地址修改为新指针的地址即可完成操作。这样的好处在于省去为第一个结点分配空间。如果表空,那么新结点插入后,作为第一个结点,它有数据,并且 next 指向NULL(原本的 L)。
  2. 在其他位置插入时,和有头结点无异,但是因为单独拎出了 i==1 的情况,在 else 中 i 被允许的范围是从 2 开始的。 

总结说来,表空的时候,在头指针前面插,后续则把第一个结点当头结点继续插。 

②指定结点前插、后插

指定结点插入有无头结点都是一样的,需要注意的是,在第一个结点的前插,并不是通过找前驱后插实现的,而是采取了后插再交换的方式,所以有没有头结点都无影响。

4.2.7 删除

bool ListDelete(LinkList &L, int i, ElemType &e){
    if(!L) return false;
    if(i==1) return NodeDelete(L, e);
    LNode *p = L->next;
    int j = 2;
    while(p && j<i-1){
        p = p->next;
        j ++;
    }
    if(!p->next || j>i-1) return false;
    LNode *q = p->next;
    e = q->data;
    p->next = q->next;
    free(q);
    return true;
}

删除的过程中,仍然需要针对头指针进行特殊处理,因为删除的本质是指定位置的前驱和后继相链接,头指针没有前驱,所以需要用指定结点删除的方式,先把第二个结点的数据存到第一个结点中,再删除第二个结点。

4.2.8 头插法、尾插法

bool ListInsertHead(LinkList &L){ // presumed the list has been initiated
    //if(!L) return false; //available if not empty
    ElemType e;
    scanf("%d", &e);
    while(e!=EOI){
        LNode *s = (LNode *)malloc(sizeof(LNode));
        if(!s) return false;
        s->data = e;
        s->next = L;
        L = s;
        scanf("%d", &e);
    }
    return true;
}

头插法的本质,和在按位插入的时候对第一个结点的处理方式相同,所以可以直接照搬过来。其实就是不断的 ListInsert(L, 1, e)。

bool ListInsertTail(LinkList &L){ // presumed the list has been initiated
    ElemType e;
    LNode *p;
    scanf("%d", &e);
    while(e!=EOI){
        if(ListEmpty()){
            L = (LNode *)malloc(sizeof(LNode));
            if(!L) return false;
            L->data = e;
            L->next = NULL;
            p = L;
        }
        else{
            LNode *s = (LNode *)malloc(sizeof(LNode));
            if(!s) return false;
            s->data = e;
            s->next = p->next;
            p->next = s;
            p = s;
        }
        scanf("%d", &e);
    }
    return true;
}

而尾插法就比较惨了,因为没有头结点,不能直接在头结点后面插。这时候就需要在表空的时候,先在第一个结点中插入数据,然后把它当成头结点(实际上不是),再进行和有头结点相同的操作。 或者,另一个比较简单的办法,就是先头插一个,然后再进行尾插,会省去一部分代码量。

4.2.9 遍历

bool ListTraverse(LinkList L){
    if(!L) return false;
    LNode *p = L;
    int i = 1;
    while(p){
        printf("%d: %d\n", i, p->data);
        i ++;
        p = p->next;
    }
    return true;
}

遍历和查找相同,注意起始条件即可。 

4.3 双向链表

 

单链表的存储结构中只有一个指示后继的指针域(next),因此从某个结点开始只能顺时针向后寻找其他结点。换取话说,在单链表中,获取后继结点(NextNode)时间复杂度为O(1),而获取前驱结点(PriorNode)时间复杂度却为O(n)。为了克服单链表这种单向性的缺点,衍生了双向链表。

 因为不带头结点的不便,后续讨论的链表都是带头结点的,如不加说明,后面提到的单链表特指有头结点的单链表。

4.3.1 定义

typedef struct DNode{
    Elemtype data;
    struct DNode *prior;
    struct DNode *next;
}DNode, *DLinkList;

双向链表比单链表多了一个指针域 prior,用于指向某结点的前驱结点。 

4.3.2 判空、求长

判空和求长的操作和判断条件较单链表而言是不变的。

4.3.3 初始化、销毁、清空

bool InitList(DLinkList &L){
    L = (DLinkList)malloc(sizeof(DNode));
    if(!L) return false;
    L->next = NULL;
    L->prior = NULL;
    return true;
}

 因为双向链表比单链表多了一个指针域 prior,所以在初始化的时候需要把头结点的 prior 指针也置空。

销毁和清空和单链表一致。

4.3.4 查找

查找有两种顺序,即后向查找和前向查找,前面单链表中实现的都是后向查找。事实上,如果按位序查找,那么只能通过头结点后向查找,但如果提供指定结点,则可通过 prior 指针进行前向查找,思路都是相同的,如果仍然使用和前面一直的按位查找和按值查找,那么代码是不变的。

4.3.5 前驱、后继

DNode * PriorNode(DNode *cur){
    if(!cur) return NULL;
    return cur->prior;
}

DNode * NextNode(DNode *cur){
    if(!cur) return NULL;
    return cur->next;
}

因为有了指向前驱的指针,所以找前驱和找后继的时间复杂度都变为了O(1)。 

4.3.6 插入

①按位插入

bool ListInsert(DLinkList L, int i, Elemtype e){
    if(!L) return false;
    DNode *p = L;
    int j = 0;
    while(p && j<i-1){
        p = p->next;
        j ++;
    }
    if(!p || j>i-1) return false;
    DNode *s = (DNode *)malloc(sizeof(DNode));
    if(!s) return false;
    s->data = e;
    s->next = p->next;
    s->prior = p;
    if(p->next) p->next->prior = s;
    p->next = s;
    return true;
}

双向链表的插入较单链表而言,操作上没有太大变化,只是增加了对 prior 指针域的处理,这里仍然是对第 i-1 个结点 p 的后插操作。除了需要把新结点 s 的 next 指向 p->next 外,还需要把 s 的 prior 指向 p。

另外需要注意,如果是在表尾插,则需要增加 p->next 是否为空的判断,实际上,当在表长+1的位置插入的时候,找到的前驱就是表尾结点,此时 p->next==NULL,此时并不需要让表尾结点后继的前驱连过来,因为它没有后继。

如果要避免这种特殊情况,就不能采用找前驱后插的方式,而是直接找到对应位置的结点进行前插。

形象地说,要在第 i 个位置插入结点 s,就需要 s->next 指向原先的第 i 个结点(插入后变成第 i+1 个),并且需要 s->prior 指向第 i-1 个结点。再把第 i-1 个结点的 next 指向 s。

 图自 《数据结构(C语言版)》

 ②指定结点后插

思路和按位插入一致

③指定结点前插

bool NodeInsertPrior(DNode *cur, Elemtype e){
    if(!cur || !cur->prior) return false;
    DNode *s = (DNode *)malloc(sizeof(DNode));
    if(!s) return false;
    s->data = e;
    s->next = cur;
    s->prior = cur->prior;
    cur->prior->next = s;
    cur->prior = s;
    return true;
}

因为可以很方便地找到指定结点的前驱,所以实现起来也不需要先后插再替换。

要在结点 cur 之前插入结点,就需要:

  1. 把新结点的 next 指针(s->next)指向第 i 个结点(cur->next);
  2. 把新结点的prior指针(s->prior)指向第 i-1 个结点(cur->prior);
  3. 把第 i-1 个(cur->prior)结点的 next 指针(cur->prior->next)指向新结点;
  4. 把第 i 个结点(cur)的 prior 指针(cur->prior)指向新结点。

一共是四步操作,也需要注意顺序,不能断链,还需要注意 cur 不能是头结点(cur->prior!=NULL)。

4.3.7 删除

①按位删除

bool ListDelete(DLinkList &L, int i, Elemtype &e){
    if(!L) return false;
    DNode *p = L->next;
    int j = 1;
    while(p && j<i){
        p = p->next;
        j ++;
    }
    if(!p || j>i) return false;
    e = p->data;
    p->prior->next = p->next;
    if(p->next) p->next->prior = p->prior;
    free(p);
    return true;
}

删除的时候,找到指定结点 p 后,和单链表不同,双向链表还需要处理 prior 指针:

  1. 指定结点的前驱的后继(p->prior->next)指向指定结点的后继(p->next);
  2. 指定结点的后继的前驱(p->nex->priort)指向指定结点的前驱(p->prior)。

这两步操作的顺序任意,但是需要注意,在删除表尾结点的时候,p->next 为空,所以需要加一句条件判断。当要删除的结点就是表尾结点的时候,只需要把指定结点的前驱链接到指定结点的后继即可,这是合理的。

 图自 《数据结构(C语言版)》

②指定结点删除

bool NodeDelete(DNode *cur, Elemtype &e){
    if(!cur || !cur->prior) return false;
    e = cur->data;
    cur->prior->next = cur->next;
    if(cur->next) cur->next->prior = cur->prior;
    return true;
}

删除的方式和按位删除一样, 但这里需要注意,第一,不允许删除头结点(cur->prior!=NULL);第二,表尾结点的特殊处理。

4.3.8 头插法、尾插法

头插和尾插比单链表多了个 prior 指针的处理,这里不再赘述。

4.3.9 遍历

bool ListTraverse(DLinkList L, int REVERSE=0){
    if(!L) return false;
    int i;
    DNode *p;
    if(!REVERSE){
        p = L->next;
        i = 1;
        while(p){
            printf("%d: %d\n", i, p->data);
            i ++;
            p = p->next;
        }
    }
    else{
        i = ListLength(L);
        p = L;
        while(p->next){
            p = p->next;
        }
        while(p->prior){
            printf("%d: %d\n", i, p->data);
            i --;
            p = p->prior;
        }
    }
    return true;
}

这里把遍历函数修改为了可选参数的形式,如果REVERSE==1,则前序遍历,否则进行普通的后序遍历。在前序遍历时,需要先找到最后一个结点,然后依次通过 prior 指针向前找结点并输出。(时间开销大,仅做测试用,如果需要可以编写指定结点的前序遍历)。 

4.4 单循环链表

 

循环链表的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。因此从表中任一结点出发均可找到表中其他结点。

4.4.1 定义

typedef struct CNode{
    int data;
    struct CNode* next;
}CNode, *CLinkList;

还是单链表的定义方式。 

4.4.2 判空、求长

bool ListEmpty(CLinkList L){
    if(L->next==L) return true;
    else return false;
}

int ListLength(CLinkList L){
    int len = 0;
    CNode *p = L->next;
    while(p!=L){
        p = p->next;
        len ++;
    }
    return len;
}

循环链表和单链表最大的不同是,循环链表的表尾结点的 next 指针指向的是头结点,也因此在判空和求长的时候,需要把判断条件修改为“下一个结点是否为头结点”。 

4.4.3 创建、销毁、清空

bool InitList(CLinkList &L){
    L = (CLinkList)malloc(sizeof(CNode));
    if(!L) return false;
    L->next = L;
    return true;
}

较单链表而言,只修改了一句,单链表中需要把头结点的 next 指针指向NULL,而循环链表则需要把头结点的 next 指针指向自身(头结点)。 

bool DestroyList(CLinkList &L){
    if(!L) return false;
    CNode *p = L->next;
    //caution
    L->next = NULL;
    L = p;
    //
    while(L){
        p = L->next;
        free(L);
        L = p;
    }
    return true;
}

在单链表的基础上,循环链表的销毁操作增加了两行代码,目的是为了把链表从头结点后面断开(断开后变成单链表,头结点变为表尾结点,原先的第一个结点现在作为头结点使用)。

如果不断呢?

这就要提到循环链表的特性了,因为表尾结点指向头结点,在第一次循环后,虽然头结点被 free 了,但是 L 仍然是存在的,它依然被保存在某块地址中,表尾结点的 next 指针就指向这块区域。但,这块区域是禁止访问的。那么当循环到表尾结点时,L 就是表尾结点,p=L->next 指向了一块未知区域。而循环链表是没有 p->next==NULL 这么方便的判断条件的。

所以,只能在某处断链,然后人为附加一个空结点链接到表尾,当作单链表处理。下面是CodeBlocks调试过程中,三个结点链表销毁前和 free 4次后的地址。

(注 L 是头结点,L->next 为第一个结点,以此类推,L->next->next->next->next 是表尾的 next,也指向头结点)

 销毁 进入循环前

销毁 循环4次后 

4.4.4 查找

bool GetElem(CLinkList L, int i, Elemtype &e){
    if(!L) return false;
    CNode *p = L->next;
    int j = 1;
    while(p->next!=L && j<i){
        p = p->next;
        j ++;
    }
    if(p->next==L && j>i) return false;
    e = p->data;
    return true;
}

查找这部分,为了写法统一(函数定义),个人并没有写传入某个结点的情况。

需要注意的只有一点,就是循环终止条件从单链表的 p->next==NULL 修改为了循环链表的 p->next==L。

但是,因为循环链表的特性,如果传入了一个结点,不需要给函数整个链表,只从指定结点开始一直向后遍历(直到它自己)就可以找遍整个链表了。这可以体现在1.4.4.5的找前驱函数上。

4.4.5 前驱、后继

CNode * PriorNode(CLinkList L, CNode *cur){
    if(!cur || !LocateElem(L, cur->data)) return NULL;
    CNode *p = cur;
    while(p->next!=cur){
        p = p->next;
    }
    return p;
}

如上所述,通过指定结点向后遍历就可以走遍整个链表。

那为什么还要传入 L 呢?

个人认为,结点 cur 并不一定在链表中,也就是说,如果不传入链表、不设置错误条件的话,存在 while 条件永远都满足而死循环的情况出现。例如,cur 是一个孤立的结点, cur->next 指向NULL。保险起见,还是传入了链表。但是这样的话,就显得这个函数比较鸡肋,既然需要在 if 中找到 cur 了,那不就已经经过了 cur 的前驱了吗?是的,但是为了体现循环链表的特性,我还是这么写了。如果能确保 cur 在某个循环链表中,那么可以修改为以下写法:

CNode * PriorNode(CNode *cur){
    if(!cur) return NULL;
    CNode *p = cur;
    while(p->next!=cur){
        p = p->next;
    }
    return p;
}

这不就清爽多了嘛。另外,找后继还是那个经典的O(1)写法。

4.4.6 插入

bool ListInsert(CLinkList &L, int i, Elemtype e){
    if(!L) return false;
    CNode *p = L;
    int j = 0;
    while(p->next!=L && j<i-1){
        p = p->next;
        j ++;
    }
    if(j>i-1) return false;
    CNode *s = (CNode *)malloc(sizeof(CNode));
    if(!s) return false;
    s->data = e;
    s->next = p->next;
    p->next = s;
    return true;
}

循环链表的插入中,需要先明确一个概念,即插入位置>表长的时候,是否允许插入?当然这是人为定义的,以上的代码也没有杜绝这种情况的出现。但实际上按照位序的概念来说,插入到一个“不存在”的位置显然不合理的,但链表中讲位序的概念,也不太合理…如果不想插入到大于表厂的位置,只需要在输入的时候加个判断即可,这里就不在赘述了。

另外,和单链表有区别的地方在于循环判断条件和报错条件。

单链表中,如果 p 不存在,则报错。

循环链表中,如果 p->next==L,那它是表尾结点。

实际上无论是单链表还是循环链表,插入的过程都是找第 i 个位置前驱的过程。在单链表中,如果找到的前驱 p 不存在,说明超出了链表的范围,这时候在它后面插入显然是不允许的;在循环链表中,如果查找的前驱 p 的 next 指向头结点,那么它是表尾结点,这时候在它后面插入显然是允许的。

造成这种情况的原因是,在单链表插入过程中,当 p 为表尾结点的时候,退出循环的条件是 j==i-1,所以 p 就在表尾结点,p 不为空;在循环链表插入过程中,当 p 为表尾结点的时候,退出循环的条件是 p->next==L,p 也在表尾结点,但此时是允许插入的;而如果单链表要以 p==NULL 退出循环,说明 i-1 已经大于表长了。

指定结点的后插,其实和按位插入也是一样的。

但是指定结点的前插,可以通过先后序遍历找前驱,然后后插完成;当然也可以后插再前后替换。

4.4.7 删除

按位删除和按位插入差不多,都是通过寻找指定位置的前驱,循环起始和终止条件也一样。

指定结点删除依旧是两种办法,找前驱后删或者前后交换删后面。

4.4.8 头插法、尾插法

这部分也一样。

4.4.9 遍历

bool ListTraverse(CLinkList L, CNode *pos=NULL){
    if(!L) return false;
    int i = 1;
    CNode *p;
    if(!pos){
        p = L->next;
        while(p!=L){
            printf("%d: %d\n", i, p->data);
            i ++;
            p = p->next;
        }
    }
    else{
        p = pos;
        while(p->next!=pos){
            if(p!=L) {
                printf("%d: %d\n", i, p->data);
                i ++;
            }
            p = p->next;

        }
        printf("%d: %d\n", i, p->data);
    }
    return true;
}

为了体现循环链表的特性,所以新增了一个参数,可选择是否从指定结点向后遍历(顺着链表找到自己),过程中会经过头结点,做个特殊处理跳过输出即可。 

4.5 静态链表、双循环链表 

 静态链表感觉我自己也写不明白,双向循环链表只需要把双向链表和单循环链表拼起来就可以了。

五、对比

 有这张图应该就够了。

==总结==

  • 10
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值