数据结构004 - 线性表(双向带头循环链表)

1. 链表的分类

链表可分为八类。

单向链表双向链表
单向带头循环双向带头循环
单向带头不循环双向带头不循环
单向不带头循环双向不带头循环
单向不带头不循环双向不带头不循环

上一篇 单链表 的博文中,主要实现的是 单向不带头非循环链表 的链表结构。这种链表结构,结构简单,一般不会单独用来存储数据。实际中更多地是作为其他数据结构的子结构,如哈希桶、图的邻接表等。

本文主要实现 双向带头循环链表 的链表结构。这种链表结构,结构最复杂,一般用于单独存储数据。实际中使用的链表数据结构,都是双向带头循环链表。 另外,这种结构虽然结构复杂,但是代码实现以后会发现结构会带来很多优势,实现反而简单了。

2.什么是双向带头循环链表

双向带头循环链表是由一个数据域和两个指针域组成,其中指针包含前驱指针 prev 和后继指针 next。其中 prev 指向上一个节点,next 指向下一个节点。

双向带头循环链表

3. 双向带头循环链表的实现

代码的实现将放在三个文件中,分别是 List.h(用于声明)、List.c(用于定义)、Test.c(用于测试)

List.h 文件

#pragma once

#include<stdio.h>
#include<stdlib.h>
#include <string.h>
#include <assert.h>

typedef int LTDataType;

// 双向带头循环链表的结构
typedef struct ListNode{
	LTDataType data;       // 数据域,存储数据
	struct ListNode* next; // 后继指针,存储下一个节点的地址
	struct ListNode* prev; // 前驱指针,存储上一个节点的地址
}ListNode;

// 双向带头循环链表的初始化
ListNode* ListInit();

// 创建一个新节点
ListNode* BuyListNode(LTDataType x);

// 打印
void ListPrint(ListNode* phead);

// 尾插
void ListPushBack(ListNode* phead, LTDataType x);

// 尾删
void ListPopBack(ListNode* phead);

// 头插
void ListPushFront(ListNode* phead, LTDataType x);

// 头删
void ListPopFront(ListNode* phead);

// 查找
ListNode* ListFind(ListNode* phead, LTDataType x);

// 在 pos 位置之前插入数据
void ListInsert(ListNode* pos, LTDataType x);

// 删除 pos 位置的节点
void ListErase(ListNode* pos);

// 销毁
void ListDestroy(ListNode* phead);

3.1 创建一个新节点

List.c 文件

#include "List.h"

// 创建一个新节点
ListNode* BuyListNode(LTDataType x){
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	// 检查内存是否开辟成功
	if (newnode == NULL){
		printf("malloc fail\n");
		exit(-1);
	}

	newnode->data = x;
	newnode->next = NULL;
	newnode->prev = NULL;
	return newnode;
}

3.2 双向带头循环链表的初始化

思路: 创建的链表为带头链表,所以在初始化链表时,需要将作为哨兵位的头节点创建好。作为哨兵位的头节点不存储数据,但却有着至关重要的作用。当链表为空链表时,头节点仍然存在。同时因为是双向链表,所以头节点的 prev 和 next 都指向自己。

List.c 文件

#include "List.h"

// 双向带头循环链表的初始化
ListNode* ListInit(){
	// 创建作为哨兵位的头节点,不存储有效数据
	ListNode* phead = BuyListNode(0);
	phead->next = phead; // 后继指针指向自己
	phead->prev = phead; // 前驱指针指向自己

	return phead;
}

3.3 打印

思路: 首先需要明确的是,作为哨兵位的头节点不存储数据,所以需要创建一个指针变量 cur 从 phead->next 开始依次遍历打印链表。因为链表是循环的,所以当 cur 回到 phead 时停止。

List.c 文件

#include "List.h"

// 打印
void ListPrint(ListNode* phead){
	assert(phead);

	ListNode* cur = phead->next;
	while (cur != phead){
		printf("%d ", cur->data);
		cur = cur->next;
	}
	printf("\n");
}

3.4 尾插

思路: 尾插体现了双向循环链表的结构优势,因为头节点的 phead->prev 指向了链表尾节点的地址,所以就找到了尾节点,此时需要再创建一个节点存储插入的数据。最后将头节点 phead、原尾节点 tail、新节点 newnode 链接起来即可。

思路

List.c 文件

#include "List.h"

// 尾插
void ListPushBack(ListNode* phead, LTDataType x){
	assert(phead);
	
	ListNode* tail = phead->prev; // 保存尾节点地址
	ListNode* newnode = BuyListNode(x); // 创建一个新的节点
	newnode->data = x;

	// 将头节点 phead、原尾节点 tail、新节点 newnode 链接起来
	tail->next = newnode;
	newnode->prev = tail;
	newnode->next = phead;
	phead->prev = newnode;

	// 方法二、调用 ListInsert 函数
	// ListInsert(phead, x);
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	ListPushBack(plist, 1);
	ListPushBack(plist, 2);
	ListPushBack(plist, 3);
	ListPushBack(plist, 4);
	// 打印
	ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.5 尾删

思路: 尾删同样体现了双向循环链表的结构优势。因为链表是循环的,所以链表尾节点的地址保存在头节点的 prev 中。我们可以创建指针变量 tail 保存尾节点地址。在删除尾节点时,也要保证尾节点的上一个节点的地址被保存,所以创建指针变量 tailPrev 指向 tail->prev,然后把 tailPrev 的 next 指向头节点 phead,将 phead 的 prev 置成 tailPrev。

思路

List.c 文件

#include "List.h"

// 尾删
void ListPopBack(ListNode* phead){
	assert(phead); // 哨兵位不能为空
	assert(phead->next != phead); // 链表不能为空,防止删除作为哨兵位的头节点

	ListNode* tail = phead->prev; // 尾节点地址放入 tail
	ListNode* tailPrev = tail->prev; // 新的尾节点 tailPrev

	free(tail);
	tail = NULL;
	// 将头节点 phead、原尾节点的上一个节点 tailPrev 链接起来
	tailPrev->next = phead;
	phead->prev = tailPrev;

	// 方法二、调用 ListErase 函数
	//ListErase(phead->prev);
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 6; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	// 尾删
	ListPopBack(plist);
	ListPopBack(plist);
	ListPopBack(plist);
	// 打印
	ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.6 头插

思路: 首先创建一个新节点 newnode,创建指针变量 next 保存 phead->next(即头节点的下一个节点的地址)。然后将头节点 phead、新节点 newnode、原头节点的下一个节点链接起来。

思路

List.c 文件

#include "List.h"

// 头插
void ListPushFront(ListNode* phead, LTDataType x){
	assert(phead);
	
	ListNode* newnode = BuyListNode(x);
	ListNode* next = phead->next; // 先找到头
	
	// 将头节点 phead、新节点 newnode、原头节点的下一个节点链接起来
	phead->next = newnode;
	newnode->prev = phead;
	newnode->next = next;
	next->prev = newnode;
	
	// 方法二、调用 ListInsert 函数
	//ListInsert(phead->next, x);
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	// 头插
	ListPushFront(plist, 10);
	ListPushFront(plist, 20);
	// 打印
    ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.7 头删

思路: 创建指针变量 next 保存 phead->next(即哨兵位的下一个节点的地址),再创建指针变量 nextNext 保存 next->next(即哨兵位后的第二个节点的地址)。然后将哨兵位 phead、哨兵位后的第二个节点链接起来。

思路

List.c 文件

#include "List.h"

// 头删
void ListPopFront(ListNode* phead){
	assert(phead);
	// 如果哨兵位的后继指针指向头,即链表为空,则不能进行头删
	assert(phead->next != phead);
	
	ListNode* next = phead->next; // 先找到哨兵位的下一个节点
	ListNode* nextNext = next->next; // 再找到哨兵位后的第二个节点
	
	// 将哨兵位 phead、哨兵位后的第二个节点链接起来,并释放原哨兵位的下一个节点
	phead->next = nextNext;
	nextNext->prev = phead;
	free(next);

	// 方法二、调用 ListErase 函数
	//ListErase(phead->next);
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	
	// 头删
	ListPopFront(plist);
	ListPopFront(plist);
	// 打印
	ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.8 查找

思路: 创建一个指针变量 cur 指向哨兵位 phead 的 next,然后遍历链表。判断 cur->data 是否为查找的数据,如果是则返回 cur,不是则继续遍历,终止条件是 cur 指向哨兵位节点(即遍历完成,找不到所查找的数据)。

List.c 文件

#include "List.h"

// 查找
ListNode* ListFind(ListNode* phead, LTDataType x){
	assert(phead);

	ListNode* cur = phead->next;
	while (cur != phead){
		// 找到返回对应的地址
		if (cur->data == x){
			return cur;
		}
		// 找不到继续向后找
		cur = cur->next;
	}
	// 彻底找不到
	return NULL;
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	
	// 查找
	ListNode* pos = ListFind(plist, 2);
	if (pos){
		printf("找到了,pos 节点:%p->%d\n", pos, pos->data);
	}
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.9 在 pos 位置之前插入数据

思路: 创建一个新节点 newnode,创建一个指针变量 posPrev 保存 pos->prev(即储存 pos 位置的上一个节点的地址),将 posPrev->next 指向新节点 newnode,同时将 newnode->prev 指向 posPrev,newnode->next 指向 pos,pos->prev 指向 newnode。

思路

List.c 文件

#include "List.h"

// 在 pos 位置之前插入数据
void ListInsert(ListNode* pos, LTDataType x){
	assert(pos);
	
	// 创建插入节点
	ListNode* newnode = BuyListNode(x);
	// 储存 pos 位置的上一个节点地址
	ListNode* posPrev = pos->prev;

	// 将 pos 的上一个节点 posPrev、新节点 newnode、pos 节点链接起来
	posPrev->next = newnode;
	newnode->prev = posPrev;
	newnode->next = pos;
	pos->prev = newnode;
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	
	// 查找
	ListNode* pos = ListFind(plist, 2);
	if (pos){
		// 在 pos 位置之前插入数据
		ListInsert(pos, 10);
	}
	// 打印
	ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.10 删除 pos 位置的节点

思路: 创建两个指针变量 posPrev 和 posNext 分别保存 pos 位置的上一个节点和下一个节点的地址,然后将 pos 节点 free 掉,连接 posPrev 和 posNext。

思路

List.c 文件

#include "List.h"

// 删除 pos 位置的节点
void ListErase(ListNode* pos){
	assert(pos);
	
	// 保存 pos 位置的前一个节点
	ListNode* posPrev = pos->prev;
	// 保存 pos 位置的下一个节点
	ListNode* posNext = pos->next;
	
	// 将 pos 的上一个节点 posPrev、下一个节点 posNext链接起来
	posPrev->next = posNext;
	posNext->prev = posPrev;
	
	free(pos);
	pos = NULL;
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	
	// 查找
	ListNode* pos = ListFind(plist, 2);
	if (pos){
		// 删除 pos 位置的节点
        ListErase(pos);
	}
	// 打印
	ListPrint(plist);
}


int main(){
	TestList1();

	return 0;
}

运行结果:

运行结果

3.11 销毁

思路: 创建指针变量 cur 遍历链表,先将有效数据的节点 free 掉,最后再将哨兵位节点 free 掉即可。

List.c 文件

#include "List.h"

// 销毁
void ListDestroy(ListNode* phead){
	assert(phead);

	// 在销毁链表时,需要将节点逐个销毁,
	// 销毁前一个节点,必须要保存下一个节点的地址
	ListNode* cur = phead->next;
	while (cur != phead){
		ListNode* next = cur->next;
		free(cur);
		cur = next;
	}
	
	free(phead);
	phead = NULL;
}

Test.c 文件

#include "List.h"

void TestList1(){
	// 初始化链表
	ListNode* plist = ListInit();
	// 尾插
	for (int i = 0; i < 4; i++){
		ListPushBack(plist, i);
	}
	// 打印
	ListPrint(plist);
	
    // 销毁
	ListDestroy(plist);
}


int main(){
	TestList1();

	return 0;
}

4. 顺序表与链表的对比总结

不同点顺序表链表
存储空间物理上连续逻辑上连续
随机访问支持 O(1) 的随机访问不支持随机访问,访问元素需要 O(N)
任意位置插入删除需要移动元素,O(N)只需改变指针指向
插入数据要考虑扩容,会带来一定的空间消耗没有容量的概念,可以按需申请和释放
缓存利用率
应用场景元素高效存储以及频繁访问的场景任意位置插入和删除频繁的场景
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值