「Buy Low Buy Lower」 低买

GitHub跳转原题关系图返回列表

题面与数据严重背离,实际评测数据为 POJ 2828 Buy Tickets (插队问题),需使用离线倒序加树状数组二分求解。

OJ: roj

题目 ID: 3165

难度:提高

标签:树状数组二分离线处理

创建: 2026-10-09 11:52

更新: 2026-10-09 11:52

形式化题目

【严重警告:本题题面与实际测试数据完全不符】

原题面描述的是求“最长严格递减子序列的长度及方案数”(类似 USACO 4.3 Buy Low Buy Lower)。然而,经过全量数据的核对与严格实测,本题真实的测试数据是经典问题 POJ 2828 (Buy Tickets)。

为了能通过系统的真实评测,以下给出实际评测数据对应的形式化题意:

有 NN 个人排队,每个人有两个属性 PiP_i 和 ViV_i。第 ii 个人在插入队伍时,会插在当前队伍的第 PiP_i 个位置(0-indexed)。请输出所有人完成排队后的最终队伍序列(输出每个人的 ViV_i)。

输入格式: 包含多组测试用例,直到文件结束(EOF)。每组测试用例第一行为 NN (1≤N≤200,0001 \le N \le 200,000)。接下来 NN 行,每行包含两个整数 PiP_i 和 ViV_i。

输出格式: 对于每组用例,输出一行 NN 个整数,表示最终队伍中按顺序每个人的 ViV_i。数字之间以空格分隔,并在行末带有空格。

正解

思路

因为后面插入的人会影响前面已排好人员的位置,正向模拟插入会导致 O(N2)O(N^2) 的时间复杂度。由于每个点只影响前面的元素,我们可以采取倒序处理的策略:

  • 最后一个人(第 NN 个人)的位置不会受任何人影响,所以他在最终队伍中的绝对位置必然是 PN+1P_N + 1(1-indexed)。
  • 倒数第二个人,他的位置在剩下的空位中必定是第 PN−1+1P_{N-1} + 1 个空位。
  • 一般地,倒数第 ii 个人,最终位置必然是当前队伍剩余空位中的第 Pi+1P_i + 1 个。

为了高效查找第 kk 个空位并在占据后更新空位状态,可以使用树状数组(BIT):

  1. 初始时,树状数组的 1∼N1 \sim N 位置都置为 1(表示空位),维护前缀和。
  2. 寻找第 kk 个空位时,直接在树状数组上利用倍增法进行二分,时间复杂度为 O(log⁡N)O(\log N)。
  3. 找到目标空位后将其在树状数组中的值减 1,表示空位被占用,并记录对应人的 ViV_i。

代码

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-09 11:13
# update_at: 2026-10-09 11:41

import sys


def kth_empty(bit: list[int], k: int, n: int) -> int:
    """倍增求前缀和首次达到 k 的位置,即当前第 k 个空位的下标(1-indexed)。"""
    pos = 0
    step = 1 << (n.bit_length() - 1)
    while step:
        nxt = pos + step
        if nxt <= n and bit[nxt] < k:
            k -= bit[nxt]
            pos = nxt
        step >>= 1
    return pos + 1


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    out: list[str] = []

    for n in data:
        try:
            op = [(next(data), next(data)) for _ in range(n)]  # 每行 (P_i, V_i)
        except StopIteration:  # 输入被截断,放弃这一组
            break

        bit = [0] + [i & -i for i in range(1, n + 1)]  # 初始全空:bit[i] = i & -i
        ans = [0] * (n + 1)

        # 倒序:第 i 个人必定落在当前剩余空位的第 P_i + 1 个
        for pos, val in reversed(op):
            p = kth_empty(bit, pos + 1, n)
            ans[p] = val
            i = p
            while i <= n:  # 占掉这个空位
                bit[i] -= 1
                i += i & -i

        out.append(''.join(f'{x} ' for x in ans[1:]))  # 行末也带一个空格

    sys.stdout.write('\n'.join(out) + '\n' if out else '')


if __name__ == '__main__':
    solve()
cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-09 11:13
 * update_at: 2026-10-09 11:41
 */

/* ROJ 3165 「Buy Low Buy Lower」低买 */

/* ⚠ 题面与真实数据不符:题面是「最长严格递减子序列 + 方案数」,真实数据是
 * POJ 2828 Buy Tickets 插队问题。这里按真实数据实现。
 *
 * 插队问题:N 个人依次入队,第 i 个人插到当时队伍的第 P_i 个位置(0-indexed),
 * 求最终队伍里每个人的 V_i。
 *
 * 做法:正向模拟插入的复杂度是 O(N^2),改用「离线倒序 + 树状数组上二分」。
 * 最后一个插入的人位置不再被后来者影响,他在最终队伍中的位置就是第 P_N+1 个空位;
 * 倒着往前,每人都占据当前剩余空位中的第 P_i+1 个。树状数组维护「每个位置是否为空」
 * 的前缀和,用倍增在 O(log N) 内定位第 k 个空位。总复杂度 O(N log N)。 */

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 200005;  // N ≤ 200000,位置按下标 1..N 使用

struct Op {
    int pos;  // P_i:插入到当时队伍的第 pos 个位置(0-indexed)
    ll val;   // V_i:这个人的数值
};

int n;              // 当前组的操作条数
Op op[MAXN];        // op[i]:第 i 条插入指令(按下标 0..n-1 读入,倒序处理)
ll ans[MAXN];       // ans[p]:最终队伍第 p 个位置(1-indexed)上的人的数值
int bit[MAXN];      // 树状数组,存「第 p 个位置是否为空」的前缀和;下标 ≤ 200000,用 int

// 单点加减:位置 i 的空位数变化 delta(0 表示已被占用)
void bit_add(int i, int delta) {
    for (; i <= n; i += i & -i) {
        bit[i] += delta;
    }
}

// 倍增求前缀和首次达到 k 的位置,即当前第 k 个空位的下标(1-indexed)
int bit_kth(int k) {
    int pos = 0;
    int sum = 0;
    int step = 1;
    while ((step << 1) <= n) {
        step <<= 1;
    }
    for (; step > 0; step >>= 1) {
        int next_pos = pos + step;
        if (next_pos <= n && sum + bit[next_pos] < k) {
            pos = next_pos;
            sum += bit[next_pos];
        }
    }
    return pos + 1;
}

void solve() {
    for (int i = 0; i < n; i++) {
        cin >> op[i].pos >> op[i].val;
    }

    // 初始时每个位置都空着:bit[i] = i & -i 是「前 i 个位置全为 1」的树状数组初值
    for (int i = 1; i <= n; i++) {
        bit[i] = i & -i;
    }

    // 倒序处理:第 i 个人必定落在当前剩余空位的第 op[i].pos + 1 个
    for (int i = n - 1; i >= 0; i--) {
        int p = bit_kth(op[i].pos + 1);
        ans[p] = op[i].val;
        bit_add(p, -1);  // 这个空位被占掉
    }

    // 输出最终队伍;每个数字后都带一个空格,行末也保留(与数据格式一致)
    for (int i = 1; i <= n; i++) {
        cout << ans[i] << ' ';
    }
    cout << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    while (cin >> n) {
        solve();
    }
    return 0;
}

复杂度

  • 时间复杂度:建树 O(N)O(N),对于 NN 个人,每次倍增查询第 kk 个空位耗时 O(log⁡N)O(\log N)。总时间复杂度为 O(Nlog⁡N)O(N \log N)。
  • 空间复杂度:需要若干大小为 NN 的数组,空间复杂度为 O(N)O(N)。

总结

  • ★★★ 题面 ≠ 数据(实锤)

    1. 输出是输入 VV 的排列(12/12组成立)。
    2. 输出严格递减(12/12组全假,存在不降反升的反例如 26816 <= 30894)。
    3. .in 是 (P,V) 对,且解析后无剩余 token。
    4. 多用例直到 EOF,且 P∈[0,N−1]P \in [0, N-1] 违反 0 次(最大测到 199121)。
    5. (i) 按题面解析有大量未消费 token:如点 1 题面读 N=5N=5 后实际剩 23 个;点 10 题面读 N=200000N=200000 剩 200000 个,题面格式不成立。
    6. (ii) 算法对照全错:按照题面所写的 LDS 算法跑题面样例完全正确,但在真实数据上 10/10 全错,输出少得可怜的长度和方案,而期望却是每行 NN 个整数。这排除了“看错题”可能,实属换题。
    7. (iii) 反向验证崩溃:题面算法跑真实数据,C++ 版报 rc=-10 (SIGBUS),因为题面格式将价格误当作下一组的 NN 从而迅速越界崩溃。
  • ★ 实际考点:POJ 2828 (Buy Tickets) 插队问题,离线倒序 + 树状数组求第 kk 空位,O(Nlog⁡N)O(N \log N)。

  • ★★★ V7 算法指纹: 操作计数证明实现确为 O(Nlog⁡N)O(N \log N):在点 10 处 kth_calls = 200000,kth_steps = 3,600,000,kth_steps / N = 18 = ⌈log₂ 200000⌉ 精确吻合。库函数替换或 O(N2)O(N^2) 不可能产生这个指纹,这是本算法复杂度的强机械证据。

  • ★★ V8 辨识度:点粒度无分辨力(一旦错全是 10/10 错),token 粒度有分辨力。插队语义被数据唯一确定(6 个候选解释只有“倒序+第 Pi+1P_i+1 个空位”能通过,不同候选的首个分歧 token 列各不相同)。

  • ★★ 多解性(独立验证): 通过全排列枚举 N≤6N \le 6 的所有情况,200组随机数据下,满足插队过程的解集合大小恒为 1。另外 4 种独立实现(BIT二分 / 正序 / 空位链表)结果 MD5 完全一致,确认唯一确定,不需要 spj。

  • ★ 逐点表: | 点 | 组数 | N | P 范围 | V 范围 | cpp ms | cpp MB | py ms | py MB | |—|—|—|—|—|—|—|—|—| | 1 | 3 | 5,4,4 | [0,4] | [33,31492] | 8.2 | 8.4 | 17.6 | 8.4 | | 2 | 1 | 5 | [0,3] | [30535,32766] | 8.7 | 8.4 | 17.0 | 8.3 | | 3 | 1 | 10 | [0,7] | [20409,32761] | 8.8 | 8.4 | 17.1 | 8.4 | | 4 | 1 | 15 | [0,9] | [5338,32766] | 8.8 | 8.4 | 18.0 | 8.3 | | 5 | 1 | 50 | [0,48] | [15089,32766] | 9.1 | 8.4 | 17.2 | 8.5 | | 6 | 1 | 100 | [0,88] | [10641,32765] | 8.6 | 8.4 | 17.2 | 8.4 | | 7 | 1 | 1000 | [0,982] | [14607,32766] | 8.9 | 8.4 | 18.3 | 8.8 | | 8 | 1 | 5000 | [0,4949] | [5633,32766] | 9.5 | 8.4 | 27.2 | 10.4 | | 9 | 1 | 10000 | [0,9886] | [16740,32764] | 10.7 | 8.4 | 41.6 | 11.8 | | 10 | 1 | 200000 | [0,199121] | [0,32767] | 53.4 | 8.4 | 619.5 | 78.6 |

  • ★ 时间与空间:C++ 峰值 53 ms / 8.4 MB,限制 1000 ms / 128 MB(余量 19×);Python 峰值 619 ms / 78.6 MB,余量约 1.6×。虽然 Python 接近上限,但在本机 Python 3.14 环境下复测为 358 ms,较为宽松,能稳定通过。

  • ★ 输出格式:

    1. 行末确实有多余空格(12/12行的最后一个数字后有空格再换行)。
    2. 行尾是 LF,不是 CRLF。
    3. 行数恰好等于组数(点1有3行对应3组,其余1行对应1组)。
    4. .in 数据点1末尾无换行,其余存在换行。
  • ★ V1 反事实表: | 变体 | 改动 | 失败点数 | |—|—|—| | va | 倒序 → 正序 | 10/10 | | vb | 第 P 个空位 → 第 P 个绝对位置(不跳空位)| 10/10 | | vc | P 当 1-indexed(k = max(P,1))| 10/10 | | vc2 | off-by-one(k = P+2)| 10/10 | | vd | 去掉行尾空格 | 10/10(字节级)| | vg | 行尾 LF → CRLF | 10/10(字节级)| | vf | BIT → 朴素 O(N2)O(N^2) 正序插队 | 0/10(如预期,仅影响速度)| | ve2 | BIT → 空位链表 O(N2)O(N^2) 倒序 | 0/10(如预期)|

  • ★ V2/V4:

    • V2 字面量:无硬编码(main.cpp 的 200005 仅为宏;输出含 32716 个独立值,与源码的 0/1 交集纯属巧合)。
    • V4 结构不变量:.in 完整解析为多组 (N, N×(P,V)) 毫无多余 token;.out 行数=组数,每行 token=该组 N,每行都是该组 V 的排列。
  • ★ T9 五项:

    1. 类型转换:无复杂类型转换,全在整数范畴操作。
    2. 溢出风险:无,所有数值和前缀和在 N≤200,000N \le 200,000 的规模下,都在 32 位带符号整型 int 范围内。
    3. 整除取模:无。
    4. 浮点格式化:无。
    5. 排序字典序:不需要任何字典序与排序干预。