题目:随机有向图可达性分析
1. 需求分析
任务描述:
从命令行接受整数V和E,随机生成含有V个顶点和E条边的简单有向图,即不包含自环和平行边,存储结构无限定。对此有向图,运行实验求从一个随机选定的顶点可以到达的顶点数量的平均值。
输入/输出要求:
- 输入形式:由空格分隔的两个整数 \(V\)和 \(E\),表示顶点数和边数。
- 输出形式:一个两位小数,表示随机取一个顶点可达的顶点数量平均值。
- 限制:\(1 \leq V \leq 1000\),\(1 \leq E \leq V(V-1)\)。
功能要求:
- 随机生成一个顶点数为V,边数为E的图。
- 求出从一个随机选定的顶点可以到达的顶点数量的平均值。
2. 概要设计
解决思路:
- 预处理:生成一个包含所有 \(V \times (V-1)\) 条可能的有向边的列表。
- 随机选边:使用 Fisher-Yates 洗牌算法将该列表打乱,并选取前 \(E\) 条边作为图的边集。
- 构建图:根据选出的 \(E\) 条边,使用邻接表数据结构构建有向图。
- 蒙特卡洛模拟:
- 重复进行 1000 次实验。
- 在每次实验中,随机选取一个顶点作为起点。
- 使用广度优先搜索 (BFS) 算法,从该起点出发,遍历所有可达的顶点并计数。
- 计算平均值:将 1000 次实验得到的可达顶点数累加,并除以 1000,得到最终的平均值。
数据结构:
-
图的存储:采用邻接表。每个顶点有一个链表,存储从该顶点出发的所有边。
typedef struct Anode { int to; struct Anode* nextarc; } ArcNode; typedef struct Vnode { ArcNode *firstarc; } AdjList[MAXV]; typedef struct { AdjList adjlist; int n, e; } Agraph; -
BFS 辅助队列:使用一个简单的数组和两个指针
front,tail来模拟队列,用于广度优先搜索。visited数组用于标记节点是否已被访问。int queue[MAXV<<1], front, tail; bool visited[MAXV];
模块关系:
- 图生成模块:
generate_graph:核心函数,协调图的生成过程。init_edges:初始化包含所有可能边的全局数组。shuffle_edges:随机打乱并选取 E 条边。add_edge:向邻接表中添加一条具体的边。
- 模拟与计算模块:
run:负责执行指定次数的模拟实验并计算平均值。single_simulate:执行单次模拟,包括随机选点和调用 BFS。
- BFS 核心模块:
push,pop,empty:提供基本的队列操作,支持广度优先搜索。
- 主程序模块:
main:程序入口,负责解析输入、调用图生成和模拟计算模块,并最终输出结果。
3. 详细设计
关键算法:
-
void generate_graph(int V, int E)- 作用:根据给定的顶点数
V和边数E,构建一个随机有向图。 - 详细步骤:
- 检查
E是否超出最大可能边数 \(V \times (V-1)\),若超出则报错退出。 - 调用
init_edges(V)生成所有可能的边,并存储到全局数组edges中。 - 调用
shuffle_edges(E)对edges数组进行随机化处理,并截取前E条。 - 初始化图
g的顶点数n。 - 遍历处理后的
edges数组,解码出每条边的起点x和终点y,并调用add_edge(&g, x, y)将边添加到邻接表中。
- 检查
- 作用:根据给定的顶点数
-
void shuffle_edges(int k)- 作用:实现 Fisher-Yates 洗牌算法,用于从
cnt条可能的边中无偏地随机选取k条。 - 实现细节:
- 循环
i从0到k-1: - 在
[i, cnt-1]范围内生成一个随机索引j。 - 交换
edges[i]和edges[j]的内容。 - 循环结束后,数组
edges的前k个元素即为随机选出的边集。 - 将全局边计数
cnt更新为k。
- 循环
- 作用:实现 Fisher-Yates 洗牌算法,用于从
-
int single_simulate(int n)- 作用:执行一次完整的模拟:随机选择一个起点,并计算从它出发可达的顶点总数。
- 详细步骤:
- 重置状态:将队列头尾指针
front和tail置为0,并使用memset清空visited数组,确保本次模拟不受上次影响。 - 随机选点:通过
rand() % n + 1随机选择一个1到n之间的顶点作为起点x。 - BFS 初始化:将起点
x推入队列 (push(x)),并标记为已访问。 - BFS 遍历:
- 当队列不为空时,出队一个顶点
u,可达顶点计数res加一。 - 遍历
u的邻接链表,对于每个邻接点v,如果v未被访问过,则将其入队并标记为已访问。
- 当队列不为空时,出队一个顶点
- 返回结果:BFS 结束后,
res即为从起点x可达的顶点总数(包括起点自身),函数返回该值。
- 重置状态:将队列头尾指针
-
double run(int n, int t)- 作用:执行
t次模拟,并计算可达顶点数的平均值。 - 流程:
- 初始化总和
res = 0。 - 循环
t次,每次调用single_simulate(n)并将其返回值累加到res中。 - 循环结束后,返回
(double)res / t作为平均值。
- 初始化总和
- 作用:执行
-
int main(int argc, char *argv[])- 作用:程序主入口,协调整个流程。
- 流程:
- 调用
srand(time(NULL))初始化随机数种子。 - 从标准输入读取顶点数
V和边数E。 - 调用
generate_graph(V, E)构建图。 - 调用
run(V, 1000)执行 1000 次模拟并获取平均值。 - 使用
printf("%.2f\n", ans)将结果格式化为两位小数并输出。
- 调用
流程图:
-
图生成流程 (
generate_graph)flowchart TD A["开始(V, E)"] --> B{"E > V*(V-1)?"}; B -->|"是"| C["报错并退出"]; B -->|"否"| D["调用 init_edges(V) 生成所有边"]; D --> E["调用 shuffle_edges(E) 随机选取E条边"]; E --> F["循环遍历选中的E条边"]; F --> G["解码出边的起点x和终点y"]; G --> H["调用 add_edge(g, x, y)"]; H --> F; F -- "循环结束" --> I["完成"]; -
单次模拟流程 (
single_simulate)flowchart TD A["开始(n)"] --> B["重置队列和visited数组"]; B --> C["随机选择起点 x"]; C --> D["将 x 入队,标记 visited[x]=true"]; D --> E{"队列是否为空?"}; E -->|"是"| F["返回可达节点数 res"]; E -->|"否"| G["u = 出队()"]; G --> H["res++"]; H --> I["遍历 u 的邻接点 v"]; I --> J{"v 是否已访问?"}; J -->|"是"| I; J -->|"否"| K["将 v 入队,标记 visited[v]=true"]; K --> I; I -- "遍历结束" --> E;
函数调用关系图:
graph TD
main --> srand;
main --> scanf;
main --> generate_graph;
main --> run;
main --> printf;
generate_graph --> init_edges;
generate_graph --> shuffle_edges;
generate_graph --> add_edge;
shuffle_edges --> swap;
run --> single_simulate;
single_simulate --> push;
single_simulate --> pop;
single_simulate --> empty;
调用关系说明:
main函数是顶层控制器,它按顺序调用了图生成函数generate_graph和模拟运行函数run。generate_graph负责建图的所有步骤,它依赖init_edges来创建候选边集,shuffle_edges来随机化选取,以及add_edge来填充邻接表。run是模拟的驱动器,它多次调用single_simulate来收集数据。single_simulate是单次实验的核心,它实现了 BFS 算法,并依赖push、pop、empty这三个辅助函数来操作队列。
4. 调试分析
问题与解决:
-
-
问题描述:在早期版本中,BFS 队列的头尾指针
front和tail是全局变量,且只在程序启动时隐式初始化一次。这导致只有第一次调用single_simulate是正确的,后续调用会因为front和tail的值未重置而出错,甚至导致数组越界。 -
解决方案:在
single_simulate函数的入口处,显式地将front和tail重置为0,即front = tail = 0;。这样确保了每次模拟都从一个干净的空队列开始。
-
-
-
问题描述:程序能够正确计算出平均值,但在
main函数中调用run函数后,没有接收其返回值,也未进行任何输出。程序运行后无声无息地结束,无法验证结果。 -
解决方案:在
main函数中,使用一个double类型的变量ans来接收run函数的返回值,并使用printf("%.2f\n", ans);将结果打印到控制台。
-
改进方向:
- 减少全局变量:当前代码大量使用全局变量(如
g,edges,queue,visited等),这使得模块间的耦合度较高,容易引发类似队列未重置的错误。未来可以将其重构为通过函数参数传递,提高代码的模块化和健壮性。 - 内存管理:
add_edge函数中使用了malloc分配内存,但程序中没有对应的free操作。虽然对于这种运行一次即退出的程序,操作系统会回收内存,但在更复杂的应用中会导致内存泄漏。可以添加一个free_graph函数,在程序结束前释放所有动态分配的内存。
复杂度分析:
- 生成图的复杂度取决于预处理边的复杂度 \(O(V^2)\)。
- 单轮模拟的复杂度为 \(O(E)\)。
- 总复杂度为 \(O(V^2+kE)\),其中 \(k\) 为模拟次数,默认值为1000。
5. 用户使用说明
运行环境:GCC 编译器 (如 GCC 13.2.0) 或其他标准 C 编译器环境。
编译与运行:
- 保存代码为
graph_sim.c。 - 使用 GCC 编译:
gcc graph_sim.c -o graph_sim.exe - 从命令行运行,提供两个整数参数 V (顶点数) 和 E (边数)。
- 通过修改RUN_TIMES的宏定义可以指定模拟次数。
输入示例:
从命令行输入,模拟一个有 100 个顶点和 500 条边的图。
100 500
程序会输出一个浮点数,代表模拟 1000 次后得到的平均可达节点数。
85.42
6. 测试结果
测试1:稀疏图
- 输入:
20 25(V=20, E=25, 边数约等于顶点数) - 输出:
1.34 - 分析: 在一个稀疏图中,节点间的连通性较差,很多节点可能形成孤立的小连通分量。因此,从一个随机点出发,平均能到达的节点数较少,符合预期。
测试2:稠密图
- 输入:
20 150(V=20, E=150, 边数接近 V*(V-1)/2) - 输出:
20 - 分析: 在一个稠密图中,节点之间有大量的边,整个图有很大概率是强连通的。因此,从任意一个节点出发,几乎可以到达所有其他节点,平均可达数接近顶点总数 V,符合预期。
测试3:大规模图
- 输入:
500 10000 - 输出:
87.83 - 分析: 对于更大规模的图,程序同样能稳定运行并给出一个合理的统计结果,证明了算法的可扩展性。