news 2026/11/3 3:46:44

递归与回溯实战:从河内之塔到八皇后,吃透51个经典算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
递归与回溯实战:从河内之塔到八皇后,吃透51个经典算法

简介:这份Word文档面向ACM竞赛选手与算法学习者,系统整理了51个经典算法实例,覆盖递归、图论、动态规划、搜索与组合优化等核心领域,适合用来打牢算法基础、训练解题思维。包内仅含1个doc文件,压缩包约1.77MB,共126页,每道题均配有题目说明、解题思路、分析过程与可运行的源码,形成从理解到实现的完整闭环。内容从河内之塔、费式数列、巴斯卡三角形等入门递归题,延伸到老鼠走迷宫、骑士走棋盘、八皇后等搜索与回溯问题,再到背包问题、蒙地卡罗法求PI、Eratosthenes筛选求质数、超长整数运算等进阶主题,目录按算法逐条编排,便于按知识点检索与对照练习。目前已有413人学习,适合希望系统刷题、查漏补缺的竞赛选手与自学者参考。

1. 递归不是玄学:从河内之塔看“把问题缩小一号”为什么总能奏效

很多人第一次写递归,是在河内之塔上翻车的:明明只有三行核心代码,却怎么也说不清为什么它能把 64 个盘子搬完。更反直觉的是,这份流传很广的《ACM51个经典算法大全》里,河内之塔的解法只有一句关键判断——盘数超过两个时,把第三个以下的盘子“遮起来”,每次只处理两个盘子。遮住的部分交给递归,递归再遮住更下面一层,直到只剩一个盘子直接搬走。这个“遮起来”的动作,就是递归最底层的设计动机:把 n 规模的问题,压缩成 n-1 规模加一次确定的移动。搞懂这一点,后面八皇后、老鼠走迷宫、骑士走棋盘、背包问题,全都是同一套骨架的不同变体。这份资料把 51 个经典例子按“题目、思路、分析、源码”四段式整理,源码可运行,正好适合拿来对照着看机制,而不是背模板。

2. 递归与回溯:为什么“退回来”比“往前走”更难写对

2.1 河内之塔的移动次数为什么是 2^n - 1

先看资料里给出的递推关系。设 n 个盘子从 A 搬到 C 需要 T(n) 次,那么必须先把上面 n-1 个搬到 B(T(n-1) 次),再把最大的一个搬到 C(1 次),最后把 n-1 个从 B 搬到 C(T(n-1) 次)。于是 T(n) = 2T(n-1) + 1,展开后就是 2^n - 1。资料里顺手算了一笔账:64 个盘子、每秒搬一个,约 5850 亿年。这个数字不是噱头,它说明递归的代价是指数级的,任何“看起来优雅”的递归写法,都要先问一句规模上限在哪。

void hanoi(int n, char A, char B, char C) { if (n == 1) { printf("Move sheet %d from %c to %c\n", n, A, C); } else { hanoi(n - 1, A, C, B); // 把上面 n-1 个借 C 搬到 B printf("Move sheet %d from %c to %c\n", n, A, C); // 最大盘直接到 C hanoi(n - 1, B, A, C); // 把 B 上的 n-1 个借 A 搬到 C } }

三个参数的顺序是这段代码最容易看晕的地方。A、B、C 不是固定的柱子身份,而是“源、辅助、目标”三个角色。第一次递归调用把 C 当辅助、B 当目标;第二次递归调用把 A 当辅助、C 当目标。角色在每一层递归里轮换,这正是递归能把“中间柱”这个概念自动处理掉的原因。我一般会这么理解:递归函数签名里的参数,描述的是“这一层要完成的任务”,而不是“全局的柱子”。

2.2 老鼠走迷宫:回溯的本质是“撤销选择”

资料里老鼠走迷宫(一)用 2 表示墙、1 表示路径、0 表示可走。核心函数 visit 先标记当前格为 1,然后按右、下、左、上四个方向试探,一旦 success 被置位就不再继续。关键在最后三行:如果四个方向都没走通,把当前格重新置 0,退回上一格。这就是回溯的“后悔药”——走错了要把脚印擦掉,否则后续路径会被假路径堵死。

int visit(int i, int j) { maze[i][j] = 1; // 先假设这一步在路径上 if (i == endI && j == endJ) success = 1; if (success != 1 && maze[i][j+1] == 0) visit(i, j+1); if (success != 1 && maze[i+1][j] == 0) visit(i+1, j); if (success != 1 && maze[i][j-1] == 0) visit(i, j-1); if (success != 1 && maze[i-1][j] == 0) visit(i-1, j); if (success != 1) maze[i][j] = 0; // 四个方向都不通,撤销标记 return success; }

注意 success 这个全局标志的作用:它让“找到一条路径”之后的所有递归调用快速短路返回,不再继续搜索。这是求单一路径和求所有路径的分水岭。资料里老鼠走迷宫(二)把 success 判断全部去掉,改成走到出口就打印整张地图,然后继续试探,最后无条件把当前格置 0。两段代码只差几行,语义却从“找一条路”变成“枚举所有路”。很多人在这一题上踩坑,就是把两种语义混在一起写,结果要么只打印一条路径,要么路径重复。

2.3 八皇后:分支修剪为什么能把 8^8 压到可算

八皇后如果暴力枚举每一列放哪一行,是 8^8 种组合。资料里的做法是逐行放置,每放一个皇后就检查同列、右上到左下、左上到右下三条线是否被占用,用 column、rup、lup 三个数组做 O(1) 判断。一旦某行无位置可放,立刻回溯到上一行换列,这就是“分支修剪”。

void backtrack(int i) { if (i > N) { showAnswer(); } else { for (int j = 1; j <= N; j++) { if (column[j] == 1 && rup[i+j] == 1 && lup[i-j+N] == 1) { queen[i] = j; column[j] = rup[i+j] = lup[i-j+N] = 0; // 占用 backtrack(i + 1); column[j] = rup[i+j] = lup[i-j+N] = 1; // 恢复 } } } }

rup 的下标是 i+j,范围 2 到 2N;lup 的下标是 i-j+N,把负数偏移到正数区间。这两个下标变换是八皇后最值得记住的细节:它把“斜线冲突”这种几何判断,变成了数组下标判断。恢复现场的三行赋值和占用时的三行完全对称,少写一行就会出现“上一行的皇后影响了下一行的判断”这种血泪 bug。

3. 分治与贪心:为什么“局部最优”有时真的能走到全局最优

3.1 骑士走棋盘:Warnsdorff 规则背后的直觉

骑士旅游如果纯递归,8x8 棋盘上分支因子是 8,深度 64,搜索空间大到不可接受。资料里明确提到 1823 年 Warnsdorff 提出的启发式:下一步优先走“再下一步可选步数最少”的那个格子。代码里先收集当前格所有可走方向,再对每个候选方向统计它的出路数 exists[l],最后选 exists 最小的那个走。

// 对每个候选下一步,统计它还能走几步 for (l = 0; l < count; l++) { for (k = 0; k < 8; k++) { tmpi = nexti[l] + ktmove1[k]; tmpj = nextj[l] + ktmove2[k]; if (tmpi < 0 || tmpj < 0 || tmpi > 7 || tmpj > 7) continue; if (board[tmpi][tmpj] == 0) exists[l]++; } } // 选出路最少的候选 tmp = exists[0]; min = 0; for (l = 1; l < count; l++) { if (exists[l] < tmp) { tmp = exists[l]; min = l; } }

这个规则的直觉是:先把“死角”走掉,把出路多的格子留到后面。它不保证一定成功,资料里也老实写了“找不到走法的机会也是有的”,但在 8x8 上成功率很高。这里的设计取舍很典型:用贪心换掉指数级搜索,牺牲完备性换效率。理解这一点,比背下八个方向的偏移数组更有价值。

3.2 背包问题:动态规划为什么能避开重复子问题

资料里背包问题用动态规划求解。核心状态是“前 i 件物品、容量 j 下的最大价值”,转移方程是取“不放第 i 件”和“放第 i 件”的较大值。和递归版相比,DP 把每个 (i, j) 状态只算一次,用二维表存下来。常见的实现思路是倒序遍历容量,把二维压成一维,避免同一件物品被重复放入。

for (int i = 0; i < n; i++) { for (int j = capacity; j >= weight[i]; j--) { // 倒序保证 dp[j-weight[i]] 还是上一轮的值 if (dp[j] < dp[j - weight[i]] + value[i]) dp[j] = dp[j - weight[i]] + value[i]; } }

倒序这一行是 0-1 背包和完全背包的分界线。正序遍历会让同一件物品被多次选取,那是完全背包的语义。很多人写背包翻车,不是转移方程错,而是循环方向错。资料里把这类“同一骨架、不同方向”的题目放在一起,正好用来对照。

3.3 三色棋:一次遍历完成分类的指针技巧

三色棋要求只用原数组,把蓝、白、红排成顺序。资料里的解法维护三个指针:bFlag 指向蓝色区末尾之后,wFlag 是当前处理位置,rFlag 指向红色区起始之前。遇到白色 wFlag 前进;遇到蓝色与 bFlag 交换,两个指针都前进;遇到红色与 rFlag 交换,rFlag 后退但 wFlag 不动,因为换过来的元素还没检查。

while (wFlag <= rFlag) { if (color[wFlag] == WHITE) wFlag++; else if (color[wFlag] == BLUE) { SWAP(bFlag, wFlag); bFlag++; wFlag++; } else { while (wFlag < rFlag && color[rFlag] == RED) rFlag--; SWAP(rFlag, wFlag); rFlag--; } }

红色分支里那个 while 是在跳过已经在正确位置的红色元素,避免无意义的交换。这个算法本质上是三路划分,后来被用在快速排序处理重复元素上。资料把它放在排序章节之前,其实埋了一条线:三色棋的指针划分,就是快排 partition 的雏形。

4. 排序与查找:为什么“改良”往往只是换了一个比较维度

4.1 Shell 排序:插入排序为什么怕“远距离逆序”

插入排序对基本有序的序列很快,但对逆序严重的大数组很慢,因为每次只能把元素往前挪一格。Shell 排序的做法是先按较大间隔分组做插入排序,让元素能一次跨越大距离,再逐步缩小间隔,最后间隔为 1 时序列已经接近有序。资料里把它称为“改良的插入排序”,这个定位很准确:它没有换算法骨架,只是换了一个“比较和移动的步长维度”。

常见的实现思路是间隔取 n/2、n/4 直到 1。这个序列不是最优的,但足够简单。理解 Shell 排序的关键是:间隔大于 1 时,排序结果不是最终有序,只是“间隔有序”。最后一轮间隔为 1 的插入排序才是收尾。很多人误以为前面几轮已经把数组排好,结果在间隔序列设计上出错。

4.2 快速排序的三种写法:枢轴选择为什么决定性能

资料里快速排序给了三个版本,差别主要在枢轴怎么选、划分怎么做。版本一取第一个元素做枢轴,版本二取中间元素,版本三做了更细的划分处理。枢轴选择直接决定最坏情况:如果每次取到最大或最小元素,递归深度退化成 n,复杂度从 O(n log n) 掉到 O(n^2)。取中间元素是一种低成本的缓解手段,但面对精心构造的输入仍然可能退化。

// 以中间元素为枢轴的划分示意 int pivot = arr[(left + right) / 2]; while (left <= right) { while (arr[left] < pivot) left++; while (arr[right] > pivot) right--; if (left <= right) { swap(&arr[left], &arr[right]); left++; right--; } }

这段划分的好处是左右指针相向而行,交换后不会死循环。注意内层两个 while 没有等号判断,等号情况交给外层 if 处理,这样重复元素也能被均匀分到两边。快排的“玄学”性能问题,多半出在枢轴和重复元素处理上,而不是递归本身。

4.3 二分查找与插补查找:有序性怎么被“利用”得更彻底

二分查找每次取中点,把搜索区间砍半,复杂度 O(log n)。插补查找则根据目标值在区间中的比例来估算位置:mid = low + (key - arr[low]) / (arr[high] - arr[low]) * (high - low)。当数据分布均匀时,插补查找接近 O(log log n),比二分更快;但数据分布倾斜时,它可能退化得比二分还慢。

资料里还提到费氏查找,用斐波那契数来分割区间,避免除法运算。这三种查找的共同前提都是“有序”,差别在于用哪种方式估计下一个检查点。理解它们的取舍,比记住公式更重要:二分最稳,插补在均匀分布上最快,费氏在特定硬件上省除法。

5. 避坑与排查:这 51 个例子里最容易翻车的几处

5.1 递归没有终止条件或终止条件写错

现象:程序栈溢出,或者输出明显多于预期。原因:河内之塔里把 n==1 写成 n==0,或者迷宫 visit 里没有在出口处正确置位 success。解决:先在纸上画出 n=1、n=2 的调用树,确认每一层都能收敛到终止条件;迷宫题在进入函数第一行就判断出口,而不是放在四个方向试探之后。

5.2 回溯后忘记恢复现场

现象:八皇后解的数量偏少,或者迷宫只找到一条路径后其他路径全被堵死。原因:占用数组或路径标记在递归返回后没有还原。解决:把“占用”和“恢复”写成对称的两段代码,紧挨着放,中间只夹递归调用;迷宫题在四个方向都失败后必须把当前格置 0。

5.3 数组下标越界或偏移量算错

现象:八皇后在某些行突然无解,或者骑士走棋盘走到边界外。原因:rup 的 i+j 最大到 2N,数组只开了 2N+1 却从 1 开始用;lup 的 i-j+N 在 i=1、j=N 时等于 1,边界没对齐。解决:把所有下标的最小值和最大值手算一遍,数组大小按最大值加一开;骑士题在每次计算 tmpi、tmpj 后立刻做边界检查,不要等到访问 board 时才检查。

5.4 排序算法的循环边界写反

现象:Shell 排序结果局部有序但整体不对,或者快排出现死循环。原因:Shell 排序的内层插入排序步长用了 1 而不是当前间隔;快排划分时 left 和 right 的移动条件写成了 <= 或 >=。解决:Shell 排序内层所有下标运算都用当前 gap;快排划分的内层 while 用严格小于/大于,等号交给外层交换处理。

5.5 大数运算按普通整型处理

现象:超长整数运算结果溢出或变成负数。原因:资料里的大数运算需要用数组或字符串逐位处理,直接套 int 或 long 会在超过范围后静默出错。解决:把每一位存进数组,从低位向高位处理进位和借位;输出时跳过前导零,但结果为 0 时要单独处理。

6. 从 51 个例子看算法资料该怎么读:一份可验证的取舍清单

这份资料的价值不在于“51”这个数字,而在于它把每个例子的题目、思路、分析、源码四段放在一起,形成了一条可验证的链路。读这类资料,我一般会做三件事。第一,先看思路段能不能用一句话说清算法的核心动作,比如河内之塔是“遮住下面、处理两个”,三色棋是“三个指针分区”,说不清就说明还没读懂。第二,把源码里的关键判断改成错误版本,跑一遍看现象是否符合预期,比如把八皇后的恢复现场删掉,看解的数量怎么变,这种对照比通读十遍更有效。第三,记录每个例子的规模上限和复杂度,形成一张自己的取舍表。

例子核心机制典型复杂度容易翻车的点
河内之塔递归缩小规模O(2^n)终止条件、参数角色轮换
老鼠走迷宫回溯撤销O(4^(m*n)) 最坏忘记恢复标记
八皇后回溯加分支修剪远小于 8^8斜线下标偏移
骑士走棋盘贪心启发式近似线性边界检查
背包问题动态规划O(n*capacity)一维数组遍历方向
三色棋三路划分O(n)红色分支 wFlag 不动
快速排序分治加划分平均 O(n log n)枢轴退化、重复元素

这张表不是让你背,而是让你在遇到新题时能快速定位“它属于哪一类骨架”。51 个例子里,递归、回溯、贪心、动态规划、排序、查找、矩阵操作这几类反复出现,真正新的东西并不多,多的是同一机制在不同场景下的变形。比如巴斯卡三角形和组合数计算是同一套递推,魔方阵和矩阵转置共享下标变换的思路,约瑟夫问题和循环链表、数组模拟都能互相印证。

还有一个容易被忽略的点:资料里的源码是 C 语言写的,很多边界处理依赖数组和全局变量。移植到其他语言时,全局状态要改成参数传递,数组越界检查要显式补上,否则原本能跑的代码换个环境就翻车。我一般会先把全局变量收进一个结构体,再把递归函数的返回值语义统一,这样调试时能少走很多弯路。

从那以后我每次读这类算法合集,都会先挑一个例子把源码改错再改对,强制走一遍“现象、原因、修复”的闭环,而不是顺着目录从头看到尾。希望这份整理也能帮到你,把 51 个例子读成几类骨架,而不是 51 个孤立的故事。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 3:45:50

AgentScope多智能体框架:从选型到落地的实践全复盘

最近在给一个售后工单系统做多智能体改造&#xff0c;得先把几十个业务线的话术模板统一收口。这个需求听起来不复杂&#xff0c;真做起来很痛苦。我试过自己撸代码&#xff0c;用一个中心循环去调度各个 prompt&#xff0c;角色一多就变成一坨&#xff1b;也试过直接套 LangCh…

作者头像 李华
网站建设 2026/11/3 3:44:42

电商数据自动化分析报表系统架构与实现

1. 电商数据自动化分析报表系统概述在电商运营的日常工作中&#xff0c;数据分析报表就像驾驶舱里的仪表盘&#xff0c;为决策者提供实时、准确的数据支持。我参与设计的这套自动化报表系统&#xff0c;正是为了解决电商团队每天手动导出数据、制作Excel报表的痛点。系统上线后…

作者头像 李华
网站建设 2026/11/3 3:43:54

GraalVM多语言互操作与JavaScript性能优化实践

1. GraalVM简介与核心优势GraalVM是Oracle开发的一款高性能运行时环境&#xff0c;它彻底改变了传统JVM的边界。与普通JVM最大的不同在于&#xff0c;GraalVM通过Truffle语言实现框架支持多语言互操作&#xff0c;其中对JavaScript的支持尤为成熟。我在实际项目中使用GraalVM处…

作者头像 李华
网站建设 2026/11/3 3:43:54

Vite 6与SVGR插件高效集成实战指南

1. Vite 6与SVGR插件组合方案解析作为前端构建工具的新锐代表&#xff0c;Vite 6在2023年第四季度发布后&#xff0c;其开发体验和构建效率再次获得提升。而vite-plugin-svgr 4.5作为SVG处理方案的黄金搭档&#xff0c;能够将SVG文件转换为React组件&#xff0c;这种组合特别适…

作者头像 李华
网站建设 2026/11/3 3:42:48

基于BAND.dat与Python拟合的能带有效质量计算实战指南

计算材料学里有个挺有意思的现象&#xff1a;很多人能跑完第一性原理计算、能画出漂亮的能带图&#xff0c;但一到要报有效质量这个数&#xff0c;就开始翻手册、找经验公式、或者干脆凭感觉估一个。我见过不少组里的新人&#xff0c;能带图看着挺像样&#xff0c;一问电子有效…

作者头像 李华
网站建设 2026/11/3 3:38:09

技术谣言传播机制与应对策略

1. 事件背景与传播现象解析2023年9月&#xff0c;某技术社区群组内一张未经证实的聊天截图突然引发行业震动。截图内容涉及某主流开源框架的架构设计争议&#xff0c;在48小时内获得超过2万次转发&#xff0c;相关话题阅读量突破500万。这种现象级传播背后反映的是技术社区特有…

作者头像 李华