一、链表基本知识
学习的主要内容来自:代码随想录
链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null,即空指针。
链表主要有三个主要的类型:单链表、双链表和循环链表,他们的主要区别在于每一个链表的节点所存储的指针域不同,在不同的场景也有不一样的作用,比如双向链表可以用于双向的查询,而循环链表用来解决约瑟夫环问题(这个不明白,后续补全)。
这里重点了解一下链表的存储方式:
区别于数组在内存空间中连续地进行存储,链表的节点在内存上并不是连续分布的,而其分配的机制取决于操作系统的内存管理,在我的理解中,链表的存在是为了弥补数组在插入和删除数据时的缺点,因为链表的插入和删除操作可以做到O(1)的时间复杂度,但是这样也同样给链表在访问元素的时候造成困难,查找元素的开销的时间复杂度是O(n)。
这里给出他们的性能分析:
这也导致他们的应用场景的区别:
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。
二、每日打卡题目
2.1 移除链表元素
这个题目的 leetcode 编号是 203,题目要求的是删除一个单链表中满足目标值的所有链表节点,并返回这个链表的头部,根据链表数据结构的存储特点,可以有的一个大致的思路是遍历链表,在遇到节点等于目标值的时候,则将上一个节点的指针域直接指向下一个节点即可,这样的话,需要维护两个指针的节点来进行链表的遍历。
自己尝试了一下,代码如下:
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* pre = new ListNode(-1);
ListNode* newHead = pre; // 拷贝一个返回值
pre->next = head;
while(head){
if(head->val == val){
pre->next = head->next;
head = head->next;
continue;
}
pre = pre->next;
head = head->next;
}
return newHead->next;
}
};
整体思路是声明双指针,其中一个是虚拟的头结点,接着用传入函数的 head 对整个链表进行遍历,当遇到等于目标值的情况时,让虚拟节点 pre 指向 head 的下一个节点,并把 head 向后移动,这里有一点值得注意的是,因为存在一种情况就是删除的节点在链表中是连续的,那么此时解决的办法就是设置一个中断,也就是不需要对 pre 节点本身做任何的移动,而是不断改变其 next 所指向的位置,直到指向了正确的节点再进行 pre 的移动,这是因为有可能此时的 pre->next == val,如果进行了移动就会出现错误。
不过感觉这个思路还是有点绕,主要是因为在遍历的时候,我是以中间节点作为外层 while 循环的依据,但其实最好是用第一个节点,因为这样就不用考虑出现 head->next 和 pre->next 重叠的情况,也就不需要使用 continue 的方式进行中断,这也是 随想录的思想,这里重新写了一下:
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummy_head = new ListNode(-1);
dummy_head->next = head;
ListNode* newHead = dummy_head;
while(dummy_head->next){
if(dummy_head->next->val == val){
dummy_head->next = dummy_head->next->next;
}else dummy_head = dummy_head->next;
}
return newHead->next;
}
};
然后是随想录给出的代码:
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点
dummyHead->next = head; // 将虚拟头结点指向head,这样方便后面做删除操作
ListNode* cur = dummyHead;
while (cur->next != NULL) {
if(cur->next->val == val) {
ListNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
} else {
cur = cur->next;
}
}
head = dummyHead->next;
delete dummyHead;
return head;
}
};
这里值得注意的是,随想录的代码里面引入了 cur ,并最后对 dummy_head 的内存进行了释放,还有过程中的 tmp 的引入,同样是对内存进行了释放,这是两个比较细节的问题,值得注意。
最后自己也使用递归的思想进行思考,这时候会发现实际上做的事情就是不断将链表中的节点压入进栈,然后在最终递归的时候进行判断和返回,感觉思路类似于二叉树的后续遍历:
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
if(head == nullptr) return nullptr;
head->next = removeElements(head->next,val);
if(head->val != val) return head;
else return head->next;
}
};
这样情况的思考主要是在最后函数出栈的过程中,不断地进行判断是否为目标节点,然后进行不同的拼接。
感想:
这里之所以要使用虚拟的头结点,是因为对于删除这个操作而言,头节点的删除操作和后续节点的删除操作是不一致的,而在实际代码运行和写的过程中,比较值得注意的是内存的释放,以及 else 和 continue 的使用,这感觉都是比较细节容易出错的地方。
2.2 设计链表
题目链接:. - 力扣(LeetCode)
这个题目本书不难,但是因为出现错误以后不是很好 debug, 导致做的过程有一点困难,最终写出了,但是运行的时间特别长,因此这里我不展示整个代码,而是把自己写的代码和随想录给出的参考作为对比,找到自己代码可以在时间复杂度上优化的点,我在这里把代码拆成很多部分:
首先是类的 private 部分和构造函数:
ListNode* prehead;
int nums = 0; // 记录总数
MyLinkedList(){
prehead = new ListNode(1001);
}
这里声明的思路没有问题,不过值得注意的是,构造函数的参数列表为空,因而可以当做初始化的是一个虚拟的头结点,另外 self->nums 这个变量是为了在后面带有索引的内容里,方便判断是否越界。
对于第一个函数:
// 这是我写的版本
int get(int index) {
// cout << "before get:" <<endl;
printList();
if(index >= nums) return -1;
int cur = 0;
ListNode* tmp = prehead->next;
while(tmp){
if(cur == index){
return tmp->val;
}
cur++;
tmp = tmp->next;
}
return -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;
}
这里我额外使用了一个变量,可以从随想的代码里面看出,这个步骤其实可以直接由索引递减来实现,注意到的是 --index 会导致死循环的原因是是如果 index 为0,那么 while 循环就会一直执行下去,这里 index-- 的话,实际上是会先执行循环体再将 index - 1,因而这里实际就保证了执行 cur = cur->next 的次数是始终比 index 要多一次的。
接着是 addAtHead 函数:
// 我的
void addAtHead(int val) {
// cout << "-----------" <<endl;
nums++;
if(prehead->next == nullptr){
ListNode* newnode = new ListNode(val);
prehead->next = newnode;
}else{
ListNode* newnode = new ListNode(val);
ListNode* tmp = prehead->next;
prehead->next = newnode;
newnode->next = tmp;
}
}
// 随想录
void addAtHead(int val) {
LinkedNode* newNode = new LinkedNode(val);
newNode->next = _dummyHead->next;
_dummyHead->next = newNode;
_size++;
}
这里稍微臃肿了一点,其实本质的原因是就算指针本身是空的,其也是可以赋值的,参考 newNode->next = _dummyHead->next; 我本身的考虑是,如果虚拟节点后面的内容是空的,那么需要区分,其实是不用的,并且也是不用借助中间的指针的,可以把赋值的顺序重新进行就可以免去一大部分空间了,也就是我本身的逻辑是: 保留头节点的后一个节点,然后将头结点的下一个节点指向新节点,再接着把新节点下一个指向所保留的节点。但是其实由于链表的存储是包括后续所有的内存节点信息的,因而可以直接把新节点的下一个指向虚拟节点的下一个,再把虚拟节点的下一个指向新节点就可以了。
void addAtTail(int val) {
// cout << "-----------" <<endl;
nums++;
if(prehead->next == nullptr){
ListNode* newnode = new ListNode(val);
prehead->next = newnode;
}else{
ListNode* tmp = prehead->next;
while(tmp->next != nullptr) tmp = tmp->next;
ListNode* newnode = new ListNode(val);
tmp->next = newnode;
}
}
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) {
// cout << "-----------" <<endl;
// 这是一个错误
// 第一个容易出的错误是这样容易忘了加1,否则如果添加到的是结尾,则会添加失败
nums++;
if(index >= nums){
nums--; // 第二个注意的就是这里如果出错了需要减
return;
}
int cur = -1;
ListNode* tmp = prehead;
// // 处理链表为空的情况
// if(tmp->next == nullptr && index == 0){
// ListNode* newnode = new ListNode(val);
// prehead->next = newnode;
// return;
// }
while(true){
if(cur + 1 == index) break;
cur++;
tmp = tmp->next;
}
ListNode* tmp_ = tmp->next;
ListNode* newnode = new ListNode(val);
tmp->next = newnode;
newnode->next = tmp_;
}
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) {
// cout << "-----------" <<endl;
// cout << "debug" <<endl;
// cout << "index = " << index << " nums = " << nums << endl;
printList();
if(index >= nums) return;
int cur = -1;
ListNode* tmp = prehead;
while(true){
if(cur + 1 == index) break;
cur++;
tmp = tmp->next;
}
ListNode* tmp_ = tmp->next;
ListNode* tmp__ = tmp->next->next;
tmp->next = tmp__;
delete tmp_;
nums--; // 错误3 忘了减了
}
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--;
}
逻辑没问题,依然是使用的变量上可以优化。
感想:
这个题目确实是考察的比较全面,不过存在很多细节的内容在第一次没有理清楚,导致代码写的很臃肿,而且在这个题目中我发现,对于 leetcode 代码来说,如果最后提交的代码时间超时了,但是自己认为逻辑没有问题,可以试着看一下是不是自己存在很多 cout 语句用来 debug,这个确实是会影响我最后的结果,我带着部分 cout 语句,导致了题目超时,注释掉了以后代码就顺利通过了。
2.3 反转链表
leetcode 的题目链接:. - 力扣(LeetCode)
这个题目老熟人了,好像遇到过 2 次,直接开干:
// 迭代的方法
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if(head == nullptr) return head;
ListNode* cur = head;
ListNode* pre = nullptr;
// while(cur->next != nullptr){
while(cur != nullptr){
ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
};
// 递归
class Solution {
public:
ListNode* reverseList(ListNode* head){
if(!head) return head;
if(head->next == nullptr) return head;
ListNode* next = head->next;
ListNode* res = reverseList(head->next);
next->next = head;
head->next = nullptr; // 处理结尾的情况,也为了避免成环
return res;
}
};
感想:
其实两种思路的本质都差不多,即使是递归,我认为其本质也是双指针,只是做这件事的方向不一样,迭代是正向不断改变索引的所以,而递归类似一个堆栈,从尾部开始先前遍历。