在圆上尽可能均匀地分布点。

29

问题陈述

我有一个圆,上面有一定数量(零个或多个)的点。这些位置是固定的。现在我需要在圆上放置另一组点,使得所有点都尽可能均匀地分布在圆周上。

目标

我的目标是开发一个算法,该算法接收一个角度列表(代表固定点),一个整数值(代表应该放置多少个额外的点),并返回一个包含新点角度的角度列表。

这些点不必真正均匀分布(彼此距离相等),但应该尽可能均匀地分布。大多数情况下不存在完美的解决方案,因为某些点是固定的。

所有角度的范围位于 -pi 和 +pi 之间。

示例

下面是我想要实现的一些示例:

fixed_points = [-pi, -pi/2, pi/2]

 v         v                   v
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

fill_circle(fixed_points, 1)
# should return: [0]

fill_circle(fixed_points, 2)
# should return: [-pi/6, pi/6]

或:

fixed_points = [-pi, -pi*3/4, -pi/4]

 v    v         v
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

fill_circle(fixed_points, 6)

这个最后的示例应该返回类似于:一个点被设置在-pi*3/4和-pi/4之间,也就是-pi/2,并在-pi/4和+pi之间分配另外5个点(记住这是一个圆,所以在这种情况下-pi=+pi):

 v    v    x    v   x   x    x   x    x
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

之前的尝试

我最初使用了一个递归算法,首先搜索两个点之间的最大间隔,并将新点设置在它们中间。但是,这并没有给出令人满意的结果。例如,考虑以下配置,需要插入两个点:

 v         v                   v
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

first step: insert right in the middle of largest interval
 v         v         x         v
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

second step: insert right in the middle of largest interval 
-> all intervals are evenly large, so one of them will be taken
 v    x    v         v         v
 |---------|---------|---------|---------|
-pi     -pi/2        0        pi/2       pi

这不是一个很好的解决方案,因为它本可以更好地分布(参见上面:-pi/6和+pi/6)。

问题有点长,希望你能理解我的意图。

我不需要一个完整的工作算法,而是需要开发一个正确的思路。如果你愿意,也许可以提供一些伪代码作为提示。非常感谢给我指明正确方向的一些提示。提前感谢!

更新:已完成算法:

谢谢大家的回答!事实证明,我基本上只需要一个非贪婪版本的现有算法。我真的很喜欢haydenmuhls简化问题的想法,通过封装间隔/段类:

class Segment:
    def __init__(self, angle, prev_angle, wrap_around):
        self.angle = angle
        self.length = abs(angle - prev_angle + \
                          (2*math.pi if wrap_around else 0))
        self.num_points = 0
    
    def sub_length(self):
        return self.length / (self.num_points + 1)
    
    def next_sub_length(self):
        return self.length / (self.num_points + 2)
    
    def add_point(self):
        self.num_points += 1

这使得实际算法非常简单易懂:

def distribute(angles, n):
    # No points given? Evenly distribute them around the circle
    if len(angles) == 0:
        return [2*math.pi / n * i - math.pi for i in xrange(n)]
    
    # Sort the angles and split the circle into segments
    s, pi, ret = sorted(angles), math.pi, []
    segments = [Segment(s[i], s[i-1], i == 0) for i in xrange(len(s))]
    
    # Calculate the length of all subsegments if the point
    # would be added; take the largest to add the point to
    for _ in xrange(n):
        max(segments, key = lambda x: x.next_sub_length()).add_point()

    # Split all segments and return angles of the points
    for seg in segments:
        for k in xrange(seg.num_points):
            a = seg.angle - seg.sub_length() * (k + 1)
            # Make sure all returned values are between -pi and +pi
            ret.append(a - 2*pi if a > pi else a + 2*pi if a < -pi else a)
    
    return ret

9
“尽可能均匀”这个说法相当模糊。我认为你首先需要制定一个“尽可能均匀”的数学定义。给定两个n个点的分布,如何确定哪个更均匀? - haydenmuhl
你的方法问题在于你使用了贪心算法。它可以为一个点提供正确答案,但随后会变得混乱不堪。 - Wayne Werner
2
@haydenmuhl:你说得对,这确实很模糊。我会尽量更精确地说明:让v_0..v_n成为n+2个点之间的间隔(包括固定点和算法执行后计算出的点)。所有横向二次差异的总和:“Sigmai=0..n”应该是最小的。 - Philip Daubmeier
@Wayne:是的,那正是我观察到的。 - Philip Daubmeier
你确定要那个指标吗?相比我假设的最大最小值版本,那个指标的优化成本要高得多。我不确定除了随机梯度下降之外是否有更好的方法来解决这个问题。(你不能仅使用梯度下降,因为可移动点永远无法跳过固定点。) - Rex Kerr
9个回答

10
假设你已经有了M个点,还需要添加N个点。如果所有的点都是等距离的,则它们之间的空隙为2*pi/(N+M)。因此,如果你在这M个点处切割,将其分成角度片段,你可以将点均匀地放置在一个角度片段中(彼此间等距),直到空间小于或等于2*pi/(N+M)
因此,如果一个片段的长度是L,那么你应该在其中放置floor(L*(N+M)/(2*pi)) - 1个点。
现在你会有一些剩余的点。通过计算向每个片段添加一个点后得到的间距来对片段进行排序,然后将点添加到具有最低排名的片段中。将其重新插入排序列表并继续执行此操作,直到没有点剩余。
由于你在每次将点放入尽可能广泛分布的片段中,并且点之间的间距不依赖于添加它们的顺序,因此你最终会得到最优间距。
(编辑:这里的“最优”指的是“最大化点之间最小距离”,即尽可能避免点重叠在一起的最差情况。)
(编辑:希望能清楚地表明,这里的想法是先决定每个片段应放置多少点,然后在所有数字都确定后,再将它们均匀分布在每个片段中。)

1
不错的答案。我会考虑先将问题转化为“将M个物品切成N段”的形式,以消除圆形角度的混淆。 - Craig Gidney
我想我明白你的意思,但必须承认我有些迷糊。特别是我不明白何时会出现剩余的点。在我看来,最坏的情况是你实际上会比放置的点还要多,这是你的意思吗? - Matthieu M.
当您将floor(L*(N+M)/(2*pi))-1个点放入长度为L的线段中时,您几乎肯定会发现可以容纳的点比您想要的少(即少于N)。这是通过构造实现的--这仅放置您绝对确定适合的点,并留下其他点的放置方法,以确保您将它们放在最佳位置。 - Rex Kerr
感谢你的帮助,Rex。希望你不会因为我没有选择你的答案作为被采纳的答案而感到难过 : )。大多数答案都是正确的,而且你也是第一个提供答案的人。然而,我发现haydenmuhls的回答最具启发性。 - Philip Daubmeier
明白了。事实上,推导出一个通用公式似乎很困难,因此最后只能通过近似来放置剩余的点。 - Matthieu M.

5
您可以使用一个Interval对象。 一个区间是圆上两个原始不可移动点之间的弧。

以下只是伪代码。 不要期望它在任何地方运行。

class Interval {

  private length;
  private point_count;

  constructor(length) {
    this.length = length;
    this.point_count = 0;
  }

  public add_point() {
    this.point_count++;
  }

  public length() {
    return this.length;
  }

  // The current length of each sub-interval
  public sub_length() {
    return this.length / (this.point_count + 1);
  }

  // The sub-interval length if you were to add another point
  public next_sub_length() { 
    return this.length / (this.point_count + 2);
  }

  public point_count() {
    return this.point_count;
  }
}

创建一个对象列表,这些对象对应于您的圆上点之间的间隔。每次添加一个点时,选择具有最大next_sub_length()的间隔。完成后,重建新的圆不难。
这将为您提供具有最大可能最小间隔的间距。也就是说,如果您按其最小间隔的长度评分解决方案,则这将为您提供可能的最高分数。我想这就是您一直追求的目标。
编辑:刚刚意识到您特别要求使用Python进行此操作。虽然我是Python新手,但您应该能够轻松将其转换为Python对象,尽管您不需要getter,因为Python中的所有内容都是公开的。

+1并接受,因为这是实际上引导我到一个漂亮而干净的算法的答案。 - Philip Daubmeier

4
假设点之间的间隔为a_1 ... a_n。然后,如果我们将每个线段分成最小大小为d的块,则可以在线段中放置floor(a_i/d) - 1个点。这意味着sum(floor(a/d) for a in interval_lengths)必须大于或等于n + s,其中n是我们要添加的点数,s是已经存在的点数。我们希望尽可能选择d最大,最好只需对最佳d进行二进制搜索。
一旦我们选择了d,只需通过每个线段添加每个d度的点,直到线段中剩余不到2 d度为止。
编辑:您需要做的就是找到d,使得sum(floor(a/d) for a in interval_lengths) == n + s,然后将floor(a_i/d) - 1分配给每个段i,每a_i/(floor(a_i/d) - 1)度。二进制搜索将快速找到这个值。
进一步编辑:
这是查找d的代码。
def get_d(n, a, lo=0, hi=None):
    s = len(a)
    lo = 360./(s + n)
    hi = 2. * lo
    d = (lo + hi)/2
    c = sum(floor(x/d) for x in a)
    while c != (n + s):
        if c < (n + s):
            hi = mid
        else:
            lo = mid
        d = (lo + hi)/2
        c = sum(floor(x/d) for x in a)
    return d

如果“尽可能均匀分布”意味着“最大化任意两点之间的最小角距离”,那么这个方法是可行的。还有其他衡量“均匀分布”的方法,这种方法可能不会产生最优结果。但仍然相当不错。+1 - Walter Mundt

2

首先,我们重新定义术语如下: 找到N个点的分布,使得这些点中任意两点之间的最小距离长度与M个预定义点的最小距离长度相比最大。 因此,您的任务是找到这个最小长度的最大值。将其称为L。 假设您有M个现有线段的长度,将它们存储在列表s中。所以如果这个长度是L,首先。

min(s) > L

最大额外分数为

 f(L) = sum(ls/L -1 for ls in s)

所以你可以使用二分查找来找到最优的L,从0开始最小值为L,最大值为s中的最小值,检查条件是如果对于每个s中的ls,sum(ls/L -1) >= N。然后对于每个段落s[i],你可以平均地放置s[i]/L-1个点。 我认为这是最优解决方案。
更新:在min(s) > L中有缺陷。对于重新定义的术语来说已经足够好了,但对于原始术语来说是错误的。我已将此条件更改为max(s) > L。另外,在二分查找中跳过长度小于L的部分。 以下是完整的更新代码:
from math import pi,floor
def distribute(angles,n):
    s = [angles[i] - angles[i-1] for i in xrange(len(angles))]
    s = [ls if ls > 0 else 2*pi+ls for ls in s]
    Lmin, Lmax = 0., max(s)
    while Lmax - Lmin >1e-9:
        L = (Lmax + Lmin)/2
        if sum(max(floor(ls/L) -1,0) for ls in s ) < n: Lmax = L
        else : Lmin = L
    L= Lmin
    p = []
    for i in xrange(len(s)):
        u = floor(s[i]/L) -1
        if u <= 0:continue
        d = s[i]/(u+1)
        for j in xrange(u):
            p.append(angles[i-1]+d*(j+1))
    return p[:n]
print distribute((0, pi/4),1)
print distribute((-pi,-pi/2,pi/2),2

1
谢谢您的回答。然而,在某些情况下它似乎不起作用,特别是当固定点紧密地靠在一起,并且只需要插入几个点时:例如 distribute((0, pi/4),1) :两个固定点位于0°和+45°上,而要插入的一个点应该位于圆的对面,正好在-157.5°处,但是您的算法返回了+90°。 - Philip Daubmeier
+1 你的算法现在完美地运行了。感谢你实际编写可工作代码所付出的努力。尽管如此,我还是实现了自己的算法,只是因为...你懂的...想要亲手写一下 :D - Philip Daubmeier

1

你从来没有说过“如何精确地测量均匀间隔”是怎么衡量的。是完美间隔大小的总均方根方差,还是其他什么?

如果您查看任何特定的开区间,我相信在该区间中放置k个点的最佳解决方案将始终平均分配它们。因此,问题就简化为选择最小间隔大小的截止点,以获得一定数量的插值点。做完之后,如果您没有足够的点要分配,请从最大到最小每个间隔删除一个点,然后重复直到获得合理的结果。

我不确定选择截止点的最佳方法是什么。


0

我有一个名为"condition"的函数,它接受两个参数 - 一个分子(常量)和一个分母(传引用)。它会根据需要"增大"或"缩小"分母的值,直到分子中能够容纳整数个"分母",也就是使得分子/分母为整数。

分母是增大还是缩小取决于哪种方式会导致变化较小。

将分子设置为2*pi,将分母设置为接近所需间距的任何值,你应该会得到非常接近均匀分布的结果。

请注意,我还有一个名为"compare"的函数,用于在一定的容差范围内比较两个双精度数是否相等。

bool compare( const double num1, const double num2, const double epsilon = 0.0001 )
{
     return abs(num1 - num2) < epsilon;
}

那么条件函数就是

void condition(const double numerator, double &denominator)
{
  double epsilon = 0.01;
  double mod = fmod( numerator, denominator );
  if( compare(mod, 0) )
    return;
  if( mod > denominator/2 ) // decide whether to grow or shrink
    epsilon *= -1;

  int count = 0;
  while( !compare( fmod( numerator, denominator ), 0, 0.1) )
  {
    denominator += epsilon;
    count++;
    if( count > 10000 ) // a safety net
      return;
  }
}

希望这能有所帮助,我知道这个小算法在很多时候都非常有用。


0

我建议您将问题考虑为:

  • 一条包裹线 - 允许您轻松确定点间距离,然后重新映射到圆形上

或者

  • 考虑点与圆心之间的角度而不是弧距离。同样,这简化了新点的定位,并且可能更容易重新映射到圆边缘点。找到已放置点之间的所有角度,然后平分最大角度(或类似角度),并在圆边缘上的适当点放置新点。

这是我已经创建的两个抽象。很抱歉,但这并不是我的问题的答案。 - Philip Daubmeier

0
一个想法,将角度写成列表(以度为单位): [30, 80, 120, 260, 310] 转换为差值: [50, 40, 140, 50, 80] 注意我们要回到第一个角度,即310+80(mod 360)= 30 对于要添加的每个点,分割最大的差值: n = 1,分割140: [50, 40, 70, 70, 50, 80] n = 2,分割80: [50, 40, 70, 70, 50, 40, 40] 转换回角度: [30, 80, 120, 190, 260, 310, 350]

不错的想法,但它本质上做的是我之前(不太优化)算法所做的事情:搜索最大间隔并将其分成两部分。无论如何,感谢您的回答。 - Philip Daubmeier

0
从数组 [30, 80, 120, 260, 310] 开始,并添加 n = 5 个角度, 给定的算法(见下文)得出 [30, 55, 80, 120, 155, 190, 225, 260, 310, 350], 其角度差的均方根 rms(diff) = sqrt[sum(diff * diff)] / n = 11.5974135047, 在实际应用中似乎是最优的。


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