目录

题目描述

277. 搜寻名人

题意分析

n 个人,编号 0 到 n-1,其中最多存在一位「名人」:所有其他人都认识他,而他谁也不认识。我们不能直接看到人际关系,只能调用 knows(a, b) 这个接口逐条询问。要求返回名人的编号,不存在则返回 -1。

「只能通过接口提问」是这道题最关键的设定,它把优化目标从「时间复杂度」换成了「提问次数」。题目明确希望调用次数低于 $O(n^2)$,这等价于要求:绝大多数人必须在只被问一两次的情况下就被排除掉。

名人的定义拆开是两条:出度为 0(他不认识任何人)和入度为 n-1(所有人都认识他)。两条都是全称量词,所以只做一遍筛选不足以下结论,必须有一个独立的验证阶段。

题目保证名人至多一位。这条唯一性是所有淘汰式解法的合法性来源——如果允许多位,「淘汰掉一个」就未必安全。

边界包括:n = 1 时唯一的人自动满足条件(不认识任何人、也没有别人需要认识他);完全不存在名人时必须返回 -1;以及被淘汰者恰好就是真名人的假象需要被验证阶段兜住。

解法:候选人淘汰

核心思路

暴力做法是对每个人 i 检查它与所有其他人的两个方向关系,共约 $2n(n-1)$ 次提问。正确但太贵,瓶颈在于我们对每个人都做了完整的 $O(n)$ 验证,而实际上只有至多一个人能通过验证,其余 n-1 个人的验证工作全是浪费。

观察点是:一次提问就能淘汰一个人。考察 knows(a, b) 的两种结果——如果 a 认识 b,那么 a 有出边,a 一定不是名人;如果 a 不认识 b,那么 b 缺了 a 这一条入边,b 一定不是名人。无论答案是什么,都必然有一方出局。这是本题从平方级降到线性的全部支点。

于是用擂台式扫描:维护一个当前候选 cand,从 0 开始,依次拿它和 i = 1, 2, ..., n-1 比一次。如果 knows(cand, i) 为真就换成 i 当候选,否则保留原候选。

第一阶段的不变量是:处理完下标 i 之后,编号在 [0, i] 范围内、且不等于 cand 的人,都已被证明不是名人。归纳理由正是上面那条「一问淘汰一人」:每一轮被淘汰的要么是旧候选(它认识别人),要么是新来的 i(它没被旧候选认识)。

因此扫描结束时,若名人存在,他一定就是 cand——因为其余所有人都已被证伪。但反过来不成立:cand 只是「唯一没被排除的人」,未必真是名人,它可能只是恰好没被问到关键的那一条关系。

所以必须有第二阶段的验证:对每个 i != cand,同时检查 knows(cand, i) 为假且 knows(i, cand) 为真。任何一条不满足就说明名人不存在,返回 -1。这一步不能省,因为第一阶段只做了 n-1 次提问,远不足以覆盖名人定义所需的 $2(n-1)$ 条关系。

值得单独点明的是,第二阶段验证 cand 之前的那些编号(i < cand)时,knows(cand, i) 其实是新信息:第一阶段里 cand 成为候选之后,就再也没被问过与更早编号的关系了。

解题步骤

  • 初始化 cand = 0。之所以可以任取一个人做起点,是因为不变量只保证「被淘汰的确实不是名人」,起点本身是否为名人交给后续流程判定。
  • i = 1 扫到 n-1,每轮问一次 knows(cand, i)。之所以每轮只问一次而不是两次,是因为一次提问的两种结果已经能各自淘汰一人,多问一次不会多淘汰。
  • knows(cand, i) 为真,令 cand = i。之所以敢直接抛弃旧候选,是因为「认识别人」直接违反名人定义中的出度为 0,旧候选被永久证伪。
  • 若为假,保持 cand 不变。之所以此时能淘汰 i,是因为 cand 不认识 i,而名人必须被所有人认识,i 的入度已经缺了一条。
  • 第一阶段结束后进入验证循环,对每个 i != cand 检查 knows(cand, i) || !knows(i, cand),成立就返回 -1。之所以要用「或」把两个方向都查一遍,是因为名人定义包含两条全称条件,任何一条被破坏都说明整个候选不成立。
  • 验证时跳过 i == cand。之所以必须跳过,是因为多数判题环境中 knows(x, x) 未定义或返回 true,拿自己和自己比会误触发返回 -1。
  • 全部验证通过则返回 cand

n = 4,关系为「1、2、3 都认识 0,0 谁也不认识,1 认识 2,2 认识 3」为例走一遍,真名人是 0。

第一阶段:cand = 0i = 1,问 knows(0, 1),0 不认识任何人,返回假,cand 保持 0,同时淘汰了 1。i = 2,问 knows(0, 2),假,淘汰 2。i = 3,问 knows(0, 3),假,淘汰 3。扫描结束 cand = 0,共 3 次提问。

第二阶段:i = 0 跳过。i = 1knows(0, 1) 为假、knows(1, 0) 为真,通过。i = 2i = 3 同理通过。返回 0,正确。

再看一个无名人的例子:n = 2,两人互相认识。第一阶段 cand = 0i = 1knows(0, 1) 为真,cand 换成 1。第二阶段 i = 0knows(1, 0) 为真,第一个条件立刻成立,返回 -1。正确——两人互相认识时谁都不是名人,说明验证阶段确实兜住了第一阶段留下的假候选。

代码实现

public class Solution extends Relation {
    public int findCelebrity(int n) {
        int cand = 0;
        for (int i = 1; i < n; i++) {
            if (knows(cand, i)) {
                cand = i;
            }
        }

        for (int i = 0; i < n; i++) {
            if (i == cand) {
                continue;
            }
            if (knows(cand, i) || !knows(i, cand)) {
                return -1;
            }
        }

        return cand;
    }
}
func findCelebrity(n int) int {
    cand := 0
    for i := 1; i < n; i++ {
        if knows(cand, i) {
            cand = i
        }
    }

    for i := 0; i < n; i++ {
        if i == cand {
            continue
        }
        if knows(cand, i) || !knows(i, cand) {
            return -1
        }
    }

    return cand
}

复杂度分析

  • 时间复杂度:$O(n)$,提问次数至多 $3(n-1)$ 次。凭据是第一阶段固定 n-1 次提问,第二阶段对 n-1 个人各问两次共 $2(n-1)$ 次,两阶段都是单层循环、无嵌套。
  • 空间复杂度:$O(1)$,凭据是全程只维护 cand 和循环变量两个整数,既没有建邻接矩阵也没有记录任何已问过的结果。

关键点总结

  • 交互式题目要把优化目标从「运行时间」改成「提问次数」,判断一个做法好不好,看的是「一次提问能换来多少确定的信息」。
  • 本题的核心引理是「一次提问必然淘汰一人」:knows(a,b) 为真淘汰 a,为假淘汰 b。凡是能找到这种「无论答案如何都有收益」的提问,都能把平方级搜索压成线性。
  • 「筛选 + 验证」是所有淘汰式算法的固定两段式。筛选阶段只保证「留下的是唯一可能」,不保证「留下的一定对」,所以验证不能省——这一点和摩尔投票法完全同构。
  • 题目保证的「至多一位名人」是淘汰合法性的前提,看到唯一性保证要立刻想到能否用淘汰法;反之若允许多个答案,淘汰逻辑通常直接失效。
  • 验证阶段必须双向都查,因为名人定义是两条独立的全称条件,只查其中一条会漏掉另一半反例。
  • 面试视角:面试官会先让你说出 $O(n^2)$ 的做法,然后问「能不能少问一点」。答题时要显式讲出淘汰引理并证明两种结果各淘汰谁,再强调验证阶段的必要性。常见追问是「为什么第二阶段还要问 knows(cand, i),第一阶段不是问过了吗」,标准回答是:cand 被换成新人之后,它与更早编号之间的关系从未被询问。

易错点总结

  • 省掉第二阶段直接返回 cand:用例 n = 2 且两人互相认识,第一阶段留下 1,直接返回 1,而正确答案是 -1。
  • 第二阶段只检查 !knows(i, cand) 而不检查 knows(cand, i):用例 n = 2 且两人互相认识,knows(1, 0) 为真通过检查,返回 1,漏掉了「候选认识别人」这一半反例。
  • 第二阶段只检查 knows(cand, i) 而不检查 knows(i, cand):用例 n = 3,0 谁也不认识、1 认识 0、2 谁也不认识,候选留在 0,knows(0, 1)knows(0, 2) 都为假通过,返回 0,但 2 并不认识 0,正确答案是 -1。
  • 验证循环里忘记跳过 i == cand:用例任意 n >= 1knows(cand, cand) 在多数判题实现里返回 true,第一个条件立刻成立,任何输入都返回 -1。
  • 第一阶段写成 if (!knows(cand, i)) cand = i;,把换人条件写反:用例 n = 3,真名人是 0,第一轮 knows(0, 1) 为假就把候选换成 1,最终候选是 2,验证不通过返回 -1,漏掉了真名人。
  • 第一阶段的循环从 i = 0 开始:用例任意输入,第一轮就问 knows(0, 0),若接口返回 true 会立刻把候选换成 0 本身,虽不改变值但引入了一次无意义提问,在按提问次数计分的判题下会超限。
  • 用二维数组把所有 knows(i, j) 预先查一遍再判断:用例 n = 10^3,提问次数达到 10^6,远超题目允许的线性调用量,即使答案正确也会被判失败。
  • 第一阶段每轮问两次(同时问 knows(cand, i)knows(i, cand))来「顺便验证」:用例任意输入,答案正确但提问次数翻倍到 $2(n-1) + 2(n-1)$,而正确认识是「一次提问已足够淘汰一人」,多问的那次不产生新的淘汰。
  • 认为第一阶段淘汰的人不需要在第二阶段再检查,只验证 i > cand 的部分:用例 n = 3,1 认识 2、2 谁也不认识、0 认识 1 和 2,候选最终是 2,只验证 i > 2 时无人可查直接返回 2,但 0 并不认识 2,正确答案是 -1。
  • n = 1 时额外写特判返回 -1:用例 n = 1,第一阶段和第二阶段的循环都不执行,正常流程返回 0 才是正确答案,多余的特判反而制造了错误。

相似题目

题目 难度 考察点
169. 多数元素 简单 摩尔投票同样是「抵消式淘汰 + 验证」,抵消依据是计数而非提问
229. 多数元素 II 中等 候选从一个扩到两个,抵消规则变复杂,验证阶段必须查两个候选
997. 找到小镇的法官 简单 关系以边表形式直接给出,可用入度出度计数一次性判定,无需淘汰
136. 只出现一次的数字 简单 同样靠成对抵消定位唯一元素,抵消手段换成异或且无需验证