03_深入理解链表

1.单向链表与双向链表

从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效

先来看删除操作、分为两种情况

  • 第一种:删除节点中“值等于某个给定值”的结点
  • 第二种:删除给定指针指向的结点

先来看第一种,对于二者,时间复杂度都为O(n)
第二种,对于单链表来说,删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点双向链表。而双向链表有着显著的优势,因为双向链表中的结点已经保存了前驱结点的指针。

同理,对于插入操作,单向链表还得从头便利寻找插入结点的前驱结点。
双向链表是用空间来换时间。

2.数组与链表

在这里插入图片描述
数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

PS: CPU缓存机制指的是什么?为什么就数组更好了?
CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(这个大小我不太确定。。)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。 如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。而链表本身没有大小的限制,天然地支持动态扩容。

3.实现最近未使用淘汰算法(LRU)

我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
1.如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
2.如果此数据没有在缓存链表中,又可以分为两种情况:
(1) 如果此时缓存未满,则将此结点直接插入到链表的头部
(2) 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

4.利用哨兵简化实现难度

回顾单链表的插入操作,如果我们在结点p后面插入一个新的结点,只需两行代码

new_node->next = p->next;
p->next = new_node;

但是,当我们向空链表中插入第一个结点,刚才的逻辑就不好用了,需加入如下代码

if (head == null) {
  head = new_node;
}

再来看删除操作,如果删除结点p的后继结点

p->next = p->next->next;

如果删除的是最后一个

if (head->next == null) {
   head = null;
}

ok, 那么如此繁重的代码,我们仅用一个哨兵即可解决
这种带有哨兵结点的链表叫带头链表
在这里插入图片描述
这种技巧,在插入排序、归并排序、动态规划中都有用到

哨兵用例:
原代码:

// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
int find(char* a, int n, char key) {
  // 边界条件处理,如果a为空,或者n<=0,说明数组中没有数据,就不用while循环比较了
  if(a == null || n <= 0) {
    return -1;
  }
  
  int i = 0;
  // 这里有两个比较操作:i<n和a[i]==key.
  while (i < n) {
    if (a[i] == key) {
      return i;
    }
    ++i;
  }
  
  return -1;
}

哨兵代码:

// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
// 我举2个例子,你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 7
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 6
int find(char* a, int n, char key) {
  if(a == null || n <= 0) {
    return -1;
  }
  
  // 这里因为要将a[n-1]的值替换成key,所以要特殊处理这个值
  if (a[n-1] == key) {
    return n-1;
  }
  
  // 把a[n-1]的值临时保存在变量tmp中,以便之后恢复。tmp=6。
  // 之所以这样做的目的是:希望find()代码不要改变a数组中的内容
  char tmp = a[n-1];
  // 把key的值放到a[n-1]中,此时a = {4, 2, 3, 5, 9, 7}
  a[n-1] = key;
  
  int i = 0;
  // while 循环比起代码一,少了i<n这个比较操作
  while (a[i] != key) {
    ++i;
  }
  
  // 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
  a[n-1] = tmp;
  
  if (i == n-1) {
    // 如果i == n-1说明,在0...n-2之间都没有key,所以返回-1
    return -1;
  } else {
    // 否则,返回i,就是等于key值的元素的下标
    return i;
  }
}

我们通过哨兵,成功省掉了一个比较语句i<n,当累积执行万次,几十万次的时候,累积的时间就很明显了。

5.留意边界条件处理

经常用来检查链表代码是否正确的边界条件有这样几个:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

链表练习题:
LeetCode对应编号:206,141,21,19,876

参考课程:极客时间王争老师的《数据结构与算法之美》

猜你喜欢

转载自blog.csdn.net/Yungang_Young/article/details/112604877