算法的故事 / 第一季第二季目录0 / 30 章已读

第二季 · 第 43 章

让一条直线扫过城市

《算法的故事》第二季 · 第三篇:让机器理解空间——本特利、奥特曼与只检查邻居的几何算法1970年代,美国。计算机正在进入一个全新的时代。工程师开始使用计算机设计集成电路。建筑师希望将图纸保存成数字数据。研究人员尝试让计算机处理越来越复杂的线条、图形与空间关系。在一张设计图里,可能存在上千条线段。其中有些彼此平行。有些相互穿过。还有一些距离很远,永远不会发生任何交叉。现在,计算机必须回答一个问题:这张图里,究竟有哪些线段相交?对于十条线段,办法似乎很简单。把它们两两取出来。检查是否相交。找到交点,就记录下来。但是,如果线段数量增加到十万条,事情就完全不同了。它们一共拥有将近五十亿组不同的线段对。即使实际只有三个交点,最直接的算法仍然可能检查大量永远不会相交的线段。于是,一位年轻的计算机科学研究者开始思考:能不能让计算机不再逐一检查所有线段对,而是利用它们在空间中的位置关系,迅速排除那些根本不可能相交的对象?1976年,迈克尔·沙莫斯(Michael Ian Shamos)与丹·霍伊(Dan Hoey)发表了一项重要研究。他们证明:如果只想知道一组线段中有没有任何交点,可以在适当条件下做到O(nlog⁡n)O(n\log n)而不需要使用平方级的全部两两比较。但新的问题随之出现。如果我们不只是想知道“有没有交点”,而是要找出所有交点呢?一旦两条线段相交,它们在空间中的上下顺序就会发生交换。扫描结构必须能够及时察觉这种变化。1979年,乔恩·本特利(Jon Bentley)与托马斯·奥特曼(Thomas Ottmann)进一步解决了这个问题。他们的方法后来被称为:Bentley–Ottmann 算法。其最经典的思想,是让一条想象中的竖直直线,从平面最左侧移动到最右侧。扫描线经过某个位置时,程序只需要维护那些正在与它相交的线段。更惊人的是:在寻找交点时,算法通常只需要关注这些线段在扫描线上相邻的关系。一项原本看起来需要检查全部对象组合的任务,就这样被转化为一连串有序事件。但这段故事还有一个更有趣的起点。在真正发现扫描线思想之前,霍伊曾经拿着彩色笔,在一张透明胶片上画出奇怪的多边形。那幅图,恰好与上一章的沃罗诺伊图有关。因此,我们今天要讲的,并不是一项凭空出现的算法。它诞生于同一群研究者不断探索计算几何的历史之中。

第一幕:十万条线段,一共有多少种可能的相交关系?

约1970年的集成电路版图制作场景。技术人员在透光工作台上处理复杂图形,这种精密几何设计是早期计算几何的重要应用背景之一。先想象一间电子工程实验室。工程师正在设计一块电路板。图纸上有大量连接线路。有些线路彼此相邻。有些线路需要跨越其他连接。但在同一导电层上,如果两条本来应该绝缘的导线错误地接触,就可能产生短路。因此,设计软件需要检测某些几何形状之间是否发生不允许的重叠或相交。真实电路版图当然不只是由零宽度线段构成。导线有宽度。连接可能有不同层。还要考虑绝缘距离和制造公差。但线段相交是一项很有代表性的基础子问题。1979年本特利与奥特曼的原始论文,就明确将印刷电路设计、建筑数据库和计算机图形学列为研究几何相交问题的应用背景。[ITS Engineering]

最直接的方法:全部两两检查

假设存在 nn 条线段。我们先检查:第1条与第2条。第1条与第3条。第1条与第4条。……然后:第2条与第3条。第2条与第4条。……直到全部不同的线段对都被检查一次。一共需要:(n2)=n(n−1)2\binom n2=\frac{n(n-1)}2次相交判定。
图43-1:线段数量与候选检查次数
线段数量 nn不同线段对数量
1045
1004,950
1,000499,500
10,00049,995,000
100,0004,999,950,000
对于十万条线段,将近五十亿组候选。而且,这只是需要比较的组合数量。如果每次判断还需要读取数据、执行几何运算、处理精度问题,实际成本会进一步增加。朴素算法的时间复杂度是:O(n2)\boxed{O(n^2)}现在设想一个极端场景。十万条线段全部水平排列。它们位于不同的高度,彼此完全不相交。真正的交点数量是:k=0k=0但朴素算法仍然需要检查将近五十亿组线段对,才能确认这个事实。这揭示了问题的关键:大量候选对象对,实际上根本没有相交机会。我们需要一种方法,在检查之前,就利用空间位置排除绝大多数无关关系。

三种任务,并不完全一样

假设我们研究一组线段。我们可能提出三种不同问题。
问题需要的答案
相交检测是否存在至少一对相交线段?
交点计数一共有多少对线段相交?
交点报告哪些线段相交?交点在哪里?
它们看起来相似,但计算要求不同。如果只想检测是否存在交点,那么找到第一对相交线段,就可以停止。如果要求报告全部交点,就必须继续寻找剩下的所有交点。假如有:kk组相交线段对,那么将这 kk 组关系逐一输出,本身就至少需要与 kk 成正比的时间。因此,报告全部交点的算法,不能仅仅用 nn 表示它的成本。还应该考虑实际输出规模:kk这就是**输出敏感算法(Output-Sensitive Algorithm)**的重要思想。它不只考虑有多少输入。也考虑真正需要输出多少结果。如果图里几乎没有交点,算法就应该尽量避免浪费计算。如果图里有数百万个交点,那么单是输出这些结果,也必然需要相当可观的工作。1976年,沙莫斯与霍伊解决了重要的检测问题。1979年,本特利与奥特曼则进一步研究计数和报告的问题。[Figshare][数字对象标识符]但这项突破,最初来自一个更简单的观察:如果让一条直线从左向右扫过平面,许多原本复杂的空间关系,就会获得自然的先后顺序。

第二幕:1974年,透明胶片上的奇怪多边形

1970年代大学计算实验室的时代环境。沙莫斯在回忆中提到,当年的耶鲁计算机系使用PDP-10等计算设备,而霍伊经常参与机器的维护。画面不是其研究现场的确证照片。这一幕的故事,有一份非常珍贵的史料。不是后人推测。而是一位主要参与者亲自留下的回忆。1999年前后,迈克尔·沙莫斯发表了一篇历史文章:The Early Years of Computational Geometry — A Personal Memoir《计算几何的早期岁月——个人回忆》。他回顾了1972年至1978年间,计算几何逐渐发展为一个研究领域的经历。文章里,保留了许多单凭正式论文很难了解的日常细节。[mathcs.holycross.edu]

一个喜欢摆弄计算机的学生

1973年春天,沙莫斯认识了霍伊。当时,霍伊还是耶鲁大学的计算机科学本科生。按照沙莫斯的回忆,霍伊经常待在机器房,帮助维护PDP-10计算机与一台排版设备。他喜欢数学谜题与游戏。对几何问题也很有兴趣。沙莫斯描述他具有深刻的计算直觉,但不太热衷于把想法写成长篇正式论文。[mathcs.holycross.edu]两人逐渐成为研究伙伴。他们经常讨论图形、距离、空间结构,以及怎样让计算机更高效地处理这些问题。

彩色笔画出的最近区域

1974年春天的一天,沙莫斯走进两人共同使用的办公室。他看见霍伊正拿着彩色记号笔,在透明胶片上画出一些奇怪的区域。霍伊正在研究:给定平面上一组点,怎样为每个点分配一个区域,使区域内的位置都距离这个点最近?他当时为这些形状取了一个名字:Proximal Polygons,可以译作“邻近多边形”。沙莫斯看见这些图形,觉得它们非常眼熟。他想到自己在固体物理中接触过的 Wigner–Seitz 单元。于是,两人开始查找相关数学文献。后来,他们发现了沃罗诺伊1908年的工作。沙莫斯认为,应当在名称中保留这位早期数学家的名字。“Voronoi Diagram”这个称呼,便在他们的研究中得到推广。[mathcs.holycross.edu]这就是上一章与这一章之间,真实存在的一段历史连接。霍伊不仅参与了线段相交算法的研究。还曾独立重新发现与沃罗诺伊区域相同的几何结构。

一项令人恼火的多边形问题

到了1975年前后,沙莫斯一直在思考另一个问题。给定一个多边形,怎样判断它是否发生自相交?想象一条由多段线段组成的封闭轮廓。如果它是一条简单多边形的边界,那么除了相邻边共享的端点以外,不应该出现其他交叉。但如果某两段非相邻边彼此穿过,多边形就会发生自相交。朴素方法当然能够解决。逐一检查全部边对即可。可是,它需要平方级的计算工作。这让沙莫斯很不满意。于是,他把问题交给了霍伊。根据沙莫斯的回忆,经过大约一周的深入思考,霍伊找到了一项非常漂亮的观察。他没有继续研究某个特定多边形。而是将问题推广成:给定一组任意线段,怎样判断其中是否存在相交?然后,他发现:不必一次性考虑整个平面。可以只考虑一条竖直扫描线与各条线段的关系。[mathcs.holycross.edu]这就是后来成为经典的平面扫描思想。

一条直线,让空间里的对象变得有序

想象一条竖直线:x=tx=t从左向右移动。当它位于某个位置时,有些线段会与它相交。有些线段还没有开始。另一些线段已经结束。对于当前与扫描线相交的线段,我们可以按照交点的纵坐标,从低到高排列。于是,我们得到一个明确的顺序。例如:D<A<C<BD<A<C<B这里表示:在当前扫描线位置,D最低,B最高。
图43-2:扫描线上的线段拥有上下顺序
x = t上下关系随扫描位置改变
查看 SVG 配图原件
示意图原件 43-004-e650f1295d51.svg
43-004-e650f1295d51.svg · 点击查看大图
概念示意:扫描线只与当前横向范围覆盖它的线段相交。真正的上下顺序应根据各线段在当前x位置的纵坐标计算。
这个顺序非常有用。因为两条线段如果想在扫描线继续移动的过程中相交,那么它们的上下关系就必须发生变化。而两个元素要想在一个连续变化的有序序列里交换位置,就必须先成为邻居。这听起来像一个普通的排序问题。却突然将整个几何相交问题,转化成了一项有序数据结构的维护问题。霍伊意识到:只要正确维护扫描线上的上下顺序,就不需要检查所有线段对。在发现相交之前,算法只需要检查那些刚刚成为相邻关系的线段。

从霍伊的直觉,到沙莫斯的证明

按照沙莫斯的回忆,霍伊提出了维护扫描顺序的关键想法。而沙莫斯承担了数据结构选型与正确性证明等工作。当时,AVL树和2-3树等平衡搜索树,仍然属于比较新的数据结构。他们选择尝试适当的平衡树来维护线段顺序。算法运行得很快。但要证明它正确,还需要认真处理几何关系与退化情况。最终,两人于1976年在第17届 IEEE 计算机科学基础年会发表:Geometric Intersection Problems《几何相交问题》。论文第208—215页,给出了在线段相交检测等问题上的重要复杂度结果。[迈克尔·沙莫斯主页][researchr publication bibtex]这里,一项科学研究从直觉走向了算法。但它还没有走完最后一步。因为霍伊和沙莫斯首先解决的,是:是否存在至少一个交点?如果我们已经找到第一处相交,算法就可以停止。可现实中的电路图和地图,往往需要找出全部交点。而这会带来一个新的困难。当两条线段真正相交之后,它们在扫描线上的上下顺序会交换。如果程序还使用原来的顺序,后续交点就可能被漏掉。这个问题,将在1979年由本特利与奥特曼进一步解决。

第三幕:1979年,两位研究者让扫描线继续向前

乔恩·本特利(Jon L. Bentley)后来以《Programming Pearls》(《编程珠玑》)闻名。1979年发表相交算法研究时,他任职于卡内基梅隆大学计算机科学及数学系。如果说1976年的研究,教会计算机如何快速发现第一处交点,那么1979年的研究,就让计算机学会了继续寻找。两位研究者分别是:乔恩·路易斯·本特利。以及:托马斯·奥特曼。本特利当时在卡内基梅隆大学工作。奥特曼任职于德国卡尔斯鲁厄大学。根据奥特曼后来由弗赖堡大学保存的履历,他1943年出生于马格德堡,1962—1969年在明斯特大学学习数学、物理和数理逻辑,1971年取得博士学位。1975年,他在卡尔斯鲁厄大学获得计算机科学领域的任教资格,并于1976年成为该校教授。[algo.informatik.uni-freiburg.de]两人的研究兴趣中,有一个重要的共同点:怎样用高效的数据结构,让复杂的算法不必反复处理无关对象?

1978年8月21日,一篇论文被送交期刊

本特利与奥特曼1979年论文首页。原始文献标注1978年8月21日收稿、1979年2月21日修订,发表于1979年9月的《IEEE Transactions on Computers》第643—647页。[ITS Engineering]论文题目是:Algorithms for Reporting and Counting Geometric Intersections《几何交点报告与计数算法》。原始论文首页直接说明,它是在 Shamos–Hoey 研究基础上的扩展。作者希望处理的不再只是:有没有两条线段相交?而是:哪些线段相交?一共有多少组相交关系?论文还讨论了当全部线段水平或竖直时,可以使用的专门方法。[数字对象标识符]这不是一个小小的措辞变化。“检测”与“列出全部结果”,在算法设计上可能具有完全不同的难度。

找到第一个交点之后,为什么不能继续使用原来的顺序?

考虑两条线段:A:(1,1)→(7,7)A:(1,1)\rightarrow(7,7)B:(1,7)→(7,1)B:(1,7)\rightarrow(7,1)它们相交于:P=(4,4)P=(4,4)如果扫描线位于:x=3x=3那么:yA=3y_A=3yB=5y_B=5此时,A在下面,B在上面。但是当扫描线到达:x=5x=5时:yA=5y_A=5yB=3y_B=3顺序已经反过来了。
图43-3:两条线段在交点前后交换顺序
BA交点之前从下到上:A、B
AB交点之后从下到上:B、A
如果我们只处理线段的左右端点,就可能错过这种排序变化。因为两条线段交叉时,并没有哪条线段开始。也没有哪条线段结束。但它们的相对顺序确实改变了。因此:交点本身,也必须成为一次事件。这正是 Bentley–Ottmann 相对于基本相交检测扫描过程的重要扩展。当扫描线抵达一处交点时:先报告交点。然后交换两条相交线段在扫描状态中的顺序。再检查由于交换而出现的新邻居关系。如果这些新邻居会在更右侧相交,就把未来交点加入事件队列。于是,算法能够继续前进。不是发现第一处交点就停止。而是让每一处交点,成为继续发现其他交点的线索。

一个有趣的卡内基梅隆研究生活片段

本特利的合作研究者沙莫斯,后来在回忆文章中还记下了一段生活细节。在卡内基梅隆大学共事期间,他与本特利白天教学、研究。晚上大约11点半,又会在沙莫斯家里见面。他们观看电视剧《Kojak》的重播,讨论研究问题。沙莫斯甚至开玩笑地把自家的狗 Nassau 称为世界上最好的犬类计算几何学家。他还回忆,本特利很喜欢将搜索算法与侦探寻找线索的过程作比较。[mathcs.holycross.edu]这段轶事有一手回忆支持。但不能据此编造“Bentley–Ottmann 算法就是在某一晚观看《Kojak》时突然想到的”。没有史料证明这一具体灵感场景。它真正展现的是1970年代算法研究者的日常生活。他们面对新兴的计算几何问题。讨论怎样减少计算。设计新的数据结构。并将看似杂乱的空间关系,整理成可以证明正确的算法。现在,我们需要理解其核心原理。为什么只检查相邻线段,就足以发现全部交点?

第四幕:算法最重要的秘密——两条线段相交之前,必然成为邻居

扫描线算法维护当前线段由下到上的顺序。即将发生相交的线段,在一般位置条件下会在交点到来之前成为相邻对象。我们先想象三条正在与扫描线相交的线段。在某个位置:x=tx=t它们由下至上排列为:A<B<CA<B<C其中,A最低。B居中。C最高。假设A与C将在更右边的某个位置相交。这时,有一个非常重要的问题。A与C之间,现在还隔着B。它们能否在B完全不参与变化的情况下,突然交换上下顺序?不能。为什么?因为随着扫描线连续移动,每一条非竖直线段与扫描线的交点高度也会连续变化。如果A原本在B下面,而之后要移动到B上面,那么它必定先与B发生相交。或者,如果B先移动到C上面,那么B也必定先与C相交。总之,在A与C直接改变相对顺序之前,当前上下关系中必定发生了某种相邻关系的改变。这就是扫描线算法能够节省大量比较的原因。

一个更精确的表述

在排除端点重合、三线共点、垂直线段等退化情况后:如果两条线段在某个未来位置相交,那么在交点到来之前的某个事件处理完成时,它们一定会成为扫描状态中的相邻线段。当它们成为相邻对象时,算法就有机会检查它们是否会在右侧相交,并安排未来的交点事件。所以:不需要现在就检查所有不相邻的线段对。只需要在邻接关系发生变化时,检查新出现的邻居。这就是局部检查能够获得全局完整结果的关键。原始论文正是用这个几何事实,将 Shamos–Hoey 的检测算法扩展为交点报告算法。[ITS Engineering]

那么,什么叫作“活动线段”?

假设一条线段从:(x1,y1)(x_1,y_1)延伸到:(x2,y2)(x_2,y_2)并且:x1<x2x_1<x_2当扫描线位于:x1≤t≤x2x_1\le t\le x_2时,它会经过这条线段。于是,这条线段就处于活动状态。如果:t<x1t<x_1扫描线还没有遇到它。如果:t>x2t>x_2扫描线已经越过了它。因此,算法只需要维护那些横向范围覆盖当前扫描位置的线段。

怎样计算一条活动线段当前的高度?

设线段端点是:P=(x1,y1)P=(x_1,y_1)Q=(x2,y2)Q=(x_2,y_2)且 x1≠x2x_1\ne x_2。它与竖直扫描线 x=tx=t 相交处的纵坐标为:y(t)=y1+t−x1x2−x1(y2−y1)\boxed{ y(t)=y_1+ \frac{t-x_1}{x_2-x_1}(y_2-y_1) }这只是直线的线性插值公式。例如,一条线段从:(1,2)(1,2)到:(9,6)(9,6)当扫描线移动到:x=5x=5时:y(5)=2+5−19−1(6−2)y(5)=2+\frac{5-1}{9-1}(6-2)所以:y(5)=4y(5)=4我们得到一个当前高度。对全部活动线段执行同样计算,就能够确定它们在扫描线上的上下顺序。

为什么不能每移动一点距离,就把全部活动线段重新排序?

这是一项很自然的实现想法。每次扫描线移动一点。重新计算所有活动线段的高度。然后重新排序。可是,这样可能产生大量重复计算。特别是当扫描线移动了一小段,但没有任何线段开始、结束,也没有任何交点发生时,活动线段之间的顺序其实完全不会改变。因此,不应该按照固定的微小距离不断扫描。应该只在真正可能改变状态的位置停下来。哪些位置会改变状态?恰好有三类:线段左端点。线段交点。线段右端点。这便将一个连续移动过程,变成了有限事件的处理过程。

第五幕:一条扫描线,为什么需要两种数据结构?

扫描线算法需要两项不同能力:按照时间或横坐标取出下一事件,以及动态维护活动线段的上下顺序。两项任务分别适合优先队列和平衡有序结构。现在,我们终于可以像1979年的研究者那样,设计整个算法。虽然扫描线是一条几何直线,但实际程序不需要在屏幕上逐像素移动它。程序只需要按正确顺序处理事件。

第一种数据结构:事件优先队列

我们需要知道:接下来最先发生的事件是哪一个?最初,我们已经知道全部线段的左右端点。因此,可以将这些端点加入一个按横坐标排列的优先队列。每次取出横坐标最小的事件。处理完成后,再继续处理下一个事件。但还有一个重要细节。交点事件并不是一开始就全部已知的。如果我们一开始就计算所有线段对的交点,再把它们放入队列,那么其实已经执行了朴素的两两比较。这就失去了扫描线算法的意义。真正的 Bentley–Ottmann 算法,是在扫描过程中,发现某两条新相邻的线段未来可能相交时,才生成对应的交点事件。因此,事件队列不仅包含最初已知的端点。还会随着扫描过程,不断加入新发现的交点。
图43-4:不断变化的事件队列
1.0左端点
2.2左端点
3.1左端点
4.6新交点
7.4右端点
概念示意:事件按扫描方向排列。“新交点”是检查新邻居时发现并加入队列的,并非最初通过全部两两比较得到。
对于大规模数据,事件优先队列可以使用二叉堆或其他适当的数据结构。插入事件、取出下一事件,一般能够在:O(log⁡n)O(\log n)或相应队列规模的对数时间内完成。

第二种数据结构:扫描状态

事件队列告诉我们:接下来要处理什么?但它并不告诉我们:当前有哪些线段正在与扫描线相交?它们从下到上是什么顺序?这需要第二种数据结构。我们称它为:扫描状态(Sweep-Line Status)。假设当前活动线段顺序为:[D,A,C,B][D,A,C,B]它不仅保存这些线段的身份,还必须支持:插入一条新线段。删除一条已经结束的线段。寻找某条线段的上方邻居。寻找它的下方邻居。在交点事件发生时,维护相交线段交换之后的顺序。这些操作适合使用平衡搜索树等动态有序结构。例如:AVL树。红黑树。或其他能够保证对数时间更新的平衡树。

三类事件,分别怎样处理?

图43-5:Bentley–Ottmann 的事件处理规则
左端点事件:加入线段新线段进入活动集合。找到它在当前上下顺序里的位置,并检查它与上、下两个新邻居是否会在未来相交。

交点事件:报告并交换顺序报告相交关系。更新两条线段的上下次序,然后只检查由于顺序交换而新出现的相邻关系。

右端点事件:移除线段线段离开活动集合。它原来上方和下方的邻居可能因此变为相邻,需要检查这一对新邻居的未来交点。

一个需要认真处理的实现细节:过期交点事件

假设当前活动顺序为:A<BA<B算法发现,它们将在更右侧相交。于是,把这处交点放入事件队列。但在扫描线真正到达那里之前,可能有第三条线段C开始进入,改变了活动顺序。这时,之前安排的某些候选事件,可能不再代表当前有效的邻接关系。如果实现使用可以删除指定事件的优先队列,就可以在邻接关系消失时取消相应的候选事件。如果采用普通堆,也可以使用**惰性失效(Lazy Invalidation)**策略:让事件暂时留在堆里。等它真正被取出时,再检查事件是否依然有效。如果已经失效,就跳过。但这样必须确保事件数量和去重机制受到正确控制。不能随意不断重复插入同一个候选交点。否则,程序可能在逻辑上正确,却因为无效事件太多而失去预期复杂度。这也是为什么教材级扫描线思路,真正实现成工业级几何库时,会比图上的三条规则复杂得多。

第六幕:扫描线碰到交点时,计算机究竟算了什么?

在线段相交计算中,需要同时判断两条所在直线是否相交,以及交点是否位于两条有限线段的范围内。两条无限直线相交,并不意味着对应线段一定相交。前面,我们一直在讨论算法如何避免无意义的比较。但是,当算法真正决定检查两条相邻线段时,仍然需要回答一个基础问题:这两条线段到底有没有交点?如果有,交点的坐标是多少?

第一步:把线段写成参数方程

设第一条线段从A到B。第二条线段从C到D。我们可以将第一条线段上的点写成:P(t)=A+t(B−A)P(t)=A+t(B-A)其中:0≤t≤10\le t\le1当:t=0t=0时,点位于A。当:t=1t=1时,点位于B。当 0<t<10<t<1 时,点位于线段内部。同理,第二条线段上的点可以写成:Q(u)=C+u(D−C)Q(u)=C+u(D-C)其中:0≤u≤10\le u\le1如果两条线段有公共点,那么存在某组参数:t,ut,\quad u使得:A+t(B−A)=C+u(D−C)\boxed{A+t(B-A)=C+u(D-C)}这就是相交条件。

第二步:利用二维叉积求出参数

记:r=B−Ar=B-As=D−Cs=D-C并定义二维向量叉积:cross⁡(a,b)=axby−aybx\operatorname{cross}(a,b)=a_xb_y-a_yb_x令:Δ=cross⁡(r,s)\Delta=\operatorname{cross}(r,s)如果:Δ≠0\Delta\ne0那么两条所在直线不平行,具有唯一交点。通过参数方程可以得到:t=cross⁡(C−A,s)cross⁡(r,s)\boxed{ t=\frac{\operatorname{cross}(C-A,s)} {\operatorname{cross}(r,s)} }以及:u=cross⁡(C−A,r)cross⁡(r,s)\boxed{ u=\frac{\operatorname{cross}(C-A,r)} {\operatorname{cross}(r,s)} }如果:0≤t≤10\le t\le1并且:0≤u≤10\le u\le1那么这个交点同时位于两条有限线段上。否则,两条无限直线虽然相交,但对应的有限线段并不相交。

第三步:亲手计算一个交点

考虑:A=(1,1),B=(7,7)A=(1,1),\quad B=(7,7)C=(1,7),D=(7,1)C=(1,7),\quad D=(7,1)则:r=(6,6)r=(6,6)s=(6,−6)s=(6,-6)所以:Δ=6(−6)−6(6)=−72\Delta=6(-6)-6(6)=-72而:C−A=(0,6)C-A=(0,6)因此:t=0(−6)−6(6)−72=12t=\frac{0(-6)-6(6)}{-72} =\frac12同样得到:u=12u=\frac12所以交点位于两条线段的中点:P=A+12(B−A)P=A+\frac12(B-A)即:P=(4,4)\boxed{P=(4,4)}这项计算也能帮助我们理解为什么交点事件必须拥有明确的位置。扫描线不是在猜测两条线段可能在哪里相交。它通过代数计算,得到交点的横坐标:xP=4x_P=4然后,安排在扫描线到达这个横坐标时处理该事件。

一个无法省略的特殊情况

如果:Δ=0\Delta=0那么两条线段的方向向量平行。它们可能:彼此平行且不共线。位于同一直线上但互不接触。只在某个端点接触。或者在一段连续区间上重叠。所以,不能简单地把:
python
if denominator == 0:
    return None
解释成“两条线段一定不相交”。对于我们接下来的经典扫描线演示,将采用一种更简单的一般位置假设:所有示例线段都非竖直。不存在共线重叠。不同线段的端点不重合。不存在三条线段在同一点相交。示例中各关键事件的横坐标也彼此不同。这样,算法就能够集中展示最重要的事件逻辑。对于真正的工业系统,这些限制显然不够。我们会在后面专门讨论如何处理退化情况。

第七幕:从平方级检查,到输出敏感的时间复杂度

密集电路版图中的几何关系说明了为什么需要高效的相交检测。实际版图验证还包含层、线宽、间距与制造规则等复杂条件,不能仅靠零宽度线段相交判断完成。到这里,我们已经能够描述完整的 Bentley–Ottmann 过程。先将全部端点事件加入优先队列。然后,从左向右逐一处理事件。每次只更新扫描状态里的局部邻居。如果发现未来交点,就将它安排到事件队列。如果扫描线到达交点,就报告结果,并更新线段的上下顺序。问题是:这套方法究竟节省了多少计算?

从事件数量开始分析

假设有:nn条线段。每条线段有两个端点。所以端点事件数量为:2n2n再设不同的相交线段对数量为:kk在一般位置且每对线段最多有一个交点的模型下,需要报告的交点事件数量为:kk因此,真正需要处理的有效事件总量约为:2n+k2n+k

每次事件处理花多少钱?

如果事件队列采用合适的优先队列,扫描状态采用适当的平衡搜索树,那么:插入事件。删除或取出事件。寻找当前上下邻居。维护活动线段顺序。这些操作通常都能在:O(log⁡n)O(\log n)量级的时间内完成。在正确管理事件、避免无界重复安排的前提下,每次有效事件只引起常数数量的局部邻接检查。因此,总时间为:O(nlog⁡n)+O(klog⁡n)O(n\log n)+O(k\log n)也就是:O((n+k)log⁡n)\boxed{O((n+k)\log n)}这正是1979年 Bentley–Ottmann 论文的经典复杂度结果。[ITS Engineering][people.scs.carleton.ca]

为什么这比平方算法好?

假设:n=100000n=100000但实际仅存在:k=10k=10组相交关系。朴素算法需要考虑:n(n−1)2\frac{n(n-1)}2约五十亿组候选。而扫描线算法的理论工作量,则与:(n+k)log⁡n(n+k)\log n成正比。这里不是在宣称某台计算机实际只需要某个固定数量的CPU指令。大O记号忽略常数因子、数据结构开销与机器环境。但它告诉我们:对于相交关系稀疏的大规模输入,扫描线可以避免大量无关比较。

如果所有线段彼此相交呢?

这时:k=Θ(n2)k=\Theta(n^2)如果要求报告全部相交线段对,那么输出本身就具有平方规模。因此,任何完整报告算法都无法避免:Ω(k)\Omega(k)的输出成本。Bentley–Ottmann 在这种最坏情况下,时间可能达到:O(n2log⁡n)O(n^2\log n)的量级。所以不能说扫描线让全部相交问题都变成了接近线性时间。它真正的价值,是让工作量随着输入大小和实际交点数量变化,而不是无条件检查全部线段对。

时间复杂度,还能继续改进吗?

可以。1979年的论文并不是线段相交报告复杂度研究的终点。后来,研究者继续探索怎样消除输出规模前面额外的对数因子。1988年,Bernard Chazelle 与 Herbert Edelsbrunner 发展了更复杂的算法,在相应计算模型和假设下,达到:O(nlog⁡n+k)\boxed{O(n\log n+k)}的时间界。这在渐近意义上达到了该问题的重要最优复杂度目标。[塔夫茨大学计算机科学系][RPI Computer Science]不过,这种理论上的改进,并不代表每一个现实项目都应该抛弃 Bentley–Ottmann。后来的最优复杂度算法可能拥有更高的实现复杂性或常数开销。而 Bentley–Ottmann 具有相对清晰的结构,也容易与事件队列、平衡搜索树和其他已有数据结构组合。因此,它仍然是计算几何教材中的经典算法。

空间复杂度也必须认真区分

如果程序要把全部 kk 个交点保存下来,那么仅保存输出,就需要:O(k)O(k)空间。如果只要求边找到交点边输出,而不把全部结果保存在内存里,则可以利用合适的事件管理方式,使扫描结构的工作空间保持在线性规模。一些后续研究改进了原始算法在事件存储方面的实现。因此,讨论空间复杂度时,必须说明:我们是在计算扫描过程本身的工作空间?还是把全部输出结果占用的内存也计算进去?这两种口径并不相同。

还有一类特殊情况:水平线与竖直线

本特利与奥特曼1979年的论文,还研究了所有线段都水平或竖直的特殊场景。这类问题在一些电路版图与正交地图数据中十分常见。对于水平线与竖直线的交点报告问题,可以采用更加专门的扫描思路。例如,从左向右扫描时:水平线段的左端点意味着在某个固定的 yy 值上加入一条活动线段。右端点意味着将它删除。当遇到一条竖直线段时,查询当前活动水平线段中:ymin⁡≤y≤ymax⁡y_{\min}\le y\le y_{\max}的那些线段即可。这就将二维相交报告,部分转化成了对活动纵坐标的一维范围查询。在适当假设与数据结构下,可以达到:O(nlog⁡n+k)O(n\log n+k)的输出敏感复杂度。但这项特别高效的结构依赖全部线段水平或竖直的条件,不能无条件推广到任意斜线。这正是算法设计中的一个重要原则:当输入具有特殊结构时,我们往往可以利用这种结构,获得比一般算法更简单或更高效的求解方式。

第八幕:互动实验室——让扫描线真正动起来

扫描线算法的核心过程:按横坐标处理线段开始、线段结束及交点事件,并在局部邻居改变时安排未来的交点。现在,我们将刚才的算法变成一次可以逐步操作的实验。下面的地图里,有四条线段:A、B、C、D。每一条线段由两个端点确定。橙色竖线代表扫描线。扫描线从左向右移动。每经过一个事件位置,程序就会更新活动线段的上下顺序。你可以选择两种案例。第一种包含多次相交。第二种完全没有交点。特别值得注意的是第一种案例。开始时,有些线段彼此并不相邻。但随着先前的交点被处理,上下顺序发生交换,新的相邻关系逐渐出现。正是这些新邻居,让算法能够发现后续交点。

实验一:逐事件运行 Bentley–Ottmann

图43-6:扫描线事件实验室这个教学模拟器实际按事件推进,只对新邻居执行候选交点检查;它使用小规模数组维护状态,不冒充平衡树实现的理论复杂度。
0 / 13
ABCDx=0.00横轴是扫描方向;纵轴向上为正
查看 SVG 配图原件
示意图原件 43-014-60eccf4adfed.svg
43-014-60eccf4adfed.svg · 点击查看大图
线段 A
线段 B
线段 C
线段 D
已报告交点
当前事件准备开始
所有端点事件已经加入队列;还没有活动线段。本次没有发现需要新安排的交点事件。
当前活动线段:从下到上
当前没有活动线段
注意观察:交点事件发生之后,局部上下顺序会交换。
已报告交点

0

待处理事件

8

线段对总数

6

实验观察:五个交点是怎样依次出现的?

在“四条相交线段”案例中,按照横坐标由小到大,应该依次报告:
交点大致横坐标
A × C4.086
A × B4.977
B × C5.802
B × D5.915
C × D5.996
请特别观察:在第一个交点A×C发生之后,A与C交换了上下位置。原来不相邻的A、B,随之成为邻居。程序于是能够安排A×B的未来交点。后续的交点又会触发新的顺序交换。每一次事件,既是一个结果,也可能成为发现其他结果的机会。这就是事件驱动扫描真正的力量。

为什么要提供“完全不相交”案例?

因为它与第一种案例形成鲜明对比。四条线段共有:(42)=6\binom42=6组可能的线段对。但在不相交案例里,真实交点数量:k=0k=0扫描线只需要处理端点事件,并维护活动线段的上下关系。不会出现交点事件。当输入规模变大,而交点依然很少时,这种输出敏感思想的价值就更为明显。**互动实验的实现说明:**这里使用真实的事件队列逻辑,只对新相邻的线段进行候选相交计算;为了方便观察,活动集合和事件队列用 JavaScript 数组维护。它准确演示一般位置下的事件过程,但不具备专业平衡树实现的 O((n+k)log⁡n)O((n+k)\log n) 运行时间保证。演示中也刻意排除了重叠线段、共点和竖直线段等特殊情况。

第九幕:把1979年的思想写成一段 Python

早期计算机图形系统依赖程序对大量几何对象进行计算。扫描线算法通过维护事件与活动对象的顺序,避免持续重算全部空间关系。现在,我们已经理解:事件队列负责决定下一个需要处理的位置。扫描状态负责维护当前活动线段的上下顺序。相邻检查负责发现未来交点。那么,怎样把这些规则写成程序?这一节我们先实现一个教学版事件驱动扫描算法。它使用 Python 标准库。可以输出前面实验中的五个交点。但是,我们要特别区分:这份程序用于解释事件处理机制。它使用普通列表维护活动线段,并在事件发生时重新排序。因此,它不能获得原始 Bentley–Ottmann 算法的全部复杂度保证。如果希望达到严格的:O((n+k)log⁡n)O((n+k)\log n)就需要进一步采用能够高效维护动态上下顺序的平衡搜索结构,并正确处理候选交点事件的管理与失效。

第一步:写出线段相交函数

python

def cross(a, b):
    return a[0] * b[1] - a[1] * b[0]


def proper_intersection(seg1, seg2):
    """
    返回两条线段的严格内部交点。

    为突出扫描线算法机制,此函数只处理
    不平行、没有端点接触的普通相交情况。

    seg1, seg2: ((x1, y1), (x2, y2))
    """

    a, b = seg1
    c, d = seg2

    r = (b[0] - a[0], b[1] - a[1])
    s = (d[0] - c[0], d[1] - c[1])
    q = (c[0] - a[0], c[1] - a[1])

    denominator = cross(r, s)

    if abs(denominator) < 1e-12:
        return None

    t = cross(q, s) / denominator
    u = cross(q, r) / denominator

    eps = 1e-10

    if not (eps < t < 1-eps):
        return None

    if not (eps < u < 1-eps):
        return None

    return (
        a[0] + t * r[0],
        a[1] + t * r[1]
    )
这里使用了非常小的数值阈值,避免教学示例中的浮点运算噪声影响判断。但它不是适用于任意坐标尺度的稳健几何谓词。对于工业级几何系统,应当采用更加可靠的精确谓词或适合具体坐标模型的数值方法。

第二步:建立事件驱动扫描器

下面的完整程序可以独立执行。它将:左右端点加入最小堆。维护活动线段。只检查新形成的相邻线段对。为未来的交点安排事件。在交点事件发生时更新顺序。最后返回全部发现的交点。
python

from heapq import heappush, heappop
from itertools import count


def cross(a, b):
    return a[0] * b[1] - a[1] * b[0]


def proper_intersection(seg1, seg2):
    a, b = seg1
    c, d = seg2

    r = (b[0]-a[0], b[1]-a[1])
    s = (d[0]-c[0], d[1]-c[1])
    q = (c[0]-a[0], c[1]-a[1])

    den = cross(r, s)

    if abs(den) < 1e-12:
        return None

    t = cross(q, s) / den
    u = cross(q, r) / den

    if not (1e-10 < t < 1-1e-10):
        return None

    if not (1e-10 < u < 1-1e-10):
        return None

    return (
        a[0] + t*r[0],
        a[1] + t*r[1]
    )


def sweep_intersections(segments):
    """
    教学版事件驱动扫描线算法。

    输入:
      {id: ((x1,y1), (x2,y2)), ...}

    要求:
      非竖直线段;
      没有端点接触或共线重叠;
      没有三线共点;
      关键事件具有不同的x坐标。

    使用普通列表维护活动状态,
    不具备生产级Bentley-Ottmann时间界。
    """

    # 规范端点方向:从左至右
    S = {}

    for name, (a, b) in segments.items():
        if a[0] == b[0]:
            raise ValueError("教学版不支持竖直线段")

        S[name] = (
            (a, b) if a[0] < b[0] else (b, a)
        )

    events = []
    serial = count()

    active = []
    scheduled = set()
    reported = []

    def add_event(x, kind, a, b=None):
        # kind: 0左端点, 1交点, 2右端点
        heappush(
            events,
            (x, kind, next(serial), a, b)
        )

    def y_at(name, x):
        (x1, y1), (x2, y2) = S[name]

        return (
            y1 + (x-x1)*(y2-y1)/(x2-x1)
        )

    def arrange(a, b, current_x):
        """检查一对新相邻线段的未来交点。"""

        if a is None or b is None:
            return

        key = tuple(sorted((a, b)))

        if key in scheduled:
            return

        point = proper_intersection(
            S[a], S[b]
        )

        if point is None:
            return

        if point[0] <= current_x + 1e-8:
            return

        scheduled.add(key)
        add_event(point[0], 1, a, b)

    def reorder(x):
        # 教学简化:整个活动列表重新排序。
        active.sort(
            key=lambda name: y_at(name, x+1e-6)
        )

    for name, (left, right) in S.items():
        add_event(left[0], 0, name)
        add_event(right[0], 2, name)

    while events:
        x, kind, _, a, b = heappop(events)

        if kind == 0:
            # 左端点:插入
            active.append(a)
            reorder(x)

            i = active.index(a)

            if i > 0:
                arrange(active[i-1], a, x)

            if i+1 < len(active):
                arrange(a, active[i+1], x)

        elif kind == 2:
            # 右端点:删除
            i = active.index(a)

            lower = (
                active[i-1] if i > 0 else None
            )

            upper = (
                active[i+1]
                if i+1 < len(active)
                else None
            )

            active.pop(i)
            arrange(lower, upper, x)

        else:
            # 交点:只有当前相邻才处理
            i = active.index(a)
            j = active.index(b)

            if abs(i-j) != 1:
                continue

            point = proper_intersection(
                S[a], S[b]
            )

            reported.append((a, b, point))

            # 扫描线越过交点后,上下顺序改变
            reorder(x)

            # 只检查两个参与交换的线段的邻居
            for name in (a, b):
                i = active.index(name)

                if i > 0:
                    arrange(
                        active[i-1], name, x
                    )

                if i+1 < len(active):
                    arrange(
                        name, active[i+1], x
                    )

    return reported


segments = {
    "A": ((0.8, 1.5), (9.0, 8.4)),
    "B": ((1.2, 8.2), (9.5, 1.2)),
    "C": ((2.0, 4.2), (8.4, 4.4)),
    "D": ((3.2, 0.8), (7.8, 6.6)),
}

results = sweep_intersections(segments)

for a, b, point in results:
    print(
        a, b,
        tuple(round(v, 3) for v in point)
    )

print("总交点数量:", len(results))
程序应得到五个相交关系:
text
A C (4.086, 4.265)
A B (4.977, 5.015)
B C (5.802, 4.319)
B D (5.915, 4.223)
C D (5.996, 4.325)
总交点数量: 5
这些结果与前面的互动实验一致。

这份代码为什么不是严格的最优实现?

请观察其中的一行:
python
active.sort(
    key=lambda name: y_at(name, x+1e-6)
)
每处理一次某种事件,我们可能重新排序全部活动线段。如果当前有:nn条活动线段,那么单次排序可能需要:O(nlog⁡n)O(n\log n)时间。而原始 Bentley–Ottmann 算法采用动态有序结构,可以在局部变化发生时,以对数时间完成相应的顺序维护。因此,教学版虽然完整展示了事件机制,却不能把它的运行时间直接写成:O((n+k)log⁡n)O((n+k)\log n)它还使用了一个固定的:10−610^{-6}横向偏移来观察交点之后的顺序。这只适用于我们预先设计、事件间隔充分的教学案例。如果事件非常接近,固定偏移可能越过其他尚未处理的事件,从而造成错误。专业实现应该使用严谨的事件顺序规则、精确的几何谓词,以及能够正确处理交点两侧顺序的比较策略。所以,这段代码有两个目的:第一,让你理解扫描线真正怎样运行。第二,让你意识到,从一个算法思想到具备完整正确性与复杂度保证的实现,还存在大量严肃的工程工作。

第三步:使用穷举法做交叉验证

对于四条线段,穷举检查只有:66组候选。完全可以拿它验证教学版的结果。
python

from itertools import combinations


def brute_force(segments):
    result = []

    for (a, sa), (b, sb) in combinations(
        segments.items(), 2
    ):
        point = proper_intersection(sa, sb)

        if point is not None:
            result.append((a, b, point))

    return result


brute = brute_force(segments)

actual_pairs = {
    tuple(sorted((a, b)))
    for a, b, _ in results
}

expected_pairs = {
    tuple(sorted((a, b)))
    for a, b, _ in brute
}

assert actual_pairs == expected_pairs

print("扫描线结果与穷举结果一致")
这是一个非常值得养成的程序开发习惯。我们可以用简单但较慢的算法,验证复杂但较快的算法。对于小规模随机案例:穷举法容易确认正确性。优化算法则用来处理大规模数据。在编程竞赛中,这种方法常被称为“对拍”。在软件工程里,它也是差分测试的一种常见形式。当然,随机测试只有在输入满足教学程序的假设时,才适合作为上述扫描器的有效验证。如果输入包含端点接触、共线重叠或多线共点,那么必须使用具备相应处理能力的完整实现。

第十幕:一处交点、四条线段——真实几何数据没有那么听话

退化几何输入示意:线段可以拥有共同端点、部分重叠或在同一位置集中相交。处理这类数据,需要更严格的事件分组与数值计算规则。前面的教学程序能够处理预先设计的普通线段。但真实世界往往没有这么整齐。一张CAD图纸里,某些线段本来就应该拥有共同端点。道路地图中的十字路口,可能有四条甚至更多道路相交。某些道路可能沿同一方向重合一段。还有些线段,恰好是竖直的。这些情况在计算几何里通常被称为退化情况(Degeneracies)。它们并不是“非常罕见、可以忽略的数据”。对于工程图纸、路网和人工设计的几何图形而言,它们反而可能十分常见。

第一种情况:垂直线段

如果线段两个端点的横坐标相同:x1=x2x_1=x_2那么我们之前计算扫描高度的公式:y(t)=y1+t−x1x2−x1(y2−y1)y(t)=y_1+ \frac{t-x_1}{x_2-x_1}(y_2-y_1)就无法使用。因为分母为零。更重要的是,一条竖直线段与扫描线重合时,交集可能不再是一个单独的点,而是一段区间。因此,程序不能只通过增加一个 if,就认为问题解决了。需要重新定义事件处理方式。一种数学上的处理办法,是对坐标系进行适当旋转,使所有线段都不再垂直。1979年的原始论文脚注就提到了这种处理思路。但在工程实现中,任意进行浮点旋转,也可能带来新的数值问题。因此,更完整的算法通常会采用精确谓词、统一的事件分组与专门的退化情况规则。[ITS Engineering]

第二种情况:多个端点拥有相同的横坐标

假设:A=(5,1)A=(5,1)B=(5,4)B=(5,4)C=(5,8)C=(5,8)有三个事件位于同一条竖直线上。现在,事件队列不能只按照横坐标比较,而不考虑事件之间的关系。它可能需要按照:(x,y)(x,y)的字典序处理几何事件。或者,在同一几何位置上,将相关事件分组,统一计算:哪些线段开始?哪些线段结束?哪些线段在这里相交?处理过程中,还必须保证更新后的活动集合与事件位置右侧的真实顺序一致。这就是经典算法的几行伪代码,为什么在真实项目里往往会增长成复杂的几何数据结构。

第三种情况:三条线段同时相交

假设:A,B,CA,\quad B,\quad C在同一个点P相交。那么:A∩B={P}A\cap B=\{P\}A∩C={P}A\cap C=\{P\}B∩C={P}B\cap C=\{P\}从“几何交点坐标”的角度看,只有一个不同位置:PP但从“相交线段对”的角度看,却有三组关系。如果共有 mm 条线段在同一点相交,那么相交线段对数量可能达到:(m2)=m(m−1)2\boxed{\binom m2=\frac{m(m-1)}2}这揭示了另一个重要区别。不同交点坐标的数量,不一定等于相交线段对的数量。如果用户要求输出全部相交线段对,就必须按照线段身份报告每一组关系。如果用户只要求输出不同的交点坐标,结果形式和复杂度分析都可能有所不同。因此,在计算几何项目开始之前,必须先明确输出语义。

第四种情况:两条线段重叠了一整段

例如:A:(0,0)→(8,0)A:(0,0)\rightarrow(8,0)B:(3,0)→(6,0)B:(3,0)\rightarrow(6,0)这两条线段的交集不是一个点。而是:[3,6]×{0}\boxed{[3,6]\times\{0\}}它包含无穷多个几何位置。如果程序只允许输出一个交点坐标,就无法完整描述这个结果。因此,健壮的几何相交函数,通常需要区分:空交集。点交集。线段交集。对于更复杂的几何对象,还可能产生其他类型的交集。
图43-7:四种需要明确处理的退化情形
竖直线段相交
共同端点
多线共点
共线区间重叠

这不仅是理论上的担忧

1990年代,Kurt Mehlhorn、Stefan Näher 等研究者研究了如何对扫描线相交算法进行健壮而高效的实际实现。他们利用 LEDA 算法库、精确算术以及浮点过滤技术,处理实际几何计算中的正确性问题。其研究报告明确指出,可靠的实现不仅需要正确的数据结构,还需要确保底层几何谓词与构造运算不被舍入误差破坏。[Google Books]今天的 CGAL 二维排列库(2D Arrangements)同样强调这一点。它使用专门的几何特征类与计算内核,支持精确交点构造,以及对复杂线段排列的处理。官方文档特别说明,使用具有精确谓词和精确构造能力的内核,可以避免普通浮点运算引发的拓扑错误。[2D Arrangements: User Manual]因此,如果你正在开发真实的CAD系统、地理数据服务或工业检测程序,可能不应该仅仅复制本章的教学代码,然后直接投入生产。你需要明确:输入坐标的数值范围。交点输出的具体定义。是否允许重叠。是否存在竖直线段。是否需要对全部交点使用精确有理数表示。这些条件决定了正确的算法实现方式。好的几何算法,不只是能够在普通样例里画对线;更重要的是,在最麻烦的边界条件下,也能够保持数学关系正确。

第十一幕:从几何交点到现代地图,扫描思想还能做什么?

GIS中的路网与矢量线数据,经常需要计算线段交点、构造拓扑关系或执行几何叠加。扫描线是相关算法体系中的一种重要思路。到了今天,扫描线思想已经远远超出了1979年的原始线段相交问题。但这里需要区分两件事。第一,Bentley–Ottmann 是一项具体的相交报告算法。第二,平面扫描(Plane Sweep)是一类更一般的算法设计范式。并不是所有使用扫描线的现代程序,都直接采用 Bentley–Ottmann 的完整事件处理方法。

应用一:地图中的道路交点

假设GIS系统里存放着大量道路折线。每条道路可能由多个线段组成。如果我们需要建立道路网络拓扑,就可能需要判断:哪些道路在几何上相交?交点在哪里?哪些线段需要在交点处拆分?哪些端点能够连接成一个道路节点?但是,几何相交不一定意味着车辆可以直接通行。例如,一座立交桥可能从另一条道路上方跨过。从二维投影看,两条道路相交。但真实道路没有可以转向的连接点。因此,地图系统还必须处理高度、道路等级和连通规则。扫描线可以帮助找出几何候选关系。但不能自动推断所有交通语义。

应用二:CAD图纸与版图验证

电路设计中的导线通常具有宽度,还可能分布在不同层上。实际设计规则检查会利用更复杂的几何表示,但线段相交与空间邻接仍然是重要基础概念。在PCB或集成电路设计中,软件需要检查大量形状之间的几何关系。例如:某些对象是否互相重叠?导线是否意外穿越?某个焊盘与走线是否连接?两段导线之间的间距是否符合设计规则?如果每个图形对象都与所有其他对象逐一比较,计算工作可能非常庞大。因此,现代几何系统通常会使用多种加速手段,包括:空间索引。包围盒筛选。扫描线。分层几何处理。以及专门的设计规则检查算法。具体实现未必直接使用1979年的 Bentley–Ottmann,但它们都体现了一种共同思想:先利用几何结构排除不相关对象,再进行真正昂贵的精确比较。

应用三:判断多边形是否发生自交

这是扫描线研究最初的重要动机之一。假设一名用户在绘图程序中,随手画出一条封闭折线。我们希望判断它是否构成简单多边形。如果有两条不相邻的边发生内部相交,那么折线就出现了自交。对于普通输入,可以利用扫描线思想,快速检测是否存在这种关系。但这里要小心。多边形相邻的两条边,本来就共享一个端点。这种合法连接,不应该被误判为自交。因此,程序必须区分:相邻边的正常端点连接。非相邻边的意外交叉。以及更复杂的重叠与重复顶点情况。这种区分,再次说明:几何对象之间是否“相交”,与它们在具体业务规则下是否“非法相交”,并不是完全相同的问题。

应用四:数据库中的空间索引

如果我们真正需要在 Python 中查找大量线段相交关系,不一定要从头实现 Bentley–Ottmann。例如,Shapely 提供了空间索引:STRtree。它使用包围盒组织空间对象,并支持根据几何谓词进一步筛选候选对象。这不等于 Bentley–Ottmann 扫描线算法。它采用的是另一种重要的空间加速思路。但对于实际项目,两者的目标具有相似性:尽量减少无关的精确几何判断。[Shapely 2.1.2 documentation]下面是一段使用 Shapely 处理线段相交的示例。
python

from shapely.geometry import LineString
from shapely.strtree import STRtree


segments = [
    LineString([(0.8, 1.5), (9.0, 8.4)]),
    LineString([(1.2, 8.2), (9.5, 1.2)]),
    LineString([(2.0, 4.2), (8.4, 4.4)]),
    LineString([(3.2, 0.8), (7.8, 6.6)]),
]

tree = STRtree(segments)

seen = set()

for i, segment in enumerate(segments):
    candidates = tree.query(
        segment,
        predicate="intersects"
    )

    for j in candidates:
        j = int(j)

        if i >= j:
            continue

        pair = (i, j)

        if pair in seen:
            continue

        seen.add(pair)

        geometry = segment.intersection(segments[j])

        print(
            "线段", i, "与", j,
            "相交,交集:",
            geometry
        )
这份代码还有一个优点。与前面的严格内部相交教学程序不同,Shapely 的几何交集可以返回不同类型的结果。如果两条线段在一点相交,结果可能是 Point。如果它们沿同一条线重叠一段,结果可能是 LineString。因此,它更适合用于探索真实几何数据中的复杂交集语义。需要注意,这段代码使用的是 Shapely 2.x 接口,STRtree.query 返回对应输入几何对象的索引。不同版本的接口行为可能有所差异,应以实际安装版本的官方文档为准。

扫描线不只属于线段相交

现在,我们可以回望上一章的 Fortune 算法。1986年,Fortune 让一条直线扫过平面,利用站点事件和圆事件构造沃罗诺伊图。1979年,本特利与奥特曼使用另一套事件,维护线段上下顺序并报告交点。它们解决的几何问题不同。维护的数据结构也不同。但都利用了一个共同设计模式:将连续空间中的复杂变化,浓缩成有限次关键事件。
图43-8:平面扫描是一种通用算法设计模式
选择扫描方向例如,让一条竖直线从左向右移动。

确定关键事件哪些位置会导致当前几何关系发生变化?

维护当前状态只保存扫描线附近仍然相关的对象及其关系。

局部更新并输出结果每个事件只处理必要变化,而不是重算整个问题。
这套思想,其实与我们以前学过的许多算法都有联系。在排序算法中,我们利用顺序减少无意义比较。在图算法中,我们利用邻接关系避免枚举全部路径。在动态规划中,我们利用状态转移避免重复求解。而在平面扫描中,我们利用事件顺序与空间邻接,避免不断检查所有几何对象之间的关系。它们都在做同一件事。寻找问题内部可以被利用的结构,并借助这个结构减少计算工作。

尾声:当计算机不再检查所有可能性,而是等待真正重要的事件

让我们再次回到1970年代。当时,计算几何还没有今天这样成熟的知识体系。研究人员正在尝试回答一些看起来非常基础的问题。怎样找出一组点中距离最近的两个点?怎样确定哪些点位于凸包边界?怎样把空间划分成最近点区域?怎样判断一个多边形有没有自交?这些问题,人类看着一张图,似乎就能够理解。但当数据量达到成千上万,计算机所面对的就不再是一张简单的图画。它面对的是大量坐标与几何关系。如果每遇到一个问题,就使用最直接的穷举方法,那么不断增长的数据量,很快就会让计算成本无法接受。沙莫斯与霍伊意识到:如果我们能够给几何对象建立一种自然顺序,或许就不必检查全部组合。他们选择了一条竖直扫描线。当扫描线穿过平面时,活动线段获得了从下到上的顺序。一旦两条线段的上下位置需要发生交换,就必须出现相交。而在发生交换之前,它们必然需要成为邻居。于是,一个原本涉及全部线段对的问题,突然拥有了局部结构。霍伊提出了维护扫描顺序的关键思想。沙莫斯参与数据结构设计与正确性证明。他们在1976年发表研究,能够以:O(nlog⁡n)O(n\log n)的时间复杂度,判断线段集合中是否存在交点。但故事还没有结束。因为检测第一处交点,只是整个问题的一部分。本特利与奥特曼继续向前。他们让交点本身成为事件。允许扫描线上的活动顺序在交点发生后改变。并且在每次顺序改变时,重新检查新出现的邻居关系。最终,他们得到了:O((n+k)log⁡n)\boxed{O((n+k)\log n)}的经典交点报告算法。这个结果背后,其实隐藏着一种非常深刻的计算思想。我们不一定需要提前知道所有可能发生的事情,只需要按照正确的顺序,处理那些真正会改变当前状态的事件。

连续世界,可以被离散事件描述

想象一条扫描线。从地图最左侧开始。向右移动。从几何直觉看,它在连续不断地前进。但是,程序不需要真的移动无穷多个微小位置。在两个相邻事件之间,如果没有线段开始、结束,也没有交点发生,那么活动线段的相对顺序不会改变。因此,程序可以直接从当前事件跳到下一事件。它跳过了中间无数个没有结构变化的位置。这是一种重要的抽象能力。把连续空间的变化,转化为有限个离散事件。它不仅属于计算几何。还与离散事件模拟、事件驱动系统和许多动态算法具有相似的思想。

这三章,其实讲的是同一件事

回顾第三篇的前三章:
《让机器理解空间》· 前三章的思想联系
第41章
Graham Scan:空间的外围边界利用叉积、极角排序和栈,找出真正决定凸包的点。

第42章
Voronoi Diagram:空间的内部分割利用距离、垂直平分线与几何对偶,将平面划分成最近站点区域。

第43章
Bentley–Ottmann:空间中的动态关系利用事件队列和动态上下顺序,高效报告线段之间的交点。
这三章分别面对:边界。区域。相交关系。它们共同构成了计算机理解二维几何的重要基础。第一章让计算机知道哪些点位于最外侧。第二章让计算机知道不同位置属于哪个最近站点。第三章让计算机知道哪些几何对象在什么位置发生交叉。人类原本依靠视觉理解的关系,逐渐被翻译成:坐标。向量。排序。数据结构。事件与数学证明。计算机并不是突然拥有了人的眼睛。它只是逐步掌握了一种能够描述空间的数学语言。

下一步:从理解空间,到真正寻找道路

但我们还有一个更大的问题没有解决。假设现在有一台机器人。它已经知道周围有哪些障碍物。也能够判断道路之间的几何关系。甚至已经建立了一个表示可通行区域的图。现在,我们让它从实验室的一角,前往另一角。怎样选择路线?最简单的方法,是把所有可能路径都尝试一遍。但这显然不可行。如果使用迪杰斯特拉算法,就能够找到最短路径。但它可能需要探索许多与最终目标方向无关的区域。能不能让算法具有某种方向感?就像人类寻找道路时,会自然地看一眼目标的大致方向?1968年,三位人工智能研究者在一篇经典论文中,给出了极具影响力的答案。他们不再只考虑已经走过的路径成本。还引入一种关于剩余距离的估计。让搜索过程能够更加有针对性地向目标推进。这便是:A* 算法。如果说本章的扫描线,让计算机学会了随着空间变化关注关键事件,那么下一章的 A*,将进一步让计算机学会:在一个拥有大量可能路线的世界里,怎样有方向地寻找目标。

历史档案:从霍伊的透明胶片,到本特利与奥特曼的论文

本章特别区分了1976年相交检测算法与1979年交点报告算法的历史贡献,避免将所有扫描线思想归于单独两个人。
时间人物与成果历史意义
1974年春Hoey、Shamos 研究最近区域与上一章沃罗诺伊图研究形成直接联系
1975年前后Hoey 提出线段扫描顺序思想从多边形自交问题推广到一般线段相交检测
1976年10月Shamos、Hoey 发表 Geometric Intersection Problems给出 O(nlog⁡n)O(n\log n) 的相交检测方法
1978年8月Bentley、Ottmann 的相关论文投稿进一步研究全部交点的报告与计数
1979年9月Bentley、Ottmann 论文正式发表给出经典输出敏感交点报告算法
1988年Chazelle、Edelsbrunner 的研究在相应模型下达到 O(nlog⁡n+k)O(n\log n+k) 的报告算法
1990年代几何算法库与精确计算研究推动退化情况处理和健壮工程实现

主要参考文献

1976年 · Shamos–Hoey 原始论文Geometric Intersection Problems第17届 IEEE 计算机科学基础年会,第208—215页。用于核查相交检测问题的定义、早期应用与 O(nlog⁡n)O(n\log n) 时间界。

1979年 · Bentley–Ottmann 原始论文Algorithms for Reporting and Counting Geometric Intersections《IEEE Transactions on Computers》第28卷第9期,第643—647页。核查事件队列、上下有序关系、交点事件、复杂度,以及水平和竖直线段的特殊处理。

1999年 · 沙莫斯的亲历者回忆The Early Years of Computational Geometry — A Personal Memoir本章最重要的历史叙事来源,记录霍伊1974年在透明胶片上研究最近区域、后来提出扫描顺序,以及沙莫斯与本特利的研究生活。

托马斯·奥特曼的大学履历Thomas Ottmann — Universität Freiburg用于核查他的教育、博士学位、卡尔斯鲁厄大学任教经历及后来的学术工作。

现代工程:CGAL 二维排列库CGAL — 2D Arrangements User Manual用于说明精确几何谓词、线段交点构造、退化情况处理,以及健壮几何算法的工程实现。

现代工程:Shapely 空间索引Shapely STRtree 官方文档用于区分现代空间索引与 Bentley–Ottmann 扫描线算法,说明在实际项目里如何使用已有的几何工具。
本章开篇的工程实验室与地图场景属于文学性教学重构,不对应某次能够确认日期的研究者谈话。霍伊、沙莫斯及本特利的相关历史轶事来自亲历者回忆,而1976年和1979年的算法贡献来自原始论文。互动实验与 Python 程序使用虚构线段坐标,并有意限制在没有退化情况的普通输入上。教学版不被表述成拥有工业级稳健性或原始论文严格复杂度保证的完整实现。
下一章预告 · 第44章

《给机器一双寻找方向的眼睛:A* 的诞生》

1968年,加利福尼亚州门洛帕克。斯坦福研究所的三位人工智能研究者,正在思考一个困扰搜索算法的问题。如果已经知道起点和终点,怎样在大量可能道路中尽快找到一条最低成本路径?迪杰斯特拉算法能够提供可靠的最短路保证。但它可能探索许多远离目标的节点。人类却会利用某种直觉。目标大概在东北方向。所以,我们可能先考虑那些朝东北方延伸的路线。能不能将这种方向感,变成一种数学上可以分析的启发式函数?下一章,我们将认识 Peter Hart、Nils Nilsson 与 Bertram Raphael,追溯1968年发表的经典论文:A Formal Basis for the Heuristic Determination of Minimum Cost Paths。[数字对象标识符]从:f(n)=g(n)+h(n)\boxed{f(n)=g(n)+h(n)}出发,我们将学习 A* 怎样结合已付出的真实路径成本与对未来成本的估计,并理解什么叫作可采纳启发式、什么叫作一致性,以及为什么这些条件决定了最优性保证。当计算机学会理解空间之后,下一步,就是让它学会朝着目标搜索。
——《算法的故事》第二季 · 第43章 完

本章配图