数据结构与JavaScript。单链表和双链表

132 阅读7分钟

* { box-sizing: border-box; } body {margin: 0;}

计算机科学中最常教授的两种数据结构是单链表和双链表。

当我被教授这些数据结构时,我向我的同龄人询问类比,以使它们概念化。我听到的是几个例子,如杂货店的清单和一辆火车。正如我最终了解到的,这些类比是不准确的。杂货清单更像是一个队列;火车更像是一个阵列。

随着时间的推移,我最终发现了一个能准确描述单链列表和双链列表的类比:寻宝游戏。如果你对寻宝游戏和链接列表之间的关系感到好奇,那么请阅读下面的答案吧

单链式列表

在计算机科学中,单链表是一种数据结构,持有一连串的链接节点。每个节点又包含数据和一个指针,可以指向另一个节点。

单链列表的节点与寻宝游戏中的步骤非常相似。每个步骤都包含一个信息(例如 "你已经到达法国")和指向下一个步骤的指针(例如 "访问这些经纬度坐标")。当我们开始对这些单独的步骤进行排序以形成一个步骤序列时,我们正在创建一个寻宝游戏。

现在我们有了一个单链列表的概念模型,让我们来探索单链列表的操作。

单链式列表的操作

由于单链表包含节点,它可以成为单链表的一个单独的构造函数,我们概述一下两个构造函数的操作:NodeSinglyList

Node

  • data 存储一个值
  • next 指向列表中的下一个节点

SinglyList

  • _length 检索列表中的节点数
  • head 指定一个节点为列表的头部
  • add(value) 添加一个节点到列表中
  • searchNodeAt(position) 在我们的列表中搜索一个处于n位置的节点
  • remove(position) 从列表中删除一个节点

单链式列表的实现

对于我们的实现,我们将首先定义一个名为Node 的构造函数,然后定义一个名为SinglyList 的构造函数。

Node 的每个实例都需要有存储数据的能力和指向另一个节点的能力。为了添加这个功能,我们将创建两个属性:datanext ,分别是。

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

接下来,我们需要定义SinglyList

class SinglyList {
  constructor() {
    this._length = 0;
    this.head = null;
  }
}

SinglyList 的每个实例将有两个属性:_lengthhead 。前者被分配给一个列表中的节点数;后者指向列表的头部--列表前面的节点。由于每一个新的SinglyList 的实例都不包含一个节点,所以head 的默认值是null_length 的默认值是0

单链式列表的方法

我们需要定义可以在列表中添加、搜索和删除节点的方法。

向列表中添加数据add(value)

让我们从向列表中添加节点开始。

//  add value to singly-linked list
add(value) {
  let node = new Node(value),
    currentNode = this.head;

  // 1st use-case: an empty list
  if (!currentNode) {
    this.head = node;
    this._length++;

    return node;
  }

  // 2nd use-case: a non-empty list
  while (currentNode.next) {
    currentNode = currentNode.next;
  }

  currentNode.next = node;
  this._length++;

  return node;
}

向我们的列表添加一个节点涉及到许多步骤。让我们从我们方法的开头开始。我们使用add(value) 的参数来创建一个新的Node 的实例,它被分配给一个名为node 的变量。我们还声明一个名为currentNode 的变量,并将其初始化为我们列表中的head 。如果列表中没有节点,那么head 的值就是null

在代码的这一点上,我们处理了两个用例。

第一个用例是向一个空的列表添加一个节点。如果head 没有指向一个节点,那么就把node 作为我们列表的头,把我们列表的长度增加1,然后返回node

第二个用例考虑将一个节点添加到一个非空的列表中。我们进入while 循环,在每次迭代中,我们评估currentNode.next 是否指向另一个节点。(在第一次迭代中,currentNode 总是指向列表的头。)

如果答案是否定的,我们将node 赋值给currentNode.next 并返回node

如果答案是肯定的,我们进入while 循环的主体。在主体中,我们将currentNode 重新赋值给currentNode.next 。这个过程重复进行,直到currentNode.next 不再指向其他节点。换句话说,currentNode 指向我们列表的最后一个节点。

while 循环中断。最后,我们将node 赋值给currentNode.next ,我们将_length 递增 1,然后我们返回node

寻找一个项目searchNodeAt(position)

我们现在可以将节点添加到我们的列表中,但是我们不能搜索列表中特定位置的节点。让我们添加这个功能并创建一个名为searchNodeAt(position) 的方法,它接受一个名为position 的参数。这个参数应该是一个整数,表示在我们的列表中处于n位置的节点。

//search an element at a specific position
function searchNodeAt(position) {
  let currentNode = this.head,
    length = this._length,
    count = 1,
    message = { failure: 'Failure: non-existent node in this list.' };

  //  1st use-case: an invalid position
  if (length === 0 || position < 1 || position > length) {
    throw new Error(message.failure);
  }

  // 2nd use-case: a valid position
  while (count < position) {
    currentNode = currentNode.next;
    count++;
  }

  return currentNode;
}

if 语句检查第一种使用情况:一个无效的位置被作为参数传递。

如果传递给searchNodeAt(position) 的索引是有效的,那么我们就进入第二种使用情况--while 循环。在while 循环的每个迭代过程中,currentNode(它首先指向head)被重新分配到列表中的下一个节点。这个迭代一直持续到count等于position。

当循环最终中断时,currentNode ,并返回。

删除一个项目remove(position)

我们将创建的最后一个方法名为remove(position)

//remove a node at a specific position
function removeAt(position) {
  let currentNode = this.head,
    length = this._length,
    count = 0,
    message = { failure: 'Failure: non-existent node in this list.' },
    beforeNodeToDelete = null,
    nodeToDelete = null,
    deletedNode = null;

  //  1st use-case: an invalid position
  if (length === 0 || position < 1 || position > length) {
    throw new Error(message.failure);
  }

  // 2nd use-case: the first node is removed
  if (position === 1) {
    this.head = currentNode.next;
    deletedNode = currentNode;
    currentNode = null;
    this._length--;

    return deletedNode;
  }

  // 3rd use-case: any other node is removed
  while (count < position) {
    beforeNodeToDelete = currentNode;
    nodeToDelete = currentNode.next;
    count++;
  }

  beforeNodeToDelete.next = nodeToDelete.next;
  deletedNode = nodeToDelete;
  nodeToDelete = null;
  this._length--;

  return deletedNode;
}

我们对remove(position) 的实现涉及三个使用情况。

  1. 一个无效的位置作为参数被传递
  2. 一个位置(列表的head )被作为一个参数传入
  3. 一个存在的位置(不是第一个位置)被作为一个参数传递

第一个和第二个用例是最简单的处理。关于第一种情况,如果列表是空的或者传递了一个不存在的位置,就会出现错误。

第二个用例处理删除列表中的第一个节点,这也是head 。如果是这种情况,那么会发生以下逻辑。

  1. head 被重新分配到currentNode.next
  2. deletedNode 指向currentNode
  3. currentNode 被重新分配到null
  4. 将我们列表中的_length 递减1
  5. deletedNode 被返回

第三种情况是最难理解的。其复杂性来自于在while 循环的每次迭代中跟踪两个节点的必要性。在我们的循环的每次迭代中,我们既要跟踪要删除的节点之前的节点*,又* 要跟踪要删除的节点。当我们的循环最终到达我们想要删除的节点的位置时,循环就终止了。

在这一点上,我们持有对三个节点的引用。 beforeNodeToDelete ,nodeToDelete, 和deletedNode 。在删除nodeToDelete 之前,我们必须把它的值next 赋给下一个值beforeNodeToDelete 。如果这一步的目的看起来不清楚,请提醒你,我们有一个链接节点的列表;只要删除一个节点,就会破坏从列表的第一个节点到列表的最后一个节点的链接。

接下来,我们将deletedNode 赋值给nodeToDelete 。然后我们将nodeToDelete 的值设置为null ,将列表的长度递减一,并返回deletedNode

从单数到双数

太棒了,我们对单链列表的实现已经完成。我们现在可以使用一个数据结构来添加、删除和搜索列表中的节点,这些节点在内存中占有非连续的空间。

然而,此时此刻,我们所有的操作都是从一个列表的开头开始,并运行到列表的结尾。换句话说,它们是单向的。

可能有这样的情况,我们希望我们的操作是双向的。如果你考虑了这种用例,那么你刚刚描述了一个双重链接的列表。

一个双链的列表

双链式列表采用了单链式列表的所有功能,并将其扩展为在列表中的双向移动。换句话说,我们可以从列表中的第一个节点遍历到列表中的最后一个节点;我们也可以从列表中的最后一个节点遍历到列表中的第一个节点。

在本节中,我们将主要保持对双链表和单链表之间的区别的关注。

双链式列表的操作

我们的列表将包括两个构造函数:NodeDoublyList 。让我们概述一下它们的操作。

Node

  • data 存储一个值
  • next 指向列表中的下一个节点
  • previous 指向列表中的上一个节点

DoublyList

  • _length 检索列表中的节点数
  • head 将一个节点指定为列表的头部
  • tail 指定一个节点为列表的尾部
  • add(value) 向列表中添加一个节点
  • searchNodeAt(position) 在我们的列表中搜索一个处于n位置的节点
  • remove(position) 从列表中删除一个节点

双链接列表的实现

让我们写一些代码吧!

对于我们的实现,我们将创建一个名为Node 的构造函数。

class Node {
  constructor(value) {
    this.data = value;
    this.previous = null;
    this.next = null;
  }
}

为了创建一个双向链接的列表的运动,我们需要指向列表两个方向的属性。这些属性已经被命名为previousnext

接下来,我们需要实现一个DoublyList ,并添加三个属性。 _length ,head, 和tail 。与单链式列表不同,双链式列表对列表的开头和结尾都有一个引用。由于DoublyList 的每个实例都是在没有节点的情况下实例化的,所以headtail 的默认值被设置为null

class DoublyList {
  constructor() {
    this._length = 0;
    this.head = null;
    this.tail = null;
  }
}

双重链接列表的方法

我们现在将探讨以下方法。 add(value) ,remove(position), 和searchNodeAt(position) 。所有这些方法都用于单链路列表;然而,它们必须为双向移动重写。

添加一个值与add(value)

// add value to doubly-linked list
add(value) {
  let node = new Node(value);

  if (this._length) {
    this.tail.next = node;
    node.previous = this.tail;
    this.tail = node;
  } else {
    this.head = node;
    this.tail = node;
  }

  this._length++;

  return node;
}

在这个方法中,我们有两种情况。首先,如果一个列表是空的,那么就把它分配到head ,并tail ,被添加的节点。第二,如果列表包含节点,那么找到列表的尾部,并将被添加的节点分配到tail.next ;同样,我们需要配置新的尾部以实现双向移动。我们需要设置,换句话说,tail.previous 到原来的尾部。

搜索一个节点,用searchNodeAt(position)

searchNodeAt(position) 的实现与单链表是相同的。如果你忘记了如何实现它,我把它包括在下面。

// Method to Search an Element at a Specific Position
searchNodeAt(position) {
  let currentNode = this.head,
    length = this._length,
    count = 1,
    message = { failure: 'Failure: non-existent node in this list.' };

  //  1st use-case: an invalid position
  if (length === 0 || position < 1 || position > length) {
    throw new Error(message.failure);
  }

  // 2nd use-case: a valid position
  while (count < position) {
    currentNode = currentNode.next;
    count++;
  }

  return currentNode;
}

用以下方法删除一个项目remove(position)

这个方法将是最难理解的。我将展示代码,然后解释。

// Method to Remove a Node at a Specific Position
remove(position) {
  let currentNode = this.head,
    length = this._length,
    count = 1,
    message = {
      failure: 'Failure: non-existent node in this list.',
      success: 'Success: node removed.',
    },
    beforeNodeToDelete = null,
    afterNodeToDelete = null,
    nodeToDelete = null,
    deletedNode = null;

  //  1st use-case: an invalid position
  if (length === 0 || position < 1 || position > length) {
    throw new Error(message.failure);
  }

  // 2nd use-case: the first node is removed
  if (position === 1) {
    this.head = currentNode.next;

    // 2nd use-case: there is a second node
    if (!this.head) {
      this.head.previous = null;
    }
    // 2nd use-case: there is no second node
    else {
      this.tail = null;
    }
  }
  // 3rd use-case: the last node is removed
  else if (position === length) {
    this.tail = this.tail.previous;
    this.tail.next = null;
  }
  // 4th use-case: a middle node is removed
  else {
    while (count < position) {
      currentNode = currentNode.next;
      count++;
    }

    beforeNodeToDelete = currentNode.previous;
    nodeToDelete = currentNode;
    afterNodeToDelete = currentNode.next;

    beforeNodeToDelete.next = afterNodeToDelete;
    afterNodeToDelete.previous = beforeNodeToDelete;
    deletedNode = nodeToDelete;
    nodeToDelete = null;
  }

  this._length--;

  return message.success;
}

remove(position) 处理四个用例。

  1. 被作为remove(position) 的参数传递的位置是不存在的。在这种情况下,我们抛出一个错误。
  2. 作为remove(position) 的参数被传递的位置是一个列表的第一个节点(head)。如果是这种情况,我们将把deletedNode 赋给head ,然后把head 重新赋给列表中的下一个节点。此刻,我们必须考虑我们的列表是否有不止一个节点。如果答案是否定的,head 将被分配到null ,我们将进入if-else 语句的if 部分。在if 的正文中,我们还必须将tail 设为null- 换句话说,我们回到了空的双链表的原始状态。如果我们要删除一个列表中的第一个节点,并且我们的列表中有不止一个节点,我们就会进入if-else 语句的else 部分。在这种情况下,我们必须正确地将headprevious 属性设置为null- 在列表的头部之前没有节点。
  3. 作为remove(position) 的参数被传递的位置是一个列表的尾部。首先,deletedNode 被分配到tail 。第二,tail 被重新分配到尾部之前的节点。第三,新的尾巴后面没有节点,需要把它的值next 设为null
  4. 这里发生了很多事情,所以我将更多地关注逻辑,而不是每一行代码。一旦currentNode 指向作为参数传递给remove(position) 的位置上的节点,我们就打破我们的while 循环。此刻,我们将beforeNodeToDelete.next 的值重新分配给nodeToDelete 之后的节点,反之,我们将afterNodeToDelete.previous 的值重新分配给nodeToDelete 之前的节点。换句话说,我们删除指向被删除节点的指针,并将其重新分配给正确的节点。接下来,我们将deletedNode 赋值给nodeToDelete 。最后,我们将nodeToDelete 赋值给null

最后,我们减去我们列表的长度,并返回deletedNode

总结

在这篇文章中,我们已经涵盖了很多的信息。如果其中有任何信息令人困惑,请再次阅读,并对代码进行试验。当它最终对你有意义时,请感到自豪。你刚刚揭开了单链式列表和双链式列表的神秘面纱。你可以把这些数据结构加入到你的编码工具带中去了!