7.面试算法-链表之高频面试题(二)

高频面试题(二)

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. 总结

通过上面的这些题目,我们能感受到链表的题目真是不少,但是大部分常规题目,都是从增删改查变换或者组合而来的。

这些题目大部分一看就知道该怎么做,但是要写出来甚至运行成功,难度还是很大的,所以,我们需要耐住寂寞,认真练习,只有练会了才可能在考场上应对自如,这就是所谓的思维能力了。

在上面这些题目中,需要特别强调的就是反转相关的几个问题必须都要会,因为这几个问题的考察频率非常高,而且对链表的能力要求也不低,必须好好掌握。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值