不用实际计算角度的最快矢量排序方法

22
许多算法(例如Graham扫描)需要按它们的角度对点或向量进行排序(也许是从某个其他点(即使用差向量)看到的角度)。这种顺序本质上是循环的,而在何处中断此周期以计算线性值通常并不重要。但实际角度值也不太重要,只要保持循环顺序即可。因此,为每个点执行atan2调用可能是浪费的。有什么更快的方法来计算严格单调的角度值,就像atan2一样?这样的函数显然被一些人称为“伪角”。

2
顺便提一下 Graham 扫描算法的情况,有一个简单的变体算法(复杂度相同),不需要任何角度排序:单调链算法。 - regnarg
@regnarg:** 单调链(monotone chain)**算法太棒了!只要询问者不关心角度,而是按角度排序向量,这就应该是最佳答案。我发现单调链算法比使用伪角度排序的 Graham 扫描算法更容易实现。 - sjaustirni
1
@Dundee:这个问题涉及到伪角,Graham扫描是其中的一个例子。因此,虽然指出单调链肯定对那些只关心如何轻松获取外壳的人来说是一个有用的评论,但伪角还有其他应用,所以我不会接受关于单调链的答案,因为它并没有回答我提出的问题。 - MvG
8个回答

18

我开始试着操作这个函数,发现其规范有点不完整。由于dx和dy变化时,atan2会在某一点上在-pi和+pi之间跳跃,从而导致不连续性。下面的图表显示了@MvG建议的两个公式,实际上它们与atan2相比,在不同的位置都存在不连续性。(注:我在第一个公式中添加了3,在备选项中添加了4,以使线在图上不重叠)。如果将atan2添加到该图中,则它将是y = x的直线。因此,我认为可能会有各种答案,具体取决于人们想要放置不连续性的位置。如果真的想复制atan2,那么答案(在这个类别中)将是

# Input:  dx, dy: coordinates of a (difference) vector.
# Output: a number from the range [-2 .. 2] which is monotonic
#         in the angle this vector makes against the x axis.
#         and with the same discontinuity as atan2
def pseudoangle(dx, dy):
    p = dx/(abs(dx)+abs(dy)) # -1 .. 1 increasing with x
    if dy < 0: return p - 1  # -2 .. 0 increasing with x
    else:      return 1 - p  #  0 .. 2 decreasing with x
这意味着,如果你正在使用的编程语言具有 sign 函数,你可以通过返回 sign(dy)(1-p) 避免分支,这将使得在返回 -2 和 +2 之间的不连续处得到 0 的答案效果。而且同样的技巧也适用于 @MvG 原始方法中,可以返回 sign(dx)(p-1)。 更新 在下面的评论中,@MvG 提出了一行 C 代码实现方式。
pseudoangle = copysign(1. - dx/(fabs(dx)+fabs(dy)),dy)

@MvG说这个很好用,我也觉得看起来不错 :-).

在此输入图片描述


1
运行良好。一个可能的C实现是 copysign(1.-x/(fabs(x)+fabs(y)),y),相比于atan2,我观察到至少快了10倍,这与@george的观察相反,他认为这可能比atan2更慢。如果您认为合适,可以随意将这个C代码片段包含在您的答案中。 - MvG

6
我知道一种可能的函数,我会在这里进行描述。
# Input:  dx, dy: coordinates of a (difference) vector.
# Output: a number from the range [-1 .. 3] (or [0 .. 4] with the comment enabled)
#         which is monotonic in the angle this vector makes against the x axis.
def pseudoangle(dx, dy):
    ax = abs(dx)
    ay = abs(dy)
    p = dy/(ax+ay)
    if dx < 0: p = 2 - p
    # elif dy < 0: p = 4 + p
    return p

因此,为什么这个算法有效呢?需要注意的是,缩放所有输入长度不会影响输出。因此,向量(dx, dy)的长度是无关紧要的,只有它的方向才重要。我们可以集中在第一象限上,暂时假设dx == 1。然后,dy / (1 + dy)从dy == 0开始单调增长,直到dy趋近于无穷大(即dx == 0)。现在必须处理其他象限。如果dy为负数,则初始p也为负数。因此,对于正的dx,我们已经有了一个角度单调递增的范围-1 <= p <= 1。对于dx < 0,我们改变符号并加上2。这给出了一个范围1 <= p <= 3,对于dx < 0,并且整个范围是-1 <= p <= 3。如果由于某种原因不希望使用负数,则可以包括elif注释行,这将把第四象限从-1…0移动到3…4。

我不知道上述函数是否有一个已经确定的名称,以及谁最先发布了它。我很久以前就得到了它,并从一个项目复制到另一个项目。然而,我在网上找到了这个函数的多个实例,因此我认为这个片段足够公开,可以被重用。

有一种方法可以获得范围[0 … 4](对于实际角度[0 … 2π]),而不引入进一步的情况区分:

# Input:  dx, dy: coordinates of a (difference) vector.
# Output: a number from the range [0 .. 4] which is monotonic
#         in the angle this vector makes against the x axis.
def pseudoangle(dx, dy):
    p = dx/(abs(dx)+abs(dy)) # -1 .. 1 increasing with x
    if dy < 0: return 3 + p  #  2 .. 4 increasing with x
    else:      return 1 - p  #  0 .. 2 decreasing with x

@EgorSkriptunoff:你是对的,我在复制时犯了一个错误。 - MvG
如果这行得通,我会感到惊讶,因为如果唯一的要求是角度单调性,那么这里的代码具有我所期望的恰好复杂度。 - Stochastically
@Stochastically:上述代码有3个分支点,我可以想象通过巧妙的技巧少用一个绝对值。如果不行,那么这个答案可能是一个有用的参考。 - MvG
1
Fowler角度 http://steve.hollasch.net/cgindex/math/fowler.html 执行类似的任务,但需要更多的计算。 - MBo
评论系统肯定有问题,因为我没有收到针对我的评论。无论如何,如果可以的话,我会花点时间思考一下(也就是说,我会尝试在Excel中玩一下!)。有一个问题,关于分支点(我假设这两个“abs”和“if dx<0”),当我是汇编语言程序员时,检查某些东西的符号只需要一个快速操作码,所以我不明白为什么分支点的数量很重要。 - Stochastically
@Stochastically:鉴于现代CPU上处理管道的长度,分支预测错误可能会轻易地造成数个周期的损失。而且由于对于随机点来说,这里的符号几乎是任意的,因此分支预测可能没有什么用处。话虽如此,我刚刚使用gcc 4.7和-O3编译了这个,结果发现fabs将被编译为andpd,这应该几乎不会有任何开销。只剩下一个分支。 - MvG

3
我有点喜欢三角学,所以我知道将角度映射到我们通常拥有的一些值的最佳方法是正切。当然,如果我们想要一个有限的数字,以免比较{sign(x),y/x}的麻烦,这会变得更加混乱。
但是有一个函数,它将[1,+inf[映射到[1,0[,称为反函数,它将允许我们有一个有限的范围来映射角度。正切的反函数是众所周知的余切,因此x/y(是的,就这么简单)。
下面是一个小插图,显示了在单位圆上正切和余切的值:

values of tangent and cotangent

当|x| = |y|时,您会看到值相同,而且如果我们在两个圆上输出[-1,1]之间的值,则可以看到已经涂满一个圆。为了使这些值的映射连续且单调,我们可以这样做:
  • 使用余切的相反数以获得与正切相同的单调性
  • 将-余切加2,以使值在tan=1处重合
  • 添加4到圆的一半(例如,x=-y对角线下方)以使值适合于不连续点之一。
这给出了以下分段函数,它是角度的连续且单调的函数,只有一个不连续点(即最小值):

continuous piecewise function of the angles into (-1,8) piecewise definition of pseudo-angle

double pseudoangle(double dx, double dy) 
{
    // 1 for above, 0 for below the diagonal/anti-diagonal
    int diag = dx > dy; 
    int adiag = dx > -dy;

    double r = !adiag ? 4 : 0;

    if (dy == 0)
        return r;

    if (diag ^ adiag)
        r += 2 - dx / dy; 
    else
        r += dy / dx; 

    return r;
}

请注意,这非常接近Fowler angles,具有相同的属性。形式上,pseudoangle(dx,dy) + 1 % 8 == Fowler(dx,dy) 谈到性能,它比Fowler的代码少得多(在我看来通常也不太复杂)。在gcc 6.1.1上使用-O3编译,上述函数生成带有4个分支的汇编代码,其中两个来自dy == 0(一个检查是否两个操作数都是“无序”的,因此如果dy是NaN,另一个则检查它们是否相等)。
我认为这个版本比其他版本更精确,因为它仅使用保持尾数的操作,直到将结果移动到正确的区间。当|x| << |y|或|y| >> |x|时,这应该特别明显,然后操作|x| + |y|会失去相当多的精度。
正如您在图表上所看到的,角度-伪角关系也非常接近线性。

看一下分支的来源,我们可以得出以下观察:

  • 我的代码不依赖于abscopysign,这使它看起来更加自包含。然而,在浮点值上处理符号位实际上相当简单,因为它只是翻转一个单独的位(没有分支!),所以这更像是一个劣势。

  • 此外,其他在此处提出的解决方案在除以abs(dx) + abs(dy)之前并不检查它是否等于0,但是只要一个分量(dy)为0,这个版本就会失败——因此引入了一个分支(或者在我的情况下是两个分支)。

如果我们选择获得大致相同的结果(最多存在舍入误差),但没有分支,我们可以滥用copysign并编写:

double pseudoangle(double dx, double dy) 
{
    double s = dx + dy; 
    double d = dx - dy; 
    double r = 2 * (1.0 - copysign(1.0, s)); 
    double xor_sign = copysign(1.0, d) * copysign(1.0, s); 

    r += (1.0 - xor_sign);
    r += (s - xor_sign * d) / (d + xor_sign * s);

    return r;
}

由于dx和dy在绝对值上接近,因此可能会发生比以前的实现更大的错误,由于d或s中的取消。与其他实现相比,没有检查除零,因为只有当dx和dy都为0时才会发生这种情况。

2
如果在排序时可以将原始向量而不是角度输入比较函数中,就可以使用以下方法使其工作:
  • 仅使用单个分支。
  • 仅使用浮点数比较和乘法。
避免加法和减法使其在数字上更加稳健。双精度可以精确表示两个浮点数的乘积,但不一定能精确表示它们的和。这意味着对于单精度输入,您可以轻松地保证完美无瑕的结果。
这基本上是Cimbali's solution为两个向量重复,消除了分支并将除法转化为乘法。它返回一个整数,符号与比较结果匹配(正、负或零)。
signed int compare(double x1, double y1, double x2, double y2) {
    unsigned int d1 = x1 > y1;
    unsigned int d2 = x2 > y2;
    unsigned int a1 = x1 > -y1;
    unsigned int a2 = x2 > -y2;

    // Quotients of both angles.
    unsigned int qa = d1 * 2 + a1;
    unsigned int qb = d2 * 2 + a2;

    if(qa != qb) return((0x6c >> qa * 2 & 6) - (0x6c >> qb * 2 & 6));

    d1 ^= a1;

    double p = x1 * y2;
    double q = x2 * y1;

    // Numerator of each remainder, multiplied by denominator of the other.
    double na = q * (1 - d1) - p * d1;
    double nb = p * (1 - d1) - q * d1;

    // Return signum(na - nb)
    return((na > nb) - (na < nb));
}

被接受的答案中的除法在现代CPU上并不是一个真正的问题,至少对于具有强大除法器的主流x86 CPU而言不是。只要将除法与其他操作混合使用,为了避免使用divss而花费更多指令通常是不值得的;FP除法仍然只有1个uop,并且具有足够好的吞吐量,不会成为主要瓶颈。它的延迟较差,如果你可以便宜地避免它(例如,在循环中乘以倒数),那么就值得避免。浮点除法与浮点乘法 - Peter Cordes
你能否举一个例子,其中被接受的答案的伪角不是单调的,即一对向量会排序错误的情况?或者任何可能导致NaN的情况? - Peter Cordes
@PeterCordes 以下内容可能需要在您的系统中使用不同的乘数,但请尝试这些值 (dx, dy): (3, 4), (35, 45), (3/13, 4/13), (35/13, 45/13)。 从数学上来说,所有角度都是相同的,但在浮点数中只有前两个是相同的。 接受的答案也为第三个提供了相同的伪角(但不是第四个)。 我刚刚意识到我的完整实现还会在最终结果为零时尝试 bignums,并且避免除法的地方很重要。 - jjrv

1
我想到的最简单的方法是制作归一化点的副本,并沿x或y轴将圆分成两半。然后使用相反的轴作为顶部或底部缓冲区开始和结束之间的线性值(在放置时,一个缓冲区需要按照反向线性顺序)。然后你可以线性地读取第一个和第二个缓冲区,它将是顺时针方向,或者反向读取第二个和第一个缓冲区以得到逆时针方向。
这可能不是一个好的解释,所以我在GitHub上放了一些代码,用这种方法对具有epsilon值的点进行排序以调整数组大小。

https://github.com/Phobos001/SpatialSort2D

这可能不适合您的用例,因为它是为图形效果渲染的性能而构建的,但它快速且简单(O(N)复杂度)。如果您处理的是点的微小变化或非常大的(数十万)数据集,则此方法可能无法正常工作,因为内存使用可能会超过性能优劣之间的平衡点。

0
如果角度本身不需要,只是用于排序,那么@jjrv的方法是最好的。以下是在Julia中的比较。
using StableRNGs
using BenchmarkTools

# Definitions
struct V{T}
  x::T
  y::T
end

function pseudoangle(v)
    copysign(1. - v.x/(abs(v.x)+abs(v.y)), v.y)
end

function isangleless(v1, v2)
    a1 = abs(v1.x) + abs(v1.y)
    a2 = abs(v2.x) + abs(v2.y)

    a2*copysign(a1 - v1.x, v1.y) < a1*copysign(a2 - v2.x, v2.y)
end

# Data
rng = StableRNG(2021)
vectors = map(x -> V(x...), zip(rand(rng, 1000), rand(rng, 1000)))

# Comparison
res1 = sort(vectors, by = x -> pseudoangle(x));
res2 = sort(vectors, lt = (x, y) -> isangleless(x, y));

@assert res1 == res2

@btime sort($vectors, by = x -> pseudoangle(x));
  # 110.437 μs (3 allocations: 23.70 KiB)

@btime sort($vectors, lt = (x, y) -> isangleless(x, y));
  # 65.703 μs (3 allocations: 23.70 KiB)

因此,避免使用除法,可以将时间减少一半而不会降低结果质量。当然,对于更精确的计算,isangleless 有时需要配备 bigfloat,但同样也适用于 pseudoangle


0
很好...这里有一个变体,返回 -Pi , Pi,就像许多arctan2函数一样。
编辑说明:将我的伪代码更改为正确的Python代码..参数顺序更改以与Python的数学模块atan2()兼容。Edit2麻烦了更多的代码来捕获dx = 0的情况。
def pseudoangle( dy , dx ):
  """ returns approximation to math.atan2(dy,dx)*2/pi"""
  if dx == 0 :
      s = cmp(dy,0)
  else::
      s = cmp(dx*dy,0)  # cmp == "sign" in many other languages.
  if s == 0 : return 0 # doesnt hurt performance much.but can omit if 0,0 never happens
  p = dy/(dx+s*dy)
  if dx < 0: return p-2*s
  return  p

在这个表格中,所有角度的最大误差只有约0.07弧度。(当然,如果您不关心幅度,可以忽略Pi/2。)
现在是坏消息--在我的系统上,使用Python math.atan2 大约比原生代码慢25%。 显然,替换简单的解释型代码无法打败编译内置的代码。

“Sign[dx dy]”这里的“Sign”是指乘积的符号吗?无论哪种情况,我都有点担心分母中出现带符号的值,因为这很容易使分母为零。例如,如果“dx = dy = -1”,那么分母就为零了。或者是我错误地理解了你的语法? - MvG
那个性能比较是用哪种语言和编译器进行的? - MvG
是的,乘积的符号。(sign[dx]*sign[dy] 可能会更快..) 它只会在0,0失败(因为其他的都不会)。我在Mathematica中进行了比较,但重点是你应该实际检查你的平台性能,而不是假设你正在打败一个内在的东西。 - agentp
我相信我终于理解了这个分母符号背后的魔力。通过优化的 C 代码比较,我的内联实现显示出至少10倍的巨大速度提升。但我同意这取决于环境。特别是在解释型语言中,其中 atan2 是一个单一的本地函数调用,但伪角实现需要进行多个解释算术运算,可能需要类型检查,因此使用伪角确实可能对性能不利。 - MvG
用Python重新编写.. 相对性能比Mathematica更好。 - agentp

-3

只需使用叉积函数。您旋转一个线段相对于另一个的方向将给出正数或负数。没有三角函数,也没有除法。快速简单。只需谷歌一下。


2
叉积通常被定义为在ℝ³中操作并产生一个向量。在ℝ²中最接近的类比是行列式,这是根据我的直觉和MathWorld所说的。但是,行列式在角度上不是单调的。它是角度余弦的函数,并且会对例如60°和120°产生相同的值。 - MvG

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接