链表的基本概念
链表是一种非常有趣的动态的数据结构,这意味这我们可以从中任意的添加或移除项,它会按需进行扩容。
在JS中要储存多个元素,数组或者列表可能是最常用的数据结构。但是这些数据结构也是有缺点的,从数组的起点或者中间插入或者删除元素的成本很高,因为需要移动元素(尽管JS内部已经实现了对数组项移动的相关操作,但其背后的情况仍然是这样的)。
链表储存有序的元素集合,但不同于数组,链表中的元素在内存中并不是连续的。每个元素有一个储存本身的节点和一个指向下一个元素的引用(也称指针或链接)组成。如下图:
相对于传统的数组,链表的一个好处在于,添加或移除元素的时候不需要移动其它元素。然而,链表需要使用指针,因此实现链表时需要额外注意。数组的另一个细节是可以直接访问任何位置的元素,而想要访问链表中间的一个元素,需要从起点开始迭代列表直到找到所需要的元素。
拿到现实中最能体现链表的例子,那就是火车。一列火车是由一系列车厢组成的。每节车厢都是互相链接的。可以很容易分离一节车厢,改变它的位置,添加或者移除它。每一节车厢就好比链表中的每一个元素,中间的链接就好比指针。
接下来你将会学到:链表和双向链表。
创建一个链表
接下来会创建一个Link
类,并实现以下功能:
- append(ele): 向链表尾部添加一个新的项
- removeAt(index): 从链表特定位置移除一项
- get(index): 获取指定位置的值
- set(index, value): 设置指定位置的值
- indexOf(ele): 返回元素在链表中的索引,如果没找到返回-1
- remove(ele): 从链表中移除一项
- insert(index, ele): 向链表指定的位置插入一个新的项
- isEmpyt(): 如果链表不包含任何元素返回true否则false
- size(): 返回链表包含元素的个数
- toString(): 由于链表使用了Node类,需要重写JS的toString方法,让其只输出元素的指
- getHead(): 返回链表的头部节点信息
构造函数如下:
1 2 3 4 5
| function Link(){ this.head = null; this.length = 0; return this; }
|
创建Node类的私有方法
为了让Node类与链表本身更加紧密关联,在这里先实现一个可以创建Node类的私有方法,代码如下:
1 2 3 4 5 6
| Link.prototype._node = function (element){ var node = {}; node.element = element; node.next = null; return node; };
|
该方法每次调用都会返回一个新对象,里面包含要保存的值以及指向下一个节点的指针。
向链表尾部追加元素
在追加元素的时候分为两种情况:链表为空,添加的是第一个元素,或者不为空,向其追加。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Link.prototype.append = function (ele){ var node = this._node(ele); var current = null; if(this.head === null){ this.head = node; }else{ current = this.head; while(current.next){ current = current.next; } current.next = node; } this.length++; return this; };
|
让我们来分析以下上面的代码:先是把要添加的元素作为参数传入,创建Node的实例。
场景一: 向空的链表添加一个元素。当我们创建Link
对象时,this.head
会指向null
,这就意味着像链表中添加第一个元素。因此要做的事情就是让head元素指向当前这个Node对象。同时下一个this.next
会自动指向null
。
链表中最后一个节点的下一个始终指向的是null
。
场景二:链表不为空。要向链表尾部添加元素,始终要记住一点就是首先需要找到最后一个元素,我们始终只有第一个元素的引用,因此循环访问链表,直到找到最后一项。为此,我们创建current
这个中间量。当current.next === null
的时候循环就会终止,这样就可以知道已经到达尾部了。之后要做的便是让最后一个元素的this.next
指向要添加的元素。下图展示了这个行为:
1 2 3 4 5
| var linked = new Link(); linked.append(1).append(2).append(3); console.log(linked);
|
最后别忘了更新链表的长度。
从链表中移除元素
移除元素也有两种场景:第一种是移除第一个元素,第二种是移除第一个以外的任意元素。
要实现的移除方法:
- 根据位置移除 : removeAt(index)
- 根据元素的值移除 : remove(element)
这一部分先来实现根据位置移除元素的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| Link.prototype.removeAt = function (index){ if(index < 0 || index > this.length -1){ return; } var current = this.head, idx = 0, previous = null; if(index === 0){ // 如果传入的是0那么就是移除第一个 // 简单的将head的指针修改即可 this.head = current.next; }else{ // 如果不是0,那么想找到对应位置的元素,就要从头开始循环 // 原理就是找到对应的元素后,将对应元素的上一个元素的next指向当前元素的下一个元素 while(idx++ < index){ previous = current; current = current.next; } // 这样循环结束之后 previous 储存的就是目标元素的上一个,而current储存的就是目标元素 // 之后将目标元素的上一个和目标元素的下一个相连 previous.next = current.next; } // 更新长度; this.length--; // 返回删除元素的值 return current.element; };
|
让我们来进一步分析上面的代码:该方法首先来验证要移除元素的位置是否有效,如果无效九返回null
,第一种场景如果是移除链表的第一个元素,就是让this.head
指向链表的第二个元素,于是巧妙的利用了current
这个中间变量。下图展示了移除第一个元素的过程,请好好理解:
第二种情况就是移除除了第一个元素意外的任意一个元素,先来看如果移除的是最后一个元素的情况,如图:
从图上可以清楚的看到,当移除的是最后一个元素的时候,在找到最后一个元素的上一个元素之后,将上一个的this.next
引用到当前元素的下一个也就是this.next = null;
。
再来看看,对于链表中其它的元素是否可以遵循同样的逻辑,如图:
可以看出来除了第一项其它的元素都遵循上面的逻辑。这里还值得说的是,当元素不再被任何变量引用,那么它占用的内存就会被垃圾回收机制释放掉。
获取指定位置的值
接下来我们要来实现链表的get()
方法,用来方便的通过索引来获取对应的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Link.prototype.get = function (index){ if(index < 0 || index > this.length - 1){ return null; } var current = null, idx = 0; if(index === 0){ return this.head.element; } current = this.head; while(idx++ < index){ current = current.next; } return current.element; };
|
设置指定位置的值
下面来实现可以设置指定位置数据的set()
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| Link.prototype.set = function (index, value){ if(index < 0 || index > this.length - 1){ return null; } var current = null, idx = 0; if(index === 0){ this.head.element = value; }else{ current = this.head; while(idx++ < index){ current = current.next; } current.element = value; } return this; };
|
测试代码:
1 2 3 4 5
| var nodeList = new Link(); nodeList.append(1).append(2).append(3); nodeList.set(0, 10); console.log(nodeList);
|
结果如图:
在任意位置插入一个元素
insert()
方法可以实现在任意地方插入新元素。其原理就是找到要插入的位置的前后元素,然后将它们链接起来。
1 2 大专栏 链表3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| Link.prototype.insert = function (index, element){ if(index < 0 || index >= this.length + 1){ throw new Error('fuck!'); } var node = this._node(element), current = this.head, previous = null, idx = 0; if(index === 0){ this.head = node; this.head.next = current; }else{ while(idx++ < index){ previous = current; current = current.next; } previous.next = node; node.next = current; } this.length++; return this; };
|
有了前面的基础,我相信再看上面的代码应该会很容易了。同样分为两种场景,第一种是像最前面添加,第二种是向指定位置添加。来看图:
向最前面添加,只需要改变this.head
的指向即可:
向任意位置添加,同样需要先找到对应位置的元素和这个元素的上一个元素,然后将新元素和它们链接起来即可:
实现其他方法
接下来将实现:toString()
indexOf()
isEmpty()
size()
等Link类的方法。
toString()
方法会把Link对象转换成一个字符串
1 2 3 4 5 6 7 8 9
| Link.prototype.toString = function (){ var current = this.head, string = ''; while(current){ string += ',' + current.element; current = current.next; } return string.slice(1); };
|
indexOf()
方法接收一个元素的值,如果在链表中找到它就返回它的位置,否则就返回-1
1 2 3 4 5 6 7 8 9 10 11 12
| Link.prototype.indexOf = function (element){ var current = this.head, index = -1; while(current){ index++; if(element === current.element){ return index; } current = current.next; } return -1; };
|
有了这个方法就可以很容易的实现上面还没完成的remove()
方法了:
1 2 3 4
| Link.prototype.remove = function (element){ var index = this.indexOf(element); return this.removeAt(index); };
|
isEment()
size()
getHead()
三个方法相对比较简单,这里一次性写完
1 2 3 4 5 6 7 8 9 10 11
| Link.prototype.isEmpty = function (){ return this.length === 0; }; Link.prototype.size = function (){ return this.length; }; Link.prototype.getHead = function (){ return this.head; };
|
双向链表
双向链表和普通链表的区别在于,一个元素不仅仅只有链向下一个节点的链接,而在双向链表中,链接是双向的,一个链向下一个元素,另一个链向上一个元素,如下图所示:
只需要在单项链表构造函数的基础上稍加改动即可实现:
1 2 3 4 5 6
| function DoubleLink(){ this.head = null; this.tail = null; // 用来存放最后一个节点 this.length = 0; return this; }
|
双向链表提供了两种迭代的方法:从头到尾或者反过来。同时还可以访问任意一个元素的上一个和下一个兄弟元素。这是双向链表的一个优点。
双向链表的Node类
按照需求,需要对Node类进行修改,添加一个指向上一个节点的指针,如下:
1 2 3 4 5 6
| DoubleLink.prototype._node = function (element){ this.element = element; this.prev = null; this.next = null; return this; };
|
在任意位置插入一个新元素
在双向链表中插入一个新元素跟单向链表非常类似。但是双向链表需要同时控制this.next
和this.prev
两个指针。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| DoubleLink.prototype.insert = function (index, element){ if(index < 0 || index > this.length){ throw new Error('fuck!'); } var count = 0, current = this.head, previous = null, node = this._node(element); if(index === 0){ if(this.head){ node.next = current; current.prev = node; this.head = node; }else{ this.head = this.tail = node; } }else if(index === this.length){ current = this.tail; current.next = node; node.prev = current; this.tail = node; }else{ while(count++ < index){ previous = current; current = current.next; } previous.next = node; node.prev = previous; node.next = current; current.prev = node; } this.length++; return this; };
|
通过示意图来分析上面的代码
第一种场景,向最前面添加元素,分为第一次添加和已经有第一个元素的两种情况。
第二种场景,向链表最后添加新元素。
第三种场景,向链表中间的位置添加新元素。
从任意位置移除元素
从双向链表中移除元素跟单向链表非常类似。唯一区别就是需要设置前一个位置的指针。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| DoubleLink.prototype.removeAt = function (index){ if(index < 0 || index > this.length - 1){ return; } var current = this.head, previous = null, count = 0; if(index === 0){ this.head = current.next; if(this.length === 1){ this.tail = null; }else{ this.head.prev = null; } }else if(index === this.length - 1){ current = this.tail; this.tail = current.prev; this.tail.next = null; }else{ while(count++ < index){ previous = current; current = current.next; } previous.next = current.next; current.next.prev = previous; } this.length--; return current.element; }
|
移除元素同样分为三种场景:从头部,从中间,从尾部;依然使用示意图来说明。
移除第一个元素的过程:
移除最后一个元素的过程:
移除中间元素的过程:
其它方法和单项链表非常类似,这里就不一一去实现了。
循环链表
学了上面的链表相关的知识,再来看循环链表会变得相当简单。循环链表和链表之间唯一的区别在于,最后一个元素指向下一个元素的指针不是null
,而是指向第一个元素this.head
,如下图所示:
单项循环链表示意图:
双向链表循环示意图:
这里暂且不对循环列表再进行coding,我相信如果上面的你学会了,那么很容易就能实现循环链表。
小结
链表的优点在于无需移动所有元素就可以方便的删除和添加元素。但是就JavaScript开发本身而言,我个人并不会去刻意的使用,或许我还没意识到这些数据结构的重要性吧。
路还长着,且学且珍惜!