C++图遍历算法深度解析:DFS与BFS实现、对比与应用场景
1. 项目概述为什么图的遍历是算法世界的“导航系统”在C的世界里数据结构是构建复杂程序的骨架而图Graph无疑是其中最复杂、也最贴近现实世界关系的一种。想象一下你面前有一张巨大的地铁线路图站点是节点轨道是边。你想从“家”这个站去到“公司”站有多少种换乘方案哪条路线最快或者你想探索整个地铁网络的所有站点是先一条线坐到底再换线还是每到一站就先把能换乘的线路都坐一遍这两种截然不同的探索策略就是我们今天要深入骨髓去理解的深度优先搜索DFS和广度优先搜索BFS。很多朋友初学数据结构学到链表、栈、队列都觉得还能跟上一碰到图就开始发怵更别提DFS和BFS了。网上教程要么一上来就甩出一段递归代码让人云里雾里要么只讲理论不接地气导致大家记住了名字却不知道到底该怎么用、什么时候用。我自己在早期做项目时就曾因为用错了遍历方式导致在一个社交网络关系分析的程序里找两个人的最短联系路径时程序像钻进了死胡同效率低得令人发指。后来才明白不是算法不行是我没理解它们各自的“性格”。这篇内容我就以一个老码农的身份带你彻底拆解DFS和BFS。我们不玩虚的直接从图的两种最常用存储方式邻接矩阵和邻接表开始用C手把手实现它们。然后我们会像解剖麻雀一样分析DFS那种“不撞南墙不回头”的递归与栈实现以及BFS那种“层层递进、雨露均沾”的队列实现。更重要的是我会分享在哪些实际场景下比如迷宫求解、社交网络、编译器依赖分析应该选择DFS哪些场景下比如最短路径、广播消息、网络爬虫必须用BFS以及那些教科书里不会写的性能陷阱和调试技巧。无论你是正在啃《数据结构》课本的学生还是准备面试急需梳理算法的求职者抑或是工作中突然需要处理图关系数据的工程师这篇文章都能给你一套可直接运行、能真正理解背后思想的代码和思路。我们不止步于“是什么”更要深挖“为什么”和“怎么用得好”。2. 图的基石两种存储方式的选择与C实现在讨论怎么“走”图之前我们必须先解决图怎么“存”的问题。存储结构选对了后续的遍历算法才能写得清晰、跑得高效。主流有两种方式邻接矩阵和邻接表。它们的关系有点像数组和链表一个胜在查询快一个赢在空间省。2.1 邻接矩阵直观的“城市间航班表”邻接矩阵用一个二维数组在C中常用vectorvectorint来表示图。假设图有V个顶点我们就创建一个V x V的矩阵graph。如果顶点i和顶点j之间存在一条边那么graph[i][j]的值就设为1对于无权图或边的权重对于有权图如果不存在边则设为0或一个特定的无效值如INT_MAX。它的优点极其明显查询速度极快判断任意两个顶点u和v之间是否有边直接访问graph[u][v]时间复杂度是O(1)。实现简单结构非常规整对于稠密图边数接近顶点数平方来说空间利用率高。方便计算某些基于矩阵运算的图算法如通过计算矩阵幂来求路径数天然适合。但缺点也同样突出空间消耗大空间复杂度是O(V²)。对于一个有10000个顶点的稀疏社交网络图如果用它存储需要1亿个存储单元其中绝大部分是0这是巨大的浪费。添加/删除顶点麻烦动态增加顶点需要重新分配和拷贝整个矩阵成本高。C实现示例无向图#include iostream #include vector using namespace std; class GraphMatrix { private: int V; // 顶点数 vectorvectorint adjMatrix; // 邻接矩阵 bool isDirected; // 是否为有向图 public: // 构造函数初始化V x V的矩阵所有元素为0 GraphMatrix(int numVertices, bool directed false) : V(numVertices), isDirected(directed) { adjMatrix.resize(V, vectorint(V, 0)); } // 添加边无权图 void addEdge(int u, int v) { if (u V || v V) { cerr 顶点索引超出范围! endl; return; } adjMatrix[u][v] 1; if (!isDirected) { // 如果是无向图对称位置也设为1 adjMatrix[v][u] 1; } } // 打印邻接矩阵 void printMatrix() { for (int i 0; i V; i) { for (int j 0; j V; j) { cout adjMatrix[i][j] ; } cout endl; } } // 获取邻接矩阵供遍历算法使用 const vectorvectorint getMatrix() const { return adjMatrix; } int getNumVertices() const { return V; } };注意在工程中对于纯无权图可以用vectorvectorbool来节省空间对于有权图则用vectorvectorint或vectorvectordouble。同时务必在构造函数或添加边时进行边界检查防止数组越界——这是初级程序员最容易栽跟头的地方之一。2.2 邻接表高效的“朋友的朋友列表”邻接表是更常用、更节省空间的存储方式尤其适用于稀疏图。它为图中的每一个顶点都维护一个链表在C中常用vectorint或listint这个链表里存储了所有与该顶点直接相邻的顶点。它的优点正好弥补了邻接矩阵的缺点空间复杂度低存储所有边需要O(V E)的空间其中E是边数。对于稀疏图这比O(V²)好太多了。遍历效率高要找出一个顶点的所有邻居直接遍历其对应的链表即可这在DFS/BFS中非常高效。动态增删灵活添加边和顶点相对容易。缺点是查询边是否存在较慢判断边(u, v)是否存在需要遍历u的邻接链表时间复杂度是O(degree(u))最坏情况是O(V)。实现稍复杂结构不如矩阵直观。C实现示例使用vector模拟邻接表#include iostream #include vector using namespace std; class GraphList { private: int V; vectorvectorint adjList; // 邻接表每个顶点对应一个vector bool isDirected; public: GraphList(int numVertices, bool directed false) : V(numVertices), isDirected(directed) { adjList.resize(V); } // 添加边 void addEdge(int u, int v) { if (u V || v V) { cerr 顶点索引超出范围! endl; return; } adjList[u].push_back(v); if (!isDirected u ! v) { // 无向图且不是自环 adjList[v].push_back(u); } } // 打印邻接表 void printList() { for (int i 0; i V; i) { cout 顶点 i 的邻居: ; for (int neighbor : adjList[i]) { cout neighbor ; } cout endl; } } // 获取邻接表供遍历算法使用 const vectorvectorint getList() const { return adjList; } int getNumVertices() const { return V; } };实操心得在大多数算法竞赛和实际工程中邻接表是默认首选。除非你明确知道图非常稠密或者需要频繁进行任意两点间是否存在边的查询否则都用邻接表。用vectorvectorint实现邻接表在缓存友好性和访问速度上通常优于listint。另外如果顶点有复杂的属性如字符串名称通常会用一个数组或vector存储顶点属性并用顶点索引来关联邻接表。3. 深度优先搜索DFS一条道走到黑的“探险家”DFS的策略就像你走迷宫时的“右手法则”遇到岔路随便选一条路通常按固定顺序一直走下去直到走进死胡同然后退回上一个岔路口尝试另一条没走过的路。这种策略天然适合用递归来实现因为“前进”和“回退”正好对应了递归的“递”和“归”。3.1 递归实现最直观的思维映射递归实现DFS的代码非常简洁几乎就是算法定义的直接翻译。核心步骤从起始顶点s开始。将s标记为“已访问”。对于s的每一个未访问的邻居顶点v递归调用DFS(v)。C实现基于邻接表#include iostream #include vector using namespace std; class DFSGraph { private: int V; const vectorvectorint adjList; // 引用外部构造好的邻接表 vectorbool visited; // 访问标记数组 // 递归的核心函数 void DFSUtil(int v) { // 1. 标记当前顶点为已访问 visited[v] true; cout v ; // 执行访问操作这里简单打印 // 2. 递归访问所有未访问的邻居 for (int neighbor : adjList[v]) { if (!visited[neighbor]) { DFSUtil(neighbor); } } } public: // 构造函数接收顶点数和邻接表 DFSGraph(int numVertices, const vectorvectorint list) : V(numVertices), adjList(list), visited(V, false) {} // 对外的DFS遍历接口 void traverseDFS(int startVertex) { // 重置访问标记如果进行多次遍历 fill(visited.begin(), visited.end(), false); cout 从顶点 startVertex 开始的DFS遍历顺序: ; DFSUtil(startVertex); cout endl; // 处理非连通图的情况遍历所有顶点 // for (int i 0; i V; i) { // if (!visited[i]) { // DFSUtil(i); // } // } } };调用示例int main() { GraphList g(5); // 创建一个有5个顶点的无向图 g.addEdge(0, 1); g.addEdge(0, 2); g.addEdge(1, 3); g.addEdge(1, 4); g.addEdge(2, 4); DFSGraph dfsWalker(5, g.getList()); dfsWalker.traverseDFS(0); // 从顶点0开始DFS // 输出可能为从顶点 0 开始的DFS遍历顺序: 0 1 3 4 2 // 注意顺序取决于邻接表中边的存储顺序 return 0; }关键点解析访问标记数组visited这是所有图遍历算法的灵魂。必须有一个全局或类成员的数据结构通常是vectorbool来记录每个顶点是否被访问过防止重复访问陷入无限循环。递归的隐式栈递归调用过程系统会自动使用函数调用栈来保存每一层的状态返回地址、局部变量等。当深入探索到尽头时通过函数返回自动实现“回溯”。遍历顺序的不确定性DFS的输出顺序不唯一它依赖于你访问邻居顶点的顺序即邻接表中边的存储顺序。上面的代码访问邻居的顺序就是adjList[v]中元素的顺序。3.2 显式栈实现理解递归的本质递归虽然优雅但在图非常深比如链状图的情况下可能导致栈溢出。我们可以用显式的栈stackint来模拟递归过程将递归转化为迭代。这能帮助你更深刻地理解DFS“后进先出”的特性。迭代DFS步骤将起始顶点s压入栈并标记为已访问。当栈不为空时 a. 弹出栈顶顶点v。 b. 访问v如果是在弹出时访问。 c. 将v的所有未访问的邻居顶点压入栈并标记为已访问。C实现#include iostream #include vector #include stack using namespace std; void DFS_Iterative(int startVertex, const vectorvectorint adjList, int V) { vectorbool visited(V, false); stackint s; // 起始顶点入栈并标记 s.push(startVertex); visited[startVertex] true; // 注意入栈时标记避免同一顶点重复入栈 cout 迭代DFS遍历顺序: ; while (!s.empty()) { int v s.top(); s.pop(); cout v ; // 在弹出时访问 // 注意这里需要将邻居逆序入栈以模拟与递归相同的访问顺序 // 因为栈是LIFO而递归是正序调用 // 如果想和递归顺序一致可以反向遍历邻居 for (auto it adjList[v].rbegin(); it ! adjList[v].rend(); it) { int neighbor *it; if (!visited[neighbor]) { visited[neighbor] true; s.push(neighbor); } } } cout endl; }注意事项与心得标记时机是坑在迭代DFS中必须在顶点入栈时立即标记为已访问而不是在弹出时。否则同一个顶点可能会被其他路径重复压入栈中多次导致重复访问和栈空间浪费。这是和递归版本在访问函数开头标记一个很重要的区别。顺序问题由于栈是后进先出为了得到和上面递归示例正序访问邻居相同的输出顺序我们需要将当前顶点的邻居逆序压入栈。如果对顺序没要求正序压入即可。访问时机递归DFS是在“进入”顶点时访问前序而上面的迭代版本是在“离开”弹出顶点时访问。如果你想在迭代中也实现“进入时访问”可以在入栈后、while循环开始前访问起始顶点并在循环内、弹出顶点后立即访问。但这通常不影响DFS解决问题的本质。3.3 DFS的核心应用场景与实战技巧理解了DFS怎么走更要明白它适合去哪。DFS的“深度优先”特性让它擅长解决以下几类问题连通性检测与路径查找判断两个顶点是否连通或者找出一条从起点到终点的路径不一定是最短。DFS会沿着一条分支深入一旦找到目标就返回非常适合“是否存在”这类问题。拓扑排序用于有向无环图DAG中安排任务的执行顺序。通过对图进行DFS并记录顶点的完成时间或逆后序可以得到一个合法的拓扑序列。这是编译器安排指令、构建系统安排编译任务的基础。寻找图的连通分量在无向图中一次DFS遍历所能到达的所有顶点构成一个连通分量。对未访问的顶点反复调用DFS就能找出图的所有连通分量。检测环在无向图中如果DFS过程中遇到了已访问的顶点并且这个顶点不是当前顶点的父节点那么就存在环。在有向图中还需要一个额外的“在递归栈中”的标记来检测环。解决回溯问题DFS是回溯算法如八皇后、数独、全排列的骨架。将问题状态空间视为一个图DFS就是在系统地探索所有可能的状态路径。实战技巧在DFS中记录路径很多时候我们不仅要知道能否到达还要知道怎么到达的。这需要在DFS过程中维护一个路径信息。// 递归DFS记录从起点到任意点的路径 bool DFS_FindPath(int current, int target, const vectorvectorint adjList, vectorbool visited, vectorint path) { visited[current] true; path.push_back(current); // 将当前节点加入路径 if (current target) { return true; // 找到目标路径在path中 } for (int neighbor : adjList[current]) { if (!visited[neighbor]) { if (DFS_FindPath(neighbor, target, adjList, visited, path)) { return true; // 如果子调用找到了直接返回true不再尝试其他邻居 } } } // 当前节点的所有邻居都走不通回溯从路径中移除当前节点 path.pop_back(); return false; }踩坑记录DFS找到的路径是一条可行路径但不一定是最短路径。如果你需要最短路径请使用BFS或专门的最短路径算法如Dijkstra。我曾在一个游戏地图寻路功能中错误地使用了DFS结果角色经常贴着地图边缘绕远路被玩家吐槽“人工智能人工智障”后来换成BFS就正常了。4. 广度优先搜索BFS层层推进的“广播塔”如果说DFS是专注的探险家那BFS就是高效的广播站。它的策略是从起点开始先访问所有距离为1的邻居第一层再访问所有距离为2的邻居第二层以此类推。这种“地毯式”搜索保证了它第一次访问到某个顶点时走过的路径一定是从起点到该顶点的最短路径在边权为1的无权图中。4.1 队列实现公平的“先来后到”BFS的实现必须借助队列Queue这种“先进先出”的数据结构这完美契合了“先访问的顶点其邻居也先被访问”的逻辑。核心步骤将起始顶点s入队并标记为已访问。当队列不为空时 a. 出队一个顶点v。 b. 访问v。 c. 将v的所有未访问的邻居顶点入队并标记为已访问。C实现基于邻接表#include iostream #include vector #include queue using namespace std; void BFS(int startVertex, const vectorvectorint adjList, int V) { vectorbool visited(V, false); queueint q; // 起始顶点入队并标记 visited[startVertex] true; q.push(startVertex); cout BFS遍历顺序: ; while (!q.empty()) { int v q.front(); q.pop(); cout v ; // 访问出队的顶点 // 将所有未访问的邻居入队 for (int neighbor : adjList[v]) { if (!visited[neighbor]) { visited[neighbor] true; // 入队前标记 q.push(neighbor); } } } cout endl; }调用示例沿用之前的图int main() { GraphList g(5); g.addEdge(0, 1); g.addEdge(0, 2); g.addEdge(1, 3); g.addEdge(1, 4); g.addEdge(2, 4); BFS(0, g.getList(), 5); // 输出BFS遍历顺序: 0 1 2 3 4 // 顺序是0第0层 - 1, 2第1层 - 3, 4第2层 return 0; }关键点解析队列的作用队列保证了顶点按“被发现”的先后顺序被访问从而实现了层次遍历。标记时机和迭代DFS一样必须在顶点入队时立即标记。如果等到出队时才标记同一个顶点可能会被多个已访问的顶点重复发现并多次入队虽然最终不会重复访问因为出队时会检查但队列中会充斥大量重复元素严重浪费空间和时间在稠密图上可能导致队列爆炸。路径最短性BFS遍历树中从根起点到任意节点的路径就是原图中的最短路径。我们可以通过记录每个顶点的“前驱”或“距离”来还原这条路径。4.2 记录层数与最短路径BFS的一个强大功能是能轻松计算从起点到所有其他顶点的最短距离边权为1。改进的BFS记录距离#include iostream #include vector #include queue using namespace std; vectorint BFS_Distance(int startVertex, const vectorvectorint adjList, int V) { vectorbool visited(V, false); vectorint distance(V, -1); // -1 表示不可达 queueint q; visited[startVertex] true; distance[startVertex] 0; // 起点到自己的距离为0 q.push(startVertex); while (!q.empty()) { int v q.front(); q.pop(); for (int neighbor : adjList[v]) { if (!visited[neighbor]) { visited[neighbor] true; distance[neighbor] distance[v] 1; // 邻居的距离是当前顶点距离1 q.push(neighbor); } } } return distance; } int main() { GraphList g(6); g.addEdge(0, 1); g.addEdge(0, 2); g.addEdge(1, 3); g.addEdge(2, 3); g.addEdge(2, 4); g.addEdge(3, 5); g.addEdge(4, 5); vectorint dist BFS_Distance(0, g.getList(), 6); for (int i 0; i 6; i) { cout 从0到顶点 i 的最短距离: dist[i] endl; } // 输出示例 // 从0到顶点0的最短距离: 0 // 从0到顶点1的最短距离: 1 // 从0到顶点2的最短距离: 1 // 从0到顶点3的最短距离: 2 (通过0-1-3或0-2-3) // 从0到顶点4的最短距离: 2 (0-2-4) // 从0到顶点5的最短距离: 3 (0-2-4-5 或 0-1-3-5) return 0; }4.3 BFS的核心应用场景BFS的“广度优先”特性让它成为解决以下问题的利器无权图的最短路径如上所示这是BFS最经典的应用。比如社交网络中计算两个人之间的最少介绍人次数六度空间理论或者棋盘游戏中棋子移动到目标位置的最少步数。广播或层级传播模拟消息在网络中的传播、病毒感染的扩散、火焰在网格上的蔓延等。BFS能清晰地展现出每一轮每一层影响的范围。网络爬虫的层级抓取爬虫从种子URL开始先抓取该页面的所有链接第一层再抓取这些链接页面中的所有链接第二层以此类推。BFS能控制抓取的深度。二分图检测BFS可以给顶点交替染色用来检测一个图是否是二分图即顶点集可以分成两个独立集使得同一集内的顶点不相邻。寻找连通分量和DFS一样BFS也可以用于寻找无向图的连通分量。5. DFS与BFS的深度对比与选型指南光知道怎么实现还不够在实际问题中能准确选择用DFS还是BFS才是真正掌握了它们。下面这个表格从多个维度进行了对比特性维度深度优先搜索 (DFS)广度优先搜索 (BFS)数据结构栈 (递归调用栈或显式栈)队列遍历顺序一条分支走到底再回溯一层一层向外扩展空间复杂度O(h)h为图的最大深度。在树形图上优秀。O(w)w为图的最大宽度。在层级多的图上可能很高。时间复杂度O(V E)所有顶点和边都被访问一次。O(V E)同DFS。最短路径不能保证找到无权图最短路径。保证找到无权图最短路径首次访问时。适用问题拓扑排序、检测环、连通分量、路径存在性、回溯问题。最短路径问题、广播问题、层级遍历、二分图检测。实现复杂度递归实现简洁迭代实现需注意顺序。实现相对统一需注意入队标记。内存风险递归深度过深可能导致栈溢出。在非常宽的图上队列可能消耗大量内存。选型决策流程图简化问题是否要求“最短”、“最少步数”是- 优先考虑BFS。否- 进入下一步。图的结构是否非常深如长链状且空间有限是- 谨慎使用递归DFS考虑迭代DFS或BFS如果宽度不大。否- 进入下一步。是否需要探索所有可能解如排列组合或判断是否存在一条路径是-DFS特别是回溯法更合适。否- 进入下一步。问题是否涉及任务依赖、编译顺序是- 使用基于DFS的拓扑排序。否- 两者通常都可根据编码习惯或额外约束选择。一个综合例子迷宫求解问题A判断迷宫是否有出口- 用DFS或BFS都可以找到一个就行。问题B找到迷宫的最短出口路径-必须用BFS。问题C找出迷宫的所有出口路径- 用DFS回溯来记录所有路径。6. 常见问题、调试技巧与性能优化实录在实际编码和面试中总会遇到一些坑。这里记录几个最常见的问题和我的解决心得。6.1 为什么我的遍历陷入了死循环这是新手最常遇到的问题根本原因几乎都是忘记了使用visited访问标记数组或者标记的时机不对。递归DFS必须在递归函数一开始就标记当前顶点为已访问。迭代DFS必须在顶点入栈时标记。BFS必须在顶点入队时标记。调试技巧在开发过程中可以在访问打印顶点时同时打印visited数组的状态或者打印栈/队列的内容清晰看到算法的执行过程。对于复杂的图可以先用一个只有3-5个顶点的小图来测试。6.2 非连通图的遍历上面的示例代码默认图是连通的。如果图是非连通的由多个互不连通的子图构成从某个起点开始的一次DFS或BFS只能遍历它所在的连通分量。为了遍历整个图我们需要用一个外层循环来驱动。void traverseEntireGraph(const vectorvectorint adjList, int V) { vectorbool visited(V, false); int componentCount 0; for (int i 0; i V; i) { if (!visited[i]) { componentCount; cout 连通分量 componentCount : ; // 可以选择用DFS或BFS遍历这个分量 // 这里以DFS递归为例 DFSUtil(i, adjList, visited); // 需要修改DFSUtil函数接受visited引用 cout endl; } } cout 图共有 componentCount 个连通分量。 endl; }6.3 处理大规模图时的性能考量当图的顶点数V和边数E达到百万甚至千万级别时每一个细微的实现差异都可能被放大。数据结构选择vectorvectorint作为邻接表在大多数情况下是最优的因为内存连续缓存友好。避免使用listint它的指针跳转对缓存不友好。visited数组用vectorbool但注意vectorbool是特化模板可能不是真正的布尔数组。对性能有极致要求时可以考虑用vectorchar或bitset。递归深度限制系统栈空间有限通常几MB。对于深度可能很大的图如一条长链递归DFS有栈溢出风险。解决方案使用显式栈的迭代DFS。或者在能预估深度且环境允许的情况下调整编译器栈大小如g的-Wl,--stack,size参数。BFS队列内存在极端宽如星型图的图上BFS的队列可能在某一层存储几乎所有的顶点内存消耗为O(V)。如果内存紧张可以考虑使用双向BFS从起点和终点同时开始搜索当两个搜索 frontier 相遇时停止。这能显著减少搜索空间尤其是在知道目标顶点的情况下。并行化可能BFS的每一层内部对顶点的处理通常是独立的这为并行化提供了可能。可以使用OpenMP等工具并行处理同一层顶点的邻居探索。而DFS由于强烈的顺序依赖并行化困难得多。6.4 如何优雅地处理顶点属性非整数索引我们之前的例子都用整数0, 1, 2...作为顶点索引。但现实中顶点可能是字符串如城市名、对象等。标准做法是建立映射#include iostream #include vector #include unordered_map #include string using namespace std; class GraphWithNames { private: unordered_mapstring, int nameToIndex; vectorstring indexToName; vectorvectorint adjList; int getOrCreateIndex(const string name) { if (nameToIndex.find(name) nameToIndex.end()) { int newIndex indexToName.size(); nameToIndex[name] newIndex; indexToName.push_back(name); adjList.push_back(vectorint()); // 为新的顶点增加邻接表 } return nameToIndex[name]; } public: void addEdge(const string u, const string v) { int idxU getOrCreateIndex(u); int idxV getOrCreateIndex(v); // 防止重复添加边根据需求可选 if (find(adjList[idxU].begin(), adjList[idxU].end(), idxV) adjList[idxU].end()) { adjList[idxU].push_back(idxV); // 如果是无向图还需要 adjList[idxV].push_back(idxU); } } void BFS(const string start) { if (nameToIndex.find(start) nameToIndex.end()) return; int startIdx nameToIndex[start]; int V indexToName.size(); vectorbool visited(V, false); queueint q; visited[startIdx] true; q.push(startIdx); cout BFS from start : ; while (!q.empty()) { int vIdx q.front(); q.pop(); cout indexToName[vIdx] ; for (int neighborIdx : adjList[vIdx]) { if (!visited[neighborIdx]) { visited[neighborIdx] true; q.push(neighborIdx); } } } cout endl; } };这套映射机制nameToIndex,indexToName是处理非整数顶点标识的通用模式在后续学习更高级的图算法时也会经常用到。图的遍历是打开图算法大门的钥匙DFS和BFS则是这把钥匙上最重要的两个齿。理解它们的本质差异——一个用栈追求深度一个用队列追求广度——并能在具体问题中做出正确选择你的算法工具箱里就多了两件趁手的兵器。多写多画图在纸上画出遍历过程多思考“如果换一种遍历方式会怎样”是掌握它们的不二法门。