什么是链表?
- 和数组一样,链表也是一种线性表。
2.从内存结构来看,链表的内存结构是不连续的内存空间,是将一组零散的内存块串联起来,从而进行数据存储的数据结构。
3.链表中的每一个内存块被称为节点Node。节点除了存储数据外,还需记录链上下一个节点的地址,即后继指针next。
为什么使用链表?即链表的特点
1.插入、删除数据效率高O(1)级别(只需更改指针指向即可),随机访问效率低O(n)级别(需要从链头至链尾进行遍历)。
2.和数组相比,内存空间消耗更大,因为每个存储数据的节点都需要额外的空间存储后继指针。
常用链表:单链表、循环链表和双向链表
单链表
单链表
- 每个节点只包含一个指针,即后继指针。
- 单链表有两个特殊的节点,即首节点和尾节点。为什么特殊?用首节点地址表示整条链表,尾节点的后继指针指向空地址null。
- 性能特点:插入和删除节点的时间复杂度为O(1),查找的时间复杂度为O(n)。
循环链表
循环列表
- 除了尾节点的后继指针指向首节点的地址外均与单链表一致。
- 适用于存储有循环特点的数据,比如约瑟夫问题。
双向链表
双向列表
- 节点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
- 首节点的前驱指针prev和尾节点的后继指针均指向空地址。
- 性能特点:
和单链表相比,存储相同的数据,需要消耗更多的存储空间。
插入、删除操作比单链表效率更高O(1)级别。以删除操作为例,删除操作分为2种情况:给定数据值删除对应节点和给定节点地址删除节点。对于前一种情况,单链表和双向链表都需要从头到尾进行遍历从而找到对应节点进行删除,时间复杂度为O(n)。对于第二种情况,要进行删除操作必须找到前驱节点,单链表需要从头到尾进行遍历直到p->next = q,时间复杂度为O(n),而双向链表可以直接找到前驱节点,时间复杂度为O(1)。
对于一个有序链表,双向链表的按值查询效率要比单链表高一些。因为我们可以记录上次查找的位置p,每一次查询时,根据要查找的值与p的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
双向循环链表:首节点的前驱指针指向尾节点,尾节点的后继指针指向首节点。
双向循环列表
选择数组还是链表?
插入、删除和随机访问的时间复杂度
比较
\
数组:插入、删除的时间复杂度是O(n),随机访问的时间复杂度是O(1)。
链表:插入、删除的时间复杂度是O(1),随机访问的时间复杂端是O(n)。
数组缺点
- 若申请内存空间很大,比如100M,但若内存空间没有100M的连续空间时,则会申请失败,尽管内存可用空间超过100M。
- 大小固定,若存储空间不足,需进行扩容,一旦扩容就要进行数据复制,而这时非常费时的。
链表缺点
- 内存空间消耗更大,因为需要额外的空间存储指针信息。
- 对链表进行频繁的插入和删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,还可能会造成频繁的GC(自动垃圾回收器)操作。
如何选择?
数组简单易用,在实现上使用连续的内存空间,可以借助CPU的缓冲机制预读数组中的数据,所以访问效率更高,而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法预读。
如果代码对内存的使用非常苛刻,那数组就更适合。
如何实现LRU缓存淘汰算法?
什么是缓存?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
为什么使用缓存淘汰策略?即缓存的特点
缓存的大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
什么是缓存淘汰策略?
指的是当缓存被用满时清理数据的优先顺序。
有哪些缓存淘汰策略?
常见的3种包括先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frenquently Used)、最近最少使用策略LRU(Least Recently Used)。
链表实现LRU缓存淘汰策略
当访问的数据没有存储在缓存的链表中时,直接将数据插入链表表头,时间复杂度为O(1);当访问的数据存在于存储的链表中时,将该数据对应的节点,插入到链表表头,时间复杂度为O(n)。如果缓存被占满,则从链表尾部的数据开始清理,时间复杂度为O(1)。
数组实现LRU缓存淘汰策略
用一个数组来存储数据,给每一个数据项标记一个访问时间戳,每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中。每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。当数组空间已满时,将时间戳最大的数据项淘汰。
利用链表和hashmap实现LRU缓存淘汰策略
当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。
设计思想
时空替换思想:“用空间换时间” 与 “用时间换空间”
当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,时间复杂度小相对较低的算法和数据结构,缓存就是空间换时间的例子。如果内存比较紧缺,比如代码跑在手机或者单片机上,这时,就要反过来用时间换空间的思路。
如何判断一个字符串是否是回文字符串的问题。
思路:
1 快慢指针定位中间节点
2 从中间节点对后半部分逆序
3 前后半部分比较,判断是否为回文
4 后半部分逆序复原
时间复杂度On, 空间复杂度O1
如何轻松写出正确的链表代码?
理解指针或引用的含义
1.含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。
2.示例:
p—>next = q; 表示p节点的后继指针存储了q节点的内存地址。
p—>next = p—>next—>next; 表示p节点的后继指针存储了p节点的下下个节点的内存地址。
警惕指针丢失和内存泄漏(单链表)
1.插入节点
\
插入
\
在节点a和节点b之间插入节点x,b是a的下一节点,,p指针指向节点a,则造成指针丢失和内存泄漏的代码:p—>next = x;x—>next = p—>next; 显然这会导致x节点的后继指针指向自身。
正确的写法是2句代码交换顺序,即:x—>next = p—>next; p—>next = x;
2.删除节点
在节点a和节点b之间删除节点b,b是a的下一节点,p指针指向节点a:p—>next = p—>next—>next;
利用“哨兵”简化实现难度
什么是“哨兵”?
链表中的“哨兵”节点是解决边界问题的,不参与业务逻辑。如果我们引入“哨兵”节点,则不管链表是否为空,head指针都会指向这个“哨兵”节点。我们把这种有“哨兵”节点的链表称为带头链表,相反,没有“哨兵”节点的链表就称为不带头链表。
未引入“哨兵”的情况
如果在p节点后插入一个节点,只需2行代码即可搞定:
new_node—>next = p—>next;
p—>next = new_node;
但,若向空链表中插入一个节点,则代码如下:
if(head == null){
head = new_node;
}
如果要删除节点p的后继节点,只需1行代码即可搞定:
p—>next = p—>next—>next;
但,若是删除链表的最有一个节点(链表中只剩下这个节点),则代码如下:
if(head—>next == null){
head = null;
}
从上面的情况可以看出,针对链表的插入、删除操作,需要对插入第一个节点和删除最后一个节点的情况进行特殊处理。这样代码就会显得很繁琐,所以引入“哨兵”节点来解决这个问题。
引入“哨兵”的情况
“哨兵”节点不存储数据,无论链表是否为空,head指针都会指向它,作为链表的头结点始终存在。这样,插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点都可以统一为相同的代码实现逻辑了。
4.“哨兵”还有哪些应用场景?
哨兵最大的作用就是简化边界条件的处理。
对于双向链表 L(L.head 为表头,表头有值,L.head.prev 为 NIL),L.nil 作为该链表的哨兵变量,
L.nil.next 指向表头;
L.nil.prev 指向表尾;
- 不含哨兵的链表(头)插入:
LIST_INSERT(L, x) x.next = L.head if L.head != NIL L.head.prev = x L.head = x x.prev = NIL - 使用哨兵之后便可以省去条件判断语句:
LIST_INSERT'(L, x) x.next = L.nil.next L.nil.next.prev = x L.nil.next = x x.prev = L.nil
重点留意边界条件处理
经常用来检查链表是否正确的边界4个边界条件:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个节点时,代码是否能正常工作?
- 如果链表只包含两个节点时,代码是否能正常工作?
- 代码逻辑在处理头尾节点时是否能正常工作?
举例画图,辅助思考
手画
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
多写多练,没有捷径
5个常见的链表操作:
- 单链表反转
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
//迭代版本1
/*ListNode* prev = nullptr;
auto cur = head;
while(cur) {
auto temp = cur->next;
cur->next = prev;
prev = cur, cur = temp;
}
return prev;*/
//迭代版本2
/*if (!head || !head->next) return head;
auto a = head, b = a->next;
while(b) {
auto c = b->next;
b->next = a;
a = b, b = c;
}
head->next = nullptr;
return a;*/
//递归版本
if(!head || !head->next) return head;
auto tail = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return tail;
}
};
- 链表中环的检测
public class LinkADT<T> {
/**
* 单链表节点
*/
private static class SingleNode<T> {
public SingleNode<T> next;
public T data;
public SingleNode(T data) {
this.data = data;
}
public T getNextNodeData() {
return next != null ? next.data : null;
}
}
/**
* 判断是否有环 快慢指针法
*
* @param node
* @return
*/
public static boolean hasLoopV1(SingleNode headNode) {
if(headNode == null) {
return false;
}
SingleNode p = headNode;
SingleNode q = headNode.next ;
// 快指针未能遍历完所有节点
while (q != null && q.next != null) {
p = p.next; // 遍历一个节点
q = q.next.next ; // 遍历两个个节点
// 已到链表末尾
if (q == null) {
return false;
} else if (p == q) {
// 快慢指针相遇,存在环
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* merge(ListNode* l1, ListNode* l2) {
//递归法:总共递归n层,需要o(n)的空间;时间复杂度o(n)
/*if(!l1) return l2;
if(!l2) return l2;
if(l1->val > l2->val) {
l2->next = merge(l2->next, l1);
return l2;
} else {
l1->next = merge(l1->next, l2);
return l1;
}*/
//归并
ListNode* dummy = new ListNode(-1);
ListNode* tail = dummy;
while (l1 && l2) {
if (l1->val >= l2->val) tail->next = l2, tail = l2, l2 = l2->next;
else tail->next = l1, tail = l1, l1 = l1->next;
}
if(l1) tail->next = l1;
if(l2) tail->next = l2;
return dummy->next;
}
};
- 删除链表倒数第n个节点
public ListNode removeNthFromEnd(ListNode head, int n) {
if(n <= 0){
return head;
}
if(null == head){
return head;
}
int i = 0;
ListNode tail = head;
while(tail != null && i < n){
tail = tail.next;
i++;
}
//节点数量小于n
if(i < n){
return head;
}
//节点数量等于n
if(tail == null){
head = head.next;
return head;
}
//节点数量大于n
ListNode newHead = head;
while(tail.next != null){
head = head.next;
tail = tail.next;
}
head.next = head.next.next;
return newHead;
}
- 求链表的中间节点
class Solution {
public ListNode middleNode(ListNode head) {
ListNode first = head;
ListNode second = head;
while(first != null && first.next != null){
first = first.next.next;
second = second.next;
}
return second;
}
}