说句实在话,我一开始看到“排序查找,简单模板”这几个字,第一反应是:这不就是每个程序员都该有的基本功吗?可等我把“排序算法”“二分查找算法”“C语言排序数组”“字符串排序”“拓扑排序”这些热搜词串在一起看,才反应过来,问题远没有这么简单。排序和查找是数据处理问题的地基,却也是最容易被轻视的部分。正因为看起来太基础,很多人在笔试、面试、实际业务里反复栽跟头:边界写错、死循环、排序结果和预期不一致、数据明明在却查不到。这篇文章就是把散落在各个热搜词里的需求收敛成一套可以直接抄的模板,把排序和查找背后的逻辑讲透,让新手能照着写,老手也能回头查漏补缺。
1. 排序查找的“模板思维”:为什么记一堆代码不如留一套模板
1.1 模板到底是什么:一种可复用的解题骨架
不少朋友听到“模板”两个字,脑子里蹦出来的是 C++ 的类模板、Java 泛型、PPT 模板或者 Word 样板文档。这些确实是模板,但排序查找里的“模板”是另一层含义:它是一段被反复验证过、能应对大部分同类问题的程序骨架。比如二分查找,不管题里数组是什么类型,只要是有序集合、要查找目标值,核心逻辑都是那十来行;变来变去的只是比较规则和区间定义。把这段骨架固定下来,遇到新题就不用从头推理边界条件,而能把注意力放在“这个题和标准模板哪里不一样”上。
我带新人的时候经常被问:我背了十来个排序算法,笔试还是会忘,是不是记忆力不行?我说你压根不该背算法,你该背的是模板。算法书里的归并排序伪代码有一大堆,但真正到了笔试考场,两三分钟能默写出来的,一定是你平时反复敲过、调过、踩过坑的那一版。这就是模板的意义:它帮你把“想”的成本变成了“查”。新人要做的第一件事,不是去网上复制高深莫测的代码,而是从朴素版本开始,敲出自己的模板库。就像厨师不会把整本菜谱背下来,但一定会在手边备一份常用调味比例和刀工节奏,模板库就是程序员的备菜筐,用的时候顺手就能抓到,不用临时翻书。
1.2 一套好模板必须包含的三个要素
我自己整理排序查找模板时,会刻意检查三样东西,缺一样都不放心放进模板库。
第一,数据怎么组织。数组还是链表,数字还是字符串,单关键字还是结构体多字段,这决定了模板的形参和返回值怎么设计。第二,比较规则是什么。升序还是降序,按第一关键字还是第二关键字,字符串是字典序还是长度优先,这些规则应该是模板里可以替换的部分,而不是写死的魔数。第三,出口条件是什么。循环什么时候结束,找不到目标返回什么值,输入为空或者长度为一时会不会出问题,这就是大家常说的边界条件。
拿二分查找举例,出口条件通常是 left > right,也就是区间里已经没有元素可查了;但如果你用的是左闭右开写法,出口就变成了 left == right。两者看起来差不多,实际运行完全不一样,混用就是死循环和越界的直接来源。模板的核心价值,就是把最容易出错的地方固定下来,不允许每次临时改来改去。这也是为什么我在做代码评审时,只要看到一个人写了好几种风格混杂的二分,就会直接告诉他:别调了,重写一版比这个快得多。
1.3 从热搜词看模板的边界:模板字符串、类模板、树状数组模板
这次整理热搜词的时候有个很有意思的现象:和“模板”绑在一起的,不止排序算法,还有“模板字符串”“类模板名称不能重复”“树状数组模板”。这说明模板思维在各条技术路线里是通用的。模板字符串是语言层面的字符串拼接骨架,类模板是类型层面的复用骨架,树状数组模板则是为解决“动态前缀和与有序统计”这类问题沉淀下来的固定写法。
所以下面给出的排序查找模板,不只是给你几段代码抄,更希望你能看到代码背后的复用思路:同样的模板,C 语言里可以用函数指针实现,C++ 里可以用仿函数和 lambda 实现,Python 里可以直接传函数参数,数据库里则变成了 ORDER BY 子句和索引查找。技术形态不同,骨架是相通的。理解了骨架,你就能在不同语言里切换,而不是每次换一门语言都要全部重学。
2. 查找类模板:从顺序查找到二分查找
2.1 顺序查找模板:最简单但应用场景最广
顺序查找可能是所有查找算法里最不值得一提的。思路就是从头到尾扫一遍,找到就返回下标,找不到返回 -1。但我得说,它的应用场景比想象中广得多:数据量小、数据无序、查找次数少、或者根本不确定用什么规则排序时,暴力遍历反而最稳。
def linear_search(arr, target): for i, value in enumerate(arr): if value == target: return i return -1这段代码没有任何技巧,时间复杂度 O(n),空间复杂度 O(1)。你可能会吐槽这也能叫模板?实际业务里我见过不少场景:一个配置文件里要查某个 key 是否存在,总共就几十项;用户上传的临时目录里要定位某个文件名;Excel 里某一行数据要按照姓名匹配另一张表……数据量在百级以内时,这种写法既不需要排序预处理,也避开了二分查找对有序要求的限制,出错概率最低。我常说,查找算法的选择不是“越高级越好”,而是“前提条件是否满足、操作成本是否可接受”。
2.2 二分查找模板:边界处理是灵魂
二分查找是查找类模板里真正的硬核角色。它有一个前提:数据必须是有序的。在这个前提下,每次取中间值和目标比较,把区间缩小一半,时间复杂度 O(log n)。大规模有序数据的查找需求,基本都是用它解决的。
def binary_search(arr, target): left, right = 0, len(arr) - 1 while left <= right: mid = left + (right - left) // 2 if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1我见过太多人在这里栽跟头,原因集中在三处。第一,mid 的算法。直接写(left + right) // 2在极端情况下可能溢出,写成left + (right - left) // 2才稳妥,这个写法在 C、C++、Java 里都适用。第二,循环条件的符号。用while left <= right表示闭区间,每轮都能处理到 left == right 时那个唯一的元素;如果写成left < right,最后一个元素就可能被漏掉。第三,在arr[mid] < target时忘记把 mid 跳过去,导致区间里永远包含当前 mid,最后死循环。
我自己的习惯是,一套代码里只维护一种区间写法。要么全用闭区间 [left, right],循环条件left <= right,更新就是left = mid + 1、right = mid - 1;要么全用左闭右开 [left, right),循环条件left < right,更新是left = mid + 1、right = mid。最怕的是左半边用左闭右开、右半边用闭区间,这种混搭是错误的最大来源。
2.3 二分查找变体模板:lower_bound 和 upper_bound
比基础二分查找更实用的,是两个变体模板。它们解决“有序数组里第一个大于等于目标值的位置”和“第一个大于目标值的位置”这类问题,对应 C++ 标准库里的 lower_bound 和 upper_bound。
def lower_bound(arr, target): left, right = 0, len(arr) while left < right: mid = left + (right - left) // 2 if arr[mid] < target: left = mid + 1 else: right = mid return left def upper_bound(arr, target): left, right = 0, len(arr) while left < right: mid = left + (right - left) // 2 if arr[mid] <= target: left = mid + 1 else: right = mid return left这两个模板用的是左闭右开区间写法。lower_bound 返回第一个arr[mid] >= target的下标,upper_bound 返回第一个arr[mid] > target的下标。二者之差,正好是 target 在数组里的出现次数。需要统计“有序集合中某个数值区间的元素个数”时,一行代码就能算出来,不用再循环遍历比大小。
为什么变体这里不用闭区间?因为左闭右开和 STL 语义天然对齐,返回值可以直接当数组下标用,对空数组也安全,不会返回一个让人误会的哨兵值。我建议把基础二分和变体二分分开记忆,二者循环条件和区间更新策略不同,强行合并成一版反而容易混。如果笔试里不确定,就花三十秒,从“数组长度为 1”这个最小例子推一遍再写,基本不会错。
2.4 从查找模板到实际场景:字符串查找、Excel 单元格查找
查找目标不一定都是纯数字数组。“python查找excel中字符串”这类需求,本质是把一列数据当成字符串数组,做精确匹配或者子串匹配。数据量大时,先排序再用二分查找模板定位;量小时,线性扫一遍足够。而“excel查找替换功能输入不了特殊符号”这个热搜词,根源在于 Excel 的查找替换对话框把星号、问号当成了通配符,想查字面的这些字符,需要在前面加波浪号转义。这和编程里查找模板的逻辑一样:查找规则不对,目标永远查不中。
所以我说,查找算法的选择要结合数据规模、有序性和查找频率。有人一上来就写二分,结果发现数据根本没排序,还得先排序,排序完又发现该数据每次都在变,维护排序的代价反而更高。这种时候顺序查找其实是更合理的选择。判断力比代码本身更值钱。
3. 排序类模板:从基础排序到复杂排序
3.1 冒泡排序与选择排序模板:O(n²) 也不丢人
排序算法里最容易在面试中被问的是快排和归并,但最容易被忽略的反而是基础排序。我在业务里见过有人用冒泡排几千行配置数据——那其实不算错,数据量小,代码简单,维护成本低。问题在于很多人连这个简单模板都写不稳。
选择排序有个隐藏的坑:每一轮并不是立刻交换,而是先找到本轮最小值下标,一轮结束后再交换。如果每比较一次就交换一次,会把大量无意义的赋值操作做进去,虽然功能没错,性能却白白降低。
void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; } } if (min_idx != i) { int tmp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = tmp; } } }冒泡排序模板建议加一个剪枝标志,如果某轮没有任何交换,说明数组已经有序,直接退出。这个优化在接近有序的数据上能省下大量时间,而且代码并没有复杂多少。
void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) break; } }这两个模板适合笔试基本功检查和小规模数据验证思路。它们不是最优雅的,但一定是最不容易写错的。快排分区逻辑一复杂就容易出问题,选择排序反而可以闭着眼写。
3.2 快速排序模板与归并排序模板:稳定性和逆序对的取舍
真正在生产环境被高频使用的排序是快速排序。绝大多数语言标准库的排序实现,底层都基于快排的变体,所以业务代码里通常不该自己写快排,直接调用 sort 就行。但作为模板,我建议掌握一个版本,因为笔试说不准什么时候就会让你手撕。
def quick_sort(arr): if len(arr) <= 1: return arr pivot = arr[len(arr) // 2] left = [x for x in arr if x < pivot] mid = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quick_sort(left) + mid + quick_sort(right)这个版本的效率不是最高的,但理解成本最低。它用三句话做了分区:小于、等于、大于。第一次接触快排的人,从这个写法入门,更容易看清分治思想。等你需要写 C 语言版本时,再把列表推导换成双指针原地分区即可。归并排序模板的价值则在稳定性和分治结构:
def merge_sort(arr): if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) res = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: res.append(left[i]) i += 1 else: res.append(right[j]) j += 1 res.extend(left[i:]) res.extend(right[j:]) return res归并排序时间复杂度稳定在 O(n log n),且是稳定排序。如果你在做“统计逆序对”的题,归并的合并过程顺手就能算出来;如果排序对象是结构体,且要求相同关键字保持原顺序,也必须选择稳定排序。这也是为什么很多算法题的标准答案都会用归并。
3.3 自定义比较器:结构体排序的灵魂
真正能让排序模板从“能跑”变到“好用”的,是比较器。C 语言的 qsort 规定比较函数返回负数、零、正数,C++ 的 sort 支持 lambda 表达式,Python 的 sort 接受 key 函数,Java 里则是 comparator。这些都是同一件事的不同外衣。
“C语言排序数组”看起来简单,但如果数组里存的是结构体呢?比如按姓名字典序排学生记录,再按年龄升序作为第二关键字。我习惯写一个比较函数传给 qsort:
struct Student { char name[64]; int age; }; int cmp_student(const void *a, const void *b) { const struct Student *sa = (const struct Student *)a; const struct Student *sb = (const struct Student *)b; int name_cmp = strcmp(sa->name, sb->name); if (name_cmp != 0) return name_cmp; return (sa->age > sb->age) - (sa->age < sb->age); } qsort(students, n, sizeof(struct Student), cmp_student);这里有个常见错误,比较函数写成return a->age - b->age。在 age 差值很大的时候可能溢出,导致排序结果诡异。稳妥的写法是像上面那样用逻辑表达式(sa->age > sb->age) - (sa->age < sb->age),它只会返回 -1、0、1,永不溢出。这类细节就是面试官最爱挖的坑,也恰恰是模板注释里最该写的注意事项。
3.4 拓扑排序模板:有依赖关系的“排序”
除了数值排序,还有一种排序在处理任务调度、课程编排、流程图时非常常见,就是拓扑排序。它的输入是有向无环图,输出是一个线性顺序,保证每个节点都出现在所有依赖它的节点之前。
from collections import deque def topo_sort(n, graph, in_degree): q = deque([i for i in range(n) if in_degree[i] == 0]) order = [] while q: u = q.popleft() order.append(u) for v in graph[u]: in_degree[v] -= 1 if in_degree[v] == 0: q.append(v) if len(order) == n: return order else: return []这个模板有两个关键点。第一,入度为 0 的节点先入队,它们没有前置依赖,可以先输出。第二,每处理完一个节点,把它后继节点的入度减一,减到 0 再入队。如果最后 order 长度不等于 n,说明图里有环,不存在合法的拓扑排序。这类模板我在给前端依赖树做构建顺序规划、给任务列表做依赖排序时都用到过,属于“排序”里思路最特别的一种。
3.5 其他排序模板:字符串排序、树状数组辅助排序
字符串排序的核心问题无非是“按字典序还是按长度排”,以及“中文按拼音还是按 Unicode 码排”。实际工程里,Python 用 sorted 加 key 参数,C 语言用 qsort 加 strcmp,Java 用 Collections.sort 加自定义 comparator。这里的模板主要就是比较规则的定义,代码本身反而简单,反而不值得去背所谓“字符串排序算法”。
树状数组模板则适合处理“动态前缀和 + 排序后统计”的场景。比如要快速查询某种数值在已排序数组中的排名,可以先对数组离散化,再用树状数组记录每个排名出现的次数,这样在某个排名区间内做统计时,查询和更新的复杂度都是 O(log n)。这类模板在竞赛题里常见,业务里用得少,但思路值得收藏起来,遇到“频繁更新 + 频繁问排名”的需求时,会比排序后暴力统计优雅得多。
4. 模板在真实业务和办公场景中的应用:不只会写代码
4.1 数据库排序与查找:MySQL ORDER BY 背后的有序性逻辑
日常写业务时,排序查找的需求很少自己写算法,而是都交给了数据库。“mysql排序”“oracle 取值大小排序”“sql server 分组后组内排序”这些热搜词说的其实就是这件事。
MySQL 的 ORDER BY 语句,如果表很小,直接全表扫再排序没什么问题;数据一旦上了百万级,没有任何一个代码模板能救你,只能靠索引。B+ 树索引天然保持数据有序,优化器在排序时可以利用索引顺序扫描,避免额外的 filesort。这里的“排序查找模板”其实就是 SQL 的写法规范:过滤条件要能命中索引,排序字段最好和 where 条件的联合索引匹配,字符串排序要注意字符集和排序规则。
举一个常见的反例:WHERE 子句里写了LOWER(name) = 'abc',这类对列做函数运算的写法会破坏索引的有效性,查询就会走全表扫描,性能断崖式下跌。把业务查询当成“数据有序性”问题来思考,其实和写二分查找模板时的思路完全一致:先看前提条件是否满足,再决定用哪种路径。这不是让你背 SQL 技巧,而是让你养成先看执行计划、再写语句的习惯。
4.2 办公自动化中的模板:Word、PPT 和“改了打不开”的问题
再来看“poi 生成 word”“通过修改模板中的图表数据修改word图表”“修改数据后无法打开生成的word”这些词。它们属于办公自动化里的模板文件操作。很多朋友会把 docx 文件当成普通文件直接改内部 XML,或者在原模板上反复覆盖再另存,结果很容易损坏文件。
我的经验是:用 Apache POI 修改 Word 模板里的图表数据,最稳妥的做法是先把模板读成内存对象,再以它为基础生成新文档,而不是每次直接改同一个文件再另存。也就是说,要保持“基础模板 + 每次生成副本”的生产模式。这样能避免文件内部引用错乱,也防止生成到一半时原模板被破坏。POI 对图表数据源的处理在不同版本上有不少差异,千万不要让修改逻辑长在同一个文件句柄上,否则很容易出现“改了之后打不开”的问题。“模板”这个词在这里的含义是复用骨架,但复用时必须保留一份干净底稿,每次都在底稿上做一次性派生。
4.3 编译期模板问题:类模板名称不能重复
热搜词里“类模板名称不能重复”其实是个经典编译报错:在同一个命名空间或作用域里声明了两个同名类模板。这类报错本身很简单,但解决并不总是删一行那么简单。如果你在做版本兼容,可能有两种功能不同但名称恰好相同的类模板,需要把其中一个放进独立的 namespace,或者用预处理宏控制编译分支。
从模板的复用视角看,这个报错也在提醒我们:代码模板不是越多越好,每个模板都要有独立的名字和明确职责。我的代码库里就曾出现过同事复制了一个模板类又忘记改名,编译报错查了半天,最后靠全局搜索同名类模板才定位。从此我给自己定了个规矩:模板文件放进工程,先检查类名唯一性,再检查构造函数、成员函数和既有模板是否冲突。这个检查耗时不到一分钟,但能省掉每次几小时排查成本。
5. 常见问题与排查技巧实录
5.1 二分查找死循环与越界:两个必查项目
二分查找最容易出现的问题有三个,我列成一张速查表:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 程序卡住不结束 | 区间更新没有排除 mid,或循环条件写反 | 确保每轮 left 或 right 严格缩小 |
| 越界访问 arr[mid] | right 初始值写成 len(arr),却使用闭区间循环 | 统一区间写法,不混用左闭右开和闭区间 |
| 找到却返回错误位置 | 比较规则写反,或者数组没有先排序 | 先排序再查找,排序比较和查找比较保持一致 |
排查时,我会在循环里打印 left、mid、right 三个值,观察区间是否一直在收缩。只要每轮都严格收缩,二分查找就不会死循环。越界问题几乎全部来自 left、right 初始化和循环条件的“半套组合”。写模板之前,先想清楚你是左闭右开还是闭区间,然后全程都按这一种写。
5.2 排序结果和预期不一致:稳定性和比较器双重检查
排序结果“看起来不对”,第一件事查的是比较器。我在一次真联调里遇到过,排序结果在本地和服务器上不一样,最后发现是比较器用了浮点数比较,精度问题导致本应相等的数值被判定不相等。第二件事是查排序是否稳定。如果排完之后相同关键字的相对顺序变了,看起来就会像“乱排”,这时要换成归并排序这类稳定排序,或者在比较器里加入第二、第三关键字,明确优先级。数据量不大时,直接在排序前打印几条原始数据,排序后再打印几条,对照着看,比盯着代码猜快得多。
5.3 查找不到时的返回值设计:别让 -1 变成炸弹
顺序查找和二分查找在找不到目标时一般返回 -1,但这在业务里经常导致后续逻辑混乱。比如你在模板方法里直接拿返回下标去取数组元素,-1 在某些语言里会访问数组最后一个元素,Python 里甚至不会报错,结果却完全是错的。所以我建议在调用处增加一个判断,找不到时走独立分支,不要继续使用结果。如果封装的是一个库函数,返回值语义要写清楚,最好用“返回下标 + 是否存在的布尔位”这种组合,或者干脆返回 NULL / 空值,避免调用方把 -1 当正常下标。这个设计看似细节,线上故障往往就藏在里面。
5.4 模板代码管理的几个土办法
最后分享几个我整理模板的土办法。一是每个模板文件开头写清楚时间复杂度和适用条件,注明这个模板的坑在哪里。二是用统一命名规则,比如 binary_search / binary_search_first_ge,前一个找精确位置,后一个找第一个不小于目标值的位置,避免两个模板长得像、用时拿错。三是定期把笔试和线上问题里踩到的新坑补进注释,把模板库当成长期积累而不是一次性脚本。你会发现,自己整理的模板才是真正能随手拿来用的,因为它记得住每个坑。
我个人在实际使用中最大的体会是,每次碰壁后把“错误写法”和“正确写法”一起补进模板注释,刚开始觉得浪费时间,但当我在一次线上问题里从模板库翻出那条注释,发现和这次问题一模一样时,才明白真正让模板值钱的,不是你十分钟内默写出的代码,而是踩过的每一个边界条件的坑。你如果也有自己的排序查找模板库,不妨花半天时间重新过一遍,把之前没写明白的边界条件补上,以后写任何查询、排序逻辑都能更踏实。这个积累,越早开始越好用。