本期玩玩线性表中的 顺序表、单向链表、带头双向循环链表
:一种数据结构的大类,有限序列,元素特性相同,逻辑结构连续
如,顺序表,链表,栈,队列…
顺序表在物理结构上也连续,链表在物理结构上不连续:
*物理结构: 内存中存储的结构
:一种线性表,物理结构上连续
既然要求物理结构连续,那数组很适合用来实现顺序表
一般顺序表有两种:
大多情况,都用动态顺序表…
逻辑结构如下:
顺序表需要 数组arr、数量size、容量capacity 来实现,自然用结构体封装起来比较合适
typedef int SLDataType;//方便修改顺序表的数据类型
typedef struct SeqList
{
SLDataType* arr;//用指针可以动态开辟
int size;//记录数量
int capacity;//当前容量
}SL;//方便使用
讲接口之前,首先得了解一个重要原则:高内聚,低耦合。
^内聚:接口内部聚集的程度,一个好的内聚模块,应该恰好只完成它指定的任务
^耦合:表示两个接口之间的关联程度,当一个接口发生变化时对另一个接口的影响很小,则称低耦合;反之,如果变化的影响很大时,则称高耦合
符合这个原则的代码,可重用性高、可移植性高
如果接口之间功能重复,互相影响……能运行出正常的程序就真是太幸运了
所以,我们接下来的实现都遵循 “高内聚,低耦合” 的原则
void SLInit(SL* psl);
void SLDestroy(SL* psl);
void SLPrint(SL* psl);
void CheckCapacity(SL* psl);
void SLPushFront(SL* psl, SLDataType x);
void SLPopFront(SL* psl);
void SLPushBack(SL* psl,SLDataType x);
void SLPopBack(SL* psl);
int SLFind(SL* psl, SLDataType x);
void SLInsert(SL* psl, int pos, SLDataType x);
void SLErase(SL* psl, int pos);
void test1()
{
SeqList sl;//创建顺序表结构体
SeqListInit(&sl);//初始化顺序表:arr,size,capacity
//头插
SeqListPushFront(&sl, 1);//需要修改顺序表结构体,传结构体指针
SeqListPushFront(&sl, 2);
SeqListPushFront(&sl, 3);
SeqListPushFront(&sl, 4);
SeqListPrint(&sl);
//头删
SeqListPopFront(&sl);//需要改变结构体,传结构体指针
SeqListPopFront(&sl);
SeqListPopFront(&sl);
SeqListPopFront(&sl);
SeqListPrint(&sl);
//尾插
SeqListPushBack(&sl, 10);
SeqListPushBack(&sl, 20);
SeqListPushBack(&sl, 30);
SeqListPushBack(&sl, 40);
SeqListPrint(&sl);
//尾删
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListDestroy(&sl);
}
void test2()
{
SeqList sl;
SeqListInit(&sl);
SeqListPushBack(&sl, 11);
SeqListPushBack(&sl, 22);
SeqListPushBack(&sl, 33);
SeqListPushBack(&sl, 44);
SeqListPrint(&sl);
//查找
int pos = SeqListFind(&sl, 44);
//插入
SeqListInsert(&sl, pos, 999);
SeqListPrint(&sl);
//删除
SeqListErase(&sl, pos);
SeqListPrint(&sl);
SeqListDestroy(&sl);
}
int main()
{
test2();
return 0;
}
思路:全部成员置空
void SLInit(SL* psl)
{
assert(psl);
psl->arr = NULL;
psl->capacity = psl->size = 0;
}
思路:释放顺序表结构内的数组,其他成员置空
void SLDestroy(SL* psl)
{
assert(psl);
free(psl->arr);
psl->arr = NULL;
psl->capacity = psl->size = 0;
}
思路:遍历
void SLPrint(SL* psl)
{
assert(psl);
int i = 0;
for (i = 0; i < psl->size; i++)
{
printf("[%d] ", psl->arr[i]);
}
printf("\n");
}
思路:如果表空,默认开辟2个元素的空间;如果表满,扩容2倍
void CheckCapacity(SL* psl)
{
assert(psl);
//size == capacity 代表顺序表满了
if (psl->size == psl->capacity)
{
//如果是第一次开辟空间,就给个默认值2,否则扩容2倍
int newcapacity = psl->capacity == 0 ? 2 : 2 * psl->capacity;
SLDataType* tmp = (SLDataType*)realloc(psl->arr, newcapacity * sizeof(SL));
if (tmp == NULL)
{
perror("realloc");
exit(-1);
}
psl->arr = tmp;
psl->capacity = newcapacity;
}
}
关于移动数组元素,跟大家分享一个小技巧:
移动数组元素分两种情况:
反之,会待移动的数据覆盖
思路:全部元素往后移动,arr[0] = x
void SLPushFront(SL* psl, SLDataType x)
{
assert(psl);
CheckCapacity(psl);
int end = psl->size - 1;
while (end >= 0)
{
//1.初始操作:arr[size] = arr[size-1]
//2.结束操作:arr[1] = arr[0]
psl->arr[end + 1] = psl->arr[end];
end--;
}
psl->arr[0] = x;
psl->size++;
}
思路:从arr[1]到arr[size-1],全部往前移动,覆盖掉arr[0]
头删数据往前移,所以从前开始操作
移动操作:arr[i] = arr[i + 1]
写出边界值的操作,更清晰:arr[0] ~ arr[size-2] = arr[1] ~ arr[size-1]
void SLPopFront(SL* psl)
{
assert(psl);
assert(psl->size > 0);
int begin = 0;
while (begin <= psl->size - 2)
{
//1.初始操作:arr[0] = arr[1]
//2.结束操作:arr[size-2] = arr[size-1]
psl->arr[begin] = psl->arr[begin + 1];
begin++;
}
psl->size--;
}
删除数据,表不能为空
为什么没有 arr[size-1] = arr[size]?要删掉的数据你还往前移啥呀,直接size–,干掉
void SLPushBack(SL* psl, SLDataType x)
{
assert(psl);
CheckCapacity(psl);
psl->arr[psl->size] = x;
psl->size++;
}
思路:size–,直接干掉
void SLPopBack(SL* psl)
{
assert(psl);
assert(psl->size > 0);
psl->size--;
}
思路:遍历
int SLFind(SL* psl, SLDataType x)
{
assert(psl);
int i = 0;
for (i = 0; i < psl->size; i++)
{
if (psl->arr[i] == x)
return i;
}
return -1;
}
思路:arr[pos] ~ arr[size-1] 移动到 arr[pos+1] ~ arr[size],完成挪动
往后移动,从后面开始操作
移动操作:arr[i + 1] = arr[i]
写出边界值的操作,更清晰:arr[pos+1] ~ arr[size] = arr[pos] ~ arr[size-1]
void SLInsert(SL* psl, int pos, SLDataType x)
{
assert(psl);
assert(pos <= psl->size);
CheckCapacity(psl);
int end = psl->size - 1;
while (end >= pos)
{
psl->arr[end + 1] = psl->arr[end];
end--;
}
psl->arr[pos] = x;
psl->size++;
}
pos要合法:下标不能超过size,下标为size的时候就是尾插
增加数据,要检查容量
思路:arr[pos+1] ~ arr[size-1] 移动到 arr[pos] ~ arr[size-2],完成覆盖
往前移动,移动后相对位置是前面
移动操作:arr[i] = arr[i + 1]
写出边界值的操作,更清晰:arr[pos] ~ arr[size-2] = arr[pos+1] ~ arr[size-1]
void SLErase(SL* psl, int pos)
{
assert(psl);
assert(pos < psl->size);
assert(psl->size > 0);
int begin = pos;
while (begin <= psl->size - 2)
{
psl->arr[begin] = psl->arr[begin + 1];
begin++;
}
psl->size--;
}
pos要合法:下标不能超过size-1,下标为size的位置没有数据
删除数据,不能为空
pos == 0,就是头删;pos == size-1,就是尾删
其实还是 “高内聚,低耦合” 的事儿,如果直接操作顺序表内的数据,你这时候写出来的操作顺序表数据的代码,可复用吗?顺序表元素换一下,又或者别的底层实现动了,你的代码就被废掉…
优势:
劣势:
:一种线性表,物理结构上不连续
链表可以解决顺序表的容量问题:按需开辟,不会浪费空间
逻辑结构:
物理结构:
typedef int SListDataType;
typedef struct SListNode
{
SListDataType val;
struct SListNode* next;//下一个结点的地址
}SLNode;
void SLDestroy(SLNode** pphead);
void SLPrint(SLNode* phead);
SLNode* BuyNode(SListDataType x);
void SLPushFront(SLNode** pphead, SListDataType x);
void SLPopFront(SLNode** pphead);
void SLPushBack(SLNode** pphead, SListDataType x);
void SLPopBack(SLNode** pphead);
SLNode* SLFind(SLNode* phead, SListDataType x);
//在pos前插入
void SLInsert(SLNode** pphead, SLNode* pos, SListDataType x);
//在pos后插入
void SLInsertAfter(SLNode** pphead, SLNode* pos, SListDataType x);
//删除pos
void SLErase(SLNode** pphead, SLNode* pos);
#include "SList.h"
void test1()
{
SLNode* plist = NULL;
SLPushFront(&plist, 1);
SLPushFront(&plist, 2);
SLPushFront(&plist, 3);
SLPushFront(&plist, 4);
SLPrint(plist);
SLPopFront(&plist);
SLPopFront(&plist);
SLPopFront(&plist);
SLPopFront(&plist);
SLPrint(plist);
SLPushBack(&plist, 10);
SLPushBack(&plist, 20);
SLPushBack(&plist, 30);
SLPushBack(&plist, 40);
SLPrint(plist);
SLPopBack(&plist);
SLPopBack(&plist);
SLPopBack(&plist);
SLPopBack(&plist);
SLPrint(plist);
SLDestroy(&plist);
}
void test2()
{
SLNode* plist = NULL;
SLPushFront(&plist, 400);
SLPushFront(&plist, 300);
SLPushFront(&plist, 200);
SLPushFront(&plist, 100);
SLPrint(plist);
SLNode* pos = SLFind(plist, 100);
//SLNode* pos = SLFind(plist, 400);
//pos->val = 123;
//SLPrint(plist);
//SLInsert(&plist, pos, 999);
SLInsertAfter(&plist, pos, 666);
SLPrint(plist);
pos = SLFind(plist, 400);
SLErase(&plist, pos);
SLPrint(plist);
SLDestroy(&plist);
}
int main()
{
//test1();
test2();
return 0;
}
单链表的初始状态就是一个值为空的头指针,没什么好初始化的了
思路:用cur来遍历链表,逐个释放,最后置空*pphead(传过来的是链表头指针地址,所以解引用后置空)
void SLDestroy(SLNode** pphead)
{
assert(pphead);
SLNode* cur = *pphead;
while (cur)
{
SLNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
思路:用cur来遍历链表,逐个打印
void SLPrint(SLNode* phead)
{
SLNode* cur = phead;
while (cur)
{
printf("[%d]->", cur->val);
cur = cur->next;
}
printf("[NULL]\n");
}
所谓cur = cur->next ,就是把cur指针内存的地址换成下一个node的地址,这时候对cur解引用,就可以找到下一个node了
为空就不打印数据或是打印一个NULL,没必要直接阻断程序
SLNode* BuyNode(SListDataType x)
{
SLNode* newnode = (SLNode*)malloc(sizeof(SLNode));
if (newnode == NULL)
{
perror("BuyNode:malloc");
exit(-1);
}
newnode->next = NULL;
newnode->val = x;
return newnode;
}
思路:创建新结点newnode,,后链接*pphead->next,并把head改成newnode
void SLPushFront(SLNode** pphead, SListDataType x)
{
assert(pphead);
SLNode* newnode = BuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
思路:*pphead直接指向*pphead->next,释放头结点
void SLPopFront(SLNode** pphead)
{
assert(pphead);
assert(*pphead);
SLNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
}
思路:找尾结点tail,创建新结点newnode,tail链接newnode
void SLPushBack(SLNode** pphead, SListDataType x)
{
assert(pphead);
SLNode* newnode = BuyNode(x);
if (NULL == *pphead)
{
*pphead = newnode;
}
else
{
SLNode* tail = *pphead;
while (tail->next)
{
tail = tail->next;
}
tail->next = newnode;
}
}
思路:找到尾结点的前驱结点 prev, prev->next = NULL,释放尾结点
void SLPopBack(SLNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLNode* prev = *pphead;
while (prev->next->next)
{
prev = prev->next;
}
SLNode* del = prev->next->next;
prev->next = NULL;
free(del);
}
}
思路:遍历
SLNode* SLFind(SLNode* phead, SListDataType x)
{
assert(phead);
SLNode* cur = phead;
while (cur)
{
if (x == cur->val)
return cur;
else
cur = cur->next;
}
return NULL;
}
思路:找到pos的前驱结点prev, 创建新结点newnode,前链接prev,后链接pos
//在pos前插入
void SLInsert(SLNode** pphead, SLNode* pos, SListDataType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
SLPushFront(pphead, x);
}
else
{
SLNode* newnode = BuyNode(x);
SLNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
assert(prev);//检查pos合法性
}
newnode->next = pos;
prev->next = newnode;
}
}
思路:创建新结点newnode,前链接pos,后链接pos->next
//在pos后插入
void SLInsertAfter(SLNode** pphead, SLNode* pos, SListDataType x)
{
assert(pphead);
assert(pos);
SLNode* newnode = BuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
思路:找到pos的前驱结点prev, prev指向pos->next,释放pos
//删除pos
void SLErase(SLNode** pphead, SLNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
if (*pphead == pos)
{
SLPopFront(pphead);
}
else
{
SLNode* del = pos;
SLNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
assert(prev);
}
prev->next = pos->next;
free(del);
}
}
边界情况:
链接:只要遵循 “待使用不改变” 即可
优势:
劣势:
这个看上去比较复杂的链表,才真正解决了顺序表的问题。虽然逻辑结构看着复杂点,但用起来真是非常爽
那么,什么是带头,什么是双向,什么是循环?
^前驱结点:前一个结点
^后继结点:后一个结点
逻辑结构如图:
我们还是采用以前通讯录类似的 接口实现、接口声明、测试接口,三大块的逻辑来创建工程:
typedef int ListDataType;
typedef struct ListNode
{
ListDataType val;
struct ListNode* next;
struct ListNode* prev;
}ListNode;
#include
#include
#include
#include
ListNode* ListInit();
void ListDestroy(ListNode* phead);
void ListPrint(ListNode* phead);
ListNode* BuyNode(ListDataType x);
size_t ListSize(ListNode* phead);
bool ListEmpty(ListNode* phead);
void ListPushFront(ListNode* phead, ListDataType x);
void ListPopFront(ListNode* phead);
void ListPushBack(ListNode* phead, ListDataType x);
void ListPopBack(ListNode* phead);
ListNode* ListFind(ListNode* phead, ListDataType x);
void ListInsert(ListNode* pos, ListDataType x);
void ListErase(ListNode* pos);
哨兵头的好处就体现出来了:
所有改头指针的情况都不需要考虑了——所有“头部操作/尾部操作”都转成 “中部操作”,永远有一个挺拔的guard站在那,为我们保驾护航。
我们也不再需要传二级指针啦。
思路:创建一个哨兵头结点 guard, 并使它自循环(这样就能直接链接)
ListNode* ListInit()
{
ListNode* guard = BuyNode(0);
guard->next = guard;
guard->prev = guard;
return guard;
}
:调用函数的条件
咱们顶上包含了头文件嘛!
思路:用一个临时指针,从phead->next(phead接收的是guard,guard->next开始才是有效数据)开始遍历,每次保存next,释放cur
void ListDestroy(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
void ListPrint(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
printf("[%d] <-> ", cur->val);
cur = cur->next;
}
printf("[NULL]\n");
}
思路:遍历,同上
思路:在堆上动态开辟空间创建结点,并初始化
ListNode* BuyNode(ListDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("BuyNode:malloc");
exit(-1);
}
else
{
newnode->val = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
}
思路:phead->next指向自己的时候就是空——为空返回真,不为空返回假
bool ListEmpty(ListNode* phead)
{
return phead->next == phead;
}
思路:遍历链表,计算大小
size_t ListSize(ListNode* phead)
{
assert(phead);
size_t cnt = 0;
ListNode* cur = phead;
while (cur != phead)
{
cnt++;
cur = cur->next;
}
return cnt;
}
思路:创建新结点,前链接 guard,后链接 guard->next
void ListPushFront(ListNode* phead, ListDataType x)
{
assert(phead);
ListNode* newnode = BuyNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
void ListPushFront(ListNode* phead, ListDataType x)
{
assert(phead);
ListInsert(phead->next, x);
}
见下文
思路:guard直接指向第二个有效结点,把第一个有效结点释放
void ListPopFront(ListNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
ListNode* first = phead->next;
ListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
}
void ListPopFront(ListNode* phead)
{
assert(phead);
ListErase(phead->next);
}
见下文
思路:创建新结点,前链接 guard->prev ,后链接 guard
void ListPushBack(ListNode* phead, ListDataType x)
{
assert(phead);
ListNode* newnode = BuyNode(x);
ListNode* tail = phead->prev;
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
void ListPushBack(ListNode* phead, ListDataType x)
{
assert(phead);
ListInsert(phead, x);
}
思路:拿一个 tail 指向尾结点,再拿一个 prev 保存尾结点的前驱结点,链接 prev 和 guard
void ListPopBack(ListNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
ListNode* tail = phead->prev;
ListNode* prev = tail->prev;
prev->next = phead;
phead->prev = prev;
free(tail);
}
void ListPopBack(ListNode* phead)
{
assert(phead);
ListErase(phead->prev);
}
思路:遍历
ListNode* ListFind(ListNode* phead, ListDataType x)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->val == x)
return cur;
else
cur = cur->next;
}
return NULL;
}
思路:用 prev 保存 pos 的上一个,创建新结点,前链接 prev, 后链接 pos
void ListInsert(ListNode* pos, ListDataType x)
{
assert(pos);
ListNode* newnode = BuyNode(x);
ListNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
思路:用 prev 保存 pos->prev,用 next 保存 pos->next,链接 prev 和 next
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* prev = pos->prev;
ListNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
实现完带头双向循环链表,可以切实体会到它相较于单向链表的优势了:
优势:
劣势:
这里的链表指的是带头双向循环链表
区别 | 顺序表 | 链表 |
---|---|---|
存储模式 | 连续存储 | 非连续存储 |
能否随机访问 | 可以,O(1) | 不适合,O(N) |
空间损耗 | 容易浪费 | 节省空间 |
任意位置的增删操作 | 效率低,要挪动元素 | 效率高,只需改变指针 |
缓存利用率 | 高 | 低 |
应用场景 | 元素高效存储+频繁访问 | 频繁的任意位置的增删 |
为什么出现缓存?
:cpu的速度比内存快太多,如果cpu计算时直接从内存中访问数据,就容易出现“cpu算完没事干,只能等内存拿数据给它”的情况,所以大佬们设计了高速缓存。
有了高速缓存,cpu又是怎么拿数据的?
:cpu每次访问数据,都从缓存中找
根据我们访问数据的习惯(访问一个数据,通常也要访问周围的数据,而且要是一个个数据读也太挫了),产生了局部性原理。
:高速缓存从内存中读取数据的时候,会把要访问的数据局部的一整块数据读取过来
局部性原理对我们理解缓存利用率有什么用呢?
缓存依这种原理,产生新的拿取数据的方式:
^缓存污染: 加载一大块数据,只有很小一块有用,其他的数据就“污染”了缓存。
^缓存空间浪费: 缓存空间很小,更多数据进入,就会相应有先前进来的数据被挤出去
:简单来说,这里的情况就是,cpu对缓存中连续数据的访问命中的概率
缓存命中率高,自然效率就会高很多,如果命中率不高,每次加载一大块数据都是白加载,这样还会造成“缓存污染”,和缓存空间的浪费
本期分享就到这啦,不足之处望请斧正