为什么不直接使用数组

  1. 向后插入元素
    const arr = []
    console.time('perfTest')
    for (let i = 0; i < 100000; i++) {
        arr.push(i)
    }
    console.timeEnd('perfTest') // perfTest: 2.828ms
    
  2. 向数组的头添加元素
    const arr = []
    console.time('perfTest')
    for (let i = 0; i < 100000; i++) {
        arr.unshift(i) // 添加、移除会导致后续元素位移,性能开销大,时间复杂度为 0(n)
    }
    console.timeEnd('perfTest') // perfTest: 792.193ms
    

链表

  1. 概念:链表是有序的数据结构,链表中的每个部分称为节点;链表可以从首、尾、中间进行数据存取;链表的元素在内存中不必是连续的空间;

  2. 链表的优缺点:

    • 优点:添加与删除不会导致其余元素位移;
    • 缺点:无法根据索引快速定位元素;

单链表

  1. 链表是有序的数据结构,链表中的每个部分称为节点;链表可以从首、尾、中间进行数据存取;

  2. 示例代码:

    // 节点类
    class LinkedNode {
      constructor(value) {
        this.value = value
        // 用于存储下一个节点的引用
        this.next = null
      }
    }
    
    // 链表类
    class LinkedList {
      constructor() {
        this.count = 0 // 单链表的长度
        this.head = null
      }
    
      // 添加节点 (尾)
      addAtTail(value) {
        // 创建新节点
        const node = new LinkedNode(value)
        // 检测链表是否存在数据
        if (this.count === 0) {
          this.head = node
        } else {
          // 找到链表尾部节点,将最后一个节点的 next 设置为 node
          let cur = this.head
          while (cur.next != null) {
            cur = cur.next
          }
          cur.next = node
        }
        this.count++
      }
    
      // 添加节点(首)
      addAtHead(value) {
        const node = new LinkedNode(value)
        if (this.count === 0) {
          this.head = node
        } else {
          // 将 node 添加到 head 的前面
          node.next = this.head
          this.head = node
        }
        this.count++
      }
    
      // 获取节点(根据索引)
      get(index) {
        if (this.count === 0 || index < 0 || index >= this.count) {
          return
        }
        // 迭代链表,找到对应节点
        let current = this.head
        for (let i = 0; i < index; i++) {
          current = current.next
        }
        return current
      }
    
      // 添加节点(根据索引)
      addAtIndex(value, index) {
        if (this.count === 0 || index >= this.count) {
          return
        }
        // 如果 index <= 0,都添加到头部即可
        if (index <= 0) {
          return this.addAtHead(value)
        }
        // 后面为正常区间处理
        const prev = this.get(index - 1)
        const next = prev.next
        const node = new LinkedNode(value)
        prev.next = node
        node.next = next
        this.count++
      }
    
      // 删除(根据索引)
      removeAtIndex(index) {
        if (this.count === 0 || index < 0 || index >= this.count) {
          return
        }
        if (index === 0) {
          this.head = this.head.next
        } else {
          const prev = this.get(index - 1)
          prev.next = prev.next.next
        }
        this.count--
      }
    }
    
    // 测试代码
    const l = new LinkedList()
    l.addAtTail('a')
    l.addAtTail('b')
    l.addAtTail('c')
    

双端链表

  1. 双向链表指的是在普通链表的基础上,增加一个用于记录上一个节点的属性 prev ,可进行双向访问;

  2. 图示:

环形链表

  1. 链表的最后一个节点的 next 指向第一个节点,形成首尾相连的循环结构,称为循环链表;在实际中,环的结束可以为链表的任意节点;

  2. 图示:

如何给链表加速

  1. 跳表(升维、空间换时间)

    • 跳表,又叫做跳跃表、跳跃列表,在有序链表的基础上增加了“跳跃”的功能;
    • 跳表在原来的有序链表上加上了多级索引,通过索引来快速查找;可以支持快速的删除、插入和查找操作;
    • 跳表实际上是一种增加了前向指针的链表,是一种随机化的数据结构;
  2. 跳表的数据结构图型:

    • 如何提高链表的线性查找的效率?
    • 如何进一步提高链表的线性查找的效率?
    • 增加多级索引:索引级数为 log2n
  3. 现实中跳表的形态:

    • 由于跳表的增删操作,索引不是那么连续规律的;
    • 维护成本较高,增加删除要更新一遍索引;

时间复杂度

  1. 索引个数统计

    索引 节点个数
    1 n/2
    2 n/4
    3 n/8
    k n/(2^k)
  2. 计算跳表的高度:假设索引有 h 级,最高级索引有 2 个节点,即 n/2^h = 2 ,h = log2^n - 1,包含原始链表这一层,整个跳表的高度就是 log2^n

  3. 计算跳表的时间复杂度

    • 假设在跳表中查询某个数据的时候,如果每一层都遍历 m 个节点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)
    • 那这个 m 是多少呢?如下图所示,假设要查找的数据是 x ,在第 k 级索引中,遍历到 y 节点之后,发现 x > y,小于后面的节点 z ,所以通过 ydown 指针,从第 k 级下降到第 k-1 级索引;
    • 在第 k-1 级索引中,yz 之间只有 3 个节点(包含 yz),所以,在 k-1 级索引中最多只需要遍历 3 个节点,以此类推,每一级索引都最多只需要遍历 3 个节点;
    • 所以 m=3 (与间距有关),因此在跳表中查询某个数据的时间复杂度就是 O(logn)

空间复杂度

  1. 原始链表大小为 n ,每 2 个节点抽一个

    索引 节点个数
    1 n/2
    2 n/4
    3 n/8
    k-2 n/2^(k-2)
    k-1 n/2^(k-1)
    k n/2^k
  2. 计算索引的节点总数:如果链表有 n 个节点,每 2 个节点抽取抽出一个节点作为上一级索引的节点,那每一级索引的节点数分别为:n/2,n/4,n/8,…,8,4,2 等比数列 log2^n 项,根据等比数列公式 Sn = a * (1 - r^n) / (1-r) 求和为 n-1 ,所以跳表的空间复杂度为 O(n)

  3. 如何优化时间复杂度:如果链表有 n 个节点,每 35 个节点抽取抽出一个节点作为上一级索引的节点,那每一级索引的节点数分别为(以 3 为例):n/3,n/9,n/27,…,27,9,3,1 等比数列求和 n/2 ,所以跳表的空间复杂度为 O(n),和每 2 个节点抽取一次相比,时间复杂度要低不少呢;

跳表 VS 二分法

  1. 二分查找针对的有序数据,二分查找强依赖顺序结构;

  2. 数据量太小不适合二分查找,如果数据太小,完全没必要用二分查找,进行逐个遍历就可以了;

  3. 数据量太大也不适合二分查找,二分查找底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求也比较苛刻;

参考资料

  1. 卡尔:《代码随想录》

打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

中午好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 为什么不直接使用数组
  2. 2. 链表
    1. 2.1. 单链表
    2. 2.2. 双端链表
    3. 2.3. 环形链表
  3. 3. 如何给链表加速
    1. 3.1. 时间复杂度
    2. 3.2. 空间复杂度
  4. 4. 跳表 VS 二分法
  5. 5. 参考资料