链表面试笔试经典题目详细解析,题目多解法,复杂度分析

前言❤️❤️

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(这个是需要画图细品的,只在脑子里想不好想到有这种情况)

  • 代码如下:

    java 复制代码
    class 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就是倒置链表的头节点,直接访问即可

  • java 复制代码
    class 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)次,如果比较的节点上的元素都相等,就认为这个链表是个回文链表

  • java 复制代码
    class 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两个链表的长度,接着搞两个引用分别指向两个链表的头节点,长的链表的引用先走,走长度的差值步,接着两个引用同时往后走,每次都比较指向的节点是否相同,这样就能找到相交节点

  • java 复制代码
    public 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,跑的最快的人会套圈跑的最慢的人)

  • java 复制代码
    public 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,区别分析

有两个经典面试题,这两个问题的回答并不完全相同

  1. 链表和顺序表的区别(广义上的概念)
  2. ArrayList和LinkedList的区别(只讨论Java标准库的实现,狭义的概念)

两个问题回答相同的地方:

  • 在存储空间上,顺序表物理上一定是连续的;链表逻辑上连续,物理上不一定连续
  • 随机访问,顺序表的时间复杂度是O(1);链表的时间复杂度是O(N)
  • 头插,顺序表需要搬移元素,时间复杂度为O(N);链表只需要修改引用的指向,时间复杂度是O(1)
  • 扩容,顺序表在空间不够时会自动扩容;链表中没有扩容的概念
  • 应用场景,当需要随机访问时,就用顺序表;需要顺序访问,不要求连续的内存空间或者是对于扩容敏感的场景,就用链表

两个问题回答不同的地方:

  1. 尾插,顺序表的时间复杂度是O(1);链表遍历找到末尾,时间复杂度是O(N)
  2. 尾插,ArrayList的时间复杂度是O(1);Java中的LinkedList做了优化,底层代码记录了尾部节点的位置,所以时间复杂度也是O(1)
  1. 中间位置插入,顺序表触发搬运,时间复杂度是O(N);链表给出插入的位置,时间复杂度为O(1)
  2. 中间位置插入,ArrayList触发搬运,时间复杂度是O(N);Java中的LinkedList这个地方设计的是不太好的,还需要遍历到中间位置,时间复杂度也是O(N)(C++链表就可以通过迭代器指定插入位置,中间位置插入元素的时间复杂度就是O(1))

结语

  • 这些题目还是比较复杂的,这些题目博主一年前就已经练习过,但是再次做这些题目,写代码画图分析,还要分析很长时间
  • 这些题目思考解法的时候需要静下心来去细品,力扣上不通过是很常见的事情,有时候可能是部分用例通过不了,就需要针对用例,代入到代码中进行认真的分析,看是不是遗漏了什么,或者什么地方的逻辑是错的
  • 对于初学数据结构的铁汁,画上几天时间,分析透这些题目都是不为过的,分析透后,代码能力就会有一个很大的提升

以上就是今天的所有内容啦~完结撒花~🥳🎉🎉

相关推荐
白狐_7982 小时前
408 数据结构|KMP做题方法:next、nextval和高频易错点
数据结构·算法
黄敬峰3 小时前
一文搞懂结构化大模型输出:从 SSE 到 Tool Calling 的四种姿势
面试
星星落进兜里3 小时前
Redis 内存缓存,面试补充
数据库·redis·面试
蒸蒸yyyyzwd4 小时前
cpp 选手秋招学习笔记 day27
服务器·c++·面试·八股
来一碗刘肉面4 小时前
平衡二叉树的删除
数据结构·算法
wabs6664 小时前
关于二叉树【力扣199.二叉树的右视图的思考】
数据结构·c++·算法·leetcode·二叉树·层序遍历
hope_wisdom5 小时前
C/C++数据结构之二叉树的遍历
c语言·数据结构·c++·二叉树·深度优先·广度优先
Cccp.1235 小时前
【leetcode】(四)排序算法汇总和链表
leetcode·链表·排序算法
程序员清风5 小时前
聊聊怎么缓解找工作的焦虑感?
java·后端·面试