[CSP-S 2021] 回文

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

固定首个取数方向后按匹配位置切成两段,用两端配对贪心构造操作串。

OJ: luogu

题目 ID: P7915

难度:提高+/省选-

标签:构造贪心双指针搜索

日期: 2026-07-06 08:46

题意

给定长度为 2n 的序列,其中 1..n 每个数都恰好出现两次。每次可以从当前序列左端或右端取出一个数,依次放到新序列 b 的末尾。

要求构造一个操作串,使最终得到的 b 是回文;如果有多种方案,输出字典序最小的操作串。规定 L < R

思路

小数据可以直接按字典序枚举所有操作串,找到第一个能得到回文的方案:

cpp
// brute.cpp:小数据暴力解,按字典序 DFS 枚举 L/R 操作直到得到回文。
#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> origin;
string answer;
bool found;

bool is_palindrome(const vector<int> &b) {
    for (int i = 0, j = (int)b.size() - 1; i < j; i++, j--) {
        if (b[i] != b[j]) {
            return false;
        }
    }
    return true;
}

void dfs(int l, int r, vector<int> &b, string &ops) {
    if (found) {
        return;
    }
    if (l > r) {
        if (is_palindrome(b)) {
            answer = ops;
            found = true;
        }
        return;
    }

    // 字典序要求优先尝试 L。
    b.push_back(origin[l]);
    ops.push_back('L');
    dfs(l + 1, r, b, ops);
    ops.pop_back();
    b.pop_back();

    b.push_back(origin[r]);
    ops.push_back('R');
    dfs(l, r - 1, b, ops);
    ops.pop_back();
    b.pop_back();
}

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

    int T;
    cin >> T;
    while (T--) {
        cin >> n;
        origin.assign(2 * n + 1, 0);
        for (int i = 1; i <= 2 * n; i++) {
            cin >> origin[i];
        }

        answer.clear();
        found = false;
        vector<int> b;
        string ops;
        dfs(1, 2 * n, b, ops);

        if (found) {
            cout << answer << '\n';
        } else {
            cout << -1 << '\n';
        }
    }

    return 0;
}

暴力的瓶颈是操作串有 2^(2n) 种。正解需要利用“每个数恰好出现两次”的性质。

一个回文序列的第一个数和最后一个数必须相同。因此,第一步只有两种本质尝试:

  • 先取左端 a[1],那么它的另一次出现必须作为最后一步被取走;
  • 先取右端 a[2n],那么它的另一次出现必须作为最后一步被取走。

因为 L 的字典序小于 R,我们先尝试第一种。如果能构造成功,它一定优于任何以 R 开头的方案。

以先取左端为例。设 a[1] 的另一次出现位置为 p。位置 p 的数要留到最后,剩下的元素被切成两段:

text
[2, p-1]      [p+1, 2n]

后续每次要为回文的前半部分取一个数,同时为回文的后半部分预定一个相同的数。当前可取的前端只可能是左段左端或右段右端;对应的后端只可能是左段右端或右段左端。

所以每一步最多检查四种配对:

text
左段左端  <-> 左段右端
左段左端  <-> 右段左端
右段右端  <-> 左段右端
右段右端  <-> 右段左端

为了字典序最小,能让当前操作为 L 时优先选 L,否则再选 R。每次记录前半部分操作,同时把匹配操作记录到后半部分;最后后半部分要反向拼回去。

如果先取左端失败,再用同样方法尝试先取右端。两次都失败才输出 -1

代码

cpp
// main.cpp:固定第一个选择后,把剩余序列分成两段,贪心构造字典序最小方案。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

int n;
int a[MAXN];
int first_pos[MAXN], second_pos[MAXN];

bool same_pos_invalid(int x, int y) {
    return x == y;
}

bool build_answer(bool choose_left_first, string &answer) {
    int total = 2 * n;
    int first_value;
    int match_pos;
    char first_op, last_op;
    int l1, r1, l2, r2;

    if (choose_left_first) {
        first_value = a[1];
        match_pos = (first_pos[first_value] == 1) ? second_pos[first_value] : first_pos[first_value];
        first_op = 'L';
        last_op = 'L';
        l1 = 2;
        r1 = match_pos - 1;
        l2 = match_pos + 1;
        r2 = total;
    } else {
        first_value = a[total];
        match_pos = (first_pos[first_value] == total) ? second_pos[first_value] : first_pos[first_value];
        first_op = 'R';
        // 最后只剩一个元素时,L/R 都能取走;为了字典序取 L。
        last_op = 'L';
        l1 = 1;
        r1 = match_pos - 1;
        l2 = match_pos + 1;
        r2 = total - 1;
    }

    string left_ops, right_ops;
    left_ops.push_back(first_op);

    for (int step = 1; step <= n - 1; step++) {
        bool done = false;

        if (l1 <= r1) {
            if (l1 < r1 && a[l1] == a[r1]) {
                left_ops.push_back('L');
                right_ops.push_back('L');
                l1++;
                r1--;
                done = true;
            } else if (l2 <= r2 && a[l1] == a[l2]) {
                left_ops.push_back('L');
                right_ops.push_back('R');
                l1++;
                l2++;
                done = true;
            }
        }

        if (!done && l2 <= r2) {
            if (l1 <= r1 && a[r2] == a[r1]) {
                left_ops.push_back('R');
                right_ops.push_back('L');
                r2--;
                r1--;
                done = true;
            } else if (l2 < r2 && a[r2] == a[l2]) {
                left_ops.push_back('R');
                right_ops.push_back('R');
                r2--;
                l2++;
                done = true;
            }
        }

        if (!done) {
            return false;
        }
    }

    if (l1 <= r1 || l2 <= r2) {
        return false;
    }

    answer = left_ops;
    for (int i = (int)right_ops.size() - 1; i >= 0; i--) {
        answer.push_back(right_ops[i]);
    }
    answer.push_back(last_op);
    return true;
}

void solve_one() {
    cin >> n;
    int total = 2 * n;
    for (int i = 1; i <= n; i++) {
        first_pos[i] = second_pos[i] = 0;
    }

    for (int i = 1; i <= total; i++) {
        cin >> a[i];
        if (first_pos[a[i]] == 0) {
            first_pos[a[i]] = i;
        } else {
            second_pos[a[i]] = i;
        }
    }

    string answer;
    if (build_answer(true, answer)) {
        cout << answer << '\n';
        return;
    }
    if (build_answer(false, answer)) {
        cout << answer << '\n';
        return;
    }
    cout << -1 << '\n';
}

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

    int T;
    cin >> T;
    while (T--) {
        solve_one();
    }

    return 0;
}

复杂度

每组数据只会在线性指针上移动,每个位置最多被处理一次。

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

总结

本题不要直接搜索完整操作串。先固定第一个数,就能确定最后一个数的位置;这个位置把剩余序列切成两段,后面的构造就变成“两段两端配对”。

字典序最小的核心是:先尝试首字母为 L 的方案,构造过程中也尽量让前半部分当前操作为 L