数据结构 - 链表

链表是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是链式存储结构。所以首先一起来了解一下线性表

线性表

线性表也叫顺序表(linear list),是零个或多个数据元素的有限序列。首先它是一个序列。元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继

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

屏幕快照 2018-09-02 上午11.42.32.png

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

在较复杂的线性表中,一个数据元素可以由若干个数据项组成

11import.png

线性表的抽象数据类型

对于一个线性表来说,插入数据和删除数据都是必须的操作。线性表的抽象数据类型定义如下:

ADT 线性表(List)
Data    
    线性表的数据对象集合为{a1, a2, ......, 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):    在线性表L中的第i个位置插入新元素e
    ListDelete(*L,i,*e):   删除线性表L中的第i个位置元素,并用e返回其值
    ListLength(L):         返回线性表L的元素个数
EndADT

对于不同的应用,线性表的基本操作是不同的,上面都是最基本的操作,实际问题中涉及的复杂操作对于通过基本操作的组合来实现。

比如:要实现两个线性表集合A和B的并集操作,即要使用集合A=AUB.其实就是把存在集合B中但不存在A中的数据元素插入到A中。仔细思考一下,不就是循环变量集合B中的每一个元素,判断当前元素是否存在A中,若不存在,则插入到A中。

typedef int ElemType;          
/* 将所有的在线性表Lb中但不在La中的数据元素插入到La中 */
void unionL(List La, List Lb)
{    

  int La_len, Lb_len, i;    /* 声明与La和Lb相同的数据元素e */    
  ElemType e;    

  La_len = ListLength(La);  /* 求线性表的长度 */                   
  Lb_len = ListLength(Lb);    

  for (i = 1; i <= Lb_len; i++)    
  {        
      GetElem(Lb, i, &e);    /* 取Lb中第i个数据元素赋给e */                              
      if (!LocateElem(*La, e))   /* La中不存在和e相同数据元素 */                    
          ListInsert(La, ++La_len, e);    /* 插入 */    
  }
}

线性表的存储方式

线性表有两种存储方式:顺序存储和链式存储

线性表的顺序存储

线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。线性表(a1,a2,⋯,ai-1,ai,⋯,an)的顺序存储如下图所示:

屏幕快照 2018-09-02 下午7.05.00.png

顺序存储的方式

线性表的顺序存储的结构代码:

typedef int ElemType;          /* ElemType类型根据实际情况而定,这里假设为int */

typedef struct
{          
    ElemType data[MAXSIZE];    /* 数组存储数据元素,最大值为MAXSIZE */    
    int length;                /* 线性表当前长度 */
} SqList;

顺序存储结构需要三个属性:

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

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

  • 数组的长度是存放线性表的存储空间的长度,存储分配后这个量一般是不变的
  • 线性表的长度是线性表中数据元素的个数,随着线性表插入和删除操作的进行,这个量是变化的。
  • 在任意时刻,线性表的长度应该小于等于数组的长度

地址计算方法

屏幕快照 2018-09-03 上午8.38.58.png

用数组存储顺序表意味着要分配固定长度的数组空间,由于线性表中可以进行插入和删除操作,因此分配的数组空间要大于等于当前线性表的长度。

存储容器中的每个存储单元都有自己的编号,这个编号就是地址。假设占用的是c个存储单元,那么线性表中第i+1个数据元素的存储位置和第i个数据元素的存储位置关系满足下列关系(LOC表示获得存储位置的函数):

LOC(ai+1)=LOC(ai)+c

所以对于第i个元素ai的存储位置可以由a1推导得出:

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

如下图所示

屏幕快照 2018-09-03 上午8.48.20.png

对每个线性表位置的存入或者取出数据,对于计算机来说都是相等的时间,也就是一个常数,存取时间性能为O(1)。通常把具有这一特点的存储结构称为随机存取结构

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

获得元素操作

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

#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;

/* Status是函数的类型,其值是函数结果状态代   码,如OK等 */
/* 初始条件:顺序线性表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;
}

插入操作

火车站买票的场景,有人求助想插队,如下图所示

15import-1.png

插入算法的思路:

1、如果插入位置不合理,抛出异常;
2、如果线性表长度大于等于数组长度,则抛出异常或动态增加容量;
3、从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;
4、将要插入元素填入位置i处;
5、表长加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;
}

删除操作

本以为好心帮助求助人,谁知道是个黄牛,被人找上门来,溜之大吉,对应来线性表的删除操作

16import.png

删除算法的思路:

1、如果删除位置不合理,抛出异常;
2、取出删除元素;
3、从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;
4、表长减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)

平均的情况,由于元素插入到第i个位置,或删除第i个元素,需要移动n-i个元素。根据概率原理,每个位置插入或删除元素的可能性是相同的,也就说位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的移动次数相等,为,所以平均时间复杂度还是O(n)

线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是O(1);而插入或删除时,时间复杂度都是O(n)

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

优点

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

缺点

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

线性表的链式存储结构

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

线性表链式存储结构定义

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

5import.png

链式结构中,除了要存数据元素信息外,还要存储它的后继元素的存储地址。对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。

存储数据元素信息的域称为数据域,存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。

n个结点(ai​​ 的存储映像)链结成一个链表,即为线性表(a1,a2,⋯,a​n)的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起,如下图

18import.png

链表中第一个结点的存储位置叫做头指针,线性链表的最后一个结点指针为“空”(通常用NULL或“^”符号表示)

19import.png

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

20import.png

头指针与头结点的异同

头指针

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

头结点

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

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

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

22import.png

改用更方便的存储示意图来表示单链表

23import.png

带有头结点的单链表

24import.png

空链表

25import.png

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

/* 线性表的单链表存储结构 */
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的值是一个指针,指向第i+1个元素。如果p−>data=ai,那么p−>next−>data=ai+1
​​

26import.png

单链表的读取

在线性表的顺序存储结构中,我们要计算一个元素的存储位置很容易,但是在单链表中,由于第i个元素到底在哪?没办法又开始就知道,必须得从头开始找。

获得链表第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)

由于单链表的结构中没有定义表长,所以不能事先知道要循环多少次,因此也就不方便使用for循环,其主要思想就是“工作指针后移”,这也是很多算法常用技术

单链表的插入与删除

单链表的插入

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

27import.png

可以看到,我们不需要移动其它结点,只需要让s−>next和p−>next的指针做一点改变即可。即让p的后继结点改成s的后继结点,再把结点s变成p的后继结点,如下图:

28import.png

实现代码

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

注意:代码的先后顺序是不可以交换的,因为如果交换了顺序,那么第一句会使得将 p->next指向s的地址,那么再执行s->next = p->next;其实等于s->next=s,这样真正的拥有ai+1的数据元素就没有上级了,这样得插入操作是失败的。

插入结点s之后,链表如下图所示:

29import.png

对于单链表的表头和表尾的特殊情况,操作是相同的,如下图:

30import.png

单链表第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个结点位置之前插入新的数据元素e,L的长度加1 */
Status ListInsert(LinkList *L, int i, ElemType e)
{    
    int j;    
    LinkList p, s;    
    p = *L;    
    j = 1;    

    while (p && j < i)                   /* 寻找第i-1个结点 */      
    {        
        p = p->next;        
        ++j;    
    }    

    if (!p || j > i)                     /* 第i个结点不存在 */
        return ERROR;                          

    s = (LinkList)malloc(sizeof(Node));  /* 生成新结点(C标准函数) */        
    s->data = e;        

    s->next = p->next;                   /* 将p的后继结点赋值给s的后继 */
    p->next = s;                         /* 将s赋值给p的后继 */
    return OK; 
}

C语言的malloc标准函数,作用是生成一个新的结点,类型与Node一样,实质就是在内存中找了一小块空地,准备用来存放数据e的s结点

单链表的删除

假设存储元素ai的结点为q,要实现将结点q删除单链表的操作,其实就是将它的前继结点的指针绕过,指向它的后继结点即可,如下图

31import.png

实际上我们只需要一步, p−>next=p−>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;    
    LinkList p, q;    
    p = *L;    
    j = 1;    
    
    while (p->next && j < i)      /* 遍历寻找第i-1个结点 */       
    {        
        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; 
}

上面代码使用了C语言的标准函数free。它的作用就是让系统回收一Node结点,释放内存。

简单分析单链表的插入和删除算法,其实可以发现,它们都是由两部分组成:

1)第一部分就是遍历查找第i个结点;
2)第二部分就是插入和删除结点

从这个算法来看,它们的时间复杂度都是O(n)。如果不知道第i个结点的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。如果希望从第i个位置,插入10个结点,对于顺序存储结构意味着,每一次插入都需要移动n−i个结点,每次都是O(n)。单链表只需要在第一次时找到第i个位置的指针,此时为O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(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));            
        /* 随机生成100以内的数字 */        
        p->data = rand() % 100 + 1;                    
        p->next = (*L)->next;        
        /* 插入到表头 */        
        (*L)->next = p;                            
    }
}

上面代码用的是插队的办法,始终让新结点在第一的位置,简称为头插法,如下图:

32import.png

有了头插法当然也有尾插法,也就是每次新结点都插在终端结点的后面。

实现算法如下

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

    /* 表示当前链表结束 */
    r->next = NULL; 
}

注意L与r的关系,L是指整个单链表,而r是指向尾结点的变量,r会随着循环不断地变化结点,L则是随着循环增长为一个多结点的链表。

上面代码r−>next=p;的意思是将刚才的表尾终端结点r的指针指向新结点p,如下图所示

343import.png

r=p; 表示将p结点这个最后的结点赋值给r,此时r又是最终的尾结点。循环结束,应该将链表的指针域置空,因此有 r->next = NULL; 以便以后遍历时可以确认其是尾部

34import.png

单链表的整表删除

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

单链表整表删除的思路

1)声明一指针p和q;
2)将第一个结点赋值给p;
3)循环:

  • 将下一结点赋值给q;
  • 释放p;
  • 将q赋值给p

实现算法如下

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

p是一个结点,它除了有数据域,还有指针域。在做free(p);时,其实是在对整个结点进行删除和内存释放的工作。

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

存储方式 时间性能 空间性能
顺序存储结构用一段连续的存储结构单元依次存储线性表的数据元素 查找:顺序查找结构O(1),单链表O(n) 顺序存储结构需要预分配存储空间,分大了,浪费,分小了易发生上溢
单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素 插入和删除:顺序存储结构需要平均移动表长一半的元素,时间为O(n);单链表在找出某位置的指针后,插入和删除时为O(1) 单链表不需要分配存储空间,只要有就可以分配,元素个数不受限制

通过上面对比,可以得到一些结论:

  • 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。
  • 若需要频繁插入和删除时,宜采用单链表结构。

比如游戏开发中,对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况都是读取,所以应该考虑用顺序结构,而游戏中玩家的武器或者装备列表,随着玩家游戏的进行,可能随时会增加或者删除,此时应该使用链式存储结构。

当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,用顺序存储结构效率会高很多

总之,线性表的顺序存储结构和单链表结构都有各自的优缺点,根据实际情况选择

静态链表

用数组描述的链表叫做静态链表(游标实现法)。为了方便插入数据,通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出

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

/* 线性表的静态链表存储结构 */
/* 假设链表的最大长度是1000 */

#define MAXSIZE 1000                     
typedef struct
{    
    ElemType data;    
    /* 游标(Cursor),为0时表示无指向 */    
    int cur;                             
} Component,StaticLinkList[MAXSIZE];   /*对于不提供结构struct的程序设计语言,可以使用一对并行数组data和cur来处理。 */

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

36import.png

实现算法如下

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

假设已经将数据存入静态链表,比如分别存放着“甲”、“乙”、“丁”、“戊”、“己”、“庚”等数据,如下图所示

37import.png

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

静态链表的插入

静态链表中要解决的是:如何用静态模拟动态链表结构存储空间的分配,需要时申请,无用时释放。在动态链表中,结点的申请和释放分别借用malloc()和free()两个函数来实现。在静态链表中,操作的是数组,不存在像动态链表的结点申请和释放问题,所以我们需要自己实现这两个函数,才可以实现插入和删除操作。

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

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

上面代码部分,一方面它的作用是返回一个下标值,即数组头元素的cur存的第一个空闲的下标。对应上图就是返回7,然后把分量7的cur值赋值给头元素,即把8给space[0].cur,之后就可以继续分配新的空闲分量,实现类似malloc()函数的作用

现在如果我们要在“乙”和“丁”之间,插入一个值为“丙”的元素,按照以前顺序结构的做法,应该要把“丁”、“戊”、“己”、“庚”等数据元素都往后移动一位。但是目前不需要了,代码实现如下:

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

分析代码:

1)当执行插入语句时,目的是要在“乙”和“丁”之间插入“丙”。调用代码时,输入i值为3
2)第4行让k=MAXSIZE-1=999
3)第7行,j=Malloc_SSL(L)值为7。此时下标为0的cur也因为7要被占用而更改备用链表的值为8
4)第11~12行,for循环l由1到2,执行两次。代码k=L[k].cur;使得k=999,得到k=L[999].cur为1,再得到k=L[1].cur=2
5)第13行,L[j].cur=L[k].cur;因j=7,而k=2,所以得到L[7].cur=L[2].cur=3。
6)第14行,L[k].cur=j;意思就是L[2].cur=7。让“乙”的cur改为指向“丙”的下标7

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

静态链表的删除操作

如果这时候想删除甲元素,那么如何实现呢?

/*  删除在L中第i个数据元素   */  
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;     
}

其实就是告诉甲已经被删除,不再是第一个元素,修改最后一个元素的游标值为新的第一个元素的下标值,即乙

/*  将下标为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中,如下图

39import.png

静态链表也可以有其它操作,比如:求链表的长度

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

静态链表的优缺点

优点

在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进链在顺序结构中的插入和删除需要移动大量元素的缺点

缺点

没有解决连续存储分配带来的表长难以确定的问题
失去了顺序存储结构随机存取的特性

循环链表

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

为了使空链表与非空链表处理一致,我们通常设置一个头结点,当然,这并不是说,循环链表一定要头结点。循环链表带有头结点的空链表如下图:

40import.png

非空的循环链表

43import.png

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

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

也没有可能用O(1)的时间访问最后一个结点呢?当然可以。改造一下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表,这样查找开始结点和终端结点都很方便

42import.png

终端结点用尾指针rear指示,查找终端结点是O(1),开始结点是rear->next->next,时间复杂也为O(1)

举个例子:如果要将两个循环链表合并成一个表,那么有了尾指针就非常简单了。比如下面两个循环链表,它们的尾指针分别是rearA和rearB:

45import.png

要想合并,只需要如下的操作即可,如下图

46import.png

简单代码实现

P=rearA->next; //保存A表的头节点,即1

rearA->next=rearB->next->next; //将本是指向B表的第一个节点(不是头节点)赋值给rearA->next,即2

rearB->next=p; //将原A表的头节点赋值给rearB->next,即3

free(p); //释放p

双向链表

双向链表(double linkedlist)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱

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

既然单链表可以有循环链表,那么双向链表当前也可以是循环表。双向链表的循环带头结点的空链表:

58import.png

非空的循环的带头结点的双向链表:

48import.png

由于是双向链表,那么对于链表中的某一结点p,它的后继和前驱是谁?当然还是它自己,它的前驱的后继自然也是它自己,即:

p->next->prior = p = p->prior->next;

双向链表是单链表中扩展出来的结构,所以它的很多操作是和单链表相同的,比如:求长度的ListLength,查找元素的GetElem,获得元素位置的LocateElem等。双向链表比单向链表多链反向变量查寻等操作,那么自然也需要付出一些小的代价,在插入和删除时,需要改变两个指针变量。

插入操作时,实现并不复杂,但是要注意顺序不要写反了。假设存储元素e的结点为s,要实现将结点s插入到结点p和p->next之间需要下图所示:

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

关键是代码执行的顺序,因为第2步和第3步都用到链p->next,如果第4步先执行,那么p->next先变成链s,使得插入工作无法完成。所以执行的顺序是先搞定s的前驱和后继,再搞定后结点的前驱,最后解决前结点的后继

若要删除结点p,只需要下面两步:

屏幕快照 2018-09-03 下午4.01.56.png
/* 把p->next赋值给p->prior的后继,如图中1 */
p->prior->next = p->next;     
/* 把p->prior赋值给p->next的前驱,如图中2 */ 
p->next->prior = p->prior; 
/* 释放结点 */ 
free(p);

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

参考

《大话数据结构》

你可能感兴趣的:(数据结构 - 链表)