目录
线性表
线性表的定义
在程序中,经常需要将一组(通常是同为某个类型的)数据元素作为整体管理和使用,需要创建这种元素组,用变量记录它们,传进传出函数等。一组数据中包含的元素个数可能发生变化(可以增加或删除元素)。
对于这种需求,最简单的解决方案便是将这样一组元素看成一个序列,用元素在序列里的位置和顺序,表示实际应用中的某种有意义的信息,或者表示数据之间的某种关系。
这样的一组序列元素的组织形式,我们可以将其抽象为 线性表。一个线性表是某类元素的一个集合,还记录着元素之间的一种顺序关系。线性表是最基本的数据结构之一,在实际程序中应用非常广泛,它还经常被用作更复杂的数据结构的实现基础。
根据线性表的实际存储方式,分为两种实现模型:
顺序表:将元素顺序地存放在一块连续的存储区里,元素间的顺序关系由它们的存储顺序自然表示。
链表:将元素存放在通过链接构造起来的一系列存储块中。
顺序表
顺序表的定义
用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储,与数组的数据类型是一致的,而数组的地址也是连续的。
顺序表结构
实现的顺序表要能随意更改长度,指定位置增删元素,更改长度,且会使用到动态内存,我们可以在堆区开辟空间,在顺序表空间不足时用realloc及时开辟空间,但静态的结构无法随意更改长度,所以动态的结构更有优势。
// 静态顺序表
#define N 10
typedef int SLDatetype;
typedef struct SeqList
{
SLDatetype a[N]; //定义有效长度的数组
int size; //有效数据个数
}SL;
// 动态顺序表
typedef int SLDatetype;
typedef struct SeqList
{
SLDatetype* a; //动态开辟的数组
int size; //有效数据个数
int capacity; //容量空间大小
}SL;
顺序表初始化
初始化时,理论上我们只需要开辟一个空间并置为空指针,并将结构体中的数据全部初始化为0即可。
但在实际开发过程中,我们一般会开辟一定大小的空间
void SLInit(SL* psl)
{
psl->a = (SLDatatype*)malloc(sizeof(SLDatatype) * 4);
if (psl->a == NULL)
{
perror("malloc fail");
return;
}
psl ->capacity = 4;
psl->size = 0;
}
顺序表扩容
在后续我们插入数据时,已开辟容量可能已经无法满足需求了。这是就需要扩容。
那一次扩到多少呢?在实际开发过程中我们一般是扩到原有空间的两倍。
void SLCheckCapacity(SL* psl)
{
if (psl->size == psl->capacity)
{
SLDatatype* tmp = (SLDatatype*)realloc(psl->a, sizeof(SLDatatype) * psl->capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
psl->a = tmp;
psl->capacity *= 2;
}
}
顺序表打印
上述函数定义完成后,我们通常需要测试打印以下相关数据,来判断相关函数定义是否成功。
void SLPrint(SL* psl)
{
for (int i = 0; i < psl->size; i++)
{
printf("%d ", psl->a[i]);
}
printf("\n");
}
顺序表尾插
但是在数据的尾部插入一个数据时,我们需要考虑一个问题:原有空间是否可以容纳新的数据,是否需要扩容。
所以我们在插入数据时,要先调用 SLCheckCapacity函数来检查是否需要扩容。
void SLPushBack(SL* psl, SLDatatype x)
{
SLCheckCapacity(psl);
psl->a[psl->size] = x;
psl->size++;
}
顺序表头查
在头部插入元素时需要注意几个细节:确保顺序表插入数据时内存足够,将顺序表当前的所有元素后移一位,只能从后往前挪动,从前往后挪动会覆盖数据,最后在头部插入元素。
void SLPushFornt(SL* psl, SLDatatype x)
{
SLCheckCapacity(psl);
int end = psl->size - 1;
while (end >= 0)
{
psl->a[end + 1] = psl->a[end];
end--;
}
psl->a[0] = x;
psl->size++;
}
顺序表尾删
尾部删除元素,只需要size--即可,但尾删同样也要考虑一个问题,空间中是否还有数据删除,所以在进行尾删时,采用assert函数断言,防止越界和判断空间中是否还有数据可删除。
void SLPopBack(SL* psl)
{
assert(psl->size > 0);
psl->size--;
}
顺序表头删
头部删除元素,同样需要进行断言,防止越界和判断空间是否还有数据可删除,再将所有元素向前移动一位,直接将第一位元素覆盖掉即可。
void SLPopFront(SL* psl)
{
assert(psl->size > 0);
int left = 1;
while (left < psl->size)
{
psl->a[left - 1] = psl->a[left];
left++;
}
psl->size--;
}
在顺序表任意位置插入
进行插入之前需要检查pos位置的下标是否是有效下标,并检查是否有足够空间来容纳新数据,是否需要扩容。之后从输入的数据下标开始,所有元素向后移动一位,并把新数据插入到下标为pos处即可。
void SLInsert(SL* psl, int pos, SLDatatype x)
{
SLCheckCapacity(psl);
int end = psl->size - 1;
while (end >= pos)
{
psl->a[end + 1] = psl->a[end];
end--;
}
psl->a[pos] = x;
psl->size++;
}
在顺序表任意位置删除
代码思路和上面任意位置插入数据思想类似。首先还是检查输入下标pos是否合法。之后从输入下标开始,后一个元素拷贝到前一个元素空间。
void SLErase(SL* psl, int pos)
{
assert(psl);
assert(pos >= 0 && pos < psl->size);
int begin = pos + 1;
while (begin < psl->size)
{
psl->a[begin - 1] = ps->a[begin];
begin++;
}
psl->size--;
}
顺序表销毁
void SLDestroy(SL* psl)
{
free(psl->a);
psl->a = NULL;
psl->capacity = 0;
psl->size = 0;
}
由于上述空间是动态开辟的。所以当我们使用完时,要及时销毁,释放空间。
链表
链表的定义
顺序表与链表都属于线性表,线性表可以简单理解为一个有限的序列。顺序表在线性表的要求上,要求数据在内存中是连续的,所以我们用动态内存开辟了一个连续的空间来放顺序表。相比于顺序表,链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的
从上图看出,链式结构逻辑上是连续的,但在内存中的存储可能是不连续的。
现实中的节点一般都是在堆上面申请的。
从堆上面申请空间是有其规律的,两次申请的空间可能连续也可能不连续。
链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
单向或双向:
带头(带哨兵位)或不带头:
循环或者非循环:
8种链表结构分别是:单向链表,单向带头,单向循环,单向带头循环,双向链表,双向带头,双向循环,双向带头循环。
其中最常用的有两个分别是单向链表,双向带头循环链表。
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。
带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。
单向非循环链表
在使用顺序表时,若遇到了内存不足时,一般会选择2倍扩容,这会导致一定内存的浪费。而对于链表,由于没有对内存的限制,完全可以用添加一个数据开辟一个空间,删除一个数据销毁一个空间。
单链表结构
创建结构体,其中有两个成员,一个用来存储数据,一个用来指向下一个空间,并将其名字定义为SListNode方便后续使用
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // 当前结构体数据
struct SListNode* next; // 指向下一个结构体的指针
}SLTNode;
动态申请一个节点
申请节点用malloc函数,申请成功后把此空间的指针返回给newnode。
malloc开辟空间时有可能失败的,当开辟失败,malloc就会返回NULL
SLTNode* BuyLTNode(SLTDataType x)
{
// 开辟一块空间,并用指针newnode维护此节点
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("newnode fail");
return 0;
}
newnode->data = x; // 将x赋值给data
newnode->next = NULL; // 初始化为NULL
return newnode; // 返回该节点的指针
}
单链表头插
先将phead的值赋给newnode->next,再将newnode的地址赋给phead
void SLTPushFront(SLTNode** phead, SLTDataType x)
{
assert(phead);
SLTNode* newnode = BuyLTNode(x);
newnode->next = *phead;
*phead = newnode;
}
单链表尾插
phead指针,即链表头指针,链表无法直接定位链表的尾部。每一个节点的next指针都存放着下一节点的指针,所以想要找到链表的尾部,就需要遍历此链表。遍历链表就会有一个不断改变的指针变量,假设此变量为tail,当tail->next == NULL时,tail指针指向的空间就是最后一个节点了,此过程称为找尾,最后将newnode指针赋值给tail->next
void SLTPushBack(SLTNode** phead, SLTDataType x)
{
SLTNode* newnode = BuyLTNode(x);
// 判断phead是否为NULL,若为NULL则将新节点赋值给phead
if (*phead == NULL)
{
*phead = newnode;
}
else
{
SLTNode* tail = *phead;
// 尾结点特征,当tail->next为空时就是尾结点
while (tail->next != NULL)
{
tail = tail->next;
}
// 链接新节点
tail->next = newnode;
}
}
单链表头删
头删需要free的是头节点的地址,当只有一个节点的时候可以直接free,当有多个节点就需要注意,头节点被直接free后,第二个节点的指针就找不到了,可以创建一个del节点先保存第二个节点的指针,然后再free掉头结点,此时phead还是指向原来的空间,再把del->next赋值给phead。
void SLPopFront(SLTNode** phead)
{
//没有节点
assert(*phead);
//一个节点
if ((*phead)->next == NULL)
{
free(*phead);
*phead = NULL;
}
//多个节点
else
{
SLTNode* del = *phead;
*phead = del->next;
free(del);
}
}
单链表尾删
尾删只有一个节点时候,头节点就是尾节点可以直接free,还需要对phead置空,当有多个节点时,与尾插一样,需要先找到尾结点,还需要将尾部节点的前一个节点置空
void SLPopBack(SLTNode** phead)
{
//没有节点
assert(*phead);
//一个节点
if ((*phead)->next == NULL)
{
free(*phead);
*phead = NULL;
}
//多个节点
else
{
SLTNode* tail = *phead;
while (tail->next->next)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
单链表查找
循环遍历查找某个数字,找到了返回该地址,没有找到返回NULL
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
单链表在目标节点前插入
由于单链表只能从前往后走,而想要从中间位置插入数据,则需要改变前一个结构体的指针,所以只能插入指定位置后面的数据。该函数与尾插的思路一致,如果对一个空链表进行插入,就需要修改phead,所以要传入pphead。
需要先判断链表是否为空链表,如果为空则可以直接头查,不为空则创建一个新节点ptr,将新节点的next指向目标节点,最后目标位置前一个节点的next指向新节点完成链接。
void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x)
{
assert(phead);
assert(pos);
if (*phead == pos)
{
SLTPushFront(phead, x);
}
else
{
SLTNode* ptr = *phead;
while (ptr->next != pos)
{
ptr = ptr->next;
}
SLTNode* newnode = BuyLTNode(x);
ptr->next = newnode;
newnode->next = pos;
}
}
单链表在目标节点后插入
该函数只需要遍历链表,找到pos位置之后先将新节点链接到pos的下一个节点,再让pos->next指向新节点即可。
void SListInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuyLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
单链表删除目标节点
判断该链表是否为一个或者多个节点,如果为一个节点则直接调用头删函数,如果为多个节点仍需要遍历找到pos的位置并用一个ptr指针来指向pos的前一个节点,使ptr->next指向pos后一个位置。直接链接ptr与pos下一个节点即可。
void SLTErase(SLTNode** phead, SLTNode* pos)
{
assert(phead);
assert(pos);
if (pos == *phead)
{
SLPopFront(phead);
}
else
{
SLTNode* ptr = *phead;
while (ptr->next != pos)
{
ptr = ptr->next;
}
ptr->next = pos->next;
free(pos);
}
}
单链表删除目标节点后的值
删除pos后面的节点,需要先断言pos后面有无节点。
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* next = pos->next;
pos->next = next->next;
free(next);
}
单链表销毁
遍历一遍,每个都需要进行释放
void SLTDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* tmp = cur->next;
free(cur);
cur = tmp;
}
*pphead = NULL;
}
双向带头循环链表
双链表也叫双向链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单
双链表结构
创建结构,其中包含三个成员,一个用来存放数据,另外两个指针,分别指向前一个空间和后一个空间
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* next; // 前驱指针
struct ListNode* prev; // 后驱指针
}ListNode;
动态申请一个节点
每次给链表插入数据时,都需要动态开辟空间申请结点。所以我们将这个过程封装成函数,方便后续使用。
ListNode* BuyLTNode(LTDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("malloc fail");
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
双链表初始化
定义一个双向循环链表后,初始化链表,此时只有一个phead(哨兵位),前驱指针和后驱指针都指向phead自己 哨兵位的数据(data)在应用中不使用,就设置成-1了,与之后使用的正整数形成差异。
ListNode* LTInit()
{
ListNode* phead = BuyLTNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
双链表判空
判空函数方便检查链表是否为空,后续方便使用
bool LTEmpty(ListNode* phead)
{
assert(phead);
return phead->next == phead;
}
双链表尾插
由于双链表的特性,其哨兵位的前置指针指向尾结点,所以我们不用像单链表一样进行遍历后才能尾插。
创建一个新结点newnode,然后将newnode插入到尾结点tail的后面,让tail的next指向newnode,让newnode的prev指向tail;让newnode的next指向头结点phead,头结点phead的prev指向newnode。建立这样的连接后,尾插就完成了。
void ListPushBack(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* tail = phead->prev;
ListNode* newnode = BuyLTNode(x);
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
双链表头查
在双链表的头结点(哨兵位)后面的一个结点前插入数据。我们调用BuyLTNode()函数创建一个新结点newnode,让newnode的next指向头结点phead的next,头结点phead的next的prev指向newnode;让头结点phead的next指向newnode,newnode的prev指向phead。
void ListPushFront(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* newnode = BuyLTNode(x);
ListNode* first = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}
双链表尾删
尾插插入结点的时候,只要能够找到尾结点,就可以很轻松地插入新结点。如果经常用尾插法,可以设置尾指针,加快查找的速度。
void ListPopBack(ListNode* phead)
{
assert(phead);
//链表不能只有一个哨兵位
assert(phead->next != phead);
ListNode* del = phead->prev;
//删除节点的前驱指针
del->prev->next = phead;
//phead的前驱指针
phead->prev = del->prev;
}
双链表头删
创建一个指针del,让该指针先存放第二个结点的地址,避免第一个结点被释放后,第二个结点找不到,先让del的下一个节点的头指针指向头结点,再让头结点的尾结点指向del的下一个节点,最后释放该结点del。
void ListPopFront(ListNode* phead)
{
assert(phead);
ListNode* del = phead->next;
del->next->prev = phead;
phead->next = del->next;
free(del);
del = NULL;
}
双链表查找
定义一个指针cur从第一个结点(非头结点)开始查询,如果找到了,就返回该结点的地址,如果找不到就返回NULL。
ListNode* ListFind(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
双链表在目标位置前插入
因为是插入数据 所以我们要先申请一个内存,又因为我们已经得到了pos的位置,所以可以直接找到pos的上一个数据,原理在于找两个位置 一个是pos,一个是pos的上一个数据,pos我们已经有了它的位置 pos的上一个位置我们只需要用指针指向它就可以了,pos的上一个位置命名为node,然后让新数据在pos的上一个数据与pos中间进行插入。
void ListInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* newnode = BuyLTNode(x);
ListNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
双链表删除目标位置节点
双向循环链表的删除大致与单链表相同,但在两相隔结点的重新链接时需要连结两个指针,即被删除结点的前置结点的后继指针指向被删除结点的后继结点,而后继结点的前置指针指向前置结点。
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* posPrev = pos->prev;
ListNode* posNext = pos->next;
posPrev->next = posNext;
posNext->prev = posPrev;
free(pos);
}
双链表销毁
遍历一遍链表进行销毁,cur碰到phead哨兵位为止,释放cur前,记录下cur->next,释放cur后,把cur->next赋值给cur,以此避免销毁cur后,cur->next不能指向下一个节点的情况,最后再把哨兵位释放置空。
void ListDestory(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
总结
不同点 | 顺序表 | 链表 |
存储空间上 |
物理上一定连续
|
逻辑上连续,但物理上不一定连
续
|
随机访问 |
支持:O(1)
| 不支持:O(N) |
任意位置插入或者删除元
素
|
可能需要搬移元素,效率低O(N)
|
只需修改指针指向
|
插入 |
动态顺序表,空间不够时需要扩
容
|
没有容量的概念
|
应用场景
| 元素高效存储+频繁访问 |
任意位置插入和删除频繁
|
缓存利用率
| 高 | 低 |
顺序表:
优点:尾删尾插效率高,访问随机下标快
缺点:空间不够需扩容(扩容代价大);头插头删及中间插入删除需要挪动数据,效率低
链表:
优点:需要扩容时,按需申请小块空间;任意位置插入效率都高(O(1))
缺点:不支持下标随机访问