算法学习 | day3/60 链表基础知识/移除链表元素/设计链表/反转链表

一、链表基本知识

        学习的主要内容来自:代码随想录

        链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null,即空指针。

        链表主要有三个主要的类型:单链表、双链表和循环链表,他们的主要区别在于每一个链表的节点所存储的指针域不同,在不同的场景也有不一样的作用,比如双向链表可以用于双向的查询,而循环链表用来解决约瑟夫环问题(这个不明白,后续补全)。

         这里重点了解一下链表的存储方式:

        区别于数组在内存空间中连续地进行存储,链表的节点在内存上并不是连续分布的,而其分配的机制取决于操作系统的内存管理,在我的理解中,链表的存在是为了弥补数组在插入和删除数据时的缺点,因为链表的插入和删除操作可以做到O(1)的时间复杂度,但是这样也同样给链表在访问元素的时候造成困难,查找元素的开销的时间复杂度是O(n)。

        这里给出他们的性能分析:

        

        这也导致他们的应用场景的区别:

        数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。

二、每日打卡题目

        2.1 移除链表元素

        这个题目的 leetcode 编号是 203,题目要求的是删除一个单链表中满足目标值的所有链表节点,并返回这个链表的头部,根据链表数据结构的存储特点,可以有的一个大致的思路是遍历链表,在遇到节点等于目标值的时候,则将上一个节点的指针域直接指向下一个节点即可,这样的话,需要维护两个指针的节点来进行链表的遍历。

        自己尝试了一下,代码如下:     

class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        ListNode* pre = new ListNode(-1);
        ListNode* newHead = pre; // 拷贝一个返回值
        pre->next = head;
        while(head){
            if(head->val == val){
                pre->next = head->next;
                head = head->next;
                continue;
            }
            pre = pre->next;
            head = head->next;
        }

        return newHead->next;
    }
};

        整体思路是声明双指针,其中一个是虚拟的头结点,接着用传入函数的 head 对整个链表进行遍历,当遇到等于目标值的情况时,让虚拟节点 pre 指向 head 的下一个节点,并把 head 向后移动,这里有一点值得注意的是,因为存在一种情况就是删除的节点在链表中是连续的,那么此时解决的办法就是设置一个中断,也就是不需要对 pre 节点本身做任何的移动,而是不断改变其 next 所指向的位置,直到指向了正确的节点再进行 pre 的移动,这是因为有可能此时的 pre->next == val,如果进行了移动就会出现错误。

        不过感觉这个思路还是有点绕,主要是因为在遍历的时候,我是以中间节点作为外层 while 循环的依据,但其实最好是用第一个节点,因为这样就不用考虑出现 head->next 和 pre->next 重叠的情况,也就不需要使用 continue 的方式进行中断,这也是 随想录的思想,这里重新写了一下:

class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        ListNode* dummy_head = new ListNode(-1);
        dummy_head->next = head;
        ListNode* newHead = dummy_head;
        while(dummy_head->next){
            if(dummy_head->next->val == val){
                dummy_head->next = dummy_head->next->next;
            }else dummy_head = dummy_head->next;
        }        
        return newHead->next;
    }
};

        然后是随想录给出的代码: 

class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点
        dummyHead->next = head; // 将虚拟头结点指向head,这样方便后面做删除操作
        ListNode* cur = dummyHead;
        while (cur->next != NULL) {
            if(cur->next->val == val) {
                ListNode* tmp = cur->next;
                cur->next = cur->next->next;
                delete tmp;
            } else {
                cur = cur->next;
            }
        }
        head = dummyHead->next;
        delete dummyHead;
        return head;
    }
};

        这里值得注意的是,随想录的代码里面引入了 cur ,并最后对 dummy_head 的内存进行了释放,还有过程中的 tmp 的引入,同样是对内存进行了释放,这是两个比较细节的问题,值得注意。

        最后自己也使用递归的思想进行思考,这时候会发现实际上做的事情就是不断将链表中的节点压入进栈,然后在最终递归的时候进行判断和返回,感觉思路类似于二叉树的后续遍历:

class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        if(head == nullptr) return nullptr;
        head->next = removeElements(head->next,val);
        if(head->val != val) return head;
        else return head->next;
    }
};

        这样情况的思考主要是在最后函数出栈的过程中,不断地进行判断是否为目标节点,然后进行不同的拼接。

        感想:

        这里之所以要使用虚拟的头结点,是因为对于删除这个操作而言,头节点的删除操作和后续节点的删除操作是不一致的,而在实际代码运行和写的过程中,比较值得注意的是内存的释放,以及 else 和 continue 的使用,这感觉都是比较细节容易出错的地方。

        2.2 设计链表

        题目链接:. - 力扣(LeetCode)

        这个题目本书不难,但是因为出现错误以后不是很好 debug, 导致做的过程有一点困难,最终写出了,但是运行的时间特别长,因此这里我不展示整个代码,而是把自己写的代码和随想录给出的参考作为对比,找到自己代码可以在时间复杂度上优化的点,我在这里把代码拆成很多部分:

        首先是类的 private 部分和构造函数:

    ListNode* prehead;
    int nums = 0; // 记录总数

    MyLinkedList(){
        prehead = new ListNode(1001);
    }

        这里声明的思路没有问题,不过值得注意的是,构造函数的参数列表为空,因而可以当做初始化的是一个虚拟的头结点,另外 self->nums 这个变量是为了在后面带有索引的内容里,方便判断是否越界。

        对于第一个函数:

    // 这是我写的版本
    int get(int index) {
        // cout << "before get:" <<endl;
        printList();
        if(index >= nums) return -1;
        int cur = 0;
        ListNode* tmp = prehead->next;
        while(tmp){
            if(cur == index){
                return tmp->val;
            }
            cur++;
            tmp = tmp->next;
        }
        return -1;
    }

    // 随想录的版本
    int get(int index) {
        if (index > (_size - 1) || index < 0) {
            return -1;
        }
        LinkedNode* cur = _dummyHead->next;
        while(index--){ // 如果--index 就会陷入死循环
            cur = cur->next;
        }
        return cur->val;
    }

        这里我额外使用了一个变量,可以从随想的代码里面看出,这个步骤其实可以直接由索引递减来实现,注意到的是 --index 会导致死循环的原因是是如果 index 为0,那么 while 循环就会一直执行下去,这里 index-- 的话,实际上是会先执行循环体再将 index - 1,因而这里实际就保证了执行 cur = cur->next 的次数是始终比 index 要多一次的。

        接着是 addAtHead 函数:

    // 我的
    void addAtHead(int val) {
            // cout << "-----------" <<endl;
        nums++;
        if(prehead->next == nullptr){
            ListNode* newnode = new ListNode(val);
            prehead->next = newnode;
        }else{
            ListNode* newnode = new ListNode(val);
            ListNode* tmp = prehead->next;
            prehead->next = newnode;
            newnode->next = tmp;
        }
    }

    // 随想录
    void addAtHead(int val) {
        LinkedNode* newNode = new LinkedNode(val);
        newNode->next = _dummyHead->next;
        _dummyHead->next = newNode;
        _size++;
    }

        这里稍微臃肿了一点,其实本质的原因是就算指针本身是空的,其也是可以赋值的,参考 newNode->next = _dummyHead->next; 我本身的考虑是,如果虚拟节点后面的内容是空的,那么需要区分,其实是不用的,并且也是不用借助中间的指针的,可以把赋值的顺序重新进行就可以免去一大部分空间了,也就是我本身的逻辑是: 保留头节点的后一个节点,然后将头结点的下一个节点指向新节点,再接着把新节点下一个指向所保留的节点。但是其实由于链表的存储是包括后续所有的内存节点信息的,因而可以直接把新节点的下一个指向虚拟节点的下一个,再把虚拟节点的下一个指向新节点就可以了。

    void addAtTail(int val) {
        // cout << "-----------" <<endl;
        nums++;
        if(prehead->next == nullptr){
            ListNode* newnode = new ListNode(val);
            prehead->next = newnode;
        }else{
            ListNode* tmp = prehead->next;
            while(tmp->next != nullptr) tmp = tmp->next;
            ListNode* newnode = new ListNode(val);
            tmp->next = newnode;
        }
    }


    void addAtTail(int val) {
        LinkedNode* newNode = new LinkedNode(val);
        LinkedNode* cur = _dummyHead;
        while(cur->next != nullptr){
            cur = cur->next;
        }
        cur->next = newNode;
        _size++;
    }

        逻辑一样,和上面的道理也是一样的,多余了一个步骤。

void addAtIndex(int index, int val) {
        // cout << "-----------" <<endl;
        // 这是一个错误
        // 第一个容易出的错误是这样容易忘了加1,否则如果添加到的是结尾,则会添加失败
        nums++;
        if(index >= nums){
            nums--; // 第二个注意的就是这里如果出错了需要减
            return;
        }
        int cur = -1;
        ListNode* tmp = prehead;
        // // 处理链表为空的情况
        // if(tmp->next == nullptr && index == 0){
        //     ListNode* newnode = new ListNode(val);
        //     prehead->next = newnode;
        //     return;
        // }
        while(true){
            if(cur + 1 == index) break;
            cur++;
            tmp = tmp->next;
        }
        ListNode* tmp_ = tmp->next;
        ListNode* newnode = new ListNode(val);
        tmp->next = newnode;
        newnode->next = tmp_;
    }

    void addAtIndex(int index, int val) {

        if(index > _size) return;
        if(index < 0) index = 0;        
        LinkedNode* newNode = new LinkedNode(val);
        LinkedNode* cur = _dummyHead;
        while(index--) {
            cur = cur->next;
        }
        newNode->next = cur->next;
        cur->next = newNode;
        _size++;
    }

        也是思路没问题,但是代码臃肿了,步骤也可以简化。

        最后一个代码:

    void deleteAtIndex(int index) {
        // cout << "-----------" <<endl;
        // cout << "debug" <<endl;
        // cout << "index = " << index << " nums = " << nums << endl;
        printList();
        if(index >= nums) return;
        int cur = -1;
        ListNode* tmp = prehead;
        while(true){
            if(cur + 1 == index) break;
            cur++;
            tmp = tmp->next;
        }
        ListNode* tmp_ = tmp->next;
        ListNode* tmp__ = tmp->next->next;
        tmp->next = tmp__;
        delete tmp_;
        nums--; // 错误3 忘了减了
    }

    void deleteAtIndex(int index) {
        if (index >= _size || index < 0) {
            return;
        }
        LinkedNode* cur = _dummyHead;
        while(index--) {
            cur = cur ->next;
        }
        LinkedNode* tmp = cur->next;
        cur->next = cur->next->next;
        delete tmp;
        //delete命令指示释放了tmp指针原本所指的那部分内存,
        //被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后,
        //如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针
        //如果之后的程序不小心使用了tmp,会指向难以预想的内存空间
        tmp=nullptr;
        _size--;
    }

        逻辑没问题,依然是使用的变量上可以优化。

        感想:

        这个题目确实是考察的比较全面,不过存在很多细节的内容在第一次没有理清楚,导致代码写的很臃肿,而且在这个题目中我发现,对于 leetcode 代码来说,如果最后提交的代码时间超时了,但是自己认为逻辑没有问题,可以试着看一下是不是自己存在很多 cout 语句用来 debug,这个确实是会影响我最后的结果,我带着部分 cout 语句,导致了题目超时,注释掉了以后代码就顺利通过了。

        2.3 反转链表

        leetcode 的题目链接:. - 力扣(LeetCode)

        这个题目老熟人了,好像遇到过 2 次,直接开干:

// 迭代的方法
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        if(head == nullptr) return head;
        ListNode* cur = head;
        ListNode* pre = nullptr;
        // while(cur->next != nullptr){
        while(cur != nullptr){
            ListNode* next = cur->next;
            cur->next = pre;
            pre = cur;
            cur = next;
        }
        return pre;
    }
};

// 递归
class Solution {
public:
    ListNode* reverseList(ListNode* head){
        if(!head) return head;
        if(head->next == nullptr) return head;
        ListNode* next = head->next;
        ListNode* res = reverseList(head->next);
        next->next = head;
        head->next = nullptr; // 处理结尾的情况,也为了避免成环
        return res;
    }
};

         感想:

​​​​​​​        其实两种思路的本质都差不多,即使是递归,我认为其本质也是双指针,只是做这件事的方向不一样,迭代是正向不断改变索引的所以,而递归类似一个堆栈,从尾部开始先前遍历。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值