链表基本概念
链表是和数组非常类似的一种基本数据结构,但链表在内从中的存储格子是连续的,二者在性能上各有所长。数组访问元素为O(1),但是数组在插入和删除操作的时候比较麻烦,因为需要其他元素移位置。链表的访问是从头到尾的,但是链表在插入和删除上有巨大的优势。除了对数据的随机访 问,链表几乎可以用在任何可以使用一维数组的情况中。如果需要随机访问,数组仍然是更好的选择。
链表的每个节点需要两个格子,第一个用于保存数据,第二个保存着链表里的下一结点的内存地址
大多数语言中数组的长度是固定的,所以当数组已被数据填满时,再要加入新的元素就会非常困难。虽然JavaScript中的数组不存在这个问题,但JavaScript 中数组的主要问题是,它们被实现成了对象,与其他语言(比如 C++ 和 Java) 的数组相比,效率很低(请参考 Crockford 那本书的第 6 章)(注:此处没去研究)。
数组的插入元素、删除元素过程
数组基本操作的复杂度
因为数组在内存中的格子必须是连续的,因此,数组中不能有空的元素,必须全部填满。这导致了数组在进行插入、删除的时候,虽然插入和删除本身只需要一步操作,但是插入[n]时,n右侧元素必须全部右移才能留出空位n;删除操作也是一样,删除[n]处的元素后会留一个空位,n右侧的必须全部左移才能填满。在插入和删除上,链表的优势就比较明显。
要标识出链表的起始节点却有点麻烦,许多链表的实现都在链表最前面有一个特殊节
点,叫做头节点。
链表插入元素、删除元素过程
可以看出来,链表在插入和删除过程中操作非常简单。插入只需要把一条链条断开,中间接入新元素的左右端即可;删除元素只需要把要删除的元素左右的链直接连起来,这样被删除元素就脱离链表了。不过要注意的是,链表的访问必须是从头开始,因此要插入cookies元素,必须先访问到eggs元素,而这个步骤是O(n)。这样似乎链表和数组的性能是差不多的,甚至数组还要更好?如下图分析。
数组与链表的性能
链表相对于数组,高效地遍历单个列表并删除其中多个元素,是链表的亮点之一。对于多个元素的插入和删除,链表从头到尾走一遍就行了,而数组每次只能操作一个元素,因为其必须保证数组是填满后才能再次操作。
用类实现链表
在用JavaScript的类实现链表之前,要知道链表的基本操作包括:
- push(element):在链尾添加元素
- insert(element,position):链表的某个位置插入
- getElementAt(index):返回链表中特定位置的元素
- remove(element):从链表中移除一个元素
- indexOf(element):返回元素在链表中的索引
- removeAt(position):从链表的特定位置移除一个元素
- isEmpty()
- size()
- getHead()
- toString()
class Node {
constructor(element) {
this.element = element;
this.next = undefined;
}
}
class List {
constructor() {
this.count = 0;
this.element = undefined;
}
//向链尾加入元素
push(element) {
let newNode = new Node(element);
let currNode;
if (this.head == null) {
this.head = newNode;
} else {
currNode = this.head;
while (currNode.next != null) {
currNode = currNode.next;
}
currNode.next = newNode;
}
this.count++;
}
//移除元素:链中的第几个
removeAt(index) {
if (index >= 0 && index < this.count) {
let currNode = this.head;
if (index === 0) {
this.head = currNode.next;
} else {
let preNode;
for (let i = 0; i < index; i++) {
preNode = currNode;
currNode = currNode.next;
}
preNode.next = currNode.next;
}
this.count--;
return currNode.element;
}
return undefined;
}
//移除元素:移除某个值
remove(element) {
const index = this.indexOf(element);
this.removeAt(index);
}
//任意位置插入一个元素
insert(element, index) {
if (index >= 0 && index < this.count) {
const newNode = new Node();
if (index === 0) {
const currNode = this.head;
newNode.next = currNode;
this.head = newNode;
} else {
const preNode = getElementAt(index - 1);
const currNode = preNode.next;
newNode.next = currNode;
preNode.next = newNode;
}
this.count++;
return true;
}
return false;
}
//获取某位置的元素
getElementAt(index) {
if (index >= 0 && index < this.count) {
let currNode = this.head;
for (let i = 0; i < index; i++) {
currNode = currNode.next;
}
return currNode.element;
}
return undefined;
}
//获取某个值的位置
indexOf(element) {
let currNode = this.head;
let result = -1;
let index = 0;
while (currNode != null) {
if (currNode.element === element) {
result = index;
break;
}
currNode = currNode.next
index++;
}
return result;
}
//有几个元素
size() {
return this.count;
}
//检查是否为空
isEmpty() {
return this.size() === 0;
}
getHead() {
return this.head;
}
//输出所有的链表元素,供测试使用
showItems() {
let currNode = this.head;
let result = [];
while (currNode != null) {
result.push(currNode.element);
currNode = currNode.next;
}
return result;
}
}
上面写的方法方法比较多,实际上很多方法都是重复的,如size()和isEmpty(),链表中元素个数为0,自然就是isEmpty = true。在比如,上面的删除包括两种方式,一种是根据index(在链中第几个位置),另外一种是根据element(元素的具体值),实际上通过indexOf(),即找到某个element在链中的位置,那么对于删除方法,index和element都是没有差别的。
在具体实现链表的时候,有一些注意事项:
- while循环中的条件是
currNode != null,还是currNode.next != null - 条件判断
xx != null的含义,与!==的区别,为什么是null,而不写undefined
其他链表
链表有很多不同种类,最普通的链表是单向的,也就是上面说介绍的那种,只能从头单位单向遍历。
双向链表
在双向链表中,链接是双向的,A节点的next指向B,同时B的previous又是指向A。
在单向链表中,如果迭代时错过了要找的元素,就需要回到起点,重新开始迭代。这是双向链表的一个优势。
双向链表示意图
class DoublyNode extends Node {
constructor(element, next, previous) {
super(element, next);
this.prevous = previous;
}
}
class DoblyList extends Link {
...
}
双向链表的具体实现上,各种方法与单向链表是类似的。注意的是,在绑定A和B的两个节点关系时,要同时说明A的next是B,B的previous是A。在这一步中,单向链表只用说明A的next是B即可,主要是这一点上的区别。
//在A和B节点中插入C节点 A=C=B
const preNode = this.getElementAt(index-1); //前一个节点A
currNode = preNode.next; //现在的节点B(即将要变成C的下一个)
const newNode = DoublyNode(element) //创建新节点C
//单向链表绑定A,顺序
newNode.next = currNode;
preNode.next = newNode;
//反向绑定
currNode.previous = newNode;
newNode.previous = preNode;
循环链表
循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链 表之间唯一的区别在于,最后一个元素指向下一个元素的指针(tail.next)不是引用 undefined,而是指向第一个元素(head),如下图所示:
单向循环链表
双向循环链表
参考资料
- 书籍:数据结构与算法图解
- 书籍:学习JavaScript数据结构与算法第三版,采用es6的class类
- 书籍:数据结构与算法JavaScript描述,2014年出版,当时没有class类,使用构造函数创建类