什么是凸包?
简单来说,可以想象在平面中有一些点,这些点的集合为 X,我们拿一个橡皮圈撑到最大,尝试套住所有的点,待橡皮圈绷紧后,它会成为一个多边形,这个多边形所有顶点组成的集合便为集合X 的凸包。如图:
凸包计算有很多办法,我在本文讲解Graham 扫描法。
算法实现:
- 找到一个一定在凸包中的初始点,比如最左下的点
- 按逆时针逐个加入可能在凸包中的点。做法就是先把剩下的点集按照相对初始点的极角排序,如果极角相同,则按距离排序,近的在前
- 从初始点开始,按顺序不断加入凸包,对于加入的每一个点,判断加入该点后,会不会使凸包呈现一种前一个点往内凹陷的形状,如果是这样的,则从凸包中删除前一个点,继续往前判断(怎么判断形状呢?利用叉乘)
下面详细解释一下算法实现
利用叉乘
对于平面中两个向量a=(Xa,Ya),b=(Xb,Yb),它们的叉乘也是一个向量,定义为c=(0,0,XaYb-XbYa),垂直于这个平面,符合右手定则。
首先,我们可以利用这个办法来实现算法的第 2 步,排序。所谓按极角顺序排序,就是将剩下点集与初始点组成的向量按逆时针排列,对其中两个向量做叉乘,如果结果为负,说明前一个向量对应的点的极角更小,于是把它排在前面,很容易完成。如果两向量共线,则按距离由小到大排,代码如下:
list.sort((a, b) -> {
/* 按叉乘逆时针 */
int diff = cross(list.get(0), a, b);
if (diff == 0) {
/* 按距离由近到远 */
return (distance(list.get(0), a) - distance(list.get(0), b));
} else {
return -diff;
}
});
怎么利用叉乘的性质来判断删除哪些点呢?举如下例子:
初始点显然是A点,剩下的点按照上述算法描述的排序顺序标号,从 B 点开始不断加入凸包,如图:
当加入 F 点后,我们要找前两个加入的点来做叉乘,如果 DF相对于DE是顺时针的,那么 E 点就会往内凹陷,应该从凸包中删除 E 点
删除这一个点并不够。我们还要找凸包中 E 点之前的两个点 C 点和 D 点,CF相对于CD是顺时针的,于是再把 D 点删除,接下来找凸包中 D 点之前的两个点 B 点和 C 点,发现BF相对于BC是逆时针的,于是 C 点满足凸包性质,则 C 点之前的点也一定满足凸包性质,就不需要在做叉乘判断了,继续往后加点,重复这个判断即可。
对于加入的每个点,都要与凸包中最近加入的两个点做叉乘运算,很显然,这里可以用栈结构来保存凸包中的点,代码如下:
Deque<Integer> stack = new ArrayDeque<Integer>();
stack.push(0);
stack.push(1);
int n = list.size();
for (int i = 2; i < n; i++) {
int top = stack.pop();
/* 如果当前元素与栈顶的两个元素构成的向量顺时针旋转或共线,则弹出栈顶元素 */
while (!stack.isEmpty() && cross(list.get(stack.peek()), list.get(top), list.get(i)) <= 0) {
top = stack.pop();
}
stack.push(top);
stack.push(i);
}
细节:距离判断
为什么对于共线的点,要按它们与初始点的距离从小到大排序呢?
考虑如下例子:
A、B、C、D 四点共线,算法会先加入 A、B 两点,当加入 C 时,AB与AC共线,则会踢掉 B 点,以此类推,最后只会保留 D 点。
反之,如果不按距离由小到大排序,假如先加入 D 点,则下一步加入 C 点时,算法判断AC与AD共线,则会踢掉 D 点,那么就会得到错误的答案。