web前端算法精选面试题

311 阅读1小时+

在算法题中,“只追求 AC” 是一种常见的应试心态或行为,具体含义和背景如下:

一、AC 的含义

AC 是英文 Accepted 的缩写,是编程竞赛、算法刷题平台(如 LeetCode、Codeforces 等)中的状态标识,表示代码通过了所有测试用例,完全满足题目要求。
例如:

  • 提交代码后,系统提示 “AC” 或 “通过”,代表程序正确解决了问题。
  • 反之,可能显示 “Wrong Answer(WA)”“Time Limit Exceeded(TLE,超时)” 等失败状态。

二、“只追求 AC” 的具体表现

  1. 以通过测试为唯一目标

    • 只要代码能在平台上通过所有预设测试用例,即使算法效率低下(如用暴力枚举解决本该用优化算法的问题)、代码结构混乱、未考虑边界情况的普适性,也认为 “完成任务”。

    • 举例:

      • 题目要求计算斐波那契数列第 n 项,用递归(时间复杂度 O (2ⁿ))实现,虽然会超时,但通过调整测试用例范围(如 n≤20)“骗” 过平台,达到 AC。
      • 忽略空间复杂度,用极端消耗内存的方法存储中间结果,仅为通过当前题目。
  2. 忽视算法优化与知识积累

    • 不深入思考更优解法(如时间复杂度更低的贪心、动态规划、数据结构优化等),也不总结同类问题的通用规律,仅满足于 “能跑通”。

    • 例如:

      • 解决 “两数之和” 问题时,用双重循环(O (n²))实现,而不学习哈希表(O (n))的优化思路。
      • 对排序算法只掌握冒泡排序(O (n²)),不了解快速排序(O (n log n))或归并排序的原理和应用场景。
  3. 缺乏代码质量意识

    • 代码可读性差(如变量名无意义、无注释)、逻辑冗余,甚至使用 “hack 手段”(如硬编码测试用例)通过题目,不考虑代码的可维护性和扩展性。

三、“只追求 AC” 的利弊分析

优点
  • 短期效率高:在时间紧张的情况下(如面试前突击刷题),快速 AC 能短期内积累题量,熟悉常见题型的套路,应对部分考察基础的面试。
  • 建立信心:对新手而言,多次 AC 可增强编程信心,避免因频繁失败而放弃。
缺点
  1. 限制技术提升

    • 无法掌握高效算法(如贪心、动态规划、图论等),难以应对大厂面试中对时间 / 空间复杂度的高要求(如要求 O (n) 或 O (n log n) 的解法)
    • 例如:LeetCode 中等难度以上的题目常要求优化算法,单纯暴力 AC 可能在面试中被面试官追问 “如何优化” 时卡住。
  2. 缺乏工程思维

    • 真实开发中,代码质量、可维护性、性能优化是核心要求。仅追求 AC 会导致习惯 “应试式编程”,难以适应实际项目需求(如处理大规模数据、高并发场景)。
  3. 面试竞争力不足

    • 大厂面试不仅考察 “能否解决问题”,更关注 “如何高效解决问题”。例如:

      • 面试官可能问:“你的解法时间复杂度是多少?有没有更优的方式?”
      • 若仅能给出暴力解法(即使 AC),可能被认为 “算法基础薄弱”,错失 offer

四、正确的刷题心态:AC 之外,更要追求 “优解”

  1. 注重算法优化

    • 解完题后,主动学习官方题解、社区讨论中的最优解法,理解其原理(如时间复杂度分析、数据结构选择)。
    • 例如:解决 “最长回文子串” 问题时,先掌握暴力解法(O (n³)),再学习动态规划(O (n²))和 Manacher 算法(O (n)),逐步优化
  2. 培养代码规范

    • 用有意义的变量名(如maxLength而非a)、添加必要注释、避免冗余代码,养成工程化编程习惯。
  3. 总结题型规律

    • 按题型分类刷题(如数组、链表、树、贪心、动态规划等),总结每种题型的常见解法和套路,形成 “解题思维模板”。
    • 例如:遇到 “求最短路径” 问题,能快速联想到 BFS、Dijkstra 算法或 Floyd 算法的适用场景。
  4. 模拟真实场景

    • 在本地 IDE(如 VS Code、IntelliJ)中编写代码,而非仅依赖在线平台的 “一键提交”,锻炼调试和处理复杂问题的能力。

总结

“只追求 AC” 在入门阶段可作为短期目标,但长期来看,算法学习的核心价值在于提升逻辑思维、优化能力和工程素养。真正的竞争力体现在:

  • 面对问题时,能快速想到最优解法;
  • 写出高效、健壮、易维护的代码;
  • 在面试中清晰阐述算法思路和优化过程。
    因此,刷题时应 “AC 与优解并重”,从 “通过测试” 迈向 “精通算法”。

数据结构类型 7类

数据结构(Data Structures)概述

数据结构是计算机中存储、组织和管理数据的方式,直接影响程序的 效率性能。不同的数据结构适用于不同的场景,例如:

  • 数组:快速随机访问,但插入/删除慢。
  • 链表动态内存分配,插入/删除快,但访问慢。
  • 栈(Stack):后进先出(LIFO),用于函数调用、表达式求值
  • 队列(Queue):先进先出(FIFO),用于任务调度、消息队列
  • 树(Tree):层次结构,如二叉树、堆、B树,用于数据库索引、文件系统。
  • 图(Graph):网络关系,如社交网络、路径规划
  • 哈希表(Hash Table):快速查找,如字典、缓存。

1. 线性数据结构

(1) 数组(Array)

  • 特点:连续内存,支持 O(1) 随机访问,但插入/删除需 O(n) 时间
  • 应用:存储固定大小的数据,如矩阵、缓存。
  • JavaScript 示例
    const arr = [1, 2, 3];
    console.log(arr[0]);  // 1(O(1) 访问)
    

(2) 链表(Linked List)

  • 特点:数据一般都是分散存储于内存中的,无须存储在连续空间内,插入/删除 O(1),但访问 O(n)。
  • 类型
    • 单链表(每个节点指向下一个节点)。
    • 双链表(节点同时指向前后节点)。
  • JavaScript 实现
    class Node {
        constructor(value) {
            this.value = value;
            this.next = null;
        }
    }
    
    class LinkedList {
        constructor() {
            this.head = null;
        }
        // 添加节点
        append(value) {
            const newNode = new Node(value);
            if (!this.head) this.head = newNode;
            else {
                let current = this.head;
                while (current.next) current = current.next;
                current.next = newNode;
            }
        }
    }
    
    const list = new LinkedList();
    list.append(1);
    list.append(2);
    

(3) 栈(Stack)

  • 特点:LIFO(后进先出),仅允许在栈顶操作
  • 应用:函数调用栈、括号匹配、撤销操作。
  • JavaScript 实现
    class Stack {
        constructor() {
            this.items = [];
        }
        push(item) { this.items.push(item); }
        pop() { return this.items.pop(); }
        peek() { return this.items[this.items.length - 1]; }
    }
    
    const stack = new Stack();
    stack.push(1);
    stack.push(2);
    console.log(stack.pop());  // 2
    

(4) 队列(Queue)

  • 特点:FIFO(先进先出),队尾入队,队首出队。
  • 变种
    • 双端队列(Deque):两端均可操作。
    • 优先队列(Priority Queue):按优先级出队(如堆实现)。
  • JavaScript 实现
    class Queue {
        constructor() {
            this.items = [];
        }
        enqueue(item) { this.items.push(item); }
        dequeue() { return this.items.shift(); }
    }
    
    const queue = new Queue();
    queue.enqueue("A");
    queue.enqueue("B");
    console.log(queue.dequeue());  // "A"
    

2. 非线性数据结构

(1) 树(Tree)

  • 特点:层次结构,每个节点有零或多个子节点。
  • 常见类型
    • 二叉树:每个节点最多两个子节点。
    • 二叉搜索树(BST):左子树 < 根 < 右子树。
    • 堆(Heap)完全二叉树,用于优先队列
    • AVL 树 / 红黑树:自平衡二叉搜索树。
  • JavaScript 实现(BST)
    class TreeNode {
        constructor(value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }
    
    class BST {
        constructor() { this.root = null; }
        insert(value) {
            const newNode = new TreeNode(value);
            if (!this.root) this.root = newNode;
            else this._insertNode(this.root, newNode);
        }
        _insertNode(node, newNode) {
            if (newNode.value < node.value) {
                if (!node.left) node.left = newNode;
                else this._insertNode(node.left, newNode);
            } else {
                if (!node.right) node.right = newNode;
                else this._insertNode(node.right, newNode);
            }
        }
    }
    
    const bst = new BST();
    bst.insert(5);
    bst.insert(3);
    bst.insert(7);
    

(2) 图(Graph)

  • 特点:由顶点(Vertex)和边(Edge)组成,表示多对多关系。
  • 表示方法
    • 邻接矩阵:二维数组,适合稠密图。
    • 邻接表:哈希表 + 链表,适合稀疏图。
  • JavaScript 实现(邻接表)
    class Graph {
        constructor() {
            this.adjacencyList = new Map();
        }
        addVertex(vertex) {
            if (!this.adjacencyList.has(vertex)) {
                this.adjacencyList.set(vertex, []);
            }
        }
        addEdge(v1, v2) {
            this.adjacencyList.get(v1).push(v2);
            this.adjacencyList.get(v2).push(v1);  // 无向图
        }
    }
    
    const graph = new Graph();
    graph.addVertex(&#34;A&#34;);
    graph.addVertex(&#34;B&#34;);
    graph.addEdge(&#34;A&#34;, &#34;B&#34;);
    

(3) 哈希表(Hash Table)

  • 特点:通过哈希函数将键映射到存储位置,实现 O(1) 平均查找。
  • 冲突解决
    • 链地址法:冲突时用链表存储。
    • 开放寻址法:冲突时寻找下一个空位。
  • JavaScript 示例
    const map = new Map();
    map.set(&#34;name&#34;, &#34;Alice&#34;);
    map.set(&#34;age&#34;, 25);
    console.log(map.get(&#34;name&#34;));  // &#34;Alice&#34;
    

3. 总结

  • 线性结构:数组、链表、栈、队列。
  • 非线性结构:树、图。
  • 哈希表高效查找,但需处理冲突
  • 选择依据:根据 访问模式数据规模操作频率 决定。

掌握数据结构是算法优化的基础,建议动手实现并比较不同结构的性能! 🚀

如何选择数据结构

需求推荐数据结构
快速查找哈希表、二叉搜索树
动态插入/删除链表、平衡树
有序数据二叉搜索树、堆
层次关系树、图
先进先出队列
后进先出

数据结构与算法面试分析

算法与数据结构

数据结构可以说是算法的基石,如果没有扎实的数据结构基础,想要把算法学好甚至融会贯通是非常困难的,而优秀的算法又往往取决于你采用哪种数据结构。学好这个专题也是很有必要的,那么我们可以稍微的做个分类。

  • 常用数据结构

    • 数组,字符串
    • 链表
    • 队列
  • 高级数据结构

    • 前缀树
    • 线段树
    • 树状数组
    • 主席树

那么显然,最常见的数据结构一定是需要掌握的,对于高级的数据结构而言,如果你有时间,对它有所热爱的话,可以深入了解,比如这个主席树在解决一些问题 的时候,算法复杂度是log级别的,某些场景下很有帮助。

这里想提及的就是。它的结构很显然是很直观的,树当然有很多的性质,这里也列举不完,比如面试中常考的树:

普通二叉树、平衡二叉树、完全二叉树、二叉搜索树、四叉树(Quadtree)、多叉树(N-ary Tree)。

对于它而言的话,我们需要到哪些程度呢?

对于常见树的遍历,从树的前序遍历,到中序遍历,后续遍历,以至于层次遍历,掌握好这四种遍历的递归写法和非递归写法是非常重要的,接下来需要懂得分析各种写法的时间复杂度和空间复杂度。

面试准备阶段,把树这个结构花时间去准备的话,对于你理解递归还是很有帮助的,同时也能帮助你学习一些图论的知识,更加准确的说,树是面试考察的热门考点尤其是二叉树!

掌握好这些数据结构是基础,绝大部分的算法面试题都得靠它们来帮忙,因此,一定要花功夫勤练题目来深入理解它们。

介绍冒泡排序,选择排序,冒泡排序如何优化

手写冒泡排序

介绍快速排序和原理 * 4

各种排序,重点是快排

手写一个快速排序 你看过归并排序行吗?

排序算法

这应该是面试最常考,最核心的算法。如果你能把排序算法理解的很透彻的话,接下来的其他算法也是一样的旁敲侧击。当时我梳理得是常见的6个排序算法:

时间复杂度指的是一个算法执行所耗费的时间,一般用代码执行的次数(步数)
空间复杂度指运行完一个程序所需内存的大小
稳定指,如果a=b,a在b的前面,排序后a仍然在b的前面
不稳定指,如果a=b,a在b的前面,排序后可能会交换位置

基本排序: 冒泡、选择、插入
高级排序: 希尔、快速、归并、堆

原生js里面的sort方法,在firefox里面是用归并排序实现的,而在chrome里面是用快速排序的变体来实现的。

在此之前,我也写过一篇排序算法的文章,个人觉得言简意赅,可以看看[「算法与数据结构」梳理6大排序算法](juejin.cn/post/685654… "juejin.cn/post/685654…;)

有时候,面试官喜欢会问冒泡排序和插入排序,基本上这些都是考察你的基础知识,并且看看你能不能快速地写出没有bug的代码。又比如,当面试官问你归并排序、快速排序和拓扑排序等的时候,这个时候考察的是你平时对算法得积累,所以有必要做个总结。

我们拿归并排序来举例子,我们应该如何表达清楚呢?首先,我们应该把这个它的思路说清楚:

归并排序的核心思想就是分治,它将一个复杂的问题分成两个或者多个相同或相似的子问题,然后把子问题分成更小的子问题,直到子问题可以简单的直接求解,最原问题的解就是子问题解的合并。归并排序将分治的思想体现得淋漓尽致。

当你向面试官理清楚这个思路时,面试官心里就有底了,他会想,嘿,这个小伙子不错!那你接下来都有底气了!

有了思想,那么实现起来就不难了:

一开始先把数组从中间划分成两个子数组,一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素,才开始排序。

排序的方法就是按照大小顺序合并两个元素,接着依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好。

对于这部分的算法而言,可以围绕从解题思路-->>实现过程-->>代码实现。 基本上以这三步来实现的话,掌握常见的排序算法完成是没有问题的。

冒泡排序

冒泡排序(Bubble Sort)—— JavaScript 实现

冒泡排序是一种简单的 比较排序算法,它重复地遍历数组,比较相邻元素并交换它们的位置,使得较大的元素逐渐“浮”到数组的末尾。尽管它的效率较低(时间复杂度 O(n²)),但由于其实现简单,常被用于教学或小规模数据排序


1. 冒泡排序步骤

  1. 外层循环:控制排序轮数,每轮确定一个最大值的位置。
  2. 内层循环:比较相邻元素,如果 arr[j] > arr[j + 1],则交换它们。
  3. 优化如果某一轮没有发生交换,说明数组已经有序,可以提前终止

2. JavaScript 实现

(1) 基础版本(无优化)

image.png

function bubbleSort(arr) {
        const len = arr.length;
        // 外面循环是数组有几个元素,就要循环几次才能排好序,
        // 最后一次数组已经拍好序了,所不用循环了所以len-1
        for (i = 0; i < len - 1; i++) {
            // 每次循环只要交换len-1次(j最大数为len-1,所以j+1最大为len换句话说最后个数,没有其他数可以比较了)
            // 每循环一次,就排好一个数字所以后面的数字就不用循环了所以 len - 1 - i 
            for (j = 0; j < len - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]//es6s
                }
            }
        }
        return arr;
}

// 测试
const arr = [5, 3, 8, 4, 2];
console.log(bubbleSort(arr));  // [2, 3, 4, 5, 8]

(2) 优化版本(提前终止)

如果某一轮没有发生交换,说明数组已经有序,可以提前结束排序:

function bubbleSortOptimized(arr) {
    const n = arr.length;
    let swapped;  // 标记是否发生交换
    for (let i = 0; i < n - 1; i++) {
        swapped = false;
        for (let j = 0; j < n - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
                swapped = true;
            }
        }
        if (!swapped) break;  // 如果没有交换,提前结束
    }
    return arr;
}

// 测试
const arr = [1, 2, 3, 4, 5];  // 已经有序
console.log(bubbleSortOptimized(arr));  // [1, 2, 3, 4, 5](仅需 1 轮)

3. 时间复杂度

情况时间复杂度说明
最坏情况O(n²)数组完全逆序
最好情况O(n)数组已经有序(优化后)
平均情况O(n²)一般情况
空间复杂度O(1)原地排序
  • 稳定性:稳定

总结

  • 冒泡排序 是最简单的排序算法之一,但效率较低。
  • 优化方法(提前终止)可以在最好情况下使时间复杂度降至 O(n)
  • 适用场景
    • 数据量小(如 n < 100)。
    • 需要稳定排序(相同元素顺序不变)
    • 教学用途(理解排序算法的基础)。

如果你的数据量较大,建议使用更高效的排序算法(如快速排序、归并排序)。

选择排序

选择排序(Selection Sort)—— JavaScript 实现

选择排序是一种简单直观的 比较排序算法,其核心思想是 每次从未排序部分选择最小(或最大)元素,放到已排序部分的末尾。虽然它的时间复杂度较高(O(n²)),但由于实现简单,适合小规模数据排序或教学用途。


1. 选择排序步骤

  1. 外层循环:控制排序轮数,每轮确定一个最小值的位置。
  2. 内层循环:遍历未排序部分,找到最小值的索引。
  3. 交换:将最小值与当前未排序部分的第一个元素交换。

2. JavaScript 实现

(1) 基础版本

image.png

image.png

function selectionSort(arr) {
    const n = arr.length;
    for (let i = 0; i < n - 1; i++) {
        let minIndex = i;  // 假设当前未排序部分的第一个是最小值
        for (let j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;  // 更新最小值的索引
            }
        }
        // 交换最小值到正确位置
        [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
    }
    return arr;
}

// 测试
const arr = [64, 25, 12, 22, 11];
console.log(selectionSort(arr));  // [11, 12, 22, 25, 64]

(2) 优化版本(同时找最小和最大值)

可以同时查找 最小值和最大值,减少交换次数:

function selectionSortOptimized(arr) {
    const n = arr.length;
    for (let i = 0; i < Math.floor(n / 2); i++) {
        let minIndex = i;
        let maxIndex = i;
        for (let j = i; j < n - i; j++) {
            if (arr[j] < arr[minIndex]) minIndex = j;
            if (arr[j] > arr[maxIndex]) maxIndex = j;
        }
        // 将最小值放到前面
        [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
        // 如果最大值原本在 i 的位置,由于交换后它被移动到了 minIndex 的位置
        if (maxIndex === i) maxIndex = minIndex;
        // 将最大值放到后面
        [arr[n - 1 - i], arr[maxIndex]] = [arr[maxIndex], arr[n - 1 - i]];
    }
    return arr;
}

// 测试
const arr = [5, 3, 8, 4, 2];
console.log(selectionSortOptimized(arr));  // [2, 3, 4, 5, 8]

3. 时间复杂度

情况时间复杂度说明
最坏情况O(n²)无论输入如何,都要比较 n(n-1)/2 次
最好情况O(n²)即使数组已经有序,仍需完整遍历
平均情况O(n²)一般情况
空间复杂度O(1)原地排序

5. 总结

  • 选择排序 是一种 简单但不高效 的排序算法,适合 小规模数据教学演示
  • 优点
    • 实现简单,代码直观。
    • 交换次数较少(最多 n-1 次交换)
  • 缺点
    • 时间复杂度始终为 O(n²),不适合大规模数据。
    • 不稳定排序(相同元素的相对位置可能改变)

适用场景

  • 数据量较小(如 n < 1000)。
  • 对内存使用敏感(原地排序,空间复杂度 O(1))。
  • 需要减少交换次数的情况。

如果数据规模较大,建议使用更高效的排序算法(如快速排序、归并排序)。

插入排序

插入排序(Insertion Sort)—— JavaScript 实现

插入排序是一种简单直观的 比较排序算法,其核心思想是 将未排序部分的元素逐个插入到已排序部分的正确位置。虽然它的平均时间复杂度为 O(n²),但在处理 小规模数据部分有序数据 时表现优秀,甚至比某些 O(n log n) 算法更快。


1. 插入排序步骤

  1. 外层循环:从第二个元素开始(假设第一个元素已排序)。
  2. 内层循环将当前元素与已排序部分的元素 从后往前 比较,找到合适位置插入。
  3. 插入操作:移动比当前元素大的元素,腾出位置插入。

image.png

image.png

2. JavaScript 实现

(1) 基础版本

function insertionSort(arr) {
    const n = arr.length;
    for (let i = 1; i < n; i++) {
        const current = arr[i];  // 当前要插入的元素
        let j = i - 1;          // 已排序部分的最后一个索引
        // 从后往前比较,找到插入位置
        while (j >= 0 && arr[j] > current) {
            arr[j + 1] = arr[j];  // 比 current 大的元素后移
            j--;
        }
        arr[j + 1] = current;  // 插入 current
    }
    return arr;
}

// 测试
const arr = [5, 2, 4, 6, 1, 3];
console.log(insertionSort(arr));  // [1, 2, 3, 4, 5, 6]

(2) 优化版本(二分查找插入位置)

对于较大的已排序部分,可以用 二分查找 优化比较次数:

function insertionSortOptimized(arr) {
    const n = arr.length;
    for (let i = 1; i < n; i++) {
        const current = arr[i];
        // 使用二分查找找到插入位置
        let left = 0, right = i - 1;
        while (left <= right) {
            const mid = Math.floor((left + right) / 2);
            if (arr[mid] < current) left = mid + 1;
            else right = mid - 1;
        }
        // 从 left 到 i-1 的元素后移
        for (let j = i - 1; j >= left; j--) {
            arr[j + 1] = arr[j];
        }
        arr[left] = current;  // 插入 current
    }
    return arr;
}

// 测试
const arr = [5, 2, 4, 6, 1, 3];
console.log(insertionSortOptimized(arr));  // [1, 2, 3, 4, 5, 6]

3. 时间复杂度

情况时间复杂度说明
最坏情况O(n²)数组完全逆序,每次插入需移动所有已排序元素
最好情况O(n)数组已经有序,只需比较 n-1 次
平均情况O(n²)一般情况
空间复杂度O(1)原地排序

4. 插入排序 vs 选择排序 vs 冒泡排序

对比项插入排序选择排序冒泡排序
时间复杂度(平均)O(n²)O(n²)O(n²)
最好情况O(n)(已有序)O(n²)O(n)(优化后)
交换/移动次数O(n²)(可能多次移动)O(n)(最多 n-1 次交换)O(n²)(可能多次交换)
稳定性稳定(不改变相同元素顺序)不稳定稳定
适用场景小规模或部分有序数据小规模数据小规模数据

5. 总结

  • 插入排序简单且高效 的排序算法,特别适合:
    • 小规模数据(如 n < 1000)。
    • 部分有序数据(如日志文件按时间近乎有序)。
  • 优点
    • 实现简单,空间复杂度 O(1)
    • 在数据近乎有序时接近 O(n) 时间复杂度
  • 缺点
    • 平均时间复杂度 O(n²),不适合大规模乱序数据。

适用场景

  • 实时数据流排序(数据逐步到来时插入排序效率高)。
  • 混合排序算法(如 Timsort 结合了插入排序和归并排序)。

如果数据规模较大或完全无序,建议使用快速排序或归并排序。 🚀

快速排序

快速排序(Quick Sort)—— JavaScript 实现

快速排序是一种高效的 分治(Divide and Conquer)排序算法,由 Tony Hoare 在 1959 年提出。它的核心思想是:

  1. 选基准(Pivot):从数组中选一个元素作为基准。
  2. 分区(Partition):将数组分为两部分,左边 ≤ 基准,右边 > 基准
  3. 递归:对左右子数组重复上述过程。

image.png


1. 快速排序步骤

(1) 选择基准(Pivot)

常见选择方式:

  • 第一个/最后一个元素简单但可能导致最坏情况
  • 随机元素减少最坏情况概率
  • 三数取中法(选首、中、尾的中位数)

(2) 分区(Partition)

两种主流分区方法:

  • Lomuto 分区(较简单,但交换次数多)
  • Hoare 分区(更高效,推荐使用)

(3) 递归排序

对左右子数组递归调用快速排序。


2. JavaScript 实现

(1) Lomuto 分区方案

function quickSort(arr, left = 0, right = arr.length - 1) {
    if (left < right) {
        const pivotIndex = partitionLomuto(arr, left, right);
        quickSort(arr, left, pivotIndex - 1);  // 排序左半部分
        quickSort(arr, pivotIndex + 1, right); // 排序右半部分
    }
    return arr;
}

function partitionLomuto(arr, left, right) {
    const pivot = arr[right];  // 选择最后一个元素作为基准
    let i = left - 1;          // 小于基准的边界指针
    
    for (let j = left; j < right; j++) {
        if (arr[j] <= pivot) {
            i++;
            [arr[i], arr[j]] = [arr[j], arr[i]];  // 交换
        }
    }
    
    [arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];  // 基准归位
    return i + 1;  // 返回基准的最终位置
}

// 测试
const arr = [3, 1, 4, 2, 8, 5, 7, 6];
console.log(quickSort(arr));  // [1, 2, 3, 4, 5, 6, 7, 8]

(2) Hoare 分区方案(更高效)

function quickSortHoare(arr, left = 0, right = arr.length - 1) {
    if (left < right) {
        const pivotIndex = partitionHoare(arr, left, right);
        quickSortHoare(arr, left, pivotIndex);  // 注意这里包含 pivotIndex
        quickSortHoare(arr, pivotIndex + 1, right);
    }
    return arr;
}

function partitionHoare(arr, left, right) {
    const pivot = arr[Math.floor((left + right) / 2)];  // 选择中间元素
    let i = left - 1;
    let j = right + 1;
    
    while (true) {
        do { i++; } while (arr[i] < pivot);
        do { j--; } while (arr[j] > pivot);
        if (i >= j) return j;
        [arr[i], arr[j]] = [arr[j], arr[i]];  // 交换
    }
}

3. 时间复杂度

情况时间复杂度说明
平均情况O(n log n)每次分区大致平衡
最好情况O(n log n)每次都能均分数组
最坏情况O(n²)数组已有序且基准选择不当
空间复杂度O(log n)递归栈的深度

4. 优化策略

(1) 随机化基准

function partitionRandom(arr, left, right) {
    const randomIndex = Math.floor(Math.random() * (right - left + 1)) + left;
    [arr[randomIndex], arr[right]] = [arr[right], arr[randomIndex]];  // 交换到末尾
    return partitionLomuto(arr, left, right);  // 继续 Lomuto 分区
}

(2) 三数取中法

function getMedianOfThree(arr, left, right) {
    const mid = Math.floor((left + right) / 2);
    // 找出三个数的中位数
    if (arr[left] > arr[mid]) [arr[left], arr[mid]] = [arr[mid], arr[left]];
    if (arr[left] > arr[right]) [arr[left], arr[right]] = [arr[right], arr[left]];
    if (arr[mid] > arr[right]) [arr[mid], arr[right]] = [arr[right], arr[mid]];
    return mid;  // 返回中间值的索引
}

(3) 小数组改用插入排序

function quickSortOptimized(arr, left = 0, right = arr.length - 1) {
    if (right - left + 1 <= 10) {  // 小数组使用插入排序
        insertionSort(arr, left, right);
        return arr;
    }
    const pivotIndex = partitionHoare(arr, left, right);
    quickSortOptimized(arr, left, pivotIndex);
    quickSortOptimized(arr, pivotIndex + 1, right);
    return arr;
}

5. 快速排序 vs 归并排序

对比项快速排序归并排序
时间复杂度O(n log n) 平均O(n log n) 最坏
空间复杂度O(log n)O(n)
稳定性不稳定稳定
适用场景通用排序需要稳定排序时

6. 总结

  • 快速排序平均性能最优 的排序算法,适合大多数场景。
  • 优化技巧
    • 随机化基准避免最坏情况
    • 三数取中法提高分区平衡性
    • 小数组切换插入排序减少递归开销
  • 适用场景
    • 大规模数据排序
    • 内存受限环境(空间复杂度 O(log n))

JavaScript 的 Array.prototype.sort() 在 Chrome/V8 引擎中实际使用 Timsort(混合了归并排序和插入排序),但快速排序仍然是面试和算法学习的重点! 🚀

常规方法

快速排序是一种高效的排序算法,它使用了分治法的策略。在快速排序中,我们选择一个“基准”元素,然后将数组分为两部分:一部分包含所有比基准小的元素,另一部分包含所有比基准大的元素。这个过程递归地应用于子数组,直到整个数组被排序。

以下是用 JavaScript 实现快速排序的代码:

    function quickSort(arr) {
        // 基本情况:如果数组长度小于等于1,则直接返回(已经排序好)
        if (arr.length <= 1) {
            return arr;
        }

        // 选择基准点(这里我们选择数组的中间元素作为基准)
        const pivotIndex = Math.floor(Math.random() * (len - 1)) //随机基准
        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]);
            }
        }

        // 递归调用quickSort,并连接结果
        return [...quickSort(left), pivot, ...quickSort(right)];
    }

    // 示例
    const array = [3, 6, 8, 10, 1, 2, 1];
    console.log(quickSort(array)); // 输出: [1, 1, 2, 3, 6, 8, 10]

这段代码首先定义了一个基本结束条件:当数组长度为1或更短时,它已经是有序的了。然后它选取了一个“基准”元素(这里选择了数组中间的元素),并创建了两个新数组left和right,分别存放比基准小和大的元素。最后,它递归地对left和right进行快速排序,并将它们与基准合并起来形成最终的结果。

请注意,虽然这个实现易于理解,但由于其使用了额外的数组空间,不是原地排序。对于大型数据集或者性能敏感的应用,你可能需要优化此算法,比如通过原地交换元素的方式来减少空间复杂度。

快速排序的性能会受到基准选择的影响,实际应用中可能会采用更复杂的策略来选择基准,如随机选择或三数取中等

归并排序

归并排序(Merge Sort)—— JavaScript 实现

归并排序是一种高效的 分治(Divide and Conquer)排序算法,采用 递归 的方式将数组不断拆分为更小的子数组进行排序,然后合并有序子数组。它的时间复杂度稳定为 O(n log n),是稳定排序算法的经典代表。


1. 归并排序步骤

  1. 分解(Divide):将数组递归地拆分为左右两半,直到子数组长度为 1。
  2. 合并(Merge):将两个有序子数组合并为一个更大的有序数组。

2. JavaScript 实现

(1) 递归版本(自顶向下)

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, 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)).concat(right.slice(j));
}

// 测试
const arr = [5, 3, 8, 4, 2, 7, 1, 6];
console.log(mergeSort(arr));  // [1, 2, 3, 4, 5, 6, 7, 8]

(2) 迭代版本(自底向上,空间优化)

function mergeSortIterative(arr) {
    const n = arr.length;
    // 外层循环控制子数组大小(1, 2, 4, 8...)
    for (let size = 1; size < n; size *= 2) {
        // 内层循环合并相邻的子数组
        for (let left = 0; left < n - size; left += 2 * size) {
            const mid = left + size - 1;
            const right = Math.min(left + 2 * size - 1, n - 1);
            mergeInPlace(arr, left, mid, right);
        }
    }
    return arr;
}

// 原地合并(需辅助数组)
function mergeInPlace(arr, left, mid, right) {
    const temp = arr.slice(left, right + 1);
    let i = 0, j = mid - left + 1, k = left;

    while (i <= mid - left && j <= right - left) {
        if (temp[i] <= temp[j]) {
            arr[k++] = temp[i++];
        } else {
            arr[k++] = temp[j++];
        }
    }

    // 处理剩余元素
    while (i <= mid - left) arr[k++] = temp[i++];
    while (j <= right - left) arr[k++] = temp[j++];
}

3. 时间复杂度

情况时间复杂度说明
最坏情况O(n log n)稳定,与输入数据无关
最好情况O(n log n)即使数组已有序仍需完整拆分合并
平均情况O(n log n)一般情况
空间复杂度O(n)需额外存储合并后的数组

4. 归并排序 vs 快速排序

对比项归并排序快速排序
时间复杂度稳定 O(n log n)平均 O(n log n),最坏 O(n²)
空间复杂度O(n)O(log n)(递归栈)
稳定性稳定(相同元素顺序不变)不稳定
适用场景链表排序、外部排序通用内部排序

5. 优化策略

(1) 小数组改用插入排序

function mergeSortOptimized(arr) {
    if (arr.length <= 16) {  // 小数组使用插入排序
        return insertionSort(arr);
    }
    const mid = Math.floor(arr.length / 2);
    const left = mergeSortOptimized(arr.slice(0, mid));
    const right = mergeSortOptimized(arr.slice(mid));
    return merge(left, right);
}

(2) 避免重复分配内存(指针操作)

适用于链表或底层优化,JavaScript 中较少使用。


6. 总结

  • 归并排序稳定且高效 的排序算法,适合:
    • 大规模数据排序(时间复杂度稳定 O(n log n))。
    • 链表排序(无需随机访问,合并操作高效)。
    • 外部排序(数据量超过内存时,分块排序后合并)。
  • 优点
    • 稳定排序,适合需要保持相同元素顺序的场景。
    • 时间复杂度不受输入数据影响。
  • 缺点
    • 需额外 O(n) 空间,内存开销较大。

JavaScript 的 Array.prototype.sort() 在 Chrome/V8 引擎中使用的是 Timsort(归并排序 + 插入排序的混合算法),可见归并排序在实践中的重要性! 🚀

分治算法: 分成多个小模块,与原问题性质相同

动态规划,参见背包问题 * 3

动态规划

动态规划(Dynamic Programming)—— JavaScript 详解

image.png

一、核心思想

动态规划(DP)是一种 解决复杂问题 的方法,通过将问题分解为 重叠子问题并存储子问题的解(记忆化),避免重复计算。其核心特点:

  • 最优子结构:问题的最优解包含子问题的最优解。
  • 重叠子问题:子问题被多次重复计算。

二、动态规划 vs 分治法
对比项动态规划分治法
子问题重叠子问题独立子问题
存储方式记忆化存储子问题的解不存储子问题的解
典型应用最短路径、背包问题、编辑距离归并排序、快速排序

三、实现步骤
  1. 定义状态:明确 dp[i]dp[i][j] 表示的含义。
  2. 确定状态转移方程:找到 dp[i]dp[i-1]dp[i-2] 等的关系。
  3. 初始化:设置初始值(如 dp[0]dp[1])。
  4. 确定计算顺序:自底向上(迭代)或自顶向下(记忆化递归)。
  5. 优化空间(可选):压缩 dp 表的维度。

四、经典问题与代码实现
1. 斐波那契数列(入门示例)
// 递归法(低效,O(2^n))
function fib(n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

// 动态规划(迭代法,O(n))
function fibDP(n) {
    if (n <= 1) return n;
    let dp = [0, 1];
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

// 空间优化版(O(1))
function fibOptimized(n) {
    if (n <= 1) return n;
    let a = 0, b = 1;
    for (let i = 2; i <= n; i++) {
        [a, b] = [b, a + b];
    }
    return b;
}
2. 爬楼梯问题(LeetCode 70)
  • 问题:每次爬1或2阶,到第n阶有多少种方法?
  • 状态定义dp[i] 表示到第i阶的方法数。
  • 转移方程dp[i] = dp[i-1] + dp[i-2]
function climbStairs(n) {
    if (n <= 2) return n;
    let a = 1, b = 2;
    for (let i = 3; i <= n; i++) {
        [a, b] = [b, a + b];
    }
    return b;
}
3. 最长递增子序列(LIS,LeetCode 300)
  • 状态定义dp[i] 表示以 nums[i] 结尾的最长递增子序列长度。
  • 转移方程dp[i] = max(dp[j] + 1),其中 j < inums[j] < nums[i]
function lengthOfLIS(nums) {
    const dp = new Array(nums.length).fill(1);
    let max = 1;
    for (let i = 1; i < nums.length; i++) {
        for (let j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        max = Math.max(max, dp[i]);
    }
    return max;
}
4. 0-1背包问题
  • 问题:给定物品重量 weights 和价值 values,背包容量 W,求最大价值。
  • 状态定义dp[i][w] 表示前 i 个物品在容量 w 下的最大价值。
  • 转移方程
    • 不选第i个物品:dp[i][w] = dp[i-1][w]
    • 选第i个物品:dp[i][w] = dp[i-1][w - weights[i]] + values[i]
function knapsack(weights, values, W) {
    const n = weights.length;
    const dp = Array.from({length: n + 1}, () => Array(W + 1).fill(0));
    
    for (let i = 1; i <= n; i++) {
        for (let w = 1; w <= W; w++) {
            if (w < weights[i-1]) {
                dp[i][w] = dp[i-1][w];
            } else {
                dp[i][w] = Math.max(
                    dp[i-1][w],
                    dp[i-1][w - weights[i-1]] + values[i-1]
                );
            }
        }
    }
    return dp[n][W];
}

// 测试
const weights = [2, 3, 4, 5];
const values = [3, 4, 5, 6];
console.log(knapsack(weights, values, 8)); // 10(选物品2和4)
5. 编辑距离(LeetCode 72)
  • 问题:将单词A转换为单词B所需的最少操作(插入、删除、替换)。
  • 状态定义dp[i][j] 表示 A[0..i-1]B[0..j-1] 的编辑距离。
  • 转移方程
    • A[i-1] === B[j-1]dp[i][j] = dp[i-1][j-1]
    • 否则:dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
function minDistance(word1, word2) {
    const m = word1.length, n = word2.length;
    const dp = Array.from({length: m + 1}, () => Array(n + 1).fill(0));
    
    for (let i = 0; i <= m; i++) dp[i][0] = i;
    for (let j = 0; j <= n; j++) dp[0][j] = j;
    
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (word1[i-1] === word2[j-1]) {
                dp[i][j] = dp[i-1][j-1];
            } else {
                dp[i][j] = 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
            }
        }
    }
    return dp[m][n];
}

// 测试
console.log(minDistance(&#34;horse&#34;, &#34;ros&#34;)); // 3

五、动态规划的两种实现方式
1. 自顶向下(记忆化递归)
  • 优点:代码直观,按需计算子问题。
  • 缺点:递归栈开销,可能栈溢出。
// 斐波那契记忆化递归
const memo = new Map();
function fibMemo(n) {
    if (n <= 1) return n;
    if (memo.has(n)) return memo.get(n);
    const res = fibMemo(n-1) + fibMemo(n-2);
    memo.set(n, res);
    return res;
}
2. 自底向上(迭代填表)
  • 优点:无递归开销,空间可优化。
  • 缺点:需要明确计算顺序。
// 斐波那契迭代法(已在前文示例中展示)

六、常见优化技巧
  1. 滚动数组:压缩空间复杂度(如斐波那契问题用变量代替数组)。
  2. 状态压缩:将二维 dp 表优化为一维(如背包问题)。
  3. 剪枝:提前终止不必要的计算(如某些路径问题)。

七、如何识别动态规划问题?
  • 关键词最大值、最小值、方案数、是否可行
  • 特征:问题可分解为重叠子问题,且具有最优子结构。
  • 经典题型:字符串匹配、路径规划、资源分配、序列问题。

八、练习建议
  1. LeetCode 题库
    • 简单:70(爬楼梯)、118(杨辉三角)
    • 中等:198(打家劫舍)、322(零钱兑换)
    • 困难:312(戳气球)、174(地下城游戏)
  2. 手写推导:画 dp 表,手动计算前几项。
  3. 对比解法:尝试递归、记忆化递归和迭代法。

九、总结
  • 动态规划本质:用空间换时间,存储子问题解以避免重复计算。
  • 核心步骤:定义状态 → 找转移方程 → 初始化 → 计算顺序 → 空间优化。
  • 适用场景:最优化问题、计数问题、可行性判断。

关键口诀
递归暴力解,优化用 DP;
状态转移方程,子问题要重叠;
表格填起来,空间省下来!"

动态规划: 每个状态都是过去历史的一个总结

动态规划

动态规划难,可以说是很多面试者也是我最怕的部分,尤其是面试的时候,怕面试官考这个算法了。遇到没有做过的题目,这个时候,能否写出状态转移方程是十分重要的。接下来我们聊一聊这个专题吧。首先,强烈推荐我之前分析这个专题如何准备的: [「算法与数据结构」一张脑图带你看动态规划算法之美](juejin.cn/post/687211… "juejin.cn/post/687211…;)

如何学动态规划,从哪里入手,应该这么去做,这么去刷题,肯定是很多初学者一开始就会遇到的问题。

  • 概念
  • 动态规划解决了什么问题
  • 动态规划解题的步骤
  • 如何高效率刷dp专题

首先,你得了解动态规划是什么,它的思想是什么,定义又是啥。这里引入维基百科对它的定义:

Wikipedia 定义:它既是一种数学优化的方法,同时也是编程的方法。

当然了,看完这段话,我们肯定对它不了解的,我们可以翻译一下,首先它可以算是一种优化的手段,优化一些重复子问题的操作,将很多重叠子问题通过编程的方式来解决,比如记忆划搜索。 又比如,如果一个原问题,可以拆分成很多子问题,它们之间没有任何后续性,当前的决策对后续没有影响的话,每个子问题的最优解,就可以组合成原问题的最优解了。

当然了,对于动态规划每个人理解是不同的,对于应用到具体的场景中,需要我们都去用多维度的状态去表述它的含义,这里也就是状态转移方程的含义所在。

嗯,那么动态规划解决了什么问题呢,很显然,对于重复性问题来说,它可以很好的解决,那么从某个维度上来看,它可以优化一个算法的时间复杂度,也就是通常意义上的,拿空间来换取时间的操作

动态规划解题步骤: 这个应该就是实际落地的操作,需要我们去通过大量的题目来完成,具体我们需要怎么做呢?

解题思路,三大步骤👇

  1. 状态定义
  2. 列出状态转移方程
  3. 初始化状态

[「算法与数据结构」一张脑图带你看动态规划算法之美](juejin.cn/post/687211… "juejin.cn/post/687211…

如何高效率刷dp专题:首先,你得找到对应的dp专题,这里的话,我帮你准备好了,接下来我说一下我是怎么刷leetcode上面的题目的。

一般而言,刷完中等的leetcode上的dp专题,基本上可以满足要求了。那么对于中等的dp题目,很多时候,我是写不出来的,那我应该如何去做呢?

  • 首先,我先看题解,把它的状态转移方程写下来,仔细的品味一下,它这么定义,解决了我之前的什么难点,为啥我是没有想到的。
  • 然后,看完之后,尝试按照这个题解思路,我自己能不能单独实现呢?
  • 如果不能的话,就照着它的代码,写一遍,多看看状态转移方程是如何写的,把这个题目收藏起来。
  • 等到下次,或者是隔天,再来看一遍题目,然后看看能不能单独完成,如果不能,第三天再这么操作。

还有,我个人建议,刷dp的话,最好从易到难,这样子自己也会有信心,也不会再去畏惧它。

斐波那契数列

斐波那契数列(Fibonacci Sequence)—— 从基础到高阶全解析


一、定义与数学背景

image.png

二、JavaScript 实现方法对比
1. 递归法(最简实现,但效率极低)
function fibRecursive(n) {
    if (n <= 1) return n;
    return fibRecursive(n-1) + fibRecursive(n-2);
}
// 示例:fibRecursive(10) → 55
  • 时间复杂度:( O(2^n) )(指数级增长)
  • 缺点:重复计算严重(例如计算 ( F(5) ) 时多次计算 ( F(3) ))。
  • 适用场景仅用于教学演示,实际开发中禁用

2. 迭代法(线性时间复杂度,推荐)
function fibIterative(n) {
    if (n <= 1) return n;
    let a = 0, b = 1;
    for (let i = 2; i <= n; i++) {
        [a, b] = [b, a + b];
    }
    return b;
}
// 示例:fibIterative(10) → 55
  • 时间复杂度:( O(n) )
  • 空间复杂度:( O(1) )
  • 优点高效,适合所有实际场景

3. 动态规划(记忆化递归)
function fibMemo(n, memo = {}) {
    if (n <= 1) return n;
    if (memo[n] !== undefined) return memo[n];
    memo[n] = fibMemo(n-1, memo) + fibMemo(n-2, memo);
    return memo[n];
}
// 示例:fibMemo(10) → 55
  • 时间复杂度:( O(n) )
  • 空间复杂度:( O(n) )(存储中间结果)
  • 优点:保留递归的直观性,避免重复计算。

4. 矩阵快速幂(对数时间复杂度,适合极大 n)
function multiply(a, b) {
    return [
        [a[0][0] * b[0][0] + a[0][1] * b[1][0], 
        a[0][0] * b[0][1] + a[0][1] * b[1][1],
        [a[1][0] * b[0][0] + a[1][1] * b[1][0], 
        a[1][0] * b[0][1] + a[1][1] * b[1][1]
    ];
}

function matrixPower(matrix, power) {
    let result = [[1, 0], [0, 1]]; // 单位矩阵
    while (power > 0) {
        if (power % 2 === 1) result = multiply(result, matrix);
        matrix = multiply(matrix, matrix);
        power = Math.floor(power / 2);
    }
    return result;
}

function fibMatrix(n) {
    if (n <= 1) return n;
    const matrix = [[1, 1], [1, 0]];
    const powered = matrixPower(matrix, n - 1);
    return powered[0][0];
}
// 示例:fibMatrix(10) → 55
  • 时间复杂度:( O(\log n) )(利用矩阵幂的快速计算)
  • 适用场景:当 ( n \geq 10^6 ) 时性能显著优于迭代法。

5. 生成器函数(按需生成无限序列)
function* fibonacciGenerator() {
    let a = 0, b = 1;
    yield a;
    yield b;
    while (true) {
        [a, b] = [b, a + b];
        yield b;
    }
}

// 示例:生成前10项
const gen = fibonacciGenerator();
Array.from({length: 10}, () => gen.next().value); 
// → [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
  • 特点:惰性求值,适合流式处理或内存有限场景。

三、大数问题与解决方案

JavaScript 的 Number 类型在 ( n > 78 ) 时会溢出,需使用 BigInt

function fibBigInt(n) {
    let a = 0n, b = 1n;
    for (let i = 2; i <= n; i++) {
        [a, b] = [b, a + b];
    }
    return b;
}
console.log(fibBigInt(100)); // 354224848179261915075n

四、应用场景
  1. 算法题爬楼梯问题、青蛙跳台阶、股票分析等。
  2. 自然界:植物叶序、鹦鹉螺壳的螺旋结构、蜂巢繁殖模型。
  3. 金融:斐波那契回调线(股票技术分析工具)。
  4. 计算机科学:动态规划问题、密码学(斐波那契哈希)。

五、性能对比(n=40)
方法时间(毫秒)空间复杂度
递归法~1500O(n)
迭代法~0.01O(1)
矩阵快速幂~0.05O(1)
BigInt迭代~0.1O(1)

六、总结
  • 基础需求:使用 迭代法动态规划
  • 超大 ( n ):选择 矩阵快速幂BigInt迭代
  • 数学探索:研究通项公式(Binet公式)与黄金分割比。
  • 避免递归:除非明确需要教学演示。

斐波那契数列不仅是编程入门的经典问题,更是连接数学、自然与计算机科学的桥梁。掌握其多种实现方法,能帮助你深入理解算法优化的核心思想!

求斐波那契数列(兔子数列)* 3

1,1,2,3,5,8,13,21,34,55,89...中的第 n 项

斐波那契数列?怎么优化?

迭代法

其实对于 ES6,已经对递归函数做了尾调用优化(尾递归),是可以用递归来实现的。而 Python 解释器是没有实现尾调用优化的。

二分法

二分法(Binary Search)


一、核心思想

二分法是一种在 有序数组 中快速查找目标值的算法,其核心思想是:

  1. 分治策略:将搜索区间不断对半分,缩小查找范围。
  2. 排除法:根据中间值与目标值的比较,舍弃不可能存在的区间。

时间复杂度

  • 平均/最坏:( O(\log n) )
  • 最优:( O(1) )(直接命中中间值)

二、适用条件
  • 有序性数组必须是有序的(升序或降序)
  • 随机访问:支持通过索引快速定位元素(如数组,链表不适用)。

三、标准实现(左闭右闭区间)
function binarySearch(arr, target) {
    let left = 0;
    let right = arr.length - 1;  // 闭区间 [left, right]

    while (left <= right) {      // 终止条件:left > right
        const mid = Math.floor((left + right) / 2);
        if (arr[mid] === target) {
            return mid;          // 找到目标,返回索引
        } else if (arr[mid] < target) {
            left = mid + 1;      // 目标在右半区间 [mid+1, right]
        } else {
            right = mid - 1;     // 目标在左半区间 [left, mid-1]
        }
    }
    return -1;  // 未找到
}

// 示例
const arr = [1, 3, 5, 7, 9];
console.log(binarySearch(arr, 7));  // 3
console.log(binarySearch(arr, 2));  // -1

四、关键细节解析
1. 区间定义
  • 左闭右闭 [left, right]
    初始化时 right = arr.length - 1,循环条件为 left <= right,更新区间时 left = mid + 1right = mid - 1

  • 左闭右开 [left, right)
    初始化时 right = arr.length,循环条件为 left < right,更新时 left = mid + 1right = mid

2. 中间值计算
  • 防溢出写法mid = left + Math.floor((right - left) / 2)
    避免 (left + right) 导致整数溢出(JavaScript 中数值范围较大,此问题不显著,但其他语言需注意)。
3. 循环终止条件
  • 左闭右闭:当 left > right 时终止,此时搜索区间为空。

五、常见变种问题
1. 查找第一个等于目标值的索引
function findFirst(arr, target) {
    let left = 0, right = arr.length - 1;
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (arr[mid] >= target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return (left < arr.length && arr[left] === target) ? left : -1;
}
2. 查找最后一个等于目标值的索引
function findLast(arr, target) {
    let left = 0, right = arr.length - 1;
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (arr[mid] <= target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return (right >= 0 && arr[right] === target) ? right : -1;
}
3. 查找插入位置(无重复元素)
function searchInsert(arr, target) {
    let left = 0, 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 left;  // 返回应插入的位置
}

六、应用场景
  1. 有序数组查找如数据库索引、缓存查询
  2. 数值计算:求平方根、寻找单调函数的解。
  3. 旋转数组问题:如寻找旋转排序数组的最小值。
  4. 范围查询:如寻找上下边界、统计目标值出现次数。

七、经典题目示例
1. 求平方根(精确到小数点后6位)
function sqrt(x) {
    if (x <= 1) return x;
    let left = 0, right = x;
    let precision = 1e-7;
    while (right - left > precision) {
        let mid = (left + right) / 2;
        if (mid * mid < x) left = mid;
        else right = mid;
    }
    return right.toFixed(6);
}

console.log(sqrt(2));  // &#34;1.414214&#34;
2. 寻找旋转排序数组中的最小值
function findMin(nums) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] > nums[right]) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return nums[left];
}

console.log(findMin([4,5,6,7,0,1,2]));  // 0

八、注意事项
  1. 有序性检查:使用前务必确保数组有序。
  2. 边界处理:仔细定义区间和终止条件,避免死循环或遗漏元素。
  3. 重复元素:根据需求选择查找第一个、最后一个或任意位置。
  4. 浮点数二分:处理精度时需设置合理的终止条件(如误差范围)。

九、总结
  • 二分法本质:通过缩小搜索区间快速定位目标,核心是 循环不变量(区间定义的一致性)。
  • 适用场景:有序数据的高效查找、数值计算、分界问题。
  • 实现关键:正确处理中间值比较与区间更新,避免“差1错误”。

掌握二分法不仅能提升算法效率,更是解决复杂问题的基石(如分布式系统中的日志检索、机器学习中的超参数搜索)。

二分查找的时间复杂度怎么求?

  • 平均/最坏:( O(\log n) )
  • 最优:( O(1) )(直接命中中间值)

二分排序法

用二分查找实现 indexOf 方法,不允许用递归

一个有序的数组进行查找操作?

(手写) 别说了,二分查找开始吧

列表转成树

在JavaScript中,将列表(数组)转换为树形结构是一个常见的任务。通常,列表中的每个对象都有一个表示其父级对象的字段(例如,parentId)。以下是一个简单的示例,展示如何实现这种转换:

function listToTree(list, parentId = null) {  
    let map = {};  
    let node, roots = [];  
    let i;  
    for (i = 0; i < list.length; i += 1) {  
        map[list[i].id] = { ...list[i], children: [] };  
    }  
    for (i = 0; i < list.length; i += 1) {  
        node = map[list[i].id];  
        if (list[i].parentId === parentId) {  
            roots.push(node);  
        } else {  
            map[list[i].parentId].children.push(node);  
        }  
    }  
    return roots;  
}  
  
// 示例数据  
const list = [  
    { id: 1, name: 'Node 1', parentId: null },  
    { id: 2, name: 'Node 1.1', parentId: 1 },  
    { id: 3, name: 'Node 1.2', parentId: 1 },  
    { id: 4, name: 'Node 2', parentId: null },  
    { id: 5, name: 'Node 2.1', parentId: 4 },  
    { id: 6, name: 'Node 2.2', parentId: 4 },  
    { id: 7, name: 'Node 2.1.1', parentId: 5 },  
];  
  
// 转换列表为树  
const tree = listToTree(list);  
  
// 打印树形结构  
function printTree(tree, level = 0) {  
    tree.forEach(node => {  
        console.log(`${'--'.repeat(level)} ${node.name}`);  
        if (node.children.length > 0) {  
            printTree(node.children, level + 1);  
        }  
    });  
}  
  
printTree(tree);

在这个示例中,listToTree 函数接受一个列表(list)和一个可选的父级ID(parentId)。它首先创建一个映射(map),将每个列表项与其ID关联起来,并初始化一个空的children数组。然后,它遍历列表,将每个项添加到其父项的children数组中,或者如果它没有父项(即它是根节点),则将其添加到roots数组中。最后,它返回根节点的数组。

printTree 函数是一个辅助函数,用于以易于阅读的格式打印树形结构。它递归地遍历树,并在每个级别前打印相应数量的破折号(--),以表示节点的深度。

搜索算法

这部分也是尤其重要的,那么重点学习深度优先搜索算法(简称为 DFS)和广度优先搜索算法(简称为 BFS)。

在这里推荐一个有趣的题目:

[穿过迷宫的最少移动次数](link.juejin.cn?target=https%3A%2F%2Fleetcode-cn.com%2Fproblems%2Fminimum-moves-to-reach-target-with-rotations%2F "leetcode-cn.com/problems/mi…;)

如果你也遇到过迷宫类似的问题,就可以考虑搜索算法了,从我个人的角度来说,它的思路其实就是模拟人的思路,每次走到一个路口的时候,我可以走哪里,我之前走过的路,怎么确保,接下来是不能走的,这里需要在编程的角度,如何去实现呢?

这里说一说我的经验,对于刚刚提到的题目而言,我盲猜使用BFS,题目做多了,自然就会有心得,对于BFS和DFS而言,做了两个类似的题目,会发现,原来搜索算法也是有迹可循,也是存在某些套路的。

给些建议:

一开始可能做的时候,抓不到头脑,有思路,但是代码很难写清楚,那么如何去做呢? 看题解,了解别人的写法是很不错的,可以多个对比,看看哪一份题解代码是你目前可以理解的,然后抄下来,看一遍。

最普通的办法就是:先画图,看看思维上跟实际代码需要做哪些改变,如何去优化这个过程。最后结合别人代码,一定不要直接copy,不去思考为什么这么写,不然后期发现,是没有多大效果的,一定要多结合自己的理解。

嗯,不会就看题解,多思考为什么这么写!!!

深度优先遍历

深度优先遍历(DFS)—— JavaScript 详解


一、核心思想

深度优先遍历(Depth-First Search)是一种 “一条路走到黑” 的遍历策略,优先沿分支纵深探索,直到无法继续再回溯。其特点:

  • 递归或栈实现:天然适合递归,也可用显式栈模拟。
  • 路径探索:常用于寻找路径、连通性检测、拓扑排序等问题。

二、DFS vs BFS 对比
对比项DFS(深度优先)BFS(广度优先)
数据结构栈(递归调用栈或显式栈)队列
空间复杂度O(h)(h为树的高度)O(w)(w为树的最大宽度)
适用场景路径存在性、拓扑排序、回溯问题最短路径、层级遍历、社交网络关系

三、DFS 实现方式
1. 树的DFS遍历(递归实现)
class TreeNode {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}

// 前序遍历:根 → 左 → 右
function preorderDFS(root) {
    const result = [];
    function traverse(node) {
        if (!node) return;
        result.push(node.val);
        traverse(node.left);
        traverse(node.right);
    }
    traverse(root);
    return result;
}

// 中序遍历:左 → 根 → 右
function inorderDFS(root) {
    const result = [];
    function traverse(node) {
        if (!node) return;
        traverse(node.left);
        result.push(node.val);
        traverse(node.right);
    }
    traverse(root);
    return result;
}

// 后序遍历:左 → 右 → 根
function postorderDFS(root) {
    const result = [];
    function traverse(node) {
        if (!node) return;
        traverse(node.left);
        traverse(node.right);
        result.push(node.val);
    }
    traverse(root);
    return result;
}
四、DFS 应用场景
1. 路径查找(LeetCode 113)
function pathSum(root, targetSum) {
    const result = [];
    const dfs = (node, path, sum) => {
        if (!node) return;
        path.push(node.val);
        sum += node.val;
        if (!node.left && !node.right && sum === targetSum) {
            result.push([...path]);
        }
        dfs(node.left, path, sum);
        dfs(node.right, path, sum);
        path.pop(); // 回溯
    };
    dfs(root, [], 0);
    return result;
}
2. 岛屿数量(LeetCode 200)
function numIslands(grid) {
    let count = 0;
    const dfs = (i, j) => {
        if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length || grid[i][j] !== '1') return;
        grid[i][j] = '0'; // 标记为已访问
        dfs(i + 1, j);
        dfs(i - 1, j);
        dfs(i, j + 1);
        dfs(i, j - 1);
    };

    for (let i = 0; i < grid.length; i++) {
        for (let j = 0; j < grid[0].length; j++) {
            if (grid[i][j] === '1') {
                dfs(i, j);
                count++;
            }
        }
    }
    return count;
}
3. 拓扑排序(LeetCode 207)
function canFinish(numCourses, prerequisites) {
    const graph = Array.from({ length: numCourses }, () => []);
    const visited = new Array(numCourses).fill(0); // 0:未访问, 1:访问中, 2:已访问
    
    // 建图
    for (const [a, b] of prerequisites) {
        graph[b].push(a);
    }
    
    // DFS检测环
    const hasCycle = (node) => {
        if (visited[node] === 1) return true;
        if (visited[node] === 2) return false;
        visited[node] = 1;
        for (const neighbor of graph[node]) {
            if (hasCycle(neighbor)) return true;
        }
        visited[node] = 2;
        return false;
    };
    
    for (let i = 0; i < numCourses; i++) {
        if (hasCycle(i)) return false;
    }
    return true;
}

五、DFS 关键技巧
  1. 标记访问状态:防止重复遍历(图中尤其重要)。
  2. 回溯法:在路径问题中撤销选择(如组合、排列问题)。
  3. 剪枝优化:提前终止无效分支(如数独求解)。
  4. 记忆化搜索:结合动态规划提升效率(如滑雪问题 LeetCode 329)。

六、复杂度分析
场景时间复杂度空间复杂度
树遍历O(n)(n为节点数)O(h)(h为树的高度)
图遍历O(V + E)(V顶点,E边)O(V)(存储访问状态)
矩阵遍历O(mn)(m行,n列)O(mn)(递归栈或标记)

七、常见问题

1. 递归栈溢出怎么办?

  • 改用迭代法(显式栈),或限制递归深度。

2. 如何记录路径?

  • 使用数组保存路径,回溯时撤销选择(见路径查找示例)。

3. 如何处理图中的环?

  • visited 数组标记访问状态,检测到重复访问立即终止。

八、总结
  • DFS核心:纵深优先探索 + 回溯。
  • 适用场景:路径问题、连通性检测、拓扑排序、回溯算法。
  • 实现方式:递归(简洁)或迭代栈(避免栈溢出)。
  • 优化方向:剪枝、记忆化、状态压缩。

练习推荐

  • LeetCode 46(全排列)、LeetCode 79(单词搜索)、LeetCode 494(目标和)

对树进行遍历,从第一个节点开始,遍历其子节点,直到它的所有子节点都被遍历完毕,然后再遍历它的兄弟节点

在JavaScript中,深度优先遍历(Depth-First Search, DFS)是一种用于遍历或搜索树或图的算法。这个算法会尽可能深地搜索树的分支。当节点v的所在边都已被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。

以下是一个简单的JavaScript函数,它实现了对树的深度优先遍历。这个函数假设树是由节点对象组成的,每个节点对象都有一个children属性,该属性是一个包含该节点的所有子节点的数组。

function depthFirstSearch(root) {  
    if (root === null) {  
        return;  
    }  
  
    console.log(root.value); // 访问当前节点  
  
    // 遍历当前节点的所有子节点  
    for (let i = 0; i < root.children.length; i++) {  
        depthFirstSearch(root.children[i]);  
    }  
}  
  
// 示例树结构  
const tree = {  
    value: 'root',  
    children: [  
        {  
            value: 'child1',  
            children: [  
                { value: 'grandchild1', children: [] },  
                { value: 'grandchild2', children: [] }  
            ]  
        },  
        {  
            value: 'child2',  
            children: [  
                { value: 'grandchild3', children: [] },  
                { value: 'grandchild4', children: [] }  
            ]  
        }  
    ]  
};  
  
// 从根节点开始深度优先遍历  
depthFirstSearch(tree);

在这个例子中,depthFirstSearch函数首先检查传入的节点是否为null。如果是,函数就返回并不做任何事情。否则,它会打印出当前节点的值,然后遍历并递归调用其所有子节点。这样就保证了每个节点都会被访问,并且是先访问其所有子节点,然后再访问其兄弟节点。

广度优先遍历

广度优先遍历(BFS)—— JavaScript 详解


一、核心思想

广度优先遍历(Breadth-First Search)是一种 逐层扩展 的遍历策略,先访问离起点最近的节点,再逐步向外探索。其特点:

  • 队列实现:天然依赖队列数据结构(先进先出)
  • 最短路径:适合求解无权图中的最短路径问题
  • 层级遍历:按层级顺序处理节点(如树的层级遍历)。

二、BFS vs DFS 对比
对比项BFS(广度优先)DFS(深度优先)
数据结构队列栈(递归调用栈或显式栈)
空间复杂度O(w)(w为树的最大宽度)O(h)(h为树的高度)
适用场景最短路径、层级遍历、社交网络关系路径存在性、拓扑排序、回溯问题

三、BFS 实现方式
1. 树的BFS遍历(层级遍历)
class TreeNode {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}

function levelOrder(root) {
    if (!root) return [];
    const result = [];
    const queue = [root];  // 初始化队列
    
    while (queue.length > 0) {
        const levelSize = queue.length;  // 当前层节点数
        const currentLevel = [];
        for (let i = 0; i < levelSize; i++) {
            const node = queue.shift();  // 队首出列
            currentLevel.push(node.val);
            if (node.left) queue.push(node.left);  // 左子节点入队
            if (node.right) queue.push(node.right); // 右子节点入队
        }
        result.push(currentLevel);
    }
    return result;
}

// 测试
const root = new TreeNode(3);
root.left = new TreeNode(9);
root.right = new TreeNode(20);
root.right.left = new TreeNode(15);
root.right.right = new TreeNode(7);
console.log(levelOrder(root)); 
// [[3], [9,20], [15,7]]
2. 图的BFS遍历(邻接表表示)
class Graph {
    constructor() {
        this.adjacencyList = new Map();
    }

    addVertex(v) {
        if (!this.adjacencyList.has(v)) {
            this.adjacencyList.set(v, []);
        }
    }

    addEdge(v1, v2) {
        this.adjacencyList.get(v1).push(v2);
        this.adjacencyList.get(v2).push(v1); // 无向图
    }

    bfs(start) {
        const queue = [start];
        const visited = new Set([start]);
        const result = [];
        
        while (queue.length) {
            const vertex = queue.shift();
            result.push(vertex);
            this.adjacencyList.get(vertex).forEach(neighbor => {
                if (!visited.has(neighbor)) {
                    visited.add(neighbor);
                    queue.push(neighbor);
                }
            });
        }
        return result;
    }
}

// 测试
const graph = new Graph();
graph.addVertex('A');
graph.addVertex('B');
graph.addVertex('C');
graph.addEdge('A', 'B');
graph.addEdge('B', 'C');
console.log(graph.bfs('A')); // ['A', 'B', 'C']

四、BFS 应用场景
1. 最短路径(无权图,LeetCode 1091)
function shortestPathBinaryMatrix(grid) {
    const n = grid.length;
    if (grid[0][0] === 1 || grid[n-1][n-1] === 1) return -1;
    
    const queue = [[0, 0, 1]];  // [x, y, steps]
    const dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
    grid[0][0] = 1;  // 标记为已访问
    
    while (queue.length) {
        const [x, y, steps] = queue.shift();
        if (x === n-1 && y === n-1) return steps;
        
        for (const [dx, dy] of dirs) {
            const nx = x + dx, ny = y + dy;
            if (nx >= 0 && ny >= 0 && nx < n && ny < n && grid[nx][ny] === 0) {
                grid[nx][ny] = 1;  // 标记已访问
                queue.push([nx, ny, steps + 1]);
            }
        }
    }
    return -1;
}

// 测试
const grid = [[0,0,0],[1,1,0],[1,1,0]];
console.log(shortestPathBinaryMatrix(grid)); // 4
2. 单词接龙(LeetCode 127)
function ladderLength(beginWord, endWord, wordList) {
    const wordSet = new Set(wordList);
    if (!wordSet.has(endWord)) return 0;
    
    const queue = [[beginWord, 1]];
    const visited = new Set([beginWord]);
    
    while (queue.length) {
        const [word, level] = queue.shift();
        if (word === endWord) return level;
        
        for (let i = 0; i < word.length; i++) {
            for (let c = 97; c <= 122; c++) {  // 遍历 a-z
                const newWord = word.slice(0, i) + String.fromCharCode(c) + word.slice(i+1);
                if (wordSet.has(newWord) {
                    if (newWord === endWord) return level + 1;
                    if (!visited.has(newWord)) {
                        visited.add(newWord);
                        queue.push([newWord, level + 1]);
                    }
                }
            }
        }
    }
    return 0;
}
3. 腐烂的橘子(LeetCode 994)
function orangesRotting(grid) {
    const queue = [];
    let fresh = 0, time = 0;
    const rows = grid.length, cols = grid[0].length;
    
    // 初始化队列和新鲜橘子计数
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            if (grid[i][j] === 2) queue.push([i, j]);
            if (grid[i][j] === 1) fresh++;
        }
    }
    
    const dirs = [[-1,0], [1,0], [0,-1], [0,1]];
    while (queue.length && fresh > 0) {
        const size = queue.length;
        for (let i = 0; i < size; i++) {
            const [x, y] = queue.shift();
            for (const [dx, dy] of dirs) {
                const nx = x + dx, ny = y + dy;
                if (nx >= 0 && ny >= 0 && nx < rows && ny < cols && grid[nx][ny] === 1) {
                    grid[nx][ny] = 2;
                    fresh--;
                    queue.push([nx, ny]);
                }
            }
        }
        time++;
    }
    return fresh === 0 ? time : -1;
}

五、BFS 关键技巧
  1. 队列管理使用队列存储待处理节点
  2. 层级控制:通过记录队列长度处理层级(如树层级遍历)。
  3. 状态标记:防止重复访问(尤其在图遍历中)。
  4. 多源BFS:初始时队列包含多个起点(如“腐烂的橘子”问题)。

六、复杂度分析
场景时间复杂度空间复杂度
树遍历O(n)(n为节点数)O(w)(w为树的最大宽度)
图遍历O(V + E)(V顶点,E边)O(V)(存储访问状态)
矩阵遍历O(mn)(m行,n列)O(mn)(队列存储)

七、常见问题

1. 如何记录路径?

  • 使用哈希表保存节点的父节点,回溯构造路径。

2. 如何处理超大矩阵的BFS?

  • 双向BFS优化(从起点和终点同时搜索)。

3. 如何判断图是否有环?

  • BFS拓扑排序:统计入度,逐步移除入度为0的节点。

八、总结
  • BFS核心:逐层扩展 + 队列管理。
  • 适用场景:最短路径、层级遍历、连通性问题。
  • 实现要点:队列初始化、层级控制、状态标记。
  • 优化方向:双向BFS、剪枝、优先队列(如Dijkstra算法)。

练习推荐

  • LeetCode 102(二叉树的层序遍历)
  • LeetCode 200(岛屿数量)
  • LeetCode 279(完全平方数)
  • LeetCode 752(打开转盘锁)

以横向的维度对树进行遍历,从第一个节点开始,依次遍历其所有的兄弟节点,再遍历第一个节点的子节点,一层层向下遍历

在JavaScript中,广度优先遍历(Breadth-First Search, BFS)是一种用于遍历或搜索树或图的算法。这种算法从根节点(或任意节点)开始,访问最靠近根节点的节点。广度优先遍历使用队列数据结构来存储待访问的节点。

以下是一个使用JavaScript实现广度优先遍历的例子。假设我们有一个树形结构,每个节点都有一个children数组来存储其子节点。

function breadthFirstSearch(root) {  
    if (root === null) {  
        return;  
    }  
  
    // 使用队列来进行广度优先遍历  
    const queue = [root];  
  
    while (queue.length > 0) {  
        const currentNode = queue.shift(); // 取出队列中的第一个节点  
        console.log(currentNode.value); // 访问当前节点的值  
  
        // 将当前节点的所有子节点加入队列  
        for (const child of currentNode.children) {  
            queue.push(child);  
        }  
    }  
}  
  
// 示例树结构  
const tree = {  
    value: 'root',  
    children: [  
        {  
            value: 'child1',  
            children: [  
                { value: 'grandchild1', children: [] },  
                { value: 'grandchild2', children: [] }  
            ]  
        },  
        {  
            value: 'child2',  
            children: [  
                { value: 'grandchild3', children: [] },  
                { value: 'grandchild4', children: [] }  
            ]  
        }  
    ]  
};  
  
// 从根节点开始广度优先遍历  
breadthFirstSearch(tree);

在这个例子中,breadthFirstSearch函数首先检查传入的根节点是否为null。如果不是,它将根节点放入队列中。然后,它进入一个循环,只要队列不为空,就持续执行以下操作:

  1. 从队列中取出第一个节点(即最早入队的节点)。
  2. 访问(在这里是打印)该节点的值。
  3. 将该节点的所有子节点加入队列。

这个过程将确保树的遍历按照广度优先的方式进行,即先遍历所有兄弟节点,再遍历子节点,一层层向下遍历。

进阶题目汇总

这个专题想进阶,就刷我下面提供的题目吧👇

DFS

BFS

查找树形结构中符合要求的节点

在JavaScript中,查找树形结构中符合特定要求的节点通常涉及递归遍历树的所有节点,并检查每个节点的属性是否满足条件。以下是一个示例函数,它接受一个树形结构和一个回调函数作为参数,回调函数定义了节点必须满足的条件。函数会返回所有满足条件的节点数组。

function findNodes(tree, predicate) {  
    const result = []; // 用于存储符合条件的节点  
  
    function traverse(node) {  
        if (predicate(node)) {  
            result.push(node); // 如果节点满足条件,将其添加到结果数组  
        }  
        for (const child of node.children) {  
            traverse(child); // 递归遍历子节点  
        }  
    }  
  
    traverse(tree); // 从根节点开始遍历  
    return result;  
}  
  
// 示例树结构  
const tree = {  
    value: 'root',  
    children: [  
        {  
            value: 'child1',  
            children: [  
                { value: 'grandchild1', children: [] },  
                { value: 'grandchild2', children: [] }  
            ]  
        },  
        {  
            value: 'child2',  
            children: [  
                { value: 'grandchild3', children: [] },  
                { value: 'grandchild4', children: [] }  
            ]  
        }  
    ]  
};  
  
// 示例:查找所有值为'grandchild'的节点  
const predicate = (node) => node.value.startsWith('grandchild');  
const matchingNodes = findNodes(tree, predicate);  
  
console.log(matchingNodes); // 输出所有符合条件的节点

在这个例子中,findNodes 函数接受一个树形结构 tree 和一个回调函数 predicatepredicate 函数应该接受一个节点作为参数,并返回一个布尔值,表示该节点是否满足搜索条件。findNodes 函数通过递归调用 traverse 函数来遍历树的每个节点,并在遇到满足条件的节点时将其添加到结果数组中。最后,函数返回所有满足条件的节点数组。

你可以根据需要调整 predicate 函数来定义不同的搜索条件。例如,你可以查找具有特定属性的节点,或者基于节点的值或其他属性进行更复杂的匹配。

二叉查找树

判断一个数组,是否为某二叉查找树的前序遍历结果,二叉查找树特点是所有的左节点比父节点的值小,所有的右节点比父节点的值大

要判断一个数组是否为二叉查找树的前序遍历结果,我们可以利用二叉查找树的特性:所有左子节点的值都小于父节点,所有右子节点的值都大于父节点。在前序遍历中,遍历的顺序是根节点、左子树、右子树。

下面是一个使用JavaScript实现的函数,该函数可以判断一个数组是否是某个二叉查找树的前序遍历结果:

function verifyPreorder(preorder) {  
    if (preorder.length === 0) {  
        return true; // 空数组是有效的前序遍历结果  
    }  
  
    const rootVal = preorder[0];  
    const leftSubtree = [];  
    const rightSubtree = [];  
  
    // 分离左子树和右子树  
    for (let i = 1; i < preorder.length; i++) {  
        if (preorder[i] < rootVal) {  
            leftSubtree.push(preorder[i]);  
        } else {  
            rightSubtree.push(preorder[i]);  
        }  
    }  
  
    // 递归验证左子树和右子树  
    return (  
        verifyPreorder(leftSubtree) && // 左子树必须是有效的二叉查找树前序遍历  
        verifyPreorder(rightSubtree) && // 右子树必须是有效的二叉查找树前序遍历  
        // 右子树中的所有元素必须都大于根节点的值  
        rightSubtree.every(val => val > rootVal)  
    );  
}  
  
// 示例  
const preorderTraversal = [8, 5, 1, 7, 10, 12];  
const notPreorderTraversal = [8, 5, 10, 1, 7, 12];  
  
console.log(verifyPreorder(preorderTraversal)); // 应该输出 true  
console.log(verifyPreorder(notPreorderTraversal)); // 应该输出 false

这个算法的思路是:

  1. 如果数组为空,那么它肯定是一个有效的二叉查找树的前序遍历结果。
  2. 否则,我们取数组的第一个元素作为根节点的值。
  3. 遍历数组中剩余的元素,将它们分为左子树和右子树两部分。左子树中的元素值都小于根节点,右子树中的元素值都大于或等于根节点。
  4. 递归地对左子树和右子树进行验证,确保它们也是有效的二叉查找树的前序遍历结果。
  5. 同时,验证右子树中的所有元素是否都大于根节点的值。

请注意,这个算法假设输入的数组不包含重复的元素。如果允许重复元素,那么前序遍历的结果可能无法唯一确定一棵二叉查找树,因此需要根据具体问题的需求进行相应调整。

买卖股票问题

给定一个整数数组,其中第 i 个元素代表了第 i天的股票价格;非负整数 fee 代表了交易股票的手续费用,求返回获得利润的最大值

这个问题可以使用动态规划(Dynamic Programming)来解决。我们可以定义两个变量,分别表示手上持有股票时的最大利润和不持有股票时的最大利润。对于每一天,我们都有两种选择:买入股票或者卖出股票。

下面是使用JavaScript实现的代码:

function maxProfit(prices, fee) {  
    if (prices.length <= 1) {  
        return 0;  
    }  
      
    let cash = 0; // 不持有股票时的最大利润  
    let hold = -prices[0]; // 持有股票时的最大利润,初始化为第一天的股票价格取反,表示买入股票  
      
    for (let i = 1; i < prices.length; i++) {  
        // 如果今天卖出股票,则利润为之前的现金加上今天的股票价格  
        cash = Math.max(cash, hold + prices[i] - fee);  
        // 如果今天买入股票,则利润为之前的持有股票利润减去今天的股票价格  
        hold = Math.max(hold, cash - prices[i]);  
    }  
      
    return cash;  
}  
  
// 示例  
const prices = [1, 3, 2, 8, 4, 9];  
const fee = 2;  
console.log(maxProfit(prices, fee)); // 输出 8

在这个例子中,我们在第2天买入股票(价格为3),在第3天卖出(价格为2),需要支付手续费2,第5天买入(价格为4),第6天卖出(价格为9),同样需要支付手续费2,所以总利润为8。

注意,我们每次卖出股票时都要支付手续费,这会影响我们的总利润。这个算法的时间复杂度是O(n),其中n是数组的长度。

滑动窗口最大值

给定一个数组 nums,有一个大小为 k 的滑动窗口,从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口中的k个数字。滑动窗口每次只向右移动一位,求返回滑动窗口最大值

要实现滑动窗口最大值的问题,一个常用的方法是使用双端队列(deque)。双端队列在这里的作用是为了保持队列中的元素是单调递减的,这样队列的头部就始终是窗口中的最大值。

以下是使用JavaScript实现滑动窗口最大值的代码:

function maxSlidingWindow(nums, k) {  
    const deque = []; // 双端队列,存储索引  
    const result = []; // 存储每个窗口的最大值  
  
    // 辅助函数,用于移除队列中超出窗口范围的元素  
    function removeOutdated(index) {  
        while (deque.length > 0 && deque[0] <= index - k) {  
            deque.shift();  
        }  
    }  
  
    // 辅助函数,用于保持队列的单调递减性  
    function maintainDecreasing() {  
        while (deque.length > 1 && nums[deque[deque.length - 1]] < nums[index]) {  
            deque.pop();  
        }  
    }  
  
    for (let index = 0; index < nums.length; index++) {  
        // 移除超出窗口范围的元素  
        removeOutdated(index);  
        // 保持队列单调递减  
        maintainDecreasing();  
        // 将当前索引添加到队列中  
        deque.push(index);  
  
        // 当窗口形成后(即index >= k-1),开始记录每个窗口的最大值  
        if (index >= k - 1) {  
            result.push(nums[deque[0]]);  
        }  
    }  
  
    return result;  
}  
  
// 示例  
const nums = [1, 3, -1, -3, 5, 3, 6, 7];  
const k = 3;  
console.log(maxSlidingWindow(nums, k)); // 输出: [3, 3, 5, 5, 6, 7]

这段代码首先定义了一个双端队列deque,用于存储当前窗口内元素的索引,以保持队列的单调递减性。然后,我们遍历数组nums,对于每个元素执行以下操作:

  1. 移除队列中超出窗口范围的元素(即索引小于index - k的元素)。
  2. 保持队列的单调递减性,即如果队列末尾的元素小于当前元素,就将其从队列中移除,直到队列为空或者末尾元素不小于当前元素。
  3. 将当前元素的索引添加到队列的末尾。
  4. 如果窗口已经形成(即index >= k - 1),则将队列头部的元素对应的值(即当前窗口的最大值)添加到结果数组result中。

最后,返回结果数组result,其中包含了每个滑动窗口的最大值。

最长递增子序列

一个整数数组 nums,找到其中一组最长递增子序列的值

在JavaScript中,最长递增子序列(Longest Increasing Subsequence,简称LIS)问题可以使用动态规划(Dynamic Programming)来解决。下面是一个示例实现:

function longestIncreasingSubsequence(nums) {  
    if (nums.length === 0) return [];  
  
    // 初始化一个数组来存储每个位置的最长递增子序列的长度  
    let lengths = new Array(nums.length).fill(1);  
    // 初始化一个数组来存储每个位置的最长递增子序列的前一个元素的索引  
    let prevIndices = new Array(nums.length).fill(-1);  
  
    let maxLength = 1; // 最长递增子序列的初始长度  
    let endIndex = 0; // 最长递增子序列的最后一个元素的索引  
  
    // 遍历数组,计算每个位置的最长递增子序列长度和前一个元素的索引  
    for (let i = 1; i < nums.length; i++) {  
        for (let j = 0; j < i; j++) {  
            if (nums[i] > nums[j] && lengths[i] < lengths[j] + 1) {  
                lengths[i] = lengths[j] + 1;  
                prevIndices[i] = j;  
            }  
        }  
        // 更新最长递增子序列的长度和最后一个元素的索引  
        if (maxLength < lengths[i]) {  
            maxLength = lengths[i];  
            endIndex = i;  
        }  
    }  
  
    // 根据最后一个元素的索引和前一个元素的索引数组,构建最长递增子序列  
    let lis = [];  
    while (endIndex !== -1) {  
        lis.unshift(nums[endIndex]);  
        endIndex = prevIndices[endIndex];  
    }  
  
    return lis;  
}  
  
// 示例  
const nums = [10, 9, 2, 5, 3, 7, 101, 18];  
const lis = longestIncreasingSubsequence(nums);  
console.log(lis); // 输出: [2, 3, 7, 101] 或者其他可能的递增子序列,如 [2, 5, 7, 101] 等

在这个实现中,lengths 数组用于存储以每个位置为结尾的最长递增子序列的长度,而 prevIndices 数组则用于存储每个位置的最长递增子序列中前一个元素的索引。我们通过两次遍历来构建这两个数组,并在遍历过程中更新最长递增子序列的长度和最后一个元素的索引。

最后,我们根据 endIndexprevIndices 数组回溯构建出最长递增子序列,并返回结果。

需要注意的是,最长递增子序列可能不唯一,因此上面的代码可能输出不同的递增子序列,但它们的长度都是相同的,即数组 nums 的最长递增子序列的长度。

PS.未完待续,文中有错误的地方也欢迎评论指出或评论分享自己的面试题。

进阶题目汇总

以下是我收集的部分题目,希望对你们有帮助。

简单

中等

困难

题目汇总

我之前刷题历程是根据这套题来的,我觉得里面题目梯度还是质量都是很不错的。

拿到这个pdf有段时间了,所以不清楚具体作者是谁,有侵权的话,可删。

数组&链表

简单

中等

Map & Set

简单

中等

堆栈&队列

简单

中等

二分查找

简单

中等

困难

递归

简单

中等

哈希表

简单

中等

困难

二叉树

简单

中等

困难

二叉搜索树

简单

中等

困难

中等

堆和排序

简单

困难

DFS

简单

中等

困难

BFS

简单

中等

Trie树

简单

中等

困难

分治算法

简单

中等

回溯算法

简单

中等

困难

贪心算法

简单

中等

动态规划

简单

中等

困难

作者:TianTianUp
链接:
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

二叉树 /介绍二叉搜索树的特点 / 二叉树遍历

加油站问题(贪心算法)

贪心算法: 局部最优解法

单链表反转,如何判断链表是否有环

线性顺序存储结构和链式存储结构有什么区别?以及优缺点

取1000个数字里面的质数

找出数组中和为给定值的两个元素,如:[1, 2, 3, 4, 5]中找出和为6的两个元素。

top-K问题,分成top-1,top-2,top-K三小问

实现setter(obj, 'a.b.c' ,val)

算法的效率

项目中树的使用场景以及了解

对图有了解吗

回溯法: 发现原先选择不优时,退回重新选择

分支限界法

观察者模式里面使用的数据结构(不具备顺序 ,是一个list)

介绍下DFS深度优先

Promise串行

从一个无序,不相等的数组中,选取N个数,使其和为M实现算法

现在有一个数组[1,2,3,4],请实现算法,得到这个数组的全排列的数组,如[2,1,3,4],[2,1,4,3]。。。。你这个算法的时间复杂度是多少

两数之和-给定一个数组 nums 和一个目标值 target,在该数组中找出和为目标值的两个数

在JavaScript中,你可以通过遍历数组并使用一个对象(在JavaScript中通常使用对象作为哈希表)来存储已经遍历过的数字及其索引,以找出和为特定目标值的两个数。以下是一个可能的实现:

function twoSum(nums, target) {  
    // 创建一个空对象来存储数组中的数字及其索引  
    const numMap = {};  
      
    // 遍历数组  
    for (let i = 0; i < nums.length; i++) {  
        const num = nums[i];  
        const complement = target - num; // 计算补数  
          
        // 检查补数是否已经在哈希表中  
        if (complement in numMap) {  
            // 如果在,返回结果  
            return [numMap[complement], i];  
        }  
          
        // 如果不在,将当前数字及其索引添加到哈希表中  
        numMap[num] = i;  
    }  
      
    // 如果没有找到符合条件的两个数,抛出一个错误  
    throw new Error('No two sum solution');  
}  
  
// 示例  
const nums = [2, 7, 11, 15];  
const target = 9;  
  
try {  
    const result = twoSum(nums, target);  
    console.log(result); // 输出: [0, 1]  
} catch (error) {  
    console.error(error.message);  
}

在这个实现中,我们首先创建了一个空对象 numMap。然后,我们遍历数组 nums 中的每个元素。对于每个元素,我们计算目标值与当前元素的差值(补数)。然后,我们检查这个补数是否已经在 numMap 中。如果在,说明我们已经找到了两个数的和为目标值,我们返回这两个数的索引。如果不在,我们将当前元素及其索引添加到 numMap 中,以便后续的检查。

如果遍历完整个数组都没有找到符合条件的两个数,我们抛出一个错误。在实际应用中,你可能希望返回一个特殊的值(如 nullundefined),而不是抛出错误,这取决于你的具体需求。

三数之和-给定一个数组nums,判断 nums 中是否存在三个元素a,b,c,使得 a + b + c = target,找出所有满足条件且不重复的三元组合

你可以使用两个嵌套的循环来遍历数组,然后对于每一对元素,使用第三个循环来查找第三个元素,使得三者的和等于目标值。但是,这种方法的时间复杂度为O(n^3),对于大数组来说可能效率不高。

一个更高效的方法是使用哈希表(在JavaScript中,我们通常使用对象作为哈希表)来存储数组中的元素,使得我们可以在O(1)的时间内查找某个元素是否存在。然后,我们可以使用两个指针来遍历数组,同时更新哈希表。这种方法的时间复杂度为O(n^2)。

以下是一个使用这种方法的JavaScript代码示例:


function threeSum(nums, target) {  
    const result = [];  
    nums.sort((a, b) => a - b); // 先对数组进行排序  
    const map = {};  
  
    for (let i = 0; i < nums.length - 2; i++) {  
        // 跳过重复元素  
        if (i > 0 && nums[i] === nums[i - 1]) continue;  
  
        let left = i + 1;  
        let right = nums.length - 1;  
  
        while (left < right) {  
            const sum = nums[i] + nums[left] + nums[right];  
  
            if (sum === target) {  
                result.push([nums[i], nums[left], nums[right]]);  
  
                // 跳过重复元素  
                while (left < right && nums[left] === nums[left + 1]) left++;  
                while (left < right && nums[right] === nums[right - 1]) right--;  
  
                left++;  
                right--;  
            } else if (sum < target) {  
                left++;  
            } else {  
                right--;  
            }  
        }  
    }  
  
    return result;  
}

这个函数的输入是一个数组nums和一个目标值target,输出是一个二维数组,其中每个子数组都是一个满足条件的三元组合。这个函数首先对数组进行排序,然后使用两个指针leftright来遍历数组,同时更新哈希表。当找到一个满足条件的三元组合时,将其添加到结果数组中,并跳过所有重复的元素。最后,返回结果数组。

输入一个字符串,找到第一个不重复字符的下标

要找到字符串中第一个不重复字符的下标,你可以遍历字符串并使用一个对象(在JavaScript中作为哈希表使用)来记录每个字符出现的次数。然后再次遍历字符串,找到第一个出现次数为1的字符并返回其下标。以下是实现这一功能的JavaScript代码示例:

ini
 体验AI代码助手
 代码解读
复制代码
function firstNonRepeatingChar(str) {  
    // 使用对象来记录每个字符出现的次数  
    const charCount = {};  
    for (let i = 0; i < str.length; i++) {  
        const char = str[i];  
        // 如果字符已经存在于对象中,则增加计数  
        if (charCount[char]) {  
            charCount[char]++;  
        } else {  
            // 否则,初始化计数为1  
            charCount[char] = 1;  
        }  
    }  
  
    // 再次遍历字符串,找到第一个出现次数为1的字符  
    for (let i = 0; i < str.length; i++) {  
        const char = str[i];  
        if (charCount[char] === 1) {  
            return i; // 返回第一个不重复字符的下标  
        }  
    }  
  
    // 如果没有找到不重复字符,则返回-1  
    return -1;  
}  
  
// 示例用法  
const inputString = &#34;abcabcbb&#34;;  
const index = firstNonRepeatingChar(inputString);  
console.log(index); // 输出: 2(因为字符'c'是第一个只出现一次的字符,其下标为2)

在这个函数中,charCount对象用于存储每个字符及其出现的次数。第一次遍历字符串时,我们更新这个对象的计数。第二次遍历字符串时,我们检查每个字符的计数,并返回第一个计数为1的字符的下标。如果遍历完整个字符串都没有找到这样的字符,则返回-1表示没有找到。

输入一个字符串,打印出该字符串中,所有字符的排列组合

在JavaScript中,要打印出字符串中所有字符的排列组合,可以使用递归的方法。以下是一个简单的实现示例:


function permute(str, memo = [], result = []) {  
    // 如果memo的长度等于str的长度,说明找到了一个完整的排列,将其添加到结果数组中  
    if (memo.length === str.length) {  
        result.push(memo.join(''));  
        return;  
    }  
  
    // 遍历字符串中的每个字符  
    for (let i = 0; i < str.length; i++) {  
        // 如果当前字符已经在memo中,则跳过以避免重复  
        if (memo.includes(str[i])) continue;  
  
        // 将当前字符添加到memo中,并递归调用permute函数继续处理剩余字符  
        memo.push(str[i]);  
        permute(str, memo, result);  
          
        // 回溯,将当前字符从memo中移除,以便尝试其他排列  
        memo.pop();  
    }  
  
    return result;  
}  
  
// 示例用法  
const inputString = &#34;abc&#34;;  
const permutations = permute(inputString);  
  
// 打印所有排列组合  
permutations.forEach(permutation => {  
    console.log(permutation);  
});

这个函数permute接受一个字符串str作为输入,并返回该字符串所有字符的排列组合。memo数组用于跟踪当前排列中已经包含的字符,而result数组则用于存储所有找到的排列。

在函数内部,我们首先检查memo数组的长度是否等于输入字符串的长度。如果是,说明我们已经找到了一个完整的排列,可以将其添加到结果数组中。然后,我们遍历输入字符串中的每个字符,对于每个字符,如果它尚未在memo中出现,我们就将其添加到memo中,并递归调用permute函数处理剩余的字符。在递归调用返回后,我们通过将字符从memo中移除来执行回溯,以便尝试其他可能的排列。

最后,我们调用permute函数并遍历返回的排列数组,打印出每个排列。

请注意,对于较长的字符串,排列的数量会非常大(n的阶乘,其中n是字符串的长度),这可能会导致性能问题和内存问题。因此,对于较长的字符串,这种方法可能不是非常实用。在实际应用中,您可能需要考虑一些优化策略或者限制排列的数量。

Web 前端开发面试中高频算法题及详细解析

以下是 Web 前端开发面试中高频算法题及详细解析,涵盖基础算法、数据结构、优化技巧和实际场景应用,帮你快速掌握核心考点:


一、数组与字符串高频题

1. 两数之和(Two Sum)
  • 题目:给定数组 nums 和目标值 target,找出和为 target 的两个元素的索引。
  • 最优解:哈希表(时间复杂度 O(n)
    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 [];
    }
    
2. 最长无重复子串(Longest Substring Without Repeating Characters)
  • 滑动窗口:用双指针维护窗口,哈希表记录字符最后出现位置(时间复杂度 O(n)
    function lengthOfLongestSubstring(s) {
      let maxLen = 0, left = 0;
      const map = new Map();
      for (let right = 0; right < s.length; right++) {
        const char = s[right];
        if (map.has(char) && map.get(char) >= left) {
          left = map.get(char) + 1; // 移动左指针
        }
        map.set(char, right);
        maxLen = Math.max(maxLen, right - left + 1);
      }
      return maxLen;
    }
    

二、链表操作

3. 反转链表(Reverse Linked List)
  • 迭代法(时间复杂度 O(n),空间复杂度 O(1)
    function reverseList(head) {
      let prev = null, curr = head;
      while (curr) {
        const next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
      }
      return prev;
    }
    
4. 环形链表检测(Linked List Cycle)
  • 快慢指针:快指针每次两步,慢指针每次一步(时间复杂度 O(n)
    function hasCycle(head) {
      let slow = head, fast = head;
      while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow === fast) return true;
      }
      return false;
    }
    

三、树与递归

5. 二叉树的最大深度(Maximum Depth of Binary Tree)
  • 递归分治(时间复杂度 O(n)
    function maxDepth(root) {
      if (!root) return 0;
      return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
    }
    
6. 二叉树的层序遍历(Level Order Traversal)
  • 队列 + BFS(时间复杂度 O(n)
    function levelOrder(root) {
      if (!root) return [];
      const res = [], queue = [root];
      while (queue.length) {
        const level = [];
        const size = queue.length;
        for (let i = 0; i < size; i++) {
          const node = queue.shift();
          level.push(node.val);
          if (node.left) queue.push(node.left);
          if (node.right) queue.push(node.right);
        }
        res.push(level);
      }
      return res;
    }
    

四、动态规划(DP)

7. 爬楼梯(Climbing Stairs)
  • 状态转移方程dp[n] = dp[n-1] + dp[n-2]
    function climbStairs(n) {
      if (n <= 2) return n;
      let a = 1, b = 2;
      for (let i = 3; i <= n; i++) {
        [a, b] = [b, a + b];
      }
      return b;
    }
    
8. 最长递增子序列(Longest Increasing Subsequence)
  • 贪心 + 二分查找(时间复杂度 O(n log n)
    function lengthOfLIS(nums) {
      const tails = [];
      for (const num of nums) {
        let left = 0, right = tails.length;
        while (left < right) {
          const mid = (left + right) >> 1;
          if (tails[mid] < num) left = mid + 1;
          else right = mid;
        }
        if (left === tails.length) tails.push(num);
        else tails[left] = num;
      }
      return tails.length;
    }
    

五、实际场景算法

9. 虚拟 DOM Diff 算法核心逻辑
  • 双端比较:新旧节点列表头尾指针对比,减少移动次数
    function updateChildren(parent, oldCh, newCh) {
      let oldStartIdx = 0, newStartIdx = 0;
      let oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;
      // 头头、尾尾、头尾、尾头对比,复用节点
      // ...(具体实现参考 Vue/React 源码)
    }
    
10. Promise 并发控制
  • 队列管理 + 递归触发:限制同时执行的异步任务数量
    function promisePool(tasks, limit) {
      const results = [];
      let activeCount = 0, index = 0;
      const run = async () => {
        if (index >= tasks.length || activeCount >= limit) return;
        const task = tasks[index++];
        activeCount++;
        results.push(await task());
        activeCount--;
        run(); // 递归触发下一个任务
      };
      while (activeCount < limit) run();
      return Promise.all(results);
    }
    

六、复杂度与优化技巧

算法时间复杂度空间复杂度优化方向
两数之和O(n)O(n)哈希表替代暴力枚举
快速排序O(n log n)O(log n)三数取中法避免最坏情况
斐波那契数列O(n)O(1)迭代替代递归,减少栈空间
最长回文子串O(n²)O(1)中心扩展法替代动态规划

七、刷题与面试建议

  1. 高频题库
    • LeetCode 热题 100(数组、字符串、链表)
    • 《剑指 Offer》经典题(递归、动态规划)
  2. 框架关联
    • 理解 React Fiber 调度算法(时间切片)
    • Vue 响应式原理中的依赖收集(图遍历)
  3. 思维训练
    • 白板手写代码(边界条件处理)
    • 口述解题思路(展现逻辑清晰度)

掌握这些算法题的核心思路和代码实现,能显著提升前端面试竞争力。建议结合具体框架源码(如 Vue 的虚拟 DOM Diff)理解算法在实际工程中的应用,体现技术深度。