【问题标题】:Fastest way to sort vectors by angle without actually computing that angle按角度对向量进行排序而不实际计算该角度的最快方法
【发布时间】:2013-05-14 11:30:55
【问题描述】:

许多算法(例如Graham scan)要求点或向量按其角度排序(也许从其他点看,即使用差异向量)。这个顺序本质上是循环的,并且这个循环被打破以计算线性值通常并不重要。但实际角度值也无关紧要,只要保持循环顺序即可。因此,为每一点都调用atan2 可能是一种浪费。有什么更快的方法来计算角度严格单调的值,atan2 的方式是?这些函数显然被某些人称为“伪角度”。

【问题讨论】:

  • 作为 Graham 扫描案例的旁注,该算法有一个简单的变体(具有相同的复杂性),不需要任何角度排序:monotone chain 算法。
  • @regnarg:单调链算法太棒了!这应该是公认的答案,只要提问者不关心角度,而是按角度对向量进行排序。我发现单调链算法比格雷厄姆的伪角排序扫描更容易实现。
  • @Dundee:这个问题是关于伪角的,以格雷厄姆扫描作为一个例子。因此,虽然指出单调链对于那些偶然发现但主要关心轻松获得船体的人来说肯定是一个有用的评论,但伪角也有其他应用,所以我不会接受关于单调链的答案,因为它没有回答我提出的问题。

标签: performance sorting math geometry angle


【解决方案1】:

我开始尝试这个并意识到规范有点不完整。 atan2 有一个不连续性,因为随着 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(dy)(1-p) 来避免分支,这会在返回之间的不连续处放置一个答案 0 -2 和 +2。同样的技巧也适用于 @MvG 的原始方法,可以返回 sign(dx)(p-1)。

更新在下面的评论中,@MvG 建议使用单行 C 实现,即

pseudoangle = copysign(1. - dx/(fabs(dx)+fabs(dy)),dy)

@MvG 说它运作良好,我觉得它看起来不错:-)。

【讨论】:

  • 效果很好。一个可能的 C 实现是copysign(1.-x/(fabs(x)+fabs(y)),y),与atan2 相比,我可以观察到它的加速至少是10 倍,而@george 观察到这可能比atan2 慢。如果您认为合适,请随意将这个 C 剪裁到您的答案中。
【解决方案2】:

我知道一种可能的这样的功能,我将在这里描述。

# 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 &lt;= p &lt;= 1 单调。对于dx &lt; 0,我们更改符号并添加两个。这给出了dx &lt; 0 的范围1 &lt;= p &lt;= 3,以及整个-1 &lt;= p &lt;= 3 的范围。如果由于某种原因不希望使用负数,则可以包含 elif 注释行,这会将第四象限从 -1…0 移动到 3…4

我不知道上面的函数是否有一个既定的名字,可能是谁先发布的。我很久以前就得到了它,并将它从一个项目复制到下一个项目。然而,我在网上找到了 occurrences,所以我认为这个被截断的公开内容足以重复使用。

有一种方法可以在不引入进一步区分大小写的情况下获得范围 [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:你说得对,我复制了这个。
  • 如果这可行,如果有可能找到任何更有效的方法,我会感到惊讶,因为这里的代码正是我所期望的复杂性,如果唯一的要求是角度。
  • @Stochasticly:上面的代码有 3 个分支点,我可以想象可以使用一些巧妙的技巧来减少一个绝对值。如果没有,那么这个答案可能是一个有用的参考。
  • Fowler angle steve.hollasch.net/cgindex/math/fowler.html 执行类似任务,但需要更多计算
  • 评论系统一定有问题,因为我没有收到发给我的评论。无论如何,如果可以的话,我会考虑一下(这意味着我会尝试找时间在 Excel 中玩一下!)。一个关于分支点的问题(我假设是两个absif dx&lt;0)。当我还是一名汇编语言程序员时,检查某事物的符号只是一个快速操作码,所以我不明白为什么分支点的数量很重要。
【解决方案3】:

我有点喜欢三角学,所以我知道将角度映射到我们通常拥有的某些值的最佳方法是切线。当然,如果我们想要一个有限的数字来避免比较 {sign(x),y/x} 的麻烦,那就有点混乱了。

但是有一个函数可以将 [1,+inf[ 映射到 [1,0[ 称为逆函数,这将允许我们有一个有限的范围来映射角度。正切的倒数是众所周知的余切,因此是 x/y(是的,就这么简单)。

一个小插图,显示单位圆上正切和余切的值:

当|x| 时,您会看到这些值是相同的。 = |y|,您还可以看到,如果我们对两个圆圈上输出值介于 [-1,1] 之间的部分进行着色,我们就可以为整个圆圈着色。要使这种值映射连续且单调,我们可以这样做:

  • 使用余切的对立面与切线具有相同的单调性
  • 将 2 添加到 -cotan,使 tan=1 处的值一致
  • 将 4 添加到圆的一半(例如,在 x=-y 对角线下方)以使值适合不连续点之一。

这给出了以下分段函数,它是角度的连续且单调的函数,只有一个不连续性(这是最小的):

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 的代码那么繁琐(而且通常也不那么复杂 imo)。在 gcc 6.1.1 上使用 -O3 编译,上面的函数生成一个带有 4 个分支的汇编代码,其中两个来自 dy == 0(一个检查两个操作数是否“无序”,因此如果 dy 是 NaN ,另一个检查它们是否相等)。

我认为这个版本比其他版本更精确,因为它只使用尾数保留操作,直到将结果转移到正确的间隔。这在 |x| 时尤其明显。 > |x|,则运算 |x| + |y|失去了相当多的精度。

如图所示,角度-伪角度关系也非常接近线性。


看看分支是从哪里来的,我们可以做如下注释:

  • 我的代码不依赖abs 也不依赖copysign,这使它看起来更加独立。然而,在浮点值上使用符号位实际上是微不足道的,因为它只是翻转一个单独的位(没有分支!),所以这更像是一个缺点。

  • 此外,这里提出的其他解决方案在除以 abs(dx) + abs(dy) == 0 之前不会检查它是否,但是只要一个组件 (dy) 为 0,此版本就会失败——因此会抛出一个分支(或 2就我而言)。

如果我们选择得到大致相同的结果(直到舍入误差)但没有分支,我们可能会滥用 copsign 并编写:

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 时发生。

【讨论】:

    【解决方案4】:

    如果您可以在排序时将原始向量而不是角度输入到比较函数中,则可以使用:

    • 只有一个分支。
    • 仅浮点比较和乘法。

    避免加法和减法使其在数值上更加稳健。 double 实际上总是可以精确地表示两个浮点数的乘积,但不一定是它们的总和。这意味着对于单精度输入,您可以毫不费力地保证完美无瑕的结果。

    这基本上是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,并且吞吐量足够好,不会成为主要瓶颈。它确实有更差的延迟,如果您可以便宜(例如,在循环中乘以倒数),则值得避免。 Floating point division vs floating point multiplication
    • 你能举一个例子说明接受答案的伪角度不是单调的,即一对会以错误方式排序的向量吗?或者任何会给出 NaN 的?
    • @PeterCordes 以下可能需要您系统上的不同乘数,但是:试试这些 (dx, dy): (3, 4), (3*5, 4*5), (3/13, 4/13), (3*5/13, 4*5/13)。从数学上讲,所有角度都是相同的。在浮点数中,只有前两个。接受的答案也为第三个(但不是第四个)给出了相同的伪角。我刚刚意识到,如果最终结果为零,我的完整实现也会尝试 bignums,这就是避免除法计数的地方。
    【解决方案5】:

    我想出的最简单的方法是制作点的标准化副本,并沿 x 或 y 轴将围绕它们的圆圈分成两半。然后使用相反的轴作为顶部或底部缓冲区的开始和结束之间的线性值(一个缓冲区在放入时需要以相反的线性顺序。)然后你可以线性读取第一个然后第二个缓冲区,它会顺时针,或逆时针第二和第一。

    这可能不是一个很好的解释,所以我在 GitHub 上放了一些代码,使用这种方法对具有 epsilion 值的点进行排序以调整数组大小。

    https://github.com/Phobos001/SpatialSort2D

    这可能不适合您的用例,因为它是为图形效果渲染性能而构建的,但它快速且简单(O(N) 复杂度)。如果您处理点的微小变化或非常大(数十万)的数据集,那么这将不起作用,因为内存使用可能超过性能优势。

    【讨论】:

      【解决方案6】:

      nice.. 这是一个返回 -Pi 的变体,Pi 像许多 arctan2 函数一样。

      编辑说明:将我的伪代码更改为正确的 python.. 更改 arg 顺序以与 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],你是指产品的标志吗?无论哪种情况,我都有些担心分母中的有符号值,因为这很容易使分母为零。例如。如果dx = dy = -1。还是我读错了你的语法?
      • 性能比较来自什么语言和编译器?
      • 是产品的标志。 (sign[dx]*sign[dy] 可能更快..)它只会在 0,0 上失败(就像其他人一样)。我在mathematica中进行了比较,但重点是您应该实际检查平台上的性能,而不是假设您正在击败内在函数。
      • 我相信我终于明白了这个分母中符号背后的魔力。与我的内联实现的优化 C 代码的比较显示出至少 10 倍的巨大速度增益。但我同意这取决于环境。特别是在解释型语言中,atan2 是一个本地函数调用,但伪角实现需要许多解释型算术运算,可能带有类型检查,那么使用伪角可能确实不利于性能。
      • 在python中重做..相对性能优于mathematica
      【解决方案7】:

      如果角度本身不需要,而仅用于排序,那么@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也可以这样说。

      【讨论】:

        【解决方案8】:

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

        【讨论】:

        • 叉积是commonly defined 在ℝ³中运算并产生一个向量。根据我的直觉和MathWorld,ℝ² 中最匹配的类比是行列式。但行列式在角度上不是单调的。它是角度余弦的函数,因此将产生相同的值,例如60° 和 120°。
        猜你喜欢
        • 2019-10-30
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2020-06-17
        • 1970-01-01
        相关资源
        最近更新 更多