代码随想录算法训练营第三天:链表的基础理论和应用
今天将结束数组,开始链表的学习,那么对于链表,他的结构与特点相比较数组还是有很大的差距的;
什么是链表,链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链表的入口节点称为链表的头结点也就是head。
如图所示:
这里只是单链表,先给大家展示单链表的基本操作
首先是初始化,那么如何构建一个链表呢,这里用c/c++来展示:
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
对于这种写法我将详细赘述:
定义链表节点
那在C++中如何定义链表结构呢,传统的定义变量的方式只能使用一种数据类型,无法处理链表这种既包含数据域名、又包含指针域的复合结构,这就需要使用到struct
结构体,结构体是一种用户自定义的数据类型,比如想要定义一个Person
的结构体
// Person结构体
struct Person {
// 使用 数据类型 成员变量的形式来定义
int age; // int类型的年龄
std::string name; // string类型的名字
}
结构体可以组合多个不同类型的成员变量,成员变量可以是各种数据类型,包括整数、浮点数、字符串、其他结构体等,所以你可以根据需要定义自己的结构体来组织数据。
// 链表节点结构体
struct ListNode {
int val; // 存储节点的数据
ListNode *next; // 下一个节点也是链表节点,所以也是ListNode类型,*表示指针(地址),next是名称
}
但结构体只是个“模具”,创建的Person
结构体虽然具有age、name
,但它只是一个Person
的概念,无法表示具体的人,只有将其“初始化”,比如"张三,18", “李四、20”,才能真正的使用。
初始化结构体的方式有很多,这里我们使用构造函数的方式来进行,构造函数的名称与结构体的名称相同,和其他函数不一样的是,构造函数没有返回类型,除此之外类似于其他的函数,构造函数也有一个(可能为空)的参数列表和一个函数体(可能为空)。链表结构体的构造函数代码如下:
ListNode(int x) : val(x), next(nullptr) {}
这里的ListNode(int x)
表示定义一个接收整数参数 x
的名称为ListNode
的构造函数(名称和结构体相同)
,:
表示初始化列表的开始,val(x)
表示链表数据域的值被初始化为传递的参数 x
,next(nullptr)
则表示
next指针
被初始化为nullptr
,表示没有下一个节点。
下面的完整代码定义了一个名为ListNode
的结构体,用于表示链表中的一个节点,包含存储节点数据的数据域和存储下一个节点地址的指针域。
// 链表节点结构体
struct ListNode {
int val; // 存储节点的数据
ListNode *next; // 指向下一个节点的指针
// 构造函数,用于初始化节点, x接收数据作为数据域,next(nullptr)表示next指针为空
ListNode(int x) : val(x), next(nullptr) {}
};
C++中的指针就像是一个地址的引用,它帮助你访问和操作存储在计算机内存中的数据。
理解起来是不是有点抽象,这里可以先把它理解为一个指示牌,这张指示牌上写着某个地方的地址。这个地址指向计算机内存中的一个特定位置,那里存储了一些数据。
想要声明指针,需要使用*
符号,比如下面的代码。
int *ptr; // 声明一个指向整数的指针
// 也可以这样写
int* ptr;
指针想要存放某个变量的地址,需要先使用取地址符&
获取地址
int x = 10;
int *ptr = &x; // 将指针初始化为变量x的地址
想要获取这个地址值,需要使用*
符号来访问, 这个过程称为解引用
int value = *ptr; // 获取ptr指针指向的值(等于x的值,即10)
指针和数组之间有密切的关系,数组名本质上是一个指向数组第一个元素的指针。
int arr[3] = {1, 2, 3};
int *ptr = arr; // 数组名arr就是指向arr[0]的指针
指针还可以执行加法、减法等算术操作,以访问内存中的不同位置。
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr; // 指向数组的第一个元素
int value = *(ptr + 2); // 获取数组的第三个元素(值为3)
除此之外,还有一个特殊的空指针值,通常表示为nullptr
,用于表示指针不指向任何有效的内存地址。
int *ptr = nullptr; // 初始化为空指针
与数组不同,链表的元素存储可以是连续的,也可以是不连续的,每个数据元素处理存储本身的信息(data数据域
)之外,还存储一个指示着下一个元素的地址的信息(next指针域
),给人的感受就好像这些元素是通过一条“链”串起来的。
链表的第一个节点的存储位置被称为头指针,然后通过next
指针域找到下一个节点,直到找到最后一个节点,最后一个节点的next
指针域并不存在,也就是“空”的,在C++中,用null
来表示这个空指针。
为了简化链表的插入和删除操作,我们经常在链表的第一个节点前添加一个节点,称为虚拟头节点(dummyNode
),头节点的数据域可以是空的,但是指针域指向第一个节点的指针。
头指针是链表指向第一个节点的指针,访问链表的入口,经常使用头指针表示链表,头指针是链表必须的
头节点是为了方便操作添加的,不存储实际数据,头节点不一定是链表必须的
关于链表,更为详细的可以看这里链表理论基础
对于链表简单的操作就是插入,删除,查找
链表的插入
插入又分为头插法和尾插法
插入元素其实就是改变指针的指向,这里配合图片更容易理解:
这里的主要操作是:
- 创建一个新的链表节点,申请内存空间,初始化它的值为val;
- 将新节点放入插入的位置,接入链表,这里先让新节点next指向插入位置的后一个节点,再让链表插入位置前一个节点指向新节点,这样做的目的是防止被覆盖;
- 插入完成;
这里用尾插法来展示:
上面的操作用代码来表示如下:
ListNode *newNode = new ListNode(val); // 通过new构造一个新的节点,节点的值为val
cur -> next = newNode; // cur节点的next节点是新节点,从而将新节点接入链表
cur = cur -> next; // 新插入的节点变更为新的尾节点,即cur发生了
这里有两个新的语法:new
运算符和箭头语法->
new
是一个运算符,它的作用就是在堆内存中动态分配内存空间,并返回分配内存的地址,使用方式一般为指针变量 = new 数据类型
, 比如下面的代码
int *arr = new int[5]; // 分配一个包含5个整数的数组的内存空间,并返回一个地址,指针arr指向这个地址
箭头语法(->
):用于通过指针访问指针所指向的对象的成员,cur
是一个指向 ListNode
结构体对象的指针,而 next
是 ListNode
结构体内部的一个成员变量(指向下一个节点的指针)。使用 cur->next
表示访问 cur
所指向的节点的 next
成员变量。
那么这里可以注意到,我们建立了一个虚拟头节点,这是为什么呢,其实,如果不建立虚拟头节点,我们要单独讨论对头结点的删除,而创建虚拟头节点后,就可以将这种情况合并成一起
删除链表
这里既然提到就讲解一下,链表的删除:
删除链表的过程则比较简单,只需要找到删除节点的前一个节点cur
, 并将其next
指针设置为指向当前节点的下下个节点,从而跳过了下一个节点,实现了节点的删除操作。
// cur->next 表示当前节点 cur 的下一个节点
// cur->next->next 表示要删除的节点的下一个节点
// 当前节点 cur 的 next 指针不再指向要删除的节点,而是指向了要删除节点的下一个节点
cur->next = cur->next->next;
那么这里我们可以看出来,其实删除的节点还是在内存里,只是不在这个链表里,所以在c++中,我们要手动释放这个节点,用到delete();
双链表
单链表中的指针域只能指向节点的下一个节点。
双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。
双链表 既可以向前查询也可以向后查询。
如图所示:
#循环链表
循环链表,顾名思义,就是链表首尾相连。
循环链表可以用来解决约瑟夫环问题。
那么下面就给大家展示今天的题目:
203.移除链表元素
题意:删除链表中等于给定值 val 的所有节点。
示例 1: 输入:head = [1,2,6,3,4,5,6], val = 6 输出:[1,2,3,4,5]
示例 2: 输入:head = [], val = 1 输出:[]
示例 3: 输入:head = [7,7,7,7], val = 7 输出:[]
#算法公开课
《代码随想录》算法视频公开课 ****(opens new window)**** :链表基础操作| LeetCode:203.移除链表元素 ****(opens new window)**** ,相信结合视频再看本篇题解,更有助于大家对本题的理解。
那么这一题就先不展示carl的讲解了,先展示这个虚拟头节点的思路,
- 直接使用原来的链表来进行删除操作。
- 设置一个虚拟头结点在进行删除操作。
来看第一种操作:直接使用原来的链表来进行移除。
移除头结点和移除其他节点的操作是不一样的,因为链表的其他节点都是通过前一个节点来移除当前节点,而头结点没有前一个节点。
所以头结点如何移除呢,其实只要将头结点向后移动一位就可以,这样就从链表中移除了一个头结点。
依然别忘将原头结点从内存中删掉。
这样移除了一个头结点,是不是发现,在单链表中移除头结点 和 移除其他节点的操作方式是不一样,其实在写代码的时候也会发现,需要单独写一段逻辑来处理移除头结点的情况。
那么可不可以 以一种统一的逻辑来移除 链表的节点呢。
其实可以设置一个虚拟头结点,这样原链表的所有节点就都可以按照统一的方式进行移除了。
来看看如何设置一个虚拟头节点。依然还是在这个链表中,移除元素1。
这里来给链表添加一个虚拟头结点为新的头结点,此时要移除这个旧头结点元素1。
这样是不是就可以使用和移除链表其他节点的方式统一了呢?
也就是我上述所说的这里引用carl的讲义再进行补充;
class Solution {
public:
ListNode*removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode *cur = dummyHead;
while (cur->next != nullptr)
{
if (cur->next->val == val)
{
if (cur->next->next != nullptr)
{
ListNode *tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
} else
{
ListNode *tmp = cur->next;
cur->next = nullptr; delete tmp;
}
}
else {
cur = cur->next;
}
}
head = dummyHead->next;
delete dummyHead;
return head;
}
};
c 版本:
用原来的链表操作:
struct ListNode* removeElements(struct ListNode* head, int val){
struct ListNode* temp;
// 当头结点存在并且头结点的值等于val时
while(head && head->val == val) {
temp = head;
// 将新的头结点设置为head->next并删除原来的头结点
head = head->next;
free(temp);
}
struct ListNode *cur = head;
// 当cur存在并且cur->next存在时
// 此解法需要判断cur存在因为cur指向head。若head本身为NULL或者原链表中元素都为val的话,cur也会为NULL
while(cur && (temp = cur->next)) {
// 若cur->next的值等于val
if(temp->val == val) {
// 将cur->next设置为cur->next->next并删除cur->next
cur->next = temp->next;
free(temp);
}
// 若cur->next不等于val,则将cur后移一位
else
cur = cur->next;
}
// 返回头结点
return head;
}
设置一个虚拟头结点:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeElements(struct ListNode* head, int val){
typedef struct ListNode ListNode;
ListNode *shead;
shead = (ListNode *)malloc(sizeof(ListNode));
shead->next = head;
ListNode *cur = shead;
while(cur->next != NULL){
if (cur->next->val == val){
ListNode *tmp = cur->next;
cur->next = cur->next->next;
free(tmp);
}
else{
cur = cur->next;
}
}
head = shead->next;
free(shead);
return head;
}
那么leetcode官方题解给出了递归的方法:
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
if (head == nullptr) {
return head;
}
head->next = removeElements(head->next, val);
return head->val == val ? head->next : head;
}
};
其实就是移动头结点,反复进行这个操作,如果等于了就进行删除,如果不等于就保留指向下一个节点,这种解法实际上是一个递归的解法,并没有显式地分类讨论头结点删除和后面节点删除操作不一样的情况,但它隐式地处理了这两种情况,使得解法更为简洁和优雅。
递归的基本思路如下:
-
终止条件:如果当前节点(
head
)为空(nullptr
),意味着已经遍历完链表,返回nullptr
。 -
对于每个节点,首先递归地处理它的下一个节点:
head->next = removeElements(head->next, val);
这一步确保了从当前节点的下一节点开始,所有值为
val
的节点都被删除。 -
最后,检查当前节点的值是否等于
val
:-
如果是,返回
head->next
,这表示删除当前节点。 -
如果不是,保留当前节点并返回
head
。这种方法巧妙地利用递归实现了在一个步骤中同时处理头结点和其他节点的删除操作,因此不需要显式地区分这两种情况。当递归调用直达最后一个节点,然后返回时,它会依次检查每个节点是否需要删除,并适当地调整指针。这样,当递归层层返回到最初的调用处时,链表中所有值为
val
的节点都已被删除且链表的链接关系也已正确重建。
-
下面这道题目把上述讲的链表的简单应用操作用到了:
707.设计链表
题意:
在链表类中实现这些功能:
- get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
- addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
- addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
- addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
- deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
这道题目设计链表的五个接口:
- 获取链表第index个节点的数值 //查找
- 在链表的最前面插入一个节点 //头插法
- 在链表的最后面插入一个节点 //尾插法
- 在链表第index个节点前面插入一个节点 //定位插入
- 删除链表的第index个节点 //定位删除
下面依旧采用设置虚拟头结点的方法:
class MyLinkedList{
public:
struct LinkedNode{
int val;
LinkedNode* next;
LinkedNode(int val):val(val),next(nullptr){}
};
MyLinkedList(){
_dummyHead = new LinkedNode(0);
_size = 0;
}
int get(int index){
if (index < 0 || index >= _size)//讨论不成立的可能
return -1;
LinkedNode *cur = _dummyHead->next;
while (index--)
{
cur = cur->next;
}
return cur->val;
}
void addAtHead(int val){
LinkedNode * newNode = new LinkedNode(val);
newNode->next = _dummyHead->next;
newNode->val = val;
_dummyHead->next = newNode;
_size++;
}
void addAtTail(int val){
LinkedNode * newNode = new LinkedNode(val);
LinkedNode * cur = _dummyHead;
while(cur->next != nullptr){
cur = cur->next;
}
cur->next = newNode;
_size++;
}
void addAtIndex(int index,int val){
if (index > _size)
return;
if (index < 0)
index = 0;
LinkedNode * newNode = new LinkedNode(val);
LinkedNode * cur = _dummyHead;
while (index--)
{
cur = cur->next;
}
newNode->next = cur->next;
cur->next = newNode;
_size++;
}
void deleteAtIndex(int index){
if (index >= _size || index < 0){
return ;
}
LinkedNode* cur = _dummyHead;
while(index--){
cur = cur->next;
}
LinkedNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
//delete命令指示释放了tmp指针原本所指的那部分内存,
//被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后,
//如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针
//如果之后的程序不小心使用了tmp,会指向难以预想的内存空间
tmp =nullptr;
_size--;
}
void printLinkedList(){
LinkedNode* cur = _dummyHead;
while(cur->next != nullptr){
cout << cur -> next -> val << " ";
cur = cur->next;
}
cout << endl;
}
private:
LinkedNode* _dummyHead;
int _size;
};
那么今天的最后一道题是翻转链表:
206.反转链表
题意:反转一个单链表。
示例: 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL
#算法公开课
《代码随想录》算法视频公开课 ****(opens new window)**** :帮你拿下反转链表 | LeetCode:206.反转链表 ****(opens new window)**** ,相信结合视频再看本篇题解,更有助于大家对本题的理解。
其实这里只需要改变next的指向,不用重新写一个链表
那么怎么改变next指向呢,在单向链表中,我们无法回头,如何让该节点指向上一节点,其实这里单纯用一个指针显然是不可以的,需要用到两个指针,如图:
这里carl指出应该先移动pre,在移动cur,这个图是有点小毛病的
首先定义一个cur指针,指向头结点,再定义一个pre指针,初始化为null。
然后就要开始反转了,首先要把 cur->next 节点用tmp指针保存一下,也就是保存一下这个节点。
为什么要保存一下这个节点呢,因为接下来要改变 cur->next 的指向了,将cur->next 指向pre ,此时已经反转了第一个节点了。
接下来,就是循环走如下代码逻辑了,继续移动pre和cur指针。
最后,cur 指针已经指向了null,循环结束,链表也反转完毕了。 此时我们return pre指针就可以了,pre指针就指向了新的头结点。
#双指针法
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* temp; // 保存cur的下一个节点
ListNode* cur = head;
ListNode* pre = NULL;
while(cur) {
temp = cur->next; // 保存一下 cur的下一个节点,因为接下来要改变cur->next
cur->next = pre; // 翻转操作
// 更新pre 和 cur指针
pre = cur;
cur = temp;
}
return pre;
}
};
写到链表又又又牵扯到递归了,递归法相对抽象一些,但是其实和双指针法是一样的逻辑,同样是当cur为空的时候循环结束,不断将cur指向pre的过程。
关键是初始化的地方,可能有的同学会不理解, 可以看到双指针法中初始化 cur = head,pre = NULL,在递归法中可以从如下代码看出初始化的逻辑也是一样的,只不过写法变了。
具体可以看代码(已经详细注释),双指针法写出来之后,理解如下递归写法就不难了,代码逻辑都是一样的。
class Solution {
public:
ListNode* reverse(ListNode* pre,ListNode* cur){
if(cur == NULL) return pre;
ListNode* temp = cur->next;
cur->next = pre;
// 可以和双指针法的代码进行对比,如下递归的写法,其实就是做了这两步
// pre = cur;
// cur = temp;
return reverse(cur,temp);
}
ListNode* reverseList(ListNode* head) {
// 和双指针法初始化是一样的逻辑
// ListNode* cur = head;
// ListNode* pre = NULL;
return reverse(NULL, head);
}
};
这里还有很多新解法,详细参考carl的解法:
其他解法
#使用虚拟头结点解决链表反转
使用虚拟头结点,通过头插法实现链表的反转(不需要栈)
// 迭代方法:增加虚头结点,使用头插法实现链表翻转
public static ListNode reverseList1(ListNode head) {
// 创建虚头结点
ListNode dumpyHead = new ListNode(-1);
dumpyHead.next = null;
// 遍历所有节点
ListNode cur = head;
while(cur != null){
ListNode temp = cur.next;
// 头插法
cur.next = dumpyHead.next;
dumpyHead.next = cur;
cur = temp;
}
return dumpyHead.next;
}
#使用栈解决反转链表的问题
- 首先将所有的结点入栈
- 然后创建一个虚拟虚拟头结点,让cur指向虚拟头结点。然后开始循环出栈,每出来一个元素,就把它加入到以虚拟头结点为头结点的链表当中,最后返回即可。
public ListNode reverseList(ListNode head) {
// 如果链表为空,则返回空
if (head == null) return null;
// 如果链表中只有只有一个元素,则直接返回
if (head.next == null) return head;
// 创建栈 每一个结点都入栈
Stack<ListNode> stack = new Stack<>();
ListNode cur = head;
while (cur != null) {
stack.push(cur);
cur = cur.next;
}
// 创建一个虚拟头结点
ListNode pHead = new ListNode(0);
cur = pHead;
while (!stack.isEmpty()) {
ListNode node = stack.pop();
cur.next = node;
cur = cur.next;
}
// 最后一个元素的next要赋值为空
cur.next = null;
return pHead.next;
}
采用这种方法需要注意一点。就是当整个出栈循环结束以后,cur正好指向原来链表的第一个结点,而此时结点1中的next指向的是结点2,因此最后还需要
cur.next = null