这周被一个朋友问到LeetCode 986 - Interval List Intersections 的C语言解法,顺手把自己当年的提交记录翻了出来,发现有两处写法完全可以优化。这题在区间类题目里属于“双指针模板题”,但用C语言提交时,二维数组的返回方式、列数数组的填充、内存分配的上限,每个地方都有隐蔽的坑。我把它当成一次完整的刷题复盘来写,从破题思路到C语言实现细节,再到边界情况和面试扩展,一次性讲透。无论你是刚刷LeetCode的C语言新手,还是准备面试前快速过一遍区间题,这篇都能直接复用。
1. 这道题到底在考什么:有序且不重叠的区间列表,暗示了哪种算法
1.1 题目本质与示例拆解
题目给了两个列表,每个列表里面存的是闭区间,形如 [start, end]。关键约束是:每个列表内部的区间两两不重叠,并且已经按 start 排好序。要求返回这两个列表的所有交集区间。
拿题目的经典示例来说:
- firstList =
[[0,2],[5,10],[13,23],[24,25]] - secondList =
[[1,5],[8,12],[15,24],[25,26]]
肉眼扫一遍就能看到:[0,2] 和 [1,5] 交集是 [1,2];[5,10] 和 [1,5] 交集是 [5,5];[5,10] 和 [8,12] 交集是 [8,10]。最终答案是:
text复制[[1,2],[5,5],[8,10],[15,23],[24,24],[25,25]]
注意 [5,5]、[24,24]、[25,25] 这种单点交集也要输出,因为题目定义的是闭区间,端点重叠也算交集。这个细节在面试里特别容易被忽略,很多人一看到 start == end 就直接跳过了,结果白丢一个测试用例。
1.2 “有序且不重叠”才是整个算法的钥匙
很多第一次接触这题的人会想:暴力枚举不就行了?两个列表套两层循环,判断每对区间是否相交,时间复杂度 O(n*m)。这个思路在数据量小的时候当然能过,但完全没有用到题目给的两个关键条件。
仔细想一下“列表内部不重叠且有序”意味着什么:在数轴上,firstList 的区间是从左往右排好的一串线段,secondList 的区间也是从左往右排好的一串线段。既然两个方向都是递增的,那么我只需要用两个指针分别扫两个列表,每个区间最多被比较有限的几次,根本不需要回头重新匹配。
这就是双指针能用的根本原因:数据本身具备单调性,指针只会一直往前走,不需要回溯。如果两个列表内部有重叠或者无序,双指针方案就直接失效了,要先排序或者用其他数据结构处理。所以面试时如果面试官给了“有序且不重叠”这个条件,他心里期待的基本就是双指针解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针推进的推导过程:先搞清楚“什么时候有交集”,再谈怎么写代码
2.1 两个区间有交集的充要条件
假设当前拿到的两个区间分别是 A = [a1, a2]、B = [b1, b2],怎么判断它们有没有交集?
直观地想:A 和 B 在数轴上如果完全分离,那就没有交集。什么情况下完全分离?要么 A 整体在 B 右边,即 a1 > b2;要么 B 整体在 A 右边,即 b1 > a2。把这两种情况取反,就得到有交集的充要条件:
text复制a1 <= b2 && b1 <= a2
这个条件每写一次都要在心里默念一遍:两个区间的起点都不能超过对方的终点。我第一次刷的时候顺手写成了 a1 <= b1 && a2 >= b2,这其实是“A 完全包含 B”的判断,不是有交集。结果遇到 A=[1,5], B=[2,6] 这种部分重叠的情况直接漏解。
有交集的时候,交集区间怎么求?
- 左端点取两个区间起点的较大值:
start = max(a1, b1) - 右端点取两个区间终点的较小值:
end = min(a2, b2)
用生活化的方式理解:两个人分别站在一段路上,A 站在 [a1, a2] 这一段,B 站在 [b1, b2] 这一段。他们共同踩到的路段,起点当然是从两人起点的较靠右那个开始,终点到两人终点的较靠左那个结束。
2.2 指针移动规则:谁先结束,谁就先退出比较
判断完当前两个区间是否有交集之后,下一步要决定移动哪个指针。标准写法是:
c复制if (a2 < b2) {
i++;
} else {
j++;
}
为什么是移动终点更小的那一侧?这里值得展开讲一下。
假设 a2 < b2,说明 A 区间已经结束了,而 B 区间还没结束。因为 secondList 内部的区间不重叠且有序,B 后面的区间起点只会比 b2 更大。既然 A 的终点 a2 已经比 b2 还小了,那么 A 区间就不可能再和 B 后面的任何一个区间产生交集。换句话说,A 这个区间已经“榨干”了它和当前 B 区间可能产生的所有交集,可以放心地移动到下一个区间。
反过来,如果 b2 < a2,就是 B 区间先结束,那么 B 就不可能再和 A 后面的区间产生交集,移动 j。
这里有一个容易纠结的细节:当 a2 == b2 时移动哪边?我的习惯是走 else 分支移动 j,其实移动任一边都对。因为两边终点相同,任选一边移动后,另一边在下一次循环也会因为条件不满足而移动,不会漏解。
这个逻辑用一句话概括:每次比较完,淘汰终点更小的那个区间,因为它已经没有机会和对方列表里后面的区间相交了。
2.3 时间复杂度的直观理解
整个算法每一步至少移动 i 或 j 中的一个指针,两个指针各自最多走完整个列表,所以循环最多执行 n + m 次。每次循环内只做常数次比较和赋值,时间复杂度是 O(n + m)。相比暴力的 O(n*m),这是质的提升。
对于这个复杂度,可以类比成两条流水线:左边一个工人负责扫 firstList,右边一个工人负责扫 secondList,两个人手上的工件谁先加工完,谁就去领下一个工件。整个过程没有任何一个工件被重复拿起来看第二遍。
3. C语言实现:LeetCode接口的返回契约与完整代码
3.1 先读懂 LeetCode 的 C 语言函数签名
力扣的 C 语言接口长这样:
c复制int** intervalIntersection(int** firstList, int firstListSize, int* firstListColSize,
int** secondList, int secondListSize, int* secondListColSize,
int* returnSize, int** returnColumnSizes)
第一次用 C 刷二维数组题的人,很容易被这一长串参数吓到。拆开看其实就三组东西:
- 输入的两个二维数组,以及各自的行数、每行的列数数组
- 输出参数
returnSize,用来告诉调用方结果有多少行 - 输出参数
returnColumnSizes,用来告诉调用方结果每一行有几列
注意 firstListColSize 和 secondListColSize 这两个参数,在本题里实际上用不到。因为题目保证每个区间都是两个整数,所以每个输入行的列数恒为 2。函数签名保留这两个参数,只是力扣所有 C 语言二维数组题目共用的接口规范,并不是每个参数都必须用上。
返回的 int** 指向的每一行,都必须是 malloc 在堆上分配的数组。因为 LeetCode 的判题程序会拿到这个指针后,按照 returnSize 遍历每一行,再依次 free。如果你返回了一个指向栈上数组的指针,轻则判题崩溃,重则产生未定义行为。
3.2 完整可提交的 C 代码
下面是力扣可提交版本,我加了一些注释方便理解:
c复制int** intervalIntersection(int** firstList, int firstListSize, int* firstListColSize,
int** secondList, int secondListSize, int* secondListColSize,
int* returnSize, int** returnColumnSizes)
{
// 两个列表任意一个为空,直接返回空结果
if (firstListSize == 0 || secondListSize == 0) {
*returnSize = 0;
*returnColumnSizes = NULL;
return NULL;
}
// 交集数量不可能超过 firstListSize + secondListSize
int capacity = firstListSize + secondListSize;
int** result = (int**)malloc(sizeof(int*) * capacity);
int* columnSizes = (int*)malloc(sizeof(int) * capacity);
*returnSize = 0;
int i = 0, j = 0;
while (i < firstListSize && j < secondListSize) {
int a1 = firstList[i][0];
int a2 = firstList[i][1];
int b1 = secondList[j][0];
int b2 = secondList[j][1];
// 有交集则记录
if (a1 <= b2 && b1 <= a2) {
int start = a1 > b1 ? a1 : b1;
int end = a2 < b2 ? a2 : b2;
result[*returnSize] = (int*)malloc(sizeof(int) * 2);
result[*returnSize][0] = start;
result[*returnSize][1] = end;
columnSizes[*returnSize] = 2;
(*returnSize)++;
}
// 移动终点更小的那一侧指针
if (a2 < b2) {
i++;
} else {
j++;
}
}
*returnColumnSizes = columnSizes;
return result;
}
这段代码有几个容易写错的地方,我单独提出来:
第一,*returnSize 必须显式初始化为 0。否则如果所有循环都不产生交集,函数返回时这个值是个未初始化的垃圾值,判题程序拿它当行数去遍历,直接访问越界。
第二,先取出四个端点的值再判断,判断完之后再移动指针。顺序不要反。我曾经见过有人把指针移动写在取数之前,导致访问 firstList[i] 时 i 已经变成列表长度,越界访问。
第三,columnSizes 这个数组必须和 result 同步填充。如果只给 result 分配了内存而不给 *returnColumnSizes 赋值,LeetCode 连第一行输出都读不出来。
3.3 内存分配上限为什么是 n + m
很多人会问:为什么 capacity 取 firstListSize + secondListSize,而不是取更小的那个?
最坏情况下,每次循环都会产生一个交集吗?不一定。但我们可以证明:整个双指针循环最多执行 n + m 次,因为每轮至少移动一个指针,而两个指针各自最多移动 n 次和 m 次。每一轮最多往结果里添加一行,所以结果最多有 n + m 行。
进一步想极端情况:firstList = [[1, 100]],secondList = [[1,1],[2,2],[3,3],...],一个长区间可以切出非常多的小交集。但即使如此,交集数量也不会超过 secondList 的区间数量 m。反之亦然。所以 n + m 作为分配上限是绝对安全的。
如果你对内存分配有洁癖,不想让结果数组的物理长度大于实际使用长度,可以改成两遍扫描:第一遍只统计交集数量,第二遍再精确分配并填充。思路一样,只是代码会多一些。实际刷题阶段,直接分配 n + m 是最省事的做法,LeetCode 的判题程序只根据 *returnSize 访问有效行,多余的空间不会被读到。
还有一个常见的 C 语言陷阱:realloc 缩容时,如果直接把返回值赋给原指针,一旦 realloc 失败,原指针就丢了。所以如果真要缩容,必须用一个临时指针接住返回值,判空后再赋值。这个在面试手写代码时如果提出来,是明显的加分项。
4. 边界条件逐个拆解:这些测试用例能帮你快速定位代码问题
4.1 常见边界场景清单
我每次写完区间题,都会拿下面这组边界条件过一遍代码,比反复看题解效率高得多:
| 场景 | 输入示例 | 期望输出 | 说明 |
|---|---|---|---|
| 空列表 | firstList = [] |
[] |
提前返回,不能访问任何行 |
| 彻底无交集 | [[1,2]], [[3,4]] |
[] |
条件判定全部不成立 |
| 端点相接 | [[1,2]], [[2,3]] |
[[2,2]] |
闭区间端点也算交集 |
| 完全包含 | [[1,10]], [[2,3]] |
[[2,3]] |
交集就是较短的那个区间 |
| 负坐标 | [[-5,-1]], [[-2,0]] |
[[-2,-1]] |
判断条件对负数完全适用 |
| 零长度区间 | [[1,1]], [[1,2]] |
[[1,1]] |
不要把 start == end 当非法输入丢弃 |
| 单点连环相交 | [[1,4]], [[1,1],[4,4]] |
[[1,1],[4,4]] |
一个长区间可以产生多个交集 |
这些用例里最容易翻车的是“端点相接”和“零长度区间”。因为很多人受“区间”这个词的影响,下意识觉得必须 start < end,忽略题目用的是闭区间,[1,1] 在数轴上就是一个点,也是合法区间。
4.2 C语言实现中容易踩的几个坑
结合我自己的提交记录和帮别人 review 代码的经验,以下几个错误出现频率极高。
第一个坑:忘了处理空列表。如果不加最前面的空列表判断,循环条件 i < firstListSize && j < secondListSize 直接不进入,看似没问题,但 *returnColumnSizes 没有被赋值,返回后判题程序拿到一个野指针。所以空列表分支必须显式设置 *returnSize = 0 和 returnColumnSizes = NULL。
第二个坑:交集判定条件写错。很多人拿着 a1 <= b2 && b1 <= a2 不敢确定,非要写成 a1 >= b1 && a2 <= b2 这种“包含”判断。建议在草稿纸上画两条线段,把六种相对位置(完全分离、部分重叠、包含、端点相接等)都画一遍,确认条件之后再写代码。
第三个坑:(*returnSize)++ 写成 *returnSize++。这是 C 语言运算符优先级问题,++ 会先作用于指针 returnSize 本身,而不是它指向的 int。这种错误编译时不报错,运行结果完全错乱,排查起来非常费劲。
第四个坑:最后忘了给 *returnColumnSizes 赋值。函数签名里 returnColumnSizes 是 int**,调用方传入的是 int* 的地址,函数内必须通过 *returnColumnSizes = columnSizes 把列数数组交出去。很多人把 columnSizes 填满了,但没赋给 *returnColumnSizes,等于白干。
4.3 本地测试的完整 main 函数
在本地用 VSCode 或者其他 IDE 调试时,可以写一个简单的 main 来验证。力扣上没必要贴这个,但本地调试非常有用:
c复制#include <stdio.h>
#include <stdlib.h>
// 这里放上面提交的 intervalIntersection 函数
int main() {
int f1[] = {0, 2}, f2[] = {5, 10}, f3[] = {13, 23}, f4[] = {24, 25};
int* firstList[] = {f1, f2, f3, f4};
int firstCol[] = {2, 2, 2, 2};
int s1[] = {1, 5}, s2[] = {8, 12}, s3[] = {15, 24}, s4[] = {25, 26};
int* secondList[] = {s1, s2, s3, s4};
int secondCol[] = {2, 2, 2, 2};
int returnSize;
int* returnColumnSizes;
int** res = intervalIntersection(firstList, 4, firstCol,
secondList, 4, secondCol,
&returnSize, &returnColumnSizes);
for (int i = 0; i < returnSize; i++) {
printf("[%d,%d] ", res[i][0], res[i][1]);
free(res[i]);
}
printf("\n");
free(res);
free(returnColumnSizes);
return 0;
}
期望输出是:
text复制[1,2] [5,5] [8,10] [15,23] [24,24] [25,25]
如果本地输出和这个不一致,优先检查 4.2 里列的四个坑。
5. 从 986 到区间问题家族:一道题串起一类面试题
5.1 相同套路下的区间题题单
LeetCode 里区间类问题非常多,但核心套路其实是共通的:利用“有序区间”或者“排序后的区间”做单次扫描,在扫描过程中用贪心或双指针维护当前状态。
顺着 986 往下刷,这几道题几乎是一脉相承:
| 题号 | 题目 | 核心思路 | 与 986 的关系 |
|---|---|---|---|
| 56 | 合并区间 | 排序后一次遍历,能合就合 | 反向操作 |
