编程导航算法通关村第一关 | 链表高频面试题

86 阅读12分钟

两个链表第一个公共子节点

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

单链表中每个节点只能指向唯一的下一个next,但是可以有多个指针指向一个节点。例如上面c1就可以被a2,b3同时指向。该怎么入手呢?如果一时想不到该怎么办呢?

告诉你一个屡试不爽的方法:将常用数据结构和常用算法思想都想一遍,看看哪些能解决问题。

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

首先想到的是蛮力法,类似于冒泡排序的方式,将第一个链表中的每一个结点依次与第二个链表的进行比较,当出现相等的结点指针时,即为相交结点。虽然简单,但是时间复杂度高,排除!

再看Hash,先将第一个链表元素全部存到Map里,然后一边遍历第二个链表,一边检测当前元素是否在Hash中,如果两个链表有交点,那就找到了。OK,第二种方法出来了。既然Hash可以,那集合呢?和Hash一样用,也能解决,OK,第三种方法出来了。

队列和栈呢?这里用队列没啥用,但用栈呢?现将两个链表分别压到两个栈里,之后一边同时出栈,一边比较出栈元素是否一致,如果一致则说明存在相交,然后继续找,最晚出栈的那组一致的节点就是要找的位置,于是就有了第四种方法。

哈希

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;
}

这里需要使用两个栈,分别将两个链表的结点入两个栈,然后分别出栈,如果相等就继续出栈,一直找到最晚出栈的那一组。这种方式需要两个O(n)的空间

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;
}

判断链表是否为回文序列

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

方法1:将链表元素都赋值到数组中,然后可以从数组两端向中间对比。这种方法会被视为逃避链表,面试不能这么干。

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

方法3:优化方法2,先遍历第一遍,得到总长度。之后一边遍历链表,一边压栈。到达链表长度一半后就不再压栈,而是一边出栈,一边遍历,一边比较,只要有一个不相等,就不是回文链表。这样可以节省一半的空间。

方法4:优化方法3:既然要得到长度,那还是要遍历一次链表才可以,那是不是可以一边遍历一边全部压栈,然后第二遍比较的时候,只比较一半的元素呢?也就是只有一半的元素出栈, 链表也只遍历一半,当然可以。

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

方法6:优化方法5,我们只反转一半的元素就行了。先遍历一遍,得到总长度。然后重新遍历,到达一半的位置后不再反转,就开始比较两个链表。

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

方法8:在遍历的时候使用递归来反转一半链表可以吗?当然可以,再组合一下我们还能想出更多的方法,解决问题的思路不止这些了,此时单纯增加解法数量没啥意义了。

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;
}

合并有序链表

合并两个有序链表

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

逐一比较两个链表中每个元素的大小,并按照顺序插入新的链表中,最后将其返回

public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
    ListNode preHead = new ListNode(-1);
    ListNode pre = preHead;
    while (list1 != null && list2 != null) {
        if (list1.val <= list2.val) {
            pre.next = list1;
            list1 = list1.next;
        }else {
            pre.next = list2;
            list2 = list2.next;
        }
        pre = pre.next;
    }
    // 最多只有一个没有被合并,这时候判断并合并
    pre.next = list1 == null ? list2 : list1;
    return preHead.next; 
}

合并 K 个链表

先将前两个合并,然后将后面的逐步合并进来(这里只展示比较简单的方法)

public ListNode mergeKLists(ListNode[] lists) {
    ListNode res = null;
    for (ListNode list: lists) {
        res = mergeTwoList(res, list);
    }
    return res;
}
public ListNode mergeTwoList(ListNode list1, ListNode list2) {
    ListNode preHead = new ListNode(-1);
    ListNode pre = preHead;
    while (list1 != null && list2 != null) {
        if (list1.val <= list2.val) {
            pre.next = list1;
            list1 = list1.next;
        }else {
            pre.next = list2;
            list2 = list2.next;
        }
        pre = pre.next;
    }
    pre.next = list1 == null ? list2 : list1;
    return preHead.next;
}

一道很无聊的题

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

根据遍历下标找到目标链表节点,然后让其指向第二个链表的头节点和尾节点

class Solution {
    public ListNode mergeInBetween(ListNode list1, int a, int b, ListNode list2) {
        ListNode pre = list1, post1 = list1, post2 = list2;
        int i = 0, j =0;
        while(j < b && pre != null && post1 != null) {
            if(i != a-1) {
                pre = pre.next;
                i++;
            }
            if(j != b) {
                post1 = post1.next;
                j++;
            }
        } 
        while(post2.next != null) {
            post2 = post2.next;
        }
        pre.next = list2;
        post2.next = post1.next;
        return list1;
    }
}

双指针

寻找中间节点

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

创建快慢指针,快指针一次走两步,慢指针一次走一步。这样,当快指针到达尾部时,慢指针一定是在链表中间,这时候将链表返回就行了

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

寻找倒数第k个元素

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

跟前面的题差不多,使用快慢指针,先将fast指针向后遍历 k+1 个节点,slow 仍指向链表的第一个节点,这时候 fast 和 slow 两个指针之间整好间隔 k 个节点,然后两个指针同时向后走,直到 fast 指针走到空节点时,slow 指针整好指向链表的倒数第 k 个节点

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;
}

旋转链表

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

我们可以将链表分为两个部分,后面 k 个元素为一部分,然后将这部分拼接到头部,就实现整体移动

k 的大小可能会超过链表,所以这里需要使用取余的思想:k = k % count

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;
        }
        // 使用取模,是为了防止k大于len
        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;
    }
}

删除链表元素

如果按照LeetCode顺序一道道刷题,会感觉毫无章法而且很慢,但是将相似类型放在一起,瞬间就发现不过就是在改改条件不断造题。我们前面已经多次见证这个情况,现在集中看一下与链表删除相关的问题。如果在链表中删除元素搞清楚了,一下子就搞定8道题,是不是很爽?

  • LeetCode 237:删除某个链表中给定的(非末尾)节点。传入函数的唯一参数为要被删除的节点。
  • LeetCode 203:给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点 。
  • LeetCode 19. 删除链表的倒数第 N 个节点。
  • LeetCode 1474. 删除链表 M 个节点之后的 N 个节点。
  • LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。
  • LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中没有重复出现的数字。

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

删除特定节点

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台 完整的步骤是:

  • 1.我们创建一个虚拟链表头dummyHead,使其next指向head。
  • 2.开始循环链表寻找目标元素,注意这里是通过cur.next.val来判断的。
  • 3.如果找到目标元素,就使用cur.next = cur.next.next;来删除。
  • 4.注意最后返回的时候要用dummyHead.next,而不是dummyHead。

代码:

class Solution {
    public ListNode removeElements(ListNode head, int val) {
        ListNode dummyNode = new ListNode(-1);
        dummyNode.next = head;
        ListNode cur = dummyNode;
        while(cur.next != null) {
            if(cur.next.val == val) {
                cur.next = cur.next.next;
            }else{
                cur = cur.next;
            }
        }
        return dummyNode.next;
    }
}

删除倒数第n个节点

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

关键字倒数,一般碰到这种题型就用快慢指针来找到那个元素,fast 需要先走 k+1 布,再同步 slow,两个指针一起移动,直到 fast == null

什么时候需要用到虚拟头节点呢?当头节点可能会被改变或者涉及的时候,就需要头节点,比如这一题,头节点也可能是需要删除的节点,因此使用虚拟头节点

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode demo = new ListNode(0);
        demo.next = head;
        ListNode fast = demo;
        ListNode slow = demo;

        for(int i=0; i<n;i++){
            fast = fast.next;
        }

        while(fast.next != null) {
            fast = fast.next;
            slow = slow.next;
        }
        slow.next = slow.next.next;
        return demo.next;
    }
}

删除重复元素

重复元素保留一个

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

我们从指针 cur 指向链表的头节点,随后开始对链表进行遍历。如果当前 cur 与cur.next 对应的元素相同,那么我们就将cur.next 从链表中移除;否则说明链表中已经不存在其它与cur 对应的元素相同的节点,因此可以将 cur 指向 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;
    }
}

重复元素都不要

力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台

当一个都不要时,链表只要直接对cur.next 以及 cur.next.next 两个node进行比较就行了,这里要注意两个node可能为空,稍加判断就行了

class Solution {
    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;
    }
}

再论第一个公共子节点问题

拼接两个字符串

先看下面的链表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开始,两个链表是一样的了,自然4就是要找的节点,所以可以通过拼接的方式来寻找交点。这么做的道理是什么呢?

我们可以从几何的角度来分析。我们假定A和B有相交的位置,以交点为中心,可以将两个链表分别分为left_a和right_a,left_b和right_b这样四个部分,并且right_a和right_b是一样的,这时候我们拼接AB和BA就是这样的结构:

image.png

我们说right_a和right_b是一样的,那这时候分别遍历AB和BA是不是从某个位置开始恰好就找到了相交的点了?

这里还可以进一步优化,如果建立新的链表太浪费空间了,我们只要在每个链表访问完了之后,调整到一下链表的表头继续遍历就行了,于是代码就出来了:

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if(headA == null || headB == null) {
            return null;
        }
        ListNode p1 = headA;
        ListNode p2 = headB;
        while(p1 != p2) {
            p1 = p1.next;
            p2 = p2.next;
            if(p1 != p2) {
                if(p1 == null) {
                    p1 = headB;
                }
                if(p2 == null) {
                    p2 = headA;
                }
            }
        }
        return p1;
    }
}

循环体里为什么需要加一个判断if (p1 != p2) 。简单来说,如果序列不存在交集的时候陷入死循环,例如 list1是1 2 3,list2是4 5 ,很明显,如果不加判断,list1和list2会不断循环,出不来。

差和使用双指针

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

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if(headA == null || headB == null) {
            return null;
        }
        ListNode current1=headA;
        ListNode current2=headB;
        int l1=0,l2=0;
        //分别统计两个链表的长度
        while(current1!=null){
            current1=current1.next;
            l1++;
        }

        while(current2!=null){
            current2=current2.next;
            l2++;
        }
        current1=headA;
        current2=headB;
        int sub=l1>l2?l1-l2:l2-l1;
        //长的先走sub步
        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;
    }
}