旋转卡壳——凸多边形的宽度

    凸多边形的宽度定义为平行切线间的最小距离。 这个定义从宽度这个词中已经略有体现。 虽然凸多边形的切线有不同的方向, 并且每个方向上的宽度(通常)是不同的。 但幸运的是, 不是每个方向上都必须被检测。 

    我们假设存在一个线段 [ a,b], 以及两条通过 ab 的平行线。 通过绕着这两个点旋转这两条线, 使他们之间的距离递增或递减。 特别的, 总存在一个 特定旋转方向 使得两条线之间的距离通过旋转变小。 

    这个简单的结论可以被应用于宽度的问题中: 不是所有的方向都需要考虑。 假设给定一个多边形, 同时还有两条平行切线。 如果他们都未与边重合, 那么我们总能通过旋转来减小他们之间的距离。 因此, 两条平行切线只有在其中至少一条与边重合的情况下才可能确定多边形的宽度。 

    这就意味着 “对踵点 点-边”以及 “边-边”对需要在计算宽度过程中被考虑。 

 

一个凸多边形宽度的示意图。 直径对如图由平行切线(红线)穿过的黑点所示。 直径如高亮的淡蓝色线所示。


    一个与计算 直径问题非常相似的算法可以通过遍历多边形对踵点对列表得到, 确定顶点-边以及边-边对来计算宽度。 选择过程如下:
  1. 计算多边形 y 方向上的端点。 我们称之为 yminymax
  2. 通过 yminymax 构造两条水平切线。如果一条(或者两条)线与边重合, 那么一个“对踵点 点-边”对或者“边-边”对已经确立了。 此时, 计算两线间的距离, 并且存为当前最小距离。
  3. 同时旋转两条线直到其中一条与多边形的一条边重合。
  4. 一个新的“对踵点 点-边”对(或者当两条线都与边重合,“边-边”对)此时产生。 计算新的距离, 并和当前最小值比较, 小于当前最小值则更新。
  5. 重复步骤3和步骤4(卡壳)的过程直到再次达到最初平行边的位置。
  6. 将获得的最小值的对作为确定宽度的对输出。

    更为直观的算法再次因为需要引进角度的计算而体现出其不足。 然而, 就如在凸多边形间最大距离问题中一样, 有时候更为简单、直观的旋转卡壳算法必须被引入计算。

 

原文地址:http://cgm.cs.mcgill.ca/~orm/width.html

 

转载请注明出处,谢谢!

## 1. 从“最远点对”到“凸包直径”:问题引入与暴力困境 想象一下,你在一个二维平面上撒了一把钉子。现在我问你:哪两颗钉子之间的距离最远?这就是计算几何中经典的“最远点对”问题,也叫“凸包直径”问题。我第一次接触这个问题时,第一反应和大多数人一样——暴力枚举。不就是把每对点之间的距离都算一遍,然后取最大值吗?听起来很简单。 但当我尝试处理成千上万个点时,暴力立刻显露出了它的苍白无力。假设有5万个点,需要计算的点对数量是 `50000 * 49999 / 2`,超过12亿对!即使每对计算只需要几个CPU周期,总时间也长得无接受。在实际的算法竞赛或者图形处理中,点集规模上十万、百万是常有的事,`O(n²)` 的复杂度是完全不可行的。 这时就需要一点几何直觉了。我们仔细观察会发现,距离最远的那两个点,一定位于整个点集“最外围”的那个凸多边形上。这个“最外围”的多边形,就是**凸包**。你可以把它想象成用一根橡皮筋套住所有钉子,橡皮筋最后绷成的形状就是凸包。最远的点对,必然是凸包上的两个顶点。这个发现至关重要,它把问题的搜索范围从内部所有点,缩小到了凸包边界上的点。而凸包上的点数,通常远小于总点数。 那么,在凸包上找最远点对,能不能比 `O(n²)` 更快呢?这就是**旋转卡壳算法**大显身手的地方了。它能以 `O(n)` 的时间复杂度,优雅地解决凸包直径问题。我第一次实现这个算法时,感觉就像发现了一个几何宝藏,原来复杂的问题可以用如此巧妙的方式高效解决。 ## 2. 凸包构建:旋转卡壳的舞台基石 旋转卡壳算法并不是凭空运行的,它需要一个精心搭建的舞台——**凸包**。如果把旋转卡壳比作一位在冰面上跳芭蕾的舞者,那么凸包就是那块光滑、规整的冰场。舞者的一切优雅动作,都建立在冰场的基础上。 ### 2.1 为什么需要凸包? 前面提到,最远点对必定在凸包上。这是旋转卡壳能够高效运行的前提。凸包有一个非常好的性质:它是一个**凸多边形**。这意味着连接凸包内任意两点的线段,都完全包含在凸包内部。对于凸包边界上的点,它们按照逆时针(或顺时针)方向排列,具有很好的单调性。这种单调性,正是后续“旋转”过程中,我们能够用双指针线性扫描的关键。 如果直接在原始散乱的点集上操作,点的顺序是混乱的,我们无找到那种随着一条边旋转,最远点也单调移动的规律。因此,**构建凸包是旋转卡壳算法不可省略的第一步**。这步的时间复杂度通常是 `O(n log n)`,主要花费在对点的排序上。虽然它比 `O(n)` 的旋转过程复杂度高,但作为预处理步骤,是可以接受的。 ### 2.2 两种主流凸包构建算法:Graham Scan 与 Andrew 构建凸包主要有两种经典算法,我都实战过很多次,它们各有特点。 **Graham Scan 算法** 的思路非常直观。它首先找到纵坐标最小的点(如果纵坐标相同则取最左边的),这个点肯定是凸包上的点。然后以这个点为基准,计算其他点相对于它的极角(可以理解为连线与水平线的夹角),并按极角排序。如果极角相同,则距离基准点近的排在前面。之后,它用一个栈来维护凸包上的点,依次检查每个点,如果栈顶的两个点与新加入的点构成一个“右转”(顺时针方向)的关系,就把栈顶的点弹出,直到构成“左转”(逆时针方向)为止。这个过程就像是用绳子一点点收紧,把凸包“勒”出来。 我更喜欢用 **Andrew 算法**,或者说“上下凸壳”。它不需要计算极角,思路更简洁。首先将所有点按 x 坐标从小到大排序(x 相同则按 y 排序)。然后从左到右扫描一遍,构建**下凸壳**;再从右到左扫描一遍,构建**上凸壳**;最后把两者合并就得到了完整的凸包。Andrew 算法避免了三角函数计算,精度更高,代码也不容易写错。下面是一个 Andrew 算法的核心代码片段,我习惯这样写: ```cpp // 点结构体定义 struct Point { double x, y; Point(double x=0, double y=0): x(x), y(y) {} // 向量减 Point operator-(const Point& b) const { return Point(x - b.x, y - b.y); } // 叉积 double operator^(const Point& b) const { return x * b.y - y * b.x; } }; // 比较函数,先按x,再按y bool cmp(const Point& a, const Point& b) { return a.x < b.x || (a.x == b.x && a.y < b.y); } // Andrew 算法求凸包,点编号从0开始,结果存在 hull 向量中,点按逆时针排列 void convexHull(vector<Point>& points, vector<Point>& hull) { if (points.size() <= 1) { hull = points; return; } sort(points.begin(), points.end(), cmp); hull.resize(points.size() * 2); // 最坏情况所有点都在凸包上 int k = 0; // 构建下凸壳 for (int i = 0; i < points.size(); ++i) { while (k >= 2 && ((hull[k-1] - hull[k-2]) ^ (points[i] - hull[k-2])) <= 0) k--; hull[k++] = points[i]; } // 构建上凸壳 for (int i = points.size() - 2, t = k + 1; i >= 0; --i) { while (k >= t && ((hull[k-1] - hull[k-2]) ^ (points[i] - hull[k-2])) <= 0) k--; hull[k++] = points[i]; } hull.resize(k - 1); // 最后一个点是起点,重复了,去掉 } ``` 这段代码中,`^` 运算符重载了叉积。叉积的正负是判断“左转”还是“右转”的关键。当 `(B-A) ^ (C-A) > 0` 时,表示从向量AB到AC是逆时针旋转(左转);小于0则是顺时针(右转);等于0表示三点共线。在构建凸包时,我们不断弹出导致“右转”或“共线”的栈顶点,以保证凸包是严格凸的。 ## 3. 旋转卡壳核心思想:对踵点与平行切线 好了,现在我们已经有了一个逆时针排列的凸包。怎么快速找到距离最远的两个顶点呢?旋转卡壳算法给出了一个绝妙的物理模型想象。 想象有一对**平行的卡尺**(就像游标卡尺的那对平行测爪),从上下(或左右)两个方向夹住这个凸多边形。最初,我们可以让这对平行线水平放置,分别与凸包的最高点和最低点相切。这时,与平行线接触的两个点,被称为一对**对踵点**。对踵点的定义就是:存在两条平行的支撑线(与凸包相切且凸包全在直线一侧),它们分别过这两个点。 **关键定理**:凸包的直径(最远点对),一定是某对对踵点之间的距离。而且,随着这对平行线绕着凸包旋转,对踵点对也会发生变化。旋转卡壳算法就是模拟这个旋转过程,在旋转中找出所有潜在的对踵点对,并记录最大距离。 在实际旋转中,平行线卡住凸包的状态主要有两种: 1. **“点-边”模式**:一条平行线卡住一个顶点,另一条平行线卡住一条边。 2. **“点-点”模式**:两条平行线各卡住一个顶点。 算法通常关注第一种“点-边”模式,因为它更容易处理和判断。对于凸包的每一条边,我们找到距离这条边最远的那个顶点。这个“最远”怎么衡量呢?这里叉积又派上用场了。对于一条边 `P[i]P[i+1]` 和一个点 `Q`,向量 `(P[i+1]-P[i])` 和 `(Q-P[i])` 的叉积的绝对值,等于以这两条向量为边构成的**平行四边形的面积**。而这个面积,又等于底边 `P[i]P[i+1]` 的长度乘以高。所以,**叉积的绝对值大小,直接反映了点Q到直线P[i]P[i+1]的垂直距离大小**。 因此,问题转化为:对于每条边,在凸包上找到一个点,使得这个点与边构成的三角形面积(叉积绝对值)最大。这个点就是这条边对应的“对踵点”候选。 ## 4. 单峰函数与双指针:线性扫描的奥秘 最精妙的部分来了。如果我们暴力地为每条边都从头扫描所有点找最远点,那复杂度还是 `O(n²)`。旋转卡壳之所以是 `O(n)`,源于凸包的一个优美性质:**当逆时针遍历凸包的边时,每条边对应的最远点,也会沿着凸包逆时针方向单调移动**。 换句话说,对于边 `e_i` 的最远点 `f(i)`,当 `i` 增加时,`f(i)` 不会减小。这是一个**单峰函数**的特性(在旋转的局部范围内,距离先增后减)。这意味着我们不需要为每条边都从头开始找最远点。我们可以维护一个指针 `j`,指向当前考虑的最远点。当处理边 `i` 时,我们从 `j` 开始检查,如果对于边 `i`,点 `j+1` 比点 `j` 更远,我们就让 `j` 加1;否则,`j` 就是边 `i` 的当前最远点。然后我们再去处理边 `i+1`。 由于 `j` 只会随着 `i` 的增加而增加(最多绕凸包一圈),`i` 和 `j` 的总移动次数都是 `O(n)` 级别的。这就将复杂度降到了线性。这个双指针技巧是旋转卡壳高效的核心。我第一次理解这一点时,感觉就像打通了任督二脉,原来几何的单调性可以这样利用。 ## 5. 算法实现与代码逐行解析 理论说再多,不如一行代码。下面我结合一个经典的洛谷模板题(P1452 / [USACO03FALL] Beauty Contest G)的AC代码,来详细拆解旋转卡壳的实现。这道题就是求平面点集最远点对距离的平方。 ```cpp #include <bits/stdc++.h> using namespace std; typedef long long ll; struct Point { ll x, y; Point(ll x=0, ll y=0): x(x), y(y) {} // 向量减 Point operator-(const Point& b) const { return Point(x - b.x, y - b.y); } // 叉积 ll operator^(const Point& b) const { return x * b.y - y * b.x; } // 点积(本题未用到,但通常一起定义) ll operator*(const Point& b) const { return x * b.x + y * b.y; } // 距离平方 ll dist2(const Point& b) const { Point d = *this - b; return d.x * d.x + d.y * d.y; } }; // 用于Andrew排序的比较函数 bool cmp(const Point& a, const Point& b) { return a.x < b.x || (a.x == b.x && a.y < b.y); } // Andrew算法求凸包,返回凸包点集(逆时针,首尾不重复) vector<Point> convexHull(vector<Point>& pts) { int n = pts.size(); if (n <= 1) return pts; sort(pts.begin(), pts.end(), cmp); vector<Point> hull(2 * n); // 预留足够空间 int k = 0; // 下凸壳 for (int i = 0; i < n; ++i) { while (k >= 2 && ((hull[k-1] - hull[k-2]) ^ (pts[i] - hull[k-2])) <= 0) k--; hull[k++] = pts[i]; } // 上凸壳 for (int i = n - 2, t = k + 1; i >= 0; --i) { while (k >= t && ((hull[k-1] - hull[k-2]) ^ (pts[i] - hull[k-2])) <= 0) k--; hull[k++] = pts[i]; } hull.resize(k - 1); // 去掉重复的起点 return hull; } // 旋转卡壳求凸包直径的平方 ll rotatingCalipers(const vector<Point>& hull) { int n = hull.size(); if (n == 1) return 0; if (n == 2) return hull[0].dist2(hull[1]); ll ans = 0; int j = 2; // 初始最远点指针,从第三个点开始 for (int i = 0; i < n; ++i) { // 当前边是 hull[i] -> hull[(i+1)%n] Point edgeVec = hull[(i+1)%n] - hull[i]; // 关键循环:当点j+1到边i的距离 >= 点j到边i的距离时,j前进 // 注意叉积比较的是面积,面积大代表距离远 while (abs((edgeVec ^ (hull[j] - hull[i]))) <= abs((edgeVec ^ (hull[(j+1)%n] - hull[i])))) { j = (j + 1) % n; } // 更新答案:最远点对可能是 (i, j) 或 (i+1, j) ans = max(ans, hull[i].dist2(hull[j])); ans = max(ans, hull[(i+1)%n].dist2(hull[j])); } return ans; } int main() { int n; scanf("%d", &n); vector<Point> pts(n); for (int i = 0; i < n; ++i) { scanf("%lld %lld", &pts[i].x, &pts[i].y); } vector<Point> hull = convexHull(pts); ll ans = rotatingCalipers(hull); printf("%lld\n", ans); return 0; } ``` 让我解释一下 `rotatingCalipers` 函数中的关键点: 1. `j` 的初始值设为2,这是一个经验值。因为对于第一条边(索引0-1),最远点不太可能是前两个点本身。 2. `while` 循环的条件是:点 `j+1` 到当前边 `i` 的“距离”(用叉积绝对值代表的面积)**大于等于** 点 `j` 的距离。注意这里是 `<=`,因为我们希望找到**最大**的距离。当 `j+1` 不比 `j` 更远时,循环停止,此时 `j` 就是对于边 `i` 的(局部)最远点。 3. 更新答案时,为什么要比较 `(i, j)` 和 `(i+1, j)` 两对点?因为最远点对不一定恰好是边端点与最远点的连线。考虑一个很扁的三角形,最远点对可能是底边端点和对面顶点。所以检查边两个端点与最远点 `j` 的距离是稳妥的。 4. 循环中 `i` 和 `j` 都是取模操作,因为凸包是环形的。当 `i` 到达末尾时,`(i+1)%n` 会回到起点。 这个实现避开了浮点数运算,全程使用整数(`long long`),避免了精度问题,最后输出距离平方即可,符合题目要求。在实际比赛中,这是非常可靠的做。 ## 6. 算法正确性深探与边界情况 你可能会问:为什么这个双指针算法是正确的?它会不会错过某些潜在的最远点对?我们可以从几何和单调性两方面来理解。 **几何解释**:对于一条固定的边 `E`,点 `P` 到直线 `E` 的距离函数,当 `P` 沿凸包逆时针移动时,是一个单峰函数。这是因为凸包是凸的,从边 `E` 的“对面”开始,随着 `P` 移动,其投影在 `E` 所在直线上的位置先是一侧,然后越过垂直点,再到另一侧,距离先增后减。因此,第一个导致距离下降的点,就是峰值点。我们的 `while` 循环正是在寻找这个峰值点。 **单调性保证**:当边 `E` 逆时针旋转到下一条边 `E'` 时,上一条边的最远点 `P`,对于新边 `E'` 来说,距离可能变小了。但重要的是,**新的最远点 `P'` 一定在 `P` 的逆时针方向(或相同)**。这是因为 `E'` 是 `E` 逆时针旋转得到的,原来在 `P` 顺时针方向的点,相对于 `E'` 的距离优势会更小。这就保证了指针 `j` 只需要前进,不需要回退。 **边界情况处理**: - **点数量少**:如果凸包只有1个点,直径是0;如果只有2个点,直径就是这两点距离。代码开头有判断。 - **共线情况**:在构建凸包时,我们的 Andrew 算法使用了 `<= 0` 来弹出栈顶点。这意味着如果三点共线,中间的点会被舍弃,凸包只保留端点。这对于旋转卡壳求直径是安全的,因为共线的中间点不可能成为最远点对(端点距离更远)。 - **凸包退化**:如果所有点共线,凸包就是一条线段。此时旋转卡壳的 `while` 循环可能不会正常更新 `j`,但最终答案会在检查 `(i, j)` 和 `(i+1, j)` 时被捕获,因为线段两个端点本身就在凸包上。 我在调试时曾经遇到一个坑:当凸包点数很少(比如3个点)时,`j` 的初始值如果设置不当,可能会在 `while` 循环里绕圈。所以确保取模运算正确,并且循环条件能正确退出,非常重要。上面的代码通过将凸包点数 `n` 传入,并妥善处理取模,规避了这个问题。 ## 7. 旋转卡壳的威力延伸:不止于直径 旋转卡壳的魅力远不止于求凸包直径。它是一种思想,一种利用凸多边形单调性,通过双指针线性扫描解决一系列极值问题的框架。掌握了这个框架,你可以解决很多看似不同但本质相似的问题。 **1. 凸包的宽度**:定义为平行切线间的最小距离。算法和求直径几乎一样,只是把求最大距离改成求最小距离。同样是旋转平行切线,记录每次“点-边”或“点-点”状态下的平行线距离,取最小值即可。 **2. 最小面积外接矩形**:这是旋转卡壳的一个经典应用。可以证明,凸包的最小面积外接矩形,至少有一条边与凸包的一条边重合。因此,我们可以枚举凸包的每一条边作为矩形的一条边,然后利用旋转卡壳确定其他三条边的位置:即找到相对于这条边“最上方”(最大点乘)、“最左方”和“最右方”(最小和最大点乘投影)的点。这样就能确定一个包围凸包的矩形,计算其面积。在所有枚举中取面积最小值。这个过程需要维护三个指针,但核心思想依然是单调性和双指针。 **3. 两凸包间的最远/最近距离**:问题可以转化为在两个凸包上分别旋转一对平行切线,寻找距离的极值。算法需要同时在两个凸包上维护指针,但原理相通。例如 POJ 3608 “Bridge Across Islands” 就是求两个凸包间最近距离的经典题。 **4. 最大面积三角形**:在凸包上寻找面积最大的三角形。朴素算法是 `O(n³)`。利用旋转卡壳思想,可以固定三角形的一个顶点 `i`,然后用两个指针 `j` 和 `k` 寻找以 `i` 为顶点的最大面积三角形。由于面积函数对于固定的 `i`,当 `j` 和 `k` 移动时也具有单调性,可以将复杂度降至 `O(n²)`。如果进一步优化,甚至可以达到接近 `O(n)`。 这些应用都共享同一个内核:**利用凸包的凸性和点的循环有序性,将看似需要平方级枚举的问题,通过单调指针的线性扫描降级**。当你遇到一个新的凸包极值问题时,不妨先思考:这个问题是否具有旋转卡壳所依赖的单调性?答案往往是肯定的。 ## 8. 实战调优与经验之谈 在算法竞赛和工程实践中,实现旋转卡壳时有一些细节值得注意,这些是我踩过坑后总结的经验。 **精度问题**:几何题最大的敌人就是精度。尽可能使用整数运算,避免 `double`。在本题中,距离用平方比较,叉积用 `long long`,完全避免了浮点数。如果必须用浮点数(如求最小矩形面积),比较时使用 `eps` 容差,并注意 `eps` 大小与数据范围的匹配。 **凸包去重**:输入的点集中可能有重复点。在构建凸包前,最好先对点进行去重,否则可能导致凸包算法出错(例如 Andrew 算法中排序后相邻重复点可能干扰栈的判断)。可以用 `sort` 和 `unique` 配合自定义的点的相等比较来去重。 **特判退化情况**:所有点共线是常见的退化情况。我们的 Andrew 算法能正确处理,生成一个只有两个点的“凸包”(线段)。旋转卡壳函数中对于 `n==2` 的特判就很重要。 **指针初始位置**:在旋转卡壳主循环中,指针 `j` 的初始位置不一定是2。一个更稳健的做是,先找到初始的一对对踵点,例如分别找到 y 最小和 y 最大的点。但在求直径的简单实现中,从2开始通常没问题,因为前两个点构成的边,最远点不太可能是它们自己。 **代码模块化**:将点类(`Point`)、向量运算(叉积、点积)、凸包构建(`convexHull`)、旋转卡壳(`rotatingCalipers`)清晰地分开。这样代码可读性好,也便于调试和复用。我提供的代码结构就是按这个思路组织的。 **测试用例**:自己构造一些测试用例,包括: - 随机点。 - 所有点在一个圆上(凸包是近似圆)。 - 所有点共线。 - 只有三个点构成三角形。 - 一个大点集,检验效率。 例如,你可以用下面的 Python 脚本生成随机数据,与暴力算法对比,验证正确性。 ```python import random, math def brute_force(points): maxd2 = 0 n = len(points) for i in range(n): for j in range(i+1, n): dx = points[i][0] - points[j][0] dy = points[i][1] - points[j][1] maxd2 = max(maxd2, dx*dx + dy*dy) return maxd2 # 生成测试数据并比较 ``` 最后,理解算法的最好方式就是动手实现。尝试用旋转卡壳解决洛谷 P3187 [HNOI2007]最小矩形覆盖,这是一个很好的进阶练习。你会对维护多个指针有更深体会。当看到原本复杂的问题被优雅地解决时,那种成就感正是学习计算几何的乐趣所在。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值