本篇文章将介绍数据结构与算法的基本概念(前端版)。
去年这个时间我在写数据结构与算法的博文,今年还在写,令人感叹。
基础
算法的基本概念
算法(Algorithm
)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制
也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出
算法的五大特性:
- 有限性(
Finiteness
):一个算法必须保证执行有限步之后结束
- 确切性(
Definiteness
): 一个算法的每一步骤必须有确切的定义
- 输入(
Input
):一个算法有零个或多个输入,以刻画运算对象的初始情况,所谓零个输入是指算法本身给定了初始条件
- 输出(
Output
):一个算法有一个或多个输出。没有输出的算法毫无意义
- 可行性(
Effectiveness
):算法中执行的任何计算步骤都是可以被分解为基本的可执行的操作步骤,即每个计算步骤都可以在有限时间内完成(也称之为有效性
算法的复杂度
时间复杂度是指执行一个算法所需要的计算工作量,它反映了算法的执行效率和数据规模的关系。我们通常用大 O
符号来表示时间复杂度,例如$O(n)、O(logn)、O(n^2)$等。时间复杂度越小,算法的效率越高。
空间复杂度是指执行一个算法所需要的内存空间,它反映了算法的存储开销和数据规模的关系。我们也用大 O
符号来表示空间复杂度,例如$O(1)、O(n)、O(n^2)$等。空间复杂度越小,算法的空间利用率越高。
查看常用排序算法的复杂度
排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 排序方式 | 稳定性 |
---|
冒泡排序 | | | | | In-place | 稳定 |
选择排序 | | | | | In-place | 不稳定 |
插入排序 | | | | | In-place | 稳定 |
希尔排序 | | | | | In-place | 不稳定 |
归并排序 | | | | | Out-place | 稳定 |
快速排序 | | | | | In-place | 不稳定 |
堆排序 | | | | | In-place | 不稳定 |
计数排序 | | | | | Out-place | 稳定 |
桶排序 | | | | | Out-place | 稳定 |
基数排序 | | | | | Out-place | 稳定 |
相关术语解释:
- 稳定:如果
a
原本在 b
前面,而 a=b
,排序之后,a
仍然在 b
的前面 - 不稳定:不满足稳定定义
- 内排序(
In-place
):所有排序操作都在内存中完成 - 外排序(
Out-place
):由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行 - 时间复杂度:一个算法执行所耗费的时间
- 空间复杂度:运行完一个程序所需内存的大小
n
:数据规模k
:「桶」的个数In-place
:不占用额外内存Out-place
:占用额外内存
数据结构
栈与队列
栈是一种先进后出的线性表,JS
中的数组可以实现一个栈(限定pop
与push
操作)。
查看栈的应用,表达式的计算
中缀表达式计算问题
前缀表达式求值过程
从 右到左 扫描表达式
遇到 数字 时,将数字压入堆栈
遇到 运算符 时
弹出栈顶的两个数(栈顶和次顶),用运算符对它们做相应的计算,并将结果入栈。
计算顺序是:先 弹出来的 (运算符) 后 弹出来的
然后重复以上步骤,直到表达式的最左端,最后运算出的值则是表达式的值。
看完前缀表达式的计算逻辑,那么你要明白的是,从一个 中缀表达式 转换为 前缀表达式 时,优先级顺序是已经处理好的,因为在求值时,不进行优先级的判定
例如:(3+4)x5-6
对应的前缀表达式为:- x + 3 4 5 6
,前缀表达式求值步骤如下:
从右到左扫描,将 6、5、4、3 压入栈
遇到 +
运算符时:
将弹出 3 和 4(3 为栈顶元素,4 为次顶元素),计算出 3 + 4 = 7
,将结果压入栈
遇到 x
运算符时
将弹出 7 和 5,计算出 7 x 5 = 35
,将 35 压入栈
遇到 -
运算符时
将弹出 35 和 6,计算出 35 - 6 = 29
,压入栈
扫描结束,栈中留下的唯一一个数字 29 则是表达式的值
后缀表达式(逆波兰表达式)
后缀表达式 又称为 逆波兰表达式,与前缀表达式类似,只是 运算符 位于 操作数之后。
比如:(3+4)x5-6
对应的后缀表达式 3 4 + 5 x 6 -
再比如:
中缀表达式 | 后缀表达式 |
---|
a + b | a b + |
a + (b-c) | a b c - + |
a+(b-c)*d | a b c - d * + |
a+d*(b-c) | a d b c - * + |
a=1+3 | a 1 3 + = |
后缀表达式求值过程
从 左到右 扫描表达式
遇到 数字 时,将数字压入堆栈
遇到 运算符 时
弹出栈顶的两个数(栈顶和次顶),用运算符对它们做相应的计算,并将结果入栈。
计算顺序是:后 弹出来的 (运算符) 先 弹出来的
然后重复以上步骤,直到表达式的最右端,最后运算出的值则是表达式的值。
比如:(3+4)x5-6
对应的后缀表达式 3 4 + 5 x 6 -
从左到右扫描,将 3、4 压入堆栈
扫描到 +
运算符时
将弹出 4 和 3,计算 3 + 4 = 7
,将 7 压入栈
将 5 入栈
扫描到 x
运算符时
将弹出 5 和 7 ,计算 7 x 5 = 35
,将 35 入栈
将 6 入栈
扫描到 -
运算符时
将弹出 6 和 35,计算 35 - 6 = 29
,将 29 压入栈
扫描表达式结束,29 是表达式的值
中缀表达式转后缀表达式
初始化两个栈:
从左到右扫描中缀表达式
遇到操作数时,将其压入 s2
遇到运算符时
比较 它 与 s1 栈顶运算符的优先级:
如果 s1 为空,或则栈顶运算符号为 (
,则将其压入符号栈 s1
如果:优先级比栈顶运算符 高,也将其压入符号栈 s1
如果:优先级比栈顶运算符 低 或 相等,将 s1 栈顶的运算符 弹出,并压入到 s2 中
再重复第 4.1 步骤,与新的栈顶运算符比较(因为 4.3 将 s1 栈顶运算符弹出了)
这里重复的步骤在实现的时候有点难以理解,下面进行解说:
如果 s1 栈顶符号 优先级比 当前符号 高或则等于,那么就将其 弹出,压入 s2 中(循环做,是只要 s1 不为空)
如果栈顶符号为 (
,优先级是 -1,就不会弹出,就跳出循环了
跳出循环后,则将当前符号压入 s1
遇到括号时:
如果是左括号 (
:则直接压入 s1
如果是右括号 )
:
则依次弹出 s1 栈顶的运算符,并压入 s2,直到遇到 左括号 为止,此时将这一对括号 丢弃
重复步骤 2 到 5,直到表达式最右端
将 s1 中的运算符依次弹出并压入 s2
依次弹出 s2 中的元素并输出,结果的 逆序 即为:中缀表达式转后缀表达式
下面进行举例说明:
将中缀表达式:1+((2+3)*4)-5
转换为后缀表达式
扫描到的元素 | s2(栈底->栈顶) | s1(栈底->栈顶) | 说明 |
---|
1 | 1 | null | 遇到操作数直接压入 s2 |
+ | 1 | + | s1 为空,直接压入 |
( | 1 | + ( | 运算符为( ,直接压入 |
( | 1 | + ( ( | 运算符为( ,直接压入 |
2 | 1 2 | + ( ( | 遇到操作数直接压入 s2 |
+ | 1 2 | + ( ( + | 栈顶元素为( ,直接压入 s1 |
3 | 1 2 3 | + ( ( + | 遇到操作数直接压入 s2 |
) | 1 2 3 + | + ( | 遇到) 时,依次弹出 s1栈顶的运算符,并压入 s2 直到遇到 左括号 为止,此时将这一对括号 丢弃 |
* | 1 2 3 + | + ( * | 栈顶元素为( ,直接压入s1 |
4 | 1 2 3 + 4 | + ( * | 遇到操作数直接压入 s2 |
) | 1 2 3 + 4 * | + | 遇到) 时,依次弹出 s1栈顶的运算符,并压入 s2 直到遇到 左括号 为止,此时将这一对括号 丢弃 |
- | 1 2 3 + 4 * + | - | 优先级比栈顶运算符 低或者相等 ,则弹出 s1栈顶元素,将其压入 s2 中,然后继续循环步骤 4,直至当前符号压入 s1 |
5 | 1 2 3 + 4 * + 5 | - | 遇到操作数直接压入 s2 |
null | 1 2 3 + 4 * + 5 - | null | 将 s1 的运算符依次弹出并压入 s2 ,结果的 逆序 即为:中缀表达式转后缀表达式的结果 |
由于 s2 是一个栈,弹出是从栈顶弹出,因此逆序后结果就是 1 2 3 + 4 * + 5 -
队列是一种先进先出的线性表,JS
中的数组可以实现一个队列(限定push
和shift
操作)。
若要充分利用数组的空间,可实现一个环形队列的数据结构。
需要留一个空位的原因为:需要留出一个位置区分队列满与队列空的情况
- 队列为空:
front === rear
。
- 队列为满:
(rear + 1) % maxSize === front
。
- 队列长度:
(rear - front + Maxsize) % maxSize
。
- 队列遍历:从
front
累加size
次,获取每次的arr[i % maxSize]
的元素。
- 取队尾元素:注意考虑
rear
索引为0
的情况。
链表
链表(Linked List
)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的,由一系列结点(链表中每一个元素称为结点)组成
每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域
注意反转链表的基本操作:
- 保存当前节点的下个节点(防止下一个节点丢失)
- 将当前节点插入道链表的头部。
- 将保存的下个节点取出,然后循环
树
- 二叉树
二叉树是一种树形结构,它的特点是每个节点最多只有两个子树,分别称为左子树和右子树。二叉树是有序的,也就是说,左子树和右子树的位置是不能互换的。二叉树是一种递归定义的结构,即一个二叉树要么是空的,要么是由一个根节点和两个非空的子二叉树组成。二叉树中的每个元素也称为一个节点,节点包含一个数据元素和若干指向子树的指针。
- 分类
- 满二叉树:如果二叉树中除了叶子结点,每个结点的度都为 2。
- 完全二叉树:如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布。
二叉树的遍历
- 前序遍历:顶点 -> 左节点 -> 右节点
- 中序遍历:左节点 -> 顶点 -> 右节点
- 后续遍历:左节点 -> 右节点 -> 顶点
堆
堆是一棵完全二叉树,满足
- 堆中某个结点的值总是不大于或不小于其父结点的值
- 堆总是一棵完全二叉树
堆又可以分成最大堆和最小堆:
- 最大堆:每个根结点,都有根结点的值大于两个孩子结点的值
- 最小堆:每个根结点,都有根结点的值小于孩子结点的值
图
在计算机科学中,图是一种抽象的数据类型,在图中的数据元素通常称为结点,V
是所有顶点的集合,E
是所有边的集合
如果两个顶点 v,w
,只能由 v
向 w
,而不能由 w
向 v
,那么我们就把这种情况叫做一个从 v
到 w
的有向边。v
也被称做初始点,w
也被称为终点。这种图就被称做有向图
如果 v
和 w
是没有顺序的,从 v
到达 w
和从 w
到达 v
是完全相同的,这种图就被称为无向图
图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在存储区中的物理位置来表示元素之间的关系
常见表达图的方式有如下:
邻接矩阵
通过使用一个二维数组G[N][N]
进行表示 N
个点到 N-1
编号,通过邻接矩阵可以立刻看出两顶点之间是否存在一条边,只需要检查邻接矩阵行 i
和列 j
是否是非零值,对于无向图,邻接矩阵是对称的。
邻接表
在JS
中可以使用普通对象或者Map
来表示
图的遍历
- 深度遍历:即遇到新节点就访问新节点,然后该节点上访问该节点的节点,也就是说尽可能往深处访问。
- 广度遍历:利用队列的数据结构。
常见算法
排序算法
冒泡排序
典中典,无需多言。
选择排序
同样经典,无需多言
插入排序
归并排序
快速排序
查找算法
二分查找算法
动态规划
01 背包和无限背包问题
回溯算法
八皇后问题
约瑟夫环问题
数组实现