JavaScript 数据结构系列目录
文章目录
一、链表的概述
链表是一种存储有序的元素集合,不同于数组,链表中的元素在内存中并不是连续放置的。
每个元素由一个存储元素本身的节点和指向下一个元素的引用(也称指针或链接)组成。
链表是线性表的一种,所谓的线性表包含顺序线性表和链表,顺序线性表是用数组实现的,在内存中有顺序排列,通过改变数组大小实现。而链表不是用顺序实现的,其使用指针实现,在内存中不连续。意思就是说,链表将一系列不连续的内存联系起来,将那种碎片内存进行合理的利用,解决空间问题。
示例图:
相较于传统的数组,链表的一个好处在于,添加/删除一个元素的时候不需要移动其他元素,只需要修改其指针。
因此实现链表的时候也要额外的注意,在数组中,我们可以直接访问任何位置的元素,而在链表里,我们则需要从起点开始迭代链表里的所有元素直到找到匹配的元素。
二、创建链表
理解了什么是链表后,就可以开始实现我们的数据结构了。
// 辅助类
class Node {
constructor(element) {
this.element = element;
this.next = null;
}
}
// 链表类
class LinkedList {
constructor() {
this.length = 0;
this.head = null;
}
// 向链表中添加节点
push (element) {}
// 在链表的指定位置插入节点
insert (position, element) {}
// 删除链表中指定位置的元素,并返回这个元素的值
removeAt (position) {}
// 删除链表中对应的元素
remove (element) {}
// 在链表中查找给定元素的索引
indexOf (element) {}
// 返回链表中索引所对应的元素
getElementAt (position) {}
// 判断链表是否为空
isEmpty () {}
// 返回链表的长度
size () {}
// 返回链表的头元素
getHead () {}
// 辅助方法,按指定格式输出链表中的所有元素,方便测试验证结果
toString () {}
}
1、push 方法
下面我们先来看一下第一个方法 push 的实现代码 。
push (element) {
let node = new Node(element);
if ( this.head == null ) this.head = node;
else {
let current = this.getElementAt(this.length - 1);
current.next = node;
}
this.length++;
}
身为一个 添加方法,那么首先必然的就是需要将添加的元素传递给他,所以我们传入一个 element ,并且实例化一个 Node 项。
其次,我们再来仔细想想,一般而言添加都是往某样东西的最尾部添加。可万一这样东西他内部为空呢?
所以这个时候添加就分两种情况,被添加的东西内部为空与不为空。
为空的情况下,那么我们就直接将 head 指向新添加的元素即可。
不为空的情况下,我们就通过 getElementAt 方法找到最后一个元素,并将该元素的 next 属性指向新添加的元素即可。
(不过值得注意的是,添加了一个元素后不要忘记记录链表的大小/长度)。
2、removeAt 方法
现在,让我们看看如何从链表类里移除对象。
这里我们需要实现两种方法。
第一种 remove : 该方法是从特定的位置移除一个元素。
第二种 removeAt : 该方法根据元素的值移除元素。
同 push 方法 一样,对于从链表中移除元素也存在两种场景。
第一种移除第一位元素;第二种移除第一个元素之外的其他元素。
那么我们先来看看 removeAt 方法实现的代码吧!
removeAt (position) {
if ( position < 0 || position >= this.length ) return null;
let current = this.head;
if ( position == 0 ) this.head = current.next;
else {
let previous = this.getElementAt(position - 1);
current = previous.next;
previous.next = current.next;
}
this.length--;
return current.element;
}
让我们一行一行的来解析一下这个代码。
由于我们这个方法需要得到移除元素的 position (位置),并且验证该 position 是有效的。
如若不是有效的位置,就无法移除元素。
因此,如果想移除第一个元素,我们要做的就是让 head 指向列表的第二个元素。
这里我们创建一个变量 current 来保存链表中第一个元素的引用。
然后再进行判断,判断是否移除第一个元素。
如果是移除第一个元素,那我们只需要将 head 的指针指向 其下一个元素。
如若不是,那我们则需要得到该元素的前一位,previous 。
并且将我们想要移除的元素赋值给 current,然后在将当前想要移除的元素的下一个节点与 previous 的下一个节点链接起来。
最后我们还需要记录一下链表的大小/长度。
remove 方法
remove (element) {
const index = this.indexOf(element);
return this.removeAt(index);
}
这里我们通过 自定的 indexOf (该自定方法效果同数组的 indexOf 方法一致) 方法获取到想要删除的元素的位置,再传入进 removeAt 方法里。
3、getElementAt 方法
前面两个方法我们都是用到了 getElementAt 方法。
那么我们这一块就来讲解一下这个方法的用处。
getElementAt (position) {
if ( position < 0 || position >= this.length ) return null;
let node = this.head;
for ( let i = 0; i < position && node != null; i++ ) node = node.next;
return node;
}
首先是第一个判断语句。
这里的判断语句同 removeAt 方法的作用一致,皆用于判断当前位置是否有效。
而当其位置有效时,我们先获取 head 元素。
然后再依次遍历,直到找到匹对的元素后,再返回。
4、insert 方法
接下来,我们需要实现 insert 方法。
该方法的作用是,可以在链表里的任何位置插入一个元素。下面是它的实现代码。
insert (position, element) {
if ( position < 0 || position > this.length ) return false;
let node = new Node(element);
if ( position === 0 ) {
node.next = this.head;
this.head = node;
} else {
let previous = this.getElementAt(position - 1);
node.next = previous.next;
previous.next = node;
}
this.length++;
return true;
}
这里让我们一行一行的解析一下。
第一行同上面两个方法的的效果一致,判定想要插入的位置是否为有效位置。
如果是有效位置,那么我们实例化一个 Node 项。
其次我们再去判定两种情况,一种是在链表的头部插入元素,与在链表的其他位置插入元素。
判定玩后我们首先来看看第一种情况:
我们只需要将实例化的 Node 项的 next 属性指向现在的 head。
然后再将现在的 head 指向 实例化的 Node 项即可。
其次再来看看第二种情况:
我们同样的先去获取想要插入的位置的前一个元素,再将 实例化的 Node 项的 next 属性 指向 前一个元素的 next 属性。
再把 前一个元素的 next 属性指向 当前 Node 项即可。
最后记录一下链表的大小。
5、indexOf 方法
在 remove 方法中我们曾看到过这个方法的使用。
当时我的描述是这样的,与数组的 indexOf 效果一致。
那么我们就来看看实现的代码吧。
indexOf (element) {
let current = this.head;
for ( let i = 0; i < this.length; i++ ) {
if ( current.element === element ) return i;
current = current.next;
}
return - 1;
}
这里我们首先用 current 变量获取 head。
再根据链表的长度来循环迭代。
如果匹配到了我想要的值,那么我们就返回当前值的 下标。
如果没有,那么我就改变 current 的指向,指向到其下一个元素。
直到末尾都没有匹配到则返回 -1。
6、isEmpty 、size 与 getHead 方法
isEmpty () { return this.size() === 0 }
size () { return this.length; }
getHead () { return this.head; }
7、toString 方法
toString 方法同数组的 toString 方法效果一致,将对象转化为字符串输出。
其实现代码如下
toString () {
if ( this.head == null ) return "";
let objString = `${this.head.element}`,
current = this.head.next;
for ( let i = 1; i < this.size(); i++ ) {
objString = `${objString},${current.element}`;
current = current.next;
}
return objString;
}
三、双向链表
上面链表中每一个元素只有一个next 指针,用来指向下一个节点,这样的链表称之为单向链表,我们只能从链表的头部开始遍历整个链表,任何一个节点只能找到它的下一个节点,而不能找到它的上一个节点。
双向链表与单向链表的区别就在于,在单向链表中,一个节点只能找到下一个节点,不能找到上一个节点。而双向链表中,链接是双向的,其既可以链接下一个元素,又可以链接上一个元素。
示例图
既然理解了双向链表的概念后,那么我们就开始动手创建双向列表的的类吧。
先从辅助类开始。
class DoublyNode extends Node {
constructor(element) {
super(element);
this.prev = null;
this.next = null;
}
}
下面是继承与 LinkedList 类的双向链表的的类。
class DoublyLinkedList extends LinkedList {
constructor() {
super();
this.tail = null;
}
}
与单向链表不同的是,双向链表不仅要维护 head 与 next,还要维护 tail 与 prev。
所以我们需要将 insert 与 removeAt 方法重写一遍。
首先来重写一遍 insert 方法吧
insert (position, element) {
if ( position < 0 || position > this.length ) return false;
let node = new DoublyNode(element);
if ( position === this.length ) this.push(element);
else if ( position === 0 ) {
if (this.head === null) this.push(element);
else {
node.next = this.head;
this.head.prev = node;
this.head = node;
}
this.length++;
} else {
let current = this.getElementAt(position),
previous = current.prev;
node.next = current;
node.prev = previous;
previous.next = node;
current.prev = node;
this.length++;
}
return true;
}
这里的第一行与声明的 DoublyNode 同原来并无区别,所以不过多介绍。
因为我们 insert 方法是可以在任意位置插入一个元素的。
所以它会出现三种情况:头部插入,中间任意位置插入,尾部插入。
首先来看看尾部插入吧。
既然是尾部插入,那么我们只需要调用 push 方法即可。因为 push(优化后的) 方法的作用就是往链表的最底部插入一个元素。
然后再来看看头部插入吧。
头部插入又分两种小情况,一种是头部为空,即链表为空。一种是头部不为空。
如果是第一种情况,那么我们也只需要调用 push(优化后的) 方法即可。
如果是第二种情况,那么我们则需要更改 插入元素的 next 指向与 head 的的 prev 指向。
更改完后,我们再将 head 指向到 插入的元素即可。
最后再来看看中间任意位置插入吧。
我们先获取当前想要插入位置的元素 current 。
然后再获取 current 的上一位元素 previous 。
获取到后,我们将 node 的 next 指向到 current, node 的 prev 指向到 previous。
再将 previous 的 next 指向到 node, current 的 prev 指向到 node 即可。
最后莫要忘了记录链表大小。
接下来是 removeAt 方法的重写
removeAt (position) {
if ( position < 0 || position >= this.length ) return null;
let current = this.head;
if ( position === 0 ) {
this.head = current.next;
this.head.prev = null;
if ( this.length === 1 ) this.tail = null;
} else if (position === this.length - 1) {
current = this.tail;
this.tail = current.prev;
this.tail.next = null;
} else {
current = this.getElementAt(position);
let previous = current.prev;
previous.next = current.next;
current.next.prev = previous;
}
this.length--;
return current.element;
}
这里的前两行也不过多描述。
直接从第三行这几个判断语句开始吧。
首先第一个是移除我们的 第一位元素。
移除我们的第一位元素,我们只需要将 head 的指向更改为 原本 head 的 next 指向,并将 head 的 prev 指向 赋空。
在这期间也有另一种情况,那就是如果链表里只有这一个元素的话,那么我们也将 tail 赋空。
第二个是移除我们的末尾元素。
移除我们的末尾元素的话,我们只需要将 current 指向到 tail,然后再将 tail 指向到 current 的 prev。
指向完后我们再把 tail 的 next 赋空。
第三个就是移除中间任意一位的元素。
这里同移除末尾元素的区别也不是很大。
先把 current 指向到想要移除的位置的元素,再通过 current 来更改指向。
这里就不过多叙述了。
之后就是一些代码性能优化。
push (element) {
let node = new DoublyNode(element);
if ( this.head === null ) {
this.head = node;
this.tail = node;
} else {
this.tail.next = node;
node.prev = this.tail;
this.tail = node;
}
this.length++;
}
getElementAt (position) {
if ( position < 0 || position >= this.length ) return null;
if ( position < Math.floor(this.length / 2) ) return super.getElementAt(position);
else {
let current = this.tail;
for (let i = this.length - 1; i > position; i--) current = current.prev;
return current;
}
}
在 getElementAt 方法里我们首先判定当前位置在整个链表中是属于考前的位置还是靠后的位置。
如若靠前的位置,那么我们就使用原本的 getElementAt 方法即可。
如若靠后的位置,那么我们就从最后一位迭代往上一个元素查询,直到匹配到想要的元素。
这样这个方法就优化完毕。
四、循环链表
循环链表可以像链表一样只有单引用,也可以像双链表一样有双向引用。
但它和这两个唯一的区别就是,其最后一个元素 next 属性指向 元素不是 null 而是第一个元素。
五、有序链表
有序链表与其他链表不同的是,其本身保持元素有序。
这里有序链表与循环链表就不过多介绍了。
End
相关文章
暂无