#无向图 #图节点 # #路径查找 #搜索 #最短路径 #所有对

bit_gossip

无权无向图中所有节点对最短路径的计算库

13个版本

0.0.13 2024年8月7日
0.0.12 2024年8月6日

#146算法

Download history 745/week @ 2024-08-02 54/week @ 2024-08-09

799 每月下载次数

MIT/Apache

160KB
2.5K SLoC

bit_gossip

github crates.io docs.rs

bit_gossip,以其实现技术命名,是一个简单的路径查找库,用于计算无权无向图中所有节点对的最短路径。

计算完成后,您可以在几乎恒定的时间内检索到任意两个节点之间的最短路径;我指的是少于一微秒!

如果您游戏的

  • 节点/瓦片数量适中(约1000个),并且
  • 有数百或数千个实体需要找到移动目标的路径。

如果您有一个大量节点(>10,000个)的静态地图,您可以在加载阶段使用此库计算所有路径。

此外,计算速度足够快,不仅可以用于静态地图,还可以用于游戏中动态变化的地图。

  • 在现代CPU上计算100个节点的所有路径只需几百微秒。
  • 在现代CPU上计算1000个节点的所有路径需要不到50毫秒。
  • 在现代CPU上计算10000个节点的所有路径需要不到几秒钟。

有关更多详细信息,请参阅基准测试

目录

点击展开

基本用法

小图

对于节点数少于128的小图,请使用Graph16Graph32Graph64Graph128

在GraphN中,如Graph16,N代表图可以包含的节点数。

0 -- 1 -- 2 -- 3
|         |    |
4 -- 5 -- 6 -- 7
|         |    |
8 -- 9 -- 10 - 11
use bit_gossip::Graph16;

// Initialize a builder with 12 nodes
let mut builder = Graph16::builder(12);

// Connect the nodes
for i in 0..12u8 {
    if i % 4 != 3 {
        builder.connect(i, i + 1);
    }
    if i < 8 {
        builder.connect(i, i + 4);
    }
}

builder.disconnect(1, 5);
builder.disconnect(5, 9);

// Build the graph
let graph = builder.build();

// Check the shortest path from 0 to 9
assert_eq!(graph.neighbor_to(0, 9), Some(4));
assert_eq!(graph.neighbor_to(4, 9), Some(8));
assert_eq!(graph.neighbor_to(8, 9), Some(9));

// Both 1 and 4 can reach 11 in the shortest path.
assert_eq!(graph.neighbors_to(0, 11).collect::<Vec<_>>(), vec![1, 4]);

// Get the path from 0 to 5
assert_eq!(graph.path_to(0, 5).collect::<Vec<_>>(), vec![0, 4, 5]);

大图

对于节点数超过128的图,请使用Graph,它可以包含任意数量的节点。

如果环境支持多线程,Graph将并行处理路径以加快计算速度。

在这个示例中,让我们创建一个100x100的网格图。

use bit_gossip::Graph;

// Initialize a builder with 10000 nodes
let mut builder = Graph::builder(10000);

// Connect the nodes
for y in 0..100u16 {
    for x in 0..100 {
        let node = y * 100 + x;

        if x < 99 {
            builder.connect(node, node + 1);
        }
        if y < 99 {
            builder.connect(node, node + 100);
        }
    }
}

// Build the graph
// This may take a few seconds
let graph = builder.build();

// Check the shortest path from 0 to 9900
// This is fast
let mut curr = 0;
let dest = 9900;

let mut hops = 0;

while curr != dest {
    let prev = curr;
    curr = graph.neighbor_to(curr, dest).unwrap();
    println!("{prev} -> {curr}");

    hops += 1;
    if curr == dest {
        println!("we've reached node '{dest}' in {hops} hops!");
        break;
    }
}

更新图

您不能直接更新图,但可以将图转换回构建器,更新节点数并添加/删除边,然后再次构建图。

我尚未测试图的调整大小功能,因此如果您发现任何错误,请提交问题。

use bit_gossip::Graph;

// Initialize a builder with 10000 nodes
let mut builder = Graph::builder(10000);

... build graph

// Build the graph
// This may take a few seconds
let graph = builder.build();

... do some work

let mut builder  = graph.into_builder();

// Resize the graph to 5000 nodes
builder.resize(5000);

// add/remove edges
builder.disconnect(0, 1);
builder.connect(0, 3000);

... 

/// Build the graph again
let graph = builder.build();

图类型

  • Graph16Graph32Graph64Graph128:使用原始数据类型进行位存储,效率非常高。

    • GraphN中,N代表图可以包含的节点数。
    • 这些类型用于小于128个节点的固定节点大小。
  • Graph:支持任意节点大小,并针对并行处理进行了优化。

    • SeqGraphParaGraph的枚举。如果环境支持多线程,则使用ParaGraph;否则,使用SeqGraph
  • SeqGraph:根据目标架构使用Vec<u32>Vec<u64>来存储位,通常比原始数据类型慢大约3倍。

  • ParaGraph:使用Vec<AtomicU32>Vec<AtomicU64>进行位存储,这比原始数据类型慢,但得益于并行处理,随着节点数的增加而变得更加高效。

对于小于128个节点的固定节点大小,请优先使用基于原始数据类型的图类型。

库公开了一个泛型Graph类型,它根据环境自动选择并行或顺序版本,也可以手动选择。

工作原理

图表示

点击展开

图中的每条边存储N位信息,其中N是图中的节点数。

在边a->b中,第n位表示从节点a到节点n在此边中是否存在最短路径。

因此,图可以存储图中所有节点对之间的所有最短路径,共存储NxM位,其中M是图中的边数。

一旦构建了图,您就可以在几乎恒定的时间内检索任意两个节点之间的最短路径,只需检查边中的位即可。

构建图

点击展开

我们将通过一个简单的图进行说明。

0 -- 1 -- 2
|    |
3 -- 4
|
5

一开始,所有边的位都是未设置的。每条边都将有如下的位 [ ][ ][ ][ ][ ]

[ ][ ][ ][ ][ ][ ] // 0 -> 1
[ ][ ][ ][ ][ ][ ] // 0 -> 3
[ ][ ][ ][ ][ ][ ] // 1 -> 2
[ ][ ][ ][ ][ ][ ] // 1 -> 4
[ ][ ][ ][ ][ ][ ] // 3 -> 4
[ ][ ][ ][ ][ ][ ] // 3 -> 5

第一次迭代

让我们从节点 0 开始。

对于边 0->1,我们将位设置为 [ ][ ][ ][ ][1][ ],因为这条边是到 1 的最短路径的一部分。

[ ][ ][ ][ ][1][ ] // 0 -> 1

对于 0->3,我们将位设置为 [ ][ ][1][ ][ ][ ],因为这条边是到 3 的最短路径的一部分。

[ ][ ][1][ ][ ][ ] // 0 -> 3

接下来,我们移动到节点 1

对于边 1->0,我们可以简单地翻转 0->1 的位。

[ ][ ][ ][ ][1][ ] // 0 -> 1

flips to

[ ][ ][ ][ ][0][ ] // 1 -> 0

我们将位 0 设置为 1。

[ ][ ][ ][ ][0][1] // 1 -> 0

flips to

[ ][ ][ ][ ][1][0] // 0 -> 1

从节点 1 的角度来看,边 1->0 是到 0 的最短路径,并且距离 1 更远。有道理,对吧?

现在,到 1->2,我们将位 2 设置为 1,因为这条边是到节点 2 的最短路径。

[ ][ ][ ][1][ ][ ] // 1 -> 2

现在,我们移动到节点 2

对于 2->1,再次翻转 1->2,我们将位 1 设置为 1

[ ][ ][ ][0][1][ ] // 2 -> 1

我们重复这个过程,直到处理完所有节点,最终得到边中以下位,以矩阵形式表示

[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][ ][0] // 0 -> 3
[ ][ ][ ][1][0][ ] // 1 -> 2
[ ][1][ ][ ][0][ ] // 1 -> 4
[ ][1][0][ ][ ][ ] // 3 -> 4
[1][ ][0][ ][ ][ ] // 3 -> 5

你在这里看到了什么模式?每条边都有两个位被设置。

第二次迭代

现在,每条边将开始与相邻边共享其位;这就是为什么这个算法被称为 bit_gossip 的原因。

注意:只有在相邻的边还没有设置位的情况下,我们才共享这个位。

我们从节点 0 的边开始。

[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][ ][0] // 0 -> 3

0->1 是到 1 的最短路径,这意味着从 0 出发的所有其他边都不能是到 1 的最短路径。如果它们是,它们的位 1 已经被设置。

所以,我们将所有其他相邻边的位 1 设置为 0。

[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][0][0] // 0 -> 3

0->3 相同。因为我们知道 0->3 是到 3 的最短路径,所有其他相邻边都不能是到 3 的最短路径。因此,我们将所有其他相邻边的位 3 设置为 0。

[ ][ ][0][ ][1][0] // 0 -> 1
[ ][ ][1][ ][0][0] // 0 -> 3

现在,让我们转到节点 1 的边。我翻转了边 0->11->0 以便于可视化。

节点 1

[ ][ ][1][ ][0][1] // 1 -> 0
[ ][ ][ ][1][0][ ] // 1 -> 2
[ ][1][ ][ ][0][ ] // 1 -> 4

因为 1->0 是到 0 的最短路径,我们将所有相邻边的位 0 设置为 0

[ ][ ][1][ ][0][1] // 1 -> 0
[ ][ ][ ][1][0][0] // 1 -> 2
[ ][1][ ][ ][0][0] // 1 -> 4

因为 1->2 是到 2 的最短路径,我们将所有相邻边的位 2 设置为 0

[ ][ ][1][0][0][1] // 1 -> 0
[ ][ ][ ][1][0][0] // 1 -> 2
[ ][1][ ][0][0][0] // 1 -> 4

对于 1->4 也是如此。

[ ][0][1][0][0][1] // 1 -> 0
[ ][0][ ][1][0][0] // 1 -> 2
[ ][1][ ][0][0][0] // 1 -> 4

我们对其他节点重复这个过程。

节点 3

[ ][ ][0][ ][1][1] // 3 -> 0
[ ][1][0][ ][ ][ ] // 3 -> 4
[1][ ][0][ ][ ][ ] // 3 -> 5

变为

[0][0][0][ ][1][1] // 3 -> 0
[0][1][0][ ][ ][0] // 3 -> 4
[1][0][0][ ][ ][0] // 3 -> 5

节点 4

[ ][0][ ][1][1][1] // 4 -> 1
[1][0][1][ ][ ][1] // 4 -> 3

变为

[ ][0][0][1][1][1] // 4 -> 1
[1][0][1][ ][0][1] // 4 -> 3

在八卦会话之后,边矩阵看起来是这样的

[ ][1][0][1][1][0] // 0 -> 1
[1][1][1][ ][0][0] // 0 -> 3
[ ][0][ ][1][0][0] // 1 -> 2
[ ][1][1][0][0][0] // 1 -> 4
[0][1][0][ ][1][0] // 3 -> 4
[1][0][0][ ][ ][0] // 3 -> 5

看看最短路径信息是如何像野火一样传播的?就像八卦一样!

因此,命名为 bit_gossip

我们重复这个过程,直到以下两种情况之一发生:

  1. 所有边的所有位都被设置,或者
  2. 迭代中没有设置任何位。

就这样!

由于所有操作都是位操作,计算非常快。

此外,“每个节点的八卦”与其他节点无关,因此我们可以并行化计算,我们在 Graph 类型中这样做。

路径检索

点击展开

所有迭代完成后,矩阵将看起来像这样

0 -- 1 -- 2
|    |
3 -- 4
|
5
[0][1][0][1][1][0] // 0 -> 1
[1][1][1][0][0][0] // 0 -> 3
[0][0][0][1][0][0] // 1 -> 2
[1][1][1][0][0][0] // 1 -> 4
[0][1][0][0][1][0] // 3 -> 4
[1][0][0][0][0][0] // 3 -> 5

现在,我们如何使用这些数据来检索两个节点之间的最短路径呢?

假设我们想要找到节点 2 和节点 5 之间的最短路径。

  1. 检查 2 在位 5 的边。

1->2 翻转到查看 2->1 的位。

[1][1][1][0][1][1] // 2 -> 1

我们看到位5被设置,因此我们移动到节点 1

  1. 检查节点 1 在位 5 的边。
[1][0][1][0][0][1] // 1 -> 0
[0][0][0][1][0][0] // 1 -> 2
[1][1][1][0][0][0] // 1 -> 4

我们看到 1->01->4 的位5都被设置;在这种情况下,我们可以移动到 04。两者都将给出相同的最短路径。

让我们移动到 4

[0][0][0][1][1][1] // 4 -> 1
[1][0][1][1][0][1] // 4 -> 3

我们看到位5对于 4->3 被设置,因此我们移动到节点 3

  1. 检查节点 3 在位 5 的边。
[0][0][0][1][1][1] // 3 -> 0
[0][1][0][0][1][0] // 3 -> 4
[1][0][0][0][0][0] // 3 -> 5

我们看到位5对于 3->5 被设置,因此我们移动到节点 5

  1. 我们已达到节点 5

因此,节点 2 和节点 5 之间的最短路径是 2 -> 1 -> 4 -> 3 -> 5

太棒了!

你可能认为找到路径需要做很多工作,但事实上,这非常快。

在实际游戏中,我们不需要检索到目的地的完整路径。

我们只需要知道,“对于当前节点的边,哪一条是到目的地节点的最短路径?”

我们到达下一个节点后,重复此过程。

如果目的地改变,我们再次简单地提问,“下一个应该去哪个邻居?”

基准测试

下面的基准测试展示了不同图大小和类型的计算时间。它们用于突出各种图表示的性能特征,而不是提供绝对指标。

请注意,不同类型的图将花费不同的时间;例如,迷宫图比瓷砖网格花费的时间略多。

机器规格: Apple M3 Pro,12核CPU,18GB RAM

基准测试是在每个节点与其四个邻居相连的瓷砖网格上进行的。

在这里,n 表示节点数,e 表示边数,(WxH) 表示网格维度。对于尺寸为 WxH 的网格,有 W*H 个节点和 2*W*H - W - H 条边。

处理时间

节点,边,网格尺寸 Graph16 Graph32 Graph64 Graph128 SeqGraph ParaGraph
16n, 24e (4x4) ~15µs ~15us ~15us ~15us ~45us ~500us
32n, 52e (4x8) ~45us ~40us ~48us ~150us ~1ms
64n, 112e (8x8) ~120us ~140us ~450us ~1.5ms
128n, 232e (8x16) ~390us ~1.5ms ~2.5ms
256n, 480e (16x16) ~5ms ~4ms
512n, 976e (16x32) ~18ms ~10ms
1024n, 1984e (32x32) ~55ms ~20ms
2048n, 4000e (32x64) ~200ms ~64ms
2500n, 4900e (50x50) ~400ms ~100ms
4900n, 9660e (70x70) ~1.70s ~300ms
7225n, 14280e (85x85) ~3.6s ~690ms
10000n, 19800e (100x100) ~6.8s ~1.3s
20000n, 39700e (100x200) ~29s ~6.3s
40000n, 79600e (200x200) ~140s ~27s
102400n, 204160e (320x320) ~991s

内存使用

以下是不同图类型基于节点数的理论内存需求。

计算边位内存使用的函数为 n * m / 8 字节,其中 n 是数据类型的位数,m 是边的数量。

计算节点邻居数据内存使用的函数为

  • 对于 GraphNn * N / 8 字节,其中 N 是数据类型的位数,n 是节点的数量。
  • 对于 SeqGraphParaGraph,对于小于 65536 个节点的图,使用 u16 为 nodeID,占用 e * 2 * 2 字节;对于超过 65536 个节点的图,使用 u32 为 nodeID,占用 e * 4 * 2 字节,其中 e 是边的数量。

下表中的值是边位和节点邻居数据内存使用的总和。

它不考虑原子、哈希表或向量结构等内存开销。

因此,实际内存使用量将远高于下表所示值。

以下图表显示内存使用量(字节)B

节点,边,网格尺寸 Graph16 Graph32 Graph64 Graph128 SeqGraph ParaGraph
16n, 24e (4x4) ~80 B ~160 B ~320 B ~640 B ~288 B ~288 B
32n, 52e (4x8) ~336 B ~672 B ~1.4 KB ~624 B ~624 B
64n, 112e (8x8) ~1.4 KB ~2.8 KB ~1.34 KB ~1.34 KB
128n, 232e (8x16) ~5.75 KB ~4.63 KB ~4.63 KB
256n, 480e (16x16) ~17.3 KB ~17.3 KB
512n, 976e (16x32) ~66.3 KB ~66.3 KB
1024n, 1984e (32x32) ~261.9 KB ~261.9 KB
2048n, 4000e (32x64) ~1 MB ~1 MB
2500n, 4900e (50x50) ~1.56 MB ~1.56 MB
4900n, 9660e (70x70) ~6.0 MB ~6.0 MB
7225n, 14280e (85x85) ~12.9 MB ~12.9 MB
10000n, 19800e (100x100) ~24.9 MB ~24.9 MB
20000n, 39700e (100x200) ~99.4 MB ~99.4 MB
40000n, 79600e (200x200) ~398 MB ~398 MB
102400n, 204160e (320x320) ~2.61 GB

特性

  • parallel:使用 Rayon 启用并行性;此功能默认启用。

示例

我已经使用 bevy 创建了一个简单的迷宫游戏来比较 bit_gossipastar

游戏将创建一个 80x80 的网格迷宫。

您可以使用箭头键移动“玩家”(绿色点)。

每次您按下 <space> 键时,都会生成 200 个额外的敌人(红色点),追逐玩家。

当玩家和敌人碰撞时,敌人将被移除。

注意:对于 bit_gossip,首先使用 80x80 的网格迷宫构建图需要几秒钟,敌人在构建完成前不会移动。构建完成后,将在控制台记录 graph built in ...s

您可以使用以下命令运行 bit_gossip 版本:

cargo run --release -p maze

您可以使用以下命令运行 astar 版本:

cargo run --release -p astar_maze

这只是为了展示如何在游戏中使用 bit_gossip

对我来说,大约有1000个敌人时,我注意到在移动玩家时 astar 版本开始出现延迟。然而,bit_gossip 版本无论敌人数量多少都不会出现延迟。

我将敌人数量增加到超过50000个,性能没有任何下降。

尽管如此,astar 即使有1000个敌人追逐玩家也能表现得如此出色,这意味着对于大多数游戏来说,astar 足够了。

您可以通过进入 examples/maze/src/main.rs 来更改大小,这是 bit_gossip 版本,以及 examples/astar_maze/src/main.rs 来更改大小,这是 astar 版本。

通过更改 MazePlugin::new(50, 50) 中的值来更改迷宫网格的宽度和高度。

Astar记录

https://github.com/user-attachments/assets/b1ddf4da-c885-4b86-b114-a2cce7ff7e98

Bit Gossip记录

https://github.com/user-attachments/assets/be9378f4-e3c8-452b-b94c-900b7a94b45a

大图优化

分区

将图划分为更小的图可以帮助提高内存使用和计算时间。

使用 astar,我认为这被称为分层路径查找?

如果您有超过10000个节点,您肯定应该考虑将图分区。

更好地划分地图

与其将瓦片制成图,不如考虑将您的地图划分为房间和门。

房间是一个空间,其中任何点都可以通过简单的直线连接到其他点。

门是连接两个房间的边。

这样,您只需找到从房间到房间的路径,然后可以简单地移动到房间内的更具体点。

我个人认为,这种方法比分区图更好。

未来计划

在开发算法时,我发现了一些关于算法的有趣点,我可以进一步探索以找到更多优化算法的方法。

只有一个邻居的节点

当一个节点只有一个邻居时,该邻居是到达所有其他节点的最短路径。

此外,如果该邻居只有两个邻居,那么该节点唯一的另一个邻居是到达所有其他节点的最短路径。

这可以成为迷宫状图的良好优化点。

信息Delta/Flow

我还不知道该叫什么,但这与图的局部更新有关。

比如说,在图构建完成后,我们想要删除一条边。

我们如何更新图而不需要重建整个图?应该更新哪些节点?

我有一个想法,但我还没有深入探索,那就是将图视为信息流。

由于删除该边而丢失的信息是通过该边才能到达的节点。

lost bits = (merged bits of all other edges) ^ (bits of the edge to be removed)

这有道理吗?

因此,对于节点的邻居及其邻居,我们只需取消设置由删除的边唯一设置的位。

而且,对于任何受影响的节点,如果它们的邻居对于丢失的任何位都有最短路径,我们就不传播它。

看起来不难... 但我需要更深入地思考,还有它能节省多少计算量?

GPU/SIMD加速

我不是GPU或SIMD的专家,但也许这有可能?

寻找悬挂节点

也应该很容易找到所有其他节点都无法到达的节点。

在一个节点中,合并所有边的位,如果某些位仍然是0,那么该节点是可达的。

远离目的地

找到一条离开目的地的路径也非常简单。

这与找到通向目的地的路径正好相反。

找到一个位设置为0的边,然后移动到该节点。这不确定是否是离目的地最远的路径,但至少你不会比最短路径更快地接近目的地。

我基于哪篇论文?

简答:没有。

当然,我尝试在游戏开发社区中寻找预计算路径查找,但我能找到的唯一类似概念是2003年写的;Richard "superpig" Fine发表了一篇文章,它使用矩阵来存储所有节点对之间的最短路径,尽管他没有详细说明如何实际计算矩阵。

即使在我开始这个项目之后,我也不知道有一个叫做所有对最短路径(APSP)的庞大数学领域,专门解决这类问题。

直到我完成了初始实现,我才突然想到,这可能是一个已知问题,有既定的解决方案,只是我没有足够深入地去寻找。

所以,然后,我开始研究图论,并了解了APSP,以及这个主题的论文。我学习了Floyd-Warshall和Johnson的算法,并找到了一些关于APSP的论文。

与Floyd-Warshall和Johnson的有些相似之处,但据我所知,实现方式完全不同。

我试图阅读这个主题的论文,但要么

  1. 它们被付费墙挡住了,或者
  2. 我根本无法理解它们;伪代码怎么可能比我的实际代码更难读懂?
  3. 而且它们都没有实际代码实现或现实生活基准。

所以,在某一点上,我完全失去了兴趣,放弃了阅读论文;我决定自己太笨,不适合学术生活。

长答:当然,也没有。

据我所知,这个实现是原创的,尽管可能有类似的概念;我还抓取了github仓库,以寻找任何类似的概念或实现,但没有找到。

完全有可能有人已经就这种实现写过论文,但我没有找到;如果是这样,请通过[email protected]给我发邮件

如果你也能用简单的话解释一下论文的内容,我将不胜感激,因为我确实想了解他们在说什么。

话虽如此,以下是我希望能够访问或能够理解的论文

如果有人能解释一下这些论文的内容,请通过[email protected]给我发邮件

依赖关系

~250–590KB
~11K SLoC