数据结构与算法----单向链表

1,416 阅读11分钟

前言

《数据结构与算法----顺序表》中总结了自己对于顺序表的学习,这里简单回顾一下

  • 顺序表是采用顺序存储结构进行存储的线性结构,
  • 其在查找方面具有比较大的优势,可以根据下标进行查找,时间复杂度为0(1)
  • 对于插入和删除操作,其时间复杂度为O(N),查找效率较低
  • 在空间方面,顺序表需要初始化时确定空间大小,有可能会出现开辟空间不足或过大的情况,不够灵活,但是在删除元素时不需要考虑空间的释放 本篇博客总结一下线性表的另一种存储结构----链式存储结构。

一、单链表

线性表在内存中以链式结构存储,就是链表。链表根据是否有前驱和后继,又可以分为单向链表和双向链表,本篇先讨论单向链表的情况。

单向链表是指一个结点,包含一个数据域和指针域,其中数据域用于存储数据,指针域存储下一个结点的指针地址。其定义可以如下代码所示:

typedef struct Node {

    ElemType data;      //!< 数据域

    struct Node *next;  //!< 指针域,存储下一结点的指针地址

}LinkNode;

1、单向链表的初始化

在进行初始化之前,先解释几个概念:

  • 头结点:指向链表的第一个结点,不存储值,数据域为空或者无意义的值
  • 首元结点:链表的第一个结点,存储实际意义的值
  • 尾结点:链表的最后一个结点,如果是循环链表,其后继指向头结点

实际上头结点起到的是一个标志作用,不设置头结点也可以实现链表,不过在后期的操作时,代码会相对麻烦一些。如下是单链表的初始化代码:

typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */

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

typedef struct Node {

    ElemType data;      //!< 数据域

    struct Node *next;  //!< 指针域,存储下一结点的指针地址

}LinkNode;

typedef LinkNode * LinkList;

Status initLinkList(LinkList *L) {

    *L = (LinkList)malloc(sizeof(LinkNode));
    if (*L == NULL) { // 容错,开辟失败的情况
        return ERROR;
    }

    LinkList temp = NULL;
    LinkList r = *L;
    for (int i = 0; i <= 10; i++) { // 此处默认加上了0~10,也可以不加,不加的话for循环可以去掉
        temp = (LinkNode *)malloc(sizeof(LinkNode));
        temp->data = i;
        r->next = temp;
        r = temp;
    }
    r->next = NULL;
    return OK;
}

2、单链表的遍历

单链表的遍历比较简单,代码如下:

void displayLink(LinkList L) {

    if (L->next == NULL) {
        printf("链表为空");
        return;
    }

    LinkList temp = L->next;
    printf("链表元素数据为:\n");
    while (temp) {
        printf("%d ", temp->data);
        temp = temp->next;
    }
    printf("\n");
}

L为单链表的头结点,如果其后继结点为空,则说明该链表没有存储实际意义的值,因此判定链表为空。当遍历链表时,执行 temp = temp->next; 语句,当temp为空时,表明链表遍历完毕。

3、前插法构建单链表

单链表的构建分为两种,即前插法和后插法,本小节先解释一下前插法。

前插法,顾名思义,链表元素由前部插入。链表为空时,插入一个元素,则将头结点指向该元素,再有元素插入时,将新元素的后继指向头结点的后继,再将头结点的后继指向新插入的结点,如下图所示:

Xnip2021-09-26_23-16-08.png

其代码如下:

Status headInsert(LinkList *L) {

    *L = (LinkList)malloc(sizeof(LinkNode));

    if (*L == NULL) {

        return ERROR;

    }
    
    (*L)->next = NULL;  // 头结点的后继先置为NULL,将影响到插入的第一个新结点的后继

    LinkList temp = NULL;

    for (int i = 0; i <= 10; i++) {

        temp = (LinkList)malloc(sizeof(LinkNode));

        temp->data = i;

        temp->next = (*L)->next; // 每次都将头结点的next赋值给新结点的next

        (*L)->next = temp; // 让头结点的next指向新的结点

    }
    return OK;
}

4、后插法构建单链表

后插法与前插法正好相反,当插入第一个元素时,将头结点的next指向插入元素,该元素的next指向NULL,再有新元素插入时,则将上一个元素的next指向新元素,新元素的next指向NULL,如下图所示:

Xnip2021-09-26_23-31-36.png

其代码如下:

Status tailInsert(LinkList *L) {
    *L = (LinkList)malloc(sizeof(LinkNode));
    if (*L == NULL) {
        return ERROR;
    }
    LinkList temp = NULL;
    LinkList r = *L;  // 变量 r 记录最后一个结点的位置,初始值为头结点
    for (int i = 0; i <= 10; i++) {
        temp = (LinkNode *)malloc(sizeof(LinkNode));
        temp->data = i;
        r->next = temp; // 将新结点插入链表末尾
        r = temp; // 变换 r 的值,此时最后一个结点为新插入的结点,将r指向该结点
    }
    r->next = NULL; // 将最后一个结点的next置为NULL,防止遍历时崩溃
    return OK;
}

对照前插法和后插法,可以发现相对于前插法,后插法会多一个临时的标记变量 r,但是实际使用时后插法更加符合逻辑。

5、单链表的插入与删除

需要注意的是,前插法和后插法都是构建单链表的一种方式,都是在链表前部或后面插入新的结点,而不是从中间插入。本小节就看下单链表的插入和删除。

1.5.1 单链表的插入

与顺序结构不同,单链表的插入不需要将插入位置之后的元素都向后移动,而是更改插入位置前一个结点的指向以及新结点的指向即可,具体可以分为以下几步:

  • 1、找到插入位置的前一个位置target
  • 2、新结点temp的next指向 target的next
  • 3、target的next指向新结点 temp

具体看参照下图所示:

Xnip2021-09-27_09-26-03.png

需要注意的一点是,第2步必须在第3步之前,否则就无法找到原本的后继结点了。插入代码如下:

Status insertElement(LinkList *L, ElemType e, **int** place) {
    if (!(*L) || place < 1) {
        return ERROR;
    }
    LinkList target = (*L)->next;

    // 找到插入位置的前一个结点
    for (int i = 1; i < place && target; target = target->next, i++) {}

    // 判断是否找到位置
    if (!target) return ERROR;
    // 创建新结点
    LinkList temp = (LinkList)malloc(sizeof(LinkNode));
    if (!temp) {
        return ERROR;
    }
    temp->data = e;
    // 将新结点的next指向 targe的next
    temp->next = target->next;
    target->next = temp;
    return OK;
}

1.5.2 单链表的删除

与插入相比,单链表的删除步骤相对简单,不过都需要一个共同的步骤,即找到操作结点的位置,总结一下分为一下几个步骤:

  • 1、找到待删除结点位置的前一个位置target
  • 2、将target的next指向待删除结点的next
  • 3、释放待删除结点的内存,完成删除

步骤可参照下图:

Xnip2021-09-27_10-24-02.png

代码如下所示:

Status deleteElement(LinkList *L, **int** place) {
    if ((*L)->next == NULL) {
        printf("链表为空");
        return ERROR;
    }

    LinkList target = (*L)->next;
    
    // 找到待删除结点位置的上一个结点
    for (int i = 1; i < place && target; target = target->next, i++) {}

    if (!target) {
        return ERROR;
    }
    LinkList temp = target->next; // 得到要删除的结点
    target->next = temp->next; // 将上一结点的next指向待删除结点的next
    free(temp); // 释放待删除结点的内存,完成删除

    return OK;
}

二、单向循环链表

单向循环链表与单链表的区别在于,单链表的最后一个结点的next指向 NULL,而单向循环链表的最后一个结点的next指向头结点(如果设置了头结点)或者首元结点(没有设置头结点的情况),如下图所示:

Xnip2021-09-27_11-06-30.png

在单链表中我们引入头结点实现了增删改查,本节不使用头结点的方式,来实现一下单向循环链表的增删改查。

1、单向循环链表的初始化

在初始化单向循环链表时,与单链表不同,每次新加入的结点要将其next指向首元结点,这里采用不加入头结点的方式,代码如下:

Status initLinkList(LinkList *L) {
    LinkList temp = NULL;
    printf("输入你想输入的数字并按空格键,输入0结束输入\n");
    LinkList r = NULL;
    int item = 0;
    while (1) {
        scanf("%d", &item); // 采用scanf函数输入的方式
        if (item == 0) {
            // 输入0结束循环
            break;
        }

        if (*L == NULL) {
            // 链表为空的情况
            *L = (LinkList)malloc(**sizeof**(LinkNode));
            if (!(*L)) {
                return ERROR;
            }
            // 此时L即为首元结点
            (*L)->data = item;
            (*L)->next = *L; // 与单链表不同的是,这里要指向自己
            r = *L;
        } else {
            // 链表不为空
            temp = (LinkList)malloc(**sizeof**(LinkNode));
            if (!temp) {
                return ERROR;
            }
            temp->data = item;
            temp->next = *L; // 与单链表不同,这一指向首元结点
            r->next = temp;
            r = temp; // 添加一个变量 r,用于记录最后一个结点的位置,不加的话需要先找到最后一个结点
        }
    }
    return OK;
}

2、单向循环链表的遍历

单向循环链表的遍历,需要考虑两个方面:

  • 1、链表为空的判断,循环链表是否为空,判断链表的首元结点是否为空即可,这里直接判断 L != NULL
  • 2、循环结束条件,单向循环链表的遍历不同于单链表的遍历,不能简单判断结点是否为空,而是要判断结点的next与首元结点是否相同 代码如下所示:
void display(LinkList L) {
    if (L == NULL) {
        // 因为未引入头结点,所以直接判断 L 是否为空即可
        printf("链表为空");
        return;
    }

    LinkList temp = L;
    // 使用do while 循环,否则在while循环前要先调用一次 temp = temp->next;
    do {
        printf("%d ", temp->data);
        temp = temp->next;
    } while (temp != L);

    printf("\n");
}

3、单向循环链表的插入

因为没有使用头结点,所以需要考虑插入结点是否是首元结点的情况:

  • 插入结点为首元结点:与单链表不同,单向循环链表不能直接将新结点的next指向原首元结点,而是要先找到尾结点,并将新结点next指向原首元结点(尾结点的next),再尾结点的next指向新结点,以此保证插入结点后链表依然是循环链表
  • 插入结点不是首元结点:找到插入结点的前一个结点target,将新结点的next指向target的next,再将target的next指向新结点,即完成插入

过程如下图所示:

插入位置为首元结点

插入位置为非首元结点

代码分为两种,下标从0开始和下标从1开始,但是其逻辑基本与上图一致,其代码分别如下所示:

1、下标从0开始

Status insertLinkList(LinkList *L, int place, ElemType e) {
    LinkList target = NULL;
    LinkList temp = NULL;
    if (place == 0) {
        // 首元结点
        // 先创建一个新结点,成功则插入,否则返回 ERROR
        // 循环链表,需要先找到最后一个结点
        // 将新结点的next指向原来的首元结点,将尾结点的next指向新结点,并将首元结点改为新结点
        for (target = *L; target->next != *L; target = target->next) {}

        if (target == NULL) {
            return ERROR;
        }
        temp = (LinkList)malloc(sizeof(LinkNode));
        if (!temp) {return ERROR;}

        temp->data = e;
        temp->next = *L;
        target->next = temp;
        *L = temp;
    } else {
        // 非首元结点
        int i = 1;

        for (target = (*L); target->next != *L && i < place; target = target->next, i++) {}

        if (target == NULL) {
            return ERROR;
        }

        temp = (LinkList)malloc(sizeof(LinkNode));

        if (!temp) {return ERROR;}
        temp->data = e;
        temp->next = target->next;
        target->next = temp;
    }
    return OK;
}

2、下标从1开始

Status insertLinkList1(LinkList *L, int place, ElemType e) {

    LinkList target = NULL;
    LinkList temp = NULL;

    if (place == 1) {
        // 首元结点
        // 先创建一个新结点,成功则插入,否则返回 ERROR
        // 循环链表,需要先找到最后一个结点
        // 将新结点的next指向原来的首元结点,将尾结点的next指向新结点,并将首元结点改为新结点

        for (target = *L; target->next != *L; target = target->next) {}

        if (target == NULL) {
            return ERROR;
        }
        temp = (LinkList)malloc(sizeof(LinkNode));
        if (!temp) {return ERROR;}
        temp->data = e;
        temp->next = *L;
        target->next = temp;
        *L = temp;
    } else {
        // 非首元结点
        int i = 1;

        for (target = (*L)->next; target->next != *L && i < place - 1; target = target->next, i++) {}

        if (target == NULL) {
            return ERROR;
        }

        temp = (LinkList)malloc(sizeof(LinkNode));
        if (!temp) {return ERROR;}
        temp->data = e;
        temp->next = target->next;
        target->next = temp;
    }

    return OK;
}

对比两部分代码,可以发现插入位置为首元结点时,下标为0或者1,对于代码的影响并不大;对于插入位置为非首元结点的情况,则需要修改下查找上一结点位置的代码,其余逻辑也无需改变。

4、单向循环链表的删除

与插入一致,单向循环链表在删除时,也要考虑删除位置为首元结点和非首元结点的情况:

  • 首元结点:首先找到尾结点,再将尾结点的next指向待删除结点的next,之后删除结点,并释放内存
  • 非首元结点:找到待删除结点的前一个位置targe,将target的next指向待删除结点的next,之后删除结点,并释放内存。 代码如下所示(这里只展示下标从1开始的情况):
Status  LinkListDelete(LinkList *L,int place){

    LinkList temp,target;
    int i;
    //temp 指向链表首元结点
    temp = *L;

    if (temp == NULL) return ERROR;
    if (place == 1) {
        //①.如果删除到只剩下首元结点了,则直接将*L置空;
        if ((*L)->next == (*L)){

            (*L) = NULL;
            return OK;
        }

        //②.链表还有很多数据,但是删除的是首结点;
        //1. 找到尾结点, 使得尾结点next 指向头结点的下一个结点 target->next = (*L)->next;
        //2. 新结点做为头结点,则释放原来的头结点
        
        for (target = *L; target->next != *L; target = target->next);

        temp = *L;
        *L = (*L)->next;
        target->next = *L;
        free(temp);
    } else {
        //如果删除其他结点--其他结点
        //1. 找到删除结点前一个结点target
        //2. 使得target->next 指向下一个结点
        //3. 释放需要删除的结点temp
        for (i=1,target = *L;target->next != *L && i != place -1;target = target->next,i++) {};

        temp = target->next;
        target->next = temp->next;
        free(temp);
    }
    return OK;
}

总结

本文主要总结了单向链表的操作,包括单链表和单向循环链表。在探索的过程中对于是否引用头结点,做了对比,可以发现在不引入头结点的情况下,需要多考虑一种情况,当然如果不在意这一点也可以不引入,这一点看个人习惯。

通过本篇的总结,我们可以发现只看单链表删除和插入操作时,其时间复杂度为O(1),而其查询操作为O(n),但是在开辟空间时不需要考虑容量的问题,通过自己手动释放内存来进行内存的管理。本篇主要是单向链表的总结,如果有不正确的地方欢迎大家指正,下一篇将进行双向链表的总结。