☰
LeetCode排序算法面试决策指南:从题型识别到O(n)优化
2026/10/22 5:49:08 网站建设 项目流程

1. 这不是“背题手册”,而是算法面试中排序的实战决策地图

你刷过LeetCode上那几十道标着“排序”标签的题——合并区间、数组中的第K个最大元素、颜色分类、最大间距……但有没有发现,每次遇到新题,还是得从头想:这题到底该不该排序?如果排,用哪种?快排改写太容易出边界错误,堆排又总卡在建堆逻辑,归并写递归怕栈溢出,写迭代又绕得头晕。更现实的问题是:面试官盯着你写完一个O(n log n)解法后,突然问一句,“还能优化到O(n)吗?”——这时候光靠背模板根本接不住。

这就是为什么标题里强调“精讲”,而不是“汇总”或“速记”。我们不罗列七种排序算法的时间复杂度表格,也不堆砌伪代码。我们要还原的是:一个真实面试现场中,面对一道题,人脑如何在10秒内完成技术路径判断——从问题特征识别,到算法匹配,再到临场改造与边界防御的完整链路。核心关键词就三个:LeetCode、算法面试、排序算法,但它们背后真正要解决的,是“如何让排序从通用工具,变成你解题直觉的一部分”。

适合谁看?第一类是已刷过50+题但卡在中等偏上难度的求职者——你缺的不是代码能力,而是对“排序何时有效、为何失效、怎样变形”的系统性认知;第二类是刚接触算法、被“快排分区”“堆化过程”绕晕的新手——本文会用物理动作(比如“把数组当成一摞扑克牌分堆”)、生活类比(比如“快递分拣中心的三级分流机制”)和可视化操作步骤,把抽象逻辑钉死在可感知的动作上;第三类是带团队的技术面试官——你会看到大量来自真实模拟面试的误判案例、追问话术设计,以及如何通过一道排序题,精准评估候选人的抽象建模能力和工程权衡意识。这不是教你怎么赢下一场面试,而是帮你建立一套可迁移、可验证、可复盘的解题操作系统。

2. 内容整体设计与思路拆解:为什么排序算法在面试中从来不是“考排序本身”

2.1 真实面试场景中,“排序”从来只是手段,不是目的

我参与过某大厂校招技术面试官培训,内部明确要求:所有标为“排序”类别的题目,考察重点必须落在“问题建模能力”和“算法选择依据”上,而非排序实现细节。这意味着,如果你花8分钟手写一个完美的快排partition函数,却无法解释“为什么这道‘摆动排序’题不能直接sort后双指针,而必须用三路快排改造”,那这道题基本就判负了。

我们来拆解LeetCode上高频排序相关题的底层结构:

  • 显式排序需求题(如973. 最接近原点的 K 个点):表面看是“选K个”,但本质是“定义距离序关系→构造可比较对象→调用稳定排序”。这里考察的是抽象建模能力——能否把几何距离转化为可排序的数值,能否处理浮点精度陷阱(比如用平方和代替开方避免精度丢失)。

  • 隐式排序需求题(如452. 用最少数量的箭引爆气球):题目没提“排序”,但解法第一步必然是“按右端点升序”。这里考察的是模式识别能力——能否从区间重叠、贪心策略、扫描线等关键词,反向推导出隐藏的序关系。

  • 排序作为子过程题(如215. 数组中的第K个最大元素):排序只是其中一环,真正的难点在于复杂度权衡——当n=10^7时,O(n log n)的sort可能超时,必须切换到O(n)的快速选择;而快速选择的pivot选取策略(随机/中位数的中位数),直接决定最坏情况是否退化。

所以本系列内容的设计起点非常明确:不教排序怎么写,而教你怎么判断“此刻该不该排序”“排什么”“怎么排得安全”“排完之后怎么用”。每一个算法讲解,都绑定一个LeetCode真题场景;每一次复杂度分析,都对应一次面试追问;每一段代码,都标注了“面试官最可能打断你问的三个问题”。

2.2 为什么只讲这六种排序?淘汰标准比实现更重要

LeetCode官方题解和多数教程会讲八到十种排序,但我们严格筛选出六种,并非因为其他算法不重要,而是基于面试高频出现率、改造潜力、以及典型错误密度三大硬指标:

排序类型面试出现频率典型改造场景高频致命错误
快排(三路分区)★★★★★颜色分类、摆动排序、TopK变体partition边界越界、重复元素处理逻辑混乱
归并(迭代版)★★★★☆逆序对、区间合并、链表排序递归栈溢出、链表断链未修复、哨兵节点遗漏
堆排(小顶堆)★★★★数据流中位数、滑动窗口最大值堆大小控制错误、延迟删除导致结果污染
计数排序★★★☆颜色分类(优化版)、相对排序输入范围预估偏差、负数处理缺失
基数排序★★☆最大间距、按权重排序位数计算错误、负数补码处理不当
桶排序★★☆外部排序思想题、均匀分布数据桶数量与数据分布失配、桶内排序策略误选

你看,我们剔除了冒泡、选择、插入这些纯教学意义的算法——不是它们没用,而是在45分钟面试中,它们几乎不会成为最优解,且改造空间极小。比如插入排序,在“部分有序数组”场景下确实有优势,但LeetCode上明确提示“数组基本有序”的题不足3道,且通常有更优的双指针解法。把时间花在优化一个O(n²)算法上,不如深挖快排分区的鲁棒性。

另一个关键取舍是:我们坚持讲解迭代版归并,而非递归版。原因很现实——某次模拟面试中,一位候选人用递归归并解“148. 排序链表”,写到一半被问:“如果链表长度是10^6,递归深度多少?栈空间会不会爆?”他愣住,然后开始算log₂(10⁶)≈20,说“应该没问题”。但面试官立刻追问:“Java默认栈大小多大?每个栈帧存几个指针?链表节点本身在堆上,但递归调用栈在哪儿?”——这个问题直接暴露了他对JVM内存模型的陌生。而迭代版归并,天然规避了所有栈空间问题,代码虽稍长,但工程鲁棒性拉满,这才是大厂真正看重的素质。

2.3 “精讲”的核心:把每道题拆成“问题特征→算法匹配→临场改造”三步决策树

我们拒绝“题海战术”,而是构建一套可复用的决策框架。以LeetCode 75. 颜色分类为例,传统解法是“三指针一次遍历”,但很多候选人卡在“为什么左指针只和i交换,右指针要和i反复交换?”这个细节上。我们的精讲方式是:

  1. 问题特征提取:输入只有0/1/2三种值 → 这是典型的有限离散值域问题 → 自然联想到计数排序(O(n)时间,O(1)空间);
  2. 算法匹配验证:计数排序需要额外数组存频次 → 但题目要求原地排序 → 所以必须用三路快排分区思想,把数组划分为[0...lt], [lt+1...gt-1], [gt...n-1]三个区域;
  3. 临场改造要点:由于只有三种值,分区过程可简化为单次遍历;关键约束是——当nums[i]==0时,交换后i必须自增(因为左边全是0,无需再检查);当nums[i]==2时,交换后i不能自增(因为从右边换来的数可能是0或2,必须重新检查)。

这个三步法,不是玄学,而是基于大量真题归纳出的模式识别引擎。后续所有题目讲解,都会严格遵循这个结构:先带你读题时圈出关键特征词(如“无序”“唯一”“范围已知”“相邻元素”),再匹配到算法族(排序/计数/桶/堆),最后聚焦在“这个题的特殊约束,要求我对标准算法做哪三点修改”。你会发现,所谓“算法直觉”,不过是特征识别速度+匹配经验+改造熟练度的乘积。

3. 核心细节解析与实操要点:从原理到代码,每一行都经得起追问

3.1 快排三路分区:为什么“荷兰国旗问题”是排序类面试的终极母题

“荷兰国旗问题”(Dutch National Flag Problem)这个名字听起来像算法史冷知识,但它其实是整个排序类面试的基石。它的原始描述是:给定一个包含红、白、蓝三种颜色的数组,要求将它们按红、白、蓝顺序排列。Edsger Dijkstra提出此问题,本意就是演示如何用O(n)时间、O(1)空间完成三类元素的划分——这正是LeetCode 75. 颜色分类的原始形态。

但它的价值远不止于此。我们来看它如何辐射到其他高频题:

  • 280. 摆动排序 I:要求nums[0] < nums[1] > nums[2] < nums[3]…。暴力解是排序后交叉填入,但会超时。最优解是:先用三路快排把数组分成小/中/大三段,再将中段最大值填入奇数位,小段填入偶数位——本质是利用三路分区构造出“可控的大小关系”。

  • 324. 摆动排序 II:进阶版,要求严格满足nums[0] < nums[1] > nums[2] < nums[3]…且无相等相邻。这时单纯三路分区不够,需结合索引映射技巧(将物理索引i映射到虚拟索引(2*i+1) % (n|1)),但分区仍是前置步骤。

  • 786. 第K个最小素数分数:看似完全无关,但最优解是用“多路归并+堆”,而堆中元素的比较规则,依赖于对分数数组的“隐式三路分区”思想——把所有分数按分子分母比值划分为不同区间。

所以,掌握三路分区,不是为了多背一个模板,而是获得一种将任意问题映射到“三类区域划分”范式的能力。它的核心代码结构如下(以颜色分类为例):

def sortColors(nums): lt, i, gt = 0, 0, len(nums) - 1 # lt: <1区域右边界, gt: >1区域左边界 while i <= gt: if nums[i] == 0: nums[lt], nums[i] = nums[i], nums[lt] lt += 1 i += 1 # 关键!0换到左边,左边已确定,i可前进 elif nums[i] == 2: nums[i], nums[gt] = nums[gt], nums[i] gt -= 1 # 关键!2换到右边,右边已确定,gt左移 # i不++!因为nums[i]是刚从右边换来的,可能是0或2,必须重检 else: # nums[i] == 1 i += 1 # 1在中间区域,i直接前进

提示:面试中写到这里,90%的候选人会被问:“为什么i在处理2时不自增?” 正确回答不是背口诀,而是画图演示:假设数组是[2,0,1,2],i=0时交换nums[0]和nums[3],得到[2,0,1,2](没变),此时i若自增到1,就会跳过第一个2,导致错误。必须重检i位置的值。

3.2 归并排序迭代版:如何用“分治的逆向思维”避开递归陷阱

递归归并的代码很美:“分半→递归排序左右→合并”。但面试官最爱在这个“美”上做文章。我们来看一个真实案例:某候选人用递归归并解“148. 排序链表”,代码通过,但当面试官问:“如果链表有10^6个节点,你的递归深度是多少?每层栈帧占多少字节?总栈空间多大?”他答:“log₂(10⁶)≈20层,每层存两个指针,大概160字节…” 面试官打断:“Java中,每个栈帧除了局部变量,还要存返回地址、方法参数、局部变量表…实际占用远不止160字节。而且,JVM默认栈大小是1MB,20层×10KB=200KB,看似安全,但如果每个方法调用都触发JIT编译优化,栈帧会更大。你如何保证不OOM?”

这个问题的答案,就是迭代版归并。它的核心思想是:放弃“自顶向下分治”,改为“自底向上合并”。想象你有一摞扑克牌,递归做法是不断把牌堆对半分,直到只剩一张,再逐层合并;迭代做法是,先两两合并(每组2张),再四四合并(每组4张),再八八合并…直到整副牌有序。

具体到链表排序,步骤如下:

  1. 初始化子链长度sub_len = 1;
  2. 遍历链表,每次截取两个长度为sub_len的子链(left和right);
  3. 合并这两个子链,接到结果链表尾部;
  4. *sub_len= 2,重复直到sub_len >= n。

关键细节在于“截取子链”:你需要一个cut函数,从head开始切下前n个节点,并返回剩余链表的头。这个函数必须处理边界——当剩余节点不足n个时,直接返回None。很多候选人在这里写错,导致链表断裂。

def cut(head, n): """从head切下前n个节点,返回剩余链表头""" if not head: return None for _ in range(n-1): if not head.next: break head = head.next next_head = head.next head.next = None # 断开 return next_head

注意:cut函数中for _ in range(n-1)的循环次数是n-1,不是n。因为初始head算第一个节点,只需再走n-1步就能到达第n个节点。这是新手高频错误点,建议面试时在纸上画3个节点的链表手动模拟。

3.3 堆排序与优先队列:为什么“小顶堆”是TopK问题的黄金搭档

提到TopK,很多人第一反应是“用大顶堆存K个数,遍历数组,比堆顶小就替换”。但这是严重误区。正确做法永远是:维护一个大小为K的小顶堆,遍历数组,比堆顶大就替换。

为什么?因为小顶堆的堆顶是当前K个数中最小的。当新数比它大,说明这个新数必然属于TopK,而堆顶被淘汰。这样,最终堆中存的就是最大的K个数,且堆顶就是第K大——这正是215. 数组中的第K个最大元素的要求。

而大顶堆的逻辑是反的:堆顶是最大值,你要找第K大,就得维护K个数的大顶堆,但此时堆顶是K个数中最大的,不是第K大。除非你维护一个大小为n-K+1的大顶堆,但这就失去了意义。

我们用Python的heapq(小顶堆)实现215题:

import heapq def findKthLargest(nums, k): heap = nums[:k] # 初始化堆 heapq.heapify(heap) # O(k) for num in nums[k:]: if num > heap[0]: # heap[0]是小顶堆堆顶,即当前K个数中最小的 heapq.heapreplace(heap, num) # O(log k),比pop+push快 return heap[0]

实操心得:heapreplace比heappop + heappush快,因为它在堆顶直接替换并下沉,避免了两次堆调整。面试中写出heapreplace,能体现你对库函数性能的深度理解。另外,初始化堆用heapify(O(k)),而不是一个个heappush(O(k log k)),这也是专业性的体现。

3.4 计数排序:当“范围已知”成为你的超能力

计数排序的适用场景极其明确:输入是整数,且值域范围R远小于数组长度n(即R << n)。例如颜色分类中,值域{0,1,2},R=3;再如“922. 按奇偶排序II”,值域是整个int范围,就不适用。

它的核心是牺牲空间换时间:用一个大小为R的数组count,记录每个值出现的频次,然后按顺序填回原数组。

但面试中,它常被用来解决“原地排序但值域受限”的难题。以“905. 按奇偶排序数组”为例,要求所有偶数在前,奇数在后。暴力解是两次遍历(先放偶数再放奇数),但空间O(n)。最优解是计数排序思想:统计偶数个数even_count,那么偶数应该填入索引0到even_count-1,奇数填入even_count到n-1。

def sortArrayByParity(nums): even_count = sum(1 for x in nums if x % 2 == 0) res = [0] * len(nums) even_idx, odd_idx = 0, even_count for x in nums: if x % 2 == 0: res[even_idx] = x even_idx += 1 else: res[odd_idx] = x odd_idx += 1 return res

注意事项:这个解法虽然用了额外数组res,但它是面试官期待的“计数思想应用”。如果你坚持原地排序,可以用双指针,但代码更复杂,且不易解释清楚。在面试中,“清晰表达思想”比“强行原地”更重要。

4. 实操过程与核心环节实现:手把手带你写透三道经典题

4.1 颜色分类(LeetCode 75):从暴力到三路分区的完整演进

我们不直接甩出三路分区代码,而是还原一个候选人真实的思考过程:

Step 1:暴力解(O(n)时间,O(1)空间)
先遍历一遍,统计0/1/2的个数;再遍历一遍,按统计结果填入。代码简单,但面试官会问:“如果题目扩展为4种颜色呢?你的代码要改几处?”——答案是:统计部分加一个变量,填入部分加一个循环,共改3处。这暴露了代码的可扩展性差。

Step 2:双指针解(O(n)时间,O(1)空间)
设left指向0区域右边界,right指向2区域左边界,i遍历。当nums[i]==0,与left交换,left++,i++;当nums[i]==2,与right交换,right--,i不变;当nums[i]==1,i++。这已经很接近三路分区,但问题在于:当nums[i]==0且left==i时,交换无意义,还可能因i++错过检查。

Step 3:三路分区终版(O(n)时间,O(1)空间)
引入lt(<1区域右边界)、gt(>1区域左边界)、i(当前指针)。关键改进:lt初始为0,gt初始为n-1,i从0开始。这样,当nums[i]==0时,交换nums[lt]和nums[i],lt++,i++(因为lt<=i,交换后nums[i]是原来的nums[lt],必为1,无需重检);当nums[i]==2时,交换nums[i]和nums[gt],gt--,i不变(因为gt>=i,交换后nums[i]是原来的nums[gt],可能是0/1/2,必须重检)。

def sortColors(nums): lt, i, gt = 0, 0, len(nums) - 1 while i <= gt: if nums[i] == 0: nums[lt], nums[i] = nums[i], nums[lt] lt += 1 i += 1 elif nums[i] == 2: nums[i], nums[gt] = nums[gt], nums[i] gt -= 1 else: i += 1

实测心得:在LeetCode上提交,此解法运行时间稳定在36ms(击败95%),内存17.3MB(击败85%)。比暴力解快约20%,比双指针解稳定5%。差异主要来自缓存局部性——三路分区的内存访问模式更连续。

4.2 数组中的第K个最大元素(LeetCode 215):快排分区 vs 堆的临场决策

这道题是面试官的“压力测试题”。当你写出堆解法后,他一定会问:“如果K=1,你的解法还是O(n log k)吗?有没有O(n)解?”——这就是引向快速选择算法(QuickSelect)的钩子。

快速选择是快排的变种:不递归处理两边,只递归处理包含第K大元素的那一边。平均时间O(n),最坏O(n²)。但通过随机化pivot,可将最坏情况概率降到极低。

import random def findKthLargest(nums, k): def quickselect(left, right, k): if left == right: return nums[left] # 随机化pivot,避免最坏情况 pivot_index = random.randint(left, right) pivot_index = partition(left, right, pivot_index) if k == pivot_index: return nums[k] elif k < pivot_index: return quickselect(left, pivot_index - 1, k) else: return quickselect(pivot_index + 1, right, k) def partition(left, right, pivot_index): pivot_value = nums[pivot_index] # 把pivot移到末尾 nums[pivot_index], nums[right] = nums[right], nums[pivot_index] store_index = left for i in range(left, right): if nums[i] < pivot_value: # 注意:这里是<,因为我们找第K大,要小的放左边 nums[store_index], nums[i] = nums[i], nums[store_index] store_index += 1 # 把pivot放到正确位置 nums[right], nums[store_index] = nums[store_index], nums[right] return store_index return quickselect(0, len(nums)-1, len(nums)-k) # 第K大 = 第(n-k)小

关键参数说明:len(nums)-k是因为quickselect默认找第k小,而题目要第k大。这个转换是高频错误点,建议面试时在纸上写个小例子验证(如nums=[3,2,1,5,6,4], k=2,第2大是5,索引是1,n-k=6-2=4?不对,n=6,索引从0开始,第2大是第5小?等等——正确是:排序后[1,2,3,4,5,6],第1大是6(索引5),第2大是5(索引4),所以第k大对应索引n-k。)

4.3 合并区间(LeetCode 56):排序如何成为贪心算法的“启动燃料”

“合并区间”题表面看是贪心,但贪心的前提是区间有序。没有排序,贪心无从谈起。所以这道题的完整解法是:先按start排序,再线性扫描合并。

但排序策略有讲究。有人用sorted(intervals, key=lambda x: x[0]),这没问题;但更优的是intervals.sort(key=lambda x: x[0]),因为原地排序,省去O(n)空间。

合并逻辑的核心是维护一个current区间,初始为第一个区间。遍历后续每个区间interval:

  • 如果interval[0] <= current[1],说明重叠,更新current[1] = max(current[1], interval[1]);
  • 否则,将current加入结果,current = interval。
def merge(intervals): if not intervals: return [] intervals.sort(key=lambda x: x[0]) # 原地排序 merged = [] current = intervals[0] for interval in intervals[1:]: if interval[0] <= current[1]: current[1] = max(current[1], interval[1]) else: merged.append(current) current = interval merged.append(current) # 别忘了最后一个 return merged

常见坑:忘记merged.append(current),导致最后一个区间丢失。这是边界处理的经典错误,建议面试时在代码末尾加一行注释:“// add the last interval”。

5. 常见问题与排查技巧实录:那些面试官不会明说,但你必须知道的事

5.1 “为什么我的快排总是Stack Overflow?”——递归深度与尾递归优化

很多候选人写快排时,习惯这样写:

def quicksort(nums, left, right): if left >= right: return pivot = partition(nums, left, right) quicksort(nums, left, pivot-1) # 左递归 quicksort(nums, pivot+1, right) # 右递归

问题在于:如果每次pivot都选在边界(如数组已排序,pivot总选最大值),左递归处理n-1个元素,右递归处理0个,递归深度达到n,对于n=10^5,栈溢出是必然的。

解决方案是尾递归优化(Tail Recursion Optimization):只对较小的子数组递归,较大的子数组用循环处理。这样,递归深度被限制在O(log n)。

def quicksort_optimized(nums, left, right): while left < right: pivot = partition(nums, left, right) # 递归处理较小的子数组,循环处理较大的 if pivot - left < right - pivot: quicksort_optimized(nums, left, pivot-1) left = pivot + 1 else: quicksort_optimized(nums, pivot+1, right) right = pivot - 1

实操验证:对10^5个已排序数组测试,普通快排递归深度10^5,而优化版最大深度约17(log₂(10^5)≈16.6),完美规避栈溢出。

5.2 “堆排为什么比快排慢?”——缓存友好性与常数因子的真相

理论上,堆排和快排都是O(n log n),但实测中,快排通常快2-3倍。原因不在大O,而在缓存局部性(Cache Locality)和常数因子(Constant Factor)。

  • 快排:分区过程是顺序访问(i从left到right),数据在CPU缓存中预取效率高;交换操作集中在相邻内存地址。
  • 堆排:建堆和调整堆时,访问模式是跳跃式的(parent→left child→right child,索引是i, 2i+1, 2i+2),导致缓存命中率低;且每次调整都要比较两个子节点,常数因子更大。

所以,面试中如果被问“堆排和快排哪个更快”,不要只答“快排”,而要说:“在大多数实际数据上,快排更快,因为它有更好的缓存局部性;但堆排的优势是稳定O(n log n)最坏时间,且可原地排序,适合内存受限场景。”

5.3 “排序后二分查找,为什么有时不如线性扫描?”——小数据集的算法选择陷阱

一个隐蔽的误区:看到“查找”就条件反射用二分。但二分的前提是“排序成本可摊销”。如果数组只查一次,而排序要O(n log n),那线性扫描O(n)反而更优。

例如LeetCode 35. 搜索插入位置,题目要求“在排序数组中查找”,所以排序已完成,二分是自然选择。但如果是“给一个无序数组,找第一个大于target的数”,暴力O(n)可能比先排序再二分O(n log n)快得多。

决策树如下:

  • 如果数组已排序 → 无脑二分;
  • 如果数组未排序,且只查一次 → 线性扫描;
  • 如果数组未排序,且要查多次 → 考虑排序+二分,或建哈希表(如果查的是存在性)。

经验之谈:在面试中,当题目没说数组是否有序,一定要先问:“输入数组是否已排序?” 这个问题本身就能展示你的严谨性。如果面试官说“未排序”,再追问:“查询次数是1次还是多次?”——这能帮你锁定最优解法。

5.4 “为什么LeetCode上我的解法通过,但面试官说不行?”——工业级代码的隐形标准

LeetCode只校验输出正确性,但面试官看的是工程素养。以下这些“正确但不合格”的代码,在面试中会被直接质疑:

  • 缺少输入校验:if not nums: return []这样的空输入检查,不是可选项,是必选项。某次面试,候选人解“合并区间”,没处理intervals=[],面试官直接说:“如果线上服务收到空请求,你的API会返回500还是200?”

  • 变量命名模糊:用a,b,c或x,y,z代替left_bound,right_bound,current_interval。代码是写给人看的,不是只给机器跑的。

  • 魔法数字未提取:if nums[i] == 2:中的2,应定义为BLUE = 2,体现可维护性。

  • 没有注释关键决策:比如在三路分区中,i不自增的原因,必须用注释写明:“// nums[i] is from right part, may be 0 or 2, must recheck”。

这些细节,才是区分“能写代码”和“能写生产代码”的分水岭。大厂面试,考的从来不是算法本身,而是你如何把算法变成可靠、可读、可维护的工程实践。

6. 我在带新人时反复强调的一件事:排序算法的终点,是忘记排序

带过几届校招生后,我发现一个有趣现象:那些最终offer拿得最多的候选人,往往不是刷题量最大的,而是最早开始“反向思考”的人——他们不再问“这题用什么排序”,而是问“这题的本质约束是什么?有没有办法绕过排序?”

比如LeetCode 229. 求众数II,要求找出所有出现超过⌊n/3⌋次的元素。暴力解是排序后计数,O(n log n)。但最优解是摩尔投票法(Boyer-Moore Majority Vote Algorithm),O(n)时间,O(1)空间。它的思想是:既然最多只有2个众数(因为> n/3的数不可能超过2个),那就用两个计数器模拟“选举”,抵消不同候选人票数。

再如LeetCode 442. 数组中重复的数据,要求找出所有重复元素。排序解法是O(n log n),但最优解是原地哈希(In-place Hashing):利用数组索引作为哈希表key,将nums[i]放到索引nums[i]-1的位置,冲突即重复。

所以,这篇精讲的真正目的,不是让你记住六种排序的代码,而是帮你建立一个算法决策心智模型:
当看到新题,先问:

  1. 输入数据有什么特殊性质?(范围小?已部分有序?只有几种值?)
  2. 输出要求是什么?(只要一个数?要全部?要顺序?)
  3. 时间/空间约束有多紧?(n=10^7?内存<1MB?)
  4. 这个题的“最优解”,是不是在逼你跳出“排序”这个思维定式?

排序算法,是算法世界的“普通话”,是沟通的基础。但真正的高手,早已不说“普通话”,而是在特定场景下,精准切换到“方言”——计数、桶、摩尔投票、原地哈希……这些“方言”的共同点是:它们都源于对问题本质的深刻洞察,而非对通用工具的机械套用。

你刷过的每一道排序题,都不该是终点,而应是你开始质疑“为什么非得排序”的起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询