算法训练营 Day3-链表 1 | 203.移除链表元素 | 707.设计链表 | 206.反转链表
查阅文档地址:programmercarl.com/
本期题目地址:
目录:
- 基本概念(做题前要理解的概念)
- 我的解法
- 疑问点(过程中产生了问题并且查找资料解决)
语言
采用C++,一些分析也是用于 C++,请注意。
基本概念
- 链表是通过指针域的指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针)。链表的入口节点称为链表的头结点,最后一个节点的指针域指向 null。
- 链表的类型:单链表 (数据域 | 指针域),双链表 (指针域 | 数据域 | 指针域)。循环链表 首尾相连。
- 链表的存储方式(与数组进行对比):数组是在内存中是连续分布的。链表中的节点在内存中是不连续分布的,是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
- 链表的定义:C/C++ 的定义链表节点方式,如下所示。
// 单链表结构体定义
struct ListNode {
// 成员变量
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
// 构造函数
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
- 如果结构体中没有显式定义构造函数,C++ 编译器会自动生成一个默认构造函数。这个默认构造函数的作用是:
- 不对成员变量进行初始化。
- 仅提供一个空的构造函数。
struct ListNode {
int val;
ListNode *next;
};
ListNode() {} //空的构造函数
- val 的值将是未定义的(可能是一个随机值):导致程序行为不可预测。
- next 指针也将是未定义的(可能是一个野指针):可能会导致程序崩溃或段错误(Segmentation Fault)。
- 不想定义构造函数,可以通过手动初始化或统一初始化来确保成员变量被正确设置
- (1) 使用初始化列表,在创建节点时,手动初始化成员变量:
ListNode* node = new ListNode;
node->val = 0;
node->next = NULL;
- (2) 使用统一初始化(C++11 及以上),从 C++11 开始,可以使用统一初始化来初始化结构体:
ListNode* node = new ListNode{0, NULL};
- 链表的操作
- 删除节点: 删除节点后,C++ 里最好是再手动释放这个被删除的节点,释放这块内存;Java、Python,就有自己的内存回收机制;
- 添加节点: 链表的增添和删除都是 O(1) 操作;查找的时间复杂度是 O(n);
- 性能分析(链表的特性和数组的特性进行一个对比)
| 对照表 | 插入/删除 | 查询 | 适用场景 |
|---|---|---|---|
| 数组 | O(n) | O(1) | 数据量固定,频繁查询,较少增删 |
| 链表 | O(1) | O(n) | 数据量不固定,频繁增删,较少查询 |
| ------- | -------- | ---- | ------------------------------ |
leetcode203.移除链表元素
建议:本题最关键是要理解,虚拟头结点的使用技巧,设置一个虚拟头结点在进行删除操作,以一种统一的逻辑来移除。
我的代码
// 方法一:头节点和其他节点分类讨论,力扣模式
// 时间复杂度: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.设计链表
建议:这是一道考察链表综合操作的题目,建议使用虚拟头结点。看题目是一道实现自定义链表类 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);
*/
我的问题
- 如何创建结构体:结构体和类在很多方面是相似的,类可以包含构造函数、成员函数和成员变量等,结构体也可以(不过默认的访问权限有所不同,结构体默认成员访问权限是 public,类默认是 private)。
- 链表操作的两种方式:直接使用原来的链表来进行操作。设置一个虚拟头结点在进行操作。
- 在 C++ 里,构造函数的初始化列表是用逗号分隔成员初始化项,且不需要额外的花括号。DListNode(int x) : val(x), pre(nullptr), next(nullptr) {}
- 空指针异常风险:当 cur->next 为 nullptr 时,也就是 cur 是链表的最后一个节点,代码里的 cur->next->pre = temp; 这一行会引发空指针异常。因为对 nullptr 进行成员访问是不被允许的。
- 代码中没有为 MyLinkedList 类提供析构函数,这会导致在对象销毁时,链表节点占用的内存无法被正确释放,从而造成内存泄漏。
// 析构函数,释放链表节点占用的内存
~MyLinkedList() {
DListNode* cur = dummyHead->next;
while (cur != dummyTail) {
DListNode* temp = cur;
cur = cur->next;
delete temp;
}
delete dummyHead;
delete dummyTail;
}
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)。最后还总结了链表操作、空指针处理等要点。