单链表
一、单链表和顺序表之间的差异
1.1 缓存命中率
在介绍缓存命中率之前首先要先明白CPU的预加载功能
什么是 CPU 预加载(Cache Preloading)
CPU 为了加快访问速度,会使用多级缓存(L1/L2/L3)来提前加载内存中的数据。它不是一个字节一个字节地加载,而是以 Cache Line 为单位(通常是 64 字节)一次性加载一块连续内存。
这就引出了一个关键点:
如果你的数据在内存中是连续的,CPU 一次加载就能命中多个数据项,性能就会更高。
1.2 顺序表 vs 链表:缓存命中率对比
特性 | 顺序表(数组) | 链表(节点+指针) |
---|---|---|
内存布局 | 物理地址连续 | 物理地址不连续 |
CPU 缓存命中率 | 高(预取命中多个元素) | 低(每次访问可能都要跳地址) |
遍历性能 | 快(线性访问) | 慢(每次都要跟随指针) |
插入/删除效率 | 慢(需要搬移元素) | 快(修改指针即可) |
随机访问效率 | O(1) | O(n) |
举个例子:遍历加一操作
假设你要对顺序表或链表中的每个元素执行 +1
操作:
- 顺序表:由于内存连续,CPU 一次预取就能加载多个元素,命中率高,遍历快。
- 链表:每个节点可能在不同内存位置,CPU 每次都要从主存重新加载,命中率低,遍历慢。
这就是为什么在需要频繁遍历或批量处理数据时,顺序表更适合。
1.3 对于工程上的一些建议
-
如果你要做大量遍历、排序、批处理 —— 用顺序表(数组)
-
如果你要频繁插入、删除 —— 用链表
-
如果你想两者兼顾,可以考虑封装结构体,或使用块状链表、跳表等混合结构
二、单向链表介绍
2.1 什么是链表?
链表是一种线性数据结构,但它不像数组那样在内存中连续存储,而是通过指针将一个个节点连接起来。每个节点通常包含两部分:
-
数据域:存储实际数据
-
指针域:指向下一个节点的地址(单向链表)或前后节点地址(双向链表)
2.2 链表的类型
类型 | 特点说明 |
---|---|
单向链表 | 每个节点只指向下一个节点,适合顺序遍历(今天只介绍这一种链表) |
双向链表 | 每个节点有两个指针,分别指向前后节点,支持双向遍历 |
循环链表 | 最后一个节点指向第一个节点,形成闭环,常用于任务调度或缓存轮询等场景 |
块状链表 | 每个节点存储一个数组块,适合大数据量场景,节省空间,提高访问效率 |
2.3 c语言定义的单项链表节点
typedef struct SListNode
{
// 要存储的数据
SListDataType data;
// 下一个节点的地址
struct SListNode* next;
}SListNode;
2.4 接口实现
我在这里写了两个接口
void SListPushBack(SListNode** pphead, SListDataType x);
void SListPopBack(SListNode** pphead);
Slist.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
//首先实现的是一个链表
typedef int SListDataType;
#define N 10
typedef struct SListNode
{
SListDataType data;
struct SListNode* next;
}SListNode;
//尾插尾删
void SListPushBack(SListNode** pphead, SListDataType x);
void SListPopBack(SListNode** pphead);
//头插头删
void SListPushFront(SListNode** pphead, SListDataType x);
void SListPopFront(SListNode** pphead);
//指定位置插入和删除
void SListInsert(SListNode** pphead, int pos, SListDataType x);
void SListErase(SListNode** pphead, int pos);
//打印链表
void SListPrint(SListNode* phead);
//创建节点
SListNode* CreateSListNode(SListDataType x);
Slist.c
#include"SList.h"
#include <assert.h>
// 打印链表
void SListPrint(SListNode* phead)
{
SListNode* cur = phead;
while(cur)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("\n");
}
// 创建节点
SListNode* CreateSListNode(SListDataType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
assert(newnode != NULL); // 确保内存分配成功
newnode->data = x;
newnode->next = NULL;
return newnode;
}
对于尾插法这里传入的是二级指针,插入图片进行解释
// 尾插法
void SListPushBack(SListNode** pphead, SListDataType x)
{
SListNode* newnode = CreateSListNode(x);
if(*pphead == NULL){
*pphead = newnode;
}
else{
//这个时候我要找到尾部的结点指针
SListNode* cur = *pphead;
SListNode* tail = NULL;
//如果这里设置的是一个cur->next
//那么就不需要tail来保存地址了
while(cur)
{
tail = cur;
cur = cur->next;
}
tail->next = newnode;
}
}
// 实现尾删
void SListPopBack(SListNode** pphead)
{
// 如果链表为空
if(*pphead == NULL){
return;
}
// 链表中只存储了一个数据
else if((*pphead)->next == NULL){
free(*pphead);
*pphead = NULL;
return;
}
//链表中的数据是大于一,这个时候需要找到尾部节点和前一个节点
//完成两件事情
// 1. 释放最后一个节点
// 2. 前一个节点的指向置为空
else{
SListNode* tail = *pphead;
SListNode* prev = NULL;
// 这个循环结束时tail就是最后一个值,prev记录了前一个数据
while(tail->next != NULL)
{
// 指向现在的尾
prev = tail;
// 指向下一个
tail = tail->next;
}
free(tail);
// 如果不加入这一不的话,会出现野指针的问题
prev->next = NULL;
}
}