关于算法的误解
- 前端没有算法?
「前端没有算法」这种认知是错误的。前端不仅有算法,而且算法在前端开发中占据的地位也越来越重要。我们常提到的 Virtual dom diff、webpack 实现、React fiber、React hooks、响应式编程、浏览器引擎工作方式等都有算法的影子。在业务代码中,哪怕写一个抽奖游戏,写一个混淆函数都离不开算法。
- 算法重要不重要?
有读者认为,我在业务开发中真正使用到算法的场景也很有限。事实上,不仅单纯的前端业务,哪怕对于后端业务来说,真正让你「徒手」实现一段算法的场景也不算多。但是据此得出算法不重要的说法还是太片面了。为什么高阶面试中总会问到算法呢?因为算法很好地反应了候选者编程思维和计算机素养;另一方面,如果我们想进阶,算法也是必须要攻克的一道难关。
前端和算法简单举例
想必不少读者写过「抽奖」代码,或者「老虎机」转盘。其中可能会涉及到一个问题,就是:
如何将一个 JavaScript 数组打乱顺序
事实上乱序一个数组不仅仅是前端课题,那么这个问题在前端的背景下,有哪些特点呢?
可能有读者首先想到使用数组的 sort API,再结合 Math.random 实现:
[12,4,16,3].sort(function() {
return .5 - Math.random();
})
这样的思路非常自然,但也许你不知道:这不是真正意义上的完全乱序。
不是真正意义上完全乱序原因
v8 在处理 sort 方法时,使用了插入排序和快排两种方案。当目标数组长度小于 10(不同版本有差别)时,使用插入排序;反之,使用快排。
其实不管用什么排序方法,大多数排序算法的时间复杂度介于 O(n) 到 O(n2) 之间,元素之间的比较次数通常情况下要远小于 n(n-1)/2,也就意味着有一些元素之间根本就没机会相比较(也就没有了随机交换的可能),这些 sort 随机排序的算法自然也不能真正随机。
通俗地说,其实我们使用 array.sort 进行乱序,理想的方案或者说纯乱序的方案是:数组中每两个元素都要进行比较,这个比较有 50% 的交换位置概率。如此一来,总共比较次数一定为 n(n-1)。
而在 sort 排序算法中,大多数情况都不会满足这样的条件,因此当然不是完全随机的结果了。
怎样实现乱序一个数组的需求?
Fisher–Yates shuffle 洗牌算法——会是一个更好的选择。这里,我们简单借助图形来理解,非常简单直观。接下来就会明白为什么这是理论上的完全乱序(图片来源于网络)
思路: 选取最后一个元素,在数组一共 9 个位置中,随机产生一个位置,该位置元素与最后一个元素进行交换。在选取倒数第二个元素重新开始交换
Array.prototype.shuffle = function() {
var array = this;
var m = array.length,
t, i;
while (m) {
i = Math.floor(Math.random() * m--);
t = array[m];
array[m] = array[i];
array[i] = t;
}
return array;
}
算法的基本概念
时间复杂度
一个算法的时间复杂度反映了程序运行从开始到结束所需要的时间。把算法中基本操作重复执行的次数(频度)作为算法的时间复杂度。 但是时间复杂度的计算既可以「有理可依」,又可以靠「主观感觉」。通常我们认为:
- 没有循环语句,时间复杂度记作 O(1),我们称为常数阶;
- 只有一重循环,那么算法的基本操作的执行频度与问题规模 n 呈线性增大关系,记作 O(n),也叫线性阶。
那么如何让时间复杂度的计算「有理可依」呢?来看几个原则:
- 只看循环次数最多的代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外复杂度的乘积 取个对数阶的案例:
const aFun = n => {
let i = 1;
while (i <= n) {
i = i * 2
}
return i
}
const cal = n => {
let sum = 0
for (let i = 1; i <= n; ++i) {
sum = sum + aFun(n)
}
return sum
}
这里的不同之处是 aFun 每次循环,i = i * 2,那么自然不再是全遍历。想想高中学过的等比数列:
2^0 * 2^1 * 2^2 * 2^k * 2^x = n
因此,我们只要知道 x 值是多少,就知道这行代码执行的次数了,通过 2x = n 求解 x,数学中求解得 x = log2n 。即上面代码的时间复杂度为 O(log2n)。
但是不知道读者有没有发现:不管是以 2 为底,还是以 K 为底,我们似乎都把所有对数阶的时间复杂度都记为 O(logn)。这又是为什么呢?
事实上,基本的数学概念告诉我们:对数之间是可以互相转换的,log3n = log32 log2n,因此 O(log3n) = O(C log2n),其中 C=log32 是一个常量。所以全部以 2 为底,并没有什么问题。
总之,需要读者准确理解:由于时间复杂度描述的是算法执行时间与数据规模的增长变化趋势,因而常量、低阶、系数实际上对这种增长趋势不产生决定性影响,所以在做时间复杂度分析时忽略这些项。
最后总结一下,常见时间复杂度:
- O(1):基本运算 +、-、*、/、%、寻址
- O(logn):二分查找,跟分治(Divide & Conquer)相关的基本上都是 logn
- O(n):线性查找
- O(nlogn):归并排序,快速排序的期望复杂度,基于比较排序的算法下界
- O(n²):冒泡排序,插入排序,朴素最近点对
- O(n³):Floyd 最短路,普通矩阵乘法
- O(2ⁿ):枚举全部子集
- O(n!):枚举全排列 O(logn) 近似于是常数的时间复杂度,当 n 为 的规模时 logn 也只是 32 而已; 对于顺序执行的语句或者算法,总的时间复杂度等于其中最大的时间复杂度。例如,O(n²) + O(n) 可直接记做 O(n²)。
空间复杂度
空间复杂度表示算法的存储空间与数据规模之间的增长关系。常见的空间复杂度:O(1)、O(n)、O(n²),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。有的题目在空间上要求 in-place(原地),是指使用 O(1) 空间,在输入的空间上进行原地操作,比如字符串反转。但 in-place 又不完全等同于常数的空间复杂度,比如数组的快排认为是 in-place 交换,但其递归产生的堆栈的空间是可以不考虑的,因此 in-place 相对 O(1) 空间的要求会更宽松一点。
对于时间复杂度和空间复杂度,开发者应该有所取舍。在设计算法时,可以考虑「牺牲空间复杂度,换取时间复杂度的优化」,反之依然。
v8 sort 排序的奥秘和演进
算法案列题
爬楼梯Fibonacci 数列
题目:假设我们需要爬一个楼梯,这个楼梯一共有 N 阶,可以一步跨越 1 个或者 2 个台阶,那么爬完楼梯一共有多少种方式?
示例:输入 2(标注 N = 2,一共是 2 级台阶);
输出:2 (爬完一共两种方法:一次跨两阶 + 分两次走完,一次走一阶)
示例:输入 3;输出 3(1 阶 + 1 阶 + 1 阶;1 阶 + 2 阶;2 阶 + 1 阶)
思路:最直接的想法其实类似 Fibonacci 数列,使用递归比较简单。比如我们爬 N 个台阶,其实就是爬 N - 1 个台阶的方法数 + 爬 N - 2 个台阶的方法数。
解法
const climbing = n => {
if (n == 1) return 1
if (n == 2) return 2
return climbing(n - 1) + climbing(n - 2)
}
我们来分析一下时间复杂度:递归方法的时间复杂度是高度为 n−1 的不完全二叉树节点数,因此近似为 O(2^n),具体数学公式不再展开。
我们来尝试进行优化。实际上,上述的计算过程肯定都包含了不少重复计算,比如 climbing(N) + climbing(N - 1) 后会计算 climbing(N - 1) + climbing(N - 2),而实际上 climbing(N - 1) 只需要计算一次就可以了。
优化方案:
const climbing = n => {
let array = []
const step = n => {
if (n == 1) return 1
if (n == 2) return 2
if (array[n] > 0) return array[n]
array[n] = step(n - 1) + step(n - 2)
return array[n]
}
return step(n)
}
我们使用了一个数组 array 来储存计算结果,时间复杂度为 O(n)。
另外一个优化方向是:所有递归都可以用循环来代替。
const climbing = n => {
if (n == 1) return 1
if (n == 2) return 2
let array = []
array[1] = 1
array[2] = 2
for (let i = 3; i<= n; i++) {
array[i] = array[i - 1] + array[i - 2]
}
return array[n]
}
时间复杂度仍然为 O(n),但是我们优化了内存的开销。
因此这道题看似「困难」,其实就是一个 Fibonacci 数列。很多算法题目都是类似的,也许第一次读题会觉得没有思路,但是隐藏在题目后边的解决方案,其实就是我们常见的知识。
Combination Sum
题目:给定一组不含重复数字的非负数组和一个非负目标数字,在数组中找出所有数加起来等于给定的目标数字的组合。
示例:输入
const array = [2, 3, 6, 7] const target = 7
输出:
[ [7], [2,2,3] ]
我们直接来看优化后的思想:回溯解决问题的套路就是先用「笨办法」,遍历所有的情况来找出问题的解,在这个遍历过程当中,以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。
回到这个问题,我们先通过图来遍历所有情况:
对于这个题目,事实上我们思考,数组 [2, 2, 3] 和 [2, 3, 2] 实际是重复的,因此可以删除掉重复的项,优化递归树为:
我们该如何用代码描述上述过程呢?这时候需要一个临时数组 tmpArray,进入递归前 push 一个结果,
最终答案:
const find = (array, target) => {
let result = []
const dfs = (index, sum, tmpArray) => {
if (sum === target) {
result.push(tmpArray.slice())
}
if (sum > target) {
return
}
for (let i = index; i < array.length; i++) {
tmpArray.push(array[i])
dfs(i, sum + array[i], tmpArray)
tmpArray.pop()
}
}
dfs(0, 0, [])
return result
}
数组去重
题目:对一个给定一个排序数组去重,同时返回去重后数组的新长度。
难点:这道题并不困难,但是需要临时加一些条件,即需要原地操作,在使用 O(1) 额外空间的条件下完成。 示例:
输入:
let array = [0,0,1,1,1,2,2,3,3,4]
输出:
console.log(removeDuplicates(array))
-> 5
console.log(array)
-> 0, 1, 2, 3, 4
这道题既然规定 in-place 的操作,那么可以考虑算法中的另一个重要思想:双指针。 使用快慢指针:
- 开始时,快指针和慢指针都指向数组中的第一项
- 如果快指针和慢指针指的数字相同,则快指针向前走一步
- 如果快指针和慢指针指的数字不同,则两个指针都向前走一步,同时快指针指向的数字赋值给慢指针指向的数字
- 当快指针走完整个数组后,慢指针当前的坐标加 1 就是数组中不同数字的个数
代码很简单:
const removeDuplicates = array => {
const length = array.length
let slowPointer = 0
for (let fastPointer = 0; fastPointer < length; fastPointer ++) {
if (array[slowPointer] !== array[fastPointer]) {
slowPointer++
array[slowPointer] = array[fastPointer]
}
}
}
这道题目如果不要求 O(n) 的时间复杂度, O(1) 的空间复杂度,那么会非常简单。如果进行空间复杂度要求,尤其是 in-place 操作,开发者往往可以考虑双指针的思路。
求众数
这也是一道简单的题目,关键点在于如何优化。
题目:给定一个大小为 N 的数组,找到其中的众数。众数是指在数组中出现次数大于 N/2 的元素。
可能大家都会想到使用一个额外的空间,记录元素出现的次数,我们往往用一个 map 就可以轻易地实现。那优化点在哪里呢?答案就是投票算法。
const find = array => {
let count = 1
let result = array[0]
for (let i = 0; i < array.lenght; i++) {
if (count === 0) result = array[i]
if (array[i] === result) {
count++
}
else {
count--
}
}
return result
}
有效括号
有效括号这个题目和前端息息相关,在之前课程模版解析时,其实都需要类似的算法进行模版的分析,进而实现数据的绑定。我们来看题目:
举例:
输入 "([)]"
输出:false
这道题目的解法非常典型,就是借助栈实现,将这些括号自右向左看做栈结构。我们把成对的括号分为左括号和右括号,需要左括号和右括号一一匹配,通过一个 Object 来维护关系:
let obj = {
"]": "[",
"}": "{",
")": "(",
}
const isValid = str => {
let stack = []
var obj = {
"]": "[",
"}": "{",
")": "(",
}
for (let i = 0; i < str.length; i++) {
if(str[i] === "[" || str[i] === "{" || str[i] === "(") {
stack.push(str[i])
}
else {
let key = stack.pop()
if(obj[key] !== str[i]) {
return false
}
}
}
if (!stack.length) {
return true
}
return false
};
LRU 缓存算法
看了这么多小算法题目,我们来换一个口味,现在看一个算法的实际应用。
LRU(Least Recently Used)算法是缓存淘汰算法的一种。简单地说,由于内存空间有限,需要根据某种策略淘汰不那么重要的数据,用以释放内存。
LRU 的策略是最早操作过的数据放最后,最晚操作过的放开始,按操作时间逆序,如果达到上限,则淘汰末尾的项。
整个 LRU 算法有一定的复杂度,并且需要很多功能扩展。因此在生产环境中建议直接使用成熟的库,比如 npm 搜索 lru-cache。
这里我们尝试实现一个微型体统级别的 LRU 算法: 在这个算法中,最复杂的应该是淘汰策略,淘汰数据的时间复杂度必须是 O(1) 的话,我们一定需要额外的数据结构来完成 O(1) 的淘汰策略。那应该用什么样的数据结构呢?答案是双向链表。
链表在插入与删除操作上,都是 O(1) 时间的复杂度,唯一有问题的查找元素过程比较麻烦,是 O(n)。但是这里我们不需要使用双向链表实现查找逻辑,因为 map 已经很好的弥补了缺陷。 我们在写入值的时候,判断缓存容量是否已经达到上限,如果缓存容量达到上限时,应该删除最近最少使用的数据值,从而为以后的新的数据值留出空间。
const LRUCache = function(capacity) {
this.map = {}
this.size = 0
this.maxSize = capacity
// 链表初始化,初始化只有一个头和尾
this.head = {
prev: null,
next: null
}
this.tail = {
prev: this.head,
next: null
}
this.head.next = this.tail
};
LRUCache.prototype.get = function(key) {
if (this.map[key]) {
const node = this.extractNode(this.map[key])
// 最新访问,将该节点放到链表的头部
this.insertNodeToHead(node)
return this.map[key].val
}
else {
return -1
}
}
LRUCache.prototype.put = function(key, value) {
let node
if (this.map[key]) {
// 该项已经存在,更新值
node = this.extractNode(this.map[key])
node.val = value
}
else {
// 如该项不存在,新创造节点
node = {
prev: null,
next: null,
val: value,
key,
}
this.map[key] = node
this.size++
}
// 最新写入,将该节点放到链表的头部
this.insertNodeToHead(node)
// 判断长度是否已经到达上限
if (this.size > this.maxSize) {
const nodeToDelete = this.tail.prev
const keyToDelete = nodeToDelete.key
this.extractNode(nodeToDelete)
this.size--
delete this.map[keyToDelete]
}
};
// 插入节点到链表首项
LRUCache.prototype.insertNodeToHead = function(node) {
const head = this.head
const lastFirstNode = this.head.next
node.prev = head
head.next = node
node.next = lastFirstNode
lastFirstNode.prev = node
return node
}
算法的一些基础思想
- 枚举
- 模拟
- 递归/分治
- 贪心
- 排序
- 二分
- 倍增
- 构造
- 前缀和/差分 参考文章: 哪些年常考的算法题