工作几年之后,我发现自己写业务代码越来越顺手,但一碰到需要“绕弯”的问题就开始卡壳。有时候明明知道该用哪个数据结构,却说不清为什么;刷题看题解能看懂,关上答案自己写却总是差一步。于是我做了一个决定——开一个“每日算法练习”系列,每天至少拿出完整的一两个小时,把经典算法从原理到实现重新过一遍。
这是Day01,也是这个系列的第一篇。今天的练习组合是二分查找、归并排序和贪心找零。这三道题分别对应了三个最基础的能力:边界条件的把控、分治思想的手感、以及贪心策略的证明意识。文章会把我实际的练习过程、代码和踩坑点都写出来。如果你也正打算重新捡起算法,或者刚入门想找一个可参考的练习节奏,这篇应该能给你一些可复现的思路。
1. 首日题单选型:为什么从“能跑通的经典题”开始
很多人一开始就挑战动态规划、KMP、红黑树这类硬核问题,结果往往是看了三天题解,代码还是抄不顺畅,最后放弃。我的建议很朴素:第一天,甚至前两周,都别碰那些“看一眼就觉得自己不会”的题,先把基础能力磨扎实。
今天选的三道题,难度都不高,但每个都值得反复练:
- 二分查找算法:代码只有短短几行,可它考察的是循环不变量、区间定义、溢出处理。能把这个边界焊死的人,写其他算法也不容易埋雷。
- 归并排序算法:最容易理解的O(n log n)排序,含分治、递归、合并三个动作。理解了它,后面学快排、堆排会轻松非常多。
- 贪心算法:思路直白,但最怕“局部最优不等于全局最优”的反例。用找零问题正好可以在“对与错”之间做对比。
这三题串起来,正好覆盖了“查找、排序、策略”三个维度。第一天的目标不是“AC多少题”,而是“把每一题的为什么讲清楚”。我做题的时候给自己定了个规矩:代码跑通不算完,必须能答上来三个问题——为什么这样写是对的、边界在哪里、换个数据还会不会出问题。
实际写下来,我发现这个要求比想象中难得多。二分查找写了两个版本,归并排序调了五分钟的递归边界,贪心找零在一组特殊面额上翻了车。这正好说明“简单题”背后并不简单。如果你也准备开启自己的每日算法练习,我建议第一天不要贪多,把两到三个经典题吃透,比快进快出看十道题有用。
2. 二分查找:第一天就把边界条件焊死
2.1 从标准写法到“为什么不能写错”
二分查找的代码,网上随便一搜就是一堆,但真正自己默写一遍,才明白边界条件有多容易出错。我今天的第一个练习是:在有序数组中查找目标值,存在则返回下标,不存在则返回-1。先看我一开始写的版本:
#include <vector> int binarySearch(std::vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }这个版本应该是大家最熟悉的。但如果你把循环条件换成while (left < right),或者把left = mid + 1写成left = mid,后面就可能出现一系列问题:要么漏掉目标值,要么死循环。
拿一个具体的测试来演示。[1, 3, 5, 7, 9]里找7,left=0,right=4,mid=2,nums[2]=5小于7,所以left变成了3。下一轮left=3,right=4,mid=3,nums[3]=7刚好命中。一切正常。但如果改成while (left < right),当left和right相邻时,比如left=3,right=4,mid=3,发现nums[3]=7直接返回了,这里看起来也正常。可一旦目标值不是7而是9,while (left < right)状态下最后left会停留到下标4,循环结束,返回-1,就错了。
所以我的建议是:第一版的标准闭区间写法,循环条件固定用left <= right,mid更新用left + (right - left) / 2。这样区间始终是闭区间[left, right],逻辑最直白,不容易自我混淆。
2.2 mid计算的溢出陷阱
这里要单独说一个老生常谈但值得刻进肌肉记忆的问题——mid溢出。如果直接写(left + right) / 2,当数组足够大、left和right都接近int上限时,left+right会溢出成负数,mid就错了。
用left + (right - left) / 2,本质上是先算区间长度的一半,再从左边界出发。这个写法在数学上等价于(left + right) / 2,但避开了加法溢出。我在本机上用一个理论上的大数组模拟过,旧写法确实会出现mid为负的情况,而新写法一切正常。
2.3 进阶练习:找第一个等于target的位置
跑通基础版之后,我又练了一个更贴近实战的变体:找第一个等于target的下标。这个场景在二分答案、区间查询里经常遇到。实现方式是把“找到target立即返回”改成“找到了继续向左收缩”:
int findFirst(std::vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; int result = -1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { result = mid; right = mid - 1; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return result; }用[1, 3, 3, 3, 5, 7]测这个函数,目标是返回下标1而不是2或3。中间过程会有多次收缩,最终落在最左侧的3上。这个变体比基础版更能检验你是否真的理解区间变化,建议第一次练习就一并写掉。
2.4 二分查找的适用前提
二分查找的前提是“单调性”,数组本身必须有序。不过很多新手不知道,单调性不一定表现为数组已经完全排好序。比如“找到第一个满足某个条件的值”,只要判断条件在数组下标上呈现true/false的单调变化,就能套二分。这个思路在后续的“二分答案”类型题里会非常常用。
今天我在笔记里记了一句话:二分查找的核心不是“查找”,而是不断维护一个有效的候选区间。每轮迭代,你都要保证target如果在数组里,就一定还在[left, right]这个区间内。只要这个区间定义不变,代码就不会乱。把这句话记住,基本就掌握了二分查找的精髓。
3. 排序算法里的手感练习:归并排序与快排的选择
3.1 为什么第一道排序题选归并
排序算法有很多:冒泡、选择、插入、归并、快速、堆排……今天我没有选最常考的快速排序,而是选了归并排序。原因很简单:归并排序的代码结构最规整,分治思想最容易讲清楚,也没有像快排那样恼人的partition边界问题。
归并排序的整个过程是:先把数组从中间劈开,分别排序左右两半,再把两个有序序列合并成一个。这个“先分后治”的过程非常直观,代码也几乎不需要什么技巧,只要递归边界和合并逻辑别写错。
3.2 默写一遍归并排序
下面是我今天实际默写的实现:
#include <vector> void mergeSort(std::vector<int>& nums, int left, int right) { if (left >= right) { return; } int mid = left + (right - left) / 2; mergeSort(nums, left, mid); mergeSort(nums, mid + 1, right); std::vector<int> tmp(right - left + 1); int i = left; int j = mid + 1; int k = 0; while (i <= mid && j <= right) { if (nums[i] <= nums[j]) { tmp[k++] = nums[i++]; } else { tmp[k++] = nums[j++]; } } while (i <= mid) { tmp[k++] = nums[i++]; } while (j <= right) { tmp[k++] = nums[j++]; } for (int p = 0; p < (int)tmp.size(); ++p) { nums[left + p] = tmp[p]; } }写完之后要特别注意两个点。第一,递归的终止条件left >= right不能漏,少了它就会出现死递归然后栈溢出。第二,合并过程中如果nums[i] == nums[j],我选择把左边数组的元素先放进去,这样归并排序是稳定的。稳定的含义是:相等元素的相对顺序在排序前后不变。在某些场景下这一点很重要,比如按多个关键字排序时,先按主关键字排,再按次关键字排,稳定的算法能保留之前的排序结果。
3.3 用测试数据验证自己的理解
我拿这组数据实际跑了一下:[38, 27, 43, 3, 9, 82, 10]。
手动追踪一遍过程:
- 第一次切分,left=0, right=6, mid=3,分成[38, 27, 43, 3]和[9, 82, 10]。
- 左侧继续切,直到每个区间只剩一个元素。
- 从最小单元开始合并,比如[38]和[27]合并成[27, 38],[43]和[3]合并成[3, 43],再合并成[3, 27, 38, 43]。
- 右侧同理,最终合并成[3, 9, 10, 27, 38, 43, 82]。
跑下来结果正确时,我特意又加了几个测试:空数组、只有一个元素的数组、全相等数组。空数组调用mergeSort(nums, 0, nums.size() - 1),由于left=0,right=-1,直接触发left >= right返回,不崩溃。这个情况很多初写者会忽略,我自己第一遍也漏了,后来用if (nums.size() <= 1)包了一层才安心。
3.4 归并排序的时间复杂度直觉
O(n log n)这个结论,不能只是背。我用今天的代码想了一下:每一轮合并,所有元素都会被扫描一遍,一共要切分log n层。每一层扫描合并都是O(n),所以总复杂度是O(n log n)。空间复杂度方面,每层递归会创建临时数组,但合并结束后临时数组会被释放,所以峰值空间是O(n)。
这也是它相比冒泡排序、插入排序的最大优势:数据规模一大,O(n²)直接扛不住。当然归并排序也有弱点,它不是原地排序,需要额外内存,而且常数因子比快速排序大。正因为各有优劣,工程上才有混合排序的策略。我今天的练习目的不是选出“最好的排序算法”,而是通过代码建立对分治的直觉,后面学快排时再对比就轻松多了。
4. 贪心算法第一课:用找零问题建立策略意识
4.1 先写一版“直觉代码”
贪心算法的核心思想是:每一步都做当前看起来最优的选择,希望最终能达成全局最优。找零问题是最经典的入门例子:假设你有面额为[1, 5, 10, 25]的硬币,要凑出某个金额,如何用最少的硬币数。
我第一版代码很直接:
def min_coins_greedy(coins, amount): coins.sort(reverse=True) count = 0 for coin in coins: if amount == 0: break count += amount // coin amount %= coin return count if amount == 0 else -1 print(min_coins_greedy([1, 5, 10, 25], 36))这个写法就是尽可能先用大面额硬币。36美分的话,先拿一个25,剩11,再拿一个10,剩1,再拿一个1,总共3枚。手动验证确实是3枚硬币,看起来没问题。
4.2 换一组硬币面额,反例马上出现
如果以为贪心在所有场景下都成立,那就大错特错了。我把硬币面额换成[1, 3, 4],要凑6,贪心会怎么做?取一个4,剩2,取两个1,总共3枚。可最优解呢?取两个3,只需要2枚。贪心在这里失效了。
这个反例让我反思:贪心不是“无脑逼近”,它需要满足特定的结构,比如“大面额是小面额的整数倍”这种特殊关系。在[1, 5, 10, 25]这套面额下,10是5的倍数,25是5的倍数,所以大面额的替换永远不亏。但在[1, 3, 4]里,4和3之间没有整除关系,拿4可能堵死后续的组合,反而得不偿失。
网上很多讲贪心算法的题解,只强调“每步选最优”,很少强调反例验证。我在第一天的笔记里专门写了一段:写贪心算法题,先问自己两个问题——这个局部最优能不能带到下一步;如果存在多个局部最优,选哪个才能保证不会破坏全局最优。回答不上来,就去试试构造反例。
4.3 对比:动态规划才是“兜底方案”
既然贪心不是万能的,那找零问题的通用解法是什么?答案是动态规划。用dp[i]表示凑出金额i所需的最少硬币数,递推公式是:
dp[i] = min(dp[i - coin] + 1),遍历所有coin面额且coin <= i。
我把这个递推也写了一遍:
def min_coins_dp(coins, amount): dp = [float('inf')] * (amount + 1) dp[0] = 0 for i in range(1, amount + 1): for coin in coins: if i >= coin: dp[i] = min(dp[i], dp[i - coin] + 1) return dp[amount] if dp[amount] != float('inf') else -1 print(min_coins_dp([1, 3, 4], 6))输出是2。对着刚才贪心输出的3,差距一目了然。
但我第一天并不打算把动态规划铺开,只是用这个例子告诉自己:贪心的策略意识很重要,可也要知道它的适用边界。后面系列学到动态规划时,这个天花板和反例会重新出现。
4.4 做这题真正的收获
很多解法教程会直接告诉你“这题贪心能过”,然后就完事了。但如果你只是记住了这个结论,换个面额体系就会踩坑。我今天用这个例子反复练习的是,如何从“直觉”走向“论证”:在[1, 5, 10, 25]下,为什么贪心一定对?核心是面额之间的倍数关系保证了“替换不会变差”。
后续做更复杂的贪心题,我会习惯性做三件事:写直觉解法、构造反例、再想证明思路。即便反例构造不出来,我也会因为多做了这个环节而对自己的答案更有信心。
5. 首日的坑与复盘:三个让我卡住的小问题
今天题目本身不算难,但实际写起来还是遇到了一些“意料之中”和“意料之外”的问题。我把其中三个典型的记录下来,都是新手容易撞上的。
5.1 坑一:二分查找里left + (right - left) / 2看起来啰嗦,但确实是防线
我一开始图省事,写成了int mid = (left + right) / 2;。在小数组上一切正常,但我用一个模拟大边界的测试去跑时,发现mid可能变成负值。原因就是left和right相加后超过了int上限。这个问题在真实项目中不常见,但在算法题里一旦出现,就是那种“偶尔卡死、很难复现”的隐秘bug。
所以,二分查找的mid计算,我以后一律写left + (right - left) / 2。这不是炫技,是防身。
5.2 坑二:归并排序的递归边界写错,直接栈溢出
写归并排序时,我第一版把终止条件写成了if (left == right) return;。看起来也没什么问题,但一旦传入的数组为空,left=0,right=-1,这时left != right,递归就会继续算mid,往一个不存在的区间递归,最终爆栈。
改成if (left >= right) return;之后,空数组、单元素数组都能安全退出。别小看这个>,它把非法区间的场景一并覆盖了。写递归函数的时候,最好养成用>=而不是==做终止条件的习惯,尤其是区间型递归。
5.3 坑三:贪心找零的反例,让我意识到“能跑通”不等于“算法对”
如果只做[1, 5, 10, 25]那组数据,贪心找零看起来就是完美的。可当我换到[1, 3, 4]找6时,贪心给出了3枚而不是最优的2枚。这个反例不是靠调试调出来的,是刻意构造出来的。
它给我的启示是:验证算法不能只依赖一两个测试用例。特别是贪心、DP这类策略型算法,最好有几组“刁钻”的手工样例——包括重复元素、边界值、特殊矛盾的数据。如果能构造出一个让直觉挂掉的反例,那对这个算法的理解反而会上一个台阶。
5.4 排错心法:把“算法正确”和“实现正确”分开检查
今天我排错时有个小技巧:先不急着看代码逻辑,而是先确认算法思路本身对不对,再确认实现细节。二分查找边界错了,可能是思路里的区间定义不明确;归并排序爆栈,可能是递归终止条件的问题;贪心不对,可能是策略本身就不适用于这组数据。
把这两类问题分开,能少走很多弯路。比如我在归并排序里发现结果不对时,我先在纸上画出切分和合并过程,确定思路正确,才去逐行查循环里的下标。这种做法强烈推荐给同样在练习算法的朋友。
6. 明天练什么:聊聊这个系列怎么持续下去
Day01只是开始,如果想靠一天的热情把算法吃透,基本不现实。我给自己定的规则是:每天必须留下可追踪的记录,哪怕只是写几行笔记、跑几个测试用例。有了Day01的基础,我在计划Day02的安排。目前的想法是这样的:
| 日期 | 练习主题 | 目标 |
|---|---|---|
| Day01 | 二分查找、归并排序、贪心找零 | 理解区间维护、分治合并、贪心边界 |
| Day02 | 快速排序与partition细节 | 对比归并排序,理解原地排序的代价 |
| Day03 | 链表基础与双指针技巧 | 熟悉快慢指针,复盘环检测 |
| Day04 | 单调栈与滑动窗口 | 学会用数据结构维护候选集合 |
| Day05 | 经典动态规划入门 | 从斐波那契到背包雏形 |
| Day06 | 本周题单回顾与手写总结 | 重新默写,检验是否真的掌握 |
排序方面,快速排序的partition边界是出了名的容易写错,而且面试常考;链表双指针则和Day01的二分查找一样,都是“代码量少但细节多”的题型。把这两类安排在系列的前几天,能把基础打得更扎实。
另外,我打算在每天结束时做一次“默写检验”:不打开任何资料,把当天的核心算法写到能跑通为止。今天我已经用这个方式检验了二分查找和归并排序,效果很好,因为这能强行逼自己找出记忆里模糊的部分。你在做每日算法练习时也可以试试这个办法,它比“看着题解点头”有效得多。
最后再分享一个小习惯:把每道题的错误样例保留下来,放进一个专门的文件里。我今天的[1, 3, 4]找零反例就属于这种保留样例,以后复习时可以快速唤醒记忆,也能提醒自己不要踩同一个坑第二次。希望这个系列能像今天的Day01一样,扎扎实实走下去。