算法训练营 Day3-链表 1 | 203.移除链表元素 | 707.设计链表 | 206.反转链表

75 阅读10分钟

算法训练营 Day3-链表 1 | 203.移除链表元素 | 707.设计链表 | 206.反转链表

查阅文档地址:programmercarl.com/

本期题目地址:

  1. 203.移除链表元素 - 简单 - 力扣
  2. 707.设计链表 - 中等 - 力扣
  3. 206.反转链表 - 简单 - 力扣

目录:

  1. 基本概念(做题前要理解的概念)
  2. 我的解法
  3. 疑问点(过程中产生了问题并且查找资料解决)

语言

采用C++,一些分析也是用于 C++,请注意。

基本概念

  1. 链表是通过指针域的指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针)。链表的入口节点称为链表的头结点最后一个节点的指针域指向 null。
  2. 链表的类型:单链表 (数据域 | 指针域),双链表 (指针域 | 数据域 | 指针域)。循环链表 首尾相连
  3. 链表的存储方式(与数组进行对比):数组是在内存中是连续分布的。链表中的节点在内存中是不连续分布的,是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
  4. 链表的定义:C/C++ 的定义链表节点方式,如下所示。
// 单链表结构体定义
struct ListNode {
    // 成员变量
    int val;  // 节点上存储的元素
    ListNode *next;  // 指向下一个节点的指针
    // 构造函数
    ListNode(int x) : val(x), next(NULL) {}  // 节点的构造函数
};
  1. 如果结构体中没有显式定义构造函数,C++ 编译器会自动生成一个默认构造函数。这个默认构造函数的作用是:
  • 不对成员变量进行初始化。
  • 仅提供一个空的构造函数。
struct ListNode {
    int val;
    ListNode *next;
};
ListNode() {} //空的构造函数
  • val 的值将是未定义的(可能是一个随机值):导致程序行为不可预测。
  • next 指针也将是未定义的(可能是一个野指针):可能会导致程序崩溃或段错误(Segmentation Fault)。
  1. 不想定义构造函数,可以通过手动初始化统一初始化来确保成员变量被正确设置
  • (1) 使用初始化列表,在创建节点时,手动初始化成员变量:
ListNode* node = new ListNode;
node->val = 0;
node->next = NULL;
  • (2) 使用统一初始化(C++11 及以上),从 C++11 开始,可以使用统一初始化来初始化结构体:
ListNode* node = new ListNode{0, NULL};
  1. 链表的操作
  • 删除节点: 删除节点后,C++ 里最好是再手动释放这个被删除的节点,释放这块内存;Java、Python,就有自己的内存回收机制;
  • 添加节点: 链表的增添和删除都是 O(1) 操作;查找的时间复杂度是 O(n);
  • 性能分析(链表的特性和数组的特性进行一个对比)
对照表插入/删除查询适用场景
数组O(n)O(1)数据量固定,频繁查询,较少增删
链表O(1)O(n)数据量不固定,频繁增删,较少查询
-------------------------------------------------

leetcode203.移除链表元素

203.移除链表元素 - 简单 - 力扣

建议:本题最关键是要理解,虚拟头结点的使用技巧,设置一个虚拟头结点在进行删除操作,以一种统一的逻辑来移除。

我的代码

// 方法一:头节点和其他节点分类讨论,力扣模式
// 时间复杂度:O(n),其中 n 是链表的长度。因为代码需要遍历链表中的每个节点一次。
// 空间复杂度:O(1),只使用了常数级的额外空间。
/**
 * 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) {
        // 问题:需要使用 while 循环检查
        while(head != nullptr && head->val == val) {
            ListNode* temp = head;
            head = head->next;
            delete temp;
        }

        ListNode* cur = head;
        // 问题一:存在一个潜在的问题,当链表为空时,代码会出现错误。而且判断顺序不能出错。
        while(cur != nullptr && cur->next != nullptr) {
            if(cur->next->val == val) {
                ListNode* temp = cur->next;
                cur->next = cur->next->next;
                delete temp;
            } else {
                cur = cur->next;
            }
        }
        return head;
    }
};
// 方法二:使用虚拟节点 + 迭代,力扣模式
// 时间复杂度:O(n),其中 n 是链表的长度。因为代码需要遍历链表中的每个节点一次。
// 空间复杂度:O(1),只使用了常数级的额外空间。
/**
 * 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) {
        // 问题一:如何创建虚拟节点
        struct ListNode* dummyNode = new ListNode(0, head);
        // 在 C++ 中,struct 关键字是可以省略的。这是因为在 C++ 里,struct 是一种类类型,定义之后就可以直接把它当作类型名来使用
        
        struct ListNode* cur = dummyNode;
        while(cur->next != nullptr) {
            if(cur->next->val == val) {
                // 问题二:手动释放内存
                ListNode* temp = cur->next;
                // 问题三:删除 cur->next 这个节点
                cur->next = cur->next->next;
                delete temp;
            } else {
                cur = cur->next;
            }
        }
        head = dummyNode->next;
        delete dummyNode;
        return head;
    }
};
// 方法三:使用递归,力扣模式
// 时间复杂度:O(n),其中 n 是链表的长度。因为代码需要遍历链表中的每个节点一次。
// 空间复杂度:O(n),这是由于递归调用会使用系统栈空间,在最坏的情况下,递归深度为链表的长度 n。
/**
 * 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) {
        if(!head) { // 问题一:不需要考虑下一个节点,链表为空返回空节点
            return head;
        }
        head->next = removeElements(head->next, val); // 问题二:接收返回类型,为拼接做准备
        // if(head->val == val) {
        //     return head->next;
        // } else {
        //     return head;
        // }
        return head->val == val ? head->next : head;
    }
};

707.设计链表

707.设计链表 - 中等 - 力扣

建议:这是一道考察链表综合操作的题目,建议使用虚拟头结点。看题目是一道实现自定义链表类 MyLinkedList 的题目。你需要实现一个单/双链表类,该类要支持以下几种操作:构造函数 MyLinkedList():用于初始化链表。

我的代码

// 方法一:单链表
// 时间复杂度:涉及 index 的相关操作为 O(index), 其余为 O(1)
// 空间复杂度:O(n)

class MyLinkedList {
private:
    int size;
    ListNode* dummyHead; // 虚拟头节点
public:
    MyLinkedList() {
        size = 0;
        dummyHead = new ListNode(0); // 问题一:dummyHead 是指针,ListNode(0) 是对象实例,new 关键字可以创建对象并且返回地址
    }
    
    int get(int index) {
        if(index < 0 || index >= size) {
            return -1;
        }
        ListNode* cur = dummyHead;
        for(int i = 0; i <= index; i ++) { // 原链表下标从 0 开始
            cur = cur->next;
        }
        return cur->val;
    }
    
    void addAtHead(int val) {
        addAtIndex(0, val);
    }
    
    void addAtTail(int val) {
        addAtIndex(size, val);
    }
    
    void addAtIndex(int index, int val) { // 插入 val 到 index 节点之前
        // size = 1;插入头节点:index = 0;插入尾节点:index = size
        // 问题二:严格按照题目要求,判断 index 的合法性
        if(index > size) {
            return;
        }
        index = max(index, 0);
        ListNode* cur = dummyHead; 
        for(int i = 0; i < index; i ++) {
            cur = cur->next;
        }
        ListNode* temp = new ListNode(val);
        temp->next = cur->next;
        cur->next = temp;
        size ++;
        return;
    }
    
    void deleteAtIndex(int index) {
       if(index < 0 || index >= size) {
            return;
        }
        ListNode* cur = dummyHead;
        for(int i = 0; i < index; i ++) {
            cur = cur->next;
        }
        ListNode* temp = cur->next;
        cur->next = temp->next;
        delete temp;
        size --; // 问题三:不要忘记
        return;
    }
};

/**
 * 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);
 */
// 方法二:双链表
// 时间复杂度:涉及 index 的相关操作为 O(index), 其余为 O(1)
// 空间复杂度:O(n)

struct DListNode {
    int val;
    DListNode* pre;
    DListNode* next;
    DListNode(int x) : val(x), pre(nullptr), next(nullptr) {}
};
class MyLinkedList {
private:
    int size;
    DListNode* dummyHead;
    DListNode* dummyTail;

public:
    MyLinkedList() {
        size = 0;
        dummyHead = new DListNode(0);
        dummyTail = new DListNode(0);
        dummyHead->pre = dummyTail;
        dummyTail->next = dummyHead;
        dummyHead->next = dummyTail;
        dummyTail->pre = dummyHead;
        // 差异一:没有在两者之间建立一个正常的“链表方向”的连接,即没有让 dummyHead 的后继指向 dummyTail,dummyTail 的前驱指向 dummyHead。
    }

    int get(int index) {
        // 差异二:在 get 方法里有一个小优化,即根据 index 与 size - index 的大小关系,决定是从 head 开始遍历还是从 tail 开始遍历,这样可以减少遍历的次数。不过这和空指针错误无关。
        if (index < 0 || index >= size) {
            return -1;
        }
        DListNode* cur = dummyHead;
        for (int i = 0; i <= index; i++) {
            cur = cur->next;
        }
        return cur->val;
    }

    void addAtHead(int val) { addAtIndex(0, val); }

    void addAtTail(int val) { addAtIndex(size, val); }
    // 差异三:addAtIndex 和 deleteAtIndex 方法中的指针操作逻辑,addAtIndex 和 deleteAtIndex 方法中同样根据 index 与 size - index 的大小关系,选择从 head 或者 tail 开始遍历,从而找到要操作的位置。但这也不是造成空指针错误的关键。
    void addAtIndex(int index, int val) {
        if (index > size) {
            return;
        }
        index = max(0, index);

        DListNode* cur = dummyHead;
        for (int i = 0; i < index; i++) {
            cur = cur->next;
        }
        DListNode* temp = new DListNode(val);
        temp->next = cur->next;
        // 问题一:空指针无法访问成员变量。检查 cur->next 是否为 nullptr
        // if (cur->next != nullptr) {
        //     cur->next->pre = temp;
        // }
        cur->next->pre = temp;
        temp->pre = cur;
        cur->next = temp;
        size++;
        return;
    }

    void deleteAtIndex(int index) {
        if (index < 0 || index >= size) {
            return;
        }
        DListNode* cur = dummyHead;
        for (int i = 0; i <= index; i++) {
            cur = cur->next;
        }
        DListNode* temp = cur->pre;
        temp->next = cur->next;
        // 问题二:要检查是否是空指针
        // if (cur->next != nullptr) {
        //     cur->next->pre = temp;
        // }
        cur->next->pre = temp;
        delete cur;
        size--;
        return;
    }
};

/**
 * 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);
 */
    

我的问题

  1. 如何创建结构体:结构体和类在很多方面是相似的,类可以包含构造函数、成员函数和成员变量等,结构体也可以(不过默认的访问权限有所不同,结构体默认成员访问权限是 public,类默认是 private)。
  2. 链表操作的两种方式:直接使用原来的链表来进行操作。设置一个虚拟头结点在进行操作。
  3. 在 C++ 里,构造函数的初始化列表是用逗号分隔成员初始化项,且不需要额外的花括号。DListNode(int x) : val(x), pre(nullptr), next(nullptr) {}
  4. 空指针异常风险:当 cur->next 为 nullptr 时,也就是 cur 是链表的最后一个节点,代码里的 cur->next->pre = temp; 这一行会引发空指针异常。因为对 nullptr 进行成员访问是不被允许的。
  5. 代码中没有为 MyLinkedList 类提供析构函数,这会导致在对象销毁时,链表节点占用的内存无法被正确释放,从而造成内存泄漏。
    // 析构函数,释放链表节点占用的内存
    ~MyLinkedList() {
        DListNode* cur = dummyHead->next;
        while (cur != dummyTail) {
            DListNode* temp = cur;
            cur = cur->next;
            delete temp;
        }
        delete dummyHead;
        delete dummyTail;
    }

206.反转链表

206.反转链表 - 简单 - 力扣

我的代码

// 方法一:双指针法(迭代法),力扣模式
// 时间复杂度:O(n)
// 空间复杂度:O(1)
/**
 * 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* reverseList(ListNode* head) {
        ListNode* pre = nullptr; // 问题一:这样定义空指针
        ListNode* cur = head;
        while(cur) {
            ListNode* temp = cur->next;
            cur->next = pre;

            pre = cur;
            cur = temp;
        }
        return pre;
    }
};
// 方法二:递归法(栈先进后出),力扣模式
// 时间复杂度:O(n), 要递归处理链表的每个节点
// 空间复杂度:O(n), 递归调用了 n 层栈空间
/**
 * 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* reverseList(ListNode* head) {
        if(!head || head->next == nullptr) {
            return head;
        }

        ListNode* newNode = reverseList(head->next);

        head->next->next = head;
        head->next = nullptr;
        return newNode;
    }
};

总结:

算法训练营 Day3 钻研链表。先讲了链表的构造、存储,对比了链表和数组。接着做了三道题:移除链表元素有三种做法,时间复杂度都为 O(n);设计链表用单、双链表实现,操作 index 时耗时 O(index),双链表构造和内存管理有问题;反转链表用双指针和递归,时间复杂度也是 O(n)。最后还总结了链表操作、空指针处理等要点。