链表简介
我们知道,如果申请一块儿连续的存储来储存数据的话,一旦遇到插入和删除操作,这个时候后续所有数据都需要向前或者向后移动,这种时间上的开销甚至可能达到O(N)。
所以我们想到用不连续的存储,即用链表来存储,每个链表节点中,含有两部分,第一部分是值部分,第二部分是指针部分,通常记作NEXT指针,NEXT指针指向下一个节点的的位置。如果其是最后一个节点,NEXT则指向NULL。
我们来看一下leetcode默认的链表表示方法:
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {
}
};
这是一种结构体,类型名为ListNode,其内包含一个值 val,和一个ListNode类型的指针NEXT,指向下一个节点。再往下就是其构造函数。
节点的插入与删除
- 删除:比如删除第三个元素,我们首先需要修改第二个元素的指针,使其指向第四个元素,然后回收第三个元素的内存。但是我们要注意一下这些问题:如果某一个元素本身不存在或者存在很多个怎么办?如果这个要被删除的元素在第一个或者最后一个怎么办?
- 插入:比如在23之间插入,使用malloc调用从系统中得到一个新的单元,然后调整元素2指向要插入的元素,要插入的元素再指向元素3即可。同理,我们要注意如果这个位置在第一个和最后一个怎么办?
出现的问题
- 表的起始端进行插入或者删除的时候,是一种很特殊的情况,因为其改变了表起始的节点
- 对当前节点进行疏忽的操作会造成表的丢失,比如内存或指针出错。
解决方法:
1.哑节点(虚拟节点)/表头:
我们在第一个位置之前(即位置0)多留一个节点,使其指向当前链表的头节点。这样即使我们删除了整个链表,表头这个节点仍然存在。
2.尽量处理当前节点的下一个节点,而非当前节点本身
这个想法和我们做dp动态规划的时候指针标识相似,我们回忆一下,我们做dp的时候,大多时候dp【0】都是一个没有实际用处却需要初始化的对象,因为我们理想的从第一个数开始处理,但是创建的数组或者vector却是从0开始的。所以很多人在写dp的时候,会如下这样写,每次处理上一个节点。
for(int i=1...)
{
dp[i-1]....
}
同样的,我们在进行链表遍历的时候,每次可以对i+1进行操作。
链表的基本操作:
一.链表翻转——206. 反转链表
链表翻转最主要的内容就是改变指针指向,
注意
如果他本身就是张空表,我们直接返回即可。
非递归写法:
我们需要两个指针,一个指向当前节点prev,一个指向下一个节点next。
每次先获取当前节点的next,把他记录下来(为什么要先做这一步,你可以想象一下, 如果我们成功的把某一根箭头反转了,那么原本下一个位置依靠于当前节点的next,如果我们改变了)
主要步骤:初始化,改向,右移,初始化
- 告诉当前节点(head)pre在哪
- 开始循环,先保存下一个节点next,避免翻转后丢失下一个节点的地址
- 改向,修改head->next
- 为下一次更改做准备,先告诉其pre在哪,再把当前节点右移(即next变成了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* reverseList(ListNode* head)
{
//head表示当前节点,pre表示上一个节点,next表示下一个节点
if(!head) return head;//head为表头的指针,指向第一个节点,如果其为null(即0),那么这个表只有一个表头(空表),我们返回即可
ListNode *pre,*next;
pre=nullptr;//为什么我们要初始化pre,因为第一个节点的pre翻转过后就是最后一个节点的指向,即nullptr
while(head)
{
next=head->next;//next先把下一个地址存下来,避免改向后丢失
head->next=pre;//更改当前节点的next指针指向前一个元素(或者空)
//现在改向已经完成,接下来右移即可,即接下来需要初始化下一个节点的pre和下一个节点自己在哪
pre=head;//下一个节点的pre就是当前的节点head
head=next;//注意不能先改head,如果先改head,下一个节点必然找不到当前节点next了
}
//注意,我们此时已经翻转了链表,需要返回的是表头的指针。即最后一次循环的pre
return pre;
}
};
递归写法
递归写法在函数参数中得先告诉其pre为多少
ListNode* reverseList(ListNode *head,ListNode *pre=nullptr)
{
//递归写法先考虑循环结束时条件,即head指向了空
if(!head) return pre;
//每次记录下个节点,改向
ListNode *next=head->next;
head->next=pre;
return reverseList(next,head);//告诉下一个节点pre是当前的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* reverseList(ListNode* head)
{
if(head==nullptr ||head->next==nullptr)//如果head本身是空或者是尾部节点,返回
{
return head;
}
ListNode* newhead=reverseList(head->next);//一直递归到5返回,即在第4层的时候,得到了第5层返回回来的新的头节点,注意当前是第4层
head->next->next=head;
head->next=nullptr;
return newhead;
}
};
二.链表合并——21. 合并两个有序链表
创建两个指针dummy和node,两个开始都指向表头,最后dummy在表头,node指在表尾。
思路时分两个阶段,
第一阶段l1和l2任意一个都没走完。
第二阶段走完了其中一个。
第一阶段只需要比较每次l1和l2的大小,然后谁小就让node-next指向谁,随后node和小的都后移。知道某一条走完了。
这时比如说l1走完了,那么l1此时必定指向空,此时我们只需要让node->next指向非空的那一个就行,l1?判定为0,所以我们选l2.
非递归写法
/**
* 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* mergeTwoLists(ListNode* l1, ListNode* l2)
{
ListNode *dummy=new ListNode(),*node=dummy;//dummy作为表头,node作为最后一个节点
while(l1 &&l2)//先并完某一条
{
if(l1->val <= l2->val)
{
node->next=l1;
l1=l1->next;
}
else
{
node->next=l2;
l2=l2->next;
}
node=node->next;
}
node->next=l1 ? l1:l2;//剩下的指向哪个就接着指哪个
return dummy->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 *nex