不会跑

Python、Linux 与算法实践笔记

23 Jun 2017

Python bisect:用时间窗口理解左右边界

作者:咩 · 更新于 2026-09-13

假设一段日志的事件时间已经按升序整理成 [1000, 1010, 1010, 1020],现在要统计从 1010 开始、到 1020 之前发生了几次事件。遍历一次当然能得到答案,但如果同一批日志要回答很多时间窗口查询,就可以利用已经有序这个条件。

本文的例子使用 Python 3.9 及以上版本,只依赖标准库。列表中的时间是为了说明边界而设定的整数,不涉及实际日志数据。

先确定时间窗口怎样包含边界

本文采用半开区间 [start, stop):包含开始时刻,不包含结束时刻。这样,连续窗口 [1000, 1010)[1010, 1020) 不会重复统计恰好发生在 1010 的事件。

bisect_left(times, start) 找到第一个不小于 start 的位置;对 stop 再执行一次同样的查找,就得到第一个不属于当前窗口的位置。两者之差就是事件数量。这里的语义依据 Python bisect 文档,下面用具体数据检查它如何影响查询结果。

查询 左边界下标 右边界下标 事件数
[1000, 1010) 0 1 1
[1010, 1020) 1 3 2
[1010, 1010) 1 1 0
[1021, 1030) 4 4 0

重复时间戳保留的是两条事件,不能先用 set 去重。查找返回 4 也是合法结果,它表示位置在列表末尾之后,并不表示存在下标为 4 的元素。

一个可以直接运行的实现

下面的函数约定 times 已经升序排列。它不会在每次查询前重新排序,也不会切片复制命中的事件。如果数据来自外部输入,应在导入时先校验时间类型并完成排序。

from bisect import bisect_left, bisect_right


def count_in_window(times, start, stop):
    if stop < start:
        raise ValueError("stop must be greater than or equal to start")
    return bisect_left(times, stop) - bisect_left(times, start)


times = [1000, 1010, 1010, 1020]
assert count_in_window(times, 1000, 1010) == 1
assert count_in_window(times, 1010, 1020) == 2
assert count_in_window(times, 1010, 1010) == 0
assert count_in_window(times, 1021, 1030) == 0
assert count_in_window([], 1000, 1020) == 0
assert count_in_window(times, 0, 2000) == len(times)

# 如果业务改成包含结束时刻,右边界才改用 bisect_right。
assert bisect_right(times, 1020) - bisect_left(times, 1010) == 3

try:
    count_in_window(times, 1020, 1010)
except ValueError:
    pass
else:
    raise AssertionError("reversed window was accepted")

print("time-window checks passed")

保存为 bisect_window.py 后执行 python3 bisect_window.py,所有检查通过时输出 time-window checks passed。开始、结束相同的窗口应该为空,而开始晚于结束是输入错误,两者不能都被默默当作零条事件。

插入位置不等于“找到了这个元素”

如果想判断事件时间 1015 是否存在,bisect_left(times, 1015) 会返回 3;那里实际存放的是 1020。这个结果只说明把 1015 放到下标 3 可以维持顺序。

因此,“判断是否存在”还需要同时检查 i < len(times)times[i] == target。如果要找不晚于目标时间的最近事件,可以取 bisect_right(times, target) - 1,但必须先排除结果为 -1 的情况;Python 的负下标会读取最后一个元素,容易把“没有历史事件”误判为“最后一条事件”。

左右边界的区别在重复值处尤其明显:对 1010,bisect_left 返回 1,bisect_right 返回 3。它们之间正好夹住所有等于 1010 的事件。

二分查找快,不代表列表插入也快

这个计数函数执行两次二分查找,查询时间是 O(log n),额外空间是 O(1)。如果还要返回窗口内的 k 条事件,切片和输出本身就需要 O(k) 的工作。

insort 则分成两步:先定位,再向 Python 列表中间插入。第二步可能移动后面的元素,所以整体仍是 O(n)。一批乱序数据如果逐条插入,最坏会积累到 O(n²);只需要最后得到有序结果时,一次 sorted 更合适。不能仅因为名字里有二分查找,就断言它一定比追加后排序更快。

对于这个日志查询例子,实用的安排是:先整理一批有序时间,再让多个查询复用它。如果多个线程同时修改同一个列表,查询边界可能失效,需要在外层统一同步;本文的函数只处理查询期间保持不变的列表。

最长递增子序列还有一个不同的用法:利用 bisect_left 定位并替换已有元素。替换不会移动后面的列表元素,所以能保住算法整体的 O(n log n) 时间。

comments powered by Disqus