[Algo Beat Contest 017 B] 线性筛

比较分段模拟和树状数组三种维护当前序列的方法,正式主解用树状数组 kth 定位动态排名。

OJ: luogu

题目 ID: P17233

难度:普及

标签:树状数组模拟数据结构

日期: 2026-08-11 07:37

形式化题目

给定一个序列。每轮在当前剩余序列中,同时删除所有排名为完全立方数的位置,剩余元素保持原相对顺序并重新编号。不断重复直到序列为空,要求输出轮数以及每轮被删除的元素值。

暴力

最直接的做法是维护当前剩余序列,每轮扫描当前位置是否为完全立方数。如果当前位置是 1,8,27,1,8,27,\ldots,就输出并删除这个元素;否则把它放入下一轮序列。

这个暴力完整体现了“同时删除”的语义:本轮所有位置都按本轮开始时的当前序列编号判断,没被删除的元素按原相对顺序进入下一轮。它适合理解题意和作为对拍基准,不作为正式主解。

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-08-11 07:37
 * update_at: 2026-08-11 11:18
 */
// brute.cpp:小数据暴力解,直接维护当前剩余序列并逐轮扫描。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

int n;
int is_cube[MAXN]; // is_cube[i] 表示 i 是否为完全立方数

void init_cube() {
    // 预处理完全立方数位置,之后每轮扫描时 O(1) 判断当前位置是否要删除。
    for (int i = 1; 1LL * i * i * i < MAXN; i++) {
        is_cube[i * i * i] = 1;
    }
}

int count_deleted(int len) {
    // 当前长度为 len 时,本轮会删除 1^3, 2^3, ... 这些排名。
    int cnt = 0;
    for (int i = 1; 1LL * i * i * i <= len; i++) cnt++;
    return cnt;
}

int count_rounds(int len) {
    // 题目要求先输出总轮数。这里只模拟长度变化,不关心具体元素。
    int rounds = 0;
    while (len > 0) {
        len -= count_deleted(len);
        rounds++;
    }
    return rounds;
}

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

    init_cube();

    cin >> n;
    vector<int> cur(n);
    for (int i = 0; i < n; i++) cin >> cur[i];

    cout << count_rounds(n) << '\n';

    // cur 表示本轮开始时的当前序列,next_cur 表示删完本轮后的剩余序列。
    vector<int> next_cur;
    while (!cur.empty()) {
        next_cur.clear();

        bool first_output = true;
        for (int i = 0; i < (int)cur.size(); i++) {
            int pos = i + 1;
            if (is_cube[pos]) {
                // 删除位置全部按本轮开始时的 cur 下标判断,符合“同时删除”。
                if (!first_output) cout << ' ';
                cout << cur[i];
                first_output = false;
            }
            else {
                // 没被删除的元素按原相对顺序进入下一轮。
                next_cur.push_back(cur[i]);
            }
        }
        cout << '\n';
        // 滚动数组:下一轮继续处理剩余序列。
        cur.swap(next_cur);
    }
    return 0;
}

思路

这一题有三种实现路线。下面按代码理解难度从低到高介绍:

  • 解法一:双队列流式重建连续段。它最接近暴力模拟,只是把当前序列压缩成若干原数组连续段。
  • 解法二:链表维护连续段。它原地维护这些连续段,展示当前序列被不断切开的过程。
  • 解法三:树状数组维护动态排名。它是正式主解,对应 main.cpp,复杂度证明最稳定。

三种解法都在解决同一个核心问题:删除依据是“当前排名”,而输出元素来自“原数组下标”。所以我们需要维护一种结构,把当前第 kk 个元素定位回原数组中的位置。

解法一:双队列流式重建连续段

思路

暴力每轮直接保存整个当前序列。双队列分段写法保留这个“每轮生成下一轮”的直觉,但不逐个元素维护,而是把当前序列压成若干连续段。

一个段 Segment{pos,start,len} 表示:当前序列中从排名 pos 开始的 len 个元素,正好对应原数组中从 start 开始的连续 len 个元素。

每轮用 q1 保存旧段,q2 保存删除后产生的新段。先生成本轮所有完全立方排名,然后从左到右扫描 q1。如果某个立方排名落在当前段内,就输出对应原数组元素;两个删除点之间没被删的部分,作为新段推入 q2。本轮结束后交换两个队列。

这种写法天然保证同时删除:本轮定位只看 q1 中的旧段,新生成的段只进入 q2,不会影响本轮后续排名。

代码

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-08-11 10:33
 * update_at: 2026-08-12 15:02
 */
// main-queue.cpp:另一种分段模拟,用两个队列维护当前剩余序列的连续原数组段。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

struct Segment {
    int pos;   // 这一段在当前序列中的起始排名
    int start; // 这一段在原数组中的起始下标
    int len;   // 这一段连续长度
};

int n;
int a[MAXN];
queue<Segment> q1, q2; // q1 本轮开始时的旧段,q2 本轮生成的新段(本轮的删除只看 q1)
int cube_pos[105];     // 本轮所有完全立方排名 1, 8, 27, ...,递增
int cube_cnt;
vector<vector<int> > rounds;

// 生成长度为 len 时的全部完全立方排名。
void build_cube_positions(int len) {
    cube_cnt = 0;
    for (long long x = 1; x * x * x <= len; x++) {
        cube_cnt++;
        cube_pos[cube_cnt] = (int)(x * x * x);
    }
}

// 把一个保留段推入新队列 q2。
void push_segment(int pos, int start, int len) {
    if (len <= 0) return;
    Segment seg;
    seg.pos = pos;
    seg.start = start;
    seg.len = len;
    q2.push(seg);
}

// 读入原数组。
void read_input() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
}

// 处理一轮:扫描 q1 中的旧段,收集本轮删除的元素值,并把保留段生成到 q2。
// new_len 通过引用带回本轮保留的元素总数。
vector<int> process_one_round(int len, int &new_len) {
    build_cube_positions(len);
    vector<int> deleted_values;

    // ptr 指向下一个要处理的立方排名;new_len 统计本轮保留的元素总数。
    int ptr = 1;
    new_len = 0;
    while (!q1.empty()) {
        Segment seg = q1.front();
        q1.pop();

        int seg_l = seg.pos;
        int seg_r = seg.pos + seg.len - 1;
        // 跳过段之前已经处理完的立方排名。
        while (ptr <= cube_cnt && cube_pos[ptr] < seg_l) ptr++;

        int last_offset = 0; // 已经处理到段内 0-based 的 last_offset 之前
        while (ptr <= cube_cnt && cube_pos[ptr] <= seg_r) {
            int offset = cube_pos[ptr] - seg.pos; // 0-based 段内偏移
            deleted_values.push_back(a[seg.start + offset]);

            // 该删除点之前没被删的部分作为保留段,进入下一轮。
            int keep_len = offset - last_offset;
            if (keep_len > 0) {
                push_segment(new_len + 1, seg.start + last_offset, keep_len);
                new_len += keep_len;
            }

            last_offset = offset + 1;
            ptr++;
        }

        // 段尾剩余部分保留。
        int tail_len = seg.len - last_offset;
        if (tail_len > 0) {
            push_segment(new_len + 1, seg.start + last_offset, tail_len);
            new_len += tail_len;
        }
    }

    return deleted_values;
}

// 逐轮删除,直到序列为空,把每轮删除的元素值存进 rounds。
void solve() {
    // 初始整个序列是一个段:排名从 1 开始,对应原数组 [1, n]。
    Segment first;
    first.pos = 1;
    first.start = 1;
    first.len = n;
    q1.push(first);

    int len = n; // 当前剩余序列长度
    while (len > 0) {
        int new_len = 0;
        rounds.push_back(process_one_round(len, new_len));
        swap(q1, q2); // 下一轮处理本轮生成的保留段
        len = new_len;
    }
}

// 输出轮数与每轮删除的元素。
void print_answers() {
    cout << rounds.size() << '\n';
    for (int i = 0; i < (int)rounds.size(); i++) {
        for (int j = 0; j < (int)rounds[i].size(); j++) {
            if (j) cout << ' ';
            cout << rounds[i][j];
        }
        cout << '\n';
    }
}

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

    read_input();
    solve();
    print_answers();

    return 0;
}

复杂度

时间复杂度与每轮段数总扫描量有关。它的代码直观,实测可以通过大数据压力测试,但严格最坏界不如树状数组清晰。

空间复杂度为 O(n)O(n)

解法二:链表维护连续段

思路

链表分段写法同样维护当前序列由哪些原数组连续段拼接而成,但它不是每轮整体生成一个新队列,而是在段链表上记录本轮要删除的位置,再统一把受影响的段切开。

每个链表节点记录一个连续段的原数组起点和长度。因为本轮的完全立方排名递增,所以可以用指针从前往后定位所有待删位置。定位阶段只记录“删哪个节点、节点内偏移是多少”,不能立刻修改链表。等本轮全部定位完成后,再按这些偏移把对应段切成若干新段。

这比双队列写法更贴近“当前序列在原地维护”的想法,但细节也更多,尤其要处理同一轮多个删除点落在同一个段内的情况。

代码

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-08-11 10:18
 * update_at: 2026-08-12 14:07
 */
// main-link.cpp:另一种解法,用链表维护当前剩余序列的连续原数组段。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

int n;
int a[MAXN];

// 每个节点表示当前序列中的一个连续段:a[start_pos] ... a[start_pos + len - 1]
int start_pos[MAXN * 3]; // 段在原数组中的起始下标
int seg_len[MAXN * 3];   // 段长度
int nxt[MAXN * 3];       // 段链表:后继节点
int pre[MAXN * 3];       // 段链表:前驱节点
int node_cnt;            // 已分配的节点数
int head_node;           // 段链表头节点

// 本轮要删除的位置记录:第 i 个删除点落在 deleted_node[i] 段内,偏移为 deleted_offset[i]。
int deleted_node[MAXN];
int deleted_offset[MAXN]; // 段内 1-based 偏移,表示该位置在段内的第几个元素
int deleted_value[MAXN];
int delete_cnt;

vector<vector<int> > rounds;

// 新建一个连续段节点:原数组从 pos 开始、长度为 len;len <= 0 返回空节点。
int new_node(int pos, int len) {
    if (len <= 0) return 0;
    node_cnt++;
    start_pos[node_cnt] = pos;
    seg_len[node_cnt] = len;
    nxt[node_cnt] = 0;
    pre[node_cnt] = 0;
    return node_cnt;
}

// 在节点 x 之后插入节点 y。
void insert_after(int x, int y) {
    if (y == 0) return;
    int z = nxt[x];
    nxt[x] = y;
    pre[y] = x;
    nxt[y] = z;
    if (z != 0) pre[z] = y;
}

// 从链表中摘掉节点 x。
void erase_node(int x) {
    int l = pre[x];
    int r = nxt[x];
    if (l != 0) nxt[l] = r;
    if (r != 0) pre[r] = l;
    if (head_node == x) head_node = r;
}

// 把段 x 在段内 1-based 偏移 offset 处切开,替换为左右两个新段。
void replace_by_split(int x, int offset) {
    int left_len = offset - 1;
    int right_len = seg_len[x] - offset;
    int old_start = start_pos[x];
    int left_node = new_node(old_start, left_len);
    int right_node = new_node(old_start + offset, right_len);

    int l = pre[x];
    int r = nxt[x];
    if (l != 0) nxt[l] = 0;
    if (r != 0) pre[r] = 0;

    if (l != 0) {
        nxt[l] = r;
        if (r != 0) pre[r] = l;
    }
    erase_node(x);

    // 按顺序把新段插回原位置。
    int first = 0;
    int last = l;
    if (left_node != 0) {
        if (l != 0) insert_after(l, left_node);
        else head_node = left_node;
        first = left_node;
        last = left_node;
    }
    if (right_node != 0) {
        if (last != 0) insert_after(last, right_node);
        else head_node = right_node;
        if (first == 0) first = right_node;
        last = right_node;
    }
    if (last != 0) {
        nxt[last] = r;
        if (r != 0) pre[r] = last;
    }
    else {
        if (l != 0) nxt[l] = r;
        if (r != 0) pre[r] = l;
        if (l == 0) head_node = r;
    }
}

// 定位本轮要删除的排名:1^3, 2^3, ... 递增,用 cur 指针从链表头往后扫。
// 只记录删除点(节点 + 段内偏移),本轮内不修改链表。
void locate_deleted_positions(int len) {
    delete_cnt = 0;
    int cur = head_node;
    int passed = 0; // cur 段之前所有段的总长度,即 cur 段起点在序列中的排名减 1

    for (long long x = 1; x * x * x <= len; x++) {
        int rank_pos = (int)(x * x * x);
        while (cur != 0 && passed + seg_len[cur] < rank_pos) {
            passed += seg_len[cur];
            cur = nxt[cur];
        }
        int offset = rank_pos - passed;
        delete_cnt++;
        deleted_node[delete_cnt] = cur;
        deleted_offset[delete_cnt] = offset;
        deleted_value[delete_cnt] = a[start_pos[cur] + offset - 1];
    }
}

// 按上一轮定位到的删除点,统一把受影响的段切开。
// 同一轮中一个段可能被多个删除点命中,按段分组后一次性处理。
void apply_deletions() {
    int i = 1;
    while (i <= delete_cnt) {
        int x = deleted_node[i];
        int old_start = start_pos[x];
        int old_len = seg_len[x];
        int l = pre[x];
        int r = nxt[x];

        // 收集落在段 x 内的所有删除偏移。
        vector<int> offsets;
        while (i <= delete_cnt && deleted_node[i] == x) {
            offsets.push_back(deleted_offset[i]);
            i++;
        }

        // 按偏移从小到大,把段切成若干"保留段",中间夹着的删除点全部去掉。
        int first_new = 0;
        int last_new = 0;
        int last_cut = 0; // 上一个删除点,新的保留段从它之后开始
        for (int j = 0; j < (int)offsets.size(); j++) {
            int off = offsets[j];
            int keep_len = off - last_cut - 1;
            int node = new_node(old_start + last_cut, keep_len);
            if (node != 0) {
                if (first_new == 0) first_new = node;
                if (last_new != 0) insert_after(last_new, node);
                last_new = node;
            }
            last_cut = off;
        }
        // 最后一个删除点之后到段尾的剩余部分。
        int tail_len = old_len - last_cut;
        int tail_node = new_node(old_start + last_cut, tail_len);
        if (tail_node != 0) {
            if (first_new == 0) first_new = tail_node;
            if (last_new != 0) insert_after(last_new, tail_node);
            last_new = tail_node;
        }

        // 用新段序列替换掉链表中的旧段 x。
        if (l != 0) nxt[l] = first_new;
        else head_node = first_new;
        if (first_new != 0) pre[first_new] = l;

        if (last_new != 0) {
            nxt[last_new] = r;
            if (r != 0) pre[r] = last_new;
        }
        else {
            if (l != 0) nxt[l] = r;
            if (r != 0) pre[r] = l;
            if (l == 0) head_node = r;
        }
    }
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    // 初始整个序列是一个段:原数组 [1, n]。
    node_cnt = 0;
    head_node = new_node(1, n);

    int len = n; // 当前剩余序列长度
    while (len > 0) {
        // 先只定位本轮删除点,再统一切开,保证"同时删除"。
        locate_deleted_positions(len);

        vector<int> values;
        for (int i = 1; i <= delete_cnt; i++) values.push_back(deleted_value[i]);
        rounds.push_back(values);

        apply_deletions();
        len -= delete_cnt;
    }

    cout << rounds.size() << '\n';
    for (int i = 0; i < (int)rounds.size(); i++) {
        for (int j = 0; j < (int)rounds[i].size(); j++) {
            if (j) cout << ' ';
            cout << rounds[i][j];
        }
        cout << '\n';
    }
    return 0;
}

复杂度

时间复杂度与每轮段数总扫描量有关。它原地维护段结构,能避免保存完整当前序列,但严格最坏界同样不如树状数组清晰。

空间复杂度为 O(n)O(n)

解法三:树状数组维护动态排名

思路

树状数组是本题的正式主解。它不显式保存当前序列,而是在原数组下标上维护一个存活标记:还在当前序列中的位置为 1,已经删除的位置为 0

这样,树状数组前缀和就表示某个原下标之前还有多少个元素存活。通过树状数组的 kth 查询,就能快速找到“当前第 kk 个存活元素”对应的原下标。

每一轮要特别注意同时删除:先枚举所有满足 x3lenx^3\leqslant len 的排名 x3x^3,用 kth 查出这些排名对应的原下标,全部保存下来;这一阶段不能修改树状数组。等本轮所有要删的位置都确定后,再统一把这些位置从树状数组中减掉。

因为 13,23,33,1^3,2^3,3^3,\ldots 是递增的,查出的当前排名也递增;剩余序列保持原顺序,所以这些原下标也是递增的,直接输出对应的 a[pos] 就满足每行按原序列先后顺序输出。

代码

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-08-11 07:37
 * update_at: 2026-08-12 14:07
 */
// main.cpp:正式主解,用树状数组维护每个原位置是否存活,kth 查询定位当前第 k 个元素。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

int n;
int a[MAXN];        // 原数组,a[i] 存第 i 个位置的元素值
int tree_array[MAXN]; // 树状数组:对每个原位置维护存活标记(1 存活、0 已删除),前缀和 = 当前位置之前的存活个数

int lowbit(int x) {
    return x & -x;
}

void add(int pos, int val) {
    for (int i = pos; i <= n; i += lowbit(i)) tree_array[i] += val;
}

// 找到当前剩余序列中排名第 k 的元素对应的原数组下标。
// 利用树状数组前缀和单调性,二分倍增跳过大段。
int kth_alive(int k) {
    int pos = 0;
    int sum = 0;
    for (int step = 1 << 20; step > 0; step >>= 1) {
        int next_pos = pos + step;
        if (next_pos <= n && sum + tree_array[next_pos] < k) {
            pos = next_pos;
            sum += tree_array[next_pos];
        }
    }
    return pos + 1;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    // 初始时所有位置都存活,树状数组第 i 个位置的值直接等于 lowbit(i)。
    for (int i = 1; i <= n; i++) tree_array[i] = lowbit(i);

    vector<vector<int> > rounds;
    int len = n; // 当前剩余序列的长度
    while (len > 0) {
        // 第一阶段:只定位本轮要删除的位置,不改树状数组,保证"同时删除"。
        vector<int> deleted_pos;
        for (long long x = 1; x * x * x <= len; x++) {
            int rank_pos = (int)(x * x * x);
            deleted_pos.push_back(kth_alive(rank_pos));
        }

        // 排名递增,查出的原下标也递增,直接按顺序输出即为本轮删除序列。
        vector<int> values;
        for (int i = 0; i < (int)deleted_pos.size(); i++) {
            values.push_back(a[deleted_pos[i]]);
        }
        rounds.push_back(values);

        // 第二阶段:统一把本轮删除的位置标记为 0。
        for (int i = 0; i < (int)deleted_pos.size(); i++) {
            add(deleted_pos[i], -1);
        }
        len -= (int)deleted_pos.size();
    }

    cout << rounds.size() << '\n';
    for (int i = 0; i < (int)rounds.size(); i++) {
        for (int j = 0; j < (int)rounds[i].size(); j++) {
            if (j) cout << ' ';
            cout << rounds[i][j];
        }
        cout << '\n';
    }
    return 0;
}

复杂度

每个元素恰好被删除一次,因此总共会进行 nnkth 查询和 nn 次删除修改。树状数组单次操作为 O(logn)O(\log n),总时间复杂度为 O(nlogn)O(n\log n)

树状数组、原数组和输出结果共占 O(n)O(n) 空间。

复杂度对比

解法 时间复杂度 空间复杂度 说明
双队列流式重建连续段 与每轮段数总扫描量有关 O(n)O(n) 代码直观,实测可过,但最坏界不如树状数组清晰
链表维护连续段 与每轮段数总扫描量有关 O(n)O(n) 原地维护段结构,细节较多
树状数组维护动态排名 O(nlogn)O(n\log n) O(n)O(n) 正式主解,复杂度证明最稳定

总结

这题的共同核心是把“当前排名”映射回“原数组中的元素”。三种解法的区别在于维护当前序列的方式。

双队列分段保留了暴力“从旧序列生成新序列”的直觉;链表分段展示了如何原地维护连续段;树状数组则把当前序列隐式成原位置的存活标记,用 kth 查询完成动态排名定位。

正式推荐先掌握树状数组版本,因为它的复杂度证明最稳。两个分段版本适合作为理解动态序列结构的补充。

图示解析

这张图比较三种解法如何完成同一个排名定位目标:

text
共同目标:把当前排名映射回原数组位置
|- 双队列分段
|  `- 本轮旧段 q1 -> 删除立方排名 -> 下一轮新段 q2
|- 链表分段
|  `- 连续段链表 -> 记录待删偏移 -> 切分受影响段
`- 树状数组
   `- 原位置存活标记 -> kth 查询当前第 k 个

三种解法都在解决同一个问题:如何把“当前第 kk 个元素”映射回原数组位置。双队列和链表都显式维护当前序列的连续段,只是一个用旧队列生成新队列,一个在链表上原地切分。树状数组不维护连续段,而是维护每个原位置是否存活,再用前缀和的 kth 查询完成排名定位。