IOI2025 集训队互测记录
Round 1
87.5+0+25,垫底了,我怎么能这么唐的。
飞带长队怎么这么牛,三个题都好厉害!点赞了。
开场开 T1,做了一会之后发现会了 40 分的判定。于是逮住这个判定研究了大半天,手动模拟之后猜到了划分出来的每个串的数量的条件大概是 \(\ge \max b_i-a_i\) 这种形式的,发现可以很容易 \(O(n^5)\) DP 这个东西。然后这个题采取了一种奇妙的回答方式,你只需要在一个测试点的不同的测试组中选一个作答就行了,但是我以为你需要回答一个前缀,于是我的算法就变成 \(O(n^5 T)\) 的了,只拿了 87.5 分。考完问 zhy 他是怎么切的,他告诉他也是五方,原来我已经想到正解了,气急败坏……气急败坏……
中途去看 T2,写了几个判定都假了,没有想到直接取序列的前若干项暴力就可以拿很多分了。T2 做了许久还是 0 分,发现时间只剩下半个小时了,最后疯狂 rush 完了 T3 的 25 分暴力。
这场比赛寄就寄在一直在纠结是继续做 T2 的判定还是去想 T1 那根本不存在的更低复杂度做法,从而整场考试都有点心神不宁,没有冷静下来复盘。如果能仔细冷静的观察局势,无论是 T1 理解错出题人的意思还是 T2 部分分一分没拿都是可以避免的。
《基础 ABC 练习题》 from 方心童
AGC055D 加强,查了一下自己水表发现自己某天心血来潮做了 AGC055 两个题就是没做这个题。
考虑一下判定一对 \((x,y)\) 是否合法,记录你目前拥有的 ABC,BCA,ACB,AB,BC,CA,A,B,C 个数,初始 ABC \(x\) 个,BCA \(y\) 个,CAB \(n-x-y\) 个,你发现假设现在你想填入一个 A,那么从 ABC,AB,A 中选一个去掉开头的字母,这样永远只可能会增加以 B 开头的串,于是不难证明先取长的更优,写一写这个判定发现可以拿 40 分。
更仔细地考虑这个过程。在这个过程中,为了保证碰到对应的字母时串的数量是够的,也就是你之前遇到的 C 的数量加上 \(x\) 不少于 A 的数量。这个过程中遇到的这些 C 可以认为他们都解放了一个 A,如果没有,说明这些 C 是从 ABC 转化成 BC 再转化成 C 的,那么在此之前,一定有个 BC 转化成了 C,也就是说所有的 BCA 已经消耗完了,那么所有的 CA 已经完全转化出来了,因此你需要优先消耗 CA 中的 C。综上所述你可以认为消耗单独一个 C 的时刻一定是一段后缀,那么在转化单独的 C 之前,所有的以 A 开头的字符串都已经被转化出来了,不需要继续考虑判定条件了。同理,对于 B 的数量和 C 的数量也是差不多的要求,可以发现这三个条件合起来是充要的。
接下来 DP 就很简单了,记 \(a_i,b_i,c_i\) 表示前 \(i\) 个字符中 A、B、C 的个数,考虑记录 \(\max(a_i-c_i),\max(b_i-a_i),\max(c_i-b_i)\) 是复杂度高的,我们考虑计算 \(a_i-c_i\le X,b_i-a_i\le Y,c_i-b_i\le Z\) 方案数是容易的,简单差反演一下就得到了最大值的方案数了,\(O(n^5)\)。
Code。
《基础 01? 练习题》 from 郑钧
首先题目中给出的序列就是 Thue-Morse 序列,也就是常见的 \(\operatorname{popcount}(i) \bmod 2\) 构成的分形序列。在连续子序列这道题中,zj 给出了一个刻画 TM 序列所有可能的子串结构的方式。
具体地,如果一个串是 TM 序列的前缀或者是其取补的前缀,那么称这个串是好的,一个串的 TM 拆分即将一个串拆分成两个非空串 \(A,B\),使得 \(A\) 的翻转 \(\operatorname{rev}(A)\) 以及 \(B\) 串都是好的。
我们断言,所有 TM 序列的子串只有三种情况,且满足这三种情况之一的一定是 TM 序列的子串:
-
单个字符构成的串 \(0/1\)。
-
具有唯一的 TM 拆分点。
-
具有两个 TM 拆分点,满足将串拆分成了三个子串 \(A,B,C\),使得 \(|A|,|C|\le |B|\),\(B\) 的长度是二的次幂,\(\operatorname{rev}(A+B)\) 和 \(B+C\) 都是好串。
可以看到 TM 拆分的本质是用 \(\operatorname{popcount}\) 奇偶性来刻画 TM 序列时,找到 \(\operatorname{lowbit}\) 最大的那个点拆分序列。上面的性质详细的证明可以看 zj 题解。
如何寻找 TM 拆分呢?发现你只需要求出每个位置最多向左/向右多少位依然是好串。这似乎是带通配符的 LCP 问题,怎么做?
不要被字符串思维束缚了!考虑 TM 序列的性质,其匹配结构是可以倍增求出的。预处理每一个长度为 \(2^k\) 的串是否与 TM 序列或其取反后的前缀相同。
于是先枚举 TM 拆分点往左右扩展,然后容斥掉拥有两个拆分点的情况,这个可以枚举中间 \(B\) 串长度往两边扩展。
接下来计数还是比较 trivial 的。经典的套路,考虑求所有的子串的答案和,相当于若干次区间加之后求历史和,线段树维护即可。
Code。
《基础 01 练习题》 from 刘海峰
很有想法的题!可以看作对于二分竞赛图性质深入研究的例题。
题意大概是说每一行 \(A\) 矩阵中等于 \(0\) 的集合包含了 \(B\) 矩阵中等于 \(0\) 的集合,或是 \(A\) 矩阵中等于 \(1\) 的集合包含了 \(B\) 矩阵中等于 \(1\) 的集合。列的话 \(B\) 矩阵中的 01 得反过来。
我们不妨转化一下题意,由于我们每次是将一些位置变成 \(1\),考虑一个位置变成 \(1\) 之后还会有哪些新的要求,如果 \(A\) 矩阵中为 \(1\) 的位置在 \(B\) 矩阵中变成了 \(1\),那么 \(A\) 矩阵同一列的 \(0\) 在 \(B\) 矩阵中一定也要同时变成 \(1\) 才行;如果是 \(A\) 矩阵中为 \(0\) 的位置在 \(B\) 矩阵中变成了 \(1\),那么就对 \(A\) 矩阵同一行中的 \(1\) 作出同样的要求。
这样就转化成了一个图论问题。优化建图之后,相当于每个 \(1\) 连出到列点,被行点连入,每个 \(0\) 连出到行点,被列点连入。题目就是让我们求有多少个包含非行列点的 SCC,暴力 tarjan 获得 25 分。
考虑到每个非行列点只有一个入度一个出度,可以直接看成一条边,那么原图就变成了左右各 \(n\) 个点,中间 \(n^2\) 条不重也不相对的有向边的二分竞赛图。
对于普通竞赛图,SCC 问题是老生常谈的,我们知道竞赛图 SCC 缩点之后的形态是一条链,而且根据兰道定理,我们将出度排序可以构建出一条哈密顿链,每一个强连通分量是一段区间,通过判断一段长为 \(i\) 前缀的度数和是否是 \({i\choose 2}\) 可以判断其是否是 SCC 的右边界。
对于二分竞赛图,是否有同样优美的性质呢?首先考虑缩点之后,很明显缩出来的很有可能不是一条链,但是这种情况之发生在有两个同侧且出邻域一模一样的点时才有可能出现。如果我们不断删去出邻域相同的其中的一个点,将删去的那个视为没删去的点的附属点,它们在图中一定保持相同的可达性地位,然后 SCC 缩点,可以保证缩出来一条链。
证明 copy from sol:
考虑删点求拓扑序的过程中,如果存在某一时刻有两个强连通分量入度为 \(0\),首先这两个强连通分量一定是同侧单点。如果不是单点,就存在至少两种颜色,那么这两个强连通分量之间就有连边了,此时这两个点都连向了没被删的异侧,所以这两个点完全相等,于是矛盾。
接下来我们考虑同样地通过度数序列刻画可达性性质。对于度数相同的所有点,它们之间有可能是之前去重时删去的点,恰有一个留在原图中的点单独构成 SCC,这样它们之间一定互相不可达;也有可能一部分留在了原图中,这样所有度数相同的点一定在同一个 SCC 中,一定互相可达。因此,我们给度数相同的点添加一条有向链一定不影响 SCC 的形态。
对于度数不同的点,由于删点后 SCC 成一条链的状态,所以度数大的点一定能到度数小的点。
考虑如何解决本题。对于两行,从左往右扫描列,找到它们第一个不同的位置,此时该位置为 \(1\) 的行(设为 \(x\))出度大于该位置为 \(0\) 的行(设为 \(y\)),那么有可达性 \(x\to y\)。由于往右扫描列的过程不会破坏原有可达性(这个观察非常关键!),所以你把所有行按照字典序排序之后,所有包含行点的 SCC 在之上构成若干个区间!主席树维护哈希直接排序就可以得到一个 \(O(n\log^2 n)\) 的做法,常数不大。
接下来的事情比较好处理了,考虑得到字典序顺序之后,每一次加入一个列点,观察其对可达性的影响。求出能直接到达其在序列中最靠前的行点,以及其能直接到达在序列中最靠后的行点,如果这个东西构成了区间,那么中间所有点一定在同一个 SCC 里,用并查集并起来;否则利用可并堆维护一下这个列点处于哪两个 SCC 之间,如果这两个 SCC 并起来了,这个列点也要加入这个新的 SCC。
注意算答案是把边看成点算包含边的 SCC 个数,所以设一个 SCC 中有 \(x\) 个行点 \(y\) 个列点,对答案的贡献是 \(\min(1-xy,0)\)。
Code。
Round 2
我自己的场,感觉对题目已经没什么可说的了,题目是按照出题时的题目编号排序的,预估的难度顺序是 T2、T1、T3。来聊聊后台看比赛的感受吧。
首先我的题数据似乎是被爆了的,zhy 验题的时候提出了随机抽样法,但是没有通过我当时的数据,让我误认为完全标搭不上关系的抽样法正确率比较低。但是似乎场上过了 T1 的人绝大多数写的是抽一个前缀,这样就不用高精之类的东西。考前的时候没有仔细考虑到每一种可能的乱搞比较失职,我自己也应该仔细对待 zhy 提出的乱搞的,但是我脑子里的想法已经被标算定型了,导致我认为思考路径接近标的做法正确率才比较高(即至少要发现“去除无效位”这个结论才能做这个题)。
我造数据的方式大概是提出一些错解,比如没有去除无效位的、没有处理有倍数关系的等等,然后与标算对拍。这样的一个我忽略了的坏处是同余方程分布太均匀了!导致抽样法很容易得到一个能够反映全局的抽样的块。
cxy 老师太牛了,拿下了 T2 一血,随后用一些奇怪的方法过了 T1。蒯他的代码下来对拍,发现将数据生成方式修改一下是可以很快拍出来的。后面几位通过的选手都可以被拍出来的数据 hack 掉。
fxt 老师似乎卡了很久 T1,之后把抽样次数改大直接通过了该题。测了一下发现他能通过刚刚造出来的所有 hack 数据,但很遗憾把他的代码拿下来对拍,依然是可以很快拍出来,跟其它六名通过此题的选手错在不同的地方啊……作为出题人对于到底发生了什么已经不懂一点了……
原本看到这里心态比较崩溃,以为大家会介意我造水了数据要扣我出题分,不过我的题似乎得票率甚至是最高的?可能是因为这个 idea 我觉得还是挺有新意的,而且这个题数据也确实挺难造的;也有可能是叉老师帮我吸引火力了,大家又不愿意去做 zhy 的题。
接下来说一下 T2,这个题是叉老师的题,被喷的点大概在于放过了 \(O(n\sqrt n \log n)\) 但卡掉了 \(O(n\sqrt n)\)。
第一次听说这题竟然放过了挂 \(\log\) 的做法感觉还是很惊奇的,确认了半天到底是不是那几个根号半 \(\log\) 的提交。后来发现确实有一个树剖分块的做法,跑得甚至比许多根号做法还快。作为赛前看过这个题的人属实震惊了。
这个 6s 其实是放宽了时间限制之后的结果,zhy 和 xde 写的两种做法不同的标都可以通过。对于某些根号被卡常赛前可能有心理预备,分块算法的常数确实极度依赖空间访问连续性等等不可预见的问题。解决方法只是说下次别出卡 \(\log\) 类型的分块题了,除非学 Ynoi 强行把数据范围、时间限制开到很大,否则造成不良好体验的风险太大了。
还有 T3,这个题做法还是十分有水平的,但是开场两个小时没有一个提交。终于有了第一个提交的时候,我们开心地跑过去一看,发现是有人交 T1 代码交到 T3 上了,更乐了。
本场 T1、T3 zak 都验了题,但值得声明的是并不是群里有人说的按 zak 1ppm 给分的。因为如果按不写代码的纯思考时间给分的话,两个题总分都不超过 30pts。
我大概是写了这个题的由 zak 提出消元做法,只需要求一系列多项式组成的矩阵的连乘然后求逆,能拿 85 分。zhy 给的标则是利用特征多项式迭代的一个做法,不会特征多项式没有写,但是消元做法确实简单又优美,zak 的功力深不可测!
zhy 觉得消元做法没有达到他毒瘤的本意,于是把模数改成了非质数,zak 在得知之后又很快提出了另一个支持非质数模数的做法。
赛时 cxy 找到了一个可以不需要求逆元的跟 zak 做法类似的想法,仅仅需要求多项式矩阵连乘不需要求逆,算是把 zhy 最后一档分的设计爆了。
两个人的题都被一个人爆了,于是那天晚上我和 zhy 都挺沮丧的,我无论如何都不想写一道题,于是在和 zhy 聊了许多关于训练、做题、灵感等等的话题之后就回家了。
最后 zhy 这个题除了 cxy 老师,做的人实在是太少了,让人感叹多项式已经成为了昨日幻影了。大家似乎都不太喜欢给 poly 题投票。
总而言之,虽然大家有可能不喜欢这一场中的某些题,也许甚至三个题都不喜欢。但是我见证着这场是如何诞生的,认为至少三位出题人都是较为认真地对待了这些题从 idea 诞生到造数据的全过程。我认为 T1 真的是我觉得我出过的最好的、最有新意的 idea 了,虽然最终我造的数据可能完全浪费了这个 idea,让大家认为这个题是猜结论或者是一道乱搞题,感到了极其遗憾。在这里自己向自己疯狂谢罪了。
不过既然已经在自己出的题目中认认真真下了功夫,也没有必要再多去懊恼了,尽力去做就是最好。
接下来是做法的一些讨论。
《生命的循环》 from 杨鑫和
本场互测最“神经”的题目,评判标准是题面中包含神经作为子串的次数最多。
Q:《生命的循环》的含义是什么?
A:没有任何含义,单纯只是想跟《矩阵的周期》起个差不多的标题,题目背景也是脑内乱 roll 出来的。
咕了,反正是自己的题,我的想法大多写在题解里了(
如果需要人话版的可以在直接摇我写一个。
Code。
《树上简单求和》 from 肖岱恩
zhy 给出了一个很牛的一句话做法,考虑一条路径在括号序意义下是一段区间,转化成经典序列分块问题。做完了……分块的空间可以做到线性。
代码没写。
《路径计数》 from 周桓毅
讲讲我唯一会的 zak 提出的消元做法,能拿 85 分。
注意到本题看起来最奇怪的、唯一一个跟下标 \(i\) 有关而不是 \(j\) 有关的系数 \(B_i\),考虑先消掉这个系数。对于所有的平着走一步(\((i,j) \to (i+1,j)\)),用乘法分配律拆开 \(\prod (B_i + C_j)\),考虑哪些位置取了 \(B_i\),把这些步直接扣掉不会改变任何其它步的系数,只有终点左移了。扣掉这些位置之后,我们就能把 \(x\) 当作时间维,需要解决的问题就变成了一个一维数轴左右游走,即你初始站在 \(0\),往右走一步系数 \(A_i\),往左走一步系数 \(D_i\),原地不动系数 \(C_i\),分别求出行动 \(0\sim n\) 次之后终点对应的 \(F_i\) 和路径系数的乘积之和。
这个问题可以列出答案的生成函数消元做,即与期望类型的随机游走类似,设 \(G_i\) 表示从位置 \(i\) 开始随机游走若干步的生成函数,把每一项表示成前两项的线性组合,手动消元之后最后只需要求逆就可以还原出 \(G_0\) 了。
接下来考虑拼回去 \(B\) 的系数,这个问题的形式是有一个低次多项式序列,你需要对于每一个前缀求前缀积与一个给定多项式点乘后的结果,这个可以分治做,每次把给定多项式与左边的前缀积减法卷积一下递归到右边,截断一下无用的位就行了。复杂度 \(O(n\log^2 n)\)。
这个做法的巧思在于意识到处理期望类型题目的常见手段也可以套用到多项式题目上来,我们可以利用生成函数刻画随机游走过程。
Round 3
最自大闭的一集。
自从组 Round 2 的时候知道了题目不是按难度顺序排就给自己心态上了巨大 debuff。开 T1,看起来好神秘;开 T2,是数据结构!想 114514 年,发现不是签;开 T3,打表刻画了许久的模式,这一点都不好拿分啊!仔细思考之后终于在 T3 拿了 17 分。只剩下一个半小时了,回去把 T1、T2 暴力补了,最后疯狂 rush T2 sub2 的 30 分,67 分签到分遗憾离场。
下午在几个月不想熬夜打 CF 之后终于有了阳间场,叉老师飞快过了 D,而我 B 罚时两发,C 罚时四发,D 做法假了乱搞交了十三发还没过。
《环上排序信息最优分割》 from 仲煦北
场上以为很难,但是后来发现是环上邮局板子,决策单调性分治的时候用链表只删除地维护排序信息就行了。
明明看过 cmd 的博客,我为什么不多开开这个题呢?怎么四边形不等式都没想到?
Code。
《研心》from 李静榕
看了半天题解被绕晕了。疑似巨大难写题,暂时咕了,以后有时间再写。
《无限地狱》from 陈旭磊
别急,应该会有的。
Round 4
比赛结束时:78+5+24,我已经全力以赴了……果然还是不够吗……
发榜时:啥?这也能前二十?
zyb 发消息时:啥?前二十的线低于签到线😯……
打互测就像在抽奖,考的我的印象是 T1 是最可做的题,T2 是非常强的 01 背包问题不好做,T3 是 ylx 出的大时限抽象题。赛后一看,发现大家用各种各样的乱搞水过了 T2,T1 一个人都没过,而 T3 相比之下显得十分具象,像道正常题。原先以为自己是不会做 T1 要菜输了,后来发现确实输了,输在不会 T2 乱搞。
现在互测最难的地方在于对形势建立清晰的认知,只要赛前知道榜,你的分数水平就会提高一大截!
upd: 这 T3 也不是一道正常题啊,场上过的人好像也不是标。
《Désive》from 陈诺
蛮不错的一个题,至少是这一场最像题的题了。但是考完破防之后点了个无,忘了点赞了。
场上我写了一个排列特殊性质下是对的乱搞做法,发现直接过了 o=1
的特殊性质拿了全场最高分,也就是说这个题虽然最后几个 sub 比较强但是没往前面的 sub 放 hack 数据。这个乱搞也就是扫描线扫描右端点,然后维护 trie 树上关于左端点下标的 DP 值。发现右端点右移的时候左端点变化期望不是很多,所以直接不扫描线了整体维护 DP 值连续段的归并再二维数点就行了。
讲点正经做法。
考虑 xormex 怎么求。一个很直接的想法是在 trie 树上 DP,也就是我上面乱搞用到的方法。但是 trie 树上 DP 不容易刻画其连续段变化的性质。
另一个想法是不难证明 trie 树上 DP 的本质是求一条链,使得这条链上所有点的兄弟节点代表的子树中,所有满子树的子树大小之和最大化。我们从这个角度入手本题。
依然是扫描线,当右端点移动的时候,考虑每棵子树变成满子树的最大左端点 \(mn_p\),只有 \(O(n)\) 个位置变化了。显然只有选择的满子树包含了至少一个 \(mn_p\) 变化了的子树的方案才会更新答案。
一个方案的更新形如对于当前所有左端点小于某个值的位置答案与一个值取 \(\max\)。
如何处理出这些方案的更新呢?一个朴素的想法是暴力扫描 \(mn_p\) 变化了的子树的兄弟子树中的叶子。这些叶子更新的方案相当于其到根的链上的点的兄弟节点的 \(mn_p\) 拿出来排个序后缀和一下,这样在修改是排列的时候依然是 \(O(2^n\operatorname{poly}(n))\) 的,但是在修改不是排列时一个大子树被反复更新就会爆炸。
你发现处理兄弟子树的所有叶子,重复处理的部分太多了!考虑将兄弟子树当成一个整体 DP,DP 出其取每一种可能的最小左端点的 xormex 是多少,再作为一个整体去和到根的链上兄弟的 \(mn_p\) 排序后缀和。但是这样复杂度没什么根本性变化,需要更好的处理。
注意到只有包含了你当前更新了 \(mn_p\) 的子树的方案才需要考虑,还注意到如果你的方案能够直接包含没有更新 \(mn_p\) 之前的 \(p\) 子树也是不需要更新的,这说明对于一次更新将 \(mn_p\) 从 \(v\) 更新到 \(v'\),你只关心 \((v,v')\) (开闭没关系,因为你的时间本来就是序列下标不会重)之间的那些分界点的更新。也就是说对于 \(p\) 的兄弟子树,你只需要求出左端点介于 \((v,v')\) 间的所有可能的 xormex 及其对应区间就行了,这一部分注意到同一个子树所有的 \((v,v')\) 区间不交,所以拿一个 trie 树双指针式地维护一下就行了;对于 \(p\) 的祖先的兄弟节点,可以暴力扫描出所有 \(mn_p\) 位于 \((v,v')\) 之间的位置处理。
这样每个修改了 \(mn_p\) 的子树贡献的修改数是 \(O(2^n n)\),看起来是三只 \(\log\),如何优化?注意到修改了 \(mn_p\) 的子树具有祖先关系,所以其实后半部分扫描到根的链的兄弟,总共只可能修改 \(n\) 个不同位置,修改又是取 \(\max\) 的形式,所以说给当前时间的所有修改位置去个重复杂度就少了一个 \(n\) 了。
后半部分当然可以线段树历史和。但是由于场上已经写了二维数点了,就直接蒯了过来用 map
+ 树状数组实现了同样的功能。似乎跑得比线段树快点?
Code。
《分道扬镳》from 葛致远
彻底咕咕算了,不想改了。
《观虫我》from 叶李蹊
idea 感觉很牛!但是实现了一下标算,发现怎么卡常都卡不过去,怎么回事?
一个经典的想法是折半,也就是修改时贡献前一半的超集,查询时查询后一半的子集,复杂度 \(O(q(\sqrt 2)^n)\)
考虑这个做法的本质,实际上相当于每一位的贡献方式对应一个矩阵 \(\begin{pmatrix}1 & 1 \\ 0 & 1\end{pmatrix}\),我们可以把它拆成两步分别做:\(\begin{pmatrix}1 & 0 \\ 0 & 1\end{pmatrix}\begin{pmatrix}1 & 1 \\ 0 & 1\end{pmatrix}\) 或者 \(\begin{pmatrix}1 & 1 \\ 0 & 1\end{pmatrix}\begin{pmatrix}1 & 0 \\ 0 & 1\end{pmatrix}\),前者在修改 1 多的时候表现差,后者在询问 0 多的时候表现差。如果询问 0 多、修改 1 多,复杂度就被卡满了。
注意到还有一种拆分方式能在询问 0 多、修改 1 多的时候表现好: \(\begin{pmatrix}1 & 0 \\ 1 & 1\end{pmatrix}\begin{pmatrix}1 & 1 \\ 1 & 0\end{pmatrix}\)。我们如果每一位随机取它是用哪种方式贡献的,那么一位期望会使当前操作的枚举空间扩大 \(\frac{4}{3}\) 倍。复杂度期望 \(O(q(\frac{4}{3})^n)\)。
但是由于这个题的对于复杂度影响是乘上什么倍率而不是加上什么东西,这也就意味这虽然期望是 \((\frac{4}{3})^n\),但是其方差往往难以接受,很容易复杂度爆炸。解决方法是随机若干个分配拆分矩阵的方式预先取最优的。
最后,使用 ull 64 位压位,相等于将后 6 位独立出来。这一部分可以直接通过预处理一些系数加使用 __builtin_parityll
做到 \(O(1)\),复杂度 \(O(q(\frac{4}{3})^{n-\log w})\)。可以接受。
没卡过常,代码不放了。
Round 5
100+40+20
想了一整场没有切 T2,小小的挫败。
还是进前 20 了,果然是集训队比烂大赛。
T1 反复确认题意之后很快想出来了,经常见的想法。每次取平均值最大的连通块跟父亲并一下就可以了。
T2 写完平方做法之后,想要用点分治直接优化这个做法。我就一直在考虑刻画这个连通块的形态:容易注意到,在这个连通块的边界,所有点都要么 \(<l\),要么 \(>r\),于是考虑点分治之后考虑边界点扫描线,可以得到一个做法?假了。因为连通块的边界点不一定在当前分治块内,无法挽救正确性。
最后认为 T2 不太好做了,之后一直在写部分分,最后在想 T2 链的部分分怎么写,意识到可以把 size 的贡献拆到每个点上去,强硬分治计数左端点、右端点、size 拆出的点这个三元组,大力分讨这些点在左右两边的哪边然后巨大麻烦数点。发现只剩一点点时间完全写不了了。最后想了想,认为这扩展不了正解。(其实可以,就是叉老师的做法)。
叉老师切掉了 T2。然后我才知道点分也是可以做两只 \(\log\) 的。我对点分治的理解太狭隘了!原来点分治不一定要把答案在第一次劈开连通块的地方统计!
原来以为是 DS 题练的不够多,后来才发现自己输在思维,不要被对特定算法的理解固定死了!
《长野原龙势流星群》from 孙培轩
考虑单个子树怎么求:二分答案,每个点的权值减去二分的答案之后直接贪心,如果这个子树当前的和 \(\ge 0\) 就保留,否则扔了。
考虑如何扩展到全局:直接在时间轴上扫描你二分的答案,你发现只有当一个子树是否保留的状态发生改变之后你才需要考虑这个时间。所以说拿一个堆维护状态发生改变的最近时间,然后找到这个子树把它跟父亲用并查集合并一下就行了。
复杂度 \(O(n\log n)\)。
Code。
《Classical Counting Problem》from 翟鹏昊
出题人的做法三个 \(\log\) 怎么好意思放出来,至少也应该把时限放大一点吧!害得我们 zhy 的 \(O(n\sqrt n)\) 做法被卡常成 55 分了 QwQ!
首先讲讲这个题的平方复杂度的刻画,每一个合法的连通块都可以通过如下方式生成,对于一个区间 \([l,r]\),满足只保留编号在 \([l,r]\) 之间的点之后 \(l,r\) 两点联通,那么 \(l,r\) 所在的联通块就是我们想要的连通块。
出题人做法:
考虑对最小值分治建 kruskal 重构树 \(T_1\),最大值分治建 kruskal 重构树 \(T_2\),那么合法连通块的刻画是对于区间 \(l,r\),满足 \(l \in T_2(r)\) 且 \(r \in T_1(l)\),\(T_1(l)\cap T_2(r)\) 就是合法连通块(其中 \(T(x)\) 表示该树上以 \(x\) 为根的子树)。
考虑对第一颗树静态链分治(即树上启发式合并),以标记出 \(T_2\) 中在 \(T_1(l)\) 子树中的节点。然后 \(l\in T_2(r)\) 的条件相当于限制 \(r\) 是 \(l\) 的祖先,\(r \in T_1(l)\) 的条件相当于限制 \(r\) 是被标记的点。对第二颗树树剖线段树(也可以全局平衡二叉树降一个 \(\log\)),设计一下线段树标记就可以求出这条树链上所有被标记的点的子树中被标记的点的个数和了。
总结一下就是 DSU 降一维,树剖降一维,线段树统计,还是三只 \(\log\) 的,这样搞完全没有任何意思啊?
来讲讲叉教给我的有意思做法:
注意到我场上的做法 naive 在我过度关注了本题中连通块的具体形态,而事实上,在将 size 拆贡献之后,本题可以转化成一个三元组统计问题,求有多少个合法的三元组 \((l,r,x)\),满足区间 \([l,r]\),张成的联通块包含了点 \(x\)。
这样看起来什么都没有改变?考虑点分治,但是一个三元组不是在分治中心第一次劈开 \([l,r]\) 张成的连通块被统计,而是在分治中心第一次将 \(l,r,x\) 劈到不同的分治块,也就是说分治中心位于 \(l,r,x\) 构成的虚树上时统计。
这样统计的好处是什么?这样的话,\([l,r]\) 区间张成的连通块包含点 \(x\),可以等价转化成张成的连通块包含 \(l \to rt\) 的路径、\(r \to rt\) 的路径、\(x \to rt\) 的路径(\(rt\) 是分治中心),变成了一个纯粹的数点问题。
\(l\to rt\)、\(r \to rt\) 的路径判掉自己跟自己冲突的情况之后,只提供了一维偏序,而 \(x\to rt\) 的路径要被 \([l,r]\) 这个区间完全包含,实际上可以把 \(x\to rt\) 的路径看成一种对权值的修改,二维数点的时候如果 \(l,r\) 的区间包含了这个区间权值就 +1。
这个做法只需要在点分治的每一层跑二维数点就行了,注意要容斥掉三个点在同一个子树内的情况,复杂度 \(O(n\log^2 n)\)。
三只 \(\log\) 运行效率吊打根号和两 \(\log\) 了。
Code。
《运筹帷幄》from 赵海鲲
咕会。
Round 6
稍微有点状态的一场。开场开 T1,想了一会之后会了一个判定,写了写 DP 发现假了,仔细研究了一下就修好了,直接 DP 可以四方,容斥+前缀和优化一下转移就可以做到三方。
接下来看 T2,这场 T2 其实是可做题,但是场上我没有很多思路,于是写了一个压缩运算表相同行列的做法,发现直接草过了 60 分,非常开心啊!
看 T3,发现二分一下可以枚举中间点贪心,原来以为得到了一个双 \(\log\) 做法,但是发现需要决策中间点的方向假了,中间的部分仍然需要平方合并。没办法写平方对数暴力跑路了。
终于进前十了……
《树数叔术》from 苟竞予
我有 \(O(n^2V)\) 爆标做法!
首先注意到 \(V\) 很大是假的,取题面中的集合为全集,发现这要求所有数的 \(\operatorname{mex}\) 等于 \(V+1\),也就是说 \(0\sim V\) 的所有数都必须至少出现一次。
然后考虑这个题的判定条件。这个题好做就好在你将必要条件一个一个分析过去发现它就充分了。首先我们可以得到一个必要条件: \(0,1\) 都只能出现一次,中间链上的节点都强制被选了。我们继续往后加入颜色,每加入一种颜色都只有两种情况,第一种是其所有出现都在之前的颜色的虚树中,第二种是其恰好在这棵虚树外出现一次。
所以我们依次枚举所有颜色,DP 它是属于第一种情况还是第二种情况,注意到如果属于第一种情况,还要强制它至少出现一次。这一部分可以考虑容斥,强制其不出现相当于就是乘上一个 -1 的系数,在 DP 中顺带统计一下就行了。
题目要求有标号树,可是我们刚才那个过程没有分配标号怎么办?注意到刚才给树染色的过程天然就为数钦定了一个顺序:每次加入一个虚树外的点时,依次给新加入的链编号就行了。那么对于这种染色生成的编号,与这棵树原来的编号恰有 \(n!\) 种对应方式,所以我们 DP 的时候完全不用考虑分配标号的事情,然后最后乘上一个阶乘就得到答案了。
前缀和优化,得到复杂度 \(O(n^2V)\)。
Code。
《欧伊昔》from 魏忠浩
加深对于 FWT 理解的一道题目,zhy 教我了一个不需要数据随机性质的做法。我们要求一个定义在任意运算表 \(op\) 下的三进制高维卷积 \(f\circ g = h\)(笔者不懂数学,符号都是乱写的,希望大家看得懂)。
首先根据 cmd 博客里指出的,FWT 的要点在于对于高维的卷积问题,每一位其实都是在线性变换意义下独立的,于是我们就是要设计三个三阶线性变换矩阵 \(A,B,C\),使得 \(C(A(f)B(g))=h\)。
现在,我们能否有一种方法使得任意的运算表都能构造出这种线性变换呢?
我们考虑形式化 FWT 的构造要求。实际上,对于一个 \(3\times 3 \times 3\) 的三阶张量(这里可以理解成立体的矩阵) \(G_{i,j,k} = [op_{i,j}=k]\),我们就是要对其进行三阶张量的 CP 分解,也就是将其分解乘若干个秩一张量。秩一张量指得是可以将这个张量分解成三个向量的外积,用形式化一点的语言来说,对 \(G\) 的 CP 分解,就是求出三个 \(r\times 3\) 的矩阵 \(A,B,C\),使得 \(G_{i,j,k}=\sum_{t=1}^r A_{t,i} B_{t,j} C_{t,k}\)。
我们 FWT 的过程就可以写成 \(h_k=\sum_i \sum_j G_{i,j,k} f_i g_j=\sum_{t=1}^r C_{t,k}(\sum_i f_i A_{t,i}) (\sum_j g_j B_{t,j})\)。
对于一些特殊的张量,其 CP 分解是熟知的。比如说一张拉丁方运算表生成的矩阵,由于所有的三阶拉丁方都可以通过模 3 意义下的加法生成,给行和列分别赋一个 3 阶排列 \(ap,bp\),那么 \(op_{i,j}=(ap_i+bp_j)\bmod 3\) 就是一个拉丁方。根据三进制 FWT 我们可以用三阶单位根来分解这个张量。
这启发我们当我们知道一个 \(G_{i,j,k}\) 的分解方式之后,可以通过一定的手段把它变换乘其它矩阵的分解方式。
我们枚举三个 \(\{0,1,2\}\to \{0,1,2\}\) 映射 \(u,v,w\)(一开始我是只枚举置换做的,这样虽然方便构造,但是不能保证能够造出 \(r=5\) 的 CP 分解;精细实现枚举置换的做法应该还是能过数据随机的版本的),求出运算表 \(w_{(u_i+v_j)\mod 3}\),这张运算表的 CP 分解可以很容易从普通的模 3 意义下加法的分解中变形得到,考虑当前运算表与这张运算表不同的位置的个数,你可以惊奇地发现不同的位置个数的最小值有极大概率不超过 2。
进一步地,你构造更多已知 CP 分解方式的张量,比如 \(w_{\max(u_i,v_j)}\)。zhy 告诉我写个暴搜你就可以发现此时所有的运算表都满足与其中至少一个运算表只相差两个位置。也就是说,通过这种方式你对于所有的运算表都构造出了一个 \(r=5\) 的 CP 分解,这是一个确定性做法,复杂度严格 \(O(5^n)\)。
注意到这种 CP 分解包含了虚数,也就是三阶单位根,你需要选一个模数来计算取值。可是本题的答案有可能爆 int
,选取 long long
范围内的模数乘法代价又更高,于是你可以选一个 int
范围内的模数跑两次来卡常。
另一个从这个题中学到的 FWT 技巧,也就是说 FWT 可以类似 Karatsuba 算法一样分治实现,这样相比于传统写法有几个好处:第一如果 FWT 后数组的长度比原数组长(换句话说就是本题中 CP 分解出来的 \(r>3\) 的时候),那么这种方式只需要开 \(O(3^n)\) 的空间而不是 \(O(5^n)\) 节省了空间常数;第二是可以通过递归到低层就改跑暴力这样常数更小。
其实本题还是非常好写的,但是为了卡常不知不觉写到了 8k,经过卡常的丑陋代码:
Code。
《人间应又雪》from 黄佳旭
我是鸽子。
Round 7
打得最抽象的一场。开场开 T1,发现这不是我们的动态笛卡尔树吗?搜这个算法第一篇就是我自己的博客欸?这不是优势在我?
然后开始写,写着些着意识到这个题需要维护子树 size,然后意识到在原树上维护会破坏直链的势能,所以得又开一颗 LCT,再一看数据范围 \(10^6\) 两秒 LCT 真的有希望吗?
花在了这题 3h 之后意识到这个题的正解可能完全不是 LCT。于是再花了一些时间拼完暴力之后只好去看 T2 了。
T2 看起来 Trie 树合并维护 SG 值直接做完了,但是空间带了 \(\log\),出题人卡空间!!!于是你需要牺牲时间换空间,改成 dsu on tree 维护空间复杂度成线性了,时间只多了一个非常小的 \(\log\),可以跑过去。
但是!!!编译器不知道把什么东西压到系统栈里导致空间炸了,所以你必须要手写栈+疯狂共用数组卡空间才能通过这个题!!最后没有调出来,45+50 遗憾离场。
最后集训队 rk21,这也能 rk21???就差一名就可以拿满互测高分的分了!!!
《Cyberangel》from 陈哲章
原来不是动态笛卡尔树,而是大海的深度!
动态笛卡尔树单 \(\log\) 做法:动态笛卡尔树维护直链剖分,发现树形态的变化由 \(O(n)\) 次 link
和 cut
组成,然后你需要维护左儿子大小乘右儿子大小乘值的和,可以类似 DDP 思想维护虚子树标记和,显然常数是过不去的。
考虑类似大海的深度,直接上分治统计过分治中心的区间的贡献,这样将 \(\max\) 的贡献转化为右边前缀 \(\max\) 和左边后缀 \(\max\) 的 \(\max\)。依然考虑依次加入最大值,考虑当前的贡献和。以左边为例,假设当前的后缀最大值取到了贡献,那么它的贡献只与它的位置、它下一个后缀最大值的位置和右边第一个比它大的数的位置有关。右边第一个比它大的位置可以用并查集维护,后缀最大值用一个栈维护就行了。
复杂度可以做到 \(O(n\log n\alpha(n))\)。
代码寄存:
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <numeric>
#define lc (p << 1)
#define rc (p << 1 | 1)
using namespace std;
typedef long long ll;
typedef __int128 lll;
int read() {
char c = getchar();
int x = 0;
while (c < 48 or c > 57)
c = getchar();
do
x = x * 10 + (c ^ 48), c = getchar();
while (c >= 48 and c <= 57);
return x;
}
const int N = 1000003;
int n, m;
lll res;
int a[N], p[N], q[N], f[N];
void write(lll x) {
if (x > 9)
write(x / 10);
putchar((x % 10) ^ 48);
}
int rt(int x) {
if (f[x] == x)
return x;
return f[x] = rt(f[x]);
}
int bel[N];
int sl[N], tl;
int sr[N], tr;
lll dlt[N];
void solve(int l, int r) {
if (l == r) {
res += (ll)(m - a[l] + 1) * a[r];
return;
}
int mid = (l + r) >> 1;
solve(l, mid), solve(mid + 1, r);
iota(f + l, f + r + 1, l);
fill(bel + l, bel + mid + 1, r + 1);
fill(bel + mid + 1, bel + r + 1, l - 1);
merge(p + l, p + mid + 1, p + mid + 1, p + r + 1, q + l, [](int x, int y) {
if (a[x] ^ a[y])
return a[x] < a[y];
else
return x < y;
});
reverse(p + l, p + mid + 1);
int cl = mid + 1, cr = mid;
tl = tr = 0;
lll ans = 0;
for (int i = l; i <= r; ++i) {
if (q[i] > mid) {
int ps = r + 1;
while (tr) {
int x = sr[tr];
if (p[x] > q[i]) {
dlt[rt(x)] -= (ll)a[p[x]] * (ps - p[x]);
ans -= (lll)(ps - p[x]) * (mid - bel[rt(x)]) * a[p[x]];
ps = p[x], --tr;
} else {
dlt[rt(x)] += (ll)a[p[x]] * (q[i] - ps);
ans += (lll)(q[i] - ps) * (mid - bel[rt(x)]) * a[p[x]];
break;
}
}
sr[++tr] = ++cr;
ans += (lll)(r + 1 - q[i]) * (mid - l + 1) * a[q[i]];
dlt[cr] = (ll)(r + 1 - q[i]) * a[q[i]];
int las = -1;
lll all = 0;
for (int x = rt(cl); x <= mid; x = rt(las + 1))
if (bel[x] > q[i]) {
ans -= (bel[x] - q[i]) * dlt[x];
all += dlt[x];
if (~las)
f[las] = x;
las = x;
} else
break;
if (~las)
bel[las] = q[i], dlt[las] = all;
} else {
int ps = l - 1;
while (tl) {
int x = sl[tl];
if (p[x] < q[i]) {
dlt[rt(x)] -= (ll)a[p[x]] * (p[x] - ps);
ans -= (lll)(p[x] - ps) * (bel[rt(x)] - mid - 1) * a[p[x]];
ps = p[x], --tl;
} else {
dlt[rt(x)] += (ll)a[p[x]] * (ps - q[i]);
ans += (lll)(ps - q[i]) * (bel[rt(x)] - mid - 1) * a[p[x]];
break;
}
}
sl[++tl] = --cl;
ans += (lll)(q[i] - l + 1) * (r - mid) * a[q[i]];
dlt[cl] = (ll)(q[i] - l + 1) * a[q[i]];
int las = -1;
lll all = 0;
for (int x = rt(cr); x > mid; x = rt(las - 1))
if (bel[x] < q[i]) {
ans -= (q[i] - bel[x]) * dlt[x];
all += dlt[x];
if (~las)
f[las] = x;
las = x;
} else
break;
if (~las)
bel[las] = q[i], dlt[las] = all;
}
if (i < r)
res += (a[q[i + 1]] - a[q[i]]) * ans;
else
res += (m + 1 - a[q[i]]) * ans;
}
copy(q + l, q + r + 1, p + l);
}
int main() {
n = read(), m = read();
for (int i = 1; i <= n; ++i)
a[p[i] = i] = read();
solve(1, n);
write(res);
putchar('\n');
return 0;
}
《新生舞会》from 蒋承佑
直接 DP SG 值就行了,设 \(f_u\) 为子树 SG 值,\(g_u\) 为 \(f_u\) 异或上所有儿子的 \(f\) 值,用 trie 树维护出子树内到子树的根这一条链的 \(g\) 异或和,在 trie 树上二分做完了。
剩下的是卡空间,把 trie 树合并改成启发式合并,然后再改成非递归式 dfs
。
这个题有任何意义在吗?
#include <algorithm>
#include <bitset>
#include <cassert>
#include <cstdio>
using namespace std;
const int N = 2000001;
int read() {
char c = getchar();
int x = 0;
while (c < 48 || c > 57)
c = getchar();
do
x = (x * 10) + (c ^ 48), c = getchar();
while (c >= 48 && c <= 57);
return x;
}
bitset<1 << 22> w;
int c[1 << 21];
int res, lim, bt, cb, dlt, nd;
int *g[N], f[N], s[N], d[N], n;
inline void upd(int x, int v) {
c[x] += v;
if (c[x] and c[x] == v) {
int t = x + lim;
while (t > 1 and w[t ^ 1])
w.set(t), t >>= 1;
w.set(t);
return;
}
if (!c[x] and c[x] != v) {
int t = x + lim;
while (w[t])
w.reset(t), t >>= 1;
return;
}
}
int sx[N], sv[N], tp;
inline void inc(int p, int cur) {
cur ^= f[p];
upd(cur, 1);
for (int i = 0; i < d[p]; ++i)
sx[tp] = g[p][i], sv[tp++] = cur;
}
inline void dec(int p, int cur) {
cur ^= f[p];
upd(cur, -1);
for (int i = 0; i < d[p]; ++i)
sx[tp] = g[p][i], sv[tp++] = cur;
}
int tx[N], ps[N];
bitset<N> tt;
int rk;
inline void sol(int p, bool del) {
f[p] = 0;
for (int i = 0; i < d[p]; ++i)
f[p] ^= s[g[p][i]];
for (int i = 1; i < d[p]; ++i) {
inc(g[p][i], dlt);
while (tp)
--tp, inc(sx[tp], sv[tp]);
}
upd(dlt, 1);
dlt ^= f[p];
nd = 1, cb = bt;
while (nd < lim) {
--cb;
nd = nd << 1 | (dlt >> cb & 1);
if (w[nd])
nd ^= 1;
}
nd -= lim;
nd ^= dlt;
f[p] ^= nd;
dlt ^= nd;
s[p] = nd;
if (p == 1)
res ^= nd;
if (del) {
dec(p, dlt);
while (tp)
--tp, dec(sx[tp], sv[tp]);
}
}
inline void ca(int p, bool del) {
int &t = ps[rk - 1];
if (t > d[p])
sol(p, del), --rk;
else if (t == d[p])
tx[rk] = g[p][0], ps[rk] = 1, tt[rk++] = 0, ++t;
else
tx[rk] = g[p][t], ps[rk] = 1, tt[rk++] = 1, ++t;
}
inline void solve() {
n = read();
dlt = 0, lim = 1, bt = 0;
while (lim <= n)
lim <<= 1, ++bt;
for (int i = 2; i <= n; ++i)
++d[s[i] = read()];
for (int i = 1; i <= n; ++i)
g[i] = new int[d[i]], d[i] = 0;
for (int i = 2; i <= n; ++i)
g[s[i]][d[s[i]]++] = i;
for (int i = n; i; --i) {
s[i] = 1;
for (int j = 0; j < d[i]; ++j) {
s[i] += s[g[i][j]];
if (s[g[i][0]] < s[g[i][j]])
swap(g[i][0], g[i][j]);
}
}
ps[0] = 1, tx[0] = 1, tt[0] = 1, rk = 1;
while (rk)
ca(tx[rk - 1], tt[rk - 1]);
for (int i = 1; i <= n; ++i) {
f[i] = d[i] = 0;
delete[] g[i];
}
}
int main() {
int tc = read();
while (tc--)
solve();
printf("%d\n", res);
return 0;
}
《PM 大师》from 屠佳铭
甚至还没来得及看题,之后有时间会改。
Round 8
越来越烂了。
开局看 T1,发现这不是我们的弦图问题吗?本题子树图的特例,子树图的 PEO 是按照最浅点的深度排序。然后这个问题就转化成了链加颜色,求单点 \(\operatorname{mex}\) 的问题。这个问题一开始我把它转换成了单点加颜色子树 \(\operatorname{mex}\),发现这就是一个动态二维数点问题:给每一个颜色用 set
维护虚树转变成纯粹的链加,外面套一个线段树二分就可以做了。但是 \(5\times 10^5\) 的数据范围两只 \(\log\) 的大常数算法动态二维数点,让我不相信这能过。纠结了很久要不要写这个看起来没前途的算法。
这个时候 lbp 已经开始交 T1 了,看起来是会了,听说了他的复杂度也是两 \(\log\) 的。于是我才意识到这个题标算是不是就是两只 \(\log\) 啊?然后我仔细想了想,由于 PEO 路径 LCA 深度不降,可以直接树剖 + 堆同样是两只 \(\log\) 维护这个东西,只不过两个都从跑得满的 \(\log\) 变成跑不满的 \(\log\)。赶紧开始狂写,写完之后发现都要跑 1.4s,要是写刚才那个算法绝对过不去了。
可是此时已经过去两个多小时了。于是看了看后面两个题,T2 发现题目名称中包含多项式,发现本题严格强于树上链分治求多项式乘积,看起来不好搞,先跳了;看 T3,这不是我们的 Cardboard Box 吗?这个问题有无数种搞法,原题题解区占大部分的按 \(b-a\) 排序考虑分界点的做法和五个堆的反悔贪心做法看起来都不太有前途。这个时候我想起这个题 zhy 魔怔的想法:对于这种背包问题,其答案在模 2 意义下有凸性,这个时候我们可以闵可夫斯基和维护,这就很类似于动态凸包问题了!看起来就是这个题想考的!
同时动态凸包问题的查询需要二分,在这个题上的表现就是 wqs 二分,我们对线段树每一个节点维护出其闵可夫斯基和之后将查询区间的线段树一起拿出来 wqs 二分,就可以做没有修改的情况了!
于是我打算先写一个 wqs 二分试试水。没想到这一试水直接淹死了。样例全过了交上去一直是 WA,验证了一下凸性,发现没问题啊?
最后发现是 wqs 二分输出答案那里应该是 \(K\times w-ans\) 而不是 \(cnt \times w - ans\),也就是你应该拿你询问的物品个数而不是你二分求出来的最优物品个数,好难受啊!!!
之后只剩下一个小时不到了,想要赶紧冲下正解,然后发现动态修改你可以直接维护 wqs 二分的加法,开始写!!!写这些着发现不对,这玩意没有可减性啊?需要上一个线段树分治!把纬度颠倒重新来!!!写着写着发现还是不对劲,这样是不是多了一个 \(\log\) 了?这下寄了。
只剩下十分钟了,干啥都来不及了,这个时候看 ckx,发现他已经切了 T2 了。我刚刚是不是扔掉了一个其实更加可做的题?于是在机房里怒吼红温😡。
发现群里讨论 T1 的人都没用弦图,唯一一个在问弦图的老哥没有做出来。感情是我会得更多做这个题想法更慢啊……
感觉这场明明全程都在干事,但是无效思考、无效调试时间太多了,感觉大部分时间浪费在了想做法 \(\to\) 假了,写代码 \(\to\) 写挂了 \(\to\) 调试的疯狂循环中。可能是考试精力不太足导致+考场策略不好导致的。
正好周六休息,想着下一场一定要打好,考前早点睡觉养足精神,不要乱开题(伏笔)。
《熟练》from 宋浩然
考虑弦图理论,按路径 LCA 深度从小到大排序之后可以求出来 PEO。然后如何构造方案呢?注意到由于路径 LCA 深度不降,所以说你可以把路径染色视为路径的两个端点到根的染色。就变成了链加颜色,求单点 \(\operatorname{mex}\)。
然后考虑使用树剖 + 堆维护这个东西。所有链在树剖意义下都是一个前缀,由于查询点深度不降,我们在其深度深于这个前缀的端点的时候删去这个前缀的信息。问题被完全转化成了 \(O(n\log n)\) 次插入、删除求 \(\operatorname{mex}\),直接用堆就可以维护了。
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <ext/pb_ds/assoc_container.hpp>
#include <ext/pb_ds/hash_policy.hpp>
#include <functional>
#include <queue>
using namespace std;
int read() {
char c = getchar();
int x = 0;
while (c < 48 or c > 57)
c = getchar();
do
x = x * 10 + (c ^ 48), c = getchar();
while (c >= 48 and c <= 57);
return x;
}
const int N = 500003;
typedef pair<int, int> pii;
int n, m, cur;
int hd[N], ver[N << 1], nxt[N << 1], tot;
void add(int u, int v) {
nxt[++tot] = hd[u], hd[u] = tot, ver[tot] = v;
}
__gnu_pbds::gp_hash_table<int, int> mp[N];
priority_queue<int, vector<int>, greater<int>> pq[N];
vector<pii> vec[N];
int eu[N], ev[N], ew[N];
int p[N], col[N], now[N];
int sz[N], tp[N], ft[N], sn[N], d[N];
int lca(int u, int v) {
while (tp[u] ^ tp[v]) {
if (d[tp[u]] < d[tp[v]])
swap(u, v);
u = ft[tp[u]];
}
if (d[u] < d[v])
return u;
else
return v;
}
void dfs(int u, int fa) {
sz[u] = 1, ft[u] = fa, sn[u] = 0;
for (int i = hd[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == fa)
continue;
d[v] = d[u] + 1;
dfs(v, u);
sz[u] += sz[v];
if (sz[v] > sz[sn[u]])
sn[u] = v;
}
}
void split(int u, int tops) {
tp[u] = tops;
if (sn[u])
split(sn[u], tops);
for (int i = hd[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == ft[u] or v == sn[u])
continue;
split(v, v);
}
}
void upd(int p, int v) {
while (p and d[p] >= cur) {
++mp[tp[p]][v];
vec[d[p]].emplace_back(tp[p], v);
p = ft[tp[p]];
}
}
void solve() {
n = read();
m = read();
for (int i = 1; i < n; ++i) {
int u = read(), v = read();
add(u, v), add(v, u);
}
d[1] = 0;
dfs(1, 0);
split(1, 1);
for (int i = 1; i <= m; ++i) {
p[i] = i;
eu[i] = read(), ev[i] = read();
ew[i] = lca(eu[i], ev[i]);
}
sort(p + 1, p + m + 1, [](int x, int y) {
if (d[ew[x]] != d[ew[y]])
return d[ew[x]] < d[ew[y]];
else
return ew[x] < ew[y];
});
cur = 0;
for (int i = 1; i <= m; ++i) {
int x = p[i];
while (cur < d[ew[x]]) {
for (auto [t, v] : vec[cur])
if (!--mp[t][v] and v <= now[t])
pq[t].emplace(v);
++cur;
}
int o = tp[ew[x]];
assert(o <= n);
while (true) {
if (pq[o].empty()) {
auto it = mp[o].find(++now[o]);
if (it != mp[o].end() and it->second)
continue;
pq[o].emplace(col[x] = now[o]);
break;
} else {
bool fl = 0;
while (!pq[o].empty()) {
int v = pq[o].top();
auto it = mp[o].find(v);
if (it != mp[o].end() and it->second) {
pq[o].pop();
continue;
}
col[x] = v, fl = 1;
break;
}
if (fl)
break;
}
}
upd(eu[x], col[x]);
upd(ev[x], col[x]);
}
printf("%d\n", *max_element(col + 1, col + m + 1));
for (int i = 1; i <= m; ++i)
printf("%d ", col[i]), col[i] = 0;
putchar('\n');
while (tot)
ver[tot] = nxt[tot] = 0, --tot;
for (int i = 0; i <= n; ++i) {
tp[i] = sn[i] = ft[i] = d[i] = hd[i] = now[i] = sz[i] = 0;
mp[i].clear();
vec[i].clear();
while (!pq[i].empty())
pq[i].pop();
}
for (int i = 0; i <= n; ++i) {
mp[i].clear();
vec[i].clear();
while (!pq[i].empty())
pq[i].pop();
}
}
int main() {
read();
int tc = read();
while (tc--)
solve();
return 0;
}
《皮鞋的多项式》from 鲜博宇
取 \(O(\sqrt {n \log n})\) 级别的 \(B\),考虑对每一个点维护一个多项式对 \((F_i,G_i)\),求子树乘积的时候,如果 \(G_i\) 的大小比 \(B\) 大了就把它乘到 \(F_i\) 上,这样 \(F_i\) 至多会发生 \(O(\frac{n}{B})\) 次变化或者与其它 \(F_i\) 合并。记录每一种不同的 \(F_i\) 就可以存储下来整颗树的子树乘积结构。
这个东西实际上可以看作某种类型的 top cluster 树分块,每一个过程里可能出现的 \((F_i,G_i)\) 都代表了一个边簇内的信息。每一次合并两个边簇,如果大小过大就新开一个簇。跟其它的树分块做法本质上十分相同,实现上更加清晰明了。
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <vector>
#define lc (p << 1)
#define rc (p << 1 | 1)
#define ALL(p) p.begin(), p.end()
#define getchar() \
(p1 == p2 && (p2 = (p1 = buf) + fread(buf, 1, 1 << 22, stdin)), \
p1 == p2 ? EOF : *p1++)
using namespace std;
const int N = 100003;
const int B = 1200;
const int P = 998244353;
char buf[1 << 22], *p1 = buf, *p2 = buf;
typedef vector<int> vi;
typedef long long ll;
int read() {
char c = getchar();
int x = 0;
bool f = 0;
while (c < 48 || c > 57)
f |= (c == '-'), c = getchar();
do
x = (x << 1) + (x << 3) + (c ^ 48), c = getchar();
while (c >= 48 && c <= 57);
if (f)
return -x;
return x;
}
int qp(int a, int b = P - 2) {
int res = 1;
while (b) {
if (b & 1)
res = (ll)res * a % P;
a = (ll)a * a % P;
b >>= 1;
}
return res;
}
int len, ilen, bt;
int rev[1 << 20], cw[1 << 20 | 1];
void init(int _len) { // mod x^len
len = 1, bt = -1;
while (len < _len)
len <<= 1, ++bt;
int w = qp(3, (P - 1) >> (bt + 1));
cw[0] = cw[len] = 1;
for (int i = 1; i < len; ++i) {
cw[i] = (ll)cw[i - 1] * w % P;
rev[i] = (rev[i >> 1] >> 1) | ((i & 1) << bt);
}
ilen = qp(len);
}
struct poly {
vi f;
poly() : f() {
}
poly(int Len) : f(Len) {
}
poly(vi Init) : f(Init) {
}
poly(initializer_list<int> Init) : f(Init) {
}
int &operator[](const int &x) {
return f[x];
}
const int &operator[](const int &x) const {
return f[x];
}
void NTT() {
f.resize(len, 0);
for (int i = 1; i < len; ++i)
if (rev[i] < i)
swap(f[rev[i]], f[i]);
for (int i = 1, tt = len >> 1; i < len; i <<= 1, tt >>= 1)
for (int j = 0; j < len; j += (i << 1))
for (int k = j, t = 0; k < (j | i); ++k, t += tt) {
int x = f[k], y = (ll)f[k | i] * cw[t] % P;
if ((f[k] = x + y) >= P)
f[k] -= P;
if ((f[k | i] = x - y) < 0)
f[k | i] += P;
}
}
void INTT() {
for (int i = 1; i < len; ++i)
if (rev[i] < i)
swap(f[rev[i]], f[i]);
for (int i = 1, tt = len >> 1; i < len; i <<= 1, tt >>= 1)
for (int j = 0; j < len; j += (i << 1))
for (int k = j, t = len; k < (j | i); ++k, t -= tt) {
int x = f[k], y = (ll)f[k | i] * cw[t] % P;
if ((f[k] = x + y) >= P)
f[k] -= P;
if ((f[k | i] = x - y) < 0)
f[k | i] += P;
}
for (int i = 0; i < len; ++i)
f[i] = (ll)f[i] * ilen % P;
}
int size() {
return f.size();
}
void reduce() {
while (!f.empty() && !f.back())
f.pop_back();
}
void show() {
for (int x : f)
printf("%d ", x);
putchar('\n');
}
void trunc(int lim) {
if (lim < int(f.size()))
f.erase(f.begin() + lim, f.end());
}
friend poly operator*(poly A, poly B) {
init(A.size() + B.size() - 1);
A.NTT();
B.NTT();
poly C(len);
for (int i = 0; i < len; ++i)
C[i] = (ll)A[i] * B[i] % P;
C.INTT();
C.reduce();
return C;
}
} e[B];
int n, q, m;
int f[N][B + 1], g[N], sz[N];
int hd[N], ver[N << 1], nxt[N << 1], tot;
void add(int u, int v) {
nxt[++tot] = hd[u], hd[u] = tot, ver[tot] = v;
}
__int128 tmp[N];
void mul(int &x, int y) {
if (!x or !y) {
x |= y;
return;
}
e[++m] = e[x] * e[y];
x = m;
}
void dfs(int u, int fa) {
for (int i = hd[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == fa)
continue;
dfs(v, u);
mul(g[u], g[v]);
for (int x = 0; x <= sz[u]; ++x)
for (int y = 0; y <= sz[v]; ++y)
tmp[x + y] += (ll)f[u][x] * f[v][y];
sz[u] += sz[v];
if (sz[u] < B) {
for (int x = 0; x <= sz[u]; ++x)
f[u][x] = tmp[x] % P, tmp[x] = 0;
} else {
e[++m].f.resize(sz[u] + 1);
for (int x = 0; x <= sz[u]; ++x)
e[m][x] = tmp[x] % P, tmp[x] = 0;
e[m].reduce();
mul(g[u], m);
f[u][sz[u] = 0] = 1;
}
}
}
int main() {
n = read(), q = read();
e[0] = poly({1});
for (int i = 1; i <= n; ++i) {
int k = read();
if (k < B) {
g[i] = 0;
sz[i] = k;
for (int j = 0; j <= k; ++j)
f[i][j] = read();
} else {
g[i] = ++m;
f[i][sz[i] = 0] = 1;
e[m].f.resize(k + 1);
for (int j = 0; j <= k; ++j)
e[m][j] = read();
}
}
for (int i = 1; i < n; ++i) {
int u = read(), v = read();
add(u, v), add(v, u);
}
dfs(1, 0);
for (int i = 0; i <= m; ++i) {
int sum = 0;
for (int &x : e[i].f) {
sum += x;
if (sum >= P)
sum -= P;
x = sum;
}
}
int res = 0;
while (q--) {
int x = read() ^ res, l = read() ^ res, r = read() ^ res;
res = 0;
for (int i = 0; i <= sz[x] and i <= r; ++i) {
if (l - i >= (int)e[g[x]].size())
continue;
if (r - i < (int)e[g[x]].size())
res = (res + (ll)e[g[x]][r - i] * f[x][i]) % P;
else
res = (res + (ll)e[g[x]].f.back() * f[x][i]) % P;
if (l > i)
res = (res + (ll)e[g[x]][l - i - 1] * (P - f[x][i])) % P;
}
printf("%d\n", res);
}
return 0;
}
《幽默还是梦》from 张力玺
wqs 二分做法考场上的想法已经假了,但是我感觉能修。
答案在模 2 意义下具有凸性,考虑 wqs 之后变成这样一个问题:限定奇偶形,求所有位置价值和的最大值,在此基础上最大化选择的体积大小。
可以对于每一个位置,求出其价值和最大的拿一个,以及最大的那一个减去次大的那一个,前者加起来,后者求最小值。(比较时价值相同按照体积比较)
如果最后最大的加起来奇偶性不对,就靠着后一个选项将奇偶性恰好调整 1。
考虑最大、次大二元组的情况只有 \(O(1)\) 种,我们将所有可能成为答案斜率的点离散化(也就是 \(2a_i,b_i,2(b_i-a_i)\)),建线段树,把所有位置和修改的每一种最大、次大二元组的情况挂在线段树对应的区间上去。
我们考虑模仿标算,可以在这棵线段树上整体 wqs 二分,递归到一侧的时候把已经确定的贡献直接贡献到询问上,注意到这个时候取 \(\min\) 时你不确定哪个体积更优(不确定当前二分的 \(w\)),但是 \(w\) 前面的系数值域 \(O(1)\),对于每一种值记录最小的截距。这样在一个线段树节点上你就可以直接查询每个询问在哪边了。
冇得代码。免责声明:上述算法纯属口胡,假了属于正常现象。这个算法想成这样已经不太可写了。写了常数也大概过不去(因为每个询问你要拆成四段放到线段树上,线段树长度还是三倍 \(n\)+修改数 级别的)。
标算的话大致看了前面的部分,感觉是按性价比选了之后调整 \(O(1)\) 个元素,看起来文明很多。
Round 9
吸取上一场的教训赛前睡足了八个半小时,感觉精力很不错。开 T1,做了做发现自己会了 \(k=0\),抓住两个对称的点算贡献就行了;继续想了想,发现抓住两个关于起点对称的点很有前途,应该可以直接拓展到 \(k=1\),于是仔细分讨了一下情况,写了些暴力,然后再对拍验证了一下,仔细修了修结论,到 2h 左右的时候发现自己 \(k=1\) 的刻画是完全正确的!剩下的只需要逐情况处理就行了。然后考虑 \(k=0\) 怎么写,发现这样子只需要一个区间加二次函数就行了。抓住对称的两个端点,考虑内部的奇偶性,然后对中点进行区间加,需要推一个等差数列求和。
然后我发现,这样需要对每一种情况的四个区间,都推一个等差数列求和,工作量爆炸!想要硬着头皮完成,尝试实现 \(k=0\) 的过程中,思路越理越乱。转变策略想打点暴力,发现这个题暴力也很难写,部分分在思维上确实有梯度,但是在实现复杂性上却几乎一样。对我这种写不出来代码的堪称疯狂折磨。
直到快 4h 了,我才发现我在这个题的沉没成本彻底收不回来了。于是赶紧跑去看 T2。发现直接对着这个东西 DP 一下就做完了,怎么签到在 T2 啊!!!狂写写了 \(O(3^q\operatorname{poly}(n))\),最后优化到 \(2^q\) 级别我已经会怎么搞了,但没来得及写完。
赛后群里有人说 T3 是 DIVCNT1,我知道这个东西的原题啊!如果赛场开了这个题大不了就去题解区现学。zhy 跳了 T1 然后就这样 200 分赢了。感觉原本重大利好我们的场次,能被我打得这么抽象也是没谁了,唯一一次得分发生在考试的最后十分钟。
结果来打的人好少,又是高分低于签到线的一场。
《游戏》from 章弥炫
《木桶效应》from 周泽坤
先不细讲做法了。
但是我的复杂度似乎是 \(O(2^q n^3)\) 的,ckx 只多一个 \(q\),恰好是慢 \(q\) 倍左右,题解里的 \(O(2^q n^3 q^2)\) 多两个 \(q\) 真能过吗🤔?
#include <cassert>
#include <cstdio>
#include <cstring>
#include <map>
using namespace std;
const int P = 998244353;
typedef long long ll;
int read() {
char c = getchar();
int x = 0;
while (c < 48 or c > 57)
c = getchar();
do
x = x * 10 + (c ^ 48), c = getchar();
while (c >= 48 and c <= 57);
return x;
}
const int N = 53, S = 1 << 10, T = 10;
inline void inc(int &x, int v) {
if ((x += v) >= P)
x -= P;
}
map<int, int> id;
int n, m, q, cnt, rk;
int f[S][N], g[S][N], h[S];
int fac[N], fiv[N];
int pfac[N], pfiv[N];
int mp[N];
int qp(int a, int b = P - 2) {
int res = 1;
while (b) {
if (b & 1)
res = (ll)res * a % P;
a = (ll)a * a % P;
b >>= 1;
}
return res;
}
inline int C(int x, int y) {
return (ll)fac[x] * fiv[y] % P * fiv[x - y] % P;
}
int w[T][T], msk2[T];
int main() {
n = read(), m = read(), q = read();
for (int i = 1; i <= n; ++i)
mp[i] = -1;
for (int i = 1; i <= q; ++i) {
int x = read(), y = read();
if (id.find(x) == id.end())
id[x] = cnt++;
if (mp[y] < 0)
mp[y] = rk++;
x = id[x], y = mp[y];
w[x][y] = read();
}
fac[0] = 1;
for (int i = 1; i <= n; ++i)
fac[i] = (ll)fac[i - 1] * i % P;
fiv[n] = qp(fac[n]);
for (int i = n; i; --i)
fiv[i - 1] = (ll)fiv[i] * i % P;
for (int i = 0; i <= n; ++i) {
pfac[i] = qp(fac[i], m - cnt);
pfiv[i] = qp(fiv[i], m - cnt);
}
f[0][0] = 1;
for (int i = n; i; --i) {
int msk1 = 0;
for (int u = 0; u < cnt; ++u) {
msk2[u] = 0;
for (int v = 0; v < rk; ++v)
if (w[u][v]) {
if (w[u][v] < i)
msk1 |= (1 << v);
else
msk2[u] |= (1 << v);
}
}
for (int s = 0; s < (1 << rk); ++s)
for (int x = 0, c = __builtin_popcount(s);
x <= n - rk and c <= n - i; ++x, ++c) {
f[s][x] = (ll)f[s][x] * pfac[n - i + 1 - c] % P;
for (int u = 0; u < cnt; ++u) {
int cur = n - i + 1 - c;
cur -= __builtin_popcount(~s & msk2[u]);
if (cur >= 0)
f[s][x] = (ll)f[s][x] * fac[cur] % P;
else
f[s][x] = 0;
}
}
for (int x = 0; x <= n - i; ++x) {
for (int s = 0; s < (1 << rk); ++s)
h[s] = f[s][x];
for (int p = 0; p < rk; ++p)
if (~msk1 >> p & 1)
for (int e = 0; e < (1 << rk); e += (2 << p))
for (int t = e; t < (e | (1 << p)); ++t)
inc(h[t | (1 << p)], h[t]);
for (int s = 0; s < (1 << rk); ++s)
for (int y = x, c = __builtin_popcount(s) + x;
y <= n - rk and c <= n - i + 1; ++y, ++c)
g[s][y] = (g[s][y] + (ll)h[s] * C(n - rk - x, y - x)) % P;
}
for (int s = 0; s < (1 << rk); ++s)
for (int x = 0, c = __builtin_popcount(s);
x <= n - rk and c <= n - i + 1; ++x, ++c) {
f[s][x] = (ll)g[s][x] * pfiv[n - i + 1 - c] % P;
g[s][x] = 0;
for (int u = 0; u < cnt; ++u) {
int cur = n - i + 1 - c;
cur -= __builtin_popcount(~s & msk2[u]);
if (cur >= 0)
f[s][x] = (ll)f[s][x] * fiv[cur] % P;
else
f[s][x] = 0;
}
}
}
printf("%d\n", f[(1 << rk) - 1][n - rk]);
return 0;
}
《月亮的背面是粉红色的》from 焦思源
DIVCNT1+类欧科技题。
最重要的是会 DIVCNT1。