在过去的几周中,我一直在努力用C ++实现
Fortune的算法 。 该算法需要很多2D点,并根据它们构建
Voronoi图 。 如果您不知道什么是Voronoi图,请看一下该图:
对于每个称为“站点”的入口点,我们需要找到比这个地方更近的地方。 这样的点集形成了上图所示的单元格。
在《财富》算法中,他及时地建立了这样的图表是很了不起的
(这是比较算法的最佳选择),其中
是个地方。
我写这篇文章是因为我认为该算法的实现非常困难。 目前,这是我必须实现的最复杂的算法。 因此,我想分享我遇到的问题以及如何解决它们。
像往常一样,代码被发布在
github上 ,并且我使用的所有参考资料都列在本文的结尾。
财富算法说明
我不会解释该算法的工作原理,因为其他人已经做得很好。 我可以建议您学习这两篇文章:
here和
here 。 第二个非常有趣-作者用Javascript编写了一个交互式演示,对于理解算法的操作很有用。 如果您需要所有证据都更正规的方法,建议阅读《
计算几何》第3版第7章。
此外,我更喜欢处理没有详细记录的实现细节。 正是他们使算法的实现变得如此复杂。 特别是,我将重点介绍所使用的数据结构。
我只是写了该算法的伪代码,以便您了解其全局结构:
将地点事件添加到每个地点的事件队列中
直到事件队列为空
检索顶级事件
如果事件是地方事件
在海岸线上插入新弧线
检查新的圈子活动
否则
在图中创建一个顶点
我们从海岸线上去除了拉紧的弧线
删除无效事件
检查新的圈子活动
图表数据结构
我遇到的第一个问题是选择存储Voronoi图的方式。
我决定使用一种广泛用于计算几何的数据结构,称为双连接边列表(DCEL)。
我的
VoronoiDiagram
类使用四个容器作为字段:
class VoronoiDiagram { public:
我将详细讨论它们中的每一个。
Site
类描述了入口点。 每个位置都有一个索引,这对于将其放入队列,坐标和指向单元格(
face
)的指针很有用:
struct Site { std::size_t index; Vector2 point; Face* face; };
单元的
Vertex
由
Vertex
类表示,它们只有一个坐标字段:
struct Vertex { Vector2 point; };
这是半边的实现:
struct HalfEdge { Vertex* origin; Vertex* destination; HalfEdge* twin; Face* incidentFace; HalfEdge* prev; HalfEdge* next; };
您可能想知道,什么是半肋骨? Voronoi图中的一条边对于两个相邻的单元是公用的。 在DCEL数据结构中,我们将这些边分成两个半边,每个边对应一个半边,并由
twin
指针链接。 而且,半边缘具有起点和终点。
incidentFace
字段指示半边线所属的面。 DCEL中的单元被实现为半边的循环双向链接列表,其中相邻的半边连接在一起。 因此,上一个和
next
字段指示单元格中的上一个和下一个半边缘。
下图显示了红色半边的所有这些字段:
最后,
Face
类定义了单元格。 它仅包含一个指向其位置的指针,另一个指向其半肋的指针。 选择哪个半边都无所谓,因为该单元格是一个封闭的多边形。 因此,在遍历循环链表时,我们可以访问所有半边。
struct Face { Site* site; HalfEdge* outerComponent; };
事件队列
实现事件队列的标准方法是使用优先级队列。 在处理地点和圈子事件的过程中,我们可能需要从队列中删除圈子事件,因为它们不再有效。 但是大多数标准优先级队列实现不允许您删除不在最前面的项目。 这尤其适用于
std::priority_queue
。
有两种方法可以解决此问题。 第一个更简单的方法是向事件添加
valid
标志。
valid
最初设置为
true
。 然后,不必从队列中删除circle事件,我们只需将其标志设置为
false
。 最后,在处理主循环中的所有事件时,我们检查事件的
valid
标志值是否为
false
,如果是,则直接跳过该事件并处理下一个事件。
我应用的第二种方法是不使用
std::priority_queue
。 相反,我实现了自己的优先级队列,该队列支持删除其中包含的任何元素。 这样的队列的实现非常简单。 我选择此方法是因为它使算法代码更整洁。
海岸线
海岸线数据结构是算法的复杂部分。 如果实现不正确,则不能保证算法将在
。 获得这种时间复杂性的关键是使用自平衡树。 但是说起来容易做起来难!
建议我研究的大多数资源(上文提到的两篇文章和《
计算几何》一书)将海岸线实现为一棵树,其中内部节点表示断点,叶子表示弧形。 但是他们没有说如何平衡一棵树。 我认为这样的模型不是最好的,原因如下:
- 其中包含冗余信息:我们知道两个相邻弧之间有一个断点,因此没有必要将这些点表示为节点
- 它不足以实现自我平衡:只有断点形成的子树才能达到平衡。 我们确实无法平衡整个树,因为否则弧会成为内部节点和断点的叶子。 编写仅平衡内部节点形成的子树的算法对我来说就像一场噩梦。
因此,我决定以不同的方式呈现海岸线。 在我的实现中,海岸线也是一棵树,但是所有节点都是弧形的。 这样的模型没有任何列出的缺点。
这是我的实现中
Arc
arc的定义:
struct Arc { enum class Color{RED, BLACK};
前三个字段用于构造树。
leftHalfEdge
字段指示由弧的最左点绘制的半边。
rightHalfEdge
位于最右点绘制的半边上。 两个指针
prev
和
next
用于直接访问海岸线的上一个弧线和下一个弧线。 此外,它们还允许您绕过海岸线,成为双向链接列表。 最后,每个弧线都有一种用于平衡海岸线的颜色。
为了平衡海岸线,我决定采用
红黑方案 。 编写代码时,我受到《
算法简介 》一书的启发。 第13章介绍了两种有趣的算法,
insertFixup
和
deleteFixup
,它们在插入或删除后平衡树。
但是,我无法使用书中所示的
insert
方法,因为键用于查找节点的插入点。 财富算法中没有键,我们只知道我们需要在海岸线的另一弧之前或之后插入弧。 为了实现这一点,我创建了
insertBefore
和
insertAfter
:
void Beachline::insertBefore(Arc* x, Arc* y) {
通过三个步骤在
x
之前插入
y
:
- 寻找一个插入新节点的地方。 为此,我使用了以下观察:左子项
x
或右子项x->prev
是Nil
,而Nil
的子项在x
之前和x->prev
。 - 在海岸线内部,我们保持双向链表的结构,因此必须相应地更新元素
x->prev
, y
和x
的prev
和next
指针。 - 最后,我们只需调用本书中介绍的
insertFixup
方法来平衡树。
insertAfter
的实现类似。
从书中删除的方法可以直接实施。
图表限制
这是上述财富算法的输出:
图像边界上的单元格的某些边缘存在一个小问题:由于它们是无限的,因此未绘制它们。
更糟糕的是,一个单元可能不是单个片段。 例如,如果我们在同一条线上取三个点,那么中点将具有两个没有连接在一起的无限半边。 这不太适合我们,因为我们将无法访问半边之一,因为该单元格是边的链接列表。
为了解决这些问题,我们将限制图表。 我的意思是,我们将限制图中的每个单元,以使它们不再具有无限的边缘,并且每个单元都是封闭的多边形。
幸运的是,Fortune的算法使我们能够快速找到无尽的边缘:在算法结束时,它们对应于仍在海岸线中的半边缘。
我的限制算法收到一个框作为输入,包括三个步骤:
- 它提供了图的每个顶点在矩形内的位置。
- 切掉每一个无限的边缘。
- 关闭单元格。
第1阶段很简单-如果矩形不包含顶点,我们只需要对其进行扩展即可。
第2阶段也非常简单-它包括计算射线与矩形之间的交点。
第三阶段也不是很复杂,只需要注意即可。 我分两个阶段执行。 首先,我将矩形的角点添加到应位于其顶点处的单元格中。 然后,确保单元的所有顶点都通过半边连接。
我建议您研究代码并询问是否需要有关此部分的详细信息。
这是边界算法的输出图:
现在我们看到所有边缘都被绘制了。 如果缩小,则可以确保所有单元格都已关闭:
矩形相交
太好了! 但是从文章开始的第一张图片更好,对吧?
在许多应用中,使Voronoi图与矩形相交是很有用的,如第一幅图所示。
好消息是,在限制图表之后,这容易得多。 坏消息是,尽管算法不是很复杂,但我们仍需小心。
这个想法是这样的:我们绕过每个像元的半边并检查半边与矩形之间的交点。 可能有五种情况:
- 半肋完全位于矩形内部:我们保存了一个半肋
- 半肋完全位于矩形的外部:我们丢弃了这样一个半肋
- 半肋超出矩形的范围:我们截断半肋并将其保存为最后出现的半肋 。
- 半肋进入矩形内部:我们将半肋截断以将其与最后一个出去的半肋连接起来(在3或5的情况下将其保存)
- 半肋穿过矩形两次:我们截断半肋,并添加一个半肋以使其与最后一个出的半肋关联,然后将其保存为新的最后一个半肋 。
是的,有很多情况。 我创建了一张图片以显示所有图片:
橙色多边形是原始单元格,红色是截断的单元格。 截断的半肋标记为红色。 添加了绿色肋骨,以连接进入矩形的半肋与半肋出来。
将此算法应用于有界图,我们可以得到预期的结果:
结论
原来这篇文章很长。 而且我敢肯定,您仍然不清楚很多方面。 但是,我希望它对您有用。 检查代码以获取详细信息。
总结并确保我们不会白白浪费时间,我在(便宜的)笔记本电脑上测量了计算不同地方的Voronoi图的时间:
- :2毫秒
- :33毫秒
- :450毫秒
- :6600毫秒
我没有什么可与这些指标进行比较,但是看来它是如此之快!
参考文献