前言❤️❤️
hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
对链表完成一些特定的操作,是笔试面试中的常考题型,很多题目都需要借助画图来解决,有的题目在代码中可能稍微代码顺序变化一点,就会出现异常,因此分析时需要细品一下🐵
非常建议铁汁们看完解析后,先自己尝试下写代码,不要直接看题解代码;或者直接不看解析,先画个图,看有没有什么思路🐵
这个专栏的数据结构是代码都是用Java来写的,JavaSE专栏现在已经全部更新完成,铁汁们复习基础知识时非常推荐使用,可以试一下💪💪💪
🎇个人主页:洋不写bug的博客
🎇所属专栏:数据结构专栏
🎇复习Java基础知识:Java学习之旅,从入门到进阶
🎇铁汁们对于数据结构基础的各种核心知识(不太常用的也有😆),都可以在上面的数据结构专栏学习,专栏正在持续更新中🐵🐵,有问题可以写在评论区或者私信我哦~
1,移除链表元素

-
这个题目的链表不是LinkedList,链表类在右边的答题框中,注意元素属性是val,不是value
-

-
题目要求是删除所有匹配的元素,上篇博客模拟实现的remove方法是只删除匹配到的第一个元素,如果拿上篇中模拟实现的remove方法改装下,是否可行呢?
-
假设要删除元素2,prev第一次定位到了元素1的位置,接着prev.next = prev.next.next,确实删除了元素2
-

-
但是在下一轮循环中,prev = prev.next,prev移动到第二个元素2的位置,判断时是判断prev.next的元素值和传入的元素值是否相同,那第二个元素2,也就漏掉了
-

-
既然单独搞一个prev不行,拿我们就搞一个cur,搞一个prev,cur节点是在prev节点后面,可以先不考虑头节点,最后再考虑
-

-
每次比较cur节点的val值和要删除的val值是否相同,如果不相同,那就cur节点和prev节点都向后移动一位
如果相同,那就说明cur节点要删除,就直接让prev.next = cur.next,让prev指向cur下个节点的位置,接着cur = prev.next(如下图),这样就不会再漏掉元素了 -

-
最后结束的标准就是cur走到null的位置,删除操作就完成,代码如下:
java
class Solution {
public ListNode removeElements(ListNode head, int val) {
ListNode prev = head;
ListNode cur = prev.next;
while(cur != null){
if(cur.val == val){
prev.next = cur.next;
cur = prev.next;
}else{
prev = prev.next;
cur = cur.next;
}
}
}
}
- 最后再考虑一些特殊情况,head为null和需要删除head的情况,完整代码如下:
java
class Solution {
public ListNode removeElements(ListNode head, int val) {
if(head == null){
return null;
}
ListNode prev = head;
ListNode cur = prev.next;
while(cur != null){
if(cur.val == val){
prev.next = cur.next;
cur = prev.next;
}else{
prev = prev.next;
cur = cur.next;
}
}
if(head.val == val){
head = head.next;
}
return head;
}
}
2,反转链表

-
这个题在考虑解法时,重点是不能让节点内存被释放掉,也就是丢失
例如1这个节点的next改为null,那就没有引用指向节点2了,节点2的内存就会被释放,所以修改节点1的next时,要提前用引用指向节点2 -

-
那就要创建3个引用,prev、cur、next
cur表示当前要修改指向的节点,prev引用表示cur的上个节点,next引用表示cur的下个节点(这样能保证cur在修改指向后,cur原先指向的节点内存不会被释放) -

-
执行完成cur.next = prev后,prev,cur,next三个引用再向右移动一步;当cur移动到null的时候,链表就翻转成功了
-

-
在写代码时,三个引用的转化这里要细品一下,像下面这样写,三个引用的转化我们看着好像是没问题,但是提交后就会显示空指针异常
java
class Solution {
public ListNode reverseList(ListNode head) {
if(head == null){
return null;
}
ListNode prev = null;
ListNode cur = head;
ListNode next = cur.next;
while(cur != null){
cur.next = prev;
prev = cur;
cur = next;
next = cur.next;
}
return prev;
}
}
-
当cur指向最后一个节点时,这时候cur不是null,进入while循环,cur = next这个代码就会把cur的值改为null,接下来执行next = cur.next,调用null的next方法,自然会有空指针异常
-

-
解决方法也很简单,把next = cur.next放在while循环最前面,这样while执行一次后,cur跟next是指向相同的位置的,next引用的更新放到下一轮执行(如果cur的值为null,也就没有下一轮,这样next也就不用更新了)
java
class Solution {
public ListNode reverseList(ListNode head) {
if(head == null){
return null;
}
ListNode prev = null;
ListNode cur = head;
ListNode next = cur.next;
while(cur != null){
next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
}
3,链表的中间节点

- 这个比较简单,写个size方法,统计下链表的长度,从head处往后移动size/2步(这个size/2可能是小数,要转为int类型)
java
class Solution {
public ListNode middleNode(ListNode head) {
ListNode middle = head;
int size = size(head);
for(int i = 0;i < (int)(size/2);i++){
middle = middle.next;
}
return middle;
}
public int size(ListNode head){
int size = 0;
while(head != null){
size++;
head = head.next;
}
return size;
}
}
- 在size方法中是可以直接head = head.next的,原链表的内容不会被释放,这里size方法中使用的head相当于是一个引用副本
4,返回倒数第k个节点

- 跟上个题目类似,写个size方法,从head处往后移动size - k步,就到达了要读取的节点(因为k保证是有效的,就不用写合法性校验代码了)
java
class Solution {
public int kthToLast(ListNode head, int k) {
ListNode cur = head;
for(int i = 0;i < size(head) - k;i++){
cur = cur.next;
}
return cur.val;
}
public int size(ListNode head){
int size = 0;
while(head != null){
head = head.next;
size++;
}
return size;
}
}
5,合并两个有序链表

-
这个题目如果用傀儡节点(搞一个节点放在链表第一个节点前面),就会方便很多
-
如下图,创建个傀儡节点,上面加两个引用newHead和newTail,newHead方便最后返回结果(直接返回newHead.next即可),newTail方便往新的合成链表中添加元素
-

-
就拿示例1来举例,当cur1.val <= cur2.val时,newTail.next = cur1,newTail就是当前合成链表的末尾,newTail移动到cur1处,同时cur1往后移动一步
-

-
当cur1.val > cur2.val时,newTail.next = cur2,newTail移动到cur2处,同时cur2往后移动一步
-

-
一直按照这个套路来搞,最后cur1和cur2有一个会先变成null(铁汁们可以画图模拟下)
这样至少有一个节点是没有加到合并链表中的,处理思路也很简单,如果是cur1 != null,那就直接newTail.next = cur1,如果是cur2 != null,那就newTail.next = cur2 -

-
就算cur2后面还有很多数据,进行newTail.next = cur2后,链表也就合并完成了
-

-
返回的是合并链表的头节点,直接返回newHead.next(如果不创建傀儡节点,还需要在中间用变量来存储头节点的位置,还要写代码判断头节点在哪个链表中,就比较麻烦)
java
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if(list1 == null){
return list2;
}
if(list2 == null){
return list1;
}
ListNode cur1 = list1;
ListNode cur2 = list2;
ListNode newHead = new ListNode(0);
ListNode newTail = newHead;
while(cur1 != null && cur2 != null){
if(cur1.val < cur2.val){
newTail.next = cur1;
newTail = cur1;
cur1 = cur1.next;
}else{
newTail.next = cur2;
newTail = cur2;
cur2 = cur2.next;
}
}
if(cur1 != null){
newTail.next = cur1;
}
if(cur2 != null){
newTail.next = cur2;
}
return newHead.next;
}
}
6,分割链表

- 题目的意思结合示例很好理解,下面说不需要保留每个分区中节点的初始位置(也就是原来节点5在节点4的后面,对链表进行分割后,节点5是可以在节点4的前面的)
- 这个题目在牛客也有其他版本,就是需要保存各个分区节点的初始位置,那这个题目我们就也按成需要保留初始位置来做
-
可以搞两个链表small和big,small链表存放小于x的节点,big链表存放大于等于x的节点,然后遍历原链表,比x小的节点插入到small链表,比x大的节点插入到big链表,最后把small链表和big链表连起来,各个分区节点的初始位置是不会变的
-
就搞两个傀儡节点,再建立个head和tail引用,tail指向当前链表的末尾元素,方便后续插入新节点
-

-
每次比较cur.val跟x的大小关系,如果cur.val小于x,那就smallTail.next = cur,smallTail = cur,接着cur = cur.next
-

-
如果cur.val大于等于x,那就是bigTail.next = cur,bigTail = cur,cur = cur.next
-

-
一直重复这个过程,直到cur等于null,整个过程走完如下图,直接smalTail.next = bigHead.next即可,最后返回smallHead.next即可,就是构建出的新链表
如果原链表的最后一个元素小于x的话,那么bigTail就会指向smallTail,bigTail本身应该指向null的,这样搞就形成了一个循环链表😅,所以最后要加上bigTail.next = null(这个是需要画图细品的,只在脑子里想不好想到有这种情况) -

代码如下:
javaclass Solution { public ListNode partition(ListNode head, int x) { ListNode smallHead = new ListNode(0); ListNode smallTail = smallHead; ListNode bigHead = new ListNode(0); ListNode bigTail = bigHead; ListNode cur = head; while(cur != null){ if(cur.val < x){ smallTail.next = cur; smallTail = cur; }else{ bigTail.next = cur; bigTail = cur; } cur = cur.next; } smallTail.next = bigHead.next; bigTail.next = null; return smallHead.next; } }7,回文链表

-
因为链表是只能从后往前遍历的,所以就可以把链表复制一份,把复制出的链表进行倒置,接着遍历倒置后的链表和原链表,看元素是否相同
-
复制链表的时候搞个傀儡节点,倒置链表后,prev就是倒置链表的头节点,直接访问即可
-
javaclass Solution { public boolean isPalindrome(ListNode head) { ListNode cur = head; ListNode newHead = new ListNode(0); ListNode newTail = newHead; while(cur != null){ ListNode newNode = new ListNode(cur.val); newTail.next = newNode; newTail = newNode; cur = cur.next; } ListNode prev = newHead; cur = newHead.next; ListNode next = cur.next; while(cur != null){ next = cur.next; cur.next = prev; prev = cur; cur = next; } ListNode cur1 = head; ListNode cur2 = prev; while(cur1 != null){ if(cur1.val != cur2.val){ return false; } cur1 = cur1.next; cur2 = cur2.next; } return true; } } -
复制链表 + 反转链表,因为多复制了一个链表,空间复杂度就是O(N),题目中最下面提到了可以用O(1)的空间复杂度来解决这个问题,那这样肯定是不能再复制一份链表的
-
不能复制,那就只能在原来的链表上进行比较,那是不是可以把原链表从中间位置划分开来,然后把后半部分反转一下,再进行比较呢?
-
需要写一个size方法,计算出链表的长度,接着让mid从head开始,移动size / 2步,mid指向的节点就属于是链表后半部分的第一个节点
-

-
当链表中的节点个数为奇数时,链表的后半部就会比前半部分多出来一个元素,在比较时,就可以比较int(size / 2)次,如果比较的节点上的元素都相等,就认为这个链表是个回文链表
-
javaclass Solution { public int size(ListNode head){ ListNode cur = head; int size = 0; while(cur != null){ size++; cur = cur.next; } return size; } public boolean isPalindrome(ListNode head) { int size = size(head); ListNode mid = head; int half = (int)(size / 2); for(int i = 0;i < (int)(size / 2);i++){ mid = mid.next; } ListNode prev = null; ListNode cur = mid; ListNode next = cur.next; while(cur != null){ next = cur.next; cur.next = prev; prev = cur; cur = next; } ListNode cur1 = head; ListNode cur2 = prev; while(half != 0){ if(cur1.val != cur2.val){ return false; } cur1 = cur1.next; cur2 = cur2.next; half--; } return true; } }8,相交链表

-
这个题目就是两个链表,在某个节点处相交,这个形状只有可能是"Y"形的,不可能是"X"形的,如下图
-

-
解法特别简单,写个size方法,统计出AB两个链表的长度,接着搞两个引用分别指向两个链表的头节点,长的链表的引用先走,走长度的差值步,接着两个引用同时往后走,每次都比较指向的节点是否相同,这样就能找到相交节点
-
javapublic class Solution { public int size(ListNode head){ int size = 0; ListNode cur = head; while(cur != null){ size++; cur = cur.next; } return size; } public ListNode getIntersectionNode(ListNode headA, ListNode headB) { ListNode curA = headA; ListNode curB = headB; int sizeA = size(headA); int sizeB = size(headB); if(sizeA > sizeB){ for(int i = 0;i < sizeA - sizeB;i++){ curA = curA.next; } }else{ for(int i = 0;i < sizeB - sizeA;i++){ curB = curB.next; } } while(curA != curB){ curA = curA.next; curB = curB.next; } return curA; } }9,环形链表Ⅰ

-
环形链表很好理解,正常链表最后一个节点应该是指向null的,环形链表最后一个节点又指向前面一个节点
-
如果想要遍历循环链表,会进入死循环,因为会重复的遍历一些节点,利用这一点,创建个顺序表,用来存储节点,在遍历时把节点存入顺序表,再用contains方法看这个节点在顺序表中是否已经存在了(也可以创建个链表来存储这些节点,但是链表的contains方法效率是低于顺序表的),如果已经存在,那这个链表就是循环链表
java
public class Solution {
public boolean hasCycle(ListNode head) {
List<ListNode> list = new ArrayList<>();
ListNode cur = head;
while(cur != null){
list.add(cur);
cur = cur.next;
if(list.contains(cur)){
return true;
}
}
return false;
}
}
-
但是如果面试时使用这种解法,面试官很大概率是不满意的,题目下方也提到可以用O(1)的空间复杂度来解决这个问题(不额外创建顺序表)
-

-
判断是不是循环链表有一个很好的解法,那就是使用快慢指针(快慢引用),先让slow引用和fast引用都指向head,slow引用一次走一步,fast引用一次走两步,如果不是循环链表,那两个引用是永远不可能相遇的,如果是循环链表,那fast和slow是一定会相遇的(就类似于在操场跑1KM,跑的最快的人会套圈跑的最慢的人)
-
javapublic class Solution { public boolean hasCycle(ListNode head) { 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; } }10,环形链表Ⅱ

-
这个题目相当于是升级版,还需要找出链表第一个入环的节点,依然使用快慢引用,我们先画图分析下
首先,fast和slow的相遇点是一定在环上的(fast绕回去才会与slow相遇) -

-
假设环的长度为R,从头节点的入口点的距离为L,从相遇点到入口点的距离是X,如下图
slow移动的距离就是L + R - X
fast移动的距离就是L + 2R - X
fast移动的距离是slow移动距离的二倍,列个等式,就能计算出L = X -

-
推导出L = X,那就可以让cur1从头节点开始,cur2从相遇点开始,同时往后走,就能够在入口点相遇,就能找到入口点了
-
代码中创建个isCycle变量,判断出不是循环链表的话,就直接返回null
java
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
boolean isCycle = false;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
isCycle = true;
break;
}
}
if(isCycle == false){
return null;
}
ListNode cur1 = head;
ListNode cur2 = fast;
while(cur1 != cur2){
cur1 = cur1.next;
cur2 = cur2.next;
}
return cur1;
}
}
11,区别分析
有两个经典面试题,这两个问题的回答并不完全相同
- 链表和顺序表的区别(广义上的概念)
- ArrayList和LinkedList的区别(只讨论Java标准库的实现,狭义的概念)
两个问题回答相同的地方:
- 在存储空间上,顺序表物理上一定是连续的;链表逻辑上连续,物理上不一定连续
- 随机访问,顺序表的时间复杂度是O(1);链表的时间复杂度是O(N)
- 头插,顺序表需要搬移元素,时间复杂度为O(N);链表只需要修改引用的指向,时间复杂度是O(1)
- 扩容,顺序表在空间不够时会自动扩容;链表中没有扩容的概念
- 应用场景,当需要随机访问时,就用顺序表;需要顺序访问,不要求连续的内存空间或者是对于扩容敏感的场景,就用链表
两个问题回答不同的地方:
- 尾插,顺序表的时间复杂度是O(1);链表遍历找到末尾,时间复杂度是O(N)
- 尾插,ArrayList的时间复杂度是O(1);Java中的LinkedList做了优化,底层代码记录了尾部节点的位置,所以时间复杂度也是O(1)
- 中间位置插入,顺序表触发搬运,时间复杂度是O(N);链表给出插入的位置,时间复杂度为O(1)
- 中间位置插入,ArrayList触发搬运,时间复杂度是O(N);Java中的LinkedList这个地方设计的是不太好的,还需要遍历到中间位置,时间复杂度也是O(N)(C++链表就可以通过迭代器指定插入位置,中间位置插入元素的时间复杂度就是O(1))
结语
- 这些题目还是比较复杂的,这些题目博主一年前就已经练习过,但是再次做这些题目,写代码画图分析,还要分析很长时间
- 这些题目思考解法的时候需要静下心来去细品,力扣上不通过是很常见的事情,有时候可能是部分用例通过不了,就需要针对用例,代入到代码中进行认真的分析,看是不是遗漏了什么,或者什么地方的逻辑是错的
- 对于初学数据结构的铁汁,画上几天时间,分析透这些题目都是不为过的,分析透后,代码能力就会有一个很大的提升
以上就是今天的所有内容啦~完结撒花~🥳🎉🎉

