【Super数据结构】线性表中的顺序表VS链表,谁才是最强赢家?

news2025/1/22 21:35:22

在这里插入图片描述

🏠关于此专栏:Super数据结构专栏将使用C/C++语言介绍顺序表、链表、栈、队列等数据结构,每篇博文会使用尽可能多的代码片段+图片的方式。
🚪归属专栏:Super数据结构
🎯每日努力一点点,技术累计看得见

文章目录

  • 线性表
  • 顺序表
    • 概念及存储结构
    • 接口实现
    • 顺序表OJ
  • 链表
    • 链表的概念及结构
    • 单链表接口实现
    • 链表的分类
    • 双向链表接口实现
    • 链表OJ
  • 顺序表和链表的区别


线性表

线性表是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串…

线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,
线性表在物理上存储时,通常以数组(物理结构连续)和链式结构(物理结构不一定连续)的形式存储。

在这里插入图片描述
这篇小文章就为大家介绍介绍上图的顺序表和链表。这两者到底有什么区别,为什么要分成两种不同的存储形式呢?且看下文分解↓↓↓

顺序表

概念及存储结构

顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。

顺序表一般可以分为:

  1. 静态顺序表:使用定长数组存储元素。
#define N 100
typedef int SLDataType;

typedef sturct SeqList
{
	SLDataType array[N];	//定长数组
	int size;				//有效元素个数
}SeqList;
  1. 动态顺序表:使用动态开辟的数组存储。
#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");
	}
}

上面就是所有的顺序表接口了,下面我们分析一下上面的操作↓↓↓

  1. 头部、中部插入数据时,需要将部分元素向后移动一个下标。时间复杂度为O(N)。
  2. 在顺序表容量不足时,需要扩容。扩容的效率较低。
  3. 对于头部插入、尾部插入均可以复用SeqListInsert,修改后的代码如下↓↓↓
void SeqListPushFront(SeqList* ps, SLDataType x)
{
	SeqListInsert(ps, 0, x);
}
void SeqListPushBack(SeqList* ps, SLDataType x)
{
	SeqListInsert(ps, ps->_size, x);
}
  1. 对于头部删除、尾部删除均可以复用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--];
    }
}

链表

链表的概念及结构

概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。

在这里插入图片描述
注意:

  1. 从上图可以看出,链式结构在逻辑上是连续的,但是在物理上不一定连续
  2. 现实中的结点一般都是从堆上申请出来的
  3. 从堆上申请的空间按照一定的策列来分配,两次申请的空间可能连续,也可能不连续(所以,链表的存储地址可能连续也可能不连续)

单链表接口实现

单链表的指针直接指向第一个元素。单链表的操作相对繁琐,下面对它的各个接口做出介绍。

首先我们要定义一个链表的结点类型。对于每个链表结点,它既要保存数据,还要保存下一个结点的地址(这样才能找到下一个结点,并访问下一个元素)。

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种链表结构:

  1. 单向或者双向。

下图中,上面一个链表为单向的,它的每个结点仅保存下一个结点的地址,它只能向后访问后序结点,无法向前访问结点;下面一个链表是双向的,它的每个结点保存了上一个结点的地址,也保存了下一个结点的地址,因而它可以向前或向后访问结点。
在这里插入图片描述
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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1529233.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

3月19日做题

[NPUCTF2020]验证&#x1f40e; if (first && second && first.length second.length && first!second && md5(firstkeys[0]) md5(secondkeys[0]))用数组绕过first1&second[1] 这里正则规律过滤位(Math.) (?:Math(?:\.\w)?) : 匹配 …

14|CAMEL:通过角色扮演脑暴一个鲜花营销方案

能否让 ChatGPT 自己生成这些引导文本呢&#xff1f; CAMEL 交流式代理框架 CAMEL 框架旨在通过角色扮演来促进交流代理之间的自主合作&#xff0c;并为其“认知”过程提供洞察。这种方法涉及使用启示式提示来指导聊天代理完成任务&#xff0c;同时保持与人类意图的一致性。…

哈尔滨工业大学 《材料物理》 笔记-3

原内容请参考哈尔滨工业大学何飞教授&#xff1a;https://www.bilibili.com/video/BV18b4y1Y7wd/?p12&spm_id_frompageDriver&vd_source61654d4a6e8d7941436149dd99026962 或《材料物理性能及其在材料研究中的应用》&#xff08;哈尔滨工业大学出版社&#xff09; 量…

Linux下安装Android Studio及创建桌面快捷方式

下载 官网地址&#xff1a;https://developer.android.com/studio?hlzh-cn点击下载最新版本即可 安装 将下载完成后文件&#xff0c;进行解压&#xff0c;然后进入android-studio-2023.2.1.23-linux/android-studio/bin目录下&#xff0c;启动studio.sh即可为了更加方便的使…

nfs介绍与配置

NFS 1. nfs简介 nfs特点 NFS&#xff08;Network File System&#xff09;即网络文件系统&#xff0c;是FreeBSD支持的文件系统中的一种&#xff0c;它允许网络中的计算机之间通过TCP/IP网络共享资源在NFS的应用中&#xff0c;本地NFS的客户端应用可以透明地读写位于远端NFS服…

2024-3-14-C++day4作业

1>思维导图 2> 要求&#xff1a; 成员函数版本实现算术运算符的重载 全局函数版本实现算术运算符的重载 源代码&#xff1a; #include <iostream>using namespace std;class Stu {friend const Stu operator/(const Stu &s1, const Stu &s2);private:i…

【RabbitMQ | 第六篇】消息重复消费问题及解决方案

文章目录 6.消息重复消费问题6.1问题介绍6.2解决思路6.3将该消息存储到Redis6.3.1将id存入string&#xff08;单消费者场景&#xff09;&#xff08;1&#xff09;实现思路&#xff08;2&#xff09;问题 6.3.2将id存入list中&#xff08;多消费场景&#xff09;&#xff08;1&…

雨云领先技术,即将推出雨盾CDN,助力云端安全与速度

随着数字化时代的不断发展&#xff0c;云计算和内容分发网络&#xff08;CDN&#xff09;的重要性日益凸显。作为行业领先的云计算服务提供商&#xff0c;雨云&#xff08;RainCloud&#xff09;即将推出其全新产品雨盾CDN&#xff0c;旨在为用户提供更安全、更高效的网络加速服…

Visual Studio 2013 - 调试模式下查看监视窗口

Visual Studio 2013 - 调试模式下查看监视窗口 1. 监视窗口References 1. 监视窗口 Ctrl Alt W&#xff0c;1-4&#xff1a;监视窗口 (数字键不能使用小键盘) or 调试 -> 窗口 -> 监视 -> 监视 1-4 调试状态下使用&#xff1a; 在窗口中点击空白行&#xff0c;…

阿里云2核4G4M轻量应用服务器价格165元一年

阿里云优惠活动&#xff0c;2核4G4M轻量应用服务器价格165元一年&#xff0c;4Mbps带宽下载速度峰值可达512KB/秒&#xff0c;系统盘是60GB高效云盘&#xff0c;不限制月流量&#xff0c;2核2G3M带宽轻量服务器一年87元12个月&#xff0c;在阿里云CLUB中心查看 aliyun.club 当前…

Python入门(小白友好)

知识图谱 搭建环境 安装Python:Download Python | Python.org 安装PyCharm:Download PyCharm: The Python IDE for data science and web development by JetBrains 注意:专业版本是收费的,新手小白使用社区版(community)即可 创建第一个项目: 一些PyCharm的设置(也适用…

架构扩展性

架构扩展性&#xff1a;应用扩展 数据扩展 组织扩展 流程扩展 核心方法论–扩展立方体&#xff1a; x轴&#xff1a;无脑克隆 y轴&#xff1a;功能分割z轴&#xff1a;客户分割扩展立方体在应用扩展的应用&#xff1a; x轴&#xff1a;横向克隆 对于无状态的应用&#xff0c;多…

《移动App测试实战》之【专项测试】

&#x1f604;作者简介&#xff1a;小曾同学.com,一个致力于测试开发的博主⛽️&#xff0c; 如果文章知识点有错误的地方&#xff0c;还请大家指正&#xff0c;让我们一起学习&#xff0c;一起进步。&#x1f60a; 座右铭&#xff1a;不想当开发的测试&#xff0c;不是一个好测…

开源的OCR工具基本使用:PaddleOCR/Tesseract/CnOCR

前言 因项目需要&#xff0c;调研了一下目前市面上一些开源的OCR工具&#xff0c;支持本地部署&#xff0c;非调用API&#xff0c;主要有PaddleOCR/CnOCR/chinese_lite OCR/EasyOCR/Tesseract/chineseocr/mmocr这几款产品。 本文主要尝试了EasyOCR/CnOCR/Tesseract/PaddleOCR这…

性能分析调优模型

性能测试除了为获取性能指标外&#xff0c;更多是为了发现性能瓶颈和性能问题&#xff0c;然后针对性能问题和性能瓶颈进行分析和调优。在当今互联网高速发展的时代&#xff0c;结合传统软件系统模型以及互联网网站特征&#xff0c;性能调优的模型可以归纳总结为如图1-5-1所示的…

ClickHouse--13--springboot+mybatis配置clickhouse

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 ClickHouse1.添加maven依赖2.配属数据源3.参数配置4.Druid连接池配置5.entity6.Mapper接口7.Mapper.xml8.controller接口9.创建一个clickhouse表10.测试 ClickHouse…

day04vue学习

day04 一、学习目标 1.组件的三大组成部分&#xff08;结构/样式/逻辑&#xff09; ​ scoped解决样式冲突/data是一个函数 2.组件通信 组件通信语法父传子子传父非父子通信&#xff08;扩展&#xff09; 3.综合案例&#xff1a;小黑记事本&#xff08;组件版&#xff09…

基于python的线上购物商城系统

技术&#xff1a;pythonmysqlvue 一、系统背景 如今的时代是信息化的时代&#xff0c;更种信息大爆炸。人们的基本工作生活中都离不开网络和计算机&#xff0c;现如今各类网站、管理系统、app都快速发展&#xff0c;为人们带来更便捷的生活体验。网站类的系统有宣传类网站、企…

Unity类银河恶魔城学习记录11-2 p104 Inventoty源代码

此章节相对较难理解&#xff0c;有时间单独出一章讲一下 Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释&#xff0c;可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili InventoryItem.cs…

一文了解Python中的运算

Python的运算符和其他语言类似 数学运算 >>>print 19 # 加法 >>>print 1.3-4 # 减法 >>>print 3*5 # 乘法 >>>print 4.5/1.5 # 除法 >>>print 3**2 # 乘方 >>>print 10%3 # 求…