高频面试题(二)
1.重点+热点:链表反转以及5道变形题
链表反转是一个出现频率特别高的算法题,笔者过去这些年面试,至少遇到过七八次。其中更夸张的是曾经两天写了三次,上午YY,下午金山云,第二天快手。链表反转在各大高频题排名网站也长期占领前三。所以链表反转是我们学习链表最重要的问题,没有之一。
那为什么反转这么重要呢?因为反转链表涉及结点的增加、删除等多种操作,能非常有效考察对指针的驾驭能力和思维能力。
另外很多题目也都要用它来做基础, 例如指定区间反转、链表K个一组翻转。还有一些在内部的某个过程用到了反转,例如两个链表生成相加链表。还有一种是链表排序的,也是需要移动元素之间的指针,难度与此差不多。接下来我们就具体看一下每个题目。
1.1 反转一个链表
给你单链表的头节点 head,请你反转链表,并返回反转后的链表。
示例1:
输入:head = [1, 2,3, 4, 5]
输出: [5, 4, 3, 2, 1]
分析
这个题同样有至少三种方法,我们都应该会,因为都很重要,面试的时候可以根据需要写。
1.1.1 建立虚拟头结点辅助反转
对于链表问题,如何处理头结点是个比较麻烦的问题。很多场景下可以先建立一个虚拟的结点ans ,使得 ans.next=head ,这样可以很好的简化我们的操作。如下图所示。
首先我们可以将1接到ans的后面之后,后面每个元素,例如 2, 3 ,4, 5,我们都将其接到ans后面,这样已经组成链的1 2 3 4 将被逐渐甩到后面去了,所以当5成功插入到ans之后,整个链表的反转就完成了。这时候只要返回ans.next就得到反转的链表了。
当我们插入元素的时候,可以创建新的结点然后接到ans后面,也可以复用已有的结点,只是调整指针,相对来说,前面一种思维难度稍微低一些,但是往往会被面试官禁止,我们提倡使用后者。直接复用已有结点,只是调整指针的代码:
/**
* 方法1:虚拟结点, ,并复用已有的结点
* @param head
* @return
*/
public static ListNode reverseList(ListNode head) {
ListNode ans = new ListNode(-1);
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = ans.next;
ans.next = cur;
cur = next;
}
return ans.next;
}
1.1.2 直接操作链表实现反转
如果不使用虚拟结点,同样可以选择创建新结点或者只调整指针,但是如果再定义一个新的会浪费空间,所以我们只看如何将每个结点的指向都反过来的方法:
那这里的问题就是如何准确的记录并调整指针,我们看执行期间的过程示意图:
在上图中,我们用cur来表示旧链表被访问的位置,也就是本轮要调整的结点,pre表示已经调整好的新链表的表头,next是先一个要调整的。注意图中箭头方向,cur和pre都是两个表的表头,每移动完一个结点之后,我们必须准确知道两个链表的表头。
cur是需要接到pre的,那该怎么知道其下一个结点5呢?代码也不算很复杂:
public ListNode reverseList(ListNode head ) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
将上面这段代码在理解的基础上背下来,是的,因为这个算法太重要
1.1.3 拓展通过递归来实现
这个问题其实还有个递归方式反转,我们在讲解递归的时候会再来看该部分,这里只做了解。
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
除了上面的基础方式,还有几个典型的考题也是面试经常见到的,我们一个个来看。
1.2 指定区间反转
题目要求
LeetCode92 :给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回反转后的链表。
示例 1:
输入:head = [1, 2, 3, 4, 5], left = 2, right = 4
输出: [1, 4, 3, 2, 5]
图示:
1.2.1 穿针引线法
我们以反转下图中蓝色区域的链表反转为例。
我们可以这么做:先反转 left 到 right 部分,然后再将三段链表拼接起来。为此,我们还需要记录 left 的前一个节点,和 right 的后一个节点。如图所示:
算法步骤:
- 第 1 步:先将待反转的区域反转;
- 第 2 步:把 pre 的 next 指针指向反转以后的链表头节点,把反转以后的链表的尾节点的 next 指针指向 succ。
编码细节我们直接看下方代码。思路想明白以后,编码不是一件很难的事情。这里要提醒大家的是,链接什么时候切断,什么时候补上去,先后顺序一定要想清楚,如果想不清楚,可以在纸上模拟,让思路清晰。
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
// 因为头节点有可能发生变化,使用虚拟头节点可以避免复杂的分类讨论
ListNode dummyNode = new ListNode(-1);
dummyNode.next = head;
ListNode pre = dummyNode;
// 第 1 步:从虚拟头节点走 left - 1 步,来到 left 节点的前一个节点
// 建议写在 for 循环里,语义清晰
for (int i = 0; i < left - 1; i++) {
pre = pre.next;
}
// 第 2 步:从 pre 再走 right - left + 1 步,来到 right 节点
ListNode rightNode = pre;
for (int i = 0; i < right - left + 1; i++) {
rightNode = rightNode.next;
}
// 第 3 步:切断出一个子链表(截取链表)
ListNode