数据结构-链表

350 阅读12分钟

在上篇文章中我们分析讨论了线性表的顺序存储结构顺序表的原理和实现,这篇文章将分析讨论线性表另外一种存储结构链式存储结构的实现原理。

1、线性表的链式存储结构

链式存储结构存储线性表数据元素的方法是把存储有数据元素的结点用指针域构成链。指针是指向物理存储单元地址的变量,我们把一个有数据元素和一个或若干个指针域组成的结构体称为一个结点。其中数据域用来存放数据元素,指针域用来构造数据元素之间的关联关系。链式存储结构的特点就是数据元素之间的逻辑关系表现在结点的链接关系上。链式存储结构的线性表称为链表。根据指针域的不同和结点构造链的方法不同,链表主要分为单链表、单循环链表和双向循环链表三种

2、单链表

单链表中构成链表的结点只有一个指向直接后继结点的指针域

2.1 单链表的表示方法

根据上面的论述可以知道单链表中每一个结点的结构如下图所示:

可以定义单链表结点的结构如下:

typedef struct Node{
    DataType   data;//存放数据
    struct Node *next; //存放指向下一个元素结点的指针
} SLNode;

其中data用来存放数据元素,next用来存放指向下一个结点的指针。 单链表有带头结点和不带头结点结构两种。我们把指向单链表的指针称作头指针头指针所指的不存放数据元素的第一个结点称作头结点。通常情况下单链表构造成带头结点的单链表,这里对不带头结点的单链表不做讨论。 如下图所示是一个带头结点的单链表结构图。 head表示头指针,头结点的数据域部分通常图上阴影,以表示该结点为头结点。符号“^”表示空指针。空指针用来标识链表的结束。空指针在算法描述中用NULL表示。在顺序存储结构中,采用的是数组的方式来存储数据元素的,用户向系统申请的是一块地址连续的内存空间,所以在任意两个逻辑上相邻的数据元素在物理上也是相邻的。而链式存储结构中,初始化时为空链,每当有新的数据元素需要存储时,用户才向系统动态申请所需要的存储空间插入链中,而这些在不同时间申请的内存空间很可能是地址不连续的,所以在链式存储结构中任意两个逻辑上相邻的数据元素在物理上不一定相邻,数据元素的逻辑次序是通过链中的指针链接来实现的。

2.2 单链表的操作实现

2.2.1、单链表初始化 ListInitiate(SLNode **head)

单链表中的每一个结点是在需要的时候才向系统申请的,这称作为动态内存空间申请。动态申请的内存空间,当不再需要时,必须要由申请者自己释放。

void ListInitiate(SLNode **head){
    *head = (SLNode *)malloc(sizeof(SLNode));//申请头结点
    (*head)->next = NULL;
}

在初始化操作前,头指针参数head没有具体的地址值,在初始化操作时,头指针参数head才得到了具体的地址值,而这个地址值要返回给调用函数,所以此时头指针参数head要设计成指针的指针类型。

2.2.2、获取当前元素个数 ListLength(SLNode *head)
int  ListLength(SLNode *head){
    SLNode *p = head;
    int size = 0;
    while (p->next != NULL){//当结点的下一个指针区域数据为NULL时,表示表结构的结束
        p = p->next;
        size++;
    }
    return size;
}
  1. 在循环前指针变量p指向头结点,计数变量size赋值0。
  2. 循环结束的条件为p->next != NULL,在循环中,每次让指针p指向它的值指向后继结点,让计数变量size加1。
  3. 函数返回计数变量size。
2.2.3、插入数据元素 ListInsert(SLNode *head,int i,DataType x)

在带头结点的单链表的第i(0isize0\leq i\leq size)个位置前插入数据元素x的结点,插入成功返回1,插入失败返回0。

int ListInsert(SLNode *head,int i,DataType x){
    SLNode *p = head;
    int j = -1;
    /*找到插入位置的前一个结点位置i-1*/
    while (p->next != NULL && j < i-1){
        p = p->next;
        j++;
    }
    if(j != i-1){
        printf("插入的位置参数错误");
        return 0;
    }
    SLNode *q = (SLNode *)malloc(sizeof(SLNode));//生成一个新的结点
    q->data = x;//将x的值赋值给新的结点

    q->next = p->next;//新的结点的指针指向I结点
    p->next = q;//修改p的指针域指向新的结点
    return 1;
}
  1. 首先在单链表中寻找到第i-1个结点并由指针p指示。
  2. 动态申请一个结点存储空间并由指针q指示,并把数据元素x赋值给新结点的数据元素域q->data = x;
  3. 修改新结点的指针域指向aia_i结点, q->next = p->next;
  4. 修改ai1a_{i-1}结点的指针域指向新结点q,p->next = q;

插入的操作如下图所示:

2.2.4、删除数据元素 ListDelete(SLNode *head,int i,DataType *x)

删除带头结点的单链表的第i(0isize0\leq i\leq size)个结点,被删除的结点的数据域值由x带回,删除成功返回1,删除失败返回0。

int ListDelete(SLNode *head,int i,DataType *x){
    SLNode *p = head;
    int j = -1;
    while (p->next != NULL && p->next->next != NULL && j < i-1){
        p = p->next;
        j++;
    }
    if(j != i -1){
        printf("插入的位置参数错误");
        return 0;
    }
    SLNode *s = p->next;
    *x = s->data;
    p->next = p->next->next;
    free(s);
    return 1;
}
  1. 在单链表中找到第i-1个结点并由指针p指示。
  2. 让指针s指向aia_i结点:SLNode *s = p->next,把结点aia_i的数据元素值赋值给x:*x = s->data。
  3. aia_i结点脱链:p->next = p->next->next 并且释放aia_i结点的存储空间:free(s)。

删除数据元素的操作如下图所示:

2.2.5、取数据元素 ListGet(SLNode *head,int i,DataType *x)

获取带头结点单链表的第i(0isize0\leq i\leq size)个位置结点的数据域的值,由x带回,获取成功返回1,获取失败返回0。

int ListGet(SLNode *head,int i,DataType *x){
    SLNode *p = head;
    int j = -1;
    while (p->next != NULL && j < i){
        p = p->next;
        j++;
    }

    if(j != i){
        printf("取元素的位置参数错误");
        return 0;
    }

    *x = p->data;
    return  1;
}
2.2.6、销毁单链表 ListDestory(SLNode **head)

因为单链表的结点空间是程序动态申请的,而系统只负责自动回收程序中静态分配的内存空间,所以和顺序表相比,单链表需要增加一个销毁单链表的操作,用来在调用程序退出前释放动态申请的内存空间。

void ListDestory(SLNode **head){
    SLNode *p ,*p1;
    p = *head;
    while (p->next != NULL){
        p1 = p;
        p = p->next;
        free(p1);
    }
    *head = NULL;
}

2.3、单链表操作的时间复杂度

单链表的插入和删除操作的时间复杂度分析方法和顺序表的插入和删除操作的时间复杂度分析方法类同。差别是单链表的插入和删除操作不需要移动数据元素,只需要比较数据元素。因此当在单链表的任何位置上插入数据元素的概率相等时,在单链表中插入一个数据元素,比较数据元素的平均次数为:\

Eis=i=0nPi(ni)=1n+1i=0n(ni)=n2E_{is} = \sum\limits_{i=0}^n P_{i}(n-i) = \frac{1}{n+1} \sum\limits_{i=0}^n (n-i) = \frac{n}{2}

删除单链表中的一个数据元素,比较数据元素的平均次数为:\

Edl=i=0n1qi(ni)=1ni=0n1(ni)=n12E_{dl} = \sum\limits_{i=0}^{n-1} q_{i}(n-i) = \frac{1}{n} \sum\limits_{i=0}^{n-1} (n-i) = \frac{n-1}{2}

由上面的分析可知:

  1. 单链表中插入和删除一个数据元素的平均时间复杂度为O(n)。
  2. 求单链表数据元素个数操作和销毁单链表操作的时间复杂度为O(n)。
  3. 单链表中取数据元素操作和数据元素个数n无关,其时间复杂度为O(1)。
  4. 和顺序表相比,单链表的主要优点在于不需要预先确定数据元素的最大个数;主要缺点是每个结点中要有一个指针域,因此空间单元利用效率不高,而且单链表操作的算法比较复杂。

2.4、循环单链表

循环单链表是单链表的另一种形式,其结构特点是链表中最后一个结点的指针域不再是结束标记,而是指向整个链表的第一个结点,从而使链表形成一个环。和单链表一样循环单链表也有带头结点结构和不带头结点结构两种,带头结点的循环单链表实现插入和删除操作较为方便。实际应用中带头结点的循环单链表更为常见。 如下图所示是带头结点的循环单链表结构。

单链表的特点是从链表头到链表尾比较方便,但无法从链表尾到链表头,而循环单链表的长处是从链表尾到链表头比较方便。当要处理的数据元素序列具有环形结构特点时,适于采用循环单链表。 带头结点的循环单链表的操作实现和带头结点的单链表的操作实现方法类同,差别有如下两点:

  1. 在初始化函数中把语句(*head)->next = NULL修改为(*head)->next = *head。
  2. 在其他函数中循环判断条件p->next != NULL和p->next->next != NULL中的NULL改为头指针head。

3、双向链表

双向链表中每一个结点除后继指针域外还有一个前驱指针域。和单链表一样,双向链表也有带头结点和不带头结点两种结构,带头结点的双向链表更为常见。另外,双向链表也可以有循环和非循坏两种结构,循环结构的双向链表更为常用。 在双向循环链表中,每个结点包括三个域,分别是data域、next域和prior域,其中data域为数据域,next域为指向后继结点的指针域,prior域为指向前驱结点的指针域。如下图所示是双向循环链表结点的结构图。

由此总结出用C语言表示的双向循环链表如下:

typedef struct Node{
    DataType   data;//存放数据
    struct Node *next; //存放指向后继结点的指针
    struct Node *prior; //存放指向前驱结点的指针
} DLNode;

在单链表中查找当前结点的后继结点并不困难,可以通过当前结点的next指针进行,但是要查看当前结点的前驱结点,就要从头指针head开始重新进行。对于一个需要频繁进行查找当前结点的后继结点和当前结点的前驱结点的程序来说,使用单链表的时间效率是非常低的,双向链表是有效解决这类问题的最佳选择。 带头结点的双向循环链表的结构图如下图所示。

3.1、双向循环链表的操作实现

在双向循环链表中,设指针p指向双向循环链表中的第i个结点,则p->next指向第i+1个结点,p->next->prior仍指向第i个结点,即p->next->prior==p;同理p->prior指向的是第i-1个结点,p->prior->next仍指向第i个结点,即p->prior->next==p。

3.1.1、初始化 ListInitiate(DLNode **head)

在初始化操作前,头指针参数head没有具体的地址值,在初始化操作时,头指针参数head才得到了具体的地址值,而这个地址值要返回给调用函数,所以此时头指针参数head要设计成指针的指针类型。

void ListInitiate(DLNode **head) {
    *head = (DLNode *) malloc(sizeof(DLNode));
    (*head)->prior = *head;
    (*head)->next = *head;
}
3.1.2、插入数据元素 ListInsert(DLNode *head, int i, DataType x)

在带头结点的双向循环链表head第i (0isize0\leq i\leq size)个位置前插入数据元素x的结点,插入成功返回1,插入失败返回0。

int ListInsert(DLNode *head, int i, DataType x) {
    DLNode *p = head->next;
    int j = 0;
    while (p != head && j < i) {
        p = p->next;
        j++;
    }

    if (j != i) {
        printf("插入的位置错误");
        return 0;
    }

    DLNode *s = (DLNode *) malloc(sizeof(DLNode));
    s->data = x;

    s->prior = p->prior;
    p->prior->next = s;
    s->next = p;
    p->prior = s;
    return 1;
}
  1. 首先在链表中找到第i个位置的结点用p表示。
  2. 动态申请一个结点存储空间并由指针s指示,并把数据元素x赋值给新结点的数据元素域s->data = x;
  3. 修改新结点的前驱结点的指针指向p的前驱结点:s->prior = p->prior,p的前驱结点的后继结点指针指向新结点s:p->prior->next = s。
  4. 新结点s的后继结点指针指向p:s->next = p。
  5. p的前驱结点指针指向s。

插入操作如下图所示:

3.1.3、删除数据元素 ListDelete(DLNode *head, int i, DataType *x)

删除带头结点的双向循环链表的第i(0isize0\leq i\leq size)个结点,被删除的结点的数据域值由x带回,删除成功返回1,删除失败返回0。

int ListDelete(DLNode *head, int i, DataType *x) {
    DLNode *p = head->next;
    int j = 0;
    while (p->next != head && j < i) {
        p = p->next;
        j++;
    }
    *x = p->data;
    if(j!=i){
        printf("删除位置参数错误");
        return 0;
    }
    p->prior->next = p->next;
    p->next->prior = p->prior;
    free(p);
    return 1;
}
  1. 首先在链表中找到第i个位置的结点用p表示。
  2. 将第i个结点的数据元素的值赋值给x:*x = p->data。
  3. 修改p的前驱结点的后继结点的指针指向p的后继结点:p->prior->next = p->next。
  4. 修改p的后继结点的前驱结点的指针指向p的前驱结点。

删除操作如下图所示:

3.1.4、获取当前元素个数ListLength(DLNode *head)
int ListLength(DLNode *head){
    DLNode *p = head;
    int size = 0;
    while (p->next != head){
        p = p->next;
        size++;
    }
    return size;
}
  1. 在循环前指针变量p指向头结点,计数变量size赋值0。
  2. 循环结束的条件为p->next != head,在循环中,每次让指针p指向它的值指向后继结点,让计数变量size加1。
  3. 函数返回计数变量size。
3.1.5 销毁单链表ListDestory(DLNode **head)

和单链表一样,双向循环链表的结点空间是程序动态申请的,而系统只负责自动回收程序中静态分配的内存空间。

void ListDestory(DLNode **head){
    DLNode *p = *head;
    DLNode *p1;
    int n = ListLength(*head);
    for (int i = 0; i <= n; i++) {
        p1 = p;
        p = p->next;
        free(p1);
    }
    *head = NULL;
}