说实话,刚看到“Day 7 数组排序查找”这个主题时,我的第一反应不是兴奋,而是“这有什么好学的?”。等到我自己写代码、跑测试、看底层库函数实现,才发现这个组合远比想象中阴险。数组排序和查找,看起来是两个独立知识点,实际上是一套组合技:排序决定了后续查找的代价,查找反过来也会要求排序满足某些顺序特性。第七天这天,我把“找第K大的数”这一道题从暴力做法一路做到了快速选择和堆,整个过程踩了不少坑,也把排序、二分和边界问题彻底串了起来。这篇文章就是那天学习的完整记录,也是一份能直接照着练的参考。
如果你正处在学数组、排序、查找的阶段,或者刷算法题刷到二分和快排时总觉得“代码跟我想的不一样”,那这篇内容应该能帮上忙。我不会只丢模板给你,我会把背后的逻辑、坑点和选型依据一起拆开讲。
1. 为什么偏偏是数组:排序与查找这对组合的底层逻辑
1.1 数组的存储特性决定了查找和排序的天然联动
数组在内存里是一段连续空间。a[i] 的地址可以写成 base + i * size,所以不管数组有多大,随机访问任意下标都只要一次内存访问,复杂度是 O(1)。可正因为“连续”,插入和删除一个元素通常需要移动后续所有元素,复杂度是 O(n)。这看起来像个劣势,但同时也带来了两个极重要的优势:第一,数组元素可以原地交换,排序时不需要额外链表指针;第二,数组一旦有序,查找就能用二分折半,查找复杂度从 O(n) 直接降到 O(log n)。
很多人学数组的时候,只记住了“下标从0开始”“长度是固定的”,却没有把“连续内存”和“二分查找”联系起来。假如现在数据结构换成链表,即便链表是有序的,二分查找也无法直接使用,因为每次取中间点都需要从头部走 n/2 步,复杂度又变回 O(n)。所以数组的连续存储是所有排序和高效查找算法存在的前提。
这也就解释了为什么算法题里大量出现“无序数组 + 先排序再查找”的模式。排序是为了构造有序条件,查找是为了在有序条件下高效工作。两者不是孤立的,而是同一条流水线的上下两段。
1.2 一个真实的任务拆解:从“找最值”到“排好序再找”
假设你手里有一组成绩数据:[78, 92, 85, 92, 63, 99],现在想知道第3高的分数是多少。最朴素的想法是:扫描数组3次,每次把当前最大值拿走,但数组本身不能真的“拿走”,你还得标记已访问。这样做的复杂度大约是 O(3n),也就是 O(kn)。如果 k 接近 n/2,扫描 k 次的成本就到了 O(n²)。
换一个思路:先对数组排序,排完序后按下标直接取。排序的时间复杂度是 O(n log n),然后用一次随机访问拿到答案。假设 n=1000,k=500,扫描法要比较大约50万次,排序法只需要一万次左右的操作。数据规模越大,排序后再查找的优势越明显。
实际做需求时也是一样:如果只查一次最大值,直接扫描就好,排序纯属浪费;但如果同一个数组要反复做范围查询、前K大、中位数、分位数,那一次排序的成本可以被后续无数次 O(log n) 查找摊薄。这种“使用频率决定要不要排序”的判断,比背任何一个算法都重要。
1.3 排序与查找的学习优先级:先建立直觉还是先套模板
我一直建议初学者先手写几个基础排序,再去学二分查找。原因是:二分查找的正确性完全建立在“数组有序”这个前提上,如果你连有序性是怎么构造出来的都没感受过,遇到边界问题会非常心虚。手写一遍冒泡、选择、插入,能让你真正看到“每轮之后哪些元素已经就位”,这比背十遍二分模板都有用。
直接背二分模板也不是不可以,但它会带来一个副作用:把算法当成黑盒。一旦题目稍作变形,比如找第一个等于 target 的元素、找最后一个小于 target 的元素,模板就失灵了。所以第七天的学习顺序我是这样定的:先手写排序,验证输出确实有序;然后在有序数组上实现二分;最后处理重复元素和边界情况。这个顺序走下来,后面做变形题会顺得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写排序前必须建立的三个心智模型
2.1 冒泡排序:相邻交换背后的“冒泡”直觉
冒泡排序的思路很直白:重复扫描数组,比较相邻两个元素,如果顺序不对就交换它们。每一轮扫描过后,当前未排序部分的最大值就会像气泡一样“浮”到数组末端。因为每一轮都能锁定一个位置,最多 n-1 轮就能完成排序。
python复制def bubble_sort(nums):
n = len(nums)
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
swapped = True
if not swapped:
break
return nums
代码里有一个很容易忽略的点:内层循环的范围是 range(n - 1 - i),因为每一轮结束后,数组末尾 i+1 个元素已经有序,不需要再参与比较。swapped 标志是优化项,如果某一轮没有任何交换,说明数组已经整体有序,可以提前退出。
冒泡排序的平均和最坏复杂度都是 O(n²),但在数组已经有序的情况下,加上提前退出后可以做到 O(n)。它是稳定的排序算法,因为只有相邻元素逆序时才交换,相等元素的相对顺序不会被破坏。它的主要价值在于教学:逻辑足够简单,能让人直观理解“交换”这件事。
2.2 选择排序:每轮锁定一个位置的“教官”思路
选择排序是另一种很容易理解的排序方式:每一轮从剩余元素中找出最小值的下标,然后把它和当前位置交换。就像教官从一支队伍里选出最矮的人,让他站到队伍最前面,然后继续在剩下的人里重复这件事。
python复制def selection_sort(nums):
n = len(nums)
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if nums[j] < nums[min_idx]:
min_idx = j
if min_idx != i:
nums[i], nums[min_idx] = nums[min_idx], nums[i]
return nums
选择排序的优势在于交换次数少,每轮最多交换一次,总共最多交换 n-1 次。这一点在“交换操作代价很高”的场景里会有意义。但它的比较次数是固定的 O(n²),无论数组是否有序都得跑完。
一个容易被忽略的缺点是不稳定。举个简单的例子:[2a, 1, 2b],第一轮找到最小值 1,与 2a 交换后数组变成 [1, 2a, 2b],此时两个 2 的相对顺序没有变;但如果最小值重复出现且与某个相等元素交换,就可能打乱相对顺序。具体场景后文会再讲。
2.3 插入排序:整理扑克牌时你早就会了
插入排序的思路,大多数人打扑克时已经用过了:从左到右处理元素,每拿到一个新元素,就把它插到前面已经排好序的部分中正确的位置。前面元素往后挪,给新元素腾出空间。
python复制def insertion_sort(nums):
n = len(nums)
for i in range(1, n):
cur = nums[i]
j = i - 1
while j >= 0 and nums[j] > cur:
nums[j + 1] = nums[j]
j -= 1
nums[j + 1] = cur
return nums
插入排序的最佳时间复杂度是 O(n),也就是数组本身已经有序时,每个新元素只需要和前面一个元素比较一下就能停下。平均和最坏是 O(n²)。它的稳定性很好,且对小规模数组、近乎有序数组非常高效。
实际上,许多语言的标准库在排序小数组时会退回到插入排序,而不是继续用快速排序。因为小规模数据下,函数调用和递归的开销可能比 O(n²) 的扫描还要贵。这也是“理论复杂度”和“工程实践”经常不一致的典型例子。
2.4 三种基础排序的复杂度对照和适用边界
把三种排序放一起看,才能理解为什么没有“最好的排序”。
| 排序算法 | 最好时间 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 教学、理解交换 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 交换代价高、数据量小 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 小规模、近乎有序数组 |
实际开发里,我不推荐你手写这三种排序去处理大数组,标准库里的排序算法通常经过大量优化,性能远比“教学版”好。但理解它们仍然很重要,因为后面学快速排序、归并排序时,你会频繁用到“分区”“合并”“有序前缀”这些概念,而这些概念在这三种基础排序里已经有了雏形。
3. 二分查找:有序数组的“折半”陷阱与正确姿势
3.1 区间定义:左闭右闭还是左闭右开?
二分查找的代码只有短短几行,但出错率极高。第一个要命的决定就是区间定义。常见的写法有两种:左闭右闭 [l, r] 和左闭右开 [l, r)。两种写法都可以,但绝不能混着来。
左闭右闭的写法最直观:
python复制def binary_search(nums, target):
l, r = 0, len(nums) - 1
while l <= r:
mid = (l + r) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
l = mid + 1
else:
r = mid - 1
return -1
这里初始化 r = len(nums) - 1,代表当前搜索区间包含 r。循环条件 l <= r 意味着“区间里至少还有一个元素时继续”。更新时,既然 mid 已经被排除,下一次搜索就应该在 [l, mid-1] 或 [mid+1, r] 中进行。
左闭右开写法也常见:
python复制def binary_search_half_open(nums, target):
l, r = 0, len(nums)
while l < r:
mid = (l + r) // 2
if nums[mid] < target:
l = mid + 1
else:
r = mid
return l if l < len(nums) and nums[l] == target else -1
左闭右开的好处是 r 本身不参与检查,通常用于需要查找“第一个满足条件的位置”的场景。难点在于很多人记不住初始化 r 到底取 n 还是 n-1,结果代码一会儿越界一会儿漏元素。
我个人的习惯是:如果只是查找某个确定的值,用左闭右闭;如果要做 lower_bound 这类边界查找,用左闭右开。把两种写法都练熟,但同一段代码里绝对不混用。
3.2 终止条件:为什么while里的等号能决定生死
很多人写二分时死循环,根本原因是对循环条件理解不够。左闭右闭写法里,l == r 时区间仍然包含一个元素,必须再比较一次。因此循环条件必须是 l <= r。如果写成 l < r,当搜索区间只剩一个元素时循环退出,可能直接漏掉正确答案。
可以用一个简单例子感受一下:nums = [1, 3, 5, 7],target 是 5。左闭右闭写法里,初始 l=0, r=3,mid=1,nums[1]=3 < 5,于是 l=2。此时 l=2, r=3,区间为 [2,3],仍然有两个元素,继续;mid=2,nums[2]=5,找到答案。
如果循环条件是 l < r,在 l=2, r=3 时会继续;但当 l==r 时退出。假设 target 是 7,数组 [1,3,5,7],最终 l=3,r=3,如果循环条件是 l < r 就会退出并返回 -1,但 7 明明在下标 3 的位置。这就是漏检。
左闭右开写法正好相反:l == r 时区间为空,所以循环条件只要 l < r 即可。两个写法的退出条件本质是“区间不为空就继续”,只是区间是否为空的判断方式不同罢了。
3.3 变体问题:查找第一个/最后一个相同元素
数组里经常有重复元素。比如 [1, 2, 2, 2, 3],单纯问“2 在不在”很好办,但“第一个 2 的下标”和“最后一个 2 的下标”就需要更精细的二分。
查找第一个不小于 target 的位置,通常叫 lower_bound:
python复制def lower_bound(nums, target):
l, r = 0, len(nums)
while l < r:
mid = (l + r) // 2
if nums[mid] < target:
l = mid + 1
else:
r = mid
return l
这段代码用左闭右开写法。当 nums[mid] < target 时,说明 mid 以及 mid 左边都太小,可以全部丢弃,所以 l = mid + 1;否则说明 mid 可能已经是答案或答案在左边,所以 r = mid。最后 l 指向第一个不小于 target 的位置。
查找第一个大于 target 的位置就是 upper_bound:
python复制def upper_bound(nums, target):
l, r = 0, len(nums)
while l < r:
mid = (l + r) // 2
if nums[mid] <= target:
l = mid + 1
else:
r = mid
return l
于是“最后一个等于 target 的下标”就是 upper_bound(nums, target) - 1,前提是这个位置确实等于 target。这两个函数也是很多语言标准库里二分接口的原型,理解了它们,再去看 C++ 的 lower_bound/upper_bound,或者 Python 的 bisect 模块,会瞬间觉得熟悉。
3.4 二分查找的超能力:不止是查数字
二分查找的本质不是“在数组里找一个数”,而是“在一个单调序列里不断缩减搜索空间”。所以它能解决很多看起来完全不像查找的问题。
比如求平方根:给定一个非负整数 x,要求整数平方根。可以令查找区间为 [0, x],判断条件是 mid * mid <= x。这个条件是单调的:一旦某个 mid 满足,所有更小的 mid 也都满足。于是二分可以找到最大的满足条件的 mid,这就是整数平方根。
再比如旋转数组找最小值。一个有序数组在某点旋转后,例如 [4,5,6,1,2,3],它不满足全局单调,但可以用二分根据中间值和右端值的关系判断最小值在左半还是右半。这类问题的共同点都是利用“一部分有序”或者“条件单调”来折半收缩。
使用二分的唯一前提是:你能判断“答案在左半边还是右半边”,而且这个判断必须单调。如果条件不单调,二分一定会给出错误结果。这是很多人把二分套到非单调问题上栽跟头的原因。
4. 排序+查找组合实战:从无序数组中快速定位第K大的数
4.1 最直接的思路:排序后按下标取
如果只要找第 K 大,最简单的做法就是先排序再取下标。升序排序后,第 K 大对应下标 len(nums) - K。
python复制def find_kth_largest_by_sort(nums, k):
nums.sort()
return nums[-k]
这段代码简洁到没什么可解释的。时间复杂度 O(n log n),空间复杂度取决于排序是否原地,Python 的 list.sort() 是原地排序,额外空间为 O(1)。
问题在于它做了大量无用功:为了知道第 K 大元素,排序把整个数组所有元素的位置都排好了。如果 K 很小,比如找第 2 大,排序后的很多工作其实没必要。但数据量不大时,这仍然是最稳妥、最少出错的方案。遇到规模几千、几万的题目,直接排序通常完全够用。
4.2 快速选择:借用快排partition的思想
快速排序的关键是 partition 函数:选择一个元素作为 pivot,把数组分成“小于 pivot”和“大于等于 pivot”两部分,并返回 pivot 最终所在的位置。这个位置一确定, pivot 在有序数组中的最终位置也就确定了。
如果我们要找第 K 大,也就是升序下标 idx = len(nums) - K 的元素。每次 partition 后,如果返回的下标正好等于 idx,直接返回;如果 idx 在左边,就只处理左半部分;如果在右边,就只处理右半部分。这相当于快排只递归了一侧。
python复制def partition(nums, l, r):
pivot = nums[r]
i = l - 1
for j in range(l, r):
if nums[j] < pivot:
i += 1
nums[i], nums[j] = nums[j], nums[i]
nums[i + 1], nums[r] = nums[r], nums[i + 1]
return i + 1
def quick_select(nums, l, r, idx):
p = partition(nums, l, r)
if p == idx:
return nums[p]
elif p < idx:
return quick_select(nums, p + 1, r, idx)
else:
return quick_select(nums, l, p - 1, idx)
调用时:find_kth_largest(nums, k) 就是 quick_select(nums, 0, len(nums)-1, len(nums)-k)。
为什么快速选择的平均复杂度是 O(n)?因为第一次 partition 需要扫描 n 个元素,之后只需要处理一侧,规模大约减半:n + n/2 + n/4 + ... = 2n。这是很漂亮的线性复杂度。但最坏情况下,如果每次 pivot 都选到最小或最大元素,每次只排除一个元素,复杂度会退化到 O(n²)。缓解方法很简单:随机选择 pivot,或者在 partition 前把 nums[r] 与某个随机位置的元素交换。
4.3 小顶堆方案:处理海量数据流
如果数组太大,无法一次性读入内存,或者数据本身是流式产生的,那么“先排序”就不现实了。这时候可以维护一个大小为 K 的小顶堆:堆顶永远是堆中最小的元素,也就是当前已经见过的元素中第 K 大的元素。
python复制import heapq
def find_kth_largest_heap(nums, k):
heap = []
for x in nums:
if len(heap) < k:
heapq.heappush(heap, x)
elif x > heap[0]:
heapq.heapreplace(heap, x)
return heap[0] if len(heap) == k else None
堆的复杂度是 O(n log K)。K 很小时非常高效,K 接近 n/2 时复杂度会上升,但仍然只消耗 O(K) 空间。更重要的是,它天然支持数据流:任何时刻想知道当前流的第 K 大元素,堆都能立刻给出答案。
还有一个细节:如果数据中有大量重复值,堆里会保留重复值,这没有关系。但如果你要的是“去重后的第 K 大”,那堆方案就不好使了,需要先哈希去重再另想办法。
4.4 三种方案的复杂度与稳定性对比
| 方案 | 时间复杂度 | 额外空间 | 数据流友好 | 改动数组 | 适用场景 |
|---|---|---|---|---|---|
| 排序后取下标 | O(n log n) | O(1) | 否 | 是 | 数据量小、代码简单 |
| 快速选择 | 平均 O(n),最坏 O(n²) | O(log n) 递归栈 | 否 | 是 | 内存可控、单次查询 |
| 小顶堆 | O(n log K) | O(K) | 是 | 否 | 海量数据、数据流 |
选型时我的建议是:能一次加载到内存且需要避免最坏情况,优先用快速选择加随机 pivot;数据是流式的,或者 K 很小且需要反复查询,用小顶堆;如果只是写一道算法题、不追求极限性能,直接 sort 然后取下标,省心不出错。
5. 实际工程中比“能不能跑”更重要的四个问题
5.1 稳定排序 vs 不稳定排序:字段排序时的次序问题
排序算法的稳定性指的是:如果两个元素相等,排序后它们的相对顺序和排序前相同。稳定排序听起来是个细节,但实际中影响很大。
假设你有一组学生数据,先按姓名排序,再按成绩排序。如果你用稳定排序做第二次排序,那么成绩相同的学生仍然会按姓名排序;但如果第二次用的是不稳定排序,姓名顺序就完全乱了。很多语言的标准库对这个有明确区分:Python 的 sorted 是稳定的,C++ 的 std::sort 不保证稳定,std::stable_sort 才保证稳定。如果你依赖了这个性质,必须先确认语言行为。
不稳定排序带来的一个经典问题就是:多次按键排序时,想保留上一次的排序结果几乎不可能,除非给每条记录加上一个自增序号作为第二关键字。稳定排序则天然解决了这个问题。
5.2 重复元素处理:边界测试最容易翻车的地方
数组里全是重复元素时,排序和查找都很容易出幺蛾子。二分查找面对 [2,2,2,2,2] 时,返回哪个下标都不算错,但如果你要的是“第一个2”,普通二分就可能返回中间的下标。快速选择面对重复元素时,如果 partition 写得不好,等于 pivot 的元素会被分到某一侧,另一侧可能完全没有进展,导致递归无法终止。
所以写完排序或查找算法后,至少要跑这几组测试:空数组、单元素数组、全部相同元素、逆序数组、已经有序数组、大量重复且目标值重复的数组。我见过太多同学用 [1,3,5,7] 测完二分就觉得自己写对了,结果一上 [1,1,1] 直接死循环。边界测试不是“加分项”,是“保命项”。
5.3 内存开销:原地排序与辅助空间的取舍
排序算法的时间复杂度被讲得很多,空间复杂度却容易被忽略。快速排序平均需要 O(log n) 的递归栈空间,最坏情况下递归深度是 O(n),虽然可以通过随机 pivot 避免,但极端输入下仍可能爆栈。归并排序稳定且复杂度固定 O(n log n),但需要 O(n) 额外空间。选择排序、插入排序、堆排序都是 O(1) 额外空间的原地排序。
在内存受限的环境下,比如处理一个接近内存上限的大数组,额外空间是致命的。这时堆排序(O(n log n)、O(1) 空间)会比快速排序更稳妥,但它不稳定。工程上经常是“稳定/不稳定”和“空间/时间”的博弈,没有银弹。这也是为什么标准库里的排序算法会针对数据规模切换策略,而不是只用一种。
5.4 排序接口的设计:比较器、回调与函数式写法
实际项目中手写排序的机会很少,大部分时间你会用语言自带的排序接口。Python 里 sorted 和 list.sort 支持 key 参数,可以指定排序依据;也支持 reverse。例如按成绩降序、姓名升序排序:
python复制data = [
{"name": "A", "score": 92},
{"name": "B", "score": 85},
{"name": "C", "score": 92},
]
data.sort(key=lambda x: (-x["score"], x["name"]))
这种写法比自定义比较器更清晰,也更容易和稳定排序结合。因为 key 函数会先计算出每个元素的排序键,排序过程中不再反复调用原数组元素。
如果你在写一个需要支持不同排序规则的库函数,那么传入比较器或 key 函数是必然的。设计接口时要注意:比较器的开销会影响整体排序性能,所以能预先计算 key 就不要在比较器里每次从头算。比如对一个对象数组按某个耗时字段排序,预先把字段提取出来作为 key,会明显更快。
6. 我的day 7实测心得:把样例喂给代码前,先做一个“纸上调试”
6.1 最容易被忽略的输入:空数组和单元素数组
很多排序和查找代码在常规例子上跑得好好的,一遇到空数组就立刻崩。比如二分查找里 l=0, r=-1,如果循环条件写错,根本不会进入循环,还能返回 -1;但如果你在循环外访问 nums[l],就直接越界。快速选择里如果数组为空,调用 partition 前就应该处理。
单元素数组也是高频坑。比如找第1大,快速选择的 l == r 时应该直接返回 nums[l],否则会继续 partition,而 partition 里 pivot = nums[r] 没问题,但递归区间可能变成 l > r,导致函数没有终止条件。无论写哪种算法,我都建议在函数入口先判断:
python复制if not nums:
return None
if l >= r:
return nums[l]
这种防御式写法不丢人,反而能让你把注意力放在核心逻辑上。
6.2 为什么调试时最好打印每一轮的状态
排序和二分这种算法,出错时眼睛很难直接看出来。我的习惯是先在循环里打印关键状态。冒泡排序打印每一轮循环结束后的数组,能立刻看到“最大值到底有没有浮到末尾”;二分查找打印 l, r, mid 以及 nums[mid],死循环的原因一眼就能定位。
有一次我写快速选择时递归栈溢出,怎么都想不明白。后来在函数开头打印 l, r, p 和当前的 nums,才发现 partition 返回的 p 一直等于 l,导致每次递归区间没有缩小。原因是我处理等于 pivot 的元素时全都放到了左边,而 pivot 又总是选到了最小值,于是每次只能排掉一个元素。看到打印结果后,问题就清楚了:要么随机选 pivot,要么把等于 pivot 的元素分散到两侧。
所以别嫌 print 慢,调试阶段 print 比断点更轻量,尤其对纯函数式算法来说,每轮状态输出就是最好的注释。
6.3 一个小死循环示例:二分里 l = mid 的后果
二分查找很容易写出一个“看似合理”但会死循环的版本:
python复制def search_bad(nums, target):
l, r = 0, len(nums) - 1
while l <= r:
mid = (l + r) // 2
if nums[mid] < target:
l = mid # 错误
elif nums[mid] > target:
r = mid # 错误
else:
return mid
return -1
当 target 大于数组中所有元素时,l 会不断变成 mid,而 mid 又被整数除法卡在原地:l=2, r=3, mid=2,更新后 l=2, r=3,永远不变。正确写法是 l = mid + 1,因为 mid 已经被检查过且不可能是答案,必须把它排除在搜索区间之外。同理,左闭右闭里 r = mid - 1,左闭右开里则可以是 r = mid,因为右端点本身不参与检查。
这个错误让我意识到一个通用原则:二分更新边界时,必须保证搜索区间确实在缩小,否则循环条件再对也会死循环。你可以用一个小数组,比如 [1, 2, 3],在纸上手写几轮循环,效果比任何调试器都好。
第七天结束前,我把快速选择、二分查找、lower_bound、upper_bound 这几套模板抄在笔记本上,每个模板旁边都写了一个最容易错的点。后面再做数组相关的题目时,这套“先理解、再写码、最后用边界样例自检”的打法帮我省了很多时间。如果你也正在学数组排序查找,别急着把代码贴上去跑,先拿一支笔在纸上走一轮小数组,很多 Bug 会在这一步自己现出来。
