线性表
顺序表、链表在数据结构里面都把它归成线性表或线性结构,线性结构就是它的数据在逻辑上是线性结构,是挨着挨着放的,但是在物理结构上也就是内存当中它不一定也是挨着挨着放的具体要看情况,比如说顺序表在内存中就是挨着挨着放的,但链表不是。
顺序表
一、顺序表的介绍
顺序表就是数组,顺序表是用一段连续的物理空间去依次存储数据,它相比数组有个要求它的数据必须是从第一个位置开始连续存储的。
二、实现
在实现这个东西前要保持一个好习惯,以工程的方式来实现声明放到 .h 文件定义放到 .c 文件。数据结构是在内存当中去帮助我们管理数据就是增删查改基本上大多数数据结构面对的都是这样一个问题。
那么要管理顺序表,就需要定义一个结构体,因为在这有一个数组那么这个数组到底放了多少个数据呢?所以可以在这个结构体里定义一个数组 a[100] 再配合一个 size 就可以知道它有多少个数据了,因为顺序表有规定比如说你有 100 个空间那你的数据到底存在哪的呢?哪些是你的数据呢?所以有个 size 比如说 size 是 3 那就表示有三个数据就在前三个位置上,有了 size 就可以表示这个顺序表了。如果后面想存其它类型的话那用 int 的地方都得改,所以我们可以把 int 给 typedef 成 SLDateType,但是这个顺序表是有一些问题的,这个顺序表严格来说它叫做静态顺序表,就是指它是写死的它一上来这个地方就是 N 个空间,这种静态的顺序表都会面临一个问题比如我定义 200 却有 210 个数据就存不下,所以它存在的问题就是 N 太小可能不够用,太大又浪费空间。
#pragma once
#define N 100
typedef int SLDataType;
struct SeqList
{
SLDataType a[N];
int size;
};
所以我们直接对它进行一个改进,我们可以向我们的程序系统去动态申请空间想要多少开多少,用一个指向这个动态数组的指针,还需要一个 capacity 用于标识空间大小。
#pragma once
typedef int SLDataType;
struct SeqList
{
SLDataType* a; //指向动态数组的指针
int size; //数据的个数
int capacity; //容量空间大小
};
结构体和声明
为了方便我们最好把 struct SeqList 再 tyedef 一下这边就用一个简写 SL
//SeqList.h
#pragma once
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* a; //指向动态数组的指针
int size; //数据的个数
int capacity; //容量空间大小
}SL;
void SLPrint(SL* ps); //打印
void SListinit(SL* ps); //初始化
void SLPushBack(SL* ps, SLDataType x); //尾插
void SLPushFront(SL* ps, SLDataType x); //头插
void SLPopBack(SL* ps); //尾删
void SLPopFront(SL* ps, SLDataType x); //头删
void SLInsert(SL* ps, int pos, SLDataType x); //任意插入
void SLErase(SL* ps, int pos); //任意删除
int SLFind(SL* ps, SLDataType x); //查找
void SLModify(SL* ps, int pos, SLDataType x); //修改
void SLDestory(SL* ps); //销毁
初始化
接下来就要写它核心的操作增删查改,当我们来一个顺序表后第一步做的就是初始化 。初始化可以这样简单一点,最开始一个值都不给,但是需要断言一下防止传的是一个空指针。
#include "SeqList.h"
void SListinit(SL* ps)
{
assert(ps);
ps->a = NULL;
ps->size = ps->capacity = 0;
}
尾插
现在要尾插一个数据就是在最后一个数据的下一个位置插入,直接 size 的位置插入再加加一下,但是在这之前还有两种情况要考虑,第一个当你去插入第一个数据的时候它是空的,因为最开始的初始化一点空间都没用,第二种情况是有可能插着插着后面就满了如果再继续插入就越界了,所以在插入数据之前还得检查容量空间,满了就要扩容一般满了一次开二倍。realloc 它就有这样一个 东西如果原来的那个指针指向的是个空指针的话它的行为就和 malloc 一样,并且扩容要的是一个新的大小不是需要扩容的大小,单位是字节数(bytes),所以新的大小是 newCapacity * sizeof(SLDataType)。realloc 也可能会失败所以还需要检查一下,realloc 它会返回指向这个内存块的指针,返回值是一个 void* 所以还需要强制类型转换一下,但如果它没有申请到足够的内存它会返回一个空指针。
void SLPushBack(SL* ps, SLDataType x)
{
assert(ps);
if (ps->capacity == ps->size) //检查容量空间,满了扩容
{
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
SLDataType* tmp = (SLDataType)realloc(ps->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
ps->a = tmp;
ps->capacity = newCapacity;
}
ps->a[ps->size] = x;
ps->size++;
}
realloc 扩容完成后原来的空间也不用我们手动释放,realloc 的功能它有可能原地扩容不需要释放,比如说现在的空间是四个要扩容到八个它会检查这段空间后面的四个空间有没有分配给其它,没有就会直接扩容把这段空间分给你,但如果这段空间后面没有足够的空间它会去其它地方找足够存储这八个数据的空间然后把之前的数据拷贝下来再释放旧空间最后返回新空间的地址。
头插
现在有一个顺序表,这个顺序表里面有三个数据,怎么在它头上插入一个数据?首先扩容只支持向后扩不能支持前面扩,所以这个时候我们要把数据挪走整体向后挪再在 0 的位置插入这个数据,这个地方挪动数据必须从后往前挪,不能从前往后挪。我们可以定义一个 end 把 end 依次往后挪,end 最开始指向的位置是 size - 1 的位置开始挪,当 end 到 -1 的时候就结束了。数据挪完了在 0 这个位置把数据放进去同时再加加 size 。在这里我们同样需要检查空间容量,所以我们干脆再增加一个函数把公共要用的提取出来。
void SLCheckCapacity(SL* ps)
{
assert(ps);
if (ps->capacity == ps->size) //检查容量空间,满了扩容
{
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
SLDataType* tmp = (SLDataType)realloc(ps->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
ps->a = tmp;
ps->capacity = newCapacity;
}
}
void SLPushFront(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps);
int end = ps->size - 1;
while (end >= 0) //挪动数据
{
ps->a[end + 1] = ps->a[end];
--end;
}
ps->a[0] = x;
ps->size++;
}
尾删
顺序表中去尾部删除数据也需要断言一下或者判断一下有没有数据再去直接减减 size 否则在空的时候去删除的话下次插入会越界。越界是不一定报错的,系统对越界的检查是设岗抽查。
void SLPopBack(SL* ps)
{
assert(ps);
assert(ps->size);
ps->size--;
//if (ps->size)
// ps->size--;
//else
// printf("SeqList is empty\n");
}
头删
头部删除也是挪动数据去进行覆盖,之前头插是从后往前挪现在头删得从头往前挪,从 1 这个位置开始依次往前挪动去覆盖再减减一下 size
void SLPopFront(SL* ps, SLDataType x)
{
assert(ps);
assert(ps->size);
int begin = 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
++begin;
}
ps->size--;
}
任意插入
现在想在顺序表某个位置插入数据,因为顺序表有规定数据必须是从第一个位置挨着挨着往后放的,也就是说这里 pos 的位置要在 size 范围之内也就再需要一个检查,再从后往前挪数据。
void SLInsert(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size);
SLCheckCapacity(ps);
int end = ps->size - 1;
while (end >= pos)
{
ps->a[end + 1] = ps->a[end];
--end;
}
ps->a[pos] = x;
ps->size++;
}
有了它后甚至可以不用再写头插,可以直接去进行一个复用
void SLPushFront(SL* ps, SLDataType x)
{
//assert(ps);
//SLCheckCapacity(ps);
//int end = ps->size - 1;
//while (end >= 0) //挪动数据
//{
// ps->a[end + 1] = ps->a[end];
// --end;
//}
//ps->a[0] = x;
//ps->size++;
SLinsert(ps, 0, x);
}
同理尾插也可以
void SLPushBack(SL* ps, SLDataType x)
{
//assert(ps);
//SLCheckCapacity(ps);
//ps->a[ps->size] = x;
//ps->size++;
SLinsert(ps, ps->size, x);
}
任意删除
有了之前的铺垫现在就可以轻车熟路了,上来第一步就先做防御性检查 assert ,但这次的 pos 不能再 <= size 了,然后再去挪动数据当 begin 等于 size - 1 时截止。
void SLErase(SL* ps, int pos)
{
assert(ps->size > 0);
assert(pos >= 0 && pos < ps->size);
int begin = pos;
while (begin < ps->size - 1)
{
ps->a[begin] = ps->a[begin + 1];
++begin;
}
ps->size--;
}
查找
当它找到以后返回下标,没有找到直接返回 -1 ,因为一个正常存在的数据的下标不可能是 -1 。
int SLFind(SL* ps, SLDataType x)
{
assert(ps);
for (int i = 0; i < ps->size; ++i)
{
if (ps->a[i] == x)
return i;
}
return -1;
}
修改
使用时,修改可以和查找进行配合
void SLModify(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
ps->a[pos] = x;
}
销毁
void SLDestory(SL* ps)
{
if (ps->a)
{
free(ps->a);
ps->a = NULL;
ps->capacity = ps->size = 0;
}
}
顺序表它本质是一个数组,只是它插入数据支持动态增长,顺序表它在物理上是一个连续的空间,它的优势是方便组织数据,有一个指向它开始的指针那它后面的数据是挨着挨着存储的,能访问第一个位置就能访问第二个第三个以此后面的数据,它还能下标的随机访问,但是它有两方面的缺点,第一是空间不够时需要扩容一般以一次二倍去扩所以会存在一定的性能消耗和空间浪费,第二个问题是头部或者中间位置的插入删除效率低下。
链表
一、链表的介绍
链表的本质就是去针对顺序表的劣势去设计的,它可以按需申请空间,并且头部或中间的插入删除也不需要挪动数据,在物理存储上是非连续的,逻辑结构上是连续的。
二、实现
链表它需要一个一个小块的内存去存储数据通常叫它结点,每个节点还应该有一个指针去指向下一个结点。
结构体和声明
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
SLTNode* BuySListNode(SLTDataType x);
void SListPrint(SLTNode* phead); //打印
void SListPushBack(SLTNode** pphead, SLTDataType x); //尾插
void SListPushFront(SLTNode** pphead, SLTDataType x); //头插
void SListPopBack(SLTNode** pphead); //尾删
void SListPopFront(SLTNode** pphead); //头删
SLTNode* SListFind(SLTNode* phead, SLTDataType x); //查找
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x); //pos位置前插入
void SListErase(SLTNode** pphead, SLTNode* pos); //pos位置删除
打印
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
尾插
先创建一个结点并赋值,如果 pphead 解引用后是个空指针就直接给它,否则就去找尾结点,将新开的结点给尾结点的 next;。这边用的是一个二级指针
SLTNode* BuySListNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
exit;
newnode->data = x;
newnode->next = NULL;
return newnode;
}
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
if (pphead == NULL)
return;
SLTNode* newnode = BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next)
{
tail = tail->next;
}
tail->next = newnode;
}
}
因为如果传来的是一个空指针那就要去改变这个 plist 所以这边需要传 plist 的地址。
头插
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
if (pphead == NULL)
return;
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
头删
先保存第二个结点,再将头部删掉,最后将保存的结点给头部。
void SListPopFront(SLTNode** pphead)
{
if (*pphead == NULL || pphead == NULL)
return;
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
尾删
首先,如果说整个链表只有一个结点就可以直接删。否则就先找到尾再找到尾的前一个,删除掉 tail 再把 prev 就是尾的前一个的 next 置空。
void SListPopBack(SLTNode** pphead)
{
if (*pphead == NULL || pphead == NULL)
return;
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
也可以不用双指针,直接找倒数第二个
void SListPopBack(SLTNode** pphead)
{
if (*pphead == NULL || pphead == NULL)
return;
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
//SLTNode* prev = NULL;
//SLTNode* tail = *pphead;
//while (tail->next)
//{
// prev = tail;
// tail = tail->next;
//}
//free(tail);
//prev->next = NULL;
SLTNode* tail = *pphead;
while (tail->next->next)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
查找
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
pos位置之前插入
如果 pos 的位置就是链表头部就直接头插,否则就去找 pos 的前一个
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
if (pos == NULL || NULL == *pphead)
return;
if (pos == *pphead)
SListPushFront(pphead, x);
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = BuySListNode(x)->next = pos;
}
}
pos位置删除
找 pos 前一个再把 pos 的后一个给前一个链起来。
void SListErase(SLTNode** pphead, SLTNode* pos)
{
if (pphead == NULL || pos == NULL)
return;
if (pos == *pphead)
SListPopFront(pphead);
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
其实单链表在 pos 位置插入和删除不是很合适,因为它是一个 O(n) 的算法,它虽然相比顺序表不需要挪动数据,但是找前一个位置反而成了负担。
带头双向循环链表循环
一、介绍
一般用它是用来单独存储数据。在它的任意位置插入删除都是 O(1) ,它几乎克服了顺序表所有的缺点。但它并不能完全替代顺序表,它真正的缺陷在于它不支持随机访问以及缓存利用率。其实链表的本质跟顺序表是相辅相成的。
二、实现
带头双向循环链表的结构是这样子的。它带一个哨兵位的头不存储有效数据,每个结点都有两个指针,一个指向前一个另一个指向后一个,尾结点的 next 指向哨兵位的头,哨兵位的 prev 指向尾结点。
结构体和声明
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
LTNode* ListInit();
void ListPrint(LTNode* phead);
void ListPushBack(LTNode* phead, LTDataType x);
void ListPushFront(LTNode* phead, LTDataType x);
void ListPopBack(LTNode* phead);
bool ListEmpty(LTNode* phead);
void ListPopFront(LTNode* phead);
void ListInsert(LTNode* pos, LTDataType x);
void ListErase(LTNode* pos);
int ListSize(LTNode* phead);
初始化
当这个链表为空至少有一个哨兵位的头结点,这个头结点的 next 指向自己 prev 也指向自己。
LTNode* BuyListNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail");
exit(-1);
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
LTNode* ListInit()
{
LTNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
尾插
有了头结点就不需要再传二级指针了,因为只需要改变结构体里面的这些东西。现在尾插就不再需要找尾,直接搞一个新结点 newnode 然后改指针关系,尾结点的 next 指向 newnode ,newnode 的 prev 指向尾结点,newnode 的 next 指向哨兵位头结点,头结点的 prev 再去指向新结点。
void ListPushBack(LTNode* phead, LTDataType x)
{
if (phead == NULL)
return;
LTNode* newnode = BuyListNode(x);
phead->prev->next = newnode;
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev = newnode;
}
打印
void ListPrint(LTNode* phead)
{
if (phead == NULL)
return;
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
头插
void ListPushFront(LTNode* phead, LTDataType x)
{
if (phead == NULL || ListEmpty(phead))
return;
LTNode* newnode = BuyListNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
尾删
直接找到尾的前一个 tailPrev ,phead 给 tailPrev 的 next ,再把 phead 的 prev 就是尾给删除,最后再把 tailPrev 给 phead 的 prev 。
void ListPopBack(LTNode* phead)
{
if (phead == NULL)
return;
LTNode* tailPrev = phead->prev->prev;
tailPrev->next = phead;
free(phead->prev);
phead->prev = tailPrev;
}
为了更清晰一点可以再加一些判断,增加一个 bool ListEmpty(LTNode* phead)
bool ListEmpty(LTNode* phead)
{
return phead->next == phead;
}
头删
void ListPopFront(LTNode* phead)
{
if (phead == NULL || ListEmpty(phead))
return;
phead->next = phead->next->next;
free(phead->next->prev);
phead->next->prev = phead;
}
查找
pos位置之前插入
有了 Insert 那么头插尾插都可以复用一下它。
void ListInsert(LTNode* pos, LTDataType x)
{
if (pos == NULL)
return;
LTNode* newnode = BuyListNode(x);
newnode->next = pos;
newnode->prev = pos->prev;
newnode->prev->next = newnode;
pos->prev = newnode;
}
void ListPushFront(LTNode* phead, LTDataType x)
{
if (phead == NULL)
return;
ListInsert(phead->next, x);
}
void ListPushBack(LTNode* phead, LTDataType x)
{
if (phead == NULL)
return;
ListInsert(phead, x);
}
pos位置删除
同样,头删尾删都能复用
void ListErase(LTNode* pos)
{
if (pos == NULL)
return;
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
void ListPopFront(LTNode* phead)
{
if (phead == NULL || ListEmpty(phead))
return;
ListErase(phead->next);
}
void ListPopBack(LTNode* phead)
{
if (phead == NULL || ListEmpty(phead))
return;
ListErase(phead->prev);
}
链表长度
int ListSize(LTNode* phead)
{
if (phead == NULL)
return;
int size = 0;
LTNode* cur = phead->next;
while (cur != phead)
{
++size;
cur = cur->next;
}
return size;
}
顺序表和链表的区别
顺序表的优点是支持下标随机访问,缺点是头部或者中间的插入删除效率低、扩容有一定程度性能消耗和可能存在的空间浪费。链表的优点它任意位置插入删除都是 O(1) 、按需申请空间,缺点是不支持下标的随机访问。
顺序表还有一个优点是 CPU 高速缓存命中率比较高,所指的是比如说这有一个顺序表,还有一个链表假设认为它们存储的了四个数据,那么顺序表和链表而言它们的数据都是在内存当中的堆区
我们的存储介质分为这样的几层。CPU它非常非常的快,它很嫌弃内存它觉得内存太慢了,为了进行它们速度的匹配一个折中的办法就是放一个三级缓存和寄存器,寄存器很小,一般都是四个字节八个字节,也就是说数据量很小就进寄存器数据量大一点就不会进寄存器会进缓存。CPU 在访问这个东西是它拿到一个地址以后访问一个位置,这个位置有可能就在缓存。CPU 在访问内存的数据它会看在不在缓存,不在缓存先加载到缓存再去缓存访问。
顺序表和链表的区别在于顺序表会先来访问第一个位置,第一个位置不在就先加载到缓存再访问第二个位置第三个位置...,但是加载缓存又有一个机制它访问这个位置的时候不会只把这个位置加载到缓存,计算机里面有一个原则叫做局部性原则,就是访问的这一块有可能会紧接着访问这一块的后面,所以说 CPU 把这个内存的数据加载到缓存它加载这个位置也会多加载后面的一段,一般加载几十个字节,具体多少跟硬件有关系。这也是顺序表物理空间连续带来的优势。而链表的的物理空间不连续,链表后面的这些结点的物理地址不一定在前一个结点的后面。