数据结构和算法躬行记(1)——链表

简介:   链表(Linked List)是不同于数组的另一种数据结构,它的存储单元(即结点或元素)除了包含任意类型的数据之外,还需要包含指向另一个结点的引用,后文会用术语链接表示对结点的引用。

  链表(Linked List)是不同于数组的另一种数据结构,它的存储单元(即结点或元素)除了包含任意类型的数据之外,还需要包含指向另一个结点的引用,后文会用术语链接表示对结点的引用。

  下面会列出链表与数组的具体不同:

  (1)数组需要一块连续的内存空间来存储;而链表则恰恰相反,通过指针将零散的内存串联在一起。

  (2)数组在插入和删除时,会做数据搬移,其时间复杂度是 O(n);而链表只需考虑相邻结点的指针变化,因此时间复杂度是 O(1)。

  (3)当随机访问第 K 个元素时,数据可根据首地址和索引计算出对应的内存地址,其时间复杂度为 O(1);而链表则需要让指针依次遍历链接的结点,因此时间复杂度是 O(n)。

  本系列中面试例题来源于LeetCode、《剑指Offer》等渠道。像下面这样以“面试题”为前缀的题目,其解法大都来源于《剑指Offer》一书。

  面试题5 替换空格。合并数组,从后往前合并,减少数字移动次数。


一、链表结构


  链表包含三种最常见的链表结构:单链表、双向链表和循环链表。

1)单链表

  单链表的结点结构如下所示,其中next是后继指针,可链接下一个结点。

class Node {
  constructor(key=null) {
    this.next = null;
    this.key = key;
  }
}

  而单链表又可以细分为有头结点的单链表和无头结点的单链表,其中头结点不存储任何数据,如下图1所示。

image.png


图 1

  下面以有头结点的单链表为例,演示单链表的插入、遍历和删除。


class List {
  constructor() {
    this.header = new Node();   //头结点
  }
  add(node) {
    //插入
    if (!this.header.next) {
      this.header.next = node;
      return;
    }
    let current = this.header;
    while (current.next != null) {
      current = current.next;
    }
    current.next = node;
  }
  traverse() {
    //遍历
    let current = this.header.next;
    while (current) {
      console.log(current.key);
      current = current.next;
    }
  }
  del(node) {
    //删除
    let current = this.header.next,     //当前结点
      prev = this.header;               //前驱结点
    while (current != node) {
      current = current.next;
      prev = prev.next;
    }
    if (current) {
      prev.next = current.next;
      current.next = null;
    }
  }
}


  尽管删除操作的时间复杂度是 O(1),但遍历查找是主要的耗时点,复杂度为 O(n)。因为在删除时需要知道前驱结点,而单链表不能直接读取,只能从头开始遍历。

  面试题6 从尾到头打印链表。每经过一个结点,就放到栈中。当遍历完后,从栈顶输出。

  面试题18 删除链表的结点。将结点 j 覆盖结点 i,结点 i 的next指针指向 j 的下一个结点,这样能避免获取结点 i 的前置结点。

  面试题52 两个链表的第一个公共结点。分别把两个链接的结点放入两个栈中,尾结点就是两个栈的顶部,如果相同就接着比较下一个栈顶,直至找到最后一个相同结点。

2)双向链表

  双向链表顾名思义包含两个方向的指针:前驱和后继,结点结构如下所示。


class Node {
  constructor(key = null) {
    this.prev = null;
    this.key = key;
    this.next = null;
  }
}

  双向链表比单链表要占更多的内存空间,依托用空间换时间的设计思想,双向链表要比单链表更加的高效。

  例如之前的删除,由于已经保存了前驱结点,也就避免了多余的遍历(如下所示)。当希望在某个结点之前插入结点,双向链表的优势也很明显。


class List {
  add(node) {
    //插入
    if (!this.header.next) {
      this.header.next = node;
      node.prev = this.header;
      return;
    }
    let current = this.header;
    while (current.next != null) {
      current = current.next;
    }
    current.next = node;
    node.prev = current;
  }
  del(node) {
    //删除
    let current = this.header.next;     //当前结点
    while (current != node) {
      current = current.next;
    }
    if (current) {
      current.prev.next = current.next;
      current.next = null;
    }
  }
}


class List {
  add(node) {
    //插入
    if (!this.header.next) {
      this.header.next = node;
      node.prev = this.header;
      return;
    }
    let current = this.header;
    while (current.next != null) {
      current = current.next;
    }
    current.next = node;
    node.prev = current;
  }
  del(node) {
    //删除
    let current = this.header.next;     //当前结点
    while (current != node) {
      current = current.next;
    }
    if (current) {
      current.prev.next = current.next;
      current.next = null;
    }
  }
}


3)循环链表

  循环链表是一种特殊的单链表,它的尾结点的后继结点是头结点,适合处理具有环形结构的问题,例如约瑟夫环。

  面试题62 圆圈中最后剩下的数字。用环形链表模拟圆圈,每删除一个数字需要 m 步运算,共有 n 个数字,时间复杂度O(mn)。


二、经典例题


1)单链表逆序

  从链表的第二个结点开始,把遍历到的结点插入到头结点的后面,直至结束,例如head→1→2→3变为 head→3→2→1。

  采用递归的方式完成单链表的逆序,如下所示。例题:LeetCode的206. 反转链表


class List {
  reverse() {
    //逆序
    this.recursive(this.header.next);
  }
  recursive(node) {
    if (!node) return;
    const current = node,
      next = current.next;
    if (!next) {
      //头结点指向逆序后链表的第一个结点
      this.header.next = current;
      return;
    }
    this.recursive(next);
    /************************************
    * 移动结点 1->2->3,1->2<-3
    * 例如Node(2).next.next就是Node(3).next
    * 巧妙的将Node(3).next链接为Node(2)
    ************************************/
    current.next.next = current;
    current.next = null;
  }
}

2)链表中环的检测

  第一种思路是缓存每个经过的结点,每到一个新结点,就判断当前序列中是否存在,如果存在,就说明访问过了。

  第二种思路是使用两个指针,快指针每次前移两步,慢指针每次前移一步,当两个指针指向相同结点时,就证明有环,否则就没有环,如下所示。例题:LeetCode的141. 环形链表

class List {
  isLoop() {
    //检测环
    let fast = this.header.next,
      slow = this.header.next;
    while (fast && fast.next) {
      slow = slow.next;
      fast = fast.next.next;
      if (slow == fast) return true;
    }
    return false;
  }
}


3)合并两个有序链表

  用两个指针遍历两个链表,如果head1指向的数据小于head2的,则将head1指向的结点归入合并后的链表中,否则用head2的,如下所示。例题:LeetCode的21. 合并两个有序链表

function merge(head1, head2) {
  let cur1 = head1.next,
    cur2 = head2.next,
    cur = null,         //合并后的尾结点
    head = null;        //合并后的头结点
  //合并后链表的头结点为第一个结点元素最小的那个链表的头结点
  if (cur1.key > cur2.key) {
    head = head2;
    cur = cur2;
    cur2 = cur2.next;
  } else {
    head = head1;
    cur = cur1;
    cur1 = cur1.next;
  }
  //每次找链表剩余结点的最小值对应的结点连接到合并后链表的尾部
  while (cur1 && cur2) {
    if (cur1.key > cur2.key) {
      cur.next = cur2;
      cur = cur2;
      cur2 = cur2.next;
    } else {
      cur.next = cur1;
      cur = cur1;
      cur1 = cur1.next;
    }
  }
  //当遍历完一个链表后把另外一个链表剩余的结点链接到合并后的链表后面
  if (cur1 != null) cur.next = cur1;
  if (cur2 != null) cur.next = cur2;
  return head;
}


4)找出链表倒数第 n 个结点

  使用两个指针,快指针比慢指针先前移 n 步,然后两个指针同时移动。当快指针到底后,慢指针的位置就是所要找的结点,如下所示。例题:LeetCode的剑指 Offer 22. 链表中倒数第k个节点

class List {
  findLast(n) {
    //删除链表倒数第 n 个结点
    let slow = null,
      fast = null;
    slow = fast = this.header.next;
    let i = 0;
    //前移 n 步
    while (i < n && fast) {
      fast = fast.next;
      i++;
    }
    while (fast) {
      fast = fast.next;
      slow = slow.next;
    }
    return slow;
  }
}

5)求链表的中间结点

  使用两个指针一起遍历链表。慢指针每次走一步,快指针每次走两步。那么当快指针到达链表的末尾时,慢指针必然处于中间位置,如下所示。例题:LeetCode的876. 链表的中间结点

class List {
  middle() {
    //求链表的中间结点
    let slow = this.header.next,
      fast = this.header.next;
    while (slow && fast && fast.next) {
      slow = slow.next;
      fast = fast.next.next;
    }
    return slow;
  }
}


相关文章
|
26天前
|
算法 数据处理 C语言
C语言中的位运算技巧,涵盖基本概念、应用场景、实用技巧及示例代码,并讨论了位运算的性能优势及其与其他数据结构和算法的结合
本文深入解析了C语言中的位运算技巧,涵盖基本概念、应用场景、实用技巧及示例代码,并讨论了位运算的性能优势及其与其他数据结构和算法的结合,旨在帮助读者掌握这一高效的数据处理方法。
38 1
|
29天前
|
机器学习/深度学习 算法 数据挖掘
K-means聚类算法是机器学习中常用的一种聚类方法,通过将数据集划分为K个簇来简化数据结构
K-means聚类算法是机器学习中常用的一种聚类方法,通过将数据集划分为K个簇来简化数据结构。本文介绍了K-means算法的基本原理,包括初始化、数据点分配与簇中心更新等步骤,以及如何在Python中实现该算法,最后讨论了其优缺点及应用场景。
96 4
|
1月前
|
存储 算法 Perl
数据结构实验之链表
本实验旨在掌握线性表中元素的前驱、后续概念及链表的建立、插入、删除等算法,并分析时间复杂度,理解链表特点。实验内容包括循环链表应用(约瑟夫回环问题)、删除单链表中重复节点及双向循环链表的设计与实现。通过编程实践,加深对链表数据结构的理解和应用能力。
57 4
|
3天前
|
存储 运维 监控
探索局域网电脑监控软件:Python算法与数据结构的巧妙结合
在数字化时代,局域网电脑监控软件成为企业管理和IT运维的重要工具,确保数据安全和网络稳定。本文探讨其背后的关键技术——Python中的算法与数据结构,如字典用于高效存储设备信息,以及数据收集、异常检测和聚合算法提升监控效率。通过Python代码示例,展示了如何实现基本监控功能,帮助读者理解其工作原理并激发技术兴趣。
41 20
|
21小时前
|
数据库
数据结构中二叉树,哈希表,顺序表,链表的比较补充
二叉搜索树,哈希表,顺序表,链表的特点的比较
数据结构中二叉树,哈希表,顺序表,链表的比较补充
|
27天前
|
存储 算法 搜索推荐
Python 中数据结构和算法的关系
数据结构是算法的载体,算法是对数据结构的操作和运用。它们共同构成了计算机程序的核心,对于提高程序的质量和性能具有至关重要的作用
|
26天前
|
数据采集 存储 算法
Python 中的数据结构和算法优化策略
Python中的数据结构和算法如何进行优化?
|
1月前
|
算法
数据结构之路由表查找算法(深度优先搜索和宽度优先搜索)
在网络通信中,路由表用于指导数据包的传输路径。本文介绍了两种常用的路由表查找算法——深度优先算法(DFS)和宽度优先算法(BFS)。DFS使用栈实现,适合路径问题;BFS使用队列,保证找到最短路径。两者均能有效查找路由信息,但适用场景不同,需根据具体需求选择。文中还提供了这两种算法的核心代码及测试结果,验证了算法的有效性。
102 23
|
1月前
|
算法
数据结构之蜜蜂算法
蜜蜂算法是一种受蜜蜂觅食行为启发的优化算法,通过模拟蜜蜂的群体智能来解决优化问题。本文介绍了蜜蜂算法的基本原理、数据结构设计、核心代码实现及算法优缺点。算法通过迭代更新蜜蜂位置,逐步优化适应度,最终找到问题的最优解。代码实现了单链表结构,用于管理蜜蜂节点,并通过适应度计算、节点移动等操作实现算法的核心功能。蜜蜂算法具有全局寻优能力强、参数设置简单等优点,但也存在对初始化参数敏感、计算复杂度高等缺点。
60 20
|
27天前
|
存储 缓存 算法
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式,强调了合理选择数据结构的重要性,并通过案例分析展示了其在实际项目中的应用,旨在帮助读者提升编程能力。
53 5