本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!
前言
链表是由一个个结点组成的数据结构,而结点一般是由数据和记录下一个结点地址的“指针”组成(双向链表还有一个记录上一个结点地址的“指针”)。
链表有很多种。
- 单链表
- 循环链表(或叫单向循环链表)
- 双向链表
- 双向循环链表
- 带头链表(带有“哨兵结点”的非循环链表)
注意:文章会用到几个术语,我们一般把结点的下一个结点称为“后继结点”,结点的记录下一个结点地址的“指针”称为“后继指针”;把结点的上一个结点称为“前驱结点”,结点的记录上一个结点地址的“指针”称为“前驱指针”。
单链表
单链表有两个特殊的结点:头结点和尾结点。
头结点记录初始地址,且没有被任何结点的“后继指针”指向。
尾结点记录最后一个地址,且“后继指针”指向 null。
注意:下面的链表代码记录了尾结点,实际上,你可以完全不用记录尾结点就可以实现链表结构。
js 代码实现
// 结点
class Node {
constructor(data) {
this.data = data
this.next = null
}
}
// 单链表
class LinkedList {
#size = 0 // 链表长度
#head = null // 头结点
#tail = null // 尾结点
/* 增 */
// 尾部添加结点
append(data) {
let node = new Node(data)
if (this.#tail) {
this.#tail.next = node
this.#tail = node
} else {
this.#head = node
this.#tail = node
}
this.#size++
}
// 根据位置添加结点(位置是从0开始)
insert(index, data) {
if (index > this.#size || index < 0) {
return false
}
let node = new Node(data)
if (this.#size === 0) {
// 没长度,直接插入
this.#head = node
this.#tail = node
} else if (index == this.#size) {
// 尾结点,特别操作
this.#tail.next = node
this.#tail = node
} else if (index == 0) {
// 头结点,特别操作
node.next = this.#head
this.#head = node
} else {
// 中间结点,前驱结点肯定会有
let target = this.#head
let prev = null
for (let i = 1; i <= index; i++) {
prev = target
target = target.next
}
node.next = target
prev.next = node
}
this.#size++
return true
}
/* 删 */
// 删除相关值最近的结点
delete(data) {
let node = this.#head
let prev = null
while (node) {
if (node.data === data) {
if (node === this.#head) { // 删除的结点为头结点,需要更改头结点
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
} else {
this.#head = this.#head.next
}
} else if (node === this.#tail) { // 删除的结点为尾结点,需要更改尾结点
prev.next = null
this.#tail = prev
} else {
prev.next = node.next
}
this.#size--
return true
} else {
prev = node
node = node.next
}
}
return false
}
// 删除相关位置的结点
deleteIndex(index) {
if (index > this.#size - 1 || index < 0) {
return false
}
if (index === 0) { // 删除头结点
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
} else {
this.#head = this.#head.next
}
} else {
let node = this.#head
let prev = null
for (let i = 1; i <= index; i++) {
prev = node
node = node.next
}
if (node === this.#tail) {
prev.next = null
this.#tail = prev
} else {
prev.next = node.next
}
}
this.#size--
return true
}
/* 查 */
// 链表长度
size() {
return this.#size
}
// 获取头结点
head() {
return this.#head
}
// 获取尾结点
tail() {
return this.#tail
}
// 找到第一次出现的结点
find(data) {
let node = this.#head
while (node) {
if (node.data === data) {
return node
} else {
node = node.next
}
}
}
// 根据位置找到结点(位置是从0开始)
findIndex(index) {
if (index > this.#size - 1 || index < 0) {
return
}
if (index == 0) {
return this.#head
}
if (index == this.#size - 1) {
return this.#tail
}
let node = this.#head
for (let i = 1; i <= index; i++) {
node = node.next
}
return node
}
// 查找值第一次出现的位置(位置是从0开始)
indexOf(data) {
let node = this.#head
let i = 0
while (node) {
if (node.data === data) {
return i
} else {
node = node.next
i++
}
}
return -1
}
// 是否为空
isEmpty() {
return this.#size === 0 ? true : false
}
// 打印(从头到尾)
print() {
if (this.#size === 0) {
console.log('空链表,头结点:', this.#head, '尾结点:', this.#tail)
return
}
let node = this.#head
while (node) {
console.log(node.data)
node = node.next
}
console.log('头结点:', this.#head)
console.log('尾结点:', this.#tail)
console.log('总长度:' + this.#size)
}
}
单向循环链表
单向循环链表是在单链表的基础下衍生出来,区别是单向循环链表的尾结点不再指向 null,而是指向头结点,形成环形结构。
这种结构用来解决“约瑟夫问题”是特别有用的。
js代码实现
// 结点
class Node {
constructor(data) {
this.data = data
this.next = null
}
}
// 循环链表
class CircleLinkedList {
#size = 0 // 链表长度
#head = null // 头结点
#tail = null // 尾结点
/* 增 */
// 尾部添加结点
append(data) {
let node = new Node(data)
if (this.#tail) {
this.#tail.next = node
this.#tail = node
// 将尾结点重新连接到头结点
this.#tail.next = this.#head
} else {
this.#head = node
this.#tail = node
this.#tail.next = this.#head
}
this.#size++
}
// 根据位置添加结点(位置是从0开始)
insert(index, data) {
if (index > this.#size || index < 0) {
return false
}
let node = new Node(data)
if (this.#size === 0) {
// 没长度,直接插入
this.#head = node
this.#tail = node
} else if (index == this.#size) {
// 尾结点,特别操作
this.#tail.next = node
this.#tail = node
this.#tail.next = this.#head
} else if (index == 0) {
// 头结点,特别操作
node.next = this.#head
this.#head = node
this.#tail.next = this.#head
} else {
// 中间结点,前驱结点肯定会有
let target = this.#head
let prev = null
for (let i = 1; i <= index; i++) {
prev = target
target = target.next
}
node.next = target
prev.next = node
}
this.#size++
}
/* 删 */
// 删除相关值最近的结点
delete(data) {
let node = this.#head
let prev = null
while (node) {
if (node.data === data) {
if (node === this.#head) {
// 删除的结点为头结点,需要更改头结点
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
node.next = null
} else {
this.#head = node.next
this.#tail.next = this.#head
}
} else if (node === this.#tail) {
// 删除的结点为尾结点,需要更改尾结点
prev.next = this.#head
this.#tail = prev
} else {
prev.next = node.next
}
node.next = null // 将删除的结点的 next 指向空
this.#size--
return true
} else {
if (node === this.#tail) {
break
}
prev = node
node = node.next
}
}
return false
}
// 删除相关位置的结点
deleteIndex(index) {
if (index > this.#size - 1 || index < 0) {
return false
}
if (index === 0) {
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
} else {
this.#head = this.#head.next
this.#tail.next = this.#head
}
} else {
let node = this.#head
let prev = null
for (let i = 1; i <= index; i++) {
prev = node
node = node.next
}
if (node === this.#tail) {
prev.next = this.#head
this.#tail = prev
} else {
prev.next = node.next
}
node.next = null
}
this.#size--
return true
}
/* 查 */
// 链表长度
size() {
return this.#size
}
// 获取头结点
head() {
return this.#head
}
// 获取尾结点
tail() {
return this.#tail
}
// 找到第一次出现的结点
find(data) {
let node = this.#head
while (node) {
if (node.data === data) {
return node
} else if (node === this.#tail) {
break
} else {
node = node.next
}
}
}
// 根据位置找到结点(位置是从0开始)
findIndex(index) {
if (index > this.#size - 1 || index < 0) {
return
}
if (index == 0) {
return this.#head
}
if (index == this.#size - 1) {
return this.#tail
}
let node = this.#head
for (let i = 1; i <= index; i++) {
node = node.next
}
return node
}
// 查找值第一次出现的位置(位置是从0开始)
indexOf(data) {
let node = this.#head
let i = 0
while (node) {
if (node.data === data) {
return i
} else if (node === this.#tail) {
break
} else {
node = node.next
i++
}
}
return -1
}
// 是否为空
isEmpty() {
return this.#size === 0 ? true : false
}
// 打印(从头到尾)
print() {
if (this.#size === 0) {
console.log('空链表,头结点:', this.#head, '尾结点:', this.#tail)
return
}
let node = this.#head
while (node) {
console.log(node.data)
if (node === this.#tail) {
break
} else {
node = node.next
}
}
console.log('头结点:', this.#head)
console.log('尾结点:', this.#tail)
console.log('尾结点的下一个结点是否是头结点:', this.#head === this.#tail.next)
console.log('总长度:' + this.#size)
}
}
双向链表
对比单向链表,双向链表多了一个“前驱指针”,“前驱指针”用来记录上一个结点的地址。由于双向链表既有“前驱指针”,也有“后继指针”,双向链表相比单向链表占用的内存会高很多。但是即使双向链表占用的内存高,我们在开发时更愿意使用这种结构,因为这种结构可以向前找结点,查找数据的效率比单向链表高,而且在一些插入或删除结点的操作中,操控前一个结点去连接另一个结点就简单多了。双向链表是典型的“空间换时间”的结构。
这里留意以下的 findIndex
方法,不再像单链表盲目从头到尾遍历。由于双向链表可以从尾到头遍历的特点,这里先判断 index
是否小于 size/2
,如果小于,则从头到尾遍历,如果大于,则从尾到头遍历。
js 代码实现
// 结点,多了一个“前驱指针”
class Node {
constructor(data) {
this.data = data
this.next = null
this.prev = null
}
}
// 双向链表
class DoubleLinkedList {
#size = 0 // 链表长度
#head = null // 头结点
#tail = null // 尾结点
/* 增 */
// 尾部添加结点
append(data) {
let node = new Node(data)
if (this.#tail) {
this.#tail.next = node
node.prev = this.#tail
this.#tail = node
} else {
this.#head = node
this.#tail = node
}
this.#size++
}
// 根据位置添加结点(位置是从0开始)
insert(index, data) {
if (index > this.#size || index < 0) {
return false
}
let node = new Node(data)
if (this.#size === 0) {
// 没长度,直接插入
this.#head = node
this.#tail = node
} else if (index == this.#size) {
console.log(123)
// 尾结点,特别操作
node.prev = this.#tail
this.#tail.next = node
this.#tail = node
} else if (index == 0) {
// 头结点,特别操作
this.#head.prev = node
node.next = this.#head
this.#head = node
} else {
// 中间结点,前驱结点肯定会有
let target = this.#head
for (let i = 1; i <= index; i++) {
target = target.next
}
node.next = target
node.prev = target.prev
target.prev.next = node // 注意这一步
target.prev = node
}
this.#size++
return true
}
/* 删 */
// 删除相关值最近的结点
delete(data) {
let node = this.#head
while (node) {
if (node.data === data) {
if (node === this.#head) { // 删除的结点为头结点,需要更改头结点
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
} else {
this.#head = this.#head.next
this.#head.prev = null
}
} else if (node === this.#tail) { // 删除的结点为尾结点,需要更改尾结点
node.prev.next = null
this.#tail = node.prev
} else {
node.next.prev = node.prev
node.prev.next = node.next
}
this.#size--
return true
} else {
node = node.next
}
}
return false
}
// 删除相关位置的结点
deleteIndex(index) {
if (index > this.#size - 1 || index < 0) {
return false
}
if (index === 0) { // 删除头结点
if (this.#head === this.#tail) {
this.#head = null
this.#tail = null
} else {
this.#head = this.#head.next
this.#head.prev = null
}
} else if (index === this.#size-1) { // 删除尾结点
this.#tail.prev.next = null
this.#tail = this.#tail.prev
} else {
// let node = this.#head
// for (let i = 1; i <= index; i++) {
// node = node.next
// }
let node = this.findIndex(index)
if (node) {
node.next.prev = node.prev
node.prev.next = node.next
} else {
return false
}
}
this.#size--
return true
}
/* 查 */
// 链表长度
size() {
return this.#size
}
// 获取头结点
head() {
return this.#head
}
// 获取尾结点
tail() {
return this.#tail
}
// 找到第一次出现的结点
find(data) {
let node = this.#head
while (node) {
if (node.data === data) {
return node
} else {
node = node.next
}
}
}
// 根据位置找到结点(位置是从0开始)
findIndex(index) {
if (index > this.#size - 1 || index < 0) {
return
}
if (index == 0) {
return this.#head
}
if (index == this.#size - 1) {
return this.#tail
}
// 这里判断 index 在 size/2 的左边还是右边,可以更快找到数据
let node
if (index < this.#size/2) {
node = this.#head
for (let i = 1; i <= index; i++) {
node = node.next
}
} else {
node = this.#tail
for (let i = this.#size-1; i > index; i--) {
node = node.prev
}
}
return node
}
// 查找值第一次出现的位置(位置是从0开始)
indexOf(data) {
let node = this.#head
let i = 0
while (node) {
if (node.data === data) {
return i
} else {
node = node.next
i++
}
}
return -1
}
// 是否为空
isEmpty() {
return this.#size === 0 ? true : false
}
// 打印(isPrev: false,从头到尾;true,从尾到头)
print(isPrev) {
if (this.#size === 0) {
console.log('空链表,头结点:', this.#head, '尾结点:', this.#tail)
return
}
if (!isPrev) {
console.log('从头到尾打印')
let node = this.#head
while (node) {
console.log(node.data)
node = node.next
}
} else {
console.log('从尾到头打印')
let node = this.#tail
while(node) {
console.log(node.data)
node = node.prev
}
}
console.log('头结点:', this.#head)
console.log('尾结点:', this.#tail)
console.log('总长度:', this.#size)
}
}
双向循环链表
和单向链表类似,双向循环链表是在双向链表的基础下,尾结点的“后继指针”指向头结点。
看了上面的单向循环链表和双向链表,相信你已经对双向循环链表有一定的实现想法了,这里就不进行实现了。
带头链表
不管是单向链表,还是双向链表,插入新的头结点和删除头结点都要进行一些特殊的操作(这里不考虑循环链表,因为循环链表是没有边界问题)。
以单向链表为例,我们正常的插入操作,都是将前一个结点的“后继指针”指向新结点,新结点的“后继指针”指向下一个结点,但如果插入的是新的头结点,因为前一个结点不存在,我们只需要将新的头结点的“后继指针”指向原来的头结点。
而我们正常的删除操作,都是将前一个结点的“后继指针”指向需要删除结点的下一个结点即可,但如果删除的是头结点,由于前一个结点不存在,我们只需要将头结点的引用指向原来的头结点的下一个结点。
有可能会有人有疑问,那插入或删除尾结点是不是也有特殊操作?
并没有。
插入尾结点 node 的操作,伪代码
node.next = tail.next // 先将新的尾结点的“后继指针”指向原来尾结点的下一个结点,也即是 null
tail.next = node // 再将原来的尾结点“后继指针”指向新的尾结点
这个操作和在链表中间插入一个新结点完全一致,不同的是,新插入的尾结点的“后继指针”会指向 null,而不是一个结点。不过如果你写的代码需要时刻记录尾结点,像我上面写的代码一样,LinkedList
既有记录头结点#head
,也记录尾结点#tail
,你确实需要一个小小的额外操作,把#tail
指向新的尾结点,事实上你写的链表也可以不记录尾结点#tail
。
回到正题,由于存在这种边界问题,在写代码时就很容易遗漏出错,所以这里可以加入了一个特殊结点“哨兵结点”来解决这种边界问题,这个“哨兵结点”的“后继指针”指向头结点,因此“哨兵结点”代替了原来的头结点,作为新的头结点,“哨兵结点”不存储任何的数据,它的数据始终是 null。
我们再来看看插入操作,如果我要插入第一个带有数据的新结点,我们需要将哨兵结点的“后继指针”指向新结点,新结点指向原来的第一个带有数据的结点。这个操作是不是和在链表中间插入新结点的操作完全一致。
删除同理,这里不做详细描述。
代码实现也很简单,只要在上面的单向链表基础下稍做修改,将原来的头结点改为哨兵结点,再稍微修改一下即可,这里就不实现了。
实践总结
链表虽然是一个很基础的结构,并且我以前就使用过 java 语言实现过一遍,但我在写的过程中依然出现问题,这让我花了一定的时间去排错,这些错误包含“边界没处理好”、“指针没有指到正确的地方”、“忘记把上一个结点的指针指到新结点上”等等。
我做了一个小总结,希望对大家有用,在写链表的时候注意以下几点:
- 单链表
- 插入或删除头结点
- 只有一个结点并进行删除这个结点时,需要将头结点和尾结点都赋值为 null
- 循环链表
- 插入或删除头结点,注意头结点和尾结点的联系
- 插入或删除尾结点,注意头结点和尾结点的联系
- 遍历数据时,注意防止死循环,遇到尾结点就跳出循环
- 双向链表
- 删除或插入中间一个结点,注意 next 和 prev 的指向,可以先在笔记本画图出来,用箭头将结点的关系表示出来,要切断哪些关系,要连接哪些关系
- 由于双向链表可以向前找结点,一些查询的操作可以优化(如上文的“双向链表”的 findIndex)