qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

窗口里的最小值走了,为什么不能只记住一个最小数?

用会过期的下标保存候选,观察相等值如何替换,以及双重循环为何仍可只做线性总工作。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看问题:最小值也有有效期
  4. 下标管有效期,数值管谁更有希望
  5. 按轨迹看:队列只留可能接班的记录
  6. while 套在 for 里,为什么总量仍是线性的
  7. 保存一个文件,运行轨迹和穷举核对
  8. 把输入约定说清楚,再讨论结果
  9. 什么时候可以照这个思路改,什么时候需要重想
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
单调队列
只保留有机会成为窗口答案的候选,并让候选值按一个方向排列的队列。
摊还分析
把一整串操作的工作量合起来计算,不要求每一步都一样快。
打开完整术语小抄 ↗

仪表盘每次显示最近三条记录的最低耗时。只保存目前最小的数看起来很省事,但那条记录离开窗口后,程序还得知道谁能接替它。

本项目把“还能成为答案的记录”排成一个队列。它保存原下标,因此既能比较大小,也能判断哪条记录已经过期;原始记录不会被删除。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。固定宽度整数列表;21324 组有限输入 oracle 核对和5类拒绝输入,含过期/重复值/负数/宽度1与全长;轨迹复制另计成本,未做性能基准。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

窗口向右走,哪些下标该离开队列?

正在准备实验控件…

  1. 下标 04
  2. 下标 12
  3. 下标 22
  4. 下标 35
  5. 下标 41
  6. 下标 53

本步读入:下标 0,值 4

从队首删除的过期下标:无

从队尾删除的旧候选下标:无

留下的候选(下标:值):0:4

当前窗口最小值:还没有读满一个窗口

已产生的结果:暂无

固定六个数,队尾用 ≥ 删除被新值支配的旧候选,因此相等时保留更新的下标。展示的是最小值队列;不同等号策略可能保留不同候选,但应得到同样的窗口最小值。

先看问题:最小值也有有效期

把 [1, 2, 3, 4] 按宽度 2 向右滑动,三个完整窗口分别是 [1, 2]、[2, 3]、[3, 4],最低值应当是 [1, 2, 3]。如果只记住历史最小值 1,后两个窗口就错了。

每次重新扫描窗口容易理解,下面也用它作独立核对方式。但长度为 n、宽度为 k 时,这种直接做法可能重复查看同一项,最坏需要 O(nk) 次量级的工作。我们想保留足够的信息,避免每次从头找。

下标管有效期,数值管谁更有希望

处理下标 i 时,当前范围是 [i-k+1, i],两端都包含。队首下标若满足 <= i-k,就已经落在范围左侧,应先移除。窗口尚未凑够 k 项时只准备候选,不输出结果。

新值从右边进入。若队尾旧值大于或等于新值,就可以移除旧候选:新值不更大,而且来得更晚、也更晚过期。对于以后的完整窗口,只要这个旧候选仍有效,新候选也有效,因此旧候选不会独自成为更好的答案。

保存下标后,重复的 2 仍然可以区分来源。这里用 >= 移除相等旧值,保留较新的那个;若用 > 保留相等值,也能求出相同的最小值,但队列形状和最小值位置的选择规则会不同。

按轨迹看:队列只留可能接班的记录

默认数据是 [4, 2, 2, 5, 1, 3],宽度 3。读到下标 1 的 2,旧 4 被淘汰;读到下标 2 的另一个 2,旧 2 被更新的 2 替换。队列此时只剩下标 [2],第一个完整窗口的答案是 2。

下标 3 的 5 进入后,队列是 [2, 3]。下标 4 的 1 到来时,先弹出代表 5 的下标 3,再弹出代表 2 的下标 2,最终队列只剩 [4]。完整输出是 [2, 2, 1, 1]。

程序中的 expired 表示因窗口过期而从队首移除,dominated 表示被新值替代而从队尾移除。默认例子主要展示后者;独立的递增例子会打印 expired_indices,展示旧最小值真正离开窗口。

while 套在 for 里,为什么总量仍是线性的

一次新值到来,确实可能弹出许多候选,不能保证每一步都只做一次弹出。但一个下标只入队一次;离开队列后不会再次进入,所以全部队首、队尾移除次数之和至多为 n。把整次扫描的工作加起来,才得到 O(n)。

deque 的两端操作和端点读取适合这个过程;若改成 list.pop(0),每次还可能移动一批元素,原来的成本分析就不能照搬。队列辅助空间为 O(k),返回的答案列表占 O(n-k+1)。

trace=True 另外复制每一步队列,以便教学观察,最坏会增加 O(nk) 的时间和存储。线性结论针对关闭轨迹时的求解过程;穷举核对调用的就是 trace=False。这里没有把打印轨迹的教学模式说成线性内存算法。

保存一个文件,运行轨迹和穷举核对

将完整代码保存为 demo.py,在该目录执行 python demo.py。只使用 Python 标准库,不需要下载依赖。2026-10-23 在 Windows、CPython 3.14.0 实际运行。

程序穷举长度 1–7、值只取 -1、0、1 的所有列表,并逐一选择每个合法宽度。共 21,324 组列表与宽度组合,每组与逐窗 min 扫描比较,同时检查每项入队一次、移除总数不超过输入长度。检查使用显式异常,python -O 不会关闭它们。

展开收起代码 · 小实验python · 82 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Integer sliding minima; no third-party dependencies or benchmark claims."""
from collections import deque
from itertools import product


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def sliding_minimum(values, width, trace=False):
    if type(width) is not int or not 1 <= width <= len(values):
        raise ValueError("width must be an integer in 1..len(values)")
    if any(type(value) is not int for value in values):
        raise TypeError("this example accepts integer values only")
    queue, minima, events = deque(), [], []
    pushes = removals = 0
    for index, value in enumerate(values):
        expired, dominated = [], []
        while queue and queue[0] <= index - width:
            expired.append(queue.popleft())
            removals += 1
        while queue and values[queue[-1]] >= value:
            dominated.append(queue.pop())
            removals += 1
        queue.append(index)
        pushes += 1
        minimum = values[queue[0]] if index >= width - 1 else None
        if index >= width - 1:
            minima.append(minimum)
        if trace:
            events.append((index, expired, dominated, list(queue), minimum))
    return minima, events, (pushes, removals)


def oracle(values, width):
    return [min(values[i:i + width]) for i in range(len(values) - width + 1)]


def main():
    values, width = [4, 2, 2, 5, 1, 3], 3
    minima, events, counts = sliding_minimum(values, width, trace=True)
    print(f"values={values}; width={width}")
    for index, expired, dominated, queue, minimum in events:
        print(f"i={index}; expired={expired}; dominated={dominated}; "
              f"queue={queue}; min={minimum}")
    print(f"minima={minima}; pushes={counts[0]}; removals={counts[1]}")
    require(minima == [2, 2, 1, 1], "default example")
    expiry, expiry_events, _ = sliding_minimum([1, 2, 3, 4], 2, trace=True)
    require(expiry == [1, 2, 3], "expiry")
    print(f"expiry_minima={expiry}; expired_indices={[e[1] for e in expiry_events]}")
    require(sliding_minimum([2, 2, 2], 2)[0] == [2, 2], "equal values")
    require(sliding_minimum([-3, 0, -1], 1)[0] == [-3, 0, -1], "width one")
    require(sliding_minimum([5, 4, 3], 3)[0] == [3], "full width")
    invalid = 0
    for data, size in [([], 1), ([1], 0), ([1], 2), ([1], True)]:
        try:
            sliding_minimum(data, size)
        except ValueError:
            invalid += 1
        else:
            raise AssertionError("invalid width accepted")
    try:
        sliding_minimum([float("nan")], 1)
    except TypeError:
        invalid += 1
    else:
        raise AssertionError("unsupported numeric domain accepted")
    cases = 0
    for length in range(1, 8):
        for data in product((-1, 0, 1), repeat=length):
            for size in range(1, length + 1):
                actual, _, (pushes, removals) = sliding_minimum(data, size)
                require(actual == oracle(data, size), (data, size))
                require(pushes == length and removals <= length, "operation bound")
                cases += 1
    print(f"oracle_cases={cases}; rejected_inputs={invalid}")
    print("PASS")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。固定宽度整数列表;21324 组有限输入 oracle 核对和5类拒绝输入,含过期/重复值/负数/宽度1与全长;轨迹复制另计成本,未做性能基准。

运行后对照结果
values=[4, 2, 2, 5, 1, 3]; width=3
i=0; expired=[]; dominated=[]; queue=[0]; min=None
i=1; expired=[]; dominated=[0]; queue=[1]; min=None
i=2; expired=[]; dominated=[1]; queue=[2]; min=2
i=3; expired=[]; dominated=[]; queue=[2, 3]; min=2
i=4; expired=[]; dominated=[3, 2]; queue=[4]; min=1
i=5; expired=[]; dominated=[]; queue=[4, 5]; min=1
minima=[2, 2, 1, 1]; pushes=6; removals=4
expiry_minima=[1, 2, 3]; expired_indices=[[], [], [0], [1]]
oracle_cases=21324; rejected_inputs=5
PASS

把输入约定说清楚,再讨论结果

本例只接收整数值,宽度必须是 1 到列表长度之间的整数。空列表、宽度为 0、超过长度以及 True 这样的布尔宽度均拒绝;另外拒绝 NaN 所在的非整数数据域。不要把浮点 NaN 的比较规则默默带进单调性证明。

宽度为 1 时结果应与输入相同,宽度等于列表长度时只输出一个数。重复值、负数、递增和递减顺序也有检查。输入需要可取长度和按下标读取;它不是直接接收无限流的接口。

穷举覆盖的是明确给出的有限输入域,不是对任意 Python 对象的正确性证明,也没有测耗时排名。总量分析来自候选进出次数,实际运行用于核对实现和边界。

什么时候可以照这个思路改,什么时候需要重想

最近 k 条记录的最小值、最大值适合维护这类候选。若改求最大值,需要同时改变淘汰方向和比较规则,并重新核对重复值;不能只把输出函数名字改掉。

它依赖窗口按输入顺序向右推进。如果可以任意删除中间记录、回到更早窗口,或者查询任意区间,这个队列保留的信息就未必足够。本篇也没有处理乱序事件时间、并发更新和窗口中位数。

阅读自己的实现时,先检查过期公式、相等值策略和输出起点,再数每个下标会进出几次。这三处比记住“用单调队列”这个名字更有用。

记住这些要点

  • 下标同时携带位置和有效期,不能只保留一个历史最小数。
  • 淘汰旧候选的理由是新值不更大且更晚过期。
  • 单次步骤可弹出多项,但每项一生至多进出一次。
  • 轨迹复制的成本需要单独说明。

别把例子用过头

  • 用队列长度代替原记录下标判断过期。
  • 把 >= 和 > 当作没有差别,忽略相等值的身份策略。
  • 用 list.pop(0) 后仍直接沿用 deque 的复杂度。
  • 只跑默认样例,漏掉旧最小值真正过期的情况。

自己试一小步

为每个完整窗口返回最小值和它在窗口内最早出现的原下标,你会怎样调整候选规则?

  1. 先选一个包含相等最小值的列表,手写每个窗口的预期二元组。
  2. 明确你的相等值处理规则,再修改函数返回结果。
  3. 加入宽度为 1、全长、递增和全相等用例。
  4. 编写按窗口完整扫描的独立核对函数,再穷举小输入。

做到这些,就算完成

  • 每个返回下标都仍在当前窗口内。
  • 同值时确实选择最早下标,而非碰巧拿到一个正确数值。
  • 实验记录区分求解过程与轨迹展示的成本。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Competitive Programmer’s Handbook,8.3 Sliding window minimum (新标签页)↗Antti Laaksonen / CSES · 英文

    单调队列保留候选、移除过期元素,以及每项至多进入和离开一次的总工作量解释。

    2026-10-23 实际打开并阅读:作者站点 PDF 的印刷页 81–82(文件页 91–92),整节滑动窗口最小值。仅声明该范围,不表示通读整部资料。

  • Python 3.14 collections:deque objects (新标签页)↗Python Software Foundation · 英文

    deque 两端追加和弹出、端点访问,以及 list.pop(0) 与中间索引的成本边界。

    2026-10-23 实际打开并阅读:deque objects 的构造说明、append、pop、popleft 和端点/中间索引复杂度段落;在线 3.14 分支文档与本地 3.14.0 运行版本分开记录。仅声明该范围,不表示通读整部资料。