代码随想录算法训练营第三天 | 203.移除链表元素、707.设计链表、 206.反转链表

LeetCode 203.移除链表元素

题目链接🔗:203.移除链表元素

解题思路🤔

首先考虑这么几种情况:

  1. 链表为空
  2. 链表不为空且头节点符合题目条件
  3. 链表不为空且非头节点符合题目条件

如果链表为空, 那么直接返回头节点;

链表不为空且头节点符合题目条件,那么删除头节点,最后返回新的头节点;

链表不为空且非头节点符合题目条件,那么可以设置一个指针指向头节点,让指针遍历整个链表寻找需要删除的节点,最后返回新的头节点即可。

再考虑如何进行优化,我们发现链表不为空时,如果有一种方法能让头节点和非头节点按照一种判断逻辑来判断的话,这样判断的代码要能少写一些。

因此,可以设置一个虚拟头节点,让其指向头节点,此时原本的头节点也可以看做是一个非头节点,这样就可以按照处理非头节点的方式来处理整个链表的节点了。

遇到的问题😢

代码实现👨🏻‍💻

直接删除法

var removeElements = function(head, val) {
    while(head !== null && head.val == val) { // 链表不为空且头节点符合题目条件
        head = head.next; // 直接删除头节点
    }
    if(!head) return head; // 链表为空   
    // 头节点之后的节点符合题目条件
    let pre = head;
    while (pre && pre.next) { 
        if(pre.next.val === val) { // 符合条件,删除头节点后的第一个节点
            pre.next = pre.next.next;
            
        } else {
            pre = pre.next; // 不符合条件,移动pre指针
        }
    }
    return head;
};

虚拟头节点

var removeElements = function(head, val) {
    const res = new ListNode(0, head); // 设置虚拟头节点
    let cur = res; // 设置cur指针指向虚拟头节点
    while(cur.next) { // 如果cur.next !== null
        if(cur.next.val === val){ // 将cur.next节点的val与val对比,如果相等表示需要删除这个节点
            cur.next = cur.next.next; // 删除操作
            continue; // 删除节点后立即退出循环,并重新进入循环
        }
        cur = cur.next; // 更新cur指针的位置
    }
    return res.next; // 虚拟头节点的下一个节点即为头节点
}

总结📖

在解题思路中已经把逻辑说得非常清楚了,代码中注释也比较清晰,如果有朋友实在看不懂,可以自己画画图帮助理解。

另外,JS有一个比较坑的地方,就是没有链表这种数据结构,因此只能用创建类的方式模拟链表及其行为,在707.设计链表中可以很好地帮助不熟悉链表的朋友快速对JS的链表有一个整体认知,建议认真体会。

LeetCode 707.设计链表

题目链接🔗:707.设计链表

解题思路🤔

JS中没有链表这一数据结构,因此为了实现链表的功能,我们使用类来模拟链表及其行为。具体的写法是创建两个类,一个类用来模拟链表中的节点,例如value与next指针;另一个类用来模拟链表的行为,例如添加、删除等等。

如果你在VS Code中尝试模拟链表,VS Code会建议你将类封装为对象,这样更符合ES6的规范,但为了更好理解,这里就直接用类来表示了。

在代码中使用了更简洁的虚拟头节点方式来获取节点,如果不熟悉请再理解一下上面的 203.移除链表元素这一题目中对虚拟头节点的使用。另外,对于多次使用到的功能,我们需要抽象封装出来以便再次使用,这也是Vue、React的组件化编程理念的另一种体现吧。

另外,在每一个函数上方我加入了函数实现功能的注释,帮助大家理解,免得分析了半天结果忘了这个函数要实现什么功能。

对于使用“瘸腿”语言的JS选手来说,面对链表这一数据结构,需要付出更多的心力在其上面,希望大家好好理解这道题目,相信认真完成后一定会对链表有更深的领悟。

遇到的问题😢

一开始做出现了各种问题,看了卡哥的代码后解决。

感慨一下,遇到想不出的问题也是去注释更详细的卡哥那看C++的代码哈哈哈,JS选手默默流下眼泪

代码实现👨🏻‍💻

模拟链表

// 模拟链表中的节点
class NodeList {
    constructor(val, next) {
        this.val = val; // 模拟链表节点的数值value
        this.next = next; // 模拟链表的next指针
    }
};

// 模拟链表行为
var MyLinkedList = function() { 
    this.size = 0; // 模拟链表长度,注意尾节点为 this.size - 1
    this.head = null; // 模拟头节点
    this.tail = null; // 模拟尾节点
};

// 获取节点函数,因为后面多次用到因此封装为一个函数
MyLinkedList.prototype.getNode = function(index) {
    // 确保index有效
    if(index < 0 || index >= this.size) return null;
    // 可以用暴力法,分别判断头、尾、非头非尾三种情况
    // 这里使用虚拟头节点
    let cur = new LinkNode(0, this.head);
    while(index-- >= 0){ // 位置是相对的,cur的初始位置在0,当index递减到0 ,cur递增就到了index初始的位置
        cur = cur.next; // 移动cur指针的位置
    }
    return cur;
} 

// 获取链表中第index个节点的值,如果索引无效返回-1
MyLinkedList.prototype.get = function(index) {
    if(index < 0 || index >= this.size) return -1; // 判断index是否有效,无效返回-1
    return this.getNode(index).val; // 调用获取节点方法,随后返回该节点的value值
};

// 在头节点之前插入val,并使其成为新的头节点
MyLinkedList.prototype.addAtHead = function(val) {
    const node = new LinkNode(val, this.head); // 创建虚拟头节点
    this.head = node; // 更新头节点
    this.size++; // 更新链表长度
    if(!this.tail) { // 如果链表为空
        this.tail = node; // 指向新添加的节点,此时链表的尾节点同时也是头节点
    }
};

// 在尾节点之后插入val,并使其成为新的尾节点
MyLinkedList.prototype.addAtTail = function(val) {
    const node = new LinkNode(val, null); // 创建虚拟尾节点
    this.size++; // 更新链表长度
    if(this.tail){ // 如果尾节点存在
        this.tail.next = node; // 初始尾节点之后的节点更新为值为val的节点
        this.tail = node; // 更新尾节点,此时尾节点就变为新添加的节点
        return;
    }
    // 如果尾节点不存在,即链表为空
    this.tail = node; // 更新尾节点
    this.head = node; // 更新头节点
};

// 在链表的第index个节点之前添加值为val的节点
// 如果index = size,添加到末尾;如果index > size,不插入节点;如果index < size,添加到头部
MyLinkedList.prototype.addAtIndex = function(index, val) {
    if(index > this.size) return; // index > size,不添加节点
    if(index <= 0) {  // index < size,添加到头部
        this.addAtHead(val);
        return;
    }
    if(index === this.size) { // 如果index = size,添加到末尾
        this.addAtTail(val);
        return;
    }
    // 0 < index < size,在第index个节点之前添加值为val的节点
    const node = this.getNode(index - 1); // 获取index节点的上一个节点
    node.next = new LinkNode(val, node.next); // 添加节点,看不懂可以画图理解
    this._size++; // 更新链表长度
};

// 如果索引index有效,则删除链表中第index个节点
MyLinkedList.prototype.deleteAtIndex = function(index) {
    if(index < 0 || index >= this.size) return ; // 判断index是否有效

    // index为链表第0项
    if(index === 0) {
        this.head = this.head.next; // 更新头节点
        if(index === this.size - 1){ // 如果要删除的头节点同时是尾节点
            this.tail = this.head; // 更新尾节点
        }
        this.size--; // 更新链表长度
        return;
    }

    // 0 < index < size
    const node = this.getNode(index - 1); // 获取index节点的上一个节点
    node.next = node.next.next; // 删除index节点
    // 如果要删除的节点同时是尾节点
    if(index === this.size - 1) {
        this.tail = node; // 更新尾节点
    }
    this.size--; // 更新链表长度
};

总结📖

基本每一句都做了注释,希望能给对JS链表感到疑惑的朋友提供一些帮助。

遇到不理解的地方,第一反应应该是画图,通过画图来分析链表的代码,事半功倍。

LeetCode 206.反转链表

题目链接🔗:206.反转链表

解题思路🤔

(看到这个题会想起大学学数据结构,搞明白递归之后,每每看到这种题总想用递归,哈哈)

拿到题目先考虑实际要完成的任务,其实就是让尾节点指向null,头节点指向第二个节点,第二个节点指向带三个节点···尾节点之前的节点指向尾节点,此时原本的尾节点就变为了头节点,原本的头节点也就变成了尾节点。

1.递归法

首先用递归来做,相信大家经过《数据结构》课本里的递归轰炸后,对递归应该是比较熟悉了。

递归的思路是通过不断调用函数自身,将一个大的操作拆解为多次执行一个相同的小操作。在本题中的具体实现为在做好一个节点反转后,不断重复直到全部反转。

具体到代码,因为函数内部套函数(想象一下俄罗斯套娃),因此当head进入函数后,它会再次进入内部的函数,而内部的函数中还套有一个函数,因此head会一直进入到最深处的函数,就像是俄罗斯套娃。

1.1 传入内部函数的参数

首先我们需要看一看如何处置传入内部函数的参数。因为如果传入内部函数的参数一直是head,相当于每次进入函数都在头节点,那就算反转了头节点,当返回到上一层函数,我们仍然在反转相同的节点,这不是做无用功嘛?

因此我们就需要让进入内部函数的参数变为head.next,这样每一层函数在进入时,拿到的节点都是上一层节点的下一个节点。这样,当完成反转后回到上一层函数,就不会重复反转了。

所以在内部函数那里我们可以这样写:

reverseList(head.next);

1.2 跳出递归的判断条件

之前说过,递归中的函数就像是俄罗斯套娃,如果不规定跳出递归的判断条件(类比于不规定俄罗斯套娃内部空间的大小),那我们就进入了死循环(想象一下内部无限大的俄罗斯套娃)。

我们已经知道传入内部函数的参数是head.next,而这个值也是存在边界的,那就是链表的尾节点。因此,我们首先得出的结论就是当head.next = null时,这个链表就遍历完了,那么当出现这种情况时,就可以跳出递归,返回上一层函数执行反转操作了。

可能你会问,那为啥不在最内层函数里执行反转操作呢?

你想想,假设我们这个链表有五个节点,那根据传参,加上最外层就得有五层套娃,如果在最内层函数就反转了,当你回到最外层,那不就反转了5次?而这里我们没有虚拟头节点,在第四次反转时就已经反转完整个链表了,第五次反转反转的是啥呢?对不?实在想不明白,数数五个手指头中间几个缝,一个指头代表一个节点,一个缝代表一个连接箭头,是不是需要反转四次?

另外,为了避免head在最外层进入时就是null,还要判断一下head是否为null

最后,我们跳出递归是要干啥?是要返回上一层函数进行反转操作。因此,在判断出来要跳出时,我们要保留此时的head以便在上一层函数中进行反转操作。

这样,跳出递归的判断条件就呼之欲出了:

if (head == null || head.next == null) return head;

1.3 执行反转操作

现在我们再来考虑,如何执行反转操作。

我们以1->2->3->4->5这个链表为例,现在我们跳出递归,回到了倒数第二层函数,因为我们在最内层(第五层)函数中拿到的head实际上是尾节点,它的实际上就是5。注意,这个head是第五层的,而第四层的head是4,因为我们进入内部函数时传的是head.next嘛。

在第四层函数中就是要将4->5反转为4<-5,而head就是4,那么就可以写出如下代码:

head.next.next = head;

head.next是谁?不就是4->5里的5吗,所以上面这句翻译一下就是:

5.next = 4;

这样就完成了反转。

反转之后先别着急,我们来看此时5指向了4,那4它也没变呀,它还是指向5呢!好家伙整了俩“相向奔赴”的节点出来是吧!

因此这里我们还需要改一下4的指向,让它指向null,嗯,这样好像就没问题了。

可以写出如下代码:

head.next.next = head; //反转节点
head.next = null; // 更新head节点指向

等一下,反转是反转完了,那我们咋回到第三层呢?

1.4 内部函数返回值

第五层回到第四层,我们返回的是第五层的head,这是利用了判断条件,但我们发现,第五层的head在第四层的反转中啥也没干,就单纯只是为了帮助返回而已。再加上第四层的head是符合条件的,没法利用它直接返回到上一层。那我们干脆就让第五层的head好人帮到底,一直帮我们返回上一层得了。

在这里我们定义一个指针cur来接收第五层的head,在反转完成后return cur。因为反转时没有用到cur,所以反转完毕回到上一层函数时这个cur也不会改变,仍然为5。cur的数值没有意义,只是为了帮助我们回到上一层函数——毕竟,想回上一层总得return点啥东西吧。

所以可以写出如下代码:

const cur = reverseList(head.next);
// 反转操作
return cur;

2.双指针法

随后考虑,既然最终是要将整个数组的指向变为相反,那么也可以从头节点开始,一个节点一个节点的反转。这就可以利用双指针来实现了。

双指针法需要定义指向head头节点的指针cur与指向null的指针pre(表示指向虚拟头节点),实际要做的第一个反转,就是让cur指向虚拟头节点,也就是cur.next = pre,此时需要注意,原本cur.next是指向头节点后面的节点(我们叫它“第二节点”)的,如果此时反转了,头节点相当于与“第二节点”断开连接了,那之后我们如何得到“第二节点”呢?

因此,需要设置一个temp指针临时保存一下“第二节点”,这样即使在反转后,我们也能通过temp指针找到“第二节点”。

一次反转完毕,随后就是利用循环更新pre指针和cur指针的位置,完成接下来的反转。

遇到的问题😢

代码实现👨🏻‍💻

递归法


var reverseList = function(head) {
    if (head == null || head.next == null) return head; // 跳出递归的判断条件
    // 递归调用
    const cur = reverseList(head.next);
    // 执行反转操作
    head.next.next = head; // 反转节点
    head.next = null;// 更新head节点指向
    // 回到上一层
    return cur;
};

双指针法

var reverseList = function(head){
    if(!head || !head.next)  return head;
    let temp = null, pre = null, cur = head;
    while(cur) {
        temp = cur.next; // 保存cur.next节点
        cur.next = pre; // cur->next原本指向“第二节点”,现在指向pre,即完成了反转
        pre = cur; // 更新pre指针的位置
        cur = temp; // 更新cur指针的位置
    }
    return pre; // 最后返回反转后的头节点
}

总结📖

递归法

递归法的难点在于:

第一是否对递归熟悉,如果不够熟悉恐怕是要憋很久才能写出代码;

第二就是如何反转节点,这就需要自己对目前位于哪一层函数有一个清晰的认识。

以上两点如果能做到,递归不难,否则就是“一看就会,一写就废”。

双指针法

在有了数组的基础后,双指针法相比递归法变得容易不少,很快就能想到。


画图!画图!画图!

链表遇到想不明白的地方,一定要画图去辅助理解,真的事半功倍。

今日收获

  1. 用最多的口水重新推导了一遍递归,我就不信这次还有人搞不懂递归!!仿佛找回第一次搞懂递归的那种愉悦感了,也不枉费自己花了一晚上写博客的时间了,哈哈。
  2. 链表相关问题画图真的很重要。
  3. JS的链表(其实我感觉更准确地说应该是模拟出的链表)对于没怎么接触过传统语言、第一次遇到链表的JS选手来说,首要的问题可能是不知道怎么定义一个链表出来。卡哥的理论基础里有范例,如果JS基础扎实,自己在网上查一查应该也能明白的,707那道题是一道很好地帮助联系链表操作的题目,建议对链表还有点迷糊的JS选手认真多做几遍。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值