1. 有向图简单路径到底在解决什么问题
有向图的简单路径,说白了就是在一张有向图里,找一条从起点到终点的路径,且路径上不能有重复的顶点。这个定义听起来简单,但真正动手写代码的时候,你会发现里面藏着不少门道。我在带新人做图论练习的时候,发现很多人第一反应就是直接上DFS,结果要么路径重复了,要么死循环出不来,要么就是漏掉了某些合法路径。
这个问题的核心应用场景其实非常广泛。比如在任务调度系统中,你需要判断某个任务的前置依赖链是否形成了环;在编译原理的依赖分析里,你需要找出模块之间的调用路径;甚至在网络路由的模拟中,简单路径的概念也被用来避免数据包在环路中反复转发。所以别看它只是一个图论的基础问题,真正把它写扎实了,对后续理解更复杂的图算法帮助极大。
这篇文章主要面向有一定C++基础、正在学习图论或者准备算法练习的读者。我会从数据结构的选择开始,一步步拆解DFS和BFS两种思路在简单路径问题上的差异,然后给出完整的可编译运行的代码实现,最后把我自己踩过的坑和调试经验都摊开来聊。你不需要有很深的算法功底,只要能看懂基本的C++语法和递归,就能跟着走下来。
有一点需要提前说明:有向图的简单路径问题,根据具体需求的不同,其实分为好几个变种。有的是找一条路径就行,有的是要找所有路径,有的是找最短的简单路径,还有的是判断两点之间是否存在简单路径。这几个变种的实现策略差别挺大,我会在正文里分别展开。你如果只是想做一道练习题,可以直接看第二节的DFS实现;如果你是在实际项目里遇到了路径查找的需求,建议把第三节的优化思路也看完。
2. 数据结构选型与图的存储方式
2.1 邻接矩阵 vs 邻接表:到底选哪个
在动手写算法之前,第一个要做的决定就是怎么存这张图。我见过不少初学者上来就用二维数组,结果图稍微大一点就内存爆炸。也见过有人无脑用邻接表,结果在需要快速判断两点之间是否有边的时候效率很低。这两种方式各有各的适用场景,选错了不会导致程序跑不起来,但会让你的代码在特定场景下性能差很多。
邻接矩阵的本质是一个二维数组adj[i][j],如果从顶点 i 到顶点 j 有边,就设为1或者边的权重,否则设为0或者无穷大。它的优势在于查询两点之间是否有边的操作是 O(1) 的,非常快。但缺点也很明显:如果图有 n 个顶点,就需要 n×n 的空间。对于一个有10000个顶点但只有50000条边的稀疏图来说,邻接矩阵要开一亿个元素的数组,而邻接表只需要存50000条边的信息,差距是数量级的。
邻接表的思路是给每个顶点维护一个链表或者动态数组,里面存所有从该顶点出发能到达的顶点。在C++里,我通常用vector<vector<int>>来实现,外层vector的下标代表顶点编号,内层vector存该顶点的所有邻居。这种方式的空间复杂度是 O(V+E),对于稀疏图非常友好。但查询两点之间是否有边就需要遍历某个顶点的邻居列表,最坏情况下是 O(V)。
那到底怎么选?我的经验是这样的:如果你的图顶点数不超过1000,而且经常需要判断任意两点之间是否有边,那就用邻接矩阵,写起来简单直接。如果顶点数超过1000,或者边的数量远小于顶点数的平方,那就果断用邻接表。对于简单路径这个问题来说,因为DFS过程中主要操作是“遍历当前顶点的所有邻居”,所以邻接表其实是更自然的选择。
2.2 有向图存储的特殊注意点
有向图和无向图在存储上最大的区别就是:无向图的一条边要存两次,有向图只存一次。这个看起来是废话,但实际写代码的时候,很多人会习惯性地按照无向图的方式去加边,结果导致算法把有向边当成了双向边来处理,找出来的路径在逻辑上是错的。
举个例子,假设有边 1→2,在邻接表里你只需要在adj[1]里加入2,不需要在adj[2]里加入1。如果你两边都加了,那算法就会认为从2也能直接到1,这显然不符合有向图的定义。我在review代码的时候,这个问题出现的频率高得离谱,尤其是从无向图模板改过来的时候特别容易犯。
另外还有一个细节:有向图中可能存在自环,也就是从顶点 i 到顶点 i 自己的边。在简单路径的定义下,自环是不能出现在路径中的,因为那会导致顶点重复。所以在DFS的过程中,判断邻居是否已经被访问过这一步,天然就把自环过滤掉了。但如果你在加边的时候没有做去重处理,邻接表里可能会出现重复的边,这会导致算法找出重复的路径。建议在加边的时候做一次去重,或者在DFS的时候用一个集合来记录已经走过的顶点。
2.3 顶点编号与数据映射
实际项目中,图的顶点往往不是从0开始的连续整数,可能是字符串名字、IP地址、任务ID等等。这时候就需要做一个映射层,把实际的顶点标识转换成从0开始的连续整数,方便用数组或vector来存储。我通常用unordered_map<string, int>来做这个映射,同时用一个vector<string>来存反向映射,方便最后输出路径的时候把编号还原成可读的名字。
这个映射层的代码虽然简单,但很容易出bug。最常见的问题是忘记检查某个顶点是否已经在映射表中,导致重复分配编号。我的做法是封装一个函数,每次需要获取顶点编号的时候都先查map,如果不存在就分配一个新的编号并同时更新正反两个映射。这样虽然多写了几行代码,但能避免很多低级错误。
3. DFS实现简单路径查找的完整思路
3.1 递归DFS的核心框架
DFS找简单路径的思路其实很直观:从起点出发,沿着一条边走到底,如果走到了终点就记录路径;如果走不通就回退到上一个顶点,换一条边继续尝试。这个“回退”的过程就是回溯,也是DFS能找出所有路径的关键。
用递归来实现的话,核心框架大概是这样:维护一个visited数组记录当前路径上已经访问过的顶点,维护一个path数组记录当前路径的顶点序列。每次进入递归函数的时候,把当前顶点标记为已访问并加入path;如果当前顶点就是终点,就把path保存到结果集里;否则遍历当前顶点的所有邻居,对每个未访问的邻居递归调用。递归返回之后,要把当前顶点从path中移除,并把visited标记清除,这就是回溯。
这个框架看起来简单,但有一个很容易忽略的点:visited数组的清除时机。如果你在递归调用之前标记visited,在递归返回之后清除visited,那是对的。但如果你在递归调用之前标记,却忘记在返回后清除,那算法就只能找到一条路径,因为其他路径上的顶点都被标记成已访问了。这个bug非常隐蔽,因为程序不会崩溃,只是结果不对,而且对于小图你可能一眼看不出问题。
3.2 递归终止条件与路径记录
递归的终止条件有两个:一是当前顶点等于终点,这时候记录路径并返回;二是当前顶点的所有邻居都已经访问过了,这时候自然返回。但这里有个细节需要注意:如果起点和终点是同一个顶点,那路径就是只包含这一个顶点的路径,长度为0。这种情况在实际问题中是否算作有效路径,取决于具体需求。我在做题的时候遇到过要求“路径长度至少为1”的情况,也就是起点和终点不能相同,这时候就需要在终止条件里加一个判断。
路径的记录方式也有讲究。如果你用vector<int>来存当前路径,每次找到一条完整路径就把它push到结果集里,那结果集里存的就是路径的副本,后续回溯不会影响已经保存的结果。但如果你图省事,直接存了path的引用或者指针,那回溯之后结果集里的路径就全乱了。这个坑我踩过不止一次,尤其是在赶时间写代码的时候特别容易犯。
另外,如果图比较大,路径数量可能非常多,全部存下来会占用大量内存。这时候可以考虑不存所有路径,而是只统计路径数量,或者在找到第一条路径后就立即返回。这些变种我会在后面的章节里展开。
3.3 处理环与重复访问的判断逻辑
有向图中存在环是很常见的情况,而简单路径的定义要求路径上不能有重复顶点,所以环天然就被visited数组挡住了。但这里有一个性能上的考量:每次判断邻居是否已访问,都需要查一次visited数组,这个操作是O(1)的,所以整体上不会成为瓶颈。
不过,有一种特殊情况需要注意:如果图中存在大量的环,DFS可能会做很多无效的探索。比如从起点出发,走进了一个很大的环,绕了一圈之后发现走不到终点,然后回溯,换一条路又走进同一个环。这种情况下,算法的时间复杂度会比较高。对于一般规模的图,这个问题不明显,但如果顶点数上万,就需要考虑加一些剪枝策略,比如提前判断终点是否从当前顶点可达。
还有一个细节是visited数组的类型选择。用bool数组是最常见的,但如果你的顶点编号不是连续的,或者图是动态变化的,用unordered_set<int>来记录已访问顶点会更灵活。不过set的查询有常数级开销,比数组慢一些,所以如果顶点编号连续且范围不大,还是用vector 或者bool数组更高效。
4. BFS在简单路径问题中的适用场景
4.1 BFS找最短简单路径
BFS和DFS最大的区别在于搜索顺序:BFS是一层一层地往外扩展,所以它天然就能找到从起点到终点的最短路径(这里的最短是指边数最少)。如果你只需要找一条最短的简单路径,BFS是比DFS更合适的选择,因为DFS可能会先找到一条很长的路径,而BFS保证第一次到达终点时经过的边数最少。
用BFS找最短简单路径的代码框架是:维护一个队列,初始时把起点入队;维护一个parent数组记录每个顶点是从哪个顶点扩展过来的;每次从队列取出一个顶点,遍历它的所有邻居,如果邻居没有被访问过,就标记为已访问,记录parent,然后入队。当终点第一次被访问到的时候,就可以通过parent数组从终点回溯到起点,得到一条最短路径。
这里有一个关键点:BFS中找到的路径天然就是简单路径,因为每个顶点只会被访问一次,所以路径上不可能有重复顶点。这也是BFS相对于DFS的一个优势——不需要额外的visited回溯操作,代码更简洁。
4.2 BFS无法直接枚举所有路径的原因
虽然BFS找最短路径很方便,但如果你想枚举所有的简单路径,BFS就不太合适了。原因在于BFS的visited数组是全局的,一个顶点一旦被访问过,就不会再被加入队列。这意味着BFS只能找到一条到达每个顶点的路径,无法找到所有可能的路径。
如果你非要用BFS来枚举所有路径,那就需要把visited数组改成“当前路径上的visited”,也就是每个队列元素都要携带自己的visited状态。这样一来,队列里存储的数据量会急剧膨胀,空间复杂度会变得很高。所以对于枚举所有简单路径这个问题,DFS是更自然的选择。
我在实际项目中遇到过需要枚举所有路径的场景,一开始想用BFS改,后来发现代码复杂度比DFS高很多,而且性能也不好。最后还是老老实实用了DFS加回溯,代码清晰,调试也方便。
4.3 双向BFS的优化思路
如果图比较大,而且你只需要找一条最短路径,双向BFS是一个值得考虑的优化方案。它的思路是从起点和终点同时开始BFS,当两个搜索 frontier 相遇的时候,就找到了一条路径。理论上,双向BFS的时间复杂度是单向BFS的平方根级别,对于大图来说提升非常明显。
不过双向BFS的实现比单向BFS复杂不少,需要维护两个visited集合、两个parent数组,还要处理两个搜索方向相遇时的路径拼接。而且它只适用于找最短路径的场景,对于枚举所有路径或者带权图的最短路径就不适用了。我的建议是:如果图的规模不大(顶点数几千以内),单向BFS足够了;只有当图特别大且只需要一条最短路径的时候,才值得上双向BFS。
5. 完整代码实现与逐段解析
5.1 图的类封装与基础操作
下面这段代码是我平时写图论题目时常用的一个模板,把图的存储和基本操作封装成了一个类。你可以直接拿去用,也可以根据自己的需求修改。
#include <iostream> #include <vector> #include <unordered_map> #include <unordered_set> #include <queue> #include <algorithm> class DirectedGraph { public: DirectedGraph() {} // 添加顶点,返回顶点编号 int addVertex(const std::string& name) { auto it = nameToId_.find(name); if (it != nameToId_.end()) { return it->second; } int id = static_cast<int>(idToName_.size()); nameToId_[name] = id; idToName_.push_back(name); adj_.emplace_back(); return id; } // 添加有向边 from -> to void addEdge(const std::string& from, const std::string& to) { int u = addVertex(from); int v = addVertex(to); // 去重,避免重复边导致重复路径 auto& neighbors = adj_[u]; if (std::find(neighbors.begin(), neighbors.end(), v) == neighbors.end()) { neighbors.push_back(v); } } int vertexCount() const { return static_cast<int>(adj_.size()); } const std::vector<int>& neighbors(int u) const { return adj_[u]; } std::string vertexName(int id) const { return idToName_[id]; } int vertexId(const std::string& name) const { auto it = nameToId_.find(name); return (it != nameToId_.end()) ? it->second : -1; } private: std::vector<std::vector<int>> adj_; std::unordered_map<std::string, int> nameToId_; std::vector<std::string> idToName_; };这个类做了几件事:用unordered_map做名字到编号的映射,用vector<vector<int>>存邻接表,加边的时候自动去重。去重这一步看起来不起眼,但如果你的输入数据里有重复边,不去重的话算法会输出重复的路径,结果就不对了。
5.2 DFS枚举所有简单路径的代码
接下来是DFS枚举所有简单路径的核心代码。我把它写成了一个独立的函数,接收图、起点、终点,返回所有简单路径。
void dfsAllPaths(const DirectedGraph& graph, int current, int end, std::vector<bool>& visited, std::vector<int>& path, std::vector<std::vector<int>>& result) { visited[current] = true; path.push_back(current); if (current == end) { result.push_back(path); } else { for (int next : graph.neighbors(current)) { if (!visited[next]) { dfsAllPaths(graph, next, end, visited, path, result); } } } // 回溯:恢复状态 path.pop_back(); visited[current] = false; } std::vector<std::vector<int>> findAllSimplePaths( const DirectedGraph& graph, const std::string& startName, const std::string& endName) { int start = graph.vertexId(startName); int end = graph.vertexId(endName); std::vector<std::vector<int>> result; if (start == -1 || end == -1) { return result; } int n = graph.vertexCount(); std::vector<bool> visited(n, false); std::vector<int> path; dfsAllPaths(graph, start, end, visited, path, result); return result; }这段代码的关键在于回溯部分:path.pop_back()和visited[current] = false这两行必须成对出现,而且顺序无所谓,但一定不能漏。我见过有人只pop了path但忘了清除visited,结果算法只能找到一条路径。也见过有人清除了visited但忘了pop path,结果路径越积越长,输出完全错误。
5.3 BFS找最短简单路径的代码
如果你只需要一条最短路径,下面这段BFS代码更合适:
std::vector<int> bfsShortestPath(const DirectedGraph& graph, const std::string& startName, const std::string& endName) { int start = graph.vertexId(startName); int end = graph.vertexId(endName); std::vector<int> result; if (start == -1 || end == -1) { return result; } int n = graph.vertexCount(); std::vector<bool> visited(n, false); std::vector<int> parent(n, -1); std::queue<int> q; visited[start] = true; q.push(start); while (!q.empty()) { int current = q.front(); q.pop(); if (current == end) { // 回溯构造路径 for (int v = end; v != -1; v = parent[v]) { result.push_back(v); } std::reverse(result.begin(), result.end()); return result; } for (int next : graph.neighbors(current)) { if (!visited[next]) { visited[next] = true; parent[next] = current; q.push(next); } } } return result; // 没有找到路径 }BFS的代码比DFS稍微长一点,但逻辑更线性,不容易出错。注意parent数组的初始值是-1,回溯的时候以-1作为终止条件。另外,找到终点后要记得reverse,因为回溯出来的路径是从终点到起点的。
5.4 测试用例与运行结果验证
为了验证代码的正确性,我构造了一个简单的有向图来测试。图的结构是这样的:A→B,A→C,B→D,C→D,D→E。从A到E的简单路径有两条:A→B→D→E 和 A→C→D→E。
int main() { DirectedGraph graph; graph.addEdge("A", "B"); graph.addEdge("A", "C"); graph.addEdge("B", "D"); graph.addEdge("C", "D"); graph.addEdge("D", "E"); auto allPaths = findAllSimplePaths(graph, "A", "E"); std::cout << "Total paths: " << allPaths.size() << std::endl; for (const auto& p : allPaths) { for (size_t i = 0; i < p.size(); ++i) { if (i > 0) std::cout << " -> "; std::cout << graph.vertexName(p[i]); } std::cout << std::endl; } auto shortest = bfsShortestPath(graph, "A", "E"); std::cout << "Shortest path: "; for (size_t i = 0; i < shortest.size(); ++i) { if (i > 0) std::cout << " -> "; std::cout << graph.vertexName(shortest[i]); } std::cout << std::endl; return 0; }运行结果应该是输出两条路径,最短路径也是这两条中的一条(因为两条路径长度相同,BFS会返回先找到的那条)。这个测试用例虽然简单,但覆盖了分叉、汇合、终点等关键情况,足以验证代码的基本正确性。
6. 性能优化与常见问题排查
6.1 路径数量爆炸时的剪枝策略
枚举所有简单路径有一个天然的问题:路径数量可能随着图的规模指数级增长。在一个稠密图中,简单路径的数量可能是天文数字,全部枚举出来既耗时又耗内存。如果你在实际项目中遇到了这个问题,有几个剪枝策略可以考虑。
第一个策略是提前判断可达性。在DFS之前,先用一次BFS或DFS从终点反向搜索,标记出所有能到达终点的顶点。然后在正向DFS的时候,只探索那些被标记过的顶点。这样可以避免在不可能到达终点的分支上浪费时间。
第二个策略是限制路径长度。如果业务需求只关心长度不超过K的路径,那在DFS的时候可以传入一个剩余步数参数,当步数用完时直接返回。这个剪枝在路径长度有限制的场景下非常有效。
第三个策略是限制结果数量。如果只需要找前N条路径,那可以在找到N条之后直接终止搜索。实现方式可以用一个计数器,每次找到路径就加一,达到上限就抛异常或者设置一个全局标志位来终止递归。
6.2 递归深度过大导致的栈溢出
DFS的递归深度等于路径的最大长度,在极端情况下可能达到图的顶点数。如果图的顶点数上万,递归深度就可能超过系统栈的默认大小,导致栈溢出。这个问题在Windows上尤其明显,因为默认栈大小通常只有1MB左右。
解决方式有两种:一是把递归改成迭代,用显式的栈来模拟递归过程;二是增大栈的大小,在编译选项里设置。第一种方式更通用,但代码会复杂一些。第二种方式简单,但依赖于编译器和操作系统,可移植性差。
我个人的建议是:如果顶点数不超过5000,递归通常没问题;如果超过5000,最好改成迭代实现。迭代版本的DFS需要手动维护一个栈,栈里存的是“当前顶点”和“下一个要探索的邻居索引”,实现起来比递归麻烦,但不受栈大小限制。
6.3 常见问题速查表
下面这张表整理了我在实现有向图简单路径算法时遇到过的典型问题,以及对应的排查思路和解决方法。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 只能找到一条路径 | visited数组回溯时未清除 | 检查递归返回后是否执行了visited[current]=false | 确保path.pop_back()和visited清除成对出现 |
| 路径中出现重复顶点 | visited判断逻辑有误 | 打印每次递归的path和visited状态 | 在加入path之前检查visited,确保不重复 |
| 输出重复路径 | 邻接表中有重复边 | 检查加边时是否去重 | 加边时用find或set去重 |
| 程序死循环 | 有向图中存在环且visited未生效 | 检查visited是否在递归前设置 | 确保进入递归前标记visited |
| 结果路径顺序颠倒 | BFS回溯时未reverse | 检查结果构造逻辑 | 回溯后调用std::reverse |
| 大图运行超时 | 路径数量爆炸 | 统计路径数量或加计数器 | 加剪枝策略或限制结果数量 |
| 栈溢出崩溃 | 递归深度过大 | 查看崩溃时的调用栈 | 改迭代实现或增大栈空间 |
这张表里的每一个问题我都实际遇到过,尤其是第一个和第二个,在初学阶段几乎必然会踩。建议你在调试的时候,先在递归函数里加几行打印语句,把每次进入和退出时的path和visited状态打出来,这样一眼就能看出问题出在哪。
6.4 调试技巧与经验分享
调试图论算法有一个很实用的技巧:把图可视化。虽然这篇文章里不能用图表,但你可以在本地用Graphviz或者简单的文本输出来把图的结构打印出来,然后手动模拟算法的执行过程。对于小图来说,手动模拟一遍比盯着代码看半天有效得多。
另一个技巧是写单元测试。图论算法的边界情况特别多,比如空图、只有一个顶点的图、起点等于终点、起点或终点不存在、图中有自环、图中有平行边等等。我通常会针对这些情况各写一个测试用例,每次修改代码后跑一遍,确保没有引入回归问题。
还有一个经验是:先用小图验证正确性,再用大图测试性能。小图用来检查逻辑对不对,大图用来检查有没有性能问题。如果小图都跑不对,就别急着上大图了,先把逻辑理清楚。
7. 实际应用中的扩展与变体
7.1 带权图中的最短简单路径
如果图中的边带有权重,那“最短路径”的定义就从“边数最少”变成了“权重之和最小”。这时候BFS就不适用了,需要用Dijkstra算法或者Bellman-Ford算法。但需要注意的是,Dijkstra算法找到的路径不一定是简单路径,因为如果图中存在零权边或者负权边,算法可能会绕圈。不过在大多数实际场景中,边权都是正数,Dijkstra找到的路径天然就是简单路径。
如果你需要找带权图中的最短简单路径,而且图中存在负权边,那就只能用Bellman-Ford或者SPFA,而且还需要额外处理负权环的情况。这个问题就比无权图复杂很多了,超出了这篇文章的范围,但思路是一样的:在松弛边的时候记录parent,最后回溯出路径。
7.2 判断两点间是否存在简单路径
有时候你不需要找出具体路径,只需要判断两点之间是否存在简单路径。这个问题比找路径简单,因为一旦找到一条就可以立即返回。实现上可以用DFS,找到一个就返回true;也可以用BFS,看终点是否被访问到。甚至可以用并查集来判断无向图的情况,但有向图不行,因为并查集无法处理方向性。
在实际项目中,判断可达性是一个非常常见的需求。比如在任务调度系统中,你需要判断任务A是否依赖于任务B,本质上就是判断从B到A是否存在有向路径。这种情况下,用BFS或DFS都可以,选择哪个取决于你对性能的要求和图的结构特点。
7.3 所有路径中的最长路径问题
最长简单路径问题在有向图中是一个NP难问题,也就是说没有多项式时间的精确算法。对于小图,可以用DFS枚举所有路径然后取最长的;对于大图,只能用近似算法或者启发式搜索。我在实际项目中遇到最长路径的需求时,通常会先确认图的规模,如果顶点数超过20,就会考虑用动态规划或者整数规划来求解,而不是暴力枚举。
有一个特殊情况是DAG(有向无环图)中的最长路径,这个问题可以用拓扑排序加动态规划在多项式时间内解决。如果你的图恰好是DAG,那就不要用暴力枚举了,拓扑排序加DP是更优雅的方案。
7.4 路径去重与结果排序
当你枚举出所有简单路径后,有时候需要对结果进行去重或排序。去重通常是因为输入数据中有重复边或者重复顶点,导致算法输出了重复路径。排序则是因为业务需求要求按路径长度或者字典序输出。
去重的思路很简单:把每条路径转换成一个字符串或者哈希值,然后用set去重。排序的话,可以用自定义比较函数,按路径长度排序或者按顶点编号的字典序排序。这些操作虽然简单,但在实际项目中很常见,值得花几分钟写个辅助函数。
8. 我个人的实操体会与建议
写了这么多年代码,有向图简单路径这个问题我至少实现过几十遍,每次都有新的体会。最大的感受是:不要一上来就追求最优解,先把正确性保证了,再考虑优化。我见过太多人为了追求性能,写了一堆复杂的剪枝逻辑,结果连基本的正确性都没保证,调试起来痛苦不堪。
另外一个体会是:图论算法的调试,可视化是关键。你脑子里想的图结构和代码里实际构建的图结构,往往有出入。把图打印出来,把每次递归的状态打印出来,比盯着代码空想有效得多。
最后分享一个小技巧:如果你在面试或者考试中遇到这个问题,先把DFS的框架写出来,确保能跑通,然后再根据题目要求加优化。不要一上来就想写最优解,那样很容易在细节上翻车。先把基础分拿到手,再争取加分项,这个策略在大多数场景下都是适用的。
还有一点关于代码风格的建议:图论算法的代码通常比较长,变量名一定要起得有意义。u、v、w这种命名在数学推导里没问题,但在代码里可读性很差。我通常用current、next、start、end这种有明确含义的名字,虽然多打几个字符,但调试的时候能省很多时间。