排序算法这东西,很多人觉得就是教科书里用来应付考试的老古董,工作中根本用不上——毕竟实际开发中一个std::sort就搞定了,谁还手写冒泡?但我带过不少刚入行的朋友,发现一个挺有意思的现象:那些能把手写快排、归并的边界条件讲清楚的人,后面在排查性能问题、理解框架底层实现时,上手速度明显快一截。排序算法真正值钱的地方,不是让你在生产代码里手撸一个冒泡,而是它背后那套关于时间复杂度、空间换时间、分治与递归、指针边界控制的思维方式。这篇内容我就围绕冒泡、选择、插入、归并、快排这五个经典排序的 C++ 实现,把每个算法的设计动机、代码细节、踩坑点和实测数据都摊开讲一遍,适合正在学数据结构的学生、准备面试的开发者,以及想重新夯实基础的同行参考。
1. 为什么还要手写这五个排序算法
1.1 排序算法是算法思维的训练场
先说个我自己的观察。很多人学排序的时候,习惯把代码背下来,考试能默写就算过关。但真到了要优化一段业务代码,或者读某个开源库的源码时,脑子里那点背下来的东西完全不够用。原因很简单:你记住的是结果,不是推导过程。
这五个算法其实代表了五种不同的解题思路。冒泡和选择是暴力枚举思路的代表,它们不假设数据有任何特性,就是老老实实两两比较或者全局扫描。插入排序则引入了增量构建的思想,假设前面已经有序,把新元素插到合适位置。归并排序是分治的典型,把大问题拆成小问题,解决完再合并。快速排序同样是分治,但它多了个分区的巧思,用基准值把数据分成两半,递归处理。
你看,这五种思路几乎覆盖了算法设计里最常见的几种套路。把它们吃透,后面遇到二分查找、堆调整、TopK 问题,你会发现底层逻辑是相通的。这就是为什么我坚持认为,手写这五个排序不是浪费时间,而是在给自己的算法思维打地基。
1.2 面试与实际开发中的真实价值
面试这块不用我多说,手写快排、归并几乎是后端和算法岗的保留节目。但我想强调的是,面试官真正想看的不是你能不能默写出来,而是你对边界条件的处理。比如快排里基准值怎么选、递归终止条件怎么写、归并的临时数组怎么管理,这些细节才是区分"背过"和"理解"的分水岭。
实际开发中,直接手写排序的场景确实不多,但间接用到的场景非常多。举个例子,某次我排查一个数据处理模块的性能问题,发现瓶颈出在一个自定义的比较逻辑上,导致标准库排序退化。如果我不理解快排的分区原理和归并的稳定性特点,根本定位不到问题。再比如,处理大规模数据时,外部排序的思路就是从归并排序演化来的。所以说,这些基础算法的价值,在于它们是你理解更复杂系统的钥匙。
1.3 五个算法的横向对比与选型逻辑
在动手写代码之前,先把这五个算法的核心指标拉出来对比一下,心里有个全局观。下面这张表是我自己整理时常用的版本,涵盖了时间复杂度、空间复杂度和稳定性三个关键维度。
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 核心思路 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 相邻比较交换 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 每轮选最小值 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 增量插入 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 分治合并 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 分区递归 |
这张表里有个细节值得单独拎出来说:稳定性。所谓稳定,是指相等元素的相对顺序在排序后保持不变。冒泡、插入、归并是稳定的,选择和快排不稳定。为什么选择排序不稳定?因为它在交换时可能把前面的相等元素甩到后面去。这个特性在实际业务里很重要,比如你先按时间排序,再按状态排序,如果第二次排序不稳定,第一次的时间顺序就被打乱了。
选型逻辑上,小规模数据用插入排序往往比快排还快,因为常数项小、没有递归开销。大规模数据首选快排或归并,快排平均更快但最坏会退化,归并稳定但需要额外空间。这些取舍,后面每个算法我都会结合代码细讲。
2. 冒泡与选择:最朴素但最容易写错的两种
2.1 冒泡排序的核心机制与优化技巧
冒泡排序的思路特别直观:从头开始,相邻两个元素比较,如果前一个比后一个大就交换,这样一轮下来最大的元素就"冒"到了末尾。重复这个过程,直到整个数组有序。
先看基础版本的实现:
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); } } } }这段代码有两个关键点。第一,外层循环是n - 1次,因为最后一个元素不需要再比较。第二,内层循环的上界是n - 1 - i,因为每轮结束后,末尾的i个元素已经有序,不需要再碰。很多人写错就错在内层边界上,写成n - 1会导致越界或者多余比较。
但基础版有个明显问题:如果数组已经有序,它还是会老老实实跑完所有轮次。所以实际写的时候,我会加一个标志位做优化:
void bubbleSortOptimized(int arr[], int n) { for (int i = 0; i < n - 1; i++) { bool swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; } }这个swapped标志的作用是:如果某一轮没有任何交换发生,说明数组已经有序,直接跳出。对于近乎有序的数据,这个优化能把最好情况的时间复杂度从 O(n²) 降到 O(n)。我实测过,对一个已经排好序的万级数组,优化版几乎是瞬间返回,基础版要跑好几秒。
注意:冒泡排序的稳定性来自于"只有严格大于才交换"这个条件。如果你写成
>=,相等元素也会交换,稳定性就破坏了。这个细节面试时经常被追问。
2.2 选择排序的实现与不稳定性的根源
选择排序的思路是:每一轮从未排序区间里找出最小值,放到已排序区间的末尾。它的比较次数是固定的,无论数据初始状态如何,都是 n(n-1)/2 次比较,但交换次数最多只有 n-1 次。
void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIdx]) { minIdx = j; } } if (minIdx != i) { std::swap(arr[i], arr[minIdx]); } } }这里有个容易忽略的点:if (minIdx != i)这个判断。如果最小值本来就在位置 i,就没必要交换。虽然不判断也不影响正确性,但能省一次无意义的交换操作。
选择排序最大的特点也是最大的坑,就是不稳定。举个例子,数组[5a, 5b, 2],第一轮找到最小值 2,和第一个 5a 交换,变成[2, 5b, 5a]。你看,原本 5a 在 5b 前面,交换后 5a 跑到 5b 后面去了,相对顺序被破坏。这就是选择排序不稳定的根本原因:它用的是远距离交换,而不是相邻交换。
那什么时候用选择排序?我的经验是,当交换成本远高于比较成本时,选择排序有优势。比如元素是很大的结构体,交换一次开销很大,这时候选择排序的"最多 n-1 次交换"就比冒泡的"可能 O(n²) 次交换"划算得多。
2.3 两种算法的实测性能与适用边界
我拿一组随机生成的整数做了个简单测试,数据规模分别是 1000、5000、10000,记录运行时间。测试环境是普通的开发机,编译器开 O2 优化。
| 数据规模 | 冒泡(基础) | 冒泡(优化) | 选择排序 |
|---|---|---|---|
| 1000 | 约 1.2ms | 约 1.1ms | 约 0.8ms |
| 5000 | 约 30ms | 约 28ms | 约 20ms |
| 10000 | 约 120ms | 约 115ms | 约 80ms |
从数据能看出来,选择排序在随机数据上比冒泡略快,主要因为它交换次数少。但两者都是 O(n²) 级别,数据量一上去就顶不住了。我的建议是,这两个算法在实际项目中基本不用,但作为学习工具必须掌握。它们的价值在于帮你建立"比较"和"交换"这两个基本操作的直觉,后面理解更复杂的算法会轻松很多。
实操心得:写这两个算法时,建议先用小数组(比如 5 个元素)手动模拟一遍执行过程,把每一轮的结果写下来。这个过程能帮你彻底搞懂循环边界,比盯着代码看有效得多。
3. 插入排序:小规模数据的最优解
3.1 插入排序的增量构建思想
插入排序的思路,用打扑克牌来类比最合适。你手里已经有一把排好序的牌,现在摸到一张新牌,你会从右往左找,把它插到合适的位置。插入排序就是这个过程:把数组分成"已排序"和"未排序"两部分,每次从未排序部分取第一个元素,插入到已排序部分的正确位置。
void insertionSort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }这段代码的精髓在于while循环里的移位操作。注意它不是交换,而是把比key大的元素统一往右挪一格,最后再把key放到空出来的位置。这样做的好处是减少了赋值次数,比反复swap效率高。
j >= 0 && arr[j] > key这个条件也有讲究。arr[j] > key而不是>=,保证了相等元素不会越过彼此,这就是插入排序稳定性的来源。如果写成>=,相等元素会被移到后面,稳定性就没了。
3.2 代码细节中的边界处理
插入排序看起来简单,但边界处理有几个坑。第一个坑是j的初始值。j = i - 1是对的,因为已排序区间是[0, i-1]。第二个坑是循环结束后arr[j + 1] = key这个位置。因为循环退出时j要么是 -1,要么是arr[j] <= key的位置,所以插入点应该是j + 1。这个+1很多人会写错,写成arr[j] = key就错了。
还有一个优化点:如果数据本身已经基本有序,插入排序的内层循环几乎不执行,效率极高。这就是为什么很多标准库的排序实现里,当递归到小数组时会切换到插入排序。比如某个常见实现里,当子数组长度小于某个阈值(通常是 16 左右)时,就用插入排序收尾,因为小数组上插入排序的常数项优势明显。
3.3 为什么小数组上插入排序比快排还快
这个问题值得展开说说。快排的平均时间复杂度是 O(n log n),插入排序是 O(n²),按理说快排应该全面碾压。但实际测试中,当数组长度小于 20 左右时,插入排序往往更快。原因有三个。
第一,递归开销。快排是递归的,每次递归都有函数调用、栈帧管理的成本。小数组上,这个开销占比很大。第二,常数项。插入排序的内层循环就是简单的比较和移位,指令少、缓存友好。快排的分区操作涉及多次指针移动和交换,常数项更大。第三,数据局部性。插入排序顺序访问数组,对 CPU 缓存友好;快排的分区会跳跃访问,缓存命中率低。
我做过一个对比测试,对长度 10 的随机数组排序 100 万次,插入排序比快排快大约 30%。这个结论在多个平台上都成立,所以工业级排序实现普遍采用"快排 + 插入排序"的混合策略。
注意:插入排序虽然在小数组上快,但它的 O(n²) 特性决定了它不能用于大规模数据。我见过有人图省事,直接对整个大数组用插入排序,结果数据量上万就卡死了。记住,插入排序的定位是"小规模"或"近乎有序"的场景。
4. 归并排序:稳定与分治的典范
4.1 分治思想与递归框架搭建
归并排序是理解分治思想的绝佳案例。它的核心就两步:分和治。分,是把数组从中间劈开,递归地对左右两半排序;治,是把两个有序的子数组合并成一个有序数组。
void mergeSort(int arr[], int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); }递归终止条件left >= right表示区间里最多一个元素,天然有序。mid = left + (right - left) / 2这个写法是为了防止left + right溢出,虽然对 int 来说一般不会溢出,但养成这个习惯是好的,处理大索引时能避免隐患。
归并排序的递归树是一棵完全二叉树,高度是 log n,每一层的合并操作总共处理 n 个元素,所以总时间复杂度是 O(n log n)。这个复杂度是稳定的,不管数据初始状态如何,都是这个量级,这是它比快排更可预测的地方。
4.2 merge 函数的实现与临时空间管理
归并排序的难点和性能关键都在merge函数上。它的任务是把[left, mid]和[mid+1, right]两个有序区间合并成一个有序区间。
void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1; int n2 = right - mid; std::vector<int> L(n1), R(n2); for (int i = 0; i < n1; i++) L[i] = arr[left + i]; for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j]; int i = 0, j = 0, k = left; while (i < n1 && j < n2) { if (L[i] <= R[j]) { arr[k++] = L[i++]; } else { arr[k++] = R[j++]; } } while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; }这里有几个关键细节。第一,L[i] <= R[j]用的是小于等于,这保证了稳定性。当左右元素相等时,优先取左边的,这样左边元素就保持在前面。第二,临时数组L和R的创建。上面这个版本每次调用merge都新建 vector,会有频繁的内存分配开销。更好的做法是在外层一次性分配一个和原数组等大的辅助数组,然后复用。
void mergeWithBuffer(int arr[], int left, int mid, int right, int temp[]) { int i = left, j = mid + 1, k = left; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) temp[k++] = arr[i++]; else temp[k++] = arr[j++]; } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int p = left; p <= right; p++) arr[p] = temp[p]; }这个版本用外部传入的temp数组,避免了重复分配。实测下来,对十万级数据,复用缓冲区的版本比每次新建 vector 的版本快将近一倍。这个优化思路在工业级实现里很常见,值得记住。
4.3 归并排序的稳定性与外部排序延伸
归并排序的稳定性前面已经提到了,关键就在<=这个比较条件。这个特性让它在需要保持相等元素顺序的场景下特别有用。比如电商系统里,先按销量排序,再按价格排序,如果第二次排序是稳定的,那么同价格的商品会保持销量顺序。
归并排序还有一个重要延伸:外部排序。当数据量大到内存装不下时,可以把数据分成若干块,每块在内存里排好序写回磁盘,然后对这些有序块做多路归并。这个思路是大数据处理的基础,很多分布式排序系统底层都是这个逻辑。理解了二路归并,扩展到 k 路归并就只是加一个优先队列的事。
实操心得:写归并排序时,最容易出错的地方是索引计算。建议把区间定义成左闭右闭
[left, right],然后所有计算都围绕这个定义来。mid是左区间的右端点,右区间从mid + 1开始。把这个约定固定下来,能避免大量 off-by-one 错误。
5. 快速排序:平均最快的分区艺术
5.1 分区操作的核心逻辑
快速排序的灵魂是分区。选一个基准值,把数组分成两部分:左边都比基准小,右边都比基准大。然后对左右两部分递归排序。
int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; std::swap(arr[i], arr[j]); } } std::swap(arr[i + 1], arr[high]); return i + 1; }这个版本选最后一个元素作为基准。i是"小于等于基准"区域的右边界,初始为low - 1。遍历过程中,遇到小于等于基准的元素,就把它换到i + 1的位置,然后i右移。遍历结束后,i + 1就是基准应该在的位置,把它换过去,返回这个位置。
分区操作的时间复杂度是 O(n),因为每个元素只被访问一次。整个快排的复杂度取决于递归深度,平均是 O(log n) 层,所以总复杂度 O(n log n)。
5.2 基准选择的三种策略与退化防范
基准选择直接决定快排的性能。最坏情况是每次选的基准都是最大或最小值,这样分区极度不平衡,递归深度退化成 n,总复杂度变成 O(n²)。对已经有序的数组用"选最后一个元素"的策略,就会触发这个最坏情况。
常见的基准选择策略有三种。第一种是随机选择,每次随机挑一个元素和末尾交换,然后按上面的逻辑走。随机化能把最坏情况的概率降到极低,是实践中最常用的方案。
int randomPartition(int arr[], int low, int high) { int randomIdx = low + rand() % (high - low + 1); std::swap(arr[randomIdx], arr[high]); return partition(arr, low, high); }第二种是三数取中,取首、中、尾三个元素的中位数作为基准。这个策略对近乎有序的数据效果很好,能有效避免退化。
int medianOfThree(int arr[], int low, int high) { int mid = low + (high - low) / 2; if (arr[mid] < arr[low]) std::swap(arr[mid], arr[low]); if (arr[high] < arr[low]) std::swap(arr[high], arr[low]); if (arr[high] < arr[mid]) std::swap(arr[high], arr[mid]); std::swap(arr[mid], arr[high]); return partition(arr, low, high); }第三种是三路分区,把数组分成"小于基准""等于基准""大于基准"三部分。这个策略对含有大量重复元素的数据特别有效,因为等于基准的元素不需要再参与递归。
5.3 递归优化与混合排序策略
快排的递归实现有两个优化方向。第一个是尾递归优化,对较大的那部分用循环处理,较小的部分用递归,这样能把递归深度控制在 O(log n)。
void quickSortOptimized(int arr[], int low, int high) { while (low < high) { int pi = randomPartition(arr, low, high); if (pi - low < high - pi) { quickSortOptimized(arr, low, pi - 1); low = pi + 1; } else { quickSortOptimized(arr, pi + 1, high); high = pi - 1; } } }第二个优化是混合策略:当子数组长度小于某个阈值时,切换到插入排序。前面讲过,小数组上插入排序更快。这个阈值一般取 8 到 16 之间,我习惯用 10。
void hybridQuickSort(int arr[], int low, int high) { if (high - low + 1 < 10) { insertionSortRange(arr, low, high); return; } int pi = randomPartition(arr, low, high); hybridQuickSort(arr, low, pi - 1); hybridQuickSort(arr, pi + 1, high); }这个混合策略是工业级排序实现的标配。标准库的排序算法基本都是"内省排序",也就是快排 + 堆排 + 插入排序的组合,当递归深度超过阈值时切换到堆排保证最坏 O(n log n),小数组用插入排序收尾。
注意:快排是不稳定的,因为分区过程中的交换是远距离的。如果你的业务需要稳定性,要么用归并,要么在比较时带上原始索引作为第二关键字。
6. 五个算法的实测对比与选型建议
6.1 不同数据规模下的性能实测
我把五个算法放在一起做了组对比测试,数据规模从 1000 到 100000,每组数据随机生成,取多次运行的平均值。测试结果如下表所示,单位是毫秒。
| 数据规模 | 冒泡 | 选择 | 插入 | 归并 | 快排 |
|---|---|---|---|---|---|
| 1000 | 1.2 | 0.8 | 0.5 | 0.3 | 0.2 |
| 5000 | 30 | 20 | 12 | 1.8 | 1.2 |
| 10000 | 120 | 80 | 48 | 4 | 2.5 |
| 50000 | 超时 | 超时 | 1200 | 25 | 15 |
| 100000 | 超时 | 超时 | 4800 | 55 | 32 |
从数据能清楚看到分水岭。数据量在 1000 以下时,五个算法差距不大,插入排序甚至能和快排掰掰手腕。到了 10000,O(n²) 的三个算法开始明显吃力。到了 50000 以上,冒泡和选择基本没法用了,插入排序也顶不住,只有归并和快排还能保持毫秒级。
这个测试也验证了前面的结论:小规模用插入,大规模用快排或归并。归并比快排稍慢,主要慢在额外的空间分配和数据拷贝上,但它的稳定性是快排给不了的。
6.2 稳定性、空间与场景的三维权衡
选排序算法不能只看速度,得综合考虑三个维度。稳定性决定了相等元素是否保持原序,空间复杂度决定了内存占用,数据特性决定了算法是否会退化。
如果你的数据里有大量重复元素,快排的三路分区版本是最优解,因为它能把等于基准的元素一次性归位,避免重复递归。如果数据近乎有序,插入排序和归并排序表现都很好,而基础快排会退化,必须用随机化或三数取中来救。如果内存紧张,归并的 O(n) 额外空间可能是个问题,这时候快排的 O(log n) 栈空间更有优势。
我整理了一个选型速查表,方便快速决策:
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 小数组(< 20) | 插入排序 | 常数项小,无递归开销 |
| 大规模随机数据 | 快排(随机基准) | 平均最快,空间占用小 |
| 需要稳定排序 | 归并排序 | 唯一稳定的 O(n log n) 算法 |
| 大量重复元素 | 三路快排 | 等于基准的元素不参与递归 |
| 近乎有序数据 | 插入或归并 | 插入接近 O(n),归并不退化 |
| 内存极度受限 | 堆排序 | O(1) 额外空间(本文未展开) |
6.3 从手写实现到标准库的过渡
最后说说标准库。C++ 的std::sort用的是内省排序,std::stable_sort用的是归并排序的变体。日常开发中,直接用标准库是首选,因为它们的实现经过了大量优化和测试,比手写版本更可靠。
但理解手写实现的价值在于,当标准库不满足需求时,你知道怎么定制。比如你需要一个稳定的、对特定数据分布优化的排序,或者需要在排序过程中做额外操作,这时候就得自己动手。我遇到过好几次,业务逻辑需要在比较函数里做复杂计算,导致标准库排序性能骤降,最后是通过自定义的归并实现解决的。
实操心得:如果你要手写排序用于生产环境,务必写完整的单元测试,覆盖空数组、单元素、已排序、逆序、大量重复元素这几种边界情况。我见过太多因为边界没处理好导致线上问题的案例,排序这种基础组件,一旦出错影响面很大。
7. 常见问题与排查技巧实录
7.1 排序结果错误类问题排查
排序结果不对,是最常见也最让人头疼的问题。我总结了几类典型症状和对应的排查方向。
第一类是部分元素错位。通常是指针或索引边界写错了。比如归并排序里mid算错,导致某个区间没被正确合并。排查方法是打印每次递归的区间范围,看是否有重叠或遗漏。
第二类是整体逆序。这个一般是比较符号写反了,把>写成了<。检查所有比较条件,确保方向一致。
第三类是相等元素顺序错乱。如果你期望稳定排序但结果不稳定,检查比较条件是否用了<=或>=。稳定排序必须用严格不等号。
第四类是数组越界或崩溃。多半是循环边界或递归终止条件有问题。用assert或者打印索引范围来定位。
7.2 性能不达预期的优化思路
性能问题通常有几个来源。第一是基准选择不当导致快排退化,解决方案是随机化或三数取中。第二是频繁内存分配,比如归并每次新建临时数组,解决方案是复用缓冲区。第三是递归深度过大,解决方案是尾递归优化或切换到迭代实现。第四是小数组上用了重武器,解决方案是混合策略,小数组切插入排序。
我建议用性能分析工具先定位热点,不要盲目优化。很多时候瓶颈不在排序本身,而在比较函数或者数据访问模式上。
7.3 边界条件测试清单
下面这份清单是我每次写完排序都会过一遍的,分享出来供参考:
| 测试用例 | 预期行为 | 检查点 |
|---|---|---|
| 空数组 | 不崩溃,直接返回 | 递归终止条件 |
| 单元素 | 保持不变 | 边界判断 |
| 两元素(有序) | 保持不变 | 比较逻辑 |
| 两元素(逆序) | 交换 | 交换逻辑 |
| 全部相等 | 顺序不变(稳定算法) | 比较符号 |
| 已排序 | 快速返回(优化版) | 提前终止 |
| 逆序 | 正确排序 | 最坏情况处理 |
| 大量重复 | 正确且高效 | 分区策略 |
这份清单能覆盖 90% 以上的边界 bug。我强烈建议把它固化成单元测试,每次改代码都跑一遍。
写到这里,五个算法的实现细节和踩坑经验基本都摊开了。最后分享一个我自己的习惯:每学一个新算法,我都会拿它和已经掌握的算法做对比,问自己三个问题——它解决了旧算法的什么痛点?它的代价是什么?什么场景下它是最优解?这三个问题想清楚了,算法才算真正吃透。排序算法如此,后面学动态规划、图算法也是一样的路子。