qinghuan
← 回到知识探索

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

投票算法最后留下一个名字,就代表它超过半数了吗?

把候选、抵消余额和真实出现次数分开:一遍挑出候选,再用第二遍判断它有没有严格超过半数。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先约定目标:严格超过半数,和出现最多不同
  4. 把不同名字配对抵消,为什么不会丢掉真正的多数
  5. 看实际轨迹:剩下一个 C,不代表 C 只出现一次
  6. 第二遍读的是同一份数据,不能拿已经耗尽的迭代器再数
  7. 运行默认反例,再对照完整计数结果
  8. 两遍仍是线性工作,常数状态也要说清楚范围
  9. 把候选当作待验证结果,别顺手扩大它能回答的问题
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 会遍历列表、比较两个值是否相等,并统计出现次数。
  • 先读 复杂度入门,区分变量数量、扫描次数和输入长度。
遇到不熟悉的词,先看这 2 个解释
严格多数
某个标签的出现次数比全部记录的一半还多。
抵消余额
不同标签配对抵消后,目前尚未被抵消的候选数量。
打开完整术语小抄 ↗

七条记录是 A、A、A、B、B、C、C。一个只保留候选和计数的小算法最后留下了 C,但 C 只出现两次;出现最多的 A 也只有三次,根本没有谁超过半数。

下面把这个看似矛盾的结果拆开:Boyer–Moore 的第一遍负责保住可能的严格多数,第二遍负责确认。我们还会观察为什么抵消后的余额不是这个名字在原数据中的次数。

把例子带到自己的电脑上

下载完整练习包

需要 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 的目录打开终端。命令分别回车执行。可重复读取的字符串list/tuple;9841条有限序列与Counter核对并反向复核9841条;严格超过半数、空/单项/恰好半数及3类拒绝输入;无一次性流或性能保证。

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

第一遍留下一个名字,第二遍才给它结论

正在准备实验控件…

  1. 0A
  2. 1A
  3. 2A
  4. 3B
  5. 4B
  6. 5C
  7. 6C
第一遍已读
0/7
活跃候选
无
抵消后的余额
0

先按顺序推进,观察候选什么时候被抵消、什么时候更换。

最多 20 个大写英文字母,每个字母代表一条记录;严格多数要求次数×2大于总数。浏览器保留输入以便重读,展示轨迹也占空间;常数辅助空间的算法分析针对不保存轨迹的求解部分。

先约定目标:严格超过半数,和出现最多不同

对长度 n 的列表,本文寻找出现次数满足 2 × 次数 > n 的标签。七项中至少要出现四次,四项中至少要出现三次。恰好一半不算,单纯排第一也不算。

最多只能有一个标签严格超过半数,否则两者出现次数相加会超过总长度。没有这样的标签时,函数返回 None;输入只允许字符串标签,因此这个 None 不会和合法数据混淆。

默认数据 AAABBCC 的最多项是 A,但它只占 3/7。本例没有要求找众数,因此算法返回“没有严格多数”才正确。

把不同名字配对抵消,为什么不会丢掉真正的多数

想象每次拿走两个不同标签。若 A 本来严格超过半数,每次最多拿走一个 A,也会拿走至少一个非 A;原来多出来的 A 不会被全部抵消。这里只是在解释计数的含义,程序并不反复删除列表元素。

程序维护 candidate 和 balance。余额为零时,当前元素成为新候选并把余额设为 1;遇到相同标签加 1,遇到不同标签减 1。余额归零就暂时没有活跃候选,下一项再开始。

这个保证是单向的:真正存在的严格多数会留下来,但留下来的标签不一定是严格多数。没有多数的输入也可能抵消出一个剩余标签。

看实际轨迹:剩下一个 C,不代表 C 只出现一次

读完前三个 A,状态是 A、余额 3。接着两个 B 把余额降到 1;第一个 C 再把它降到 0。最后一个 C 成为新候选,余额为 1。

第二遍重新扫描原数据,发现 C 的真实出现次数是 2。因为 2 × 2 不大于 7,它被否决,最终结果是 None。第一遍余额 1、真实次数 2、列表总长 7 是三个不同量。

另一个输入 AAAABBC 会留下 A、余额 1,但 A 实际出现四次,能够通过 4 × 2 > 7 的检查。两种输入的余额都是 1,却得到不同的最终结论。

第二遍读的是同一份数据,不能拿已经耗尽的迭代器再数

如果业务没有提前保证严格多数存在,就要核对候选在原输入中的次数。最后余额为零时,全部记录已被不同标签成对抵消,可以直接判定没有严格多数;有候选时才进行二遍计数。

因此 exact_half 和 empty 输出里的 frequency=0 表示没有活跃候选,没有执行候选频次统计;它不是在声称 A 或 B 在原列表中出现零次。

本例只接收可重复遍历的 list 或 tuple,并明确拒绝一次性迭代器。若从文件读取,可以按业务条件重新打开同一份稳定数据;若输入只能消费一次,就必须另外设计存储或问题约定,不能把耗尽后的零次统计当成核验。

运行默认反例,再对照完整计数结果

把下面完整代码保存为 demo.py,执行 python demo.py。2026-10-23 在 Windows、CPython 3.14.0 实际运行;只使用标准库。打印的 candidate、balance、frequency 和 majority 分别对应挑选、抵消、核验和最终结论。

程序穷举长度 0–8、标签取 A/B/C 的 9,841 条序列,以 Counter 的完整频次统计作独立核对;每条再反向读取一次,确认最终严格多数结论不变。反向读取可能改变假候选,但不该改变验证后的答案。

另外检查恰好半数、空列表、单元素,拒绝一次性迭代器、None 标签和非字符串标签。显式 AssertionError 检查不依赖 Python assert 开关。

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

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

小实验 · python
"""Strict majority for replayable string sequences; candidate then verification."""
from collections import Counter
from itertools import product


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


def majority(values, trace=False):
    if not isinstance(values, (list, tuple)):
        raise TypeError("a replayable list or tuple is required")
    if any(type(value) is not str for value in values):
        raise TypeError("this example accepts string labels only")
    candidate, balance, events = None, 0, []
    for index, value in enumerate(values):
        if balance == 0:
            candidate, balance = value, 1
        elif value == candidate:
            balance += 1
        else:
            balance -= 1
            if balance == 0:
                candidate = None
        if trace:
            events.append((index, value, candidate, balance))
    frequency = sum(value == candidate for value in values) if balance else 0
    verified = candidate if frequency * 2 > len(values) else None
    return candidate, balance, frequency, verified, events


def oracle(values):
    winners = [key for key, count in Counter(values).items() if count * 2 > len(values)]
    require(len(winners) <= 1, "strict majority is unique")
    return winners[0] if winners else None


def main():
    values = list("AAABBCC")
    candidate, balance, frequency, verified, events = majority(values, trace=True)
    print(f"values={values}")
    for index, value, current, remaining in events:
        print(f"i={index}; value={value}; candidate={current}; balance={remaining}")
    print(f"candidate={candidate}; balance={balance}; frequency={frequency}; majority={verified}")
    require((candidate, balance, frequency, verified) == ("C", 1, 2, None), "false candidate")
    for label, data in [("has_majority", list("AAAABBC")), ("exact_half", list("AABB")),
                        ("empty", []), ("single", ["Z"])]:
        c, b, f, result, _ = majority(data)
        require(result == oracle(data), label)
        print(f"{label}: candidate={c}; balance={b}; frequency={f}; majority={result}")
    rejected = 0
    for unsupported in [iter(["A", "A"]), [None], [1]]:
        try:
            majority(unsupported)
        except TypeError:
            rejected += 1
        else:
            raise AssertionError("unsupported input accepted")
    cases = 0
    for length in range(9):
        for data in product("ABC", repeat=length):
            expected = oracle(data)
            require(majority(data)[3] == expected, data)
            require(majority(tuple(reversed(data)))[3] == expected, "reversal changed answer")
            cases += 1
    print(f"oracle_sequences={cases}; reversed_sequences={cases}; rejected_inputs={rejected}")
    print("PASS")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。可重复读取的字符串list/tuple;9841条有限序列与Counter核对并反向复核9841条;严格超过半数、空/单项/恰好半数及3类拒绝输入;无一次性流或性能保证。

运行后对照结果
values=['A', 'A', 'A', 'B', 'B', 'C', 'C']
i=0; value=A; candidate=A; balance=1
i=1; value=A; candidate=A; balance=2
i=2; value=A; candidate=A; balance=3
i=3; value=B; candidate=A; balance=2
i=4; value=B; candidate=A; balance=1
i=5; value=C; candidate=None; balance=0
i=6; value=C; candidate=C; balance=1
candidate=C; balance=1; frequency=2; majority=None
has_majority: candidate=A; balance=1; frequency=4; majority=A
exact_half: candidate=None; balance=0; frequency=0; majority=None
empty: candidate=None; balance=0; frequency=0; majority=None
single: candidate=Z; balance=1; frequency=1; majority=Z
oracle_sequences=9841; reversed_sequences=9841; rejected_inputs=3
PASS

两遍仍是线性工作,常数状态也要说清楚范围

两遍扫描的工作量相加仍为 O(n)。关闭 trace 时,求解部分只保存候选、余额和频次等常数个状态变量,不为每一种标签建立计数表;已有输入列表的存储不算新增的辅助空间。

这里的常数空间按常数个机器字的常见分析口径表述。若逐位计量,能计到 n 的整数本身需要约 log n 位。打开 trace 会额外保存 n 条状态,Counter 也只用于测试,不能把整个教学程序都说成只用常数空间。

字符串比较还取决于标签长度。本文的有限字母实验不测长字符串比较成本、吞吐或外部读取速度,也没有把算法计数直接换算成毫秒承诺。

把候选当作待验证结果,别顺手扩大它能回答的问题

这个算法适合严格多数的存在性与身份问题,不会同时列出所有标签频率,也不能仅凭最后一个候选求出出现最多的标签。默认反例中,留下的 C 就连众数都不是。

若目标换成“超过三分之一”或“前几个最常见项”,候选数量、抵消规则和核验方式都需要重新设计。先把阈值写清楚,再判断现有保证是否适用。

代码评审时可以直接问三件事:是否保证多数存在、是否验证严格阈值、第二遍读取的是否仍是相同数据。它们决定候选什么时候能成为最终答案。

记住这些要点

  • 第一遍留下的是候选,不是存在严格多数的证明。
  • balance 是抵消余额,不是原数据中的出现次数。
  • 没有多数保证时,需要按原输入再次核验候选。
  • 恰好半数和出现最多都不等于严格超过半数。

别把例子用过头

  • 直接返回候选,漏掉没有严格多数的输入。
  • 用 balance 代替真实频次检查阈值。
  • 把 > 写成 >=,让恰好一半通过。
  • 第二遍遍历已经耗尽的迭代器,误把零次当成真实计数。

自己试一小步

只保留第一遍的候选和余额,能否区分所有“有多数”和“无多数”的输入?请用实验检查这个主张。

  1. 自选两组相同长度的列表,记录第一遍结束状态和完整频次表。
  2. 搜索是否能找到相同第一遍状态但最终多数结论不同的输入。
  3. 对找到的输入调换顺序,区分候选变化和验证后答案变化。
  4. 说明你的数据源怎样支持第二遍读取,并写明失败时的处理方式。

做到这些,就算完成

  • 用严格大于半数的条件判断,不把众数替代为答案。
  • 输出原数据频次和第一遍余额两个独立字段。
  • 结论包含空输入和一次性数据源的处理约定。

查原始资料

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

  • A Linear-Time Majority Vote Algorithm:作者逐步示例 (新标签页)↗J Strother Moore / University of Texas at Austin · 英文

    候选与计数的更新规则、仅在存在多数时的候选保证,以及必须再次扫描才能确认的反例。

    2026-10-23 实际打开并阅读:示例页的规则、全部逐步状态和结尾修改输入后仍留下候选但没有多数的说明;另读作者算法简介页。仅声明该范围,不表示通读整部资料。

  • CS 3530 Session 15:Solving the Elections Puzzle (新标签页)↗Eugene Wallingford / University of Northern Iowa · 英文

    删除不同元素对的保持性质、正反扫描可留下不同假候选、二次计数确认和常数个状态变量。

    2026-10-23 实际打开并阅读:Solving the Elections Puzzle Using Decrease-and-Conquer 中 The Idea、Examples、The Algorithm 三小节;未把同页其他算法作为本文依据。仅声明该范围,不表示通读整部资料。