Backto Algo List
Linked List
和 Array
是亲兄弟, 但是性格秉性完全相反, 各有大用.
二者都是在内存空间上顺序排列相同的元素, 最本质的区别是 Array
需要一块儿连续的内存, 而 Linked List
则不需要内存联系, 通过指针把零散的内存空间组织起来.
单链表: 最简单的链表实现
Linked List 中的每个节点都由 data 数据
和 next 指针
两部分组成.
- 第一个节点叫 head, 也是 Linked List 的起始地址
- 最后一个节点叫 tail, tail 的 next 指针指向 NULL, 代表 Linked List 结束
循环链表: 首尾相连
循环链表就是 tail 的 next 指针指向 head, 从而可以很方便的从 tail 访问 head 数据. 这种数据结构非常适合于具有环形结构的数据, 比如 约瑟夫问题.
双向链表: 前后兼顾
很多情况下, 我们不仅需要知道一个节点的下一个节点 next, 还需要知道上一个节点 previous. 对于单向列表, 找 prev 需要从 head 开始遍历比较, 复杂度 O ( n ) O(n) O(n), 当这一操作很频繁的时候耗时严重, 于是引入了双向链表. 但是双向链表因为每个节点都增加了一个 prev 指针, 所以占用内存空间会更多. 所以, 这就面临一种 时间-空间 上的技术选择
- 空间比较紧张, 就
用时间换空间
, 选择单向列表 - 空间比较充足, 就
用空间换时间
, 选择双向列表
在现实开发中, 除了嵌入式开发等对内存严格要求的环境, 一般空间都是充足的, 所以 双向链表是应用最广泛的.
双向循环链表: 前后兼顾, 首尾相连
还想要啥? 通通给你.
复杂度分析
理论上, Array 和 Linked List 常用操作的 average 时间复杂度如下表
Data Structure | Access | Search | Insertion | Deletion |
---|---|---|---|---|
Array | O ( 1 ) O(1) O(1) | O ( n ) O(n) O(n) | O ( n ) O(n) O(n) | O ( n ) O(n) O(n) |
Linked List | O ( n ) O(n) O(n) | O ( n ) O(n) O(n) | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) |
注意, 这里指的是理论上的所有信息都透明的原子操作.
但是, 在实际业务场景中, 情况就变得复杂了, 不然也不会衍生出那么多不同的 Linked List.
比如, Linked List 中理论上 Insertion 和 Deletion 的复杂度都是
O
(
1
)
O(1)
O(1), 但是在实际业务中, 给定的场景往往是
- 给定一个定值 value, 把 data == value 的节点删除, 或者在这个节点之前之后insertion
- 给定一个节点的 pointer, 把这个节点 delete 或者 在前在后 insertion
对于第一个场景,首先需要先找到这个节点, search 就是一个 O ( n ) O(n) O(n) 操作了。对于第二种操作, 若在给定 pointer 的节点之前 insertion,Singly-Linked List 就又要从 head 开始去 search, 复杂度 O ( n ) O(n) O(n),而对于 Doubly-Linked List 而言,因为有 prev 指针,就是一个简单的 O ( 1 ) O(1) O(1)操作。
单链表问题汇总 singly-linked list
- 定义链表
#typedef int ElemType // for easy implementation
typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkedList; // LinkedList is type of (struct LNode*)
- 判断链表是否为空
bool IsEmpty(LinkedList *head) {
return head == NULL;
}
- 打印链表
void PrintLinkedList(LinkedList *head) {
if(!IsEmpty(head)) {
LNode *iter = *head;
int idx = 0;
while(iter) {
printf("[%02d]: %08d\n", idx++, iter->val);
iter = iter->next;
}
}
}
- 给定数组, 从head 开始初始化 list
LinkedList* InitListFromHeadWithArray(const int* arr, const int& arr_length) {
if(arr == NULL || arr_length == 0)
return NULL;
LinkList head =(LinkList)malloc(sizeof(LNode)); // always stands for the head
head->next = NULL;
head->data = arr_length; // head node stands for the length of elems
for(int i = 0; i < arr_length; ++i) {
LNode* node = (LNode*)malloc(sizeof(LNode));
node ->data = arr[i];
node ->nex= head;
head->next= node;
}
return &head;
}
- 给定位置插入 node
void Insert(LinkedList *pos, LNode *elem) {
if(IsEmpty(pos) || elem == NULL)
return;
if(&pos)
}
- 从头部插入node
- 从尾部插入node
- 从任意位置插入 node
- 删除
- 反转链表
- 链表排序
- 找到中间值
业务场景
Scenario 01 : LRU 缓存淘汰算法
缓存(Cache) 是一种提高数据读取性能的技术, 但 Cache 制造成本高, 空间大小很受限. 因此其核心问题有两个 有free space 的时候, 哪些数据被 put in?
和 space 被占满后, 哪些数据 被 wipe out?
. 针对后一个问题的解决方案, 就是 缓存淘汰策略
, 常用的策略有三种
- FIFO(First-In First-Out)
- LFU(Least Frequently Used)
- LRU(Least Recently Used)
其中, LRU 用一个 Ordered Singly-Linked List 来实现就很容易, Ordered 的排序就是越靠近 head 的就是越 recently 被访问的. 当有一个 new data 需要被访问的时候, 我们就开始遍历这个 list, 找到了(已经在Cache 里了), 就把它原地删除挪到 head 来. 如果没找到, 就把 tail 删除, 把 new data 放在 head 的位置. 当然这个思路是
O
(
n
)
O(n)
O(n) 的, 在 Cache 这么对性能要求严苛的场景一般是不会采用的. 继续优化的话, 可以采用 散列表 Hash table
记录每个数据的位置, 从而将缓存访问的时间复杂度降为
O
(
1
)
O(1)
O(1).
Ref
- 数据结构与算法之美–By 王争
- 链表 : 基础操作讲解的很好
- 微软等100题系列V0.1版:链表面试题集锦 : 比较高深的题目
- 看图理解单链表反转的三种方法 : 不错