🏠关于此专栏:Super数据结构专栏将使用C/C++语言介绍顺序表、链表、栈、队列等数据结构,每篇博文会使用尽可能多的代码片段+图片的方式。
🚪归属专栏:Super数据结构
🎯每日努力一点点,技术累计看得见
文章目录
- 线性表
- 顺序表
- 概念及存储结构
- 接口实现
- 顺序表OJ
- 链表
- 链表的概念及结构
- 单链表接口实现
- 链表的分类
- 双向链表接口实现
- 链表OJ
- 顺序表和链表的区别
线性表
线性表是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串…
线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,
线性表在物理上存储时,通常以数组(物理结构连续)和链式结构(物理结构不一定连续)的形式存储。
这篇小文章就为大家介绍介绍上图的顺序表和链表。这两者到底有什么区别,为什么要分成两种不同的存储形式呢?且看下文分解↓↓↓
顺序表
概念及存储结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。
顺序表一般可以分为:
- 静态顺序表:使用定长数组存储元素。
#define N 100
typedef int SLDataType;
typedef sturct SeqList
{
SLDataType array[N]; //定长数组
int size; //有效元素个数
}SeqList;
- 动态顺序表:使用动态开辟的数组存储。
#define N 100
typedef int SLDataType;
typedef stuct SeqList
{
SLDataType* array; //指向动态开辟的数组
int size; //已经存储的有效元素个数
int capacity; //已开辟的空间大小
}SeqList;
接口实现
作为一种存储结构,那它就逃不过增、删、改、查。下面我们就对顺序表的各种功能接口做出介绍。
对于静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
首先,我们定义一个SeqList结构体,该结构体包含指向动态开辟的数组的指针、已存储元素个数、已开辟的空间的大小(也就是当前顺序表的容量)。↓↓↓
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* _a; //指向动态开辟的数组的指针
int _size; //已存储元素个数
int _capacity; //顺序表的容量
}SeqList;
下面咱们来看一下顺序表的全部接口:
//初始化
void SeqListInit(SeqList* ps, int capacity);
//容量检查(扩大容量)
void CheckCapacity(SeqList* ps);
//尾部插入
void SeqListPushBack(SeqList* ps, SLDataType x);
//尾部删除
void SeqListPopBack(SeqList* ps);
//头部插入
void SeqListPushFront(SeqList* ps, SLDataType x);
//头部删除
void SeqListPopFront(SeqList* ps);
//指定位置插入
void SeqListInsert(SeqList* ps, int index, SLDataType x);
//指定位置删除
void SeqListErase(SeqList* ps, int index);
//查找
int SeqListFind(SeqList* ps, SLDataType x);
//销毁
void SeqListDestory(SeqList* ps);
//打印顺序表
void SeqListPrint(SeqList* ps);
顺序表初始化
在顺序表的所有操作前,我们需要对顺序表做初始化操作。初始时,为线性表开辟一定大小的空间。实现代码如下↓↓↓
void SeqListInit(SeqList* ps, int capacity)
{
assert(ps);
assert(capacity > 0);
ps->_a = (SLDataType*)malloc(sizeof(SLDataType) * capacity);
if(ps->_a == NULL)
{
perror("malloc error!\n");
return;
}
ps->_size = 0;
ps->_capacity = capacity;
}
顺序表尾部插入元素
对于顺序表,我们可以在它的尾部增加一个数据。假设此时我们有一个容量为8,已经存储了2个有效数据的顺序表。此时我们只需要在ps->_size
的位置放置新的元素,再将ps->_size
加1,即可完成尾部增加数据的操作。
void SeqListPushBack(SeqList* ps, SLDataType x)
{
assert(ps);
ps->_a[ps->_size] = x;
ps->_size++;
}
如果代码写成上面这样,会存在什么问题呢?如果容量为8,已经插入8个有效数据的线性表,此时访问ps->_a[ps->_size]
时,就会发生数组越界。因此,我们需要在插入新数据前,对顺序表进行容量检查。
void CheckCapacity(SeqList* ps)
{
if(ps->_size == ps->_capacity)
{
ps->_a = (SLDataType*)realloc(ps->_a, sizeof(SLDataType) * ps->_capacity * 2);
if(ps->_a == NULL)
{
perror("malloc error\n");
return;
}
ps->_capacity *= 2;
}
}
void SeqListPushBack(SeqList* ps, SLDataType x)
{
assert(ps);
CheckCapacity(ps);
ps->_a[ps->_size] = x;
ps->_size++;
}
顺序表尾部删除元素
我们不仅可以在尾部增加数据,也可以删除尾部的数据。但要注意的是,如果顺序表的已存储数据的数量为0时,此时我们不能再进行删除。
void SeqListPopBack(SeqList* ps)
{
assert(ps);
assert(ps->_size > 0);
ps->_size--;
}
★ps:在删除数据时,我们需不需要将ps->_a[ps->_size]中的元素先置为0,再执行ps->_size–呢?答案是不用。因为ps->_size表示的是有效数据的长度,下标为ps->_size到ps->capacity-1的元素均不属于有效数据,我们也不应该支持用户访问这些空间。既然用户访问不到,我们也就不需要将它设置为0。后序新增数据,将会覆盖这些旧的数据。
上面两个接口都是在尾部进行操作,下面再介绍两个在头部进行操作的接口——头部插入和头部删除。
顺序表头部插入元素
对于头部插入来说,既然是新增数据,我们就需要保证顺序表的容量足以保存新元素。因而在新增元素前,需要对容量进行检查。另外,在将新元素插入头部前,我们需要将所有有效数组向后移动一个下标。下图为一个容量为8,已经存储3个有效数据的顺序表,先将3个有效数据后移一个下标后,再将新元素66放置到0下标处。
void SeqListPushFront(SeqList* ps, SLDataType x)
{
assert(ps);
CheckCapacity(ps);
//有效数据后移
for(int i = ps->_size; i > 0; i--)
{
ps->_a[i] = ps->_a[i - 1];
}
ps->_a[0] = x;
ps->_size++;
}
顺序表头部删除元素
那如果是要删除第一个元素呢?我们就需要将除下标为0的元素向前移动一个下标,再对ps->_size做减1操作。
void SeqListPopFront(SeqList* ps)
{
assert(ps);
for(int i = 0; i < ps->_size - 1; i++)
{
ps->_a[i] = ps->_a[i + 1];
}
ps->_size--;
}
上面的插入、删除操作都是在头部或者尾部,那可以不可以在任意位置做插入删除呢?答案肯定是可以的。我们先来介绍如何在任意位置插入一个数据。
顺序表任意位置插入元素
如果我们有3个数据,则我们可以在下标为0到3的位置插入一个新的数据。在插入数据前,我们需要对顺序表的容量做出检查,再将要插入位置及该位置后面的有效数据向后移动一个下标。
如果我们现在一个容量为9,已经存储4个数据的顺序表。我们要在第1号下标插入一个66时,需要将1号下标到3号下标的元素向后移动1位,再将第1号下标元素改为66。最后,将size++,就完成插入操作了。
void SeqListInsert(SeqList* ps, int index, SLDataType x)
{
assert(ps);
assert(index >= 0 && index <= ps->_size);
CheckCapacity(ps);
for(int i = ps->_size; i > index; i--)
{
ps->_a[i] = ps->_a[i - 1];
}
ps->_a[index] = x;
ps->_size++;
}
顺序表任意位置删除元素
如果是删除某个位置的元素,只要将该位置之后的元素向前移动一个下标,覆盖这个将要被删除的元素,再将ps->_size做减1操作即可。
如果们要删除下图数组的1号下标元素,只要将1号元素后序元素向前移动一位,将1号下标存储内容覆盖后,再将size–即可。
void SeqListErase(SeqList* ps, int index)
{
assert(ps);
assert(index >= 0 && index < ps->_size);
for(int i = index; i < ps->_size - 1; i++)
{
ps->_a[i] = ps->_a[i + 1];
}
ps->_size--;
}
顺序表查找元素
介绍完上面的增删操作后,我们来实现查找操作,如果找到指定元素返回第一个等于待查找数据的下标位置;如果没有找到则返回-1。
int SeqListFind(SeqList* ps, SLDataType x)
{
assert(ps);
for(int i = 0; i < ps->_size; i++)
{
if(ps->_a[i] == x)
{
return i;
}
}
return -1;
}
顺序表打印、销毁
最后,我们再实现销毁顺序表和打印顺序表的操作。
void SeqListDestory(SeqList* ps)
{
assert(ps);
free(ps->_a);
ps->_size = ps->_capacity = 0;
}
void SeqListPrint(SeqList* ps)
{
assert(ps);
for(int i = 0; i < ps->_size; i++)
{
printf("%d ", ps->_a[i]);
}
if(ps->_size != 0)
{
printf("\n");
}
}
上面就是所有的顺序表接口了,下面我们分析一下上面的操作↓↓↓
- 头部、中部插入数据时,需要将部分元素向后移动一个下标。时间复杂度为O(N)。
- 在顺序表容量不足时,需要扩容。扩容的效率较低。
- 对于头部插入、尾部插入均可以复用SeqListInsert,修改后的代码如下↓↓↓
void SeqListPushFront(SeqList* ps, SLDataType x)
{
SeqListInsert(ps, 0, x);
}
void SeqListPushBack(SeqList* ps, SLDataType x)
{
SeqListInsert(ps, ps->_size, x);
}
- 对于头部删除、尾部删除均可以复用SeqListErase,修改后的代码如下↓↓↓
void SeqListPopFront(SeqList* ps)
{
SeqListErase(ps, 0);
}
void SeqListPopBack(SeqList* ps)
{
SeqListErase(ps, ps->_size - 1);
}
顺序表OJ
了解完顺序表之后,我们来使用它的性质解决一些问题吧!(点击题目链接,可以跳转到对应题目)
( ఠൠఠ )ノtest1:移除元素
题目要求我们移除所有数值为val的元素。
题目要求实现算法的空间复杂度为O(1),时间复杂度为O(N)。我们可以借助双指针的思想,利用变量tail保存不等于val的元素的个数,变量i用于遍历整个数组。如果num[i]!=val,则执行num[tail]=num[i],tail++,i++;如果num[i]==val,则执行i++。下图演示了上述文字描述的思想。
我们可以发现最终结果中,tail所在下标前的所有位置保存了所有不等于val的值,同时tail就是不等于val的值的数量。下面我们实现上述思想↓↓↓
int removeElement(int* nums, int numsSize, int val) {
int tail = 0;
for(int i = 0; i < numsSize; i++)
{
if(nums[i] != val)
{
nums[tail++] = nums[i];
}
}
return tail;
}
( ఠൠఠ )ノtest2:删除有序数组中的重复项
使用一个变量tail将各个元素记录一遍。用storage记录第一个元素。如果在遍历的过程中,nums[i]不等于storage,说明storage到nums[i]之间重复元素已经被略过(或者它们之间根本没有重复元素)。由于storage与nums[i]不同,此时storage保存到tail指向的下标之前,storage变为与原存储值不同的nums[i]。最后需要将storage再存储一遍,因为storage与tail已经存储的值均不同。
int removeDuplicates(int* nums, int numsSize) {
int storage = nums[0];
int tail = 0;
for(int i = 1; i < numsSize; i++)
{
if(nums[i] != storage)
{
nums[tail++] = storage;
storage = nums[i];
}
}
nums[tail++] = storage;
return tail;
}
( ఠൠఠ )ノtest3:合并两个有序数组
这道题的解题思路有点类似于归并排序。我们可以借助归并排序思想结合双指针来解决。定义一个变量p1指向第一个数组的最后一个元素,定义一个变量p2指向第二个数组的最后一个元素。再定义一个变量tail指向数组一的最后一个位置。
如果nums1[p1]<nums2[p2]则nums[tail]保存p1所指向的元素,否则保存p2指向的元素。这里p1和p2指向的是所在数组当前最大元素,而tail指向区域用于保存两个数组尾元素较大的那一个。但如果p1已经将nums1中的所有元素都存储到tail后面的位置,这时nums2中还有元素的话,就需要将它们都拷贝到nums1中。
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n) {
int p1 = m - 1;
int p2 = n - 1;
int tail = nums1Size - 1;
while(p1 >= 0 && p2 >= 0)
{
if(nums1[p1] > nums2[p2])
{
nums1[tail--] = nums1[p1--];
}
else
{
nums1[tail--] = nums2[p2--];
}
}
while(p2 >= 0)
{
nums1[tail--] = nums2[p2--];
}
}
链表
链表的概念及结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
注意:
- 从上图可以看出,链式结构在逻辑上是连续的,但是在物理上不一定连续
- 现实中的结点一般都是从堆上申请出来的
- 从堆上申请的空间按照一定的策列来分配,两次申请的空间可能连续,也可能不连续(所以,链表的存储地址可能连续也可能不连续)
单链表接口实现
单链表的指针直接指向第一个元素。单链表的操作相对繁琐,下面对它的各个接口做出介绍。
首先我们要定义一个链表的结点类型。对于每个链表结点,它既要保存数据,还要保存下一个结点的地址(这样才能找到下一个结点,并访问下一个元素)。
typedef int SLDataType;
typedef struct ListNode
{
SLDataType val;
struct ListNode* next;
}ListNode;
再来观览一下单链表的所有接口
//创建结点
ListNode* BuyListNode(SLDataType x);
//尾部插入
void SingleListPushBack(ListNode** pplist, SLDataType x);
//尾部删除
void SingleListPopBack(ListNode** pplist);
//头部插入
void SingleListPushFront(ListNode** pplist, SLDataType x);
//头部删除
void SingleListPopFront(ListNode** pplist);
//查找元素
ListNode* SingleListFind(ListNode* plist, SLDataType x);
//打印链表
void SingleListPrint(ListNode* plist);
//在指定结点后插入元素
void SingleListInsertAfter(ListNode* pos, SLDataType x);
//删除指定结点的后一个元素
void SingleListErase(ListNode* pos);
动态申请结点
在头部插入、尾部插入、中间插入中,均要申请新的结点,用于保存新插入的元素。我们可以将申请新结点的操作封装成一个函数。
ListNode* BuyListNode(SLDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
newnode->val = x;
newnode->next = NULL;
return newnode;
}
单链表尾部插入
由于单链表初始时没有任何元素,我们在初始时定义单链表是这么定义的↓↓↓
void test()
{
ListNode* plist = NULL;
}
如果链表中没有任何元素时,插入新结点就需要修改链表指针的指向,让它指向新开辟的结点。如果要对指针中保存的内容做修改,则需要传递二级指针。
如果链表中已经有元素了,这时候需要找到整个链表的最后一个结点,在它的后面链上新开辟的结点。
void SingleListPushBack(ListNode** pplist, SLDataType x)
{
assert(pplist);
if(*pplist == NULL)
{
*pplist = BuyListNode(x);
}
else
{
ListNode* cur = *pplist;
ListNode* prev = cur;
while(cur)
{
prev = cur;
cur = cur->next;
}
prev->next = BuyListNode(x);
}
}
单链表尾部删除元素
如果链表中没有任何结点,则删除失败。我们可以在删除前对单链表指针的值做出判断,如果该指针为NULL,说明它没有任何结点。
如果链表中只有一个结点,我们需要将单链表指针的值置为NULL,这时候函数参数需要是二级指针(因为我们对指针存储的值做了修改)。
如果链表中有多个结点,我们需要找到最后一个结点和倒数第二个结点,释放最后一个结点的空间,并将倒数第二个结点的next域置为NULL。
void SingleListPopBack(ListNode** pplist)
{
assert(pplist);
assert(*pplist);
ListNode* cur = *pplist;
ListNode* prev = *pplist;
if(cur->next == NULL)
{
free(*pplist);
*pplist = NULL;
}
else
{
while(cur->next)
{
prev = cur;
cur = cur->next;
}
free(cur);
prev->next = NULL;
}
}
单链表头部插入元素
如果单链表中没有任何结点,此时,我们需要将单链表指针保存的地址改为新插入结点的地址,这时需要使用二级指针。
如果链表中已经有元素了,需要将新开辟结点的next域保存原链表的头节点的地址,再将单链表指针保存的头节点地址该为新结点地址。这个操作一样需要用到二级指针。
上述两种情况可以合并,不管单链表指针中保存的是NULL还是头节点地址。我们只需要使用新开辟的结点next域存储原单链表指针内容,并将单链表指针存储内容改为新开辟结点地址即可。
void SingleListPushFront(ListNode** pplist, SLDataType x)
{
assert(pplist);
ListNode* newHead = BuyListNode(x);
newHead->next = *pplist;
*pplist = newHead;
}
单链表头部删除元素
如果单链表已经为空,则删除失败。我们需要在删除前对单链表指针存储的内容做出判断,如果为NULL则报错。
单链表如果只有一个结点,则将其删除后,需要将单链表指针内容置空。如果单链表中不止一个结点,则删除头节点后,要将单链表指针中的存储内容改为头节点的下一个结点的地址。
这两种情况一样可以合并成一种操作。由于尾结点的next域保存值为NULL,则对于单链表只有一个节点时,它的头节点也是尾结点,它的next域保存的是NULL;对于单链表有多个结点,则头节点保存的是第二个结点的地址。所以不管链表中只有一个元素还是有多个元素,我们都可以将单链表指针的值置为头节点的next域所保存的值,再将头节点释放。
void SingleListPopFront(ListNode** pplist)
{
assert(pplist);
assert(*pplist);
ListNode* del = *pplist;
*pplist = (*pplist)->next;
free(del);
}
单链表中查找元素
如果要在单链表中查找元素,就要从前向后挨个比较。如果该元素存在,则返回它的结点地址,如果不存在则返回空。
ListNode* SingleListFind(ListNode* plist, SLDataType x)
{
while(plist)
{
if(plist->val == x)
return plist;
plist = plist->next;
}
return NULL;
}
打印单链表
打印单链表与在单链表中查找元素的操作类似,代码如下。
void SingleListPrint(ListNode* plist)
{
ListNode* cur = plist;
while(cur)
{
printf("%d ", cur->val);
cur = cur->next;
}
if(plist)
{
printf("\n");
}
}
在指定结点后插入元素
在指定结点插入新元素,需要开辟一个新结点用于保存新元素,将该结点的next域保存指定结点的next域,并将该结点的next域改为新开辟结点的地址即可。
void SingleListInsertAfter(ListNode* pos, SLDataType x)
{
assert(pos);
ListNode* newnode = BuyListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
删除指定结点后一个结点
在删除指定结点后一个结点前,我们需要先将被删除结点保存下来,将指定结点的next域改为待删除结点的next域,再将待删除结点释放。
void SingleListErase(ListNode* pos)
{
assert(pos);
ListNode* del = pos->next;
pos->next = del->next;
free(del);
}
链表的分类
链表可不单单有上面的单链表,链表有多种类别,我们来认识一下它们↓↓↓
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
- 单向或者双向。
下图中,上面一个链表为单向的,它的每个结点仅保存下一个结点的地址,它只能向后访问后序结点,无法向前访问结点;下面一个链表是双向的,它的每个结点保存了上一个结点的地址,也保存了下一个结点的地址,因而它可以向前或向后访问结点。
2. 带头或者不带头
上面在介绍单向和双向时,它们的第一个结点用于保存具体数值,这种链表就是不带头的。如果一个链表的第一个结点不用于存储数据,并且在链表中没有任何元素时都会有一个结点,那么这种链表就被称为带头链表。带头链表的第一个结点被称为头节点,也称为哨兵位结点。
3. 循环或者非循环
上面介绍的链表如果一直向后访问会访问到空,也就是链表有结束的位置,这种就是非循环的。如果链表一直向后访问时,能循环回到头部结点,则这种链表就是循环链表(如下图所示)。
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构。分别是无头单向非循环链表和带头循环双向链表。
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。
带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
双向链表接口实现
上面说到的两种常用的链表结构,无头单向非循环链表已经在上面给出各个接口的代码实现了。下面我们再来谈一谈带头双向循环链表的结构实现。首先,我们来看一下它的各个接口及结点定义格式。
typedef int DLDataType;
typedef struct ListNode
{
DLDataType val;
struct ListNode* prev;
struct ListNode* next;
}ListNode;
//判断链表是否非空
bool IsEmpty(ListNode* plist);
//创建新结点
ListNode* BuyListNode(DLDataType x);
//创建链表
ListNode* ListCreate();
//销毁链表
void ListDestory(ListNode* plist);
//尾部插入
void ListPushBack(ListNode* plist, DLDataType x);
//尾部删除
void ListPopBack(ListNode* plist);
//头部插入
void ListPushFront(ListNode* plist, DLDataType x);
//头部删除
void ListPopFront(ListNode* plist);
//查找元素
ListNode* ListFind(ListNode* plist, DLDataType x);
//指定位置插入
void ListInsert(ListNode* pos, DLDataType x);
//指定位置删除
void ListErase(ListNode* pos);
//打印链表
void ListPrint(ListNode* plist);
判断链表是否为空
由于我们创建的是一个带头双向循环链表。当链表为空是,头的next域和prev域都存储头节点地址。因此,我们可以通过判断phead->next==phead
是否成立,成立则链表为空,不成立则不为空。
bool IsEmpty(ListNode* plist)
{
assert(plist);
return plist->next == plist;
}
创建链表与创建新结点
在创建链表时,链表中没有任何元素,但我们也需要给链表分配一个头节点。由于刚创建的链表没有任何元素,故要让phead->next = phead->prev = phead
。由于后序插入元素都需要申请结点,我们可以将申请结点的操作封装成函数。
ListNode* BuyListNode(DLDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("malloc error!\n");
return NULL;
}
newnode->val = x;
newnode->next = newnode->prev = NULL;
}
ListNode* ListCreate()
{
ListNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
}
链表销毁
由于双向带头循环链表在遍历整个链表时,最终还可以回到头节点处。我们可以先对头节点的后序结点依次释放,再释放头结点。
void ListDestory(ListNode* plist)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(plist);
}
尾部插入
头结点的prev域保存的是整个链表的最后一个元素,利用tail = phead->prev
就能找到尾部结点,再将新创建的结点链入tail的后面,phead的前面即可。
★ps:要在phead->prev被修改为newnode之前,将尾结点的地址保存下来,否则无法直接通过头结点找到尾结点。
void ListPushBack(ListNode* plist, DLDataType x)
{
assert(plist);
ListNode* tail = plist->prev;
ListNode* newnode = BuyListNode(x);
tail->next = newnode;
newnode->prev = tail;
newnode->next = plist;
plist->prev = newnode;
}
尾部删除
尾部删除结点时,要先保存尾结点的前一个结点(也就是倒数第二个结点)的地址,否则在尾结点被释放后,就无法通过phead->prev->prev找到该结点。该节点将作为新的尾结点。
void ListPopBack(ListNode* plist)
{
assert(plist);
assert(!IsEmpty(plist));
ListNode* tail = plist->prev;
ListNode* tailPrev = tail->prev;
tailPrev->next = plist;
plist->prev = tailPrev;
free(tail);
}
头部插入、删除、链表打印等余下接口实现
后序接口的实现与上述并没有特别大的差别。这里直接给出代码↓↓↓
void ListPushFront(ListNode* plist, DLDataType x)
{
assert(plist);
ListNode* first = plist->next;
ListNode* newnode = BuyListNode(x);
plist->next = newnode;
newnode->prev = plist;
first->prev = newnode;
newnode->next = first;
}
void ListPopFront(ListNode* plist)
{
assert(plist);
assert(!IsEmpty(plist));
ListNode* first = plist->next;
ListNode* second = first->next;
plist->next = second;
second->prev = plist;
free(first);
}
ListNode* ListFind(ListNode* plist, DLDataType x)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
if (cur->val == x)
return cur;
cur = cur->next;
}
return NULL;
}
void ListInsert(ListNode* pos, DLDataType x)
{
assert(pos);
ListNode* newnode = BuyListNode(x);
ListNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* prevNode = pos->prev;
ListNode* nextNode = pos->next;
prevNode->next = nextNode;
nextNode->prev = prevNode;
free(pos);
}
void ListPrint(ListNode* plist)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
printf("%d ", cur->val);
cur = cur->next;
}
if (!IsEmpty(plist))
{
printf("\n");
}
}
链表OJ
学完链表的内容,下面就一起来练练手吧!
( ఠൠఠ )ノtest1:移除链表元素
我们可以使用一个cur指针指向当前结点,一个prev结点指向cur的前一个结点,如果cur指向的结点等于val则将prev的next域改为cur的next域,并将cur结点释放,cur再改为prev的next域即可。
但如果链表的第一个结点等于val,则处理起来过于繁琐。我们可以在整个链表的头部增加一个结点,在返回的时候返回该结点的next域即可。这样就不用对头结点做特殊处理了。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeElements(struct ListNode* head, int val) {
struct ListNode preHead;
preHead.next = head;
struct ListNode* prev = &preHead;
struct ListNode* cur = head;
while(cur)
{
if(cur->val == val)
{
prev->next = cur->next;
free(cur);
cur = prev->next;
}
else
{
prev = cur;
cur = cur->next;
}
}
return preHead.next;
}
( ఠൠఠ )ノtest2:反转单链表
这道题需要定义3个指针,一个指针指向已经反转的链表的头结点,一个指针指向待反转链表的头结点,剩下一个结点在cur修改next域时,要帮cur先保存它的后序待反转的链表的头结点。此时cur->next=prev就可以将cur所指向的结点练到反转链表的头结点之前,prev=cur就可以让prev指向已经反转的单链表的表头,cur=next后,继续向后执行上述反转操作。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode* prev = NULL;
struct ListNode* cur = head;
while(cur)
{
struct ListNode* next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
( ఠൠఠ )ノtest3:链表的中间结点
这题需要使用快慢指针。我们一个慢指针slow和一个快指针fast,让它们都指向第一个结点。当slow走一步时,fast走两步。我们可以发现,如果结点有奇数个时,fast指向最后一个结点时,slow可以指向中间结点;如果结点有偶数个时,fast指针走到NULL时,slow可以指向中间两个结点中的后一个。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* middleNode(struct ListNode* head) {
struct ListNode* slow = head;
struct ListNode* fast = head;
while(fast && fast->next)
{
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
( ఠൠఠ )ノtest4:删除链表倒数第N个结点
这道题一样可以使用快慢指针的做法。我们可以fast指针先向前走走N个结点,再让slow和fast一起向后走,当fast走到空时,slow指向的就是倒数第N个结点。由于我们要删除第N个结点,所以我们需要使用一个prev指针指向slow的前一个位置。
如果要删除整个链表的第一个元素,我们就需要对头结点做特殊的处理。为了删除头结点的操作与上面一致,我们可以在整个链表的头部新增一个结点。就可以使得删除第一个结点的操作和删除其他结点一致。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeNthFromEnd(struct ListNode* head, int n) {
struct ListNode prevHead;
prevHead.next = head;
struct ListNode* slow = head;
struct ListNode* fast = head;
while(n--)
{
fast = fast->next;
}
struct ListNode* prev = &prevHead;
while(fast)
{
prev = slow;
slow = slow->next;
fast = fast->next;
}
prev->next = slow->next;
free(slow);
return prevHead.next;
}
( ఠൠఠ )ノtest5:合并两个有序链表
这道题的思想和顺序表中其中一题一致。为了操作方便,我们可以创建一个头结点,在它的后面链上合并后的有序数组。比较两个两个链表中中的元素,将小的结点放到合并链表的尾部。如果其中一个链表已经全部链接完毕,只要在合并的链表的尾部链上该未处理完毕的链表即可。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
struct ListNode phead;
phead.next = NULL;
struct ListNode* tail = &phead;
while(list1 && list2)
{
if(list1->val < list2->val)
{
tail->next = list1;
list1 = list1->next;
}
else
{
tail->next = list2;
list2 = list2->next;
}
tail = tail->next;
}
if(list1) tail->next = list1;
if(list2) tail->next = list2;
return phead.next;
}
( ఠൠఠ )ノtest6:相交链表
我们可以先遍历给出的两个两个链表表头,计算链表的长度。让长的里链表表头结点指针向后移动,直到两个链表长度相同。假设两个链表相交,且都是从相交结点前N个结点开始向后遍历,那么总能在链表走到空是,判断到两个链表指针指向的结点地址相同。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
int lenA = 0;
struct ListNode* pa = headA;
while(pa)
{
lenA++;
pa = pa->next;
}
int lenB = 0;
struct ListNode* pb = headB;
while(pb)
{
lenB++;
pb = pb->next;
}
struct ListNode* pl = lenA > lenB ? headA : headB;
struct ListNode* ps = lenA <= lenB ? headA : headB;
int gap = abs(lenA - lenB);
while(gap--)
{
pl = pl->next;
}
while(pl && ps)
{
if(pl == ps) return pl;
pl = pl->next;
ps = ps->next;
}
return NULL;
}
( ఠൠఠ )ノtest7:环形链表
这道题需要使用快慢指针。慢指针走一步,快指针走两步。假设链表有环,当快指针和慢指针都进入环中后,快指针在快慢指针每次移动时,都会和慢指针拉近一步,最终两个指针指向同一个结点。
Q1:快指针和慢指针一定会相遇吗?
假设快慢指针均进入环中时,两者的距离为L。由于快指针一次走2步,慢指针一次走1步,因而快指针每次都能和慢指针拉近一个近距离。即两者的距离变为为L、L-1、…、2、1、0。最终两者一定会相遇。
Q2:一定要快指针走两步,慢指针走一步吗?快指针一次走三步不行吗?
假设快慢指针进入环中时,两者的距离为L。如果快指针一次走三步,慢指针一次走一步,两者每次移动会缩小2个距离。如果L的大小为不是2的倍数,则L的变化为L、L-2、…、3、1、-1。快慢指针相距为-1后,我们假设整个环长度为C,如果C-1是奇数,则两者永远遇不上。会陷入C-1、C-3、…、3、1、C-1的循环。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
bool hasCycle(struct ListNode *head) {
struct ListNode* slow = head;
struct ListNode* fast = head;
while(fast && fast->next)
{
slow = slow->next;
fast = fast->next->next;
if(slow == fast) return true;
}
return false;
}
顺序表和链表的区别
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持 | 不支持(需要从头到尾遍历) |
任意位置插入或者删除元素 | 需要搬移元素,效率低O(N) | 只需要修改指针指向 |
扩容问题 | 动态顺序表,空间不够时需要扩容 | 没有容量的概念,需要时直接开辟新结点 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除 |
缓存利用率 | 高 | 低 |
对于上面的表格内容,下面做出进一步的解释。
链表在插入和删除一个元素时,只需要修改结点指针,时间效率为O(1)。顺序表在插入和删除一个元素时,需要移动元素,时间效率为O(N)。
链表在需要空间时申请一个结点的空间,而顺序表如果插入的元素数量超出容量,则需要扩容。如果进行异地扩容,则需要将原数据拷贝到扩容后的区域,扩容需要付出时间代价。而且顺序表中有许多位置是没有存储元素的,存在空间上的浪费。
对于二分查找等需要访问指定下标元素的情况,只能使用顺序表。因为链表要访问某个元素时,需要从头到尾遍历。
顺序表开辟的空间是连续的,如果程序要使用的顺序表内容不在高速缓存中,则会将顺序表指定元素载入到高速缓存。根据局部原理,在载入某个地址的内容时,会将周边元素也一起载入。由于顺序表连续存储,这些被顺带载入的元素很可能是顺序表中的内容。后序访问顺序表时,顺序表的元素已经在高速缓存中了,不再需要到内存或磁盘中读取,缓存命中率高。而链表的结点是分散的,地址不一定连续,即使将载入元素周边的内容一并载入,链表的后序结点大概率不在这里,故链表的缓存命中率低。
另外,局部性原理是指程序访问了某个地址的数据后,很可能再访问它周边的数据。由于链表不会访问它周边的数据,这些数据跟程序无关,导致缓存中载入大量不被访问的数据,因而产生缓存污染。
文章结语:这篇文章对时间复杂度、空间复杂度、数据结构与算法概念进行了简要的介绍。
🎈欢迎进入Super数据结构专栏,查看更多文章。
如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d