☰
C语言实战:二分查找破解LeetCode 153旋转数组最小值
2026/9/28 17:14:10 网站建设 项目流程

LeetCode 153这道题,我在不同阶段刷过好几遍。第一次纯粹为了过题,O(n)扫描直接AC;第二次认真想,才意识到它真正考察的是二分查找的边界功底。很多C语言初学者刷到这里,第一反应往往是“不就是找最小值嘛,一个循环遍历的事”,但题目明确要求O(log n)复杂度,这基本就是在点名要你用二分。更关键的是,这道题的唯一性让它成为理解“旋转排序数组”这一类问题的最佳入口——后面遇到154题、33题,思路全是围绕它展开的。

这篇文章我用C语言来拆这道题:从旋转数组的数学特征讲起,解释二分查找为什么能稳定定位最小值,再给出完整可提交的代码、几个必须手推的边界用例,最后把我自己踩过的坑和调试技巧一次性整理出来。适合正在刷LeetCode的C语言学习者,也适合那些“二分模板背熟了但一换题目就懵”的读者。

1. 题目本质:认识旋转排序数组这个“怪东西”

1.1 旋转数组的结构特征

什么叫旋转排序数组?就是把一个严格升序的数组从某个位置切开,把前面那一段搬到后面去。比如[1,2,3,4,5]从3和4之间切开,旋转后变成[4,5,1,2,3]。LeetCode 153给的输入,本质上是这样的一个旋转结果。

这里有个极其重要的规律:不管旋转点在哪,数组始终由两段升序子序列拼接而成,并且左段的所有元素一定大于右段的所有元素。注意,是“大于”而不是“大于等于”,因为153题默认数组中的元素互不相同。这个条件看似不起眼,实际上决定了整个二分策略的走向——一旦出现重复值,这题就变成了154题,处理逻辑立刻要加一层。

我用一个生活场景帮助理解:想象一个圆形表盘,刻度1到12均匀排布,现在把表盘从某个位置剪开拉成一条直线。原本按顺序排列的刻度会变成类似[8,9,10,11,12,1,2,...,7]的样子。最小值1出现在第二段的开头,而最大值12和最小值1恰好就是“切口”的两侧。153题要我们找的,就是切口右侧的第一个元素。

1.2 为什么暴力也能过,但二分才是正解

最朴素的做法是遍历整个数组,维护一个min变量,遇到更小的就更新。时间复杂度O(n),空间复杂度O(1)。在数组长度只有几千的情况下,跑起来毫无压力。可一旦数据规模到达10^5甚至10^6,O(n)和O(log n)的差距就非常明显了。

但“性能差异”还不是最重要的。最关键的是,这道题出现在LeetCode上,本身就是想考察二分查找在“非完全有序数组”上的应用。一个升序数组旋转之后,整体不再有序,却保留了“局部有序”的特性——任意取一个中点,中点两侧至少有一侧是严格升序的。这个性质让二分成为可能。

二分查找的核心从来不是“在有序数组里找数”,而是“利用可排除的信息,把搜索范围缩小一半”。旋转排序数组里没有目标值的位置信息,但我们能根据中点和边界的大小关系,判断最小值到底在中点的哪一侧,从而放心丢弃另一半。这个思想,比这道题本身更重要。理解了它,后面刷33题(搜索旋转排序数组中的目标值)、81题(有重复值的搜索旋转排序数组)都会顺很多。

2. 核心思路拆解:二分查找如何定位最小值

2.1 关键比较对象:为什么选 nums[mid] 和 nums[right]

很多初学者拿到这道题,第一反应是拿nums[mid]和nums[left]比较。我也见过这么写的题解,代码能跑通,但边界处理极其别扭。相比之下,和nums[right]比较要自然得多,也更容易推出正确的收缩规则。

先看一个事实:如果nums[mid] > nums[right],说明什么?说明中点到右边界这一段,不是严格升序的——因为如果这段升序,那么必然有nums[mid] < nums[right]。既然它不是升序的,那这段区域里一定跨过了“断点”,而最小值恰恰在断点右侧。进一步说,nums[mid]本身落在左段,它一定大于右段的所有元素,所以mid位置的值不可能是最小值,可以放心让left = mid + 1。

反过来,如果nums[mid] < nums[right],说明从mid到right是严格升序的,也就是说断点不在这个区间里。最小值只可能在left到mid之间,甚至mid自己就是最小值。所以此时只能让right = mid,不能把mid也排除掉。

注意:这里的关键是“能不能排除mid”。第一种情况下mid确定不是最小值,所以 +1 跳过;第二种情况下mid可能就是最小值,所以只能收缩到mid。

2.2 边界收缩的两种写法与死循环陷阱

基于上面的推理,标准写法如下:

while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] > nums[right]) { left = mid + 1; } else { right = mid; } } return nums[left];

这里要特别强调left = mid + 1和right = mid是配套的,不能随意更改。如果把left改成left = mid,当区间只剩两个元素时会出现死循环。举个例子,nums = [2,1],初始left=0, right=1, mid=0。如果nums[0] > nums[1],按规则应该left = mid + 1即1,循环结束,返回1,正确。但如果错误地写成left = mid,left会一直是0,mid也一直是0,永远无法退出循环。

再比如把right改成right = mid - 1,同样会出问题。假设nums = [4,5,1,2,3],第一次计算mid=2, nums[2]=1, nums[right]=3,因为1 < 3,说明mid可能就是最小值。如果此时执行right = mid - 1 = 1,就把下标2的位置直接丢掉了,最小值的下标记永远不会被搜索到。这就是“排除掉可能是答案的位置”的典型错误。

还有一种写法是和nums[left]比较:

while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] < nums[left]) { right = mid; } else if (nums[mid] > nums[right]) { left = mid + 1; } else { return nums[left]; } } return nums[left];

这种写法也能出正确结果,但需要额外判断“整个区间已经有序”的情况,代码更繁琐。我更推荐只和nums[right]比较的版本,因为它的逻辑链条更短,不容易写错,特别是对新手来说,只有一个判断分支,记忆负担小很多。

2.3 复杂度分析与适用场景

时间复杂度O(log n),因为每轮循环都丢弃一半的区间。空间复杂度O(1),只用到了几个整型变量,没有额外数组,也不涉及递归栈。

这个解法可以推广到所有“局部有序”的数组场景。比如在一个循环有序数组中查找某个目标值,思路是先判断当前区间是否升序,再根据目标值的位置决定收缩方向。包括后面会提到的154题,也是在同一框架上加一个相等判断条件。所以153题虽然是基础题,但它是理解旋转数组二分的一把钥匙。

3. C语言实现:完整代码与边界推演

3.1 参考代码与代码结构说明

LeetCode提交C语言版本时,只需要实现题目给出的函数签名,不需要自己写main和输入输出。153题的函数签名是:

int findMin(int* nums, int numsSize)

这里有个C语言经典细节:nums是数组首地址的指针,数组名在传入函数后退化为指针。所以千万不能在函数内部用sizeof(nums) / sizeof(nums[0])去算数组长度——这时候sizeof(nums)得到的是指针大小,而不是整个数组的字节数。幸好LeetCode把长度通过numsSize传进来了,我们直接用即可。

完整实现:

int findMin(int* nums, int numsSize) { int left = 0; int right = numsSize - 1; while (left < right) { int mid = left + (right - left) / 2; // 中值比右边界大,说明中值落在旋转后的左半段 // 最小值一定在 mid 的右边 if (nums[mid] > nums[right]) { left = mid + 1; } else { // 否则,mid 落在右半段,mid 本身可能就是最小值 right = mid; } } return nums[left]; }

中位数计算用left + (right - left) / 2,而不是(left + right) / 2,是为了防止整数溢出。虽然这道题numsSize一般到不了那么大,但C语言刷题时养成这个习惯能帮你避开很多潜在问题。LeetCode的C评测环境里int通常是32位,两个接近2^31-1的数相加会直接溢出成负数,二分立刻崩掉。

3.2 三个必须手推的边界用例

我强烈建议你拿到代码后,不要直接提交,先在纸上手动推几个用例。二分查找这东西,光看代码“好像对了”是不够的,必须理解每一步为什么这样收缩。

第一个用例选经典样例:[3,4,5,1,2]。

  • 初始left=0, right=4, mid=2,nums[2]=5,nums[4]=2。因为5 > 2,说明mid落在左段,最小值在右边,left = 3。
  • 区间变成[3,4],mid=3,nums[3]=1,nums[4]=2。因为1 < 2,说明mid到right升序,最小值可能是mid本身,right = 3。
  • left == right == 3,循环结束,返回nums[3] = 1。结果正确。

第二个用例是网上常见的[4,5,6,7,0,1,2],旋转点刚好在中点偏左。

  • 初始left=0, right=6, mid=3,nums[3]=7,nums[6]=2。7 > 2,left = 4。
  • 区间[4,6],mid=5,nums[5]=1,nums[6]=2。1 < 2,right = 5。
  • 区间[4,5],mid=4,nums[4]=0,nums[5]=1。0 < 1,right = 4。
  • left == right == 4,返回0。正确。

第三个用例是“没有旋转”的情况,比如[11,13,15,17]。它验证代码的鲁棒性。

  • 初始left=0, right=3, mid=1,nums[1]=13,nums[3]=17。13 < 17,right = 1。
  • 区间[0,1],mid=0,nums[0]=11,nums[1]=13。11 < 13,right = 0。
  • 返回nums[0] = 11。正确。

这三个用例分别覆盖了“最小值在右边”“最小值在左边”“整个数组没有旋转”三种典型情况。能把它们完整推一遍,边界条件基本就掌握了。

3.3 从 LeetCode 到本地调试:C语言刷题环境搭建要点

LeetCode网页上的在线编辑器虽然方便,但二分这类问题如果全靠“脑内调试”,效率很低。我更建议本地配一个C语言环境,加上printf打印中间结果,调试体验完全不一样。

我们只需要一个编译器和一个趁手的编辑器。Windows上可以装MinGW-w64或者Visual Studio的C/C++开发组件,编辑器用VSCode加C/C++扩展就行。VSCode里配置好gcc编译路径后,写一个测试文件,手动构造测试用例:

#include <stdio.h> int findMin(int* nums, int numsSize) { // 这里粘贴上面的实现 } int main() { int nums1[] = {3, 4, 5, 1, 2}; int nums2[] = {4, 5, 6, 7, 0, 1, 2}; int nums3[] = {11, 13, 15, 17}; printf("%d\n", findMin(nums1, 5)); printf("%d\n", findMin(nums2, 7)); printf("%d\n", findMin(nums3, 4)); return 0; }

注意在main函数里可以直接用sizeof(nums1)/sizeof(nums1[0])求长度,因为这时nums1还是真正的数组,没有退化成指针。但一旦传进findMin函数,nums就是指针了,所以我在调用时手动写上了数组长度。这个sizeof的坑,几乎每个C语言初学者都踩过。

4. 实战排查:刷这道题最容易踩的坑

4.1 易错点速查表

我把常见的错误整理成一张表格,每一条都是我在实际调试中见过的:

错误写法错误后果正确做法
int mid = (left + right) / 2;大数相加可能溢出left + (right - left) / 2
if (nums[mid] >= nums[right])等号导致某些情况误判用>(153元素互不相同)
left = mid;只剩两个元素时死循环left = mid + 1
right = mid - 1;可能跳过最小值位置right = mid
循环条件写成left <= right左右相等时继续访问,可能越界用left < right

第一个错误很好理解,是整数溢出的经典问题。第二个错误需要多说一句:153题没有重复元素,所以nums[mid] == nums[right]理论上不会发生。如果你写了>=,在某些边界情况下可能会把真正的最小值排除掉。一旦题目换成154题,出现相等情况的时候,才需要加单独的right--分支,那是后话。

第三个和第四个错误属于配套问题。left = mid + 1配合right = mid,是整个算法的核心循环不变量。这两个边界更新方向一旦记错,不是死循环就是漏答案。我的记忆口诀是:确定排除的左边界加一,不确定的右边界保留原位。

关于第五个错误,很多教科书里的标准二分查找用的是left <= right,但那是针对数组中有明确目标值、且找到就返回的情况。这道题是“搜索空间收敛到唯一值”,用left < right更自然,也能避免mid访问越界的问题。

4.2 调试技巧:用 printf 观察二分走向

如果本地调试时发现结果不对,第一件事不是猜,而是把每一步的left、mid、right都打印出来。C语言的printf在刷题调试时是效率最高的工具,没有之一。

在while循环开头加一行:

printf("left=%d mid=%d right=%d nums[mid]=%d nums[right]=%d\n", left, mid, right, nums[mid], nums[right]);

打印出来的信息足够看清每一次比较的走向。比如输入[4,5,6,7,0,1,2],输出会是这样的规律:

left=0 mid=3 right=6 nums[mid]=7 nums[right]=2 left=4 mid=5 right=6 nums[mid]=1 nums[right]=2 left=4 mid=4 right=5 nums[mid]=0 nums[right]=1

看到left从0跳到4、再慢慢收敛到4,整个收缩过程一目了然。如果打印后发现left和mid长时间不变,那一定是left = mid死循环了;如果打印后直接跳过了正确答案的下标,那一定是right = mid - 1把答案排除了。

另外一个小技巧:写代码前先在纸上画一个经典用例的二分过程,把每一步的left、mid、right写下来,再对照代码逐行验证。二分查找不是背模板,而是理解“每次排除掉哪一部分,为什么可以排除”。这个理解到位了,所有旋转数组的变体题都会变得非常简单。

4.3 变体与进阶:从 153 到 154 和 33

153题做完,紧接着可以做它的两个“亲戚”。第一个是154题,允许数组中有重复元素。重复值破坏了“左段全部大于右段”的性质,当nums[mid] == nums[right]时,你无法判断mid到底落在哪一段。这时候保守的做法是把right往左移一位:

if (nums[mid] > nums[right]) { left = mid + 1; } else if (nums[mid] < nums[right]) { right = mid; } else { right--; }

因为无法判断,就缩小一格范围继续试,所以154题的最坏时间复杂度退化到O(n)。这也是为什么153题要求“元素互不相同”——它保证了二分的稳定性。

第二个变体是33题“搜索旋转排序数组”,目标不是找最小值,而是找某个具体值。思路是先判断mid两侧哪一边是有序的,再根据目标值是否在这个有序区间内决定收缩方向。理解153题之后,33题的核心框架几乎就是顺手的事。

如果时间允许,我建议把153、154、33这三道题放在同一天刷。它们共用一套二分框架,区别只在于判断条件和边界收缩的细节。刷完你会对“边界条件”这四个字有非常直观的感受——很多题不是不会,是边界的细节没吃透。

最后分享一个我自己的习惯:这道题我后来给新人讲的时候,一定会让他们先把[4,5,6,7,0,1,2]在纸上完整推一遍,把每一次left、mid、right三个值都写出来,再对照代码看。二分查找这种事情,眼睛看懂了和手推出来是完全两种体验。亲手推完两个用例之后,再去看154题的right--处理,你会发现那就是“等号情况下的保守收缩”,一通百通。

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

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

立即咨询