* { box-sizing: border-box; } body {margin: 0;}
计算机科学中最常教授的两种数据结构是单链表和双链表。
当我被教授这些数据结构时,我向我的同龄人询问类比,以使它们概念化。我听到的是几个例子,如杂货店的清单和一辆火车。正如我最终了解到的,这些类比是不准确的。杂货清单更像是一个队列;火车更像是一个阵列。
随着时间的推移,我最终发现了一个能准确描述单链列表和双链列表的类比:寻宝游戏。如果你对寻宝游戏和链接列表之间的关系感到好奇,那么请阅读下面的答案吧
单链式列表
在计算机科学中,单链表是一种数据结构,持有一连串的链接节点。每个节点又包含数据和一个指针,可以指向另一个节点。
单链列表的节点与寻宝游戏中的步骤非常相似。每个步骤都包含一个信息(例如 "你已经到达法国")和指向下一个步骤的指针(例如 "访问这些经纬度坐标")。当我们开始对这些单独的步骤进行排序以形成一个步骤序列时,我们正在创建一个寻宝游戏。
现在我们有了一个单链列表的概念模型,让我们来探索单链列表的操作。
单链式列表的操作
由于单链表包含节点,它可以成为单链表的一个单独的构造函数,我们概述一下两个构造函数的操作:Node 和SinglyList 。
Node
data存储一个值next指向列表中的下一个节点
SinglyList
_length检索列表中的节点数head指定一个节点为列表的头部add(value)添加一个节点到列表中searchNodeAt(position)在我们的列表中搜索一个处于n位置的节点remove(position)从列表中删除一个节点
单链式列表的实现
对于我们的实现,我们将首先定义一个名为Node 的构造函数,然后定义一个名为SinglyList 的构造函数。
Node 的每个实例都需要有存储数据的能力和指向另一个节点的能力。为了添加这个功能,我们将创建两个属性:data 和next ,分别是。
class Node {
constructor(data) {
this.data = data;
this.next = null;
}
}
接下来,我们需要定义SinglyList 。
class SinglyList {
constructor() {
this._length = 0;
this.head = null;
}
}
SinglyList 的每个实例将有两个属性:_length 和head 。前者被分配给一个列表中的节点数;后者指向列表的头部--列表前面的节点。由于每一个新的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) 的实现涉及三个使用情况。
- 一个无效的位置作为参数被传递
- 一个位置(列表的
head)被作为一个参数传入 - 一个存在的位置(不是第一个位置)被作为一个参数传递
第一个和第二个用例是最简单的处理。关于第一种情况,如果列表是空的或者传递了一个不存在的位置,就会出现错误。
第二个用例处理删除列表中的第一个节点,这也是head 。如果是这种情况,那么会发生以下逻辑。
head被重新分配到currentNode.nextdeletedNode指向currentNodecurrentNode被重新分配到null- 将我们列表中的
_length递减1 deletedNode被返回
第三种情况是最难理解的。其复杂性来自于在while 循环的每次迭代中跟踪两个节点的必要性。在我们的循环的每次迭代中,我们既要跟踪要删除的节点之前的节点*,又* 要跟踪要删除的节点。当我们的循环最终到达我们想要删除的节点的位置时,循环就终止了。
在这一点上,我们持有对三个节点的引用。 beforeNodeToDelete ,nodeToDelete, 和deletedNode 。在删除nodeToDelete 之前,我们必须把它的值next 赋给下一个值beforeNodeToDelete 。如果这一步的目的看起来不清楚,请提醒你,我们有一个链接节点的列表;只要删除一个节点,就会破坏从列表的第一个节点到列表的最后一个节点的链接。
接下来,我们将deletedNode 赋值给nodeToDelete 。然后我们将nodeToDelete 的值设置为null ,将列表的长度递减一,并返回deletedNode 。
从单数到双数
太棒了,我们对单链列表的实现已经完成。我们现在可以使用一个数据结构来添加、删除和搜索列表中的节点,这些节点在内存中占有非连续的空间。
然而,此时此刻,我们所有的操作都是从一个列表的开头开始,并运行到列表的结尾。换句话说,它们是单向的。
可能有这样的情况,我们希望我们的操作是双向的。如果你考虑了这种用例,那么你刚刚描述了一个双重链接的列表。
一个双链的列表
双链式列表采用了单链式列表的所有功能,并将其扩展为在列表中的双向移动。换句话说,我们可以从列表中的第一个节点遍历到列表中的最后一个节点;我们也可以从列表中的最后一个节点遍历到列表中的第一个节点。
在本节中,我们将主要保持对双链表和单链表之间的区别的关注。
双链式列表的操作
我们的列表将包括两个构造函数:Node 和DoublyList 。让我们概述一下它们的操作。
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;
}
}
为了创建一个双向链接的列表的运动,我们需要指向列表两个方向的属性。这些属性已经被命名为previous 和next 。
接下来,我们需要实现一个DoublyList ,并添加三个属性。 _length ,head, 和tail 。与单链式列表不同,双链式列表对列表的开头和结尾都有一个引用。由于DoublyList 的每个实例都是在没有节点的情况下实例化的,所以head 和tail 的默认值被设置为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) 处理四个用例。
- 被作为
remove(position)的参数传递的位置是不存在的。在这种情况下,我们抛出一个错误。 - 作为
remove(position)的参数被传递的位置是一个列表的第一个节点(head)。如果是这种情况,我们将把deletedNode赋给head,然后把head重新赋给列表中的下一个节点。此刻,我们必须考虑我们的列表是否有不止一个节点。如果答案是否定的,head将被分配到null,我们将进入if-else语句的if部分。在if的正文中,我们还必须将tail设为null- 换句话说,我们回到了空的双链表的原始状态。如果我们要删除一个列表中的第一个节点,并且我们的列表中有不止一个节点,我们就会进入if-else语句的else部分。在这种情况下,我们必须正确地将head的previous属性设置为null- 在列表的头部之前没有节点。 - 作为
remove(position)的参数被传递的位置是一个列表的尾部。首先,deletedNode被分配到tail。第二,tail被重新分配到尾部之前的节点。第三,新的尾巴后面没有节点,需要把它的值next设为null。 - 这里发生了很多事情,所以我将更多地关注逻辑,而不是每一行代码。一旦
currentNode指向作为参数传递给remove(position)的位置上的节点,我们就打破我们的while循环。此刻,我们将beforeNodeToDelete.next的值重新分配给nodeToDelete之后的节点,反之,我们将afterNodeToDelete.previous的值重新分配给nodeToDelete之前的节点。换句话说,我们删除指向被删除节点的指针,并将其重新分配给正确的节点。接下来,我们将deletedNode赋值给nodeToDelete。最后,我们将nodeToDelete赋值给null。
最后,我们减去我们列表的长度,并返回deletedNode 。
总结
在这篇文章中,我们已经涵盖了很多的信息。如果其中有任何信息令人困惑,请再次阅读,并对代码进行试验。当它最终对你有意义时,请感到自豪。你刚刚揭开了单链式列表和双链式列表的神秘面纱。你可以把这些数据结构加入到你的编码工具带中去了!
