【算法突围 03】核心算法思想:分治/递归/动态规划与 LeetCode 高频真题解析
📖 本文导读
为什么快速排序平均 O(N log N) 却最坏 O(N²)?递归如何避免栈溢出?动态规划真的那么难吗?LeetCode 刷题总是没有思路怎么办?
本文将通过分治法 (快速排序)、递归与回溯 (文件遍历、全排列)、动态规划 (爬楼梯、零钱兑换)三大核心思想,展示从暴力解法推导到最优解的完整思维过程。所有代码均用 Java 实现,逐行注释,配合 LeetCode 高频真题实战。
适合人群:算法初学者、准备 LeetCode 面试者、想提升算法思维的 Java 开发者。
阅读收获 :掌握分治/递归/动态规划的核心套路,学会识别 DP 问题,形成自己的解题模板,能够独立分析 LeetCode 中等难度题目。
一、引言:算法不是刷题,是解决问题的思维
误区:算法没用?
很多开发者工作后会产生一种错觉:"我天天写 CRUD,算法根本用不上。"
确实,如果你只是简单地从数据库查数据、组装 JSON、返回给前端,确实用不到什么高深算法。但当你遇到以下场景时,算法能力直接决定了系统的上限:
| 场景 | 算法能力决定什么 |
|---|---|
| 秒杀系统 | 库存扣减的并发控制,用 Redis 原子操作还是 Lua 脚本? |
| 推荐系统 | 如何快速从百万商品中找到用户可能感兴趣的 Top 100? |
| 日志分析 | 如何在亿级日志中快速定位异常? |
| 路径规划 | 外卖骑手如何规划最短配送路线? |
真相:算法决定了系统的上限
举个例子:秒杀系统的库存扣减。
- 初级做法:先查库存,再判断,再扣减。并发一上来,超卖!
- 进阶做法 :用 Redis 的
DECR原子操作,但 Redis 挂了怎么办? - 高手做法:Redis 预扣减 + 消息队列异步落库 + 兜底方案。
这里面的每一个决策,都需要你对数据结构的性能特征 和算法的复杂度有深刻理解。
本文的目标
本文不讲所有排序算法,也不罗列 LeetCode 题目。我们要做的是:
- 理解核心思想:分治、递归、动态规划。
- 从暴力到最优:展示思维推导过程,而不是直接给答案。
- Java 实战:所有代码都用 Java 实现,逐行注释。
准备好了吗?让我们开始这场算法思维的修炼之旅。
二、排序算法:快速排序的深度剖析
2.1 思想:分治法(Divide and Conquer)
快速排序(Quick Sort)的核心思想是分治法:
将一个大问题分解为若干个小问题,分别解决,再合并结果。
类比:整理一副乱序的扑克牌。
- 普通做法:一张张找最小的,放左边(选择排序,O(N²))。
- 快排做法:随便抽一张牌作为"基准",比它小的放左边,大的放右边。然后递归整理左右两堆。
2.2 步骤:选基准、分区、递归
原始数组:[3, 6, 8, 10, 1, 2, 1]
第 1 轮:选 3 作为基准
小于 3:[1, 2, 1]
等于 3:[3]
大于 3:[6, 8, 10]
第 2 轮:递归处理 [1, 2, 1],选 1 作为基准
小于 1:[]
等于 1:[1, 1]
大于 1:[2]
第 3 轮:递归处理 [6, 8, 10],选 6 作为基准
...
最终结果:[1, 1, 2, 3, 6, 8, 10]
2.3 代码实现:手写 QuickSort
java
import java.util.Arrays;
/**
* 快速排序实现
* 时间复杂度:平均 O(N log N),最坏 O(N²)
* 空间复杂度:O(log N) 递归栈空间
*/
public class QuickSort {
/**
* 对外暴露的排序方法
* @param arr 待排序数组
*/
public static void sort(int[] arr) {
if (arr == null || arr.length <= 1) {
return; // 空数组或单元素,无需排序
}
quickSort(arr, 0, arr.length - 1);
}
/**
* 递归实现快速排序
* @param arr 数组
* @param left 左边界索引
* @param right 右边界索引
*/
private static void quickSort(int[] arr, int left, int right) {
// 终止条件:子数组只剩一个元素
if (left >= right) {
return;
}
// 分区操作:返回基准元素的最终位置
int pivotIndex = partition(arr, left, right);
// 递归排序左半部分(小于基准的元素)
quickSort(arr, left, pivotIndex - 1);
// 递归排序右半部分(大于基准的元素)
quickSort(arr, pivotIndex + 1, right);
}
/**
* 分区操作:将数组分为 <pivot, =pivot, >pivot 三部分
* @return 基准元素的最终位置
*/
private static int partition(int[] arr, int left, int right) {
// 选择最右边的元素作为基准
int pivot = arr[right];
// i 指向"小于基准"区域的下一个位置
int i = left;
// j 遍历数组,将小于基准的元素放到左边
for (int j = left; j < right; j++) {
if (arr[j] < pivot) {
// 发现小于基准的元素,交换到 i 位置
swap(arr, i, j);
i++; // 扩展"小于区域"
}
}
// 将基准元素放到正确位置(i 位置)
swap(arr, i, right);
return i; // 返回基准元素的最终索引
}
/**
* 交换数组中两个元素的位置
*/
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 测试
public static void main(String[] args) {
int[] arr = {3, 6, 8, 10, 1, 2, 1};
System.out.println("排序前:" + Arrays.toString(arr));
sort(arr);
System.out.println("排序后:" + Arrays.toString(arr));
// 输出:[1, 1, 2, 3, 6, 8, 10]
}
}
2.4 优化:三路快排(处理大量重复元素)
当数组中有大量重复元素时,普通快排会退化到 O(N²)。三路快排 将数组分为三部分:< pivot、== pivot、> pivot。
java
/**
* 三路快速排序
* 适用于有大量重复元素的场景
*/
public class ThreeWayQuickSort {
public static void sort(int[] arr) {
if (arr == null || arr.length <= 1) return;
quickSort3Way(arr, 0, arr.length - 1);
}
private static void quickSort3Way(int[] arr, int left, int right) {
if (left >= right) return;
// 随机选择基准,避免最坏情况
int randomIndex = left + (int)(Math.random() * (right - left + 1));
swap(arr, left, randomIndex);
int pivot = arr[left];
// 三路分区指针
// [left, lt) : < pivot
// [lt, gt] : == pivot
// (gt, right] : > pivot
int lt = left; // less than 的边界
int gt = right; // greater than 的边界
int i = left + 1; // 当前遍历位置
while (i <= gt) {
if (arr[i] < pivot) {
// 小于基准,交换到 lt 区域
swap(arr, i, lt);
lt++;
i++;
} else if (arr[i] > pivot) {
// 大于基准,交换到 gt 区域
swap(arr, i, gt);
gt--;
// i 不递增,因为交换过来的元素还没处理
} else {
// 等于基准,直接跳过
i++;
}
}
// 递归处理小于和大于区域(等于区域已经有序)
quickSort3Way(arr, left, lt - 1);
quickSort3Way(arr, gt + 1, right);
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
2.5 实战:利用 PriorityQueue 解决 Top K 问题
问题:从 10 亿个数字中找到最大的 100 个。
思路:维护一个大小为 K 的小顶堆,遍历所有数字:
- 如果堆不满,直接加入。
- 如果堆已满,比较当前数字与堆顶,如果更大则替换。
java
import java.util.PriorityQueue;
import java.util.Arrays;
/**
* Top K 问题:找出数组中最大的 K 个元素
*/
public class TopK {
/**
* 使用小顶堆找最大的 K 个元素
* 时间复杂度:O(N log K)
* 空间复杂度:O(K)
*/
public static int[] findTopK(int[] nums, int k) {
if (k <= 0 || nums == null || nums.length == 0) {
return new int[0];
}
// 小顶堆:堆顶是最小的元素
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int num : nums) {
if (minHeap.size() < k) {
// 堆还没满,直接加入
minHeap.offer(num);
} else if (num > minHeap.peek()) {
// 当前数字比堆顶大,替换堆顶
minHeap.poll();
minHeap.offer(num);
}
}
// 将堆中元素转为数组
int[] result = new int[minHeap.size()];
for (int i = 0; i < result.length; i++) {
result[i] = minHeap.poll();
}
return result;
}
public static void main(String[] args) {
int[] nums = {3, 2, 1, 5, 6, 4, 8, 7, 9};
int k = 3;
int[] topK = findTopK(nums, k);
System.out.println("最大的 " + k + " 个数:" + Arrays.toString(topK));
// 输出:[7, 8, 9](顺序可能不同)
}
}
三、递归与回溯:程序的"套娃"艺术
3.1 递归三要素
递归就像俄罗斯套娃,每个娃娃里面都装着一个更小的自己。写递归函数时,必须明确三个要素:
1. 终止条件:什么时候停止递归?(最小的娃娃不能再拆了)
2. 递归逻辑:如何分解问题?(当前娃娃做什么,剩下的交给下一个)
3. 状态恢复:回溯时如何恢复原状?(把娃娃重新装好)
3.2 案例 1:文件树遍历
java
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* 递归遍历文件目录
*/
public class FileTreeTraversal {
/**
* 递归列出目录下所有文件
* @param dirPath 目录路径
* @return 所有文件的完整路径列表
*/
public static List<String> listAllFiles(String dirPath) {
List<String> result = new ArrayList<>();
File dir = new File(dirPath);
if (!dir.exists() || !dir.isDirectory()) {
return result; // 终止条件:不是有效目录
}
listFilesRecursive(dir, result);
return result;
}
private static void listFilesRecursive(File dir, List<String> result) {
File[] files = dir.listFiles();
// 终止条件:空目录
if (files == null) {
return;
}
for (File file : files) {
if (file.isFile()) {
// 是文件,加入结果
result.add(file.getAbsolutePath());
} else if (file.isDirectory()) {
// 是目录,递归遍历
System.out.println("进入目录:" + file.getName());
listFilesRecursive(file, result);
System.out.println("退出目录:" + file.getName());
}
}
}
public static void main(String[] args) {
List<String> files = listAllFiles(".");
System.out.println("共找到 " + files.size() + " 个文件");
}
}
3.3 案例 2:全排列问题(回溯法)
问题 :给定数组 [1, 2, 3],输出所有排列。
思路:
- 选择一个数作为当前位的值。
- 递归处理剩下的数。
- 回溯:撤销选择,尝试其他可能。
java
import java.util.ArrayList;
import java.util.List;
/**
* 全排列问题:回溯法经典案例
*/
public class Permutations {
private List<List<Integer>> result = new ArrayList<>();
/**
* 获取数组的所有排列
*/
public List<List<Integer>> permute(int[] nums) {
List<Integer> path = new ArrayList<>();
boolean[] used = new boolean[nums.length]; // 标记哪些数字已使用
backtrack(nums, path, used);
return result;
}
/**
* 回溯函数
* @param nums 原始数组
* @param path 当前已选择的数字(路径)
* @param used 标记数组,used[i] 表示 nums[i] 是否已使用
*/
private void backtrack(int[] nums, List<Integer> path, boolean[] used) {
// 终止条件:路径长度等于数组长度,说明找到一个完整排列
if (path.size() == nums.length) {
result.add(new ArrayList<>(path)); // 必须新建一个副本!
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) {
continue; // 跳过已使用的数字
}
// 做选择:将 nums[i] 加入路径
path.add(nums[i]);
used[i] = true;
// 递归:进入下一层决策树
backtrack(nums, path, used);
// 撤销选择:回溯,恢复状态
path.remove(path.size() - 1);
used[i] = false;
}
}
public static void main(String[] args) {
Permutations solution = new Permutations();
int[] nums = {1, 2, 3};
List<List<Integer>> result = solution.permute(nums);
System.out.println("所有排列:");
for (List<Integer> perm : result) {
System.out.println(perm);
}
// 输出:
// [1, 2, 3]
// [1, 3, 2]
// [2, 1, 3]
// [2, 3, 1]
// [3, 1, 2]
// [3, 2, 1]
}
}
3.4 陷阱:栈溢出(StackOverflowError)如何避免?
递归的致命缺陷:每次递归调用都会消耗栈空间。
递归深度 10000 时:
- 每个栈帧约占用 1KB
- 总栈空间:10000 * 1KB = 10MB
- 超过 JVM 默认栈大小,抛出 StackOverflowError
解决方案:
| 方法 | 说明 |
|---|---|
| 尾递归优化 | 某些编译器/JVM 可以优化尾递归(Java 目前不支持) |
| 显式栈 | 用 Stack 或 Deque 手动模拟递归过程 |
| 限制递归深度 | 设置最大递归深度,超过则改用迭代 |
用显式栈改写文件遍历:
java
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
/**
* 使用显式栈避免递归深度过大
*/
public class FileTraversalIterative {
public static List<String> listAllFiles(String dirPath) {
List<String> result = new ArrayList<>();
File rootDir = new File(dirPath);
if (!rootDir.exists() || !rootDir.isDirectory()) {
return result;
}
// 用栈代替递归
Stack<File> stack = new Stack<>();
stack.push(rootDir);
while (!stack.isEmpty()) {
File current = stack.pop();
File[] files = current.listFiles();
if (files == null) continue;
for (File file : files) {
if (file.isFile()) {
result.add(file.getAbsolutePath());
} else if (file.isDirectory()) {
stack.push(file); // 将子目录压栈
}
}
}
return result;
}
}
四、动态规划(DP):从"爬楼梯"看最优子结构
4.1 核心思想:记住过去,避免重复
动态规划(Dynamic Programming)的本质是用空间换时间:
将大问题分解为重叠的子问题,存储子问题的解,避免重复计算。
类比:走楼梯。
假设你要爬 10 级楼梯,每次可以走 1 级或 2 级。有多少种走法?
- 暴力递归:f(10) = f(9) + f(8),然后递归计算 f(9) 和 f(8)。
- 问题:f(8) 会被计算两次,f(7) 会被计算三次...大量重复计算!
- DP 解法 :用数组
dp[i]记录爬到第 i 级的走法数,只计算一次。
4.2 案例 1:爬楼梯(斐波那契数列)
问题:有 n 级台阶,每次可以爬 1 级或 2 级,有多少种不同的方法爬到楼顶?
状态转移方程:
dp[i] = dp[i-1] + dp[i-2]
解释:
- 最后一步走 1 级:前面有 dp[i-1] 种方法
- 最后一步走 2 级:前面有 dp[i-2] 种方法
解法 1:暴力递归(超时)
java
/**
* 暴力递归:时间复杂度 O(2^N),会超时
*/
public int climbStairsRecursive(int n) {
// 终止条件
if (n <= 2) {
return n; // n=1 有 1 种,n=2 有 2 种
}
// 递归:最后一步走 1 级或 2 级
return climbStairsRecursive(n - 1) + climbStairsRecursive(n - 2);
}
解法 2:记忆化搜索(自顶向下)
java
/**
* 记忆化搜索:用数组缓存已计算的结果
* 时间复杂度:O(N),空间复杂度:O(N)
*/
public int climbStairsMemo(int n) {
int[] memo = new int[n + 1];
return climbStairsHelper(n, memo);
}
private int climbStairsHelper(int n, int[] memo) {
if (n <= 2) {
return n;
}
// 如果已经计算过,直接返回
if (memo[n] != 0) {
return memo[n];
}
// 计算并缓存结果
memo[n] = climbStairsHelper(n - 1, memo)
+ climbStairsHelper(n - 2, memo);
return memo[n];
}
解法 3:动态规划(自底向上,最优)
java
/**
* 动态规划:自底向上填表
* 时间复杂度:O(N),空间复杂度:O(1)(滚动数组优化)
*/
public int climbStairsDP(int n) {
if (n <= 2) {
return n;
}
// 只需要保存前两个状态,不需要整个数组
int prev2 = 1; // dp[i-2],即爬到第 i-2 级的方法数
int prev1 = 2; // dp[i-1],即爬到第 i-1 级的方法数
for (int i = 3; i <= n; i++) {
int current = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
prev2 = prev1; // 更新 dp[i-2]
prev1 = current; // 更新 dp[i-1]
}
return prev1;
}
4.3 案例 2:零钱兑换
问题:给定不同面额的硬币 coins 和一个总金额 amount,计算凑成总金额所需的最少硬币个数。
示例 :coins = [1, 2, 5], amount = 11
答案:11 = 5 + 5 + 1,最少 3 枚硬币。
状态定义 :dp[i] 表示凑成金额 i 所需的最少硬币数。
状态转移方程:
dp[i] = min(dp[i - coin] + 1) 对于所有 coin in coins 且 coin <= i
解释:
- 如果最后一枚硬币是 1,则 dp[i] = dp[i-1] + 1
- 如果最后一枚硬币是 2,则 dp[i] = dp[i-2] + 1
- 取所有可能中的最小值
java
import java.util.Arrays;
/**
* 零钱兑换:动态规划经典问题
*/
public class CoinChange {
/**
* 计算凑成金额所需的最少硬币数
* @param coins 硬币面额数组
* @param amount 目标金额
* @return 最少硬币数,如果无法凑成则返回 -1
*/
public int coinChange(int[] coins, int amount) {
// dp[i] 表示凑成金额 i 所需的最少硬币数
// 初始化为 amount + 1(表示无穷大,因为最多用 amount 枚 1 元硬币)
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
// 凑成金额 0 需要 0 枚硬币
dp[0] = 0;
// 自底向上填表
for (int i = 1; i <= amount; i++) {
// 尝试每一种硬币
for (int coin : coins) {
if (coin <= i) {
// 如果可以用这枚硬币,更新最小值
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 如果 dp[amount] 还是初始值,说明无法凑成
return dp[amount] > amount ? -1 : dp[amount];
}
public static void main(String[] args) {
CoinChange solution = new CoinChange();
int[] coins = {1, 2, 5};
int amount = 11;
int result = solution.coinChange(coins, amount);
System.out.println("凑成 " + amount + " 需要最少 " + result + " 枚硬币");
// 输出:凑成 11 需要最少 3 枚硬币
}
}
4.4 套路:如何识别一个问题是不是 DP?
三个特征:
| 特征 | 说明 | 示例 |
|---|---|---|
| 最优子结构 | 问题的最优解包含子问题的最优解 | 最短路径的子路径也是最短路径 |
| 重叠子问题 | 不同的决策序列会到达相同的子问题 | 爬楼梯中 f(5) 被多次计算 |
| 无后效性 | 当前决策只与状态有关,与如何到达该状态无关 | 爬到第 5 级的方法数与怎么爬到第 5 级无关 |
解题步骤:
1. 定义状态:dp[i] 或 dp[i][j] 代表什么?
2. 状态转移方程:dp[i] 与哪些状态有关?
3. 初始化:边界条件是什么?
4. 遍历顺序:按什么顺序填表?
5. 返回结果:最终答案在哪个状态?
五、LeetCode 高频真题解析
5.1 题目 1:两数之和(HashMap 应用)
题目:给定数组 nums 和目标值 target,找出和为 target 的两个数的索引。
暴力解法:双重循环,O(N²)。
优化解法:用 HashMap 存储已遍历的数,O(N)。
java
import java.util.HashMap;
import java.util.Map;
/**
* LeetCode 1: 两数之和
* 时间复杂度:O(N)
* 空间复杂度:O(N)
*/
public class TwoSum {
public int[] twoSum(int[] nums, int target) {
// key: 数字,value: 索引
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i]; // 需要找的另一个数
// 如果 complement 已经在 map 中,说明找到了
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
// 将当前数加入 map
map.put(nums[i], i);
}
return new int[0]; // 题目保证有解,这里不会执行
}
public static void main(String[] args) {
TwoSum solution = new TwoSum();
int[] nums = {2, 7, 11, 15};
int target = 9;
int[] result = solution.twoSum(nums, target);
System.out.println("索引:[" + result[0] + ", " + result[1] + "]");
// 输出:索引:[0, 1]
}
}
5.2 题目 2:无重复字符的最长子串(滑动窗口)
题目:给定字符串,找出不含重复字符的最长子串的长度。
滑动窗口思想:
- 用左右两个指针表示窗口的边界。
- 右指针向右扩展窗口,直到遇到重复字符。
- 左指针向右收缩窗口,直到没有重复字符。
- 记录窗口的最大长度。
java
import java.util.HashSet;
import java.util.Set;
/**
* LeetCode 3: 无重复字符的最长子串
* 滑动窗口经典问题
*/
public class LongestSubstring {
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) {
return 0;
}
Set<Character> window = new HashSet<>(); // 当前窗口内的字符
int left = 0; // 左指针
int maxLen = 0; // 最大长度
// 右指针遍历字符串
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 如果字符已在窗口中,收缩左边界直到没有重复
while (window.contains(c)) {
window.remove(s.charAt(left));
left++;
}
// 将当前字符加入窗口
window.add(c);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
public static void main(String[] args) {
LongestSubstring solution = new LongestSubstring();
String s = "abcabcbb";
int result = solution.lengthOfLongestSubstring(s);
System.out.println("最长无重复子串长度:" + result);
// 输出:最长无重复子串长度:3("abc")
}
}
六、总结与学习路线
6.1 算法学习的正确姿势
第一步:理解思想(最重要!)
↓ 不要急着写代码,先想清楚为什么
第二步:动手实现
↓ 自己写一遍,不要复制粘贴
第三步:刷题巩固
↓ LeetCode 按标签刷,不要随机刷
第四步:总结套路
↓ 形成自己的解题模板
6.2 核心思想回顾
| 算法思想 | 核心要点 | 经典应用 |
|---|---|---|
| 分治法 | 大问题分解为小问题,分别解决后合并 | 快速排序、归并排序 |
| 递归 | 函数调用自身,明确终止条件和递归逻辑 | 树遍历、全排列 |
| 回溯 | 试探 + 撤销,遍历所有可能解 | 八皇后、子集问题 |
| 动态规划 | 重叠子问题 + 最优子结构,用空间换时间 | 爬楼梯、背包问题 |
| 滑动窗口 | 维护一个可变大小的窗口,高效处理子数组/子串问题 | 无重复字符最长子串 |
6.3 推荐学习资源
LeetCode 刷题路线:
- 入门阶段:LeetCode Hot 100(按频率排序的高频题)
- 进阶阶段:按标签刷题(数组、链表、树、动态规划)
- 冲刺阶段:剑指 Offer(国内面试必刷)
重点题单:
| 类型 | 推荐题目 |
|---|---|
| 数组 | 两数之和、三数之和、盛最多水的容器 |
| 链表 | 反转链表、合并两个有序链表、环形链表 |
| 树 | 二叉树遍历、二叉树层序遍历、二叉搜索树验证 |
| 动态规划 | 爬楼梯、零钱兑换、最长递增子序列 |
| 回溯 | 全排列、子集、N 皇后 |
6.4 思维导图
核心算法思想
├── 排序算法
│ └── 快速排序(分治法)
│ ├── 选基准、分区、递归
│ └── 三路快排(处理重复元素)
│
├── 递归与回溯
│ ├── 递归三要素
│ │ ├── 终止条件
│ │ ├── 递归逻辑
│ │ └── 状态恢复
│ └── 经典问题
│ ├── 文件树遍历
│ └── 全排列(回溯)
│
├── 动态规划
│ ├── 核心思想
│ │ └── 记住过去,避免重复计算
│ ├── 解题步骤
│ │ ├── 定义状态
│ │ ├── 状态转移方程
│ │ ├── 初始化
│ │ └── 遍历顺序
│ └── 经典问题
│ ├── 爬楼梯
│ └── 零钱兑换
│
└── LeetCode 实战
├── 两数之和(HashMap)
└── 无重复字符最长子串(滑动窗口)
最后的话:算法不是刷题,是解决问题的思维。当你理解了一个算法的本质,你就能在合适的场景下自然地运用它。从暴力解法推导到最优解的过程,比答案本身更有价值。祝你在算法修炼之路上越走越远!
参考链接:
