定义
- 链表是线性表的一种。
- 每个结点除了存放数据外,还存放指向下一个结点的指针;
- 不支持随机存取,只能顺序读取。
- 找到头结点就等于找到了一个链表,所以头结点可以用来表示一个链表。
- 链表分为无环链表和有环链表,本文仅讨论无环链表,即链表的最后一个节点的next指向NULL。
代码定义
Leetcode链表节点统一定义为:
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
head节点和dummy节点
严蔚敏/408 对链表的定义
在严蔚敏《数据结构》(以下简称”严书“)中,链表分为带头节点和不带头节点两种。
头节点head是专门用来标识链表的起始位置,即head.next指向链表的第一个元素,头节点本身不存放数据。
在严书中,标识链表的变量为头指针L.例如,可用如下c++代码实现链表判空:
- 不带头结点
bool Empty(LinkList L){
return L==NULL;
}
- 带头结点
bool Empty(LinkList L){
return (L->next==NULL);
}
leetcode及工程中对链表的定义
Leetcode中,head指针指向链表第一个结点,也就是严书中的头指针L。
无特殊情况下,leetcode中的所有链表题,结构都是如此。也就是严书中所谓”不带头节点链表“。
很多情况下,如果按默认的结构去对链表进行增删改查,都要对head节点做额外判断操作,有时候会很复杂。于是,我们引入dummy节点,其作用与严书定义的头结点一致。其next指针指向head,存放的数据无意义,不会去读取它。
引入dummy节点后,无需另外判断欲处理节点是否为头结点。
常见应用:
fun(ListNode head){
ListNode dummy = new ListNode(0);
dummy.next = head;
}
增删
我对于增删操作,总结了一个口诀:
先接管后继,再接入前驱
什么意思呢?
比如,在下面的例子中,我打算在a、b节点之间插入一个c。
那么,我就可以先让c接管a的后继b,也就是c.next = b;
随后,再让c接入b的前驱a,也就是a.next = c,完成一次插入操作。
对于删除操作,比如我们要删除节点b,那么就让a接管b的后继,再让b指向Null即可,gc会回收。不过很多时候,不用让被删节点指向null也行,因为链表不会走到b,删不删意义也不大了。反正遍历出来链表不带b就完事了。
经典题目
以下所有题号都是leetcode题号。
相交链表
160.相交链表
题目很明确。所谓相交链表,也就是两条链表在某一处指向了同一个节点,那么就会相交。由于链表特性,允许多个指针指向它,但是next指针只能指向一个结点,因此,链表一旦相交,后续的结点都是一样的。
再来看示例1:
示例中我们注意到相交节点的值不是’1’,也就是说实际上A中val为1的节点和B中val为1的节点不是一回事。这其实也很容易理解,链表中,节点的值和节点存放的地址无关。两个节点虽然值一致,但是地址不一致。
这道题解法很多,也是著名的有梗,号称算法第一深情。此处就不过多展开,有兴趣可自行搜索或查看题解。不用掌握过多解法,掌握一两种普适性的即可。这里介绍两种不同的解法。
- 使用高级数据结构
使用set很容易解决这个问题。A、B的节点分别放入set,只要在某结点处发现set中已存在该节点,那么就说明这个结点是交点。这个解法非常容易想到也很好写。时间复杂度和空间复杂度都是 O ( n ) O(n) O(n)。
public ListNode getIntersectionNode(ListNode headA, ListNode headB){
ListNode a = headA,b = headB;
Set<ListNode> set = new HashSet<>();
while (a!=null){
set.add(a);
a = a.next;
}
while (b!=null){
if(set.contains(b)){
return b;
}
set.add(b);
b = b.next;
}
return null;
}
- 使用双指针
由示例可以看出,如果两个链表相交,取两个指针同时从交点前N位开始每次向交点移动1位,一定会同时到达交点。
声明两个指针a,b分别指向链表头结点,较长的链表指针先移动两表距离的差距步,如示例1中,指针b先走1步,再和指针a一起向后走。因为它们到交点的距离是一致的,所以当两个指针相等时,这就是交点。
同时,链表如果不相交,那么两个指针也会同时到达Null。这就是循环退出条件。
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode a = headA,b = headB;
int lenA = 0,lenB = 0;
while (a!=null){
lenA++;
a = a.next;
}
while (b!=null){
lenB++;
b = b.next;
}
a = headA;
b = headB;
int diff = Math.abs(lenA - lenB),cnt=0;
//a、b指针移动到同一起跑线
if(lenA>=lenB){
while (cnt<diff){
a = a.next;
cnt++;
}
}else {
while (cnt<diff){
b = b.next;
cnt++;
}
}
while (a!=b){
a = a.next;
b = b.next;
}
return a;
}
链表合并
21.合并两个有序链表
题干如下:
首先,做题时,我一般先考虑边界条件,把容易想到的边界条件丢掉后再考虑一般情况。
由示例2、3可以很容易想到,只要有一个链表为空,直接返回另一个链表即可。这是最容易想到的边界条件。
对于示例1,也很容易想到如果链表等长,我们可以逐一判断结点值大小,先把小的拼上去,再把大的拼上去。
如果链表不等长,那么当一条链表遍历完毕后,直接把另一条链表剩下的部分拼上去即可。代码如下:
//判空,直接返回另一个
if(list1==null) return list2;
if(list2==null) return list1;
//建立一个新链表
ListNode dummy = new ListNode();
ListNode p = dummy;
while (list1!=null && list2!=null){
if(list1.val<=list2.val){
p.next = new ListNode(list1.val);
p = p.next;
list1 = list1.next;
}else {
p.next = new ListNode(list2.val);
p = p.next;
list2 = list2.next;
}
}
//还有链表不为空
if (list1!=null || list2 != null){
if(list1!=null){
p.next = list1;
}else {
p.next = list2;
}
}
return dummy.next;
在这里,我的新链表是带dummy结点的,因为在第一步,我们需要对链表头部进行插入,此处当然也可以用do-while或进循环之前额外的判断解决,但是不美观、代码冗余且边界问题和判断更复杂了。
后续的逻辑也非常简单。判断到更小的,就先接上,然后对应的链表指针前进一步,继续判断。
如果合并K个链表,怎么做?
最简单的做法,就是先合并前两个,再依次合并后续的。
1669.合并两个链表
题干如下,难度标为中等,实际上非常简单。
首先明确题意:
题目要求我们将list2插入list1中,list2的先驱改为list1的a-1节点,后继改为list1的b+1节点,list1中间的节点全删掉。
实际上,本题主要就是考察链表的增删操作,非常简单。
我们可以把被删除部分和list2看成一个”大节点“,按照正常的节点删除、插入操作即可。
按题目图示,我们找到被删除部分的前驱和后继,再找到list2的后继,图示如下:
想到这里,很容易写出如下代码。
ListNode aPrev = null,bRear = null,l2Rear = null;
ListNode p1 = list1;
ListNode p2 = list2;
int cnt = 0;
while (p1!=null){
if(cnt==a-1) aPrev = p1;
if(cnt==b+1) bRear = p1;
p1 = p1.next;
cnt++;
}
while (p2!=null){
if(p2.next==null) l2Rear = p2;
p2 = p2.next;
}
l2Rear.next = bRear;
aPrev.next = list2;
return list1;
双指针应用
链表中有许多题目需要使用到双指针。在链表中,一般用快慢指针。
876.链表的中间结点
此题有两种解法:
- 先遍历一遍,得到长度len,再从头走len/2步即可。
- 定义双指针slow、fast,同时出发,slow走一步,fast走两步,fast走到NULL时,slow机会走到中间。
上面的时间复杂度实际上都是 O ( n ) O(n) O(n),但是双指针可以少走半个循环。
ListNode fast = head,slow = head;
if(fast==null || fast.next == null) return fast;
while(fast.next!=null){
fast = fast.next.next;
slow = slow.next;
}
return slow;
写出如上代码后,会发现有部分测试点出现了空指针异常。
可以发现是循环跳出条件有问题。
FAST如果走到最后一个结点,就不可能再往后走两步。那么,多加一个条件即可。
ListNode fast = head,slow = head;
if(fast==null || fast.next == null) return fast;
while(fast!=null && fast.next!=null){
fast = fast.next.next;
slow = slow.next;
}
return slow;
面试题02.02 返回倒数第 k 个节点
这是一道非常经典的题目,往后只要是找倒数第k个节点,都需要使用一样的算法。
fast先走k步,再和slow一起走,当fast走到null之后,slow就到了倒数第k个节点。
如果k大于链表长,返回错误。由于本题限定了k是有效的,因此不需要处理。
ListNode fast = head,slow = head;
int cnt = 0;
while (fast!=null && cnt<k){
fast = fast.next;
cnt++;
}
while (fast!=null){
slow = slow.next;
fast =fast.next;
}
return slow.val;
61.旋转链表
本题,需要我们观察到,题目的意思就是从倒数第k处断开,然后断开处接到head。
注意,如果k的值是有可能大于链表长度len的。那么,假设k的值大于链表长度,有如下式子:
k
=
n
∗
l
e
n
+
m
k =n*len + m
k=n∗len+m
也就是说,链表的实际有效移动长度是m,因为链表每移动len位,就会回到初始状态。即:
k
%
=
l
e
n
k \%= len
k%=len
容易发现,这道题跟1669很像,也是将多个节点视作一个结点,随后进行增删操作。只不过这里是要接到链表头而已。
也就是说,题目等价为:将remove节点插入到a位置,slow指针对应节点指向null。
要插入到a位置,还是那个口诀,remove先接管NULL的后继a,再接入前驱NULL。由于插入到a为止后,remove的第一个节点变为头节点,返回这个节点即可,也就相当于前驱是NULL了。
那么,我们就可以明确slow和fast最后的位置应该是在:
- slow:在remove节点之前一个位置;
- fast:链表最后一个节点。
明确以上算法后,编码实现就非常简单了。此处快指针和02.02的快指针移动的步伐不同,少走一步,fast最后next指向null。
ListNode fast = head,slow = head,p = head;
int len = 0;
//获取链表长度
while (p!=null){
len++;
p = p.next;
}
k %= len;
if(k==0) return head;
int cnt = 0;
while (cnt<k){
fast = fast.next;
cnt++;
}
while (fast.next!=null){
slow = slow.next;
fast = fast.next;
}
ListNode newHead = slow.next;
fast.next = head;
slow.next = null;
return newHead;
删除节点
82.删除排序链表中的重复元素 II
删除节点实际上前面已经接触过了不少题目。这里就讲一道最具代表性的题。此题难度mid。
先看题。容易想到边界条件是len为0和1,即head==null
和head.next==null
时,返回原表即可。
随后,声明指针cur指向链表头,如果cur.next.val == cur.next.next.val
,那么就把这俩都删了。但是我们马上想到,如果像示例2,我们还得把头结点删了。
在链表删除操作中,常常会引入dummy节点来解决头结点的特殊性问题。引入dummy节点后,cur指向dummy,依然是之前的判断,然而这次就不需要再对头结点有特殊操作了。
引入dummy后,先判断存不存在后两个节点相等的情况。如果存在,先记下这个值为val,随后,只要cur.next的值还等于val,就一直删除cur.next节点。由于链表保证是不递减的,因此只要发现next不为val,cur就可以继续移动了。
想通这些,编码就非常简单了。
ListNode dummy = new ListNode();
dummy.next = head;
ListNode p = dummy;
while (p.next!=null){
if(p.next.next!=null && p.next.val == p.next.next.val){
int val = p.next.val;
while (p.next!=null && p.next.val == val){
ListNode del = p.next;
p.next = del.next;
del.next = null;
}
}else {
p = p.next;
}
}
return dummy.next;