面试常考的链表算法集锦

最常用的链表算法集锦

一.单链表的反转_递归&迭代

1.1 问题描述

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
进阶:
你可以迭代或递归地反转链表。你能否用两种方法解决这道题?

1.2 思路及复杂度分析

反转单链表,直观的理解,就是将每个节点的指针指向它的上一个节点。显然,这是一个循环操作。而循环操作中除了原生的循环之外,还包括迭代递归两种特殊场景下的循环。

很多人搞不清楚迭代与递归有怎样的爱恨情仇,其实答主同样是如此。我们来迈过这个砍——

迭代与递归都是循环的一种。从代码结构上看,递归重复调用函数自身。而迭代只是循环代码的一部分

另外,迭代中变量的结果会保存作为下一次循环的初始值。考虑递归时不要为难自己的大脑,想清楚一层就ok了,

递归本质上就是一个问题的演化。

好了,回归正题。我们首先选择好理解的迭代实现方式。既然需要变后继节点为前驱节点,我们不妨申请两个指针,指针curr指向当前节点,指针prev指向前驱节点。让curr指向head节点,而prev自然声明为null。在迭代部分分为两步,第一步即将当前节点的指针指向上一个节点第二步需要为下一次的迭代变量确定初始值,具体操作是curr,prev指针均后移即可。迭代部分确定了,我们再来看边界条件:这里循环结束的条件比较难确认,需要手绘试一下。最终能同时满足链表长度为空,只有一个元素时代码仍可以正常运行的边界条件是curr!=null.

再来看递归实现,递归的问题规模可能很多,但每一层实际做的不过是调用函数自身。递归的问题我们只需要抓住两个核心点:递归公式+递归终止条件。这里的递归终止条件很朴素,也就是head!=null && head.next!=null.所以我们需要考虑的只有递归公式。假设F表示的是反转关系,则F(n)表示第n层的链表反转,那F(n)可以表示为第n层的头结点与第n-1层反转后的结果整体做反转。有点拗口,我们用数学公式描述一下——
F ( n ) = F ( h e a d , F ( n − 1 ) ) F(n)=F(head,F(n-1)) F(n)=F(head,F(n1))
那么这个数学公式用代码怎么转化呢?其实就很简洁了.

ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;

1.3 趣味图解

双指针实现,图来自https://leetcode-cn.com/problems/reverse-linked-list/solution/fan-zhuan-lian-biao-shuang-zhi-zhen-di-gui-yao-mo-/

img

递归实现,图来自https://leetcode-cn.com/problems/reverse-linked-list/solution/fan-zhuan-lian-biao-shuang-zhi-zhen-di-gui-yao-mo-/

img

1.4 代码演示

/**
  Definition for singly-linked list.
  public class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
  }
 */

class Solution {
  //迭代解法
  public ListNode reverseList(ListNode head) {
      //声明两个指针,分别指向当前元素和上一个元素
   ListNode curr = head;
    ListNode pre = null;
    while(curr != null){
      //得到下一次操作的节点
      ListNode node = curr.next;
      //反转两个节点的指针
      curr.next = pre;
      //下一次反转的初始状态
      pre = curr;
      curr = node;
    }
    return pre;
  }

  //递归解法
  public ListNode reverseList(ListNode head) {
    if(head==null || head.next==null){
      return head;
    }
    ListNode newHead = reverseList(head.next);
    head.next.next = head;
    head.next = null;
    return newHead;
  }

}

二.回文链表的常规与递归解法

2.1 问题描述

请判断一个链表是否为回文链表。

示例 1:

输入: 1->2
输出: false
示例 2:

输入: 1->2->2->1
输出: true
进阶:
你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?


2.2 思路及复杂度分析

老规矩先审题,将链表是否是回文的问题转化一下,即以中间节点作为分界点,前半部分与后半部分的反转对应节点位置上的数值相同,显然步骤已经呼之欲出了。但在上手之前,我们有必要先入为主地判断下代码的执行效率与内存消耗。

我们知道判断时间复杂度只需要找到量级最大的代码就行了。找中间节点使用快慢指针的方式,时间复杂度为O(n).反转代码刚写过,O(n)没意外,比较对应位置上元素是否相等,不严谨的说还是O(n).那空间复杂度呢?

假如使用递归方式的反转链表,因为要保存局部变量就还会用到栈,不推荐,相对而言,使用迭代的方式在运行时不需要额外的空间,所以空间复杂度为O(1),就它了!

接下来,就是思路剖析环节——

  1. 首先找中间节点,无疑使用快慢指针的方式。但这里有一个小细节,假如在偶数场景下,我们将中间之前的节点作为中间节点,在后面判断时就会出现head.next.next。虽然逻辑满分,但实际运行时非常容易出现空指针错误。所以将中间偏后的节点作为中间节点是首选。
  2. 反转没得说,建议使用迭代方式降低内存消耗。
  3. 最终判断环节,显然后半部分是更短的,那循环结束条件自然是满足限制大的,即满足后半部分边界条件即可。

递归解法?

逛了下leetcode评论区,朋友们清一色的留下<递归,永远的的神>这样的评论。

我不由得老脸一红,不用递归实现下岂不是没有牌面,那使用递归怎么实现呢?

在刚看到这道题的时候,相信很多朋友碎碎念,要是用数组实现就舒服了,因为数组能够实现反序遍历,但链表不行,至少严谨的说是这样的。

诚然,单链表没有前驱指针。但并不是说没有前驱指针链表的反向遍历就不能实现!!!

前面谈及解决递归问题有两个核心:递归终止条件和递归公式。这里,终止条件无疑是head==null,。再假设F表示逆序遍历的关系,则第n层的逆序遍历可以表示为:
F ( n ) = F ( h e a d , F ( n − 1 ) ) F(n)=F(head,F(n-1)) F(n)=F(head,F(n1))
到这里,非常有必要在纸上画类似这样的一张图——

img

图来自 https://blog.csdn.net/LutherK/article/details/105046744

所以对链表的逆序遍历是这样子的——

public void reverseTraversalListNode(ListNode head) {
    if (head == null)
        return;
    reverseTraversalListNode(head.next);
    System.out.print(head.val);
}

那我们只需要在实现逆序遍历的同时判断顺序遍历和逆序遍历对应的值是否相等即可,代码非常简洁,但我写不出来。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ocI41pI4-1612873105140)(http://qnuez6z2b.hn-bkt.clouddn.com/typora/下载 (1)].png)

福利环节

img


.3 趣味图解

图片来自 :https://leetcode-cn.com/problems/palindrome-linked-list/solution/hui-wen-lian-biao-by-leetcode-solution/

img

2.4 代码演示

常规解法

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public boolean isPalindrome(ListNode head) {
        if(head==null || head.next==null)
            return true;
        //偶数和技术确定重点的方式不同,但都使用双指针机制
        ListNode slow = head,fast = head;
        while(fast!=null && fast.next!= null){
            slow = slow.next;
            fast = fast.next.next;
        }
        if(fast != null)
            slow = slow.next;
        //以中点为界将链表分成两部分
        fast = slow;
        //反转后半部分
        fast = reverseList(fast);
        //遍历比较
        while(fast != null){
            if(head.val != fast.val)
                return false;
            else{
                head = head.next;
                fast = fast.next;
            }
            
        }
        return true;   
    }

  public ListNode reverseList(ListNode head) {
      //声明两个指针,分别指向当前元素和上一个元素
   ListNode curr = head;
    ListNode pre = null;
    while(curr != null){
      //得到下一次操作的节点
      ListNode node = curr.next;
      //反转两个节点的指针
      curr.next = pre;
      //下一次反转的初始状态
      pre = curr;
      curr = node;
    }
    return pre;
  }
}

迭代解法

ListNode temp;

public boolean isPalindrome(ListNode head) {
    temp = head;
    return check(head);
}

private boolean check(ListNode head) {
    if (head == null)
        return true;
    boolean res = check(head.next) && (temp.val == head.val);
    //顺序遍历的指针指向下一个节点
    temp = temp.next;
    return res;
}

三 .链表是否有环

3.1 问题描述

给定一个链表,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true 。 否则,返回 false 。

进阶:

你能用 O(1)(即,常量)内存解决此问题吗?

示例 1:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-veD1lP0Q-1612873105147)(C:\Users\zyz\AppData\Roaming\Typora\typora-user-images\image-20210206224828269.png)]

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

3.2 思路及复杂度分析

在我看来,解决算法问题最重要的是对问题的抽象和转化,那么链表是否有环这个问题要从何下手分析呢?

链表有环的具体表现为在遍历链表时,对相同的节点会遍历不止一次。所以直观地看,我们只需要判断在遍历链表时是否有节点被二次遍历即可,也就是说,我们需要使用一种可以保证元素唯一性的数据结构来存顺序添加链表元素,显然,那就是散列表咯。

只是呢,引入散列表会导致空间复杂度为O(n),这可不能入各位看官的法眼。继续出发咯——

快慢指针

在初中时,我们经常会碰到相遇问题,这类问题说明了在一个环形空间中,速度不一致的两个物体总会在某一时刻相遇。

对应到环形链表,同样如是。

我们只需声明一个快指针,一个慢指针。快指针每次指向它的后继节点的后继节点。而慢指针只需循规蹈矩地指向它的后继节点即可。在每次遍历的时候都去判断是否出现快慢指针指向一致的情况就ok了。

在逛leetcode评论区的时候,发现有大佬去测试不同跳指针的优劣。对此我个人觉得没有必要,映射到实际问题,要想让两个人尽快相遇,这取决于两个人速度的关系和跑道的长度。抛开跑道长只谈速度的关系意义不大。

从思路上来说,编写快慢指针的代码很舒服,那它的时间空间复杂度是不是也是如此呢?

快慢指针的相遇取决于问题的规模N,所以它的时间复杂度为O(n),在代码运行时并不需要申请额外空间,空间复杂度为O(1)。nice~ 看起来是要皆大圆满的节奏,但是从思维挑战来说,故事才刚刚开始——

链表反转后判断头结点

先给出结论:有环链表反转后头节点与反转前头结点相同。为了得到这个结论,动手是必然的——

IMG_20210206_234155

在反转的基础上加一个判断就行了。而它的时间空间复杂度自然同反转链表一致,分别为O(n),O(1).

到这里,才算是皆大圆满了,碎觉觉。可是呢,大晚上的,本来挺困,画完贼精神。算法果然比咖啡管用~

img

3.3 趣味图解

图片来自于:https://leetcode-cn.com/problems/linked-list-cycle/solution/lian-biao-you-huan-zui-jia-jie-da-by-tinet-shenjg/

img

图片来自于:https://leetcode-cn.com/problems/linked-list-cycle/solution/dong-hua-yan-shi-141huan-xing-lian-biao-b99vd/

image-20210206235258719

3.4 代码演示

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public boolean hasCycle(ListNode head) {
        //哈希表
        Set<ListNode> set = new HashSet<>();
        while(head != null){
            if(!set.add(head)){
                return true;
            }
            head = head.next;
        }
        return false;
    }

    public boolean hasCycle(ListNode head) {
        //如果链表为空,则不成环
        if(head == null)
            return false;
        //声明一个快指针
        ListNode fast = head;
        //快指针走两个格,慢指针走一格,判断能否相遇
        while(fast!=null && fast.next != null){
            head = head.next;
            fast = fast.next.next;
            if(head == fast)
                return true;
        }
        return false;
    }

    public boolean hasCycle(ListNode head) {
        if(head==null || head.next==null)
            return false;
        ListNode curr = head;
        ListNode prev = null;
        while(curr != null){
            //得到要反转的下一个节点
            ListNode tmp = curr.next;
            //反转
            curr.next = prev;
            //得到下一个状态的初始值
            prev = curr;
            curr = tmp;
        }
        //有环的链表反转后头结点与反转前头结点相同
        if(prev == head)
            return true;
        return false;
    }  
}

四.合并两个有序链表


4.1 问题描述

image-20210208222849950

4.2 思路及复杂度分析

老规矩,先审题。将两个有序的链表合并为一个有序的链表最直观简洁的方式就是合并链表再排序咯,这样问题就被转化为了排序问题。排序时可以考虑使用原地排序的桶排序算法,时间空间复杂度都为O(n),效率还是很不错的。

但排序不是我们的核心内容,至少目前不是。要合并为一个有序的单链表,就必然会涉及基于遍历的比较。而遍历链表,也就是循环的实现通常有两种,即迭代和递归。这里两种方法都能实现目标。让我们一探究竟咯——

迭代

迭代是我们最长使用的循环方式,再啰嗦下,迭代的典型特点是每次迭代的结果会作为下一次迭代的初始值。

迭代的内容即是找出两个链表所比较元素的较小值,然后需要指针指向这个较小值作为合并后链表的元素。也就是说我们需要一个指针始终指向下一个比较得到的较小值。映射到第一次的比较,就会很明了的想到,需要一个哨兵节点

那哨兵节点需要满足什么条件呢?不存储元素且指向为空。这里有一个小技巧,为了不发生空指针错误,我们必须让引用指向一块内存空间。而使用带参数的构造方式时可以将数据域声明为-1,也就是不合法的数据,这样就不会干扰数据的正常排序了,当然,即便我们不显示地声明,虚拟机也会将其默认声明为0.

这里还有另一个容易出错的内容,至少在我做的时候排查了很久才发现,那就是迭代部分的循环结束条件。我们在不手绘的情况下,容易先入为主地认为是l1!=null || l2!=null,这样想的原因是我们认为当出现一个链表为空后,哨兵节点会自然得指向另一个非空的链表,但却忽略了进入循环后仍需要比较,而在l1为空的情况相爱,l1.val会发生什么就显而易见了。

也就是说,在循环结束后,还需要我们手动将哨兵节点指向非空的链表,这就很容易了:prev.next = l1==null?l2:l1;

思路已经很详细了,我们来看下时空复杂度。时间复杂度取决于迭代的次数,显然,迭代的规模也就是需要比较的次数,即O(n+m),而空间的话,只需要常数的空间存放元素就ok了,也就是O(1).

递归

题解区对于递归解法的调侃无过于一看全会,一写全废。这说的不就是我嘛。

2jZjE

不可否认,这评价的确很中肯~

递归的学习是基于理解和熟练度的,大多数的题目,都是有迹可循,使用适当的方法论就可以解决的,没有智商差一说。大可不必妄自菲薄。

老规矩,找递归公式递归终止条件。递归终止条件很友好,即**l1null || l2null.**而递归公式就需要分析了。合并的问题能否分为子问题 ,子问题的解决方法相同,而且又需要有递和归的演化,就是它咯——
$$
list1[0]+merge(list1[1:],list2) list1[0]<list2[0]\

list2[0]+merge(list1,list2[1:])otherwise
$$
搞定了核心的两步,剩下的就只有将l1/l2.next指向比较结果这样的边角料了。

4.3 趣味图解

请大家移步力扣官方题解:https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/he-bing-liang-ge-you-xu-lian-biao-by-leetcode-solu/

在链表的章节,一定要保持手绘的习惯!!!图的解释能力比干巴巴的语言要好很多。

4.4 代码演示

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
       //特判,如果链表为空,则
        if(l1 == null || l2==null)
            return l1==null?l2:l1;
        if(l1.val > l2.val){
            l2.next = mergeTwoLists(l1,l2.next);
            return l2;
        }
        else{
            l1.next =  mergeTwoLists(l1.next,l2);
            return l1;
        }
    }

    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        //声明哨兵节点
        ListNode phread = new ListNode(-1);
        //声明一个最终返回的节点
        ListNode prev = phread;
        //循环终止条件,手绘一下会发现如果是||会导致空指针
        while(l1!=null && l2!=null){
            if(l1.val <= l2.val){
                prev.next = l1;
                l1 = l1.next;
            }else{
                prev.next = l2;
                l2 = l2.next;
            }
            prev = prev.next;
        }
        //合并还有剩余节点的链表
        prev.next = l1==null?l2:l1;
        //返回
        return phread.next;
    } 
}

五. 删除链表倒数第N个元素

5.1 问题描述

image-20210209193807433

5.2 思路及复杂度分析

老规矩,先审题。要删除链表的某个元素,我们必须知道它的前驱节点,所以不管哪种解法,哨兵节点的添加都可以简化解题流程。

最直观的解法解法无过于将问题转化为删除链表第length-n+1个元素。我们不妨称之为朴素的解法。

朴素解法

问题转化以后,思路就变得很明晰了。首先我们需要遍历得到链表的长度。然后,既然要删除链表的第length-n+1个元素,显然首先需要得到第length-n个元素。而链表是没有下标的,所以我们要维护一个计数器。然后遍历链表,计数器递增,当计数器的值等于length-n时,我们记录下当前指针指向的元素,也就是待删除节点的上一个节点。最后删除返回链表就ok了。

e3X59

快慢指针

在聊快慢指针之前,我们有必要先了解一下什么是模式识别

所谓模式识别的问题就是用计算的方法根据样本的特征将样本划分到一定的类别中去。

这不就是数据挖掘中的分类问题吗?的确,算法的解决很多都要基于方法论

那对于删除倒数第N个节点,我们可以提取出什么模式呢——

  • 需要得到前驱节点:哨兵节点
  • 需要定位确切位置的节点:快慢指针

讲真,九年义务教育我们最大的成果就是通过思考得到模式,再按照模式做题,但可笑的是,当我们在之后所谓的深造中,却将最大的财富抛在脑后,真是可怜至极。

2jXQ3

好啦,言归正传。哨兵节点就不再赘述了。如何通过快慢指针找到要删除节点的前一个节点呢?

开局一张图~

image-20210209200834415

也就是说,快指针的初始位置为慢指针向前移动n+1个位置。然后遍历链表,移动快慢指针,确定慢指针的位置。最后,就是删除咯——
image-20210209201449913

5.3 趣味图解

请朋友们移步力扣官方视频,一如既往的给力——
力扣官方视频

5.4 代码演示

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    /**
    *常规解法
    */
    public ListNode removeNthFromEnd(ListNode head, int n) {
        //声明哨兵节点
        ListNode dummy = new ListNode(0,head);
        //得到链表长度
        int length = getlength(head);
        ListNode prev = dummy;
        for(int i=1;i<=length-n;i++){
            prev = prev.next;
        }
        prev.next = prev.next.next;
        return dummy.next;
    }

    public int getlength(ListNode head){
        int length = 0;
        while(head != null){
            length++;
            head = head.next;
        }
        return length;
    }
	/**
	*快慢指针
	*/
    public ListNode removeNthFromEnd(ListNode head, int n) {
        //我们总需要得到待删除节点的前一个节点,对于头结点来说,就需要一个哨兵节点
        ListNode pummy = new ListNode(0,head);
        //快慢指针法
        ListNode slow = pummy;
        ListNode fast = pummy;
        //得到超前慢指针n+1个位置的fast指针
        for(int i=0;i<=n;i++){
            fast = fast.next;
        }
        //slow指针指向要删除的节点的前一个节点
        while(fast != null){
            fast = fast.next;
            slow = slow.next;
        }
        //删除节点
        ListNode tmp = slow.next;
        slow.next = tmp.next;
        tmp.next = null;
        return pummy.next;
    }
}

日拱一卒,功不唐捐。

注:补上昨天的,生活和学习的失衡是无可避免的事情,但维系每件重要事情的平衡也同样势在必行,加油吧,时光不问赶路人。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值