文章目录
摘要
这篇文章主要是记录一下在LeetCode刷题过程中,与“链表“相关的题目。实际上刷的题数肯定不止,但要慢慢整理,写好思路跟代码。所以,慢慢来吧。
一. 前言
链表是一种常用数据结构,其题型也是很有辨识度。对于链表的基础知识在这里也不必多说,直接整理一下LeetCode上比较经典的链表题目吧。很多链表的题目,看起来很简单,但自己动手就会发现很多问题。所以一定要亲自去实现一次,不能仅仅停留在读懂解题思路的阶段。
链表问题通常都需要循环遍历,有时候需要判断是否为null。那么到底什么情况是判断head != null
还是head.next != null
呢?这种实际上也是编程的基本功。当你在调用一个对象的方法,你需要确保它不为空,否则就会抛出那每一个Java程序员都会碰到的NullPointerException。如果你的代码里用到了,比如current = current.next
,你需要确保的是current不为空,这样就不会导致空指针错误。如果你的代码里用到了current = current.next.next
,这时候首先你要确保current不为空,其次current.next也不为空。
对于头结点的处理,通常都比较特殊,因为当我们改变了头结点时候,后续return就很麻烦。这时候可以新建一个我们自定义的“伪头结点"dummy,并且使得Dummy.next = head
。这样不管后面head是否发生改变,只需要最后return的是dummy.next即可,即将头节点普通化,不再需要认为头节点是特殊的节点。但是dummy的值要巧妙设置,不能影响后续的处理。
而且,切记不要直接操作head,虽然有一些题目无关紧要,但通常我们只想遍历,如果直接head = head.next
,那么整个链表都会随着遍历而改变了。正确的做法是创建一个变量,比如current,指向head,然后改变这个current。
对于链表的问题,如果不能直接想出来,那么就多画图吧,画图就很好理解了。
二. LeetCode 面试题02.01. 移除重复节点
题目描述: 编写代码,移除未排序链表中的重复节点。保留最开始出现的节点。题目链接
分析:如果使用O(n)的空间复杂度,那么很简单,只需要创建一个哈希表。如果已经存在该节点的值,便跳过,删除。如果不能使用额外的空间,也就是O(1)的空间复杂度,那么就需要用双指针进行两层遍历,此时时间复杂度为O(n ^ 2)。
代码①: (O(n)时间复杂度,O(n)空间复杂度)
class Solution {
public ListNode removeDuplicateNodes(ListNode head) {
Set<Integer> set = new HashSet<>();
ListNode current = head, prev = head;
while (current != null) {
if (set.contains(current.val))
prev.next = current.next;
else {
set.add(current.val);
prev = current; // 如果contains,就不需要更改prev
}
current = current.next;
}
return head;
}
}
代码②: (O(n ^ 2)时间复杂度,O(1)空间复杂度)
class Solution {
public ListNode removeDuplicateNodes(ListNode head) {
ListNode dummy = new ListNode(-1);
dummy.next = head; // 创造一个dummy头结点
ListNode current = head, prev = dummy, helper;
while (current != null) {
helper = head;
boolean flag = false;
while (helper != current) { // 检查current前面是否有重复
if (helper.val == current.val) {
flag = true;
break;
}
helper = helper.next;
}
if (flag)
prev.next = current.next;
else
prev = current; // 如果contains,就不需要更改prev
current = current.next;
}
return dummy.next;
}
}
三. LeetCode 206. 反转链表
描述:反转一个单链表。题目链接
分析:直接定义一个prev变量,用于记录当前值的前一个值。至于什么时候设置prev,当然是在上一轮循环的时候。在当前循环要进行的操作就是:current.next = prev
①,这样就使得当前的current指向了上一个值。可是我们需要遍历,这时候我们遍历所用的current = current.next
已经不可以了,因为current.next已经改变。所以我们还要增加一个next变量,在执行①之前先记录current.next的值,然后执行完①,就可以更改prev的值,并且将current指向原本的下一个值。(同样地,修改prev的操作需要在修改current前进行)。只要清晰整个了逻辑,代码就水到渠成。
迭代代码:
class Solution {
public ListNode reverseList(ListNode head) {
ListNode current = head;
ListNode prev = null; // head的前一个是null
while (current != null) {
ListNode next = current.next; // 先记录current.next
current.next = prev;
prev = current; // prev改为指向当前的current
current = next; // current指向原本的current.next
}
return prev; //最后current为null,prev就是最后的元素
}
}
这是迭代的版本,如果使用递归,代码更简洁,但逻辑要更加清晰。递归的关键在于反向工作。假设列表的其余部分已经被反转,现在我该如何反转它前面的部分?
假设列表为:
n(1)→…→n(k−1)→(nk)→n(k+1)→…→n(m)→∅
若从节点 n(k+1) 到 n(m) 已经被反转,而我们正处于 n(k)。
n(1)→…→n(k−1)→(nk)→n(k+1)←…←n(m)
我们希望n(k+1)的下一个节点指向n(k),所以: n(k).next.next = nk
除此之外,n1的下一个必须指向null。否则当链表长度为2时,会形成循环,导致出错。
递归代码:
class Solution {
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null)
return head;
ListNode rest = reverseList(head.next); // 把rest想成一个整体,rest实际上是原本的tail
head.next.next = head; // 注意此时head.next是rest的尾结点
head.next = null; // 避免链表成环
return rest;
}
}
四. LeetCode 160. 相交链表
题目描述:编写一个程序,找到两个单链表相交的起始节点。题目链接
分析: 寻找交点,如果我们要使用两个相同速度的指针,那么就是要确保在某一个时候,二者走的距离相同,并且会在交点相遇。假设二者存在交点,那么这两个链表的差别主要在于交点前的长度不同(相交之后是公共的链表部分)。那么很容易想到,只要让两个指针都同时遍历了一次这两段交点前部分即可。所以思路:让指针到达终点时,将指针指向另一段单链表的起点,然后继续前行。这样保证两个指针会在交点相遇。同时,这种“指向起点”的操作只需要进行1次,如果无法相遇,说明两个单链表没有交点。
代码:
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode currentA = headA, currentB = headB;
boolean flag1 = false, flag2 = false; // 记录二者是否都到达了一次end
while (currentA != null && currentB != null) {
if (currentA == currentB)
return currentA; // 相交的结点
currentA = currentA.next;
currentB = currentB.next;
if (currentA == null && !flag1) {
currentA = headB; // A跳转到headB的开头
flag1 = true;
}
if (currentB == null && !flag2) {
currentB = headA;
flag2 = true; // 确保只跳转一次
}
}
return null;
}
}
这段代码比较直接,完全就是按照解题思路写的。但其实还有更简便的方法,实际上只要两个指针指向同一个位置的时候,此处就是交点,当然到达终点依然会进行一次跳转操作。所以此时的循环结束条件是curentA == currentB
。如果没有交点该如何?这时候两个指针必定会同时到达尾部,此时二者都为null,所以同样是返回currentA或者currentB即可。
简洁版代码:
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if(headA == null || headB == null) return null;
ListNode pA = headA, pB = headB;
while(pA != pB) {
pA = pA == null ? headB : pA.next;
pB = pB == null ? headA : pB.next;
}
return pA;
}
}
同时这道题有点像,如果一个链表有环,返回环的入口(双指针文章里有提到)。那么这里我们同样可以构造出一个环:遍历链表B,直到终点last,然后将last.next == headA
。这时候对于单链表A来说,如果有交点,那么就存在环。如果无交点,就会构成一个环,而环入口就是二者的交点。如果fast最后会到达null,或者fast.next为null,说明不成环,无交点。如果存在环,二者一定在某一时刻相遇,此时只要将fast或者slow置于起始点(headA),然后以同样的速度继续前行,二者会在交点处/环入口相遇(证明过程见“双指针”一文)。同时,题目不允许对原链表进行修改,所以在返回之前,还得把last.next改回去,即last.next = null
构造环:
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null)
return null;
ListNode last = headB;
while (last.next != null)
last = last.next;
last.next = headB; // 此时如果有交点,那么A存在环
ListNode fast = headA, slow = headA;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast) {
slow = headA;
break;
}
}
if (fast == null || fast.next == null) {
last.next = null; // reset
return null; // 无交点
}
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
last.next = null; // reset
return slow; // or fast
}
}
五. LeetCode 21. 合并两个有序链表
题目描述:将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 题目链接
分析:直接比较两个链表,选择更小的值添加到新链表中。直到其中一条为null,直接把另一条直接添加即可。同时可以创建一个dummy头结点,便于操作。
代码:
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(1);
ListNode res = new ListNode(100);
dummy.next = res;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
res.next = l1;
l1 = l1.next;
}
else {
res.next = l2;
l2 = l2.next;
}
res = res.next;
}
if (l1 == null)
res.next = l2;
else
res.next = l1;
return dummy.next.next;
}
}
六. LeetCode 83. 删除排序链表中的重复元素
题目:给定一个排序链表,删除所有重复的元素,使得每个元素只出现一次。题目链接
分析:最简单的方法当然就是HashSet了,但那样就浪费了题中的条件“排序链表”。因为是排序,所以我们只要与前一个元素相比,只要相同,那么就跳过当前的元素。值得注意的是,使用pre来代表前一个元素,但pre并不是每一次都要改变。比如测试用例[1, 1, 1],当current与pre相同的时候,这时候不需要改变pre。本题的唯一难点。
代码:
class Solution {
public ListNode deleteDuplicates(ListNode head) {
if (head == null)
return head;
ListNode prev = head;
ListNode current = head.next; // 从第二个开始
while (current != null) {
if (prev.val == current.val)
prev.next = current.next;
else
prev = current; // 相同的情况下,无须改变prev
current = current.next;
}
return head;
}
}
七. LeetCode 19. 删除链表的倒数第N个结点
描述: RT(保证给定的n的有效的)题目链接
分析:如果是进行两趟扫描,那么思路很简单,第一遍先获取链表的长度,然后就可以知道倒数第n个是顺序的第几个。这道题的难点是如何顺序第一遍就知道正确的位置。可以使用两个指针,先让其中一个走n步,那么当这个先行的指针走到结尾,后行的指针就正处于倒数第n个的位置。
代码① (两趟扫描):
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
int length = 0;
ListNode current = head;
while (current != null) {
length++;
current = current.next;
}
current = dummy; // 从dummy开始,不然删除head就比较麻烦
int tmp = 0;
while (tmp <= length - n - 1) {
current = current.next;
tmp++;
}
current.next = current.next.next;
return dummy.next;
}
}
代码② (一趟扫描):
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode fast = dummy;
for (int i = 0; i <= n; i++) // n从1开始,要多走一步
fast = fast.next;
ListNode slow = dummy;
while (fast != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
}
八. LeetCode 24. 两两较换链表中的结点
描述:给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。题目链接
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
分析:直接定义三个变量,pre,first,second。我们需要设置:second.next = first
,first.next = second.next
,可以提前保留second.next这个值。操作完成之后,更换pre,first,second的值。这里要注意是否会为null的情况。因为first = next
,second = next.next
,显然要判断next是否为null。如果为null,说明后面没有元素,无须继续操作,可以结束循环。直觉上很容易想,如果还剩下一个元素应该怎么办?此时next不为null,但next.null为null。这时候仍然可以结束循环,所以如果你愿意,你可以把这个判断条件更改为:
if (next != null || next.next != null)
。但实际上,只剩下1个元素的情况下,实际上就是second为null,这时候再循环一次其实就在while循环里结束了,所以是一样的。(对于while跟if的结束条件,有很多种写法,理解就好)
代码:
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null)
return head;
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode pre = dummy;
ListNode first = head;
ListNode second = head.next;
while (first != null && second != null) {
ListNode next = second.next;
pre.next = second;
second.next = first;
first.next = next;
pre = first;
first = next;
if (next == null)
break;
second = next.next;
}
return dummy.next;
}
}
以上是迭代算法,同样地,也写出递归算法,就跟206题一样。递归要考虑的是3个部分:
①终止条件 ②返回值 ③本级递归应该做什么 (此处参考“网恋教父”的的文章 )
主要是把整个链表看作3个部分,head,next,已经处理完的部分。然后经过操作变成:
next,head,已经处理完的部分
递归代码:
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null)
return head;
ListNode next = head.next;
head.next = swapPairs(next.next); // 一个整体
next.next = head;
return next;
}
}
九. LeetCode 445. 两数相加 Ⅱ
题目描述: 给定两个非空链表来代表两个非负整数。数字最高位位于链表开始位置。它们的每个节点只存储单个数字。将这两数相加会返回一个新的链表。题目链接
你可以假设除了数字 0 之外,这两个数字都不会以零开头。
如果输入链表不能修改该如何处理?换句话说,你不能对列表中的节点进行翻转。
分析:对链表进行倒序读取是很麻烦的操作,所以一下子就想到了用两个数组/栈来重新维护数据。实现的过程也遇到了一点小麻烦,效率也还可以,排前面的代码也是这种思路,所以看来这就是最终的方法了。这里显然是用栈来维护最好(LinkedList),因为我们只需要对首尾的元素进行操作。值得注意的是,当两个栈都为空,如果此时还有进位,那么还需要额外new一个值为1的头结点。
代码:
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
LinkedList<Integer> list1 = new LinkedList<>();
LinkedList<Integer> list2 = new LinkedList<>();
ListNode h1 = l1, h2 = l2;
while (h1 != null) {
list1.addLast(h1.val);
h1 = h1.next;
}
while (h2 != null) {
list2.addLast(h2.val);
h2 = h2.next; // 数据存放到list1和list2中
}
ListNode next = null;
int increment = 0;
while (true) {
int val1 = list1.size() == 0 ? 0 : list1.removeLast();
int val2 = list2.size() == 0 ? 0 : list2.removeLast();
int sum = val1 + val2 + increment;
if (sum >= 10) {
sum %= 10;
increment = 1;
}
else
increment = 0;
ListNode current = new ListNode(sum);
current.next = next;
next = current;
if (list1.size() == 0 && list2.size() == 0) {
if (increment == 0)
return current;
ListNode head = new ListNode(1); // 循环结束,但还有进位的情况
head.next = current;
return head;
}
}
}
}
十. LeetCode 234. 回文链表
描述: 判断一个链表是否为回文链表,要求时间复杂度为O(n),空间复杂度为O(1)。题目链接
分析: 空间复杂度为O(1),所以不能使用List等额外的数据结构。对于回文链表,可以理解为中点左右的值都相等。所以我们可以先找到链表的中点,然后将后半段反转,这时候逐个比较后半段与链表开头的值。其实LeetCode里就有寻找中点的题,也是双指针。但该题题目要求写道,如果有两个中间结点,那么返回第二个结点。这里是一个细节。我们获得了中间结点之后,是不需要把中间结点与head比较的,所以我们会是从mid.next与head开始相比,这就导致了奇偶数情况不同:如果是奇数,可以直接mid.next即可。如果是偶数,因为那样的解法是获得第二个中点,显然我们应该获得“第一个“作为中点。这时候需要在寻找中点的做法里做一些改动。
代码:
class Solution {
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null)
return true;
ListNode fast = head, slow = head;
while (fast.next != null && fast.next.next != null) {
// 这会使得偶数链表返回第一个中间结点
// 如果想返回第二个中间结点, 应该用 fast != null && fast.next != null
fast = fast.next.next;
slow = slow.next;
}
slow = reverse(slow.next);
while (slow != null) {
if (slow.val != head.val)
return false;
slow = slow.next;
head = head.next;
}
return true;
}
public ListNode reverse(ListNode head) {
if (head == null || head.next == null)
return head;
ListNode prev = null;
ListNode current = head;
while (current != null) {
ListNode next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}
}