数据结构和算法对于开发人员来说一直是一块难啃的骨头 ( 也可能只是对于我来说哈哈哈 )
所以是时候系统的对这些知识进行回顾学习啦~~
数据结构的类型非常多,并且对于前端而言,研究提及率最多的数据结构和算法就已经足够。
准备工作
学习这种偏理论性的知识,正确的学习方式非常有必要,那么究竟该用什么样的方法去准备呢?
-
全面了解
在学习之前,对数据结构和算法的定义、分类做一个全面的理解,不然之后在做题时将完全不知道在做什么,从而陷入盲目寻找答案的过程,这个过程非常痛苦且往往收益甚微。
-
分类练习
在开始练习之前,最好对这种具体的类别进行一个详细的了解, 对其具体的定义、相关的概念和应用、可能出现的题目类型进行梳理,然后再开始。 并且进行分类练习,即按每种类别练习,例如:这段时间只练习二叉树的题目,后面开始练习回溯算法的题目。
-
定期回顾总结
在对一个类型针对练习一些题目之后,总结规律,什么样的题目用什么样的解法。对典型的题目和发现的解题方法,进行总结。当下次再遇到这种类型的题目,就能很快想到解题思路。
大道理说完了接下来就是理论学习啦~
时间复杂度和空间复杂度
在学习数据结构和算法之前,我们首先要知道时间复杂度和空间复杂度的概念,它们的高低共同决定着一段代码的质量。
时间复杂度
一个算法的时间复杂度反映了程序运行从开始到结束所需要的时间。把算法中基本操作重复执行的次数(频度)作为算法的时间复杂度。
我们一般把没有循环语句的代码计作 o(1),也叫 常数阶。
只有一重循环,则算法基本操作的执行频度与问题规模 n 呈线性增大关系,计作 o(n),也叫 线性阶。
常见的空间复杂度有:(按照空间复杂度从小到大排序)
- O(1) —— 常数复杂度
- O(log n) —— 对数复杂度
- O(n) —— 线性时间复杂度
- O(n^2) —— 平方阶
- O(n^3) —— 立方阶
- O(2^n) —— 指数阶
- O(n!) —— 阶乘复杂度
上图:
其中x轴代表n值,y轴代表T(n)(时间复杂度)。
T(n)值随着n的值的变化而变化,其中可以看出O(n!)和O(2ⁿ)随着n值的增大,它们的T(n)值上升幅度非常大,而O(logn)、O(n)、O(nlogn)随着n值的增大,T(n)值上升幅度则很小。
空间复杂度
一个程序的空间复杂度是指运行完一个程序所需内存的大小。
利用程序的空间复杂度,可以对程序的运行所需要的内存多少有个预估。
我们平时的程序,除了需要存储空间和存储本身所使用的指令、常数、变量和输入数据外。
还需要一些对数据进行操作的工作单元和存储一些为现实计算所需信息的辅助空间。
什么是数据机构
数据结构这个词我们都不陌生,那 数据结构 到底是什么?
数据结构是指相互之间存在一种或多种特定关系的数据元素的集合
我们可以从两个维度来理解数据结构
-
逻辑结构 —— 反映数据之间的逻辑关系,主要分为:
-
线性结构:是一个有序数据元素的集合。其中数据元素之间的关系是一对一的关系,即除了第一个和最后一个数据元素之外,其它数据元素都是首尾相接的。
比如:栈,队列,链表,线性表
-
非线性结构:各个数据元素不再保持在一个线性序列中,每个数据元素可能与零个或者多个其他数据元素发生联系。
比如:二维数组,树
-
-
存储结构:逻辑结构用计算机语言的表示,主要分为:
- 顺序存储:在内存中的位置是连续的,比如 数组
- 链式存储:数据间有关联关系,在内存中却不一定连续,比如 链表
- 散列存储:顺序和逻辑上都不存在顺序关系,但是可以通过一定的方式去访问它,比如 哈希 Map
- 索引存储: -
数据结构 - 数组
定义
数组是一个有序的数据集合,我们可以通过数组名称 (name) 和索引 (index) 进行访问。index 从 0 开始访问
特点
-
数组是用一组连续的内存空间来存储的。所以数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
-
低效的插入和删除。数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效,因为底层通常是要进行大量的数据搬移来保持数据的连续性
插入与删除的时间复杂度如下:
插入:从最好 O(1) ,最坏 O(n) ,平均 O(n)
删除:从最好 O(1) ,最坏 O(n) ,平均 O(n)
注意
由于 JavaScript 是弱类型的语言,弱类型则允许隐式类型转换。所以数组的每一项可以是不同的类型,
const arr = [ 12, 34, "abc" ]
且定义的数组的大小是可变的,不像强类型语言,定义某个数组变量的时候就要定义该变量的大小。
实现
JavaScript 原生支持数组,且提供了很多操作方法,没有太必要展开讲。
数据结构 - 栈
定义
后进先出 LIFO - Last in First out
新添加的或待删除的元素都保存在栈的末尾,称作栈顶,另一端就叫栈底。
从栈的操作特性来看,是一种操作受限的线性表,只允许在一端插入和删除数据。
我们把不包含任何元素的栈称为空栈。
实现
根据栈的特性我们可以得到栈有以下方法:
- push(element) —— 添加一个或多个新元素到栈顶
- pop() —— 移除栈顶的元素,同时返回被移除的元素
- peek() —— 返回栈顶的元素,不对栈做任何修改
- isEmpty() —— 如果栈中没有任何元素返回 true 否则返回 false
- clear() —— 移除栈中的所有元素
- size() —— 返回栈中元素的个数
- print() —— 打印栈中的元素
// Stack 类
function Stack() {
this.items = [];
// 添加新元素到栈顶
this.push = function (element) {
this.items.push(element);
}
// 移除栈顶的元素,同时返回被移除的元素
this.pop = function () {
return this.items.pop();
}
// * peek() —— 返回栈顶的元素,不对栈做任何修改
this.peek = function () {
return this.items[this.items.length - 1];
}
// * isEmpty() —— 如果栈中没有任何元素返回 true 否则返回 false
this.isEmpty = function () {
return this.items.length === 0;
}
// * clear() —— 移除栈中的所有元素
this.clear = function () {
this.items = [];
}
// * size() —— 返回栈中元素的个数
this.size = function () {
return this.items.length;
}
// 打印栈中的元素
this.print = function () {
console.log(this.items.toString());
}
}
实现完了,接下来就是测试啦~~
let stack = new Stack(); // undefined
console.log(stack.isEmpty()); // true
stack.push(2); // undefined
stack.push(5); // undefined
stack.push(0); // undefined
stack.push(7); // undefined
stack.peek(); // 7
stack.pop(); // 7
stack.print(); // 2,5,0
stack.size(); // 3
stack.isEmpty(); // false
stack.clear(); // undefined
stack.isEmpty(); // true
应用
由于栈的特点是后进先出,和我们平时用到的浏览器的前进后退十分类似,所以完全可以实用栈来实现一个前端路由:
数据结构 - 队列
定义
队列是遵循先进先出 FIFO - First In First Out 原则的一组有序的项 队列在尾部添加新元素,并从顶部移除元素。最新添加的元素必须排在队列的末尾。 队列只有 入队 push() 和出队 pop()。
实现
我们可以实现一些队列的方法,就像栈那样~~
- enqueue(element) —— 向队列尾部添加新项
- dequeue() —— 移除队列的第一项,并返回被移除的元素
- front() —— 返回队列中第一个元素,队列不做任何变动
- isEmpty() —— 如果队列中没有任何元素返回 true 否则返回 false
- clear() —— 移除队列中的所有元素
- size() —— 返回队列中元素的个数
- print() —— 打印队列中的元素
// 队列
function Queue() {
this.items = [];
// * enqueue(element) —— 向队列尾部添加新项
this.enqueue = function (element) {
this.items.push(element);
}
// * dequeue() —— 移除队列的第一项,并返回被移除的元素
this.dequeue = function () {
return this.items.shift();
}
// * front() —— 返回队列中第一个元素,队列不做任何变动
this.front = function () {
return this.items[0];
}
// * isEmpty() —— 如果队列中没有任何元素返回 true 否则返回 false
this.isEmpty = function () {
return this.items.length === 0;
}
// * clear() —— 移除队列中的所有元素
this.clear = function () {
this.items = [];
}
// * size() —— 返回队列中元素的个数
this.size = function () {
return this.items.length;
}
// * print() —— 打印队列中的元素
this.print = function () {
console.log(this.items.toString());
}
}
实现完了,接下来就是测试啦~~
let queue = new Queue(); // undefined
queue.size(); // 0
queue.isEmpty(); // true
queue.enqueue(1); // undefined
queue.enqueue(8); // undefined
queue.enqueue(0); // undefined
queue.enqueue(2); // undefined
queue.print(); // 32 1,8,0,2
queue.front(); // 1
queue.dequeue(); // 1
queue.size(); // 3
queue.clear(); // undefined
queue.isEmpty(); // true
基础队列总结完啦,接下来我们需要知道的是队列的分类:优先队列 / 循环队列 / 阻塞队列 等等,这里我们介绍 优先队列 / 循环队列
优先队列
定义:优先队列中的元素的移除和添加是依赖于 优先级 的。
打个比方说:机场登机的顺序,头等舱和商务舱乘客的优先级要高于经济舱乘客。 火车,老年人、孕妇和带小孩的乘客是享有优先检票权的。
优先队列的分类:最小优先队列 / 最大优先队列
最小优先队列是把优先级的值最小的元素被放置到队列的最前面(代表最高的优先级)。 比如:有四个元素:"John", "Jack", "Camila", "Tom",他们的优先级值分别为 4,3,2,1。 那么最小优先队列排序应该为:"Tom","Camila","Jack","John"。
最大优先队列正好相反,把优先级值最大的元素放置在队列的最前面。 以上面的为例,最大优先队列排序应该为:"John", "Jack", "Camila", "Tom"。
实现一个优先队列,有两种选项:
- 设置优先级,根据优先级正确添加元素,然后和普通队列一样正常移除
- 设置优先级,和普通队列一样正常按顺序添加,然后根据优先级移除
这里最小优先队列和最大优先队列都采用第一种方式实现,第二种可以另外尝试。
下面只重写 enqueue() 方法和 print() 方法,其他方法和上面的普通队列完全相同。
实现最小优先队列 和 最大优先队列
实现最小优先队列 和 最大优先队列的 enqueue() 方法和 print() 方法:
// 定义最大最小优先队列
function MinPriorityQueue () {
this.items = [];
this.enqueue = enqueue;
this.print = print;
// 其他方法和普通队列相同Ï
}
// 优先队列添加元素,要根据优先级判断在队列中的插入顺序
function enqueue(element, priority) {
let queueElement = {
element: element,
priority: priority
};
if (this.isEmpty()) {
this.items.push(queueElement);
} else {
let added = false;
for (let i = 0; i < this.size(); i++) {
// if (queueElement.priority > this.items[i].priority) { // 最大优先队列
if (queueElement.priority < this.items[i].priority) { // 最小优先队列
// 自行选择,最大优先队列和最小优先只有这里不一样
this.items.splice(i, 0, queueElement);
added = true
break;
}
}
if (!added) {
this.items.push(queueElement);
}
}
}
// 打印队列里面的元素
function print() {
let srArr = [];
strArr = this.items.map(item => {
return `${item.element} -> ${item.priority}`;
});
console.log(strArr.toString());
}
// 自行测试~~
循环队列
定义:循环队列,顾名思义,它长得像一个环,可以把它想像成一个圆的钟。
循环队列的一个比较常见的例子就是击鼓传花,在这个游戏中,人们围成一个圆圈,击鼓的时候把花尽快的传递给旁边的人。某一时刻击鼓停止,这时花在谁的手里,谁就退出圆圈直到游戏结束。重复这个过程,直到只剩一个人(胜者)。
所以我们可以在普通队列的基础上增加一个击鼓传花的实现例子:
function hotPotato (nameList, num) {
let queue = new Queue();
for (let i = 0; i < nameList.length; i++) {
queue.enqueue(nameList[i]);
}
var eliminated = '';
while (queue.size() > 1) {
// 循环 num 次, 队首出来去到队尾
for (let i = 0; i < num; i++) {
queue.enqueue(queue.dequeue());
}
// 循环 num 次后,移除当前队首的元素
eliminated = queue.dequeue();
console.log(`${eliminated}在击鼓传花中被淘汰`);
}
// 只剩最后一个元素
return queue.dequeue();
}
// 测试
let nameList = ['Echo', 'Jose', 'Xin~', 'MaoMao', 'Zero', 'Luluco'];
let winner = hotPotato(nameList, 10);
console.log(`最后的胜利者是:${winner}`);
数据结构 - 链表
简介
- 链表是存储有序的元素集合,但不同于数组,链表中的元素在内存中并不是连续放置的,它是通过
指针将零散的内存块串连起来的。 - 每个元素由一个存储元素本身的 节点 和一个指向下一个元素的 引用(也称指针或链接)组成。
如下图所示~
上图中,我们说 data2 跟在 data1 后面,而不是说 data2是链表中的第二个元素。
链表的尾元素指向了 null 节点,表示链接结束的位置。
链表的特点:
- 链表是通过指针将零散的内存块串连起来的—— 所以链表不支持 随机访问,如果要找特定的项,只能从头开始遍历,直到找到某个项。 所以访问的时间复杂度为 O(n)。
- 高效的插入和删除 —— 链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的,只需要考虑相邻结点的指针改变。 所以,在链表中插入和删除一个数据是非常快速的,时间复杂度为 O(1)。
链表的种类: 单链表 (上面我们举例的就是单链表) / 双向链表 / 循环链表
单链表
实现单链表我们需要以下两个关键数据:
- Node 类 —— 用来表示节点。
- LinkedList 类 —— 提供插入节点、删除节点等一些操作。
单链表的常用操作有以下:
- append(element) —— 尾部添加元素。
- insert(position, element) —— 特定位置插入一个新的项。
- removeAt(position) —— 特定位置移除一项。
- remove(element) —— 移除一项。
- indexOf(element) —— 返回元素在链表中的索引。如果链表中没有该元素则返回 -1。
- isEmpty() —— 如果链表中不包含任何元素,返回 true,如果链表长度大于 0,返回 false。
- size() —— 返回链表包含的元素个数,与数组的 length 属性类似。
- getHead() —— 返回链表的第一个元素。
- toString() —— 由于链表使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString() 方法,让其只输出元素的值。
- print() —— 打印链表的所有元素。
实现
function SinglyLinkedList () {
// 节点
function Node (element) {
this.element = element; // 当前节点的元素
this.next = null; // 下一个节点指针
}
let length = 0; // 链表长度
let head = null; // 链表头部节点
// 向链表尾部添加一个新的节点
this.append = function (element) {
let node = new Node(element);
let currentNode = head;
// 判断是否是空链表
if (head === null) {
// 空链表,把当前节点当作头部节点
head = node;
} else {
// 从head开始一直找,直到找到最后一个node
while (currentNode.next) {
// 后面还有node
currentNode = currentNode.next;
}
currentNode.next = node;
}
// 链表长度+1
length++;
};
// 向链表的特定位置增加一个新节点
this.insert = function (position, element) {
if (position < 0 || position > length) {
// 越界
return false;
} else {
let node = new Node(element);
let index = 0;
let currentNode = head;
let previousNode = null;
if (position === 0) {
// 在最前面插入节点
node.next = currentNode;
head = node;
} else {
// 循环找到位置
while (index < position) {
index++;
previousNode = currentNode;
currentNode = currentNode.next;
}
// 把前一个节点的指针指向新节点,新节点的指针指向当前节点,保持连接性
previousNode.next = node;
node.next = currentNode;
}
length++;
return true;
}
};
// 从链表的特定位置移除一项
this.removeAt = function (position) {
if ((position < 0 && position >= length) || length === 0) {
// 越界
return false;
} else {
let currentNode = head;
let index = 0;
let previousNode;
if (position === 0) {
head = currentNode.next;
} else {
// 循环找到位置
while (index < position) {
index++;
previousNode = currentNode;
currentNode = currentNode.next;
}
// 把当前节点的 next 指针 指向 当前节点的 next 指针,即是 删除了当前节点
previousNode.next = currentNode.next;
}
length--;
return true;
}
};
// 从链表中移除指定项
this.remove = function (element) {
let index = this.indexOf(element);
return this.removeAt(index);
};
// 返回元素在链表的索引,如果链表中没有该元素则返回 -1
this.indexOf = function (element) {
let currentNode = head;
let index = 0;
while (currentNode) {
if (currentNode.element === element) {
return index;
}
index++;
currentNode = currentNode.next;
}
return -1;
};
// 如果链表中不包含任何元素,返回 true,如果链表长度大于 0,返回 false
this.isEmpty = function() {
return length === 0;
};
// 返回链表包含的元素个数,与数组的 length 属性类似
this.size = function() {
return length;
};
// 获取链表头部元素
this.getHead = function() {
return head.element;
};
// 由于链表使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString() 方法,让其只输出元素的值
this.toString = function() {
let currentNode = head;
let string = '';
while (currentNode) {
string += ',' + currentNode.element;
currentNode = currentNode.next;
}
return string.slice(1);
};
// 打印链表数据
this.print = function() {
console.log(this.toString());
};
// 获取整个链表
this.list = function() {
console.log('head: ', head);
return head;
};
}
// 测试
let singlyLinked = new SinglyLinkedList();
console.log(singlyLinked.removeAt(0)); // false
console.log(singlyLinked.isEmpty()); // true
singlyLinked.append('Tom');
singlyLinked.append('Peter');
singlyLinked.append('Paul');
singlyLinked.print(); // "Tom,Peter,Paul"
singlyLinked.insert(0, 'Susan');
singlyLinked.print(); // "Susan,Tom,Peter,Paul"
singlyLinked.insert(1, 'Jack');
singlyLinked.print(); // "Susan,Jack,Tom,Peter,Paul"
console.log(singlyLinked.getHead()); // "Susan"
console.log(singlyLinked.isEmpty()); // false
console.log(singlyLinked.indexOf('Peter')); // 3
console.log(singlyLinked.indexOf('Cris')); // -1
singlyLinked.remove('Tom');
singlyLinked.removeAt(2);
singlyLinked.print(); // "Susan,Jack,Paul"
singlyLinked.list(); // 具体控制台
那么,问题来了,整个链表数据在 JavaScript 里是怎样的呢 ? 我们可以直接看内置在singlyLinked中的list函数:
所以,在 JavaScript 中,单链表的真实数据有点类似于对象,实际上是 Node 类生成的实例。
双向链表
定义
刚刚我们介绍的单链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。
而双向链表,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
双向链表和单链表的比较
- 双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
- 双向链表提供了两种迭代列表的方法:从头到尾,或者从尾到头。我们可以访问一个特定节点的下一个或前一个元素。
- 在单向链表中,如果迭代链表时错过了要找的元素,就需要回到链表起点,重新开始迭代。
- 在双向链表中,可以从任一节点,向前或向后迭代,这是双向链表的一个优点。
- 所以,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。
实现
// 创建双向链表 DoublyLinkedList 类
function DoublyLinkedList () {
function Node (element) {
this.element = element; //当前节点的元素
this.next = null; //下一个节点指针
this.previous = null; //上一个节点指针
}
let length = 0; // 链表长度
let head = null; // 链表头部
let tail = null; // 链表尾部
// 向链表尾部添加一个新的项
this.append = function (element) {
let node = new Node(element);
let currentNode = tail;
// 判断是否为空链表
if (currentNode === null) {
// 空链表
head = node;
tail = node;
} else {
currentNode.next = node;
node.prev = currentNode;
tail = node;
}
length++;
};
// 向链表特定位置插入一个新的项
this.insert = function (position, element) {
if (position < 0 || position > length) {
// 越界
return false;
} else {
let node = new Node(element);
let index = 0;
let currentNode = head;
let previousNode;
if (position === 0) {
if (!head) {
head = node;
tail = node;
} else {
node.next = currentNode;
currentNode.prev = node;
head = node;
}
} else if (position === length) {
this.append(element);
} else {
while (index < position) {
index++;
previousNode = currentNode;
currentNode = currentNode.next;
}
previousNode.next = node;
node.next = currentNode;
node.prev = previousNode;
currentNode.prev = node;
}
length++;
return true;
}
};
// 从链表的特定位置移除一项
this.removeAt = function (position) {
if ((position < 0 && position >= length) || length === 0) {
// 越界
return false;
} else {
let currentNode = head;
let index = 0;
let previousNode;
if (position === 0) {
// 移除第一项
if (length === 1) {
head = null;
tail = null;
} else {
head = currentNode.next;
head.prev = null;
}
} else if (position === length - 1) {
// 移除最后一项
if (length === 1) {
head = null;
tail = null;
} else {
currentNode = tail;
tail = currentNode.prev;
tail.next = null;
}
} else {
while (index < position) {
index++;
previousNode = currentNode;
currentNode = currentNode.next;
}
previousNode.next = currentNode.next;
previousNode = currentNode.next.prev;
}
length--;
return true;
}
};
// 从链表中移除指定项
this.remove = function (element) {
let index = this.indexOf(element);
return this.removeAt(index);
};
// 返回元素在链表的索引,如果链表中没有该元素则返回 -1
this.indexOf = function (element) {
let currentNode = head;
let index = 0;
while (currentNode) {
if (currentNode.element === element) {
return index;
}
index++;
currentNode = currentNode.next;
}
return -1;
};
// 如果链表中不包含任何元素,返回 true ,如果链表长度大于 0 ,返回 false
this.isEmpty = function () {
return length == 0;
};
// 返回链表包含的元素个数,与数组的 length 属性类似
this.size = function () {
return length;
};
// 获取链表头部元素
this.getHead = function () {
return head.element;
};
// 由于链表使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString() 方法,让其只输出元素的值
this.toString = function () {
let currentNode = head;
let string = '';
while (currentNode) {
string += ',' + currentNode.element;
currentNode = currentNode.next;
}
return string.slice(1);
};
this.print = function () {
console.log(this.toString());
};
// 获取整个链表
this.list = function () {
console.log('head: ', head);
return head;
};
}
// 测试
// 创建双向链表
let doublyLinked = new DoublyLinkedList();
console.log(doublyLinked.isEmpty()); // true
doublyLinked.append('Tom');
doublyLinked.append('Peter');
doublyLinked.append('Paul');
doublyLinked.print(); // "Tom,Peter,Paul"
doublyLinked.insert(0, 'Susan');
doublyLinked.print(); // "Susan,Tom,Peter,Paul"
doublyLinked.insert(1, 'Jack');
doublyLinked.print(); // "Susan,Jack,Tom,Peter,Paul"
console.log(doublyLinked.getHead()); // "Susan"
console.log(doublyLinked.isEmpty()); // false
console.log(doublyLinked.indexOf('Peter')); // 3
console.log(doublyLinked.indexOf('Cris')); // -1
doublyLinked.remove('Tom');
doublyLinked.removeAt(2);
doublyLinked.print(); // "Susan,Jack,Paul"
doublyLinked.list(); // 请看控制台输出
总结
这就是基础的关于 数组 / 栈 / 队列 / 链表 的知识啦,更多关于 堆 / 树的知识在下一篇更新~