普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月13日首页

Hello 算法:让前端人真正理解算法

作者 灵感__idea
2025年10月13日 00:02

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

程序员圈儿有两种怪象:

1、人人称工程师,但少有人能真正担起一项“工程”。

2、掌握算法本是理所应当,实际寥寥无几。

一直以来,算法好像跟前端开发没多少关联,顶多用来应付面试。

本系列要做的,就是同大家一起啃下这块硬骨头,真正理解算法。

算法是什么

算法是什么,没有标准答案。

先看几个实际案例:

查字典

在字典里,每个汉字都对应一个拼音,而字典是按照字母顺序排列的。

查找”ren“的大概过程如下:

  • 翻开字典约一半的页数,查看该页的首字母,假设为 j。
  • 字母表中r位于j之后,所以排除前半部分,查找范围缩小一半。
  • 不断重复前两个步骤 ,直至找到首字母为r的页码。

打扑克

打牌时,每局都需要整理扑克牌,使其从小到大排列。过程大概如下:

  • 将扑克牌划分为“有序”和“无序”两部分,并假设初始状态下最左 1 张扑克牌已经有序。
  • 从无序部分抽出一张扑克牌,插入有序部分的正确位置。
  • 不断重复步骤 2 ,直至所有扑克牌都有序。

找零钱

在超市购买了 69 元的商品,给收银员 100 元,收银员需要找 31 元。大概过程如下:

  • 可选项是比 31 元面值更小的货币,包括 1 元、5 元、10 元、20 元。
  • 从中拿出最大的 20 元,剩余 31 − 20 = 11 元。
  • 从剩余选项中拿出最大的 10 元,还剩 11 − 10 = 1 元。

这三个例子涉及的场景大家都非常熟悉,只是可能没意识到,它们就是算法。

算法就是问题的解决方案,并不一定跟编程相关。

从平常的生活细节,到巧夺天工的技艺,都隐藏着精妙的算法思想。

有个著名公式:程序 = 数据结构 + 算法

所以不要说你没用过算法,只要在写程序,就在运用算法。

学习的难处

如此说来,理解算法并不难,为什么大家觉得难?原因大致两点:

1、大家所熟知的,只是简单问题的一般解,不一定是最优解,算法学习的目标是寻找最优解

2、从解决思路到代码表示,需要一个转换过程

编程中有两道坎:一是从完全不懂到具备编程思维,二是从语法正确到算法精良。

跨过第一道坎,写10行代码和1000行代码没有本质区别;跨过第二道坎,就能摆脱”工具人“属性,真正创造价值。

很多人都有这样的感受,学习算法像是重新学一遍编程,若干新概念扑面而来:冒泡、贪心、递归、动态规划、红黑树、B+树...

你得先理解这些概念,再将概念转换成代码。

理解不难,难的是转换,如果脑子转不过来弯儿,就难以完成转换。

怎么办?有人说刷题,刷题的确是一种受欢迎且看似有效的方法,但只是对自学能力强的人而言,基础薄弱的,可能每次都是从一道题开始,又从一道题结束,就像有些人学英语永远都是“abandon”。

何以解忧

学习这件事,没有毫无争议的“标准答案”,只有匹配读者当下认知水平的答案。

我们通常看到的资料和教程,讲师的背景和履历都很强,但稍作了解就知道,段位远高于基础。

它试图降低身段教会我们,但你刚理解了一个简单的问题,觉得自己好像可以,接着就迎来一段100行的代码,大脑马上投降——“谁爱看谁看去吧”。

我们需要的不是“看起来很厉害”,是能看懂,大脑可以跟随思考的东西,所以有了本书的选择,以及本系列文章。

本书重点着眼于“如何入门“,讲解的内容不一定是最优解,也不能保证学完拿到offer,但可以帮你慢慢找到”吸收“算法的感觉,这样以来,学习的进度条才能往前走,哪怕走得慢一些,只要有动力一直走,早晚能到不是吗?

先来两道开胃菜。

数据结构和算法

数据结构总是和算法同时出现,它们是什么关系?

其实生活中随处可见数据结构。

地铁线路是“”,公司组织架构是“”,叠在一起的盘子是”“,车站排队进站是”队列“。

你或许想问,这些在JavaScript中都没有,怎么办?

JavaScript中,我们熟悉的数据结构只有“数组”和“对象”,但其实数组是一种通用型数据结构,以它为基础,可构造出栈、队列、树等,对象则是散列。

那么多数据结构,怎么区分和记忆?

它们可分为“逻辑结构”和“存储结构”这两类。

逻辑结构:比如树,逻辑上具有“父—>子”关系。

存储结构:物理上是集中存储,还是分散存储。如:数组、链表。

数据结构是算法的基石,提供了结构化存储的数据和操作数据的方法。

算法是数据结构发挥作用的舞台,数据结构结合算法才能解决特定问题。

但要清楚,二者不是一对一的绑定关系,就像生活中的问题总有不同解。算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,所以,往往对应着更优的选择。

质量评估

研究算法,是为了写出更好的程序,那什么是“好”?

如果把代码比作一匹马,好的算法就是“吃得少,还跑得快”。

当我们运行一段代码,直观感受到的,是快慢,不易察觉的,是内存占用。

那么优劣的评定就可归为两个维度:速度和内存,即“时间”和“空间”。

有了评定标准,怎么验证呢?不可能带着代码去每一台机器上跑一遍。

这时候,就需要一套与机器和网络都无关的评定方法——”复杂度分析“。

复杂度分析体现算法运行所需的资源与输入数据大小之间的关系。它描述了随着数据大小的增加,算法执行所需时间和空间的增长趋势

趋势是个很有意思的东西,有三个特点:

1、不精确,但能区分相对优劣。

2、忽略细枝末节,只关注大头。

3、量变引起质变,小于某个值的时候,A比B优,超过之后,可能是B比A优。

同时,时间和空间的利用往往难以共存,需要牺牲一方,成全另一方。

后续的学习过程中,还有很多机会感受它们的魅力。

我们的目标

本系列文章,尝试帮助不那么擅长编程的伙伴消除对算法的排斥和恐惧,培养算法思维,了解常见算法的用途和优势。

这里不得不提本书作者靳宇栋(@krahets) ,富有且慷慨,自身技术过硬的同时,能关注到基础相对薄弱的群体的需求,有耐心和创造力创作出这么好的作品,是为行业一大贡献。

著名物理学家费曼教授曾说:“Knowledge isn't free. You have to pay attention.”

书籍和文章可能是免费的,但要掌握它们,你需要付出时间和耐心。

希望大家通过这次旅程,体会到思考的乐趣,在后续的生活和工作中,更从容地解决自己遇到的问题。

本书 Github 已有超 110k star, 官网地址: www.hello-algo.com

不论你使用哪种语言,喜欢什么形式,它都能满足,如果等不及,可以先睹为快~

更多好文第一时间接收,可关注公众号:“前端说书匠”

昨天以前首页

if 语句对程序性能的影响

作者 韩非
2025年10月11日 17:25

力扣关联题目:3494. 酿造药水需要的最少总时间

1. 一组代码对比

针对背景涉及的力扣题目,我使用了一种简单的方式去求解:

  1. 计算第一瓶药水的开始处理时间以及每名巫师处理完第一瓶药水的结束时间
  2. 假设从第 0 秒处理第 n 瓶药水,需要保证药水处理不阻塞的条件是,药水在第 k 名巫师处理完后,其时间不得早于第 k + 1 名巫师处理完 n-1 瓶药水的时间
  3. 根据条件2遍历出第 n 瓶药水的最早可以开始处理的时间

其中代码的20-23行就是在计算每瓶药水的最早可以开始处理的时间。

程序一:

class Solution:
    def minTime(self, skill: List[int], mana: List[int]) -> int:
        n = len(skill)
        m = len(mana)
        
        prev_row = [0] * (n + 1)
        curr_row = [0] * (n + 1)
        
        # 初始化第一行
        for j in range(1, n+1):
            prev_row[j] = prev_row[j-1] + skill[j-1] * mana[0]
        
        for i in range(1, m):
            # 计算当前行
            for j in range(1, n+1):
                curr_row[j] = curr_row[j-1] + skill[j-1] * mana[i]
            
            # 计算最大差值
            maxDelta = 0
            for j in range(n):
                delta = prev_row[j+1] - curr_row[j]
                if delta > 0:
                    maxDelta = max(maxDelta,delta)
            
            # 调整当前行
            if maxDelta > 0:
                for j in range(n+1):
                    curr_row[j] += maxDelta
            
            # 滚动数组
            prev_row, curr_row = curr_row, prev_row
        
        return prev_row[n]

程序二

class Solution:
    def minTime(self, skill: List[int], mana: List[int]) -> int:
        n = len(skill)
        m = len(mana)
        
        prev_row = [0] * (n + 1)
        curr_row = [0] * (n + 1)
        
        # 初始化第一行
        for j in range(1, n+1):
            prev_row[j] = prev_row[j-1] + skill[j-1] * mana[0]
        
        for i in range(1, m):
            # 计算当前行
            for j in range(1, n+1):
                curr_row[j] = curr_row[j-1] + skill[j-1] * mana[i]
            
            # 计算最大差值
            maxDelta = 0
            for j in range(n):
                delta = prev_row[j+1] - curr_row[j]
                if delta > maxDelta:
                    maxDelta = delta
            
            # 调整当前行
            if maxDelta > 0:
                for j in range(n+1):
                    curr_row[j] += maxDelta
            
            # 滚动数组
            prev_row, curr_row = curr_row, prev_row
        
        return prev_row[n]

上面的两组程序是针对背景题目的同样的解法,唯一的区别在于第20行到第23行部分的条件判断语句的不同。
程序一在执行测试用例时,当数据量较大时,会出现超时的错误,程序二在同样条件下,可以在时间限制内执行完全部的测试用例。

将其中有差别的代码片段摘录出来如下
片段一:

...
maxDelta = 0
for j in range(n):
    delta = prev_row[j+1] - curr_row[j]
    if delta > 0:
        maxDelta = max(maxDelta,delta)
...

片段二:

...
maxDelta = 0
for j in range(n):
    delta = prev_row[j+1] - curr_row[j]
    if delta > maxDelta:
        maxDelta = delta
...

这两个代码片段的差异主要体现在以下方面:

  1. 条件判断的不同,片段一使用的是差值与 0 的对比,片段二使用的是差值与maxDelta的对比
  2. 片段一执行多一次 max 函数调用,其中 max 函数涉及到一次判断和一次赋值操作

可以看到,其中的核心区别在于条件判断的次数和条件判断的条件。

由条件判断导致的程序性能问题,其核心原因在于:

CPU流水线中,存在对分支的预测行为,预测的分支指令会占用CPU时钟,预测失败时,流水线清除,造成时钟浪费

2. CPU 中的流水线

在计算机中,流水线的存在可以让单个的处理器并行的执行多层级的指令。如下,是一个基础的五阶段流水线示意图。

image.png

流水线中各阶段操作如下:

阶段 缩写 功能
IF Instruction Fetch 从指令缓存中取指令
ID Instruction Decode 解码指令,读取寄存器
EX Execute 执行运算(ALU操作)
MEM Memory Access 访问数据内存(加载/存储)
WB Write Back 将结果写回寄存器

对于上图,可以参考下面的示意图来进行理解

image.png

时间轴从左至右:

  • 当第一个时钟周期到达后:
    • CPU 执行 IF 操作,取缓存中的第一条指令,在第一个时钟周期结束后,将 IF 的结果放入 IF 寄存器
  • 当第二个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第二条指令,在第二个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第一条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
  • 当第三个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第三条指令,在第三个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第二条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第一条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
  • 当第四个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第四条指令,在第四个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第三条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第二条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
    • CPU 执行 ME 操作,从 EX 寄存器中取第一条 EX 指令的结果,执行内存数据读取,将结果存入 EX 寄存器
  • 当第五个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第四条指令,在第四个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第四条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第三条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
    • CPU 执行 ME 操作,从 EX 寄存器中取第二条 EX 指令的结果,执行内存数据读取,将结果存入 EX 寄存器
    • CPU 执行 WB 操作,从 ME 寄存器中取第一条指令的结果,将结果存入寄存器

注意:
由于信号传输时间、功耗等物理条件的限制和均衡考虑,无法在一个时钟内执行完同一条指令的所有操作,时钟与时钟之间,CPU需要进行流水线寄存器的读写操作,以此在时钟之间传递数据。

在上面的五个时钟周期过后,缓存中的第一条指令执行完成,第二条指令还差最后一个阶段,使用五阶段流水线执行指令的耗时如下:

指令序号 1 2 3 4 5
流水线耗时 5时钟 6时钟 7时钟 8时钟 9时钟
串行耗时 5时钟 10时钟 15时钟 20时钟 25时钟

由此说明,在CPU中流水线并行操作,对于计算资源利用的必要性。

为了充分的利用CPU中的各操作单元,需要尽可能的让单个时钟周期内各个操作单元处于忙碌的状态,但是对于if...else... 类的分支逻辑,在指令运行到条件判断前,无法得知后续的指令内容,如果闲置操作单元,会造成资源的浪费,为了让计算机资源尽可能的被利用,CPU中就产生了分支预测行为。

3. CPU 中的分支预测

image.png

上图是一个4阶段流水线的执行示意图,其中红、蓝、紫、绿四个正方形表示的是一条指令,指令经过流水线从上向下执行,在指令等待区中就包括明确要执行的指令和CPU猜测可能需要执行的指令。

双向分支(if-else)通常通过条件跳转指令实现。条件跳转可以是"被采纳(taken)"并跳转到程序内存中的不同位置,也可以是"不被采纳"并继续紧接在条件跳转之后执行。在条件被计算出来并且条件跳转指令通过指令流水线中的执行阶段之前,无法确定条件跳转是会被采纳还是不被采纳(见图1)。

如果没有分支预测,处理器将不得不等待条件跳转指令通过执行阶段后,下一条指令才能进入流水线中的取指阶段。分支预测器试图通过猜测条件跳转最有可能被采纳还是不被采纳来避免这种时间浪费。然后,被猜测为最有可能的分支会被取指并被推测性地执行。如果之后检测到猜测错误,那么这些被推测性执行或部分执行的指令将被丢弃,流水线会从正确的分支重新开始,从而产生延迟。

在分支预测错误的情况下,所浪费的时间等于从取指阶段到执行阶段之间流水线的级数。现代微处理器往往具有相当长的流水线,因此预测错误导致的延迟在 10 到 20 个时钟周期之间。

第一次遇到条件跳转指令时,没有太多信息可以作为预测的基础。然而,分支预测器会记录分支是否被采纳的历史,因此当它遇到之前出现过多次的条件跳转时,它可以基于记录的历史进行预测。例如,分支预测器可以识别出该条件跳转"被采纳"的情况多于"不被采纳",或者它是每隔一次被采纳。

CPU中有专门的分支预测器及算法,负责相关的分支预测工作。

预测准确率 = (正确预测的分支数 / 总分支数) × 100%

不同预测器的表现:

  • 简单预测器:70-85%
  • 现代高级预测器:95-99%
  • 完美预测:100%

准确率每提升1%,整体性能可能提升1-2%

4. 编码风格分支预测器的影响

条件语句的特性对分支预测准确率有决定性影响。不同的条件语句模式会导致预测准确率从接近100%到接近50%的巨大差异。

4.1. 条件语句的模式分类

  • 高预测性模式(准确率 > 95%)

    • 高度偏向性条件
    // 模式:几乎总是成立或总是不成立
    if (array_size > 0) {        // 99.9% 成立
        // 处理数组
    }
    
    if (debug_mode_enabled) {    // 99.9% 不成立
        // 调试代码
    }
    

    预测效果:简单预测器就能达到极高准确率。

    • 循环终止条件
    // 模式:前N-1次成立,最后1次不成立
    for (int i = 0; i < 100; i++) {
        // 循环体
        // 条件 i < 100: 99次成立,1次不成立
    }
    

    预测效果:2位饱和计数器能完美预测,准确率接近100%。

    • 规律性交替
    // 模式:固定周期交替
    for (int i = 0; i < 100; i++) {
        if (i % 4 == 0) {        // 规律:每4次成立1次
            // 特殊处理
        }
    }
    

    预测效果:局部历史预测器能学习模式,准确率高。

  • 低预测性模式(准确率 50-80%)

    • 数据依赖性条件
    // 模式:依赖输入数据,难以预测
    if (user_input > threshold) {
        // 处理
    }
    
    if (data[i] % 2 == 0) {      // 奇偶随机分布
        // 偶数处理
    }
    

    预测效果:准确率接近随机猜测(50%)。

    • 哈希表/缓存查找
    // 模式:依赖哈希冲突率
    if (hash_table[hash(key)] != NULL) {
        // 键存在
    }
    

    预测效果:取决于数据分布,通常60-80%。

    • 随机性条件
    // 模式:基于随机数
    if (random() < 0.3) {        // 30% 概率成立
        // 随机事件
    }
    

    预测效果:准确率约70%(预测总选概率高的方向)。

4.2. 条件复杂度的影响

简单条件 vs 复杂条件

// 简单条件 - 易于预测
if (x > 0) { ... }

// 复杂条件 - 预测困难
if ((x > 0 && y < 10) || (z == 5 && !flag)) { ... }

影响

  • 简单条件:模式清晰,预测器容易学习
  • 复杂条件:多个变量的组合导致模式混乱

4.3. 数据局部性的影响

数据排序的威力

// 未排序数据 - 预测困难
int data[] = {3, -1, 8, -5, 2, -9, 7, -2};
for (int i = 0; i < 8; i++) {
    if (data[i] > 0) {   // 模式: T, F, T, F, T, F, T, F
        sum += data[i];
    }
}
// 预测准确率: ~50%

// 排序后数据 - 预测容易
int sorted_data[] = {-9, -5, -2, -1, 2, 3, 7, 8};
for (int i = 0; i < 8; i++) {
    if (sorted_data[i] > 0) { // 模式: F, F, F, F, T, T, T, T
        sum += sorted_data[i];
    }
}
// 预测准确率: ~100%

4.4 编程习惯的影响

  • 可预测的代码风格

    // 好的写法:创造可预测的模式
    // 1. 将大概率路径放在前面
    if (likely_success) {    // 使用likely宏提示编译器
        // 常见路径
    } else {
        // 罕见路径
    }
    
    // 2. 循环展开减少分支频率
    for (int i = 0; i < n; i += 4) {
        // 一次处理4个元素,减少循环条件判断
    }
    
    // 3. 使用查表代替复杂条件
    static const int action_table[] = {ACTION_A, ACTION_B, ...};
    action = action_table[condition1 * 4 + condition2 * 2 + condition3];
    
  • 难以预测的代码风格

    // 差的写法:引入随机性
    // 1. 过度使用小函数
    if (is_valid(input) && should_process(input) && can_retry(input)) {
        // 每个函数调用都可能隐藏分支
    }
    
    // 2. 复杂的状态机
    switch (get_complex_state()) {
        case STATE_A: ... break;
        case STATE_B: ... break; // 多个case分支难以预测
    }
    

5. 结论

回到一开始的问题:

  • 片段一:

    ...
    maxDelta = 0
    for j in range(n):
        delta = prev_row[j+1] - curr_row[j]
        if delta > 0: # 数据随机,预测成功率接近随机 50%
            maxDelta = max(maxDelta,delta) # 函数调用中存在内置的条件判断 ,增加分支预测难度
    ...
    
  • 片段二:

    ...
    maxDelta = 0
    for j in range(n):
        delta = prev_row[j+1] - curr_row[j]
        if delta > maxDelta: # delta 大概率比 maxDelta 小,分支可预测程度高,且判断条件简单
            maxDelta = delta
    ...
    

参考链接

  1. CPU流水线
  2. 分支预测

前端算法相关详解

2025年10月9日 15:37

一、前端为什么需要算法

1. 面试要求

  • 大厂面试必考内容,直接影响薪资水平
  • 考察逻辑思维和问题解决能力
  • 评估候选人的计算机基础

2. 实际应用

  • 数据处理:大量数据的排序、过滤、搜索
  • 性能优化:减少时间和空间复杂度
  • 复杂交互:图表渲染、动画计算
  • 业务逻辑:推荐算法、规则引擎

二、前端必备算法类型

1. 基础数据结构

数组相关

// 数组去重
function uniqueArray(arr) {
  return [...new Set(arr)];
}

// 数组扁平化
function flatten(arr) {
  return arr.reduce((prev, cur) => 
    prev.concat(Array.isArray(cur) ? flatten(cur) : cur), []);
}

链表操作

// 链表节点定义
class ListNode {
  constructor(val, next) {
    this.val = (val === undefined ? 0 : val);
    this.next = (next === undefined ? null : next);
  }
}

// 反转链表
function reverseList(head) {
  let prev = null;
  let current = head;
  
  while (current) {
    const next = current.next;
    current.next = prev;
    prev = current;
    current = next;
  }
  
  return prev;
}

树结构

// 二叉树节点
class TreeNode {
  constructor(val, left, right) {
    this.val = (val === undefined ? 0 : val);
    this.left = (left === undefined ? null : left);
    this.right = (right === undefined ? null : right);
  }
}

// 二叉树遍历
function inorderTraversal(root) {
  const result = [];
  
  function inorder(node) {
    if (node) {
      inorder(node.left);
      result.push(node.val);
      inorder(node.right);
    }
  }
  
  inorder(root);
  return result;
}

2. 常见算法题型

排序算法

// 快速排序
function quickSort(arr) {
  if (arr.length <= 1) return arr;
  
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr[pivotIndex];
  const left = [];
  const right = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (i === pivotIndex) continue;
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  
  return [...quickSort(left), pivot, ...quickSort(right)];
}

// 归并排序
function mergeSort(arr) {
  if (arr.length <= 1) return arr;
  
  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));
  
  return merge(left, right);
}

function merge(left, right) {
  const result = [];
  let i = 0;
  let j = 0;
  
  while (i < left.length && j < right.length) {
    if (left[i] < right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }
  
  return result.concat(left.slice(i), right.slice(j));
}

搜索算法

// 二分查找
function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1;
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) {
      return mid;
    } else if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  
  return -1;
}

// 深度优先搜索 (DFS)
function dfs(root, target) {
  if (!root) return false;
  if (root.val === target) return true;
  
  return dfs(root.left, target) || dfs(root.right, target);
}

// 广度优先搜索 (BFS)
function bfs(root, target) {
  if (!root) return false;
  
  const queue = [root];
  
  while (queue.length > 0) {
    const node = queue.shift();
    if (node.val === target) return true;
    
    if (node.left) queue.push(node.left);
    if (node.right) queue.push(node.right);
  }
  
  return false;
}

动态规划

// 斐波那契数列
function fibonacci(n) {
  if (n <= 1) return n;
  
  const dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  
  return dp[n];
}

// 最长公共子序列
function longestCommonSubsequence(text1, text2) {
  const m = text1.length;
  const n = text2.length;
  const dp = Array(m + 1).fill().map(() => Array(n + 1).fill(0));
  
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
  
  return dp[m][n];
}

三、前端场景中的算法应用

1. 虚拟列表优化

// 虚拟滚动算法
class VirtualList {
  constructor(container, options) {
    this.container = container;
    this.itemHeight = options.itemHeight;
    this.buffer = options.buffer || 5;
    this.data = options.data;
  }
  
  // 计算可见区域的项目
  getVisibleItems(scrollTop, containerHeight) {
    const startIdx = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
    const visibleCount = Math.ceil(containerHeight / this.itemHeight) + this.buffer * 2;
    const endIdx = Math.min(this.data.length - 1, startIdx + visibleCount);
    
    return {
      startIdx,
      endIdx,
      offsetY: startIdx * this.itemHeight
    };
  }
}

2. 节流防抖算法

// 防抖
function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

// 节流
function throttle(func, limit) {
  let inThrottle;
  return function (...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

3. 字符串匹配算法

// KMP算法实现
function kmpSearch(text, pattern) {
  const lps = computeLPS(pattern);
  const matches = [];
  let i = 0; // text index
  let j = 0; // pattern index
  
  while (i < text.length) {
    if (pattern[j] === text[i]) {
      i++;
      j++;
    }
    
    if (j === pattern.length) {
      matches.push(i - j);
      j = lps[j - 1];
    } else if (i < text.length && pattern[j] !== text[i]) {
      if (j !== 0) {
        j = lps[j - 1];
      } else {
        i++;
      }
    }
  }
  
  return matches;
}

function computeLPS(pattern) {
  const lps = new Array(pattern.length).fill(0);
  let len = 0;
  let i = 1;
  
  while (i < pattern.length) {
    if (pattern[i] === pattern[len]) {
      len++;
      lps[i] = len;
      i++;
    } else {
      if (len !== 0) {
        len = lps[len - 1];
      } else {
        lps[i] = 0;
        i++;
      }
    }
  }
  
  return lps;
}

四、算法学习路径

1. 初级阶段(1-2个月)

  • 熟悉基本数据结构:数组、链表、栈、队列
  • 掌握基础算法:排序、搜索
  • 练习简单题目:LeetCode 简单难度

2. 中级阶段(3-4个月)

  • 学习高级数据结构:树、图、堆
  • 掌握复杂算法:动态规划、回溯、贪心
  • 练习中等难度题目

3. 高级阶段(持续提升)

  • 学习图论算法:最短路径、最小生成树
  • 掌握高级技巧:并查集、线段树
  • 挑战困难题目,参与竞赛

五、推荐练习平台

1. 在线练习平台

  • LeetCode:最主流的算法练习平台
  • 牛客网:国内知名面试刷题平台
  • Codeforces:国际编程竞赛平台
  • HackerRank:多语言算法练习

2. 学习资源

  • 《算法导论》:经典算法教材
  • 《剑指Offer》:面试算法经典
  • 《LeetCode 101》:LeetCode 题解指南

六、前端算法面试准备

1. 常考题型分类

  • 数组类:两数之和、三数之和、盛最多水的容器
  • 链表类:反转链表、环形链表、合并链表
  • 树类:二叉树遍历、最大深度、路径和
  • 动态规划:爬楼梯、最长递增子序列、背包问题

2. 解题技巧

// 解题五步法
// 1. 理解题目要求
// 2. 分析输入输出
// 3. 设计算法思路
// 4. 编写代码实现
// 5. 测试验证结果

// 示例:两数之和
function twoSum(nums, target) {
  const map = new Map();
  
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i];
    if (map.has(complement)) {
      return [map.get(complement), i];
    }
    map.set(nums[i], i);
  }
  
  return [];
}

3. 时间复杂度优化

  • O(1) :常数时间操作
  • O(log n) :二分查找类算法
  • O(n) :单层循环
  • O(n log n) :快速排序、归并排序
  • O(n²) :双重循环,需优化

掌握算法不仅能帮助前端开发者在面试中脱颖而出,更重要的是能够提升解决复杂问题的能力,在实际工作中编写出更高效的代码。建议每天坚持练习1-2道算法题,循序渐进地提升算法能力。

LeetCode 热题 100 : 普通数组

2025年10月9日 01:09

1. 最大子数组和

题目描述

给你一个整数数组 nums,请你找出数组中连续子数组(最少包含一个元素)的最大和

解题思路

💡 核心技巧:动态规划 + 状态压缩
与滑动窗口不同,本题不能用双指针,因为数组包含负数,子数组和不具有单调性。

关键洞察

  • 定义 dp[i] 为以 nums[i] 结尾的最大子数组和
  • 状态转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])
  • 无需存储整个 dp 数组,用 sum 变量实时更新

为什么有效:当 sum 变为负数时,说明以当前元素结尾的子数组和不如从下一个元素开始,因此重置 sum = num

代码实现

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    let ans = -Infinity;
    let sum = 0;
    
    for (let num of nums) {
        // 选择:从当前元素开始,还是延续之前子数组
        sum = Math.max(sum + num, num);
        ans = Math.max(ans, sum);
    }
    
    return ans;
};

复杂度分析

  • 时间复杂度:O(n),单次遍历
  • 空间复杂度:O(1),仅用常数额外空间

2. 合并区间

题目描述

给定一组区间,合并所有重叠的区间。

解题思路

💡 核心技巧:排序 + 一次遍历
区间合并的关键在于排序,让重叠区间在遍历中自然出现。

关键洞察

  1. 按区间起始位置排序(intervals.sort((a, b) => a[0] - b[0])
  2. 初始化结果数组,放入第一个区间
  3. 遍历后续区间:
    • 若当前区间与结果数组最后一个区间重叠(current[0] <= last[1]),则合并
    • 否则,将当前区间加入结果

为什么排序是关键:排序后,重叠区间必然相邻,避免了 O(n²) 的暴力检查。

代码实现

/**
 * @param {number[][]} intervals
 * @return {number[][]}
 */
var merge = function(intervals) {
    if (intervals.length === 0) return [];
    
    // 按起始位置升序排序
    intervals.sort((a, b) => a[0] - b[0]);
    
    const merged = [intervals[0]];
    
    for (let i = 1; i < intervals.length; i++) {
        const current = intervals[i];
        const lastMerged = merged[merged.length - 1];
        
        // 检查重叠:当前区间起始 <= 上一个合并区间的结束
        if (current[0] <= lastMerged[1]) {
            // 合并:更新结束位置为两者的最大值
            lastMerged[1] = Math.max(lastMerged[1], current[1]);
        } else {
            // 无重叠,直接加入
            merged.push(current);
        }
    }
    
    return merged;
};

复杂度分析

  • 时间复杂度:O(n log n),排序占主导
  • 空间复杂度:O(n),结果数组最多存储 n 个区间

3. 旋转数组

题目描述

给定一个整数数组 nums 和一个整数 k将数组向右旋转 k 次

解题思路

💡 核心技巧:三次反转
无需额外空间,仅通过数组反转实现旋转。

关键洞察

  1. 整体反转:[1,2,3,4,5][5,4,3,2,1]
  2. 反转前 k 个元素:[5,4][4,5][4,5,3,2,1]
  3. 反转剩余元素:[3,2,1][1,2,3][4,5,1,2,3]

代码实现

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var rotate = function(nums, k) {
    const n = nums.length;
    k %= n; // 避免无效旋转
    if (k === 0) return;

    const reverse = (start, end) => {
        while (start < end) {
            [nums[start], nums[end]] = [nums[end], nums[start]];
            start++;
            end--;
        }
    };

    reverse(0, n - 1);       // 整体反转
    reverse(0, k - 1);       // 前 k 个反转
    reverse(k, n - 1);       // 后面部分反转
};

复杂度分析

  • 时间复杂度:O(n),三次反转各 O(n)
  • 空间复杂度:O(1),原地操作

4. 除自身以外的数组乘积

题目描述

给你一个整数数组 nums,返回一个数组 answer,其中 answer[i]numsnums[i] 以外所有元素的乘积

解题思路

💡 核心技巧:前缀乘积 × 后缀乘积

关键洞察

  • ans[i] = (nums[0] * ... * nums[i-1]) * (nums[i+1] * ... * nums[n-1])
  • pre 数组存前缀乘积,suf 数组存后缀乘积

代码实现

/**
 * @param {number[]} nums
 * @return {number[]}
 */
var productExceptSelf = function(nums) {
    const n = nums.length;
    const ans = new Array(n);
    
    // 计算前缀乘积(ans[0] = 1)
    let pre = 1;
    for (let i = 0; i < n; i++) {
        ans[i] = pre;
        pre *= nums[i];
    }
    
    // 计算后缀乘积并累乘到 ans
    let suf = 1;
    for (let i = n - 1; i >= 0; i--) {
        ans[i] *= suf;
        suf *= nums[i];
    }
    
    return ans;
};

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

空间优化:直接在 ans 数组中同时存前缀和后缀,可以使空间复杂度降到O(1)


5. 第一个缺失的正数

题目描述

给你一个未排序的整数数组 nums,找出其中最小的缺失的正整数

解题思路

💡 核心技巧:原地哈希
利用数组下标作为哈希表,将正整数放到对应位置。

关键洞察

  1. 有效范围:1 ~ n(n 为数组长度)
  2. 交换操作:将 nums[i] 放到 nums[nums[i]-1] 位置
  3. 最后遍历数组,第一个 nums[i] != i+1 的位置即为答案

为什么高效:O(n) 时间,O(1) 空间,无需额外哈希表。

代码实现

/**
 * @param {number[]} nums
 * @return {number}
 */
var firstMissingPositive = function(nums) {
    const n = nums.length;
    
    // 第一步:将正整数放到正确的位置(1~n)
    for (let i = 0; i < n; i++) {
        // 当前元素在1~n范围内,且不在正确位置
        while (nums[i] > 0 && nums[i] <= n && nums[i] !== nums[nums[i] - 1]) {
            // 交换到目标位置
            const j = nums[i] - 1;
            [nums[i], nums[j]] = [nums[j], nums[i]];
        }
    }
    
    // 第二步:遍历数组,找到第一个不符合条件的位置
    for (let i = 0; i < n; i++) {
        if (nums[i] !== i + 1) {
            return i + 1;
        }
    }
    
    // 如果全部符合,返回n+1
    return n + 1;
};

复杂度分析

  • 时间复杂度:O(n),两次遍历,交换操作总次数 ≤ n
  • 空间复杂度:O(1),原地操作

总结对比

题目 核心技巧 关键优化
最大子数组和 动态规划 + 状态压缩 无需额外数组
合并区间 排序 + 一次遍历 排序后重叠区间自然相邻
旋转数组 三次反转 无额外空间
除自身以外的数组乘积 前缀/后缀乘积 用结果数组存储中间结果
第一个缺失的正数 原地哈希 利用数组下标作为哈希表

希望这篇解析对你有帮助!如果你喜欢这类结构清晰、对比鲜明的算法总结,欢迎关注我的 LeetCode 热题 100 系列 👋

❌
❌