前端常考的算法题

2,745 阅读6分钟

关于算法的误解

  • 前端没有算法?

「前端没有算法」这种认知是错误的。前端不仅有算法,而且算法在前端开发中占据的地位也越来越重要。我们常提到的 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 为 2322^{32} 的规模时 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] ] 我们直接来看优化后的思想:回溯解决问题的套路就是先用「笨办法」,遍历所有的情况来找出问题的解,在这个遍历过程当中,以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

回到这个问题,我们先通过图来遍历所有情况:

WechatIMG450.png

对于这个题目,事实上我们思考,数组 [2, 2, 3] 和 [2, 3, 2] 实际是重复的,因此可以删除掉重复的项,优化递归树为:

WechatIMG451.png 我们该如何用代码描述上述过程呢?这时候需要一个临时数组 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 = {
   "]": "[",
   "}": "{",
   ")": "(",
}

如果编译器中在解析时,遇见左括号,我们就入栈;如果是右括号,就取出栈顶元素检查是否匹配。如果匹配,就出栈;否则,就返回 false。
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
}

算法的一些基础思想