【趣味算法】 用emoj 🍓 给大家解释leetcode链表题型

1,050 阅读21分钟

前言

此文用emoj表示链表,让抽象题目变具体。在上一篇二叉树的文章发出来的时候你们的"呆妹"——LinDaiDai_霖呆呆大佬告诉我,其实我可以在vscode里写一个二叉树来证明这些题目。

玲珑觉得:good idea!💡 于是链表题型里我用一些emoj来表示链表结点展示算法的过程便于大家理解其过程,虽然需要时间但是图来的会比文字和数字更直接,也更好理解~

当然这一篇文章不仅仅是一些emoj而已,玲珑对每个算法的思想进行解释,同时会总结出每一题的难理解模块(说出由思想转换为代码的难点在哪里)。

比如160链表相交这个题很多解题说遍历的长度是a+c+bb+c+a。但我觉得应该是a+c+b+1b+c+a+1这样更准确。还有为什么是a存在不是a.next存在?这只是举个栗子看不明白没关系滴,后面都有详细说明。

所以每一个题除了题目,解析,答案三个模块,还增加了一个难理解模块。目的是帮助正处于算法迷雾中的你更好理解题目的思想,如果给你带来一点启发和思考,玲珑会无比开心,感谢你认真看过这篇文章。

那么一起来看看有趣的emoj吧~

链表题型

1.链表相交160

题目

题目160相交链表

编写一个程序,找到两个单链表相交的起始节点。
如下面的两个链表:
在节点 c1 开始相交。

解析:

说实话这个题我觉得很新奇。这是我算法练习第一题的第一题(当时就感觉好牛皮)。

这个题目可以将两个链表合并判断链表有没有环也可以用一种很巧的方式遍历链表证明了错的人迟早会走散,对的人迟早会相逢。

一起来看看这种巧妙的双指针遍历方式 题目中链表A的节点数是5,链表B的节点数是6.我们两个指针a,b同时遍历这两个链表。

  1. 当指针a遍历完A链表的五个结点后开始遍历链表B;当指针A遍历到B链表的b1结点的时候。指针b刚好遍历完B链表此时指向C3,让指针b开始遍历A链表。

  2. 指针b遍历A链表第一个结点a1的时候指针a指向结点b2,指针b遍历到a2的时候指针a指向了b3。再往后遍历他们就指向了同一个结点就是相同结点。

  3. 总结:双发遍历完自己的链表后遍历对方链表最后找到相同结点。

如果还是晕晕的,看看下面的这个图是不是好理解多了?

说明一下,图中所说的b结点和a结点是两个指针,指向了链表的某个结点。图里说成结点其实不太准确。

代码:

先看正确的。当两个链表遍历完两者都是null或者找到相同结点的时候退出循环。

//正确的姿势,注释部分表示代码可以简化为注释部分
var getIntersectionNode = function(headA, headB) {
    let a = headA;
    let b = headB;
    if(a==null||b==null)return null;
    while(a != b ) {
        if(!a) a= headB;
        else a = a.next;
        if(!b) b = headA;
        else b = b.next;
        // a = a!=null? a.next: headB;
        // b = b!=null ? b.next :headA;
    }
    return a;
};

这是一种错误的方法,我在训练的时候写的错误方法。

为什么要把错误的方法拿出来大说特说呀???

因为总结错误,锻炼算法思维,写出更好的解决方案啊!

//错误的代码
var getIntersectionNode = function(headA, headB) {
    let a = headA;
    let b = headB;
    if(a==null||b==null)return null;
    while(a != b ) {
        if(!a.next) a= headB;
        else a = a.next;
        if(!b.next) b = headA;
        else b = b.next;
    }
    return a;
};

第二个错误的代码示例中不相交的链表会提示超时错误,相交链表可以正确输出结果。原因在难理解模块说明。

难理解

难理解模块来了,先看看上面错误的方法

这里当a或b都是空的时候没有找到相同的结点就返回null。一直遍历两个链表,直到a = b ,遍历的过程中如果a是当前最后一个结点,就让a从链表B开始,如果b是B链表的最后一个结点,就让他从链表A开始。好像没有问题呀😕🤔

没关系,我们看一个两链表不相交和相交的情况吧,看图更好理解,图中已列出遍历的情况

两种方法对要相交结点的链表似乎没有影响。

对于不交的链表,怎么好像进入了一个无限循环。

因为if(!a.next) a= headB;这语句说当a指向的结点的下一个结点不存在的时候就会让a指向链表B,当遍历到链表B结点6的时候,他的下一个是null,此时就又让a指针指向的结点指向链表B开头。因此进入了无限死循环。

对于链表B也是一样的道理。

如果用正确的方法呢?if(!a) a= headB;这个语句不会跳过末尾的空节点。因此遍历的过程是这样的。

对A: [2, 3, 4, 5, null, 9, 9, 6, null]

对B: [9, 9, 6, null, 2, 3, 4, 5, null ]

当两个指针指向结点都是null的时候相等跳出循环。不会出现超时的情况。

2.奇偶链表328

题目leetcode328

给定一个单链表,把所有的奇数节点和偶数节点分别排在一起。请注意,这里的奇数节点和偶数节点指的是节点编号的奇偶性,而不是节点的值的奇偶性。
请尝试使用原地算法完成。你的算法的空间复杂度应为 O(1),时间复杂度应为 O(nodes),nodes 为节点总数。
示例 1:
输入: 1->2->3->4->5->NULL
输出: 1->3->5->2->4->NULL

解析

注意奇数偶数不是说结点值,而代表的是结点的位置,下图中圣诞树在开始(一号)位置是奇数,草莓在5号位置也是奇数。

这个题目用双指针实现链表的分类,将这个链表拆分为奇数和偶数两个链表,然后再把偶数链表放到奇数链表末尾。

另外这个题目要求原地完成,你可能会说链表拆分成两个链表出现了新空间呀?空间复杂度就不是O(1)了。

其实我们只增加了3个指针并没有增加额外的新空间。比如你有一个链表长度为10的链表,五个偶数,五个奇数。我们只是把这个链表拆分为两个链表然后进行合并因此存放链表结点的空间没有新增。但是如果创建一个链,表将长度为n的这个链表赋值过去,相当于空间复杂度由O(1)变成了O(n).

我们看看拆分的过程:如下图定义两个指针,old和even分别指向奇数偶数结点。

当他们的下一个结点不为空的时候,让奇数结点的下一个结点指向偶数结点的下一个结点。偶数结点的下一个结点指向奇数结点的下一个结点,这样就把链表拆分开来。如图橙色箭头和黑色箭头的分别代表奇数和偶数两个链表。

拆分后进行合并,也及时奇数指针old的下一个结点应该是偶数链表的开头结点(sec结点) 也就是把偶数结点链表拼接到奇数链表的后面

代码

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var oddEvenList = function(head) {
    if(head == null) return head;
    let old = head;
    let even = head.next;
    let sec = even;
    while(old.next != null&& even.next!=null) {
        old.next = even.next;
        old = even.next;
        even.next = old.next;
        even = old.next;
    }
    old.next = sec;
    return head;
};

3.翻转链表206

题目

反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL

解析

递归:当前结点与后面已经逆序的结点进行交换(逆序).后面所有结点的逆序:后面一个结点与后面所有的结点逆序结果进行交换。比如1->2->3->4->5->NULL就是1和2->3->4->5->NULL逆序的记结果进行交换。2->3->4->5->NULL逆序的结果是2和3->4->5->NULL的结果进行交换...当递到5->NULL的时候开始往上归。

迭代法:构造一个pre=null结点指针,cur指针指向当前结点,next指针指向下一个结点。让cur指向pre。然后让pre和cur依次往后移动(迭代).

代码

/**
 * @param {ListNode} head
 * @return {ListNode}
 * 递归实现
 */
var reverseList = function(head) {
    if(!head||!head.next) return head;
    let tail = head.next;
    let cur  = reverseList(head.next);
    // cur.next = head;
    tail.next = head;
    head.next = null;
    return cur;
};
/**
 * @param {ListNode} head
 * @return {ListNode}
 * 迭代实现
 */
var reverseList = function(head) {
var pre=null;
var cur=head;
while(cur!=null){
var next=cur.next;
cur.next=pre;
pre=cur;
cur=next;
}
//此时的pre是最后一个数据结点,cur只是空节点
return pre;
};

难理解

递归解题我个人觉得难理解的地方在let tail = head.next;let tail = head.next;这两句上面,我们知道最后的结果是第一个结点和后面所有逆序后结点的逆序。相当于你把从第二个结点开始逆序的结果保存到cur这个结点里。

那么我们直接让cur.next = head为什么不可以呢?

还是来看图:一个原始链表。最后一次逆序的时候。是tail和第一个结点交换不是最后一个结点与之交换。

至于迭代,我觉得直接手动测,按照代码试画图分析问题就应该不大.注释里面的return pre而不是热土入门cur要注意一下,有问题就评论区见啦

4.合并两个有序列表21

题目21合并两个有序链表

将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4

解题:

这个题目看着很简单,递归的思路是根据当前两个结点比较大小来找出递推关系式。

如果l1指向的结点元素小于l2指向的结点元素。那么返回的结果是l1指向结点和l1后面的结点与l2有序合并的结果

l1后面的结点与l2有序合并的结果=l1后面结点中的第一个结点(l1.next)与l2比较大小,如果l1.next的值小于l2...

至于迭代的思路就是通过循环遍历两个链表,不断跟新cur指针,node1指针,node2指针指向的链表结点。

这里不能展示emoj了。因为这个题目的链表是有序的,emoj的大小不好比较。sory~sory~

放心后面一题绝对有。呐,送你一个心心💖

代码:迭代和非递归

/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}递归解题
 */
var mergeTwoLists = function(l1, l2) {
 if(l1==null||l2==null)return l1||l2;
 if(l1.val<l2.val){
     l1.next= mergeTwoLists(l1.next,l2);
     return l1;//层层递归
 }//l2小
 else{
    l2.next = mergeTwoLists(l1, l2.next);
    return l2;
 } 
}
/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode} 非递归的迭代
 */
var mergeTwoLists = function(l1, l2) {
    let node1 = l1; let node2 = l2;
    let preresult = cur = new ListNode(0);
    if(!l1||!l2) return l1||l2;
    while(node1 && node2){
         if(node1.val > node2.val) {
            cur.next = node2;
            node2 = node2.next;
            cur = cur.next;
        }else{
            cur.next = node1;
            node1 = node1.next;
            cur = cur.next;
        }
    }
    if(!node2)cur.next = node1;
    else cur.next = node2;
    return preresult.next;
};

难理解

我想说一说迭代中let preresult = cur = new ListNode(0);这一个表达式定义了cur和preresult两个结点(也可以说是指针吧),结点值是0(这个可以任意给)。

  • 为什么要定义preresult和cur两个变量,定义一个cur然后返回return l1可以吗?

    • 如果只有一个cur虽然可以通过自带的测试用例,但是运行时不通过的。假如l1=[2],l2=[1]。最后返回的结果就是[2]。虽然会经过以下过程
    //模拟过程,只是模拟l1=[2],l2=[1]的情况
    cur = 0;
    cur.next = 1;
    cur = cur.next;
    node2.next = null;
    cur.next = 2;
    return l1;//[2]
    

    可以看出对l1链表没有任何的改变。但是测试用例l1=[1, 2, 4]和l2=[1, 3, 4]会对链表进行改变,因而返回结果与期望相符合。

    所以我们要返回的结果是初始cur后面的结点,但是cur这个指针在遍历链表的时候不断移动。因此通过presresult来返回我们的结果。

  • let preresult = cur = null 可以不可以

    • 当然是不可以的,如果定义指针空后面遇到cur.next?就会报错。、

总的来说就是要面面俱到,每种可能的情况都要考虑到,先有一个大致的思路然后再完善

5.删除有序链表重复结点83

题目83删除排序列表的重复元素

一个排好序的链表可能含有相同的元素,返回删除重复的元素链表

解题

😂怎么又是有序链表,不过这个影响不大,假如emoj 链表🎄 -> 🙌 -> 🍡 -> 🍓 -> 🍓 -> 🍓是有序的。删除链表的重复元素。知道链表是排好序的,那么重复元素一定在相邻的结点中。

用cur和nex指针对链表进行遍历。

代码

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var deleteDuplicates = function(head) {
if(!head||!head.next) return head;
let cur = head;
let nex = head.next;
while(cur && nex){
    if(cur.val == nex.val) {
        cur.next = nex.next;
        // cur = cur.next? cur.next: null;如果加上这一句不满足[1, 1, 1]这种情况
        //因为cur和nex都后移的话结果是[1,1]不是[1]。如果相等删除相等结点后只用移动nex
        //不相等才执行else让cur和nex都移动
        nex = cur.next;
    }else{
        cur = cur.next;
        nex = nex.next;
    }
}
return head;
};

难理解

个人觉得不好理解的地方在于为什么相等的时候cur和nex指针只要移动nex一个指针就可以。但是不相等的时候两个都移动。这是为什么呢?

其实上面的代码注释已经说明白啦,如果还不理解没关系。请接着下面🤞🤞

  1. 假如有一个链表[1, 1, 1]

  2. 前面两个元素都是1,相等。此时head和cur都指向第一个结点元素

  3. 我们让cur.next = nex.next。也就是第一个1的下一个结点是第三个结点1。head的下一个结点也是第三个接结点1

  4. 此时cur=cur.next;说明cur指向了第三个结点。

  5. 此时nex = cur.next(这个时候的cur是第三个结点),说明nex指向null.

  6. nex==null退出循环返回head,结合上面判断head结果是[1,1]

如果不移动cur指针,就会多进行一次相等的判断返回结果[1],现在弄明白了吗?

6.删除链表的倒数第 n 个节点19(mid)

题目19删除倒数结点

给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
示例:
给定一个链表: 1->2->3->4->5, 和 n = 2.
当删除了倒数第二个节点后,链表变为 1->2->3->5.

解题:

暴力循环解题是大家最容易向到的,但是面试的时候暴力解题估计要凉凉。

我们这里出现一个很奇妙的思想。双指针。第一个指针和第二个指针之间的间隔就是n个结点的大小。

为什么要这样呢?也许你不明白原因,没关系看了我下面的解答就会赞双指针的神奇。😁

如下图n=3的时候,两个指针相差三个(n)距离,当sec指针后移到链表末尾的时候,fir还是和sec指针相隔n的结点。于是fir结点的下一个元素就是我们要删除的结点。

这样就只循环一次减小时间复杂度。

代码

/**
 * @param {ListNode} head
 * @param {number} n
 * @return {ListNode}
 */
var removeNthFromEnd = function(head, n) {
    let node = new ListNode(0);
    node.next = head;
    let sec = fir = node;
    if(!head || !head.next) return null;
    for(let i = 0; i < n; i++) sec = sec.next;
    while(sec.next) {
        fir = fir.next;
        sec = sec.next;
    }
    fir.next = fir.next.next;
    return node.next;
};

难理解

为什么要在head前面构造一个结点呢?快慢指针初始化的时候指向head不行吗?

构造一个哑结点node是为了避免删除正数第一个结点为空的情况。比如有一个链表[1,2]。你想删除倒数第二个结点(正数第一个结点). 我们不用哑结点node会出现以下问题

//不创建node结点。
var removeNthFromEnd = function(head, n) {
    let sec = fir = head;
    if(!head || !head.next) return null;
    for(let i = 0; i < n; i++) sec = sec.next;
    while(sec.next) {
        fir = fir.next;
        sec = sec.next;
    }
    fir.next = fir.next.next;
    return head;
};
  1. sec和nex都指向结点1,然后结点sec通过两次sec=sec.next指向了末尾结点。
  2. 遇到 while(sec.next)报错TypeError: Cannot read property 'next' of null

7.交换链表相邻结点24(mid)

题目力扣(LeetCode)

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
示例:
给定 1->2->3->4, 你应该返回 2->1->4->1

解题

递归:这个题目递归是个很简洁的方法。我们可以把链表分成三部分。

函数返回结果=前面两个结点两两交换的结果+后面所有结点两两交换的结果。

后面所有结点交换的结果(假如后面有5个结点)= 前面两个结点交换的结果+后面三个结点两两交换的结果。

一直递到后面没有结点后者只剩下一个结点的时候(找到出口)开始归.

代码

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var swapPairs = function(head) {
    if(! head || !head.next) return head;
    let fir = head;
    let sec = head.next;
    fir.next = swapPairs(sec.next);
    sec.next = fir;
    return sec;
};

难理解

玲珑觉得还是递归的过程不好理解吧。后面所有的结点抽象为一个结点看待。然后仅仅交换前面两个结点就好理解了。

也就是整个链表当做三个结点。pre,cur,nex.

pre.next = nex cur.next = pre 上面是交换两个结点,然后对于第三个结点是后面所有结点交换的结果nex==swap(nex)

8.链表求和445(mid)

题目力扣(LeetCode)

给你两个 非空 链表来代表两个非负整数。数字最高位位于链表开始位置。
它们的每个节点只存储一位数字。将这两数相加会返回一个新的链表。
你可以假设除了数字 0 之外,这两个数字都不会以零开头。

简单来说就是两个整数做加法,每一个整数是链表的一个结点。

解题

两个数相加要从低位开始相加,两链表也要从链表的尾结点开始相加。我们可以翻转链表然后累加求和。也可以利用栈的先进后出。用两个栈保存链表的结点,栈不为空的时候弹出栈顶元素相加求和。

代码

/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var addTwoNumbers = function(l1, l2) {
    var stack1=[];
    var stack2=[];
    while(l1!=null){
        stack1.push(l1.val);
        l1=l1.next;
    }
    while(l2!=null){
        stack2.push(l2.val);
        l2=l2.next;
    }
    //相加和的进位
    var jinwei=0;
    var head=new ListNode(0);
    while(stack2.length>0||stack1.length>0||jinwei!=0){
        var a=stack1.length==0?0:stack1.pop();
        var b=stack2.length==0?0:stack2.pop();
        var sum=a+b+jinwei;
        //sum取余数是本位
        var node=new ListNode(sum%10);
        node.next=head.next;
        head.next=node;
        //向下取整
        jinwei=Math.floor(sum/10); 
    }
    return head.next;
};

难理解

我觉得难理解的是进位jinwei这个变量要考虑到while循环这个判断里面。

为什么?举一个栗子🦞

比如十位数相加要考虑到个位的进位。同时也要看看十位数相加会不会产生进位生成百位。比如两个十位数10+99的结果是三位数生成了百位。

所以要对进位这个变量jnwei判断是否为0,如果两个栈都为空&&jinwei!=0的时候还是要进行求和操作。

9.回文链表234

题目

请判断一个链表是否为回文链表。
示例 1:
输入: 1->2
输出: false

解题

这个题有三种方法。

第一种借助栈将链表元素全部入栈,如果栈不空弹出栈顶元素与链表进行对比看是否相同。如果都相同就会返回true。

第二种是借助栈将链表元素一半入栈,然后取出栈顶元素与剩下的链表结点对比看是否相同。

但是原题目里面要O(1)的空间复杂度,因此不借助栈,我们找到链表中间结点后,将链表逆序。也就是将链表一分为二,判断两者是否相同。

问题来了,如何找到链表的中间结点,当然是利用快慢指针呀。快指针一次走两步,慢指针一次走一步。快的是慢的速度的两倍就找到了中间结点。但是我当时没有那么机智(代码里是循环找到的中间结点)

代码

//解析里面的方法二利用栈
/**
 * @param {ListNode} head
 * @return {boolean}
 */
var isPalindrome = function(head) {
    var a = [];
    var temp1 = head, count=0;
    while(temp1 != null){
        count++;
        temp1 = temp1.next;
    }
    temp1=head;
    for(var i=0;i<Math.floor(count/2);i++){
        a.push(temp1.val);
        temp1=temp1.next
    }
    return compare(head,a, count, temp1);
    function compare(head,b, length,temp2){
        var result= true;
        if(length%2!=0){
           temp2=temp2.next;
        }
        while(b.length!=0){
           if(b.pop()!=temp2.val) result= false;
           temp2=temp2.next;
        }
        return result;
    }
};
//方法三:翻转前半部分链表
var isPalindrome = function(head) {
    if(head==null||head.next==null) return true;
    let len=1;
    let cur = head;let nex = head.next;let pre = null;
    while(nex!=null) {
        len++;//链表长度
        nex = nex.next;
    }
    //找到中间的结点,将链表翻转
    let mid = Math.floor(len/2);
    //链表长为偶数就取mid的前一个结点作为中间结点
    mid = (len % 2 == 0)? mid-1 : mid;
    //cur2表示后半部分的第一个链表
     let cur2 = head;
     for(let i = 0; i <= mid; i++) cur2 = cur2.next;
    //链表前半部分逆序
    // temp表示要逆序多少个结点,即翻转的次数
            let temp =(len%2==0)? mid+1:mid;
    while(temp--) {
    nex = cur.next;
    cur.next = pre;
    pre = cur;
    cur = nex;
    } 
    const compare = (cur,  cur2) => {
        let result = true;
        while(cur && cur2) {
            if(cur.val != cur2.val) {
               result = false; 
               break;
            }else{
                cur = cur.next;
                cur2 = cur2.next;
            }
        }
        return result;
    }
     return compare(pre, cur2);
 }

呐,这是emoj演示的结果截图。 前面有两道题的思路很简单,相信大家能看懂,就没有演示emoj,不好理解的我都有emoj+流程图演示。

(好吧,其实主要原因是玲珑只是想偷个懒,画这个图太要时间啦😋)

难理解

这里的思想很简单,翻转链表在前面也有说过。但是这个题我觉得有很多坑。我花了四个小时解决,一直不知道逻辑出现在哪里于是去vsCode里找bug,如果详细记录的话可以写一篇文章了😂其实还是水平不够要多多练习。

这里主要说两点吧

  • 逆序的时候是前半部分逆序,不是所有的都逆序。 在逆序的过程里,我先是当cur!=null就逆序导致整个链表逆序出现了一些问题,然后就是逆序前边部分如何用代码表示出来。

    • 在链表结点为奇数的时候,比如链表[1, 2, 3]这个时候mid是第一个结点(结点值1的结点是第0个结点,结点值2是第1个结点)mid==1。我们要翻转的是第一个结点之前的结点(翻转一次,即翻转mid次)。
    • 在链表结点数是偶数的时候,比如链表[1, 2, 3, 4]。mid开始等于len/2=2,但是是偶数mid=mid-1=1。即中间结点是第一个结点(结点值为2的结点是第一个结点)。我们要将[1,2]逆序就要进行两次单转的循环(即mid+1次)
  • 逆序结束后返回逆序后的链表应该是return pre,因为此时cur指向null。在一点在前面翻转链表题型里有说。

  • 在最后执行isPalindrome函数的时候,我在编辑器里面直接执行他返回的结果竟然都是true,后来我把编辑器isPalindrome(node1)改为console.log(isPalindrome(node1))才返回预期的结果。

文章末尾

虽然只有几道题,但是这里面包含了递归,迭代,双指针,栈等思想,还有一些用代码实现的细节问题值得我们仔细揣摩。

不知道大家有么有看我的emoj呢?这篇文章开始也是大段的文字论述,很多文字好像就很难继续看下去😢。于是每一道题花了好久放到编辑器里面测试,写案例,因为写文章的初衷是想给每一个看到我的文章的人带来一丝启发和灵感。这里感谢摸鱼老湿前辈的一篇用emoj讲数组方法的文章给了我一些灵感~,我发现图像往往比文字和数字来的要更加直接,也更容易记住。

如果你有其他地方难理解自己想不明白不要吝啬你的留言鸭,我也是算法小白,我们一起解决问题,锻炼自己的算法思想💕。

最后算法系列的文章还有👇 算法题型玲珑也会不断更新下去的:

【趣味算法】31道二叉树算法,给自己的五一礼物

(《雾都孤儿》)图文并茂,手刃算法

本文使用 mdnice 排版