一,为什么要学习数据结构
前端开发的进阶。高级工程师的象征。
了解数据结构可以在开发中有效的利用合适的数据结构来处理代码,可以合理的利用内存、性能等,同时可以处理一些复杂的逻辑。
二,数据结构分类
js中自带的最简单的数据结构:数组Array、集合Set
类似数组的基础的数据结构:栈、队列
带有节点的数据结构:链表、树、图
依赖哈希函数的数据结构:哈希表(字典、散列)
三,数组Array
结构:
js的数组是最简单的一种线性表的数据结构,他是用一组连续的空间来存储数据的。
特点:
1,他的访问是高效的,可以通过索引来访问
2,他的插入和删除是低效的,因为插入或者删除一个值后,要将后面的所有的值都向前/向后移动一个位置
四,集合Set
结构:
ES6新增的Set是一个无序的,没有重复值的数据集合,他类似堆栈和队列数据结构。也可以与数学中的集合相对比(二者类似)
特点:
1,,Set采用了自己独特的算法来判断两个值是否相等,而非===。可以用在对特定列表执行比较和判断是否相等时
2,Set的存储空间占用比Array要少,同时Array和Set的转换也比较简单,因此可以用到
Array
的场合可以优先考虑一下Set
3,支持类似数学中的集合操作,求交集、并集、差集、判断子集
(交集:A and B共同;并集:A + B所有;差集:only in A或only in B)
五,栈(Stack)
先进后出(LIFO)的有序集合。新的元素和待删除的元素都在栈顶,老的元素都在栈底。
(类似于叠起来的一堆书,要想拿最底下的一本书,需要将上面的书一个一个拿掉)
js中可以利用数组的push和pop方法来模拟实现一个栈:
class Stack {
constructor() {
this.items = [];
}
// 入栈
set(item) {
this.items.push(item);
}
// 出栈
remove() {
this.items.pop();
}
// 获取栈顶元素
get() {
return this.items[this.items.lnegth];
}
size() {
return this.items.length;
}
// ... ...
};
// 调用栈
let s = new Stack();
s.push('hello');
s.push('world');
console.log(s.get());
s.remove();
console.log(s.get());
六,队列(Queue)
与栈相反,是先进先出(FIFO)的有序集合。(比如打饭的排队)
在js中同样可以利用数组的原生方法(push()、shift())来模拟实现。
针对默认的队列也增加了两个修改版本的队列。
优先队列:
元素添加和移除的时候设置优先级。(比如医院排队,孕妇和老人优先通道...)
循环队列:
为了充分利用空间,克服"假溢出"现象的方法,将队列的首尾相连,想象成一个圆环。(例如队列一共10个元素,访问索引11,即为访问第一个元素)
七,链表(Linked List)
与数组类似,也是一个有序的元素集合,不同的是链表的元素在内存中的存储并不是连续的,每个元素都由当前的节点和下一个节点的引用(指针)组成。
特点:
1,在链表中插入和删除数据常量的时间,因为只更改指针即可;而在数据中插入或删除数据需要线性时间,因为后现的所有元素都需要移位。
2,数组访问一个元素可以直接访问到任何位置的元素;而链表访问一个元素,需要从头开始遍历,直到找到要找的元素。
总结:
综合实际情况,访问数据更多可以用数据结构,操作数据(插入删除)更多,可以用链表
js同样可以通过用object设置current、next等引用属性值,来模拟实现一个链表。
// eg:追加一个元素
append(element) {
const node = new Node(element)
let current = null
if (this.head === null) { // 如果链表还没有第一个元素,则当前元素赋值为第一个元素head
this.head = node;
} else {
current = this.head; // 从头遍历
while(current.next) {
current = current.next; //一直找到最后一个元素(没有指向下一个元素的指针next)
}
current.next = node; // 赋值为下一个元素
}
this.length++;
}
// eg:在指定位置插入一个元素
insert(position, element) {
if (0 < position <= this.items.length) {
const node = new Node(element); // 要插入的元素节点
let current = this.head; // 设置当前的元素
let previous = null; // 设置上一个元素(从头遍历)
let index = 0;
if (index === 0) { // 如果在第一个位置插入,则直接赋值为head元素
this.head = node;
} else {
while (i++ < position) { // 循环遍历到指定位置
privious = current; // 将当前的值赋值给上一个元素
current = current.next; // 将当前值的下一个元素赋值给当前元素
}
// 找到了目标位置,修改目标位置的上一个元素和下一个元素的引用
node.next = current; // 将当前节点的下一个元素引用赋值为当前元素
privious.next = node; // 将当前节点赋值给上一个元素的next引用
}
this.length++;
return true;
}
return false;
}
实际应用中,在此基础上链表又细分了双向链表和循环链表。
双向链表:就是每个节点同时又包含一上一个元素的引用。他的优势是可以支持倒着迭代链表。
循环链表:他与普通链表的唯一区别就是最后一个元素的next不是null,而是指向第一个元素head。循环链表同时也支持单项链表和双向链表。
八,树(Tree)
概念:
树是一种非顺序的数据结构,类似于数组,但是他的父节点同时有多个子节点的引用。js中类似的树场景有html标签、原型继承、React组件。生活中的例子有公司组织架构图、族谱等。
细分:
二叉树:
一种特殊的树结构,他的节点最多有2个子节点(左侧子节点、右侧子节点)。二叉树可以帮我们写出更高效的插入、删除、查询元素的算法,在计算机方面应用比较广泛。
二叉搜索树:
属于二叉树的一种。左侧子节点的值必须<=父节点的值;右侧子节点的值必须>父节点的值。
注意:
在树中,节点称为“键”,而不是“项”。
树的遍历:
通常我们要访问树的某一个节点就需要通过遍历先找到节点。这里的遍历类别可以分为水平方向和垂直方向两大类。遍历方式可以分为中序遍历、先序遍历和后序遍历3种方式。
中序:按照从小到大的顺序访问所有节点(左子节点>父节点>右子节点)
先序:先访问根节点,再访问叶子节点(父节点>左子节点>右子节点)
后序:先访问叶子节点,再访问根节点(左子节点>右子节点>根节点)
垂直方向(DPF-深度遍历):深度遍历通常使用递归算法。
// eg: 递归实现先序遍历 function preOrderTree(callback) { const orders = (node, callback) => { callback(node); // 先执行父节点 orders(node.left, callback); // 递归执行左子节点 orders(node.right, callback); // 递归执行右子节点 } orders(this.root, callback); // 开始执行递归函数 }
水平方向(BFT-广度遍历):广度遍历,例如树的形状如果大于深度,通常使用迭代算法。这需要一个队列来追踪每次迭代的所有子节点。(迭代遍历可以利用js的数组来模拟实现)
// eg: 迭代实现先序遍历 function preOrderTree(root, res = []) { const stack = []; // 定义一个空栈 if (root) { stack.push(root); // 如果根节点不为空,则压入栈,然后进入循环 } // 先序遍历的顺序是中、左、右;因此入栈的顺序便是右、左、中 while(stack.length) { const node = stack.pop(); // 取出栈顶元素 if(!node) { // 栈顶元素为空,就取出前一个元素值放入数组,进行下一次循环 res.push(stack.pop().val); continue; } // 如果栈顶元素不为空,就先压入栈右子节点 if (node.right) stack.push(node.right); // 再压入栈左子节点 if (node.left) stack.push(node.left); // 再压入栈当前节点 stack.push(node); // 最后压入空节点,为了让叶子节点成为下一次循环的中间节点,保证先入栈中节点 stack.push(null); }; return res; // 直到stack栈为空,结束循环,返回数组res }
九,图(Graph)
概念:
图是网络结构的抽象模型,图是由一组边连接的节点。如果一个树可以自由的拥有多个父节点,那么这个树也变成了图。
特点:
任何二元关系都可以用图来表示,一个多对多的关系。(比如各种社交网络、互联网本身、人的大脑等等)
相关概念:
相邻顶点:由一条边连接一起的顶点称为相邻顶点。
顶点的度:一个顶点的度是他相邻顶点的数量(比如A点和B、C、D三个顶点相邻,则A的度是3)
有向图和无向图:图可以是无向的(边没有方向)和有向的(无向图)
加权图与未加权图:大部分的图都是未加权图。如果边被赋予了权值,则为加权图
图的实现方法:
图的数据结构有很多种实现方法,具体取决于要解决的问题类型。常见的有邻接矩阵,邻接表,关联矩阵。
邻接矩阵:(比较常见)通常用二维数组表示两个节点是否相邻,比如i和j节点相邻,则arr[i][j] === 1;不相邻,则arr[i][j] === 0;但对于稀疏的图(非强链接)就会有很多0冗余浪费内存。
邻接表:(大多数问题下很常用)由每个顶点和当前顶点的相邻顶点组成(可以用数组、链表、字典来表示)。
关联矩阵:(用在顶点数量比边多的情况)矩阵的行表示顶点,列表示边。如果顶点 v 是边 e 的入射点,则
array[v][e] === 1
; 否则,array [v][e] === 0
。
js模拟图的实现大概思路如下:
class Graph{
constructor() {
this.vertList = []; // 存放数组第一维度的顶点列表(可以知道一共有多少个顶点)
this.adjList = new Dictionary(); // 假设定义了一个字典类,实例化一个字典列表
}
// 添加顶点
addVert(v) {
this.vertList.push(v); // push到顶点列表中
this.adjList.set(v, []); // 字典中对该顶点设置空的相邻顶点列表
}
// 添加边(就是关联两个相邻顶点)
addEdge(pointA, pointB) {
this.adjList.get(pointA).push(pointB); // A顶点的相邻顶点列表中加入B顶点
this.adjList.get(pointB).push(pointA); // B顶点的相邻顶点列表中加入A顶点
}
// 打印所有顶点的关系
toString() {
this.vertList.reduce((prevV, currV, index) => {
// 当前顶点的相邻顶点字典列表
const currAdjList = this.adjList.get(currV);
// 为相邻顶点列表的reduce循环设置初始值为当前顶点
const initAdVal = `${prevV}\n ${currV} => `;
// 遍历并获取相邻顶点列表的各个顶点
currAdjList.reduce((prevAd, currAd, indexAd) => {
return `${prevAd}${currAd} `;
}, initAdVal);
});
}
}
// 调用
const graph = new Graph();
['A', 'B', 'C', 'D', 'E'].forEach((item) => {
graph.addVert(item); // 添加顶点
});
graph.addEdge('A', 'B'); // 为A顶点添加边
graph.addEdge('A', 'C');
graph.addEdge('A', 'D');
graph.addEdge('C', 'D');
graph.addEdge('C', 'E');
console.log(graph.toString());
/**
* 输出
* A => B C D
* C => D E
*/
图的遍历:
图和树的遍历类似,都有BFS(广度)和DFS(深度)。
具体的实践如果用到可自行学习网上参考资料。
十,哈希表(Hash Table)
概念:
理解:哈希表是类似于字典的一种结构,使用key、value的形式存储数据。每个键值对的存储位置由哈希函数来决定。
js原生的Set集合其实也属于哈希表的一种。其他哈希表还有字典(映射)、散列。js中的Map数据结构和Object都是哈希表的一种实现。
原理:
哈希表是基于数组实现的。而哈希表是利用哈希函数将一个单词转换成了哈希化之后的数字,而把这个数字做成类似数组的下标。而我们访问一个值,比如名字,实际上这个对象同时也存储了下标的值,因此就可以通过这个名字直接找到对应的下标,从而访问数据。
特点:
优点:
1,哈希表是为了解决数组的一些缺点来实现的数据结构。比如数组在已知下标的索引可以快速检索到值,但是如果下标未知(比如只知道员工的姓名,来查员工的信息),数组的性能就不及哈希表。
2,对于删除、插入数据,数组的效率也比较低,但是哈希表进行插入、删除等操作效率就非常高
缺点:
1,哈希表的key值不能重复
2,数据是无序的,不能被遍历。
3,不能快速的找到最大、最小值
4,空间利用率不高,底层使用的数组可能并不是每个单元都被利用了
分类:
字典:
如果说集合是{value: value}的形式存储,而字典是{key: value}的形式存储。js中的object对象就是典型的一个字典的实现。
散列:
HashTable 类,也叫 HashMap 类。他提供了一个散列算法, 用来快速的获取一个值,实现方式就如上述原理描述,不需要遍历。
js模拟实现字典:和封装一个对象类基本一致。
js模拟实现散列:js中模拟散列HashTable类,就可以封装一个静态函数作为散列函数,将一个字符串转换成一个唯一的数值索引。注意的是在实现中删除元素,不要实际去从数组中删除,因为会造成元素移动。
class HashTable{
constructor() {
this.table = [];
}
// 散列函数
static hashCodeClum(key) {
let hash = 0;
for (let codePoint of key) {
hash += codePoint.charCodeAt()
}
return hash % 37;
}
// 添加和修改元素
put(key, value) {
const position = HashTable.hashCodeClum(key); // 通过散列函数获取索引
this.table[position] = value;
}
// 获取元素
get(key) {
const position = HashTable.hashCodeClum(key);
return this.table[position];
}
// 删除元素
remove(key) {
const position = HashTable.hashCodeClum(key);
this.table[position] = undefined;
}
}
散列表和散列集合:
散列表与散列一样,而散列集合是一个集合构成,也是使用的散列函数。
如果出现相同的散列值,就需要解决这种冲突通,常有两种方式:
分离链接(为散列表的每一个位置创建一个链表并将元素存储在里面)
线性探查(插入一个元素时,如果索引为 index 的位置已经被占据了,就尝试 index+1的位置,以此类推)