从头再来!社招找工作——算法题复习二:链表
链表
链表是一个基本的数据结构,它和数组有相似之处也有区别之处,在Java中我们常用ArrayList或LinkedList来创建链表。这些是老生常谈了,我们直接看经典的链表算法题,在实践中巩固知识。还是要提一句,多画图,尤其是链表题。
链表题一般都会给出已经设计好的一个链表结点类:
public ListNode {
int val;
ListNode next;
ListNode();
ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
然后,一个链表算法题会给你一个链表的头结点head,让你写代码,最终要么返回一个新的链表头结点,要么没有返回值继续让head作为头结点。
下面是一些经典编程题,感谢牛客网提供的题目,另外还有测试平台,大家可以在上面测试代码能否通过牛客网提供的各种测试用例。
Example 1 合并两个有序链表
给定两个无环有序递增的链表List1和List2的头结点head1和head2,想要返回将两个链表合并起来后仍然有序递增的链表List。有返回值,返回的是新链表List的头结点head.
这题比较简单,可以直接将链表看作是数组,不用画图也可以想象出来。代码如下:
public void mergeTwoSortedLists(ListNode head1, ListNode head2) {
ListNode preHead = new ListNode(-1, null);
ListNode curr = preHead; // 因为curr要随着head1和head2的遍历的深入不断深入的,所以我们需要找一个头结点的前一个结点,是为preHead
while (head1 != null && head2 != null) {
if (head1.val <= head2.val) { // curr链接到较小的那个
curr.next = head1;
head1 = head1.next;
}
else {
curr.next = head2;
head2 = head2.next;
}
curr = curr.next;
}
curr.next = (head1 != null) ? head1 : head2; // 哪个链表还有剩余,就链接到哪个
return preHead.next; // 这个头结点的前一个结点在这里发挥了作用
}
Example 2 链表相加
给定两个无环有序递增的链表List1和List2的头结点head1和head2,想要返回将两个链表按位相加之后的链表List(链表结点的值可以大于等于10)。如果两个链表的长度不同,那么可以看作是短链表后面补0,补到与长链表长度相同。有返回值,返回的是新链表List的头结点head.
经过上一题的铺垫,本题应该也没什么难度。代码如下:
public void addTwoLists(ListNode head1, ListNode head2) {
ListNode preHead = new ListNode(-1, null);
ListNode curr = preHead; // 原理同上
while (head1 != null && head2 != null) {
curr.next = new ListNode(head1.val + head2.val, null); // 本题规定了“链表结点的值可以大于等于10”,因此不必考虑进位的事情
head1 = head1.next;
head2 = head2.next;
curr = curr.next;
}
curr.next = (head1 != null) ? head1 : head2; // 因为短链表的不足的部分将补齐0,所以和上一题一样,哪个链表还有剩余,就链接到哪个
return preHead.next;
}
Example 3 反转链表
给定一个无环链表的头结点head,请返回一个逆序的链表,该链表仍然使用head作为头结点。
这个题目,其实将代码背下来也很容易,但要彻底理解,还是要根据代码画个图。
先给出代码如下:
public void reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode tmp = curr.next;
curr.next = prev;
prev = curr;
curr = tmp;
}
return prev;
}
我们再结合代码来画个图,加深一下理解:
还有两次循环没有画出来,大家可以自己画一下,加深印象。
Example 4 链表的倒数第k个结点
给定一个结点个数大于k的无环链表,现想要返回该链表的的倒数第k个结点
首先不难想到一种方法:先将链表全部遍历一遍,计算得到链表的长度n,再将链表从头遍历n-k个结点,即可找到倒数第k个结点。该方法代码比较简单清晰,代码就不给出了。
结合上一题反转链表,有些朋友应该已经想到了第二种方法:先反转链表,再从反转链表的头开始遍历k个结点,就可以得到原链表的倒数第k个结点。该方法的代码只需要再上一题的代码上扩展即可。
这里直接给出第三种方法:设置两个结点node1和node2,node1首先从head开始遍历k个结点。接下来,node1遍历整个链表直至末尾,同时,node1每次遍历一个结点的时候node2也从head开始遍历一个结点。假设链表的长度为n,那么node1接下来只会遍历n-k次,而同时node2也遍历了n-k次,刚好到达倒数第k个结点!!!
Amazing! 我们给出代码:
public void lastKthNode(ListNode head, int k) {
ListNode node1 = head;
ListNode node2 = head;
for (int i = 0;i < k;i++) {
node1 = node1.next;
}
while (node1 != null) {
node1 = node1.next;
node2 = node2.next;
}
return node2;
}
Example 5 找到两个链表的第一个公共结点
给定两个链表list1和list2的头结点head1和head2,它们可能是有公共结点的。如果没有,返回null;如果有,返回第一个公共结点。
首先承认,一开始准备题目的时候,这道题我是没什么思绪的,最后不得已去翻了牛客网上的原题题解。这道题的解法也很精妙,与Example 4的第三种方法类似,也要用两个结点去遍历!
设置两个结点node1和node2,node1链接到head1,node2链接到head2。随后,node1开始遍历list1,node2开始遍历list2。运气好的话,如果list1和list2的长度一样,这一遍就能遇到node1==node2的时候;运气不好也没关系,无论node1和node2哪个遍历到空结点,都换一个链表继续从头遍历。list1的长度+list2的长度,肯定等于list2的长度+list1的长度,所以两个一定同时遍历结束。如果两个链表存在公共结点的话,node1和node2总有一天是会相等的。而它们第一次相等的时候,就是两个链表的第一个公共结点!
根据以上逻辑可以写出代码:
public void firstCommonNode(ListNode head1, ListNode head2) {
ListNode node1 = head1;
ListNode node2 = head2;
for (int i = 1;i <= 2;i++) { // 让node1=head2和node2=head1各能执行一次
while (node1 != null && node2 != null) {
node1 = node1.next;
node2 = node2.next;
}
if (node1 == null) {
node1 = head2;
}
if (node2 == null) {
node2 = head1;
}
}
while (node1 != null && node2 != null && node1 != node2) {
node1 = node1.next;
node2 = node2.next;
}
return node1; // node1要么为null,要么为第一个公共结点
}
Example 6 判断链表中是否有环
给定一个链表,想要知道它是否有环。
这道题要用到一个新奇的算法:快慢指针。快指针每次遍历两个结点,慢指针每次遍历一个结点。看起来快慢结点平平无奇的,能判断出链表中是否含有环吗?答案是肯定的。
假设一个链表没有环,那么快指针一定会提前遍历完整个链表;假设一个链表有环,那么快指针和慢指针都会进入环内。由于快指针每次遍历两个结点,慢指针每次遍历一个结点,所以如果以慢指针为参考系(回忆一下,这是高中物理知识!),就好像慢指针没有动,快指针每次遍历一个结点,在这个环中追逐慢指针。显然,快指针是一定能够和慢指针相遇的,这就证明了链表有环。
让我们一起写下这段代码:
public boolean doesListHasLoop(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast.next != null || fast.next.next != null) { // 防止越界;越界意味着没有环
fast = head.next.next; // 快指针遍历两次
slow = slow.next; // 慢指针遍历一次
if (fast == slow) {
return true;
}
}
return false;
Example 7 找到有环链表的环的入口
显然,我们要用到上一题中的我们知道了可以使用快慢指针定性分析环、环的入口的存在,但是怎么样定量分析得到环的入口的位置呢?
接下来就用用数字来计算了。如图所示:
那么代码就很简单,再设置一个新的指针,让它和慢指针一起一个结点一个结点地遍历,遍历x次(x是几根本不重要)后就会相遇,相遇处就是环的入口。代码如下:
public Node entryOfListLoop(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast.next != null || fast.next.next != null) { // 防止越界;越界意味着没有环
fast = head.next.next; // 快指针遍历两次
slow = slow.next; // 慢指针遍历一次
if (fast == slow) {
ListNode curr = head;
while (curr != slow) {
curr = curr.next;
slow = slow.next;
}
return curr;
}
}
return null;
Example 8 链表的奇数位、偶数位分开(奇数值、偶数值分开)
给定一个链表的头结点head,现在希望将该链表中的第1个、第3个、第5个等奇数结点连成一个链表,第2个、第4个、第6个等偶数结点连成另一个链表。返回这两个新链表的头指针。
熟练掌握链表的结构,这道题应该只能算是道饭后甜点了:
public void lastKthNode(ListNode head) {
ListNode preHeadOdd = new ListNode(-1, null);
ListNode preHeadEven = new ListNode(-1, null);
ListNode currOdd = preHeadOdd;
ListNode currEven = preHeadEven;
int cnt = 1;
while (head != null) {
if (cnt % 2 == 1) {
currOdd.next = head;
currOdd = currOdd.next;
}
else {
currEven.next = head;
currEven = currEven.next;
}
head = head.next;
cnt++;
}
return preHeadOdd.next, preHeadEven.next;
}
大家可以思考一道类似的题目:如果是把链表中所有值为奇数和值为偶数的拆开成为两条链表,代码该怎么写?
Example 9 删除有序递增链表中的重复值只保留一个(全部删除不保留)
给定一个有序递增的链表,其中链表值有重复的也有不重复的。现希望如果有重复值,就删除重复的直至所有原来有的值都出现且只出现一次。
这道题需要精妙的链表操作,代码如下:
public ListNode deleteDuplicates(ListNode head) {
if (head == null || head.next == null) { // 此处可进行特殊处理
return head;
}
ListNode curr = head;
while (curr.next != null) {
if (curr.val == curr.next.val) { // 如果下一个结点的值与当前结点的值相同
curr.next = curr.next.next; // 说明下一个结点不应该出现在链表中
}
else {
curr = curr.next; // 否则,就正常遍历下一个结点
}
}
return head;
}
一个变体问题是,只要有重复的值,那么这个值都要被删掉一个不留。这个又怎么写代码呢?
显然这个就比原题难度提升了一点。此时,我们要记录一个curr的前一个结点pre,这样才能让curr结点在遍历使重复值的时候,放心的与后面结点去比较,如果真的有相同的值,那么pre结点后面的那串重复值结点都可以去掉链接了。
代码如下:
public ListNode deleteDuplicates(ListNode head) {
if (head == null || head.next == null) { // 此处可进行特殊处理
return head;
}
ListNode preHead = new ListNode(-1, head);
ListNode pre = preHead;
ListNode curr = head;
while (curr != null && curr.next != null) { // 多了个curr != null,因为curr可能遍历着遍历着自己就空了
int value = curr.val; // 先把值另行保存起来
boolean flag = true; // 用于记录是否发生过重复值事件
if (curr.next.val == value) { // 出现重复值了
flag = false;
while (curr.next != null && curr.next.val == value) {
curr.next = curr.next.next;
}
}
if (flag) { // 没有重复值,正常遍历
pre = pre.next;
curr = curr.next;
else { // 直接扔掉所有重复值结点
curr = curr.next; // 当前结点是重复值第一次出现的地方,也要扔掉!
pre.next = curr;
}
}
return preHead.next;
}
链表题总结
解决链表问题,首先是要多画图,将每个结点与它的下一个结点的链接关系画清楚,链表问题就能解决掉一大半。另外,还有些特殊技巧,比如双指针、快慢指针,则是需要积累经验。相信经过今天的多道编程题的考验,大家对于解决链表题应该很有自信了吧。最后再给牛客网打个免费广告,大家可以去这上面试试。这些题都通过了,大家再去Leetcode上解决题目也更游刃有余了。