day3:第二章 链表 Part01

今日任务(2024/05/13)

本来是2024/05/10的任务,前些天太忙啦,今天补起来~

  • 链表理论基础
  • 203.移除链表元素
  • 707.设计链表
  • 206.翻转链表

链表理论基础

题目建议

  • 了解一下链接基础,以及链表和数组的区别

链表的类型

特点图示
单链表只能向前查询单向链表
双链表双向查询双向链表
循环链表首尾相连(解决约瑟夫环问题)环形链表

链表的存储方式

  • 数组:在内存中连续分布
  • 链表:在内存中可不连续分布(散乱分布)

链表的定义

这部分需要会自己手写,这里需要掌握单链表的手写,双链表在此基础上修改指针域即可。

构造函数可以选择是否写上:

  • 构造函数1:可以初始化val
  • 构造函数2:可以初始化valnext
  • 默认构造函数:不会初始化任何成员变量
struct ListNode {
    int val;
    ListNode* next;
    // 默认构造函数是不需要传参的:ListNode * head = new ListNode();
    ListNode(int x) : val(x), next(nullptr) {}				// 构造函数1
    ListNode(int x, ListNode* next) : val(x), next(next) {}	// 构造函数2
}

链表基本操作

链表基本操作中,注意区分头节点和中间节点,他们的操作略有不同(但是可以通过***设置虚拟头节点来统一两种情况!***这个后面再说),中间节点的基本操作如下:

删除节点

如图删除节点D,可以直接将节点C的next指针指向节点E,但是注意:

  • 在C++里节点D仍然留在内存中,因此需要手动释放节点D的这块内存
  • Java、Python等语言有自己的内存回收机制,无需手动释放
    删除节点

添加节点

增删都是 O ( 1 ) O(1) O(1)操作,但是要找到需要操作的位置需要从头节点开始查找,这个查找过程是 O ( n ) O(n) O(n)操作哦~
添加节点

链表和数组的区别

特性数组链表
内存分配连续内存空间非连续内存空间,每个元素(节点)独立分配
大小固定性一旦创建,大小固定大小动态变化,可随时插入或删除节点
访问方式通过索引随机访问,速度快 O ( 1 ) O(1) O(1)只能顺序访问,速度较慢 O ( n ) O(n) O(n)
插入和删除需要移动元素,可能效率较低 O ( n ) O(n) O(n)只需改变指针,效率高 O ( 1 ) O(1) O(1)
内存浪费可能预留了未使用的空间由于指针的存在,可能占用更多内存
使用场景适用于大小已知且不经常变动的数据集合适用于频繁插入和删除的场景
声明方式指定大小和类型使用指针和动态内存分配
内存管理静态或自动管理手动管理,需释放不再使用的节点
扩展性不易扩展易于扩展
时间复杂度访问:O(1),插入和删除:O(n)访问:O(n),插入和删除:O(1)
空间复杂度O(n),其中 n 是数组元素数量O(n),其中 n 是链表节点数量

203.移除链表元素

题目建议

  • 本题最关键是要理解虚拟头结点的使用技巧,这个对链表题目很重要。

题目:203. 移除链表元素 - 力扣(LeetCode)

给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点

示例 1:

示例1

输入:head = [1,2,6,3,4,5,6], val = 6
输出:[1,2,3,4,5]

示例 2:

输入:head = [], val = 1
输出:[]

示例 3:

输入:head = [7,7,7,7], val = 7
输出:[]

提示:

  • 列表中的节点数目在范围 [0, 104]
  • 1 <= Node.val <= 50
  • 0 <= val <= 50

虚拟头节点法[ 用时: 26 m 48 s ]

思路

要区分头节点的删除和中间节点的删除。

为了统一两种情况的操作,这里设置虚拟头节点dummyHead,将头节点的操作转化成了中间节点的删除~(很巧妙)后面还需要释放删除节点的内存!

具体步骤如下:

  1. 设置虚拟头节点dummyHead,指向head
  2. 从虚拟头节点dummyHead开始,遍历整条链表(令pre = dummyHead,要判断是否删除的节点是cur,下一个节点是temp
    • 发现cur需要删除:
      • prenext指针指向temp
      • 释放cur内存
    • cur不需要删除:
      • pre移动至下一个节点
  3. 删除后,链表的头节点是dummyHead->next

代码

注意:

  • 关键点1:遍历链表时要注意是否为空(如果要使用多个节点,需要多个判断,比如说在这里判断了prepre->next

  • 关键点2:释放某节点内存时,还需要避免野指针的产生(可以采用置nullptr 或 赋新的有效地址的方式)

    为啥是nullptr?

    • 一般C++中NULL0可以互换使用,都代表”无“或”空“的概念

    • 但是C++不允许将void*隐式转换为其他类型,在进行C++重载时会出现混乱(比如说使用foo(NULL)时会调用foo(int)

      void foo(char*);
      void foo(int);
      
    • 为避免这种情况,C++11中引入nullptr关键字来区分空指针和0,nullptrNULL的强类型等价物,且类型安全(推荐使用!!!)

  • 关键点3:在删除某节点后,不能立马移动pre指针,因为每个循环都需要判断pre->next(也就是cur)是否需要删除,这里已经删除了先前的cur,而新的pre->next还未做判断,因此不能移动pre指针

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        // 虚拟头节点,指向head
        ListNode* dummyHead = new ListNode(-1, head);
        ListNode* pre = dummyHead;
        // 关键点1:链表遍历
        while (pre != nullptr && pre->next != nullptr) {
            ListNode* cur = pre->next;
            ListNode* temp = cur->next;
            if (cur->val == val) {  // 情况1:当前cur要删除
                pre->next = temp;   // 调整指针指向
                // 关键点2:cur内存释放
                delete cur;         // 释放节点内存
                // 此时cur成为野指针,仍指向已被释放的内存
                cur = nullptr;      // 避免野指针:置nullptr 或 赋新的有效地址
                // 关键点3:为何此时不移动pre?
            } else {                // 情况2:当前cur不用删除
                pre = pre->next;    // pre移动到下一节点
            }
        }
        return dummyHead->next;
    }
};
  • 时间复杂度: O ( n ) O(n) O(n)

  • 空间复杂度: O ( 1 ) O(1) O(1)

Carl思路

代码随想录 (programmercarl.com)

Carl使用了两种方法去删除节点:

  1. 直接使用原来的链表来进行删除操作
  2. 直接使用原来的链表来进行删除操作再进行删除操作

并且关于第一种直接操作的方法里,给出了明确的2种情况的代码,可以很直观地看到虚拟头节点法的机智~

// 删除头结点
while (head != NULL && head->val == val) { // 注意这里不是if
    ListNode* tmp = head;
    head = head->next;
    delete tmp;
}

// 删除非头结点
ListNode* cur = head;
while (cur != NULL && cur->next!= NULL) {
    if (cur->next->val == val) {
        ListNode* tmp = cur->next;
        cur->next = cur->next->next;
        delete tmp;
    } else {
        cur = cur->next;
    }
}

707.设计链表

题目建议

  • 这是一道考察 链表综合操作的题目,不算容易,可以练一练 使用虚拟头结点

题目:707. 设计链表 - 力扣(LeetCode)

你可以选择使用单链表或者双链表,设计并实现自己的链表。

单链表中的节点应该具备两个属性:valnextval 是当前节点的值,next 是指向下一个节点的指针/引用。

如果是双向链表,则还需要属性 prev 以指示链表中的上一个节点。假设链表中的所有节点下标从 0 开始。

实现 MyLinkedList 类:

  • MyLinkedList() 初始化 MyLinkedList 对象。
  • int get(int index) 获取链表中下标为 index 的节点的值。如果下标无效,则返回 -1
  • void addAtHead(int val) 将一个值为 val 的节点插入到链表中第一个元素之前。在插入完成后,新节点会成为链表的第一个节点。
  • void addAtTail(int val) 将一个值为 val 的节点追加到链表中作为链表的最后一个元素。
  • void addAtIndex(int index, int val) 将一个值为 val 的节点插入到链表中下标为 index 的节点之前。如果 index 等于链表的长度,那么该节点会被追加到链表的末尾。如果 index 比长度更大,该节点将 不会插入 到链表中。
  • void deleteAtIndex(int index) 如果下标有效,则删除链表中下标为 index 的节点。

示例:

输入
["MyLinkedList", "addAtHead", "addAtTail", "addAtIndex", "get", "deleteAtIndex", "get"]
[[], [1], [3], [1, 2], [1], [1], [1]]
输出
[null, null, null, null, 2, null, 3]

解释
MyLinkedList myLinkedList = new MyLinkedList();
myLinkedList.addAtHead(1);
myLinkedList.addAtTail(3);
myLinkedList.addAtIndex(1, 2);    // 链表变为 1->2->3
myLinkedList.get(1);              // 返回 2
myLinkedList.deleteAtIndex(1);    // 现在,链表变为 1->3
myLinkedList.get(1);              // 返回 3

提示:

  • 0 <= index, val <= 1000
  • 请不要使用内置的 LinkedList 库。
  • 调用 getaddAtHeadaddAtTailaddAtIndexdeleteAtIndex 的次数不超过 2000

我的思路 [ 用时: 37 m 7 s ]

思路

本题需要实现获取、插入(头插/尾插/中间插)、删除节点的操作,用虚拟头节点法好处多多:

  • 在删除节点时统一了删除头节点、删除中间节点两种情况,代码会更简洁
  • 在插入节点时统一了头插/尾插/中间插三种情况,头插/尾插可以直接调用中间插的函数

注意:下标index是从0开始的~

代码

小小一道题目,做了很久,各种报错,有很多需要注意的点:

  • 注意项1:要在构造函数中进行动态内存分配

    • 对于使用动态内存分配的 _dummyHead,需要在构造函数中进行分配,而不是在类的作用域内!!!因为这样做会导致未定义行为(undefined behavior, UB)
    // 错误示范(在类的作用域内分配动态内存)
    private:
        MyLinkedList *_dummyHead = new MyLinkedList(-1);
    	...
    
  • 注意项2:使用析构函数释放所有动态分配的对象占用的内存

    • 对于非指针类型的成员变量,如内置数据类型(int, float, double 等),基本不需要在析构函数中进行特别的释放操作,因为它们的生命周期仅限于对象本身,当对象被销毁时,它们占用的内存会自动释放
    • 对于指针类型的成员变量,如果它们指向了动态分配的内存(即通过 new 分配的内存),则需要在析构函数中显式地释放这些内存,以避免内存泄漏。
  • 注意项3:类的私有成员变量不能在类定义中直接初始化,除非它们是常量或者静态成员变量

    静态成员变量属于类本身,而不是类的任何对象。这意味着无论创建多少个类的实例,静态成员变量都只有一个副本,所有实例都共享这个变量

class MyLinkedList {
public:
    // 注意项1:要在构造函数中进行动态内存分配
    MyLinkedList(): val(0), next(nullptr), _size(0), _dummyHead(new MyLinkedList(-1)) {}
    MyLinkedList(int val): val(val), next(nullptr), _size(1), _dummyHead(nullptr){}
    MyLinkedList(int val, MyLinkedList* next): val(val), next(next), _size(1), _dummyHead(nullptr){}
    
    // 注意项2:使用析构函数释放所有动态分配的对象
    ~MyLinkedList() {
        while (_dummyHead != nullptr && _dummyHead->next != nullptr) {
            MyLinkedList* temp = _dummyHead->next;
            _dummyHead = temp->next;
            delete temp;
            temp = nullptr;
        }
        delete _dummyHead;
        _dummyHead = nullptr;
    }

    int get(int index) {
        // 下标无效
        if (index < 0 || index >= _size) return -1;
        // 搜索节点
        MyLinkedList* node = _dummyHead->next;
        while (index--) {
            node = node->next;
        }
        return node->val;
    }
    
    void addAtHead(int val) {
        // 相当于插入到0节点之前
        addAtIndex(0, val);
    }
    
    void addAtTail(int val) {
        // 相当于插入到_size节点之前
        addAtIndex(_size, val);
    }
    
    void addAtIndex(int index, int val) {
        // 下标无效
        if (index < 0 || index > _size) return;
        // 搜索到链表下标为index - 1的节点pre(node的前一个节点)
        MyLinkedList* pre = _dummyHead;
        while (index--) {
            pre = pre->next;
        }
        // 要插入的节点
        MyLinkedList* node = new MyLinkedList(val, pre->next);
        pre->next = node;
        // 更新链表容量
        _size++;
    }
    
    void deleteAtIndex(int index) {
        // 下标无效
        if (index < 0 || index >= _size) return;
        MyLinkedList* pre = _dummyHead;
        while (index--) {
            pre = pre->next;
        }
        // 需要删除的是pre->next
        MyLinkedList* temp = pre->next;
        pre->next = temp->next;
        delete temp;
        temp = nullptr;
        // 更新链表容量
        _size--;
    }
private:
    // 注意项3:类的私有成员变量不能在类定义中直接初始化,除非它们是常量或者静态成员变量
    int val;
    MyLinkedList* next;
    MyLinkedList *_dummyHead;
    int _size;
};

/**
 * Your MyLinkedList object will be instantiated and called as such:
 * MyLinkedList* obj = new MyLinkedList();
 * int param_1 = obj->get(index);
 * obj->addAtHead(val);
 * obj->addAtTail(val);
 * obj->addAtIndex(index,val);
 * obj->deleteAtIndex(index);
 */

分析不足

  • 关于是否能够初始化,以及析构函数的内存释放已经把我彻底绕晕了,浪费了太久太久的时间!!!

Carl思路

代码随想录 (programmercarl.com)

206.翻转链表

题目建议

  • 这个非常容易考,但是又很容易错,经常今天了,明天就了(@_@😉
  • 真的很离谱,不知道为啥总是不能老老实实地呆在我的脑子里!!!

题目:206. 反转链表 - 力扣(LeetCode)

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
示例1

示例 1:

输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

示例 2:
示例2

输入:head = [1,2]
输出:[2,1]

示例 3:

输入:head = []
输出:[]

提示:

  • 链表中节点的数目范围是 [0, 5000]
  • -5000 <= Node.val <= 5000

**进阶:**链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?

改变箭头方向 [ 用时: 14 m 21 s ]

思路

  1. 初始化pre = nullptrcur = head
  2. 遍历链表,逐个翻转箭头:
    • 定义temp = cur->next
    • cur->next指向pre
    • 更新节点:
      • pre = cur
      • cur = temp
  3. 翻转后的头节点也就是之前的尾节点——遍历之后的pre
    206.翻转链表

代码

class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        ListNode *pre = nullptr, *cur = head;
        while (cur != nullptr) {
            ListNode* temp = cur->next;
            cur->next = pre;
            // 更新节点
            pre = cur;
            cur = temp;
        }
        return pre;
    }
};
  • 时间复杂度: O ( n ) O(n) O(n)

  • 空间复杂度: O ( 1 ) O(1) O(1)

挨个拎人站第一 [ 用时: 23 m 2 s ]

我又开始瞎起名啦

思路

这个其实有些像递归,逐个遍历链表,从第二个人开始,我们每次都对ta说“你,站第一个去”,所以相当于不断让面前这个人离开原位置 并 添加到队首去。每次递归的内容就是:拎一个人站到第一位

根据上面的思路,我们要做一个递归函数,需要知道什么呢?

  • 原来站第一的人,也就是现在的头节点first
  • 现在面前的人,也就是我们要拎走的人cur
  • cur前面的一个人pre、后面一个人temp:因为我们要在cur走了之后恢复这里的队伍链接

知道这些后可以开始处理啦:

  1. 保存cur后一个人temp = cur->next
  2. cur站到第一个:cur->next = first
  3. 恢复cur处的链接:pre->next = temp
  4. 更新目前的位置信息:
    • first = cur
    • cur = temp
    • pre = pre(没错,pre没有变动哦,也就是说pre一直指向同一个人——原来的第一名head!)

代码

class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        if (head == nullptr) return nullptr;
        ListNode *first = head;
        ListNode *pre = head;
        ListNode *cur = head->next;
        while (cur) {
            ListNode *temp = cur->next; // 保存cur后一个人
            cur->next = first;          // 让cur站到第一个
            pre->next = temp;           // 恢复cur处的链接
            // 更新位置信息
            first = cur;
            cur = temp;
        }
        return first;
    }
};
  • 时间复杂度: O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1)

Carl思路

代码随想录 (programmercarl.com)

Carl这里提到了两种方法:

  • 第一种方法和我的第一种方法一样——双指针法

  • 第二种方法是递归法,也就是将双指针法中while循环的内容提取出来作为递归函数

    /**
     * Definition for singly-linked list.
     * struct ListNode {
     *     int val;
     *     ListNode *next;
     *     ListNode() : val(0), next(nullptr) {}
     *     ListNode(int x) : val(x), next(nullptr) {}
     *     ListNode(int x, ListNode *next) : val(x), next(next) {}
     * };
     */
    class Solution {
    private:
        ListNode* reverse(ListNode* pre, ListNode* cur) {
            // 注意:当最后一个cur为空指针时,说明已经完成翻转
            if (cur == nullptr) return pre;
            ListNode* temp = cur->next;
            cur->next = pre;
            return reverse(cur, temp);
        }
    public:
        ListNode* reverseList(ListNode* head) {
            return reverse(nullptr, head);
        }
    };
    
    • 时间复杂度: O ( n ) O(n) O(n)
    • 空间复杂度: O ( n ) O(n) O(n)
      • 递归函数要调用 n n n层栈空间

收获

今天学习的内容都是非常易错的一些题目,链表痛点呀!

  • 链表理论基础
    • 注意链表的定义、和数组的区别
  • 203.移除链表元素
    • 初次感受到虚拟头节点的便利!
  • 707.设计链表
    • 私有成员变量最好别在类定义中初始化,在构造函数中弄吧!(常量和静态成员变量除外)
    • 动态内存分配的两个关键点:
      1. 动态分配内存的成员变量只能在构造函数中初始化
      2. 动态分配内存的对象得在析构函数中手动释放掉
  • 206.翻转链表
    • 两种双指针法:
      • 方法1:直接改变指针朝向
      • 方法2:拎个人站第一
    • 递归法:改造方法1
  • 本文部分图片来自代码随想录
  • 若存在侵权,烦请指出,本人会立马删除相关内容;
  • 本文内容若有不正确或不规范指出,请大家不吝赐教~
  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值