链表
代码随想录刷题笔记
理论基础
- 链表的种类主要为:单链表,双链表,循环链表
- 链表的存储方式:链表的节点在内存中是分散存储的,通过指针连在一起。
- 链表是如何进行增删改查的。
- 数组和链表在不同场景下的性能分析。
什么是链表?
链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链接的入口节点称为链表的头结点也就是head。
如图所示:
链表的类型
-
单链表
定义中即为单链表定义
-
双链表
每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。
双链表 既可以向前查询也可以向后查询。
如图所示:
-
循环链表
循环链表,顾名思义,就是链表首尾相连。
循环链表可以用来解决约瑟夫环问题。
如图所示
链表的存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
链表是通过指针域的指针链接在内存中各个节点。
所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
如图所示:
链表的定义及操作
链表的定义
C/C++的定义链表节点方式
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
C++可以默认生成一个构造函数。
但是这个构造函数不会初始化任何成员变量,对比如下:
- 通过自己定义构造函数初始化节点:
ListNode* head = new ListNode(5);
- 使用默认构造函数初始化节点:
ListNode* head = new ListNode();
head->val = 5;
所以如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值!
链表的操作
作者:编程文青李狗蛋
链接:https://www.nowcoder.com/discuss/979216?type=all&order=recall&pos=&page=1&ncTraceId=&channel=-1&source_id=search_all_nctrack&gio_id=FA40978499A88F5CBFB8D2DF24C5F352-1659493938879
来源:牛客网
(1)单链表的操作
和数组一样,单链表也有查找、插入和删除等操作。因为链表的存储空间是不连续的,所以链表的插入和删除操作是很快速的。
- 插入操作
假设我们要完成一个插入操作:在节点 p 后面插入节点 s。
正确的做法是,只需要将节点 s 插入到节点 p 和节点 p.next 之间就可以,说起来很简单,具体操作请看下图。
从上图中可以看出,单链表的插入其实根本不需要惊动其它的节点,只需要让 s.next 的指针和 p.next 的指针稍作改变。让节点 s 的后继指针指向 p 的后继节点,然后 p 的后继指针指向节点 s,这里切记,插入操作的顺序一定不能改变。
可以看出插入操作的时间复杂度是 O(1)。
- 删除操作
假设我们要完成一个删除操作:删除节点 p 的后继节点 q。
其实也简单,就是将 p 的后继指针绕过 q,直接指向 q 的后继节点即可,具体操作如下图。
由上图看出,同样只需要一步就可以实现删除操作,直接让 p.next 指向 q.next 即可,所以删除操作的时间复杂度为 O(1)。
- 查找操作
当然啦,有快的地方就有慢的地方,元素的查找就是链表美中不足的地方。
数组在内存中排排坐不同,链表的在内存中的地址是分散的,只能通过前一个节点的 next 才知道当前节点的位置,所以在链表中想要找第 i 个元素,只能从头开始找,直到找到第 i 个元素位置。
从这可以看出,链表中查找操作的时间复杂度是 O(n)。
(2)双向链表操作
双向链表,顾名思义,两个方向向的链表。**相比起单链表来说,它多了一个前驱指针 prev,指向前驱节点。**这样双向链表既可以往前走,也可以往后走。
从上面两张图看,双向链表多了一个前驱指针,使得在内存上比单链表占用更多的空间,但是双向链表在查询链表元素的时候会更加方便,比如可以在 O(1) 的时间内超找到当前节点的前驱节点,这是典型的用空间换时间。
空间换时间,在内存够用的情况下,为了追求更快的执行速度,选择空间复杂度较高,时间复杂度较低的数据结构或者算法。
既然用了时间换空间,那双向链表比单链表快在哪个地方呢?那这个还是从插入、删除操作说起。
疑问:单链表的插入和删除都已经是 O(1) 了,还要怎么快?
其实准确点来说,**插入和删除是 O(1),更多的是针对插入或删除这单个动作来说的,在实际情况下,一切都有前置条件。**在某些前置条件下的实际应用场景,双向链表比单链表更快。
下面开始表演。
- 插入操作优势
插入操作,无非就是 2 种情况:
- 在 “data域等于某个特定值”的节点前或者后插入一个新的节点。
- 在给定的节点前或者后插入一个新的节点。
针对第 1 种,这俩时间复杂度都差不多。都是分两个步骤:
第一步找到 data 域等于某个特定值的节点,无论单链表还是双向链表都需要从头开始一个一个的遍历,这一步的时间复杂度都是 O(n)。
第二步就是插入操作。如果都是向后插入的话,那时间复杂度都是 O(1),如果是向前插入的话,那单链表慢一些,它需要再重新找到特定值节点的前驱节点,这个时间又花费 O(n),而双向链表不用,因为它有前驱节点,直接就能找到特定值节点的前驱节点,这个时间花费的是 O(1) 。
针对第 2 种情况,其实他就是第 1 种情况中的第二步,已经找到了要插入的节点,如果都是向后插入的话,单链表和双链表的时间复杂度都一样,都是 O(1)。差别就差向前插入,向前插入的话就需要知道当前节点的前驱节点,单链表需要重新遍历到当前节点的前驱节点,总的时间复杂度是 O(n),而双向链表一下子就能找到它的前驱节点,时间复杂度是 O(1)。
- 删除操作优势
删除操作,和插入操作差不多,也基本上是 2 种情况:
- 删除 “data域等于某个特定值”的节点。
- 删除给定的节点。
第 1 种情况就不说了,单链表和双向链表都一样,都是从头开始一个个的遍历,时间复杂度都是 O(n)。
第 2 种情况,删除给定的节点,这就要求需要知道这个节点的前驱节点。单链表需要从头开始找,直到“node.next = 当前节点”的适合,才是找到了当前节点的前驱节点 node,这个过程的时间复杂度是 O(n)。而双向链表直接就知道它的前驱节点,只需要时间复杂度是 O(1) 就可以搞定。
性能分析
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qqFh2El1-1680139256014)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220801182703165.png)]
使用场景:
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。
链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。
虚拟头结点的技巧
203. 移除链表元素
给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点 。
示例 1:
输入:head = [1,2,6,3,4,5,6], val = 6
输出:[1,2,3,4,5]
移除链表节点基本思想:
将要删除元素的前一个节点直接指向该元素后一个节点,从而将该元素从链表中移除。即让节点next指针直接指向下下一个节点
注意:
如果使用C,C++编程语言的话,不要忘了还要从内存中删除这两个移除的节点。
当然如果使用java ,python的话就不用手动管理内存了。
(说明:就算使用C++来做leetcode,如果移除一个节点之后,没有手动在内存中删除这个节点,leetcode依然也是可以通过的,只不过,内存使用的空间大一些而已,但建议依然要养成手动清理内存的习惯。)
但是!
如果删除的是头结点该怎么办?
有两种操作方法,分别如下:
- 直接使用原来的链表来进行删除操作。
- 设置一个虚拟头结点在进行删除操作。
方法一:直接使用原链表进行移除
移除头结点和移除其他节点的操作是不一样的,因为链表的其他节点都是通过前一个节点来移除当前节点,而头结点没有前一个节点。
所以头结点如何移除呢,其实只要将头结点向后移动一位就可以,这样就从链表中移除了一个头结点。
依然别忘将原头结点从内存中删掉。
但是该方法,需要单独写一段逻辑来处理移除头结点情况,所以方法二可以以一种统一的逻辑移除链表节点。
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
// 删除头结点
while (head != NULL && head->val == val) { // 注意这里不是if,是一个持续移除的过程(eg,1111,删除1)。判断头结点不为空原因:头结点为空,等同于操作空指针,编译出错。第二个判断条件:头节点的值为要删除的元素
ListNode* tmp = head; //用于内存释放,删除头结点
head = head->next; //将头结点向后移动一位,即头指针指向头结点的下一个
delete tmp; //c++中需要内存释放,删除tmp
}
// 删除非头结点
ListNode* cur = head; //定义一个指针,使其指向头结点head,注意cur是从head开始(因为删除一个元素,是使其前一个节点指向下一个节点,如果cur指向head后一个元素,无法寻找该元素cur的前一个节点(单向链表没有记录))。所以每次操作的元素其实是cur->next,判断它的值是不是要删除的元素
while (cur != NULL && cur->next!= NULL) {
if (cur->next->val == val) {
ListNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
} else {
cur = cur->next;
}
}
return head; //返回链表的头指针。head仍是指向列表的头结点,以上所有删除操作都是定义了一个tmp指针,进行移动和删除操作,没有影响head所指位置
}
};
方法二:设置虚拟头结点进行移除
这里来给链表添加一个虚拟头结点为新的头结点,此时要移除这个旧头结点元素1,就可以使用和移除链表其他节点的同一方法。还是熟悉的方式,然后从内存中删除元素1。
最后!return 头结点的时候,别忘了 return dummyNode->next;
,这个才是新的头结点。
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点
dummyHead->next = head; // 将虚拟头结点指向head,这样方面后面做删除操作
ListNode* cur = dummyHead; //定义一个临时指针用来遍历链表。注意:头结点不能用做遍历链表,最后返回的是头结点所指向的链表。同方法一中删除非头结点时定义指针原理相同(必须知道删除元素的上一个元素是什么,也就是操作删除cur->next)
while (cur->next != NULL) { //注意也是使用while循环,而不是if
if(cur->next->val == val) {
ListNode* tmp = cur->next; //使用tmp指针,方便最后删除tmp进行内存释放
cur->next = cur->next->next;
delete tmp;
} else {
cur = cur->next; //若不是需要删除的元素,则将cur指针向后移动继续遍历链表,直到cur->next == NULL即遍历完整个链表
}
}
//注意此处,最后返回的是dummyHead->next,也就是原链表的头结点,并且将dummyHead删除释放内存(为什么要搞这一步?因为原链表的head节点有可能已经被删除,到此为止该链表头结点还是dummyHead,所以需要将dummyHead->next赋值给head,最终return head也就是return dummyHead->next
head = dummyHead->next;
delete dummyHead;
return head;
}
};
Q&A:
1 带头结点和不带头结点链表的区别
有很多人会不清楚带头结点和不带头结点链表的区别,甚至搞不懂什么是带头结点和不带头结点,我给大家阐述一下:
带头结点:head指针始终指向一个节点,这个节点不存储有效值仅仅起到一个标识作用(相当于班主任带学生)
不带头结点:head指针始终指向第一个有效节点,这个节点储存有效数值。
那么带头结点和不带头结点的链表有啥区别呢?
查找上:无大区别,带头结点需要多找一次。
插入上、删除上:非第0个位置插入区别不大,不带头结点的插入和删除,在第0号位置之后需要重新改变head头的指向。
总而言之:
带头结点通过一个固定的头可以使链表中任意一个节点都同等的插入、删除。
而不带头结点的链表在插入、删除第0号位置时候需要特殊处理,最后还要改变head指向。
两者区别就是插入删除首位(尤其插入)当然我是建议你以后在使用链表时候**尽量用带头结点的链表**避免不必要的麻烦。
2 头指针与头节点的区别
- 头指针,顾名思义,是指向链表第一个结点的指针,如果有头结点的话,那么就是指向头结点的指针。
它是链表的必备元素且无论链表是否为空,头指针都不能为空,因为在访问链表的时候你总得知道它在什么位置,这样才能通过它的指针域找到下一个结点的位置,也就是说知道了头指针,整个链表的元素我们都是可以访问的。
所以头指针必须要存在,这也就是我们常说的标识,这也就是为什么我们一般用头指针来标识链表。
- 头结点,是放在第一个元素的节点之前,它的数据域一般没有意义,并且它本身也不是链表必须要带的。
它的设立是单纯是为了操作的统一和方便,其实就是为了在某些时候可以更方便的对链表进行操作,有了头结点,我们在对第一个元素前插入或者删除结点的时候,它的操作与其它结点的操作就统一了。
不管是带头节点,还是不带头节点,或者是空的链表,它们都是有头指针的,
所以:“头指针是链表的必备元素,无论链表是否为空,头指针都不能为空”。
类似题目:
237. 删除链表中的节点
请编写一个函数,用于 删除单链表中某个特定节点 。在设计函数时需要注意,你无法访问链表的头节点 head ,只能直接访问 要被删除的节点 。
题目数据保证需要删除的节点 不是末尾节点 。
示例 1:
输入:head = [4,5,1,9], node = 5
输出:[4,1,9]
解释:指定链表中值为 5 的第二个节点,那么在调用了你的函数之后,该链表应变为 4 -> 1 -> 9
解题思路:
这道题和普通移除链表元素不同之处在于:你无法访问链表的头节点 head ,只能直接访问 要被删除的节点,也就是不知道要删除节点的前一个节点,也不能使用将前一个节点指向下一个节点的常规思路。于是有一种“狸猫换太子”的新思路。也就是:
我们并不是真正删除当前节点,而是删除当前节点的后继节点;那么就需要用后继节点将当前节点覆盖(也就是将后一节点的值复制到当前节点),从而将当前节点“伪装”成下一个节点,然后将后一节点当做【带删除节点】进行常规删除操作:也就是将当前节点指向下一个节点的下一个节点,从而实现了删除操作。最终由于c++没有内存自动回收机制,所以需要删除当前节点的下一个节点。
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
void deleteNode(ListNode* node) {
node->val = node->next->val; //将待删除节点后一节点的值覆盖该结点的值
ListNode* tmp = node->next; //内存回收,删除该节点的下一个节点
node->next = node->next->next; //将该结点指向下下个结点,达到删除效果
delete tmp;
}
};
链表的基本操作
707. 设计链表
设计链表的实现。您可以选择使用单链表或双链表。单链表中的节点应该具有两个属性:val 和 next。val 是当前节点的值,next 是指向下一个节点的指针/引用。如果要使用双向链表,则还需要一个属性 prev 以指示链表中的上一个节点。假设链表中的所有节点都是 0-index 的。
在链表类中实现这些功能:
get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
链表常见操作解题思路:
注意:
该题目节点是从0开始实现的(头结点索引为0)。
并且在解题过程中,统一使用虚拟头结点的方式,方便对链表进行增删改操作。
涉及到n的操作时,首先要对n进行合法性判断后再进行对应操作。
-
获取第n个节点的值
定义一个用于遍历链表的指针cur(注意不能使用头指针遍历,这样最后return head会错误),初始化指向dummyhead->next;在while循环中进行指针的移动。注意边界值n=0和n=size-1
-
头部插入节点
插入节点流程:
先定义一个新的链表节点newnode。先将newnode指向head(newnode->next = dummyhead->next),再虚拟节点指向newnode(dummyhead->next = newnode),注意顺序!最终size++。(其实这个功能因为head指向头结点,所以顺序可以改变,但在其他位置不能改变顺序)
-
尾部插入节点
关键点:如何找到尾部?while(cur->next != NULL),直到cur->next 为空时,再进行插入节点操作,也就是直接将cur指向newnode即可(cur->next = newnode),最终size++。
-
第n个节点前插入节点
在第n个节点前插入,首先必须知道第n-1个节点的指针,才能进行插入操作,所以cur应该指向n-1个节点。关键:如何寻找第n个节点?while(n–)遍历链表(cur = cur->next),找到第n个节点(也就是将cur指向第n-1个节点,从而保证cur->next为第n个节点,才能对第n个节点进行插入操作)。插入操作同2。最终size++。
-
删除第n个节点
首先,找到第n个节点,思想同4,也是将cur指向第n-1个节点,操作cur,保证cur->next为第n个节点,才能对第n个节点进行删除操作(cur->next = cur->next->next)
参考代码:
class MyLinkedList {
public:
//定义链表节点结构体
struct LinkedNode {
int val;
LinkedNode* next;
LinkedNode(int val):val(val), next(nullptr){}
};
//初始化链表
MyLinkedList() {
_dummyHead = new LinkedNode(0); //定义虚拟头结点
_size = 0;
}
//获取链表中第 index 个节点的值。如果索引无效,则返回-1。
int get(int index) {
if(index > _size - 1 || index < 0){
return -1;
}
LinkedNode* cur = _dummyHead->next;
while(index--){// 如果--index 就会陷入死循环
cur = cur->next;
}
return cur->val;
}
//在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
void addAtHead(int val) {
LinkedNode* newNode = new LinkedNode(val);
newNode->next = _dummyHead->next;
_dummyHead->next = newNode;
_size++;
}
//将值为 val 的节点追加到链表的最后一个元素。
void addAtTail(int val) {
LinkedNode* newNode = new LinkedNode(val);
LinkedNode* cur = _dummyHead;
while(cur->next != nullptr){
cur = cur->next;
}
cur->next = newNode;
_size++;
}
// 在第index个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。
// 如果index 等于链表的长度,则说明是新插入的节点为链表的尾结点
// 如果index大于链表的长度,则返回空
// 如果index小于0,则置为0,作为链表的新头节点。
void addAtIndex(int index, int val) {
if(index > _size || index < 0){
return;
}
LinkedNode* newNode = new LinkedNode(val);
LinkedNode* cur = _dummyHead;
while(index--){
cur = cur->next;
}
newNode->next = cur->next;
cur->next = newNode;
_size++;
}
//如果索引 index 有效,则删除链表中的第 index 个节点。
void deleteAtIndex(int index) {
if(index < 0 || index >= _size){
return;
}
LinkedNode* cur = _dummyHead;
while(index--){
cur = cur->next;
}
LinkedNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
_size--;
}
//打印链表
void printLinkedList(){
LinkedNode* cur = _dummyHead;
while(cur->next != nullptr){
cout << cur->next->val<<" ";
cur = cur->next;
}
cout << endl;
}
private:
int _size;
LinkedNode* _dummyHead;
};
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList* obj = new MyLinkedList();
* int param_1 = obj->get(index);
* obj->addAtHead(val);
* obj->addAtTail(val);
* obj->addAtIndex(index,val);
* obj->deleteAtIndex(index);
*/
反转链表
206. 反转链表
给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。
示例 1:
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
本题为高频基础数据结构题
解题思路:
本题可以有两种解法,分别为双指针写法和递归写法,建议先掌握双指针解法,后对照双指针解法,写出递归的解法
1、双指针解法
定义两个指针,cur和pre,cur指向当前节点,pre指向cur的前一个节点;方便将指针改向,将cur指向后一位的方向改为指向前一位的方向(也就是cur指向pre的方向)。
代码思路:
-
初始化cur和pre
cur = head
pre = NULL(head的前一位为空,所以pre初始化为NULL)
-
遍历链表过程
(1)注意while循环结束条件(cur为NULL时,遍历结束)
while(cur)
(2)当cur指向pre时,cur与下一个节点断开连接,cur如何移动到下一位呢?
定义一个临时指针tmp,在赋值之前,利用tmp提前保存cur的下一个节点(tmp = cur->next)
(3)改变方向cur->next = pre
(4)移动指针时,应该先移动pre再移动cur。
顺序反了,cur先移动后值已经改变,pre指向原先cur的位置就会很麻烦。而pre先移动到cur位置,cur再后移就很轻松了
-
返回新链表头结点
循环结束后,cur指向NULL,pre指向新链表头结点,所以return pre即可
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {//双指针解法
ListNode* cur = head;
ListNode* pre = NULL;
ListNode* tmp; //保存cur的下一个节点
while(cur){ //注意循环终止条件为cur为NULL时,此时循环结束
tmp = cur->next; //先保存cur的下一个节点,因为接下来要改变cur->next
cur->next = pre; //执行反转操作,改变链表方向
//更新cur和pre指针
pre = cur;
cur = tmp;
}
return pre; //cur为空时,cur的前一个节点pre正好为翻转后链表的头结点,直接返回pre即可
}
};
2、递归解法
逻辑同双指针解法,其实是双指针写法的浓缩提炼,所以一定要掌握双指针解法。
1、定义reverse函数,传入的是cur和pre。
2、在leetcode中的输入方式为举例reverseList(head),所以主函数中调用reverse用来反转链表,初始化调用函数时如下:reverse(head, NULL) 因为在双指针解法中,cur和pre初始化分别为head和NULL。
3、递归终止条件:递归遍历时if(cur == NULL)终止,并且return pre;所以在主函数中直接 return reverse(head, NULL)即可。执行reverse(cur, pre)后,将链表处理后头结点返回给主函数中调用的reverse(head, NULL)函数,reverse函数再将结果(头结点)返回给主函数。
4、改变链表方向的思路同双指针中2,但是在移动指针时,需要使用递归的思想。**递归调用reverse(tmp, cur)**进入下一层递归。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverse(ListNode* pre, ListNode* cur){
if(cur == NULL) return pre; //终止条件
ListNode* tmp = cur->next;
cur->next = pre;
//pp.双指针解法,将如下两步,改变为递归写法,
// pre = cur;
// cur = temp;
return reverse(cur, tmp); //递归调用
}
ListNode* reverseList(ListNode* head) {//递归解法
// 和双指针法初始化是一样的逻辑
// ListNode* cur = head;
// ListNode* pre = NULL;
return reverse(NULL, head);
}
};
92. 反转链表 II
给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回 反转后的链表 。
示例 1:
输入:head = [1,2,3,4,5], left = 2, right = 4
输出:[1,4,3,2,5]
解题思路:
这道题与上一道题比较,需要将left到right区间的链表反转,反转的思想还是不变,但就是最后需要重新组装连接链表时需要注意,以及需要一个新的指针hh,指向left之前的一个节点,用做最后连接链表时使用,
同时需要一个虚拟指针,这样的话,如果left指向的是第一个节点,那么hh指针就可以指向虚拟指针,就可以统一操作了。
在控制反转链表次数(right-left)次时,可以在left值还没有变化时,将right-left,while循环中,只需要将while(r-- > 0)即可控制循环次数。
最终连接操作,首先将hh->next->next = cur;接着hh->next = pre; 顺序不能改变。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ln6Qved6-1680139256016)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220805162927379.png)]
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
//定义虚拟头结点
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
right -= left; //注意此处!要在这个时候就将r-l,用于后面控制翻转次数
//hh为left前一个节点,用于最后连接链表
ListNode* hh = dummyHead;
//将hh移动到left前一个节点位置
while(left-- > 1) {
hh = hh->next;
}
//定义cur和pre用于反转链表
ListNode* pre = hh->next;
ListNode* cur = pre->next;
//进行链表反转
while(right-- > 0){
ListNode* tmp = cur->next; //用于下一次寻找cur的位置
cur->next = pre;
pre = cur;
cur = tmp;
}
//连接重新组装链表,顺序不能改变,此时hh->next为left所指的位置
hh->next->next = cur;
hh->next = pre;
return dummyHead->next;
}
};
正常模拟
24. 两两交换链表中的节点
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
示例 1:
输入:head = [1,2,3,4]
输出:[2,1,4,3]
解题思路:
注意,该题交换的是物理节点,而不是交换链表中的数值。
如果是奇数节点,则最后一个节点不进行任何操作。如果是偶数节点,则正好可以两两交换。
仍旧需要一个虚拟指针dummyHead。因为要操作两节点,cur指向两节点的前一个节点,才能改变两节点中头一个节点的指向。
如何进行两两交换?
cur指向2 , 2指向1 , 1指向3
代码细节:(注意空指针异常和死循环)
(1)初始化:虚拟头结点dummyHead指向head(dummyHead->next = head),以及cur指向dummyHead(cur = dummyHead)。
(2)终止条件
分为两种情况,当节点数为奇数个时,cur->next->next == NULL时,结束。节点数为偶数个时,cur->next == NULL时,结束。所以while条件如下:
while(cur->next != NULL && cur->next->next != NULL)
注意!这两个顺序不能改变!改变容易发生空指针异常。当顺序改变后,如果cur->next为空,那么cur->next->next 就是对空指针取值(取next值)就会发生空指针异常。所以一定先对cur->next判断是否为空。
(3)遍历过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4ftVWe2J-1680139257149)(null)]
cur指向2 , 2指向1 , 1指向3
cur->next = cur->next->next
进行此步骤会将cur指向1的指针释放,这样2指向1就无法实现(找不到节点1),所以需要在还没有改变指针之前定义一个临时节点tmp1保存节点1,(tmp1 = cur->next)。
cur->next->next = tmp1
同理,当2指向1时,会将2指向3的指针释放,所以也需要在最开始还没有改变指针时,定义一个tmp2保存节点3,(tmp2 = cur->next->next->next)
tmp1->next = tmp2;
注意:定义tmp1和tmp2,需要在改变指针操作之前定义,应该写在循环的最前面,然后再进行三次改变节点指向的操作;
(4)移动操作
cur每次需要往后移动2位,即cur= cur->next->next
(5)最终返回的是return dummyHead->next
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode* dummyHead = new ListNode(0); //设置一个虚拟头结点
dummyHead->next = head;
ListNode* cur = dummyHead; //cur初始化指向虚拟头结点
while(cur->next != nullptr && cur->next->next != nullptr){
ListNode* tmp1 = cur->next; //记录临时节点1
ListNode* tmp2 = cur->next->next->next; //记录临时节点3
cur->next = cur->next->next; //cur指向2
cur->next->next = tmp1; //2指向1
tmp1->next = tmp2; //1指向3
cur = cur->next->next; //cur向后移动两位,准备下一轮交换
}
return dummyHead->next; //返回链表头结点
}
};
删除倒数第N个节点
19. 删除链表的倒数第 N 个结点
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
示例 1:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
解题思路:
由于删除的是倒数第n个结点,所以可以想到一种巧妙的解题思路:双指针解法,这道题也是双指针的经典应用。
设置两个指针fast和slow,如果要删除倒数第n个结点,让fast先移动n步,然后让fast和slow同时移动,直到fast指向链表末尾,删掉slow所指向的节点即可。
代码细节:
(1)建议使用虚拟头结点。
(2)定义fast和slow指针,初始化为虚拟头结点
(3)fast首先走n步,在同时移动时(保持两者距离不变),slow和fast(包含slow和fast)间隔n+1个节点(方便以后的删除操作)
(4)终止条件:fast->next = NULL
fast指向最后一个节点时,slow指向的是待删除元素(倒数第n个元素)的前一个节点,有利于删除操作
(5)删除操作只需要将slow指向slow的下下个结点即可。(slow->next = slow->next->next)
要注意的是可能是要删除第一个节点,这个时候可以直接返回head -> next
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
//定义虚拟头结点
ListNode* dummyhead = new ListNode(0);
dummyhead->next = head;
//定义fast和slow指针并初始化为dummyhead位置
ListNode* fast = dummyhead;
ListNode* slow = dummyhead;
//fast先走n步,此时slow和fast(包含slow和fast)间隔n+1个节点
while(n-- && fast != NULL){
fast = fast->next;
}
if(!fast){
return head->next;
}
//将fast和slow同时向后移动,并且将fast移动至最后一个节点位置终止
while(fast->next != NULL){
slow = slow->next;
fast = fast->next;
}
//执行删除节点操作
slow->next = slow->next->next;
return dummyhead->next;
}
};
链表相交
面试题 02.07. 链表相交
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null 。
图示两个链表在节点 c1 开始相交:
题目数据 保证 整个链式结构中不存在环。
注意,函数返回结果后,链表必须 保持其原始结构 。
解题思路:
本题有两种思路,非常巧妙。
题解1:
双指针计算节点个数法。
假设公共节点为node,链表a的节点数量为a,链表b的节点数量为b,两个链表的公共尾部节点数量为c。那么:
- 头节点
headA
到node
前,共有 a - c 个节点; - 头节点
headB
到node
前,共有 b - c 个节点;
可以通过构建两个指针A,B,初始化指向两个链表的头结点,同时做以下操作:
- 指针A先遍历链表a,在开始遍历链表b,当走到node时,共走步数为:a+(b−c)
- 指针B先遍历链表b,在开始遍历链表a,当走到node时,共走步数为:b+(a−c)
A = (A != NULL ? A->next : headB);
B = (B != NULL ? B->next : headA);
此时指针A,B重合(判断条件:while( A != B)),且有 a+(b−c)=b+(a−c)
并有两种情况:
1、若两链表 有 公共尾部 (即 c > 0) :指针 A , B 同时指向「第一个公共节点」node 。
2、若两链表 无 公共尾部 (即 c = 0 ) :指针 A , B 同时指向 null。
因此,返回指针A即可。
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* A = headA;
ListNode* B = headB;
while(A != B){ //AB指针不相交或最后都指向NULL之前
A != NULL ? A = A->next : A = headB; //如果指针A没有遍历完链表A(A != NULL),那么将A向后移动,如果指针A遍历完链表A(A == NULL),那么将指针A指向B链表头结点(headB),继续向后遍历,直到A、B指针相等(注意不是数值相等),此时退出循环,找到公共相交节点或者没有公共节点(均指向NULL)
B != NULL ? B = B->next : B = headA; //思路同上
}
return A;
}
};
题解2:
本题,需要求两个链表交点节点的指针,注意交点不是数值相等,而是指针相等。
首先需要设置两个指针A和B,并初始化指向A、B两链表的头结点。
首先求出两个链表长度,并求出两链表长度的差值,然后让A指针移动到和B指针末尾对齐的位置(也就是将A、B链表按照末尾对齐之后将A、B指针分别指向短链表头结点对应两链表的位置,也就是A、B在同一起点)
如图所示:
此时,遍历A和B,比较A、B指针是否相同,如果不相同,则同时向后移动A、B指针,比较A指针是否等于B指针(注意此时判断的是指针是否相等),
如果遇到A == B,那么就找到交点,否则(遍历完链表,一直没有找到交点)循环退出,返回空指针。
该思路较第一种思路比较复杂。
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* A = headA;
ListNode* B = headB;
int lenA = 0, lenB = 0;
while(A !=NULL){ //遍历链表A,求链表A的长度,注意是A !=NULL,要不lenA会少一位
lenA++;
A = A->next;
}
while(B != NULL){ //遍历链表B,求链表B的长度
lenB++;
B = B->next;
}
//重新设置指针A、B,分别指向两链表头结点
A = headA;
B = headB;
//规定指针A指向两链表中长度较长的链表的头结点,所以如果A所指链表长度短,交换两指针以及指向链表的长度
if(lenA < lenB){
swap(lenB, lenA);
swap(A, B);
}
//求长度差
int gap = lenA - lenB;
//移动A链表指针A,将两指针放在末尾位置对齐的同一起点上
while(gap--){
A = A->next;
}
//遍历两指针A、B,遇到指针相同时,返回该指针
while(A != NULL){
if(A == B){
return A;
}
A = A->next;
B = B->next;
}
//循环退出即遍历完全部链表还是没有找到相交指针,那么就返回NULL
return NULL;
}
};
环形链表
141. 环形链表
给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true 。 否则,返回 false 。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
解题思路:
判断链表是否有环,使用快慢指针的方法,具体思路详见142解题思路分析。
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool hasCycle(ListNode *head) {
//定义快慢指针,初始化指向头结点
ListNode* fast = head;
ListNode* slow = head;
while(fast != NULL && fast->next != NULL){ //注意此处的循环条件判断,是判断快指针以及快指针指向的下一个节点是否为空!!!注意顺序,首先先判断fast是否为空,然后在看他下一个节点是否为空
fast=fast->next->next; //快指针每次走两步
slow=slow->next; //慢指针每次走一步
if(fast == slow){ //快慢指针相遇
return true;
}
}
return false; //快指针到达尾部,快慢指针仍未相遇
}
};
142. 环形链表 II
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
解题思路:
本题需要解决两个问题,分别是:1、判断是否有环;2、如果有环找到环的入口
1、首先解决是否有环的问题:
使用快慢指针的方法:
如果链表没有环,那么快指针走得快,慢指针走的慢,他们沿着一条直线走下去,肯定不会相遇
如果链表有环,那么快指针首先会进入环里一直绕圈下去,慢指针也会一步步的进入环里绕圈下去,这样快慢指针因为每次走的步幅大小不同,所以会在环中相遇。
因此只需要判断快慢指针是否相遇,就可以判断出链表中是否有环。
代码细节:
注意定义快慢指针每次移动的步数
快慢指针分别指向链表头节点,快指针每次循环走两步,慢指针每次循环走一步。
疑问,如果有环,快慢指针为什么一定会在环中相遇呢?
快指针首先进入环,慢指针之后进入环,快指针的速度是两个节点,慢指针的速度是一个节点,所以快指针相对于慢指针是每次移一个节点的移动速度去追慢指针,而不会让快指针直接跳过慢指针永远不相遇。所以关键是快慢指针的速度差(快相对于慢)为一个节点的速度。
2、如何找到环的入口?
假设快慢指针在环的某点处相遇,从起始位置到入口处的距离为x(也就是要求解的pos),从入口处到相遇位置的距离为y,从相遇位置到入口处的距离为z。
可以写出以下等式(连接等式桥梁:快指针每次走两个节点,慢指针每次走一个节点,所以相同的循环次数,慢指针走过的距离(x + y ) 的二倍 等于 快指针走过的距离(**x + y + n * (y + z) **):也就是:
*2 (x + y ) = x + y + n * (y + z)
ps:其中n表示在快慢指针相遇前,快指针已经在环中转了n圈。
因为要找环形的入口,那么要求的是x,因为x表示 头结点到 环形入口节点的的距离。
化简等式后,可以求出x = n *(y + z) - y
再从n(y+z)中提出一个 (y+z)来,整理公式之后为如下公式: *x = (n - 1) (y + z) + z
由于n肯定是大于等于1的(快指针不可能一圈也没绕完就追上慢指针,fast指针至少要多走一圈才能相遇slow指针)
一定要注意理解这个等式x = (n - 1) *(y + z) + z的含义!
先拿n为1的情况来举例,意味着fast指针在环形里转了一圈之后,就遇到了 slow指针了。
- 当 n为1的时候,公式就化解为
x = z
,
也就是意味着,从头结点出发一个指针,同时从相遇节点也出发一个指针,这两个指针每次只走一个节点,那么当这两个指针相遇时,就是环形入口的节点。
具体实现方法:在相遇节点处,定义一个指针index1;在头结点处,定义一个指针index2。让index1和index2同时移动,每次移动一个节点,那么他们相遇的地方就是环形入口的节点。
- 当n如果大于1什么情况呢?
其实效果和n为1是一样的,一样可以通过上述方法找到环形入口节点,只不过,index1指针在环里多转了(n-1)圈,然后在遇到index2,相遇节点仍是环形的入口节点;所以fast在环中转了几圈都无所谓,**最关键的还是在环中相遇之后,走的那一段距离(也就是从相遇位置到入口处的距离z)**通过x=z来间接寻找环形的入口节点。
ps:答疑解惑
为什么第一次在环中相遇,slow的 步数 是 x+y 而不是 x + 若干环的长度 + y 呢?
即这个公式:**2 *(x + y ) = x + y + n * (y + z),**等式左边slow为什么不会在环中转了好多圈环的长度?也就是为什么慢指针一定在第一圈的时候就被快指针追上了呢?
解释:将环铺平,每一圈长度为n,则当slow从入2进入时,fast在环的某一位置(入1和入2之间)。
假设slow绕环一圈到达入3时,因为fast每次走2步,所以此时fast所走距离为slow所走距离的2倍,所以此时fast一定在入3和入4之间的某个位置(已经超过slow)。
因此可以得出结论:快慢指针一定在慢指针绕环一圈之前就相遇了。
所以slow一定没有转弯一圈就被fast追上。也就是第一次在环中相遇,slow的步数一定为x+y,不会包含多圈环长度。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PrDFJnj5-1680139256018)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220809091117893.png)]
代码细节:
(1)定义快慢指针fast和slow,初始化指向head
(2)循环中:
1、while循环条件:while( fast !=NULL && fast->next != NULL)(因为快指针走的快,所以只需要判断快指针是否为空,且每次走两步,所以需要判断fast->next快指针指向的下一个节点是否为空)
2、快指针每次循环走两步(fast = fast->next->next),慢指针每次循环走一步(slow = slow->next)。
3、如果快慢指针相遇 (if ( fast == slow)),则说明找到环,可进行寻找环入口的步骤。
4、寻找环入口:
- 定义index1指向相遇位置(index1 = fast),定义index2指向头结点(index2 = head)
- while循环直到index1和index2相遇终止,index1与index2同时一直向后移
- while循环终止后,找到相遇节点,即return index1;
5、如果没有环,那么第一层while循环结束时,return NULL即可。
参考代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
//初始化定义快慢指针
ListNode* fast = head;
ListNode* slow = head;
//移动快慢指针判断是否有环
while(fast != NULL && fast->next != NULL){//注意此处的循环条件判断,是判断快指针以及快指针指向的下一个节点是否为空!!!注意顺序,首先先判断fast是否为空,然后在看他下一个节点是否为空
fast = fast->next->next; //快指针每次走两步
slow = slow->next; //慢指针每次走一步
if(fast == slow){ //快慢指针相遇,则寻找环的入口节点
ListNode* index1 = fast; //index1指向相遇节点处
ListNode* index2 = head; //index2指向头结点处
while(index1 != index2){ //寻找环入口节点(index1与index2相遇处)
index1 = index1->next;
index2 = index2->next;
}
return index1; //返回环入口节点
}
}
return NULL;//快慢指针没有相遇,则链表没有环,返回null
}
};
总结
考察链表的操作其实就是考察指针的操作,是面试中的常见类型。
理论基础知识:
- 链表的种类主要为:单链表,双链表,循环链表
- 链表的存储方式:链表的节点在内存中是分散存储的,通过指针连在一起。
- 链表是如何进行增删改查的。
- 数组和链表在不同场景下的性能分析。
题型总结:
虚拟头结点
在链表:听说用虚拟头节点会方便很多? (opens new window)中,我们讲解了链表操作中一个非常总要的技巧:虚拟头节点。
链表的一大问题就是操作当前节点必须要找前一个节点才能操作。这就造成了,头结点的尴尬,因为头结点没有前一个节点了。
每次对应头结点的情况都要单独处理,所以使用虚拟头结点的技巧,就可以解决这个问题。
在链表:听说用虚拟头节点会方便很多? (opens new window)中,我给出了用虚拟头结点和没用虚拟头结点的代码,大家对比一下就会发现,使用虚拟头结点的好处。
链表的基本操作
在链表:一道题目考察了常见的五个操作! (opens new window)中,我们通设计链表把链表常见的五个操作练习了一遍。
这是练习链表基础操作的非常好的一道题目,考察了:
- 获取链表第index个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第index个节点前面插入一个节点
- 删除链表的第index个节点的数值
可以说把这道题目做了,链表基本操作就OK了,再也不用担心链表增删改查整不明白了。
这里我依然使用了虚拟头结点的技巧,大家复习的时候,可以去看一下代码。
反转链表
在链表:听说过两天反转链表又写不出来了? (opens new window)中,讲解了如何反转链表。
因为反转链表的代码相对简单,有的同学可能直接背下来了,但一写还是容易出问题。
反转链表是面试中高频题目,很考察面试者对链表操作的熟练程度。
我在文章 (opens new window)中,给出了两种反转的方式,迭代法和递归法。
建议大家先学透迭代法,然后再看递归法,因为递归法比较绕,如果迭代还写不明白,递归基本也写不明白了。
可以先通过迭代法,彻底弄清楚链表反转的过程!
删除倒数第N个节点
在链表:删除链表倒数第N个节点,怎么删? (opens new window)中我们结合虚拟头结点 和 双指针法来移除链表倒数第N个节点。
链表相交
链表:链表相交 (opens new window)使用双指针来找到两个链表的交点(引用完全相同,即:内存地址完全相同的交点)
环形链表
在链表:环找到了,那入口呢? (opens new window)中,讲解了在链表如何找环,以及如何找环的入口位置。
这道题目可以说是链表的比较难的题目了。 但代码却十分简洁,主要在于一些数学证明。
点没有前一个节点了。
每次对应头结点的情况都要单独处理,所以使用虚拟头结点的技巧,就可以解决这个问题。
在链表:听说用虚拟头节点会方便很多? (opens new window)中,我给出了用虚拟头结点和没用虚拟头结点的代码,大家对比一下就会发现,使用虚拟头结点的好处。
链表的基本操作
在链表:一道题目考察了常见的五个操作! (opens new window)中,我们通设计链表把链表常见的五个操作练习了一遍。
这是练习链表基础操作的非常好的一道题目,考察了:
- 获取链表第index个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第index个节点前面插入一个节点
- 删除链表的第index个节点的数值
可以说把这道题目做了,链表基本操作就OK了,再也不用担心链表增删改查整不明白了。
这里我依然使用了虚拟头结点的技巧,大家复习的时候,可以去看一下代码。
反转链表
在链表:听说过两天反转链表又写不出来了? (opens new window)中,讲解了如何反转链表。
因为反转链表的代码相对简单,有的同学可能直接背下来了,但一写还是容易出问题。
反转链表是面试中高频题目,很考察面试者对链表操作的熟练程度。
我在文章 (opens new window)中,给出了两种反转的方式,迭代法和递归法。
建议大家先学透迭代法,然后再看递归法,因为递归法比较绕,如果迭代还写不明白,递归基本也写不明白了。
可以先通过迭代法,彻底弄清楚链表反转的过程!
删除倒数第N个节点
在链表:删除链表倒数第N个节点,怎么删? (opens new window)中我们结合虚拟头结点 和 双指针法来移除链表倒数第N个节点。
链表相交
链表:链表相交 (opens new window)使用双指针来找到两个链表的交点(引用完全相同,即:内存地址完全相同的交点)
环形链表
在链表:环找到了,那入口呢? (opens new window)中,讲解了在链表如何找环,以及如何找环的入口位置。
这道题目可以说是链表的比较难的题目了。 但代码却十分简洁,主要在于一些数学证明。