题目列表
√ √
- https://leetcode-cn.com/problems/reverse-linked-list/
- https://leetcode-cn.com/problems/swap-nodes-in-pairs
- https://leetcode-cn.com/problems/linked-list-cycle
- https://leetcode-cn.com/problems/linked-list-cycle-ii
- https://leetcode-cn.com/problems/reverse-nodes-in-k-group/
- https://leetcode-cn.com/problems/merge-two-sorted-lists/
经典的链表应用场景,那就是 LRU 缓存淘汰算法
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?
这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
底层的存储结构
三种最常见的链表结构
单链表、双向链表和循环链表
单链表
头节点===>记录链表的基地址
尾节点===>指向一个空地址NULL
和数组一样支持查找、插入、删除
但是和数组不同的地方,链表的插入和删除不需要保持内存的连续移动元素,链表本身就是不连续的内存,所以链表的插入和删除一个数据是非常快速的。
时间复杂度是O(1),
但是,在查找效率就差很多了,无法随机访问第k个元素,随机访问时间复杂度为O(n)
循环链表
和单链表相比,优点是从链尾到链头比较方便,
当处理数据具有环形结构特点时,就特别适合采用循环链表。比如与瑟夫问题。
双向链表
双向链表优点:可以支持O(1)的时间复杂度情况找到前驱节点,所以茶树和删除操作要比单链表高效
链表删除操作两种情况:
删除结点中“值等于某个给定值”的结点;O(n) 需要遍历查找
删除给定指针指向的结点。单链表O(n) 双链表O(1)
双向链表在有序链表的情况下效率更高
双向链表占用的内存更多,但是应用的却比单向链表更多,主要思想===>空间换时间
没有玩过的全新版本=====>双向循环链表
链表和数组性能比较
在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。
数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
链表本身没有大小的限制,天然地支持动态扩容
ArrayList 容器,也可以支持动态扩容
如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的
如何基于链表实现 LRU 缓存淘汰算法?
我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。
当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
- 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
- . 如果此数据没有在缓存链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部
不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)
优化 ::散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)
利用链表解决回文字符串问题
由于回文串最重要的就是对称,那么最重要的问题就是找到那个中心,用快指针每步两格走,当他到达链表末端的时候,慢指针刚好到达中心,慢指针在过来的这趟路上还做了一件事,他把走过的节点反向了,在中心点再开辟一个新的指针用于往回走,而慢指针继续向前,当慢指针扫完整个链表,就可以判断这是回文串,否则就提前退出,总的来说时间复杂度按慢指针遍历一遍来算是O(n),空间复杂度因为只开辟了3个额外的辅助,所以是o(1)
写好链表的小技巧
1. 理解指针和引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
2. 注意指针丢失和内存泄漏
- 插入结点时,一定要注意操作的顺序
- 删除链表结点时,也一定要记得手动释放内存空间
3. 利用哨兵简化实现难度
针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理===>不然这样代码会复杂难懂,。我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。使用带头链表就不需要考虑插入时链表为空。。。问题
4. 注意边界条件处理
代码在一些边界或者异常情况下,最容易产生 Bug
经常用来检查链表代码是否正确的边界条件有这样几个:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个结点时,代码是否能正常工作?
- 如果链表只包含两个结点时,代码是否能正常工作?
- 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
实际上,不光光是写链表代码,你在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,你的代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!
- 画图,举例辅助思考
C++实现链表
//链表类型
typedef struct LNode
{
ListDataType data;
struct LNode *next;
}LNode, *LinkList;
//创建n个元素的链表L, 元素值存储在data数组中
Status createList(LinkList &L, ListDataType *data, int n);
//e从链表末尾入链表
Status enList(LinkList &L, ListDataType &e);
//e从链表头取出结点
Status deList(LinkList &L, ListDataType &e);
//遍历
Status travelList(LinkList &L);
//创建n个元素的链表L, 元素值存储在data数组中
Status createList(LinkList &L, ListDataType *data, int n)
{
LNode *p, *q;
int i;
if(n < 0) return OVERFLOW;
p = L = NULL;
q = (LNode *)malloc(sizeof(LNode));
if(NULL == q) return OVERFLOW;
q->next = NULL;
p = L = q;
for(i = 0; i < n; i++)
{
q = (LNode *)malloc(sizeof(LNode));
if(NULL == q) return OVERFLOW;
q->data = data[i];
p->next = q;
q->next = NULL;
p = q;
}
return OK;
}
//e从链表末尾入链表
Status enList(LinkList &L, ListDataType &e)
{
LNode * p, * q;
q = (LNode *)malloc(sizeof(LNode));
if(NULL == q) return OVERFLOW;
q->data = e;
q->next = NULL;
if(NULL == L)
{
L = (LNode *)malloc(sizeof(LNode));
if(NULL == L) return OVERFLOW;
L->next = q;
}
else if(NULL == L->next)
{
L->next = q;
}
else
{
p = L;
while(NULL != p->next)
{
p = p->next;
}
p->next = q;
}
}
//e从链表头取出结点
Status deList(LinkList &L, ListDataType &e)
{
if(NULL == L || NULL == L->next) return OVERFLOW;
LNode * p;
p = L->next;
e = p->data;
L->next = p->next;
free(p);
return OK;
}
//遍历
Status travelList(LinkList &L)
{
if(NULL == L || NULL == L->next) return OVERFLOW;
for(LNode * p = L->next; NULL != p; p = p->next)
{
printf("%d\t", p->data);
}
return OK;
}
- 多写多练
. https://leetcode.com/problems/reverse-linked-list/
https://leetcode.com/problems/swap-nodes-in-pairs
https://leetcode.com/problems/linked-list-cycle
https://leetcode.com/problems/linked-list-cycle-ii
https://leetcode.com/problems/reverse-nodes-in-k-group/
5 个常见的链表操作,把这几个操作都能写熟练,不熟就多写几遍
* 单链表反转
- 迭代实现
- 递归实现
* 链表中环的检测 leetcode 141
- 快慢指针方法,如果有环,快慢指针必然会相遇。
class Solution {
public:
bool hasCycle(ListNode *head) {
ListNode *fast = head, *slow = head;
while (fast != NULL && fast->next != NULL) {
fast = fast->next->next;
slow = slow->next;
if (fast == slow) return true;
}
return false;
}
};
* 两个有序的链表合并
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode head = ListNode(0);
ListNode *cur = &head;
while(l1 != NULL && l2 != NULL)
{
if(l1->val >= l2->val)
{
cur->next = l2;
cur = cur->next;
l2 = l2->next;
}
else
{
cur->next = l1;
cur = cur->next;
l1 = l1->next;
}
}
if(l1 == NULL) { cur->next = l2;}
if(l2 == NULL) { cur->next = l1;}
return head.next;
}
};
* 删除链表倒数第 n 个结点
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
int count = 0;
ListNode *p = head, *q = head;
// 快指针先走n步
for(int i = 0; i < n; i++)
{
q = q->next;
}
if(q == NULL) { return head->next;} // 删除头节点
while(q->next != NULL)
{
p = p->next;
q = q->next;
count++;
}
p->next = p->next->next;
return head;
}
};
- 求链表的中间结点
/*求链表的中间节点(利用快慢指针)*/
/*链表有奇数个节点时,中间节点只有一个;有偶数个节点时,结果为输出节点和它的下一个*/
struct list_head *find_mid(struct list_head *head)
{
struct list_head *slow, *fast;
slow = head;/*快慢指针都指向第一个节点*/
fast = head;
while (fast != NULL && fast->next != NULL && fast->next->next != NULL) {
slow = slow->next;/*慢指针每次走一步*/
fast = fast->next->next;/*快指针每次走两步*/
}
return slow;
}