6.面试算法-链表之高频面试题(一)

高频面试题(一)

1.1 五种方法解决两个链表第一个公共子节点

这是一道经典的链表问题先看一下题目。

输入两个链表,找出它们的第一个公共节点。

例如下面的两个链表:
在这里插入图片描述
两个链表的头结点都是已知的,相交之后成为一个单链表,但是相交的位置未知,并且相交之前的结点数也是未知的,请设计算法找到两个链表的合并点。

1.没有思路时该怎么解题
这种问题该怎么入手呢?如果一时想不到该怎么办呢?其实这时候我们可以将常用数据结构和常用算法都想一遍,看看哪些能解决问题。

常用的数据结构有数组、链表、队、栈、Hash 、集合、树、堆。常用的算法思想有查找、排序、双指针、递归、迭代、分治、贪心、回溯和动态规划等等。

我们先在脑子里快速过一下谁有可能解决问题。首先想到的是蛮力法,类似于冒泡排序的方式,将第一个链表中的 每一个结点依次与第二个链表的进行比较,当出现相等的结点指针时,即为相交结点,但是这种方法时间复杂度高,而且有可能只是部分匹配上,所以还有要处理复杂的情况。排除!

其次Hash呢?模模糊糊感觉行的, OK。

之后是集合呢?和Hash一样用,目测也能解决, OK。

队列和栈呢?貌似队列没啥用,但是栈能解决问题,于是就有了第三种方法。

其他的几种结构或者算法呢?貌似都不太好用。这时候我们可以直接和面试官说,应该可以用HashMap做,另外集合和栈应该也能解决问题。面试官很明显就问了,怎么解决?

那这时候你可以继续考虑HashMap、集合和栈具体应该怎么解决,假如错了呢?比如你说队列也行,但是后面发现根本解决不了,这时候直接对面试官说“ 队列不行,我想想其他方法” ,一般对方就不会再细究了。

算法面试本身也是一个相互交流的过程,如果有些地方你不清楚,他甚至会提醒你一下,所以不用紧张,也不用怕他盯着你写代码,努力去做就行了。

(1)HashMap法

先将一个链表全部存到Map里,然后再遍历第二个,如果有交点,那么一定能在访问到某个元素的时候检测出来如果面试官点头,就可以手写了:

import java.util.HashMap;
public class Solution {
	public ListNode findFirstCommonNodeByMap(ListNode pHead1, ListNode pHead2) { 
		if(pHead1==null || pHead2==null){
			return null;
		}
		ListNode current1=pHead1;
		ListNode current2=pHead2;

		HashMap<ListNode,Integer>hashMap=new HashMap<>();
		while(current1!=null){
			hashMap.put(current1,null);
			current1=current1.next;
		}

		while(current2!=null){
			if(hashMap.containsKey(current2))
				return current2;
			current2=current2.next;
		}
	return null;
	}
}

(2) 集合Set法

能用Hash,那能不能用Set呢?其实思路和上面的一样,

先把第一个链表的节点全部存放到集合set中,然后遍历第二个链表的每一个节点,判断在集合set中是否存在,如果存在就直接返回这个存在的结点。如果遍历完了,在集合set中还没找到,说明他们没有相交,直接返回null即 可。

public ListNode findFirstCommonNodeBySet(ListNode headA, ListNode headB) {
	Set<ListNode> set = new HashSet<>();
	while (headA != null) {
		set.add(headA);
		headA = headA.next;
	}

	while (headB != null) {
		if (set.contains(headB))
			return headB;
		headB = headB.next;
	}
	return null;
}

(3) 使用栈

这里需要使用两个栈,分别将两个链表的结点入两个栈,然后分别出栈,如果相等就继续出栈,不相等的时候就找到了分界线了。这种方式需要两个O(n)的空间,所以在面试时不占优势,但是能够很好锻炼我们,所以花十分钟写一个吧:

import java.util.Stack;
public class Solution {
	public ListNode findFirstCommonNodeByStack(ListNode headA, ListNode headB) {
		Stack<ListNode> stackA=new Stack();
		Stack<ListNode> stackB=new Stack();
		while(headA!=null){
			stackA.push(headA);
			headA=headA.next;
		}
		while(headB!=null){
			stackB.push(headB);
			headB=headB.next;
		}

		ListNode preNode=null;
		while(stackB.size()>0 && stackA.size()>0){
			if(stackA.peek()==stackB.peek()){
				preNode=stackA.pop();
				stackB.pop();
			}else{
				break;
			}
		}
		return preNode;
	}
}

看到了吗,从一开始没啥思路到最后搞出三种方法,熟练掌握数据结构是多么重要!!

(4)拼接两个字符串

先看下面的链表A和B:
A: 0-1-2-3-4-5
B:a-b-4-5

如果分别拼接成AB和BA会怎么样呢?
AB:0-1-2-3-4-5-a-b-4-5
BA:a-b-4-5-0-1-2-3-4-5

我们发现最后从4开始的就是公共子节点,但是建立新的链表太浪费空间了,我们只要在每个队列访问到头之后调整一下指针就行了,于是代码就出来了:

public ListNode findFirstCommonNode(ListNode pHead1, ListNode pHead2) { 					 
	if(pHead1==null || pHead2==null){
		return null;
	}
	ListNode p1=pHead1;
	ListNode p2=pHead2;
	while(p1!=p2){
		p1=p1.next;
		p2=p2.next;
		if(p1!=p2){
			if(p1==null){
				p1=pHead2;
			}
			if(p2==null){
				p2=pHead1;
			}
		}
	}
	return p1;
}

(5) 拓展 差和双指针

如果你想到了这三种方法中的两个,并且顺利手写并运行出一个来,面试基本就过了,至少面试官对你的基本功是满意的。但是对方可能会再来一句:还有其他方式吗?或者说,有没有申请空间大小是O(1)的方法。

我们前面介绍过双指针,那能否用一下呢?貌似可以,但是不能直接用。

假如公共子节点一定存在第一轮遍历,假设La长度为L1, Lb长度为L2.则| L2-L1 |就是两个的差值。第二轮遍历,长的先走| L2-L1|,然后两个链表同时向前走,结点一样的时候就是公共结点了。

public ListNode findFirstCommonNode(ListNode pHead1, ListNode pHead2) {
	if(pHead1==null || pHead2==null){ 
		return null;
	}
	ListNode current1=pHead1;
	ListNode current2=pHead2;
	int l1=0,l2=0;
	while(current1!=null){
		current1=current1.next;
		l1++;
	}

	while(current2!=null){
		current2=current2.next;
		l2++;
	}
	current1=pHead1;
	current2=pHead2;

	int sub=l1>l2?l1-l2:l2-l1;

	if(l1>l2){
		int a=0;
		while(a<sub){
			current1=current1.next;
			a++;
		}
	}

	if(l1<l2){
		int a=0;
		while(a<sub){
			current2=current2.next;
			a++;
		}
	}
	
	while(current2!=current1){
		current2=current2.next;
		current1=current1.next;
	}
	
	return current1;
}

一个普通的算法,我们整出来了5种方式, 就相当于做了五道题,但是思路比单纯做5道题更加开阔。下一个题我们继续练习这种思路。

1.2 六种判断链表是否为回文序列

这也是一道不难,但是很经典的链表题,请判断一个链表是否为回文链表。

示例1:
输入 : 1->2->2->1 输出 : true
进阶
你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

看到这个题你有几种思路解决,经过前面这些题目的蹂躏,我现在看到这个题瞬间就想到6种解法。虽然有几个是相同的,但是仍然可以是新的方法。

方法1:将链表元素都赋值到数组中,然后可以从数组两端向中间对比。

方法2:将链表元素全部压栈,然后一边出栈,一边重新遍历链表,一边比较,只要有一个不相等,那就不是回文 链表了。

方法3:上面方法的改造,先遍历第一遍,得到总长度。之后一遍历链表,一遍压栈。当到达链表长度一半的位置 之后,就不再压栈,而是一边出栈,一遍遍历,一遍比较,只要有一个不相等,就不是回文链表。

方法4:反转链表法, 先创建一个链表newList,然后原始链表oldList的元素值逆序保存到newList中,然后重新遍历newList和oldList,同时比较元素的值,只要有一个位置的元素值不一样,就不是回文链表。

方法5:将4进行优化,我们其实只反转一半的元素就行了。步骤是:先遍历一遍链表,得到长度,然后重新遍历链表,一边遍历,一边将链表反转。之后到达一半的位置后,就不再反转,而是比较两个链表,只要有一个元素不一 样,就不是回文链表。

方法6:还是对4的改进,我们使用快慢指针 ,fast一次走两步, slow一次走一步。当fast到达表尾的时候, slow正好到达一半的位置,那么接下来可以从头开始逆序一半的元素,或者从slow开始逆序一半的元素,都可以。

方法7:假如使用递归法等等,我们还能相处更多的方法,但是这个没啥意义了。
上面这些解法中,方法1.即使你说了,面试官一般也不会让你写。方法2和3,如果能写出来,算及格,但是面试官问这个题一般就想考察你的链表操作能力,所以链表反转是绕不过去的问题。

但是这里我们尽量不要真的将原始链表给改了,在创建新链表的时候,采用头插法就行了,也就是每次都将要逆序的元素添加到新链表的head后面。

(1)快慢指针+一半反转法

这个实现略有难度,主要是在while循环中pre.next = prepre;和prepre = pre;实现了一边遍历一遍将访问过的链表 给反转了,所以理解起来有些难度,可以在学完链表反转之后再看这个问题。

public boolean isPalindrome(ListNode head) { 
	if(head == null || head.next == null) {
		return true;
	}
	ListNode slow = head, fast = head;
	ListNode pre = head, prepre = null;
	while(fast != null && fast.next != null) {
		pre = slow;
		slow = slow.next;
		//将前半部分链表反转
		fast = fast.next.next; 
		pre.next = prepre;
		prepre = pre;
	}
	if(fast != null) {
		slow = slow.next;
	}
	while(pre != null && slow != null) {
		if(pre.val != slow.val) {
			return false;
		}
		pre = pre.next;
		slow = slow.next;
	}
	return true;
}

(2) 使用栈:全部压栈

将链表元素全部压栈,然后一边出栈,一边重新遍历链表,一边比较,只要有一个不相等,那就不是回文链表了。 代码:

public boolean isPalindrome(ListNode head) {
	ListNode temp = head;
	Stack<Integer> stack = new Stack(); //把链表节点的值存放到栈中
	while (temp != null) {
		stack.push(temp.val);
		temp = temp.next;
	}
	//然后再出栈
	while (head != null) {
		if (head.val != stack.pop()) {
			return false;
		}
	head = head.next;
	}
	return true;
}

(3) 使用栈:部分压栈

改造上面的方法,先遍历第一遍,得到总长度。之后一遍历链表,一遍压栈。当到达链表长度一半的位置之后,就不再压栈,而是一边出栈,一遍遍历,一遍比较,只要有一个不相等,就不是回文链表。代码就是这样:

public boolean isPalindrome(ListNode head) {
	if (head == null)
		return true;
	ListNode temp = head;
	Stack<Integer> stack = new Stack();
	//链表的长度
	int len = 0;
	//把链表节点的值存放到栈中
	while (temp != null) {
		stack.push(temp.val);
		temp = temp.next;
		len++;
	}
	//len长度除以2 len >>= 1;
	//然后再出栈
	while (len-- >= 0) {
		if (head.val != stack.pop())
			return false;
		head = head.next;
	}
	return true;
}

(4) 拓展 递归法

如果对链表逆序打印有没有简单的放呢?有的,而且这个本身就是一个可以考察的算法题,可以这样写:

private void printListNode(ListNode head) {
	if (head == null)
		return;
	printListNode(head.next);
	System.out.println(head.val);
}

也就是说最先打印的是链表的尾结点,是从后往前打印的,如果以后谁再给你说单向链表不能从后往前遍历,你就甩出这段代码。看到这里是不是有灵感了,我们来对上面的对面进行改造一下:

ListNode temp;
public boolean isPalindrome(ListNode head) {
	temp = head;
	return check(head);
}
private boolean check(ListNode head) {
	if (head == null)
		return true;
	boolean res = check(head.next) && (temp.val == head.val);
	temp = temp.next;
	return res;
}

上面的temp就是反转之后的链表。除此之外,还可以单独写个链表反转等方法,但是有点大材小用了,我们就不写了。

1.3 合并有序链表

学完这个专题之后,你会更加相信LeetCode就是在造题。数组中我们研究过合并的问题,链表同样可以造出两个或者多个链表合并的问题。

1.3.1 合并两个有序链表

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

这种题有两种要求,一种是新建一个链表,另外一个就是这里的将一个合并到另外一个去,难度都不大,根据题目要求写就行了。但是代码的好坏却差别很大,例如先看这个不太好的方式:

public ListNode mergeTwoLists (ListNode list1, ListNode list2) {
	ListNode newHead=new ListNode(-1);
	ListNode res=newHead;
	//这么写,减少while的数量,可能耗时会小一些
	while(list1!=null||list2!=null){ 
		//都不为空的情况
		if(list1!=null&&list2!=null){
			if(list1.val<list2.val){
				newHead.next=list1;
				list1=list1.next;
			}else if(list1.val>list2.val){
			newHead.next=list2;
			list2=list2.next;
			//相等的情况,分别接两个链 
			}else{ 
				newHead.next=list2;
				list2=list2.next;
				newHead=newHead.next;
				newHead.next=list1;
				list1=list1.next;
			}
			newHead=newHead.next;
		 
		//情况2:假如还有链表一个不为空
		}else if(list1!=null&&list2==null){
			newHead.next=list1;
			list1=list1.next;
			newHead=newHead.next;
		}else if(list1==null&&list2!=null){
			newHead.next=list2;
			list2=list2.next;
			newHead=newHead.next;
		}
	}
	return res.next;
}

上面这种方式能完成基本的功能,但是所有的处理都在一个大while循环里,代码过于臃肿,我们可以将其拆开, 第一个while只处理两个list 都不为空。之后单独写while分别处理list1或者list2不为null的情况。也就是这样:

public ListNode mergeTwoLists (ListNode list1, ListNode list2) {
	ListNode newHead=new ListNode(-1);
	ListNode res=newHead;
	//这么写,减少while的数量,可能耗时会小一些
	while(list1!=null&& list2!=null){ 
		if(list1.val<list2.val){
			newHead.next=list1;
			list1=list1.next;
		}else if(list1.val>list2.val){
			newHead.next=list2;
			list2=list2.next;
		//相等的情况,分别接两个链
		}else{ 
			newHead.next=list2;
			list2=list2.next;
			newHead=newHead.next; 
			newHead.next=list1;
			list1=list1.next;
		}
		newHead=newHead.next;
	}
	
	while(list1!=null){
		newHead.next=list1;
		list1=list1.next;
		newHead=newHead.next;
	}
	
	while(list2!=null){
		newHead.next=list2;
		list2=list2.next;
		newHead=newHead.next;
	}
	return res.next;
}

上面这个方式的问题是所有的处理都在一个大while循环里,代码过于臃肿,我们可以将其拆开,第一个while只处 理两个list 都不为空。之后判断list1和list2哪个不为空就接到合并后的list后面就行了,循环都不用写,也就是这
样:

class Solution {
	public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
		ListNode prehead = new ListNode(-1);
		ListNode prev = prehead;
		while (l1 != null && l2 != null) {
			if (l1.val <= l2.val) {
				prev.next = l1;
				l1 = l1.next;
			} else {
				prev.next = l2;
				l2 = l2.next;
			}
			prev = prev.next;
		}
		//最多只有一个还未被合并完,直接接上去就行了 ,这是链表合并比数组合并方便的地方
		prev.next = l1 == null ? l2 : l1;
		return prehead.next;
	}
}
拓展:能够给面试官下马威的写法

大部分面试官可能也只知道前面得写法,不见得一下子明白如何通过递归来做。如果面试的时候遇到了,建议先用(2)的写法写出来,面试官满意之后,接着说“我还可以用递归的方式解决这个问题”。

递归我们后面还会分析,这里先简单看一下思想。当我们将list1或者list2中拿到最小的那个结点之后,剩下的不管是list1还是list2都还是完整的链表,而且执行一样的判断逻辑,所以我们就可以通过递归的方式来继续处理:

class Solution {
	public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
		if (l1 == null) {
			return l2;
		}
		if (l2 == null) {
			return l1;
		}
		else if (l1.val < l2.val) {
			l1.next = mergeTwoLists(l1.next, l2);
			return l1;
		}
		else {
			l2.next = mergeTwoLists(l1, l2.next);
			return l2;
		}
	}
}

1.3.3 合并K个链表

合并k个链表,有多种方式,如果面试,我倾向的方式是先将前两个合并,之后再将后面的逐步合并进来,因为这样的话,我只要将两个合并的写清楚就行了,也就是直接调用二中的方法:

class Solution {
	public ListNode mergeKLists(ListNode[] lists) {
		ListNode res = null;
		for (ListNode list: lists) {
			res = merge2Lists(res, list);
		}
		return res;
	}
}

1.3.4 一道很无聊的好题

LeetCode1669题:给你两个链表 list1 和 list2 ,它们包含的元素分别为 n 个和 m 个。请你将 list1 中第 a 个节点 到第 b 个节点删除,并将list2 接在被删除节点的位置。

1669题的意思就是将list1中的[a,b)区间的删掉,然后将list2接进去,你觉得难吗?如果这也是算法的话,我至少可 以造出七八道题,例如:

(1)定义list1的[a,b)区间为list3,将list3和list2按照升序合并成一个链表。

(2)将list2也将区间[a,b)的元素删掉,然后将list1和list2合并成一个链表。

(3)定义list2的[a,b)区间为list4,将list2和list4合并成有序链表。

看到了吗?掌握基础是多么重要,我们自己都能造出题目来。

这也是为什么算法会越刷越少,因为到后面会发现套路就这么写,花样随便换,以不变应万变就是我们的宗旨。

不仅仅造题,链表合并我们还有五六种方法解决,最后拓展部分我们再介绍遍历找到链表1保留部分的尾节点和链表2的尾节点,将两链表连接起来。

class Solution {
	public ListNode mergeInBetween(ListNode list1, int a, int b, ListNode list2) {
		ListNode pre1 = list1, post1 = list1, post2 = list2;
		int i = 0, j = 0;
		while(pre1 != null && post1 != null && j < b){
			if(i != a - 1){
				pre1 = pre1.next;
				i++;
			}
			if(j != b){
				post1 = post1.next;
				j++;
			}
		}
		//寻找list2的尾节点
		post1 = post1.next; 
		while(post2.next != null){
			post2 = post2.next;
		}
		//链1尾接链2头,链2尾接链1后半部分的头 pre1.next = list2;
		post2.next = post1;
		return list1;
	}
}

1.4 双指针专题

在数组里我们介绍过双指针的思想, 可以简单有效的解决很多问题,而所谓的双指针只不过是两个变量而已。在链表中同样可以使用双指针来轻松解决一部分算法问题,这类题目的整体难度不大。

1.4.1 寻找中间结点

题目要求:
给定一个头结点为 head 的非空单链表,返回链表的中间结点。 如果有两个中间结点,则返回第二个中间结点。
示例1
输入: [1, 2, 3, 4, 5]
输出:此列表中的结点 3
示例2:
输入: [1, 2, 3, 4, 5, 6] 输出:此列表中的结点 4

这个问题用经典的快慢指针可以轻松搞定,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。甚至奇数偶数的问题也不用考虑。

class Solution {
	public ListNode middleNode(ListNode head) {
		ListNode slow = head, fast = head;
		while (fast != null && fast.next != null) {
			slow = slow.next;
			fast = fast.next.next;
		}
		return slow;
	}
}

1.4.2寻找倒数第K个元素

这个问题也是经典的快慢双指针问题,先看要求:

输入一个链表,输出该链表中倒数第k个节点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾节点是倒数第 1个节点。
示例
给定一个链表 : 1->2->3->4->5, 和 k = 2. 返回链表 4->5.

使用快慢双指针可以轻松处理该问题,我们将第一个指针fast 指向链表的第 k+1 个节点,第二个指针 slow 指向链 表的第一个节点,此时指针fast 与slow 二者之间刚好间隔 k 个节点。此时两个指针同步向后走,当第一个指针fast 走到链表的尾部空节点时,则此时 slow 指针刚好指向链表的倒数第k个节点。

这里需要强调的是,链表的长度可能小于K,该情况是本题的菁华,必须考虑。

class Solution {
	public ListNode getKthFromEnd(ListNode head, int k) {
		ListNode fast = head;
		ListNode slow = head;
		
		while (fast != null && k > 0) {
			fast = fast.next;
			k--;
		}
		while (fast != null) {
			fast = fast.next;
			slow = slow.next;
		}
		
		return slow;
	}
}

1.4.3 旋转链表

先看题目要求:

给你一个链表的头节点 head ,旋转链表,将链表每个节点向右移动 k 个位置。
示例1,示意图如下
输入:head = [1, 2, 3, 4, 5], k = 2 输出: [4, 5, 1, 2, 3]

在这里插入图片描述

这个题有多种解决思路,首先想到的是根据题目要求硬写,但是这样比较麻烦,也容易错。我们可以这么做: 首先将整个链表反转,然后再将前K个和N-K个分别反转,这样就轻松解决了。请读者自行解决。

如果使用双指针策略有可以这么做:

因为k有可能大于链表长度,所以首先获取一下链表长度len。如果k % len == 0,等于不用旋转,直接返回头结点。否则:

  • 快指针先走k步。
  • 慢指针和快指针一起走。
  • 快指针走到链表尾部时,慢指针刚好走到旋转链表(返回的链表)的尾部。把快指针指向的节点连到原链表头部,慢指针指向的节点断开和下一节点的联系。
  • 返回结束时慢指针指向节点的下一节点。
class Solution {
	public ListNode rotateRight(ListNode head, int k) { 
		if(head == null || k == 0){
			return head;
		}
		ListNode temp = head;
		ListNode fast = head;
		ListNode slow = head;
		int len = 0;
		while(head != null){
			head = head.next;
			len++;
		}
		if(k % len == 0){
			return temp;
		}
		while((k % len) > 0){
			k--;
			fast = fast.next;
		}
		while(fast.next != null){
			fast = fast.next;
			slow = slow.next;
		}
		ListNode res = slow.next;
		slow.next = null;
		fast.next = temp;
		return res;
	}
}

1.4.4 判断链表中是否存在环

这个题目同样特别经典,要求也非常简单:给定一个链表,判断链表中是否有环。拓展问题,假如有环,那么环的位置怎么判断呢?

示例1:
输入:head = [3, 2, 0, -4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

在这里插入图片描述
判断是否有环,最容易的方法是使用Hash,遍历的时候将元素放入到map中,如果有环一定会发生碰撞。发生碰撞的位置也就是入口的位置,因此这个题so easy。如果在工程中,我们这么做就OK了。

代码如下:

public ListNode detectCycle(ListNode head) {
	ListNode pos = head;
	Set<ListNode> visited = new HashSet<ListNode>();
	while (pos != null) {
		if (visited.contains(pos)) {
			return pos;
		} else {
			visited.add(pos);
		}
		pos = pos.next;
	}
	return null;
}

但是如果只有 O(1)的空间该怎么做呢?我们必须逐步讨论了。

首先看如何确定是否有环,最有效的方法就是双指针,一个快指针(一次走两步),一个慢指针(一次走一步)。 如果快的能到达表尾就不会有环,否则如果存在圈,则慢指针一定会在某个位置与快指针相遇。这就像在操场长跑,一个人快一个人慢,只要时间够,快的一定能在某个时候再次追上慢的人(也就是所谓的套圈)。

代码:

public boolean hasCycle(ListNode head) {  
	if(head==null || head.next==null){
		return false;
	}
	ListNode fast=head;
	ListNode slow=head;
	while(fast!=null && fast.next!=null){
		fast=fast.next.next;
		slow=slow.next;
		if(fast==slow)
			return true;
	}
	return false;
}

这里的问题是如果知道了一定有入口,那么如何确定入口的位置呢?

如果不使用双指针,而直接使用hash,则分分钟搞定这个题目。然而这样的话题目就没有思维含量了,所以如果工 程中遇到这个问题就用Hash,但是算法面试中却不行。

先说结论

先按照快慢方式寻找到相遇的位置(假如为下图中Z),然后将两指针分别放在链表头(X)和相遇位置(Z),并 改为相同速度推进,则两指针在环开始位置相遇(Y)。

结论很简单,但这是为什么呢?
①先看一个简单的场景
为了便于理解,我们首先假定快指针在第二次进入环的时候就相遇了:
在这里插入图片描述
此时的过程是:

1.找环中相汇点。分别用fast 、slow表示快慢指针, slow每次走一步, fast就走两步,直到在环中的某个位置相会,假如是图中的Z。

2.第一次相遇:

那么我们可以知道fast指针走了a+b+c+b步,slow指针走了a+b步 那么:2*(a+b) = a+b+c+b,所以a = c

因此此时让slow从Z继续向前走, fast回到起点,两个同时开始走(两个每次都走一步),一次走一步那么它们最终会相遇在y点,正是环的起始点。

② 普通场景

如果是普通场景会怎么样呢?

设链表中环外部分的长度为 a 。slow 指针进入环后,又走了 b 的距离与 fast 相遇。此时, fast 指针已经走完了环的 n 圈,因此它走过的总距离为:

Fast: a+n(b+c)+b=a+(n+1)b+nc

根据题意,任意时刻, fast 指针走过的距离都为slow 指针的 2 倍。因此,我们有 a+(n+1)b+nc=2(a+b),也就是: a=c+(n-1)LEN

由于b+c就是环的长度,假如为LEN,则: a=c+(n-1)LEN

这说明什么呢?说明相遇的时候快指针在环了已经转了(n-1)LEN圈,如果n-1就退化成了我们上面说的一圈的场景。

假如n是2, 3, 4, …呢,这只是说明当一个指针p1重新开始从head走的时候,另一个指针p2从Z点开始,两者恰好在入口处相遇,只不过p2要先在环中转n-1圈。

当然上面的p1和p2要以相同速度,我们发现slow和fast指针在找到位置Z之后就没有作用了,因此完全可以用slow 和fast来代表p1和p2。因此代码如下:

public class Solution {
	public ListNode detectCycle(ListNode head) {
		if (head == null) {
			return null;
		}
		ListNode slow = head, fast = head;
		while (fast != null) {
			slow = slow.next;
			if (fast.next != null) {
				fast = fast.next.next;
			} else {
				return null;
			}
			if (fast == slow) {
				ListNode ptr = head;
				while (ptr != slow) {
					ptr = ptr.next;
					slow = slow.next;
				}
				return ptr;
			}
		}
		return null;
	}
}

1.5 删除链表元素专题

1.5.1 又开始造题了

如果你按照LeetCode顺序一道道刷题,会感觉毫无章法,但是如果将相关类型放在一起,你瞬间发现这不就是在改改条件不断造题吗?

我们前面已经多次见证这个情况,几天集中看一下链表中删除元素的造题合集。如果在链表 中删除元素搞清楚了,一下子就搞定8道题,是不是很爽?一下子看到这么多题目你可能会蒙,但是没关系,我们 后面会有具体的解析

【1】 LeetCode 237:删除某个链表中给定的(非末尾)节点。传入函数的唯一参数为要被删除的节点 。
【2】 LeetCode 203:给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的 节点,并返回新的头节点 。
【3】 LeetCode 19. 删除链表的倒数第 N 个节点
【4】 LeetCode 1474. 删除链表 M 个节点之后的 N 个节点。
【5】 LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。
【6】 LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表 中没有重复出现的数字。
【7】 LeetCode 1836. 从未排序链表中删除重复元素。
下面这个题目比较特殊,我们在后面分析nSum问题的时候统一来看:
【8】 LeetCode 1171 请你编写代码,反复删去链表中由总和值为 0 的连续节点组成的序列,直到不存在这样的序列为止。

我们在链表基本操作部分介绍了删除的方法,至少需要考虑删除头部,删除尾部和中间位置三种情况的处理。而上面这些题目就是这个删除操作的进一步拓展。

1.5.2 删除特定结点

先看一个简单的问题:

给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点
示例1:
输入:head = [1, 2, 6, 3, 4, 5, 6], val = 6 输出: [1, 2, 3, 4, 5]

在这里插入图片描述

一般情况下,我们删除节点cur时,必须知道其前驱pre节点和后继next节点,然后让pre.next=next就讲cur脱离开链表了。 cur节点会在gc进行垃圾回收时回收掉。

对于删除来说,最麻烦的处理是如果删除的元素是首元素该怎么处理,因为其指针移动规律和后面的不一样,为此,我们可以先创建一个虚拟节点 dummyHead,使其指向head,也就是dummyHead.next=head,这样就不用单独处理首节点了。当然,在返回的时候注意要返回的地址是dummyHead.next,而不是dummyHead。

完整的步骤是:

  • 1.我们创建一个虚拟链表头dummyHead,然后使用temp = dummyHead进行链表操作
  • 2.开始循环head链表
  • 3.当该节点的值不等于val时,将temp的next指向该节点,然后将temp指向它的next节点。

这里注意temp在结束时,需要将它的未节点指向None,避免出现符合条件的最后一次赋值后,链接指向错误问题。

代码实现过程:

class Solution {
	public ListNode removeElements(ListNode head, int val) {
		ListNode dummyHead = new ListNode(0);
		dummyHead.next = head;
		ListNode temp = dummyHead;
		while (temp.next != null) {
			if (temp.next.val == val) {
				temp.next = temp.next.next;
			} else {
				temp = temp.next;
			}
		}
		return dummyHead.next;
	}
}

我们继续看下面这几个题,其实就是一个题对不?

LeetCode 19. 删除链表的倒数第 N 个节点
LeetCode 1474. 删除链表 M 个节点之后的 N 个节点。

既然要删除倒数第N个节点,那一定要先找到倒数第N个节点,这本身就是一道算法题,我们前面已经介绍过,而这里的删除不过是找到位置之后将其删除。我们重点看一下19题, 1474题我们就不看了。

这里需要注意的是,我们要删除结点node时,必须依赖其前驱结点,因此我们需要用cur.next是否等于node来判断。具体题目要求

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
进阶:你能尝试使用一趟扫描实现吗?
示例1:
输入:head = [1, 2, 3, 4, 5],n = 2 输出: [1, 2, 3, 5]

在这里插入图片描述

分析

如果不考虑使用一次扫描,我们可以先遍历一遍,找到链表总长度L,然后重新遍历,位置L-N+1的元素就是我们要删的。但是如果要一趟扫描该怎么做呢?

我们前面说过,还是先在脑子里快速过一下常用的数据结构和算法思想,看看哪些看上去能解决问题?

貌似栈可以?先将元素全部压栈,然后弹出第N个的时候就是我们要的? OK ,搞定一种方法。

我们还提到常见的思想中有个双指针,那我们定义first和second两个指针,first先走N步,然后second再开始走,当first走到队尾的时候,second就是我们要的节点对不?

OK ,到此为止,我们的问题就解决了。

方法1:计算链表长度

首先从头节点开始对链表进行一次遍历,得到链表的长度 L。随后我们再从头节点开始对链表进行一次遍历,当遍历到第L-n+1 个节点时,它就是我们需要删除的节点。

虽然这个方式不是最优的,但是确实能解决问题,在很多场景下也能用,所以我们先练一下,至少是个及格分

class Solution {
	public ListNode removeNthFromEnd(ListNode head, int n) {
		ListNode dummy = new ListNode(0);
		dummy.next=head;
		int length = getLength(head);
		ListNode cur = dummy;
		for (int i = 1; i < length - n + 1; ++i) {
			cur = cur.next;
		}
		cur.next = cur.next.next;
		ListNode ans = dummy.next;
		return ans;
		}
		
	public int getLength(ListNode head) {
		int length = 0;
		while (head != null) {
			++length;
			head = head.next;
		}
		return length;
	}
}
方法二: 双指针

我们定义first和second两个指针,first先走N步,然后second再开始走,当first走到队尾的时候,second就是我们要的节点对不?

class Solution {
	public ListNode removeNthFromEnd(ListNode head, int n) {
		ListNode dummy = new ListNode(0);
		dummy.next=head;
		ListNode second = dummy;
		for (int i = 0; i < n; ++i) {
			first = first.next;
		}
		while (first != null) {
			first = first.next;
			second = second.next;
		}
		second.next = second.next.next;
		ListNode ans = dummy.next;
		return ans;
	}
}

1.5.3 删除重复元素的三道题

我们继续看三组关于结点删除的题:

【1】 LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。
【2】 LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表 中没有重复出现的数字。
【3】 LeetCode 1836. 从未排序链表中删除重复元素。

LeetCode82和83 这两个题其实是一个,区别就是一个要将出现重复的保留一个,一个是只要重复都不要了,这种细微的差别我们处理起来并不是难事。这也再次用事实说明LeetCode不需要全部刷完,高质量刷三四百道就足够了。这里为了完整,我们还是都看一下。

LeetCode 1836虽然也是在82的基础上改了一下条件,将链表改成无序的了,这个难度要增加不少,接下来也一起分析。

【1】重复元素保留一个

我们还是先看题目要求:

LeetCode83 存在一个按升序排列的链表,给你这个链表的头节点 head ,请你删除所有重复的元素,使每个元素只出现一次。返回同样按升序排列的结果链表。

示例1:
输入:head = [1, 1, 2, 3, 3]
输出: [1, 2, 3]

在这里插入图片描述

由于给定的链表是排好序的,因此重复的元素在链表中出现的位置是连续的,因此我们只需要对链表进行一次遍历,就可以删除重复的元素。

具体地,我们从指针 cur 指向链表的头节点,随后开始对链表进行遍历。如果当前 cur 与cur.next 对应的元素相同,那么我们就将cur.next 从链表中移除;否则说明链表中已经不存在其它与cur 对应的元素相同的节点,因此可 以将 cur 指向 cur.next 。当遍历完整个链表之后,我们返回链表的头节点即可。

另外要注意的是 当我们遍历到链表的最后一个节点时, cur.next 为空节点,如果不加以判断,访问 cur.next 对应 的元素会产生运行错误。因此我们只需要遍历到链表的最后一个节点,而不需要遍历完整个链表。

上代码:

class Solution {
	public ListNode deleteDuplicates(ListNode head) {
		if (head == null) {
			return head;
		}
		ListNode cur = head;
		while (cur.next != null) {
			if (cur.val == cur.next.val) {
				cur.next = cur.next.next;
			} else {
				cur = cur.next;
			}
		}
		return head;
	}
}
【2】重复元素都不要

LeetCode82:这个题目的要求与83的区别仅仅是重复的元素都不要了。例如:

示例1:
输入:head = [1, 2, 3, 3, 4, 4, 5]
输出: [1, 2, 5]

在这里插入图片描述

如果换成数组,解决起来比链表要麻烦,因为数组必须考虑元素指向和后序移动问题,而对于链表则直接对cur.next 以及 cur.next.next 两个node进行比较就行了。但是这里要注意两个node可能为空节点,稍加判断就行了。

public ListNode deleteDuplicates(ListNode head) {
	if (head == null) {
		return head;
	}
	
	ListNode dummy = new ListNode(0, head);
	
	ListNode cur = dummy;
	while (cur.next != null && cur.next.next != null) {
		if (cur.next.val == cur.next.next.val) {
			int x = cur.next.val;
			while (cur.next != null && cur.next.val == x) {
				cur.next = cur.next.next;
			}
		} else {
			cur = cur.next;
		}
	}
	return dummy.next;
}
【3】从未排序链表中删除重复元素

如果将82中的排序链表改成无序的该怎么删呢?

示例1:
输入:head = [3, 2, 2, 1, 3, 2, 4]
输出 : [1, 4]

分析这个题目之前,我们先造一个题:无序链表中,重复元素只保留一个,该怎么做?思路不复杂就是借助一个 hash,遍历的时候一边访问元素,一边写到hashMap中,如果发生碰撞,就将当前结点删除,因此一次遍历就行 了。

但是这个题要求重复的都不要,那一次遍历就不行了。我们需要扫描两遍+一个HashMap, HashMap 记录每个不同 node.val 的出现次数。第一遍扫描的时候记录每个不同 node.val 的出现次数,第二遍扫描的时候,创建一个虚拟 dummy 节点,用dummy.next去试探下一个节点是否是需要删除的节点,如果是,就直接跳过即可。

/**
* Definition for singly-linked list.
* public class ListNode {
*     int val;
*     ListNode next;
*     ListNode() {}
*     ListNode(int val) { this.val = val; }
*     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
	public ListNode deleteDuplicatesUnsorted(ListNode head) {
		// dummy - 最后需要return用的
		HashMap<Integer, Integer> map = new HashMap<>(); 
		// dummy2 - 第二遍扫描的时候需要用的,因为涉及到删除操作,所以只能用 .next试探 		
		ListNode dummy = new ListNode(0);
		ListNode dummy2 = dummy;
		dummy.next = head;
		
		ListNode cur = head;
		while (cur != null) {
			map[cur.val]++;
			cur = cur.next;
		}
		while (dummy2.next != null) {
			if (map[dummy2.next.val] > 1) {
				dummy2.next = dummy2.next.next;
			} else {
				dummy2 = dummy2.next;
			}
		}
		return dummy.next;
	}
}

1.5.4一个特殊的结点删除问题

LeetCode 237:删除某个链表中给定的(非末尾)节点。传入函数的唯一参数为要被删除的节点 。

示例1:
输入:head = [4, 5, 1, 9], node = 5
输出: [4, 1, 9]
解释:给定你链表中值为 5 的第二个节点,那么在调用了你的函数之后,该链表应变为 4 -> 1 -> 9.

示例2:
输入:head = [4, 5, 1, 9], node = 1
输出: [4, 5, 9]
解释:给定你链表中值为 1 的第三个节点,那么在调用了你的函数之后,该链表应变为 4 -> 5 -> 9.

这里的意思是给你的node节点不是链表的头节点,而是直接要删除的节点。例如上面给的node=5,这个node是 直接要删除的,而不是首节点,那就不用使用前面的方式先遍历找前驱,再删除了。

那该怎么删呢, 其实也不难,我们可以采用数组移动的思想,将node后面的元素值逐个覆盖其前面的元素就行了。

例如下面这个图:
在这里插入图片描述

我们要删除的node=3,那我们就用node.next的值4来覆盖3。然后后面的5覆盖4就可以了。这就是数组删除元素的套路嘛(又是套路)

所以,代码就可以这么写:

class Solution {
	public void deleteNode(ListNode node) {
		node.val = node.next.val;
		node.next = node.next.next;
	}
}

你是不是觉得写错了,为啥只有两行?但是确实想清楚之后,两行就解决了。

上面这种移动元素值的情况遇到的很少,一般的算法题,如果我们想移动值,或者将其先保存到数组里,会被面试官毙掉,所以我们不到万不得已,不能用这种方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值