1. 线性表
1.1 概念
线性表(linear list)是若干个具有相同特性的数据元素的有限序列,其本质是数组。
1.2 对线性的理解
这里的线性指的是逻辑上的连续,而不是物理空间上的连续。也就是说,以后所有关于链表的图示都是想象出来的,实际上在内存中不是这样的。当然顺序表在内存中的形式和图示相同。
2. 顺序表
2.1 概念
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。
顺序表分为两大类:
- 静态顺序表:使用定长数组存储。
- 动态顺序表:使用动态开辟的数组存储。
两者孰优孰劣一眼便知
顺序表的静态存储
#define N 100 typedef int SeqDataType; //*typedef定义数据类型,方便以后修改数据类型 typedef struct SeqList { SLDataType array[N]; // 定长数组 int size; // 有效数据的个数 }SeqList;
顺序表的静态存储
typedef int SeqDataType; typedef struct SeqList { SLDataType* array; // 指向动态开辟的内存 int size; // 有效数据个数 int capacity; // 容量空间的大小(新增) }SeqList;
在增加数据之前,需要通过比较容量计数器(capacity)和数据个数(size),判断是否要增加开辟的内存
2.2 实例
由于顺序表实际上就是数组,所以对顺序表的增删查改就是对数组的增删查改。下面给出代码。
2.2.0 命名习惯
在使用顺序表/链表等数据结构时,常常有以下命名习惯(只取一种)
以最常使用的驼峰命名法为例
中文名 | 接口 |
顺序表 | SeqList |
初始化 | Init |
头部 | Front |
尾部 | Back |
增 | Push |
删 | Pop |
查 | Find |
改 | Modify |
插入 | Insert |
检查 | Check |
2.2.1 SeqList.h
#pragma once #include<stdio.h> #include<stdlib.h> #include<assert.h> typedef int SeqDataType; typedef struct SeqList { SeqDataType* data; int size; int capacity; }SeqList; void SeqListInit(SeqList* sq);//初始化 void SeqListDestory(SeqList* sq);//销毁内存 void SeqListPrint(SeqList* sq);//打印 void SeqCheckCapacity(SeqList* sq);//检查容量 void SeqListPushBack(SeqList* sq, SeqDataType x);//尾增 void SeqListPushFront(SeqList* sq, SeqDataType x); //头增 void SeqListPopBack(SeqList* sq, SeqDataType x);//尾删 void SeqListPopFront(SeqList* sq, SeqDataType x);//头删 void SeqListInsert(SeqList* sq, SeqDataType x);//任意位置插入 void SeqListErase(SeqList* sq, SeqDataType x);//任意位置删除
2.2.2 SeqList.h(核心功能实现)
#define _CRT_SECURE_NO_WARNINGS 1 #include"SeqList.h" //初始化 void SeqListInit(SeqList* sq) { assert(sq); sq->data = NULL; sq->capacity = 0; sq->size = 0; } //销毁内存 void SeqListDestory(SeqList* sq) { assert(sq); free(sq->data); sq->data = NULL; sq->capacity = 0; } //打印 void SeqListPrint(SeqList* sq) { assert(sq); for (int i = 0; i < sq->size; i++) { printf("%d ", sq->data[i]); } printf("\n"); } //检查容量 void SeqCheckCapacity(SeqList* sq) { assert(sq); if (sq->capacity == sq->size)//增容 { int Newcapacity = sq->capacity == 0 ? 4 : sq->capacity * 2;//如果容量为0,则开辟4个 SeqDataType* NewArr = realloc(sq->data, sizeof(SeqDataType) * Newcapacity); if (NULL == NewArr) { perror("realloc\n"); return; } sq->data = NewArr;//更新内存地址 sq->capacity = Newcapacity;//更新容量 } } //尾增 void SeqListPushBack(SeqList* sq, SeqDataType x) { assert(sq); SeqCheckCapacity(sq); sq->data[sq->size] = x; sq->size++; } //头增 void SeqListPushFront(SeqList* sq, SeqDataType x) { assert(sq); SeqCheckCapacity(sq); int end = sq->size ; while (end>= 0) { sq->data[end] = sq->data[end - 1];//数据往后移动一位 end--; } sq->data[0] = x; ++sq->size; } //尾删 void SeqListPopBack(SeqList* sq, SeqDataType x) { assert(sq); --sq->size; } //头删 void SeqListPopFront(SeqList* sq, SeqDataType x) { assert(sq); int start = 0; while (start++ <= sq->size - 1) { sq->data[start] = sq->data[start + 1];//数据向前移动一位 } sq->size--; } //查找 void SeqListFind(SeqList* sq, SeqDataType x) { assert(sq); for (int i = 0; i < sq->size; i++) { if (sq->data[i] == x) { printf("找到了\n"); break; } } } //任意位置插入 void SeqListInsert(SeqList* sq, SeqDataType x, int adr) { assert(sq); SeqCheckCapacity(sq); int end = sq->size; while (end >= adr) { sq->data[end] = sq->data[end - 1];//移动数据 end--; } sq->data[adr] = x;//插入数据 sq->size++; } //任意位置删除 void SeqListErase(SeqList* sq, int adr) { assert(sq); int begin = adr; while (begin + 1 <= sq->size - 1) { sq->data[begin] = sq->data[begin + 1]; begin++; } sq->size--; }
2.2.3 test.c(测试用例)
#define _CRT_SECURE_NO_WARNINGS 1 #include"SeqList.h" void test1() { SeqList s; SeqListInit(&s); SeqListPushBack(&s, 1); SeqListPushBack(&s, 2); SeqListPushBack(&s, 3); SeqListPushBack(&s, 4); SeqListPushFront(&s, 4); SeqListPushFront(&s, 4); SeqListPushFront(&s, 4); //先放一组数据进去 SeqListPrint(&s); //打印 SeqListInsert(&s, 0, 2); SeqListPrint(&s); //在第三个位置插入0 SeqListErase(&s, 5); //删除第五个位置的数据 SeqListPrint(&s); SeqListDestory(&s); } int main() { test1(); return 0; }
2.4 顺序表的特点
- 增容有两种情况:一是在原内存后增容;二是空间不足,需要重新开辟内存,将原内存中的数据拷贝一份过去。这样不断拷贝数据,释放内存,会消耗机器性能。
- 头部或在中间插入或删除数据,时间复杂度为O(N)
- 增容一般以1.5倍或2倍增长,会造成一定程度上的空间浪费
链表能解决上述问题
3. 单向不带头非循环链表
3.1 概念
逻辑结构由链表中的指针连接而成,物理内存中并不是连续的
链表分为:单双向、带/不带头、循环/非循环。由这些不同的结构组合一共有8种不同的链表结构。
此处仅介绍最简单(无头单向非循环链表)和最复杂(带头双向循环链表)的两种链表,前者是考试、OJ题的模板,后者是实际应用中最常使用的链表。其他类型的链表也会在学习它们的过程中掌握。
3.2 链表的实现
说明:为了篇幅,此处仅放实现功能的核心代码
定义链表
typedef int SListDataType; typedef struct SListNode { SListDataType data; struct SListNode* next;//指针变量,指向下一个节点的地址 }SListNode; SListNode* s = NULL;//注意这里是一个指针变量 //这么做的目的是连接每个节点,因为next也是一个指针变量
3.2.1 增加节点
//新增新节点 SListNode* CrateSListNode(SListDataType x) { SListNode* newnode = (SListNode*)malloc(sizeof(SListNode)); newnode->data = x; newnode->next = NULL; return newnode;//返回开辟的地址 }
3.2.2 头插
//头插 void SListNodePushFront(SListNode** pplist, SListDataType x) { SListNode* newnode = CrateSListNode(x); newnode->next = *pplist; *pplist = newnode; }
将新增的空间的next指向原本的第一个节点的地址。因为是用二级指针接收的节点的地址,所以pplist就是节点本身的地址,由于这个函数不返回新增节点的地址newnode ,则还需将newnode给pplist 。相当于原来的地址更新了。
3.2.3 尾插
//尾插 void SListNodePushBack(SListNode** pplist, SListDataType x) { SListNode* newnode = CrateSListNode(x); //第一个节点为空 if (*pplist == NULL) { *pplist = newnode; } else { SListNode* tail = *pplist;//找尾 while (tail->next != NULL) { tail = tail->next; } tail->next = newnode;//新尾 } }
这里最需要注意的是,链表有一个缺点:不能像顺序表(数组)一样随机访问,因为链表的内存不是连续的,所以要找到某一个节点,每次都要从头开始,根据条件让迭代停止,保存尾节点tail。(迭代即循环遍历)
所以要在链表尾端插入数据,需要先找尾节点,然后将尾节点的next指针变量指向新增节点的地址。(通过创建节点函数新增的节点的next指针已经是NULL了)
要注意判断第一个节点是否为空的条件
3.2.4 尾删
//尾删 void SListNodePopBack(SListNode** pplist) { if (*pplist == NULL)//空链表 { return; } else if ((*pplist)->next == NULL)//只有一个节点 { free(*pplist); *pplist = NULL; } else { SListNode* pre = NULL; SListNode* tail = *pplist; while (tail != NULL)//找尾 { pre = tail;//先保存前一个节点的地址再迭代 tail = tail->next; } free(tail); pre->next = NULL; } }
尾删的主要步骤就是 1. 找尾(要删的节点) 2. 将尾节点的前一个节点pre的next指针变量置空。要更新pre的值,就要在迭代循环中,在tail更新之前将它保存,即为pre的值。
3.2.5 在pos后插入节点
//在pos后插入节点 void SListNodeInsertAfter(SListNode** pplist, SListNode* pos, SListDataType x) { assert(pos); SListNode* newnode = CrateSListNode(x); newnode->next = pos->next; pos->next = newnode; }
此处代码的实现最重要的是不能丢掉pos下一个节点的位置,即pos->next
一般有两种办法:1. 另外用一个变量(通常命名为next)保存 2. 在它改变之前将其值赋给需要的变量。两种办法因情况使用。
3.2.6 在pos前插入节点(仅示例)
//在pos前插入节点 void SListNodeInsertBefore(SListNode** pplist, SListNode* pos, SListDataType x) { assert(pos); SListNode* newnode = CrateSListNode(x); if (*pplist == pos) { SListNodePushFront(pplist, x); } else { SListNode* prev = NULL; SListNode* cur = *pplist; while (cur != pos)//找尾 { prev = cur;//同等地位,prev不要往下访问成员 cur = cur->next; } newnode->next = pos; prev->next = newnode; } }
在pos前插入节点需要判断节点是否为首节点,还要找尾,因为是在pos前插入,所以要找到pos前的节点prev。其他操作和3.2.5一样。
在pos前插入节点,在实际中是不会使用的,因为它的效率没有在pos后插入节点高。
3.2.7 删除pos后的节点
//删除pos后的节点 void SListNodeEraseAfter(SListNode* pos) { assert(pos); if (pos->next == NULL) { return; } else { SListNode* next = pos->next; pos->next = next->next; free(next); } }
删除pos后的节点,思路是:保存pos的下一个节点(next)的地址,这个节点也是要被删除的。将next的next指针变量的值给pos位置的next指针,这样pos后面的节点与两部分都没有联系了,最后free掉它。
相同思路的另一种解释:从pos出发:pos->next->next,访问到pos后第二个节点的地址,相当于走了两步,直接跨过了中间要删除的节点,最后将它free掉即可。
4. 双向带头循环链表
双向带头循环链表虽然结构最复杂,但结构的复杂度使得在处理它时相比于最简单的结构(单向不带头非循环链表)更为简单。
结构图示:
其中哨兵位头结点不存放有效数据。
如果不看这个头结点,单看循环结构的话,链表是不存在头或尾的,不论在何处插入或删除数据,都相当于在链表中间插入,为了方便处理,人们把头结点视为链表的起点。
4.1 核心代码
4.1.1 查找函数
查找函数通过输入的链表起始地址和位置,找到该位置则返回地址。插入和删除函数都需要调用它。
struct ListNode* ListNodeFind(struct ListNode* plist, int pos) { if (pos < 0) return -1; assert(plist); int count = 0; struct ListNode* cur = plist->next; while (cur != plist && count < pos) { count++; cur = cur->next; } return cur; }
4.1.2 初始化函数
在主函数测试时,结构体中什么都没有,所以需要让头结点的prev和next都指向自己。
//初始化链表,新增一个哨兵位节点 struct ListNode* ListNodeInit() { struct ListNode* head = (struct ListNode*)malloc(sizeof(struct ListNode)); //只有一个哨兵位节点,让它两个指针指向自己 head->next = head; head->prev = head; return head; }
4.1.3 创建新节点函数
在插入函数中需要被调用,返回开辟的地址
//创建新节点 struct ListNode* CreatListNode(LTDataType x) { struct ListNode* newnode = (struct ListNode*)malloc(sizeof(struct ListNode)); newnode->val = x; return newnode; }
4.1.4 插入新节点函数
在任意位置插入新节点,需要调用2.3函数
4.1.5 删除节点函数
//删除任意位置的节点 void ListNodeErase(struct ListNode* pos, int Pos) { assert(pos); pos = ListNodeFind(pos, Pos); struct ListNode* prev = pos->prev; struct ListNode* next = pos->next; prev->next = next; next->prev = prev; free(pos); }
4.1.6 打印函数
void ListNodePrint(struct ListNode* plist) { assert(plist); struct ListNode* cur = plist->next; while (cur != plist)//有效节点不等于哨兵位节点 { printf("%d ", cur->val); cur = cur->next; } printf("\n"); }
//在任意位置插入新节点 void ListNodeInsert(LTDataType x, struct ListNode* pos, int Pos) { assert(pos); pos = ListNodeFind(pos, Pos); struct ListNode* newnode = CreatListNode(x); struct ListNode* prev = pos->prev; //链接 newnode->next = pos; pos->prev = newnode; prev->next = newnode; newnode->prev = prev; }
5. 顺序表和链表的优缺点
5.1 顺序表
顺序表的优点:
- 能通过下标随机访问
- cpu高速缓存命中率更高
顺序表的缺点:
- 不断增容,会造成性能消耗,也可能存在一定的空间浪费。
- 头部或中间插入数据需要挪动数据,时间复杂度为O(N)
5.2 链表
链表的优点:
- 按需申请开辟内存,不存在空间浪费
- 插入数据不需要挪动数据,时间复杂度为O(1)
链表的缺点
- 不能通过下标随机访问
更新日志
4/12/2022 4/19/2022 新增双向带头循环链表 顺序表和链表的优缺点对比 Man9o