DFS / 递归调试助手
DFS / 递归调试助手
:::success[修订日志] [2026-07-26] 第 3 版。\ [2026-07-16] 第 2 版。\ [2026-01-31~2026-04-01] 第 1 版(疑似为 shi)。 :::
本文小部分借鉴 C++ 调试模板这篇文章的一些想法,若有侵权、纰漏或不妥的地方,请各位指出,我会尽量及时更改。
本文由洛谷题解格式化工具格式化。
0. 前言
0.1 关于工具的定位
在我们编写 DFS / 递归程序时,常见的调试方式有两种:
-
在代码中打印中间变量 —— 但是难以直观反映调用层级。
-
使用 VSCode / GDB 等调试器单步跟踪 —— 但是只能看到当前时刻的调用栈,无法纵览整个递归过程的完整流程,且在有些情况下无法使用(比如在在线编辑器中写代码,而这些编辑器大多只提供运行的功能)。
本工具的设计目标是填补上述两种方法的空白:将整个递归树从根到叶一次性完整打印,以缩进展示调用层级,帮助开发者从全局审视递归结构是否符合预期,快速定位逻辑错误与冗余搜索。
0.2 关于代码
本文的代码涉及一些可能不太常用的 C++11 的语言知识,如果不懂可以先去学习或直接划到下面查看使用方法并获取代码。
:::info[理解本文的代码所需的前置知识]{open}
- 宏
- 类
- 模板
-
模板参数包(通俗一点:数量可变的模板参数)
1. 代码展示
C++11 及以上才可用!
:::success[代码]{open}
#include <iostream>
#include <utility>
#ifdef ENABLE_DEBUG
void print_split() {}
template <typename T1, typename... RestTs>
void print_split(T1 &&arg1, RestTs &&...rest_args) {
std::cerr << std::forward<T1>(arg1) << ' ';
print_split(std::forward<RestTs>(rest_args)...);
}
class DFSLogger {
private:
static long long stack_num;
static long long call_count;
public:
template <typename... Args>
DFSLogger(Args &&...args) {
++call_count;
print_split(call_count, std::string(4 * stack_num, ' '),
std::forward<Args>(args)...);
std::cerr << std::endl;
++stack_num;
}
~DFSLogger() { --stack_num; }
static long long get_stack_num() { return stack_num; }
static long long get_call_count() { return call_count; }
};
long long DFSLogger::stack_num = 0;
long long DFSLogger::call_count = 0;
#define dfslog_novarname(...) DFSLogger abcdefg__(__VA_ARGS__)
#define dfslog(...) dfslog_novarname(#__VA_ARGS__, __VA_ARGS__)
#else
#define dfslog_novarname(...) void(0)
#define dfslog(...) void(0)
#endif
:::
2. 使用方法
首先,调试助手提供了一个在编译时控制是否输出调试信息:ENABLE_DEBUG 宏,定义它就会开启输出调试信息,不定义它就不会开启。
:::info[关于如何定义此宏]{open}
可以通过在编译时向 GCC / Clang 编译器传递 -DENABLE_DEBUG 或调试助手的代码前添加独立的一行 #define ENABLE_DEBUG 来定义此宏。
:::
定义完上述的 ENABLE_DEBUG 宏后,在你的深搜 / 递归函数的最开始添加一行代码 dfslog(<你要输出的变量>);,就可以输出每次函数调用时你要输出的变量的名称和值,一并显示函数的调用流程和次数,以方便调试。
函数的调用流程通过缩进来显示,一行中输出的缩进个数 +1 等于当时此函数的调用栈的层数(即此函数当前有多少次调用还没退出)。
注意:不要把这行代码写到 if-else 条件分支或循环中去,也尽量避免在同一程序的多个递归中使用。
:::success[举一个简单的例子]
下面代码定义了一个计算斐波那契数列第 n 项的函数 dfs_fib,在其中添加 DFS 调试助手,主函数调用它计算数列的第 7 项。
Code 2
// ... (前面的代码)
int dfs_fib(int x) {
// 添加这一行。
dfslog(x);
// 下面是正常计算斐波那契数列的代码。
if (x < 2) {
return 1;
}
return dfs_fib(x - 1) + dfs_fib(x - 2);
}
int main() {
std::cout << dfs_fib(7) << std::endl;
return 0;
}
运行这段代码会输出以下内容:
1 x 7
2 x 6
3 x 5
4 x 4
5 x 3
6 x 2
7 x 1
8 x 0
9 x 1
10 x 2
11 x 1
12 x 0
13 x 3
14 x 2
15 x 1
16 x 0
17 x 1
18 x 4
19 x 3
20 x 2
21 x 1
22 x 0
23 x 1
24 x 2
25 x 1
26 x 0
27 x 5
28 x 4
29 x 3
30 x 2
31 x 1
32 x 0
33 x 1
34 x 2
35 x 1
36 x 0
37 x 3
38 x 2
39 x 1
40 x 0
41 x 1
21
| 其中最后一行是正常输出结果 21,即斐波那契数列第 7 项,前面就是调试助手输出的调试信息了。 |
|---|
3. 解析
3.1 print_split 函数
:::info[放一个这个函数的副本]
void print_split() {}
template <typename T1, typename... RestTs>
void print_split(T1 &&arg1, RestTs &&...rest_args) {
std::cerr << std::forward<T1>(arg1) << ' ';
print_split(std::forward<RestTs>(rest_args)...);
}
:::
函数作用:将任意数量的参数以空格为分割输出。
由于 std::cerr 的 << 语法限制,每次只能输出一定数量的变量(至少 C++17 前是这样的),包展开无法直接输出所有参数,所以我们想到每次只输出第一个参数(代码中的 arg1),用其余的参数(代码中的 rest_args)再次调用本函数递归处理。
副本中第 1 行无参数的 print_split 函数是递归的终止条件:没有参数,不做任何事。
:::info[关于 std::forward]
不理解的话看的时候忽略它就行。
| 作用:用于原样保留原来参数的左右值属性,可以稍微提升一点性能(因为防止了移动语义失效)。 |
|---|
3.2 DFSLogger 类
:::info[放一个这个类的副本]
class DFSLogger {
private:
static long long stack_num;
static long long call_count;
public:
template <typename... Args>
DFSLogger(Args &&...args) {
++call_count;
print_split(call_count, std::string(4 * stack_num, ' '),
std::forward<Args>(args)...);
std::cerr << std::endl;
++stack_num;
}
~DFSLogger() { --stack_num; }
static long long get_stack_num() { return stack_num; }
static long long get_call_count() { return call_count; }
};
long long DFSLogger::stack_num = 0;
long long DFSLogger::call_count = 0;
:::
这个类是实现输出调试信息的核心部件。
做到我们想要的在输出变量的同时显示调用流程这个效果,我们第一个想到的做法可能是:
- 在进入函数时打印指定数量的缩进和要输出的变量,并增加输出的缩进,表示入栈。
- 在离开函数时减少输出的缩进,表示出栈。
但是,这种做法要在每个 return 前加上减少输出的缩进的语句,麻烦,又容易忘记添加。(别问我怎么知道的)
于是回顾做法,我们又想到了:定义在函数体开头的对象的生命周期贯穿整个函数,其构造函数会在进入函数时被调用,其析构函数会在离开函数时被调用。
所以我们创建 DFSLogger 这个类,用于进行上述操作。
DFSLogger 类的数据成员
DFSLogger 类有两个私有静态成员:
stack_num表示每次函数调用栈的大小。call_count表示调用类构造函数的次数(同时也代表要调试的递归函数的调用次数)。
DFSLogger 类的构造函数
DFSLogger 类的构造函数是一个模板,用于支持任意个数的参数的输出,它会输出函数的调用过程和调用次数。
它执行以下步骤:
- 将
call_count增加 1,表示又调用了一次构造函数。 - 将
call_count开头的空格(即4 * stack_num个空格)和参数转发到print_split函数进行输出。 - 将
stack_num增加 1,表示要调试的递归函数又被调用了一次。
DFSLogger 类的析构函数
DFSLogger 类的析构函数将 stack_num 减少 1,调试的递归函数退出了一次。
现在可以解释上面说“不要把这行代码写到 if-else 条件分支或循环中去”的原因了:
- 因为记录函数退出是析构函数的作用,这么写会导致在分支 / 循环结束时就调用析构函数,导致无法正确记录函数的调用流程,让输出变得奇怪,所以请不要做上面的操作。
DFSLogger 类的其他成员函数
写了约等于没写,正常没人用到,可跳过。
DFSLogger 类有两个静态成员函数:
get_stack_num,用于获取每次函数调用栈的大小。get_call_count,用于获取调试的递归函数的调用次数。
3.3 dfslog 宏
将 DFSLogger 类包装的更好看罢了。
:::info[放一个这个宏的副本]
#define dfslog_novarname(...) DFSLogger abcdefg__(__VA_ARGS__)
#define dfslog(...) dfslog_novarname(#__VA_ARGS__, __VA_ARGS__)
:::
dfslog_novarname 宏:定义一个 DFSLogger 对象,并原样转发参数到其构造函数。
注意:由于这个宏定义的对象占了 abcdefg__ 这个变量名,所以这个函数内不能再用这个名字定义变量了。(其实应该没人会给变量定义成这个名吧)
dfslog 宏:对 dfslog_novarname 的再包装,添加第一个参数为把调用参数转成字符串字面量,方便大家知道自己输出了什么变量。
4. 结语
这个调试助手十分的简陋,只有一些基本功能,但是对于简单的调试我觉得够用了,欢迎各位来提供更好的意见。
感谢您的阅读!