首页 / 资讯中心 / 文章详情

排序算法动图与源码对照:从直觉到工程实现

排序算法动图与源码对照:从直觉到工程实现 ★ FEATURED ARTICLE
排序算法这四个字几乎是每个写代码的人绕不过去的一道坎。我见过太多人面试前把快排、归并的代码背得滚瓜烂熟真到项目里遇到一个“按多个字段排序、数据量还不小”的需求照样抓瞎。问题不在于他们没学过而在于学的时候只记住了代码长什么样没搞明白数据在内存里到底是怎么一步步挪位置的。这篇内容就是想把“动图里看到的”和“源码里写出来的”这两件事对上让你看完之后随便给一个排序场景脑子里能自动浮现出数据流动的画面手上能直接写出可用的实现。核心关键词排序算法、动图演示、源码实现会贯穿全文不管你是刚接触算法的新手还是想回头把基础夯实的老手都能从里面找到能直接抄作业的东西。1. 为什么“看动图”和“读源码”必须绑在一起学1.1 排序算法到底在解决什么问题先把话说透。排序算法的本质是把一组无序的数据按照某个约定的顺序重新排列。听起来简单但“重新排列”这四个字背后藏着两个完全不同的成本维度一是比较次数二是移动次数。前者决定了算法在数据比较昂贵时比如比较的是长字符串或者复杂对象的表现后者决定了算法在数据搬移昂贵时比如元素是很大的结构体的表现。很多人学排序只盯着时间复杂度那个大O其实那只是比较次数的渐近上界真正落到工程里移动成本和空间占用往往才是决定你选哪个算法的关键。动图的价值就在这里。一张好的排序动图能让你直观看到“哪两个元素在被比较”“哪个元素被挪到了哪里”“已经有序的部分是怎么一点点长出来的”。这些信息在纯代码里是隐性的你盯着几层嵌套循环看半天不如看三秒钟的动画来得清楚。但动图也有它的局限——它把细节抹平了你看不到边界条件怎么处理看不到循环变量怎么变化更看不到那些“差一错误”是怎么被规避的。所以动图负责建立直觉源码负责落地细节两者缺一不可。我个人的习惯是拿到一个新算法先找动图看两遍把数据流动的“骨架”记住然后关掉动图自己对着空白编辑器默写一遍源码写完再对照标准实现找差异。这个流程走下来一个算法基本就刻在脑子里了比反复读十遍教材都管用。1.2 动图看不出来的三个关键细节动图虽然直观但它天然会隐藏三类信息而这三类恰恰是手写源码时最容易翻车的地方。第一类是边界条件的处理。动图通常展示的是一组“刚刚好”的数据长度适中、没有重复元素、不会触发极端分支。但真实代码里空数组、单元素数组、全部相同的元素、已经有序的数组这些情况都得单独考虑。比如快速排序里基准值的选择如果动图里恰好选到了中间值你看着一切顺利实际写的时候随手取第一个元素当基准遇到有序数组直接退化成平方复杂度。第二类是循环不变量的维护。每个排序算法在它的主循环里都维持着某种“不变量”比如“索引 i 之前的元素都已经有序”“区间 [left, right] 之外的元素都已经就位”。动图展示的是不变量的结果但不会告诉你这个不变量是怎么在每次迭代中被保持的。源码里的那些i、j--、交换操作本质上都是在维护这个不变量。理解不了不变量你写出来的代码就是“照着抄能跑改一点就崩”。第三类是空间的开销。动图是二维平面上的动画它不会告诉你归并排序在合并阶段额外开了一个多大的辅助数组也不会告诉你递归调用栈有多深。而这些在工程里都是实打实的成本。一个在十万级数据上跑得飞快的归并排序如果递归深度没控制好照样能把栈撑爆。提示看动图时养成一个习惯——每看完一个算法的动画立刻问自己三个问题它的循环不变量是什么它在最坏情况下会多花多少空间它对重复元素友好吗能答上来说明你是真看懂了。1.3 本文的讲解路线与适用人群接下来的内容会按照“从简到繁、从直观到抽象”的顺序推进。先讲冒泡、插入、选择这三个入门级算法它们的动图最直观源码也最短适合建立最初的直觉然后进入希尔排序和归并排序这两个开始涉及“分组”和“分治”的思想是思维上的第一个台阶最后是快速排序和堆排序这两个是工程里真正用得最多的也是面试里问得最深的。每个算法我都会给出三样东西一段描述数据流动过程的文字相当于把动图翻译成文字、一份可直接运行的源码、以及若干条从实际踩坑中总结出来的注意事项。源码统一用 Python 和 Java 两种语言给出Python 版本侧重可读性方便你理解逻辑Java 版本侧重工程性边界处理和类型声明更严谨方便你直接搬到项目里。如果你只熟悉其中一种语言挑对应的看就行逻辑是相通的。2. 三个入门算法把数据流动的直觉建立起来2.1 冒泡排序最直观但也最容易被低估冒泡排序的动图大概是所有排序动画里最好认的——相邻两个元素比较大的往后挪一轮下来最大的元素像气泡一样“浮”到末尾。这个画面太经典了经典到很多人觉得它“太简单没什么好讲的”。但恰恰是它最能说明“循环不变量”这个概念。冒泡排序的核心不变量是每完成一轮外层循环数组末尾就多出一个已经就位的最大元素。第一轮结束最大的到了最后第二轮结束第二大的到了倒数第二以此类推。理解了这一点你就明白为什么内层循环的边界可以写成n - 1 - i——因为末尾的 i 个元素已经有序没必要再比较了。def bubble_sort(arr): n len(arr) for i in range(n - 1): swapped False for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True if not swapped: break return arr上面这段代码里有个swapped标志这是冒泡排序最实用的一個优化。如果某一轮下来一次交换都没发生说明整个数组已经有序可以直接提前退出。这个优化在“数组基本有序”的场景下效果极其明显——原本要跑 n 轮现在可能一两轮就结束了。动图里通常不会画这个提前退出的分支但实际写代码时一定要加上。Java 版本的结构完全一样只是交换操作要借助临时变量public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) break; } }冒泡排序的注意事项有这么几条。第一它的交换操作是“相邻交换”每次只挪一个位置所以移动次数天然就多数据量大时性能很差实际项目里基本不会用它排大数据但它是理解“稳定性”的绝佳例子——相邻交换不会改变相等元素的相对顺序所以冒泡是稳定的。第二那个swapped优化虽然好但别指望它能把最坏情况救回来逆序数组该跑 n 轮还是 n 轮。第三写内层循环时边界最容易错记住n - 1 - i这个式子i 是已经就位的元素个数。2.2 插入排序像整理扑克牌一样自然插入排序的动图看起来就像有人在整理手里的扑克牌——左手是已经排好的牌右手每次抽一张插到左手合适的位置。这个类比不是随便说说的它精确对应了算法的执行过程数组被分成“已排序区”和“未排序区”每次从未排序区取第一个元素在已排序区里从后往前找位置找到就插进去。插入排序的循环不变量是索引 i 之前的元素区间 [0, i)始终是有序的。注意这里是“有序”不是“就位”——已排序区的元素位置还可能因为后续插入而整体后移但它们之间的相对顺序是对的。这个区别很重要它解释了为什么插入排序在数据基本有序时特别快如果数组本来就接近有序每次插入只需要比较一两次就能找到位置几乎不用移动元素。def insertion_sort(arr): for i in range(1, len(arr)): key arr[i] j i - 1 while j 0 and arr[j] key: arr[j 1] arr[j] j - 1 arr[j 1] key return arr这段代码里有个细节值得单独拎出来说arr[j] key这个比较用的是严格大于而不是大于等于。这个选择直接决定了插入排序的稳定性——遇到相等元素时循环停止新元素插在相等元素的后面相对顺序得以保持。如果你手滑写成相等元素就会被插到前面去稳定性就破坏了。这种“一个符号决定性质”的地方是读源码时最该留意的。Java 版本public static void insertionSort(int[] arr) { for (int i 1; i arr.length; 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; } }插入排序在实际工程里的地位比很多人以为的高。Java 标准库里的Arrays.sort在处理小数组长度小于某个阈值通常是几十时底层用的就是插入排序。原因很简单小数组上插入排序的常数因子小、没有递归开销比快排和归并都划算。这是个很典型的“理论复杂度不代表一切”的例子——O(n²) 的算法在特定规模下能打赢 O(n log n) 的算法。注意插入排序在“数据量小”和“数据基本有序”这两个场景下表现优异但别把它用在随机的大数组上。十万级随机数据插入排序的耗时可能是快排的几百倍这个差距不是靠优化能抹平的。2.3 选择排序交换次数最少的朴素算法选择排序的动图逻辑非常清晰——每一轮在未排序区里扫一遍找到最小的那个和未排序区的第一个元素交换。它的循环不变量是索引 i 之前的元素区间 [0, i)已经就位且是整个数组中最小的 i 个元素。注意这里用的是“就位”比插入排序的“有序”更强——选择排序一旦把元素放到位置上它就再也不会动了。def selection_sort(arr): n len(arr) for i in range(n - 1): min_idx i for j in range(i 1, n): if arr[j] arr[min_idx]: min_idx j if min_idx ! i: arr[i], arr[min_idx] arr[min_idx], arr[i] return arr选择排序最大的特点是交换次数少最多 n-1 次交换。这个特性在“元素很大、交换成本很高”的场景下很有价值——比如你要排序的是一组很大的结构体每次交换都要拷贝大量内存这时候选择排序的交换次数优势就体现出来了。但它的比较次数是固定的 n(n-1)/2跟数据初始状态无关所以对“基本有序”的数据没有任何加速效果。Java 版本public static void selectionSort(int[] arr) { int n arr.length; 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) { int temp arr[i]; arr[i] arr[minIdx]; arr[minIdx] temp; } } }选择排序有个容易被忽略的问题它是不稳定的。原因在于那个“远距离交换”——把最小值换到前面时可能把某个相等元素的相对顺序打乱。举个例子数组[5a, 5b, 2]第一轮找到最小值 2和第一个位置的 5a 交换得到[2, 5b, 5a]两个 5 的相对顺序就反了。这个细节动图里通常看不出来但写代码时必须心里有数。3. 进阶算法分组与分治的思想跃迁3.1 希尔排序插入排序的“加速版”希尔排序的动图第一次看会有点懵——它不像前面几个算法那样“一个元素一个元素地处理”而是“隔几个元素跳着处理”。这恰恰是它的精髓插入排序在数据基本有序时很快那能不能先用某种方式把数据变得“基本有序”再用插入排序收尾希尔排序就是干这个的。它的做法是引入一个“增量序列”gap先按较大的 gap 把数组分成若干子序列对每个子序列做插入排序然后缩小 gap重复这个过程最后 gap 缩到 1做一次标准的插入排序。前面几轮大 gap 的排序让元素能够“大步跳跃”地接近最终位置等 gap 变小时数组已经接近有序插入排序就能发挥它的优势了。def shell_sort(arr): n len(arr) gap n // 2 while gap 0: for i in range(gap, n): key arr[i] j i - gap while j 0 and arr[j] key: arr[j gap] arr[j] j - gap arr[j gap] key gap // 2 return arr这段代码和插入排序的骨架几乎一模一样唯一的区别是把所有的1换成了gap。这个“换法”不是巧合而是因为 gap1 时它退化成标准的插入排序。理解这一点你就理解了希尔排序的本质——它是插入排序的推广插入排序是它的特例。Java 版本public static void shellSort(int[] arr) { int n arr.length; for (int gap n / 2; gap 0; gap / 2) { for (int i gap; i n; i) { int key arr[i]; int j i - gap; while (j 0 arr[j] key) { arr[j gap] arr[j]; j - gap; } arr[j gap] key; } } }希尔排序的性能高度依赖增量序列的选择。上面用的是最简单的“折半”序列n/2, n/4, ...它的最坏复杂度是 O(n²)。如果换成 Hibbard 序列1, 3, 7, 15, ...即 2^k - 1最坏复杂度能降到 O(n^1.5)换成 Sedgewick 序列还能更好。这个选择在动图里完全看不出来但对性能影响很大。实际工程里如果真要用希尔排序建议直接用 Sedgewick 序列实现起来也就多一个数组的事。提示希尔排序是不稳定的因为相同元素可能被分到不同的子序列里跨子序列的移动会打乱它们的相对顺序。如果你的场景要求稳定性别用希尔排序。3.2 归并排序分治思想的第一个样板归并排序的动图通常是这样的一条线从中间劈开左右两半各自排序然后合并。这个“劈开-排序-合并”的过程就是分治的经典三步。它的核心在于“合并”这一步——两个已经有序的子数组怎么合并成一个有序的大数组答案是双指针两个指针分别指向两个子数组的开头每次比较两个指针指向的元素小的那个放进结果数组指针后移。归并排序的循环不变量体现在合并阶段结果数组中已经放入的元素始终是两个子数组中剩余元素里最小的那些。这个不变量保证了合并的正确性。def merge_sort(arr): if len(arr) 1: return arr mid len(arr) // 2 left merge_sort(arr[:mid]) right merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): result [] i j 0 while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) i 1 else: result.append(right[j]) j 1 result.extend(left[i:]) result.extend(right[j:]) return result合并函数里的left[i] right[j]这个比较用的是小于等于这是保证稳定性的关键——当左右元素相等时优先取左边的这样左边元素就排在了右边相等元素的前面相对顺序得以保持。如果写成稳定性就没了。上面这个 Python 版本为了可读性每次递归都创建了新数组空间开销比较大。工程里更常见的写法是用一个辅助数组在原数组上做原地归并public static void mergeSort(int[] arr) { if (arr null || arr.length 2) return; int[] temp new int[arr.length]; mergeSortHelper(arr, temp, 0, arr.length - 1); } private static void mergeSortHelper(int[] arr, int[] temp, int left, int right) { if (left right) return; int mid left (right - left) / 2; mergeSortHelper(arr, temp, left, mid); mergeSortHelper(arr, temp, mid 1, right); merge(arr, temp, left, mid, right); } private static void merge(int[] arr, int[] temp, int left, int mid, int right) { 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]; } }这个 Java 版本有几个工程上的讲究。mid left (right - left) / 2而不是(left right) / 2是为了防止 left 和 right 都很大时整数溢出——虽然在这个场景下不太可能触发但这是个好习惯。辅助数组temp只在最外层创建一次递归过程中复用避免了反复分配内存的开销。合并完成后要把temp里的数据拷回arr这一步不能省。归并排序的注意事项它是稳定的只要合并时用时间复杂度稳定在 O(n log n)不管数据初始状态如何。代价是需要 O(n) 的额外空间。这个空间开销在数据量特别大时是个问题所以有些场景会用“原地归并”的变体但原地归并的实现复杂度高很多而且常数因子大实际未必更快。我的经验是除非内存真的紧张否则老老实实用辅助数组的版本简单可靠。4. 工程主力快排与堆排的实战拆解4.1 快速排序分区是灵魂基准是命门快速排序的动图是所有排序动画里最“热闹”的——两个指针从两端向中间扫遇到该交换的就交换最后基准值归位数组被分成两半。这个“分区”过程是快排的核心也是它区别于归并的关键归并是“先分后合”快排是“先分分完就基本有序了不用合”。分区的逻辑值得掰开揉碎讲。以最常用的 Lomuto 分区为例选最后一个元素作为基准值 pivot然后用一个指针 i 记录“小于 pivot 的区域”的右边界另一个指针 j 从头扫到尾遇到比 pivot 小的元素就和 i 位置的元素交换i 后移。扫完之后把 pivot 换到 i 的位置此时 i 左边的都小于 pivot右边的都大于等于 pivot。def quick_sort(arr, low0, highNone): if high is None: high len(arr) - 1 if low high: pivot_idx partition(arr, low, high) quick_sort(arr, low, pivot_idx - 1) quick_sort(arr, pivot_idx 1, high) return arr def partition(arr, low, high): pivot arr[high] i low for j in range(low, high): if arr[j] pivot: arr[i], arr[j] arr[j], arr[i] i 1 arr[i], arr[high] arr[high], arr[i] return i这段代码能跑但有个致命问题如果数组已经有序或者逆序每次选的 pivot 都是最大或最小值分区极度不平衡递归深度变成 n时间复杂度退化成 O(n²)。这就是快排的“命门”——基准值的选择。解决办法有两个一是随机选基准随机选一个位置和最后一个交换再走标准流程二是三数取中取首、中、尾三个数的中位数作为基准。工程里更常用的是三数取中因为它没有随机数生成的开销效果也足够好。public static void quickSort(int[] arr, int low, int high) { if (low high) return; int pivotIdx partition(arr, low, high); quickSort(arr, low, pivotIdx - 1); quickSort(arr, pivotIdx 1, high); } private static int partition(int[] arr, int low, int high) { int mid low (high - low) / 2; // 三数取中把中位数换到 high 位置 if (arr[low] arr[mid]) swap(arr, low, mid); if (arr[low] arr[high]) swap(arr, low, high); if (arr[mid] arr[high]) swap(arr, mid, high); swap(arr, mid, high); int pivot arr[high]; int i low; for (int j low; j high; j) { if (arr[j] pivot) { swap(arr, i, j); i; } } swap(arr, i, high); return i; } private static void swap(int[] arr, int i, int j) { int temp arr[i]; arr[i] arr[j]; arr[j] temp; }三数取中这段代码看起来有点绕其实逻辑很简单先把 low、mid、high 三个位置的值排好序让 arr[low] arr[mid] arr[high]然后把 mid 位置的值也就是中位数换到 high 位置当基准。这样基准值就不会是极端值分区更均衡。快排还有几个实战要点。第一递归到小数组时比如长度小于 10可以切换到插入排序这是 Java 标准库的做法能减少递归开销。第二快排是不稳定的分区过程中的远距离交换会打乱相等元素的顺序。第三快排的最坏空间复杂度是 O(n)递归栈深度平均是 O(log n)在数据量极大且基准选择不当时要小心栈溢出。注意写快排的递归时一定要先判断low high再递归否则会无限递归。这个判断放在函数开头做提前返回比放在递归调用处判断更清晰也更不容易漏。4.2 堆排序把数组当成一棵完全二叉树堆排序的动图可能是所有排序里最“抽象”的——它画出来的是一棵树但代码里操作的却是一个数组。理解堆排序的关键是先在脑子里建立“数组下标”和“二叉树节点”之间的映射关系下标 i 的节点左孩子是 2i1右孩子是 2i2父节点是 (i-1)/2。这个映射是堆排序的全部基础动图里那棵树其实就是数组画成了树的样子。堆排序分两步建堆和排序。建堆是把无序数组调整成一个大顶堆每个节点都大于等于它的孩子排序是反复把堆顶最大值和堆尾交换然后缩小堆的范围重新调整堆顶。这个“交换-缩小-调整”的过程动图里看得很清楚但代码里的“调整”也就是下沉操作才是难点。def heap_sort(arr): n len(arr) for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n) for i in range(n - 1, 0, -1): arr[0], arr[i] arr[i], arr[0] sift_down(arr, 0, i) return arr def sift_down(arr, i, n): while True: largest i left 2 * i 1 right 2 * i 2 if left n and arr[left] arr[largest]: largest left if right n and arr[right] arr[largest]: largest right if largest i: break arr[i], arr[largest] arr[largest], arr[i] i largest建堆的循环从n // 2 - 1开始这个起点不是随便定的——下标大于等于 n/2 的节点都是叶子节点叶子节点天然满足堆性质不需要调整。从最后一个非叶子节点开始往前调整每个节点下沉到合适位置最终整个数组就变成了堆。这个“从后往前”的顺序很关键它保证了每次下沉时孩子节点已经是合法的堆下沉操作才能正确进行。Java 版本public static void heapSort(int[] arr) { int n arr.length; for (int i n / 2 - 1; i 0; i--) { siftDown(arr, i, n); } for (int i n - 1; i 0; i--) { int temp arr[0]; arr[0] arr[i]; arr[i] temp; siftDown(arr, 0, i); } } private static void siftDown(int[] arr, int i, int n) { while (true) { int largest i; int left 2 * i 1; int right 2 * i 2; if (left n arr[left] arr[largest]) largest left; if (right n arr[right] arr[largest]) largest right; if (largest i) break; int temp arr[i]; arr[i] arr[largest]; arr[largest] temp; i largest; } }堆排序的特点是时间复杂度稳定 O(n log n)空间复杂度 O(1)原地排序而且最坏情况也是 O(n log n)不像快排会退化。但它的常数因子比快排大缓存局部性也差因为要频繁跳着访问数组所以实际跑起来通常比快排慢。它的优势场景是“内存受限”或者“要求最坏情况有保证”的场合。另外堆排序是不稳定的交换堆顶和堆尾时会打乱相等元素的顺序。5. 常见问题与排查技巧实录5.1 排序算法选型速查表选算法这件事光看复杂度不够得结合数据规模、数据特征、稳定性要求、内存限制一起看。下面这张表是我根据实际项目经验整理的覆盖了最常见的几种场景。场景特征推荐算法理由需要避开的坑数据量小几十以内插入排序常数因子小无递归开销别用快排递归开销反而拖慢数据基本有序插入排序或冒泡带优化接近 O(n)别用选择排序它不看初始状态要求稳定归并排序合并时用即可保证稳定快排、堆排、希尔、选择都不稳定内存紧张堆排序原地排序O(1) 额外空间缓存局部性差实际比快排慢通用大数据快速排序平均性能最好常数因子小必须处理基准选择防退化元素很大、交换昂贵选择排序或归并选择排序交换次数最少归并需要额外空间放指针这张表不是绝对的实际选型还要看具体数据。比如“数据量小”的阈值Java 标准库用的是 47不同版本略有差异这个数字是实测调出来的不是理论推导的。你在自己的项目里也可以做类似的基准测试找到适合自己数据特征的阈值。5.2 手写排序时最容易翻车的五个地方第一个坑是边界条件。空数组、单元素数组、两个元素、全部相同这四种情况几乎每个算法都要单独测一遍。我见过太多人写的快排在空数组上直接数组越界因为没判断low high就取了arr[high]。第二个坑是稳定性被无意破坏。最典型的是插入排序里把写成或者归并排序里把写成。这种错误不会导致结果错误排序还是对的但会破坏稳定性而且极难通过测试发现——除非你专门构造带重复元素的测试用例并检查相等元素的原始顺序。第三个坑是递归深度。快排和归并在数据量大时递归很深如果基准选择不当或者数据分布极端可能栈溢出。解决办法是快排用三数取中或随机基准归并用迭代版本或者手动维护栈。第四个坑是整数溢出。计算中点时写(left right) / 2当 left 和 right 都接近整数上限时会溢出成负数。正确写法是left (right - left) / 2。这个坑在数据量不大时不会触发但一旦触发就是灾难性的。第五个坑是交换操作的临时变量。用异或交换a ^ b; b ^ a; a ^ b看起来很酷但当 a 和 b 是同一个位置时会把它清零。老老实实用临时变量或者用 Python 的元组交换别炫技。提示测试排序算法时除了常规的随机数据一定要加上这四组空数组、单元素、全部相同、已经有序。这四组能覆盖绝大多数边界 bug。5.3 从动图到源码的对照练习方法最后分享一个我自己练排序的方法比单纯看动图或者抄代码有效得多。具体做法是找一张排序动图逐帧暂停在纸上画出每一帧对应的数组状态同时标注出当前在比较哪两个下标、哪个区间已经有序、哪个元素刚刚被移动。画完之后对照这个状态序列自己推导出下一帧应该做什么操作再和动图对照验证。这个练习做上三五个算法你对“数据流动”的直觉就建立起来了。之后再写源码脑子里会自动浮现出数组状态的变化写出来的代码也更不容易出错。我当初练快排的时候就是靠这个方法把分区过程的每个下标变化都搞清楚了后来再也没在边界上翻过车。另一个有效的练习是“反向练习”给一段排序源码自己画出它对应的动图。这个练习逼着你去理解每一行代码在数据层面做了什么比正向看动图更能暴露理解上的盲区。如果你画出来的动图和标准动图对不上那说明你对某一步的理解有偏差回头去看源码里对应的那几行往往能发现问题所在。排序算法这东西看懂了和写对了之间隔着一道坎写对了和用对了之间又隔着一道坎。动图帮你跨过第一道坎源码帮你跨过第二道坎而真正把两者结合起来反复练才能跨过第三道坎。我自己到现在每隔一段时间还会挑一个排序算法重新手写一遍不是为了记住代码而是为了保持那种“看着数据在脑子里流动”的感觉。这种感觉一旦生疏写出来的代码就容易在边界上出问题。
阅读完成 · 觉得有帮助?
咨询建站