数据结构中的线性表是一种非常基础的数据结构,在实际运用中也非常广泛,本文主要介绍线性表中顺序表和链表。
目录
一、线性表
什么是线性表?
所谓线性表,则是n个具有相同特性的数据元素的有限序列。线性表在逻辑上是呈线性结构的,但在物理结构上不一定连续,线性表在物理结构上储存时,通常是用数据或者链表的形式储存。
二、顺序表
首先我们要了解的是顺序表,顺序表即是 用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。顺序表主要分为静态顺序表和动态顺序表,本文主要介绍动态顺序表。
1、顺序表的实现
老规矩,我们首先创建一个.h文件,用来声明我们需要实现的接口名称。即如下图。
我们在.h文件声明,在.c文件实现接口函数,既然是动态版本,我们需要动态开辟内存空间,因为当内存不够用时,我们可以重新再开一块更大的空间,我们定义一个结构体,保存这个顺序表的起始地址,大小以及容量。如下
//SeqList.h文件
//顺序表中储存的数据类型重定义
typedef int SeqListDataType;
typedef struct SeqList
{
SeqListDataType* data;
int capacity;
int size;
}SeqList;
接着我们需要实现以下函数接口,如下
//初始化顺序表
void SLInit(SeqList* ps);
//顺序表的销毁
void SLDestory(SeqList* ps);
//顺序表的打印
void SLPrint(SeqList* ps);
//头插数据
void SLPushFront(SeqList* ps, SeqListDataType x);
//尾插数据
void SLPushBack(SeqList* ps, SeqListDataType x);
//头删数据
void SLPopFront(SeqList* ps);
//尾删数据
void SLPopBack(SeqList* ps);
//顺序表的查找
int SLFind(SeqList* ps, SeqListDataType x);
//顺序表任意位置插入
void SLInsert(SeqList* ps, int position, SeqListDataType x);
//顺序表任意位置删除
void SLErase(SeqList* ps, int position);
初始化顺序表;
//初始化顺序表
void SLInit(SeqList* ps)
{
ps->capacity = 4;
SeqListDataType* tmp = (SeqListDataType*)malloc(sizeof(SeqListDataType) * ps->capacity);
if (NULL == tmp)
{
perror("malloc fail");
exit(-1);
}
ps->data = tmp;
ps->size = 0;
}
我们默认数据顺序表的初始容量为4,并动态开辟一块4个数据大小的空间,将数据其实个数设置为0;
数据表的打印;
//顺序表的打印
void SLPrint(SeqList* ps)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
printf("%d ", ps->data[i]);
}
}
顺序表的打印也非常简单,即打印顺序表中的数据,首先我们得判断会不会有人在传参得过程中传入一个空指针,因此我们需要断言ps,若为非空我们即可一个一个得打印我们动态开辟的内存空间了。
尾插数据;
//检查内存是否够用
void CheckCapacity(SeqList* ps)
{
assert(ps);
//如果满了就扩容
if (ps->size == ps->capacity)
{
SeqListDataType* tmp = (SeqListDataType*)realloc(ps->data, ps->capacity * 2 * sizeof(SeqListDataType));
if (NULL == tmp)
{
perror("realloc fail");
exit(-1);
}
ps->data = tmp;
ps->capacity *= 2;
}
}
//尾插数据
void SLPushBack(SeqList* ps, SeqListDataType x)
{
assert(ps);
CheckCapacity(ps);
ps->data[ps->size] = x;
ps->size++;
}
顺序表的尾插即也就是数组的,尾插数据,相信这个对于大家都不算难的问题,我们主要需要关注的是内存不够用时应该扩容的问题。每次插入数据时,我们都应该先判断一下是否应该扩容,如果需要扩容就得及时扩容,不然会造成各种隐患问题。
尾删数据;
//尾删数据
void SLPopBack(SeqList* ps)
{
assert(ps);
assert(ps->size != 0);
ps->size--;
}
尾删数据直接将size个数减1即可,但是我们需要注意的是,当size为0时,此时我们不能在继续删除数据了,因为此时顺序表中已经没有数据了,在删程序就会出现问题,因此,这里我们需要提前断言。
头插数据;
//头插数据
void SLPushFront(SeqList* ps, SeqListDataType x)
{
assert(ps);
//检查内存是否够用
CheckCapacity(ps);
int end = ps->size;
while (end > 0)
{
ps->data[end] = ps->data[end - 1];
end--;
}
ps->data[0] = x;
ps->size++;
}
相比于尾插,头插较为麻烦,我们需要将所有数据都往后挪动一位,然后再将数据插入的数据放到数组的第一位置,同样,每次插入我们都需要判断内存是否够用再插入数据。如果不够用则会自动扩容。
头删数据;
}
//头删数据
void SLPopFront(SeqList* ps)
{
assert(ps);
assert(ps->size != 0);
int end = ps->size;
for (int i = 1; i < end; i++)
{
ps->data[i - 1] = ps->data[i];
}
ps->size--;
}
向比尾删,头删也稍稍麻烦一些,头删则时将第二个数据及以后数据都往前挪动一位,覆盖第一个数据,同样我们需要断言,因为数据为0时不能删除数据。
数据的查找;
//顺序表的查找
int SLFind(SeqList* ps, SeqListDataType x)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
if (x == ps->data[i])
{
return i;
}
}
return -1;
}
数据的查找则是数组的遍历,我们依次遍历数组,与数组中的数据一一进行比较,如果找到了则返回该数据的下标,没找到则返回-1,因为数组下标不可能会有负数的,因此没找到返回-1比较合理。
数据指定位置的插入;
//顺序表任意位置插入
void SLInsert(SeqList* ps, int position, SeqListDataType x)
{
assert(ps);
assert(position <= ps->size);
CheckCapacity(ps);
int end = ps->size;
while (end > position)
{
ps->data[end] = ps->data[end - 1];
end--;
}
ps->data[position] = x;
ps->size++;
}
凡是插入我们都需要检查内存是否够用,不够用扩容之后才可以插入数据,指定位置的插入,即是将指定位置即以后的数据往后挪动一个位置,然后再将数据插入这个位置上。
指定位置的数据删除;
//顺序表任意位置删除
void SLErase(SeqList* ps, int position)
{
assert(ps);
assert(position < ps->size);
for (int i = position; i < ps->size - 1; i++)
{
ps->data[i] = ps->data[i + 1];
}
ps->size--;
}
无论什么样的数据删除等都需要先判断是否有数据可以删除,当size为0时,则无法删除数据,删除指定位置的数据即是将这个位置的数据以后的数据往前挪动一位,将需要被删除的数据覆盖掉。
顺序表的销毁;
//顺序表的销毁
void SLDestory(SeqList* ps)
{
free(ps->data);
ps->data = NULL;
ps->capacity = 4;
ps->size = 0;
}
我们在申请空间之后,我们使用完这块空间后要释放该空间,不然会造成内存泄漏;
2、顺序表的缺陷
(1)头部/中间的插入和删除时间复杂度为O(n) 。
(2)再扩容时,有可能会出现异地扩容的现象,异地扩容会消耗较多的资源。
(3)扩容一般是以2倍的形式,而有可能会导致空间的浪费。
三、链表
链表其实分为很多种,是否带头、是否循环。是否双向,其中随意组合可以组成8种本文主要介绍两种链表。
1、不带头单向不循环链表 (单链表)
以上为逻辑结构图,是为了方便我们理解这种数据结构而形成的图。
以上为物理结构图,即在内存中很可能是以上情况的,他们不一定是连续的内存空间,但是我们可以通过其上一个数据找到下一块数据的空间地址。
还是与实现顺序表一样,我们分为以下文件来实现这种链表;
我们在.h文件中声明我们的函数接口,在.c文件实现这些函数;
我们首先在.h文件中添加如下代码;接着来一一实现这些接口;
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
//重定义数据类型
typedef int SLDataType;
//声明并重定义链表结点
typedef struct SListNode
{
SLDataType data;
struct SListNode* next;
}SListNode;
//动态申请一个结点
SListNode* BuySListNode(SLDataType x);
//单链表的销毁
void SListDestory(SListNode* plist);
//单链表打印
void SListPrint(SListNode* phead);
//单链表尾插
void SListPushBack(SListNode** pphead, SLDataType x);
//单链表头插
void SListPushFront(SListNode** pphead, SLDataType x);
//单链表尾删
void SListPopBack(SListNode** pphead);
//单链表头删
void SListPopFront(SListNode** pphead);
//单链表查找
SListNode* SListFind(SListNode* phead, SLDataType x);
//单链表指定位置插入
void SListInsertAfter(SListNode* pos, SLDataType x);
//单链表指定位置删除
void SListEraseAfter(SListNode* pos);
动态申请一个结点;该功能会重复利用,所以先封装成一个函数;
//动态申请一个结点
SListNode* BuySListNode(SLDataType x)
{
SListNode* plist = (SListNode*)malloc(sizeof(SListNode));
if (NULL == plist)
{
perror("malloc fail");
exit(-1);
}
plist->data = x;
plist->next = NULL;
return plist;
}
单链表的销毁;
//单链表的销毁
void SListDestory(SListNode** pphead)
{
assert(*pphead);
SListNode* cur = *pphead;
while (cur)
{
SListNode* prev = cur;
cur = prev->next;
free(prev);
}
*pphead = NULL;
}
在销毁当前结点前,我们首先必须先考虑链表中是否有结点来让我们销毁,因此*pphead不能为空,每当我们释放当前结点前,我们都要知道下个结点的位置,然后再删除这个结点。
单链表的打印;
//单链表打印
void SListPrint(SListNode* phead)
{
SListNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL");
}
单链表的打印即按照链表顺序依次打印即可;
单链表的尾插;
//单链表尾插
void SListPushBack(SListNode** pphead, SLDataType x)
{
assert(pphead);
SListNode* newnode = BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SListNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
要想在链表尾部插入数据,我们必须得先知道尾部的地址,因此我们先循环遍历找到尾部的地址,然后将尾部的next指针指向插入数据的地址即可;
单链表的尾删;
//单链表尾删
void SListPopBack(SListNode** pphead)
{
assert(pphead);
assert(*pphead);
SListNode* ptail = *pphead;
if (ptail->next == NULL)
{
free(ptail);
*pphead = NULL;
}
else
{
SListNode* cur = ptail;
while (ptail->next != NULL)
{
cur = ptail;
ptail = ptail->next;
}
free(ptail);
cur->next = NULL;
}
}
凡是链表的删除就涉及三种情况,一,链表没有数据,即传过来的是空指针,这时我们用assert断言即可;二,链表只有一个数据,此时不管是尾删还是头删,将指针改为NULL即可;三,链表中的数据有两个及以上,此时我们尾删则必须找到最后一个数据的位置,并保存最后一个数据的前一个数据的位置,我们才能删除最后一个位置;
单链表的头插;
//单链表头插
void SListPushFront(SListNode** pphead, SLDataType x)
{
assert(pphead);
SListNode* newnode = BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
newnode->next = *pphead;
*pphead = newnode;
}
}
单链表的头插也分为两种情况:一,单链表中没有数据,这时我们直接将头指针指向新结点即可;二,单链表有数据,此时,我们将新节点的next指向头节点,在将头节点改为新节点的地址即可;
单链表的头删;
//单链表头删
void SListPopFront(SListNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SListNode* del = *pphead;
*pphead = del->next;
free(del);
}
单链表的头删也较为简单,如果单链表没有数据则无法头删;如果单链表只有一个数据,直接将头节点改为NULL然后释放即可;其他情况我们找到第二个节点,然后将头指针指向第二个结点,然后释放第一个结点即可;
单链表的查找
//单链表查找
SListNode* SListFind(SListNode* phead, SLDataType x)
{
SListNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return cur;
}
单链表的查找即循环遍历找指定的值返回结点地址即可,也很简单;
单链表指定位置之后插入
//单链表指定位置之后插入
void SListInsertAfter(SListNode* pos, SLDataType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
SListNode* next = pos->next;
pos->next = newnode;
newnode->next = next;
}
为什么是那个位置之后插入呢,因此找到指定位置后,插入其之后的时间复杂度为O(1),无需找到其前一个结点;
单链表指定之后位置删除
//单链表指定之后位置删除
void SListEraseAfter(SListNode* pos)
{
assert(pos);
if (pos->next == NULL)
{
return;
}
else
{
SListNode* nextnode = pos->next->next;
free(pos->next);
pos->next = nextnode;
}
}
同样,删除指定结点之后的结点要方便很多,时间复杂度也为O(1);
2、单链表的缺陷
单链表实际上是一种不完善的结构,单链表的头插头删虽然相对于顺序表会方便很多,但是单链表的尾插和尾删,非常不方便,我们需要找到最后一个结点的地址,我们只能通过循环一层一层的找到最后一个结点,接下来我们来学习一种较为完善的链表结构。
3、带头双向循环链表
该链表虽然名字听起来很复杂,但却是一个非常简单好用的链表结构;
如上图,该连链表有一个哨兵位头节点,每个结点都会保存前一个结点和后一个结点的地址,形成循环链;
废话不多说,接着带大家来一起实现这种链表;
同样,我们先创建.h和.c文件,将我们写的链表结构项目化;
我们在.h文件声明我们要提供接口名,如下代码所示;
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int DLDataType;
typedef struct DListNode
{
DLDataType data;
struct DListNode* prev;
struct DListNode* next;
}DListNode;
//动态创建一个结点
DListNode* BuyDListNode(DLDataType x);
//初始化双向链表
DListNode* DListInit();
//销毁
void DListDestory(DListNode* phead);
//打印
void DListPrint(DListNode* phead);
//头插
void DListPushFront(DListNode* phead, DLDataType x);
//头删
void DListPopFront(DListNode* phead);
//尾插
void DListPushBack(DListNode* phead, DLDataType x);
//尾删
void DListPopBack(DListNode* phead);
//查找数据的指定位置
DListNode* DListFind(DListNode* phead, DLDataType x);
//指定位置插入
void DListInsert(DListNode* pos);
//指定位置删除
void DListErase(DListNode* pos);
//是否为空
int DListEmpty(DListNode* phead);
//大小
int DListSize(DListNode* phead);
动态创建一个结点;
//动态创建一个结点
DListNode* BuyDListNode(DLDataType x)
{
DListNode* newnode = (DListNode*)malloc(sizeof(DListNode));
if (NULL == newnode)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
该函数会反复利用,所以单独封装成一个函数,前面实现单链表也有实现这个函数,此处就不多介绍了;
初始化双向链表
//初始化双向链表
DListNode* DListInit()
{
DListNode* guard = BuyDListNode(-1);
guard->next = guard;
guard->prev = guard;
return guard;
}
细心的小伙伴可能发现了,为什么单链表没有初始化函数,因此该链表带哨兵位头节点,因此在没有插入任何数据时,也有一个自己指向自己的哨兵位头节点;
双向链表的销毁
//销毁
void DListDestory(DListNode* phead)
{
assert(phead);
DListNode* cur = phead->next;
while (cur != phead)
{
DListNode* nextnode = cur->next;
free(cur);
cur = nextnode;
}
free(phead);
}
该链表的销毁即是按照顺序遍历,依次销毁;循环结束的条件应该以地址判断,而不能以哨兵位头节点的值判断;因此插入的结点中的值可能与哨兵位头节点的值相等;
双向链表的打印
//打印
void DListPrint(DListNode* phead)
{
assert(phead);
DListNode* cur = phead->next;
while (cur != phead)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("phead");
}
双向链表的打印也是循环遍历,与销毁类似;
双向链表的头插
//头插
void DListPushFront(DListNode* phead, DLDataType x)
{
assert(phead);
DListNode* newnode = BuyDListNode(x);
DListNode* first = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}
头插即找到第一个结点,连接头尾结点即可;
双向链表的头删
//头删
void DListPopFront(DListNode* phead)
{
assert(phead);
assert(phead->next != phead);
DListNode* first = phead->next;
DListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
}
值得注意的是,当哨兵位头节点指向自己的时候意味着链表中没有数据,不能再继续删除了;
双向链表的尾插;
//尾插
void DListPushBack(DListNode* phead, DLDataType x)
{
assert(phead);
DListNode* newnode = BuyDListNode(x);
DListNode* lastnode = phead->prev;
lastnode->next = newnode;
newnode->prev = lastnode;
newnode->next = phead;
phead->prev = newnode;
}
对比于单链表,该链表的尾插就方便很多了,双向链表可以直接通过哨兵位头节点直接找到最后一个结点,不用循环遍历;
双向链表的尾删;
//尾删
void DListPopBack(DListNode* phead)
{
assert(phead);
assert(phead->next != phead);
DListNode* lastnode = phead->prev;
DListNode* last_prevnode = lastnode->prev;
phead->prev = last_prevnode;
last_prevnode->next = phead;
free(lastnode);
}
尾删也是,在删除数据前需考虑链表中是否有数据可以删除;链表中最后一个结点也可以通过哨兵位头节点访问到;
双向链表指定位置前插入;
//指定位置前插入
void DListInsert(DListNode* pos, DLDataType x)
{
assert(pos);
DListNode* newnode = BuyDListNode(x);
DListNode* prevnode = pos->prev;
prevnode->next = newnode;
newnode->prev = prevnode;
newnode->next = pos;
pos->prev = newnode;
}
我们可以通过prev指针很轻松找到指定位置的前一个结点,因此该插入也不难实现;
双向链表指定位置的删除;
//指定位置删除
void DListErase(DListNode* pos)
{
assert(pos);
DListNode* prevnode = pos->prev;
DListNode* nextnode = pos->next;
prevnode->next = nextnode;
nextnode->prev = prevnode;
free(pos);
}
删除指定位置,我们得到指定位置的前后指针即可删除;
链表判空;
//是否为空
int DListEmpty(DListNode* phead)
{
return phead->next == phead;
}
判空即判断是否为初始状态;初始状态即为哨兵位头节点的下一个结点还是自己;
链表的大小(数据个数);
//大小
int DListSize(DListNode* phead)
{
int size = 0;
DListNode* cur = phead->next;
while (cur != phead)
{
size++;
cur = cur->next;
}
return size;
}
循环遍历即可得到链表大小,也没有什么难度;
四、 顺序表与链表的区别
不同点 | 顺序表 | 链表 |
储存空间上 | 物理上一定连续 | 逻辑上连续,物理上不一定连续 |
随机访问 | 支持 | 不支持 |
任意位置插入或删除数据 | 可能需要搬移数据,效率较低 | 只需要改变指针指向 |
插入 | 动态顺序表,空间不够会自动扩容 | 没有容量概念 |
应用场景 | 元素高效储存+频繁访问 | 频繁进行任意位置插入和删除 |