1. 链表的概念及结构
1.1 链表的概念
链表是一种 物理存储结构上非连续、非顺序的存储结构,数据元素的 逻辑顺序 是通过链表中的指针链接次序实现的 。
下图为可能的物理分布
1.2 链表的优缺点
优点:
1、按需申请空间,不使用就释放空间(更合理地使用空间)。
2、在链表头部/中间插入/删除数据,不需要挪动数据。
3、不存在空间浪费。
缺点:
1、每一个数据,都要储存一个指针链接后面的数据节点。
2、不支持随机访问(用下标直接访问第 i 个)。
1.3 链表的结构分类
在实际中,链表的结构非常多样,分类有如下几种:
1、单向链表 与 双向链表
2、不带头链表 与 带头链表(此处 “头” 也称为 “哨兵”)
3、非循环链表 与 循环链表
将以上结构排列组合起来,可得有八种结构的链表。在实际中,最常用的两种结构是 无头单向非循环链表 和 带头双向循环链表。
(1)无头单向非循环链表 :结构简单,一般不会单独用于存储数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等。
(2)带头双向循环链表 :结构最复杂,一般用于单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这种结构虽然结构复杂,但是在使用代码实现以后,会发现结构会带来很多优势,实现反而简单了。
2. 单链表的实现
代码的实现将放在三个文件中,分别是 SList.h(用于声明)、SList.c(用于定义)、Test.c(用于测试)。
SList.h 文件
#pragma once #include<stdio.h> #include<stdlib.h> #include <string.h> #include <assert.h> typedef int SLTDataType; // 单链表的结构 typedef struct SListNode{ SLTDataType data; // 数据域,储存数据 struct SListNode* next; // 指针域,储存下一个节点的地址 }SListNode; // 申请一个新节点 SListNode* BuySListNode(SLTDataType x); // 打印单链表 void SListPrint(SListNode* phead); // 尾插 void SListPushBack(SListNode* phead, SLTDataType x); // 头插 void SListPushFront(SListNode** pphead, SLTDataType x); // 尾删 void SListPopBack(SListNode** pphead); // 头删 void SListPopFront(SListNode** pphead); // 查找 SListNode* SListFind(SListNode* phead, SLTDataType x); // 在 pos 位置之前插入一个节点 void SListInsertBefore(SListNode** pphead, SListNode* pos, SLTDataType x); // 在 pos 位置之后插入一个节点 void SListInsertAfter(SListNode* pos, SLTDataType x); // 删除 pos 位置的节点 void SListErase(SListNode** pphead, SListNode* pos); // 删除 pos 位置之后的节点 void SListEraseAfter(SListNode* pos); // 销毁 void SListDestroy(SListNode** pphead);
2.1 申请一个新节点
SList.c 文件
#include "SList.h" // 申请一个新节点 SListNode* BuySListNode(SLTDataType x){ SListNode* newnode = (SListNode*)malloc(sizeof(SListNode)); // malloc 后需要检查是否申请成功,因此要判断 newnode 是否为空 if (newnode == NULL){ printf("malloc fail\n"); exit(-1); } newnode->data = x; // x 的值储存在 newnode 数据域中 newnode->next = NULL; // newnode 的指针域置空 return newnode; }
2.2 打印单链表
SList.c 文件
#include "SList.h" // 打印单链表 void SListPrint(SListNode* phead){ SListNode* cur = phead; while (cur != NULL){ printf("%d->", cur->data); // 打印链表数据 cur = cur->next; // 让 cur 指向下一个数据 } // 当 cur 为空时,链表打印结束 printf("NULL\n"); }
2.3 尾插
思路:
(1)如果链表本身是空链表,则把新申请的节点分配给 phead。需要注意的是:形参是实参的临时拷贝,形参的改变不影响实参。这里要改变实参 plist,形参 phead 是实参 plist 的一份临时拷贝,形参 phead 的改变不影响实参 plist,所以要传 plist 的地址,而 plist 本身就是指针,所以要传指针的地址,那么就要用二级指针来接收。
(2)如果链表不是空链表,此时就需要创建一个指针变量 tail 来遍历链表,找到链表的最后一个节点,然后把新创建的节点插入进去。
SList.c 文件
#include "SList.h" // 尾插 void SListPushBack(SListNode** pphead, SLTDataType x){ assert(pphead); // 申请一个新节点 SListNode* newnode = BuySListNode(x); if (*pphead == NULL){ *pphead = newnode; } else{ // 寻找尾节点 SListNode* tail = *pphead; while (tail->next != NULL){ tail = tail->next; } // 找到尾节点,将尾节点的指针域指向插入的新节点的地址 tail->next = newnode; } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.4 头插
思路: 头插相对于尾插简单一点,只需要把新创建的节点放入链表头部,需要注意的是头插和尾插一样,需要传二级指针。
SList.c 文件
#include "SList.h" // 头插 void SListPushFront(SListNode** pphead, SLTDataType x){ assert(pphead); // 申请一个新节点 SListNode* newnode = BuySListNode(x); newnode->next = *pphead; // newnode 的指针域指向链表的数据域(链表的头) *pphead = newnode; // 让 newnode 作为新链表的头 }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); // 头插 for (int i = 1; i < 4; i++){ SListPushFront(&plist, i * 10); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.5 尾删
思路: 寻找最后一个节点,定义一个指针变量 tail 遍历链表,当 tail->next == NULL 时就找到了尾节点。但是当 free 最后一个节点时,无法找到倒数第二个节点,而尾删需要将倒数第二个节点的 next 置为空,所以还需要创建一个指针变量 prev,尾删后将 prev->next 置为空。
另外,尾删还需要分链表为空无节点、只有一个节点、多个节点三种情况判断。
SList.c 文件
#include "SList.h" // 尾删 void SListPopBack(SListNode** pphead){ assert(pphead); // 链表为空时,粗暴的处理 // assert(*pphead != NULL); // 链表为空时,温柔的处理 if (*pphead == NULL){ return; } // 只有一个节点 else if ((*pphead)->next == NULL){ free(*pphead); *pphead = NULL; } // 有多个节点 else{ // 先找到尾节点,再记录尾节点的前一个节点 SListNode* tail = *pphead; SListNode* prev = NULL; while (tail->next != NULL){ prev = tail; tail = tail->next; } // 找到尾节点,释放并置空; free(tail); tail = NULL; // 此时尾节点的前一个节点(prev)还保存着 tail 节点的数据的地址,要将它置空; prev->next = NULL; } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); // 尾删 SListPopBack(&plist); SListPopBack(&plist); // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.6 头删
思路: 当链表为空时,不用删除直接返回空链表。当链表不为空时,可以创建一个指针变量 next 来保存第二个节点的地址,即第一个节点的 next,然后 free 第一个节点,将 next 赋值给 *pphead。
SList.c 文件
#include "SList.h" // 头删 void SListPopFront(SListNode** pphead){ assert(pphead); if (*pphead == NULL){ return; } else{ // 记录头节点储存的下一个节点的地址 SListNode* next = (*pphead)->next; free(*pphead); // 释放头节点 *pphead = next; // 头节点的下一个节点作新的头节点 } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); // 头删 SListPopFront(&plist); SListPopFront(&plist); // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.7 查找、修改
思路: 遍历链表。
SList.c 文件
#include "SList.h" // 查找 SListNode* SListFind(SListNode* phead, SLTDataType x){ SListNode* cur = phead; while (cur != NULL){ if (cur->data == x){ return cur; } else{ cur = cur->next; } } return NULL; }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 SListPushBack(&plist, 1); SListPushBack(&plist, 2); SListPushBack(&plist, 3); SListPushBack(&plist, 4); SListPushBack(&plist, 2); SListPushBack(&plist, 5); SListPushBack(&plist, 2); // 打印单链表 SListPrint(plist); // 查找 SListNode* pos = SListFind(plist, 2); int i = 1; while (pos){ printf("找到了,第 %d 个 pos 节点:%p->%d\n", i++, pos, pos->data); pos = SListFind(pos->next, 2); } // 修改 pos = SListFind(plist, 3); if (pos){ pos->data = 30; } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.8 在 pos 位置之前插入一个节点
思路:
(1)pos 不在头节点 。pos 是节点的地址,通过 SListFind 函数找到 pos。同时还需要找到 pos 前一个节点的地址,只有这样才能将链表的节点依次链接起来。可以创建一个指针变量 posPrev,然后遍历链表,当 posPrev->next==pos 时,posPrev 就是 pos 前一个节点的地址,然后创建一个新的节点,实现节点的插入。
(2)pos 在头节点。此时相当于头插。
SList.c 文件
#include "SList.h" // 在 pos 位置之前插入一个节点 void SListInsertBefore(SListNode** pphead, SListNode* pos, SLTDataType x){ assert(pphead); assert(pos); // 申请一个新节点 SListNode* newnode = BuySListNode(x); // 如果 pos 是头节点 if (*pphead == pos){ newnode->next = *pphead; *pphead = newnode; // pos 是第一个节点,相当于头插 // SListPushFront(pphead, x); } // 如果 pos 不是头节点 else{ // 找到 pos 位置的前一个节点 SListNode* posPrev = *pphead; while (posPrev->next != pos){ posPrev = posPrev->next; } // 让新节点的指针域指向 pos,再让 pos 的前一个节点的指针域重新指向新节点的数据域地址,就插入到 pos 的前面了 newnode->next = pos; posPrev->next = newnode; } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 5; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); SListNode* pos = SListFind(plist, 3); if (pos){ SListInsertBefore(&plist, pos, 10); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.9 在 pos 位置之后插入一个节点
思路:更加推荐在 pos 位置之后插入的方式。 因为相较于在 pos 位置之前插入来说更加方便,不需要寻找 pos 位置的上一个节点。
SList.c 文件
#include "SList.h" // 在 pos 位置之后插入一个节点 void SListInsertAfter(SListNode* pos, SLTDataType x){ assert(pos); // 申请一个新节点 SListNode* newnode = BuySListNode(x); // 让新节点的指针域指向 pos 位置的下一个节点的数据域地址 newnode->next = pos->next; // pos 的指针域指向新节点的数据域地址 pos->next = newnode; }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 5; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); SListNode* pos = SListFind(plist, 3); if (pos){ SListInsertAfter(pos, 10); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.10 删除 pos 位置的节点
思路: 当 pos 为第一个节点时,只需要调用头删。当 pos 不是第一个节点时,需要创建一个指针变量 posPrev 指向 pos 的前一个节点,然后将 posPrev->next 指向 pos 的下一个节点,再将 pos 节点 free 掉。
SList.c 文件
#include "SList.h" // 删除 pos 位置的节点 void SListErase(SListNode** pphead, SListNode* pos){ assert(pphead); assert(pos); // 当 pos 为第一个节点时 if (*pphead == pos){ // 方法一:调用头删函数 SListPopFront(pphead); // 方法二 //pphead = pos->next; //free(pos); } else{ // 找到 pos 位置的前一个位置(posPrev) SListNode* posPrev = *pphead; while (posPrev->next != pos){ posPrev = posPrev->next; } // 让 pos 位置的前一个节点 posPrev 的指针域指向 pos 节点的指针域 // 如果 pos 位置后还有节点,即指向了 pos 位置的下一个节点的数据域的地址 posPrev->next = pos->next; free(pos); pos = NULL; } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); SListNode* pos = SListFind(plist, 2); if (pos){ SListErase(&plist, pos); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.11 删除 pos 位置之后的节点
思路: 需要创建一个指针变量 next 指向 pos 的指针域,即指向了 pos 位置的下一个节点的数据域的地址。然后将 next->next(即保存的 pos 之后第二个节点的数据域的地址)赋值给 pos->next,最后将 next 变量 free 掉。
SList.c 文件
#include "SList.h" // 删除 pos 位置之后的节点 void SListEraseAfter(SListNode* pos){ assert(pos); assert(pos->next); SListNode* next = pos->next; if (next){ pos->next = next->next; free(next); next = NULL; } }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); SListNode* pos = SListFind(plist, 2); if (pos){ SListEraseAfter(pos); } // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
2.12 销毁单链表
思路: 单链表的销毁并不能和顺序表一样直接释放,顺序表是开辟的连续的空间,链表不是。要将链表释放需要释放每一个结点;可以采用双指针。
SList.c 文件
#include "SList.h" // 销毁 void SListDestroy(SListNode** pphead){ assert(pphead); SListNode* cur = *pphead; while (cur){ SListNode* next = cur->next; free(cur); cur = next; } *pphead = NULL; }
Test.c 文件
#include "SList.h" void TestSList1(){ // 空链表 SListNode* plist = NULL; // 尾插 for (int i = 0; i < 4; i++){ SListPushBack(&plist, i); } // 打印单链表 SListPrint(plist); SListDestroy(&plist); // 打印单链表 SListPrint(plist); } int main(){ TestSList1(); return 0; }
运行结果:
3. 单链表的适用场景
单链表结构,适合头插头删,不适合在尾部或者中间某个位置插入或者删除。如果要使用链表结构存储数据,使用 双向链表 更为合适。
单链表会作为复杂数据结构的子结构(图的邻接表、哈希桶)。