文章目录
线性表
线性表是数据结构里面比较常见的结构。它的含义是在逻辑上是线性的。(物理存储上不一定是连续的)。常见的例子有:链表(list),顺序表(vector),栈(stack),队列(queue),字符串(string)等等。
线性表的储存一般是连续结构(如:数组)和链式结构(如:链表)。
连续结构图:
注:地址都是虚构的,只是体现特征
链式结构:
注:地址都是虚构的,只是体现特征
顺序表
前言:
我们要记住:数据结构只规定了结构的特征,具体如何实现或者说它的对应接口是什么等等问题,是没有一个统一规定的。因此具体实现是看每个人的需求。我们没必要追求一模一样。
顺序表主要满足下列需求即可:
1.连续储存(像数组一样)
2. 可以自动增容
3. 可以实现增删查改
顺序表的实现(C语言版本)
顺序表可以定义成静态的,也可以定义成动态的。
静态版本:
#define N 100
typedef int SeqListDataType;//顺序表数据类型
typedef struct SeqList//顺序表
{
SeqListDataType a[N];//顺序表数组
int size;//有效数据
}SeqList;
如果想要把顺序表扩大,就把N的值改变即可。但是这样就不能在运行阶段对顺序表进行扩容。只能在编译前就改变顺序表大小。
总体来说:不好用。
动态版本:
typedef int SeqListDataType;//顺序表
typedef struct SeqList//顺序表
{
SeqListDataType* a;//定义成指针就可以用realloc进行动态扩容了
int size;//有效数据
int capacity;//最大容量
}SeqList;
动态版本的指针就可以进行动态扩容。比静态版本更加灵活好用。
下面我就实现这个版本的顺序表。
这里有一个问题:为什么动态的顺序表比静态的顺序表多一个成员capacity?
这是因为我们进行顺序表的扩容时并不是一个一个扩大的,capacity很有可能比size要大。
我们可以把顺序表理解成一个水杯。size是现在水杯里的水量。capacity是这个水杯的最大容量刻度。通过这个例子我们很容易就能理解:size不一定要等于capacity
顺序表接口实现
我不想空白的想出一些接口,我就拿一些C++中stl的container的vector里常用的一些接口作为例子来实现一下。(有兴趣的看下面的链接进入)
vector的文档
注:vector就是顺序表,只不过c++里面叫vector而已。
顺序表构造和销毁
constructor是构造函数的意思。我们知道这是创建顺序表即可(这是c++的语法,不用深究)
destructor是析构函数的意思,也不用深究,这些不关紧要。它是销毁顺序表的意思。
这两个接口很常用,我们用C语言实现一下。
构造顺序表函数(SeqListInit)
void SeqListInit(SeqList* Seq)//顺序表本质就是数组,扩容也是扩数组
{
assert(Seq);
Seq->a = (SeqListDataType*)malloc(sizeof(SeqListDataType)*2);//一开始给两个大小
Seq->size = 0;
Seq->capacity = 2;
}
首先我们创建两个空间给顺序表来存储数据。一开始开多大你可以根据实际情况来决定。
销毁顺序表函数(DestroySeqList)
void SeqListDestroy(SeqList* Seq)
{
assert(Seq);
Seq->size = 0;
Seq->capacity = 0;
free(Seq->a);
}
把这段空间释放即可。
顺序表关于capacity的接口
下面打勾的是c++里面关于capacity和size的常用接口。(没打勾的这里就不实现了)
返回顺序表的有效数据个数
size_t size(SeqList* s)
{
assert(s);
return s->size;
}
很简单
判断顺序表是否为空
bool empty(SeqList* s)
{
assert(s);
return s->size == 0;//为空就返回true,否则返回false
}
reserve是用来扩大capacity的,保证capacity可以存放下足够的数据。
void reserve(SeqList* s,size_t n)
{
size_t sz = size();
if (n > capacity())
{
SeqListDataType* tmp = (SeqListDataType*)realloc(s->a,sizeof(SeqListDataType)*n);
if(tmp)
s = tmp;
free(tmp);
}
}
上面这种扩容方法要传你想要的capacity(n),在c语言里面不是很好用。
再写一种不用传n的,只要你size==capacity就自动扩容。换一个名字:SeqListCheckCapacity**(顺序表检查容量)**
void SeqListCheckCapacity(SeqList* Seq)
{
assert(Seq);
if (Seq->capacity == Seq->size)
{
Seq->capacity *= 2;
SeqListDataType* tmp = (SeqListDataType*)realloc(Seq->a, sizeof(SeqListDataType) * (Seq->capacity));
if (tmp != NULL)
{
Seq->a = tmp;
}
else
{
printf("realloc fail\n");
exit(-1);
}
}
}
下面是最经典的增删查改接口了。
push_back
void push_back(SeqList* Seq,SeqListDataType x)
{
assert(Seq);
SeqListCheckCapacity(Seq);
Seq->a[Seq->size] = x;
Seq->size++;
pop_back
void pop_back(SeqList* Seq)
{
assert(Seq);
assert(Seq->size > 0);
Seq->size--;
}
直接让size–就可以了。不需要让数据为0,没有意义。万一那个数据是0呢?
insert
insert是在任意位置插入数据,因此需要挪动数据。时间复杂度是O(N),尽量还是不要使用了。
void insert(SeqList* Seq, int pos, SeqListDataType x)
{
assert(Seq);
assert(pos <= Seq->size&& pos >= 0);
SeqListCheckCapacity(Seq);
int end = Seq->size - 1;
for (int i = end; i >= pos; i--)
{
Seq->a[i + 1] = Seq->a[i];
}
Seq->a[pos] = x;
Seq->size++;
}
erase
erase也要挪动数据。时间复杂度也是O(N),尽量不要用。除非尾删。
void erase(SeqList* Seq, int pos)
{
assert(Seq);
assert(pos <= Seq->size && pos >= 0);
SeqListCheckCapacity(Seq);
for (int i = pos; i < Seq->size-1; i++)
{
Seq->a[i] = Seq->a[i + 1];
}
Seq->size--;
}
注:通过insert和erase可以让push_back和pop_back复用。把pos改一改就行了。
链表
前言:在学数据结构中的链表时,往往会出现引用&或者二级指针问题。这里会着重讲。其他问题其实单链表并不难。这里就不着重关注接口如何实现了
链表有许多种。但都可以归为三类:单向双向,带不带头,是否循环
因此排列组合可以有8种搭配。
最常用的两种是:
- 不带头不循环的单向链表
- 带头双向循环链表
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构。在刷题网站种的链表也一般是这种形式。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。
这里就实现一下这两种。
不带头单链表
单链表的结构体是每一个节点。然后我们通过这每一个节点来连接成一个单链表。
typedef int SLDataType;
typedef struct SListNode
{
struct SList* next;
SLDataType data;
}SListNode;
单链表接口实现
还是一边看一下stl库里的接口一边结合c语言来写适合c语言的接口。
单链表的构造和销毁
单链表的构造和销毁其实都是针对节点来说的,本质是创建节点和销毁节点。
创建节点
SListNode* SListCreateNode(SLDataType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
newnode->data = x;
newnode->next = NULL;
return newnode;
}
销毁整条链表
即把每一个节点都给销毁了。
void Destroy(SListNode* head)
{
if (head)
return;
SListNode* cur = head,*next = head->next;
while (cur)
{
free(cur);
cur = next;
if (next)
next = next->next;
}
}
增删接口
注:insert接口在stl里面默认是前插的。这里我们写两种,一种前插一种后插。
关于单链表的二级指针问题(***)
push_back接口
void SListPushBack(SList** plist, SLDataType x)
{
SList* newnode = SListBuyNode(x);
if (plist == NULL)
{
*plist = newnode;
return;
}
SList* tail = *plist;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
push_back的逻辑就是创建一个新节点,然后让尾部链接起来就可以了。
问题是:为什么会有这个二级指针?没有可不可以呢?
或者说:什么时候该有,什么时候没必要有?
画个图就能很好的理解了。
如果使用二级指针:
现在是要在尾部插入,因此ppList不断迭代走到尾部。如图:
然后插入数据。这是二级指针的效果。完成了任务。
现在看一下一级指针的效果:
由于函数传参是一个临时拷贝,因此传一级指针并不是把自己传了进去,而是传了自己的拷贝。
然后不断迭代到尾部:
然后直接插入数据,结果也是没有问题的。在这种情况下一级指针依旧可以完成任务。
有些人可能就晕了。既然两种指针都可以,为什么要做区分。注意:这里只是尾插push_back的一种情况。
再举一个例子:假设链表里面一个节点也没有。
现在要push_back,我们首先要创建一个节点。然后让pList指向这个新的节点,让这张链表就有了头指针。
现在假设pList还是一级指针,看一下会发生什么?
这是刚开始的代码对应的内存视角图
开始指向:
函数结束:
总结一下:用一级指针在链表为空的时候是行不通的。
我们来看一下二级指针的图:
总结一下:二级指针是行的通的。
上面两个例子可以慢慢感悟一下。
这里直接说一个类似结论的东西:
如果你要改变头指针,就要传二级指针。不需要改变头指针的话,传一级是完全没问题的。
这也是为什么有时候课本上有一些带头节点的链表在push_back或者push_front的时候可以传一级指针,因为它们的头节点不会被改变,因此不用传二级指针。
注:引用的效果和二级指针是一样的。
剩下的接口要不要传二级指针也是一样的解决。现在把后面的接口也实现一下吧(这些其实不太重要了)
pop_back()
传二级指针的原因是如果说只剩下一个节点了,再删除就会改变头指针,改变头指针要影响到主函数,因此传二级指针。
void SListPopBack(SList** plist)
{
if (*plist == NULL)
{
return;
}
else if ((*plist)->next == NULL)
{
free(*plist);
*plist = NULL;
}
SList* tail = *plist;
SList* prev = NULL;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
prev->next = NULL;
free(tail);
tail = NULL;
}
insert
同样的,如果要头插,头指针一样被改变了,因此要传二级指针。
具体的实现不是重点。
void SListInsert(SList** pphead, SList* pos, SLTDateType x)
{
SLTNode* newnode = BuyListNode(x);
if (*pphead == pos)
{
newnode->next = *pphead;
*pphead = newnode;
}
else
{
// 找到pos的前一个位置
SLTNode* posPrev = *pphead;
while (posPrev->next != pos)
{
posPrev = posPrev->next;
}
posPrev->next = newnode;
newnode->next = pos;
}
}
erase同理,也要传二级指针。
双向带头循环链表
这是在实际中用来存储数据的链表类型。也是stl中的list容器。
erase的实现:
这里看到并没有传二级指针。再一次验证了上面所说的。因为这是带头节点的链表,头指针并不会改变。
void ListInsert(LTNode* pos, LTDateType x)
{
assert(pos);
LTNode* posPrev = pos->prev;
LTNode* newnode = BuyListNode(x);
// posPrev newnode pos
posPrev->next = newnode;
newnode->prev = posPrev;
newnode->next = pos;
pos->prev = newnode;
}
push_back
这次就用复用函数的角度去实现吧。
void ListPushBack(LTNode* phead, LTDateType x)
{
assert(phead);
ListInsert(phead, x);
}
注:这里是phead的原因,其实就相当于在链表的尾部的前一个位置插入了一个节点。
其余接口这里不写了。
顺序表和链表的对比
顺序表的优点:
- 支持下标访问,可以随机访问
- cpu高速缓存命中率更高(*)
顺序表的缺点:
- 插入删除时间复杂度高,要挪动数据
- 空间增容有一定损耗,为了避免频繁增容,我们要以一定的倍数增容,因此会造成一些内存的浪费。
这里讲一下高速缓存命中率。
下面以一种大白话的方式来说,比较好懂
计算机在读取数据先把数据读到缓存。(读到缓存的好处是缓存读取速度快),然后再去缓存里面读取。如果数据的地址是连续的,计算机就在缓存里面可以读到很多有效数据。如果地址不是连续的,缓存里面就有很多无用信息。
由于数组的数据是连续存储的,因此缓存中的命中率高。
链表的存储是离散的,因此缓存的命中率低。