常见算法解题范式
算法分类总览
基础算法分类表
| 枚举算法 | 贪心算法 | 回溯算法 | 分治算法 | 动态规划 | 数据结构 | 
|---|---|---|---|---|---|
| 枚举 | 贪心 | 回溯 | 分治 | 动态规划 | 数组 | 
| 模拟 | 归并排序 | 记忆化搜索 | 链表 | ||
| 快速选择 | 状态压缩 | 双向链表 | |||
| 递归 | 栈 | ||||
| 队列 | |||||
| 哈希表 | |||||
| 前缀和 | |||||
| 单调栈 | |||||
| 单调队列 | |||||
| 滚动哈希 | |||||
| 后缀数组 | |||||
| 树 | |||||
| 二叉树 | |||||
| 二叉搜索树 | |||||
| 堆(优先队列) | |||||
| 字典树 | |||||
| 并查集 | |||||
| 线段树 | |||||
| 树状数组 | |||||
| 图 | |||||
| 最小生成树 | |||||
| 强连通分量 | |||||
| 欧拉回路 | |||||
| 双连通分量 | |||||
| 哈希函数 | |||||
| 有序集合 | 
特定场景算法分类表
| 搜索与遍历 | 排序 | 数学与逻辑 | 特殊场景类型 | 
|---|---|---|---|
| 深度优先搜索 (DFS) | 排序 | 数学 | 数据库 | 
| 广度优先搜索 (BFS) | 计数排序 | 位运算 | Shell | 
| 二分查找 | 基数排序 | 计数 | 设计 | 
| 双指针 | 桶排序 | 数论 | 数据流 | 
| 滑动窗口 | 几何 | 交互 | |
| 拓扑排序 | 博弈 | 多线程 | |
| 最短路 | 组合数学 | 迭代器 | |
| 字符串匹配 | 概率与统计 | ||
| 字符串 | 脑筋急转弯 | ||
| 矩阵 | 扫描线 | ||
| 随机化 | |||
| 水塘抽样 | |||
| 拒绝采样 | 
基础算法分类
枚举算法
这是最直接、最容易想到的方法。它的核心思想是穷举所有可能的解,然后从中找到符合条件的那个。
- 特点: 简单粗暴,实现起来相对容易。
 - 优点: 适用于那些没有明显规律、或者问题规模很小的情况。
 - 缺点: 效率通常很低,时间复杂度很高,大部分情况下会超时。
 - 作用: 在你没有思路时,先用暴力解法作为保底,验证一下逻辑是否正确。这也可以作为优化的起点,让你更清楚地看到问题所在的瓶颈。
 
具体解题思路如下:
- 确定枚举的范围和对象: 明确问题有多少个“东西”需要枚举,以及这些“东西”的取值范围是什么。例如,如果要解决一个有 n 个数字的问题,那么枚举的范围就是这些数字的所有组合。
 - 建立搜索策略: 设计一个能覆盖所有可能解的策略,通常使用循环嵌套实现,确保不遗漏任何一个可能的解,同时避免重复。
 - 编写判断条件: 为每一个枚举的候选解设置一个判断条件,检查它是否满足问题的要求,即是否是问题的“有效解”。
 - 进行枚举和筛选: 根据设定的策略,逐一枚举所有可能的候选解。对于每一个候选解,用判断条件进行验证,保留所有符合条件的解。
 - 寻找最优解(如果需要): 在所有符合条件的解中,根据问题的需求找出最优的解。
 - 优化(可选): 为了提高效率,可以考虑引入剪枝策略,在枚举过程中排除那些不可能得到最优解的候选解,从而缩小搜索空间
 
贪心算法
贪心算法相关的算法题单:
贪心
贪心算法的策略是每一步都选择当前看起来最优的解,希望通过一系列局部最优的选择,最终得到一个全局最优的解。
贪心算法的核心在于局部最优。它不从整体上考虑问题,而是将问题分解成多个子问题,在每个子问题上都做出当时看起来最好的决策。它假设通过一系列这样的局部最优决策,最终能够得到一个全局最优解。
并非所有问题都能用贪心算法解决。如果贪心选择性质不成立,那么局部最优解将无法保证全局最优解
贪心选择性质(Greedy Choice Property): 问题的全局最优解可以通过一系列局部最优的选择来达到。这意味着,每一步的局部最优选择都将对最终的全局最优解产生积极影响,并且这个选择一旦做出,就不需要再考虑其对后续步骤的影响。
最优子结构性质(Optimal Substructure): 问题的最优解包含其子问题的最优解。这是动态规划和贪心算法都具备的性质。
如果一个问题同时具备这两个性质,那么贪心算法通常是求解它的最佳选择。
经典案例:找零钱问题
假设你是一个收银员,需要给顾客找零 27 元。你有的零钱是 1 元、5 元、10 元,并且每种零钱的数量是无限的。你的目标是用最少的硬币数量来完成找零。
贪心策略:在每一步都选择面额最大的硬币。
- 找 27 元:你选择面额最大的 10 元硬币。还剩 17 元要找。
 - 找 17 元:你再次选择 10 元硬币。还剩 7 元要找。
 - 找 7 元:你选择 5 元硬币。还剩 2 元要找。
 - 找 2 元:你选择 1 元硬币。还剩 1 元要找。
 - 找 1 元:你选择 1 元硬币。还剩 0 元。
 
最终,你使用了 2 个 10 元、1 个 5 元和 2 个 1 元,总共 5 枚硬币。这种贪心策略在这里是正确的,因为它能得到最优解。
贪心算法的局限性
并非所有问题都能用贪心算法解决。如果贪心选择性质不成立,那么局部最优解将无法保证全局最优解。
反例:如果你的硬币面额是 1 元、3 元和 4 元,需要找零 6 元。
- 贪心策略:
- 你选择 4 元硬币。还剩 2 元。
 - 你只能用 1 元硬币找,需要 2 个 1 元。
 - 最终使用了 3 枚硬币(4 + 1 + 1)。
 
 - 正确解法:
- 如果你选择 3 元硬币,还剩 3 元。
 - 再选择一个 3 元硬币。
 - 最终只使用了 2 枚硬币(3 + 3)。
 
 
在这个例子中,贪心策略失败了。这是因为在第一步选择了 4 元后,剩余的 2 元无法被 3 元硬币找零,导致后续只能用更小的硬币,从而导致了次优解。
贪心算法是一种简单、快速的算法,但它只适用于那些具备贪心选择性质和最优子结构的问题。在解决问题时,如果直观上能想到一种贪心策略,你应该先尝试证明它的正确性,然后再进行编码。如果证明失败,可能需要考虑使用动态规划或其他算法。
回溯算法
回溯算法相关的算法题单:
回溯
它的核心思想是: 从一个起点出发,一步步地向前探索,如果发现当前的选择无法达到目标,就退回到上一步,重新选择另一条路径,直到找到所有可能的解或第一个解。
通过将问题建模为决策树或图,并利用递归和剪枝策略寻找所有可行解或最优解。核心在于定义问题中的路径(已做选择)、选择列表(当前可选操作)和终止条件(搜索结束或找到目标),并通过不断选择、递归、回溯(撤销选择)来探索所有可能性。
这个过程就像在一个迷宫里寻找出口: 你选择一条路一直走,如果遇到死胡同,就原路返回到上一个岔路口,选择另一条未走过的路继续探索。
具体解题步骤如下:
- 定义问题和解空间: 将待解决的问题转化为一个抽象的结构,如决策树或图,并确定其包含问题的解(至少是一个)。
 - 确定搜索方式: 通常采用深度优先搜索策略来遍历解空间。
 - 构建递归函数:
- 判断终止条件: 当当前状态满足搜索的终止条件(例如,达到目标,或解空间已探索完毕)时,返回结果。
 - 遍历选择列表: 在当前状态下,列出所有可行的选择。
 - 做出选择: 选择一个选项,并更新当前状态,将这个选择加入到“路径”中。
 - 递归: 对新的状态进行递归调用,继续探索解空间。
 - 回溯(撤销选择): 在从递归调用返回后,撤销刚才的选择,从“路径”中移除它,以便尝试其他选项,这相当于在决策树中从当前节点返回到父节点。
 
 - 剪枝: 在探索过程中,加入剪枝函数来判断当前路径是否可能导向无效解或非最优解,从而提前终止搜索,避免不必要的计算。
 
经典案例: 全排列问题
以“给定一个不含重复数字的数组 [1, 2, 3],返回它的所有排列”为例,我们可以用回溯算法来解决。
问题: 求 [1, 2, 3] 的所有排列。
回溯思路: 我们可以把这个问题看作是,从 3 个数字中,依次选择一个,填入三个位置。
- 第 1 个位置: 你有 
[1, 2, 3]三个选择。 - 第 2 个位置: 假设你选择了 1,那么下一个位置你只能从剩下的 
[2, 3]中选。 - 第 3 个位置: 假设你又选择了 2,那么最后一个位置你只剩下 3 可以选。
 
现在,我们通过一步步的回溯来找到所有解。
- 路径: 
[],可选项:[1, 2, 3],- 选择 1, 路径: [1]可选项: 
[2, 3] 
- 选择 2, 路径: 
[1, 2],可选项:[3]- 选择 3, 路径: 
[1, 2, 3]。已用完所有数字,找到一个解:[1, 2, 3]- 回溯,回到上一步。
 
 - 回溯,回到 [1]。现在还有另一个选择: 3
 
 - 选择 3, 路径: 
 - 选择 3, 路径: 
[1, 3],可选项:[2]- 选择 2, 路径: 
[1, 3, 2]。找到第二个解:[1, 3, 2]。- 回溯。
 
 - 回溯。
 
 - 选择 2, 路径: 
 - 回溯,直到回到 
[] 
 - 选择 1, 路径: [1]可选项: 
 - 现在回到起点 
[],选择 2- 路径: 
[2],可选项:[1, 3]- …重复上面的过程,会找到 
[2, 1, 3]和[2, 3, 1] 
 - …重复上面的过程,会找到 
 
 - 路径: 
 - 再次回到起点 
[],选择 3- 路径: 
[3],可选项:[1, 2] 
- …重复上面的过程,会找到 
[3, 1, 2]和[3, 2, 1] 
 - 路径: 
 
通过这种“选择-探索-回溯”的方式,我们能够不重不漏地找到所有可能的排列组合
回溯算法通常可以抽象为一个递归函数,它的伪代码结构通常如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function backtrack(路径, 选择列表):
  // 满足结束条件,找到一个解
  if 满足结束条件:
      result.add(路径)
      return
  // 遍历所有可能的选择
  for choice in 选择列表:
      // 做出选择
      路径.add(choice)
      // 递归,进入下一层决策树
      backtrack(路径, 新的选择列表)
      // 撤销选择 (回溯),为下一轮选择做准备
      路径.remove(choice)
分治算法
分治算法(Divide and Conquer)是一种将复杂问题分解为多个规模更小、相互独立的子问题,独立递归地解决这些子问题,然后将子问题的解合并,得到原问题的解。
例子: 归并排序。将一个数组分成两半,分别对左右两半进行排序,这两个子任务是独立的,互不影响。最后将排序好的两半合并。
- 核心思想: 分解 -> 解决 -> 合并。
 - 经典应用: 归并排序、快速排序、大整数乘法等。
 
这个过程可以类比为一次大型团队项目:
- 分解(Divide):将项目分解成几个独立的、可管理的小任务。
 - 解决(Conquer):让不同的团队成员分别去完成各自的小任务。他们可以独立工作,互不影响。
 - 合并(Combine):当所有小任务都完成后,将它们的结果汇总、整合,形成最终的项目成果。
 
分治算法的魅力在于,它能将一个难以直接解决的大问题,转化为一系列易于解决的小问题。
经典案例:归并排序(Merge Sort)
归并排序是分治算法最经典的例子之一。它的目标是排序一个数组。
假设我们要对数组 [4, 7, 2, 8, 1, 5, 3, 6] 进行排序。
分解 (Divide)
- 将数组一分为二,得到 
[4, 7, 2, 8]和[1, 5, 3, 6]。 - 接着,再将这两个子数组各自一分为二,直到每个子数组只剩下一个元素。
 - 这个过程一直递归地进行下去,直到每个子数组都只包含一个元素。一个单元素的数组被认为是天然有序的。
 
- 将数组一分为二,得到 
 解决 (Conquer)
- 对每个只有一个元素的子数组进行排序。因为它们只有一个元素,所以本身就是有序的。
 - 比如 
[4]、[7]、[2]、[8]… 
合并 (Combine)
- 现在,我们开始将有序的子数组两两合并,形成更大的有序数组。
 - 合并 
[4]和[7],得到[4, 7]。 - 合并 
[2]和[8],得到[2, 8]。 - 合并 
[1]和[5],得到[1, 5]。 - 合并 
[3]和[6],得到[3, 6]。 - 接着,继续合并 
[4, 7]和[2, 8],得到[2, 4, 7, 8]。 - 同时,合并 
[1, 5]和[3, 6],得到[1, 3, 5, 6]。 - 最后,合并 
[2, 4, 7, 8]和[1, 3, 5, 6],得到最终排序好的数组[1, 2, 3, 4, 5, 6, 7, 8]。 - 这个过程的核心在于,每次分解出的两个子数组是完全独立的,对其中一个子数组的排序不会影响到另一个。
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function divideAndConquer(problem):
    // 1. 基本情况 (Base Case)
    // 这是分治的终止条件。当问题规模足够小,可以直接求解时,
    // 无需再进行分解,直接返回结果。
    if problem is simple enough:
        return solve(problem)
    // 2. 分解 (Divide)
    // 将原问题分解成两个或多个相互独立的子问题。
    subproblems = divide(problem)
    
    // 3. 解决 (Conquer)
    // 递归地解决每一个子问题。
    subresults = []
    for subproblem in subproblems:
        subresults.add(divideAndConquer(subproblem))
    // 4. 合并 (Combine)
    // 将所有子问题的解合并成原问题的解。
    result = combine(subresults)
    return result
动态规划
动态规划是一种通过拆分问题,定义状态,并利用历史结果来解决复杂问题的方法。它通常用于解决具有重叠子问题和最优子结构特性的问题
重叠子问题: 比如计算斐波那契数列,计算 F(5) 需要 F(4) 和 F(3),而计算 F(4) 又需要 F(3) 和 F(2),F(3) 被重复计算了。如果不加处理,每次都重新计算,效率会非常低。
最优子结构: 问题的最优解可以由其子问题的最优解推导而来。这意味着,如果能找到子问题的最优解,你就可以组合它们来构建整个问题的最优解。
基本解题思路:
- 定义状态: 用一个数组(通常是 
dp数组)来表示问题的解。dp[i]可能表示前 i 个元素的解。- 找出状态转移方程: 找出子问题之间的关系, 主要是找到 
dp[i]和之前状态的关系,比如dp[i] = max(dp[i-1], ...)。 
 - 找出状态转移方程: 找出子问题之间的关系, 主要是找到 
 - 确定初始状态: 找到 
dp数组的初始值。 - 顺序计算: 通常采用自底向上(Bottom-Up)的方式,从最小的子问题开始计算,逐步得到最终解。
 
经典案例:爬楼梯问题
- 假设你需要爬 n 级台阶,每次可以爬 1 级或 2 级。请问有多少种不同的方法可以爬到楼顶?
 - 定义状态: 
dp[i]表示爬到第 i 级台阶的不同方法数。 - 状态转移方程: 要爬到第 i 级台阶,你只能从第 i-1 级(再爬 1 级)或第 i-2 级(再爬 2 级)上来。因此,
dp[i] = dp[i-1] + dp[i-2]。 - 初始状态:
dp[1] = 1(爬 1 级台阶只有 1 种方法)dp[2] = 2(爬 2 级台阶有 2 种方法:1+1 或 2)
 - 计算:
dp[3] = dp[2] + dp[1] = 2 + 1 = 3dp[4] = dp[3] + dp[2] = 3 + 2 = 5- …直到 
dp[n]。 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function dynamicProgramming(n):
    // 1. 定义状态和初始化 (Define State and Initialize)
    // 通常使用一个 dp 数组来存储子问题的解。
    // dp[i] 表示规模为 i 的问题的解。
    dp = new Array(n + 1)
    dp[0] = base_case_value_0
    dp[1] = base_case_value_1
    // 2. 状态转移和计算 (State Transition and Computation)
    // 自底向上地从小规模问题计算到大规模问题。
    // 这个循环是动态规划的核心。
    for i = 2 to n:
        // 状态转移方程:
        // dp[i] 的值依赖于之前子问题的解,例如 dp[i-1]、dp[i-2]...
        dp[i] = calculate_value_from(dp[i-1], dp[i-2], ...)
    // 3. 返回最终结果 (Return Final Result)
    // 最终问题的解通常是 dp 数组的最后一个元素。
    return dp[n]
分治(Divide and Conquer)和动态规划(Dynamic Programming)都是通过将一个大问题分解成子问题来解决的算法思想,但它们在处理子问题的方式上有本质的区别。
分治: 解决的子问题是相互独立的,没有重叠。它将问题分解,然后独立地解决每个子问题,最后将子问题的解合并
动态规划: 解决的子问题是相互重叠的。这意味着一个子问题可能会被多次计算。动态规划通过存储这些子问题的解,避免了重复计算,从而提高效率。这个过程就像一个有记忆的工人,如果他之前算过一个零件的成本,下次再需要这个零件时,他会直接查记录,而不是重新计算
数据结构
很多算法题的解法都离不开对合适数据结构的运用。选择正确的数据结构能极大地简化问题,提高效率
线性数据结构
线性数据结构(如数组、链表、栈、队列)是算法题中最常见的基础。针对这些数据结构的题目,存在一些非常普遍且高效的解题方法或模式
双指针 (Two Pointers)
这是处理数组或链表问题时最常用的技巧之一。通过使用两个指针,从不同方向或以不同速度遍历数据,可以高效地解决许多问题。
- 对撞指针(Two Pointers from Opposite Ends): 一个指针从数组开头向后移动,另一个从结尾向前移动。常用于在有序数组中查找特定值对,比如“两数之和”。
 - 快慢指针(Fast and Slow Pointers): 两个指针从同一点出发,一个移动得快,一个移动得慢。常用于检测链表中的环、找到链表的中间节点等。
 - 同向指针(Sliding Window): 两个指针都从开头出发,一个指针负责扩张窗口,另一个负责收缩窗口。常用于解决子数组或子串问题,比如找到满足条件的最小子数组。
 
哈希表 (Hash Table)
哈希表因其 O(1) 的平均查找、插入和删除时间复杂度,成为解决许多线性数据结构问题的利器。它的核心思想是用空间换时间。
- 频率统计: 用哈希表记录元素出现的次数,轻松解决“众数”、“出现一次的数字”等问题。
 - 快速查找: 将元素存入哈希表,之后可以在 O(1) 时间内判断某个元素是否存在。例如,“两数之和”问题,可以把每个元素及其索引存入哈希表,之后遍历时查找目标元素是否已存在。
 - 去重和唯一性判断: 利用哈希表自动去重的特性,或者用来检查元素是否唯一。
 
栈和队列 (Stack and Queue)
虽然它们本身就是数据结构,但作为解题工具,它们经常用于处理特定模式的问题。
- 栈(Stack): 后进先出(LIFO)。常用于:
- 括号匹配: 遍历字符串,遇到左括号入栈,遇到右括号时弹出栈顶元素进行匹配。
 - 语法解析: 逆波兰表达式求值等。
 - 单调栈: 用于找到数组中每个元素左边或右边第一个比它大或小的元素,比如“柱状图中最大的矩形”。
 
 - 队列(Queue): 先进先出(FIFO)。常用于:
- 广度优先搜索 (BFS): 遍历图或树时按层级进行。
 - 任务调度: 按照顺序处理任务。
 - 单调队列: 用于解决“滑动窗口最大值”等问题,保证队列的单调性。
 
 
- 栈(Stack): 后进先出(LIFO)。常用于:
 排序 (Sorting)
许多看似复杂的线性数据结构问题,在对数据进行排序后,往往会变得简单。
- 预处理: 在解决问题前,先对数组进行排序。例如,在有序数组中找“三数之和”,排序后可以使用双指针快速查找。
 - 特定算法的预备: 一些算法(如二分查找)本身就要求数据是有序的。
 
前缀和与差分 (Prefix Sum & Difference Array)
这是一类基于数组的预处理技巧,能将区间和、区间修改等操作从 O(n) 降到 O(1)。
- 前缀和 (Prefix Sum): 预先计算数组中每个位置之前的元素总和。之后,求任意区间的和就可以通过两个前缀和相减得到。
 - 差分数组 (Difference Array): 通过记录相邻元素之间的差值,将对区间的统一修改(例如,区间内的每个元素都加 1)转化为对两个端点的单点修改。
 
递归与分治 (Recursion & Divide and Conquer)
- 递归: 许多链表问题天然适合递归解决,例如反转链表、合并两个有序链表。
 - 分治: 将问题分解为独立的子问题。最典型的例子是归并排序,它在数组上实现了高效排序。
 
总而言之,处理线性数据结构的算法题时,你应首先思考:
- 能不能用双指针来提高效率?
 - 能不能用哈希表来换取查找速度?
 - 栈或队列能否简化问题?
 - 排序后问题是否会变得更简单
 
树形数据结构
处理树形数据结构的算法题,通常有两种最核心、最通用的方法:递归和迭代。这两种方法又可以引申出几种经典的遍历模式
递归(Recursion)
模式一:自顶向下(Top-Down)
这种模式从根节点开始,通过递归函数向下传递信息。在处理当前节点时,我们假设它的子节点已经通过递归调用处理完毕
模式二:自底向上(Bottom-Up)
这种模式从叶子节点开始,通过递归调用向上返回信息。在处理当前节点时,我们先递归地处理它的子树,然后根据子树返回的信息来处理当前节点。
迭代(Iteration)
虽然递归很直观,但它可能会导致栈溢出,特别是在处理深度很大的树时。这时候,迭代就成为了一个更稳健的选择。迭代通常使用栈(Stack)或队列(Queue)来模拟递归过程。
模式一:深度优先遍历(DFS)
DFS 优先探索树的深度,通常使用栈来实现。它的访问顺序与递归类似。
模式二:广度优先遍历(BFS)
BFS 逐层探索树的节点,通常使用队列来实现。它能确保你总是先访问离根节点最近的节点。
递归是处理树最优雅的方式,自顶向下和自底向上是它的两种基本模式。
迭代通常使用栈(DFS)或队列(BFS)来避免递归的深度限制,并解决特定类型的问题。
对于任何树形问题,你都应该首先考虑这两种通用方法,然后根据问题的具体要求,选择最合适的遍历模式。
图数据结构
处理图结构最核心、最通用的方法是图遍历(Graph Traversal),这通常有两种基本方式和处理树一样:深度优先搜索(DFS)和广度优先搜索(BFS)
图和树的BFS和DFS的区别:
- 树(Tree):树是一种特殊的图,它不包含环。
 - 图(Graph):图可以包含环。
 
在树上进行 DFS 或 BFS 遍历时,由于没有环,你永远不会重复访问同一个节点。但在图上,从一个节点出发,可能会经过一系列边,最终又回到这个节点。如果没有处理好,DFS 或 BFS 可能会陷入死循环
为了避免死循环,在图的遍历中,必须使用一个访问标记数组(visited array)或哈希集合(hash set)来记录已经访问过的节点。
当你面对一个图问题时,通常可以问自己:
这个问题是关于路径还是连通性?如果是,DFS 或 BFS 可能是你的起点。
问题是否要求最短路径?如果是,BFS(无权图)或 Dijkstra/Floyd-Warshall(带权图)可能是答案。
问题是否与最小代价或最小连接有关?如果是,Prim 或 Kruskal 可能是正确方向。
处理图结构的问题,最基础的思路是遍历。DFS 和 BFS 是两种最通用的遍历方式,各自有不同的应用场景。在此基础上,结合贪心、动态规划等思想,可以解决更复杂的图论问题
其他数据结构
特定场景算法分类
搜索与遍历
搜索与遍历相关的算法题单:
深度优先搜索、广度优先搜索、二分查找、双指针、滑动窗口、拓扑排序、最短路、字符串匹配、字符串、矩阵
排序
排序算法的种类, 实现可参考: 排序算法全面解析
数学与逻辑
数学与逻辑相关的算法题单:
数学、位运算、计数、数论、几何、博弈、组合数学、概率与统计、脑筋急转弯、扫描线、随机化、水塘抽样、拒绝采样