构造凸包-Graham Scan法

Graham Scan算法的流程

假设待处理点集S共有n个点。

1、预处理

  1. 预排序(presorting

    Graham Scan首先要做的是一个预处理排序操作(presorting)。即找到某个基准点,然后将其余所有的点按照相对于基准点的极坐标排序。如下图:

    img

    点的排序可以套用任意排序算法的框架,只是将排序对象由数值变为了平面上的点,而比较器改为to left test实现。

    如何找出第一个点:点1?

    以点1为基准点,其余点按照相当于点1的极角依次排序为2、3、4……理论上讲任何一个点都能当第一个基准点,为了简化算法通常选择lowest-then-leftmost point(LTL)作为基准点。

  2. 找出起始边

    对于与基准点1极角最小的点,也就是图中点2(假设没有三点共线的情况)。将点1和点2作为算法的起始点。

  3. 将起始边 与 剩下的n-2条边分别存储在不同的栈(stack)中:

    算法开始前先将起始点1和2入栈S,其他的n-2个点入栈T,如下图。注意S和T中元素的入栈顺序。至此预处理已经完成。

    Graham Scan用到的数据结构。整个算法非常简明,核心数据结构只有两个,分别记作栈S和栈T。便于理解我们将S和T画成开口相对的形式,如上图。

2、scan操作(此处为逆时针扫描)

完成预处理之后,就能开始算法的核心:scan操作。scan的过程主要关注三个点:栈S的栈顶(S[0])、次栈顶(S[1])和栈T的栈顶(T[0])。也就下图红色标注的三个点:

img

对这三个关注的点,进行检测,检测的框架为:

image-20200314182719216

举例

1、先来看一个最简单的例子,即点集S中所有的点都在凸包边界上。如下图:

image-20200314183056395

image-20200314211052355


2、上面列举了最简单的情况下Graham Scan的过程,接下来列举一个更有代表性的实例深入算法的细节。输入的点集S,并进行预处理排序,并初始化栈S、T,如下图:

20180207215925185

验证算法的正确性

思路上的正确性

了解了算法的整体流程之后,我们再来论证一下算法的正确性。证明一个算法正确性的方法有很多,在此选用数学归纳法。数学归纳法的思想可用多米诺骨牌类比,要做的无非是两件事:证明第1张骨牌会倒;证明如果第n张骨牌会倒则第n+1张骨牌也会倒下。

Graham Scan过程就是一个个引入点的过程。每当我们得到第k个点的时候,算法所得到的就是前k个点对应的“最好的凸包”。因此当k = n时得到的是整体的凸包。

  1. 归纳的第一步就是证明k = 3时得到的是当前点集S‘ = {1,2,3}中的极边——也就是证明第1张骨牌会倒。

    显然边1→2是S’的一条极边。而根据预处理的方式,3相较于1的极角一定大于2,因此点3一定在边1→2的左侧,因此边2→3会得到保留。对于这三个点来说,任意两条边一定都是极边,2→3也是一条极边。

  2. 然后证明:假设已经处理到第k个点,得到的是前点集S’ = {1,2,3,…,k}中所谓“最好的凸包”。根据算法处理方式,接下来从S’’ = {1,2,3,…,k,k+1}得到的结果是否也是正确的——也就是证明第n张骨牌会倒,则第n+1张骨牌也会倒下。

    预处理的方式是对2~n所有点相较于点1按极角排序,因此下一个要处理点k+1一定出现在线1→k的左侧,也就是下图蓝色区域和绿色区域(假设k = 9):

    img

    而根据目前接纳的最后一条极边( k-1)→k (例如图中8→9)来划分,点k+1可能出现的区域又分为两块,即该极边的左侧(绿色区域)和右侧(蓝色区域)。这也正对应于算法判定的两个分支。

至此,算法思路上的正确性已经证明完毕。

表述方式的正确性

接下来还要考虑算法的表述方式是否有漏洞:代码中每次to left test之前并没有判断S栈中是否有≥2个元素。这也可以由预处理的方式来论证。点1选取的是LTL,而点2是相对于点1极角最小的点,这样的做法保证了除了点1和点2之外所有的点一定是在边1→2左侧的。因此算法回溯最多到点2,永远不可能把点2丢弃,S中元素任何时候至少有两个。


Graham Scan算法的正确性论证完毕。

预处理操作的必要性

最后来思考一下预处理操作:presorting。仔细回顾上述论证过程会发现,每一步的正确性都是建立在最初的排序上的。那么这个预处理排序真的是必要的吗?可以来举极端的反例,每次选取下一个点都是随机的,例如下图的路径:

img

上图中从点1开始出发进行to left测试,可以发现,每次判定结果都为true,最终所有的点都被保留了下了,而显然这并不是一个凸包。因此presorting是整个算法成立的基础。

分析复杂度

上面证明了Graham Scan算法的正确性,接下来分析其复杂度是否满足O(nlogn),实现所谓的最优算法。

直观上无法断定Graham Scan是一个最优的算法,尤其是以下极端情况令人质疑其效率:

image-20200314220437069

Graham Scan算法复杂度由三部分决定:

所以算法的总体复杂度:O(nlogn + n * ?) ,可见scan的复杂度决定了算法总体的复杂度。

其实上述分析并非错误,只是不够精确。O(n^2)确实是Graham Scan算法的一个上界,但是这个上界并不是紧的。

问题就出在分析假定了每次都会出现回退高达O(n)个点。

算法推广

Graham Scan算法不仅可以用于凸包构造问题,在其他许多场景下中也十分有效。为了推广Graham Scan算法,首先可以对其做简化,以方便利用在其他问题。

首先再来回顾一下预处理排序,这是算法成立必不可少的一步。排序算法套用成熟的方法即可,利用数学方法计算偏角不仅复杂而且引入了误差,所以要采用to left test。要做的就是两点:

按照这样的流程就能间接地实现persorting。

1、原始点集已经有某种次序

有时候我们并不是从零开始构造凸包,例如得到的待处理点集已经是有某种次序的(比如已经按x坐标大小排序,如下图)。

img

这种情况也不一定非得进行persorting构造新的次序,通常改变观察的角度,换一种理解方式就能免去预处理而直接进行后面的线性的scan操作了。

上半部分凸包:

考虑y轴负方向无穷远一个点,所有的点相对于这个点的极角排序恰好就是各点的x坐标序!也就是将无穷远的点看作起始点①,最右侧点(图中点8)看作点②,进行scan过程直到最左边的点(图中点1)结束,就得到了凸包的上半部分(upper hull),也就是下图的8→7→2→1:

注:对 “考虑y轴负方向无穷远一个点,所有的点相对于这个点的极角排序恰好就是各点的x坐标序!” 这句话的理解:

img

下半部分凸包(lower hull)的构造

也是如此。考虑一个在y轴正方向无穷远的一个点,以此为起点进行scan,最终得到lower hull:1→4→7。最后将两个凸包合二为一即可。

代码实现


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,以下是 Graham-Scan 算的 C++ 实现,用于求解凸包问题: ```cpp #include <bits/stdc++.h> using namespace std; struct Point { int x, y; }; // 按照 x 坐标从小到大排序,若 x 坐标相等,则按照 y 坐标从小到大排序。 bool cmp(Point a, Point b) { if (a.x == b.x) return a.y < b.y; return a.x < b.x; } // 计算叉积。 int cross(Point a, Point b, Point c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); } // Graham-Scan 算求解凸包。 vector<Point> grahamScan(vector<Point> &points) { int n = points.size(); if (n <= 1) return points; sort(points.begin(), points.end(), cmp); vector<Point> hull(2 * n); int k = 0; // 构建下凸壳。 for (int i = 0; i < n; ++i) { while (k >= 2 && cross(hull[k - 2], hull[k - 1], points[i]) <= 0) k--; hull[k++] = points[i]; } // 构建上凸壳。 for (int i = n - 2, t = k + 1; i >= 0; --i) { while (k >= t && cross(hull[k - 2], hull[k - 1], points[i]) <= 0) k--; hull[k++] = points[i]; } // 去除重复点。 hull.resize(k - 1); return hull; } int main() { // 测试数据。 vector<Point> points = {{0, 3}, {1, 1}, {2, 2}, {4, 4}, {0, 0}, {1, 2}, {3, 1}, {3, 3}}; vector<Point> hull = grahamScan(points); // 输出凸包的顶点。 for (int i = 0; i < hull.size(); ++i) { cout << "(" << hull[i].x << ", " << hull[i].y << ")" << endl; } return 0; } ``` 注意点: 1. 为了方便起见,我直接使用了 C++11 的新特性,使用 vector 存储点集,如果你使用的是较老的编译器,可以使用数组代替 vector。 2. 实现中为了方便起见,我使用了三个点 $A(a_x,a_y)$、$B(b_x,b_y)$、$C(c_x,c_y)$ 的叉积 $cross(A,B,C)$ 表示向量 $\vec{AB}$ 和 $\vec{AC}$ 的叉积。当叉积 $cross(A,B,C)>0$ 时,表示 $\vec{AB}$ 在 $\vec{AC}$ 的逆时针方向;当叉积 $cross(A,B,C)<0$ 时,表示 $\vec{AB}$ 在 $\vec{AC}$ 的顺时针方向;当叉积 $cross(A,B,C)=0$ 时,表示 $\vec{AB}$ 和 $\vec{AC}$ 共线。 3. 为了避免精度误差,最好使用整数类型存储坐标,如 int 类型。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值