算法解题思路指南
看到题目如何思考?用什么算法?如何分析?
目录
1. 解题思维框架
1.1 看到题目的第一反应(5秒判断)
题目输入 → 观察特征 → 初步判断 → 确定方向
第一步:看数据规模
n ≤ 20:可能用暴力、回溯、DFSn ≤ 100:O(n²) 可接受,双指针、模拟n ≤ 1000:O(n²) 勉强,考虑优化到 O(n log n)n ≤ 10⁵:必须 O(n) 或 O(n log n),考虑双指针、滑动窗口、二分、贪心n ≤ 10⁶:必须 O(n),不能用嵌套循环n ≤ 10⁸:必须 O(log n) 或 O(1),二分、数学推导
第二步:看数据类型
- 数组 → 连续存储 → 索引访问快 → 双指针/滑动窗口/二分
- 链表 → 离散存储 → 只能顺序访问 → 快慢指针/虚拟头节点
- 二叉树 → 层次结构 → 递归天然适合 → DFS/BFS
- 图 → 网络结构 → DFS/BFS/最短路径
- 字符串 → 特殊数组 → 双指针/KMP
第三步:看问题类型
- 查找问题 → 有序?二分;无序?哈希表
- 最值问题 → 动态规划/贪心/单调栈
- 计数问题 → 哈希表/前缀和
- 排列组合 → 回溯
- 区间问题 → 滑动窗口/前缀和/贪心
- 路径问题 → DFS/BFS/动态规划
1.2 五步解题法
第一步:理解题目
第二步:分析数据特征
第三步:确定算法类型
第四步:写出代码框架
第五步:完善细节、边界处理
详细展开:
第一步:理解题目
- 明确输入输出
- 找出约束条件
- 理解题目本质要求
- 画图举例验证理解
第二步:分析数据特征
- 数据有序吗?(有序→二分)
- 数据有重复吗?(无重复→哈希表、二分更简单)
- 数据范围大吗?(大→需要优化)
- 数据是连续还是离散?(连续→数组技巧;离散→链表技巧)
- 数据之间有联系吗?(有→图/树结构)
第三步:确定算法类型
- 查找类:二分、哈希、双指针
- 遍历类:DFS、BFS、迭代、递归
- 计算类:动态规划、贪心、数学
- 构造类:回溯、模拟
- 组合类:分治、滑动窗口
第四步:写出代码框架
- 根据算法类型写出基本结构
- 不必先考虑细节,先写出骨架
第五步:完善细节
- 边界条件处理
- 特殊情况判断
- 优化常数时间
- 检查常见错误
1.3 通用解题模板
模板一:双指针
cpp
// 适用场景:数组、字符串、链表
// 核心思想:两个指针协同工作
// 1. 快慢指针(链表找中点、判断环)
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
// 2. 左右指针(数组反转、二分查找)
int left = 0, right = n - 1;
while (left < right) {
// 处理逻辑
left++;
right--;
}
// 3. 滑动窗口(子数组问题)
int left = 0;
for (int right = 0; right < n; right++) {
// 扩展窗口
while (满足收缩条件) {
// 收缩窗口
left++;
}
// 更新结果
}
模板二:递归
cpp
// 适用场景:树、链表、分治问题
// 核心思想:大问题分解为小问题
// 递归三部曲
// 1. 确定参数和返回值
// 2. 确定终止条件
// 3. 确定单层递归逻辑
TreeNode* func(TreeNode* root) {
// 终止条件
if (root == nullptr) return nullptr;
// 单层逻辑
TreeNode* left = func(root->left); // 递归左子树
TreeNode* right = func(root->right); // 递归右子树
// 处理当前节点
return root;
}
模板三:回溯
cpp
// 适用场景:组合、排列、子集、切割问题
// 核心思想:穷举+剪枝
// 回溯三部曲
// 1. 确定递归函数参数
// 2. 确定终止条件
// 3. 确定单层搜索逻辑
void backtrack(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择 : 本层所有选择) {
处理节点;
backtrack(下一层参数); // 递归
回溯,撤销处理结果; // 恢复状态
}
}
模板四:动态规划
cpp
// 适用场景:最值、计数、可行性问题
// 核心思想:状态转移
// 动规五步曲
// 1. 确定dp数组及下标含义
// 2. 确定递推公式
// 3. 确定dp数组初始化
// 4. 确定遍历顺序
// 5. 举例推导dp数组
// 01背包模板
vector<int> dp(容量 + 1, 0);
for (int i = 0; i < 物品数量; i++) { // 遍历物品
for (int j = 容量; j >= weight[i]; j--) { // 遍历容量(逆序)
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
2. 题目特征识别速查表
2.1 看到这些关键词 → 想到这些算法
| 关键词 | 可能算法 | 原理说明 |
|---|---|---|
| 有序数组 | 二分查找 | 利用有序性,每次排除一半 |
| 子数组/子串 | 滑动窗口、前缀和 | 连续区间,动态调整边界 |
| 两个数组对比 | 双指针、哈希表 | 避免嵌套循环 |
| 链表 | 快慢指针、虚拟头节点 | 无法索引访问 |
| 环形/循环 | 快慢指针 | 快指针追慢指针 |
| 树 | DFS、BFS、递归 | 层次结构天然递归 |
| 图 | DFS、BFS、最短路径 | 网络遍历 |
| 排列/组合 | 回溯 | 穷举所有可能 |
| 最值问题 | 动态规划、贪心 | 状态转移或局部最优 |
| 重复元素 | 哈希表 | 快速查找是否出现过 |
| 括号匹配 | 栈 | 后进先出特性 |
| 单调性 | 单调栈/队列 | 维护单调序列 |
| 区间问题 | 贪心、前缀和 | 排序后处理或预处理 |
| 最近/相邻 | 单调栈 | 维护候选元素 |
| 最长/最短 | 动态规划、二分 | 状态转移或搜索 |
2.2 数据结构特征识别
数组特征:
- 有序 → 二分查找 、双指针
- 无序但需查找 → 哈希表 、排序后二分
- 连续子数组 → 滑动窗口 、前缀和
- 原地修改 → 双指针(避免额外空间)
链表特征:
- 找中点 → 快慢指针
- 判断环 → 快慢指针
- 反转 → 迭代/递归
- 删除/插入 → 虚拟头节点
- 合并 → 双指针
字符串特征:
- 反转 → 双指针
- 子串 → 滑动窗口
- 匹配 → KMP算法
- 括号 → 栈
二叉树特征:
- 深度/路径 → DFS
- 层次遍历 → BFS
- 判断性质 → 递归
- 构造树 → 递归
- BST操作 → 利用有序性
2.3 问题类型识别
查找类问题:
有序数组 → 二分查找(O(log n))
无序数组 → 哈希表(O(n))
链表 → 顺序查找或快慢指针
树 → DFS/BFS
最值类问题:
单序列最值 → 动态规划
多阶段决策 → 动态规划
局部最优推全局 → 贪心
区间最值 → 单调栈
计数类问题:
统计出现次数 → 哈希表
统计区间和 → 前缀和
统计路径数 → 动态规划
排列组合类问题:
所有排列 → 回溯
所有组合 → 回溯
所有子集 → 回溯
特定排列 → 数学/动态规划
3. 数据结构选择策略
3.1 查找场景选择
| 需求 | 时间复杂度要求 | 推荐数据结构 |
|---|---|---|
| 频繁查找单个元素 | O(1) | 哈希表(unordered_map/set) |
| 范围查找、有序遍历 | O(log n) | 有序数组+二分 / map/set |
| 查找最值 | O(1) | 堆(priority_queue) |
| 查找相邻元素 | O(1) | 单调栈/双指针 |
| 查找前驱后继 | O(log n) | 平衡树(map/set) |
3.2 存储场景选择
| 需求 | 推荐数据结构 | 原因 |
|---|---|---|
| 顺序访问 | 数组 | 缓存友好 |
| 频繁插入删除 | 链表 | O(1)操作 |
| 后进先出 | 栈 | 递归模拟、括号匹配 |
| 先进先出 | 队列 | BFS、滑动窗口 |
| 唯一元素 | set | 自动去重 |
| 键值映射 | map | 快速查找 |
3.3 优先级比较
查找频率高 → 哈希表优先
需要有序 → map/set优先
需要最值 → 堆优先
需要遍历 → 数组优先
内存受限 → 数组优先(比哈希表省空间)
4. 算法选择决策树
4.1 查找问题决策
查找问题
├── 数据有序?
│ ├── 是 → 二分查找(O(log n))
│ └── 否
│ ├── 查找频率高?
│ │ ├── 是 → 哈希表预处理(O(n)预处理,O(1)查找)
│ │ └── 否 → 排序后二分(O(n log n)排序 + O(log n)查找)
│ └── 单次查找 → 顺序查找(O(n))
4.2 数组问题决策
数组问题
├── 有序?
│ ├── 是 → 二分查找
│ └── 否
│ ├── 子数组问题?
│ │ ├── 是
│ │ │ ├── 固定长度 → 滑动窗口
│ │ │ ├── 可变长度 → 滑动窗口 + 条件判断
│ │ │ └── 需要前缀和 → 前缀和数组
│ │ └── 否
│ │ ├── 双指针可解? → 双指针(O(n))
│ │ └── 否 → 暴力(O(n²))或哈希表优化
├── 需要原地操作?
│ ├── 是 → 双指针(快慢指针)
│ └── 否 → 创建新数组
4.3 链表问题决策
链表问题
├── 找位置?
│ ├── 找中点 → 快慢指针(fast走两步,slow走一步)
│ ├── 找倒数第k → 快慢指针(fast先走k步)
│ └── 找特定节点 → 顺序遍历
├── 判断环? → 快慢指针(相遇则有环)
├── 删除/插入?
│ ├── 头节点 → 虚拟头节点
│ └── 其他位置 → 双指针(pre和cur)
├── 反转?
│ ├── 整体反转 → 迭代(pre、cur、next三指针)
│ └── 局部反转 → 递归或迭代
├── 合并?
│ ├── 两个有序 → 双指针比较
│ └── k个 → 分治或堆
4.4 树问题决策
树问题
├── 需要深度?
│ ├── 是 → DFS(递归或栈)
│ └── 否
│ ├── 需要层次信息? → BFS(队列)
│ └── 否 → 根据问题选择
├── 需要路径?
│ ├── 所有路径 → DFS
│ ├── 最短路径 → BFS
│ └── 特定路径 → DFS + 条件
├── 需要遍历?
│ ├── 前序/中序/后序 → 递归或迭代(栈)
│ ├── 层序 → BFS(队列)
│ └── 所有节点 → DFS或BFS均可
├── 构造树?
│ ├── 从数组 → 递归(找根节点,划分左右)
│ └── 从遍历序列 → 递归(前序+中序,后序+中序)
4.5 最值问题决策
最值问题
├── 问题可分解?
│ ├── 是
│ │ ├── 子问题独立? → 动态规划
│ │ ├── 子问题重叠? → 动态规划
│ │ └── 局部最优可推全局? → 贪心
│ └── 否 → 暴力或数学推导
├── 有约束条件?
│ ├── 是 → 动态规划(背包问题)
│ └── 否 → 贪心或数学
├── 多阶段决策?
│ ├── 是 → 动态规划
│ └── 否 → 根据问题选择
4.6 组合问题决策
组合问题
├── 求所有可能?
│ ├── 是 → 回溯(穷举)
│ └── 否
│ ├── 求数量? → 动态规划或数学公式
│ ├── 求最优解? → 动态规划
│ └── 求是否存在? → DFS或动态规划
├── 有剪枝条件?
│ ├── 是 → 回溯+剪枝
│ └── 否 → 回溯
5. 各类问题解题套路
5.1 二分查找类问题
判断是否可用二分:
1. 数据是否有序?(必须条件)
2. 是否需要查找特定值或边界?
3. 是否可以通过比较排除一半?
解题套路:
cpp
// 第一步:确定区间定义(左闭右闭 or 左闭右开)
// 推荐:左闭右闭,更容易理解
// 第二步:确定循环条件
// 左闭右闭:while (left <= right)
// 左闭右开:while (left < right)
// 第三步:确定mid计算(防止溢出)
int mid = left + (right - left) / 2;
// 第四步:确定边界更新
// 左闭右闭:right = mid - 1 或 left = mid + 1
// 左闭右开:right = mid 或 left = mid + 1
// 第五步:确定返回值
// 找到:return mid
// 未找到:return -1 或 left(插入位置)
常见变体:
- 查找第一个等于target的位置(左边界)
- 查找最后一个等于target的位置(右边界)
- 查找第一个大于target的位置
- 查找最后一个小于target的位置
5.2 双指针类问题
判断是否可用双指针:
1. 数组/字符串连续存储?
2. 两个方向协同工作能简化问题?
3. 能避免嵌套循环?
解题套路:
cpp
// 类型一:快慢指针(原地修改)
int slow = 0;
for (int fast = 0; fast < n; fast++) {
if (满足条件) {
nums[slow++] = nums[fast]; // 保留
}
}
return slow; // 新长度
// 类型二:左右指针(两端向中间)
int left = 0, right = n - 1;
while (left < right) {
if (nums[left] + nums[right] == target) {
// 找到
} else if (nums[left] + nums[right] < target) {
left++;
} else {
right--;
}
}
// 类型三:快慢指针(链表)
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow; // 中点
5.3 滑动窗口类问题
判断是否可用滑动窗口:
1. 是否是子数组/子串问题?
2. 是否求连续区间?
3. 窗口是否可以动态调整?
解题套路:
cpp
// 模板框架
int left = 0;
int result = 初始值;
for (int right = 0; right < n; right++) {
// 1. 扩展窗口:加入right元素
更新窗口状态;
// 2. 判断是否需要收缩
while (窗口不满足条件) {
// 收缩窗口:移除left元素
更新窗口状态;
left++;
}
// 3. 更新结果
if (满足条件) {
result = 更新结果;
}
}
return result;
关键点:
- 窗口内维护什么状态?(计数、和、最大值等)
- 什么时候扩展?什么时候收缩?
- 如何判断窗口满足条件?
- 结果在什么时机更新?
5.4 回溯类问题
判断是否可用回溯:
1. 需要穷举所有可能?
2. 问题可以抽象为树形结构?
3. 可以通过剪枝减少搜索?
解题套路:
cpp
// 回溯模板
void backtrack(参数) {
// 终止条件
if (满足终止条件) {
result.push_back(path); // 收集结果
return;
}
// 单层搜索
for (int i = startIndex; i < n; i++) {
// 处理当前节点
path.push_back(元素);
// 递归下一层
backtrack(新参数); // startIndex+1 或 i+1
// 回溯:撤销处理
path.pop_back();
}
}
剪枝技巧:
- 剩余元素不足以填满结果 → 提前终止
- 已有元素不符合条件 → 跳过
- 排序后避免重复 →
if (i > startIndex && nums[i] == nums[i-1]) continue
5.5 动态规划类问题
判断是否可用动规:
1. 是否求最值、计数、可行性?
2. 问题可分解为重叠子问题?
3. 最优子结构?(子问题的最优解能推出原问题最优解)
4. 无后效性?(当前状态确定后,之前的状态不影响后续)
解题套路(五步曲):
cpp
// 第一步:确定dp数组含义
// dp[i] 表示什么?dp[i][j] 表示什么?
// 第二步:确定递推公式
// dp[i] = f(dp[i-1], dp[i-2], ...)
// 第三步:确定初始化
// dp[0] = ?, dp[1] = ?
// 第四步:确定遍历顺序
// 正序遍历?逆序遍历?(01背包逆序)
// 第五步:举例推导验证
// 手动计算几个例子,验证公式正确
// 实现示例(斐波那契)
vector<int> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
背包问题模板:
cpp
// 01背包(每件物品只能用一次)
vector<int> dp(V + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = V; j >= weight[i]; j--) { // 逆序遍历容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
// 完全背包(每件物品可用多次)
vector<int> dp(V + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = weight[i]; j <= V; j++) { // 正序遍历容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
5.6 贪心类问题
判断是否可用贪心:
1. 局部最优能否推出全局最优?
2. 没有明显重叠子问题?
3. 问题可以分解为一系列选择?
解题套路:
cpp
// 贪心模板
int result = 0;
int 当前状态 = 初始值;
while (还有选择) {
// 选择局部最优
int 选择 = 选择局部最优解;
// 更新状态
result += 选择的影响;
当前状态 = 新状态;
}
return result;
常见贪心场景:
- 区间调度:按结束时间排序,选最早结束的
- 区间覆盖:按开始时间排序
- 跳跃游戏:维护能到达的最远位置
- 分发糖果:两个方向分别考虑
6. 复杂度分析与优化方向
6.1 复杂度判断
| 复杂度 | 数据规模 | 常见算法 |
|---|---|---|
| O(1) | 任意 | 直接计算、数学公式 |
| O(log n) | ≤ 10⁸ | 二分查找、树的查找 |
| O(n) | ≤ 10⁶ | 双指针、滑动窗口、线性遍历 |
| O(n log n) | ≤ 10⁵ | 排序、堆、二分优化 |
| O(n²) | ≤ 1000 | 双重循环、DP二维 |
| O(n³) | ≤ 100 | 三重循环 |
| O(2ⁿ) | ≤ 20 | 回溯、DFS穷举 |
| O(n!) | ≤ 10 | 全排列 |
6.2 优化思路
从 O(n²) 到 O(n):
1. 查找优化:哈希表代替内层循环查找
2. 双指针:利用有序性或单调性
3. 滑动窗口:连续子数组问题
4. 前缀和:区间求和问题
从 O(n log n) 到 O(n):
1. 滑动窗口替代排序+二分
2. 哈希表替代排序+查找
3. 单调栈替代排序+比较
从 O(n) 到 O(log n):
1. 二分查找替代线性查找(有序数据)
2. 利用数学公式直接计算
6.3 空间优化
1. 滚动数组:dp[i]只依赖dp[i-1],不用保存整个数组
2. 原地修改:双指针在原数组操作
3. 状态压缩:多维dp压缩为一维
4. 位运算:用int存储多个状态
7. 常见错误与避坑指南
7.1 二分查找常见错误
❌ 错误:mid = (left + right) / 2 // 可能溢出
✅ 正确:mid = left + (right - left) / 2
❌ 错误:区间定义不一致(左闭右闭用left < right)
✅ 正确:明确区间定义,坚持循环不变量原则
❌ 错误:边界更新不一致(左闭右闭用right = mid)
✅ 正确:根据区间定义更新边界
7.2 双指针常见错误
❌ 错误:忘记更新指针位置
✅ 正确:每次循环都要移动指针
❌ 错误:快指针和慢指针步数设置错误
✅ 正确:找中点fast走两步,找倒数k个fast先走k步
❌ 错误:循环条件错误
✅ 正确:链表遍历要检查fast && fast->next
7.3 滑动窗口常见错误
❌ 错误:用if代替while收缩窗口
✅ 正确:窗口需要持续收缩时用while
❌ 错误:窗口状态更新时机错误
✅ 正确:扩展时更新,收缩时也要更新
❌ 错误:忘记处理剩余元素
✅ 正确:循环结束后检查是否满足条件
7.4 回溯常见错误
❌ 错误:忘记回溯步骤(撤销处理)
✅ 正确:path.pop_back()恢复状态
❌ 错误:参数传递错误
✅ 正确:startIndex控制下一层起始位置
❌ 错误:剪枝条件判断错误
✅ 正确:理解剪枝的数学条件
7.5 动态规划常见错误
❌ 错误:dp数组含义定义不清
✅ 正确:明确dp[i]或dp[i][j]表示什么
❌ 错误:初始化错误
✅ 正确:根据递推公式确定初始化值
❌ 错误:遍历顺序错误
✅ 正确:01背包容量逆序,完全背包容量正序
❌ 错误:没有推导验证
✅ 正确:手动计算几个例子验证公式
8. 实战解题流程
8.1 看到题目后的思考步骤
Step 1: 读题(30秒)
- 输入是什么?输出是什么?
- 数据规模?
- 有什么约束?
Step 2: 分类(30秒)
- 这是什么类型的问题?(查找/遍历/计算/构造)
- 数据有什么特征?(有序/连续/离散/层次)
Step 3: 选择算法(60秒)
- 根据分类和数据特征选择算法
- 估算时间复杂度是否满足要求
Step 4: 写代码框架(5分钟)
- 不考虑细节,写出基本结构
- 明确关键变量和循环结构
Step 5: 完善细节(5分钟)
- 边界条件处理
- 特殊情况判断
- 添加必要注释
Step 6: 验证(2分钟)
- 手动跑几个例子
- 检查边界情况
- 检查常见错误点
8.2 卡住时的应对策略
卡在思路:
1. 重新理解题目(画图举例)
2. 从暴力解法开始
3. 思考暴力解法的优化方向
4. 回顾类似题目
5. 查看题目分类提示
卡在实现:
1. 写出伪代码
2. 分模块实现
3. 先写主逻辑,再处理边界
4. 简化问题(去掉约束,先解简单版)
卡在边界:
1. 列举所有边界情况
2. 逐一处理
3. 用assert验证假设
4. 看题解的边界处理方式
总结
解题的核心是:观察特征 → 选择算法 → 遵循模板 → 完善细节
记住:
- 数据规模决定复杂度要求
- 数据特征决定数据结构选择
- 问题类型决定算法方向
- 循环不变量保证边界正确
最后更新:2024 年