参考文章:
力扣加加-链表专题
一、链表简介
各种数据结构,不管是队列,栈等线性数据结构还是树,图的等非线性数据结构,从根本上底层都是数组和链表。不管你用的是数组还是链表,用的都是计算机内存,物理内存是一个个大小相同的内存单元构成的,如图:
而数组和链表里的数据虽然用的都是物理内存,都是两者在对物理内存的使用上是非常不一样的,如图:
数组是连续的内存空间,通常每一个单位的大小也是固定的,因此可以按下标随机访问。而链表则不一定连续,因此其查找只能依靠别的方式,一般我们是通过一个叫 next 指针来遍历查找。链表其实就是一个类。比如一个可能的单链表的定义可以是:
public class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
val是数据域,用来存放数据,next 则是指向下一个节点的具体对象。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。
从上面的物理结构图可以看出数组是一块连续的空间,数组的每一项都是紧密相连的,因此如果要执行插入和删除操作就很麻烦。对数组头部的插入和删除时间复杂度都是
O
(
N
)
O(N)
O(N),只有对尾部的插入和删除才是
O
(
1
)
O(1)
O(1),通过计算平均复杂度也是
O
(
N
)
O(N)
O(N)。简单来说“数组对查询特别友好,对删除和添加不友好”。为了解决这个问题,就有了链表这种数据结构。链表适合数据有一定的顺序,并且又需要进行频繁增、删的场景。一个典型的链表逻辑结构图如下:
普通链表只有一个后驱节点 next,如果是双向链表还会有一个前驱节点 pre。(实际上链表就是特殊的树,即一叉树)
二、链表基操
1.插入
插入只需要考虑要插入位置前驱节点和后继节点(双向链表的情况下需要更新后继节点)即可,其他节点不受影响。因此在给定指针的情况下插入操作的时间复杂度为O(1)。这里给定指针中的指针指的是插入位置的前驱节点。
temp = 待插入位置的前驱节点.next
待插入位置的前驱节点.next = 待插入指针
待插入指针.next = temp
如果没有给定指针,我们需要先遍历找到节点,因此最坏情况下时间复杂度为 O(N)。
注意: 考虑头尾指针插入的情况。
2.删除
只需要将需要删除的节点的前驱指针的 next 指针修正为下下个节点即可,注意考虑边界条件。
待删除位置的前驱节点.next = 待删除位置的前驱节点.next.next
注意: 考虑头尾指针插入的情况。
3.遍历
遍历比较简单,遍历的伪代码:
当前指针 = 头指针
while(当前节点不为空){
print(当前节点)
当前指针 = 当前指针.next
}
一个前序遍历的递归的伪代码:
dfs(cur) {
if 当前节点为空 return
print(cur.val)
return dfs(cur.next)
}
4.链表和数组到底有多大的差异?
数组和链表同样作为线性的数组结构,二者在很多方面都是相同的,只是在细微的操作和使用场景上有差异而已,而使用场景,很难在题目中直接考察。因此,对于我们做题来说,二者的差异通常就只是细微的操作差异。
给大家举几个例子,感受一下细微操作的差异。
数组的遍历:
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
链表的遍历:
for (ListNode cur = head; cur != null; cur = cur.next) {
System.out.println((cur.val));
}
从中可以看出二者逻辑是一致的,只不过细微操作不一样。数组是索引 ++,而链表是 cur = cur.next。
总结一下: 数组和链表逻辑上二者有很多相似之处,不同的只是一些使用场景和操作细节,对于做题来说,我们通常更关注的是操作细节。关于细节,接下来给大家介绍,这一小节主要让大家知道二者在思想和逻辑的神相似。
(有些小伙伴做链表题先把链表换成数组,然后用数组做,本人不推荐这种做法,这等于是否认了链表存在的价值,小朋友不要模仿)
三、链表做题技巧
给大家准备了一个口诀,一个原则,两种考点,三个注意,四个技巧,让你轻松搞定链表题,再也不怕手撕链表。 我们依次来看下这个口诀的内容。
1.一个原则
一个原则就是画图,尤其是对于新手来说。不管是简单题还是难题一定要画图,这是贯穿链表题目的一个准则。
画图可以减少我们的认知负担,这其实和打草稿,备忘录道理是一样的,将存在脑子里的东西放到纸上。举一个不太恰当的例子就是你的脑子就是 CPU,脑子的记忆就是寄存器。寄存器的容量有限,我们需要把不那么频繁使用的东西放到内存,把寄存器用在真正该用的地方,这个内存就是纸或者电脑平板等一切你可以画图的东西。画的好看不好看都不重要,能看清就行了。用笔随便勾画一下, 能看出关系就够了。
2.两个考点
我把力扣的链表做了个遍。发现一个有趣的现象,那就是链表的考点很单一。除了设计类题目,考点无非就两点:1.指针的修改;2.链表的拼接
指针的修改
其中指针修改最典型的就是链表反转。链表反转本质就是修改指针。对于数组这种支持随机访问的数据结构来说,反转很容易,只需要头尾不断交换即可。
int[] arr = new int[]{1, 2, 3, 4, 5, 6};
int left = 0;
int right = arr.length - 1;
while (left < right) {
int tmp = arr[right];
arr[right] = arr[left];
arr[left] = tmp;
left++;
right--;
}
for (int i : arr) {
System.out.println(i);
}
而对于链表来说,就没那么容易了。leetcode关于反转链表的题简直不要太多了,面试也都问烂了。今天我给大家写了一个最完整的链表反转,以后碰到可以直接用。当然,前提是大家要先理解再去套。这里以leetcode 206题为例。
public ListNode reverseList(ListNode head)
其中 head 指的是需要反转的头节点,其实就是反转整个链表。接下来,我们就来实现它。
首先,我们要做的就是画图。这个在一个原则部分讲过了。
如下图,是我们需要反转的部分链表:
而我们期望反转之后的长这个样子:
不难看出,最终返回 tail 即可。
由于链表的递归性(一叉树),实际上,我们只要反转其中相邻的两个,剩下的采用同样的方法完成即可。
具体操作:
对于两个节点来说,我们只需要修改一次指针即可,这好像不难。
cur.next = pre
就是这一个操作,不仅有了环,让你死循环。还让cur与后面的节点失联。关于节点分道扬镳的这个事情不难解决, 我们只需要在反转前,提起记录好下一个节点即可:
next = cur.next
cur.next = pre
cur = next
失联的问题解决了,那么环的问题呢? 实际上, 环不用解决。因为如果我们是从前往后遍历,那么实际上,前面的链表已经被反转了。
我们可以写出如下代码:
//leetcode 206
public ListNode reverseList(ListNode head) {
if (head == null) return null;
ListNode cur = head;
ListNode preNode = null;
while (cur != null) {
ListNode tmp = cur.next;
cur.next = preNode;
preNode = cur;
cur = tmp;
}
return preNode;
}
链表的拼接
大家有没有发现链表总喜欢穿来穿去(拼接)的?比如反转链表 II,再比如合并有序链表等。为啥链表总喜欢穿来穿去呢?实际上,这就是链表存在的价值,这就是设计它的初衷呀!链表的价值就在于其不必要求物理内存的连续性,以及对插入和删除的友好。因此链表的题目很多都需要拼接的操作,如果上面我讲的链表基本操作你会了,我相信这难不倒你。除了环,边界等特殊情况。 这几个问题我们后面再看。
3.三个注意
链表最容易出错的地方就是我们应该注意的地方。链表最容易出的错 90 % 集中在以下三种情况:
1.出现了环,造成死循环。
2.分不清边界,导致边界条件出错。
3.搞不懂递归怎么做
接下来,我们一一来看。
环
环的考点有两个:
- 题目中就可能有环,让你判断是否有环,以及环的位置。
- 题目链表没环,但是被你操作指针整出环了。
这里我们只讨论第二种,而第一种可以用我们后面提到的快慢指针算法。
避免出现环最简单有效的措施就是画图,如果两个或者几个链表节点构成了环,通过图是很容易看出来的。因此一个简单的实操技巧就是先画图,然后对指针的操作都反应在图中。但是链表那么长,我不可能全部画出来呀。其实完全不用,上面提到了链表是递归的数据结构, 很多链表问题天生具有递归性,比如反转链表,因此仅仅画出一个子结构就可以了。这个知识,我们放在后面的前后序部分讲解。
边界
很多人错的原因,都是没有考虑边界。一个考虑边界的技巧就是看题目信息。
- 如果题目的头节点可能被移除,那么考虑使用虚拟节点,这样头节点就变成了中间节点,就不需要为头节点做特殊判断了。
- 题目让你返回的不是原本的头节点,而是尾部节点或者其他中间节点,这个时候要注意指针的变化。
以上两者部分的具体内容,我们在稍后讲到的虚拟头部分讲解。老规矩,大家留个印象即可。
前后序
ok,是时候填坑了。上面提到了链表结构天生具有递归性,那么使用递归的解法或者递归的思维都会对我们解题有帮助。在二叉树遍历部分,我讲了二叉树的三种流行的遍历方法,分别是前序遍历,中序遍历和后序遍历。前中后序实际上是指的当前节点相对子节点的处理顺序。如果先处理当前节点再处理子节点,那么就是前序。如果先处理左节点,再处理当前节点,最后处理右节点,就是中序遍历。后序遍历自然是最后处理当前节点了。
绝大多数的题目都是单链表,而单链表只有一个后继指针。因此只有前序和后序,没有中序遍历。这两种遍历写法不管是边界,入参,还是代码都不太一样。其实大家只要记住一个很简单的话就好了,那就是如果是前序遍历,那么你可以想象前面的链表都处理好了,怎么处理的不用管。相应地如果是后序遍历,那么你可以想象后面的链表都处理好了,怎么处理的不用管。这句话的正确性也是毋庸置疑。
如下图,是前序遍历的时候,我们应该画的图。大家把注意力集中在中间的框(子结构)就行了,同时注意两点。
- 前面的已经处理好了
- 后面的还没处理好
据此,我们不难写出以下递归代码,代码注释很详细,大家看注释就好了。
public static void reverseListDFS(ListNode head) {
if (head == null) return;
ListNode pre = null;
ListNode tmp = head.next;
head.next = pre;
reverseListDFS(tmp);
}
reverseListDFS(head);
如果是后序遍历呢?老规矩,秉承我们的一个原则,先画图。
不难看出,我们可以通过 head.next 拿到下一个元素,然后将下一个元素的 next 指向自身来完成反转。用代码表示就是:
head.next.next = head
画出图之后,是不是很容易看出图中有一个环?现在知道画图的好处了吧?就是这么直观,当你很熟练了,就不需要画了,但是在此之前,请不要偷懒。因此我们需要将 head.next 改为不会造成环的一个值,比如置空,代码这样也就写好啦。
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) return head;
ListNode cur = reverseListDFS2(head.next);
head.next.next = head;
head.next = null;
return cur;
}
最后在这里给大家插播一个写递归的技巧,那就是想象我们已经处理好了一部分数据,并把他们用手挡起来,但是还有一部分等待处理,接下来思考”如何根据已经处理的数据和当前的数据来推导还没有处理的数据“就行了。
4.四个技巧
针对上面的考点和注意点,我总结了四个技巧来应对,这都是在平时做题中非常实用的技巧。
(1)虚拟头
来了解虚拟头的意义之前,先给大家做几个小测验。
Q1: 如下代码 ans.next 指向什么?
ans = ListNode(1)
ans.next = head
head = head.next
head = head.next
A1: 最开始的 head。
Q2:如下代码 ans.next 指向什么?
ans = ListNode(1)
head = ans
head.next = ListNode(3)
head.next = ListNode(4)
A2: ListNode(4)
似乎也不难,我们继续看一道题。
Q3: 如下代码 ans.next 指向什么?
ans = ListNode(1)
head = ans
head.next = ListNode(3)
head = ListNode(2)
head.next = ListNode(4)
A3: ListNode(3)
如果三道题你都答对了,那么恭喜你,这一部分可以跳过。
如果你没有懂也没关系,我这里简单解释一下你就懂了。
ans.next 指向什么取决于最后切断 ans.next 指向的地方在哪。比如 Q1,ans.next 指向的是 head,我们假设其指向的内存编号为 9527。
之后执行 head = head.next (ans 和 head 被切断联系了),此时的内存图:我们假设头节点的 next 指针指向的节点的内存地址为 10200
不难看出,ans 没变。
对于第二个例子。一开始和上面例子一样,都是指向 9527。而后执行了:
head.next = ListNode(3)
head.next = ListNode(4)
ans 和 head 又同时指向 ListNode(3) 了。如图:
head.next = ListNode(4) 也是同理。因此最终的指向 ans.next 是ListNode(4)。
我们来看最后一个。前半部分和 Q2 是一样的。
ans = ListNode(1)
head = ans
head.next = ListNode(3)
按照上面的分析,此时 head 和 ans 的 next 都指向 ListNode(3)。关键是下面两行:
head = ListNode(2)
head.next = ListNode(4)
指向了 head = ListNode(2) 之后, head 和 ans 的关系就被切断了,当前以及之后所有的 head 操作都不会影响到 ans,因此 ans 还指向被切断前的节点,因此 ans.next 输出的是 ListNode(3)。
花了这么大的篇幅讲这个东西的原因就是,指针操作是链表的核心,如果这些基础不懂, 那么就很难做。
接下来,我们介绍主角 - 虚拟头。
相信做过链表的小伙伴都听过这么个名字。为什么它这么好用?它的作用无非就两个:
- 将头节点变成中间节点,简化判断。
- 通过在合适的时候断开链接,返回链表的中间节点。
我上面提到了链表的三个注意,有一个是边界。头节点是最常见的边界,那如果我们用一个虚拟头指向头节点,虚拟头就是新的头节点了,而虚拟头不是题目给的节点,不参与运算,因此不需要特殊判断,虚拟头就是这个作用。
如果题目需要返回链表中间的某个节点呢?实际上也可借助虚拟节点。由于我上面提到的指针的操作,实际上,你可以新建一个虚拟头,然后让虚拟头在恰当的时候(刚好指向需要返回的节点)断开连接,这样我们就可以返回虚拟头的 next 就 ok 了。25. K 个一组翻转链表 就用到了这个技巧。
快慢指针
判断链表是否有环,以及环的入口都是使用快慢指针即可解决。除了这个,求链表的交点也是快慢指针,算法也是类似的。这都属于不知道就难,知道了就容易。
另外由于链表不支持随机访问,因此如果想要获取数组中间项和倒数第几项等特定元素就需要一些特殊的手段,而这个手段就是快慢指针。比如要找链表中间项就搞两个指针,一个大步走(一次走两步),一个小步走(一次走一步),这样快指针走到头,慢指针刚好在中间。 如果要求链表倒数第 2 个,那就让快指针先走一步,慢指针再走,这样快指针走到头,慢指针刚好在倒数第二个。这个原理不难理解吧?
穿针引线
这是链表的第二个考点 - 拼接链表。我在 25. K 个一组翻转链表,61. 旋转链表 和 92. 反转链表 II 都用了这个方法。穿针引线是我自己起的一个名字,起名字的好处就是方便记忆。这个方法通常不是最优解,但是好理解,方便书写,不易出错,推荐新手用。还是以反转链表为例,只不过这次是反转链表的中间一部分,那我们该怎么做?
反转前面我们已经讲过了,于是我假设链表已经反转好了,那么如何将反转好的链表拼后去呢?
我们想要的效果是这样的:
那怎么达到图上的效果呢?我的做法是从左到右给断点编号。如图有两个断点,共涉及到四个节点。于是我给它们依次编号为 a,b,c,d。
其实 a,d 分别是需要反转的链表部分的前驱和后继(不参与反转),而 b 和 c 是需要反转的部分的头和尾(参与反转)。
因此除了 cur, 多用两个指针 pre 和 next 即可找到 a,b,c,d。
找到后就简单了,直接穿针引线。
我记得的就有 25 题,61 题 和 92 题都是这么做的,大伙可以试试。
// leetcode 25
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || head.next == null){
return head;
}
//定义一个假的节点。
ListNode dummy=new ListNode(0);
//假节点的next指向head。
// dummy->1->2->3->4->5
dummy.next=head;
//初始化pre和end都指向dummy。pre指每次要翻转的链表的头结点的上一个节点。end指每次要翻转的链表的尾节点
ListNode pre=dummy;
ListNode end=dummy;
while(end.next!=null){
//循环k次,找到需要翻转的链表的结尾,这里每次循环要判断end是否等于空,因为如果为空,end.next会报空指针异常。
//dummy->1->2->3->4->5 若k为2,循环2次,end指向2
for(int i=0;i<k&&end != null;i++){
end=end.next;
}
//如果end==null,即需要翻转的链表的节点数小于k,不执行翻转。
if(end==null){
break;
}
//先记录下end.next,方便后面链接链表
ListNode next=end.next;
//然后断开链表
end.next=null;
//记录下要翻转链表的头节点
ListNode start=pre.next;
//翻转链表,pre.next指向翻转后的链表。1->2 变成2->1。 dummy->2->1
pre.next=reverse(start);
//翻转后头节点变到最后。通过.next把断开的链表重新链接。
start.next=next;
//将pre换成下次要翻转的链表的头结点的上一个节点。即start
pre=start;
//翻转结束,将end置为下次要翻转的链表的头结点的上一个节点。即start
end=start;
}
return dummy.next;
}
//链表翻转
// 例子: head: 1->2->3->4
public ListNode reverse(ListNode head) {
//单链表为空或只有一个节点,直接返回原单链表
if (head == null || head.next == null){
return head;
}
//前一个节点指针
ListNode preNode = null;
//当前节点指针
ListNode curNode = head;
//下一个节点指针
ListNode nextNode = null;
while (curNode != null){
nextNode = curNode.next;//nextNode 指向下一个节点,保存当前节点后面的链表。
curNode.next=preNode;//将当前节点next域指向前一个节点 null<-1<-2<-3<-4
preNode = curNode;//preNode 指针向后移动。preNode指向当前节点。
curNode = nextNode;//curNode指针向后移动。下一个节点变成当前节点
}
return preNode;
}
判空
这是四个技巧的最后一个技巧了。虽然是最后讲,但并不意味着它不重要。相反,它的实操价值很大。穿针引线完了之后,代码的总数已经确定了,无非就是看看哪行代码会有空指针异常。我们需要考虑的仅仅是被改变 next 指针的部分。
while(cur){
cur = cur.next
}
这样的代码,我们需要考虑 cur 是否为空呢?很明显不可能,因为 while 条件保证了,因此不需判空。
那如何是这样的代码呢?
while(cur){
next = cur.next
n_next = next.next
}
如上代码有两个 next,第一个不用判空,上面已经讲了。而第二个是需要的,因为 next 可能是 null。如果 next 是 null ,就会引发空指针异常。因此需要修改为类似这样的代码:
while(cur){
next = cur.next
if (next==null) break;
n_next = next.next
}
以上就是给大家的四个技巧了。冲冲冲!