前端数据结构与算法细致分析上复杂度分析

4次阅读

共计 9520 个字符,预计需要花费 24 分钟才能阅读完成。

前端要不要学算法?

这段时间一直在读 vue3 源码以及 C。时间挤不出来了, 只能每天写一点, 接下来是一套算法系列。当然只是针对前端同学,后端的可以按后退键了,因为这些对于后台来说肯定是小 case.
首先, 写这篇文章之前, 先说一下前端要不要学习算法。
先给上我的答案: 要,而且一定要。不知道你有没有听说: 程序 = 数据结构 + 算法。有代码的地方就有数据结构。你的业务代码里面全是全局变量, 全局函数,那也叫有数据结构, 你的数据结构是一盘散沙。再比如你的业务代码里一些前端进行插入删除操作非常非常频繁的需求比较多,那么你可能需要自己底层实现一个链表结构。
不排除一些前端的同学会说,我就做一个纯静态页面的,都是 form 表单,不用管那么多,有很多人都抱怨过 (之前的我也是): 面试造火箭, 实际拧螺丝。那么:你在别人眼中还是程序员吗?你拿到的待遇还是程序员的待遇吗?你未来的竞争力还是程序员所具备的抗风险能力吗?
业务代码谁都会写, 再难的交互和计算我们都可以实现。假如你目前要做一个需要前端来处理一个很庞大的数据 (比如公司让你去开发一个 h5 小游戏),里面涉及到的查找,删除,插入,位移操作非常频繁, 如果你这个时候还是中规中矩地去写去实现,从前到后撸数组,一个 for 解决不了就再来两个, 不知道跳表,散列,二叉 … 甚至不知道链表是个什么东西(咦?这个是戴的吗?),那么可能做出来你的上级会说,怎么这么卡?这么慢?你回答: 就这样。
其实算法本身也不是高深莫测,目的是去高效解决问题。比如之前做彩票业务,会有奖金计算的需求。若前端不擅长算法,可能就会和服务端同学说:前端算不出来,把数据提交到后端,后端再把结果返回给前端吧。殊不知,这样的做法既牺牲了用户体验,也加大了服务端的开销导致公司成本的上升。所以我说,前端必须会算法,但是作为一个纯前端来说,你可以只做到了解常用算法,会分析,会用。并不需要像算法工程师那样设计算法架构,更不需要你手动实现一个红黑树。所以我接下来的几篇文章只讲一些基础,如果再加上你的多多练习,那么只是应付面试真的是足够了。

算法难不难?怎么学?

其实我在最开始学的时候也觉得它非常的枯燥无味,甚至觉得很浪费时间。甚至对自己的智商产生了怀疑 –! 因为确实,它很抽象。没有什么权威的基本概念。但是我觉得其实真正的原因是没有找到好的学习方法,没有抓住学习的重点。实际上,数据结构和算法的东西并不多,常用的、基础的知识点更是屈指可数。只要掌握了正确的学习方法,学起来并没有看上去那么难,更不需要什么高智商、厚底子。当然,大量的刷题确实能帮助你短时间提升一下,但是在这之前为何不先去了解一下底层的知识点?这样的话你能更高效地去写出验证甚至优化你自己或者别人写的代码

数据结构?算法?

什么是数据结构?什么是算法?首先,我明确地告诉你千万不要死扣概念这样会让你陷入误区 (我连什么是都不知道怎么学?),但是真的,我觉得算法这东西就是在学的时候慢慢理解起来的,你不需要死记硬背它是什么,只需开始的时候去知道个大概,在后面慢慢理解。下面我先从广义上来将一下帮助你理解:
比如你在网易公司,公司应该是将人先按类(部门)分组,然后每个组再可以细化出一个下标(第几组)来存储‘人’这种数据。当你要找一个人,比如你是研发部的,你需要去找一个销售部的人。你会怎么办?从左上角找到右下角吗?开个玩笑。。。你应该会先去找这个销售部在哪个位置,然后这个人在销售部的哪个组?这个组在哪一排?然后从这一排里找到。不管是怎么找,这些都可以理解为算法。
如果细扣的话,队列、栈、堆、二分查找、动态规划等。这些都是前人智慧的结晶,我们站在了巨人的肩膀上,可以直接拿来用。这些经典数据结构和算法,都是前人从很多实际操作场景中抽象出来的,经过非常多的求证和检验,可以高效地帮助我们解决很多实际的开发问题。

数据结构和算法的关系?

那么这两者到底有什么关系?为什么好多资料或者书乃至我今天讲得都叫 数据结构与算法 呢?
这是因为,数据结构和算法是相互作用的。特定的场合需要特定的数据结构,这类数据结构会对应一种综合来说很高效的算法。也可以说数据结构为算法服务。所以,我们无法忽视数据结构来学算法,也无法忽视算法来学数据结构。一个已知有序数组里面找到一个特定数字,当数组非常大时, 绝对是二分查找最快。一个长度为 10 亿的有序数组(假设有),二分查找也就不超过 31 次。我所说的就是,当你在这种业务需求,遇到这种数据结构的时候,你可以找到一个很适合的算法来解决这类。再比如一些业务需要首先按一个属性排序,相等的话要按另一个属性继续排。那么你就可能优先选择一个稳定地排序算法。接下来我们进入正题

数据结构与算法的核心?

首先,学好算法最核心的最基本的要求,需要你知道一个概念 -> 复杂度以及分析
不要小看它,它的是算法的精髓以及好坏的判断依据!我们刚开始都不是高手,只有反复地去写,写完去耐心分析,在成长中一点点积累,才能学好算法。方法 + 量变引起质变,相信你也能达到高手的行列。
高手们都是怎么分析复杂度?———- 凭感觉!

为什么要会复杂度分析?

也许有人会把代码从上到下运行一遍,借助 console.time,timeend, 再借助监控、统计,就能得到算法执行的时间和占用的内存大小。用实时说话! 不比你去分析来得更准确?这种 事后统计法 很多场合确实能用。但是局限性非常大。
1、很依赖环境,可能你在 chrome 上运行时间为 n, 但是你没有测试其它机型,会产生很多变数
2、数据规模会限制你的准确性, 时间复杂度为 10000n 的 a 算法和一个 0.5n^2 的 b 算法,你只测试了一些 n 为 50,60?的情况,你就说 b 比 a 快!
因此我们要找到一个不用具体到某个数据,某种情况就能进行准确分析的方法 - 时间复杂度分析法、空间复杂度分析法~

大 O 复杂度表示法

算法的执行时间效率,说白了就是代码的执行时间,你可以用 time and timeend 来计算出来,但是我上面说了,那样的话你很难做到测试的 公平 我们看一段非常简单的代码

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        result += i++ // 4
    }
    return result
}

因为这里不涉及到高级 API 的使用, 所以我们假设每一行代码的运行时间 (比如 let result = 100, let i = 0) 都为一个单元时间 time_base
那么当函数 add 运行时 1,2 行代码共用了 time_base + time_base 的时间,而第三行用了 n time_base, 第四行也是 n time base。所以该算法总共用了 time_base + time_base + n time_base + n * time_base = 2(n + 1)time_base 时间
假设总时间为 T(n)的话那么 T(n) = 2(n + 1)time_base。我们可以清晰地看到总时间与变量 n 之间成正比。怎么样是不是基础分析还是很简单的?我再稍稍改变一下:

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        let j = 0; // 4
        while(j<=n) { // 5
            result += i++ * j++ // 6
        }
    }
    return result
}    

第一行代码: time_base 第二行代码: time_base
第三行代码: time_base n 第四行代码: time_base n
第五行代码: time_base n n
第六行代码: time_base n n(先忽略 i ++,j++)
所以总的时间:
T(n) = 2time_base+2ntime_base+2n^2*time_base

T(n) = 2(n^2 + n + 1) * time_base

我们就得出这段代码的总时间与 n 成正比。即 n 越大,总时间一定也就越大。假设 f(n) = 2 (n^2 + n + 1)
代入上面公式 -> T(n) = f(n) * time_base
但是像我们这么写的话貌似有些啰嗦,因为我们知道 time_base 一定是个非 0 非负数。这样,大 O 出世:
T(n) = O(f(n))
其中 f(n) 是所有代码的运行次数 (n) 的总和,Tn 刚才说了是总得运行时间,特别注意的一点是 大 O 并不是你想象中的 time_base, 它只是一个表明 Tn 与 n 的一个变化趋势,你可以把它想象成为一个坐标轴上的 增长曲线 ,全称 渐进时间复杂度 所以上个例子中可以表示
T(n) = O (2(n^2 + n + 1)), 我们知道当 n 趋近于无穷大的时候,n^2>>n>> 常熟 C,2(n^2 + n + 1) 无限接近 2(n^2),n 无限大时, 这里的 2 对 n^2 的增长趋势产生的影响越来越小,所以最后我们可以表示成 O(n^2), 第一个例子中可以表示成 O(n).

如何做复杂度分析?

我们知道了 大 O 描述的是一种变化趋势,其中 fn 中的常量,比例系数以及低阶都可以在 n 趋于无穷大的时候忽视,所以我们可以得出第一个复杂度分析的常用方法 ——

找出循环中执行次数最多的部分即可

拿第一个例子我们知道第三四行执行最多为 n,所以,复杂度为 O(n),第二个例子第 5,6 行执行最多,所以为 O(n^2)
第二个,加法法则:总复杂度等于量级最大的那段代码的复杂度
看下面一段代码:

function ex(n) {
    let r1 = 0, r2 = 0, r3 = 0;
    let k1 = 0, k2 = 0, k3 = 0, j3 = 0;
    while (k1++ <= 100000) {r1 += k1}
    while (k2++ <= n) {r2 += k2}
    while (k3++ <= n) {
        j3 = 0;
        while (k1++ <= 100000) {r3 += k3 * j3}
    }
    return sum;
}

所以 Tn 为三个循环加起来的时间加上一个常数,后面的与第一个方法的分析类似,不再多啰嗦,只是特地强调一下 即使 第一个循环中的 100000 并不会影响增长趋势,这个增长趋势你可以更简单地理解一下,当数据规模 n 增大的时候, 线性切角是否变化。
总结: 假设 T1(n) = O(f(n)), T2(n) = O(h(n)), 若 T(n) = T1(n) + T2(n)。则 T(n) = O(f(n)) + O(h(n)) = max(O(f(n)),O(h(n))) = O(max(f(n), h(n)))

乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
这个就不多说了,上面的循环嵌套就是一个例子,总结:
如果 T1(n)=O(f(n)),T2(n)=O(h(n));那么 T(n)=T1(n)T2(n)=O(f(n))O(h(n))=O(f(n)*h(n))

常见的时间复杂度

复杂度量级:
常量阶: O1
对数阶: O(logn)
线性阶: O(n)
线性对数阶: O(n*logn)
k 次方阶: O(n^k)
指数阶: O(2^n)
阶乘阶: O(n!)
其中的 2^n 和 n!
时间复杂度错略的分为两类:多项式量级和非多项式量级 . 其中, 非多项式量级只有两个: O(2^n) 和 O(n!)我们把时间复杂度为非多项式量级的算法问题叫做 NP 问题(Non-Deterministic Polynomial**, 非确定多项式).
多项量级就是说这个时间复杂度是由 n 作为底数的 O(n) O(nlogn) 
非多项量级就是 n 不是作为底数!
其中 非多项式量级 的不做分析, 这类属于爆炸增长, 你自己可以算一下。当 n =30 的时候, 就需要运算 2 *10000…(32 个 0)的时间单元了, 一般能写出这种算法的那绝对是上面有人。
对于上面的 logn,刚接触算法的肯定有些陌生, 我举个例子


function ex(n) {
    let i = 1;
    while(i < n) {i *= 2}
}

我们来实际计算一下,每次循环 i 都在本身的基础上乘以 2,直到 >n;
所以 1 , 2 , 4 , 8 , 16 , …. m= n 即每一次的运行过程是
2^0 , 2^1 , 2^2 , 2^3 , … , 2^m = n, 整体运行次数其实就是 m(当运行了 m 次后,i > n,循环结束),所以 m = logn.
那么

function ex2(n) {
    let i = 1;
    while(i < n) {i *= 5}
}

我们根据上面的推导可以得出 m = log5n(以 5 为底 n 的对数), 那么这个时间复杂度就是 log5n 吧。
其中 log5n = log₅2 * log₂n。我们知道系数是可以省略的在大 O 复杂度表示法中。所以忽略底数后, 可以表示为 logn。

那么 nlogn 呢?我们根据 乘法法则 可以很容易想到

function ex3(n) {
    let i = 1;
    let j = 1;
    while(i < n) {
        j = 1;
        while(j < n) {j *= 2}
    }
}

如何分析不再啰嗦。至于空间复杂度, 相比于时间, 很简单,也不做介绍。有兴趣的可以去自己查阅一下资料。回到 为什么我们要做复杂度分析的问题 。你可能会有一个自己的想法。这些分析与寄生环境无关,虽然它也只是粗略地来表明一个算法的效率,因为你不能输 O(1) 就一定比 O(n^2)好, 在性能极致优化的情况下, 我们甚至还需要针对 n 的数据规模,分段设计不同的算法, 举个后面我会来教大家的一个例子: 我们都知道 sort 这个数组 API 吧? 但是你有没有了解它的底层实现?拿 V8 引擎来说

我们看实现和注释可以知道,数组长度小于等于 22 的用插入排序,其它的用快速排序,想深入研究的可以看下 array 源码
只有有了这些基础,你才能对不同的算法有了一个“效率”上的感性认识。以至于以后可以灵活地去运用,写出更高效地程序, 使你的页面交互更快更加流畅!

时间复杂度的最好情况和最坏情况

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        result += i++ // 4
    }
    return result
}

我们知道上面的代码很容易分析, 因为不论 n 多大, 你都要 i 从 1 开始增长到 n, 这个过程我们是确定的, 可以预知的。但是我说一种情况, 只要是做过前端开发的同学一定都熟悉这种业务场景, 后台返回一个数组, 前端判断是否有一个特定的标识, 有就继续下一步操作,并返回, 没有就提示失败, 不用 indexof, includes。我们可能会这样写(这个数组长度假如无序,未知)

function findViptag(arr, target) {
    let l = arr.length - 1;
    let findResult = false;
    for (let i = 0; i < l; i++) {if (arri[i] === target) {findResult = true}
    }
    return findResult
}

我们可以一眼看出这个算法的时间复杂度为 O(n), 但是这样未免太浪费性能, 因为我们关系的结果(有、无)。所以只要我们拿到结果之后,我们的目的就达到了!所以:

function findViptag(arr, target) {
    let l = arr.length - 1;
    let findResult = false;
    for (let i = 0; i < l; i++) {if (arri[i] === target) {return findResult = true}
    }
    return findResult
}

这样,当我们拿到结果之后,此段代码终止,那么这段代码的时间复杂度?显然我之前所说的在这里好像看不出来, 但是我们知道最好情况其实就是我们要查找的结果在数组的第一个位置, 这样的话我们只需要循环开始的第一次就结束了,这种情况就是 最好情况时间复杂度 。那么最快情况呢?显然,要找的东西不在数组里或者是在数组的最后一个位置,那么我们就需要遍历整个数组。这种情况就是 最坏情况时间复杂度 ,那么该算法的时间复杂度到底是多少? 是不是分情况?这时候,我们来引入另一个概念 平均情况时间复杂度

平均情况时间复杂度

我们知道在一个非常庞大的数组中, 你所要找的元素刚好出现在第一个位置或者是最后一个位置的情况并不多。
平均时间怎么算?
假设我们要找的元素出现在数组中还是没有出现的概率相等, 且出现在数组的任意一位置的可能性也都相同, 那么我们就可以求出, 我们平均要遍历多少次, 假设数组的长度为 n, 所以我们共有可能遍历的情况为 1,2,3,4,5,…,n – 1, n, n。注意我的最后写了两个 n 是因为该元素没有在数组中的时候你需要遍历 n 次,在最后一个位置的时候也需要 n 次, 那么一共就这 n + 1 中可能性,所以平均为 (1 + 2 + … + n – 1 + n + n)/ (n + 1) = ((1 + n) n + n)/2(n + 1) = (n^2 + 2n) /2(n + 1), 根据我上边讲得,忽略系数,常熟,取最高阶,这个算法的时间复杂度为 O(n)。但是这样算的话可能不公平, 那么我们再从概率的角度再推导一遍, 因为,假设条件不变, 那么我们知道 这个数要么出现在数组里要么不在, 所以 出现在每一个位置的概率为 1/2 1/n = 1 / 2n。那么需要遍历 1,2,3,4,5,6…n 次的概率为 (1/2n) 1 + (1/2n) 2 + … + (1/2n) n + 1/2 n = (3n + 1) / 4 时间复杂度也为 O(n).
你们应该了解概率中的期望,这种其实就是 期望值 ,也是 加权平均值 ,同时这种复杂度的推导也叫 加权平均(或期望)时间复杂度
其实大多数情况下我们是不需要进行这种推导的。只有在各个情况出现的概率有着明显的倾斜或者做追求到系数甚至常量级别的性能分析时才会考虑进去。

均摊时间复杂度

这种复杂度分析对于前端来说一般不重要,可以简单了解一下, 不明白也没事, 假设我们由于业务需要要维护一个数组, 它的长度是定的, 为 n,我们要像里面添加数据:

... k => new Array(n)
...............
function insert(d) {
    // 如果数组长度满了, 我们希望将现有数组做一下整合, 比如
    // 对比一下数据, 或者做个求和, 求积? 都可以, 总之要遍历
    if (// 数组长度满) {
        // 遍历做处理
        ...
        ...
        // 然后将数组长度扩容 * 2
        ...
        k.length = 2 * k.length
    } else {
        // 直接插入空位置
        ...
    }
}

这样当我们知道,当插入的时候, 只是简单的一个按下标随机访问地插入操作, 时间复杂度 O(1), 但是当容量不够的时候, 我们需要遍历整合, 时间复杂度为 O(n), 那么到底时间复杂度是多少呢?这种情况,其实我们没有必要像刚才那样求平均复杂度那么麻烦, 简答的分析一下, 与刚才的求平均时间复杂度作比较, 上个例子中,极少地概率会出现 O(1)的情况,即 (所找元素正好为第一个), 但是本例中 n - 1 量级数据内都是 O(1), 在第 n 次为 O(n), 即 n 次操作我们需要 O(n) + n(n – 1) * O(1) 的复杂度, 平均下来也是 O(1). 可以这样说, 耗时最长的那个操作被前面大部分操作均摊了下来。
不懂没关系, 这段可以跳过。继续,有插入就必然伴随着删除, 那么当删除的时候我们假设都是从后面开始删除, 那么时间复杂度也是 O(1), 但是当数组中空元素占据绝大多数时,即数组中的内容很少,假设这个时候我们的数组已经扩容到了 n * 4. 这个时候,为了节省空间,我们可以进行缩容,假设缩容那次我们也要所遍历整合, 即前面 (n-1) 次操作耗费时间总和为 (n-1), 第 n 次操作耗费时间为(n+1),n 为对数组进行缩容操作耗时,1 为删除这个元素耗时。所以均摊来看每次耗费时间仍然是 2,时间复杂度为 O(1)。
那么这样设计的话就完美了吗?没有,你又可能会遇到复杂度震荡

复杂度震荡

假设, 缩容后数组容量退化为 n,这时候又插入一个元素, 还需要扩容,也就是这两次的时间复杂度是 2 (O(n) + 1)。如果我们插入删除的平衡点刚好卡再这里, 那么这种算法的时间复杂度就由 O(1)退化到了 O(n). 那怎么办呢?其实只需我们扩容和缩容的分界点不同就可以了,比如我们可以在 n 的时候扩容到 2n 容量, 在数据量剩余不足 1 /8 n 时进行缩容。这样即使有插入有删除我们也都是 O(1)级别的操作。

加餐

上面我讲了一些基础的算法复杂度分析,接下来,增加一点难度。
我们应该都知道递归,尤其是当你设计一套算法的时候,很大概率地会使用递归,那么如何求解递归算法的时间复杂度呢?
我这里拿一个归并排序的例子来讲 (后面写排序的时候我会细致地给你分析)。不知道归并排序不要紧,代码你应该能看得懂。
排序相信大家耳熟能详,面试问的更是多,下个专题我会非常细致地把基本的排序用法以及它的原理进行细致地讲解,至少能足够让你应付一些基础面试。
归并排序的思想是分合,如下图:

这张图你应该能一目了然, 用 javascript 可以这样实现归并:

function sort(arr) {return divide(arr, 0, arr.length - 1)
}

function divide(nowArray, start, end) {if (start >= end) {return [ nowArray[start] ];
    }
    let middle = Math.floor((start + end) / 2);
    let left = divide(nowArray, start, middle);
    let right = divide(nowArray, middle + 1, end);
    return merge(left, right)
}

function merge(left, right) {let arr = [];
    let pointer = 0, lindex = 0, rindex = 0;
    let l = left.length;
    let r = right.length;
    while (lindex !== l && rindex !== r) {if (left[lindex] < right[rindex]) {arr.push(left[lindex++])
        } else {arr.push(right[rindex++])
        }
    }
    // 说明 left 有剩余
    if (l !== lindex) {while (lindex !== l) {arr.push(left[lindex++])
        }
    } else {while (rindex !== r) {arr.push(right[rindex++])
        }
    }
    return arr;
}

sort(arr)

具体分析我在下一专题来讲, 我们先来分析它的复杂度 O,
我们设总时间为 T(n),其中我们知道,divide 中有递归, 声明变量所花费的时间我们忽略, 其实总的时间 T(n)分解之后主要为 left 递归和 right 递归以及 merge left 和 right,其实我们可以这么理解,T(n)分为了两个子问题 (程序)T(left) 和 T(right)以及 merge left 和 right
所以 T(n) = T(left) + T(right) + merge(left, right)
我们设 merge(left, right) = C;
T(n) = T(left) + T(right) + C;
因为我们是从中间分开, 所以若总时间为 T(n)那么两个相等的子数组排序并 merge 的时间为 T(n/2), 我们知道最后 merge 两个子数组的时间复杂度 O(n)[不用深入考虑递归,我们只看最后的 left 和 right]
所以

T(n) = 2T(n/2) + n
     = 2(2T(n/4) + n/2) + n = 4T(n/4) + 2n
     = 4(2T(n/8) + n/4) + 2n = 8T(n/8) + 3n
     = 8(2T(n/16) + n/8) + 3n = 16T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
整理一下可以得到 
T(n) = 2^kT(n/2^k)+kn
当 T(n/2^k)=T(1)即 n /2^k = 1;所以 k =log2n
代入可得 T(n)=n+nlog2n = n(logn + 1)

所以时间复杂度就是 O(nlogn)
如果看不懂也没有关系,我后面还会再细致地写, 其实这种问题用二叉树来分析会更简单, 后面我也会介绍怎么用。

结语

还是那句话想学好算法, 最基本的复杂度分析一定要掌握, 一定要会, 这是基础, 也是你能看出,评测出你写或者别人写的算法的效率。文章可能有错别字,请大家见谅,大家加油,下期见!

正文完
 0