今日任务(2024/05/13)
本来是2024/05/10的任务,前些天太忙啦,今天补起来~
- 链表理论基础
- 203.移除链表元素
- 707.设计链表
- 206.翻转链表
链表理论基础
题目建议:
- 了解一下链接基础,以及链表和数组的区别
链表的类型
特点 | 图示 | |
---|---|---|
单链表 | 只能向前查询 | |
双链表 | 双向查询 | |
循环链表 | 首尾相连(解决约瑟夫环问题) |
链表的存储方式
- 数组:在内存中连续分布
- 链表:在内存中可不连续分布(散乱分布)
链表的定义
这部分需要会自己手写,这里需要掌握单链表的手写,双链表在此基础上修改指针域即可。
构造函数可以选择是否写上:
- 构造函数1:可以初始化
val
- 构造函数2:可以初始化
val
、next
- 默认构造函数:不会初始化任何成员变量
struct ListNode {
int val;
ListNode* next;
// 默认构造函数是不需要传参的:ListNode * head = new ListNode();
ListNode(int x) : val(x), next(nullptr) {} // 构造函数1
ListNode(int x, ListNode* next) : val(x), next(next) {} // 构造函数2
}
链表基本操作
链表基本操作中,注意区分头节点和中间节点,他们的操作略有不同(但是可以通过***设置虚拟头节点来统一两种情况!***这个后面再说),中间节点的基本操作如下:
删除节点
如图删除节点D,可以直接将节点C的next
指针指向节点E,但是注意:
- 在C++里节点D仍然留在内存中,因此需要手动释放节点D的这块内存;
- Java、Python等语言有自己的内存回收机制,无需手动释放
添加节点
增删都是
O
(
1
)
O(1)
O(1)操作,但是要找到需要操作的位置需要从头节点开始查找,这个查找过程是
O
(
n
)
O(n)
O(n)操作哦~
链表和数组的区别
特性 | 数组 | 链表 |
---|---|---|
内存分配 | 连续内存空间 | 非连续内存空间,每个元素(节点)独立分配 |
大小固定性 | 一旦创建,大小固定 | 大小动态变化,可随时插入或删除节点 |
访问方式 | 通过索引随机访问,速度快 O ( 1 ) O(1) O(1) | 只能顺序访问,速度较慢 O ( n ) O(n) O(n) |
插入和删除 | 需要移动元素,可能效率较低 O ( n ) O(n) O(n) | 只需改变指针,效率高 O ( 1 ) O(1) O(1) |
内存浪费 | 可能预留了未使用的空间 | 由于指针的存在,可能占用更多内存 |
使用场景 | 适用于大小已知且不经常变动的数据集合 | 适用于频繁插入和删除的场景 |
声明方式 | 指定大小和类型 | 使用指针和动态内存分配 |
内存管理 | 静态或自动管理 | 手动管理,需释放不再使用的节点 |
扩展性 | 不易扩展 | 易于扩展 |
时间复杂度 | 访问:O(1),插入和删除:O(n) | 访问:O(n),插入和删除:O(1) |
空间复杂度 | O(n),其中 n 是数组元素数量 | O(n),其中 n 是链表节点数量 |
203.移除链表元素
题目建议:
- 本题最关键是要理解虚拟头结点的使用技巧,这个对链表题目很重要。
给你一个链表的头节点
head
和一个整数val
,请你删除链表中所有满足Node.val == 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 输出:[]
提示:
- 列表中的节点数目在范围
[0, 104]
内1 <= Node.val <= 50
0 <= val <= 50
虚拟头节点法[ 用时: 26 m 48 s ]
思路
要区分头节点的删除和中间节点的删除。
为了统一两种情况的操作,这里设置虚拟头节点dummyHead
,将头节点的操作转化成了中间节点的删除~(很巧妙)后面还需要释放删除节点的内存!
具体步骤如下:
- 设置虚拟头节点
dummyHead
,指向head
- 从虚拟头节点
dummyHead
开始,遍历整条链表(令pre = dummyHead
,要判断是否删除的节点是cur
,下一个节点是temp
)- 发现
cur
需要删除:- 将
pre
的next
指针指向temp
- 释放
cur
内存
- 将
cur
不需要删除:- 将
pre
移动至下一个节点
- 将
- 发现
- 删除后,链表的头节点是
dummyHead->next
代码
注意:
-
关键点1:遍历链表时要注意是否为空(如果要使用多个节点,需要多个判断,比如说在这里判断了
pre
和pre->next
) -
关键点2:释放某节点内存时,还需要避免野指针的产生(可以采用置
nullptr
或 赋新的有效地址的方式)为啥是
nullptr
?-
一般C++中
NULL
和0
可以互换使用,都代表”无“或”空“的概念 -
但是C++不允许将
void*
隐式转换为其他类型,在进行C++重载时会出现混乱(比如说使用foo(NULL)
时会调用foo(int)
)void foo(char*); void foo(int);
-
为避免这种情况,C++11中引入
nullptr
关键字来区分空指针和0,nullptr
是NULL
的强类型等价物,且类型安全(推荐使用!!!)
-
-
关键点3:在删除某节点后,不能立马移动
pre
指针,因为每个循环都需要判断pre->next
(也就是cur
)是否需要删除,这里已经删除了先前的cur
,而新的pre->next
还未做判断,因此不能移动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* removeElements(ListNode* head, int val) {
// 虚拟头节点,指向head
ListNode* dummyHead = new ListNode(-1, head);
ListNode* pre = dummyHead;
// 关键点1:链表遍历
while (pre != nullptr && pre->next != nullptr) {
ListNode* cur = pre->next;
ListNode* temp = cur->next;
if (cur->val == val) { // 情况1:当前cur要删除
pre->next = temp; // 调整指针指向
// 关键点2:cur内存释放
delete cur; // 释放节点内存
// 此时cur成为野指针,仍指向已被释放的内存
cur = nullptr; // 避免野指针:置nullptr 或 赋新的有效地址
// 关键点3:为何此时不移动pre?
} else { // 情况2:当前cur不用删除
pre = pre->next; // pre移动到下一节点
}
}
return dummyHead->next;
}
};
-
时间复杂度: O ( n ) O(n) O(n)
-
空间复杂度: O ( 1 ) O(1) O(1)
Carl思路
Carl使用了两种方法去删除节点:
- 直接使用原来的链表来进行删除操作
- 直接使用原来的链表来进行删除操作再进行删除操作
并且关于第一种直接操作的方法里,给出了明确的2种情况的代码,可以很直观地看到虚拟头节点法的机智~
// 删除头结点
while (head != NULL && head->val == val) { // 注意这里不是if
ListNode* tmp = head;
head = head->next;
delete tmp;
}
// 删除非头结点
ListNode* cur = head;
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;
}
}
707.设计链表
题目建议:
- 这是一道考察 链表综合操作的题目,不算容易,可以练一练 使用虚拟头结点
你可以选择使用单链表或者双链表,设计并实现自己的链表。
单链表中的节点应该具备两个属性:
val
和next
。val
是当前节点的值,next
是指向下一个节点的指针/引用。如果是双向链表,则还需要属性
prev
以指示链表中的上一个节点。假设链表中的所有节点下标从 0 开始。实现
MyLinkedList
类:
MyLinkedList()
初始化MyLinkedList
对象。int get(int index)
获取链表中下标为index
的节点的值。如果下标无效,则返回-1
。void addAtHead(int val)
将一个值为val
的节点插入到链表中第一个元素之前。在插入完成后,新节点会成为链表的第一个节点。void addAtTail(int val)
将一个值为val
的节点追加到链表中作为链表的最后一个元素。void addAtIndex(int index, int val)
将一个值为val
的节点插入到链表中下标为index
的节点之前。如果index
等于链表的长度,那么该节点会被追加到链表的末尾。如果index
比长度更大,该节点将 不会插入 到链表中。void deleteAtIndex(int index)
如果下标有效,则删除链表中下标为index
的节点。示例:
输入 ["MyLinkedList", "addAtHead", "addAtTail", "addAtIndex", "get", "deleteAtIndex", "get"] [[], [1], [3], [1, 2], [1], [1], [1]] 输出 [null, null, null, null, 2, null, 3] 解释 MyLinkedList myLinkedList = new MyLinkedList(); myLinkedList.addAtHead(1); myLinkedList.addAtTail(3); myLinkedList.addAtIndex(1, 2); // 链表变为 1->2->3 myLinkedList.get(1); // 返回 2 myLinkedList.deleteAtIndex(1); // 现在,链表变为 1->3 myLinkedList.get(1); // 返回 3
提示:
0 <= index, val <= 1000
- 请不要使用内置的 LinkedList 库。
- 调用
get
、addAtHead
、addAtTail
、addAtIndex
和deleteAtIndex
的次数不超过2000
。
我的思路 [ 用时: 37 m 7 s ]
思路
本题需要实现获取、插入(头插/尾插/中间插)、删除节点的操作,用虚拟头节点法好处多多:
- 在删除节点时统一了删除头节点、删除中间节点两种情况,代码会更简洁
- 在插入节点时统一了头插/尾插/中间插三种情况,头插/尾插可以直接调用中间插的函数
注意:下标index
是从0
开始的~
代码
小小一道题目,做了很久,各种报错,有很多需要注意的点:
-
注意项1:要在构造函数中进行动态内存分配
- 对于使用动态内存分配的
_dummyHead
,需要在构造函数中进行分配,而不是在类的作用域内!!!因为这样做会导致未定义行为(undefined behavior, UB)
// 错误示范(在类的作用域内分配动态内存) private: MyLinkedList *_dummyHead = new MyLinkedList(-1); ...
- 对于使用动态内存分配的
-
注意项2:使用析构函数释放所有动态分配的对象占用的内存
- 对于非指针类型的成员变量,如内置数据类型(
int
,float
,double
等),基本不需要在析构函数中进行特别的释放操作,因为它们的生命周期仅限于对象本身,当对象被销毁时,它们占用的内存会自动释放 - 对于指针类型的成员变量,如果它们指向了动态分配的内存(即通过
new
分配的内存),则需要在析构函数中显式地释放这些内存,以避免内存泄漏。
- 对于非指针类型的成员变量,如内置数据类型(
-
注意项3:类的私有成员变量不能在类定义中直接初始化,除非它们是常量或者静态成员变量
静态成员变量属于类本身,而不是类的任何对象。这意味着无论创建多少个类的实例,静态成员变量都只有一个副本,所有实例都共享这个变量
class MyLinkedList {
public:
// 注意项1:要在构造函数中进行动态内存分配
MyLinkedList(): val(0), next(nullptr), _size(0), _dummyHead(new MyLinkedList(-1)) {}
MyLinkedList(int val): val(val), next(nullptr), _size(1), _dummyHead(nullptr){}
MyLinkedList(int val, MyLinkedList* next): val(val), next(next), _size(1), _dummyHead(nullptr){}
// 注意项2:使用析构函数释放所有动态分配的对象
~MyLinkedList() {
while (_dummyHead != nullptr && _dummyHead->next != nullptr) {
MyLinkedList* temp = _dummyHead->next;
_dummyHead = temp->next;
delete temp;
temp = nullptr;
}
delete _dummyHead;
_dummyHead = nullptr;
}
int get(int index) {
// 下标无效
if (index < 0 || index >= _size) return -1;
// 搜索节点
MyLinkedList* node = _dummyHead->next;
while (index--) {
node = node->next;
}
return node->val;
}
void addAtHead(int val) {
// 相当于插入到0节点之前
addAtIndex(0, val);
}
void addAtTail(int val) {
// 相当于插入到_size节点之前
addAtIndex(_size, val);
}
void addAtIndex(int index, int val) {
// 下标无效
if (index < 0 || index > _size) return;
// 搜索到链表下标为index - 1的节点pre(node的前一个节点)
MyLinkedList* pre = _dummyHead;
while (index--) {
pre = pre->next;
}
// 要插入的节点
MyLinkedList* node = new MyLinkedList(val, pre->next);
pre->next = node;
// 更新链表容量
_size++;
}
void deleteAtIndex(int index) {
// 下标无效
if (index < 0 || index >= _size) return;
MyLinkedList* pre = _dummyHead;
while (index--) {
pre = pre->next;
}
// 需要删除的是pre->next
MyLinkedList* temp = pre->next;
pre->next = temp->next;
delete temp;
temp = nullptr;
// 更新链表容量
_size--;
}
private:
// 注意项3:类的私有成员变量不能在类定义中直接初始化,除非它们是常量或者静态成员变量
int val;
MyLinkedList* next;
MyLinkedList *_dummyHead;
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);
*/
分析不足
- 关于是否能够初始化,以及析构函数的内存释放已经把我彻底绕晕了,浪费了太久太久的时间!!!
Carl思路
206.翻转链表
题目建议:
- 这个非常容易考,但是又很容易错,经常今天悟了,明天就误了(@_@😉
- 真的很离谱,不知道为啥总是不能老老实实地呆在我的脑子里!!!
给你单链表的头节点
head
,请你反转链表,并返回反转后的链表。
示例 1:
输入:head = [1,2,3,4,5] 输出:[5,4,3,2,1]
示例 2:
输入:head = [1,2] 输出:[2,1]
示例 3:
输入:head = [] 输出:[]
提示:
- 链表中节点的数目范围是
[0, 5000]
-5000 <= Node.val <= 5000
**进阶:**链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?
改变箭头方向 [ 用时: 14 m 21 s ]
思路
- 初始化
pre = nullptr
,cur = head
- 遍历链表,逐个翻转箭头:
- 定义
temp = cur->next
- 让
cur->next
指向pre
- 更新节点:
pre = cur
cur = temp
- 定义
- 翻转后的头节点也就是之前的尾节点——遍历之后的
pre
代码
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode *pre = nullptr, *cur = head;
while (cur != nullptr) {
ListNode* temp = cur->next;
cur->next = pre;
// 更新节点
pre = cur;
cur = temp;
}
return pre;
}
};
-
时间复杂度: O ( n ) O(n) O(n)
-
空间复杂度: O ( 1 ) O(1) O(1)
挨个拎人站第一 [ 用时: 23 m 2 s ]
我又开始瞎起名啦
思路
这个其实有些像递归,逐个遍历链表,从第二个人开始,我们每次都对ta说“你,站第一个去”,所以相当于不断让面前这个人离开原位置 并 添加到队首去。每次递归的内容就是:拎一个人站到第一位。
根据上面的思路,我们要做一个递归函数,需要知道什么呢?
- 原来站第一的人,也就是现在的头节点
first
- 现在面前的人,也就是我们要拎走的人
cur
cur
前面的一个人pre
、后面一个人temp
:因为我们要在cur
走了之后恢复这里的队伍链接
知道这些后可以开始处理啦:
- 保存
cur
后一个人temp = cur->next
- 让
cur
站到第一个:cur->next = first
- 恢复
cur
处的链接:pre->next = temp
- 更新目前的位置信息:
first = cur
cur = temp
pre = pre
(没错,pre
没有变动哦,也就是说pre
一直指向同一个人——原来的第一名head
!)
代码
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode *first = head;
ListNode *pre = head;
ListNode *cur = head->next;
while (cur) {
ListNode *temp = cur->next; // 保存cur后一个人
cur->next = first; // 让cur站到第一个
pre->next = temp; // 恢复cur处的链接
// 更新位置信息
first = cur;
cur = temp;
}
return first;
}
};
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度: O ( 1 ) O(1) O(1)
Carl思路
Carl这里提到了两种方法:
-
第一种方法和我的第一种方法一样——双指针法
-
第二种方法是递归法,也就是将双指针法中
while
循环的内容提取出来作为递归函数/** * 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 { private: ListNode* reverse(ListNode* pre, ListNode* cur) { // 注意:当最后一个cur为空指针时,说明已经完成翻转 if (cur == nullptr) return pre; ListNode* temp = cur->next; cur->next = pre; return reverse(cur, temp); } public: ListNode* reverseList(ListNode* head) { return reverse(nullptr, head); } };
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度:
O
(
n
)
O(n)
O(n)
- 递归函数要调用 n n n层栈空间
收获
今天学习的内容都是非常易错的一些题目,链表痛点呀!
- 链表理论基础
- 注意链表的定义、和数组的区别
- 203.移除链表元素
- 初次感受到虚拟头节点的便利!
- 707.设计链表
- 私有成员变量最好别在类定义中初始化,在构造函数中弄吧!(常量和静态成员变量除外)
- 动态内存分配的两个关键点:
- 动态分配内存的成员变量只能在构造函数中初始化
- 动态分配内存的对象得在析构函数中手动释放掉
- 206.翻转链表
- 两种双指针法:
- 方法1:直接改变指针朝向
- 方法2:拎个人站第一
- 递归法:改造方法1
- 两种双指针法:
- 本文部分图片来自代码随想录;
- 若存在侵权,烦请指出,本人会立马删除相关内容;
- 本文内容若有不正确或不规范指出,请大家不吝赐教~