前言
嘿,掘友们!今天我们来了解并实现数据结构 —— 链表。
本文主要内容
- 单向链表
- 双向链表
- 循环链表
链表
要存储多个元素,数组可能是最常用的数据结构。但是这种数据结构有一个缺点,数组的大小是固定的,从数组的起点或中间插入或移除的成本很高,因为需要移动元素。
链表存储有序的元素集合,不同于数组的是,链表中的元素在内存中并不是连续放置的。每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(指针或链接)组成。下图是一个链表的结构。
相比数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。然而,链表需要使用指针。在数组中,我们可以直接访问任何位置的任何元素,而想要访问链表中间的一个元素,则需要从起点开始迭代链表知道找到所需要的元素。
在实现链表之前,我们先声明和定义属性和方法。
- 属性
count存储链表中的元素数量head头指针equalsFn比较元素是否相等
- 方法
push(element)向链表尾部添加一个新元素insert(element, index)向链表的特定位置插入一个新元素getElementAt(index)返回链表中的特定位置的元素,如果不存在返回undefinedremove(element)从链表中移除一个元素removeAt(index)从链表的特定位置移除一个元素indexOf(index)返回元素在链表中的索引,如果链表中没有该元素,返回-1isEmpty()如果链表中不包含任何元素,返回true,否则返回falsesize()返回链表包含的元素个数print()返回表示整个链表的字符串
定义defaultEquals函数,作为默认的相等性比较函数。
function defaultEquals(a, b) {
return a === b
}
单向链表
要表示链表中的元素,我们需要一个助手类,叫做 Node。Node类表示我们想要添加到链表中的项。
class Node {
constructor(element) {
this.element = element // 元素的值
this.next = undefined // 下一个元素的指针
}
}
创建 LinkedList 类的“骨架”
class LinkedList {
constructor() {
this.count = 0
this.head = undefined
}
}
1. 向链表尾部添加元素
在尾部添加元素可能有两种场景:链表为空,添加的是第一个元素;链表不为空,向其尾部添加元素
class LinkedList {
constructor() { ... }
push(element) {
const node = new Node(element) // 创建Node项
if(this.head == null) {
this.head = node
} else {
let current = this.head // 指向链表的current变量
while (current.next != null) {
current = current.next
}
current.next = node
}
this.count++
}
}
首先,把 element作为值传入,创建Node项。
先实现第一个场景,向空链表添加一个元素,当创建一个 LinkedList 对象时,head会指向 undefined。
如果 head 元素为 undefined 或 null,就意味着在向链表添加第一个元素。因此要做的就是让 head 指向 node 元素。
再来看第二个场景,向一个不为空的链表尾部添加元素。
要在链表的尾部添加元素,就要找到最后一个元素。但我们只有第一个元素的引用,需要循环访问链表,直到最后一项。当 current.next 元素为 undefined 或 null 时,我们就知道到达链表尾部了。然后让当前元素的 next 指向 node 元素。
this.head == null 相当于 this.head === undefined || this.head === null
current.next !=null 相当于 current.next !== undefined || current.next !== null
2. 循环迭代链表找到目标
循环到目标 index 的代码片段在 LinkedList 类的方法中很常见。将这部分逻辑独立为单独的方法,这样就能在不同的地方复用它。
class LinkedList {
constructor() { ... }
push(element) { ... }
getElementAt(index) {
if(index >= 0 && index < this.count) {
let node = this.head
for(let i = 0; i < index && node != null; i++) {
node = node.next
}
return node
}
return undefined
}
}
为了确保我们能迭代链表知道找到一个合法的位置,需要对传入的 index 参数进行合法性验证。如果传入的位置不合法,返回 undefined,因为这个位置在链表中不存在。
然后,初始化 node 变量,该变量会从链表的第一个元素 head 开始,迭代整个链表知道目标 index,结束循环时,node 元素将是 index 位置元素的引用。
3. 从链表中移除元素
我们要实现两种移除元素的方法。第一种是从特定位置移除一个元素(removeAt),第二种是根据元素值移除元素(remove)。
我们先实现第一种移除元素的方法,要移除元素存在两种场景:第一种,移除第一个元素,第二种,移除第一个元素以外的元素。
removeAt(index) {
if(index >= 0 && index < this.count) {
if(index === 0) {
this.head = this.head.next
} else {
const previous = this.getElement(index - 1)
const current = previous.next
previous.next = current.next
}
this.count--
return current.element
}
return undefined
}
先看第一种场景:我们从链表中移除第一个元素。想移除第一个元素,让 head 指向链表的第二个元素就实现了。
再看第二种场景:移除除第一个元素以外的元素。我们获取要删除元素的前一个元素。current 引用要删除的元素。将前一个元素的 next 指向要删除元素的 next,就可以实现了。
移除最后一个元素也通用,previous 引用最后元素的前一个元素,最后一个元素的 next 指向 undefined,那么将 previous.next = undefined,就完成了最后一个元素的移除。
我们再来实现移除元素的第二种方法:根据元素值移除元素(remove)。
remove(element) {
const index = this.getElement(element)
return this.removeAt(index)
}
我们复用前面的两种方法 getElement 和 removeAt ,就可以实现。
先获取要删除元素的索引,再根据特定位置删除元素。
4. 在任意位置插入元素
insert(element, index) {
if(index >= 0 && index < this.count) {
const node = new Node(element)
if(index === 0) {
const current = this.head
node.next = current
this.head = node
} else {
const previous = this.getElementAt(index-1)
const current = previous.next
node.next = current
previous.next = node
}
this.count++
return true
}
return false
}
由于处理的是索引,就需要检查合法性。任意位置插入也有两种场景:在链表起点添加一个元素和在链表中间或尾部添加一个元素。
先看第一种场景,使用 current 变量引用链表中的第一个元素,将 node.next 指向 current(第一个元素),此时 head 和 node.next 都指向了 current。将 head 指向 node,这样链表就在头部添加了一个元素。
再看第二种场景,首先我们需要迭代链表,找到目标位置的前一个元素。current变量指向插入新元素的位置之后的元素。我们需要在 previous 和 current 之间添加新元素,因此,先把新元素和 current 连接,然后改变 previous 和 current 之间的连接,也就是让 previous.next = node,取代 current。
5. 返回一个元素的位置
indexOf 方法接收一个元素的值,在链表中找到它,就返回元素的位置,否则返回 -1。
indexOf(element) {
let current = this.head
for(let i = 0; i < this.count && current != null; i++){
if(this.equalsFn(element, current.element)) {
return i
}
current = current.next
}
return -1
}
我们需要一个变量来帮助我们循环访问链表。迭代元素,从 head 开始,知道链表的长度为止。为了确保不会发生运行错误,可以验证下 current 变量是否为 null 或 undefined。
在每次迭代时,验证 current 节点的元素和目标元素是否相等。如果当前位置的元素是我们要找的元素,返回它的位置。如果不是,就迭代下一个链表节点。
如果链表为空或迭代到链表的尾部,循环结束。如果没有找到目标,则返回 -1。
6. isEmpty、size 和 getHead 方法
size() {
return this.count
}
isEmpty() {
return this.size === 0
}
getHead() {
return this.head
}
7. print 方法
print 方法会把 LinkedList 对象转换成一个字符串。
print() {
if(this.head == null) return ''
let string = `${this.head.element}`
let current = this.head.next
for(let i = 1; i < this.count && current != null; i++) {
string = `${string}, ${current.element}`
current = current.next
}
return string
}
首先,如果链表为空,我们就返回一个空字符串。这里也可以用 this.isEmpty() 来进行判断。
如果链表不为空,我们就用链表第一个元素的值来初始化字符串(string)。然后迭代链表的其他元素,将元素值添加到字符串上。如果链表只有一个元素, current != null将不会执行验证,因为current 变量的值为 undefined 或 null,算法不会向 string 添加其他值。
最后,返回链表内容的字符串。
完整代码
class LinkedList {
constructor(equalsFn = defaultEquals) {
this.count = 0
this.head = undefined
this.equalsFn = equalsFn
}
push(element) {
const node = new Node(element)
if(this.head == null) {
this.head = node
} else {
let current = this.head
while(current.next != null) {
current = current.next
}
current.next = node
}
this.count++
}
removeAt(index) {
if(index>=0 && index<this.count) {
let current = this.head
if(index === 0) {
this.head = current.next
} else {
const previous = this.getElementAt(index - 1)
current = previous.next
previous.next = current.next
}
this.count--
return current.element
}
return undefined
}
getElementAt(index) {
if(index>=0 && index<this.count) {
let node = this.head
for(let i = 0; i < index; i++){
node = node.next
}
return node
}
return undefined
}
insert(element, index) {
if(index >= 0 && index < this.count) {
const node = new Node(element)
if(index === 0) {
const current = this.head
node.next = current
this.head = node
} else {
const previous = this.getElementAt(index-1)
const current = previous.next
node.next = current
previous.next = node
}
this.count++
return true
}
return false
}
indexOf(element) {
let current = this.head
for(let i = 0; i < this.count && current != null; i++){
if(this.equalsFn(element, current.element)) {
return i
}
current = current.next
}
return -1
}
remove(element) {
const index = this.indexOf(element)
return this.removeAt(index)
}
size() {
return this.count
}
isEmpty() {
return this.size() === 0
}
getHead() {
return this.head
}
print() {
if(this.head == null) return ''
let string = `${this.head.element}`
let current = this.head.next
for(let i = 1; i < this.count && current != null; i++) {
string = `${string}, ${current.element}`
current = current.next
}
return string
}
}
双向链表
双向链表和单向链表的区别在于,单向链表是一个节点只有链向下一个节点的链接;在双向链表中,链接是双向的,一个链向下一个元素,另一个链向前一个元素。
要表示双向链表中的元素,我们对 Node 类添加一个属性 prev。prev用来指向前一个元素
class DoublyNode extends Node {
constructor(element, next, prev) {
super(element, next) // 继承 Node 的 element 和 next
this.prev = prev // 指向前一个元素
}
}
创建 DoublyLinkedList 类的“骨架”
class DoublyLinkedList extends LinkedList {
constructor(equalsFn = defaultEquals) {
super(equalsFn)
this.tail = undefined // 指向最后一个元素
}
}
DoublyLinkedList 类是一个特殊的 LinkedList 类,我们要扩展 LinkedList 类。这表示 DoublyLinkedList 类将继承(可访问) LinkedList 类中的所有属性和方法。
双向链表提供了两种迭代的方法:从头到尾,或者从尾到头。我们也可以访问一个特定节点的下一个或前一个元素。为了实现这种行为,还需要追踪每个节点的前一个节点。所以除了 Node 类中的 element 和 next 属性,DoublyLinkedList 会使用一个特殊的节点,这个名为 DoublyNode 的节点有一个叫做 prev 的属性。DoublyNode 扩展了 Node 类,因此我们可以继承 element 和 next 属性。由于使用了继承,我们需要在 DoublyNode 类的构造函数中调用 Node 的构造函数。
1. 在任意位置插入元素
向双向链表中插入一个新元素跟单向链表非常相似。区别在于,链表只需控制一个 next 指针,而双向链表则要同时控制 prev 和 next 这两个指针。所以,我们要重写 insert 方法,表示我们会使用一个和 LinkedList 类中的方法行为不同的方法
insert(element, index) {
if(index >= 0 && index <= this.count) {
const node = new DoublyNode(element)
let current = this.head
if(index === 0) {
// 头部插入
if(this.head == null) {
this.head = node
this.tail = node
} else {
node.next = this.head
current.prev = node
this.head = node
}
} else if (index == this.count) {
// 尾部插入
current = this.tail
current.next = node
node.prev = current
this.tail = node
} else {
// 中间插入
const previous = this.getElementAt(index - 1)
current = previous.next
node.next = current
previous.next = node
current.prev = node
node.prev = previous
}
this.count++
return true
}
return false
}
我们先分析第一种场景:在头部插入一个新元素。如果双向链表为空,只需要让 head 和 tail 都指向 node 节点。如果不为空,current 变量将是对双向链表的第一个元素的引用。先将 node 和 头节点链接(node.next = this.head),然后让 head 指向 node。和单向链表不同的是,还需要为指向上一个元素的指针设一个值,将 current.prev 指针将由指向 undefined 变为 node。而 node.prev 指针已经是 undefined,因此无须更新。
下面看第二种场景:在尾部插入一个新元素。这是一个特殊情况,因为我们控制着指向最后一个元素的指针。首先,让 current 引用最后一个元素,然后建立链接,current.next 指向 node,node.prev 引用 current。最后,更新 tail,将由指向 current 变为指向 node。
还有第三种场景:在中间插入一个新元素。这里和之前的方法很相似,迭代双向链表,直到要找的位置。由于 getElement 方法是从 LinkedList 类中继承的,不需要重写。
我们将在 previous 和 current 之间插入新元素。首先,将 node.next 指向 current,previous.next 指向 node。这样就不会丢失节点之间的链接。然后处理向前的链接,将 current.prev 指向 node,node.prev 指向 previous。
2. 从任意位置移除元素
双向链表移除元素和单向链表差不多,唯一的区别就是还需要设置前一个位置的指针。
removeAt(index) {
if(index >= 0 && index < this.count) {
let current = this.head
if(index === 0) {
// 头部删除
this.head = current.next
if(this.count === 1) {
this.tail = undefined
} else {
this.head.prev = undefined
}
} else if(index === this.count - 1) {
// 尾部删除
current = this.tail
this.tail = current.prev
this.tail.next = undefined
} else {
// 中间删除
const previous = this.getElementAt(index - 1)
current = previous.next
previous.next = current.next
current.next.prev = previous
}
this.count--
return current.element
}
return undefined
}
我们需要处理三种场景:头部删除、尾部删除和中间删除。
我们先看移除第一个元素。current 作为对第一个节点的引用。我们想要移除第一个元素,要做的就是改变 head 的引用。我们先让 head 指向下一个元素,如果双向链表的长度为 1,说明链表内为空,此时 head = current.next,而 current.next 就是 undefined,无须处理。将 tail 指向由 current 变为 undefined。如果不为空,此时 head 指向了下一个元素,但这个元素的 prev 还指向之前的元素,将 this.head.prev 由 current 变为 undefined。
下一种场景是从最后一个位置移除元素。先让 current 引用最后一个元素,将 tail 指向前面的元素,但是前面的元素的 next 指向要删除的元素,将这个元素的 next 变为 undefined。
第三种场景是从中间移除元素。首先需要迭代双向链表,知道要到的位置。current 变量所引用的就是要移除的元素。我们通过更新 previous.next 和 current.next.prev 的引用,在双向链表中跳过他。因此 previous.next 指向 current.next,而 current.next.prev 指向 previous。
3. 在链表尾部添加元素
双向链表在尾部添加元素和单向链表也很相似,不同的是,当链表不为空时,因为存在 tail 指向最后一个节点,可以直接使用 tail 引用最后一个元素,不需要像单向链表迭代到最后一个元素再处理。最后,要将 tail 指向新增的 node 节点
push(element) {
const node = new DoublyNode(element)
if(this.head == null) {
this.head = node
} else {
let current = this.tail
current.next = node
node.prev = current
}
this.tail = node
this.count++
}
4. 循环迭代链表直到目标位置
双向链表由两种迭代方式,从头到尾,或者从尾到头,我们重写 getElementAt 方法,根据查找的位置,优化查找速度。如果索引 <= 链表长度 - 索引,我们就从头开始迭代,如果索引 > 链表长度 - 索引,我们从尾向头迭代。
getElementAt(index) {
if(index >= 0 && index <= this.count) {
if(index <= this.count - index) {
let node = this.head
for (let i = 0; i < index && node != null; i++) {
node = node.next
}
return node
} else {
let node = this.tail
for(let i = this.count - 1; i > index && node != null; i--) {
node = node.prev
}
return node
}
}
return undefined
}
5. 获取尾部节点(新增)
因为双向链表提供 tail 节点,他指向链表的最后一个元素。我们新增一个 getTail 方法,来直接获取最后一个节点
getTail() {
return this.tail
}
其他方法,直接继承 LinkedList 类中的方法。
完整代码
class DoublyLinkedList extends LinkedList {
constructor(equalsFn = defaultEquals) {
super(equalsFn)
this.tail = undefined
}
insert(element, index) {
if(index >= 0 && index <= this.count) {
const node = new DoublyNode(element)
let current = this.head
if(index === 0) {
// 头部插入
if(this.head == null) {
this.head = node
this.tail = node
} else {
node.next = this.head
current.prev = node
this.head = node
}
} else if (index == this.count) {
// 尾部插入
current = this.tail
current.next = node
node.prev = current
this.tail = node
} else {
// 中间插入
const previous = this.getElementAt(index - 1)
current = previous.next
node.next = current
previous.next = node
current.prev = node
node.prev = previous
}
this.count++
return true
}
return false
}
removeAt(index) {
if(index >= 0 && index < this.count) {
let current = this.head
if(index === 0) {
// 头部删除
this.head = current.next
if(this.count === 1) {
this.tail = undefined
} else {
this.head.prev = undefined
}
} else if(index === this.count - 1) {
// 尾部删除
current = this.tail
this.tail = current.prev
this.tail.next = undefined
} else {
// 中间删除
const previous = this.getElementAt(index - 1)
current = previous.next
previous.next = current.next
current.next.prev = previous
}
this.count--
return current.element
}
return undefined
}
push(element) {
const node = new DoublyNode(element)
if(this.head == null) {
this.head = node
} else {
let current = this.tail
current.next = node
node.prev = current
}
this.tail = node
this.count++
}
getElementAt(index) {
if(index >= 0 && index <= this.count) {
if(index <= this.count - index) {
let node = this.head
for (let i = 0; i < index && node != null; i++) {
node = node.next
}
return node
} else {
let node = this.tail
for(let i = this.count - 1; i > index && node != null; i--) {
node = node.prev
}
return node
}
}
return undefined
}
// 新增
getTail() {
return this.tail
}
/**
* 剩下的方法从父类继承
* size()
* isEmpty()
* getHead()
* print()
* remove(element)
* indexOf(element)
*/
}
循环链表
循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链表之间的却别在于,最后一个元素指向下一个元素的指针(tail.next) 不是引用 undefined,而是指向第一个元素(head)。
这里我就不多说了,感兴趣的掘友可以自己去实现一下,我在下面附上循环单向链表和循环双向链表的实现。
单向循环链表
class CircularLinkedList extends LinkedList {
constructor(equalsFn = defaultEquals) {
super(equalsFn)
}
insert(element, index) {
if(index >= 0 && index <= this.count) {
const node = new Node(element)
let current = this.head
if(index === 0) {
if(this.head == null) {
this.head = node
node.next = this.head
} else {
node.next = current
current = this.getElementAt(index - 1)
this.head = node
current.next = this.head
}
} else {
const previous = this.getElementAt(index - 1)
node.next = previous.next
previous.next = node
}
this.count++
return true
}
return false
}
removeAt(index) {
if(index >= 0 && index < this.count) {
let current = this.head
if(index === 0) {
if(this.size() === 1) {
this.head = undefined
} else {
const removed = this.head
current = this.getElementAt(this.size()-1) // 最后一个节点
this.head = this.head.next
current.next = this.head
current = removed
}
} else {
const previous = this.getElementAt(index-1)
current = previous.next
previous.next = current.next
}
this.count--
return current.element
}
return undefined
}
push(element) {
const node = new Node(element)
if(this.head == null) {
this.head = node
node.next = this.head
} else {
let current = this.head
while (current.next != this.head) {
current = current.next
}
current.next = node
node.next = this.head
}
this.count++
}
indexOf(element) {
let current = this.head
for(let i = 0; i < this.count; i++) {
if(this.equalsFn(element, current.element)) return i
current = current.next
}
return -1
}
/**
* 剩下的方法从父类继承
* size()
* isEmpty()
* getHead()
* print()
* remove(element)
* getElementAt(index)
*/
}
双向循环链表
// 循环双向链表
class CircularDoublyLinkedList extends DoublyLinkedList {
constructor(equalsFn = defaultEquals) {
super(equalsFn)
}
insert(element, index) {
if(index >= 0 && index <= this.count) {
const node = new DoublyNode(element)
let current = this.head
if(index === 0) {
if(this.head == null) {
this.head = node
this.tail = node
node.next = this.head // 新增
node.prev = this.head
} else {
node.next = this.head
current.prev = node
this.head = node
this.tail.next = this.head // 新增
node.prev = this.tail
}
} else if(index === this.count) {
current = this.tail
current.next = node
node.prev = current
node.next = this.head
this.tail = node
this.head.prev = this.tail
} else {
console.log('shang');
const previous = this.getElementAt(index - 1)
current = previous.next
previous.next = node
node.next = current
node.prev = previous
current.prev = node
}
this.count++
return true
}
return false
}
removeAt(index) {
if(index >= 0 && index < this.count) {
let current = this.head
if(index === 0) {
if(this.count === 1) {
this.head = undefined
this.tail = undefined
} else {
this.head = current.next
this.tail.next = this.head
this.head.prev = this.tail
}
} else if (index === this.count - 1) {
current = this.tail
this.tail = current.prev
this.tail.next = this.head
this.head.prev = this.tail
} else {
current = this.getElementAt(index)
const previous = current.prev
previous.next = current.next
current.next.prev = previous
}
this.count--
return current.element
}
return undefined
}
push(element) {
const node = new DoublyNode(element)
if(this.head == null) {
this.head = node
this.tail = node
node.next = this.head
node.prev = this.tail
} else {
let current = this.tail
current.next = node
node.prev = current
node.next = this.head
this.tail = node
}
this.count++
}
/**
* 剩下的方法从父类继承
* indexOf(element)
* size()
* isEmpty()
* getHead()
* print()
* remove(element)
* getElementAt(index)
*/
}
结语
链表还有一种结构叫有序链表,有序链表是保持元素有序的链表结构。除了使用排序算法之外,我们还可以将元素插入到正确的位置来保证链表的有序性。要实现也很简单,只需要添加一个活的插入元素的正确位置的方法,使用 insert 方法就可以实现啦。
我们还可以用链表作为内部的数据结构来创建其他数据结构,例如:栈、队列和双向队列。
链表相比数组最重要的优点,就是无需移动链表中的元素,就能轻松地添加和删除元素。因此,当你需要频繁地做添加和删除操作时,最好的选择是链表,而非数组。
* 图片来源:《学习JavaScript数据结构与算法》