0. 前言
最近开始学习《学习JavaScript数据结构与算法》,因为大多数看书时间都是在上班地铁里(吐槽下狮厂的加班),看的比较零碎,所以想一边学习,一边自己梳理一下,就有了这篇文章。
如果大家看了觉得有兴趣的话,可以购买这本书,支持下作者。这篇文章我不会去复制原书内容(一是避免侵犯作者版权,二是本书很多地方翻译的也很是拗口,三是kindle的pc版电子书压根就没法复制粘贴...),所以尽量通过自己的理解来重新整理这篇文章。
文章整理过程中也参考了《ECMAScript6 入门》《JavaScript高级程序设计》《你不知道的JavaScript》等书籍,同样的希望大家可以通过购买正版书来支持这些作者。
文章如有错误的地方,恳请各位大佬指正。
代码放在了github上:
代码里还将会有一个typescript版本,后期会陆续加上
如果可以的话,麻烦大家star一下 嘿嘿
所有源码都可以直接用node执行,查看相关运行结果
1. 数组
数组是JavaScript中非常常见的一种数据结构,JavaScript为数组提供了很多常用的原生方法(ES6又扩展了一些新方法),其中有一些特性需要重点关注一下:
1.1 Iterator 遍历器
遍历器(Iterator)是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
ES6为数组也提供了Iterator属性,同时也为数组提供了三个新方法entries,keys和values,用于遍历数组。它们都返回一个遍历器对象,可以用for...of循环进行遍历,区别是keys()是对键名的遍历、values()是对键值的遍历,entries是对键值对的遍历。
这里需要重点关注的是entries方法,该方法返回包含键值对的Iterator:
let aArray = ['a', 'b', 'c'];let aEntries = aArray.entries();console.log(aEntries.next().value); // [0, 'a']console.log(aEntries.next().value); // [1, 'b']console.log(aEntries.next().value); // [2, 'c']复制代码
使用for...of循环:
for (let [index, elem] of ['a', 'b'].entries()) { console.log(index, elem);}// 0 'a'// 1 'b'复制代码
在后续使用集合、字典、散列表等数据结构时,能够取出键值对是很有用的。
1.2 迭代
1) every方法:迭代数组中每个元素,如果数组中检测到有一个元素不满足,则整个表达式返回 false ,且剩余的元素不会再进行迭代。如果所有元素都满足条件,则返回 true。不会改变原始数组
aArray.every((value)=>...)复制代码
2) some方法:迭代数组中每个元素,如果有一个元素满足条件,则表达式返回true , 剩余的元素不会再执行迭代。如果没有满足条件的元素,则返回false。不会改变原始数组
aArray.some((value)=>...)复制代码
3) forEach方法:用于完全迭代整个数组,跟使用for循环结果相同。不会改变原始数组
aArray.forEach((value)=>...)复制代码
4) map方法:按照原始数组元素顺序依次处理元素,返回一个新数组,数组中的元素为原始数组元素调用函数处理后的值(简单理解就是map的字面意思:依照给定函数,对数组中的值做映射)。不会改变原始数组
let aMap = aArray.map((value)=>...)复制代码
5) reduce方法:接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值。不会改变原始数组
比如实现数组中所有元素求和
let total = aArray.reduce((total, currentValue)=>{ return total + currentValue})复制代码
6) filter方法:返回一个新数组,新数组中的元素是数组中通过检查符合条件的所有元素。不会改变原始数组
let aFilter = aArray.filter((value)=>...)复制代码
这边需要重点关注的是map,filter和reduce,这三个方法是后面函数式编程的基础
1.3 排序
1) reverse方法:用于颠倒数组中元素的顺序。会改变原始数组
aArray.reverse()复制代码
2) sort方法:用于对数组的元素进行排序,默认排序顺序为按字母升序。该方法把所有元素都当成字符串进行比较,所以如果是对数字进行排序的话,会出现意想不到的结果(比如10排在了2前面),所以要对数字排序的话,需要写一个比较函数作为参数传入sort方法。会改变原始数组
e.g. 数字排序:
let aArray = [40,100,1,5,25,10];aArray.sort((a,b)=>{ return a-b})复制代码
1.4 搜索
1) indexOf方法:返回数组中某个指定的元素位置,如果在数组中没找到指定元素则返回 -1
let aArray = [1, 2, 3, 4];let a = aArray.indexOf(3); //2let b = aArray.indexOf(5); //-1复制代码
可以传入第二个参数,指定搜索开始的位置
2) find方法(ES6新增):返回通过测试(函数内判断)的数组的第一个元素的值
当数组中的元素在测试条件时返回 true 时, find() 返回符合条件的元素的值,之后的值不会再调用执行函数。如果没有符合条件的元素返回 undefined
let aArray = [1, 2, 3, 4];let a= aArray.find((value)=>{ return value % 2 == 0; //2})复制代码
3) findIndex方法(ES6新增):返回传入一个测试条件(函数)符合条件的数组第一个元素位置
当数组中的元素在测试条件时返回 true 时, findIndex() 返回符合条件的元素的索引位置,之后的值不会再调用执行函数。 如果没有符合条件的元素返回 -1
let aArray = [1, 2, 3, 4];let a= aArray.findIndex((value)=>{ return value % 2 == 0; //1})复制代码
4) includes方法(ES7新增):判断一个数组是否包含一个指定的值,如果是返回 true,否则false
该方法跟之前提到的:通常数组的indexOf方法,判断返回值是否等于-1来检查是否包含某个值。 相比之下,indexOf方法有两个缺点:
- 一是不够语义化,它的含义是找到参数值的第一个出现位置,所以要去比较是否不等于-1,表达起来不够直观。
- 二是,它内部使用严格相等运算符(===)进行判断,这会导致对NaN的误判。
[NaN].indexOf(NaN); // -1复制代码
includes使用的是不一样的判断算法,就没有这个问题。
[NaN].includes(NaN); // true复制代码
2. 栈
栈是一种遵从先进后出原则的有序集合,新添加和待删除的元素都保存在栈的同一端,称作栈顶,另一端叫做栈底 栈被广泛用在编程语言的编译器和内存中,用作保存变量、方法调用(调用栈)等
2.1 创建栈
2.1.1 通过构造函数创建栈并且定义一些常用的栈方法
前面聊的数组是非常适合作为保存栈中元素的一种数据结构,通过使用数组自带的push和pop方法,可以非常方便的模拟栈数据结构
function Stack(){ let items = [] //push 添加新元素到栈顶 this.push = function(value){ items.push(value) } //pop 移除栈顶的元素并将其返回 this.pop = function(){ return items.pop() } //peek 返回栈顶的元素,不改变栈 this.peak = function(){ return items[items.length-1] } //检查栈是否为空 this.isEmpty = function(){ return items.length == 0 } // 返回栈中元素个数 this.size = function(){ return items.length } // 清空栈中元素 this.clear = function(){ items = [] } // 控制台打印栈中元素 this.print = function(){ console.log(this.items.toString()) }}复制代码
2.1.2 使用ES6的Class语法创建栈
class Stack { constructor() { this.items = []; } push(element) { this.items.push(element); } //...}复制代码
这里存在一个问题:2.1.1中创建的Stack构造函数中的items是私有(局部)变量,只能被Stack函数访问,所以任何Stack函数的调用者只能访问定义好的的方法,而不能直接修改items。 然而ES6的Class是不提供私有属性的,意味着调用者可以直接操作items,这是不合理的。
不过可以变通的在Class中实现私有属性。
1)使用Symbol
由于以 Symbol 值作为名称的属性,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法 我们声明一个Symbol类型的变量_items,然后将上述代码的this.items都替换成this[_items]就可以了:
const _items = Symbol('stackItems');class Stack { constructor() { this[_items] = []; } push(element) { this[_items].push(element); } //...}复制代码
事实上,利用Symbol定义私有属性依然是不保险的,虽说可以保证该属性不会被for...in、for...of、Object.keys、Object.getOwnPropertyNames、JSON.stringify等方法所返回,但是可以被下列两个新增的API所访问到:
- Object.getOwnPropertySymbols(可以获取指定对象的所有 Symbol 属性名)
- Reflect.ownKeys(可以返回所有类型的键名,包括常规键名和 Symbol 键名)
这就意味着Stack类的使用者可以通过上述两种方法获取到[_items]数组,并对其进行操作
不过还有另外一个方案
2)使用WeakMap
先看下改造后的代码:
let Stack = (function () { //创建一个闭包 const _items = new WeakMap(); class Stack { constructor() { //以this作为key,把代表栈的数组存入_items _items.set(this, []); } push(element) { //以this作为key,从_items中取值 const items = _items.get(this); items.push(element) } pop() { const items = _items.get(this); const result = items.pop(); return result; } //... } return Stack })();复制代码
现在来看下可以通过WeakMap实现私有属性的原因:
Map和Set是ES6新增的两种数据结构,同时也新增了WeakMap和WeakSet这两种数据结构,可以看作分别是Map和Set的弱化版本,区别在于:
- WeakMap和WeakSet只能用对象作为键(null除外)
- WeakMap和WeakSet没有遍历操作(即没有entries、keys和values等方法)
所以我们在上面的代码中,可以将this对象(指向Stack自身)作为key,然后将数组存入WeakMap中,又因为WeakMap没有遍历操作,使得数组无法被外界访问到
同时通过一个立即执行函数创建了一个闭包,保证WeakMap本身无法被外部所访问,当Stack函数里的构造函数被调用时,会返回Stack类的一个实例
2.2 使用栈数据结构解决问题
栈的实际应用非常广泛,比如存储访问路径或撤销操作等。我们可以试着使用栈来实现三个著名的算法示例:十进制转二进制、平衡圆括号及汉诺塔
2.2.1 十进制转二进制
要把十进制转成二进制,可以通过将十进制数字不断跟2进行整除,直到结果是0为止。
function decimalToBinary(decNumber) { const remStack = new Stack(); //用我们之前写的Stack类,new一个实例 let rem; let binaryString = ''; while (decNumber > 0) { rem = Math.floor(decNumber % 2); remStack.push(rem); //余数入栈 decNumber = Math.floor(decNumber / 2); //除以2并取整数部分 } while (!remStack.isEmpty()) { binaryString += remStack.pop().toString(); //出栈,后进先出,得到结果 } return binaryString;}复制代码
类似的可以实现十进制和其他进制的转换,源码放在github上了,这边就不贴了
大体思路就是传入一个任意进制的基数作为参数,然后要对余数做一个转换(主要是16进制)
function baseConverter(decNumber, base) { //传入base const remStack = new Stack(); const digits = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; //用来替换余数 let number = decNumber; let rem; let baseString = ''; if (!(base >= 2 && base <= 36)) { return ''; } while (number > 0) { rem = Math.floor(number % base); remStack.push(rem); number = Math.floor(number / base); } while (!remStack.isEmpty()) { baseString += digits[remStack.pop()]; //替换余数 } return baseString;}复制代码
2.2.2 平衡圆括号
几乎所有的IDE都会提供括号的校验功能,这个功能通过栈可以很容易实现
大体思路是遍历整个字符串:
- 如果某个字符是任一左侧括号,push进栈
- 如果不是左侧括号,并且当前栈是空的,将平衡标识符设为false
- 如果是任一右侧侧括号,并且当前栈不为空,则将栈顶的左括号出栈,并判断该左括号是否与该右侧括号匹配,若不是,则将平衡标识符设为false
说的可能比较拗口,看代码反而更清晰:
function balancedSymbols(symbols) { const stack = new Stack(); //我们之前创建的Stack类,new一个实例 const opens = '([{'; const closers = ')]}'; let balanced = true; //平衡标识符 let index = 0; let symbol; let top; while (index < symbols.length && balanced) { //遍历整个字符串 symbol = symbols[index]; if (opens.includes(symbol)) { stack.push(symbol); //如果是任一左侧括号,入栈 } else if (stack.isEmpty()) { balanced = false; //栈是空的,则当前状态肯定是不平衡的 } else if(closers.includes(symbol)) { top = stack.pop(); //如果是任一右侧括号,把栈顶的左侧括号出栈 if (!(opens.indexOf(top) === closers.indexOf(symbol))) { balanced = false; //如果左右两括号不匹配,设为false } } index++; } return balanced && stack.isEmpty();}复制代码
2.2.3 汉诺塔
汉诺塔我们小时候应该都玩过,先看下百度百科上对汉诺塔的解释:
汉诺塔:汉诺塔(又称河内塔)问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘
实现汉诺塔的关键在于递归,先看下道爷的蝴蝶书中是如何实现的:
function hanoi (disc, src, aux, dst) { if (disc > 0) { hanoi(disc - 1, src, dst, aux); console.log('Move ' + disc + ' from ' + src + ' to ' + dst); hanoi(disc - 1, aux, src, dst); };};hanoi(3, 'A', 'B', 'C');复制代码
输出结果是这样的:
Move 1 from A to CMove 2 from A to BMove 1 from C to BMove 3 from A to CMove 1 from B to AMove 2 from B to CMove 1 from A to C复制代码
用一张流程图看的更明白些(图片来引用自网络):
上面的hanoi方法可以记录下来每一步操作的流程并输出出来,但是如果要记录每一步操作的状态的话,可以借助栈来实现。代码如下:function towerOfHanoi(plates, source, helper, dest, sourceName, helperName, destName, moves = []) { if (plates <= 0) { return moves; } if (plates === 1) { dest.push(source.pop()); const move = {}; move[sourceName] = source.toString(); move[helperName] = helper.toString(); move[destName] = dest.toString(); moves.push(move); } else { towerOfHanoi(plates - 1, source, dest, helper, sourceName, destName, helperName, moves); dest.push(source.pop()); const move = {}; move[sourceName] = source.toString(); move[helperName] = helper.toString(); move[destName] = dest.toString(); moves.push(move); towerOfHanoi(plates - 1, helper, source, dest, helperName, sourceName, destName, moves); } return moves;}function hanoiStack(plates, sourceName, helperName, destName) { const source = new Stack(); const helper = new Stack(); const dest = new Stack(); for (let i = plates; i > 0; i--) { source.push(i); } return towerOfHanoi(plates, source, helper, dest, sourceName, helperName, destName);}console.log(hanoiStack(3,"source","helper","dest"))复制代码
打印结果如下:
[ { A: '3,2', B: '', C: '1' }, { A: '3', C: '1', B: '2' }, { C: '', A: '3', B: '2,1' }, { A: '', B: '2,1', C: '3' }, { B: '2', C: '3', A: '1' }, { B: '', A: '1', C: '3,2' }, { A: '', B: '', C: '3,2,1' } ]复制代码
这里实现的思路跟hanoi是一样的,唯一的区别就是将A,B,C三个柱子分别new成了Stack实例,然后通过调用push,pop方法完成出栈入栈操作,并记录在了每一次的move对象中,这样就保存了每一次操作是A,B,C三个柱子的状态。
2.3 总结
总的来说,栈数据结构其实就是一种约定了特殊行为的数组(遵循先进后出),其他并没有什么特殊的地方。我们创建的Stack类中的方法,其实也都是借助Array对象的方法去实现的。 但是就是这样一个看起来非常简单的数据结构,在计算机科学当中却扮演了非常重要的角色,除了我们前面提到的十进制转换、平衡圆括号、汉诺塔状态记录外,栈还有很多非常广阔的运用。
比如说递归:递归就是一层层的在调用函数(自身),每次函数调用都会会形成一个调用帧,而所有的调用帧都是用栈结构来存放的(调用栈),最后压入栈中的函数(栈顶)先执行完就会出栈,然后一层层出栈,直到最先调用的函数(在栈底),最后调用栈为空,递归结束。
提到递归就顺便多说一说,在JavaScript中(在其他语言中应该也一样),递归是一个非常“重”的操作,因为要保存非常多的调用帧,所以非常的消耗内存,甚至出现栈溢出。解决该问题的方法有很多,一种思路是把递归执行转为循环执行(比如借助蹦床函数 trampoline),还有一种办法就是做尾递归优化(还有个概念叫函数尾调用,简单来讲就是函数执行的最后一步是调用另一个函数,相应的,尾递归就是递归最后调用的是自身。要做尾递归优化往往需要对递归函数进行改写,把所有用到的内部变量改写成函数的参数,比如说通过函数柯里化来将多参数函数转换成单个参数,或是用函数默认值等),尾递归优化只存在一个调用帧,所以永远不会出现栈溢出。ES6规定了所有ECMAScript的实现,都必须部署尾调用优化,不过仅在严格模式下生效(因为严格模式下禁用arguments和caller两个变量),然而目前似乎只有chrome70+,safari11,nodejs(6.5及以上版本中使用--harmony-tailcalls关键字运行)实现了尾递归优化,其他的JS引擎都不支持。
3. 队列
队列和栈非常类似,区别在于栈遵循了先进后出的原则,而队列则是先进先出
3.1 创建队列
创建队列跟栈的思路非常相似,区别就在于dequeue方法(对应栈的pop方法)和peek方法,dequeue方法移除队列的第一项,并返回被移除的元素(可以通过Array类的shift方法实现),而peek方法返回队列中最先被添加的元素(即取数组的第一个元素)
源码就这边就不贴了,感兴趣的同学可以去上查看(顺便给个star呗~)
3.2 特殊队列
3.2.1 最小优先队列
优先队列很好理解,就是每个入列的元素都有一个指定的优先级,优先级会决定该元素入列时在队列中的位置,而最小优先队列,顾名思义就是优先级越小,位置越靠前
看一下具体实现:
function PriorityQueue() { let items = []; function QueueElement(element, priority) { //要添加的元素的构造函数 this.element = element; //入列的元素 this.priority = priority; //指定元素的优先级 } this.enqueue = function(element, priority) { let queueElement = new QueueElement(element, priority); let added = false; for (let i = 0; i < items.length; i++) { if (queueElement.priority < items[i].priority) { //队列中当前元素的priority比待插入元素的priority大 items.splice(i, 0, queueElement); //把待插入元素插到它的前面 added = true; break; } } if (!added) { //待插入元素的priority比队列中任何元素的priority都要大,则插到最后 items.push(queueElement); } }; this.print = function() { for (let i = 0; i < items.length; i++) { console.log(`${items[i].element} - ${items[i].priority}`); //打印元素及优先级 } }; //其他方法和普通的Queue类的实现相同}复制代码
3.2.2 最大优先队列
最大优先队列跟最小优先队列区别在于,元素的priority越大,位置越靠前,代码就不贴了,感兴趣的同学可以自己尝试改写下
3.2.3 循环队列 ——— 击鼓传花
具体规则就是一群小孩围城一圈,把花依次传递给旁边的人,按给定的数字传递几次,数字到了的时候,花在谁手里谁就被淘汰,重复这个过程,一直到只剩最后一个小孩,就是胜利者 实现思路很简单,就是以给定的数字去迭代一个队列,从队列第一项开始,依次出列(dequeue),并做入列操作,即重新插入队列尾部(enqueue),一旦迭代到达给定的数字的那一项,就把该小孩淘汰掉(dequeue),一直到只剩最后一个小孩(size==1)时迭代结束
const Queue = require('./queueClass')function hotPotato(elementsList, num) { const queue = new Queue(); for (let i = 0; i < elementsList.length; i++) { queue.enqueue(elementsList[i]); } let eliminated; while (queue.size() > 1) { for (let i = 0; i < num; i++) { queue.enqueue(queue.dequeue()); } eliminated = queue.dequeue(); console.log(eliminated,'被淘汰') } return queue.dequeue();}const names = ['阿汪','凯哥','娜姐','萍姐','1200'];let winner = hotPotato(names,1);console.log(winner,'获胜')//output凯哥 被淘汰萍姐 被淘汰阿汪 被淘汰1200 被淘汰娜姐 获胜复制代码
3.2.4 JavaScript任务队列
JavaScript主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。 直接附上阮老师的博客
4. 链表
数组这种数据结构又一个缺点,就是其大小是固定的,往数组中插入或移除元素的成本很高,因为往往需要移动元素。 有一种数据结构叫链表,链表不同于数据,它在内存中并不是连续放置的,每个元素由一个储存元素本身的节点和一个指向下一个元素的指针组成:
这样的数据结构有一个好处在于,添加或删除元素的时候,不需要移动其他元素。但是也有一个缺点,就是想要访问某个元素的话,必须从头开始迭代知道找到所需元素为止。4.1 创建链表
4.1.1 定义链表中的元素Node类
class Node { constructor(element, next) { this.element = element; this.next = next; }}复制代码
Node类中包含两个属性,一个是元素本身,一个是指向下一个元素的指针
4.1.2 定义链表中append方法,向链表尾部添加元素节点
向链表尾部添加元素实现起来是比较简单的:
首先判断头指针是否为null,如果是null,说明当前链表是空的,只要让head指向新元素即可。
如果当前链表不是空的,需要先循环依次链表,找到链表的最后一项,然后将链表最后一项的next指向新元素即可,同时将链表的长度加一。
class LinkedList { constructor() { this.count = 0; //链表长度 this.head = null; //定义头指针 } append(element) { const node = new Node(element,null); //创建Node实例,该节点next默认指向null let current; if (this.head == null) { this.head = node; //如果是空链表,则头指针指向该节点 } else { current = this.head; //循环链表,知道找到最后一项 while (current.next != null) { current = current.next; } //最后一项的next指向新元素 current.next = node; } //长度加一 this.count++; } //...}复制代码
4.1.3 定义链表中remove方法
我们要实现两种remove方法,一种是按特定位置移除,一种是按元素的值移除,但首先我们得先创建一个按位置查找元素的方法:
实现思路就是对链表进行遍历,直到找到对应位置的元素为止。这里首先要判断下传入的位置参数是否越界,如果越界,直接返回null
getElementAt(index) { if (index >= 0 && index <= this.count) { let node = this.head; for (let i = 0; i < index && node != null; i++) { node = node.next; } return node; } return null;}复制代码
随后我们先实现按照特定为止的removeAt方法:
有两种场景需要分别处理,一个是从链表中移除第一个位置的元素,就直接让head指向第二个元素就可以了
如果是非首个元素的话,就先通过上面的getElementAt方法找到对应要移除的元素,然后让该元素的上一个节点直接指向其下一个节点。这样当前元素就会被丢弃在内存中,等着被垃圾回收机制清除。
关于js中的垃圾回收机制,可参考《JavaScript高级程序设计》中的“垃圾收集”章节。 实现代码如下:
removeAt(index) { if (index >= 0 && index < this.count) { let current = this.head; if (index === 0) { this.head = current.next; //移除首个元素 } else { //找到目标元素的元素的上个节点 const previous = this.getElementAt(index - 1); current = previous.next; //下个节点 previous.next = current.next; //链接起来 } this.count--; //记得长度减一 return current.element; } return null; }复制代码
有了这个方法之后,想通过元素的值去做移除就很简单了:只要找到对应元素的位置,然后调用removeAt即可(这种方法每次只能删除匹配到的第一个元素,并不能删除匹配到的所有元素)
先创建一个indexOf方法,用于根据元素值查找位置:
indexOf(element) { let current = this.head; for (let i = 0; i < this.count && current != null; i++) { if (element === current.element) { return i; } current = current.next; } return -1;}复制代码
然后是remove方法:
remove(element) { const index = this.indexOf(element); return this.removeAt(index);}复制代码
4.1.4 按位置插入方法insert
insert方法也分两个情况:
一是在首位插入元素,这种情况要把head指向新元素,并且将新元素的next指向原来的第一个元素
二是向中间位置或尾部插入元素,先通过getElementAt方法找到要插入元素所在的位置的节点,然后将上个节点的next指向新元素节点,再将新元素的next指向原位置的节点,最后记得长度自增一
insert(element, index) { if (index >= 0 && index <= this.count) { const node = new Node(element); if (index === 0) { const current = this.head; node.next = current; this.head = node; } else { const previous = this.getElementAt(index - 1); node.next = previous.next; previous.next = node; } this.count++; return true; } return false;}复制代码
完整代码请参照
4.2 双向链表
我们上面实现了的是一个单向链表,每个节点只包含指向下一个节点的指针,而双向链表,顾名思义就是即包含上个节点的指针,也包含下个节点的指针。
4.2.1 定义双向链表的DoubleNode类
单向链表中我们定义了一个Node类,用于存放元素及下个节点的指针,这里我们再定义一个双向链表的DoubleNode类
根据上面的描述,DoubleNode类就比Node类多了一个prev属性,所以可以很方便的通过继承的方式来实现
class DoublyNode extends Node { constructor(element, next, prev) { super(element, next); this.prev = prev; }}复制代码
4.2.2 双向链表的方法实现
其实跟单向链表差不多,就是每次操作节点的时候,需要同时操作prev和next两个指针,感兴趣的同学可以自己写一遍,代码在这里
5. 集合
集合是由一组无序且唯一的项组成的,es6中也新增了这种数据结构Set,现在我们试一试自己来创建一个Set类
ps:按照阮一峰老师的《ECMAScript6入门》中,es6提供的set类进行遍历顺序就是插入顺序,并非是无序的,所以这里实现的Set类跟ES6的Set还是有差异的
5.1 创建集合
集合其实可以看作一个既没有重复元素,也没有顺序概念的数组。我们可能会很自然的想到,借用数组来创建我们的Set类,但是其实相较数组而言,Object是更加适合的,因为JavaScript中的Object的属性名本身就是不可重复的(同时也确实是无序的)
5.1.1 集合的add方法
首先实现一个has方法,判断当前set中是否包含某个元素
has(element) { return this.items.hasOwnProperty(element)}复制代码
然后是add 方法:
add(element) { if (!this.has(element)) { this.items[element] = element; return true; } return false;}复制代码
这是原书中的add的实现方法,但是个人感觉这个方法是欠妥的,应为如果element是对象的话,通过this.items[element] = element来给key和value赋值,就会造成key是"[object Object]",(Object.prototype.toString),这样一来就导致只能add一次对象元素,后面无论怎么添加对象元素,都会直接返回false,这明显是不合适的
个人觉得这里对于对象或数组,可以用JSON.stringify作为key,然后存入,但是这样还是有个问题,就是无法区分两个相同的对象或数组,这时可以通过Object.is来进行区分
而在ES6的Set中,两个对象永远是不想等的,但同时NaN是等于NaN的,这个特性跟Object.is是一致的:
let set = new Set();set.add({});set.size // 1set.add({});set.size // 2复制代码
5.1.2 其余的几个方法
这几个方法的实现都很简单,都可以直接借助object的方法去实现,直接看代码吧
delete:
delete(element) { if (this.has(element)) { delete this.items[element]; return true; } return false;}复制代码
clear:
clear() { this.items = {};}复制代码
size:
size() { return Object.keys(this.items).length;}复制代码
values:
values() { return Object.values(this.items);}复制代码
5.1.3 集合的操作
并集:
实现思路很简单,遍历两个集合,分别添加到并集的集合中就行了
union(otherSet) { const unionSet = new Set(); this.values().forEach(value => unionSet.add(value)); otherSet.values().forEach(value => unionSet.add(value)); return unionSet;}复制代码
交集:
遍历一个集合,借用上面实现了的has方法,判断每个元素是否也存在于另一个集合中:
intersection(otherSet) { const intersectionSet = new Set(); this.values().forEach((value) => { if (otherSet.has(value)) { intersectionSet.add(value); } }); return intersectionSet;}复制代码
差集:
跟交集的实现基本一样,区别就是判断元素不在另一个集合中,就调用add方法
difference(otherSet) { const differenceSet = new Set(); this.values().forEach(value => { if (!otherSet.has(value)) { differenceSet.add(value); } }); return differenceSet;}复制代码
子集:
先验证当前set和对比set的大小,当前set更大的话,说明肯定不是对比set的子集,直接返回false; 否则的话遍历set,有任何一个元素不在set中,则返回false;
isSubsetOf(otherSet) { if (this.size() > otherSet.size()) { return false; } const isSubset = this.values().every((value) => otherSet.has(value)); return isSubset;}复制代码
6. 字典和散列表
6.1 字典 Dictionary
字典跟之前实现的set类,不同地方在于,set存储的是[值,值]对,而字典存储的是[键,值]对,所以可以很方便的直接通过object去实现。这里我们重点看下散列表
6.2 散列表 HashTable
散列算法的作用是给定一个键,然后直接返回对应的值,这比遍历整个数据结构去查找值要快上很多。
6.2.1 "lose lose"散列函数
“lose lose”是一种最常见的散列函数,实现方式是将每个键中的每个字母的ASCII值相加
loseloseHashCode(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash += key.charCodeAt(i); } return hash % 37;}复制代码
我们选用数组来表示散列表,并用散列函数的结果来作为数组下标,所以为了避免下标过大,最后会做个除法取余数。
6.2.2 put和remove
put方法就是直接操作数组就可以了,在对应位置上存放一个键值对
class ValuePair { constructor(key, value) { this.key = key; this.value = value; }}put(key, value) { if (key != null && value != null) { const position = this.loseloseHashCode(key); this.table[position] = new ValuePair(key, value); //table是个数组 return true; } return false;}复制代码
remove方法:
remove方法不能简单的将数组对应位置的元素移除,因为这样会改变其他元素的位置,所以将其置为undefined即可。
remove(key) { const position = this.loseloseHashCode(key); this.table[position] = undefined; //table是个数组}复制代码
6.2.3 处理冲突
肯定有同学会产生疑问,上面的“lose lose”函数肯定会有不同的key产生相同的散列值,这种情况就产生了冲突,导致先存入数组的键值对被后面的给覆盖掉。 解决冲突的方式有很多,这里我们介绍两种方法,分离链接和线性探查
6.2.4 分离链接
分离链接是为散列表每个位置创建一个链表来保存键值对,这是解决冲突的最简单的方法,缺点是需要占用额外的存储空间
修改下put方法:
put(key, value) { if (key != null && value != null) { const position = this.loseloseHashCode(key); if (this.table[position] == undefined) { this.table[position] = new LinkedList(); } this.table[position].append(new ValuePair(key, value)); return true; } return false;}复制代码
这里利用我们之前实现的LinkedList类,如果散列表对应位置没有值,就创new一个新的链表,然后调用LinkedList的append方法,将键值对存放在链表中。
相应的get方法修改如下:
get(key) { const position = this.loseloseHashCode(key); const linkedList = this.table[position]; if (linkedList != undefined && !linkedList.isEmpty()) { let current = linkedList.getHead(); while (current != null) { if (current.element.key === key) { return current.element.value; } current = current.next; } } return undefined;}复制代码
先找到散列表对应位置,然后在通过遍历linkedList去逐个查找
最后是remove方法:
remove(key) { const position = this.loseloseHashCode(key); const linkedList = this.table[position]; if (linkedList != undefined && !linkedList.isEmpty()) { let current = linkedList.getHead(); while (current != null) { if (current.element.key === key) { linkedList.remove(current.element); if (linkedList.isEmpty()) { this.table[position] = undefined; } return true; } current = current.next; } } return false;}复制代码
还是先找到散列表对应位置,然后通过之前实现了的linkedList的remove方法,将对应的键值对从链表中删除
6.2.5 线性探查
另一种解决冲突的方式是线性探查。如果向index位置添加新元素的时候,index位置已经被占据了,就尝试index+1位置,如果index+1也被占据了,就尝试index+2,以此类推直到找到空的位置。
代码实现起来比较简单:
put(key, value) { if (key != null && value != null) { const position = this.loseloseHashCode(key); if (this.table[position] == undefined) { this.table[position] = new ValuePair(key, value); } else { let index = position + 1; while (this.table[index] != undefined) { index++; } this.table[index] = new ValuePair(key, value); } return true; } return false;}复制代码
get(key) { const position = this.hashCode(key); if (this.table[position] != undefined) { if (this.table[position].key === key) { return this.table[position].value; } let index = position + 1; while (this.table[index] != undefined && this.table[index].key !== key) { index++; } if (this.table[index] != undefined && this.table[index].key === key) { return this.table[position].value; } } return undefined;}复制代码
remove(key) { const position = this.hashCode(key); if (this.table[position] != undefined) { if (this.table[position].key === key) { this.table[position].value = undefined; } let index = position + 1; while (this.table[index] != undefined && this.table[index].key !== key) { index++; } if (this.table[index] != undefined && this.table[index].key === key) { this.table[position].value = undefined; } } return undefined;}复制代码
6.2.5 djb2散列函数
上面我们通过分离链接和线性探查解决了“lose lose”散列函数中的冲突问题,另一个更好的散列函数是djb2,这是最受社区推崇的散列函数之一,看一下具体实现:
djb2HashCode(key) { let hash = 5381; for (let i = 0; i < key.length; i++) { hash = (hash * 33) + key.charCodeAt(i); } return hash % 1013;}复制代码
初始化一个hash变量,这个值是个质数(大多数实现都是使用5381,具体原理不详),然后将33相乘(魔力数?)并迭代相加, 最后与另一个随机质数(比我们认为的散列表的大小要大)相除的余数。
没大明白具体原理,先了解一下吧
6.2.6 一些思考
这里我们选用了数组来表示散列表,使用数组的优点是查找速度非常快,通过下标访问数组元素的算法复杂度为O(1),但是缺点是数组中会存在大量的空缺位置,内存方面有额外开销。
其实也可以用object,如果用object来实现散列表的话,优点在于不会占用额外的内存,缺点在于查找的时候速度稍慢,访问对象的属性是一个O(n)操作。
具体可参考《JavaScript高级程序设计》第24章第2小节:性能
7. 树
跟散列表一样,数也是一种非顺序数据结构,它非常适合存储需要快速查找的数据。
7.1 二叉树和二叉搜索树
二叉树中的节点最多只能有两个子节点,一个左侧子节点,一个右侧子节点。
二叉搜索树(BST)是二叉树的一种,但是只允许在左侧节点存储比父节点小的值,在右侧节点存储比父节点大的值。
7.1.1 创建Node类
跟链表类似,BST中我们也将声明一个Node类来表示树的每个节点,同时跟双向链表一样,也使用两个指针,分别指向左侧子节点和右侧子节点,另外我们还需要定义一个root,代表树的根节点。class TreeNode { constructor(key) { this.key = key; this.left = null; this.right = null; }}复制代码
然后创建一个BinarySearchTree类
class BinarySearchTree { constructor() { this.root = null; }}复制代码
7.1.2 插入方法(insert)
insert(key) { const newNode = new Node(key); if (this.root == null) { this.root = newNode; } else { this.insertNode(this.root, newNode); }}复制代码
这里要判断两种情况:
1.如果树是一颗空的树,即代表要插入的节点将是树的根节点,只要让root指向新节点即可 2.如果要插在非根节点的话,需要借助一个辅助函数,该辅助函数insertNode定义如下:
insertNode(node, newNode) { if (newNode.key < node.key) { if (node.left == null) { node.left = newNode; } else { this.insertNode(node.left, newNode); } } else if (node.right == null) { node.right = newNode; } else { this.insertNode(node.right, newNode); }}复制代码
insertNode函数会帮助我们找到新节点在BST中的位置,具体实现如下: 1.insertNode函数接受两个参数,对比的当前节点和新节点,并且从根节点开始逐次递归进行比较 2.如果新节点的key小于当前节点,就判断当前节点的左侧节点是否为null,是的话就把当前节点的left指向新节点 3.如果当前节点左侧节点不是null,就再次调用insertNode,并将左侧节点和新节点作为参数传入 4.如果新节点的key比当前节点的key大,则判断当前节点的右侧节点是否为null,是的话就把当前节点的right指向新节点 5.如果右侧节点不是null,就再次调用inserNode,并将右侧节点和新节点作为参数传入
可以看出,向BST中插入节点的关键就是利用递归,下面我们看下树的几种遍历方式
7.1.3 中序遍历
中序遍历是以从小到大的顺序访问所有节点,这样就可以对树进行排序操作
首先定义中序遍历方法inOrderTraverse,该方法接受一个回调函数作为参数,回调函数用来定义我们对遍历到的每个节点所要进行的操作(类似Array的map等方法),同时借助一个辅助函数对树的节点进行递归:
inOrderTraverse(callback) { this.inOrderTraverseNode(this.root, callback);}inOrderTraverseNode(node, callback) { if (node != null) { this.inOrderTraverseNode(node.left, callback); callback(node.key); this.inOrderTraverseNode(node.right, callback); }}复制代码
tree.inOrderTraverse(value => console.log(value)) //传入的回调函数复制代码
inOrderTraverseNode辅助函数从root开始,先检查传入的节点是否为null,这是停止递归的判断条件,先访问左侧节点,然后对当前节点执行回调函数,然后在访问右侧节点,这里使用了递归,这个过程中,回调函数被不停的push进执行栈,直到找到了最小节点,然后开始逐一执行,最终实现了从小到大打印所有树节点
过程如下图所示:
7.1.4 先序遍历
先序遍历是以优先于后代节点的顺序访问每个节点的,可用于打印一个结构化文档
实现方式跟中序遍历很相似,唯一的不同在于,先调用callback函数,访问当前节点,然后访问它的左侧节点,再然后是右侧节点
preOrderTraverse(callback) { this.preOrderTraverseNode(this.root, callback);}preOrderTraverseNode(node, callback) { if (node != null) { callback(node.key); this.preOrderTraverseNode(node.left, callback); this.preOrderTraverseNode(node.right, callback); }}复制代码
过程如下图所示:
7.1.5 后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身,后序遍历的一种应用是计算一个目录和它的子目录中所有文件所占空间的大小
postOrderTraverse(callback) { this.postOrderTraverseNode(this.root, callback);}postOrderTraverseNode(node, callback) { if (node != null) { this.postOrderTraverseNode(node.left, callback); this.postOrderTraverseNode(node.right, callback); callback(node.key); }}复制代码
过程如下图所示:
7.1.6搜索树中的值
通常有三种经常执行的搜索类型 搜索最小值,搜索最大值,搜索是否存在特定值
7.1.7 搜索最小值和最大值
在二叉搜索树中,可以快速的找到最小值和最大值:树的最左侧节点即是最小值,最右侧节点则是最大值
所以这两个方法实现起来就很简单了:
min() { return this.minNode(this.root);}minNode(node) { let current = node; while (current != null && current.left != null) { current = current.left; } return current;}复制代码
max() { return this.maxNode(this.root);}maxNode(node) { let current = node; while (current != null && current.right != null) { current = current.right; } return current;}复制代码
7.1.8 搜索是否存在特定值
实现思路就是从根节点逐层往下递归,该方法类似二分搜索算法
search(key) { return this.searchNode(this.root, key);}searchNode(node, key) { if (node == null) { return false; } if (key < node.key) { return this.searchNode(node.left, key); } else if (key > node.key) { return this.searchNode(node.right, key); } return true;}复制代码
7.1.9 移除一个节点
remove(key) { this.root = this.removeNode(this.root, key);}removeNode(node, key) { if (node == null) { //如果正在检测的节点为null,说明key不存在于树中 return null; } if (key < node.key) { //key比当前节点小,继续沿着左侧找下一个节点 node.left = this.removeNode(node.left, key); return node; } else if (key > node.key) { //key比当前节点大,继续沿着右侧找下一个节点 node.right = this.removeNode(node.right, key); return node; } //key等于当前节点,考虑三种情况 //情况1:节点是一个叶节点,如图A if (node.left == null && node.right == null) { node = null; //把当前节点设为null,即删除了改节点 return node; //将父节点对应的指针指向null } //如果该节点有一个左侧子节点或一个右侧子节点,则让父节点的指针直接指向该子节点,图B if (node.left == null) { node = node.right; return node; } else if (node.right == null) { node = node.left; return node; } //如果该节点既有左侧子节点,也有右侧子节点,图C const aux = this.minNode(node.right); //先找到右边子树中最小节点 node.key = aux.key; //用最小节点替换这个节点,但是这样,树中就有两个相同的key了 node.right = this.removeNode(node.right, aux.key); //把右侧子树中原来的最小节点删掉 return node;}复制代码
图A:移除一个叶节点
图B:节点有一个左侧子节点或一个右侧子节点 图C:节点既有左侧子节点,也有右侧子节点7.2 自平衡树
BST会存在一个问题,就是树的某一条边可能会非常深:
这会使添加,移除和搜索某个节点时,出现性能问题。自平衡树(AVL)就是解决这个问题的,自平衡树的任何一个节点左右两侧子树的高度之差最多为1,添加或移除节点时,AVL会尽可能自己尝试转换为完全树
7.2.1 向AVL树中插入节点
AVL树中插入节点和BST基本相同,不同之处在于我们需要检验它的平衡因子,如果有需要,则将其逻辑应用于树的自平衡
在AVL树中,需要对每个节点计算右子树高度(hr)和左子树高度(hl)的差值,该值应为0,1或-1,如果不是这三个值之一,则表示该树不平衡。这就是平衡因子的概念
首先看下计算节点高度的代码:
getNodeHeight(node) { if (node == null) { return -1; } return Math.max(this.getNodeHeight(node.left), this.getNodeHeight(node.right)) + 1;}复制代码
待填坑
8. 图
待填坑
9. 排序和搜索算法
排序和搜索算法在解决各种日常问题中非常常用
其中排序算法有冒泡排序,选择排序,插入排序,归并排序,快速排序和堆排序等
搜索算法有顺序搜索和二分搜索算法等
9.1 排序算法
我们先创建一个函数用于生成一个倒序的数组
function createArray(size){ let i = size; function *ary(){ while(i > 0){ yield i--; } } return [...ary()]}复制代码
正好复习一下遍历器接口,generator,扩展运算符相关知识
以及一个用于交换数组位置的函数
function swap(array, a, b) { [array[a], array[b]] = [array[b], array[a]];}复制代码
9.1.1 冒泡排序
直接看代码吧
function bubbleSort(array) { const length = array.length; for (let i = 0; i < length; i++) { for (let j = 0; j < length - 1; j++) { if (array[j] > array[j + 1]) { swap(array, j ,j+1) } } } return array;}复制代码
效率很差,算法复杂度为O(n²),可以稍加优化,但是算法复杂度没有变化
function bubbleSort(array) { const length = array.length; for (let i = 0; i < length; i++) { for (let j = 0; j < length-1-i; j++) { if (array[j] > array[j + 1]) { swap(array, j ,j+1) } } } return array;}复制代码
9.1.2 选择排序
选择排序的思路是:找到数组中的最小值,然后将其放到第一位,然后找第二小的值,放到第二位,以此类推
function selectionSort (array) { const length = array.length; let indexMin; for (let i = 0; i < length - 1; i++) { indexMin = i; console.log('index ' + array[i]); for (let j = i; j < length; j++) { if (array[indexMin] > array[j]) { console.log('new index min ' + array[j]); indexMin = j; } } if (i !== indexMin) { console.log('swap ' + array[i] + ' with ' + array[indexMin]); swap(array, i, indexMin); } } return array;};复制代码
跟冒泡排序一样,选择排序同样是个复杂度为O(n²)的算法
9.1.3 插入排序
插入排序,光是听这名字就让人很兴奋,但不知道性能咋样
一般我们手动给一副扑克牌进行排序时,往往用的就是插入排序:
假设第一项已经排序了,并从第二项开始,跟前面的项逐一比较,找到它的位置,然后开始帮第三项找位置,以此类推
function insertionSort(array) { const length = array.length; let temp; for (let i = 1; i < length; i++) { let j = i; temp = array[i]; console.log('to be inserted ' + temp); while (j > 0 && array[j - 1] > temp) { console.log('shift ' + array[j - 1]); array[j] = array[j - 1]; j--; } console.log('insert ' + temp); array[j] = temp; } return array;}复制代码
排序小型数组时,插入排序比冒泡排序和选择排序性能要好,但复杂度依然是O(n²)
9.1.4 归并排序
归并算法采用了分治法,具体思路是将原始数组切分为较小数组,直到每个小数组里只包含一个元素,然后再将小数组逐步归并成较大数组,同时进行排序,直到归并为一个排序完毕的大数组,如图所示:
所以该排序方法分为两个部分: 1.将数组分组,通过Math.floor(length/2)找到数组的中间位(向下取整或向上取整都行),将数组分组一分为二,然后通过递归,将数组逐步拆分为多个只有一位的数组 2.对小数组进行排序并合并function merge(left, right) { let i = 0; let j = 0; const result = []; while (i < left.length && j < right.length) { result.push(left[i] < right[j] ? left[i++] : right[j++]); } return result.concat(i < left.length ? left.slice(i) : right.slice(j));}function mergeSort(array) { if (array.length > 1) { const { length } = array; const middle = Math.floor(length / 2); const left = mergeSort(array.slice(0, middle)); const right = mergeSort(array.slice(middle, length)); array = merge(left, right); } return array;}复制代码
归并算法的复杂度是O(nlogn),跟前面三种排序算法比起来要好很多,火狐浏览器中的Array.prototype.sort的实现就是采用了归并算法
9.1.5 快速排序
快速排序的实现思路: 1.先从数组中选择一个中间项作为主元 2.创建两个指针,左边一个指向数组第一个项,右边一个指向数组最后一个项。移动左指针直到我们找到一个比主元大的元素,接着移动右指针直到找到一个比主元小的元素,然后交换他们,重复这个过程,直到左指针超过了右指针便停止。这个过程使得比主元小的值都排在主元左边,比主元大的值都排在主元右边,这一步就做划分操作 3.接着继续划分数组,制定主元,重复上个步骤,直到数组完全排序
function partition(array, left, right) { const pivot = array[Math.floor((right + left) / 2)]; let i = left; let j = right; while (i <= j) { while (array[i] < pivot) { i++; } while (array[j] > pivot) { j--; } if (i <= j) { swap(array, i, j); i++; j--; } } return i;}function quick(array, left, right) { let index; if (array.length > 1) { index = partition(array, left, right); if (left < index - 1) { quick(array, left, index - 1); } if (index < right) { quick(array, index, right); } } return array;}function quickSort(array) { return quick(array, 0, array.length - 1);}复制代码
快速排序的复杂度也为O(nlogn),但是其性能通常比其他的复杂度同为O(nlogn)的算法要好,chrome中的Array.prototype.sort方法的实现用的就是快速排序
9.1.6 堆排序
堆排序也是一种很高效的算法,因其将数组当作二叉树排序而得名: 1.索引0是树的根节点 2.除根节点外,任意节点N的父节点是N/2 3.节点L的左子节点是2L 4.节点R的右子节点是2R+1
待填坑
9.2 搜索算法
9.2.1 顺序搜索
实现起来最简单,但也是最低效的一种搜索算法
function sequentialSearch(array, value) { for (let i = 0; i < array.length; i++) { if (value === array[i]) { return i; } } return 'not found';}复制代码
9.2.2 二分搜索
二分搜索算法有个前提,就是被搜索的数据结构是已排序的: 1.选择数组的中间值 2.如果中间值便是待搜索值,算法执行完毕 3.如果待搜索值比选中值小,则在选中值左边的子数组中重复步骤一 4.同样,如果待搜索值比选中值大,则在选中值右边的子数组中重复步骤一