线性表实战:顺序表与链表的奥秘
目录
线性表
顺序表
顺序表的c语言实现
顺序表的时间复杂度
顺序表的相关面试题
顺序表的问题及思考
链表
链表的c语言实现(不带头单向)
单链表的时间复杂度
单链表的相关面试题
带头双向循环链表的c语言实现
顺序表和链表的优缺点
顺序表优点:
顺序表缺点:
链表的优点:
链表的缺点:
对于cpu命中率补充讲解
线性表
线性表(linear list)是n个具有相同特性的数据元素的有限序列。线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...
线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储。(注意逻辑也就是我们可以把他想象出来的结构,比如树之类的,物理结构就是实际上存储的)
顺序表
概念:顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。
这里是连续存储数据,不能跳跃
顺序表一般可以分为:
1.静态顺序表:使用定长数组存储
2.动态顺序表:使用动态开辟的数组存储
静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
顺序表的c语言实现
// 顺序表的动态存储定义
typedef struct SeqList
{SLDataType* array; // 指向动态开辟的数组size_t size; // 有效数据个数size_t capacity; // 容量空间的大小
} SeqList;// 基本增删查改接口声明
// 顺序表初始化
void SeqListInit(SeqList* psl, size_t capacity);
// 顺序表销毁
void SeqListDestory(SeqList* psl);
// 顺序表打印
void SeqListPrint(SeqList* psl);
// 检查空间,如果满了,进行增容
void CheckCapacity(SeqList* psl);
// 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x);
// 顺序表尾删
void SeqListPopBack(SeqList* psl);
// 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x);
// 顺序表头删
void SeqListPopFront(SeqList* psl);
// 顺序表查找
int SeqListFind(SeqList* psl, SLDataType x);
//顺序表在pos位置插入x
void SeqListInsert(SeqList*psl, size_tpos, SLDataType x);
//顺序表删除pos位置的值
void SeqListErase(SeqList*psl, size_tpos);
注意:size是可以标记的,比如删除的时候,我们只要让size--就行了,下一次插入的时候把size覆盖就行
注意以下代码在实现的时候,如果加了注释表示原来可以这样实现,采用后来的方案是调用别的函数,比如头插尾插可以调用任意位置插入的insert函数,但是如果有两个版本的实现可能是两个版本都行,或者一个版本有误(这里都是有说明的)
void SLPrint(const SL* psl)
{assert(psl);for (int i = 0; i < psl->size; ++i){printf("%d ", psl->a[i]);}printf("\n");
}void SLInit(SL* psl)
{assert(psl);psl->a = NULL;psl->capacity = psl->size = 0;
}void SLDestory(SL* psl)
{assert(psl);/*if (psl->a){*/free(psl->a);psl->a = NULL;psl->capacity = psl->size = 0;//}
}void SLCheckCapacity(SL* psl)
{// 检查容量if (psl->size == psl->capacity){int newCapcity = psl->capacity == 0 ? 4 : psl->capacity * 2;SLDataType* tmp = (SLDataType*)realloc(psl->a, newCapcity*sizeof(SLDataType));if (tmp == NULL){perror("realloc fail");return;//exit(-1);}psl->a = tmp;psl->capacity = newCapcity;}
注意:扩容这里是有说法的,一次扩容多了,存在空间浪费,一次扩少了,频繁扩容,效率损失,这里选择扩容2倍不是一定扩2倍,2倍比较合适,可以根据自己的场景需求选择
void SLPushBack(SL* psl, SLDataType x)
{/*assert(psl);SLCheckCapacity(psl);psl->a[psl->size] = x;psl->size++;*/SLInsert(psl, psl->size, x);
}void SLPushFront(SL* psl, SLDataType x)
{//assert(psl);//SLCheckCapacity(psl);//// 挪动数据//int end = psl->size - 1;//while (end >= 0)//{// psl->a[end + 1] = psl->a[end];// --end;//}//psl->a[0] = x;//psl->size++;SLInsert(psl, 0, x);
}void SLPopBack(SL* psl)
{//assert(psl);//// 温柔的检查///*if (psl->size == 0)//{//return;//}*///// 暴力的检查//assert(psl->size > 0);//psl->size--;SLErase(psl, psl->size - 1);
}void SLPopFront(SL* psl)
{//assert(psl);//assert(psl->size > 0);///*int begin = 0;//while (begin < psl->size-1)//{// psl->a[begin] = psl->a[begin + 1];// ++begin;//}*///int begin = 1;//while (begin < psl->size)//{// psl->a[begin-1] = psl->a[begin];// ++begin;//}//--psl->size;SLErase(psl, 0);
}
头插挪动数据是有讲究的:不是从前往后移动,而是从后往前,否则会覆盖数据,同理只要挪动数据我们都需要进行考虑
int SLFind(SL* psl, SLDataType x)
{assert(psl);for (int i = 0; i < psl->size; ++i){if (psl->a[i] == x){return i;}}return -1;
}//void SLInsert(SL* psl, int pos, SLDataType x)
void SLInsert(SL* psl, size_t pos, SLDataType x)
{assert(psl);assert(pos <= psl->size);SLCheckCapacity(psl);// 挪动数据/*int end = psl->size - 1;while (end >= (int)pos){psl->a[end + 1] = psl->a[end];--end;}*/size_t end = psl->size;while (end > pos){psl->a[end] = psl->a[end-1];--end;}psl->a[pos] = x;++psl->size;
}// 顺序表删除pos位置的值
void SLErase(SL* psl, size_t pos)
{assert(psl);assert(pos < psl->size);size_t begin = pos;while (begin < psl->size - 1){psl->a[begin] = psl->a[begin + 1];++begin;}psl->size--;
}
注意insert,如果是一个空表,size一开始定义的时候是一个size_t无符号整型,size-1,0-1=-1,由于本身是无符号,所以如果没有转换成一个有符号的int end就会造成溢出变成最大数,如果你是一个无符号size_t end,如果pos刚好=0,那end>=pos就会永远是真,所以有两个缺陷,
第一最好end是一个int类型
第二pos最好强转一下,可能会无法比较
但是这样还是存在问题
end变成int还是可能会变成负数的最大值,如果pos的值超过int存储的最大值,强制转换的时候可能会精度丢失
最好使用没有注释的版本,避免-1导致的问题
至此顺序表的基本实现已经讲解完毕,可以自行实现一下
顺序表的时间复杂度
增:头插中间插O(N),尾插O(1)
删:头删中间删O(N),尾删O(1)
查:按索引查O(1),按值查O(N)
改:O(1)
顺序表的相关面试题
一个数组如何移除指定的元素
一个数组如何去重
如何合并两个有序数组
顺序表的问题及思考
问题:
1.中间/头部的插入删除,时间复杂度为O(N)
2.增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3.增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:
如何解决以上问题,下面给出了链表的结构,按需申请空间,不用了就释放空间,头部中间插入删除数据,不需要挪动数据,不存在空间浪费
顺序表的优点:支持随机访问,有些算法,有些结构支持随机访问,比如二分查找,优化的快排等等
后面再进行两者的比较和总结
链表
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表是有指针域和数据域的
实际中,链表有8种结构
1.单向、双向
2.带头、不带头
3.循环、非循环
实际中比较常用的是无头单向非循环链表 带头双向循环链表
1.无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现较多
2.带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
注意:以上均为逻辑结构(想象出来的箭头),不是物理结构(真实的存储,存储的是地址)
链表的c语言实现(不带头单向)
#include <stdio.h>
#include <stdlib.h>
// 数据类型定义
typedef int SLTDateType;
// 链表节点结构定义
typedef struct SListNode
{SLTDateType data; // 节点存储的数据struct SListNode* next; // 指向下一个节点的指针
} SListNode;
// 动态申请一个节点
SListNode* BuySListNode(SLTDateType x);
// 单链表打印
void SListPrint(SListNode* plist);
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x);
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDateType x);
// 单链表的尾删
void SListPopBack(SListNode** pplist);
// 单链表头删
void SListPopFront(SListNode** pplist);
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDateType x);
// 单链表在 pos 位置之后插入 x
// 分析思考为什么不在 pos 位置之前插入?
void SListInsertAfter(SListNode* pos, SLTDateType x);
// 单链表删除 pos 位置之后的值
// 分析思考为什么不删除 pos 位置?
void SListEraseAfter(SListNode* pos);
void SListPrint(SLTNode* phead)
{SLTNode* cur = phead;while (cur != NULL){printf("%d->", cur->data);cur = cur->next;}printf("NULL\n");
}SLTNode* BuySLTNode(SLTDataType x)
{SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));if (newnode == NULL){perror("malloc fail");exit(-1);}newnode->data = x;newnode->next = NULL;return newnode;
}void SListDestory(SLTNode** pphead)
{assert(pphead);SLTNode* cur = *pphead;//while (cur != NULL)while (cur){SLTNode* next = cur->next;free(cur);cur = next;}*pphead = NULL;
}
void SListPushFront(SLTNode** pphead, SLTDataType x)
{assert(pphead);SLTNode* newnode = BuySLTNode(x);newnode->next = *pphead;*pphead = newnode;
}void SListPushBack(SLTNode** pphead, SLTDataType x)
{assert(pphead);SLTNode* newnode = BuySLTNode(x);// 1、空// 2、非空if (*pphead == NULL){*pphead = newnode;}else{// 找尾SLTNode* tail = *pphead;while (tail->next != NULL){tail = tail->next;}tail->next = newnode;}
}
void SListPopBack(SLTNode** pphead)
{assert(pphead);// 温柔的检查if (*pphead == NULL){return;}// 暴力检查//assert(*pphead != NULL);// 1、一个节点// 2、多个节点if ((*pphead)->next == NULL){free(*pphead);*pphead = NULL;}else{// 找尾/*SLTNode* prev = NULL;SLTNode* tail = *pphead;while (tail->next != NULL){prev = tail;tail = tail->next;}prev->next = NULL;free(tail);tail = NULL;*/SLTNode* tail = *pphead;while (tail->next->next != NULL){tail = tail->next;}free(tail->next);tail->next = NULL;}
}void SListPopFront(SLTNode** pphead)
{assert(pphead);// 温柔的检查if (*pphead == NULL){return;}// 暴力检查//assert(*pphead != NULL);SLTNode* del = *pphead;*pphead = (*pphead)->next;free(del);del = NULL;
}
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{SLTNode* cur = phead;while (cur){if (cur->data == x){return cur;}cur = cur->next;}return NULL;
}// 在pos之前插入
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{assert(pphead);assert(pos);if (pos == *pphead){SListPushFront(pphead, x);}else{SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;// 暴力检查,pos不在链表中.prev为空,还没有找到pos,说明pos传错了assert(prev);}SLTNode* newnode = BuySLTNode(x);prev->next = newnode;newnode->next = pos;}
}// 在pos后面插入
void SListInsertAfter(SLTNode* pos, SLTDataType x)
{assert(pos);SLTNode* newnode = BuySLTNode(x);newnode->next = pos->next;pos->next = newnode;
}// 删除pos位置
void SListErase(SLTNode** pphead, SLTNode* pos)
{assert(pphead);assert(pos);if (*pphead == pos){SListPopFront(pphead);}else{SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;// 检查pos不是链表中节点,参数传错了assert(prev);}prev->next = pos->next;free(pos);//pos = NULL;}
}// 删除pos后面位置
void SListEraseAfter(SLTNode* pos)
{assert(pos);if (pos->next == NULL){return;}else{SLTNode* next = pos->next;pos->next = next->next;free(next);}
}
注意:pos位置是通过find位置查找的
注意:为什么有些是一级指针,有些是二级指针
因为如果你要是改变头节点就需要传二级指针,如果是改变里面别的结点就不需要传,传一级指针就行,因为你本身头节点就是一个一级指针,你要是进入函数想要改变值就需要传一级指针的地址,用二级指针接受一级指针地址。如果你是改变头节点内部的数据/next也是不需要传入二级指针的,因为修改节点内部的数据,本质是修改指针指向的内存空间中的内容,而非修改指针本身的指向,因此一级指针足够。
单链表的时间复杂度
增:头插、pos后插O(1),尾插、pos前插O(N)
删:头删、pos后删O(1),尾删、pos删O(N)
查:按地址查O(1),按值查O(N)
改:按地址改O(1),按值改O(N)
单链表中的pos删能否实现到O(1)
替换法:把下一个结点的val替换到pos结点的,然后再删除pos后面的那个结点,相当于间接删除,但是缺陷就是pos不能是尾结点,所以还是不能
单链表:只适合头插头删O(1)
双向链表:任意位置高效插入删除
单链表的相关面试题
1.移除链表元素(leetcode203)
2.合并两个有序链表(leetcode21)
3.反转链表(leetcode206)
4.链表的中间结点(leetcode876)
5.链表中倒数第k个结点(牛客网)
6.链表分隔(牛客CM11)
7.链表的回文结构(牛客OR36)
8.相交链表(leetcode160)
9.环形链表(leetcode141)
10.环形链表(leetcode142)
11.复制带随机指针的链表(leetcode138)
带头双向循环链表的c语言实现
// 2. 带头+双向+循环链表的增删查改实现
typedef int LTDataType;typedef struct ListNode
{struct ListNode* next;struct ListNode* prev;LTDataType data;
}LTNode;//void ListInit(LTNode** pphead);
LTNode* ListInit();
void ListDestory(LTNode* phead);void ListPrint(LTNode* phead);
void ListPushBack(LTNode* phead, LTDataType x);
void ListPushFront(LTNode* phead, LTDataType x);
void ListPopBack(LTNode* phead);
void ListPopFront(LTNode* phead);
bool ListEmpty(LTNode* phead);
size_t ListSize(LTNode* phead);
LTNode* ListFind(LTNode* phead, LTDataType x);// 在pos之前插入
void ListInsert(LTNode* pos, LTDataType x);
// 删除pos位置
void ListErase(LTNode* pos);
LTNode* ListInit()
{LTNode* guard = (LTNode*)malloc(sizeof(LTNode));if (guard == NULL){perror("malloc fail");exit(-1);}guard->next = guard;guard->prev = guard;return guard;
}LTNode* BuyListNode(LTDataType x)
{LTNode* node = (LTNode*)malloc(sizeof(LTNode));if (node == NULL){perror("malloc fail");exit(-1);}node->next = NULL;node->prev = NULL;node->data = x;return node;
}void ListPrint(LTNode* phead)
{assert(phead);printf("phead<=>");LTNode* cur = phead->next;while (cur != phead){printf("%d<=>", cur->data);cur = cur->next;}printf("\n");
}
// 可以传二级,内部置空头结点
// 建议:也可以考虑用一级指针,让调用ListDestory的人置空 (保持接口一致性)
void ListDestory(LTNode* phead)
{assert(phead);LTNode* cur = phead->next;while (cur != phead){LTNode* next = cur->next;free(cur);cur = next;}free(phead);//phead = NULL;
}
void ListPushBack(LTNode* phead, LTDataType x)
{assert(phead);/*LTNode* newnode = BuyListNode(x);LTNode* tail = phead->prev;tail->next = newnode;newnode->prev = tail;newnode->next = phead;phead->prev = newnode;*/ListInsert(phead, x);
}void ListPushFront(LTNode* phead, LTDataType x)
{assert(phead);// 先链接newnode 和 phead->next节点之间的关系/*LTNode* newnode = BuyListNode(x);newnode->next = phead->next;phead->next->prev = newnode;phead->next = newnode;newnode->prev = phead;*/// 不关心顺序//LTNode* newnode = BuyListNode(x);//LTNode* first = phead->next;//phead->next = newnode;//newnode->prev = phead;//newnode->next = first;//first->prev = newnode;ListInsert(phead->next, x);
}void ListPopBack(LTNode* phead)
{assert(phead);assert(!ListEmpty(phead));//LTNode* tail = phead->prev;//LTNode* prev = tail->prev;//prev->next = phead;//phead->prev = prev;//free(tail);//tail = NULL;ListErase(phead->prev);
}void ListPopFront(LTNode* phead)
{assert(phead);assert(!ListEmpty(phead));/*LTNode* first = phead->next;LTNode* second = first->next;phead->next = second;second->prev = phead;free(first);first = NULL;*/ListErase(phead->next);
}
bool ListEmpty(LTNode* phead)
{assert(phead);/*if (phead->next == phead)return true;elsereturn false;*/return phead->next == phead;
}size_t ListSize(LTNode* phead)
{assert(phead);size_t n = 0;LTNode* cur = phead->next;while (cur != phead){++n;cur = cur->next;}return n;
}LTNode* ListFind(LTNode* phead, LTDataType x)
{assert(phead);size_t n = 0;LTNode* cur = phead->next;while (cur != phead){if (cur->data == x){return cur;}cur = cur->next;}return NULL;
}
// 在pos之前插入
void ListInsert(LTNode* pos, LTDataType x)
{assert(pos);LTNode* prev = pos->prev;LTNode* newnode = BuyListNode(x);// prev newnode pos;prev->next = newnode;newnode->prev = prev;newnode->next = pos;pos->prev = newnode;
}// 删除pos位置
void ListErase(LTNode* pos)
{assert(pos);LTNode* prev = pos->prev;LTNode* next = pos->next;prev->next = next;next->prev = prev;free(pos);//pos = NULL;
}
注意:这里的insert的插入是在pos位置之前,是为了和c++接口保持一致
顺序表和链表的优缺点
顺序表优点:
1.空间连续,支持随机访问(下标):需要随机访问结构支持的算法可以很好的适用(比如快排/二分查找)
2.cpu高速缓存命中率更高
顺序表缺点:
1.中间或前面部分的插入删除时间复杂度O(N)
2.增容的代价比较大。连续的物理空间,空间不够时需要增容
a.增容是有一定程度消耗
b.为了避免频繁扩容,一般我们都按倍数去增,所以可能会有空间损耗
链表的优点:
1.任意位置插入删除时间复杂度为O(1)
2.没有增容问题,插入一个开辟一个空间。(按需申请释放空间)
链表的缺点:
1.以结点为单位存储,不支持随机访问(有些算法不适用)
2.cpu命中率更低
对于cpu命中率补充讲解
这里的 “CPU 命中率” 特指 CPU 缓存命中率(Cache Hit Rate),简单说就是 CPU 从高速缓存(Cache)中成功获取数据的概率。数组的缓存命中率更高,核心原因是两者的内存存储特性存在本质差异。
CPU 访问内存的速度很慢(约几十到上百纳秒),而 CPU 缓存(L1/L2/L3)的访问速度仅需几纳秒。为了提升效率,CPU 会采用 “预读机制”:当访问某块内存时,会把这块内存相邻的连续数据一起加载到缓存中(因为程序通常有 “局部性原理”—— 当前访问的数据,其相邻数据大概率会被后续访问)。
缓存的最小存储单位是 “缓存行”(通常 64 字节),一次预读会加载整行数据,后续访问同一缓存行内的数据时,直接从缓存获取,无需访问内存,这就是 “缓存命中”。
因为数组是连续存储的,所以预加载的时候会把周围的数据也加载进来,而链表是动态内存分配,是一个个分散的结点,所以把周围数据加载进来的时候一般也加载不到,访问下一个节点时,其内存地址不在当前缓存行中,需要重新访问内存并加载新的缓存行,导致 “缓存未命中”。频繁的未命中会迫使 CPU 反复访问低速内存,效率大幅下降。
CPU 缓存命中率的核心取决于数据的 “内存局部性”—— 数组的连续存储天然符合局部性原理,能充分利用缓存预读机制;而链表的离散存储破坏了局部性,无法有效利用缓存。
可以看到cpu性能这里有L1、L2、L3缓存,可以看到越快越小
至此,线性表讲解结束,后面会在c++容器当中再次实现相关容器