大厂真题 / 京东

京东 2026-8-22 笔试真题 - 算法岗

本场考试概述

考试时间:2026 年 8 月 22 日

考试岗位:算法岗

难度评级:中等偏难

考点分析

  • 第 1 题:Split Conformal 分类预测集、分位点、NumPy 向量化(中等)
  • 第 2 题:离线坐标压缩、树状数组、动态前驱后继、间隔多重集(困难)

建议策略

  • 第 1 题按题面顺序实现非一致性分数、有限样本分位点和预测集映射,重点检查闭区间边界。
  • 第 2 题先证明答案只取决于去重后相邻位置的最大间距,再设计单次修改只更新常数条间隔的数据结构。
  • 两题都要先保证输入输出格式正确;第 1 题只允许输出 JSON 整数数组,第 2 题每次修改后输出一行答案。

第 1 题:检索召回置信集映射

题目描述

制度问答检索系统上线前,需要对二分类召回结果输出置信集。模型不确定时可以拒识,以换取更稳定的覆盖率。

给定:

  • 校准集真实标签 cal_y
  • 校准集正类概率 cal_p1
  • 测试集正类概率 test_p1

固定显著性水平 $\alpha=0.2$,按以下流程构造确定性的 Split Conformal 分类预测集。

1. 非一致性分数

对校准集第 $i$ 个样本,正类概率为 $p_i$,真实标签为 $y_i$。非一致性分数为

\[s_i= \begin{cases} p_i, & y_i=0,\\ 1-p_i, & y_i=1. \end{cases}\]

它等价于 $1-P(\text{真实类别})$:模型赋给真实类别的概率越低,分数越大。

2. 计算阈值

设校准集大小为 $n$,将全部非一致性分数升序排列为

\[s_{(1)}\le s_{(2)}\le\cdots\le s_{(n)}.\]

计算一基下标

\[k=\left\lceil(n+1)(1-\alpha)\right\rceil.\]

若 $k>n$,令 $k=n$;阈值为 $q=s_{(k)}$。

3. 构造预测集

对测试概率 $p$:

  • 类别 $0$ 纳入预测集,当且仅当 $p\le q$;
  • 类别 $1$ 纳入预测集,当且仅当 $p\ge1-q$。

两个条件均包含等号。

4. 映射为整数

预测集 输出
${0}$ 0
${1}$ 1
${0,1}$ -1(不确定/拒识)
$\varnothing$ -2

输入描述

标准输入为单行 JSON:

{"cal_y":[0,1,0],"cal_p1":[0.12,0.83,0.05],"test_p1":[0.20,0.70,0.95]}

其中:

  • cal_ycal_p1 长度相同;
  • cal_y 中每个元素为 01
  • cal_p1test_p1 中每个概率均位于 $[0,1]$。

输出描述

标准输出仅一行,为长度等于 test_p1 的 JSON 整数数组。

样例

输入

{"cal_y":[0,0,1,1,0,1,0,1],"cal_p1":[0.08,0.12,0.88,0.92,0.25,0.85,0.15,0.78],"test_p1":[0.0,1.0,0.5,0.18,0.82]}

输出

[0,1,-2,0,1]

思路分析

先按真实标签计算非一致性分数。本例得到:

\[[0.08,0.12,0.12,0.08,0.25,0.15,0.15,0.22].\]

排序后为:

\[[0.08,0.08,0.12,0.12,0.15,0.15,0.22,0.25].\]

$n=8$,所以

\[k=\lceil9\times0.8\rceil=8,\]

阈值 $q=0.25$。于是类别 $0$ 的纳入条件为 $p\le0.25$,类别 $1$ 的纳入条件为 $p\ge0.75$。

对五个测试概率依次判断:

  • $p=0$:只有类别 $0$ 入选,输出 0
  • $p=1$:只有类别 $1$ 入选,输出 1
  • $p=0.5$:两类都不入选,输出 -2
  • $p=0.18$:只有类别 $0$ 入选,输出 0
  • $p=0.82$:只有类别 $1$ 入选,输出 1

正确性证明

引理 1:代码计算的 scores[i] 等于第 $i$ 个校准样本的非一致性分数。

证明:当真实标签为 $0$ 时,代码取 $p_i$;当真实标签为 $1$ 时,代码取 $1-p_i$,与题目定义逐项一致。

引理 2:代码得到的 q 是题目规定的有限样本分位点。

证明:代码将分数升序排序,并使用零基下标 $\lceil(n+1)(1-\alpha)\rceil-1$。当一基下标超过 $n$ 时退回最后一个元素,因此与题目定义完全一致。

引理 3:代码对每个测试概率输出正确映射。

证明take0take1 分别精确表示 $p\le q$ 与 $p\ge1-q$。两个布尔值共有四种组合,代码分别映射到 01-1-2,与题意一一对应。

定理:算法输出每个测试样本的正确预测集整数标签。

证明:由引理 1 和引理 2,算法得到正确阈值;由引理 3,该阈值下每个测试样本均被正确判定和映射,因此整个输出数组正确。

ACM Python 代码

import json
import math
import sys

import numpy as np


ALPHA = 0.2


def solve():
    data = json.loads(sys.stdin.buffer.readline())
    cal_y = np.asarray(data["cal_y"], dtype=np.int64)
    cal_p1 = np.asarray(data["cal_p1"], dtype=np.float64)
    test_p1 = np.asarray(data["test_p1"], dtype=np.float64)

    scores = np.where(cal_y == 1, 1.0 - cal_p1, cal_p1)
    scores.sort()

    n = scores.size
    k = math.ceil((n + 1) * (1.0 - ALPHA)) - 1
    if k >= n:
        k = n - 1
    q = scores[k]

    take0 = test_p1 <= q
    take1 = test_p1 >= 1.0 - q

    result = np.where(
        take0 & take1,
        -1,
        np.where(take1, 1, np.where(take0, 0, -2)),
    )
    print(json.dumps(result.tolist(), separators=(",", ":")))


if __name__ == "__main__":
    solve()

复杂度分析

设校准集长度为 $n$,测试集长度为 $m$。

  • 时间复杂度:$O(n\log n+m)$,主要开销是校准分数排序。
  • 空间复杂度:$O(n+m)$,用于分数数组和测试集布尔掩码。

易错点

  • 分位下标使用 $n+1$ 做有限样本校正,并注意一基与零基下标转换。
  • 下标超过校准集范围时取最大分数,不能访问越界。
  • 两个纳入条件都是闭区间,不能写成严格不等号。
  • -1 表示两类都入选,-2 表示两类都不入选,不能写反。
  • 标准输出不能混入解释文字或调试日志。

第 2 题:锚点最远盲区

题目描述

在整数坐标轴上有 $n$ 个温感锚点,第 $i$ 个锚点的位置为 $u_i$。全体位置构成整数数组 $U$。

对任意非空数组 $U$,定义:

\[L=\min(U),\qquad R=\max(U).\]

对区间 $[L,R]$ 内的每个整数 $x$,其到最近锚点的距离为

\[d(x)=\min_i|x-u_i|.\]

盲区半径定义为

\[W(U)=\max_{x\in\mathbb Z,\ L\le x\le R}d(x).\]

现在进行 $t$ 次修改。每次给出 r z,将编号为 $r$ 的锚点位置改为 $z$。每次修改后输出当前的 $W(U)$。

同一位置可以有多个锚点。

输入描述

第一行输入两个整数 $n,t$,分别表示锚点数量和修改次数。

第二行输入 $n$ 个整数 $u_1,u_2,\ldots,u_n$,表示初始位置。

接下来 $t$ 行,每行输入两个整数 $r,z$,表示将第 $r$ 个锚点的位置改为 $z$。

本题修改规模达到 $10^5$ 量级,需要近似 $O(\log(n+t))$ 地处理每次修改。

输出描述

输出 $t$ 行,每行一个整数,表示对应修改后的盲区半径。

样例

输入

4 2
2 5 9 12
2 7
4 20

输出

2
5

解释

第一次修改后,去重位置为 $[2,7,9,12]$,相邻最大间距为 $5$,所以盲区半径为 $\lfloor5/2\rfloor=2$。

第二次修改后,去重位置为 $[2,7,9,20]$,相邻最大间距为 $11$,所以答案为 $\lfloor11/2\rfloor=5$。

关键化简

将当前不同位置去重并排序:

\[x_1<x_2<\cdots<x_k.\]

任意整数点 $x\in[L,R]$ 要么位于锚点上,要么位于某一对相邻位置 $x_i,x_{i+1}$ 之间。在该段内,最近锚点距离为

\[\min(x-x_i,\ x_{i+1}-x),\]

其最大值出现在中点附近,等于

\[\left\lfloor\frac{x_{i+1}-x_i}{2}\right\rfloor.\]

因此

\[W(U)= \begin{cases} 0, & k=1,\\ \displaystyle\max_{1\le i<k}\left\lfloor\frac{x_{i+1}-x_i}{2}\right\rfloor, & k\ge2. \end{cases}\]

因为向下取整是单调的,只需维护去重后相邻位置的最大间距 $G$,答案就是 $\lfloor G/2\rfloor$。

数据结构设计

所有可能出现的位置只来自初始数组和 $t$ 次修改目标。先离线读入全部修改,对这些坐标排序去重并压缩。

维护三部分状态:

  1. cnt[i]:压缩坐标 i 上当前有多少个锚点;
  2. 树状数组:保存各压缩坐标的锚点计数,用前缀秩查询当前位置的前驱和后继;
  3. 间隔多重集:保存去重位置序列中每一对相邻坐标的间距,支持插入、删除和取最大值。

Python 没有内置有序多重集,可以使用“计数字典 + 懒删除大根堆”。删除间隔时只减少计数;查询最大值时不断弹出计数已经归零的堆顶。

删除一个锚点

先将旧位置计数减一:

  • 若计数仍大于 $0$,去重位置序列没有变化,不修改任何间隔;
  • 若计数从 $1$ 变成 $0$,该位置真正消失:
    • 左右邻居都存在:删除两段旧间隔,加入左右邻居之间的新间隔;
    • 只有一侧邻居:删除端点处的一段旧间隔;
    • 两侧都不存在:当前没有间隔。

插入一个锚点

若目标位置原本已有锚点,只增加计数,不修改间隔。

若计数从 $0$ 变成 $1$:

  • 左右邻居都存在:删除跨过新位置的旧间隔,加入两段新间隔;
  • 只有一侧邻居:加入端点处的新间隔;
  • 两侧都不存在:它是唯一的去重位置,不产生间隔。

一次修改固定按“删除旧位置,再插入新位置”处理。即使新旧坐标相同,也会正确恢复原状态。

正确性证明

引理 1:对于任意两个相邻的不同锚点位置 $x_i<x_{i+1}$,该区间内整数点到最近锚点距离的最大值为 $\lfloor(x_{i+1}-x_i)/2\rfloor$。

证明:区间内任意点的最近距离为 $\min(x-x_i,x_{i+1}-x)$。前一项随 $x$ 增大,后一项随 $x$ 减小,最小值在两者最接近时最大,即中点附近;整数点上的最大值为间距的一半向下取整。

引理 2:间隔多重集始终恰好包含当前去重位置序列的全部相邻间距。

证明:初始时按去重排序序列加入所有相邻间距。删除一个仍有重复锚点的位置或插入已有位置时,去重序列不变。真正删除位置时,只有它两侧的相邻关系变化,算法按邻居存在情况准确删除旧边并合并;真正插入位置时,只有跨过该位置的相邻关系变化,算法准确拆分或新增端点边。因此每次操作后不变量成立。

引理 3:树状数组查询得到的前驱和后继是当前去重位置中距离目标坐标最近的左右邻居。

证明:树状数组按坐标顺序保存正计数。目标位置为空时,其前缀计数给出左侧锚点总秩,第 r 个锚点所在坐标即最近前驱,第 r+1 个锚点所在坐标即最近后继。重复锚点只占同一压缩坐标,不改变前驱后继坐标。

定理:每次修改后算法输出正确的 $W(U)$。

证明:由引理 2,间隔多重集维护当前全部相邻间距;由引理 1,最大间距除以二向下取整就是盲区半径。若只有一个不同位置,则区间退化为单点,算法输出 $0$。因此每次答案正确。

ACM Python 代码

import sys
from heapq import heappop, heappush


def solve():
    data = list(map(int, sys.stdin.buffer.read().split()))
    iterator = iter(data)
    n = next(iterator)
    t = next(iterator)
    positions = [next(iterator) for _ in range(n)]
    queries = [(next(iterator) - 1, next(iterator)) for _ in range(t)]

    coords = sorted(set(positions) | {value for _, value in queries})
    size = len(coords)
    rank = {value: index for index, value in enumerate(coords)}

    count = [0] * size
    bit = [0] * (size + 1)
    total = 0

    def bit_add(index, delta):
        nonlocal total
        total += delta
        index += 1
        while index <= size:
            bit[index] += delta
            index += index & -index

    def bit_sum(index):
        result = 0
        index += 1
        while index > 0:
            result += bit[index]
            index -= index & -index
        return result

    def bit_kth(k):
        """返回第 k 个锚点所在的零基压缩坐标,k 从 1 开始。"""
        index = 0
        step = 1 << (size.bit_length() - 1)
        while step:
            nxt = index + step
            if nxt <= size and bit[nxt] < k:
                index = nxt
                k -= bit[nxt]
            step >>= 1
        return index

    def neighbors(index):
        """仅在 count[index] == 0 时调用。"""
        left_count = bit_sum(index)
        left = bit_kth(left_count) if left_count > 0 else -1
        right = bit_kth(left_count + 1) if left_count < total else -1
        return left, right

    gap_count = {}
    max_heap = []

    def gap_add(gap):
        gap_count[gap] = gap_count.get(gap, 0) + 1
        heappush(max_heap, -gap)

    def gap_remove(gap):
        gap_count[gap] -= 1

    def largest_gap():
        while max_heap and gap_count.get(-max_heap[0], 0) == 0:
            heappop(max_heap)
        return -max_heap[0] if max_heap else 0

    distinct = 0

    def remove_at(index):
        nonlocal distinct
        count[index] -= 1
        bit_add(index, -1)
        if count[index] > 0:
            return

        distinct -= 1
        left, right = neighbors(index)
        if left >= 0 and right >= 0:
            gap_remove(coords[index] - coords[left])
            gap_remove(coords[right] - coords[index])
            gap_add(coords[right] - coords[left])
        elif left >= 0:
            gap_remove(coords[index] - coords[left])
        elif right >= 0:
            gap_remove(coords[right] - coords[index])

    def insert_at(index):
        nonlocal distinct
        if count[index] == 0:
            left, right = neighbors(index)
            if left >= 0 and right >= 0:
                gap_remove(coords[right] - coords[left])
                gap_add(coords[index] - coords[left])
                gap_add(coords[right] - coords[index])
            elif left >= 0:
                gap_add(coords[index] - coords[left])
            elif right >= 0:
                gap_add(coords[right] - coords[index])
            distinct += 1

        count[index] += 1
        bit_add(index, 1)

    for value in positions:
        index = rank[value]
        if count[index] == 0:
            distinct += 1
        count[index] += 1
        bit_add(index, 1)

    active = [index for index in range(size) if count[index] > 0]
    for left, right in zip(active, active[1:]):
        gap_add(coords[right] - coords[left])

    answers = []
    for item_index, new_value in queries:
        remove_at(rank[positions[item_index]])
        positions[item_index] = new_value
        insert_at(rank[new_value])
        answers.append(str(0 if distinct <= 1 else largest_gap() // 2))

    sys.stdout.write("\n".join(answers))


if __name__ == "__main__":
    solve()

复杂度分析

设离线收集后共有 $m\le n+t$ 个不同坐标。

  • 坐标压缩:$O((n+t)\log(n+t))$。
  • 每次修改:删除与插入各执行常数次树状数组和堆操作,均摊 $O(\log(n+t))$。
  • 总时间复杂度:$O((n+t)\log(n+t))$。
  • 空间复杂度:$O(n+t)$。

易错点

  • 必须先对位置去重;同一坐标上的多个锚点不会产生长度为 $0$ 的“相邻间隔”。
  • 只有位置计数跨越 $0$ 与 $1$ 时才更新间隔。
  • 删除中间位置是“两段合一段”,插入中间位置是“一段拆两段”。
  • 答案是最大间距整除 $2$,不是向上取整。
  • 需要离线读取修改目标后再做坐标压缩。
  • 懒删除堆中可能保留失效元素,读取堆顶前必须根据计数表清理。

小结

  • 第 1 题重在严格复现统计流程:真实类别概率、有限样本分位点和闭区间边界都不能自行改写。
  • 第 2 题的关键不是先选数据结构,而是先证明 $W(U)$ 等于去重后最大相邻间距的一半向下取整。
  • 动态集合中存在重复位置时,要把“锚点数量变化”和“不同坐标集合变化”分开处理。