41.那些年常考的前端算法(1)

在上一讲中,我们全面梳理了重要的数据结构,并用 JavaScript 对各种数据结构进行了实现和方法模拟。数据结构常与算法一起出现,两者相互依存密不可分。这一讲,我们来研究一下「那些年常考的前端算法」。

主要内容如下:

我们将通过三讲的内容来剖析算法问题。本讲先「轻松」一下,主要介绍前端和算法的关系,以及算法中的一些基本概念。

前端和算法

前端和算法有什么关系呢?我想先纠正两个常见的错误认知。

关于算法的误解

  • 前端没有算法?

「前端没有算法」这种说法往往出自算法岗甚至后端读者,这种认知是错误的。前端不仅有算法,而且算法在前端开发中占据的地位也越来越重要。我们常提到的 Virtual dom diff、webpack 实现、React fiber、React hooks、响应式编程、浏览器引擎工作方式等都有算法的影子。在业务代码中,哪怕写一个抽奖游戏,写一个混淆函数都离不开算法。

  • 算法重要不重要?

有读者认为,前端中算法只是提供了一些偏底层的能力和实现支持,我在业务开发中真正使用到算法的场景也很有限。事实上,不仅单纯的前端业务,哪怕对于后端业务来说,真正让你「徒手」实现一段算法的场景也不算多。但是据此得出算法不重要的说法还是太片面了。为什么高阶面试中总会问到算法呢?因为算法很好地反应了候选者编程思维和计算机素养;另一方面,如果我们想进阶,算法也是必须要攻克的一道难关。

前端和算法简单举例

我就先举一个例子作为引子,一起先热热身,看看算法应用在前端开发中的一个小细节。

想必不少读者写过「抽奖」代码,或者「老虎机」转盘。其中可能会涉及到一个问题,就是:

「如何将一个 JavaScript 数组打乱顺序?」

事实上乱序一个数组不仅仅是前端课题,那么这个问题在前端的背景下,有哪些特点呢?可能有读者首先想到使用数组的 sort API,再结合 Math.random 实现:

[12,4,16,3].sort(function() {
   return .5 - Math.random();
})

这样的思路非常自然,但也许你不知道:这不是真正意义上的完全乱序。

为此我们进行验证,对数组

let letters = ['A','B','C','D','E','F','G','H','I','J']

使用 array.sort 方法进行了 10000 次乱序处理,并对乱序之后得到的新数组中,每一个字母出现的位置进行统计,并可视化输出:


不管点击按钮几次,你都会发现整体乱序之后的结果绝对不是「完全随机」。

比如,A 元素大概率出现在数组的头部,J 元素大概率出现在数组的尾部,所有元素大概率停留在自己初始位置。

这是为什么呢?

究其原因,在 Chrome v8 引擎源码中,可以清晰看到:

v8 在处理 sort 方法时,使用了插入排序和快排两种方案。当目标数组长度小于 10(不同版本有差别)时,使用插入排序;反之,使用快排。

其实不管用什么排序方法,大多数排序算法的时间复杂度介于 O(n) 到 O(n2) 之间,元素之间的比较次数通常情况下要远小于 n(n-1)/2,也就意味着有一些元素之间根本就没机会相比较(也就没有了随机交换的可能),这些 sort 随机排序的算法自然也不能真正随机。

通俗地说,其实我们使用 array.sort 进行乱序,理想的方案或者说纯乱序的方案是:数组中每两个元素都要进行比较,这个比较有 50% 的交换位置概率。如此一来,总共比较次数一定为 n(n-1)。

而在 sort 排序算法中,大多数情况都不会满足这样的条件,因此当然不是完全随机的结果了。

那为了满足乱序一个数组的需求,我们应该怎么做呢?

Fisher–Yates shuffle 洗牌算法——会是一个更好的选择。这里,我们简单借助图形来理解,非常简单直观。接下来就会明白为什么这是理论上的完全乱序(图片来源于网络)。

首先我们有一个已经排好序的数组:


Step1:

这一步需要做的就是,从数组末尾开始,选取最后一个元素。


在数组一共 9 个位置中,随机产生一个位置,该位置元素与最后一个元素进行交换。


Step2:

在上一步中,我们已经把数组末尾元素进行随机置换。

接下来,对数组倒数第二个元素动手。在除去已经排好的最后一个元素位置以外的 8 个位置中,随机产生一个位置,该位置元素与倒数第二个元素进行交换。


Step3:

理解了前两步,接下来就是依次进行,如此简单。


明白了原理,代码实现也很简单:

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 cal = n => {
  let sum = 0
  let i = 1
  for (; i <= n; ++i) {
    sum = sum + i
  }
  return sum
}

执行次数最多的是 for 循环及里面的代码,执行了 n 次,应该「只看循环次数最多的代码」原则,因此时间复杂度为 O(n)。

const cal = n => {
  let sum1 = 0
  let p = 1

  for (; p < 100; ++p) {
    sum1= sum1 + p
  }

  let sum2 = 0
  let q = 1
  for (; q < n; ++q) {
    sum2 = sum2 + q
  }

  let sum3 = 0
  let i = 1
  let j = 1
  for (; i <= n; ++i) {
    j = 1
    for (; j <= n; ++j) {
      sum3 = sum3 +  i * j
    }
  }

  return sum1 + sum2 + sum3
}

上述代码分别对 sum1、sum2、sum3 求和:

  • 对于 sum1 求和,循环 100 次,常数执行时间,时间复杂度为 O(1);
  • 对于 sum2 求和,循环规模为 n,时间复杂度为 O(n);
  • 对于 sum3 求和,两层循环,时间复杂度为 O(n²)。

因此 O(1) + O(n) + O(n²),取三段代码的最大量级,上面例子最终的时间复杂度为 O(n²)。

对于代码:

const cal = n => {
  let ret = 0
  let i = 1
  for (; i < n; ++i) {
    ret = ret + f(i); // 注意  f(i)
  }
}

const f = n => {
 let sum = 0
 let i = 1
 for (; i < n; ++i) {
   sum = sum + i
 }
 return sum
}

方法 cal 循环里面调用 f 方法,而 f 方法里面也有循环,这时应用第三个原则——乘法原则,得到时间复杂度 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 为底,并没有什么问题。

总之,需要读者准确理解:由于时间复杂度描述的是算法执行时间与数据规模的增长变化趋势,因而常量、低阶、系数实际上对这种增长趋势不产生决定性影响,所以在做时间复杂度分析时忽略这些项。

最好、最坏时间复杂度,平均时间复杂度,均摊时间复杂度
我们来看一段代码:

const find = (array, x) => {
   let pos = -1
   let len = array.length
   for (let i = 0 ; i < n; ++i) {
       if (array[i] === x) {
           pos = i
       }
   }
   return pos
}

上面的代码有一层循环,循环规模和 n 成线性关系。因此时间复杂度为 O(n),我们改动代码为:

const find = (array, x) => {
   let pos = -1
   let len = array.length
   for (let i = 0 ; i < n; ++i) {
       if (array[i] === x) {
           pos = i
           return pos
       }
   }
  
}

在找到第一个匹配元素后,循环终止,那么时间复杂度就不一定是 O(n) 了,因此就有了最好时间复杂度、最坏时间复杂度的区别。针对上述代码最好时间复杂度就是 O(1)、最坏时间复杂度还是 O(n)。

最好时间复杂度、最坏时间复杂度其实都是极端情况,我们可以从统计学角度给出一个平均时间复杂度。在上述代码中,平均时间复杂度的计算方式应该是:

(1/(n+1)) * 1 + (1/(n+1)) * 2 + ... + (1/(n+1)) * n + (1/(n+1)) * n

得到结果为:n(n+3)/2(n+1)

因为变量 x 出现在数组中的位置分别有 0 —— n-1 种情况,对应需要遍历的次数;除此之外,还有变量 x 不出现在数组中,这种情况仍然后遍历完数组。

上述结果简化之后仍然得到 O(n)。

我们再来看一段代码:

let array = new Array(n)
let count = 0
function insert(val) {
   let len = array.length
   if (count === len) {
       let sum = 0
       for (let i = 0; i < len; i++) {
           sum = sum + array[i]
       }
       array[0] = sum
       count = 1
   }
   array[count] = val
   ++count
}

这段代码逻辑很简单:我们实现了一个往数组中插入数据的功能。但是多了些判断:当数组满了之后,即 count === len 时,采用 for 循环对数组进行求和,求和完毕之后:先清空数组,然后将求和之后的结果放到数组的第一个位置,最后再将新的数据插入。

这是一段非常典型的代码,我们来看它的时间复杂度:

  • 最好时间复杂度
    数组中有空闲,count !== len,直接执行插入操作,复杂度为 O(1)。

  • 最好时间复杂度
    数组已满,count === len,需要先遍历一遍再求和,复杂度为 O(n)。

  • 平均时间复杂度
    假设数组长度为 n,数组空闲时,复杂度为 O(1);数组已满,复杂度为 O(n)。采用平均加权方式:(1/(n+1)) * 1 + (1/(n+1)) * 1 + ... + (1/(n+1)) * n
    公式求和仍为 O(1),主观上想:我们的操作是在进行了 n 个 O(1) 的插入操作后,此时数组满了,执行一次 O(n) 的求和和清空操作。这样一来,其实前面的 n 个 O (1) 和最后的 1 个 O(n) 其实是可以抵消掉的,这是一种均摊时间复杂度的概念。

这种均摊的概念是有实际应用场景的。例如,C++ 里的 vector 动态数组的自动扩容机制,每次往 vector 里 push 值的时候会判断当前 size 是否等于 capacity,一旦元素超过容器限制,则再申请扩大一倍的内存空间,把原来 vector 里的值复制到新的空间里,触发扩容的这次 push 操作的时间复杂度是 O(n),但均摊到前面 n 个元素后,可以认为时间复杂度是 O(1) 常数。

最后总结一下,常见时间复杂度:

  • 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) 空间的要求会更宽松一点。

对于时间复杂度和空间复杂度,开发者应该有所取舍。在设计算法时,可以考虑「牺牲空间复杂度,换取时间复杂度的优化」,反之依然。空间复杂度我们不再过多介绍。

总结

本讲我们介绍了算法的基本概念,重点就是时间复杂度和空间复杂度分析,同时剖出了一个「乱序数组」算法进行热身。算法的大门才刚刚打开,请读者继续保持学习。

你可能感兴趣的:(41.那些年常考的前端算法(1))