「前端必备」数据结构与算法完全指南:从堆到回溯,白话讲解 + LeetCode实战

88 阅读16分钟

堆其实就是一个特殊的二叉树,但我们通常用数组来存储它。想象一下,堆就像一个金字塔,最重要的元素总是在顶端。最常见的有两种:最小堆(最小的在顶端)和最大堆(最大的在顶端)。

堆解决什么问题?

堆最擅长解决"前K个"这类问题。比如:

  • 找出数组中前K个最大的数
  • 找出前K个最频繁的元素
  • 找出第K大的元素

只要你看到题目中有"前K个"、"第K大"、"第K小"这样的描述,十有八九可以用堆来解决。堆的时间复杂度是O(n log k),其中k是堆的大小,通常比完全排序O(n log n)要快很多。

最小堆的实现

最小堆有个特点:父节点总是比子节点小。我们用数组来存储,有几个重要的公式:

  • 父节点位置:(当前位置 - 1) ÷ 2
  • 左子节点位置:当前位置 × 2 + 1
  • 右子节点位置:当前位置 × 2 + 2
// 最小堆的完整实现
class MinHeap {
  constructor() {
    this.heap = []; // 用数组存储堆
  }
  
  // 获取父节点的位置
  getParentIndex(index) {
    return Math.floor((index - 1) / 2);
  }
  
  // 获取左子节点的位置
  getLeftIndex(index) {
    return index * 2 + 1;
  }
  
  // 获取右子节点的位置
  getRightIndex(index) {
    return index * 2 + 2;
  }
  
  // 交换两个位置的元素
  swap(i1, i2) {
    const temp = this.heap[i1];
    this.heap[i1] = this.heap[i2];
    this.heap[i2] = temp;
  }
  
  // 上浮操作:新插入的元素往上调整位置
  shiftUp(index) {
    // 如果已经是根节点,就不用调整了
    if (index === 0) return;
    
    // 找到父节点
    const parentIndex = this.getParentIndex(index);
    
    // 如果父节点比当前节点大,就交换位置
    if (this.heap[parentIndex] > this.heap[index]) {
      this.swap(parentIndex, index);
      // 继续往上调整
      this.shiftUp(parentIndex);
    }
  }
  
  // 下沉操作:删除堆顶后,调整堆的结构
  shiftDown(index) {
    const leftIndex = this.getLeftIndex(index);
    const rightIndex = this.getRightIndex(index);
    
    // 和左子节点比较
    if (this.heap[leftIndex] && this.heap[index] > this.heap[leftIndex]) {
      this.swap(index, leftIndex);
      this.shiftDown(leftIndex);
    }
    
    // 和右子节点比较
    if (this.heap[rightIndex] && this.heap[index] > this.heap[rightIndex]) {
      this.swap(index, rightIndex);
      this.shiftDown(rightIndex);
    }
  }
  
  // 插入新元素
  insert(value) {
    // 先放到数组最后
    this.heap.push(value);
    // 然后往上调整位置
    this.shiftUp(this.heap.length - 1);
  }
  
  // 删除并返回堆顶元素(最小值)
  pop() {
    if (this.heap.length === 0) return undefined;
    if (this.heap.length === 1) return this.heap.pop();
    
    // 保存堆顶元素
    const top = this.heap[0];
    // 把最后一个元素移到堆顶
    this.heap[0] = this.heap.pop();
    // 往下调整位置
    this.shiftDown(0);
    return top;
  }
  
  // 查看堆顶元素(不删除)
  peek() {
    return this.heap[0];
  }
  
  // 获取堆的大小
  size() {
    return this.heap.length;
  }
}

使用方式

// 创建一个最小堆
const heap = new MinHeap();

// 插入一些数字
heap.insert(3);
heap.insert(2);
heap.insert(1);

// 删除最小值
heap.pop(); // 返回 1

console.log(heap.heap); // [2, 3]

LeetCode算法题

215. 数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。这道题就是堆的经典应用!

解题思路:用最小堆保存前K个最大的元素。当堆的大小超过K时,就删除堆顶(最小值),这样堆顶始终是第K大的元素。

var findKthLargest = function(nums, k) {
    const heap = new MinHeap();
    
    for (const num of nums) {
        heap.insert(num);
        // 如果堆的大小超过k,就删除最小值
        if (heap.size() > k) {
            heap.pop();
        }
    }
    
    // 堆顶就是第K大的元素
    return heap.peek();
};

// 测试
console.log(findKthLargest([3,2,1,5,6,4], 2)); // 5
console.log(findKthLargest([3,2,3,1,2,4,5,5,6], 4)); // 4

347. 前 K 个高频元素

给你一个整数数组 nums 和一个整数 k,请你返回其中出现频率前 k 高的元素。

解题思路:先用Map统计每个元素的出现次数,然后用最小堆保存频率前K高的元素。

var topKFrequent = function(nums, k) {
    // 第一步:统计每个元素的出现次数
    const map = new Map();
    for (const num of nums) {
        map.set(num, (map.get(num) || 0) + 1);
    }
    
    // 第二步:用最小堆保存频率前K高的元素
    const heap = [];
    
    for (const [num, freq] of map) {
        heap.push([freq, num]); // [频率, 元素]
        
        if (heap.length > k) {
            // 删除频率最小的元素
            heap.sort((a, b) => a[0] - b[0]);
            heap.shift();
        }
    }
    
    // 第三步:提取结果
    return heap.map(item => item[1]);
};

// 测试
console.log(topKFrequent([1,1,1,2,2,3], 2)); // [1, 2]
console.log(topKFrequent([1], 1)); // [1]

排序

排序在编程中超级常用!JavaScript已经给我们提供了现成的排序方法:

  • 升序排序arr.sort((a, b) => a - b)
  • 降序排序arr.sort((a, b) => b - a)

但你有没有想过,这些排序方法底层是怎么实现的呢?让我们一起来了解几种经典的排序算法!

常见的排序算法

我们刚开始学编程时,通常会接触到冒泡排序、插入排序、选择排序。这些算法虽然容易理解,但时间复杂度都是O(n²),效率比较低。

而JavaScript内置的sort()方法使用的是更高效的算法,比如归并排序或快速排序,它们的平均时间复杂度是O(n log n),比O(n²)快很多!

快速排序

快速排序的思路很简单:选一个"基准值",把数组分成两部分——比基准值小的放左边,比基准值大的放右边,然后递归地对左右两部分继续排序。

// 快速排序的实现
Array.prototype.quickSort = function () {
  const quickSortHelper = (arr) => {
    // 递归出口:数组长度小于等于1时直接返回
    if (arr.length <= 1) return arr;
    
    // 选择第一个元素作为基准值
    const pivot = arr[0];
    const left = [];  // 存放比基准值小的元素
    const right = []; // 存放比基准值大的元素
    
    // 遍历数组,将元素分配到左右两个数组
    for (let i = 1; i < arr.length; i++) {
      if (arr[i] < pivot) {
        left.push(arr[i]);
      } else {
        right.push(arr[i]);
      }
    }
    
    // 递归排序左右两部分,然后合并结果
    return [...quickSortHelper(left), pivot, ...quickSortHelper(right)];
  };
  
  // 调用辅助函数进行排序
  const sorted = quickSortHelper(this);
  
  // 将排序结果复制回原数组
  for (let i = 0; i < sorted.length; i++) {
    this[i] = sorted[i];
  }
  
  return this;
};

// 测试
const arr = [64, 34, 25, 12, 22, 11, 90];
console.log("原数组:", [...arr]);
arr.quickSort();
console.log("排序后:", arr); // [11, 12, 22, 25, 34, 64, 90]

查找

查找也是编程中的基本操作。JavaScript提供了很多查找方法:

  • find() - 找到第一个满足条件的元素
  • includes() - 检查数组是否包含某个元素
  • indexOf() - 找到元素的位置
  • findIndex() - 找到第一个满足条件的元素的位置

但如果我们要手写查找算法,该怎么做呢?

顺序查找(线性查找)

顺序查找是最简单的查找方法:从头到尾遍历数组,逐个比较每个元素。

// 顺序查找的实现
function linearSearch(arr, target) {
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] === target) {
            return i; // 找到了,返回位置
        }
    }
    return -1; // 没找到,返回-1
}

// 测试
const arr = [64, 34, 25, 12, 22, 11, 90];
console.log(linearSearch(arr, 22)); // 4
console.log(linearSearch(arr, 99)); // -1

顺序查找的优点是简单,缺点是效率低,时间复杂度是O(n)。

二分查找(折半查找)

二分查找是一种高效的查找算法,但有个前提:数组必须是有序的!

它的思路是:每次都从数组的中间开始比较,如果中间的值比目标值大,就在左半部分继续查找;如果比目标值小,就在右半部分继续查找。这样每次都能排除一半的元素,效率很高!

// 二分查找的实现
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; // 没找到
}

// 测试(注意:数组必须是有序的)
const sortedArr = [11, 12, 22, 25, 34, 64, 90];
console.log(binarySearch(sortedArr, 22)); // 2
console.log(binarySearch(sortedArr, 99)); // -1

二分查找的时间复杂度是O(log n),比顺序查找的O(n)快很多!但记住:只能用于有序数组。

分治算法

分治算法就是把一个复杂的大问题分解成若干个相同或相似的小问题,然后递归地解决这些小问题,最后将结果合并起来。简单来说就是"分而治之"。

分治算法的核心思想

分治算法有三个步骤:

  1. 分解(Divide):将原问题分解为若干个规模较小的相同问题
  2. 解决(Conquer):若子问题足够小,则直接求解;否则递归地解决各个子问题
  3. 合并(Combine):将各个子问题的解合并为原问题的解

经典例子:归并排序

归并排序是分治算法的经典应用。它的思路很简单:把数组一分为二,分别排序,然后合并。

// 归并排序的实现
function mergeSort(arr) {
    // 如果数组长度小于等于1,直接返回(递归出口)
    if (arr.length <= 1) {
        return arr;
    }
    
    // 分解:找到中间位置,将数组分成两半
    const mid = Math.floor(arr.length / 2);
    const left = arr.slice(0, mid);
    const right = arr.slice(mid);
    
    // 递归:分别对左右两部分进行排序
    const sortedLeft = mergeSort(left);
    const sortedRight = mergeSort(right);
    
    // 合并:将两个有序数组合并成一个有序数组
    return merge(sortedLeft, sortedRight);
}

// 合并两个有序数组
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]);
            i++;
        } else {
            result.push(right[j]);
            j++;
        }
    }
    
    // 将剩余元素加入结果数组
    while (i < left.length) {
        result.push(left[i]);
        i++;
    }
    
    while (j < right.length) {
        result.push(right[j]);
        j++;
    }
    
    return result;
}

// 测试
const arr = [64, 34, 25, 12, 22, 11, 90];
console.log("原数组:", arr);
console.log("排序后:", mergeSort(arr));

LeetCode经典题目

169. 多数元素

给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 n/2 的元素。

// 分治解法
var majorityElement = function(nums) {
    // 递归函数
    function majority(left, right) {
        // 基础情况:只有一个元素
        if (left === right) {
            return nums[left];
        }
        
        // 分解:找到中间位置
        const mid = Math.floor((left + right) / 2);
        
        // 递归:分别找左右两部分的多数元素
        const leftMajority = majority(left, mid);
        const rightMajority = majority(mid + 1, right);
        
        // 如果左右两部分的多数元素相同,直接返回
        if (leftMajority === rightMajority) {
            return leftMajority;
        }
        
        // 合并:统计左右两个候选元素在整个区间的出现次数
        const leftCount = countInRange(nums, leftMajority, left, right);
        const rightCount = countInRange(nums, rightMajority, left, right);
        
        // 返回出现次数更多的元素
        return leftCount > rightCount ? leftMajority : rightMajority;
    }
    
    // 统计元素在指定范围内的出现次数
    function countInRange(nums, target, left, right) {
        let count = 0;
        for (let i = left; i <= right; i++) {
            if (nums[i] === target) {
                count++;
            }
        }
        return count;
    }
    
    return majority(0, nums.length - 1);
};

动态规划

动态规划(DP)听起来很高大上,其实就是一种"记住之前计算结果"的方法。它的核心思想是:如果一个问题可以分解为子问题,并且子问题之间有重叠,那么我们可以保存子问题的解,避免重复计算。

动态规划的特点

  1. 最优子结构:问题的最优解包含子问题的最优解
  2. 重叠子问题:在递归过程中,同样的子问题会被多次计算
  3. 状态转移方程:描述问题状态之间关系的方程式

经典例子:斐波那契数列

斐波那契数列是动态规划的入门例子。我们来看看普通递归和动态规划的区别:

// 普通递归(效率很低,会重复计算)
function fibRecursive(n) {
    if (n <= 1) return n;
    return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// 动态规划 - 自底向上(推荐)
function fibDP(n) {
    if (n <= 1) return n;
    
    // 用数组保存之前计算的结果
    const dp = [0, 1];
    
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    
    return dp[n];
}

// 空间优化版本(只需要保存前两个数)
function fibOptimized(n) {
    if (n <= 1) return n;
    
    let prev2 = 0; // f(0)
    let prev1 = 1; // f(1)
    
    for (let i = 2; i <= n; i++) {
        const current = prev1 + prev2;
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
}

// 测试
console.log(fibDP(10)); // 55
console.log(fibOptimized(10)); // 55

LeetCode经典题目

70. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

// 思路:到达第n阶的方法数 = 到达第(n-1)阶的方法数 + 到达第(n-2)阶的方法数
var climbStairs = function(n) {
    if (n <= 2) return n;
    
    // dp[i] 表示到达第i阶的方法数
    const dp = [0, 1, 2];
    
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    
    return dp[n];
};

// 空间优化版本
var climbStairsOptimized = function(n) {
    if (n <= 2) return n;
    
    let prev2 = 1; // 第1阶
    let prev1 = 2; // 第2阶
    
    for (let i = 3; i <= n; i++) {
        const current = prev1 + prev2;
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
};

198. 打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

// 思路:对于每个房子,我们有两个选择:偷或不偷
// dp[i] = max(dp[i-1], dp[i-2] + nums[i])
var rob = function(nums) {
    if (nums.length === 0) return 0;
    if (nums.length === 1) return nums[0];
    
    // dp[i] 表示偷到第i个房子时能获得的最大金额
    const dp = [nums[0], Math.max(nums[0], nums[1])];
    
    for (let i = 2; i < nums.length; i++) {
        // 要么不偷当前房子(dp[i-1]),要么偷当前房子(dp[i-2] + nums[i])
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    
    return dp[nums.length - 1];
};

// 空间优化版本
var robOptimized = function(nums) {
    if (nums.length === 0) return 0;
    if (nums.length === 1) return nums[0];
    
    let prev2 = nums[0];
    let prev1 = Math.max(nums[0], nums[1]);
    
    for (let i = 2; i < nums.length; i++) {
        const current = Math.max(prev1, prev2 + nums[i]);
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
};

贪心算法

贪心算法的思想很简单:每一步都做当前看起来最好的选择,希望通过局部最优选择能达到全局最优。就像你去自助餐厅,每次都挑看起来最好吃的菜,希望最后整顿饭都很满意。

贪心算法的特点

  1. 贪心选择性质:每一步的最优选择不会影响后续选择
  2. 最优子结构:问题的最优解包含子问题的最优解
  3. 无后效性:前面的选择不会影响后面的选择

经典例子:找零钱问题

假设你是收银员,需要用最少的硬币数量找零。硬币面值有:1元、5元、10元、25元。

// 贪心策略:每次都选择不超过剩余金额的最大面值硬币
function makeChange(amount) {
    const coins = [25, 10, 5, 1]; // 从大到小排序
    const result = [];
    
    for (let coin of coins) {
        // 计算当前面值硬币的使用数量
        const count = Math.floor(amount / coin);
        
        // 添加到结果中
        for (let i = 0; i < count; i++) {
            result.push(coin);
        }
        
        // 更新剩余金额
        amount = amount % coin;
        
        // 如果金额为0,提前结束
        if (amount === 0) break;
    }
    
    return result;
}

// 测试
console.log(makeChange(67)); // [25, 25, 10, 5, 1, 1]
console.log(makeChange(41)); // [25, 10, 5, 1]

LeetCode经典题目

455. 分发饼干

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。

// 贪心策略:用最小的能满足需求的饼干去满足胃口最小的孩子
var findContentChildren = function(g, s) {
    // 将孩子的胃口和饼干大小都排序
    g.sort((a, b) => a - b); // 孩子胃口从小到大
    s.sort((a, b) => a - b); // 饼干从小到大
    
    let child = 0; // 孩子指针
    let cookie = 0; // 饼干指针
    let satisfied = 0; // 满足的孩子数量
    
    // 遍历所有饼干
    while (child < g.length && cookie < s.length) {
        // 如果当前饼干能满足当前孩子
        if (s[cookie] >= g[child]) {
            satisfied++;
            child++; // 下一个孩子
        }
        cookie++; // 下一块饼干
    }
    
    return satisfied;
};

// 测试
console.log(findContentChildren([1,2,3], [1,1])); // 1
console.log(findContentChildren([1,2], [1,2,3])); // 2

122. 买卖股票的最佳时机 II

给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

// 贪心策略:只要明天比今天贵,今天就买入明天卖出
var maxProfit = function(prices) {
    let profit = 0;
    
    for (let i = 1; i < prices.length; i++) {
        // 如果明天价格比今天高,就进行交易
        if (prices[i] > prices[i - 1]) {
            profit += prices[i] - prices[i - 1];
        }
    }
    
    return profit;
};

// 测试
console.log(maxProfit([7,1,5,3,6,4])); // 7 (在第2天买入,第3天卖出,第4天买入,第5天卖出)
console.log(maxProfit([1,2,3,4,5])); // 4 (第1天买入,第5天卖出)

回溯算法

回溯算法就像走迷宫:当你走到死路时,你会回到上一个路口,尝试其他路径。在编程中,回溯算法用来解决需要"尝试所有可能性"的问题。

回溯算法的核心思想

  1. 选择:在每一步中做出一个选择
  2. 约束:检查这个选择是否满足约束条件
  3. 目标:检查是否达到了目标
  4. 回溯:如果当前路径不能达到目标,撤销选择,尝试其他选择

回溯算法模板

function backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push(路径);
        return;
    }
    
    for (选择 of 选择列表) {
        // 做选择
        路径.push(选择);
        
        // 递归
        backtrack(路径, 新的选择列表);
        
        // 撤销选择
        路径.pop();
    }
}

LeetCode经典题目

46. 全排列

给定一个不含重复数字的数组 nums ,返回其所有可能的全排列。

var permute = function(nums) {
    const result = [];
    const path = [];
    const used = new Array(nums.length).fill(false);
    
    function backtrack() {
        // 结束条件:路径长度等于数组长度
        if (path.length === nums.length) {
            result.push([...path]); // 注意要复制数组
            return;
        }
        
        // 遍历所有选择
        for (let i = 0; i < nums.length; i++) {
            // 如果已经使用过,跳过
            if (used[i]) continue;
            
            // 做选择
            path.push(nums[i]);
            used[i] = true;
            
            // 递归
            backtrack();
            
            // 撤销选择
            path.pop();
            used[i] = false;
        }
    }
    
    backtrack();
    return result;
};

// 测试
console.log(permute([1,2,3])); 
// [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

78. 子集

给你一个整数数组 nums ,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。

var subsets = function(nums) {
    const result = [];
    const path = [];
    
    function backtrack(start) {
        // 每一步都是一个有效的子集
        result.push([...path]);
        
        // 从start开始遍历,避免重复
        for (let i = start; i < nums.length; i++) {
            // 做选择
            path.push(nums[i]);
            
            // 递归(注意下一层从i+1开始)
            backtrack(i + 1);
            
            // 撤销选择
            path.pop();
        }
    }
    
    backtrack(0);
    return result;
};

// 测试
console.log(subsets([1,2,3])); 
// [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

51. N皇后

n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

var solveNQueens = function(n) {
    const result = [];
    const board = Array(n).fill().map(() => Array(n).fill('.'));
    
    // 检查在(row, col)位置放置皇后是否有效
    function isValid(row, col) {
        // 检查列
        for (let i = 0; i < row; i++) {
            if (board[i][col] === 'Q') return false;
        }
        
        // 检查左上对角线
        for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
            if (board[i][j] === 'Q') return false;
        }
        
        // 检查右上对角线
        for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
            if (board[i][j] === 'Q') return false;
        }
        
        return true;
    }
    
    function backtrack(row) {
        // 结束条件:所有行都放置了皇后
        if (row === n) {
            result.push(board.map(row => row.join('')));
            return;
        }
        
        // 尝试在当前行的每一列放置皇后
        for (let col = 0; col < n; col++) {
            if (!isValid(row, col)) continue;
            
            // 做选择
            board[row][col] = 'Q';
            
            // 递归
            backtrack(row + 1);
            
            // 撤销选择
            board[row][col] = '.';
        }
    }
    
    backtrack(0);
    return result;
};

// 测试
console.log(solveNQueens(4));

总结

这些算法思想在实际开发中都很有用:

  • :处理"前K个"问题,优先队列
  • 排序:数据处理的基础,理解底层原理很重要
  • 分治:大问题分解为小问题,递归思想
  • 动态规划:避免重复计算,用空间换时间
  • 贪心:每步都做最优选择,适合特定问题
  • 回溯:穷举所有可能,适合组合、排列问题

记住:算法不是为了炫技,而是为了解决实际问题。理解思想比记住代码更重要!