数据结构与算法学习-链表上

1,019 阅读8分钟

前言

这一篇笔记主要记录总结了线性表数据结构中的链表概念,以及和数组的对比,数组和链表都是计算机中最基本的数据结构,是构建其他高级复杂数据的结构的基础。开发中应该根据具体的场景选择最合适的数据结构和算法。下一篇将会实现链表相关的算法。

链表概念

上一篇数组讲了数组是一种是一种线性表数据结构,是用一组连续的内存空间,来存储一组具有相同数据类型的数据。和数组相同的是,链表也是一种线性表数据结构,不同的是链表是通过指针将一组零散的内存块串联起来存储数据,可以存储不同数据类型的数据。

链表的种类五花八门,主要有以下几类:

单链表

每个内存块被称为链表的结点,每个结点包含了数据域指针域,数据域存储数据,指针域的指针指向下一个结点的内存地址,称为后继指针next

另外,通常把第一个结点称作头结点,头结点用来记录链表的基地址,有了头结点就可以遍历整个链表。最后一个结点称作尾节点,尾结点的指针指向NUll

循环链表

循环链表是一种特殊的单链表。和单链表唯一的区别是循环链表的尾指针不是指向NUll,而是指向头结点。像环一样首尾相连,所以叫做循环链表

循环链表的优点是在处理的数据具有环形结构特点时,特别适合使用,如经典的约瑟夫问题

双向链表

双向链表除了像单链表一样结点指针域有后继指针外,还有前驱指针prev,指向上一个结点

缺点是需要额外开辟两个空间来存储后继结点和前驱结点的地址,如果存储同样的数据,双向链表要比单链表占用更多的空间

优点是支持双向遍历,可以在 O(1)的复杂度下找到前驱结点,所以在某些情况下的插入、删除等操作比单链表简单高效。

如删除指定指针指向的结点q,单链表需要先遍历找到到这个结点的前驱结点,直到 p->nex = q, 时间复杂度是O(n),而双向链表的结点指针域中已经存储了前驱结点的指针,不需要遍历,时间复杂度是O(1)。同理,要在指定结点前面插入一个结点,使用双向链表也是只要 O(1) 复杂度就能完成。

另外,对于有序链表,查找一个数据时,可以记录上次查找的数据的位置p,后面的查找的数据可以和 p 位置的数据比大小,决定是向前还是向后查找,这样平均查找只需要找一半的数据。

双向链表里面有个重要的设计思想就是空间换时间思想,当需要很在意运行时间时,可以选择空间复杂度较高而时间复杂度相对较低的算法或者数据结构,相反,如果内存空间比较重要,如在单片机上的程序,就要反过来使用时间换空间思想。 缓存的思想就是利用了空间换时间,让经常使用到的数据存储到到高速的内存中,大大提高了数据读取的速度。

双向循环链表

双向循环链表双向链表循环链表的结合体,相比较双向链表,尾结点的后继指针指向了头结点,头结点的前驱指针指向尾结点。

链表和数组对比

可以看到,再算法时间复杂度上,数组和链表在随机访问插入删除的复杂度上正好相反

数组是用一组连续的内存空间来存储数据,优点是可以借助 CPU 缓存机制,预先读取数组中的数据,访问效率更高,而链表在内存中不连续,所以对 CPU 缓存不友好。

数组的缺点是大小固定,要占用整块连续的内存空间,如果声明的数组过大,系统可能没有足够连续的空间给分配,就会导致内存不足,如果申请的数组大小过小,出现不够用,就需要重新申请一块更大的连续内存空间,然后将之前的数据全部拷贝一份过来,这个过程很耗时,而链表天然就支持动态扩容

所以,如果代码对内存使用很苛刻,就使用用数组,因为链表存储相同的数据,需要更多的内存空间。在实际的开发中,需要根据不同情况选用最合适的数据结构和算法。

LRU 缓存淘汰算法

最近最少使用策略 LRU 是一种常见的缓存策略。常用缓存策略的有下列几种:

  • 先进先出策略 FIFO(First In,Fitst Out)。
  • 最少使用策略 LFU(Least Frequently Used)。
  • 最近最少使用策略 LRU(Least Recently Used)。

链表实现

维护一个有序的单链表,越靠近链表头部的越是最近访问的,也靠近尾结点的是越早之前访问的。

  1. 当该数据存在缓存的链表中,缓存命中,遍历得到数据对应到的结点,从原位置删除,然后将该结点插入到头结点位置。
  2. 没有命中缓存,需要将该数据加入到缓存中,如果缓存未满,直接将该数据结点插入到链表的头部,如果缓存已满,删除链表尾结点,再将该数据结点插入到链表的头结点位置。

数组实现

维护一个有序的数组,下标越小越是最近访问的,下标越大越是越早之前访问的。

  1. 当该数据存在数组中,缓存命中,从原位置删除该元素,然后将该元素插入到数组的首位置。
  2. 缓存没有命中,需要将数据存入到缓存中。这个时候如果缓存还没满,就直接将该数据插入到数组的首位置。如果缓存已经满了,先删除数组最后一个元素,然后再将该数据插入到数组的首位置。

课后问题

  1. 如何判断一个字符串是否是回文字符串的问题,我想你应该听过,我们今天的思题目就是基于这个问题的改造版本。如果字符串是通过单链表来存储的,那该如何来判断是一个回文串呢?你有什么好的解决思路呢?相应的时间空间复杂度又是多少呢?

解答:

  1. 使用快慢指针定位到中间结点,同时翻转链表前半部分使其逆序;
  2. 如果链表长度是偶数,那么快指针为空,此时慢指针指向的是下中位结点,如果是链表长度是奇数,快指针不为空,此时慢指针指向的正好是链表的中间结点,再将慢指针向前走一步。
  3. 遍历链表比较链表两端的结点,如果有一个结点不相等,则不是回文串,遍历结束,如果都相等则是回文串。
  4. 时间复杂度遍历一遍链表是O(n),空间复杂度没有利用额外的空间是O(1)。

代码如下:


/*
 * @lc app=leetcode.cn id=234 lang=c
 *
 * [234] 回文链表
 *
 * https://leetcode-cn.com/problems/palindrome-linked-list/description/
 *
 * algorithms
 * Easy (34.93%)
 * Total Accepted:    22.3K
 * Total Submissions: 61.2K
 * Testcase Example:  '[1,2]'
 *
 * 请判断一个链表是否为回文链表。
 * 
 * 示例 1:
 * 
 * 输入: 1->2
 * 输出: false
 * 
 * 示例 2:
 * 
 * 输入: 1->2->2->1
 * 输出: true
 * 
 * 
 * 进阶:
 * 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?
 * 
 */
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */


bool isPalindrome(struct ListNode* head){
    if(head == NULL || head->next == NULL) {
        return true;
    }

    // 快慢指针获取中间结点指针
    // 快指针,每次走两步
    struct ListNode *fast = head;
    // 慢指针,每次走一步
    struct ListNode *slow = head;
    struct ListNode *previous = NULL;
    while (slow && fast && fast->next) {
        fast = fast->next->next;
        // 将前半部分链表翻转
        struct ListNode *temp = slow->next;
        slow->next = previous;
        previous = slow;
        slow = temp;
    }
    
    // 链表长度是偶数时,fast 为空
    if (fast) {
        // 链表数是奇数,此时 slow 指向中间结点,provious 指向头结点,slow指针需要向前走一步,
        slow = slow->next;
    }

    while (slow) {
        if (slow->val != previous->val) {
            return false;
        }
        
        slow = slow->next;
        previous = previous->next;
    }
    
    return true;
}

扩展阅读

《算法图解》读书笔记—像小说一样有趣的算法入门书

数据结构与算法学习-数组

数据结构与算法学习-复杂度分析

数据结构与算法学习-开篇


分享个人技术学习记录和跑步马拉松训练比赛、读书笔记等内容,感兴趣的朋友可以关注我的公众号「青争哥哥」。

青争哥哥