链表基础知识
一、什么是链表?
- 和数组一样,链表也是一种线性表。
- 从内存结构来看,链表的内存结构是不连续的内存空间,是将一组零散的内存块串联起来,从而进行数据存储的数据结构。
- 链表中的每一个内存块被称为节点Node。节点除了存储数据外,还需记录链上下一个节点的地址,即后继指针next。
二、为什么使用链表?即链表的特点
- 插入、删除数据效率高O(1)级别(只需更改指针指向即可),随机访问效率低O(n)级别(需要从链头至链尾进行遍历)。
- 和数组相比,内存空间消耗更大,因为每个存储数据的节点都需要额外的空间存储后继指针。
三、常用链表:单链表、循环链表和双向链表
1. 单链表
1)每个节点只包含一个指针,即后继指针。
2)单链表有两个特殊的节点,即首节点和尾节点。为什么特殊?用首节点地址表示整条链表,尾节点的后继指针指向空地址null。
3)性能特点:插入和删除节点的时间复杂度为O(1),查找的时间复杂度为O(n)。
头文件
//链式
struct ListNode
{
int val;
struct ListNode *next;
};
//头插
struct ListNode *linkListCreateHead(int *arr, int size);
//尾插
struct ListNode *linkListCreateTail(int *arr, int size);
void printNode(struct ListNode *p);
实现文件
#include <stdlib.h>
//带头结点
//头插
struct ListNode *linkListCreateHead(int *arr, int size)
{
struct ListNode *list;
list = (struct ListNode*)malloc(sizeof(struct ListNode));
list->next = NULL;
for (int i = 0; i < size; i++) {
struct ListNode *temp = (struct ListNode *)malloc(sizeof(struct ListNode));
temp->val = arr[i];
temp->next = NULL;
//头插法
temp->next = list->next;
list->next = temp;
}
return list;
}
//尾插
struct ListNode *linkListCreateTail(int *arr, int size)
{
struct ListNode *list;
list = (struct ListNode*)malloc(sizeof(struct ListNode));
list->next = NULL;
for (int i = 0; i < size; i++) {
struct ListNode *temp = (struct ListNode *)malloc(sizeof(struct ListNode));
temp->val = arr[i];
temp->next = NULL;
//尾插法
struct ListNode *p = list->next;
while (p != NULL) {
p = p->next;
}
p->next = temp;
}
return list;
}
struct ListNode *linkListFindNode(struct ListNode *list, int data) {
struct ListNode *p = list->next;
while (p != NULL) {
if (p->val == data) {
return p;
}
p = p->next;
}
return NULL;
}
int linkListFindIndex(struct ListNode *list, int i, int *e) {
struct ListNode *p = list->next;
int j = 1;
while (p && j < i) {
p = p->next;
j++;
}
if (!p || j > i) {
return -1;
}
*e = p->val;
return 0;
}
int linkListInsert(struct ListNode *list, int i, int e) {
struct ListNode *p = list->next;
int j = 1;
while (p && j < i) {
p = p->next;
++j;
}
if (!p || j > i) {
return -1;
}
struct ListNode *temp = (struct ListNode *)malloc(sizeof(struct ListNode));
temp->val = e;
temp->next = p->next;
p->next = temp;
return 0;
}
int linkListInsertDelete(struct ListNode *list, int i, int *e)
{
struct ListNode *p = list->next;
int j = 1;
while (p->next && j < i) {
p = p->next;
++j;
}
if (!(p->next) || j > i) {
return -1;
}
struct ListNode *q = p->next;
p->next = q->next;
*e = q->val;
free(q);
return 0;
}
int linkListDestory(struct ListNode *list)
{
struct ListNode *p = list->next;
struct ListNode *q;
while (p) {
q = p->next;
free(p);
p = q;
}
free(p);
return 0;
}
void printNode(struct ListNode *p)
{
struct ListNode *temp = p;
while (temp) {
printf("%d",temp->val);
temp = temp->next;
if (temp) {
printf("->");
}
}
printf("\n");
}
2.循环链表
1)除了尾节点的后继指针指向首节点的地址外均与单链表一致。
2)适用于存储有循环特点的数据,比如约瑟夫问题。
头文件
实现文件
3. 双向链表
1)节点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
2)首节点的前驱指针prev和尾节点的后继指针均指向空地址。
3)性能特点:
和单链表相比,存储相同的数据,需要消耗更多的存储空间。
插入、删除操作比单链表效率更高O(1)级别。以删除操作为例,删除操作分为2种情况:给定数据值删除对应节点和给定节点地址删除节点。对于前一种情况,单链表和双向链表都需要从头到尾进行遍历从而找到对应节点进行删除,时间复杂度为O(n)。对于第二种情况,要进行删除操作必须找到前驱节点,单链表需要从头到尾进行遍历直到p->next = q,时间复杂度为O(n),而双向链表可以直接找到前驱节点,时间复杂度为O(1)。
对于一个有序链表,双向链表的按值查询效率要比单链表高一些。因为我们可以记录上次查找的位置p,每一次查询时,根据要查找的值与p的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
头文件
//双向链表
typedef struct double_link_list_node
{
struct double_link_list_node *prev;
struct double_link_list_node *next;
int data;
}DoubleLinkListNode;
typedef struct double_link_list
{
int size;
DoubleLinkListNode *head;//头指针
DoubleLinkListNode *tail;//尾指针
}DoubleLinkList;
实现文件
#include <stdlib.h>
#include <string.h>
void doubleLinkListCreate(DoubleLinkList *list) {
list->size = 0;
list->head = NULL;
list->tail = NULL;
}
void doubleLinkListDestory(DoubleLinkList *list) {
DoubleLinkListNode *temp = NULL;
while (list->size > 0) {
temp = list->head;
list->head = temp->next;
free(temp);
list->size--;
}
memset(list, 0, sizeof(DoubleLinkList));
}
int doubleLinkListInsertHead(DoubleLinkList *list, DoubleLinkListNode *node, int data) {
if (node == NULL) {
node = (DoubleLinkListNode *)malloc(sizeof(DoubleLinkListNode));
if (node == NULL) {
return -1;
}
}
node->data = data;
node->prev = NULL;
node->next = NULL;
if (list->size == 0) {
list->head = node;
list->tail = node;
}
else {
node->next = list->head;
list->head->prev = node;
list->head = node;
}
list->size++;
return 0;
}
DoubleLinkListNode *doubleLinkListRemoveTail(DoubleLinkList *list) {
DoubleLinkListNode *node = NULL;
if (list->size == 0) {
return NULL;
}
node = list->tail;
if (list->size > 1) {
list->tail = list->tail->prev;
list->tail->next = NULL;
}
else {
list->head = NULL;
list->tail = NULL;
}
list->size--;
return node;
}
void doubleLinkListRemoveNode(DoubleLinkList *list, DoubleLinkListNode *node) {
if ((list == NULL) || (node == NULL)) {
return;
}
if (list->head == node) {
list->head = list->head->next;
}
else if (list->tail == node) {
list->tail = node->prev;
list->tail->next = NULL;
}
else {
node->prev->next = node->next;
node->next->prev = node->prev;
}
list->size--;
node->prev = NULL;
node->next = NULL;
if (list->size == 0) {
memset(list, 0, sizeof(DoubleLinkList));
}
}
DoubleLinkListNode *doubleLinkListSearch(DoubleLinkList *list, int data) {
DoubleLinkListNode *node = list->head;
while (node != NULL) {
if (node->data == data) {
return node;
}
node = node->next;
}
return NULL;
}
void doubleLinkListDump(DoubleLinkList *list) {
int count = 0;
printf("打印链表\n");
DoubleLinkListNode *node = list->head;
while (node != NULL) {
printf("[%d] = %d\n",count++, node->data);
node = node->next;
}
}
/*
我们维护一个有序循环链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
如果此数据没有在缓存链表中,又可以分为两种情况:
如果此时缓存未满,则将此结点直接插入到链表的头部;
如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
*/
void doubleLinkListLRU(DoubleLinkList *list, int data) {
DoubleLinkListNode *node = doubleLinkListSearch(list, data);
if (node != NULL) {
doubleLinkListRemoveNode(list, node);
}
else if (list->size >= 4) {
node = doubleLinkListRemoveTail(list);
}
doubleLinkListInsertHead(list, node, data);
}
void doubleLinkListTest() {
DoubleLinkList list = {0};
DoubleLinkListNode *node = NULL;
doubleLinkListCreate(&list);
printf("插入 1 2 3\n");
doubleLinkListInsertHead(&list, NULL, 1);
doubleLinkListInsertHead(&list, NULL, 2);
doubleLinkListInsertHead(&list, NULL, 3);
doubleLinkListDump(&list);
node = doubleLinkListRemoveTail(&list);
if (node != NULL) {
printf("删除 %d\n",node->data);
}
doubleLinkListInsertHead(&list, node, 4);
doubleLinkListDump(&list);
doubleLinkListLRU(&list, 5);
doubleLinkListDump(&list);
doubleLinkListLRU(&list, 6);
doubleLinkListDump(&list);
doubleLinkListLRU(&list, 7);
doubleLinkListDump(&list);
doubleLinkListLRU(&list, 5);
doubleLinkListDump(&list);
while (list.size > 0) {
node = doubleLinkListRemoveTail(&list);
if (node != NULL) {
printf("删除 %d\n", node->data);
free(node);
}
}
}
4.双向循环链表
首节点的前驱指针指向尾节点,尾节点的后继指针指向首节点。
头文件
实现文件
四、选择数组还是链表?
-
插入、删除和随机访问的时间复杂度
数组:插入、删除的时间复杂度是O(n),随机访问的时间复杂度是O(1)。
链表:插入、删除的时间复杂度是O(1),随机访问的时间复杂端是O(n)。 -
数组缺点
1)若申请内存空间很大,比如100M,但若内存空间没有100M的连续空间时,则会申请失败,尽管内存可用空间超过100M。
2)大小固定,若存储空间不足,需进行扩容,一旦扩容就要进行数据复制,而这时非常费时的。 -
链表缺点
1)内存空间消耗更大,因为需要额外的空间存储指针信息。
2)对链表进行频繁的插入和删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,还可能会造成频繁的GC(自动垃圾回收器)操作。 -
如何选择?
数组简单易用,在实现上使用连续的内存空间,可以借助CPU的缓冲机制预读数组中的数据,所以访问效率更高,而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法预读。
如果代码对内存的使用非常苛刻,那数组就更适合。
五、应用
- 如何分别用链表和数组实现LRU缓冲淘汰策略?
1)什么是缓存?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
2)为什么使用缓存?即缓存的特点
缓存的大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
3)什么是缓存淘汰策略?
指的是当缓存被用满时清理数据的优先顺序。
4)有哪些缓存淘汰策略?
常见的3种包括先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frenquently Used)、最近最少使用策略LRU(Least Recently Used)。
5)链表实现LRU缓存淘汰策略
当访问的数据没有存储在缓存的链表中时,直接将数据插入链表表头,时间复杂度为O(1);当访问的数据存在于存储的链表中时,将该数据对应的节点删除,并插入到链表表头,时间复杂度为O(n)。如果缓存被占满,则从链表尾部的数据开始清理,并插入到链表表头,时间复杂度为O(1)。
6)数组实现LRU缓存淘汰策略
方式一:首位置保存最新访问数据,末尾位置优先清理
当访问的数据未存在于缓存的数组中时,直接将数据插入数组第一个元素位置,此时数组所有元素需要向后移动1个位置,时间复杂度为O(n);当访问的数据存在于缓存的数组中时,查找到数据并将其插入数组的第一个位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉末尾的数据,时间复杂度为O(1)。
方式二:首位置优先清理,末尾位置保存最新访问数据
当访问的数据未存在于缓存的数组中时,直接将数据添加进数组作为当前最有一个元素时间复杂度为O(1);当访问的数据存在于缓存的数组中时,查找到数据并将其插入当前数组最后一个元素的位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉数组首位置的元素,且剩余数组元素需整体前移一位,时间复杂度为O(n)。(优化:清理的时候可以考虑一次性清理一定数量,从而降低清理次数,提高性能。)
- 如何通过单链表实现“判断某个字符串是否为水仙花字符串”?(比如 上海自来水来自海上)
1)前提:字符串以单个字符的形式存储在单链表中。
2)遍历链表,判断字符个数是否为奇数,若为偶数,则不是。
3)将链表中的字符倒序存储一份在另一个链表中。
4)同步遍历2个链表,比较对应的字符是否相等,若相等,则是水仙花字串,否则,不是。
方法二
六、设计思想
时空替换思想:“用空间换时间” 与 “用时间换空间”
当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,时间复杂度小相对较低的算法和数据结构,缓存就是空间换时间的例子。如果内存比较紧缺,比如代码跑在手机或者单片机上,这时,就要反过来用时间换空间的思路。
链表使用
一、理解指针或引用的含义
- 含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。
- 示例:
p—>next = q; 表示p节点的后继指针存储了q节点的内存地址。
p—>next = p—>next—>next; 表示p节点的后继指针存储了p节点的下下个节点的内存地址。
二、警惕指针丢失和内存泄漏(单链表)
- 插入节点
在节点a和节点b之间插入节点x,b是a的下一节点,,p指针指向节点a,则造成指针丢失和内存泄漏的代码:
p—>next = x;x—>next = p—>next; 显然这会导致x节点的后继指针指向自身。
正确的写法是2句代码交换顺序,即:
x—>next = p—>next; p—>next = x;
- 删除节点
在节点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指针都会指向它,作为链表的头结点始终存在。这样,插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点都可以统一为相同的代码实现逻辑了。 -
“哨兵”还有哪些应用场景?
iOS AutoreleasePool ,哨兵最大的作用就是简化边界条件的处理。
四、重点留意边界条件处理
经常用来检查链表是否正确的边界4个边界条件:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个节点时,代码是否能正常工作?
- 如果链表只包含两个节点时,代码是否能正常工作?
- 代码逻辑在处理头尾节点时是否能正常工作?
五、举例画图,辅助思考
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
六、多写多练,没有捷径
5个常见的链表操作:
网友评论