导图 - 链表篇
2.1 链表理论基础
-
链表的类型:单、双、循环(可解决约瑟夫环问题)
-
链表的存储方式:通过指针连接,散布在内存中
-
链表的操作:增删改查
-
链表的定义
// C++ // 单链表 struct ListNode { int val; // 节点上存储的元素 ListNode *next; // 指向下一个节点的指针 ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数 }; // 虚拟头结点:创建了一个val为0,next为NULL的结点 ListNode* dummy_head = new ListNode(0); // 无虚拟头结点 ListNode* head = NULL;
# Python class ListNode: def __init__(self, val, next=None): self.val = val self.next = next # 虚拟头结点:创建了一个val为0,next为None的结点 dummy_head = ListNode(0) # 无虚拟头结点 head = None
-
与数组对比
2.2 203.移除链表元素
思路
-
在链表中如何删除一个元素?(C/C++还要从内存中删除移除的节点,Java、Python不用手动管理内存)
-
除头结点以外的其他结点的删除(C/C++还要从内存中删除移除的节点)
-
头结点的删除:只要将头结点向后移动一位就可以,这样就从链表中移除了一个头结点(C/C++还要将原头结点从内存中删除)
-
-
如何处理头结点和其他结点删除操作不同的情况?
-
直接用原来的链表进行删除,只是头结点需要特殊处理
-
用虚拟头结点,统一删除操作:删除当前结点,找到其前一个结点即可
-
上面的图片均出自代码随想录(203.移除链表元素)
代码实现(这里只实现有虚拟头结点的代码)
无虚拟头结点的代码实现时要注意
- 删除头结点时要用循环:因为头结点是不断更新的,即删掉一个,后面的结点自动成为新的头结点。新的头结点也需要特殊处理,所以干脆直接用循环
- 删除其他结点时的循环条件为
while (cur && cur->next)
:删完头结点后进入循环删除其他结点时,循环的条件不仅仅是cur->next != NULL
,还要加一个cur != NULL
,因为删除完头结点后,cur = head
,但是head
此时可能为空;
/**
* 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* removeElements(ListNode* head, int val) {
// 有虚拟头结点
ListNode* dummy_head = new ListNode(0, head);
ListNode* cur = dummy_head;
while (cur->next) { // 若头结点为空,cur->next为空,不进入循环;若cur为最后一个结点,一样
if (cur->next->val == val) { // 删除当前结点,要找到其前一个结点;cur指向要删除结点的前一个结点
ListNode* temp = cur->next; // 手动释放结点
cur->next = cur->next->next; // 删除操作
delete temp;
}
else {
cur = cur->next;
}
}
head = dummy_head->next; // 头结点head可能已被删除,直接用dummy_head->next表示新头结点即可
delete dummy_head;
return head;
}
};
// 时间复杂度:O(n)
// 空间复杂度:O(1)
- 第1次写的时候,忘记手动释放内存了
遍历链表时为什么要定义一个current指针来遍历?or 为什么不用头结点来遍历?
因为大多数时候,我们操作完链表以后,要返回头结点
如果直接操作头结点或虚拟头结点的话,头结点的值都改了或删了,如何返回链表的头结点呢?
所以定义一个临时指针current
,通过操作临时指针current
来操作链表即可
最终返回 head
或 dummy_head.next
虚拟头结点 小结
- 作用:不用对头结点进行特殊判断,而可以采用统一的方式对每个结点进行操作。方便增、删操作
- 初始化:创建一个
val
为0(即空、无意义),next
为NULL
或None
(或指向头结点)的结点(普通结点的val
、next
都有意义) - 在链表的操作中,注意传入的结点是头结点还是虚拟头结点(自己瞎加的,应该不用太担心,一般都是头结点)
2.3 707.设计链表
思路
通过题目要求实现的操作发现,就是以下各个操作的集合
- 判断
n
的合法性(索引为n
or 第n
个):0
、表长size
与n
的关系,链表的操作不同,其关系也不同 - 删除
cur
的下一个结点:cur->next = cur->next->next
(删除完后要size--
) - 在
cur
后新增一个结点:(构造函数为LinkedNode(int val):val(val), next(nullptr){})
)
ListNode* newNode = new ListNode(val);
newNode->next = cur->next;
cur->next = newNode
(添加完结点后不要忘记size++
) - 找到头结点、头结点的前一个结点、尾结点、尾结点的前一个结点
- 头结点:
head
、dummy_head->next
- 头结点的前一个结点:
dummy_head
- 尾结点:
while(cur->next) cur = cur->next
- 尾结点的前一个结点:
while(cur->next->next) cur = cur->next
- 头结点:
- 找到第
n
个、下标为n
的结点或其前一个结点(如何分别用for
、while
循环实现,三步走)
① 确定cur
起始位置 和 结束位置 的下标
(下标:统一从head
为0开始算,dummy_head
为-1)
② 确定 循环次数 和 每次循环的跨度(即一次循环路过几个结点)
③cur结束位置下标 - cur起始位置下标 = 循环次数/每次循环的跨度
如下图,2-(-1) = (3/1)
代码实现(这里只给出了单链表,且带虚拟头结点的实现)
单链表
class MyLinkedList {
public:
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
MyLinkedList() {
// 初始化一个有虚拟头结点的单链表
dummy_head = new ListNode(0);
size = 0;
}
int get(int index) {
// 获取链表中下标为 index 的节点的值
// index的合法性判断 0-size-1
if (index < 0 || index > size-1)
return -1;
ListNode* cur = dummy_head->next;
// 起始0,终止index,循环次数 index
for (int i = 0; i < index; i++) {
cur = cur->next;
}
return cur->val;
}
void addAtHead(int val) {
// 在表头插入值为val的结点
ListNode* newNode = new ListNode(val);
newNode->next = dummy_head->next;
dummy_head->next = newNode;
size++;
}
void addAtTail(int val) {
// 在表尾插入值为val的结点
ListNode* newNode = new ListNode(val);
ListNode* cur = dummy_head;
while (cur->next) {
cur = cur->next;
}
cur->next = newNode;
size++;
}
void addAtIndex(int index, int val) {
// 在下标为index结点之前插入一个值为val的结点
// index的合法性判断 0, size
if (index < 0 || index > size)
return;
ListNode* cur = dummy_head;
// 起始-1, 终止index-1, 循环次数index
while (index) {
cur = cur->next;
index--;
}
ListNode* newNode = new ListNode(val);
newNode->next = cur->next;
cur->next = newNode;
size++;
}
void deleteAtIndex(int index) {
// 删除下标为index的结点,要找到其前一个结点
// 判断index合法性 0,size-1
if (index < 0 || index > size-1)
return;
ListNode* cur = dummy_head;
// 起始-1,终止index-1,循环次数index
while (index--) { // 先index执行循环体,循环体执行完再--
cur = cur->next;
}
ListNode* temp = cur->next; // 手动释放内存
cur->next = cur->next->next;
delete temp;
size--;
}
private:
ListNode* dummy_head;
int size;
};
/**
* 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);
*/
// 时间复杂度:在尾部删除和有index参数的为O(n),其他为O(1)
// 空间复杂度:O(1)
遇到的问题
- 单链表的定义,不太懂构造函数的语法
- 初始化部分,要在最后加
private
,这里的语法也不太懂 - 在添加或删除结点后总是忘记操作
size
变量,导致get(index)
函数空指针异常 - 申请一个新的结点的时候的语法为:
ListNode* newNode = new ListNode(n)
,经常忘记new
- 看代码最后的对象调用方法的语法不太懂
obj->get(index)
- 删除结点操作,忘记手动释放内存
双链表
- 双链表的实现主要是空间换时间,所以前一半数据和后一半数据的处理思路是不同的
- 双链表似乎不是很需要虚拟头结点
2.4 206.反转链表
思路
只需要改变链表的next
指针的指向,直接将链表反转 ,而不用重新定义一个新的链表
设cur
和pre
,挨个后移,依次把指针指向反转即可
 : 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 = nullptr;
while (cur) {
ListNode* temp = cur->next;
cur->next = pre;
pre = cur;
cur = temp;
}
return pre;
}
};
// 时间复杂度:O(n)
// 空间复杂度:O(1)
递归法
/**
* 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) {
// 递归三要素:1.确定参数和返回值;2.确定终止条件;3.确定单层递归逻辑
// 1.参数和返回值
// 2.确定终止条件
if (cur == nullptr)
return pre;
// 3.单层递归逻辑
ListNode* temp = cur->next;
cur->next = pre;
return reverse(cur, temp);
}
ListNode* reverseList(ListNode* head) {
// 递归法
return reverse(nullptr, head);
}
};
// 时间复杂度:O(n) 要递归处理链表的每个节点
// 空间复杂度:O(n) 递归调用了 n 层栈空间
还有一种从后往前的递归,见代码随想录参考代码(206.反转链表)
2.5 24.两两交换链表中的节点
思路
这种题一定要画图帮助理解
cur
要指向要交换的两个结点之前的一个结点,然后进行交换结点的操作,循环往复,直至cur->next
或cur->next->next
为nullptr
- 设虚拟头结点来交换第1、2个结点,更方便操作
图片均出自代码随想录(24.两两交换链表中的节点)
代码实现
/**
* 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* dummy_head = new ListNode(0, head);
ListNode* cur = dummy_head;
while (cur->next != nullptr && cur->next->next != nullptr) { // 这里是&&不是||
// cur->next == nullptr 说明cur后无结点需要交换了
// cur->next->next == nullptr 说明cur后只剩一个结点,也无需交换
// 假设cur所在结点编号为0,后面仨结点依次为1、2、3,最终交换完成顺序为,0213
ListNode* temp1 = cur->next; // 存一下1
ListNode* temp2 = cur->next->next->next; // 存一下3
cur->next = cur->next->next; // 步骤1
cur->next->next = temp1; // 步骤2
temp1->next = temp2; // 步骤3
cur = temp1; // 交换后顺序为0213,cur移到1的位置,准备交换34
}
head = dummy_head->next;
delete dummy_head;
return head;
}
};
// 时间复杂度:O(n)
// 空间复杂度:O(1)
2.6 19.删除链表的倒数第N个节点
思路
双指针的经典应用,如果要删除倒数第n个节点,让fast
移动n步,然后让fast
和slow
同时移动,直到fast
指向链表末尾。删掉slow
所指向的节点就可以了
-
本题可能需要删除头结点,所以设虚拟头结点来统一操作
-
fast
和slow
都从虚拟头结点开始,fast
先走n+1步,slow
再走,这样fast==nullptr
时,slow
刚好指向要删除结点的前一个结点(例如,删除倒数第n个结点,即头结点,fast
)fast
:n+1
次循环,起始-1
,终止size
代码实现
/**
* 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* dummy_head = new ListNode(0, head);
ListNode* slow = dummy_head;
ListNode* fast = dummy_head;
// 可以再整个n的合法性判断
for (int i = 0; i < n+1; i++) {
fast = fast->next;
}
while (fast != nullptr) {
fast = fast->next;
slow = slow->next;
}
ListNode* temp = slow->next;
slow->next = slow->next->next;
delete temp;
head = dummy_head->next;
delete dummy_head;
return head;
}
};
// 时间复杂度:O(n)
// 空间复杂度:O(1)
2.7 面试题 02.07.链表相交
视频参考:睡不醒的鲤鱼 (强烈推荐!)
思路
代码实现
尾部对齐,同步后移判等
/**
* 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) {
// 尾部对齐,找相交
int sizeA = 0, sizeB = 0, mov = 0; // mov是记录较长的那个链表的curA后移长度
ListNode* curA = headA;
ListNode* curB = headB;
// 求表A和表B的表长:起始0,终止size,循环次数sizeA,即表长
while (curA) {
curA = curA->next;
sizeA++;
}
while (curB) {
curB = curB->next;
sizeB++;
}
// 求sizeA和sizeB的差值mov,是差的结点个数,只要长的一方后移mov次,尾部就能对齐
if (sizeA > sizeB) { // A长,移curA
mov = sizeA - sizeB;
curA = headA;
while (mov--) {
curA = curA->next;
}
curB = headB;
}
else if (sizeA < sizeB) { // B长,移curB
mov = sizeB - sizeA;
curB = headB;
while (mov--) {
curB = curB->next;
}
curA = headA;
}
else { // 一样长,不用移动
curA = headA;
curB = headB;
}
// curA和curB若最后指向nullptr,是同步的,所以只用判断一个即可
while (curA != nullptr && curA != curB) {
curA = curA->next;
curB = curB->next;
}
// 若为空,说明没有相交结点;不为空,curA、curB指的是同一结点,随便返回一个
if (curA == nullptr)
return nullptr;
else
return curA;
}
};
// 时间复杂度:O(m+n) 两个链表,各遍历了两次
// 空间复杂度:O(1)
以上是我自己写的,代码随想录参考代码更简洁一些些
- 最后的循环不用有 && curA != curB
- 并且可以直接return curA 因为curA和curB无论是不是nullptr都是同步的
但是下面这个方法更简洁
数学关系,遍历两次判等
/**
* 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* curA = headA;
ListNode* curB = headB;
while (curA != curB) {
curA = curA == nullptr ? headB : curA->next; // max= a>b ? a : b;
curB = curB == nullptr ? headA : curB->next;
}
return curA; // 最后curA,curB要么一起指向同一个结点,要么一起指向nullptr,一定是同步的
}
};
// 时间复杂度:O(m+n) 最坏情况,两个链表,各遍历了两次,也没找到
// 空间复杂度:O(1)
2.8 142.环形链表Ⅱ
同样推荐视频:睡不醒的鲤鱼
思路:双指针法
假设slow
每次走1步,fast
每次走2步
若有环,fast
先入环,slow
后入环,最终在一点相遇,分析各自的路程得下图和等式
fast
:x+y+n(y+z)
slow
:2(x+y)
;因为fast
的速度是slow
的2倍
化简等式后,得 slow
从表头出发,fast
从相遇结点出发,都以1步的速度出发,将在环的入口结点相遇
最终得到等式x=z
代码实现
/**
* 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* slow = head;
ListNode* fast = head;
// 先找到相遇结点
while (fast && fast->next) { // fast head为空,直接退出;fast->next为空,则无环
fast = fast->next->next; // fast走两步
slow = slow->next; // slow走一步
if (fast == slow) { // 此时fast处于相遇结点上,fast从相遇结点出发
slow = head; // slow从表头出发
while (slow != fast) { // 本循环结束,即找到环的入口结点
slow = slow->next;
fast = fast->next;
}
return slow;
}
}
return nullptr; // 没找到返回空
}
};
// 时间复杂度:O(n) slow在找相遇结点和环的入口结点的时都不会超过链表长度n,所以总体为O(n)
// 空间复杂度:O(1)
遇到的问题
在找环的入口结点时,
slow
和fast
要以相同的速度前进,而不是原先的速度
我第1次写的时候是以原本的速度,slow
1,fast
2,我模拟了一下,这样只会让它们依然在相遇结点再次相遇,而不是在环的入口结点相遇
从路程上分析也是,slow
仍然走的是x+y
的距离,fast
仍然走的是z+n(y+z)
的距离,和他俩从起点出发得到的等式是一样的,所以只会在相遇结点再次相遇
所以只有slow
和fast
要以相同的速度前进,才能找到环的入口结点