← 返回目录


在一条直线上给定n个点的取值区间,如何让相邻点之间的最小间隔最大化?

钻研人类记忆,探索复习算法。改善教育公平,践行自由学习。

28 👍 / 11 💬

问题描述

n 个点的取值区间可能相互重叠,且点的取值为整数。相邻点是指取点后在直线上相邻的点,而非从相邻区间中取出的点。

case 1:有 3 个点,取值区间是[1, 3], [2, 6], [0, 9]。最优的取点方法是 1, 5, 9,相邻点之间的最小间隔是 4。

case 2: [1, 10], [2, 7], [5, 6]。最优的取点方式是 10, 2, 6,最小间隔是 4。

附加题:在满足最小间隔最大化的条件下,让相邻点之间的间隔尽可能大,即平均间隔最大。并在平均间隔最大的同时,间隔的方差最小。

例如:存在几个点的取值区间非常窄,且相互靠近。比如 [0, 0], [1, 1], [2, 5], [4, 7],此时最小间隔只可能是 1,输出可能是 0, 1, 2, 4。但实际上更好的输出是 0, 1, 4, 7。

可以使用近似算法。


快过去三年了,突然想起这个问题,就想考考 GPT-6,结果它从一篇 1981 年的论文中挖出了一个复杂度为 O(n\log n\log W) 的答案。感谢之前回答问题的网友们( @AVALON@Yixiao Huang@Mass Point@Serika Onoe@wywzxxz ),感兴趣的话可以看看 GPT-6 的回答。


这个问题可以精确求解。即使区间互相重叠、互相包含,主问题仍有 O(n\log n\log(W+2)) 的算法,其中 W 是所有区间覆盖的总跨度。

解题路线是:二分最小间隔,把每次判定转成等长任务调度;求出最优最小间隔后,再处理平均值和方差。 下面先把这条路线讲清楚,再分别给出主问题和完整附加题的 Python 实现。

设第 i 个区间为 I_i=[L_i,R_i],从中选出整数 x_i。将取出的点排序:

 y_1\le y_2\le\cdots\le y_n, \qquad d_k=y_{k+1}-y_k.

要最大化的是 \min_k d_k。区间与点的对应关系不能丢,但点在直线上的顺序可以改变。

以下假设 n\ge2、区间非空且端点为整数。实数端点可以先向内取整:左端点取上整,右端点取下整;取整后出现空区间则无解。

先把“求最大值”变成“判断能否做到”。 固定一个候选间隔 g>0,问是否存在

 x_i\in[L_i,R_i]\cap\mathbb Z, \qquad |x_i-x_j|\ge g\quad(i\ne j).

这与相邻点间隔至少为 g 等价,并且具有单调性:间隔 5 能做到,间隔 4 就能做到。

 W=\max_iR_i-\min_iL_i.

因为 n 个点有 n-1 个相邻间隔,所以

 (n-1)g\le y_n-y_1\le W.

答案必在 0\lfloor W/(n-1)\rfloor 之间。只要能精确判断一个 g 是否可行,二分就能找到最大值,判定次数为 O(\log(W+2))g=0 时任取区间内的整数即可,因此只需讨论正间隔。

这个判定器来自等长任务调度。 给每个点 x_i 向右接一根长度为 g 的线段 [x_i,x_i+g)。点之间至少相隔 g,恰好等价于这些半开线段互不重叠。

于是,每个区间都可以变成一个虚拟任务:

任务属性取值
最早开始时间L_i
处理时长g
最晚完成时间R_i+g

任务开始时间就是要选的点。截止时间必须加上 g,因为原来的 R_i 限制的是开始时间。这个转换是双向的:可行取点对应可行调度,可行调度的开始时间也满足原区间和间隔约束。

Garey、Johnson、Simons 和 Tarjan 在 1981 年给出了这个调度问题的 O(n\log n) 精确算法。将时间除以 g,就是原论文中处理时长为 1、释放时间和截止时间可以非整数的情形。原论文

算法处理包含关系的办法,是先找出“不能开始任何任务”的时间段,再安排任务。 普通的就绪任务贪心会抢占尚未释放的紧迫任务所需的空间,禁区正是用来阻止这种抢占。

先说明禁区如何产生。按释放时间 r 从大到小处理,对一个截止时间 D,考察所有释放时间不早于 r、完成截止时间不晚于 D 的任务。暂时只保留这个集合的共同边界和已经发现的禁区,将它们视为相同长度的块,从 D 向前尽量紧排。设最前面的块最晚能在 c 开始:

第二条可以直接解释:其他任务若在这个区间开始,会占用时间直到 c 之后;而这批任务在它开始之前还没释放,只能被迫整体延后,超过刚才算出的容量限制。对整数日期,禁区对应 [c-g+1,r-1]

用题目的 case 2 演示整个过程。记

 A=[1,10],\quad B=[2,7],\quad C=[5,6],

检查 g=4 时,三个任务都是长度 4:

任务最早开始最晚开始最晚完成
A11014
B2711
C5610

从右向左分析释放时间,先处理 C。C 最早在 5 开始,最晚在 6 开始,因此临界时间为 c=6,得到禁区 (2,5):其他任务在 3 或 4 开始,就会运行到 7 或 8,挤掉 C。

再处理 B、C 的共同约束:它们都不能早于 2 开始,都必须在 11 前完成。从 11 倒排两个长度块,后一个可放在 [7,11),前一个原本应在 [3,7)。但 3 已落入禁区,只能把前一个块移到 [2,6)。因此这批任务中至少一个必须不晚于 2 开始,产生新禁区 (-2,2)

这里的倒排只是计算容量上界,尚未给 B、C 分配具体位置。它的关键结论是:A 不能在 1 开始,否则就会挡住 B、C。 继续处理 A 不会增加新的有效禁区,最终的整数禁区为 [-1,1][3,4]

现在从左向右安排:

  1. 最早开始时间 1 被禁止,跳到 2。A、B 都已就绪,B 的截止时间更早,先安排 B。
  2. 到 6,C 已就绪且比 A 更紧迫,安排 C。
  3. 到 10,安排 A。

得到 B=2,C=6,A=10,按输入顺序返回就是 (10,2,6)。最窄的区间 C 并没有排在最前面,宽区间 A 则被留到最后。

原论文证明,按释放时间从右向左构造禁区之后,遵守禁区的最早截止时间调度是精确的可行性判定器。未知顺序由调度产生,区间的包含关系没有破坏“每个任务只有一个连续窗口、所有任务长度相同”的结构。一般嵌套区间的多项式可解性也在区间分散问题的后续论文中被明确讨论。Biedl 等,2021,§3.1

要达到 O(n\log n),还需要避免逐个更新全部截止日期。 论文的 Algorithm B 将判定组织成三类信息的批量维护;这份 Python 实现使用下面的数据结构:

需要维护的信息批量处理方式
各截止日期之前必须完成多少任务树状数组维护计数,通过前缀和查询负载
哪些截止日期仍可能形成最紧约束永久删除已经被支配的截止日期
跳过禁区产生的伪偏移按模 g 的余数分组,用带权并查集合并

每个截止日期最多被删除一次,已经合并的成员不会再拆开,整个过程只有 O(n) 次建组、删除和合并。每次维护或查询花费 O(\log n),加上排序和最终的堆调度,单次判定就是 O(n\log n)。这也是代码中多个循环仍不会产生二次复杂度的原因。

整数条件也不会造成额外困难。对任意实数可行调度,沿其已有顺序 \pi 尽量向左安排:

 t_{\pi(1)}=L_{\pi(1)},\qquad t_{\pi(k)}=\max\!\left(L_{\pi(k)},t_{\pi(k-1)}+g\right).

得到的时间都是整数,而且归纳可知,它们不会晚于原调度的对应时间,因此仍满足右端点限制。这证明固定整数 g 时,实数可行性与整数可行性一致。

于是主问题的总复杂度为

 \boxed{O(n\log n\log(W+2))},

额外空间为 O(n)。若端点使用至多 b 个二进制位表示,则 \log(W+2)=O(b),所以这个算法对输入长度是多项式时间;计入大整数运算的位成本也不改变这一结论。

下面给出主问题的完整实现,只依赖 Python 标准库。函数返回 (最优最小间隔, 按原输入区间顺序排列的取点结果)。代码中的普通贪心是快速尝试;失败后仍会进行精确检查。只有左右端点同时有序的特例,才能依据交换论证直接判定贪心失败意味着不可行。

from bisect import bisect_left, bisect_right
from heapq import heappop, heappush


class Fenwick:
"""树状数组:前缀和、单点更新、按排名查找。"""

def __init__(self, size, full=False):
self.size = size
self.tree = [0] + ([i & -i for i in range(1, size + 1)] if full else [0] * size)
self.top_bit = 1 << (size.bit_length() - 1) if size else 0

def add(self, index, delta):
index += 1
while index <= self.size:
self.tree[index] += delta
index += index & -index

def prefix_sum(self, end):
total = 0
while end:
total += self.tree[end]
end -= end & -end
return total

def select(self, rank):
index = 0
bit = self.top_bit
while bit:
candidate = index + bit
if candidate <= self.size and self.tree[candidate] < rank:
rank -= self.tree[candidate]
index = candidate
bit >>= 1
return index


class Offsets:
"""按模 gap 的余数分组,用带权并查集维护伪偏移。"""

def __init__(self, deadlines, gap):
self.gap = gap
self.phases = []
for phase in sorted(deadline % gap for deadline in deadlines):
if not self.phases or phase != self.phases[-1]:
self.phases.append(phase)
self.present = Fenwick(len(self.phases))
self.roots = [-1] * len(self.phases)
self.parents = [-1] * len(deadlines)
self.sizes = [1] * len(deadlines)
self.weights = [0] * len(deadlines)

def offset(self, index):
if self.parents[index] < 0:
return 0
value = self.weights[index]
while self.parents[index] != index:
index = self.parents[index]
value += self.weights[index]
return value

def _union(self, first, second):
if self.sizes[first] < self.sizes[second]:
first, second = second, first
self.parents[second] = first
self.weights[second] -= self.weights[first]
self.sizes[first] += self.sizes[second]
return first

def activate(self, index, deadline):
self.parents[index] = index
phase = bisect_left(self.phases, deadline % self.gap)
if self.roots[phase] < 0:
self.roots[phase] = index
self.present.add(phase, 1)
else:
self.roots[phase] = self._union(self.roots[phase], index)

def forbid(self, start, end):
left = start % self.gap
right = end % self.gap
target = bisect_left(self.phases, left)
assert self.phases[target] == left and self.roots[target] >= 0
if left < right:
self._merge_phases(
target, bisect_right(self.phases, left), bisect_left(self.phases, right)
)
else:
self._merge_phases(
target, bisect_right(self.phases, left), len(self.phases)
)
self._merge_phases(target, 0, bisect_left(self.phases, right))

def _merge_phases(self, target, start, end):
rank = self.present.prefix_sum(start) + 1
phase = self.present.select(rank)
while phase < end:
root = self.roots[phase]
self.weights[root] += (self.phases[phase] - self.phases[target]) % self.gap
self.roots[target] = self._union(self.roots[target], root)
self.roots[phase] = -1
self.present.add(phase, -1)
phase = self.present.select(rank)


def _dispatch(points, min_gap, release_order, starts, ends):
ready = []
pending = 0
date = points[release_order[0]][0]
arrangement = [0] * len(points)
for _ in points:
if not ready:
date = max(date, points[release_order[pending]][0])
region = bisect_right(starts, date) - 1
if region >= 0 and date <= ends[region]:
date = ends[region] + 1
while pending < len(points) and points[release_order[pending]][0] <= date:
i = release_order[pending]
left, right = points[i]
heappush(ready, (right, left, i))
pending += 1
right, _, i = heappop(ready)
if date > right:
return None
arrangement[i] = date
date += min_gap
return arrangement


def _forbidden(points, min_gap, release_order):
"""构造禁用区间;候选间隔不可行时返回 None。"""
deadlines = []
for right in sorted(right for _, right in points):
deadline = right + min_gap
if not deadlines or deadline != deadlines[-1]:
deadlines.append(deadline)
count = len(deadlines)
loads = Fenwick(count)
relevant = Fenwick(count, full=True)
predecessor = list(range(-1, count - 1))
offsets = Offsets(deadlines, min_gap)

def critical(index):
return (
deadlines[index]
- min_gap * loads.prefix_sum(index + 1)
- offsets.offset(index)
)

forbidden_starts = []
forbidden_ends = []
first_active = count
next_offset = count - 1
pending = len(points) - 1
while pending >= 0:
release = points[release_order[pending]][0]
while pending >= 0 and points[release_order[pending]][0] == release:
_, right = points[release_order[pending]]
index = bisect_left(deadlines, right + min_gap)
loads.add(index, 1)
successor = relevant.select(relevant.prefix_sum(index) + 1)
successor_time = critical(successor)
while (
predecessor[successor] >= 0
and critical(predecessor[successor]) > successor_time
):
removed = predecessor[successor]
relevant.add(removed, -1)
predecessor[successor] = predecessor[removed]
if first_active == removed:
first_active = successor
first_active = min(first_active, successor)
pending -= 1
latest_start = critical(first_active)
if latest_start < release:
return None
lower = latest_start - min_gap + 1
upper = release - 1
if lower <= upper:
while next_offset >= 0 and deadlines[next_offset] > latest_start:
offsets.activate(next_offset, deadlines[next_offset])
next_offset -= 1
offsets.forbid(latest_start - min_gap, release)
if forbidden_starts and upper >= forbidden_starts[-1] - 1:
forbidden_starts[-1] = min(forbidden_starts[-1], lower)
else:
forbidden_starts.append(lower)
forbidden_ends.append(upper)
forbidden_starts.reverse()
forbidden_ends.reverse()
return forbidden_starts, forbidden_ends


def _place(points, min_gap):
"""固定最小间隔的精确判定,返回原区间顺序的点。"""
if not points or min_gap == 0:
return [left for left, _ in points]
release_order = sorted(
range(len(points)), key=lambda i: (points[i][0], points[i][1], i)
)
arrangement = _dispatch(points, min_gap, release_order, [], [])
if arrangement is not None:
return arrangement
if all(
points[first][1] <= points[second][1]
for first, second in zip(release_order, release_order[1:])
):
return None
regions = _forbidden(points, min_gap, release_order)
if regions is None:
return None
return _dispatch(points, min_gap, release_order, *regions)


def maximin_gap(points):
"""返回 (最优最小间隔, 按原区间顺序排列的取点结果)。"""
points = [tuple(bounds) for bounds in points]
if len(points) < 2:
raise ValueError("至少需要两个区间")
if any(
len(bounds) != 2 or any(not isinstance(v, int) for v in bounds)
for bounds in points
):
raise ValueError("区间端点必须是整数")
if any(left > right for left, right in points):
raise ValueError("区间不能为空")
arrangement = [left for left, _ in points]
min_gap = 1
max_gap = (max(right for _, right in points) - min(arrangement)) // (
len(points) - 1
)
best_gap = 0
while min_gap <= max_gap:
mid_gap = (min_gap + max_gap) // 2
candidate = _place(points, mid_gap)
if candidate is not None:
best_gap = mid_gap
arrangement = candidate
min_gap = mid_gap + 1
else:
max_gap = mid_gap - 1
return best_gap, arrangement

调用题目的两个例子:

print(maximin_gap([(1, 3), (2, 6), (0, 9)]))
print(maximin_gap([(1, 10), (2, 7), (5, 6)]))

输出:

(4, [1, 5, 9])
(4, [10, 2, 6])

两个例子的 W 都是 9,所以最小间隔至多为 \lfloor9/2\rfloor=4,这两个输出确实达到最优。独立实现还通过了 63261 组整数窗口穷举,以及 1000 组较宽窗口的独立参考算法对照。

接下来处理附加题。 上面的算法已经求出了最优最小间隔 g^*。接下来的选择必须在所有间隔都不小于 g^* 的方案中进行,不能为了平均值或方差牺牲它。

平均间隔有一个简单但重要的性质:

 \bar d=\frac{\sum_{k=1}^{n-1}d_k}{n-1} =\frac{y_n-y_1}{n-1}.

因此第二目标就是最大化最左点和最右点之间的跨度。中间点仍影响方案是否可行,但不直接出现在平均值公式中。

固定 g^* 后,这一步可以在 O(n^2\log n) 时间内完成。只需枚举最右点来自哪个区间 j,因为任意可行解的最右点都可以外移到该区间的右端点 R_j,而不会减小相邻间隔。

对每个 j,执行下面的步骤:

  1. 将区间 j 固定为 [R_j,R_j]
  2. 将其他区间裁剪为 [L_i,\min(R_i,R_j-g^*)],确保没有其他点超过 R_j,且它们与固定点至少相隔 g^*。出现空区间就跳过。
  3. 调用前面代码中的 _place(new_intervals, g_star)。若可行,记返回方案的最左坐标为 a_j=\min_i x_i
  4. 此次枚举的最大跨度为 R_j-a_j。在所有可行枚举中取最大值,即得到 S^*

这里用到了判定器的一个性质:它返回的可行方案具有最早可能的首点。 快速贪心成功时,首点就在最早释放时间;一般情形下,调度器也从最早释放时间出发,只跳过任何可行方案都不能使用的禁区。因此,在固定最右点为 R_j 的子问题中,不可能还有一个首点比 a_j 更早的可行方案。这就同时保证了 R_j-a_j 是该子问题的最大跨度。

最右点有 n 种选择,每次裁剪需要 O(n),固定间隔判定需要 O(n\log n),所以第二阶段的时间上界为 O(n^2\log n)。这是一个可证明的算法上界,并不声称已经证明无法进一步改进。

固定最大跨度 S^* 后,平均值也固定了。方差为

 \operatorname{Var}(d) =\frac1{n-1}\sum_{k=1}^{n-1}d_k^2 -\left(\frac{S^*}{n-1}\right)^2.

因此第三目标等价于最小化间隔平方和,同时保持前两个目标的最优值。完整的优先级是

 \max\min d_k \quad\longrightarrow\quad \max(y_n-y_1) \quad\longrightarrow\quad \min\sum d_k^2.

附加题给出的例子可以直接手算。0 和 1 固定,故最小间隔至多为 1;最大跨度是 7,所以最后一个点取 7。令第三个点为 z,三个间隔就是 1,z-1,7-z。后两个间隔之和为 6,平方和在二者相等时最小,因此 z=4,得到

 \boxed{0,1,4,7}.

其最小间隔为 1、平均间隔为 7/3、方差为 8/9。方差只在前两个目标已经最优的方案之间比较;跨度更小的方案即使方差更小,也不应被选中。

为了用一份较短的代码处理完整附加题,可以使用 OR-Tools CP-SAT,按三个阶段依次求解。这个通用模型适合作为可复现实现和小规模基准,不具有前面专用主问题算法的复杂度保证

模型直接使用排序后的坐标 y_k,再用布尔变量 z_{ik} 表示“区间 i 的点是否占据位置 k”。每个区间和每个位置都恰好使用一次,顺序由求解器选择。每个阶段必须证明最优,才能固定目标值进入下一阶段;FEASIBLE 只表示有可行解,OPTIMAL 才表示已证明最优。CP-SAT 文档

安装依赖:

pip install ortools
from ortools.sat.python import cp_model


def solve_intervals(intervals):
n = len(intervals)
if n < 2 or any(left > right for left, right in intervals):
raise ValueError("需要至少两个非空整数区间")

base = min(left for left, _ in intervals)
width = max(right for _, right in intervals) - base
model = cp_model.CpModel()
y = [model.new_int_var(0, width, f"y{k}") for k in range(n)]

# z[i][k]:区间 i 的点占据排序后的第 k 个位置。
z = [[model.new_bool_var(f"z{i}_{k}") for k in range(n)] for i in range(n)]
for i, (left, right) in enumerate(intervals):
model.add(sum(z[i]) == 1)
for k in range(n):
model.add(y[k] >= left - base).only_enforce_if(z[i][k])
model.add(y[k] <= right - base).only_enforce_if(z[i][k])
for k in range(n):
model.add(sum(z[i][k] for i in range(n)) == 1)

gaps = [model.new_int_var(0, width, f"d{k}") for k in range(n - 1)]
for k in range(n - 1):
model.add(gaps[k] == y[k + 1] - y[k])
minimum = model.new_int_var(0, width // (n - 1), "minimum")
model.add_min_equality(minimum, gaps)

solver = cp_model.CpSolver()
solver.parameters.num_search_workers = 1

def solve_optimal():
status = solver.solve(model)
if status != cp_model.OPTIMAL:
raise RuntimeError(f"未证明最优:{solver.status_name(status)}")

# 第一阶段:最大化最小间隔,然后固定最优值。
model.maximize(minimum)
solve_optimal()
model.add(minimum == solver.value(minimum))

# 第二阶段:最大化平均间隔,等价于最大化总跨度。
span = y[-1] - y[0]
model.maximize(span)
solve_optimal()
model.add(span == solver.value(span))

# 第三阶段:平均值固定后,最小化间隔平方和。
# 平方约束只在此时加入,避免影响前两个阶段的求解。
squares = [model.new_int_var(0, width * width, f"q{k}") for k in range(n - 1)]
for square, gap in zip(squares, gaps):
model.add_multiplication_equality(square, [gap, gap])
model.minimize(sum(squares))
solve_optimal()

# 按原区间顺序返回取点结果。
return [
next(solver.value(y[k]) + base for k in range(n) if solver.value(z[i][k]))
for i in range(n)
]

运行三个例子,结果按原区间顺序返回:

print(solve_intervals([(1, 3), (2, 6), (0, 9)]))
print(solve_intervals([(1, 10), (2, 7), (5, 6)]))
print(solve_intervals([(0, 0), (1, 1), (2, 5), (4, 7)]))
[1, 5, 9]
[10, 2, 6]
[0, 1, 4, 7]

这份三阶段模型已在 OR-Tools 9.15.6755 上运行,并与 300 组随机小样本的完整三目标穷举结果一致。若在大实例中设置求解时间限制,尚未返回 OPTIMAL 的阶段应标为未证明最优,不能把限时得到的可行结果当作严格的三阶段最优解。


← 返回目录