高频面试题(二)
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 leftNode = pre.next;
ListNode curr = rightNode.next;
// 注意:切断链接
pre.next = null;
rightNode.next = null;
// 第 4 步:同第 206 题,反转链表的子区间 reverseLinkedList(leftNode);
// 第 5 步:接回到原来的链表中
pre.next = rightNode;
leftNode.next = curr;
return dummyNode.next;
}
private void reverseLinkedList(ListNode head) {
// 也可以使用递归反转一个链表
ListNode pre = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
}
}
1.2.2 头插法(虚拟)
方法一的缺点是:如果 left 和 right 的区域很大,恰好是链表的头节点和尾节点时,找到 left 和 right 需要遍历一次,反转它们之间的链表还需要遍历一次,虽然总的时间复杂度为 O(N),但遍历了链表 2次,可不可以只遍历一次呢?答案是可以的。我们依然画图进行说明。
我们依然以方法一的示例为例进行说明。
整体思想是:在需要反转的区间里,每遍历到一个节点,让这个新节点来到反转部分的起始位置。下面的图展示了整个流程。
下面我们具体解释如何实现。图示比较多,但是逻辑不是很复杂。
使用三个指针变量 pre 、curr 、next 来记录反转的过程中需要的变量,它们的意义如下:
- curr :指向待反转区域的第一个节点 left;
- next:永远指向 curr 的下一个节点,循环过程中, curr 变化以后 next 会变化;
- pre:永远指向待反转区域的第一个节点 left 的前一个节点,在循环过程中不变。
第 1 步,我们使用 ①、②、③ 标注「穿针引线」的步骤。
操作步骤:
- 先将 curr 的下一个节点记录为 next;
- 执行操作 ①:把 curr 的下一个节点指向 next 的下一个节点;
- 执行操作 ②:把 next 的下一个节点指向 pre 的下一个节点;
- 执行操作 ③:把 pre 的下一个节点指向 next。
第 1 步完成以后「拉直」的效果如下:
第 2 步,同理。同样需要注意 「穿针引线」操作的先后顺序。
第 2 步完成以后「拉直」的效果如下:
第 3 步,同理。
第 3 步完成以后「拉直」的效果如下:
代码如下:
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
// 设置 dummyNode 是这一类问题的一般做法
ListNode dummyNode = new ListNode(-1);
dummyNode.next = head;
ListNode pre = dummyNode;
for (int i = 0; i < left - 1; i++) {
pre = pre.next;
}
ListNode cur = pre.next;
ListNode next;
for (int i = 0; i < right - left; i++) {
next = cur.next;
cur.next = next.next;
next.next = pre.next;
pre.next = next;
}
return dummyNode.next;
}
}
1.3 K个一组反转链表
给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。k 是一个正整数,它的值小于或等于链表的长度。
如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
进阶:
你可以设计一个只使用常数额外空间的算法来解决此问题吗?
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例1:
输入:head = [1, 2, 3, 4, 5], k = 2
输出: [2, 1, 4, 3, 5]
示例2:
输入:head = [1, 2, 3, 4, 5], k = 3
输出: [3, 2, 1, 4, 5]
分析
这个问题一个比较困难的问题,思路并不复杂,但是实现比较困难,难题在于每个段的首尾需要自动确定还要能接到移位。
参考网络上各路神仙的解决思路,基本思路都是先将每个区间的元素拿到,之后将每个区间使用独立的函数来反转。当然反转可以使用链表反转,也可以使用栈。
1.3.1 穿针引线法
这种思路与上面的穿针引线类似,图示比较复杂,先看文字表述:
- 链表分区为已翻转部分+待翻转部分+未翻转部分
- 每次翻转前,要确定翻转链表的范围,这个必须通过 k 此循环来确定
- 需记录翻转链表前驱和后继,方便翻转完成后把已翻转部分和未翻转部分连接起来
- 初始需要两个变量 pre 和 end,pre 代表待翻转链表的前驱,end 代表待翻转链表的末尾
- 经过k此循环,end 到达末尾,记录待翻转链表的后继 next = end.next
- 翻转链表,然后将三部分链表连接起来,然后重置 pre 和 end 指针,然后进入下一次循环
- 特殊情况,当翻转部分长度不足 k 时,在定位end 完成后,end==null ,已经到达末尾,说明题目已完成,直接返回即可
- 时间复杂度为 O(n*K) 最好的情况为 O(n)最差的情况未 O(n^2),空间复杂度为 O(1) 除了几个必须的节点指针外,我们并没有占用其他空间。
实现代码:
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy;
ListNode end = dummy;
while (end.next != null) {
for (int i = 0; i < k && end != null; i++)
end = end.next;
if (end == null) break;
ListNode start = pre.next;
ListNode next = end.next;
end.next = null;
pre.next = reverse(start);
start.next = next;
pre = start;
end = pre;
}
return dummy.next;
}
private ListNode reverse(ListNode head) {
ListNode pre = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = pre;
pre = curr;
curr = next;
}
return pre;
}
1.3.2 头插法
与上一题的头插法,大致过程为:
- 1、找到待翻转的k个节点(注意:若剩余数量小于k 的话,则不需要反转,因此直接返回待翻转部分的头结点即可)。
- 2、对其进行翻转。并返回翻转后的头结点(注意:翻转为左闭又开区间,所以本轮操作的尾结点其实就是下 一轮操作的头结点)。
- 3、对下一轮 k 个节点也进行翻转操作。
- 4、将上一轮翻转后的尾结点指向下一轮翻转后的头节点,即将每一轮翻转的k的节点连接起来。
实现代码:
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || head.next == null) {
return head;
}
ListNode tail = head;
for (int i = 0; i < k; i++) {
//剩余数量小于k的话,则不需要反转。
if (tail == null) {
return head;
}
tail = tail.next;
}
// 反转前 k 个元素
ListNode newHead = reverse(head, tail);
//下一轮的开始的地方就是tail
head.next = reverseKGroup(tail, k);
return newHead;
}
/*
左闭又开区间
*/
private ListNode reverse(ListNode head, ListNode tail) {
ListNode pre = null;
ListNode next = null;
while (head != tail) {
next = head.next;
head.next = pre;
pre = head;
head = next;
}
return pre;
}
1.4 两两交换链表中的节点
这个题的要求是给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值 ,而是需要实际的进行节点交换。
分析
这里为什么会有这个加粗的部分的补充要求呢?其实我们进行调整有一种方法就是先将元素值保存到数组里,然后调整好之后再写回到链表,也就是只改链表的结点值,而不修改节点。这种方式降低了反转的难度,加粗部分就是要毙掉这种方式。
看到这个题是否感觉又是换了条件瞎搞的题?将上面的K换成2不就是这个题吗?如果将K设置尾3 ,4, 5,那式是不是又可以造题了?
道理确实如此,但是如果K为2的时候,可以两两进行,基于相邻结点的特性重新设计和实现就行,不需要上面这么 复杂的操作,所以我们需要单独看一下。如果K是3 ,4, 5等,则完全采用K来处理就行了。
为了便于实现,我们创建虚拟结点 dummyHead,令 dummyHead.next = head。令 temp 表示当前到达的节 点,初始时 temp = dummyHead。每次需要交换 temp 后面的两个节点。
如果 temp 的后面没有节点或者只有一个节点,则没有更多的节点需要交换,因此结束交换。否则,获得 temp 后 面的两个节点 node1 和 node2 ,通过更新节点的指针关系实现两两交换节点。
具体而言,交换之前的节点关系是 temp -> node1 -> node2,交换之后的节点关系要变成 temp -> node2 -> node1 ,因此需要进行如下操作。
temp.next = node2
node1.next = node2.next
node2.next = node1
完成上述操作之后,节点关系即变成 temp -> node2 -> node1。再令 temp = node1,对链表中的其余节点进行两 两交换,直到全部节点都被两两交换。
两两交换链表中的节点之后,新的链表的头节点是 dummyHead.next ,返回新的链表的头节点即可。
完整的代码是:
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummyHead = new ListNode(0);
dummyHead.next = head;
ListNode temp = dummyHead;
while (temp.next != null && temp.next.next != null) {
ListNode node1 = temp.next;
ListNode node2 = temp.next.next;
temp.next = node2;
node1.next = node2.next;
node2.next = node1;
temp = node1;
}
return dummyHead.next;
}
}
2.链表反转的应用
链表的反转我们研究了很多种情况,这几种都非常重要,但是这还不足已征服链表反转,我们再看几个应用链表反转的例子。
2.1 单链表加1
用一个非空单链表来表示一个非负整数,然后将这个整数加一。
你可以假设这个整数除了 0 本身,没有任何前导的 0。
这个整数的各个数位按照 高位在链表头部、低位在链表尾部的顺序排列。
示例 :
输入 : [1,2,3]
输出 : [1,2,4]
在数组部分我们处理过几种加法和进制的算法,这里换成的链表又要搞一遍,你说算法是不是就是换换条件继续折腾。我们看一下加法的过程:
计算是从低位开始的,而链表是从高位开始的,所以要处理就必须反转过来,此时可以使用栈,也可以使用链表反转来实现。
2.1.1 基于栈实现
我们这里这么做:
1.先把题目给出的链表遍历放到栈中;
2.从栈中弹出栈顶数字 digit,计算 adder 之和(adder 在初始化的时候是 1,之后都是 0;表示链表与 1 相加), 再加上进位 carry,得到当前位置的和 sum。
[1] 如果 sum >= 10 ,那么进位 carry = 1 ,当前位设置为 sum - 10。
[2] 如果 sum < 10,那么进位 carry = 0,当前位设置为 sum。
3.设置新链表节点,其值为 sum ,逆序拼接成链表即可。
完整代码:
class Solution {
public ListNode plusOne(ListNode head) {
Stack<Integer> st = new Stack();
while (head != null) {
st.push(head.val);
head = head.next;
}
int carry = 0;
ListNode dummy = new ListNode(0);
int adder = 1;
while (!st.empty() || adder != 0 || carry > 0) {
int digit = st.empty() ? 0 : st.pop();
int sum = digit + adder + carry;
carry = sum >= 10 ? 1 : 0;
sum = sum >= 10 ? sum - 10 : sum;
ListNode cur = new ListNode(sum);
cur.next = dummy.next;
dummy.next = cur;
adder = 0;
}
return dummy.next;
}
}
2.1.2 基于链表反转实现
如果这里不使用栈,要求你使用链表反转来实现,该怎么做呢?很显然,我们先将原始链表反转,这方面完成加1 和进位等处理,完成之后再次反转。
这里就不具体展开讲解了。
2.2 链表加法
相加相链表是基于链表构造的一种特殊题,反转只是其中的一部分。这个题还存在进位等的问题,因此看似简单, 但是手写成功并不容易,这个题目在LeetCode中我没找到原题,但是在很多材料里有,而且我也确实曾经遇到过,所以我们就来研究一下。
题目要求是这样的:
假设链表中每一个节点的值都在 0 - 9 之间,那么链表整体就可以代表一个整数。 给定两个这种链表,请生成代表两个整数相加值的结果链表。
例如:链表 1 为 9->3->7,链表 2 为 6->3,最后生成新的结果链表为 1->0->0->0。
示例1:
输入: (7 -> 1 -> 6) + (5 -> 9 -> 2),即617 + 295
输出:2 -> 1 -> 9,即912
进阶:思考一下,假设这些数位是正向存放的,又该如何解决呢?
示例2:
输入: (6 -> 1 -> 7) + (2 -> 9 -> 5),即617 + 295
输出:9 -> 1 -> 2,即912
分析
这个题目的难点在于存放是从最高位向最低位开始的,但是因为低位会产生进位的问题,计算的时候必须从最低位 开始。所以我们必须想办法将链表节点的元素反转过来,如下图所示:
怎么反转呢?首先想到可以先用栈来将两个链表分别反转,然后再计算。当你将思路说出来之后,面试官通过会说:“栈要开辟O(n)的空间,你有只需要O(1)空间的方法吗? ” 。其实言外之意是栈太简单了,那这时候只能用链表反转来做了。
不过呢,为了充分思考,两种方式我们都看一下。
2.2.1 使用栈实现
思路是先将两个链表的元素分别压栈,然后再一起出栈,将两个结果分别计算。之后对计算结果取模,模数保存到新的链表中,进位保存到下一轮。
完成之后再进行一次反转就行了。
我们知道在链表插入有头插法和尾插法两种。头插法就是每次都将新的结点插入到head之前。而尾插法就是将新结点都插入到链表的表尾。两者的区别是尾插法的顺序与原始链表是一致的,而头插法与原始链表是逆序的,所以上面最后一步如果不想进行反转,可以将新结点以头插法。
public class Solution {
public ListNode addInList (ListNode head1, ListNode head2) {
Stack<ListNode> st1 = new Stack<ListNode>();
Stack<ListNode> st2 = new Stack<ListNode>();
while(head1!=null){
st1.push(head1);
head1=head1.next;
}
while(head2!=null){
st2.push(head2);
head2=head2.next;
}
ListNode newHead=new ListNode(-1);
int carry=0;
//这里设置carry!=0,是因为当st1,st2都遍历完时,如果carry=0,就不需要进入循环了
while(!st1.empty() ||!st2.empty() ||carry!=0){
ListNode a=new ListNode(0);
ListNode b=new ListNode(0);
if(!st1.empty()){
a=st1.pop();
}
if(!st2.empty()){
b=st2.pop();
}
//每次的和应该是对应位相加再加上进位
int get_sum=a.val+b.val+carry;
//对累加的结果取余
int ans=get_sum%10;
//如果大于0,就进位
carry=get_sum/10;
ListNode cur=new ListNode(ans);
cur.next=newHead.next;
//每次把最新得到的节点更新到neHead.next中
newHead.next=cur;
}
return newHead.next;
}
}
2.2.2 使用链表反转实现
如果不用栈,那只能用链表反转了,先将两个链表分别反转,最后计算完之后再将结果反转,一共需要三次。进位等的处理与上面差不多。
public class Solution {
public ListNode addInList (ListNode head1, ListNode head2) {
head1 = reverse(head1);
head2 = reverse(head2);
ListNode head = new ListNode(-1);
ListNode cur = head;
int carry = 0;
while(head1 != null || head2 != null) {
int val = carry;
if (head1 != null) {
val += head1.val;
head1 = head1.next;
}
if (head2 != null) {
val += head2.val;
head2 = head2.next;
}
cur.next = new ListNode(val % 10);
carry = val / 10;
cur = cur.next;
}
if (carry > 0) {
cur.next = new ListNode(carry);
}
return reverse(head.next);
}
private ListNode reverse(ListNode head) {
ListNode cur = head;
ListNode pre = null;
while(cur != null) {
ListNode temp = cur.next;
cur.next = pre;
pre = cur;
cur = temp;
}
return pre;
}
}
上面我们直接调用了反转函数,这样代码写起来就容易很多,如果你没手写过反转,所有功能都是在一个方法里, 那复杂度要高好几个数量级,甚至自己都搞不清楚了。
既然加法可以,那如果是减法呢?读者可以自己想想该怎么处理。
3. 总结
通过上面的这些题目,我们能感受到链表的题目真是不少,但是大部分常规题目,都是从增删改查变换或者组合而来的。
这些题目大部分一看就知道该怎么做,但是要写出来甚至运行成功,难度还是很大的,所以,我们需要耐住寂寞,认真练习,只有练会了才可能在考场上应对自如,这就是所谓的思维能力了。
在上面这些题目中,需要特别强调的就是反转相关的几个问题必须都要会,因为这几个问题的考察频率非常高,而且对链表的能力要求也不低,必须好好掌握。