《学习JavaScript数据结构与算法》
(下图比较大,建议下载后看)
第1章 JavaScript简介
第2章 数组
pop/push
shift/unshift
concat
every/some/forEach/map/filter/reduce
reverse/sort
indexOf/lastIndexOf
toString/join
第3章 栈
后进先出(LIFO,Last In First Out)
Stack类
1.数据结构
function Stack(){
var items=[];
this.push=function(){...};
this.pop=function(){...};
this.peek=function(){...};//返回栈顶元素
this.isEmpty=function(){...};
this.size=function(){...};//返回栈的元素个数
this.clear=function(){...};//移除栈里所有元素
this.print=function(){...};//把栈里元素都输出到控制台
}
2)应用
十进制转其他进制
第4章 队列
先进先出(FIFO,First In First Out)
Queue类
1.数据结构
function Queue(){
var items=[];
this.enqueue=function(){...};//向队列尾部添加一个或多个新的项
this.dequeue=function(){...};//移除队列的第一项,并返回被移除的元素
this.front=function(){...};//返回队列中第一个元素
this.isEmpty=function(){...};
this.size=function(){...};
this.clear=function(){...};
this.print=function(){...};
}
2.优先队列
1)最小优先队列
与默认队列的不同在于添加元素时,需循环判断当前新元素的优先级是否小于(是否优先级更高于)队列中已有元素的优先级,若是则将新元素插入原队列中,若循环结束仍未插入,则新元素将放入队列末尾。
2)最大优先队列
则是相反,优先级的值较大的元素放置在队列最前面。
3.循环队列
应用:击鼓传花
原理:在循环遍历过程中,每次移除一个元素,就将该元素添加到队列末尾,形成循环队列。
第5章 链表
链表中的元素在内存中并不是连续放置的。每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(也称指针或链接)组成。
要想访问链表中间的一个元素,需要从起点(表头)开始迭代列表,直到找到所需的元素。
1.数据结构
function LinkedList(){
var Node=function(element){
this.element=element;
this.next=null;
};
var length=0;
var head=null;
this.append=function(element){};//向列表尾部添加一个新的项
this.insert=function(position,element){};//向列表特定位置插入一个新的项
this.removeAt=function(position){};//从列表中移除一项
this.remove=function(element){};//从列表中移除一项
this.indexOf=function(element){};//返回元素在列表中的索引,若无则返回-1
this.isEmpty=function(){...};
this.size=function(){...};
this.toString=function(){...};//由于列表项使用了Node类,就要重写toString,让其只输出元素的值
this.print=function(){...};
}
1)append
2种场景:
- 列表为空,添加的是第一个元素
- 列表不为空,向其追加元素
this.append=function(element){
var node=new Node(element),current;
if(head===null){//列表为空
head=node;
}else{//列表不为空
current=head;
while(current.next){//循环列表,直到最后一项
current=current.next;
}
current.next=node;//找到最后一项,将其next赋为node
}
length++;//更新列表的长度
};
2)removeAt
2种场景:
- 移除第一个元素
- 移除第一个以外的任一元素
this.removeAt=function(position){
if(position>-1&&position<length){//检查越界
var current=head,previous,index=0;
if(position===0){//移除第一项
head=current.next;
}else{
while(index++<position){//通过循环找到需要删除的元素,并赋值给current
previous=current;
current=curent.next;
}
previous.next=current.next;//删除current节点
}
length--;//更新列表长度
return current.element;//返回删除的元素
}else{
return null;
}
};
3)insert
this.insert=function(position,element){
if(position>=0&&position<=length){
var node=new Node(element),current=head,previous,index=0;
if(position===0){
node.next=current;
head=node;
}else{
while(index++<position){
previous=current;
current=current.next;
}
node.next=current;
previous.next=node;
}
length++;
return true;
}else{
return false;
}
};
4)indexOf
this.indexOf=function(element){
var current=head,index=-1;
while(current){
if(element===current.element){
return index;
}
idnex++;
current=current.next;
}
return -1;
};
利用indexOf实现remove:
this.remove=function(element){
var index=this.indexOf(element);
return this.removeAt(index);
}
2.双向链表
链接是双向的,一个链向下一个元素,另一个链向前一个元素。
1)数据结构
function DoublyLinkedList(){
var Node=function(element){
this.element=element;
this.next=null;
this.prev=null;
}
var length=0;
var head=null;
var tail=null;
}
2)insert
this.insert=function(position,element){
if(position>=0&&position<=length){
var node=new Node(element),current=head,previous,index=0;
if(position===0){//头部插入新元素
if(!head){//列表为空
head=node;
tail=node;
}else{//列表不为空
node.next=current;
current.prev=node;
head=node;
}
}else if(position===length){//最后一项
current=tail;
current.next=node;
node.prev=current;
tail=node;
}else{//previous和current中间插入
while(index++<position){
previous=current;
current=current.next;
}
node.next=current;
previous.next=node;
current.prev=node;//设置反向
node.prev=previous;
}
length++;
}else{
return false;
}
};
其他方法不再赘述,都是在原基础上增加反向的情况。
3.循环链表
可以是单向链表,可以是双向链表。唯一区别在于,最后一个元素指向下一个元素的指针,指向第一个元素(head);双向链表的tail.next指向head,head.prev指向tail。
第6章 集合 Set
存储唯一值(不重复的值)的数据结构。
集合以[值,值]的形式存储元素。
集合是由一组无序且唯一(即不能重复)的项组成的。这个数据结构使用了与有限集合相同的数学概念,应用在计算机科学的数据结构中。
可以把集合想象成一个既没有重复元素,也没有顺序概念的数组。有并集、交集、差集等基本操作。
在这一章中,我们要实现的类就是以ECMAScript 6中 Set 类的实现为基础的。(模拟与ECMAScript 6实现相同的 Set 类)
1.数据结构
function Set(){
var items={};//对象保证了不会出现相同的属性,即集合里的元素都是唯一的
this.add=function(value){};//添加一个新的项
this.remove=function(value){};//移除一个值
this.has=function(value){};//判断值是否在集合中
this.clear=function(){};//移除所有项
this.size=function(){};//返回集合所包含元素的数量
this.values=function(){};//返回一个包含集合中所有值的数组
}
1)has
this.has=function(value){
return items.hasOwnProperty(value);
};
2)add
this.add=function(value){
if(!this.has(value)){//原集合中是否有该值
item[value]=value;
return true;
}
return false;
}
3)remove
this.remove=function(value){
if(this.has(value)){//验证给定的value是否存在于集合中
delete items[value];
return true;
}
return false;
}
4)clear
移除所有值
this.clear=function(){
items={};
}
5)size
//方法一:使用add和remove时,维护一个length变量,size()中直接返回该变量
//方法二:使用ES6的Object.keys
this.size=function(){
return Object.keys(items).length;
}
//方法三:手动提取每一个属性,并记录个数
this.size=function(){
var count=0;
for(var prop in items){
if(items.hasOwnProperty(prop)){
++count;
}
}
return count;
}
6)values
遍历对象的所有属性
this.values=function(){
return Object.keys(items);
}
2.集合的特性
1)并集union
对于给定的两个集合,返回一个包含两个集合中所有元素的新集合。
重复元素只出现一次。
this.union=function(otherSet){
var unionSet=new Set();
var values=this.values();
for(var i=0;i<values.length;i++){
unionSet.add(values[i]);
}
values=otherSet.values();
for(var i=0;i<values.length;i++){
unionSet.add(values[i]);
}
return unionSet;
}
2)交集intersection
对于给定的两个集合,返回一个包含两个集合中共有元素的新集合。
this.intersection=function(otherSet){
var intersectionSet=new Set();
var values=this.values();
for(Var i=0;i<values.length;i++){
if(otherSet.has(values[i])){
intersectionSet.add(values[i]);
}
}
return intersectionSet;
}
3)差集difference
对于给定的两个集合,返回一个包含所有存在于第一个集合且不存在于第二个集合的元素的新集合。
this.difference=function(otherSet){
var differenceSet=new Set();
var values=this.values();
for(var i=0;i<values.length;i++){
if(!otherSet.has(values[i])){//如果第二个集合没有当前属性,才add
differenceSet.add(values[i]);
}
}
return differenceSet;
}
4)子集subset
验证一个给定集合是否是另一集合的子集。
this.subset=function(otherSet){
if(this.size()>otherSet.size()){//若第1个集合的元素个数多余第2个
return false;
}
var values=this.values();
for(var i=0;i<values.length;i++){
if(!otherSet.has(values[i])){//若某一值不存在于第2个集合中
return false;
}
}
return true;
}
第7章 字典和散列表
存储唯一值(不重复的值)的数据结构。
1.字典
字典以[键,值]的形式来存储元素。
字典也称映射。
1)数据结构
function Dictionary(){
var items={};
this.set=function(key,value){};
this.remove=function(key){};
this.has=function(key){};
this.get=function(key){};
this.clear=function(){};
this.size=function(){};
this.keys=function(){};
this.values=function(){};
}
2)has和set
this.has=function(key){
return key in items;
}
this.set=function(key,value){
items[key]=value;
}
3)remove
this.remove=function(key){
if(this.has(key)){
delete items[key];
return true;
}
return false;
}
4)get和values
this.get=function(key){
return this.has(key)?items[key]:undefined;
}
this.values=function(){
var values={};
for(var k in items){
if(this.has(k)){
values.push(items[k]);
}
}
return values;
}
5)clear、size、keys和getItems
clear、size、keys与Set类一致。
getItems:返回items变量的方法
this.getItems=function(){
return items;
}
2.散列表
HashTable 类,也叫 HashMap 类,是 Dictionary 类的一种散列表实现方式。
作用是尽可能快地在数据结构中找到一个值。
1)数据结构
function HashTable(){
var table=[];
var loseloseHashCode=function(key){};
this.put=function(key,value){};
this.remove=function(key){};
this.get=function(key){};
}
2)loseloseHashCode(私有)
var loseloseHashCode=function(key){
var hash=0;
for(var i=0;i<key.length;i++){
hash+=key.charCodeAt(i);
}
return hash%37;
}
3)put
this.put=function(key,value){
var position=loseloseHashCode(key);
console.log(position+'-'+key);
table[position]=value;
}
4)get
this.get=function(key){
return table[loseloseHashCode(key)];
}
5)remove
this.remove=function(key){
table[loseloseHashCode(key)]=undefined;
}
6)处理冲突:散列值相同的情况
- 分离链接
分离链接法包括为散列表的每一个位置创建一个链表并将元素存储在里面。它是解决冲突的最简单的方法,但是它在 HashTable 实例之外还需要额外的存储空间。
HashTable类内部定义一个新的辅助类ValuePair,存储键值对:
var ValuePair=function(key,value){
this.key=key;
this.value=value;
this.toString=function(){
return '['+this.key+'-'+this.value+']';
}
}
改写put方法:
this.put=function(key,value){
var position=loseloseHashCode(key);
if(table[position]==undefined){
table[position]=new LinkedList();
}
table[position].append(new ValuePair(key,value));
};
改写get方法:
this.get=function(key){
var position=loseloseHashCode(key);
if(table[position]!==undefined){
//遍历链表来寻找键/值
var current=table[position].getHead();
while(current.next){
if(current.element.key===key){
return current.element.value;
}
current=current.next;
}
//检查元素在链表第一个或最后一个节点的情况
if(current.element.key===key){
return current.element.value;
}
}
return undefined;
}
改写remove方法:
this.remove=function(key){
var position=loseloseHashCode(key);
if(table[position]!==undefined){
var current=table[position].getHead();
while(current.next){
if(current.element.key===key){
table[position].remove(current.element);
if(table[position].isEmpty()){
table[position]=undefined;
}
return true;
}
current=current.next;
}
if(current.element.key===key){
table[position].remove(current.element);
if(table[position].isEmpty()){
table[position]=undefined;
}
return true;
}
}
return false;
};
- 线性探查
当想向表中某个位置加入一个新元素的时候,如果索引为index的位置已经被占据了,就尝试index+1的位置,以此类推。
改写put方法:
this.put=function(key,value){
var position=loseloseHashCode(key);
if(table[position]==undefined){
table[position]=new ValuePair(key,value);
}else{
var index=++position;
while(table[index]!=undefined){
index++;
}
table[index]=new ValuePair(key,value);
}
};
改写get方法:
this.get=function(key){
var position=loseloseHashCode(key);
if(table[position]!==undefined){
if(table[position].key===key){
table[index]=undefined;//与get不同的操作
}else{
var index=++position;
while(table[index]===undefined||table[index].key!==key){
index++;
}
if(table[index].key===key){
table[index]=undefined;//与get不同的操作
}
}
}
return undefined;
};
改写remove方法:
this.get=function(key){
var position=loseloseHashCode(key);
if(table[position]!==undefined){
if(table[position].key===key){
return table[position].value;
}else{
var index=++position;
while(table[index]===undefined||table[index].key!==key){
index++;
}
if(table[index].key===key){
return table[index].value;
}
}
}
return undefined;
};
- 双散列法
7)创建更好的散列函数
loseloseHashCode并不是一个良好的散列函数,因为会产生太多冲突。一个表现良好的散列函数是由几方面构成的:插入和检索元素的时间(即性能),也包括较低的冲突可能性。
更好的散列函数推荐之一djb2:
var djb2HashCode=function(key){
var hash=5381;//初始化为一个质数
for(var i=0;i<key.length;i++){
hash=hash*33+key.charCodeAt(i);//33作为一个魔力数
}
return hash%1013;//相加的和与另一个随机质数1031(比我们认为的散列表大小要大)相除,取余
}
第8章 树
非顺序数据结构,对于存储需要快速查找的数据非常有用。
1.相关术语
节点:树中每个元素。分为内部节点和外部节点。一个节点可以有祖先和后代。
根节点:位于树顶部的节点。它没有父节点。
内部节点:至少有一个子节点的节点。
外部节点/叶节点:没有子元素的节点。
节点的祖先:包括父节点、祖父节点、曾祖父节点等。
节点的后代:子节点、孙子节点、曾孙节点等。
节点的深度:取决于它的祖先节点的数量。
子树:子树由节点和它的后代构成。
树的高度:取决于所有节点深度的最大值。
2.二叉树和二叉搜索树(BST)
向/从树中插入、查找、删除节点。
二叉搜索树:是二叉树的一种,但是只允许在左侧节点存储比父节点小的值,在右侧节点存储比父节点大或等于的值。
本章主要研究二叉搜索树。
3.BST的数据结构
function BinarySearchTree(){
var Node=function(key){//树中的每个节点
this.key=key;
this.left=null;
this.right=null;
};
var root=null;
this.insert=function(key){};//向树中插入一个新的节点
this.search=function(key){};//查找节点
this.inOrderTraverse=function(){};//中序遍历方式遍历
this.preOrderTraverse=function(){};//先序遍历方式遍历
this.postOrderTraverse=function(){};//后序遍历方式遍历
this.min=function(){};//返回树中最小的值
this.max=function(){};//返回树中最大的值
this.remove=function(key){};//移除
}
和链表一样,将通过指针来表示节点之间的关系(术语称其为边)。
1)insert
this.insert=function(key){
var newNode=new Node(key);
if(root===null){
root=newNode;
}else{
insertNode(root,newNode);
}
};
var insertNode=function(node,newNode){
//私有辅助函数,将节点加在非根节点的其他位置
if(newNode.key<node.key){
if(node.left===null){
node.left=newNode;
}else{
insertNode(node.left,newNode);
}
}else{
if(node.right===null){
node.right=newNode;
}else{
insertNode(node.right,newNode);
}
}
}
4.树的遍历
1)中序遍历
中序遍历的一种应用就是对树进行排序操作。
this.inOrderTraverse=function(callback){
inOrderTraverseNode(root,callback);
}
var inOrderTraverseNode=function(node,callback){
if(node!==null){//停止递归继续执行的判断条件:节点是否为null
inOrderTraverseNode(node.left,callback);
callback(node.key);
inOrderTraverseNode(node.right,callback);
}
};
2)先序遍历
是以优先于后代节点的顺序访问每个节点的。先序遍历的一种应用是打印一个结构化的文档。
this.preOrderTraverse=function(callback){
preOrderTraverseNode(root,callback);
};
function preOrderTraverseNode=function(node,callback){
if(node!==null){
callback(node.key);
preOrderTraverseNode(node.left,callback);
preOrderTraverseNode(node.right,callback);
}
}
先序遍历会先访问节点本身,然后再访问它的左侧子节点,最后是右侧子节点。
3)后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身。后序遍历的一种应用是计算一个目录和它的子目录中所有文件所占空间的大小。
this.postOrderTraverse=function(callback){
postOrderTraverseNode(root,callback);
};
function postOrderTraverseNode=function(node,callback){
if(node!==null){
postOrderTraverseNode(node.left,callback);
postOrderTraverseNode(node.right,callback);
callback(node.key);
}
}
后序遍历会先访问左侧子节点,然后是右侧子节点,最后是父节点本身。
5.搜索树中的值
1)搜索最小值和最大值
最小值即为BST的最后一层最左侧叶节点,最大值为BST的最后一层最右侧叶节点。
搜索最小值min方法:
this.min=function(){
return minNode(root);
}
var minNode=function(node){
if(node){
while(node&&node.left!==null){
node=node.left;
}
return node.key;
}
return null;
}
以相似的方式实现max方法:
this.max=function(){
return maxNode(root);
}
var maxNode=function(node){
if(node){
while(node&&node.right!==null){
node=node.right;
}
return node.key;
}
return null;
}
2)搜索一个特定值
search方法:
this.search=function(key){
return searchNode(root,key);
}
var searchNode=function(node,key){
if(node===null){
return false;
}
if(key<node.key){
return searchNode(node.left,key);
}else if(key>node.key){
return searchNode(node.right,key);
}else{
return true;
}
}
6.移除节点remove(复杂)
this.remove=function(key){
root=removeNode(root,key);
}
var removeNode=function(node,key){
if(node===null){
return null;
}
if(key<node.key){
node.left=removeNode(node.left,key);
return node;
}else if(key>node.key){
node.right=removeNode(node.right,key);
return node;
}else{//键等于node.key
//第1种情况--1个叶节点
if(node.left===null&&node.right===null){
node=null;
return node;
}
//第2种情况--1个只有1个子节点的节点
if(node.left===null){
node=node.right;
return node;
}else if(node.right===null){
node=node.left;
return node;
}
//第3种情况--1个有2个子节点的节点
var aux=findMinNode(node.right);//findMinNode寻找最小节点,并返回这个最小节点
node.key=aux.key;
node.right=removeNode(node.right,aux.key);
return node;
}
}
第3种情况最复杂,其实是用当前节点的右侧子树最小值去替换当前节点,并移除这个最小节点。如图。
7.BST的问题
取决于你添加的节点数,树的一条边可能会非常深;也就是说,树的一条分支会有很多层,而其他的分支却只有几层。如图。
平衡二叉树(AVL树)可以解决这个问题。
8.红黑树
自平衡二叉查找树,是一种特化的AVL树。
都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
典型的用途是实现关联数组。
时间复杂度:O(log n),n 是树中元素的数目
1)特征
- 节点是红色或黑色
- 根节点是黑色
- 所有叶子都是黑色。(叶子是NIL节点)
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
关键性质: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。
最短的可能路径都是黑色节点,最长的可能路径有交替的红色和黑色节点。
2)树的旋转
参考文章:https://www.jianshu.com/p/e136ec79235c
通过对树进行旋转(例如左旋和右旋操作),即修改树中某些结点的颜色及指针结构,以达到对红黑树进行插入、删除结点等操作时,红黑树依然能保持它特有的性质。
- 左旋
以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。如图。
- 右旋
以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。如图。
- 变色:结点的颜色由红变黑或由黑变红。
旋转操作是局部的。另外可以看出旋转能保持红黑树平衡的一些端详了:当一边子树的结点少了,那么向另外一边子树“借”一些结点;当一边子树的结点多了,那么向另外一边子树“租”一些结点。
3)查找、插入、删除
- 查找
因为红黑树是一颗二叉平衡树,并且查找不会破坏树的平衡,所以查找跟二叉平衡树的查找无异。
- 插入
包括两部分工作:一查找插入的位置;二插入后自平衡。
所有插入的情景如图:
- 删除(最复杂)
包括两部分工作:一查找目标结点;而删除后自平衡。
3种情情景:
-
- 情景1:若删除结点无子结点,直接删除
-
- 情景2:若删除结点只有一个子结点,用子结点替换删除结点
-
- 情景3:若删除结点有两个子结点,用后继结点(大于删除结点的最小结点)替换删除结点
找前继和后继结点的直观的方法-- 二叉树投射x轴后有序:
把二叉树所有结点投射在X轴上,所有结点都是从左到右排好序的,所有目标结点的前后结点就是对应前继和后继结点。
一个重要的思路:删除结点被替代后,在不考虑结点的键值的情况下,对于树来说,可以认为删除的是替代结点!
基于此,上面所说的3种二叉树的删除情景可以相互转换并且最终都是转换为情景1。
所有删除的情景如图:
3)应用
它的统计性能要好于平衡二叉树。(得益于自平衡)
红黑树已广泛应用Linux 的进程管理、内存管理,设备驱动及虚拟内存跟踪等一系列场景中。
9.堆Heap
参考文章:https://www.jianshu.com/p/6b526aa481b1
堆通常是一个可以被看做一棵完全二叉树的数组对象。
常见的堆有二叉堆、斐波那契堆等。
性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
应用:
- 构建优先队列
- 支持堆排序
- 快速找出一个集合中的最小值(或者最大值)
1)堆属性
- 最大堆:父节点的值比每一个子节点的值都要大。
- 最小堆:父节点的值比每一个子节点的值都要小。
2)数组实现堆
父节点和子节点的公式:
//其中i为数组索引
parent(i) = floor((i - 1)/2)
left(i) = 2i + 1
right(i) = 2i + 2
//right(i) 就是简单的 left(i) + 1
数组索引和层级的关系
由上图可以看到,数组中父节点总是在子节点的前面。
在堆中,在当前层级所有的节点都已经填满之前不允许开是下一层的填充。即堆是一棵完全二叉树。
3)堆的数学特性
树的高度是指从树的根节点到最低的叶节点所需要的步数。一个高度为 h 的堆有 h+1 层。
上图的堆的高度为3,层数为4。
-
如果一个堆有 n 个节点,那么它的高度是
h = floor(log2(n))
。 -
如果最下面的一层已经填满,那么那一层包含
2^h
个节点。 -
整个堆中的节点数目为:
2^(h+1) - 1
。 -
叶节点总是位于数组的
floor(n/2)
和n-1
之间。
4)插入、删除等操作
- shiftUp:上浮。如果一个节点比它的父节点大(最大堆)或者小(最小堆),那么需要将它同父节点交换位置。
- shiftDown: 下沉。如果一个节点比它的子节点小(最大堆)或者大(最小堆),那么需要将它向下移动。这个操作也称作“堆化(heapify)”。
- top:取顶。返回最大/小值。时间复杂度是O(1)。
shiftUp和shiftDown是一个递归的过程,所以它的时间复杂度是 O(log n)。
基于这2个操作进行插入、删除:
- push:插入到最后一个元素,然后使用
shiftUp
来修复堆。 - pop: 移除并返回最大值(最大堆)或者最小值(最小堆)。将最后一个元素替换到根节点的位置,然后使用
shiftDown
方法来修复堆。
上面2个的操作的时间复杂度都是 O(log n),因为 shiftUp 和 shiftDown 都很费时。
- heapSort:堆排序。由于堆就是一个数组,我们可以使用它独特的属性将数组从低到高排序。时间复杂度:O(n lg n)。
6)堆排序
将堆转换成有序数组,需要使用堆排序。堆排序是一种**选择排序,**它的最坏、最好、平均时间复杂度均为O(nlogn),它也是不稳定排序。
基本思想:将待排序序列构造成一个最大堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次大值。如此反复执行,便能得到一个有序序列了。
第9章 图
1.术语
相邻顶点、度、路径、简单路径、环
图是无环/连通/强连通/未加权/加权的
稀疏图(不是强连通的图)、有向图、无向图
两个顶点之间的最短路径、环检测
2.图的表示
1)邻接矩阵
每个节点都和一个整数相关联,该整数将作为数组的索引。我们用一个二维数组来表示顶点之间的连接。如果索引为i的节点和索引为j的节点相邻,则array[i][j]=== 1
,否则array[i][j] === 0
,如下图所示:
缺点:
- 图中顶点的数量可能会改变,而2维数组不太灵活。
- 稀疏图如果用邻接矩阵来表示,则矩阵中将会有很多0,意味着浪费了计算机存储空间来表示根本不存在的边。
2)邻接表
动态数据结构
邻接表由图中每个顶点的相邻顶点列表所组成。
本书的示例中,将使用邻接表表示法。
3)关联矩阵
在关联矩阵中,矩阵的行表示顶点,列表示边。
关联矩阵通常用于边的数量比顶点多的情况下,以节省空间和内存。
3.数据结构(使用邻接表)
function Graph(){
var vertices=[];//存储顶点
var adjList=new Dictionary();//使用字典存储邻接表,顶点名为键,邻接顶点列表作为值
this.addVertex=function(v){//向图中添加一个新的顶点
vertices.push(v);
adjList.set(v,[]);
};
this.addEdge=function(v,w){//添加顶点之间的边,参数为2个顶点
adjList.get(v).push(w);
adjList.get(w).push(v);
}
}
4.图的遍历
图遍历可以用来寻找特定的顶点或寻找2个顶点之间的路径,检查图是否连通,检查图是否含有环等。
完全探索一个顶点要求我们查看该顶点的每一条边。对于每一条边所连接的没有被访问过的顶点,将其标注为被发现的,并将其加进待访问顶点列表中。
说明算法前,先进行约定:当要标注已经访问过的顶点时,我们用三种颜色来反映它们的状态。
- 白色:表示该顶点还没有被访问。
- 灰色:表示该顶点被访问过,但并未被探索过。
- 黑色:表示该顶点被访问过且被完全探索过。
1)广度优先搜索(BFS)
数据结构:队列
通过将顶点存入队列中,最先入队列的顶点先被探索。
广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的相邻点,就像一次访问图的一层。
算法步骤:
(1) 创建一个队列Q。
(2) 将v标注为被发现的(灰色),并将v入队列Q。
(3) 如果Q非空,则运行以下步骤:
(a) 将u从Q中出队列;
(b) 将标注u为被发现的(灰色);
© 将u所有未被访问过的邻点(白色)入队列;
(d) 将u标注为已被探索的(黑色)
代码实现:
var initializeColor=function(){
var color=[];
for(var i=0;i<vertices.length;i++){
color[vertices[i]]='white';
}
return color;
};
this.bfs=function(v,callback){
var color=initializeColor(),queue=new Queue();
queue.enqueue(v);
while(!queue.isEmpty()){
var u=queue.dequeue(),neighbors=adjList.get(u);
color[u]='grey';
for(var i=0;i<neighbors.length;i++){
var w=neightbors[i];
if(color[w]==='white'){
color[w]='grey';
queue.enqueue(w);
}
}
color[u]='black';
if(callback){
callback(u);
}
}
};
寻找最短路径:
修改bsf的实现,返回:从v到u的距离d[u];前溯点pred[u],用来推导出从v到其他每个顶点u的最短路径。
this.BFS=function(v){
var color=initializeColor(),queue=new Queue(),d=[],pred=[];
queue.enqueue(v);
for(var i=0;i<vertices.length;i++){
d[vertices[i]]=0;
pred[vertices[i]]=null;
}
while(!queue.isEmpty()){
var u=queue.dequeue(),neighbors=adjList.get(u);
color[u]='grey';
for(i=0;i<neightbors.length;i++){
var w=neighbors[i];
if(color[w]==='white'){
color[w]='grey';
d[w]=d[u]+1;
pred[w]=u;
queue.enqueue(w);
}
}
color[u]='black';
}
return {
distances:d,
predecessors:pred
};
};
输出根顶点到所有其他顶点的最短路径:
var fromVertex=myVertices[0];
for(var i=1;i<myVertices.length;i++){
var toVertex=myVertices[i],path=new Stack();
for(var v=toVertex;v!==fromVertex;v=shortestPathA.predecessors[v]){
path.push(v);
}
path.push(fromVertex);
var s=path.pop();
while(!path.isEmpty()){
s+='-'+path.pop();
}
console.log(s);
}
2)深度优先搜索(DFS)
数据结构:栈
通过将顶点存入栈中,顶点式沿着路径被探索的,存在新的相邻顶点就去访问。
深度优先搜索算法将会从第一个指定的顶点开始遍历图,沿着路径直到这条路径最后一个顶点被访问了,接着原路回退并探索下一条路径。
算法步骤:
(1) 标注v为被发现的(灰色)。
(2) 对于v的所有未访问的邻点w:
(a) 访问顶点w。
(3) 标注v为已被探索的(黑色)。
代码实现:
this.dfs=function(callback){
var color=initializeColor();
for(var i=0;i<vertices.length;i++){
if(color[vertices[i]]==='white'){
dfsVisit(vertices[i],color,callback);
}
}
};
var dfsVisit=function(u,color,callback){
color[u]='grey';
if(callback){
callback(u);
}
var neighbors=adjList.get(u);
for(var i=0;i<neighbors.length;i++){
var w=neighbors[i];
if(color[w]==='white'){
dfsVisit(w,color,callback);
}
}
color[u]='black';
};
发现时间和完成探索时间
修改dfs方法,并返回: 顶点u的发现时间d[u];当顶点u被标注为黑色时,u的完成探索时间f[u]; 顶点u的前溯点p[u]。
var time=0;
this.DFS=function(){
var color=initializeColor(),d=[],f=[],p=[];
time=0;
for(var i=0;i<vertices.length;i++){
f[vertices[i]]=0;
d[vertices[i]]=0;
p[vertices[i]]=null;
}
for(i=0;i<vertices.length;i++){
if(color[vertices[i]]==='white'){
DFSVisit(vertices[i],color,d,f,p);
}
}
return {
discovery:d,
finished:f,
predecessors:p
};
};
var DFSVisit=function(u,color,d,f,p){
console.log('discovered '+u);
color[u]='grey';
d[u]=++time;
var neighbors=adjList.get(u);
for(var i=0;i<neighbors.length;i++){
var w=neighbors[i];
if(color[w]==='white'){
p[w]=u;
DFSVisit(w,color,d,f,p);
}
}
color[u]='black';
f[u]=++time;
console.log('explored '+u);
};
拓扑排序
当我们需要编排一些任务或步骤的执行顺序时,这称为拓扑排序。拓扑排序只能应用于有向无环图DAG。
给定下图(有向无环图DAG),假定每个顶点都是一个我们需要去执行的任务:
调用DFS方法,得到上图的发现时间和完成探索时间。上图结果倒序,得出该图的拓扑排序:
B-A-D-C-F-E
第10章 排序和搜索算法
一、排序算法
待排序的数据结构:
function ArrayList(){
var array=[];
this.insert=function(item){
array.push(item);
};
this.toString=function(){
return array.join();
};
}
1.冒泡排序
冒泡排序比较任何两个相邻的项,如果第一个比第二个大,则交换它们。元素项向上移动至正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
时间复杂度是O(n^2)
this.bubleSort=function(){
var length=array.length;
for(var i=0;i<length;i++){
for(var j=0;j<length-1;j++){
if(array[j]>array[j+1]){
swap(j,j+1);
}
}
}
};
var swap=function(index1,index2){
var aux=array[index];
array[index1]=array[index2];
array[index2]=aux;
};
上述冒泡排序的实现进行到后面,其实已经排过了(前面较大的数字已换到了后面)但还在继续遍历比较,改进后的冒泡排序:
//如果从内循环减去外循环中已跑过的轮数,就可以避免内循环中所有不必要的比较
this.modifiedBubbleSort=function(){
var length=array.length;
for(var i=0;i<length;i++){
for(varj=0;j<length-1-i;j++){//从内循环减去外循环中已跑过的轮数
if(array[j]>array[j+1]){
swap(j,j+1);
}
}
}
};
2.选择排序
选择排序算法是一种原址比较排序算法。选择排序大致的思路是找到数据结构中的最小值并将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
时间复杂度为O(n^2)
this.selectionSort=function(){
var length=array.length,indexMin;
for(var i=0;i<length-1;i++){
indexMin=i;
for(var j=i;j<length;j++){
if(array[indexMin]>array[j]){
indexMin=j;
}
}
if(i!==indexMin){
swap(i,indexMin);
}
}
};
过程:
5,4,3,2,1
1,4,3,2,5 //从5开始找,找到最小值1,1和5交换
1,2,3,4,5 //从4开始找,找到最小值2,2和4交换
1,2,3,4,5 //从3开始找,找到最小值3,不交换
1,2,3,4,5 //从4开始找,找到最小值4,不交换
=>结束(遍历至length-1,为最后2位数的比较,因此不用遍历至最后一位)
3.插入排序
排序小型数组时,此算法比选择排序和冒泡排序性能要好。
插入排序每次排一个数组项,以此方式构建最后的排序数组。假定第一项已经排序了,接着,它和第二项进行比较,第二项是应该待在原位还是插到第一项之前呢?这样,头两项就已正确排序,接着和第三项比较(它是该插入到第一、第二还是第三的位置呢?),以此类推。
this.insertionSort=function(){
var length=array.length,j,temp;
for(var i=1;i<length;i++){//从第二个位置(索引 1 )而不是 0 位置开始的,我们认为第一项已排序了
j=i;
temp=array[i];
while(j>0&&array[j-1]>temp){//找到正确的位置来插入项目
j--;
}
array[j]=temp;
}
}
过程:
35142
--------- //j=1,从5开始,3<5跳出内循环,j=1,i=1
31542 //j=2,从1开始,5>1,j=1,i=2
13542 //j=1,从1开始,3>1,j=0跳出内循环,i=1
13452 //j=3,从4开始,5>4,j=2,i=3
--------- //j=2,从4开始,3<4跳出内循环,j=2,i=3
13425 //j=4,从2开始,5>2,j=3,i=4
13245 //j=3,从2开始,4>2,j=2,i=4
12345 //j=2,从2开始,3>2,j=1,i=4
--------- //j=1,从2开始,1<2跳出内循环,j=1,i=4
=>结束
4.归并排序
归并排序是第一个可以被实际使用的排序算法。前三个排序算法性能不好,但归并排序性能不错,其复杂度为O(nlog n)。
归并排序是一种分治算法。其思想是将原始数组切分成较小的数组,直到每个小数组只有一个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
是递归的。
this.mergeSort=function(){
array=mergeSortRec(array);
};
var mergeSortRec=function(array){
var length=array.length;
if(length===1){
return array;
}
var mid=Math.floor(length/2),
left=array.slice(0,mid),
right=array.slice(mid,length);
return merge(mergeSortRec(left),mergeSort(right));
};
var merge=function(left,right){//主要的比较
var result=[],il=0,ir=0;
while(il<left.length&&ir<right.length){//用来比较前length-1位数,左右数组进行比较
if(left[il]<right[ir]){
result.push(left[il++]);
}else{
result.push(right[ir++]);
}
}
//以下2个循环用来处理最后一位没有比较后放入result的数
while(il<left.length){
result.push(left[il++]);
}
while(ir<right.length){
result.push(right[ir++]);
}
return result;
};
归并排序将一个大数组转化为多个小数组直到只有一个项。由于算法是递归的,我们需要一
个停止条件,在这里此条件是判断数组的长度是否为 1 (行 {1} )。如果是,则直接返回这个长度
为 1 的数组(行 {2} ),因为它已排序了。
过程:
5.快速排序*
复杂度为O(nlog n )。
它的性能通常比其他的复杂度为O(nlog n )的排序算法要好。是最常用的排序算法。
将原始数组分为较小的数组(但它没有像归并排序那样将它们分割开)。
步骤:
- 首先,从数组中选择中间一项作为主元。
- 创建两个指针,左边一个指向数组第一个项,右边一个指向数组最后一个项。移动左指
针直到我们找到一个比主元大的元素,接着,移动右指针直到找到一个比主元小的元素,然后交
换它们,重复这个过程,直到左指针超过了右指针。这个过程将使得比主元小的值都排在主元之
前,而比主元大的值都排在主元之后。这一步叫作划分操作。 - 接着,算法对划分后的小数组(较主元小的值组成的子数组,以及较主元大的值组成的
子数组)重复之前的两个步骤,直至数组已完全排序。
代码实现:
this.quickSort=function(){
quick(array,0,array.length-1);
};
var quick=function(array,left,right){
var 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);
}
}
};
var partition=function(array,left,right){//划分过程
var pivot=array[Math.floor((right+left)/2)],//选择中间项作为主元
i=left,j=right;//left初始化为数组第一个元素;right初始化为数组最后一个元素
while(i<=j){
while(array[i]<pivot){
i++;
}
while(array[j]>pivot){
j--;
}
if(i<=j){
swapQuickSort(array,i,j);
i++;
j--;
}
}
return i;
};
var swapQuickSort=function(array,index1,index2){
var aux=array[index1];
array[index1]=array[index2];
array[index2]=aux;
};
二、搜索算法
1.顺序搜索
将每一个数据结构中的元素和我们要找的元素做比较。顺序搜索是最低效的一种搜索算法。
this.sequentialSearch=function(item){
for(var i=0;i<array.length;i++){
if(item===array[i]){
return i;
}
}
return -1;
};
2.二分搜索
二分搜索算法的原理和猜数字游戏类似,就是那个有人说“我正想着一个1到100的数字”的游戏。我们每回应一个数字,那个人就会说这个数字是高了、低了还是对了。
这个算法要求被搜索的数据结构已排序。以下是该算法遵循的步骤:
(1) 选择数组的中间值。
(2) 如果选中值是待搜索值,那么算法执行完毕(值找到了)。
(3) 如果待搜索值比选中值要小,则返回步骤1并在选中值左边的子数组中寻找。
(4) 如果待搜索值比选中值要大,则返回步骤1并在选种值右边的子数组中寻找。
this.binarySearch=function(item){
this.quickSort();
var low=0,
high=array.length-1,
mid,element;
while(low<=high){
mid=Math.floor((low+high)/2);
element=array[mid];
if(element<item){
low=mid+1;
}else if(element>item){
high=mid-1;
}else{
return mid;
}
}
return -1;
};
BinarySearchTree 类有一个 search 方法,和这个二分搜索完全一样,只不过它是针对树数据结构的。
第11章 算法补充知识
1.递归
它解决问题的各个小部分,直到解决最初的大问题。通常涉及函数调用自身。
每个递归函数都必须要有边界条件,即一个不再递归调用的条件(停止点),以防止无限递归。
1)JavaScript 调用栈大小的限制
如果忘记加上用以停止函数递归调用的边界条件,会发生什么呢?递归并不会无限地执行下去;浏览器会抛出错误,也就是所谓的栈溢出错误(stack overflow error)。
2)斐波那契数列
- 1和2的斐波那契数是 1;
- n(n>2)的斐波那契数是(n-1)的斐波那契数加上(n-2)的斐波那契数。
function fibonacci(num){
if(num===1||num===2){
return 1;
}
return fibonacci(num-1)+fibonacci(num-2);
}
试着找6的斐波那契数:
非递归方式实现斐波那契函数:
function fib(num){
var n1=1,n2=1,n=1;
for(var i=3;i<num;i++){
n=n1+n2;
n1=n2;
n2=n;
}
return n;
}
递归并不比普通版本更快,反倒更慢。但要知道,递归更容易理解,并且它所需的代码量更少。
【ES6中,因为尾调用优化的缘故,递归并不会更慢。但是在其他语言中,递归通常更慢。】
2.动态规划
动态规划(Dynamic Programming,DP)是一种将复杂问题分解成更小的子问题来解决的优化技术。
用动态规划解决的一个问题是图的深度优先搜索。
1)与分治的区别
要注意动态规划和分而治之(归并排序和快速排序算法中用到的那种)是不同的方法。分而治之方法是把问题分解成相互独立的子问题,然后组合它们的答案,而动态规划则是将问题分解成相互依赖的子问题。
2)使用动态规划步骤
- 定义子问题;
- 实现要反复执行而解决子问题的部分(参考递归的步骤);
- 识别并求解出边界条件。
3)解决的著名问题
- 背包问题:给出一组项目,各自有值和容量,目标是找出总值最大的项目的集合。这个问题的限制是,总容量必须小于等于“背包”的容量。
- 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变余下元素的顺序而得到)。
- 矩阵链相乘:给出一系列矩阵,目标是找到这些矩阵相乘的最高效办法(计算次数尽可能少)。相乘操作不会进行,解决方案是找到这些矩阵各自相乘的顺序。
- 硬币找零:给出面额为d1 …dn 的一定数量的硬币和要找零的钱数,找出有多少种找零的方法。
- 图的全源最短路径:对所有顶点对(u, v),找出从顶点u到顶点v的最短路径。
4)最少硬币找零问题
是硬币找零问题的一个变种。
硬币找零问题是给出要找零的钱数,以及可用的硬币面额d 1 …d n 及其数量,找出有多少种找零方法。
最少硬币找零问题是给出要找零的钱数,以及可用的硬币面额d 1 …d n 及其数量,找到所需的最少的硬币个数。
最少硬币找零的解决方案是找到n所需的最小硬币数。但要做到这一点,首先得找到对每个x<n的解。然后,将解建立在更小的值的解的基础上。
function MinCoinChange(coins){
//传参coins代表问题中硬币系统的面额,对美国的硬币系统而言,它是 [1, 5, 10, 25]
var coins=coins;
var cache={};
this.makeChange=function(amount){//解决找零问题
var me=this;
if(!amount){//递归边界点
return [];
}
if(cache[amount]){
return cache[amount];
}
//重点
var min=[],newMin,newAmount;
for(var i=0;i<coins.length;i++){
var coin=coins[i];
newAmount=amount-coin;
if(newAmount>=0){
newMin=me.makeChange(newAmount);//递归实现嵌套遍历硬币面额
}
if(
newAmount>=0
&&(newMin.length<min.length-1||!min.length)
&&(newMin.length||!newAmount)
){
min=[coin].concat(newMin);
console.log('new Min '+min+' for '+amount);
}
}
return cache[amount]=min;//返回值包含用来找零的各个面额的硬币数量(最少硬币数)
};
}
3.贪心算法
贪心算法遵循一种近似解决问题的技术,期盼通过每个阶段的局部最优选择(当前最好的解),从而达到全局的最优(全局最优解)。
1)最少硬币找零问题
最少硬币找零问题也能用贪心算法解决。大部分情况的结果是最优的,不过对有些面额而言,结果不会是最优的。
function MinCoinChange(coins){
var coins=coins;
this.makeChange=function(amount){
var change=[],total=0;
for(var i=coins.length;i>=0;i--){
var coin=coins[i];
while(total+coin<=amount){
change.push(coin);
total+=coin;
}
}
return change;
};
}
比起动态规划算法而言,贪心算法更简单、更快。然而,如我们所见,它并不总是得到最优答案。但是综合来看,它相对执行时间来说,输出了一个可以接受的解。
4.大O表示法
描述算法的性能和复杂程度。
讨论大O表示法时,一般考虑的是CPU(时间)占用。
1)O(1)
function increment(num){
return ++num;
}
假设运行 increment(1) 函数,执行时间等于X。如果再用不同的参数(例如 2 )运行一次increment 函数,执行时间依然是X。和参数无关, increment 函数的性能都一样。因此,我们说上述函数的复杂度是O(1)(常数)。
2)O(n)
//顺序搜索算法
function sequentialSearch(array, item){
for (var i=0; i<array.length; i++){
if (item === array[i]){ //{1}
return i;
}
}
return -1;
}
最坏情况下,如果数组大小是10,开销就是10;如果数组大小是1000,开销就是1000。可以得出该函数的时间复杂度是O(n),n是(输入)数组的大小。
3)O(n^2)
//冒泡排序
function bubbleSort(array){
var length = array.length;
for (var i=0; i<length; i++){ //{1}
for (var j=0; j<length-1; j++ ){ //{2}
if (array[j] > array[j+1]){
swap(array, j, j+1);
}
}
}
}
如果用大小为10的数组执行 bubbleSort ,开销是100(10^2)。
【时间复杂度O(n)
的代码只有一层循环,而O(n^2 )
的代码有双层嵌套循环。如果算法有三层遍历数组的嵌套循环,它的时间复杂度很可能就是O(n^3 )
。】