CF1769C2 Подкрутка II 题解

发布时间 2023-08-08 22:46:57作者: Sorato

题解背景

某一天上课时,老师在讲这道题,zxk 同学想出了一个非 dp 的贪心想法,但是在敲了 20 分钟后就很痛苦地离开了。

一位名为 zzn 的同学对此付出了实践,并给出了严谨的贪心证明,zxk 同学被他的黄金精神深深地感动了,

最后和 zzn 同学齐心协力在测试了 14 组毒瘤数据,修改 11 次之后终于 A 掉了这道题。

(所以代码中会出现两种码风)

思路

本着能贪就贪的思路,我们先想一想 \(O(n^2)\) 的做法,其实很简单,就是对于每一个数字往后面进行枚举就可以,但是时间复杂度明显很高,那么我们思考一下我们的时间都浪费在了哪里,进行优化。

对于一串连续的数字,我们很明显知道这串数字一定会连接在一起,但是我们还是把他们拆开来一次又一次地计算我们明明已经知道的结果。

比方说,对于一串数字

1 2 3 4 5 6

我们明明知道他们一定会连接在一起,但是我们在双循环枚举的时候还是会把他们拆开然后一次又一次地重复地计算。

那么我们应该如何避免这种局面的产生呢?

Step 1:去重

对于一个连续不下降的数列,比方说

1 2 3 3 3 3 3 7 8 8 8 9 11

其实这么多的 \(3\)\(8\) 对于我们来说,有意义的实际上只有两个:第一个不变,第二个上升 \(1\) 那么多余的我们完全可以去掉。

n = read();a[1] = read();
for(reg int i = 2;i <= n;i = -~i/*位运算优化的i++*/)
{
	a[i] = read();
	if(a[i] == a[i - 1] && a[i] == a[i - 2])
		-- i, -- n;
}

这就是一个去重自动机 (瞎编的),其中 \(i\)\(n\) 自减的操作可以自己体会一下 。

Step 2:切开分块(此分块不是数据结构)

我们将所有原数列分为 \(2\) 种块:方和圆,用 \(vbl_i\) 标记第 \(i\) 个块属于 \(0\)(圆)还是 \(1\)(方)。

我们定义这样的块为方:这个块中有且只有两个数原来相同,将后一个 \(+1\),使该块成为一个等差且公差为 \(1\) 的数列,且这个数列以这个 \(+1\) 了的数结尾(最后这句是为了方便处理和解释)

定义这样的块为圆:这个块是一个等差且公差为 \(1\) 的数列,且没有任何数是经过 \(+1\) 得到的

\(Vbl_i\)(注意 \(V\) 大写)标记第 \(i\) 个数是(\(1\))否(\(0\))放进了方中,用 \(Vro_i\) 标记第 \(i\) 个数是(\(1\))否(\(0\))放进了圆中。

分块的代码比较难解释,看着代码说吧:

vec[1].push_back(a[1]);//用vector类型存储各块

对于 \(a_1\),我们无法确定它将会在一个方中还是在一个圆中,所以直接放入第 \(1\) 个块中,不去定义这个块的类型。

for(reg int i = 2;i <= n; i = -~i)
{
    if(a[i]==a[i-1])//a[i]将会被放在方里
    {
        if(Vro[i-1])/*a[i-1]在一个圆里*/
        {
            vbl[cnt]=1;//根据方的定义,我们要把a[i]放入a[i-1]的块中,这个块就会变为方
            vec[cnt++].push_back(a[i]+1);//根据方的定义,a[i]放入该块后,该块就结束,cnt++,下同
            Vbl[i]=1;
        }
        else//当a[i-1]所在的块没有被定义时,为什么不会出现Vbl[i-1]==1,即a[i-1]被确定在方里的情况?思考
        {
            vbl[cnt]=1;//定义该块为方
            vec[cnt++].push_back(a[i]+1);
            Vbl[i-1]=1;Vbl[i]=1;
        }
    }

下证:此种情况中,\(a_i\) 的上一个数不可能已经被确定为放在一个方里

∵ 当一个块中有且只有 \(2\) 个数相同时,定义这个块为方

且 去重后最多只有 \(2\) 个数相同

\(a_i\) 不可能和它前边的元素相同

\(a_i\) 也就不会被确定为放在一个方里

证毕。

else if(a[i]-a[i-1]<=1)//a[i]将会被放入当前圆中
{
    if(Vro[i-1])    vec[cnt].push_back(a[i]),Vro[i]=1;
    else if(Vbl[i-1])	vec[cnt].push_back(a[i]);//1.思考为什么不确定Vro[i]=1
    else//当a[i-1]所在的块没有被定义时
    {
        vec[cnt].push_back(a[i]);//同1.
        Vro[i-1]=1;Vro[i]=1;//这里修不修改Vro[i-1]其实没有影响,但出于严谨还是修改
    }
    //2.思考cnt这里为什么不自加
}

对于 \(1\)

  • 上一个数被放入为了方中,说明上一个块已被确定为方;

  • 所以上一个块已经结束,当前块是一个新块,所以无法确定当前块是方还是圆;

  • 如果 \(a_{i+1}=a_i\),这个块就是方,否则就是圆,但这里不需要写,下一个数时会处理。

对于 \(2\)

  • 对于此种放入圆的情况,不知道下一个数是否还会被放进这个圆中;

  • 所以我们不可能结束这个圆,\(cnt\) 不自加。

else//a[i]-a[i-1]>1,则需再开一个新块
    {
        if(Vbl[i-1])	vec[cnt].push_back(a[i]);//cnt不自加,因为确定上个块为方时已经结束了上个块,开了一个新块
        else if(Vro[i-1])	vec[++cnt].push_back(a[i]);//不能确定a[i]在圆中还是在方中,同上
        else	Vro[i-1]=1,Vro[i]=1,vec[++cnt].push_back(a[i]);//a[i-1]没有被确定是在圆中还是在方中,根据定义,放入圆
    }
} 

结束了?并没有:

if(vec[cnt].empty())	cnt--;

如果最后一个块为方,处理那个块时已经开了新块,但是后边已经没有数了,所以在此特判。

Step 3:情况讨论并合并

下面我们尝试合并块,用 \(len\) 表示当前合并出的块的包含的不同值的个数, 指针 \(pnt\) 指向当前访问的块,\(maxx\) 表示最终答案。

思考:

  • 对于一个方(以及一个含有方的块集),它之中一定有一个数是进行过上升(\(+1\))操作的,所以我们在合并时不能让它整体上升
  • 对于一个圆(以及一个仅由圆合并成的块集),它之中没有数单独上升过,所以它是可以整体上升的。

所以,我们用 \(vis\) 标记当前合并出的块集能(\(0\))否(\(1\))整体上升,用 \(up_i\) 标记第 \(i\) 个块是(\(1\)) 否(\(0\))整体上升过。

现在我们清楚了两种块的区别,就可以开始讨论可合并的情况并合并了(有些情况可合起来写,在此方便理解拆开写)。

首先,初始化:

int pnt = 1;
len = vec[1].size();
bool vis = 0;
clears();
while(pnt < cnt)
{
    bool wk=0;//wk的意义后面再说
  1. 方(\(pnt\) 指向该块)加方,首(下个块的)尾(当前块的)相差 \(1\)

    if(vbl[pnt]+vbl[pnt+1]==2&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-1)
    {
        vis=1;//只要合并的块中有方vis就要变为1,下同
        len += vec[pnt + 1].size();
        wk = 1;
    }
    
  2. 方加方,首尾相等。

    此种情况虽然会使区间长度相对更长,但是答案对区间长度没有要求,但由于首尾颜色相等,所以 \(-1。\)

    else if(vbl[pnt]+vbl[pnt+1]==2&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0])
    {
        vis=1;
        len += vec[pnt + 1].size()-1;
        wk = 1;
    }
    
  3. 方加圆,首尾相差 \(1\),右圆不用整体上移。

    对于圆,圆中没有数单独上升过,所以一个圆是可以整体上升的。

    else if(vbl[pnt]&&!vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-1)
    {
        vis=1;
        len += vec[pnt + 1].size();
        wk = 1;
    }
    
  4. 方加圆,首尾相等,右圆需要整体上移。

    else if(vbl[pnt]&&!vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0])
    {
        vis = 1;
        up[pnt+1]=1;
        len += vec[pnt + 1].size();
        wk = 1;
    }
    
  5. 圆加方,首尾相差 \(1\),左圆(已合并出的块集)不用整体上移。

    else if(!vbl[pnt]&&vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-1)
    {
        len += vec[pnt + 1].size();
        wk = 1;
    } 
    
  6. 圆加方,首尾相差 \(2\),左圆已整体上移过。

    else if(!vbl[pnt]&&vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-2&&up[pnt])
    {
        vis=1;
        len += vec[pnt + 1].size();
        wk = 1;
    } 
    
  7. 圆加方,首尾相差 \(2\),左圆需要整体上移。

    else if(!vbl[pnt]&&vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-2&&!vis)
    {
        vis=1;
        up[pnt]=1;
        len += vec[pnt + 1].size();
        wk = 1;
    } 
    
  8. 圆加圆,首尾相差 \(1\),左圆不用整体上移。

    else if(!vbl[pnt]&&!vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-1)
    {
        len += vec[pnt + 1].size();
        wk = 1;
    }
    
  9. 圆加圆,首尾相差 \(2\),左圆已整体上移过。

    else if(!vbl[pnt]&&!vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-2&&up[pnt])
    {
        vis=1;
        len += vec[pnt + 1].size();
        wk = 1;
    }
    
  10. 圆加圆,首尾相差 \(2\),左圆需要整体上移。

    else if(!vbl[pnt]&&!vbl[pnt+1]&&vec[pnt][vec[pnt].size()-1]==vec[pnt+1][0]-2&&!vis)
    {
        vis=1;
        up[pnt]=1;
        len += vec[pnt + 1].size();
        wk = 1;
    }
    

最后:

}//这是while循环的大括号
pnt = -~pnt;//合并完后指针向后移

Step 4:处理无法合并的情况

如果 \(pnt\) 指向一个块时,下一个块无法合并进当块集,这个时候我们就需要结算。

结算程序如下:

if(!wk)//上边每种合并中wk都赋值为1,如果!wk,就证明无法合并了
{
    maxx = max(maxx , len);//更新maxx,maxx为最终答案
    if(vis&&!vbl[pnt-1])	-- pnt;//下边细说
    len = vec[pnt].size();
    vis = vbl[pnt];
    up[pnt] = 0;//重新开始合并,所以up,len,vis都初始化
}

细说第 \(4\) 行:

  • 对于一个方,它无法合并一定就是无法合并了,它不可能再上升然后合并。

  • 对于一个圆,它不能合并,可能是因为当前块集中有一个数单独上升过,导致它不能跟着块集一起上升和后边的;

    如果这个圆单独上升,是有可能继续合并的,这时 \(pnt\) 要回跳,从这个圆开始往后合并,这就是 !vbl[pnt-1]的意义;

    但是如果说当前块集压根没有上升过,这个圆在前边是可以单独上升的,但它仍然无法合并, \(pnt\) 就不需要回跳;

    \(pnt\) 只需要回跳 \(1\),一个很简单的证明:

    这个圆前面只可能是 \(1\) 种块:一个方 或 一个不上升时无法和它合并的圆;

    这时,这个圆不可能再和它前面的块合起来去和后边的块合并;

    \(pnt\) 只需要回跳 \(1\),即回跳到当前圆;

    证毕。

Step 5:处理答案并输出

终于要结束了!!!!!

maxx = max(max(len,(int)vec[cnt].size()), maxx);
printf("%lld\n",maxx);

可能到最后一个块是正常合并的,所以没有进入 \(Step~4\) 进行结算,所以 \(maxx=\max(len,maxx)\)

\(\operatorname{while}\) 循环是 \(pnt<cnt\),所以要再考虑一下 \(vec_{cnt}.size()\)\(maxx=\max(vec_{cnt}.size(),maxx)\)

于是有了第一行的式子。

Step 6:Clear

inline void clears()
{
    for(reg int i = 1;i <= cnt ;i = -~i)	vec[i].clear(), vbl[i] = 0, up[i] = 0;
    for(reg int i=1;i<=n;i++)	Vbl[i]=0,Vro[i]=0; 
    cnt=1;
    maxx = -999;
}

贪心正确性证明:

zzn 和 zxk 向老师提出这种思路后,老师举了一种反例:如果我遍历到一个块,这个块无法和下一个块合并,那我有没有可能跳过下一个块,从下下一个块合并会更优呢?

由于当时是放学,zzn 脑子不太清醒,吓了一跳,回寝后冷静下来,想出了正确性证明。

证明:

假设中间有一个块无法和它的下一个块拼接;

但是,如果这个块无法和它的下一个块拼接,则 这个块的最后一个元素 一定比 它的下一个块的第一个元素 小 超过2

因为原序列是一个不下降序列,所以下个块之后的块中的元素 **一定 \(\geq\) ** 下个块中的元素;

又因为要求连续 \(x\) 种值,所以下个块之后的块 一定不能 和这个块拼起来;

综上,如果略过这个块,直接选择后边的是不可行的;

证毕。

时间复杂度:接近 \(O(n)\)