代码随想录训练营Day3 | 链表理论基础 | 203.移除链表元素 | 707.设计链表 | 206.反转链表
今天任务:学习链表理论基础
链表的类型
链表的存储方式
链表的定义
链表的操作
性能分析
学习文档:代码随想录 (programmercarl.com)
链表的类型:单链表、双链表、循环链表(区别就在于其结构不同)
链表是一种常用的数据结构,通过指针串联在一起,相对于数组有以下几方面优点:
-
动态大小:链表的大小是动态的,可以在运行时根据需要进行扩展或缩减。而数组的大小在声明时就固定了,不能动态改变。
-
内存利用率:链表不需要像数组那样预先分配一块连续的内存空间,因此可以更有效地利用内存,尤其是在内存碎片较多的情况下。
-
插入和删除操作搞笑:在链表中,插入和删除节点通常只需要改变指针,而不需要移动其他元素。这使得链表在插入和删除操作上比数组更高效,因为数组需要移动插入点或删除点之后的所有元素。
-
不需要初始化大小:在创建链表时,不需要指定链表的大小,可以根据需要逐步构建链表。
-
空间分配:链表的节点可以在需要时单独分配,这意味着即使链表很大,也不需要一次性分配大块内存,从而减少了内存的浪费。
-
灵活的数据结构:链表可以很容易地构建成其他复杂的数据结构,如双向链表、循环链表
等,这些结构可以支持更复杂的操作。
链表也有其缺点,比如访问元素时需要从头开始遍历,导致访问时间较长;指针的额外存储空间可能会增加内存的开销;以及由于指针的存在,可能会导致程序的复杂性增加。
链表的存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。链表是通过指针域的指针链接在内存中各个节点。所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
链表的定义
单链表的定义 (特别注意,在面试中可能需要自己定义链表)
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
链表的基本操作:删除、添加
删除节点
删除D节点,如图所示:
只要将C节点的next指针 指向E节点就可以了。注意此时D节点依旧留在内存中,只不过是没有在这个链表中而已,在使用C++最好手动释放这个D节点,释放这块内除。
添加节点
可以看出链表的增添和删除都是O(1)操作,也不会影响到其他节点。但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过next指针进行删除操作,查找的时间复杂度是O(n)
性能分析
Leetcode: 203.移除链表元素
题目描述:
给你一个链表的头节点 head
和一个整数 val
,请你删除链表中所有满足 Node.val == val
的节点,并返回 新的头节点 。
示例 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 输出:[]
解题思路:
这题就是简单的删除链表元素,但是要注意区分两种删除方式
1.删除头节点
2.删除非头节点
完整代码:
/**
* 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 因为删除有可能需要一直删 不止一个val
// 如果val是头节点 直接将head = head->next;即可
while(head!=NULL && head->val == val) {
ListNode* tmp = head;
head = head->next;
delete tmp;
}
ListNode* cur = head;
// 如果不是头节点,需要cur->next = cur->next->next; 这样就删除了cur->next这个节点
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;
}
}
return head;
}
};
使用虚拟头节点:
使用一个虚拟头节点,可以统一逻辑来删除链表节点
/**
* 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) {
// 设置一个虚拟头节点
ListNode* dummyHead = new ListNode(0);
// 将虚拟头节点设置为这个链表的头节点
dummyHead->next = head;
// 从虚拟头节点开始遍历
ListNode*cur = dummyHead;
// 统一删除节点逻辑 都是删除非头节点
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;
}
}
// 重新设置头节点
head = dummyHead->next;
delete dummyHead;
return head;
}
};
Leetcode: 707.设计链表
题目描述
你可以选择使用单链表或者双链表,设计并实现自己的链表。单链表中的节点应该具备两个属性:val
和 next
。val
是当前节点的值,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
的节点。
解题思路
这道题目设计链表的五个接口:
- 获取链表第index个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第index个节点前面插入一个节点
- 删除链表的第index个节点
可以说这五个接口,已经覆盖了链表的常见操作,是练习链表操作非常好的一道题目
可以继续使用上面的操作,使用虚拟头节点来操作。
class MyLinkedList {
public:
// 定义链表节点的结构体
struct LinkedNode {
int val;
LinkedNode* next;
LinkedNode(int val) : val(val), next(nullptr){}
};
// 初始化链表 这里定义的头节点是一个虚拟头节点 而不是真正的链表头节点
MyLinkedList() {
_dummyhead = new LinkedNode(0);
// 一个整型变量,用于存储链表中实际节点的数量
_size = 0;
}
// 获取链表第index个节点的数值
int get(int index) {
// 如果索引无效(即超出链表范围),返回 -1
if(index > (_size - 1) || index < 0) {
return -1;
}
// 从虚拟头节点的下一个节点开始遍历,直到到达指定索引的节点,然后返回该节点的值
LinkedNode* cur = _dummyhead->next;
while(index--) {
cur = cur->next;
}
return cur->val;
}
// 在链表最前面插入一个节点 ,插入完成后,新插入的节点为链表新的头节点
void addAtHead(int val) {
LinkedNode* newnode = new LinkedNode(val);
// 注意这里的顺序不能改变 统一插入的赋值顺序 先将新头节点插入在head之前 然后将虚拟头节点依旧放在最前面
newnode->next = _dummyhead->next;
_dummyhead->next = newnode;
_size++;
}
// 在链表最末尾插入节点
void addAtTail(int val) {
LinkedNode* newnode = new LinkedNode(val);
LinkedNode* cur = _dummyhead;
// 先将cur指向最后一个节点 判断条件cur->next != NULL
while(cur->next != NULL) {
cur = cur->next;
}
cur->next = newnode;
_size++;
}
// 在第index个节点之前插入一个新节点 使用虚拟头节点就可以方便处理index为0 插入头节点的情况
void addAtIndex(int index, int val) {
if(index > _size) {
return;
}
LinkedNode* newnode = new LinkedNode(val);
LinkedNode* cur = _dummyhead;
while(index--) {
cur = cur->next;
}
newnode->next = cur->next;
cur->next = newnode;
_size++;
}
//删除第index个节点
void deleteAtIndex(int index) {
//当 index 等于链表长度时,cur->next 将为 nullptr,因此不能访问 cur->next->next
if(index >= _size || index < 0) {
return;
}
LinkedNode* cur = _dummyhead;
// 注意index是从0开始的 这样刚好指向index前一个节点
while(index--) {
cur = cur->next;
}
LinkedNode* tmp = cur->next;
// 删除index节点
cur->next = cur->next->next;
delete tmp;
_size--;
}
private:
int _size;
LinkedNode* _dummyhead;
};
总结:要用意识去使用虚拟头节点,对于一些边界条件判断不到位!
Leetcode: 206.反转链表
题目描述
给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。
示例 1:
输入:head = [1,2,3,4,5] 输出:[5,4,3,2,1]
解题思路
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* former = NULL;
ListNode* mid = head;
ListNode* latter = NULL;
while(mid != NULL) {
// 保存mid的下一个节点,因为接下来要改变mid->next的指向了
latter = mid->next;
mid->next = former;
former = mid;
mid = latter;
}
// 注意最后一次while循环 将latter赋给了mid 所以former是反转链表后的头节点
return former;
}
};
2.递归法
递归法相对抽象一些,但是其实和双指针法是一样的逻辑,同样是当cur为空的时候循环结束,不断将cur指向pre的过程。
关键是初始化的地方, 可以看到双指针法中初始化 cur = head,pre = NULL,在递归法中可以从如下代码看出初始化的逻辑也是一样的,只不过写法变了。
具体可以看代码(已经详细注释),双指针法写出来之后,理解如下递归写法就不难了,代码逻辑都是一样的。
class Solution {
public:
ListNode* reverse(ListNode* pre,ListNode* cur){
if(cur == NULL) return pre;
ListNode* temp = cur->next;
cur->next = pre;
// 可以和双指针法的代码进行对比,如下递归的写法,其实就是做了这两步
// pre = cur;
// cur = temp;
return reverse(cur,temp);
}
ListNode* reverseList(ListNode* head) {
// 和双指针法初始化是一样的逻辑
// ListNode* cur = head;
// ListNode* pre = NULL;
return reverse(NULL, head);
}
};