资讯动态

堆排序详解:从二叉堆到TopK,掌握建堆与下沉核心原理

发布时间:2026/10/17 17:04:22 来源:尧图企业网站定制
堆排序这门手艺属于那种“课本上一看就懂面试一写就懵工作三年基本用不上但真到用的时候又绕不开”的算法。说出来有点尴尬我最早学堆排序的时候背了建堆和调整的模板考试能过但一直说不清“为什么建堆是 O(n)”这个结论更别提在纸上手写一遍不出错了。后来自己在实际项目里要用堆处理海量数据的 TopK 问题才逼着把它从头彻底摸了一遍。这篇文章就围绕堆排序展开从二叉堆的底层结构说起再到上浮下沉两个原子操作然后拆开建堆和排序主流程给出可直接复现的代码最后聊聊复杂度分析和它在真实系统里的定位。无论你是准备面试、刷算法题还是想在工程里用堆解决优先级调度、TopK 这类问题这篇都值得看完——我会把你可能踩的坑也一并指出来。1. 先想清楚堆排序到底凭什么值得学排序算法那么多快排在多数通用场景里才是默认选择归并排序有稳定性这个加分项插入排序在小规模数据下简单暴力还有奇效。堆排序夹在中间性能快不过快排稳定性也谈不上那它存在的意义是什么我的答案是三个关键词最坏情况不退化、原地排序、结构复用。快排最怕碰上糟糕的划分一旦每次选的主元都很偏时间复杂度会掉到 O(n²)堆排序没有这个毛病不管输入是顺序、逆序还是乱序它的比较次数上下浮动其实不大永远稳定在 O(n log n) 这个量级。归并排序虽然稳定但需要 O(n) 的额外空间来合并数组堆排序直接在原数组上倒腾额外空间 O(1)对内存敏感的场景非常友好。更重要的是堆排序的背后是二叉堆这个数据结构而二叉堆在工程里的出场率比堆排序本尊高得多——优先级队列、定时器、TopK、图算法里的最短路全都在用堆。把堆排序拆开看它由两个阶段组成第一阶段把原始数组收拾成一个大顶堆或小顶堆第二阶段反复取出堆顶元素放在数组末尾同时把剩下的元素重新调整成堆。第二阶段的本质是“执行了 n 次删除堆顶的操作”。这个套路你可能觉得很眼熟。没错堆排序在宏观上和选择排序很像每次挑一个当前最大或最小的元素放到最终位置。但是选择排序找最大值需要扫描整个区段每次 O(n)堆排序利用二叉堆的性质把“找最大值”的操作压到了 O(log n)整体复杂度于是从 O(n²) 降到了 O(n log n)。要真正理解堆排序就不能把堆只当成一棵画在纸上的树。接下来从它的底层结构说起把最关键的几个公式和操作彻底讲透。1.1 二叉堆不是二叉树那么简单堆的数学定义是一棵完全二叉树且每个父节点的值都大于等于或小于等于它的子节点。大部分文章会画两个三角树来展示大顶堆和小顶堆画完就结束了但真正动手写代码的时候堆是用数组存的因为完全二叉树可以连续排列不需要任何指针。这种存储方式给每个节点安排了一个下标。数组从 0 开始的话对于下标为i的任意节点它左孩子的下标是2*i 1右孩子的下标是2*i 2它的父节点下标是(i-1)/2后向下取整。比如一个长度为 10 的数组最后一个元素的下标是 9它的父节点是下标(9-1)/2 4。这几个公式就是堆排序全部代码的骨架任何操作都脱不开它们。我刚开始学的时候总记混这条公式后来找到一个很笨但有效的记忆方式把二叉树和数组下标对应关系想象成电影院座位。父节点坐在i号位它的两个孩子一定坐在更靠后的区域分别是2i1和2i2号位数组从 0 开始计数的情况下。因为完全二叉树永远是“从左往右、从上往下”填的正因为有这个约束才可能把一个二维的树结构压进一维数组。1.2 大顶堆、小顶堆和排序方向的关系实现堆排序前你要先选一个方向。如果用大顶堆堆顶就是整个数组里的最大值每次把堆顶“扔”到数组末尾结束之后数组是从小到大排列的。如果用小顶堆堆顶是最小值扔到末尾之后得到的是从大到小的序列。这个选择看起来很简单但实践中经常有人做反。我的建议是默认你就写大顶堆配合升序排列因为堆排序通常默认的期望输出是升序这样可以少绕一层。你在很多教科书上看到的HeapSort实现内部用的都是构建大顶堆后反复取堆顶的操作这也成了社区里的主流写法。2. 上浮与下沉堆的两个原子操作堆的一切操作包括建堆、插入、删除堆顶、调整最终都归结为两个基础动作上浮sift-up和下沉sift-down。理解这两个动作比死记硬背整个排序流程重要得多因为堆排序的代码就是在恰当的地方反复调用同一个下沉动作。2.1 上浮把“个子矮的家长”往上提上浮通常用在插入场景往堆尾加入一个新元素此时它可能比自己的祖先更大大顶堆的情形需要一路向上比较并交换直到它找到一个合适的层级。假设大顶堆里有[10, 8, 7, 6, 5]现在插入一个9先把它放到数组尾部。它的父节点是89 比 8 大交换再上一层父节点是109 不大于 10停止。上浮操作的复杂度与树的层高有关因为每上升一层只做一次比较和可能的交换二叉堆的高度始终不超过⌊log2 n⌋所以上浮一次是 O(log n)。这个动作很像员工空降进一个按级别排好的公司新人先坐到最底的工位发现自己比直属上司级别还高就跟上司换位置一路往上升直到遇到一个真正比自己级别高的人为止。2.2 下沉把“扶不起的堆顶”往下放下沉的触发场景是指定位置的元素不符合堆的性质需要从上往下调整。堆排序过程中最常用的就是它比如把堆顶换成了数组尾部的某个小元素这个小元素很可能比自己的子节点还小于是它需要一路“滑”到合适的位置去。具体过程是从当前节点开始找出它的左孩子和右孩子。在两孩子中挑一个更大的大顶堆场景和当前节点比。如果当前节点比那个更大的孩子小就交换位置然后继续在新位置上重复。直到当前节点没有孩子或者它大于等于所有孩子停止。这个操作同样只沿一条路径向下走每层一次比较加一次交换耗时 O(log n)。有一点我提醒过很多回下沉时比较的顺序是先选左右孩子里的大值再拿这个大值和父节点比。很多初学者写反了写成“父亲先和左孩子比再和右孩子比”这样会导致堆的性质恢复得不彻底——你可能把父亲换下去了但换上来的是相对小的那个孩子堆顶依然不够大。2.3 用 Python 来给下沉动作“定型”我们从一开始就把下沉的代码写清楚后面的建堆和排序都直接复用它。以下是大顶堆版本的下沉def sift_down(arr, n, i): arr: 数组 n: 当前堆的有效长度注意不是整个数组的长度 i: 需要下沉的节点下标 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表示的是堆的“有效边界”排序过程中数组末尾那些已经放好最终元素的位置不应该再参与堆调整如果不把n传进去控制边界很容易把已经排序好的元素又重新搅进堆里。第二外面传进来的i不必是堆顶下沉函数可以作用在任意节点上后面的建堆阶段就是靠这一点从后往前批量调用它。3. 建堆阶段最常见的认知偏差为什么明明是“预处理”反而更便宜堆排序的第一阶段是建堆。你可能会想把 n 个元素插进堆里每一个都要 O(log n)总共不应该是 O(n log n) 吗这本来是输入文章里那个“建堆 O(n)”结论最劝退人的地方。答案其实藏在建堆的方式上——不是从空堆开始逐个插入那是上浮的路线确实是 O(n log n)而是把整个数组当成一棵还没整理好的完全二叉树然后从最后一个非叶子节点开始做下沉。3.1 从最后一个非叶子节点向前推进为什么能成立给定一个长度的数组最后一个非叶子节点的下标是n//2 - 1数组从 0 开始时。例如n10最后一个非叶子节点就是下标4。为什么因为最后一个元素的下标是 9它的父节点(9-1)//2正好是4。比 4 更大的下标都是叶子节点叶子节点本身没有孩子天然满足堆的性质不需要调整。从i n//2 - 1开始往前逐个执行下沉def build_max_heap(arr): n len(arr) for i in range(n // 2 - 1, -1, -1): sift_down(arr, n, i)这样工作的原理是保证每个子树都是堆然后不断向上合并。当处理到某个节点时它的左右子树其实已经是合法的堆了所以只需要让这个节点一路下沉到合适位置整棵以它为根的子树就变成合法堆。从底向上每一层都能依赖已经调整好的下一层。举个例子。数组[3, 5, 9, 6, 8, 7, 1]长度 7最后一个非叶子下标是7//2 - 1 2对应元素9。先处理 9它的孩子是7和19 已经是最大值不需要动再处理下标 1 的节点5它的孩子是6和88 更大于是 5 和 8 交换数组变成[3, 8, 9, 6, 5, 7, 1]最后处理下标 0 的3它的左右孩子分别是8和99 最大3 和 9 交换随后 3 来到下标 2 的位置此时它的孩子7和1中 7 更大继续交换数组变成[9, 8, 7, 6, 5, 3, 1]。可以看到一趟下来大顶堆已经形成过程里只调用了三次下沉。3.2 为什么总代价是 O(n)一层一层算别只背结论这个结论让人迷惑的点在于明明有 n/2 个节点要调用下沉每次下沉看起来要跑 O(log n)相乘自然像 O(n log n)。但注意只有根节点下沉时才会跑满 log n 层。上层的节点数量极少越接近底层的节点数量越多而下沉能走的路越短。两个趋势刚好抵消。我把代价拆开算给读者看。二叉树从根开始第 k 层的节点数量是 2^k这些节点下沉时最多移动的层数是log2 n - k。总的操作量近似等于根层1 × log n第二层2 × (log n - 1)第三层4 × (log n - 2)……底层附近n/2 × 1把这个求和整理出来结果是 n 量级。数学上常见的结果是交换总次数上界大约是2n所以建堆部分的时间复杂度就是 O(n)而不是大多数人不经计算猜测的 O(n log n)。如果你想要一个更直观的记忆方式可以这样理解高层节点数量少下沉路长低层节点数量多下沉路短。把这两种情况相乘加总每层贡献的量级都差不多而层数加起来就是一棵树的总节点数于是总代价反而被压到了线性。3.3 建堆为什么不用上浮路线顺手说一下既然自底向上的下沉能做到 O(n)为什么自顶向下逐个插入的上浮只做到 O(n log n)因为自顶向下插入时最坏情况下数组尾部每个新增元素都要一路浮到根附近几乎每个元素都走了 O(log n) 的长路径而且这种路线从第 1 个元素就开始“较真”完全没必要。建堆的正确打开方式是“先放任不管再统一整理”这和你整理一堆散在地上的书是一个道理从中间某本书开始把后面的顺手归位比一本一本地从头摆放要快得多。4. 排序主体不断摘掉堆顶把数组“倒着填满”建好大顶堆之后数组里的最大值已经坐在了 0 号位。排序阶段的主体操作异常简单把堆顶元素和当前堆的最后一个元素交换然后堆的有效长度减一再对新堆顶做一次下沉。重复这个过程直到堆的有效长度变成 1。4.1 为什么是交换而不是直接挪走直接拿走最大值很简单但数组里留了个空位后续没法原地继续。交换的方法是把最大值放到数组真正的尾部去尾部元素跑上来顶替堆顶位置然后这片区域彻底退出堆的管辖。这样一来所有“交换—缩小堆范围—下沉”的操作都发生在同一个数组上全程不申请额外空间这就是堆排序原地性得以成立的根基。排序部分的核心代码def heap_sort(arr): n len(arr) build_max_heap(arr) for end in range(n - 1, 0, -1): arr[0], arr[end] arr[end], arr[0] sift_down(arr, end, 0) return arr注意这里的细节每次end减一传给下沉函数的n是当前堆的长度end而不是原始的数组长度。因为下标end到n-1这段区间已经存放了排好的最大元素序列堆排序不会再去触碰它们。用一个例子把整个排序过程走一遍。数组还是[3, 5, 9, 6, 8, 7, 1]前面建堆后变成了[9, 8, 7, 6, 5, 3, 1]。交换 9 和 1堆范围变成前 6 个元素对堆顶 1 做下沉数组变成[8, 6, 7, 1, 5, 3, 9]。交换 8 和 3堆范围缩到前 5 个堆顶 3 下沉变成[7, 6, 3, 1, 5, 8, 9]。交换 7 和 5下沉堆顶 5得到[6, 5, 3, 1, 4, 7, 8, 9]这里举例数组中本来没有 4纯示意。 实际操作到此步时数组后段会逐渐形成有序区前段仍然是一个大顶堆。最终数组全部耗尽时序列已经是升序排列。这个“反复摘堆顶”的过程本质上就是把数组后面预留的区域从后往前填。你在脑子里的画面应该是左边一个在不断变小的堆右边一个在持续变长的有序区。4.2 稳定性问题堆排序为什么被永久钉在“不稳定”的标签上这一点几乎所有排序算法比较文章都会提但大多只是给个结论。我想用更直白的话解释所谓稳定排序是指相等元素的相对顺序在排序前后保持不变。堆排序破坏了这一性质的原因在于跳跃式交换。建堆和排序过程中元素的移动动辄跨越数组的若干个位置而不是像插入排序那样挨个平移。例如数组里有两个值相同的元素a和ba原本在b前面堆排过程中a和b可能因为对应的节点位置不同先后被交换到完全不同的方向最终顺序无法保证。举一个具体的触发器。数组[5, 5, 1]两个 5 的下标分别是 0 和 1。构建大顶堆时下标 0 的 5 已经满足大顶堆性质不用调整。排序阶段第一次操作交换 0 号位的大顶堆顶元素第一个 5和数组末尾的 1数组变成[1, 5, 5]。第二个 5 留在了中间位置第一个 5 跑到了末尾它们的相对顺序翻转过来了。这就是一个教科书级别的“不稳定”实例初始时下标 0 的 5 在 1 的 5 前面排序结束后相同键的两个 5原来靠前的反而排到了后面。4.3 和选择排序的对比为什么它叫高级选择排序如果你坐下来观察堆排序的宏观行为会发现每一轮它都固定一个当前堆中最大元素的最终位置这和选择排序按轮次挑选最小值或最大值放进有序区的方式在宏观上没有任何区别。区别只在于“如何找到下一轮的最大值”。选择排序在待排序区线性扫描找最大值需要 O(n)整个排序自然是 O(n²)。堆排序用堆的特性把“查找最大值”优化成了 O(log n)代价是前期多付一次 O(n) 的建堆成本。正因如此有人把堆排序称作“选择排序的堆优化版”我觉得这个说法确实点到了本质。5. 三种语言的完整实现与常见写法陷阱只讲原理不给完整可跑代码的算法文章本质上都是空中楼阁。这一节我会给出堆排序在几种主流语言下的完整实现把每处容易踩的坑单独拎出来讲保证写完就能在你的机器上直接运行验证。5.1 Java 实现边界条件从不让人省心public class HeapSort { private static void siftDown(int[] arr, int n, int i) { 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 tmp arr[i]; arr[i] arr[largest]; arr[largest] tmp; i largest; } } private static void buildMaxHeap(int[] arr) { int n arr.length; for (int i n / 2 - 1; i 0; i--) { siftDown(arr, n, i); } } public static void heapSort(int[] arr) { buildMaxHeap(arr); int n arr.length; for (int end n - 1; end 0; end--) { int tmp arr[0]; arr[0] arr[end]; arr[end] tmp; siftDown(arr, end, 0); } } }Java 版本最容易出错的点集中在left n和right n两个判断。数组长度为偶数时最后一个父节点只有一个左孩子如果你不加这个边界检查直接访问arr[right]不是越界就是在比较一个完全不存在的垃圾值。我在写的时候额外让 while 循环而不是递归来实现下沉原因也很实际堆栈深度最坏是 log n 层递归版本在超大数组上反而更慢而且 Java 每次递归都要压栈循环版本干净利落。5.2 Python 实现简洁但埋着负索引的暗雷def heap_sort(arr): # 下沉 def sift_down(n, i): 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 total len(arr) for i in range(total // 2 - 1, -1, -1): sift_down(total, i) for end in range(total - 1, 0, -1): arr[0], arr[end] arr[end], arr[0] sift_down(end, 0) return arrPython 的一个隐性坑是负索引。写的时候要注意在排序阶段的arr[0], arr[end] arr[end], arr[0]这行end是基于“当前堆长度”的初始是n-1每次递减如果误用了arr[-1]或者下标计算出现负数Python 不会报错会自己绕到数组尾部去逻辑就会悄悄出错且极难排查。所以我建议在 Python 中所有堆操作相关的下标变量都写成显式整数尽量不用便捷的负索引语法。另一个 Python 特有的收益是列表解构交换的速度非常快写出来的代码比 Java 版少很多临时变量报错定位时也更直观。5.3 Go 实现切片和下标的分寸感package main import fmt func siftDown(arr []int, n, i int) { for { largest : i left : 2*i 1 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 { return } arr[i], arr[largest] arr[largest], arr[i] i largest } } func heapSort(arr []int) { n : len(arr) for i : n/2 - 1; i 0; i-- { siftDown(arr, n, i) } for end : n - 1; end 0; end-- { arr[0], arr[end] arr[end], arr[0] siftDown(arr, end, 0) } } func main() { data : []int{3, 5, 9, 6, 8, 7, 1} heapSort(data) fmt.Println(data) }Go 的细节主要在切片传参函数拿到的是切片底层数组是共享的因此在siftDown里修改元素可以直接反映到调用侧不需要返回新切片。但这也带来了心智负担——你要时刻记住普通函数里对切片的append操作不会影响外部变量而这里我们是直接改下标不会触发扩容机制所以可以放心。另外Go 的标准库里container/heap包提供了heap.Init和heap.Pop实际工程中完全可以不手写堆排序。直接调库更稳妥但自己实现一遍能锻炼对接口机制的熟悉程度建议两边都试试。5.4 小学徒永远的噩梦测试用例设计写完代码不测试约等于白干。我觉得单测至少覆盖这几种类型空数组和单元素数组验证代码不会出现奇奇怪怪的下标越界。顺序数组[1, 2, 3, 4, 5]建堆时会出现大量交换检查最终序列。逆序数组[5, 4, 3, 2, 1]最考验建堆算法的输入。大量相同元素的数组[5, 5, 5, 5]验证不会死循环。随机数组拿标准库的排序结果做对照这是最可靠的方法。我通常的做法是拿随机序列跑几千遍用语言内置排序做验证若有任何一次不一致大概率是下标边界写错了尽早定位能省不少时间。6. 复杂度结论与真实性能别被 O(n log n) 骗了写算法文章不给出复杂度分析读者拿到代码也无法判断适用场景。堆排序的复杂度结论可以总结成一张表然后我们再把“理论上限”和“真实跑出来的表现”拉出来对比看它到底和快排差多少。6.1 各项复杂度汇总维度结论备注时间复杂度最坏O(n log n)建堆 O(n) 排序阶段 n 次下沉每次 O(log n)时间复杂度最好O(n log n)即使数组已经有序也要进行同样多轮下沉时间复杂度平均O(n log n)比较次数波动不大这是它区别于快排的优点空间复杂度原地O(1)只用了几个临时变量不依赖递归栈稳定性不稳定相等元素的相对位置可能颠倒有一个容易被忽略的地方是“最好情况”。快排对有序数组会退化归并排序已经有序时可以减少比较次数优化版但堆排序就算输入完全有序堆调整的次数也几乎不减少。原因很简单建堆时元素本来已经满足堆序排序阶段照样要交换堆顶和末尾再下沉恢复堆序这些步骤一步都省不掉。所以堆排序的时间复杂度是“铁打的 O(n log n)”这句话对你既是优势也是劣势——它绝不会超时但也绝不会走捷径。6.2 堆排序和快排、归并排序的实测差距很多人觉得堆排序最坏情况也是 O(n log n)应该能和快排掰掰手腕但真实跑起来往往出乎意料。数据量为 100 万随机整数时我在同样环境下实测过几次快排通常比堆排序快 1.5 到 2 倍归并排序介于两者之间。为什么堆排理论复杂度不差实际却赢不过快排根因在缓存局部性。快排的读写集中在数组的某个连续区间CPU 的缓存命中率极高堆排序则频繁在数组的不同区域之间跳跃——堆顶元素一换就要沿着一条随机路径下沉这条路径在数组里对应的下标分布完全取决于完全二叉树的结构每次访问都可能跨越很大跨度缓存命中率自然低。加上下沉过程的比较次数比快排的平均交换次数要多常数项被进一步放大。这不是堆排序的缺陷而是它在工程上相对少被当作默认排序工具的真实原因。了解这一点之后你在系统设计时就不会犯“因为堆排序复杂度好就到处用它”的错误。6.3 数据规模对堆排序地位的影响堆排序在小数组上和大数组上的表现也不是比例一致的。数据量低于几百的时候插入排序反而经常是最快的因为它的常数项极小数据量达到千万级时快排基于分治的局部性依然占优堆排序的用武之地更多在于“维护一个动态集合的最值”这种场景而不是一次性排序整个数组。7. 生产环境里的堆排序不是它的主战场到这里堆排序的核心知识已经全部拆解完毕。但我想再多说几句因为就算你真的在工作中不直接调用堆排序函数堆这个数据结构本身在工程里的戏份也非常重。7.1 海量数据 TopK这是堆最能打的地方当你有几十 GB 的数据只要最大的 100 个你会怎么做全部排序显然不现实内存装不下。正确的思路是用一个容量为 100 的小顶堆遍历数据时如果当前元素比堆顶当前前 100 中的最小值还小就直接跳过如果更大就移除堆顶插入新元素。遍历结束后堆里的 100 个元素就是全局最大的 100 个。这个方案的额外空间固定为 K是常数级别时间约等于 O(n log K)数据规模再大也撑得住。我在某数据处理 Demo 中处理过千万级日志的关键字段统计用的就是这个方案堆的容量只有 200内存占用始终平静如水。相比之下如果一开始想着排序哪怕用外排也要白做大量工作。7.2 优先级队列和调度场景操作系统线程调度、网络事件循环、任务定时器几乎全都依赖优先级队列。优先级队列的底层实现最标准的就是二叉堆入队对应“上浮”出队对应“交换堆顶和末尾再下沉”这两招正好就是我们在堆排序里反复练的操作。如果你已经掌握了堆排序的代码再去看标准库的优先级队列实现会觉得它简直一模一样。区别仅仅在于优先级队列通常维护的是抽象任务对象而不是裸的数字数组比较逻辑也更复杂一点。7.3 面试和刷题中常见的堆排序变体堆相关的算法题覆盖面很广常见的有找无序数组的第 K 大元素维护一个容量为 K 的小顶堆遍历一遍即可。合并 K 个有序链表把每个链表的当前头结点放入小顶堆每次弹出最小值并补位。滑动窗口最大值用大顶堆保存窗口内元素窗口移动时懒删除过期下标。数据流中的中位数用两个堆分别保存较小一半和较大一半维持两个堆的规模差不超过 1。这些题的解法几乎跑不开“上浮/下沉”这两板斧熟练掌握了堆排序你就等于拿到了这一整类题的钥匙。很多人刷题时总觉得每个题都是一个新解法其实题与题之间复用的内核是高度一致的。7.4 那我到底该不该用现成的堆排序函数大部分编程语言的标准库里都有现成的堆或排行榜排序函数比如不少语言提供了堆相关的容器操作。生产环境求稳我强烈建议直接调标准库。标准库通过了海量测试、性能调优边界处理也完善自己手写一遍更多是为了教学、面试和深入理解底层机制。真要自造轮子也要先用随机测试把排序正确性砸实再考虑集成进系统。从实际工程角度看堆排序更常见的身份是“算法素养的试金石”。它能快速检验你对完全二叉树、数组下标映射、最值维护这些基本概念的掌握程度而理解了它再去看优先级队列、TopK、堆优化 Dijkstra通透感会是直接跳代码的人给不了的。我最后补一句个人体会算法不是为了考试背模板而是帮你建立一组“数据结构操作集合复杂度代价”的直觉一旦这个直觉形成了你在系统设计里选型会从容得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑