小知识,大挑战!本文正在参与「程序员必备小知识」创作活动
本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。
一、线性表
1 | scss复制代码线性表(linear list )是n个具有相同特性的数据元素的有限序列。线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串... |
二、顺序表
💦 顺序表的概念和结构
1 | 复制代码顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。 |
❗ 静态顺序表:使用定长数组存储 ❕
缺点就是小了不够用,大了浪费
❗ 动态顺序表:使用动态开辟的数组存储 ❕
可根据自己的需要调整大小
💦 顺序表各接口实现 (动图分析)
1 | 复制代码 静态顺序表只适用于确定知道需要存多少数据的场景。 |
这里新建三个文件:
1️⃣ SeqList.h文件,用于函数声明
2️⃣ SeqList.c文件,用于函数的定义
3️⃣ Test.c文件,用于测试函数
⭕ 接口1:定义结构体SLT
1 | C复制代码typedef int SQDataType; |
⭕ 接口2:初始化结构体 (SeqListInit)
函数原型:
函数实现:
1 | C复制代码void SeqListInit(SLT* psl) |
📐 测试
⭕ 接口3:检查容量 (SeqListCheckCapcity)
函数原型:
函数实现:
1 | c复制代码void SeqListCheckCapcity(SLT* psl) |
📐 测试
⭕ 接口4:指定位置插入数据 (SeqListInser)
函数原型
函数实现
1 | c复制代码void SeqListInser(SLT* psl, size_t pos, SQDataType x) |
📐 测试
⭕ 接口5:输出数据 (SeqListPrint)
函数原型
函数实现
1 | c复制代码void SeqListPrint(SLT* psl) |
📐 测试
⭕ 接口6:尾插 (SeqListPushBack)
函数原型
函数实现
1 | c复制代码void SeqListPushBack(SLT* psl, SQDataType x) |
📐 测试
⭕ 接口7:头插 (SeqListPushFront)
函数原型
函数实现
1 | c复制代码void SeqListPushFront(SLT* psl, SQDataType x) |
📐 测试
⭕ 接口8:指定位置删除数据 (SeqListErase)
函数原型
函数实现
1 | c复制代码void SeqListErase(SLT* psl, size_t pos) |
📐 测试
⭕ 接口9:尾删 (SeqListPopBack)
函数原型
函数实现
1 | c复制代码void SeqListPopBack(SLT* psl) |
📐 测试
⭕ 接口10:头删 (SeqListPopFront)
函数原型
函数实现
1 | c复制代码void SeqListPopFront(SLT* psl) |
📐 测试
⭕ 接口11:查找 (SeqListFind)
函数原型
函数实现
1 | c复制代码//找到返回下标,找不到返回-1 |
📐 测试
⭕ 接口12:统计 (SeqListSize)
函数原型
函数实现
1 | c复制代码size_t SeqListSize(SLT* psl) |
📐 测试
⭕ 接口13:修改 (SeqListAt)
函数原型
函数实现
1 | c复制代码size_t SeqListAt(SLT* psl, size_t pos, SQDataType x) |
📐 测试
⭕ 接口14:销毁 (SeqListDestory)
函数原型
函数实现
1 | c复制代码void SeqListDestory(SLT* psl) |
📐 测试
📝 完整代码
❗ SeqList.h ❕
1 | c复制代码#pragma once |
❗ SeqList.c ❕
1 | c复制代码#include"SeqList.h" |
❗ Test.c ❕
1 | c复制代码#include"SeqList.h" |
💨 输出结果
⚠ 关于数组越界有几个注意的点
1 | c复制代码#include<stdio.h> |
▶ 从以上就可以知道越界不一定报错,系统对越界的检查是抽查的形式,越界读一般是无法检查的;越界写也有可能无法检查,但是如果修改到标志位就会被检查出来
▶ 所谓标志位,就是如果越界写把标志位修改了,它就会报错,但是它不可能把后面的数据都设为标志位并检查,这也是为什么后面的数据无法检查的原因
❓ 为什么有了顺序表,还需要有链表这样的数据结构 ❔
其实不难想象顺序表一定有一些缺陷,而链表恰恰可以优化缺陷
1️⃣ 中间或头部的插入和删除,需要挪动数据,时间复杂度为O(N)
2️⃣ 增容需要申请空间,拷贝数据,释放旧空间,会有不少的消耗
3️⃣ 增容一般是以2倍的形式进行增长,势必会有一定的空间浪费。例如当前容量为100,增容后容量为200,我们只需要再继续插入5个数据,后面就没有数据了,那么将会浪费95个空间
三、链表
💦 链表的概念和结构
1 | 复制代码链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。 |
🎗 这里就来实现一个简单的链表(有三个文件:SList.h、SList.c、Test.c)
❗ SList.h文件 ❕
1 | c复制代码#pragma once |
❗ SList.c文件 ❕
1 | c复制代码#include"SList.h" |
❗ Test.c文件 ❕
1 | c复制代码#include"SList.h" |
💨 结果
⚠ 注意
1️⃣ 从上图可看出,链式结构在逻辑上是连续的,但是在物理上不一定连续
2️⃣ 现实中的结点一般都是从堆上申请出来的
3️⃣ 从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续
假设在32位系统上,结点中值域为int类型,则一个节点的大小为8个字节,则也可能有下述链表:
💦 链表的分类
1 | 复制代码实际中链表的结构非常多样,以下情况组合起来就有8种链表结构: |
1️⃣ 单向或者双向
2️⃣ 带头或者不带头
3️⃣ 循环或者非循环
🎗 虽然有这么多的链表结构,但是实际中最常用的只有两种结构
1️⃣ 无头单向非循环链表
2️⃣ 带头双向循环链表
⚠ 注意:
▶ 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
▶ 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
💦 单链表各接口实现 (动图分析)
这里写了三个文件:
1️⃣ SList.h文件,用于函数声明
2️⃣ SList.c文件,用于函数的定义
3️⃣ Test.c文件,用于测试函数
⭕ 首先需要定义结构体SLTNode
1 | c复制代码typedef int SLTDataType; |
并定义plist变量 -> Test.c
1 | c复制代码SLTNode* plist = NULL; |
⭕ 接口1:开辟空间 (BuySListNode)
函数原型:
函数实现:
1 | c复制代码SLTNode* BuySListNode(SLTDataType x) |
⭕ 接口2:输出数据 (SListPrint)
函数原型:
函数实现:
1 | c复制代码void SListPrint(SLTNode* phead) |
⭕ 接口3:尾插数据 (SListPushBack),详解请看下图:
函数原型:
函数实现:
1 | c复制代码void SListPushBack(SLTNode** pphead, SLTDataType x) |
📐 测试
⭕ 接口4:头插数据 (SListPushFront),详解请看下图:
函数原型:
函数实现:
1 | c复制代码void SListPushFront(SLTNode** pphead, SLTDataType x) |
📐 测试
⭕ 接口5:尾删数据 (SListPopBack),详解请看下图(2种方式):
方式1:
方式2:
函数原型:
函数实现:
1 | c复制代码void SListPopBack(SLTNode** pphead) |
📐 测试
⭕ 接口6:头删数据 (SListPopFront),详解请看下图
函数原型:
函数实现:
1 | c复制代码void SListPopFront(SLTNode** pphead) |
📐 测试
、
⭕ 接口7:查找数据 (SListFind)
函数原型:
函数实现:
1 | c复制代码SLTNode* SListFind(SLTNode* phead, SLTDataType x) |
⭕ 接口8:指定的数之前插入数据 (SlistInser),不支持尾插,详解请看下图
因此可以看出单链表不适合在指定的数的前面插入的,因为它需要前面一个节点的地址
函数原型:
函数实现:
1 | c复制代码void SlistInser(SLTNode** pphead, SLTNode* pos, SLTDataType x) |
📐 测试
⭕ 接口9:指定的数之后插入数据 (SlistInser),不支持头插,详解请看下图
函数原型:
函数实现:
1 | c复制代码void SlistInserAfter(SLTNode* pos, SLTDataType x) |
📐 测试
⭕ 接口10:指定的数删除 (SlistErase),详解请看下图
因此可以看出单链表不适合在删除指定的数,因为它需要前面一个节点的地址
函数原型:
函数实现:
1 | c复制代码void SlistErase(SLTNode** pphead, SLTNode* pos) |
📐 测试
⭕ 接口11:指定的数之后删除 (SlistEraseAfter),详解请看下图
函数原型:
函数实现:
1 | c复制代码void SlistEraseAfter(SLTNode* pos) |
📐 测试
⭕ 接口12:统计 (SListSize)
函数原型:
函数实现:
1 | c复制代码int SListSize(SLTNode* phead) |
📐 测试
⭕ 接口13:判空 (SListSize)
函数原型:
函数实现:
1 | c复制代码bool SListEmpty(SLTNode* phead) |
📐 测试
⭕ 接口14:销毁 (SListDestory)
函数原型:
函数实现:
1 | c复制代码void SListDestory(SLTNode** pphead) |
📐 测试
📝 完整代码
❗ SList.h ❕
1 | c复制代码#pragma once |
❗ SList.c ❕
1 | c复制代码#include"SList.h" |
❗ Test.c ❕
1 | c复制代码#include"SList.h" |
💨 结果
四、顺序表和链表的区别和联系
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持 O(1) | 不支持 O(N) |
任意位置插入或删除元素 | 可能需要搬移元素,效率低 | 只需要修改指针指向 |
插入 | 动态顺序表,空间不够时需要扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率、缓存命中率 | 高 | 低 |
💨 总结:
链表和顺序表没有谁更优,它们各有优缺点,相辅相成
备注:缓存利用率参考存储体系结构以及局部原理性
假设写了一个程序,实现分别对顺序表和链表上的每个数据+1,那么这个程序会编译成指令,然后CPU再执行
CPU运算的速度很快,那内存的速度跟不上,CPU一般就不会直接访问内存,而是把要访问的数据先加载到缓存体系,如果是 ≤ 8byte的数据 (寄存器一般是8byte),会直接到寄存器;而大的数据,会到三级缓存,CPU直接跟缓存交互。
CPU执行指令运算要访问内存,先要取0x10 (假设) 的数据,拿0x10去缓存中找,发现没有 (不命中) ,这时会把主存中这个地址开始的一段空间都读进来 (缓存)。
如果是整型数组,下一次访问0x14、0x18… 就会命中
如果是链表,先取第1个节点0x60,不命中;再取第2个0x90,不命中…。这样会造成一定的缓存污染
本文转载自: 掘金