ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java面试必考堆排序:从建堆原理到手写代码与TopK追问全解

Java面试必考堆排序:从建堆原理到手写代码与TopK追问全解 堆排序在 Java 面试里属于那种“看着简单一上手就翻车”的题。我自己准备面试时用纳米AI当备考陪练把堆排序从原理、手写代码到高频追问完整过了一遍今天就把这套核心考点拆开讲透应该对正在刷题和准备手撕算法题的同学都有帮助。先说结论堆排序不是最难写的排序但它是面试官特别喜欢深挖的排序之一。问它既考数组下标和二叉树思维的转换又考边界条件的处理还能顺势追问优先级队列、TopK、稳定性一道题能带出一串考点性价比极高。这篇文章里我会把建堆、下沉、交换、复杂度证明、面试追问都讲清楚顺便把我在 Debug 时踩过的坑一起列出来。1. 面试里堆排序的地位为什么值得死磕1.1 高频但正确率高不了它到底在考什么很多同学准备排序时优先背快排、归并觉得堆排序比较冷门。我的实际经验是堆排序出现频率并不低尤其是中高阶面试里经常作为“手写题”出现。原因很简单它表面上是一个排序算法实际上考察的是数据结构基本功。我总结了一下面试官问堆排序通常分三层第一层让你手写堆排序能把数组排对验证你对堆的核心操作下沉/上浮是否真的懂。第二层问复杂度尤其是“建堆为什么是 O(n)”这种问题考你对树结构层数和节点数量的建模能力。第三层追问稳定性、TopK、优先队列、堆排和快排的取舍考你在真实项目里的选型能力。大多数人只准备了第一层所以第二层和第三层一问就卡壳。这也是我为什么用纳米AI做模拟面试的原因它能连续追问帮我发现自己“以为会了其实没深想”的点。比如建堆复杂度这个问题我第一次就只记得结论“是 O(n)”被追问“为什么不是 O(n log n)”时就懵了。1.2 备考时我怎么设计练习节奏我用纳米AI做备考不算是替代刷题更像是给自己加了一个“随时让我解释每一步”的陪练。我喜欢让它扮演一个很较真的面试官每写完一段代码就问我“你这一步为什么从n / 2 - 1开始”“下沉和上浮你能分清吗”“如果数组全是重复元素你的代码会不会退化”这种追问式的练习比自己闷头看笔记管用得多。你回答一次再让它评价就相当于把认知盲区提前暴露了。我的建议是堆排序这类基础算法不要只看不写。哪怕很熟也要在纸上或编辑器里至少手写三遍第一遍能写出基本逻辑第二遍能解释每一步第三遍要做到边写边说面试现场才不会断档。2. 堆排序先别写代码这些模型得先吃透2.1 数组就是一棵完全二叉树别把两者割裂堆排序最核心的认知是理解“数组下标和完全二叉树节点位置”的映射关系。很多人卡住是因为脑子里数组是数组、树是树没有建立一一对应。给定一个数组arr把下标i当作二叉树的一个节点那么父节点下标(i - 1) / 2左孩子下标2 * i 1右孩子下标2 * i 2比如数组[4, 10, 3, 5, 1]它的堆结构长这样4 / \ 10 3 / \ 5 1这个映射关系就是堆排序的一切基础。孩子下标超过数组长度时说明节点不存在。面试时我一般会先在白板上写这三个公式再开始写代码这既能向面试官展示思路清晰也能避免自己写着写着下标错乱。2.2 做堆排序只需要掌握两个关键动作堆排序涉及的堆一般指最大堆父节点值不小于孩子节点值所以堆顶是全局最大值。要维护这个性质最常见的操作是“下沉”sift down。下沉的意思是把某个节点往下调整。它反复比较当前节点和它两个孩子中较大的那个如果当前节点更小就和较大的孩子交换然后继续在新位置比较直到满足最大堆性质。还有一个对称操作叫“上浮”sift up它是把节点向上调整常用于往堆里插入元素。很多人会把下沉和上浮搞混。我记的一个通俗方法是下沉是“大孩子上位”上浮是“自己攀关系”。堆排序的主流程用的是下沉插入新元素用的是上浮面试手写堆排序时千万别写反。2.3 三个复杂度疑点面试会连环问堆排序时间、空间、稳定性的分析是问答环节的重点别看它表面简单深挖起来其实有不少细节。建堆复杂度是 O(n)很多人不理解因为直觉上觉得每个节点都要调整。其实叶子节点不需要调整越靠近底层的节点数量虽然多但下沉楼层浅越靠近顶层节点数量少下沉楼层深加起来是一个收敛为常数的级数最终是 O(n)。这个证明有点像做幂级数求和面试时我能用简短方式说清楚后面章节我会展开讲。排序阶段复杂度是 O(n log n)每次把堆顶交换到尾部堆规模减一再对新的堆顶做一次下沉下沉深度是 log n 级别重复 n 次。空间复杂度是 O(1)因为是在原数组上原地操作辅助空间只用了常数级别的临时变量。堆排序不稳定因为它采用了“交换”的方式来调整位置相等元素的相对顺序无法保证。我在第 4 节会用一个具体数组举例。这三个点虽然三句话就能说完但每一句背后都可能引出追问比如“为什么建堆不是 O(n log n)”“不稳定能不能举反例”“原地怎么做到不占额外数组”等。提前把每个问题的细节理清才是真掌握了。3. 一份能直接跑的 Java 实现与逐步拆解3.1 完整代码先摆出来我先贴一版我实际在面试手感里比较推荐的 Java 实现。它不是最极致的优化版但逻辑清晰、容易记忆、不容易写出 bug应付面试足够。public class HeapSort { public static void heapSort(int[] arr) { if (arr null || arr.length 2) { return; } int n arr.length; // 第一步自底向上下沉建最大堆 for (int i n / 2 - 1; i 0; i--) { siftDown(arr, i, n); } // 第二步不断把堆顶最大值交换到末尾然后缩堆 for (int end n - 1; end 0; end--) { swap(arr, 0, end); siftDown(arr, 0, end); } } private static void siftDown(int[] arr, int i, int size) { while (i size / 2) { int left 2 * i 1; int right left 1; int larger left; if (right size arr[right] arr[left]) { larger right; } if (arr[i] arr[larger]) { break; } swap(arr, i, larger); i larger; } } private static void swap(int[] arr, int i, int j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } }3.2 为什么建堆要从n / 2 - 1开始倒着走这是堆排序最关键的步骤我单独说说。有孩子的节点才算需要调整的节点。完全二叉树里最后一个非叶子节点的下标就是n / 2 - 1。比如数组长度是 10最后一个非叶子节点是下标 4。我们从它开始往 0 方向倒着下沉就能保证每一层在向上层调整前下层已经是一个合法的堆。为什么不能正着从 0 开始因为下沉需要依赖左右子树本身已经满足堆性质。只有先处理好子树才能处理父节点。倒着遍历正好是“从底层往顶层”建立堆的顺序这是建堆的关键。排序阶段的循环我解释一下堆顶是当前最大值把它和数组末尾交换后最大值就到了最终位置。这时堆的有效长度减一堆顶被换进来的元素大概率不满足最大堆性质所以对堆顶做一次下沉。这个过程重复n-1次整个数组就从小到大排好了。还有人问为什么不直接PriorityQueue存一下再输出那样能做对但不是面试要的“原地排序”空间复杂度从 O(1) 变成了 O(n)并且没有考到堆操作本身。写堆排序时目标就是在原数组上腾挪。3.3 用测试用例验证正确性别只测随机数组写完代码我会用四类测试用例来验证空数组[]、单元素数组[1]保证不越界。普通乱序数组[5, 1, 8, 3, 7, 6, 2, 4]验证基本排序。已升序数组[1, 2, 3, 4, 5]和降序数组[5, 4, 3, 2, 1]验证建堆和排序时不退化。大量重复元素数组[2, 2, 2, 1, 1, 2]验证稳定性虽不要求但边界不能崩。我习惯再用 Java 自带的Arrays.sort做交叉验证随机生成几千个数排完和系统排序结果对比看是否有差异。这种方法比肉眼看数组靠谱得多。我在用纳米AI准备时也会让它生成特殊用例比如长度刚好是奇数、偶数、包含负数反正能把它想到的边界都测一遍测的时候真能发现不少问题。4. 手撕现场边写边说的高分示范4.1 在白板上我会按这个顺序写代码面试和考试不一样面试官想听的不只是最终答案更是你的解题思路。所以我每次手写代码都会先把思路说出来堆排序分两步第一步建最大堆第二步不断取出堆顶并放到末尾。然后按顺序写先写swap几行而已放在后面随时用。再写siftDown把这段核心逻辑先从脑中推导清楚。最后写heapSort主流程用双层循环把前面方法串起来。写siftDown时我会同时说当前节点如果有左孩子就找左孩子和右孩子中更大的一个如果自己比孩子小就交换然后继续下沉如果已经大于等于两个孩子就停止。这样的话术一出来面试官会认为你是真的理解而不是背代码。写完以后我还会主动说一句“这段代码最需要注意的地方是右孩子下标可能越界所以判断right size是必要的。”主动抛出边界点是加分项。4.2 高频追问建堆为什么是 O(n)这个问题我差点翻车后来终于用数学方式理解透了。堆是一棵完全二叉树。假设堆的高度为h叶子层在第h层叶子不需要下沉所以不考虑。第k层从最底层往上数令第 0 层是叶子上一层的节点数最多是n / 2^(k1)每个节点下沉的最大深度是k。把每层工作量加起来总工作量 sum(k * n / 2^(k1)) n * sum(k / 2^(k1))后面的级数sum(k / 2^k)是收敛的趋近一个常数所以总复杂度是 O(n)。这就是为什么建堆比“每个节点都 O(log n)”的直觉要快的原因。记住这个推导面试时直接画层数和节点数说明就行。再回答一下“堆排序整体复杂度为什么是 O(n log n)”建堆 O(n)排序阶段每次取堆顶并下沉 O(log n)共 n-1 次所以后面是 O(n log n)合起来还是 O(n log n)。但要注意这里的 O(log n) 在最坏情况下也不会退化这是堆排序相对快排的一个优势。4.3 高频追问堆排序为什么不稳定我用一个具体例子说明假设有数组[5a, 7, 5b, 4]其中5a和5b都表示值为 5 的元素只是我用字母区分它们在原数组中的先后顺序。建堆和排序过程中我们经常把元素交换来交换去。堆顶的较大值会被交换到末尾这个过程可能把两个相同值元素的相对顺序打乱。比如在上面这个例子里最终可能变成[4, 5b, 5a, 7]或类似顺序5a原本在5b前面排完序后反而排在后面了。既然相等元素的相对位置不能保证堆排序就是不稳定排序。实际面试中我用这个例子讲一遍比背一句“因为交换所以不稳定”有说服力得多。4.4 高频追问TopK 应该用大顶堆还是小顶堆这个追问特别常见而且很多人当场反了。求最大 K 个元素时正确做法是维护一个大小为 K 的小顶堆。为什么因为小顶堆的堆顶是堆中最小的元素。遍历数据时如果当前元素比堆顶大就把堆顶弹出把当前元素插入。这样堆里始终保留“目前看过的最大 K 个元素”堆顶就是这 K 个里最小的那个也就是第 K 大的元素。如果求最小 K 个元素就要反过来用大顶堆。关于大堆小堆我有一个不太严谨但好记的判断你在淘汰堆顶堆顶应该是最容易淘汰的那个所以选它对应堆序里最小的一个。求最大 K 时堆顶是小所以用小顶堆。我给出用 Java 的PriorityQueue实现的 TopK 版本可以对比着记public int[] topKMax(int[] nums, int k) { if (nums null || k 0) { return new int[0]; } PriorityQueueInteger 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[] res new int[minHeap.size()]; int index 0; for (int val : minHeap) { res[index] val; } return res; }这段代码虽然用了 API但思路和手写堆完全一致面试时说清楚“底层就是堆”就行了。5. 我踩过的坑和调试实录5.1 边界与索引类错误堆排序里最容易错的是下标。我自己至少犯过这几种错误for (int i n / 2; i 0; i--)多算了一个不存在的节点。如果n / 2本身就可能是叶子节点正确起点是n / 2 - 1。从叶子下沉不会出错但纯属浪费性能。判断右孩子时忘了right size导致访问越界。当左孩子是最后一个元素时右孩子不存在这时候arr[right]会直接抛数组越界。while (left size)和while (i size / 2)混用。两者都能写对但i size / 2更准确地表达了“当前节点有孩子”这个条件。我自己更习惯用后者因为可以少算一次 left 变量。调试方法很简单在每次交换后打印数组看看堆结构是否在“肉眼可见地”走向有序。打印数组虽然笨但找边界问题非常快。5.2 逻辑混淆类错误下沉上浮分不清我见过不少同学写的代码明明叫siftDown实际上内部却在拿父节点和子节点比较后把子节点往上换最后效果像上浮。这种代码有时碰巧能排序但一旦数据量变大或输入特殊就会出错。我自己的记法是下沉是“从根往叶子方向调整”上浮是“从叶子往根方向调整”。建堆和排序阶段都是把新元素放到一个可能不合适的位置然后往下调所以都用下沉。写代码前在注释里先标好“从 i 开始向下调整”能够减少混淆概率。还有一个常见的坑是排序阶段交换堆顶到末尾后忘记缩堆。如果不缩堆下一次下沉又会把已经排好的末尾元素再次纳入堆调整范围排序结果自然不对。这里需要强调size是动态递减的每个循环里传的end就是新的堆长度。5.3 性能细节虽然面试不一定问但代码质量要看堆排序有一些性能问题我可以简单说说避免被问到的时候哑口无言。首先是常数项比较大。堆排序的 O(n log n) 中间包含大量下标计算和比较真实执行速度通常比快排慢一些这也是生产环境里很多场合不用堆排代替快排的原因。其次是如果我用递归写下沉在最坏情况下可能栈深度过深。面试里写迭代版本最稳妥。还有如果对Integer数组用包装类型并频繁装箱拆箱性能会更差。手写基本类型数组版时没有这个问题。最后如果要给对象数组排序不要直接在方法里大量用 lambda 表达式创建比较器Java 的泛型和比较器会带来额外开销。面试时能用基本类型讲清楚就不要画蛇添足。6. 堆排序之外的延伸思考6.1 和快排、归并的对比我习惯把堆排序放进“排序全家桶”里对比这样面试时无论从哪个角度切入都能接上话。下面这张表是我的常备内容排序算法最好时间复杂度最坏时间复杂度平均时间复杂度空间复杂度稳定性堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定快速排序O(n log n)O(n²)O(n log n)O(log n)递归栈不稳定归并排序O(n log n)O(n log n)O(n log n)O(n)稳定这张表的价值不只是背下来而是要理解背后的取舍堆排序最大的优势是“最坏情况仍然 O(n log n)”并且原地完成不像归并那样需要额外数组。快速排序最坏可能退化但平均常数小在通用场景里通常更快。归并排序额外空间多但它稳定适合链表排序以及需要稳定性的场景。面试问到“你项目里排序用什么”我会说优先用系统库因为系统库会根据数据类型选择策略只有在手写算法题时才需要自己控制排序逻辑。这样回答既显得有工程经验又不装。6.2 真实工程项目里哪些地方在用堆堆排序本身在生产中其实不算常见的直接实现方案但堆这种数据结构到处都是优先队列Java 的PriorityQueue底层就是堆线程池、任务调度都广泛用到。TopK 问题超大日志里找出现次数最多的 K 个请求不可能全量排序用小顶堆过一遍就好。数据流中位数维护一个大顶堆和一个小顶堆分别保存较小一半和较大一半堆顶合起来就是中位数。定时器/延迟任务按触发时间建小顶堆每次取堆顶效率比遍历列表高很多。因此背堆排序不能只背代码更要把“堆适合找极值、适合动态插入删除最值”这种抽象能力掌握。面试官深挖其实是在问这个。6.3 接下来怎么继续练如果你正处在刷题阶段我的建议是组一个“堆专题”练习而不是只做一道堆排序就结束。可以按这个顺序手写最大堆、最小堆实现offer和poll不借助PriorityQueue。完成TopK、数据流中位数这类经典题。做合并 K 个有序链表这类用堆解决的多路归并题。再回头把堆排序手写一遍验证自己是否真的理解了。这组训练下来堆相关的内容就不会再怕了。我在练习时会让纳米AI给我随机出题限时十分钟然后立刻复盘这种节奏比较像真实笔试对于培养手感和时间感很有帮助。7. 备考心得与一个小建议用纳米AI备考这段时间我最大的体会是与其花大量时间看一堆堆排序的资料不如把时间花在“自己讲出来”上。只要你能清晰地讲出“为什么要从 n/2 - 1 开始建堆”“下沉时右孩子的越界问题”“TopK 为什么用反过来的堆”面试基本就稳了。另外给一个小建议面试手写堆排序前先花三十秒在脑中过一遍流程不要上来就写。按我给的“先写 swap再写 siftDown最后写主流程”的顺序走能减少很多低级错误。如果写的时候发现和预期结果不一样先用小数组手动模拟一遍而不是反复猜代码这是最有效的排错方式。堆排序说到底是个“磨刀题”它考察的不只是这个算法本身更是看你能不能把复杂逻辑拆成简单的堆操作。把这道题吃透优先级队列、TopK、调度这些延伸场景都会跟着通。希望这篇解析能让你在面试时也做到心里有底手上有数。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进