前几天有个学弟问我,KMP 算法的 next 数组到底怎么求。他给我看了两本书,一本说 next[i] 是模式串的最长相同前后缀长度,另一本说 next[i] 是失配之后的跳转位置,两本都配了代码,跑起来结果却不一样。我跟他讲,这个问题的根子不在代码,在概念——算法是什么、算法的步骤凭什么可信、怎么描述才算没有歧义,这些最基础的东西没立住,后面全是空中楼阁。这也是今天想把这些东西一次说透的原因:把算法学习里最该先搭起来的框架,从核心概念、基本特性、设计与分析的关系、常用设计技术,到与程序的区别和评价指标,系统过一遍。适合正在学算法基础、准备数据结构与算法考试、或者工作中发现自己“代码能写但总选不对算法”的读者。
1. 算法和程序从来不是一回事:一个抽象规则,一个具体实现
1.1 算法的本质是一套“操作说明书”
先回到最基础的定义:算法是解决特定问题的一系列有限操作步骤。这个定义里每个词都值得掰开看。“特定问题”说明算法有明确目标,不是为了写而写;“一系列”说明它由多步组成,步骤之间要有先后和逻辑关系;“操作步骤”说明每一步都可被执行、可被理解;“有限”则划出了底线——不能无限循环下去。把这四个限定组合起来,算法就是一套“操作说明书”。
我用做饭来类比:算法是菜谱,程序是按菜谱做出来的那盘菜。菜谱只告诉你先放油、再下葱姜蒜、最后加盐,它不关心你用电磁炉还是燃气灶;同一个菜谱可以被不同的厨师、不同的锅具复现出同一道菜。算法也一样,它不绑定任何编程语言,同一个排序算法,C++ 能写、Java 能写、Python 也能写,写出来的代码形态完全不同,但背后的步骤逻辑是一致的。
算法的描述方式也很多样:自然语言、伪代码、流程图都可以。很多教材喜欢用伪代码讲算法,就是因为伪代码去掉了一门语言里和问题无关的语法噪音,把注意力放在步骤本身。流程图也是常见表达方式,好画的流程图一定能让人一眼看出执行顺序和判断分支。这些形式都是在强调一件事:算法是逻辑层面的抽象,不是某一行具体代码。
1.2 程序比算法多出来的那些事
既然算法是抽象的,程序就是它的具体落地。但程序并不是简单地把算法翻译成代码就完了,它还额外承担了一堆算法层面根本不关心的工程问题。
举几个最常见的例子:输入校验和异常处理。排序算法的描述里通常假设“输入是一个可比较元素的数组”,但如果程序真去接一个线上请求,数据结构可能来自数据库,字段可能为空,类型可能不确定,程序必须在算法开始之前做好数据清洗和容错。再说资源管理:内存怎么分配、连接怎么释放、日志怎么打、出错时返回什么错误码——这些全都不是算法核心步骤,但它们是一部完整程序能不能稳定运行的前提。
还有一个特别能说明问题的现象:经常有人问“我写的程序无法运行,是不是算法有问题”。比如一个可执行文件打不开,提示不是有效的应用程序,这大概率是编译平台、系统环境或依赖库的问题,和算法本身一点关系都没有。程序运行不了,第一步排查的是运行环境;程序能跑但结果不对,才轮得到算法逻辑出场。这两个层面混在一起,排查问题的时候会非常痛苦。
1.3 算法 + 数据结构 = 程序,还差了点什么?
学算法的人一定听过那句经典公式:算法 + 数据结构 = 程序。这个公式的价值在于它点出了完整程序的构成不只是解题步骤,还包含数据在内存里怎么组织、怎么存取。同样的功能,用不同的数据结构实现,效果天差地别。
举个例子:100 万条记录,需要频繁按某个字段查找。如果只存成链表,一次查找要 O(n) 地从头遍历;如果建一个哈希索引,直接就是 O(1)。数据结构的选择差异,远大于算法微调带来的收益。所以学算法的人,数据结构功底必须同步跟上。
不过也要补一句:这个公式在今天的工程语境下,其实还少了点东西。现代程序的复杂度有很大一部分来自并发控制、分布式通信、I/O 调度、服务治理这些基础设施,不是“算法 + 数据结构”五个字能完全覆盖的。但对于学习算法基础的人来说,先把公式里的两部分吃透,就已经解决了最核心的地基问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五个基本特性是算法的“体检标准”,缺一条就不是算法
2.1 有穷性与确定性:两条最容易被突破的红线
教材里说算法的五个基本特性:有穷性、确定性、可行性、输入、输出。前两个是最容易被忽略、也最容易在实际代码里出问题的。
有穷性指算法必须在有限步骤后结束,不能是死循环。听起来是废话,但写递归时忘写终止条件、写树遍历时没标记访问过的节点、写图搜索时没有处理环,都是典型的有穷性破坏。你可能觉得这些是“写 bug”,但从定义角度看,这个步骤序列已经不满足成为算法的最低条件了。工程里很多服务端程序常驻运行、看起来永不结束,跟这里说的“有穷性”不是一回事,因为单个请求的处理算法本身还是要终止的,常驻是程序层面的设计选择,不是算法层面的循环。
确定性指每一步操作必须有明确含义,不能有歧义。同样的输入,无论谁执行、执行多少遍,得到的都是同一个结果。这个特性看起来理所当然,但实际学习里经常被各种“约定俗成”破坏,下一节就有一个特别典型的例子。
2.2 可行性与输入输出:算法的边界和“下线条件”
可行性要求每一步操作都必须在有限时间内、用有限的资源完成。这不是一句空话,有些理想化的算法步骤在现实机器上根本没法执行。比如某一步要求“用无限精度的实数做运算”,理论推导可以这么写,但一台物理机器做不到无限精度,真去实现就得换成浮点数,一换精度损失,结果可能就变了。所以设计算法时,不能只追求理论上的优雅,还要确认每一步在当前计算模型下是可执行的。
输入和输出则是算法的边界约定。一个算法可以有零个或多个输入,但至少有一个输出。零输入不是没输入,而是算法本身不依赖外部条件。生成随机数、生成全局唯一 ID、获取当前时间戳的这些算法,都不需要用户输入什么,但它们必须有输出,没有输出,算法等于白算,外部无法验证它到底做了什么。
2.3 KMP 的 next 数组之争:一个看清“确定性”价值的真实例子
回到开头那个 KMP 的困惑。模式串 p="abacaba",如果你去查资料,会看到很多种 next 数组求法。标准的部分匹配表大约是 [0, 0, 1, 0, 1, 2, 3],每一项表示当前位置之前的子串里,最长相同前后缀的长度;但有些教材把 next[0] 定义为 -1,有的定义为 0,有的把整个数组定义成“失配时应该跳转到的位置”,于是数组内容就完全不同了。两套方案都能配合对应的匹配代码跑通 KMP,但你不能混用。
这正是确定性在现实中的分量。教材里如果只写一句“next 数组用于失配时避免回溯”,根本不定义清楚每个位置代表什么,读者照抄代码就是灾难。很多初学者跑来问 KMP 为什么写不对,原因往往不是逻辑推导能力差,而是参考资料的算法描述本身就有歧义,他没意识到要先确认“这套代码到底配的是哪种 next 定义”。理解了这一点,你就明白为什么算法每一步都必须无歧义——在真实协作里,一个含糊的算法描述可以让整个团队做出好几个互不兼容的实现版本。
3. 设计与分析是一体两面:先证明“做对了”,再量化“有多快”
3.1 正确性靠证明,不靠“跑通了”
一个算法设计完成之后,第一件事是证明它正确,而不是急着写代码调试。证明正确性的常见手段是循环不变式加数学归纳法,说人话就是:找到一个在每轮循环开始前都为真的条件,证明它初始时成立、循环中保持、循环结束时能推出结论。
拿插入排序举例。它的循环不变式可以这样写:外层循环执行到第 i 次时,数组前 i 个元素已经有序,并且正好是原数组前 i 个元素。初始 i=1,单元素数组天然有序,成立;假设第 i 次成立,第 i+1 次时把新元素往左插到正确位置,前 i+1 个元素仍然有序,成立;循环结束,整个数组有序。这就是一个完整的正确性论证。
我当年学的时候觉得这是考试用的表演套路,直到后来自己排查一个线上排序问题,才意识到“感觉对”和“证明对”之间差着多少个边界条件。凡是没写循环不变式就拍胸脯说“我的排序没问题”的代码,十有八九会在空数组、单元素数组、全部相等元素、已有序数组这些边界上翻车。算法的正确性不是靠几个用例试出来的,而是靠逻辑推出来的;测试只能证明“程序没在测试用例上出错”,不能证明“算法对了”。
3.2 大 O、量级差异与一个惊人的算例
分析算法效率,最常用的是大 O 表示法,描述的是输入规模 n 趋近无穷时,运行时间的增长趋势。它忽略常数因子和低阶项,只保留主导项。比如 3n^2 + 5n + 2,写成 O(n^2),因为它增长最快的项是 n^2。与它配套的还有大 Ω(下界)和大 Θ(紧渐近界),工程上最常说的是最坏情况复杂度,因为我们要评估的是“极端情况下会不会超时”。
复杂度量级的差别有多大,很多人可能没有直观感受。假设一个程序每秒能执行 10^9 次基本操作,输入规模 n=10^6:
| 复杂度 | 操作次数估算 | 耗时估算 |
|---|---|---|
| O(n) | 约 10^6 | 约 1 毫秒 |
| O(n log n) | 约 2×10^7 | 约 20 毫秒 |
| O(n^2) | 约 10^12 | 约 1000 秒,也就是 16 分半 |
| O(2^n) | n=30 就已接近 10^9 | 秒级;n=40 直接到 10^12 级别 |
这就是为什么算法设计技术如此重要。同一个问题,一个 O(n^2) 的朴素解和一个 O(n log n) 的分治解,在数据量上来之后,差距不是“快一点”,而是“从 16 分钟变成 20 毫秒”。在真实系统里,这就是一个接口可用和不可用的区别。
3.3 分析结果是设计优化的方向:KMP 是怎么赢朴素匹配的
设计算法和分析算法不是一前一后各干各的,分析结果往往是下一步设计的输入。最经典的例子还是 KMP。
朴素字符串匹配的做法是:文本串从每个位置开始,把模式串逐位对齐比较,失配就整体右移一位重来。最坏情况是 O(mn)(m 是文本长度,n 是模式串长度)。为什么慢?因为每次失配后,之前比较过的字符信息全部丢弃,文本指针要回退,模式串只右移一格,大量重复比较被浪费。
KMP 的做法是先分析模式串自身的结构——哪些前缀和后缀是相同的,记录成 next 数组。匹配时一旦失配,模式串不是右移一格,而是根据 next 数组直接跳到下一个可能匹配的位置,文本指针不回退。这样总复杂度降到 O(m+n)。整个 KMP 的诞生逻辑就是先分析朴素算法的瓶颈,再针对这个瓶颈设计新的数据结构来消除重复劳动。这就是我反复说的“设计-分析-优化”循环:先写一个能跑的版本,分析它慢在哪,再根据分析结果去设计更优的方案。
4. 常用设计技术怎么选:分治、贪心、动态规划、回溯的一次说清
4.1 分治法:拆解、求解、合并,三件事一件都不能少
分治法的思想是:把大问题拆成若干个结构相同、规模更小的子问题,递归求解子问题,再把子问题的解合并成原问题的解。归并排序就是最标准的例子。拆:把数组从中间切成两半;解:递归排序左右两半;合:把两个有序子数组合并成一个有序数组,单次合并 O(n)。复杂度满足 T(n)=2T(n/2)+O(n),解出来就是 O(n log n)。
分治的关键在“合并”这一步。拆得再漂亮,如果合并做不了或者合并代价太高,这个分治方案就不成立。快速排序和归并排序的差别也在这里:快排的拆分是选基准、分区,合并几乎为零;归并的拆分是 O(1),代价全在合并。理解这个差异,你就知道为什么快排通常更快、而归并排序更适合链表和外部排序。
分治法还有一个常见误用场景:问题确实能拆成子问题,但子问题之间大量重叠,拆完发现同一件事被反复算了很多遍。这种时候用纯分治会非常低效,经典例子是斐波那契数列的朴素分治递归,复杂度直接到 O(2^n)。这种重叠子问题,恰恰是动态规划的用武之地。
4.2 贪心与动态规划:局部最优和全局最优的分水岭
贪心和动态规划是被问得最多的两个设计技术,因为它们都要求问题有“最优子结构”——全局最优包含局部最优,但适用条件完全不同。
贪心算法每一步都选当前看起来最好的那个选择,期望局部最优能凑成全局最优。它成立需要满足“贪心选择性质”:每一步的局部最优选择,不会影响后续选择,并且能被证明最终就是全局最优。活动选择问题就是典型:会议室要排一批活动,每个活动有开始和结束时间,怎么做才能排最多场?按结束时间最早优先,每次选完直接跳到下一个不冲突的,就是答案。贪心在这里是高效且正确的。但很多问题并不满足这个性质,比如 0/1 背包,每一步选当前单位价值最高的物品,很可能因为容量限制导致整体不是最优,这时候就必须上动态规划。
动态规划处理的是有重叠子问题和最优子结构的问题。它的核心是定义状态和状态转移方程。拿最长公共子序列举例:设 dp[i][j] 表示字符串 s 的前 i 个字符和 t 的前 j 个字符的最长公共子序列长度。如果 s[i-1]==t[j-1],dp[i][j]=dp[i-1][j-1]+1;否则 dp[i][j]=max(dp[i-1][j], dp[i][j-1])。这就是把大问题拆成一层层子问题,用表格存中间结果避免重复计算。
我给判断时常用一句口语:如果你发现一个问题的暴力枚举会反复计算很多一模一样的子问题,那大概率能动态规划;如果你能证明每一步根本不需要管前面发生了什么,只看当前状态选最赚的就行,那就是贪心。这两者的分水岭,本质上是“子问题之间有没有依赖”。
4.3 回溯与分支限界:给暴力搜索装上“刹车”
回溯法本质是深度优先搜索加剪枝:在解空间里一条路走到黑,走不通就回退到上一个岔路口换一条路。经典题目是 N 皇后:逐行放皇后,每次放之前检查是否和已放的同列或同对角线冲突,冲突就换一列,换到没有可放的了就回退到上一行。整个过程就是在解空间树上做一次带约束条件的深度优先遍历,最终找出所有可行解或一个可行解。
分支限界法和回溯很像,它通常用于最优化问题,而且更多采用广度优先或优先级队列的方式遍历解空间。区别在于它除了剪枝,还会维护一个“界限函数”——提前估算某个分支继续往下搜能不能比当前最优解更好,如果不可能,整棵子树直接放弃。旅行商问题这类组合优化问题,用分支限界就比纯暴力枚举少搜很多分支。
这两个技术的共性是:明确告诉你暴力搜索在指数级空间里不可行,但你可以用问题本身的约束条件把大量不可能的分支提前砍掉。把“能不能放这个王妃”的检查写严谨,比优化遍历顺序更值得花时间,因为剪枝剪得越早,效率提升越大。
4.4 别忽视的扩展技法:随机化、近似算法、在线算法
除了前面几类,还有几类技术在实际项目里经常出现,初学者可以先留个印象。
随机化算法分两类:蒙特卡洛算法可能出错但很快,拉斯维加斯算法结果一定正确但运行时间不固定。随机快速排序就是最简单的例子,随机选基准,把有序输入导致的最坏情况的概率大大降低。近似算法和启发式算法则在 NP 难问题上大量出现,比如旅行商、调度问题,常见的粒子群算法、模拟退火算法都算启发式家族。机器学习算法和深度学习算法本质上也是算法大家族的分支,只是它们的目标函数和评价方式更复杂。这些内容不用在一开始就深挖,但知道它们的存在,能让你在遇到教科书经典方法解决不了的问题时,知道还有别的路可走。
5. 评价指标从来不是单一维度的:稳定、健壮、可读、场景适配都得看
5.1 排序三巨头同是 O(n log n),实际表现差在哪
很多人学排序学到“平均 O(n log n)”就停了,以为这三个算法差不多。真到工程里选型,它们差别非常大。
| 算法 | 平均/最坏复杂度 | 空间 | 稳定性 | 实际特点 |
|---|---|---|---|---|
| 快速排序 | O(n log n) / O(n^2)(随机化可规避) | O(log n) 栈空间 | 不稳定 | 常数小、缓存友好,综合最快 |
| 归并排序 | O(n log n) / O(n log n) | O(n) 额外空间 | 稳定 | 适合链表、外部排序 |
| 堆排序 | O(n log n) / O(n log n) | O(1) 原地 | 不稳定 | 常数大、缓存不友好 |
同样是 O(n log n),快排在几十万数据规模下通常比堆排序快两三倍,原因在于常数因子和缓存局部性不同。堆排序虽然最坏情况下复杂度有保证,但它的比较次数多、内存访问跳跃大,实际运行就是慢半拍。这也是为什么很多标准库的默认排序不是理论最优的那个,而是综合工程表现最稳的那个。学算法的人如果眼睛只盯大 O,不看常数和场景,选出来的“理论最优算法”很可能在真实环境里被打得找不到北。
5.2 稳定性为什么重要:多关键字排序里的坑
稳定性是排序算法的一个隐藏评价维度,它指的是:排序后,相等元素的相对顺序保持不变。很多人在基础阶段觉得稳定不稳定无所谓,直到做多关键字排序才意识到它的价值。
假设要对一批人员信息先按姓氏升序排,再按名字升序排。如果第二次排序用的是不稳定的快速排序,姓“李”的那一组内部,原来按名字排好的顺序可能被打乱。但如果第二次排序是稳定排序,那么相等姓的人之间,会保留第一次排序产生的名字顺序,结果自然正确。Python 的 sorted 采用 Timsort 并且保证稳定,就是这个原因。当你需要分阶段地按多个字段排序时,稳定排序可以省掉一大堆额外处理。
5.3 工程视角下的指标取舍:最坏情况、常数因子、可维护性
实际项目里选算法,很少只有一个评价维度。至少还要看三件事。
第一,最坏情况 vs 平均情况。实时系统里最坏情况是生死线。比如自动驾驶里的路径规划,平均一秒内算完没用,必须保证最坏情况下也能在限定时间内返回结果,所以这类场景往往选择复杂度的上界有保证的算法,而不是平均值漂亮但偶尔会“抽风”的算法。
第二,常数因子和硬件特性。大 O 相同的两个算法,常数差几倍,在大规模数据上就是几倍的资源消耗。缓存是否友好、能不能并行、IO 次数多不多,这些都直接影响真实运行时间。
第三,可读性和可维护性。一个 O(n log n) 但写得晦涩难懂的算法,在团队协作里可能比一个 O(n^2) 但逻辑清楚的实现更难维护。优化是有代价的,复杂度降下来了,代码复杂度可能上去了。正确做法通常是先用清晰、简单的实现把功能跑通,再用复杂度分析确认它是否真的慢,慢在哪个环节,再做针对性的算法优化。
