「双指针&前缀和&回溯法」weight

本题为3月14日23上半学期集训每日一题中B题的题解

题面

题目描述

已知原数列 \(a_1, a_2, \cdots, a_n\) 中的前1项,前2项,前3项,...,前n项的和,以及后1项,后2项,后3项,...,后n项的和,但是所有的数都被打乱了顺序。此外,我们还知道数列中的数存在于集合S中。试求原数列。

当存在多组可能的数列时,求字典序最小的数列。

输入

第1行,一个整数n。

第2行, \(2 \times n\) 个整数,注意:数据已被打乱。

第3行,一个整数m,表示S集合的大小。

第4行,m个整数,表示S集合中的元素。

输出

输出满足条件的最小数列。

样例输入

5
1 2 5 7 7 9 12 13 14 14
4 
1 2 4 5

样例输出

1 1 5 2 5

提示

数据范围:

$ n\leq 1000 ,m\leq 500,且S\in $ { \(1,2,⋯,500\) }

样例解释:

从左往右求和 从右往左求和
\(\phantom{0}1 = 1\) \(\phantom{0}5 = \phantom{1 + 1 + 5 + 2 + }5\)
\(\phantom{0}2 = 1 + 1\) \(\phantom{0}7 = \phantom{1 + 1 + 5 + }2 + 5\)
\(\phantom{0}7 = 1 + 1 + 5\) \(\phantom{}12 = \phantom{1 + 1 + }5 + 2 + 5\)
\(\phantom{0}9 = 1 + 1 + 5 + 2\) \(\phantom{}13 = \phantom{1 + }1 + 5 + 2 + 5\)
\(\phantom{}14 = 1 + 1 + 5 + 2 + 5\) \(\phantom{}14 = \phantom{}1 + 1 + 5 + 2 + 5\)

思路分析

本题题目tag指出这是一道可以用回溯法做的题,所以这里不考虑其他方法,有想法可以自己试一试.

这题有一个非常明显的暴力的思路,那就是我对每一个和是作为前缀还是后缀2种情况分别进行枚举,判断每种选择情况是否可行.这个思路可以使用DFS或者二进制枚举实现,但是显然这样的时间复杂度会高达 \(O(2 ^ N)\) ,属于无解算法,所以我们需要通过剪枝来减小平均时间复杂度.

那如何剪枝呢?上述暴力枚举的思路是先确定所有数据的前后缀情况,最后再判断的.那我们能否在确定前后缀情况的过程中直接判断出已经显然不成立的情况呢?是可以的.

首先,由于对前缀和进行差分即可得到每一个元素,即 \(a[i] = sum[i] - sum[i - 1]\) (此处题意中的前缀包含当前元素,所以是这样的形式),所以我们可以通过当前假设为前缀和的数减掉前面的那个前缀和,来计算出一个元素,如果这个元素不在给出的集合中,那就说明当前的情况是不成立的,那么接下来我们无论如何假设都不可能成立,所以此时我们可以无需继续进行接下来的假设,即进行剪枝.对后缀和同理.

其次,对于前缀与后缀,显然具有如下的性质: \(prefL[i] + prefR[i + 1] = sum = prefL[n - 1] = prefR[0]\) (其中 \(prefL\) 表示前缀和数组, \(prefR\) 表示后缀和数组),即一个数的前缀和加上其后面那个数的后缀和,会得到整个数组的总和,而第一个元素的后缀和以及最后一个元素的前缀和也正好等于整个数组的总和(因为它们的后缀/前缀就是整个数组).既然如此,当前确定一个数是一个位置上的前缀/后缀时,也就同时确定了与之对应的那个数为另一个位置上的后缀/前缀.所以我们实际上只需要处理所有和的一半即可.且显然prefL和prefR是一大一小的.所以我们可以对输入进来的各个和的序列进行一次排序,然后直接只处理小的那一半即可.且这样排序之后,由于集合中的数都是正数,所以距离最近的两个前缀和或两个后缀和一定是相邻的,故我们也无需单独去考虑当前这个前缀/后缀和的前面的那个前缀/后缀和它到底是哪一个了.

所以这题回溯法(所谓回溯法就是在DFS的同时进行剪枝)的思路也就很明确了,为了方便地在DFS的同时直接算出所求的数字,这里再利用双指针来辅助完成所求数组的生成:

  1. 先对输入进来的各个和从小到大排序
  2. 启动DFS,挨个对各个和进行假设,由于这里要求我们输出字典序最小的可能,所以越前面的数越小越好,又由于我们对各个和已经进行了排序,各个和优先假设为前缀和即可满足这一条件.故我们这里假设时先假设为前缀和,后假设为后缀和.
  3. 如果假设为前缀和时发现计算出来的当前数不能在集合中找到,那就证明这样的假设不行,就无需继续递归下去(直接剪枝),然后再尝试假设为后缀和,如果也不行,即当前无论如何假设都不能产生一个集合中的数,那就证明前面某一处的假设有误(产生原因是因为某个和在判定时发现既可以做前缀也可以做后缀,然后我们先尝试了前缀,但实际上其实应该是后缀),那也无需继续递归下去(直接剪枝).
  4. 在进行DFS的同时,维护两个指针,一个指向要生成数组中当前最左边尚为生成的元素的下标,另一个指向最右边尚为生成元素的下标.当我们假设一个和是前缀时,我们计算出的当前元素其实就是放在最左边尚为生成元素的指针所指向的位置上的,我们将它放入,然后指针移向下一个元素;假设为后缀的情况同理.这样通过这两个指针就可以在DFS的同时完成对所求数组的生成.
  5. 当那两个指针相离(就是左边那个大于右边)的时候,证明当前数组已经生成完毕,我们对各个和的假设已经完成.由于前面某个和在判定时可能会既可以做前缀也可以做后缀,我们先尝试了前缀,但其可能实际上应该是后缀,所以我们还需要再验证一下当前生成这个数组的总和对不对,根据前面的分析,这个总和显然是最大的那个和,所以我们验证一下是否相等即可,如果不相等,那就证明当前的假设不成立,继续尝试其他假设;反之,当前假设成立,且由于先尝试前缀,这一定是字典序最小的情况,所以这就是答案,我们即可停止所有的递归调用,输出结果.

如果上述文字单独看看不清楚的话可以和下面的代码对照阅读.

参考代码

时间复杂度: 最坏 \(O(2 ^ N)\),平均 \(O\left(\frac{NM}{max_{i=0}^{n \times 2 -1}(sum[i])}\right)\) (最坏的时间复杂度是假设没有发生剪枝的,在此题中难以达到;平均时间复杂度的计算方法涉及到概率期望,这里省略,感兴趣可以去问问newbing,因为你看到的这个式子就是newbing算的 (才不是因为我不会算期望) )

空间复杂度: \(O(N + M)\) (计入输入数据存放空间,其中递归本身也要使用 \(O(N)\) 的空间)

#pragma GCC optimize(1)
#pragma GCC optimize(2)
#pragma GCC optimize(3, "Ofast", "inline")
#include <bits/stdc++.h>

using namespace std;

using i64 = long long;

bool isOK = false;
int *sum;
int *res;
int n;
unordered_set<int> s;

// i为当前正在假设的和的下标,l为当前数组中最左边尚为生成的元素的下标,r为最右边尚为生成的元素的下标,prefL为当前左边的前缀和,prefR为当前右边的后缀和
void dfs(int i = 0, int l = 0, int r = n - 1, int prefL = 0, int prefR = 0) {
    // 基线条件
    if (l > r) { // 当前数组已生成完成
        // 验证当前数组的总和是否正确
        int a = 0;
        for (int i = 0; i < n; i++) {
            a += res[i];
        }

        if (a != sum[(n << 1) - 1]) { // sum[(n << 1) - 1]即sum[n * 2- 1],根据前面的分析,一定是所求数组的和,如果当前算出来的和不是它,那一定是个错误的解
            return;
        }
        isOK = true; // 已产生正确解,停止所有递归调用
    }

    // 已产生正确解,停止所有递归调用
    if (isOK) {
        return;
    }

    int a = sum[i] - prefL; // 当前和作为前缀和,所确定出来的数
    int b = sum[i] - prefR; // 当前和作为后缀和,所确定出来的数

    // 基线条件,如果当前和作为前缀和后缀都不可行,证明前面某处假设错误,且之后无论如何假设也是错误的,那么直接剪枝
    if (s.find(a) == s.end() && s.find(b) == s.end()) {
        return;
    }

    // 分别枚举作为前缀和后缀的情况,由于要按字典序最小的输出,所以先尝试作为前缀
    if (s.find(a) != s.end()) { // 如果当前确定出来的数在集合里,证明可能可以作为前缀
        res[l] = a; // 把当前算出来的数填入数组
        dfs(i + 1, l + 1, r, sum[i], prefR); // 继续假设下一个和(因为l已填,所以改为l + 1,同时更新前缀和)
        // 此处无需还原res,因为当前层的递归调用依然是在操作l位置上的数,会直接覆盖掉
    }

    // 已产生正确解,停止所有递归调用
    if (isOK) {
        return;
    }
    
    // 能执行这里说明前面假设成前缀和不成立,再次尝试假设为后缀和
    if (s.find(b) != s.end()) {
        res[r] = b; // 把当前算出来的数填入数组
        dfs(i + 1, l, r - 1, prefL, sum[i]); // 继续假设下一个和(因为r已填,所以改为r - 1,同时更新后缀和)
        // 此处无需还原res,因为当前层的递归调用依然是在操作r位置上的数,会直接覆盖掉
    }
}

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

    cin >> n;
    sum = new int[n << 1]; // 各个和,n << 1等效n * 2,下同,注意优先级
    res = new int[n];

    // 输入各个和
    for (int i = 0; i < (n << 1); i++) {
        cin >> sum[i];
    }

    // 输入元素集合,为了方便查找,用unordered_set或set存放
    int m;
    cin >> m;
    while (m--) {
        int a;
        cin >> a;
        s.emplace(a);
    }

    // 对各个和排序
    sort(sum, sum + (n << 1));

    // 启动DFS
    dfs();

    // 输出生成的结果
    for (int i = 0; i < n; i++) {
        cout << res[i] << " ";
    }
    cout << "\n";

    delete[] sum;
    delete[] res;
    return 0;
}

"正是我们每天反复做的事情,最终造就了我们,优秀不是一种行为,而是一种习惯" ---亚里士多德

posted @ 2023-03-14 18:00  星双子  阅读(43)  评论(0编辑  收藏  举报