从不浪费时间的人,没有工夫抱怨时间不够。 —— 杰弗逊node
线性表是由 n 个数据元素组成的有限序列,也是最基本、最简单、最经常使用的一种数据结构。git
做者简介:五月君,Nodejs Developer,热爱技术、喜欢分享的 90 后青年,公众号「Nodejs技术栈」,Github 开源项目 www.nodejs.redgithub
本篇文章历时一周多,差很少花费了两个周末的时间,在书写的过程当中更多的仍是在思考每一种线性表的算法实现,链表的指针域部分对于不理解指针或者对象引用的童鞋,在阅读代码的时候可能会蒙蒙的,本篇文章代码部分采用的 JavaScript 编程语言,可是实现思想是相通的,若是你用 Java、Python 等也可作参考,如文章有理解错误之处欢迎在下方评论区指正。算法
根据线性表的定义,可得出几个关键词:n 个数据元素、有限序列,也就是说它是有长度限制的且元素之间是有序的,在多个元素之间,第一个元素无前驱,最后一个元素无后继,中间元素有且只有一个前驱和后继。编程
举一个与你们都息息相关的十二生肖例子,以“子(鼠)” 开头,“亥(猪)”结尾,其中间的每一个生肖也都有其前驱和后继,图例以下所示:数组
下面再介绍一个复杂的线性表,其一个元素由多个数据项构成,例如,咱们的班级名单,含学生的学号、姓名、年龄、性别等信息,图例以下所示:bash
线性表两种存储结构数据结构
线性表有两种存储结构,一种为顺序结构存储,称为顺序表;另外一种为链式形式存储,称为链表,链表根据指针域的不一样,链表分为单向链表、双向链表、循环链表等。详细的内容会在后面展开讲解。编程语言
顺序表是在计算机内存中以数组的形式保存的线性表,是指用一组地址连续的存储单元依次存储数据元素的线性结构。函数
在线性表里顺序表相对更容易些,所以也先从顺序表讲起,经过实现编码的方式带着你们从零开始实现一个顺序表,网上不少教程大多都是以 C 语言为例子,其实现思想都是相通的,这里采用 JavaScript 编码实现。
实现步骤
初始化顺序表空间
在构造函数的 constructor 里进行声明,传入 capacity 初始化顺序表空间同时初始化顺序表的元素长度(length)为 0。
/** * * @param { Number } capacity 栈空间容量 */
constructor(capacity) {
if (!capacity) {
throw new Error('The capacity field is required!');
}
this.capacity = capacity;
this.list = new Array(capacity);
this.length = 0; // 初始化顺序表元素长度
}
复制代码
顺序表是否为空检查
定义 isEmpty() 方法返回顺序表是否为空,根据 length 顺序表元素进行判断。
isEmpty() {
return this.length === 0 ? true : false;
}
复制代码
顺序表是否溢出检查
定义 isOverflow() 方法返回顺序表空间是否溢出,根据顺序表元素长度和初始化的空间容量进行判断。
isOverflow() {
return this.length === this.capacity;
}
复制代码
查找指定位置元素
返回顺序表中第 i 个数据元素的值
getElement(i) {
if (i < 0 || i > this.length) {
return false;
}
return this.list[i];
}
复制代码
查找元素的第一个位置索引
返回顺序表中第 1 个与 e 知足关系的元素,存在则返回其索引值;不存在,则返回值为 -1
locateElement(e) {
for (let i=0; i<this.length; i++) {
if (this.list[i] === e) {
return i;
}
}
return -1;
}
复制代码
在顺序表中返回指定元素的前驱
这里就用到了上面定义的 locateElement 函数,先找到元素对应的索引位置,若是前驱就取前一个位置,后继就取后一个位置,在这以前先校验当前元素的索引位置是否存在合法。
priorElement(e) {
const i = this.locateElement(e);
if (i === -1) {
return false;
}
if (i === 0) { // 没有前驱
return false;
}
return this.list[i - 1]; // 返回前驱(即前一个元素)
}
复制代码
在顺序表中返回指定元素的后继
nextElement(e) {
const i = this.locateElement(e);
if (i === -1) {
return false;
}
if (i === this.length - 1) { // 为最后一个元素,没有后继
return false;
}
return this.list[i + 1]; // 返回后继(即后 一个元素)
}
复制代码
插入元素
在顺序表中第 i 个位置以前插入新的数据元素 e,在插入以前先进行元素位置后移,插入以后顺序表元素的长度要加 1。
举个例子,咱们去火车站取票,恰逢人多你们都在排队,忽然来一个美女或者帅哥对你说个人车次立刻要开车了,你可能赞成了,此时你的位置及你后面的童鞋就要后移一位了,也许你会听到一些声音,怎么回事呀?怎么插队了呀,其实后面的人有的也不清楚什么缘由 “233”,看一个图
算法实现以下:
listInsert(i, e) {
if (i < 0 || i > this.length) {
return false; // 不合法的 i 值
}
for (let k=this.length; k>=i; k--) { // 元素位置后移 1 位
this.list[k + 1] = this.list[k];
}
this.list[i] = e;
this.length++;
return true;
}
复制代码
删除元素
删除顺序表的第 i 个数据元素,并返回其值,与插入相反,须要将删除位置以后的元素进行前移,最后将顺序表元素长度减 1。
一样以火车站取票的例子说明,若是你们都正在排队取票,忽然你前面一个妹子有急事临时走了,那么你及你后面的童鞋就要前进一步,图例以下所示:
算法实现以下:
listDelete(i) {
if (i < 0 || i >= this.length) {
return false; // 不合法的 i 值
}
const e = this.list[i];
for (let j=i+1; j<this.length; j++) { // 元素位置前移 1 位
this.list[j - 1] = this.list[j];
}
this.length--;
return e;
}
复制代码
清除顺序表元素
这里有几种实现,你也能够把顺序表的空间进行初始化,或者把 length 栈位置设为 0 也可。
clear() {
this.length = 0;
}
复制代码
顺序表销毁
在一些高级语言中都会有垃圾回收机制,例如 JS 中只要当前对象再也不持有引用,下次垃圾回收来临时将会被回收。不清楚的能够看看我以前写的 Node.js 内存管理和 V8 垃圾回收机制
destroy() {
this.list = null;
}
复制代码
顺序表元素遍历
定义 traversing() 方法对顺序表的元素进行遍历输出。
traversing(isBottom = false){
const arr = [];
for (let i=0; i < this.length; i++) {
arr.push(this.list[i])
}
console.log(arr.join('|'));
}
复制代码
作一些测试
作下测试分别看下插入、删除、遍历等操做,其它的功能你们在练习的过程当中可自行实践。
const [e1, e2, e3, e4, e5] = [3, 6, 1, 8, 7];
const list = new SequenceTable(10);
list.listInsert(0, e1);
list.listInsert(1, e2);
list.listInsert(2, e3);
list.listInsert(3, e4);
list.listInsert(1, e5);
list.traversing(); // 3|7|6|1|8
console.log(list.priorElement(3) ? '有前驱' : '无前驱'); // 无前驱
console.log(list.priorElement(6) ? '有前驱' : '无前驱'); // 有前驱
console.log(list.nextElement(3) ? '有后继' : '无后继'); // 有后继
console.log(list.nextElement(8) ? '有后继' : '无后继'); // 无后继
list.listDelete(0); // 3
list.traversing(); // 7|6|1|8
复制代码
顺序表的运行机制源码地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/sequence-table.js
复制代码
顺序表优缺点总结
插入、删除元素若是是在最后一个位置时间复杂度为 O(1),若是是在第一个(或其它非最后一个)位置,此时时间复杂度为 O(1),就要移动全部的元素向后或向前,时间复杂度为 O(n),当顺序表的长度越大,插入和删除操做可能就须要大量的移动操做。
对于存取操做,能够快速存取顺序表中任意位置元素,时间复杂度为 O(1)。
链表(Linked list)是一种常见的基础数据结构,是一种线性表,可是并不会按线性的顺序存储数据,而是在每个节点里存到下一个节点的指针(Pointer)。因为没必要须按顺序存储,链表在插入的时候能够达到O(1)的复杂度,比另外一种线性表顺序表快得多,可是链表查找一个节点或者访问特定编号的节点则须要O(n)的时间,而顺序表相应的时间复杂度分别是O(logn)和O(1)。
使用链表结构能够克服数组链表须要预先知道数据大小的缺点,链表结构能够充分利用计算机内存空间,实现灵活的内存动态管理。可是链表失去了数组随机读取的优势,同时链表因为增长告终点的指针域,空间开销比较大。
链表中最简单的一种是单向链表,它包含两个域,一个信息域和一个指针域。这个连接指向列表中的下一个节点,而最后一个节点则指向一个空值,图例以下:
除了单向链表以外还有双向链表、循环链表,在学习这些以前先从单向链表开始,所以,这里会完整讲解单向链表的实现,其它的几种后续都会在这个基础之上进行改造。
单向链表实现步骤
初始化链表
在构造函数的 constructor 里进行声明,无需传入参数,分别对如下几个属性和方法作了声明:
当咱们实例化一个 SingleList 对象时 head 指向为 null 及 length 默认等于 0,代码示例以下:
class SingleList {
constructor() {
this.node = function(element) {
return {
element,
next: null,
}
};
this.length = 0;
this.head = null;
}
}
复制代码
链表是否为空检查
定义 isEmpty() 方法返回链表是否为空,根据链表的 length 进行判断。
isEmpty() {
return this.length === 0 ? true : false;
}
复制代码
返回链表长度
一样使用链表的 length 便可
length() {
return this.length;
}
复制代码
链表尾部插入元素
链表 SingleList 尾部增长元素,须要考虑两种状况:一种是链表(head)为空,直接赋值添加第一个元素,另外一种状况就是链表不为空,找到链表最后一个节点在其尾部增长新的节点(node)便可。
第一种状况,假设咱们插入一个元素 1,此时因为链表为空,就会走到(行 {2})代码处,示意图以下:
第二种状况,假设咱们再插入一个元素 2,此时链表头部 head 指向不为空,走到(行 {3})代码处,经过 while 循环直到找到最后一个节点,也就是当 current.next = null 时说明已经达到链表尾部了,接下来咱们要作的就是将 current.next 指向想要添加到链表的节点,示意图以下:
算法实现以下:
insertTail(e) {
let node = this.node(e); // {1}
let current;
if (this.head === null) { // 列表中尚未元素 {2}
this.head = node;
} else { // {3}
current = this.head;
while (current.next) { // 下个节点存在
current = current.next;
}
current.next = node;
}
this.length++;
}
复制代码
链表指定位置插入元素
实现链表的 insert 方法,在任意位置插入数据,一样分为两种状况,如下一一进行介绍。
若是是链表的第一个位置,很简单看代码块(行 {1})处,将 node.next 设置为 current(链表中的第一个元素),此时的 node 就是咱们想要的值,接下来将 node 的引用改成 head(node、head 这两个变量此时在堆内存中的地址是相同的),示意图以下所示:
若是要插入的元素不是链表第一个位置,经过 for 循环,从链表的第一个位置开始循环,定位到要插入的目标位置,for 循环中的变量 previous(行 {3})是对想要插入新元素位置以前的一个对象引用,current(行 {4})是对想要插入新元素位置以后的一个对象引用,清楚这个关系以后开始连接,咱们本次要插入的节点 node.next 与 current(行 {5})进行连接,以后 previous.next 指向 node(行 {6})。
算法实现以下:
/** * 在任意位置插入元素 * @param { Number } i 插入的元素位置 * @param { * } e 插入的元素 */
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) { // {1}
node.next = current;
this.head = node;
} else { // {2}
for (let k=0; k<i; k++) {
previous = current; // {3}
current = current.next; // 保存当前节点的下一个节点 {4}
}
node.next = current; // {5}
previous.next = node; // 注意,这块涉及到对象的引用关系 {6}
}
this.length++;
return true;
}
复制代码
移除指定位置的元素
定义 delete(i) 方法实现移除任意位置的元素,一样也有两种状况,第一种就是移除第一个元素(行 {1})处,第二种就是移除第一个元素之外的任一元素,经过 for 循环,从链表的第一个位置开始循环,定位到要删除的目标位置,for 循环中的变量 previous(行 {2})是对想要删除元素位置以前的一个对象引用,current(行 {3})是对想要删除元素位置以后的一个对象引用,要从列表中移除元素,须要作的就是将 previous.next 与 current.next 进行连接,那么当前元素会被丢弃于计算机内存中,等待垃圾回收器回收处理。
关于内存管理和垃圾回收机制的知识可参考文章 Node.js 内存管理和 V8 垃圾回收机制
经过一张图,来看下删除一个元素的过程:
算法实现以下:
delete(i) {
// 要删除的元素位置不能超过链表的最后一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) { // {1}
this.head = current.next;
} else {
for (let k=0; k<i; k++) {
previous = current; // {2}
current = current.next; // {3}
}
previous.next = current.next;
}
this.length--;
return current.element;
}
复制代码
获取指定位置元素
定义 getElement(i) 方法获取指定位置元素,相似于 delete 方法可作参考,在锁定位置目标后,返回当前的元素便可 previous.element。
getElement(i) {
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
for (let k=0; k<=i; k++) {
previous = current
current = current.next;
}
return previous.element;
}
复制代码
查找元素的第一个位置索引
返回链表中第 1 个与 e 知足关系的元素,存在则返回其索引值;不存在,则返回值为 -1
locateElement(e) {
let current = this.head;
let index = 0;
while (current.next) { // 下个节点存在
if (index === 0) {
if (current.element === e) {
return index;
}
}
current = current.next;
index++;
if (current.element === e) {
return index;
}
}
return -1;
}
复制代码
在链表中返回指定元素的前驱
若是是第一个元素,是没有前驱的直接返回 false,不然的话,须要遍历链表,定位到目标元素返回其前驱即当前元素的上一个元素,若是在链表中没有找到,则返回 false。
priorElement(e) {
let current = this.head;
let previous;
if (current.element === e) { // 第 0 个节点
return false; // 没有前驱
} else {
while (current.next) { // 下个节点存在
previous = current;
current = current.next;
if (current.element === e) {
return previous.element;
}
}
}
return false;
}
复制代码
在链表中返回指定元素的后继
nextElement(e) {
let current = this.head;
while (current.next) { // 下个节点存在
if (current.element === e) {
return current.next.element;
}
current = current.next;
}
return false;
}
复制代码
链表元素遍历
定义 traversing() 方法对链表的元素进行遍历输出,主要是将 elment 转为字符串拼接输出。
traversing(){
//console.log(JSON.stringify(this.head));
let current = this.head,
string = '';
while (current) {
string += current.element + ' ';
current = current.next;
}
console.log(string);
return string;
}
复制代码
单向链表与顺序表优缺点比较
单向链表源码地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/single-list.js
复制代码
双向链表也叫双链表。与单向链表的区别是双向链表中不只有指向后一个节点的指针,还有指向前一个节点的指针。这样能够从任何一个节点访问前一个节点,固然也能够访问后一个节点,以致整个链表。
双向链表是基于单向链表的扩展,不少操做与单向链表仍是相同的,在构造函数中咱们要增长 prev 指向前一个元素的指针和 tail 用来保存最后一个元素的引用,能够从尾到头反向查找,重点修改插入、删除方法。
修改初始化链表
constructor() {
this.node = function(element) {
return {
element,
next: null,
prev: null, // 新增
}
};
this.length = 0;
this.head = null;
this.tail = null; // 新增
}
复制代码
修改链表指定位置插入元素
在双向链表中咱们须要控制 prev 和 next 两个指针,比单向链表要复杂些,这里可能会出现三种状况:
状况一:链表头部添加
若是是在链表的第一个位置插入元素,当 head 头部指针为 null 时,将 head 和 tail 都指向 node 节点便可,若是 head 头部节点不为空,将 node.next 的下一个元素为 current,那么一样 current 的上个元素就为 node(current.prev = node),node 就为第一个元素且 prev(node.prev = null)为空,最后咱们将 head 指向 node。
假设咱们当前链表仅有一个元素 b,咱们要在第一个位置插入元素 a,图例以下:
状况二:链表尾部添加
这又是一种特殊的状况链表尾部添加,这时候咱们要改变 current 的指向为 tail(引用最后一个元素),开始连接把 current 的 next 指向咱们要添加的节点 node,一样 node 的上个节点 prev 就为 current,最后咱们将 tail 指向 node。
继续上面的例子,咱们在链表尾部在增长一个元素 d
状况三:非链表头部、尾部的任意位置添加
这个和单向链表插入那块是同样的思路,不清楚的,在回头去看下,只不过增长了节点的向前一个元素的引用,current.prev 指向 node,node.prev 指向 previous。
继续上面的例子,在元素 d 的位置插入元素 c,那么 d 就会变成 c 的下一个元素,图例以下:
算法实现以下:
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) { // 有修改
if (current) {
node.next = current;
current.prev = node;
this.head = node;
} else {
this.head = this.tail = node;
}
} else if (i === this.length) { // 新增长
current = this.tail;
current.next = node;
node.prev = current;
this.tail = node;
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next; // 保存当前节点的下一个节点
}
node.next = current;
previous.next = node; // 注意,这块涉及到对象的引用关系
current.prev = node; // 新增长
node.prev = previous; // 新增长
}
this.length++;
return true;
}
复制代码
移除链表元素
双向链表中移除元素同插入同样,须要考虑三种状况,下面分别看下各自实现:
状况一:链表头部移除
current 是链表中第一个元素的引用,对于移除第一个元素,咱们让 head = current 的下一个元素,即 current.next,这在单向链表中就已经完成了,可是双向链表咱们还要修改节点的上一个指针域,再次判断当前链表长度是否等于 1,若是仅有一个元素,删除以后链表就为空了,那么 tail 也要置为 null,若是不是一个元素,将 head 的 prev 设置为 null,图例以下所示:
状况二:链表尾部移除
改变 current 的指向为 tail(引用最后一个元素),在这是 tail 的引用为 current 的上个元素,即最后一个元素的前一个元素,最后再将 tail 的下一个元素 next 设置为 null,图例以下所示:
状况三:链表尾部移除
这个和单向链表删除那块是同样的思路,不清楚的,在回头去看下,只增长了 current.next.prev = previous 当前节点的下一个节点的 prev 指针域等于当前节点的上一个节点 previous,图例以下所示:
算法实现以下:
delete(i) {
// 要删除的元素位置不能超过链表的最后一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) {
this.head = current.next;
if (this.length === 1) {
this.tail = null;
} else {
this.head.prev = null;
}
} else if (i === this.length -1) {
current = this.tail;
this.tail = current.prev;
this.tail.next = null;
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next;
}
previous.next = current.next;
current.next.prev = previous; // 新增长
}
this.length--;
return current.element;
}
复制代码
双向链表源码地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/doubly-linked-list.js
复制代码
在单向链表和双向链表中,若是一个节点没有前驱或后继该节点的指针域就指向为 null,循环链表中最后一个节点 tail.next 不会指向 null 而是指向第一个节点 head,一样双向引用中 head.prev 也会指向 tail 元素,以下图所示:
能够看出循环链表能够将整个链表造成一个环,既能够向单向链表那样只有单向引用,也能够向双向链表那样拥有双向引用。
如下基于单向链表一节的代码进行改造
尾部插入元素
对于环形链表的节点插入与单向链表的方式不一样,若是当前节点为空,当前节点的 next 值不指向为 null,指向 head。若是头部节点不为空,遍历到尾部节点,注意这里不能在用 current.next 为空进行判断了,不然会进入死循环,咱们须要判断当前节点的下个节点是否等于头部节点,算法实现以下所示:
insertTail(e) {
let node = this.node(e);
let current;
if (this.head === null) { // 列表中尚未元素
this.head = node;
node.next = this.head; // 新增
} else {
current = this.head;
while (current.next !== this.head) { // 下个节点存在
current = current.next;
}
current.next = node;
node.next = this.head; // 新增,尾节点指向头节点
}
this.length++;
}
复制代码
链表任意位置插入元素
实现同链表尾部插入类似,注意:将新节点插入在原链表头部以前,首先,要将新节点的指针指向原链表头节点,并遍历整个链表找到链表尾部,将链表尾部指针指向新增节点,图例以下:
算法实现以下所示:
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) {
if (this.head === null) { // 新增
this.head = node;
node.next = this.head;
} else {
node.next = current;
const lastElement = this.getNodeAt(this.length - 1);
this.head = node;
// 新增,更新最后一个元素的头部引用
lastElement.next = this.head
}
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next; // 保存当前节点的下一个节
}
node.next = current;
previous.next = node; // 注意,这块涉及到对象的引用关系
}
this.length++;
return true;
}
复制代码
移除指定位置元素
与以前不一样的是,若是删除第一个节点,先判断链表在仅有一个节点的状况下直接将 head 置为 null,不然不只仅只有一个节点的状况下,首先将链表头指针移动到下一个节点,同时将最后一个节点的指针指向新的链表头部
算法实现以下所示:
delete(i) {
// 要删除的元素位置不能超过链表的最后一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) {
if (this.length === 1) {
this.head = null;
} else {
const lastElement = this.getNodeAt(this.length - 1);
this.head = current.next;
lastElement.next = this.head;
current = lastElement;
}
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next;
}
previous.next = current.next;
}
this.length--;
return current.element;
}
复制代码
最后在遍历的时候也要注意,不能在根据 current.next 是否为空来判断链表是否结束,能够根据链表元素长度或者 current.next 是否等于头节点来判断,本节源码实现连接以下所示:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/circular-linked-list.js
复制代码
本节主要讲解的是线性表,从顺序表->单向链表->双向链表->循环链表,这个过程也是按部就班的,前两个讲的很详细,双向链表与循环链表经过与前两个不一样的地方进行比较针对性的进行了讲解,另外学习线性表也是学习其它数据结构的基础,数据结构特别是涉及到一些实现算法的时候,有时候并非看一遍就能理解的,总之多实践、多思考。