开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 22 天,点击查看活动详情
什么是数据结构与算法
数据结构
比如现在我们需要保存100个学生的数据,那么首先想到的肯定是使用数组。但是很多时候我们不仅仅是存放这些数据,基本所有的业务场景都会对数据进行修改,查询等操作,这时候,使用数组在某些场景下,效率并不如意。
因此我们需要一种更好的数据表示方式,才能做到更高效增删改查这样的操作,而完成这些操作所用到的方法,称其为“算法”,算法下文介绍,下图是几个常见的数据结构。
算法
上方网站中对数据结构与算法做了介绍,本文不再赘述,注意,需要重点了解时间、空间复杂度以及其计算方式。下面我们简单看一个例子。
案例:二分搜索算法
现在有一个从小到大排序的数组,给你一个目标值target,现在请你找到这个值在数组中的对应下标,如果没有,请返回-1:
int search(int* nums, int numsSize, int target){
//请实现查找算法
}
int main() {
int arr[] = {1, 3, 4, 6, 7,8, 10, 11, 13, 15}, target = 3;
printf("%d", search(arr, 10, target));
}
这是力扣上非常经典的一道题目,有多种解法,下面展开介绍:
最简单的方法就是将数组中的元素一个一个进行遍历,如果遍历完后一个都没有,那么就结束:
int search(int* nums, int numsSize, int target){
for (int i = 0; i < len; ++i) {
if(nums[i] == target) return i; //循环n次,直到找到为止
}
return -1;
}
虽然这样的算法简单粗暴,但是并不是最好的,我们需要遍历n次才能得到结果,时间复杂度为O(n),显然不是最优解。这里我们利用它的有序性,当我们查找到大于目标target的数时,就没必要继续寻找了:
int search(int* nums, int numsSize, int target){
for (int i = 0; i < len; ++i) {
if(nums[i] == target) return i;
if(nums[i] > target) break;
}
return -1;
}
这样循环进行的次数也许就会减小了,但是如果我们要寻找的目标target是最后几个元素,约等于没有优化。但是,我们依然可以继续利用数组有序的特性,既然是有序的,那么我们不妨随机在数组中找一个数,如果这个数大于目标,那么就不再考虑右边的部分,如果小于目标,那么就考虑左边的部分,然后继续在另一部分中再次随机找一个数,这样每次都能将范围缩小,直到找到为止。
二分思想就是将一个有序数组不断进行平分,直到找到为止,这样我们每次寻找的范围会不断除以2,相比一个一个比较,效率就高了不少:
int binarySearch(int * nums, int target, int left, int right){ //left代表左边界,right代表右边界
if(left > right) return -1; //如果左边大于右边,那么肯定就找完了,所以直接返回
int mid = (left + right) / 2; //这里计算出中间位置
if(nums[mid] == target) return mid; //直接比较,如果相等就返回下标
if(nums[mid] > target) //这里就是大于或小于的情况了,这里mid+1和mid-1很多人不理解,实际上就是在下一次寻找中不算上当前的mid,因为这里已经比较过了,所以说左边就-1,右边就+1
return binarySearch(nums, target, left, mid - 1); //如果大于,那么说明肯定不在右边,直接去左边找
else
return binarySearch(nums, target, mid + 1, right); //如果小于,那么说明肯定不在左边,直接去右边找
}
int search(int* nums, int numsSize, int target){
return binarySearch(nums, target, 0, numsSize - 1);
}
线性结构篇
线性表
作为开篇,我们从最简单的线性表开始。开头我们提出一个存储学生信息的场景,数组无法高效的做到有序存储查询,那么我们定义一个更加高级的数据结构来实现——线性表。
线性表是由同一类型的数据元素构成的有序序列的线性结构。线性表中元素的个数就是线性表的长度,表的起始位置称为表头,表的结束位置称为表尾,当一个线性表中没有元素时,称为空表。
线性表一般需要包含以下功能:
- 初始化线性表: 将一个线性表进行初始化,得到一个全新的线性表。
- 获取指定位置上的元素: 直接获取线性表指定位置i上的元素。
- 获取元素的位置: 获取某个元素在线性表上的位置i。
- 插入元素: 在指定位置i上插入一个元素。
- 删除元素: 删除指定位置i上的一个元素。
- 获取长度: 返回线性表的长度。
现在我们需要设计的是一种功能完善的表结构,它不像是数组那么低级,而是真正意义上的表。
实现线性表的结构一般有两种,一种是顺序存储实现,还有一种是链式存储实现,我们先来看第一种,也是最简单的的一种。
顺序表
既然数组无法实现这样的高级表结构,那么就基于数组,对其进行拓展,实际上我们存放数据还是使用数组,但是我们可以为其编写一些额外的功能来强化为线性表,像这样底层依然采用顺序存储实现的线性表,我们称为顺序表。
这里我们可以先定义一个新的结构体类型,将一些需要用到的数据保存在一起,这里我们以int类型的线性表为例:
typedef int E; //元素类型以int为例,起别名为E
typedef struct List * ArrayList; //List的指针起别名为ArrayList
struct List {
E array[10]; //实现顺序表的底层数组
int capacity; //表示底层数组的容量
};
// 初始化操作
void initList(ArrayList list){
list->capacity = 10; //直接将数组的容量设定为10即可
}
此处存在一个问题,如果这样定义,顺序表长度就固定为10了。而前面我们线性表要求的是长度是动态增长的,那么我们可以直接使用一个指针来指向底层数组的内存区域,当装不下的时候,我们可以创建一个新的更大的内存空间来存放数据,这样就可以实现扩容了。
void initList(ArrayList list){ //默认所有的顺序表初始大小都为10
list->array = malloc(sizeof(E) * 10); //使用malloc函数申请10个int大小的内存空间,作为底层数组使用
list->capacity = 10; //容量同样设定为10
}
表里面,默认情况下是没有任何元素的,我们创建一个变量来表示当前表中的元素数量:
typedef struct List * ArrayList; //List的指针起别名为ArrayList
struct List {
int * array; //指向顺序表的底层数组
int capacity; //数组的容量
int size; //表中的元素数量
};
void initList(ArrayList list){ //这里就默认所有的顺序表初始大小都为10吧,随意
list->array = malloc(sizeof(int) * 10); //使用malloc函数申请10个int大小的内存空间,作为底层数组使用
list->capacity = 10; //容量同样设定为10
list->size = 0; //元素数量默认为0
}
下面再做一次优化,如果内存不够,无法为数组分配空间,那么也需要返回一个结果:
_Bool initList(ArrayList list){
list->array = malloc(sizeof(int) * 10);
if(list->array == NULL) return 0; //需要判断如果申请的结果为NULL的话表示内存空间申请失败
list->capacity = 10;
list->size = 0;
return 1; //正常情况下返回true也就是1
}
这样,一个比较简单的顺序表就定义好,通过initList函数对其进行初始化:
int main() {
struct List list; //创建新的结构体变量
if(initList(&list)){ //对其进行初始化,如果失败就直接结束
// do something
} else{
printf("顺序表初始化失败!");
}
}
接下来我们来编写一下插入操作:
//list就是待操作的表,element就是需要插入的元素,index就是插入的位置(注意顺序表的index是按位序计算的,从1开始,一般都是第index个元素)
void insertList(ArrayList list, int element, int index){
for (int i = list->size; i > index - 1; i--) //先使用for循环将待插入位置后续的元素全部放到后一位
list->array[i] = list->array[i - 1];
list->array[index - 1] = element; //挪完之后,位置就腾出来了,直接设定即可
list->size++; //插入之后相当于多了一个元素,需要size + 1
}
编写一个输出的方法:
void printList(ArrayList list){ //编写一个函数用于打印表当前的数据
for (int i = 0; i < list->size; ++i) //表里面每个元素都拿出来打印一次
printf("%d ", list->array[i]);
printf("\n");
}
下面是完整代码:
#include <stdio.h>
#include <stdlib.h>
typedef struct List * ArrayList; //List的指针起别名为ArrayList
struct List {
int * array; //指向顺序表的底层数组
int capacity; //数组的容量
int size; //表中的元素数量
};
// 初始化数组
_Bool initList(ArrayList list){
list->array = malloc(sizeof(int) * 10);
if(list->array == NULL) return 0; //需要判断如果申请的结果为NULL的话表示内存空间申请失败
list->capacity = 10;
list->size = 0;
return 1; //正常情况下返回true也就是1
}
//list就是待操作的表,element就是需要插入的元素,index就是插入的位置(注意顺序表的index是按位序计算的,从1开始,一般都是第index个元素)
void insertList(ArrayList list, int element, int index){
for (int i = list->size; i > index - 1; i--) //先使用for循环将待插入位置后续的元素全部放到后一位
list->array[i] = list->array[i - 1];
list->array[index - 1] = element; //挪完之后,位置就腾出来了,直接设定即可
list->size++; //插入之后相当于多了一个元素,需要size + 1
}
//编写一个函数用于打印表当前的数据
void printList(ArrayList list){
for (int i = 0; i < list->size; ++i) //表里面每个元素都拿出来打印一次
printf("%d ", list->array[i]);
printf("\n");
}
int main() {
struct List list;
if(initList(&list)){
insertList(&list, 111, 1); //每次插入操作后都打印一下表
printList(&list);
insertList(&list, 222, 1);
printList(&list);
insertList(&list, 333, 2);
printList(&list);
} else{
printf("顺序表初始化失败!");
}
}
但是如果我们在非法的位置插入元素会出现问题,继续完善一下插入方法,首先我们要确认能插入的范围是[1,size+1],然后在一开始进行判断:
_Bool insertList(ArrayList list, E element, int index){
if(index < 1 || index > list->size + 1) return 0; //如果在非法位置插入,返回0表示插入操作执行失败
for (int i = list->size; i > index - 1; i--)
list->array[i] = list->array[i - 1];
list->array[index - 1] = element;
list->size++;
return 1; //正常情况返回1
}
// 测试
if(insertList(&list, 666, -1)){
printList(&list);
} else{
printf("插入失败!");
}
如果我们的表已经装满了,此时需要考虑进行扩容了,否则就没办法插入新的元素了:
_Bool insertList(ArrayList list, E element, int index){
if(index < 1 || index > list->size + 1) return 0;
if(list->size == list->capacity) { //如果size已经到达最大的容量了,那么此时就需要扩容了
int newCapacity = list->capacity + (list->capacity >> 1); //先计算一下新的容量大小,取1.5倍原长度(自行调整)
E * newArray = realloc(list->array, sizeof(E) * newCapacity); //使用函数realloc重新申请更大的内存空间
if(newArray == NULL) return 0; //如果申请失败,那么就确实没办法插入了,只能返回0表示插入失败了
list->array = newArray;
list->capacity = newCapacity;
}
for (int i = list->size; i > index - 1; i--)
list->array[i] = list->array[i - 1];
list->array[index - 1] = element;
list->size++;
return 1;
}
realloc函数可以做到控制动态内存开辟的大小,重新申请的内存空间大小就是我们指定的新的大小,并且原有的数据也会放到新申请的空间中,所以非常方便。当然如果因为内存不足之类的原因导致内存空间申请失败,那么会返回NULL。
接着我们来编写一下删除操作,其实删除操作也比较类似,也需要对元素进行批量移动,但无需考虑扩容,只需考虑范围[1,size-1]:
_Bool deleteList(ArrayList list, int index){
if(index < 1 || index > list->size) return 0;
for (int i = index - 1; i < list->size - 1; ++i)
list->array[i] = list->array[i + 1]; //实际上只需要依次把后面的元素覆盖到前一个即可
list->size--; //最后别忘了size - 1
return 1;
}
接下来我们再完善一下查询操作:
- 按位置获取元素
E * getList(ArrayList list, int index){
if(index < 1 || index > list->size) return NULL; //如果超出范围就返回NULL
return &list->array[index - 1];
}
- 查找指定元素的位置
int findList(ArrayList list, E element){
for (int i = 0; i < list->size; ++i) { //一直遍历,如果找到那就返回位序
if(list->array[i] == element) return i + 1;
}
return -1; //如果遍历完了都没找到,那么就返回-1
}
综上,我们完成了一个完整的顺序表,全部代码如下:
#include "stdio.h"
#include "stdlib.h"
typedef struct List *ArrayList;
struct List {
int *array;
int capacity;
int size;
};
_Bool initList(ArrayList list) {
list->array = malloc(sizeof(int) * 10);
if (list->array == NULL) return 0;
list->capacity = 10;
list->size = 0;
return 1;
}
_Bool insertList(ArrayList list, int element, int index) {
if (index < 1 || index > list->size + 1) return 0;
if (list->size == list->capacity) {
int newCapacity = list->capacity + (list->capacity >> 1);
int *newArray = realloc(list->array, newCapacity * sizeof(int));
if (newArray == NULL) return 0;
list->array = newArray;
list->capacity = newCapacity;
}
for (int i = list->size; i > index + 1; --i) {
list->array[i] = list->array[i - 1];
}
list->array[index - 1] = element;
list->size++;
return 1;
}
void printList(ArrayList list) {
for (int i = 0; i < list->size; ++i) {
printf("%d ", list->array[i]);
}
printf("\n");
}
_Bool deleteList(ArrayList list, int index) {
if (index < 1 || index > list->size) return 0;
for (int i = index - 1; i < list->size - 1; ++i) {
list->array[i] = list->array[i + 1];
}
list->size--;
return 1;
}
int sizeList(ArrayList list) {
return list->size;
}
int getList(ArrayList list, int index) {
if (index < 1 || index > list->size) return NULL;
return list->array[index - 1];
}
int findList(ArrayList list, int element) {
for (int i = 0; i < list->size; ++i) {
if (list->array[i] == element) return i + 1;
}
return -1;
}
int main() {
struct List list;
if (initList(&list)) {
for (int i = 0; i < 30; ++i)
insertList(&list, i, i);
printList(&list);
deleteList(&list, 29);
printList(&list);
int size = sizeList(&list);
printf("size:%d\n", size);
int num_28 = getList(&list, 28);
printf("num_28:%d\n", num_28);
int index_28 = findList(&list, 28);
printf("index_28:%d", index_28);
} else {
printf("顺序表初始化失败,无法启动程序!");
}
}
顺序实现的线性表,插入、删除、获取元素操作的时间复杂度为:
- 插入: 因为要将后续所有元素都向后移动,所以平均时间复杂度为O(n)
- 删除: 同上,因为要将所有元素向前移动,所以平均时间复杂度为`O()
- 获取元素: 因为可以利用数组特性直接通过下标访问到对应元素,所以时间复杂度为O(1)
习题:
- 在一个长度为n的顺序表中,向第i个元素前插入一个新的元素时,需要向后移动多少个元素?A. n - i B. n - i + 1 C. n - i - 1 D. i答案是B
- 顺序表是一种( )的存储结构?A. 随机存取 B. 顺序存取 C. 索引存取 D. 散列存取首先顺序表底层是基于数组实现的,那么它肯定是支持随机访问的,因为我们可以直接使用下标想访问哪一个就访问哪一个,所以选择A。
链表
前面我们介绍了如何使用数组实现线性表,我们接着来看第二种方式,我们可以使用链表来实现。
链表不同于顺序表,顺序表底层采用数组作为存储容器,需要分配一块连续且完整的内存空间进行使用,而链表则不需要,它通过一个指针来连接各个分散的结点,形成了一个链状的结构,每个结点存放一个元素,以及一个指向下一个结点的指针,通过这样一个一个相连,最后形成了链表。它不需要申请连续的空间,只需要按照顺序连接即可,虽然物理上可能不相邻,但是在逻辑上依然是每个元素相邻存放的,这样的结构叫做链表(单链表)。
链表分为带头结点的链表和不带头结点的链表,戴头结点的链表就是会有一个头结点指向后续的整个链表,但是头结点不存放数据:
而不带头结点的链表就像上面那样,第一个节点就是存放数据的结点,一般设计链表都会采用带头结点的结构,因为操作更加方便。
下面是一个带头结点的链表:
typedef int E;
struct ListNode {
E element; //保存当前元素
struct ListNode * next; //指向下一个结点的指针
};
typedef struct ListNode * Node; //这里我们直接为结点指针起别名,可以直接作为链表实现
同样的,我们先将初始化函数写好:
void initList(Node node){
node->next = NULL; //头结点默认下一个为NULL
}
int main() {
struct ListNode head; //这里创建一个新的头结点,头结点不存放任何元素,只做连接,连接整个链表
initList(&head); //先进行初始化
}
接着我们来设计一下链表的插入和删除,我们可以先修改新插入的结点的后继结点(也就是下一个结点)指向,指向原本在这个位置的结点:
接着我们可以将前驱结点(也就是上一个结点)的后继结点指向修改为我们新插入的结点:
这样,我们就成功插入了一个新的结点,现在新插入的结点到达了原本的第二个位置上:
设计一下函数:
void insertList(Node head, E element, int index){
//head是头结点,element为待插入元素,index是待插入下标
}
接着我们需要先找到待插入位置的前驱结点:
_Bool insertList(Node head, E element, int index){
if(index < 1) return 0; //如果插入的位置小于1,非法
while (--index) { //通过--index的方式不断向后寻找前驱结点
head = head->next; //正常情况下继续向后找
if(head == NULL) return 0;
//如果在寻找的过程中发现已经没有后续结点了,那么说明index超出可插入的范围了,也是非法的,直接润
}
return 1;
}
在循环操作完成后,如果没问题那么会找到对应插入位置的前驱结点,我们只需要按照上面分析的操作来编写代码即可:
_Bool insertList(Node head, E element, int index){
if(index < 1) return 0;
while (--index) {
head = head->next;
if(head == NULL) return 0;
}
Node node = malloc(sizeof (struct ListNode));
if(node == NULL) return 0; //创建一个新的结点,如果内存空间申请失败返回0
node->element = element; //将元素保存到新创建的结点中
node->next = head->next; //先让新插入的节点指向原本位置上的这个结点
head->next = node; //接着将前驱结点指向新的这个结点
return 1;
}
这样,我们就编写好了链表的插入操作了,我们可以来测试一下:
#include <stdio.h>
#include <stdlib.h>
typedef int E;
struct ListNode {
E element; //保存当前元素
struct ListNode * next; //指向下一个结点的指针
};
typedef struct ListNode *Node; //这里我们直接为结点指针起别名,可以直接作为链表实现
void initList(Node node){
node->next = NULL; //头结点默认下一个为NULL
}
_Bool insertList(Node head, E element, int index){
if(index < 1) return 0;
while (--index) {
head = head->next;
if(head == NULL) return 0;
}
Node node = malloc(sizeof (struct ListNode));
if(node == NULL) return 0; //创建一个新的结点,如果内存空间申请失败返回0
node->element = element; //将元素保存到新创建的结点中
node->next = head->next; //先让新插入的节点指向原本位置上的这个结点
head->next = node; //接着将前驱结点指向新的这个结点
return 1;
}
void printList(Node head){
while (head->next) {
head = head->next;
printf("%d ", head->element); //因为头结点不存放数据,所以从第二个开始打印
}
}
int main() {
struct ListNode head;
initList(&head);
for (int i = 0; i < 3; i++) {
insertList(&head, i * 100, i); //依次插入3个元素
}
printList(&head); //打印一下看看
}
接着就是结点的删除了,我们可以直接将待删除节点的前驱结点指向修改为待删除节点的下一个:
这样,在逻辑上来说,待删除结点其实已经不在链表中了,所以我们只需要释放掉待删除结点占用的内存空间就行了:
我们按照上面的思路设计下函数:
void deleteList(Node head, int index){
//head就是头结点,index依然是待删除的结点位序
}
首先我们还是需要找到待删除结点的前驱结点:
_Bool deleteList(Node head, int index){
if(index < 1) return 0;
while (--index) {
head = head->next;
if(head == NULL) return 0;
}
if(head->next == NULL) return 0;
return 1;
}
然后就是按照上图所示删除结点了:
_Bool deleteList(Node head, int index){
if(index < 0) return 0;
while (index--) {
head = head->next;
if(head == NULL) return 0;
}
if(head->next == NULL) return 0;
Node tmp = head->next; //先拿到待删除结点
head->next = head->next->next; //直接让前驱结点指向下一个的下一个结点
free(tmp); //最后使用free函数释放掉待删除结点的内存!!!这是非常重要的操作,如果程序不结束,内存会被一直占用,直接死机
return 1;
}
下面我们测试一下删除的操作:
int main() {
struct ListNode head;
initList(&head);
for (int i = 0; i < 3; i++) {
insertList(&head, i * 100, i);
}
printList(&head);
printf("\n");
deleteList(&head, 1);
printList(&head);
}
下面实现一些其他的操作,获取对应位置上的元素:
E * getList(Node head, int index){
if(index < 1) return NULL;
do {
head = head->next; //因为不算头结点,所以使用do-while语句
if(head == NULL) return NULL; //超出长度
} while (--index); //到达index就结束
return &head->element;
}
接着是查找对应元素的位置:
int findList(Node head, E element){
head = head->next; //先走到第一个结点
int i = 1; //计数器
while (head) {
if(head->element == element) return i; //如果找到,那么就返回i
head = head->next; //没找到就继续向后看
i++;
}
return -1; //都已经走到链表尾部了,那么就确实没找到了,返回-1
}
接着是求链表的长度:
int sizeList(Node head){
int i = 0; //从0开始
while (head->next) { //如果下一个为NULL那就停止
head = head->next;
i++; //每向后找一个就+1
}
return i;
}
这样,我们的链表就编写完成了,整个代码如下:
#include <stdio.h>
#include <stdlib.h>
typedef int E;
struct ListNode {
E element;
struct ListNode *next;
};
typedef struct ListNode *Node;
void initList(Node node) {
node->next = NULL;
}
_Bool insertList(Node head, E element, int index) {
if (index < 1) return 0;
while (--index) {
head = head->next;
if (head == NULL) return 0;
}
Node node = malloc(sizeof(struct ListNode));
if (node == NULL) return 0;
node->element = element;
node->next = head->next;
head->next = node;
return 1;
}
_Bool deleteList(Node head, int index) {
if (index < 1) return 0;
while (--index) {
head = head->next;
if (head == NULL) return 0;
}
if (head->next == NULL) return 0;
Node tmp = head->next;
head->next = head->next->next;
free(tmp);
return 1;
}
int getList(Node head, int index) {
if (index < 1) return 0;
do {
head = head->next;
if (head == NULL) return 0;
} while (--index);
return head->element;
}
int findList(Node head,E element) {
head = head->next;
int i = 1;
while (head) {
if (head->element == element) return 1;
head = head->next;
i++;
}
return -1;
}
int sizeList(Node head) {
int i = -1;
while (head) {
head = head->next;
i++;
}
return i;
}
void printList(Node head){
while (head->next) {
head = head->next;
printf("%d ", head->element); //因为头结点不存放数据,所以从第二个开始打印
}
}
int main() {
struct ListNode head;
initList(&head);
for (int i = 0; i < 5; i++) {
insertList(&head, i * 100, i); //依次插入3个元素
}
printList(&head);
printf("\n");
// deleteList(&head, 1);
// printList(&head);
// printf("\n");
int get_1 = getList(&head, 1);
printf("get_1:%d", get_1);
printf("\n");
int find_1 = findList(&head, 100);
printf("find_1:%d", find_1);
printf("\n");
printf("size:%d", sizeList(&head));
}
链式实现的线性表,插入、删除、获取元素操作的时间复杂度为:
- 插入: 因为要寻找对应位置的前驱结点,所以平均时间复杂度为O(n),但是不需要做任何的移动操作,效率肯定是比顺序表要高的。
- 删除: 同上,所以平均时间复杂度为O(n)
- 获取元素: 由于必须要挨个向后寻找,才能找到对应的结点,所以时间复杂度为O(n),不支持随机访问,只能顺序访问,比顺序表慢。
什么情况下使用顺序表,什么情况下使用链表呢?
- 通过分析顺序表和链表的特性我们不难发现,链表在随机访问元素时,需要通过遍历来完成,而顺序表则利用数组的特性直接访问得到,所以,当我们读取数据多于插入或是删除数据的情况下时,使用顺序表会更好。
- 而顺序表在插入元素时就显得有些鸡肋了,因为需要移动后续元素,整个移动操作会浪费时间,而链表则不需要,只需要修改结点指向即可完成插入,所以在频繁出现插入或删除的情况下,使用链表会更好。
链表练习题:
- 在一个长度为n (n>1)的单链表上,设有头和尾两个指针,执行( )操作与链表的长度有关?A.删除单链表中的第一个元素B.删除单链表中的最后一个元素C.在单链表第一个元素前插入一个新元素D.在单链表最后一个元素后插入一个新元素注意题干,现在有指向链表头尾的两个指针,那么A、C肯定是可以直接通过头结点找到的,无论链表长度如何都不影响,D也可以直接通过尾指针进行拼接,只有B需要尾指针的前驱结点,此时只能从头开始遍历得到,所以选择B
- 在一个单链表HL中(HL为头结点指针),若要向表头插入一个由指针p指向的结点,则执行?A. HL=p; p->next=HL;B. p->next=HL; HL=p;C. p->next=HL; p=HL;D. p->next=HL->next; HL->next=p;既然要在表头插入一个数据,也就是说要在第一个位置插入,那么根据我们之前讲解的链表的插入,只需要将头结点指向新的结点,再让新的结点指向原本的第一个结点即可,所以选择D
- 链表不具备的特点是?A.可随机访问任一结点 B.插入删除不需要移动元素C.不必事先估计存储空间 D.所需空间与其长度成正比我们前面说了,链表由于是链式存储结构,无法直接访问到对应下标的元素,所以我们只能通过遍历去找到对应位置的元素,故选择A
双向链表和循环链表
前面我们介绍了单链表,通过这样的链式存储,我们不用再像顺序表那样一次性申请一段连续的空间,而是只需要单独为结点申请内存空间,同时在插入和删除的速度上也比顺序表轻松。不过有一个问题就是,如果我们想要操作某一个结点,比如删除或是插入,那么由于单链表的性质,我们只能先去找到它的前驱结点,才能进行。
为了解决这种问题,我们可以让结点不仅保存指向后续结点的指针,同时也保存指向前驱结点的指针:
这里我们也来尝试实现一下,首先定义好结构体:
typedef int E;
struct ListNode {
E element; //保存当前元素
struct ListNode * next; //指向下一个结点的指针
struct ListNode * prev; //指向上一个结点的指针
};
typedef struct ListNode * Node;
接着是初始化方法,在初始化时需要将前驱和后继都设置为NULL:
void initNode(Node node){
node->next = node->prev = NULL;
}
int main() {
struct ListNode head;
initNode(&head);
}
接着是双向链表的插入操作,这就比单链表要麻烦一些了,我们先来看图分析一下:
首先我们需要考虑后继结点,当新的结点插入之后,新的结点的后继结点就是原本在此位置上的结点,所以我们可以先将待插入结点的后继指针指向此位置上的结点:
typedef int E;
struct ListNode {
E element; //保存当前元素
struct ListNode * next; //指向下一个结点的指针
struct ListNode * prev; //指向上一个结点的指针
};
typedef struct ListNode * Node;
接着是初始化方法,在初始化时需要将前驱和后继都设置为NULL:
void initNode(Node node){
node->next = node->prev = NULL;
}
int main() {
struct ListNode head;
initNode(&head);
}
接着是双向链表的插入操作,相较于单链表要麻烦些了:
首先我们需要考虑后继结点,当新的结点插入之后,新的结点的后继结点就是原本在此位置上的结点,所以我们可以先将待插入结点的后继指针指向此位置上的结点:

接着我们来处理一下前驱结点,首先将前驱结点的后继指针修改为新的结点:
最后我们将新的结点的前驱指针指向前驱结点即可:
这样,我们就完成了双向链表中结点的插入操作,按照这个思路,设计一下函数:
_Bool insertList(Node head, E element, int index){
if(index < 1) return 0; //先找到对应的位置
while (--index) {
head = head->next;
if(head == NULL) return 0;
}
Node node = malloc(sizeof (struct ListNode)); //创建新的结点
if(node == NULL) return 0;
node->element = element;
if(head->next) { //首先处理后继结点,现在有两种情况,一种是后继结点不存在的情况,还有一种是后继结点存在的情况
head->next->prev = node; //如果存在则修改对应的两个指针
node->next = head->next;
} else {
node->next = NULL; //不存在直接将新结点的后继指针置为NULL
}
head->next = node; //接着是前驱结点
node->prev = head;
return 1;
}
这样,我们就编写好了双向链表的插入操作,来测试一下:
int main() {
struct ListNode head;
initNode(&head);
for (int i = 0; i < 5; ++i) //插5个元素
insertList(&head, i * 100, i);
Node node = &head; //正向遍历
do {
node = node->next;
printf("%d -> ", node->element);
} while (node->next != NULL);
printf("\n"); //反向遍历
do {
printf("%d -> ", node->element);
node = node->prev;
} while (node->prev != NULL);
}
我们接着来看删除操作,其实删除操作也是差不多的方式:

我们只需将前驱结点和后继结点的指向修改即可:
接着直接删除对应的结点即可:
现在我们来看代码:
_Bool deleteList(Node head, int index){
if(index < 1) return 0; //先找到对应的位置
while (--index) {
head = head->next;
if(head == NULL) return 0;
}
if(head->next == NULL) return 0;
Node tmp = head->next; //先拿到待删除结点
if(head->next->next) { //这里有两种情况待删除结点存在后继结点或是不存在
head->next->next->prev = head;
head->next = head->next->next;
}else{
head->next = NULL; //相当于删的是最后一个结点,所以直接后继为NULL
}
free(tmp); //最后释放已删除结点的内存
return 1;
}
这样,我们就实现了双向链表的插入和删除操作,剩下的操作几乎与之前一致。
循环链表,这种链表实际上和前面我们讲的链表是一样的,但是它的最后一个结点,是与头结点相连的,双向链表和单向链表都可以做成这样的环形结构,我们这里以单链表为例:
这种类型的链表实际上与普通链表的唯一区别就在于最后是否连接到头结点,因此循环链表支持从任意一个结点出发都可以到达任何的结点,而普通的链表则只能从头结点出发才能到达任意结点。
链表练习题:
- 与单链表相比,双链表的优点之一是?A.插入、删除操作更简单B.可以进行随机访问C.可以省略表头指针或表尾指针D.顺序访问相邻结点更灵活首先插入删除操作并没有更简单,反而更复杂了,随机访问肯定也是不行的,省略表头表尾指针实际上单链表也可以,所以选D
- 非空的循环单链表head的尾结点(由p所指向)满足?A.p->next == NULL B.p == NULLC.p->next ==head D.p == head循环链表实际上唯一区别就是尾部的下一个结点会指向头部,所以这里选择C
- 若某表最常用的操作是在最后一个结点之后插入一个结点或删除最后一个结点,则采用什么存储方式最节省运算时间?A.单链表 B.给出表头指针的单循环链表 C.双链表 D.带头结点的双循环链表题干说明了常用的是在尾结点插入或删除尾结点,那么此时不仅需要快速找到最后一个结点,也需要快速找到最后一个结点的前驱结点,所以肯定是使用双向链表,为了快速找到尾结点,使用循环双向链表从头结点直接向前就能找到,所以选择D
- 如果对线性表的操作只有两种,即删除第一个元素,在最后一个元素的后面插入新元素,则最好使用?A.只有表头指针没有表尾指针的循环单链表B.只有表尾指针没有表头指针的循环单链表C.非循环双链表D.循环双链表首先这里需要操作两个内容,一个是删除第一个元素,另一个是在最后插入新元素,所以A的话只有表头指针虽然循环但是还是得往后遍历才行,而B正好符合,因为循环链表的尾指针可以快速到达头结点,C不可能,D的话,循环双链表也可以,但是没有单链表节省空间,故B是最优解
特殊线性表
前面我们提到的都是基础的线性表,这一部分,我们将继续认识一些特殊的线性表,它有着特别的规则,在特定场景有着很大的作用。
栈
栈(也叫堆栈,Stack)是一种特殊的线性表,它只能在在表尾进行插入和删除操作,就像下面这样:
也就是说,我们只能在一端进行插入和删除,当我们依次插入1、2、3、4这四个元素后,连续进行四次删除操作,删除的顺序刚好相反:4、3、2、1,我们一般将其竖着看:
底部称为栈底,顶部称为栈顶,所有的操作只能在栈顶进行,也就是说,被压在下方的元素,只能等待其上方的元素出栈之后才能取出,这就是栈的思想,它是一种先进后出的数据结构(FILO,First In, Last Out)
实现栈也是非常简单的,这里我们先使用顺序表来实现一下,我们需要实现两个新的操作:
- pop:出栈操作,从栈顶取出一个元素。
- push:入栈操作,向栈中压入一个新的元素。
我们模仿之前顺序表的方式进行编写:
typedef int E;
struct Stack {
E * array;
int capacity;
int top; //这里使用top来表示当前的栈顶位置,存的是栈顶元素的下标
};
typedef struct Stack * ArrayStack; //起个别名
接着我们编写一个初始化方法:
_Bool initStack(ArrayStack stack){
stack->array = malloc(sizeof(E) * 10);
if(stack->array == NULL) return 0;
stack->capacity = 10; //容量还是10
stack->top = -1; //由于栈内没有元素,那么栈顶默认就为-1
return 1;
}
int main(){
struct Stack stack;
initStack(&stack);
}
接着就是栈的两个操作了,一个是入栈操作,一个是出栈操作:
_Bool pushStack(ArrayStack stack, E element){
//入栈操作只需要给元素就可以,不需要index,因为只能从尾部入栈
stack->array[stack->top + 1] = element; //直接设定栈顶元素
stack->top++; //栈顶top变量记得自增
return 1;
}
测试一下:
void printStack(ArrayStack stack){
printf("| ");
for (int i = 0; i < stack->top + 1; ++i) {
printf("%d, ", stack->array[i]);
}
printf("\n");
}
int main(){
struct Stack stack;
initStack(&stack);
for (int i = 0; i < 3; ++i) {
pushStack(&stack, i*100);
}
printStack(&stack);
}
可以看到,从栈底到栈顶一次是0、100、200,不过我们现在的push操作还不够完美,因为栈有可能塞满,所以要进行扩容处理:
_Bool pushStack(ArrayStack stack, E element){
if(stack->top + 1 == stack->capacity) { //栈顶+1如果等于容量的话,那么说明已经塞满了
int newCapacity = stack->capacity + (stack->capacity >> 1); //操作几乎和顺序表一致
E * newArray = realloc(stack->array, newCapacity * sizeof(E));
if(newArray == NULL) return 0;
stack->array = newArray;
stack->capacity = newCapacity;
}
stack->array[stack->top + 1] = element;
stack->top++;
return 1;
}
接着是出栈操作,出栈操作我们只需要将栈顶元素取出即可:
_Bool isEmpty(ArrayStack stack){ //在出栈之前,我们还需要使用isEmpty判断一下栈是否为空,空栈元素都没有出个毛线
return stack->top == -1;
}
E popStack(ArrayStack stack){
return stack->array[stack->top--]; //直接返回栈顶元素,注意多加一个自减操作
}
测试一下:
int main(){
struct Stack stack;
initStack(&stack);
for (int i = 0; i < 3; ++i) {
pushStack(&stack, i*100);
}
printStack(&stack);
while (!isEmpty(&stack)) {
printf("%d ", popStack(&stack)); //将栈中所有元素依次出栈
}
}
栈练习题:
- 若进栈序列为1,2,3,4,则不可能得到的出栈序列是?A. 3,2,1,4 B. 3,2,4,1C. 4,2,3,1 D. 2,3,4,1注意进栈并不一定会一次性全部进栈,可能会出现边进边出的情况,所以出栈的顺序可能有很多种情况,首先来看A,第一个出栈的是3,那么按照顺序,说明前面一定入栈了2、1,在出栈时4还没有入栈,然后是2、1最后是4,没有问题。接着是B,跟前面的A一样,不过这次是先出站3、2,而1留在栈中,接着4入栈,然后再让4、1出栈,也是正确的。然后是C,首先是4出栈,那么说明前三个一定都入栈了,而此时却紧接着的一定是3,而这里是2,错误。所以选择C
- 假设有5个整数以1、2、3、4、5的顺序被压入堆栈,且出栈顺序为3、5、4、2、1,那么栈大小至少为?A.2B.3C.4D.5首先我们分析一下,第一个出栈的元素为3,那么也就是说前面的1、2都在栈内,所以大小至少为3,然后是5,那么说明此时栈内为1、2、4,算是出栈的5,那么至少需要的大小就是4了,所以选择C
队列
前面我们学习了栈,栈中元素只能栈顶出入,它是一种特殊的线性表,同样的,队列(Queue)也是一种特殊的线性表。
队列有队头和队尾,遵循先来后到的原则,队列中的元素只能从队尾进入,只能从队首出去,也就是说,入队顺序为1、2、3、4,那么出队顺序也一定是1、2、3、4,所以队列是一种先进先出(FIFO,First In, First Out)的数据结构。
想要实现队列也是很简单的,也可以通过两种线性表来实现,我们使用顺序表实现队列,假设一开始的时候队列中有0个元素,队首和队尾一般都初始都是-1这个位置:

此时有新的元素入队了,队尾向后移动一格(+1),然后在所指向位置插入新的元素:
之后都是同样的方式进行插入,队尾会一直向后移动:
现在我们想要执行出队操作,那么需要将队首向后移动一格,然后删除队首指向的元素:
存在这样一个问题,这个队列是一次性的,如果队列经过反复出队入队操作,那么最后指针会直接指向数组的最后,如果我们延长数组的话,也不可能无限制的延伸下去。一般采用循环队列的形式,来实现重复使用一个数组(不过就没办法扩容了,大小是固定的)
我们可以在移动队首队尾指针时,考虑循环的问题,也就是说如果到达了数组尽头,那么就直接从数组的前面重新开始计算,这样就相当于逻辑上循环了,队首和队尾指针在一开始的时候都指向同一个位置,每入队一个新的元素,依然是先让队尾后移一位,在所指向位置插入元素,出队同理。
下面我们怎么判断队列是否已满呢?
由于队首指针和队尾指针重合时表示队列为空,所以我们只能舍弃一个存储单元,当队尾距离队首一个单元的时候,表示队列已满。
好了,现在理论简述完毕,我们开始编写代码:
typedef int E;
struct Queue {
E * array;
int capacity; //数组容量
int rear, front; //队尾、队首指针
};
typedef struct Queue * ArrayQueue;
接着我们来对其进行初始化:
_Bool initQueue(ArrayQueue queue){
queue->array = malloc(sizeof(E) * 10);
if(queue->array == NULL) return 0;
queue->capacity = 10;
queue->front = queue->rear = 0; //默认情况下队首和队尾都指向0的位置
return 1;
}
int main(){
struct Queue queue;
initQueue(&queue);
}
下面是入队操作:
_Bool offerQueue(ArrayQueue queue, E element){
if((queue->rear + 1) % queue->capacity == queue->front) //先判断队列是否已满,如果队尾下一个就是队首,那么说明已满
return 0;
queue->rear = (queue->rear + 1) % queue->capacity; //队尾先向前移动一位,注意取余计算才能实现循环
queue->array[queue->rear] = element; //在新的位置插入元素
return 1;
}
下面我们测试一下:
void printQueue(ArrayQueue queue){
printf("<<< ");
int i = queue->front; //遍历队列需要从队首开始
do {
i = (i + 1) % queue->capacity; //先向后循环移动
printf("%d ", queue->array[i]); //然后打印当前位置上的元素
} while (i != queue->rear); //当到达队尾时,结束
printf("<<<\n");
}
int main(){
struct Queue queue;
initQueue(&queue);
for (int i = 0; i < 5; ++i) {
offerQueue(&queue, i * 100);
}
printQueue(&queue);
}
我们接着来看出队操作:
_Bool isEmpty(ArrayQueue queue){ //在出队之前需要先看看容量是否足够
return queue->rear == queue->front;
}
E pollQueue(ArrayQueue queue){
queue->front = (queue->front + 1) % queue->capacity; //先将队首指针后移
return queue->array[queue->front]; //出队,完事
}
可以看到,队列是先进先出的,我们是以什么顺序放入队列中,那么出来的就是是什么顺序。
同样的,队列也可以使用链表来实现,并且使用链表的话就不需要关心容量之类的问题了:
我们需要同时保存队首和队尾两个指针,因为是单链表,所以队首需要存放指向头结点的指针,因为需要的是前驱结点,而队尾则直接是指向尾结点的指针即可,后面只需要直接在后面拼接就行。
当有新的元素入队时,只需要拼在队尾就行了,同时队尾指针也要后移一位:
出队时,只需要移除队首指向的下一个元素即可:
那么我们就按照这个思路,来编写一下代码:
typedef int E;
struct LNode {
E element;
struct LNode * next;
};
typedef struct LNode * Node;
struct Queue{
Node front, rear;
};
typedef struct Queue * LinkedQueue; //因为要存储首位两个指针,所以这里封装一个新的结构体
接着是初始化,初始化的时候,需要把头结点先创建出来:
_Bool initQueue(LinkedQueue queue){
Node node = malloc(sizeof(struct LNode));
if(node == NULL) return 0;
queue->front = queue->rear = node; //一开始两个指针都是指向头结点的,表示队列为空
return 1;
}
int main(){
struct Queue queue;
initQueue(&queue);
}
首先是入队操作,入队直接在后面插入新的结点:
_Bool offerQueue(LinkedQueue queue, E element){
Node node = malloc(sizeof(struct LNode));
if(node == NULL) return 0;
node->element = element;
queue->rear->next = node; //先让尾结点的下一个指向新的结点
queue->rear = node; //然后让队尾指针指向新的尾结点
return 1;
}
来测试一下:
void printQueue(LinkedQueue queue){
printf("<<< ");
Node node = queue->front->next;
while (1) {
printf("%d ", node->element);
if(node == queue->rear) break;
else node = node->next;
}
printf("<<<\n");
}
int main(){
struct Queue queue;
initQueue(&queue);
for (int i = 0; i < 5; ++i) {
offerQueue(&queue, i*100);
}
printQueue(&queue);
}
接着是出队操作,出队操作要相对麻烦一点:
_Bool isEmpty(LinkedQueue queue){ //在出队之前需要先看看容量是否足够
return queue->rear == queue->front;
}
E pollQueue(LinkedQueue queue){
E e = queue->front->next->element;
Node node = queue->front->next;
queue->front->next = queue->front->next->next; //直接让头结点指向下下个结点
if(queue->rear == node) queue->rear = queue->front; //如果队尾就是待出队的结点,那么队尾回到队首位置上
free(node); //释放内存
return e;
}
测试一下:
int main(){
struct Queue queue;
initQueue(&queue);
for (int i = 0; i < 5; ++i) {
offerQueue(&queue, i*100);
}
printQueue(&queue);
while (!isEmpty(&queue)){
printf("%d ", pollQueue(&queue));
}
}
队列练习题:
- 使用链表方式存储的队列,在进行出队操作时需要?A. 仅修改头结点指向 B. 仅修改尾指针 C. 头结点指向、尾指针都要修改 D. 头结点指向、尾指针可能都要修改首先出队肯定是要动头结点指向的,但是不一定需要动尾指针,因为只有当尾指针指向的是待出队的元素时才需要,因为执行后队列就为空了,所以需要将队尾指针移回头结点处,选择D
- 引起循环队列队头位置发生变化的操作是?A. 出队B. 入队C. 获取队头元素D. 获取队尾元素这个题还是很简单的,因为只有出队操作才会使得队头位置后移,所以选择A