这篇文章将分析面试算法题中链表高频题中和链表元素删除有关的问题。先看下面这三个问题,你能做出来吗?
LeetCode 203:给你一个链表的头结点head和一个整数val,删除链表中所有满足node.val == val的节点,并返回新的头结点。
LeetCode 19: 删除链表中倒数第K个节点
LeetCode 83: 删除某个升序链表中的重复元素,使重复的元素都只出现一次
LeetCode 82: 删除某个升序链表中的重复元素且不保留,即只含原始链表中没有重复出现过的元素。
看到这些题目你会怎么解决呢?如果分开做这些题目,也许会非常没有章法,但在本专题中我们把同一类型的题放在一起,你会发现,哦,原来这些题目都有自己固定的套路,换来换去还是那些东西,万变不离其宗。让我们一起来看看这几道题背后的规律。
1.删除元素的万能方法
删除元素的万能方法,就是找到目标节点,保存目标节点的前驱节点的引用,让前驱结点的next,指向目标节点的后继节点。这样,没有目标节点的引用,它将被gc回收。图示如下:
这里cur是前驱节点的引用。删除元素步骤如下:首先我们在题目中通过各种判断,确定cur.next指向的,就是我们需要删除的目标节点 。然后,把cur.next.next赋值给cur.next。
如果你感觉有些晕的话,让我来详细给你分析分析:cur.next指向目标节点。把cur.next作为一个整体看成一个指向目标节点的引用。
那么这个引用(cur.next)的.next,就是目标节点的next实例变量。也就是说,cur.next.next就是目标节点的next,指向后继结点。
现在我们已经知道cur.next.next指向目标节点的后继结点了。那回到之前所说的,把cur.next.next赋值给cur.next,这也就是说,让cur.next也指向后继结点。
哈哈,笔者画图到这里也感到很兴奋,大家注意到没有,此时前驱结点的next已经指向了后继结点。那原来的目标节点呢?已经不连接在链表中了,并因其没有引用变量指向,将会被gc回收。
这样,通过前驱节点的引用cur,让cur.next = cur.next.next,我们就完成了对目标节点的删除。
但这个时候有些细心的小伙伴可能会有疑问:你这删除是讲清楚了,但是我发现这样的删除需要有前驱节点和后继节点,那如果是删除链表头结点(没有前驱结点)或是删除链表尾结点(没有后继结点),那怎么办呢?哈哈,先告诉结论:链表尾结点的状况,上述做法可以包含进来。而链表头结点,要么分情况讨论,要么使用我们接下来使用的方法:建立虚拟链表头结点。容我为你细细道来。
1)cur.next = cur.next.next 包含删除尾结点情况
如图,当目标节点为尾结点时,cur.next.next == null,这样cur.next会被赋值为null
也就相当于删除了尾结点。
由此我们得出:cur.next = cur.next.next是可以适用于尾结点删除情况的。
2)构造虚拟头结点来应对删除链表头结点的情况
在上文中我们得出尾结点是包含在cur.next = cur.next.next中的。但是对于头结点就不好使了。为什么呢?因为如果后继结点是null,那还可以把null赋给一个引用,但是如果前驱结点cur为null,那么cur.next就会直接抛出一个异常了!那我们怎么办呢?第一种方法是if-else判断,如果删除元素是头结点就直接返回head.next。但是这样写的话代码比较乱,这里介绍一种更好的办法:构造虚拟头结点。这是啥意思呢?所谓虚拟头结点,就是在原链表的头结点前方接上一个节点。这样,原链表的所有结点在新链表中都不是头结点了,自然就可以沿用上面的赋值式。
如图,先创建虚拟头结点temp(值设为-1),然后让它的next指向原链表头结点,然后让cur初始化为temp(cur = temp),这样cur就永不为null,删除节点永远有前驱节点,cur.next = cur.next.next就可以继续使用。下面是构建虚拟头结点后,删除原链表头结点时的过程。
这样我们就成功用cur.next = cur.next.next完成了头结点的删除。
需要注意的是,此方法到最后,返回删除元素后的新链表的头结点时,应该返回的是temp.next,而不是temp。
基础知识到这里就讲解完毕了。接下来让我们看上述提到的LeetCode四道题。
2.面试题目讲解
1)LeetCode 203:给你一个链表的头结点head和一个整数val,删除链表中所有满足node.val == val的节点,并返回新的头结点。
先给出链表定义:
public class ListNode { public int val; public ListNode next; }
public static ListNode deleteListNode(ListNode head,int val){ //删除所有val结点,删除节点的方式是找到前驱节点cur,让cur.next = cur.next.next ListNode temp = new ListNode(-1); temp.next = head; ListNode cur = temp;//构造了带虚拟头结点的链表并初始化cur //用cur.next.val判断是因为cur.next.val == val判断cur.next是否为目标节点, //这样cur就是前驱结点的引用了。我们在删除节点时必须考虑到保留前驱结点引用。 while (cur.next != null){ if (cur.next.val == val){ cur.next = cur.next.next;//删除目标节点。cur不移动,继续与下一个节点比较 }else { cur = cur.next;//如果当前cur.next不符合删除要求,cur 向链表后方移动一次再次比较 } } return temp.next; }
解析:这里假设val为5就删除
最后返回temp.next,新链表头结点。
2)LeetCode 19: 删除链表中倒数第K个节点
这里我们使用双指针法,先让快慢指针步差为K+1,然后快慢指针同步移动,这样当快指针指向链表末尾的null时(你也可以理解成链表最后一个元素是倒数第1节点,最后一个元素的null指针域是倒数第0节点),即快指针指向倒数第0个节点时,因为步差K+1,所以这时慢指针指向倒数第K+1个节点,也就是前驱节点。不知道你发现没有,2)题这类问题的核心,就是在于寻找删除节点的前驱结点。
然后按部就班的删除即可。
public static ListNode deleteLastKByTwoPointers(ListNode head,int k){ ListNode temp = new ListNode(-1); temp.next = head; ListNode slow = temp; ListNode fast = temp; //初始化,虚拟头结点构造,快慢指针指向虚拟头结点 //fast先走k+1步 for (int i = 0; i < k+1; i++) { fast = fast.next; } while (fast != null){ fast = fast.next; slow = slow.next; }//同步前进直到fast为null slow.next = slow.next.next;//删除待删除的节点 return temp.next; }
3)LeetCode 83: 删除某个升序链表中的重复元素,使重复的元素都只出现一次
LeetCode 82: 删除某个升序链表中的重复元素且不保留,即只含原始链表中没有重复出现过的元素。
这类问题的关键在于你的cur引用是指向第一个重复元素,还是指向第一个重复元素的前驱结点。
这样会留下一个重复节点,即LeetCode83.
public static ListNode deleteRepeatAndSaveOne(ListNode head){ if (head == null){ return null; } //这题不用构建虚拟头结点 ListNode cur = head; while (cur.next != null){ //逐个逐个比较直到后继结点为null if (cur.val == cur.next.val){ //如果值相同,则删除cur.next, cur不移动,和下一个cur.next继续比较 cur.next = cur.next.next; }else { cur = cur.next;//如果值不同,则移动cur至后继结点,开始下一轮比较 } } return head; }
这样重复值节点一个也不会留下,即LeetCode82
public static ListNode deleteRepeatButSaveNone(ListNode head){ //删除全部重复元素不保留,此时需要虚拟头结点,因为如果头结点就是重复节点,就要删除头结点 ListNode temp = new ListNode(-1); temp.next = head; ListNode node = temp;//构造虚拟头结点和初始化node = temp while (node.next != null && node.next.next != null){ if (node.next.val == node.next.next.val){ //为了防止有三个及以上重复元素,应该先将重复元素存储在变量中 //然后逐个比较,一直删除到不是重复数据域的元素为止 int repeatData = node.next.val; while (node.next != null && node.next.val == repeatData){ //只要node.next的数据域等于保存的repeatData, //就删除node.next,循环删除至没有repeatData节点 node.next = node.next.next; } }else { node = node.next;//如果这两个节点的val不相等,node前进一步继续比较 } } return temp.next; }
我们发现,其实单链表删除都是一样的套路。找好前驱节点,明晰删除条件,遍历小心谨慎,脑中不断构建图,其实单链表删除真的不难。
写本篇文章至深夜,很是辛苦,大家如果有收获,能给孩子一点小小的鼓励么?