Python bisect 区间计数:重复时间戳遇到开闭边界,怎样一条都不漏
先确定边界上的事件归谁
统计某段时间里的请求数时,最容易出错的并非查找速度,而是恰好落在边界上的记录。假设第十秒发生两次请求,第十五秒发生三次请求,同样写“十到十五秒”,包含两端与只包含左端就会相差三条。先把区间约定写清楚,才能判断结果究竟是六条还是三条。
本文用整数表示统一单位的事件时间,同一个整数可以出现多次,每次出现都算一条事件。方括号表示包含端点,圆括号表示排除端点。相邻统计窗口适合统一采用左闭右开:上一段不收右端点,下一段收左端点,边界事件便只归入一个窗口。
把数值边界换成数组位置
在已经升序排列的序列里,bisect_left 返回第一个不小于目标值的位置,bisect_right 返回第一个大于目标值的位置。两者都返回插入位置,目标值不存在时也有结果;重复值存在时,它们分别落在整段重复值的左侧和右侧。不要把返回值直接当成某条命中记录的下标。
要包含左端点,就从左侧插入位置开始;要排除左端点,就越过它的全部重复值。右边界则相反:包含时取右侧位置,排除时取左侧位置。结束位置减去开始位置,就是条数。整个计算只需要两个位置,不必真的切出一份事件列表。
一次运行核对四种区间
下面保存为 Python 文件运行,只使用标准库。函数接受已排序序列,遇到左端点大于右端点会明确报错。端点相同而又没有同时包含两端时,区间为空,先返回零,避免两次插入位置相减得到负数。后半段还用逐条判断作为独立对照,覆盖多组端点。
AI生成概念示意图,非真实界面
from bisect import bisect_left, bisect_right
from itertools import product
events = tuple(sorted([15, 10, 20, 15, 12, 10, 15]))
def count_range(values, low, high, include_low=True, include_high=False):
if low > high:
raise ValueError('low must not exceed high')
if low == high and not (include_low and include_high):
return 0
start = (bisect_left if include_low else bisect_right)(values, low)
stop = (bisect_right if include_high else bisect_left)(values, high)
return stop - start
cases = [('[10,15]', True, True, 6),
('[10,15)', True, False, 3),
('(10,15]', False, True, 4),
('(10,15)', False, False, 1)]
for label, left, right, expected in cases:
actual = count_range(events, 10, 15, left, right)
assert actual == expected
print(label, actual)
assert count_range(events, 15, 15, True, True) == 3
assert count_range(events, 15, 15, False, False) == 0
assert count_range((), 0, 1) == 0
print('equal:', count_range(events, 15, 15, True, True))
print('empty:', count_range(events, 16, 19, True, True))
try:
count_range(events, 20, 10)
except ValueError:
print('reversed: rejected')
else:
raise AssertionError('reversed endpoints were accepted')
for low, high in product(range(9, 22), repeat=2):
if low > high:
continue
for left, right in product((False, True), repeat=2):
expected = sum(
(x >= low if left else x > low)
and (x <= high if right else x < high)
for x in events
)
assert count_range(events, low, high, left, right) == expected
print('oracle checks passed')前四行应依次输出 [10,15] 6、[10,15) 3、(10,15] 4、(10,15) 1。接着 equal 显示三个等于十五的事件,empty 显示零,reversed 显示 rejected,最后出现 oracle checks passed。四种结果的差异完全来自端点重复项,没有删重步骤。
别用减一或极小数修补边界
当时间单位是秒时,把右端点减一似乎可以排除边界;换成毫秒、微秒或小数时间后,这个写法就改变了统计范围。直接选择左侧或右侧插入位置,才能把“是否包含相等值”与“时间单位是多少”分开。本文选择整数也便于避免浮点近似值影响相等判断。
排序是函数的前提,二分查找不会替你验证。示例只在准备数据时排序一次;每次查询再排序会让一次对数级定位退化为排序开销。真实事件还带有编号与内容时,可以另建一份排序键列表,但必须保证它与记录保持同一顺序,更新时一起维护。
时间来源也要统一。两条格式相似的本地时间文字,可能来自不同的时区;把它们直接按字符串排序,并不能保证真实发生顺序。应先按业务约定转换成可比较的时间值,再保存统计单位。含有无法建立一致大小关系的值时,应在数据入口拒绝或单独处理。
批量查询期间保持这份排序数据不变。若另一个执行流程同时插入或删除,两个边界可能来自不同状态,计数就失去意义。这里用不可变元组固定演示数据;实际服务可以为一轮报表建立快照,并在结果里记录该快照覆盖到什么时间。
验收时至少保留三类样本:边界有重复项、整个区间没有事件,以及两个端点相同。只用互不重复的中间值测试,很难看出左右两种插入位置的区别。若业务需要统计不同时间点的数量,应另行声明去重规则,不能悄悄把事件条数换成时间点数。


