资讯动态

深度优先搜索(DFS)实战:从整数拆分问题掌握递归与回溯算法

发布时间:2026/8/28 3:03:11 来源:尧图企业网站定制
1. 从一道“简单”的算法题说起加法分解最近在整理蓝桥杯的历年真题和练习题时我又翻到了ALGO-633这道题。题目名字叫“加法分解”听起来平平无奇甚至有点“小学数学”的味道。很多刚接触算法竞赛的同学看到这种题目第一反应可能就是“这不就是枚举吗暴力循环一下不就行了” 我最初也是这么想的但真正动手去实现并且思考如何优化、如何清晰地表达解题思路时才发现这道题远不止“枚举”那么简单。它像一块很好的试金石能检验出你对循环控制、递归思想、去重逻辑以及问题建模的掌握是否扎实。今天我就结合这道题和大家深入聊聊如何拆解一个看似简单的算法问题并写出高效、清晰的代码。这不仅仅是解一道题更是锻炼我们将模糊的自然语言描述转化为精确的计算机逻辑的能力。这道题的核心要求是给定一个正整数N要求输出所有将N分解为若干个正整数之和的表达式且分解出的正整数要求非递增排列即后一个数不能大于前一个数。例如对于N4分解有431, 422, 4211, 41111。注意44本身也是一种分解。这其实就是数学中的“整数拆分”问题的一个变体或简化版。我们今天的讨论将完全使用C语言因为这是蓝桥杯竞赛中最主流、最考验基本功的语言。通过这道题我希望不仅能给出答案更能分享一套面对此类“无序阶段”练习题时的系统性解题框架。2. 问题本质分析与递归树模型构建拿到题目第一步不是急着写代码而是彻底理解问题边界和输出要求。我们来仔细分析一下“加法分解”的规则输入一个正整数N。输出所有可能的加法表达式每个表达式中的加数都是正整数。关键约束表达式中的加数按非递增顺序排列。这是避免重复的关键。例如对于13和31在数学上是同一种分解我们的程序只应输出31非递增而不应输出13。输出格式通常每个表达式占一行格式如“Nabc...”。为什么“非递增”这个约束如此重要因为它为我们提供了一种自然的搜索顺序使得我们能够系统性地、不重复也不遗漏地生成所有解。如果没有这个约束我们就会陷入对排列组合的混乱枚举中效率极低且很难去重。最直观的解题思路是深度优先搜索DFS。我们可以把整个搜索过程想象成一棵递归树树的根节点是我们要分解的数N以及一个“当前最大可用加数”的初始值为了满足非递增第一个加数最大不能超过N本身我们可以从N开始尝试。树的每一层代表我们决定分解式中的下一个加数是多少。树的每个分支代表我们选择了一个具体的数作为加数。树的叶子节点代表一种完整的分解方案当剩余需要分解的数值为0时。搜索过程就是从根开始尝试所有可能的“下一个加数”这个加数不能大于“当前最大可用加数”也不能大于剩余数值选定后将剩余数值减去这个加数更新“当前最大可用加数”为这个选定的加数以保证非递增然后进入下一层递归。当剩余数值为0时我们就找到了一条从根到叶子的路径这条路径上的所有选择就构成了一个合法的分解式。这个模型清晰地将问题转化为了一个带约束的路径搜索问题。接下来我们就用代码来实现这个模型。3. 核心递归算法实现与逐行解读基于上面的递归树模型我们可以设计一个DFS函数。这个函数需要知道几个状态remain当前剩余多少数需要被分解。max_val当前可以使用的最大加数为了保证非递增。path[]一个数组用于记录当前路径上已经选择的加数。depth当前路径的深度也就是已经选择了几个加数同时它也是path数组下一个空闲位置的索引。下面是我用C语言实现的代码我会加上详细的注释#include stdio.h #define MAX_SIZE 1000 // 假设N最大为1000路径数组足够大 int path[MAX_SIZE]; // 全局数组存储当前分解路径 int count 0; // 可选用于统计分解方案总数 /** * brief 深度优先搜索函数用于生成所有加法分解 * param remain 当前剩余需要分解的数值 * param max_val 当前层允许使用的最大加数保证非递增 * param depth 当前路径的深度即已存储的加数个数 */ void dfs(int remain, int max_val, int depth) { // 递归终止条件剩余数为0说明找到一组有效分解 if (remain 0) { // 打印分解式。注意格式第一个数前不加 printf(%d, path[0]); for (int i 1; i depth; i) { printf(%d, path[i]); } printf(\n); count; // 方案数加一 return; } // 尝试所有可能的“下一个加数”i // i的范围从1到 min(remain, max_val) // 从1开始是因为加数是正整数 // 不能超过remain否则就超了 // 不能超过max_val以保证非递增 for (int i (remain max_val ? remain : max_val); i 1; --i) { // 将当前选择的加数i记录到路径中 path[depth] i; // 递归进入下一层剩余数减少i新的最大加数更新为i非递增关键 dfs(remain - i, i, depth 1); // 注意这里没有典型的“回溯撤销操作”因为path[depth]会在下一次循环被覆盖 // 这是一种隐式的回溯 } } int main() { int N; printf(请输入一个正整数N: ); scanf(%d, N); if (N 0) { printf(请输入一个正整数。\n); return 1; } printf(数字%d的所有加法分解非递增序如下\n, N); count 0; // 初始调用剩余数为N最大可用加数为N当前路径深度为0 dfs(N, N, 0); printf(共计 %d 种分解方式。\n, count); return 0; }逐行解读与关键点分析dfs函数参数设计remain和max_val是核心状态。depth用于管理path数组这是一个非常经典的DFS记录路径的模式。终止条件if (remain 0)。当没有剩余数需要分解时一条完整的分解路径就形成了直接打印即可。循环设计与非递增实现for (int i (remain max_val ? remain : max_val); i 1; --i)这是算法的灵魂。int i (remain max_val ? remain : max_val)这里决定了当前层可以尝试的最大数。它必须是remain和max_val中的较小者。为什么max_val来自上一层保证了非递增但你不能选一个比remain还大的数那样remain-i就成负数了。这个条件处理了边界。i 1加数至少为1。--i注意这里是递减循环这是一个重要的技巧。如果我们按i1到max的顺序循环输出的分解式会是像411114112...这样的非递减顺序虽然集合是对的但不符合题目“非递增”的直观输出习惯通常我们期望先输出包含大数的分解。让i从大到小循环可以让我们优先选择较大的数这样生成的路径和最终的输出顺序更符合“非递增”的视觉习惯例如先输出44431再输出422...。这对理解和调试都有帮助。递归调用dfs(remain - i, i, depth 1)。这里更新了状态剩余数减少并且将max_val更新为当前选择的i。这正是保证整个分解序列非递增的关键下一层可用的最大数永远不会超过上一层选择的数。路径记录path[depth] i;。在递归调用前记录选择调用结束后无需显式“清理”因为下一轮循环会覆盖这个位置。这种在数组固定位置写入依靠深度depth来索引的方式比显式的入栈出栈更简洁高效。主函数调用dfs(N, N, 0)。初始时剩余数为N第一个加数最大可以为N即NN这种分解路径深度为0。提示i的循环顺序递增或递减不影响解的正确性只影响解的打印顺序。只要max_val的约束在所有解都会被找到且不重复。按递减循环是一种更符合直觉和常见输出范例的做法。4. 算法正确性验证与实例推演理论说得再好不如跑几个例子看看。我们以N4为例手动推演一下递归树并对照程序输出验证算法的正确性。初始状态dfs(4, 4, 0)循环i从4到1。选择 i4:path[0]4。递归调用dfs(0, 4, 1)。触发终止条件打印4。得到分解式4。选择 i3:path[0]3。递归调用dfs(1, 3, 1)。在新调用中remain1,max_val3。循环i从1到1因为min(1,3)1。选择i1:path[1]1。递归调用dfs(0, 1, 2)打印31。得到分解式31。选择 i2:path[0]2。递归调用dfs(2, 2, 1)。在新调用中remain2,max_val2。循环i从2到1。选择i2:path[1]2。递归调用dfs(0, 2, 2)打印22。得到分解式22。选择i1:path[1]1。递归调用dfs(1, 1, 2)。在第三层调用中remain1,max_val1。循环i从1到1。选择i1:path[2]1。递归调用dfs(0, 1, 3)打印211。得到分解式211。选择 i1:path[0]1。递归调用dfs(3, 1, 1)。在新调用中remain3,max_val1。循环i从1到1因为max_val被限制为1。选择i1:path[1]1。递归调用dfs(2, 1, 2)。在第三层remain2,max_val1。循环i从1到1。选择i1:path[2]1。递归调用dfs(1, 1, 3)。在第四层remain1,max_val1。循环i从1到1。选择i1:path[3]1。递归调用dfs(0, 1, 4)打印1111。得到分解式1111。最终输出顺序为4,31,22,211,1111。这与题目要求完全一致。通过这个推演我们可以清晰地看到max_val参数是如何像一把锁牢牢控制住分解序列的顺序避免了13这种逆序情况的产生。5. 性能分析与潜在优化空间探讨我们的DFS算法在正确性上没有问题但它的性能如何这是一个整数拆分问题解的数量随着N增长会急剧增加近似于指数增长。对于算法竞赛我们通常关注的是在给定的时间限制如1秒内能处理多大的N。时间复杂度最坏情况是生成所有分解方案。整数拆分方案数p(N)的增长速度很快虽然没有简单的闭式解但已知p(100)约等于2亿。因此我们的算法时间复杂度是O(p(N))量级的。对于N30左右方案数已经上万N50方案数可达20多万。在普通PC上N50通常可以在1秒内完成。N再大主要瓶颈就不是计算而是输出打印了。空间复杂度主要是递归调用栈的深度和path数组。递归深度最大为N当全部拆分成1时path数组也最多存储N个数。因此空间复杂度是O(N)这对于N1000是绰绰有余的。那么有没有优化空间对于“输出所有方案”这类问题算法本身已经接近最优因为你必须遍历所有解。优化主要在于剪枝和减少常数开销循环下界的优化在我们当前的循环for (int i min(remain, max_val); i 1; --i)中下界是1。但我们可以思考有没有必要尝试很小的数例如当remain很大而max_val也很大的时候如果第一个数就选了1那么后面可能需要非常多的1来凑这会生成非常“长”的分解探索这类分支可能效率较低但对于需要输出所有解的场景这个分支不能剪掉因为它是合法解的一部分。所以在必须输出所有解的前提下循环下界无法优化。记忆化搜索在这个问题中不适用。记忆化搜索Memoization通常用于有大量重叠子问题的场景比如斐波那契数列。但在加法分解中状态(remain, max_val)几乎不会重复吗我们仔细分析(5, 3)这个状态表示剩余5最大可用3。它可能从(8,5)选择3而来也可能从(7,4)选择2后再在下一层遇到(5,3)不会。因为我们的max_val是严格递减或不变的当选择相同的数时而remain是严格递减的。每条路径上的状态(remain, max_val)都是唯一的没有重叠子问题。因此记忆化搜索在这里没有用武之地。输出优化当N很大时打印到屏幕或文件会成为主要耗时。在竞赛中如果遇到极端情况可以考虑用putchar逐字符输出或者先写入一个大的字符缓冲区再一次性输出以减少I/O次数。但在解题阶段用printf通常就够了。迭代替代递归递归代码简洁但存在函数调用开销和栈溢出风险虽然对于N1000深度1000的递归在开启优化后通常没问题。可以用栈数据结构手动模拟递归过程写成迭代形式。但这会大大增加代码复杂度除非有严格栈空间限制否则递归DFS是更优的选择。所以对于ALGO-633这道题我们给出的DFS解法在时间、空间和代码简洁性上达到了一个很好的平衡是标准的正解。6. 代码的健壮性完善与边界处理一个健壮的程序必须考虑各种边界和非法输入。我们之前的main函数已经有了初步判断现在我们来完善它并增加一些更有用的功能。#include stdio.h #include stdlib.h // 用于exit函数 #define MAX_N 100 // 根据题目要求或实际情况设定N的最大值 #define MAX_PATH 1000 int path[MAX_PATH]; int total_count 0; FILE *output_fp NULL; // 可选输出到文件 void dfs(int remain, int max_val, int depth, FILE *fp) { if (remain 0) { total_count; if (fp stdout) { // 输出到屏幕可以稍微格式化 fprintf(fp, No.%4d: %d, total_count, path[0]); } else { // 输出到文件格式可以简化 fprintf(fp, %d, path[0]); } for (int i 1; i depth; i) { fprintf(fp, %d, path[i]); } fprintf(fp, \n); return; } // 更清晰的循环条件计算 int upper_bound (remain max_val) ? remain : max_val; for (int i upper_bound; i 1; --i) { // 一个可选的、轻微的优化如果剩余数remain远大于当前最大允许值max_val的平方 // 不这里没有通用的数学剪枝条件。保留循环。 path[depth] i; dfs(remain - i, i, depth 1, fp); } } int main() { int N; char choice; printf( 加法分解计算器 \n); printf(请输入正整数 N (1 N %d): , MAX_N); if (scanf(%d, N) ! 1) { printf(输入错误请输入一个整数。\n); // 清空输入缓冲区 while (getchar() ! \n); return 1; } if (N 1 || N MAX_N) { printf(N的取值范围应为 1 ~ %d。\n, MAX_N); return 1; } printf(输出方式\n); printf( 1. 输出到屏幕 (Screen)\n); printf( 2. 输出到文件 (File, result_N.txt)\n); printf(请选择 (1 or 2): ); getchar(); // 吃掉之前输入N时留下的回车 choice getchar(); output_fp stdout; // 默认输出到屏幕 char filename[50]; if (choice 2) { sprintf(filename, result_%d.txt, N); output_fp fopen(filename, w); if (output_fp NULL) { printf(无法创建文件 %s将输出到屏幕。\n, filename); output_fp stdout; } else { printf(结果将输出到文件: %s\n, filename); } } fprintf(output_fp, 数字 %d 的所有非递增加法分解\n, N); fprintf(output_fp, \n); total_count 0; // 记录开始时间可选需要time.h // clock_t start clock(); dfs(N, N, 0, output_fp); // clock_t end clock(); // double duration (double)(end - start) / CLOCKS_PER_SEC; fprintf(output_fp, \n); fprintf(output_fp, 总计: %d 种分解方式。\n, total_count); // fprintf(output_fp, 计算耗时: %.3f 秒\n, duration); if (output_fp ! stdout) { fclose(output_fp); printf(计算完成结果已保存至 %s\n, filename); } // 一个额外的提示对于较大的N解的数量会非常多 if (N 30) { printf(\n提示N%d 的解数量可能非常庞大输出文件可能会很大。\n, N); } return 0; }完善点说明输入验证检查scanf返回值确保成功读入整数检查N的范围。输入缓冲区清理在错误输入后用while(getchar()!\n);清理缓冲区避免影响后续输入。输出选择提供输出到屏幕或文件的选择。当N较大时输出到文件更合适。解的数量统计使用全局变量total_count在每次成功分解时递增。代码结构清晰将dfs函数声明在前main函数在后。函数参数增加了FILE*指针使输出目标更灵活。常量定义使用#define定义MAX_N和MAX_PATH提高可配置性和可读性。用户提示对于较大的N给出文件可能很大的提示。这些改进让程序从一个简单的解题代码变成了一个更友好、更健壮的小工具。在竞赛中你可能不需要这么复杂的交互但在平时练习和调试中这些习惯能帮你节省大量时间。7. 举一反三算法思想的延伸与应用通过“加法分解”这道题我们深入实践了深度优先搜索DFS和递归的思想。这种思想的应用极其广泛绝不仅限于这一道题。我们可以看看它还能解决哪些类似问题组合问题从n个不同元素中选出r个的所有组合C(n, r)。DFS的状态可以是(当前起始位置 已选数量 当前路径)通过控制起始位置递增来保证组合的唯一性避免顺序不同视为相同这和我们用max_val控制非递增的思路异曲同工。子集生成求一个集合的所有子集。可以看作每个元素有“选”或“不选”两种状态DFS遍历这棵二叉树即可。排列问题求n个元素的全排列。DFS的状态需要记录哪些元素已被使用过路径记录排列顺序。图的遍历DFS是图和树结构遍历的基石。在迷宫问题、连通块计数、拓扑排序中都有核心应用。回溯法解决约束满足问题如八皇后、数独、0-1背包等。这类问题的特点是需要尝试所有可能的选择并在不满足约束时“回溯”到上一步。我们这道题的DFS其实也包含了回溯的思想通过循环尝试不同分支。如何识别这类问题当你看到问题要求你“找出所有可能的...”、“列出所有情况...”、“有多少种方案...”时并且数据规模不是特别大因为解的数量可能是指数级就应该立刻想到DFS/回溯法。设计DFS函数的关键状态定义像我们这里的(remain, max_val, depth)要能唯一确定当前搜索位置。选择列表在当前状态下你可以做出哪些选择如i从upper_bound到1。路径记录如何保存已经做出的选择如path数组。约束条件哪些选择是合法的如i max_val且i remain。目标状态什么时候算找到一个解如remain 0。掌握这个模板你就能解决一大类搜索和枚举问题。ALGO-633作为一个起点完美地诠释了这个模板的各个部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价