记一次内存占用异常排查 —— memory ballast 被分配了物理内存

memory ballast 的概念这里不再赘述,相信在使用 Golang 的读者应该都知道。确实不了解的话可以阅读提出这个概念的文章,里面有详细的描述。这几年,ballast 被大量运用,在大家的认知里,ballast 是降低 GC 频率的一个简单、实用的方法,我也一直没有看到过关于它的负面报道 —— 直到这次之前。 在 golang-nuts 邮件列表中,也有一个关于这个问题的讨论,但对 golang 了解不多,或者英文不太好的读者可能会一头雾水。本文会对这个问题的来龙去脉做一个简单易懂的概述。如果有错误,欢迎指正。 背景 最近遇到,总有一小部分实例,内存(RSS)占用比其他实例大。而且和正常的实例相比,经过反复排查也没有看出它们的环境有明显的差异。 后面发现,这些实例的 ballast 都整个地被分配了物理内存,并且是启动、创建 ballast 时就这样。 原因 OS(熟悉者可跳过) 众所周知,现代操作系统,尤其是类 Unix 系统中,虚拟内存机制被广泛使用。用户进程对内存的申请、访问等都是在虚拟地址空间内进行的,当进程访问内存时,才会通过“缺页异常”中断,调入对应的内存分页。 比如,当 Go runtime 申请了一块大小为 1GB 的连续内存时,会在虚拟地址空间中得到一段长度为 1GB 的地址,但在它被访问之前,OS 并不会调入对应的物理内存分页,此时也不会占用 1GB 的物理内存。这是 ballast 的理论基础。 ballast 通常的实现是,申请一个大切片,并设置它 KeepAlive(防止 Go 帮倒忙把它优化掉),然后保持它存在但永不访问它,这样结果就不会占用物理内存,同时会占着堆内存,使得 GC 的触发频率降低。 而事实上却出现了 ballast 占用物理内存的情况,最容易想到的原因是 Go runtime 在创建 ballast 大切片时访问了它。 Go runtime 在 Go 的内存分配机制中,大于 32KB 的内存属于大内存,是通过 mheap 分配的。Go 语言原本对应章节中有提到一个“清零”操作。如果在分配 ballast 的内存时,发生了这个清零操作,结果似乎就是会发生 ballast 吃内存的情况。Go 语言原本里没有介绍如何判断是否需要清零。 关于清零,在开头提到的邮件列表里,Golang 团队的开发者,也是下文将提到的 go1.19 GC 相关新特性的提出者,Michael Knyszek 进行了一段回复(译文): runtime 有一个简单的启发式方法来避免清零操作,但它远非完美。 因此,ballast 本质上总是会有一点风险。 在某些平台上尤其如此,例如 Windows,因为无法避免将内存标记为已提交(Windows 可以自由地对范围内的内存使用按需分页,因此整体系统内存压力可能会增加,但您不能避免将其计为特定进程的已提交)。 判断的具体逻辑(Github 地址): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 // allocNeedsZero checks if the region of address space [base, base+npage*pageSize), // assumed to be allocated, needs to be zeroed, updating heap arena metadata for // future allocations. // // This must be called each time pages are allocated from the heap, even if the page // allocator can otherwise prove the memory it's allocating is already zero because // they're fresh from the operating system. It updates heapArena metadata that is // critical for future page allocations. // // There are no locking constraints on this method. func (h *mheap) allocNeedsZero(base, npage uintptr) (needZero bool) { for npage > 0 { ai := arenaIndex(base) ha := h.arenas[ai.l1()][ai.l2()] zeroedBase := atomic.Loaduintptr(&ha.zeroedBase) arenaBase := base % heapArenaBytes if arenaBase < zeroedBase { // We extended into the non-zeroed part of the // arena, so this region needs to be zeroed before use. // // zeroedBase is monotonically increasing, so if we see this now then // we can be sure we need to zero this memory region. // // We still need to update zeroedBase for this arena, and // potentially more arenas. needZero = true } // We may observe arenaBase > zeroedBase if we're racing with one or more // allocations which are acquiring memory directly before us in the address // space. But, because we know no one else is acquiring *this* memory, it's // still safe to not zero. // Compute how far into the arena we extend into, capped // at heapArenaBytes. arenaLimit := arenaBase + npage*pageSize if arenaLimit > heapArenaBytes { arenaLimit = heapArenaBytes } // Increase ha.zeroedBase so it's >= arenaLimit. // We may be racing with other updates. for arenaLimit > zeroedBase { if atomic.Casuintptr(&ha.zeroedBase, zeroedBase, arenaLimit) { break } zeroedBase = atomic.Loaduintptr(&ha.zeroedBase) // Double check basic conditions of zeroedBase. if zeroedBase <= arenaLimit && zeroedBase > arenaBase { // The zeroedBase moved into the space we were trying to // claim. That's very bad, and indicates someone allocated // the same region we did. throw("potentially overlapping in-use allocations detected") } } // Move base forward and subtract from npage to move into // the next arena, or finish. base += arenaLimit - arenaBase npage -= (arenaLimit - arenaBase) / pageSize } return } 注:原子操作 Casuintptr 的作用是,如果 p1 == p2,则 p1 = p3 并 return 1;否则无操作,return 0。 它会去遍历此次分配内存将涉及到的各个 arena(Go 内存分配中的一类大对象,详见 Go 语言原本),分别检查它们的 zeroedBase(值越大说明无需清零的内存越少),判断是否需要清零,并会增大 zeroedBase 的值。即,它的值可以理解为已被分配过、需要清零的值。需要注意的是,只要有一个 arena 符合 arenaBase < zeroedBase,都是整体地返回 true。 可以看出,arena 里已经被分配过又回收的内存,再次分配给 ballast 时,这次分配就会被判断为需要清零,进而出现开头描述的问题。因为 ballast 通常都是在启动早期创建的,在它之前分配的内存很少,所以这是个概率较小的事件,但确实存在。 建议 对于仍在继续使用 ballast 的读者,为了预防此问题,建议考虑以下方案替代它。 memory target 这是 1.19 的新功能,可以设置一个固定数值的,GC 触发的目标堆大小。有两种方法: 环境变量 GOMEMLIMIT。设置为数字,单位 byte;也可以用数字加单位如 1MiB,1GiB。 debug.SetMemoryTarget(limit int64),单位也是 byte 这个功能是为了替代 ballast 设计的,当它被设置后,runtime 会通过多种方法,包括调整 GC 触发频率、返还内存给操作系统的频率等,尽量使内存不超过它。它测量内存是否达到限制的指标是 go runtime 管理的所有内存,相当于 memStats 中 Sys - HeapReleased 的值。它的效果理论上类似且优于 ballast。 使用它限制内存时,可以关闭按比例的 GC(GOGC=off),或将其比例调大。 不过,它和 ballast 一样,不是硬限制,不要把它的值设置为环境允许的内存占用极限。 gc tuner 对于旧版本的 golang,还有一个方案是由 uber 提出的的。思路是动态地调整 GC 触发的比例。有两个开源实现:cch123/gogctuner、bytedance/gopkg/util/gctuner。 仍然使用 ballast 如果想继续使用 ballast ,我想以下两点可能有助于降低该问题发生的概率: 尽量早创建 ballast 在创建 ballast 前关闭 GC

2023/3/12
articleCard.readMore

Goodbye ICPC

在 2020 年底,ICPC 济南站获得第一块金牌之后,由于队友们在学业等其他方向上各有安排,加上里下一场比赛的时间稍远(当时还不确定会去打省赛),我们一起训练的时间相对减少。虽然自己也会做一些练习,但效果一般。后面的省赛中,我校选手都拿了金,其中也包括我们队。 而后则是一直拖到2021年7月的沈阳区域赛,这也是我唯一一次飞到现场参加的比赛。沈阳的气候和南方不同,尽管七月的沈阳不至于寒冷,但第一次到北方的我还是不太适应。在比赛现场上,我出现了较大的失误,整体上我们队伍的优势也没有发挥出来,最后只拿到了铜牌。 最后一年,先后有秋招、毕设等比较重要的事。我们除了赛前会集合训练一下以外,大部分时候都是自己练习。我们先后参加了 CCPC 哈尔滨站和 ICPC 济南站,都取得了银牌。2022 年初,一名队友因为保研夏令营退出队伍,我们直到三月初才最终确定了新队员,是一名大一的学弟,高中时是 OI 选手,实力很强。四月我们便参加了今年的第二场 ICPC 区域赛——昆明站,由于我们俩在搞毕设,学弟课程也很多,一起训练的时间仍然有限。尽管如此,我们最后还是拿到了金牌,这也是我们俩的第二块金牌。 拿了金牌之后,我们也获得了 ICPC EC Final 的参赛名额。西安主办方为了线下比赛,把时间一直拖到了7月19-20日,且在七月初,西安出现了疫情,包括数个高风险区。这个时间段已经毕业在家,赛后不久就是我入职时间。综合考虑可能出现的各种风险、主办方允许部分更换队员的情况(按照往年的要求,除了奖励名额外,其余名额是要在今年区域赛中排名靠前的整支队伍参赛的,但今年似乎放宽了要求,可能和疫情有关),我最后选择了退出比赛,更换为一名学弟。可惜的是,最后他们只拿了铜牌。 现在我已没有机会参加 ICPC 区域赛,是时候和 ICPC 说 Goobye 了。回想这几年,经过了大一大二的高度热情和大量训练,我在大三时终于获得了第一块金牌,而后拿了 ICPC 与 CCPC 的数块银牌和第二块金,在此中间也夹杂着省赛、CCCC 等比赛。这些是我大学期间,最重要的经历与回忆,其中既有成功的喜悦,也有失败的遗憾,但无论如何,都不可能再重来。 在 ICPC 生涯结束后,我很快将会成为一名普通的开发。我会在工作上多多努力,不断提升自己。

2022/7/21
articleCard.readMore

2020 ICPC 济南站感想和部分题解

在上次 CCPC 打铜之后,我们打了一次很失败的校赛二等奖。济南报名之后,正式比赛之前,我感觉自己的状态和心态都并不是很好。准备这次比赛时想着拿铜有点小亏,拿银就比较正常了。 在赛场上,签到四题的部分共 WA 一发,还算比较稳,但并不快,排名在银区。 然后自然是跟榜看 A、L,但是都没有想出做法,尤其是 A 涉及的矩阵变换等是我最弱的方向。 后来发现有人过 J 题之后,我也开始看,一开始想在反图(原图不存在的所有边组成新图)里找强连通分量来构造,后面发现似乎并不可行。然后我想起了树上可以相邻点异色染色之类的操作,就发现它可以染两种色,同色点之间无边,这样这题一下子就豁然开朗了。很快地过了 J,排名一下子到了 9。 然后就进入了罚坐模式,期间我甚至想提前放弃🌚。A 题过的队越来越多,我们却迟迟找不到做法。由于我矩阵、代数等方面不太行,帮不上忙,便把所有没过的题都看了,然后也没发现有思路的,心态有点炸。A 题队友找到了把等式变形,矩阵按列拆开之后每列算自由度的做法,继而想到高斯消元和线性基。因为高斯消元算各列,总的复杂度是 $O(n^4)$,即 $1.6 \times 10^9$,感觉过不了,然后线性基连板子都没人看得懂,又开始卡。直到最后没办法才尝试用高斯消元去做,结果直接过了。赛后大佬们都说高斯消元本来复杂度就卡不满🌚,另外这题也可以 bitset 优化,不过没优化也过了。 A 题过了之后金牌应该是稳了,我一下子心态好了非常多,然后我们再一起看 L,想到了枚举后面几位的方法,但是只有不到半小时,来不及过了。不过金牌已经有了,这题过了也离出线差一题,所以也没太大影响。 这场我前中期发挥还可以,但在 A 卡题过程中不仅没帮上什么忙,反而因为自己心态不好,对队友心态也有些影响。很感谢他们并没有一起心态爆炸,并且最终过了这题,让我们第一次打 ICPC 就获得了金牌的好成绩。 A Matrix Equation 题意:已知相同大小的 0-1 方阵 $A$ 和 $B$,定义运算 $\times$ 是普通的矩阵乘,所有元素对 2 取模。定义 $\odot$ 为对应位置元素相乘(也就是相与),求使等式 $A \times C = B \odot C$ 成立的 $C$ 的个数,对 998244353 取模。 很显然,$C$ 的一个元素只会影响它所在的那一列,所以可以以列为单位分开考虑。 好像是只考虑一列之后,就可以得出方程组,然后用高斯消元算自由度。这题队友过的,我先溜了🌚。 C Stone Game 题意:有若干堆石头,每堆有最多三个石头,MianKing 想把它们合并为一堆。他每次可以把任意两堆合并为一堆,如果合并前两堆石头各有 $x$、$y$ 个,则代价为 $(x \mod 3) (y \mod 3)$,求最小总代价。 首先,石堆的个数只有模 3 后的部分有意义。模 3 为 0 的石堆,它与任何石堆合并代价为 0,也不会影响其个数模 3 的值,故可以无视。 然后,1 与 2 都有时,把它们两两合并为 0 是最优解,因为如果不这样做,两个 1 变成 2 代价 1,两个 2 变成 1 代价 4,怎么想都是亏的。因此,优先合并为 0,耗尽 1 和 2 中较少的那种。 对于多出的 1 或 2,显然只能合并,然后得到 2 或 1,然后按照上面的结论,优先合并 0。也就是三个 1 或 2 合并得 0,代价是 3 或 6,直至剩下不到三个为止。 上一步如果刚好够或者剩一个,都没有额外代价。剩两个时则还有一次额外的合并。 D Fight against involution 题意:期末论文有一个奇怪的打分规则:一个人的成绩等于全班总人数减去论文字数比他多的人数,不含相等。每个人能写的字数都有一个区间 $[L, R]$ 作为限制。为了拿高分,卷王们自然都会写最多的字数。但是,如果大家都不那么卷,适当减少字数,或许可以让所有人的成绩都不降低。这题要求找到一个这样的方案,在所有人成绩不降低的前提下,让所有人写的字数总和最小。只输出最小总和的值即可。 这个成绩实际上就是排名,并列往高算。 所有人排名不降,也就是排序不能变。因为并列算高,原本不并列的可以变并列,反之则不能。 显然可以贪心,按原字数排序,原字数最少的尽量减(到 $L$),后面的在不比前面的少的情况下尽量减。并列的只能一起减到它们的 $L$ 中的最大值。 具体做法可以把 $R$ map 到对应的最大 $L$,也可以对 $R$ 并列的按 $L$ 从大到小排,等等。 G Xor Transformation 题意:通过最多五次操作把 $x$ 变成 $y$,每次操作是任选满足 $0 \le a < x$ 的 $a$,令 $x = x \oplus a$。保证 $y < x$。 第一次可以把 $x$ 补满成 $2^k-1$ 的形式,第二次则直接变成 $y$。由于允许 $a = 0$,这种做法无需任何特判。 J Tree Constructer 题意:已知一棵无向树,要求为每个点赋值($[0,2^{60})$),使有连边的两点所赋值按位或结果等于 $2^{60}-1$,无连边的两点所赋值按位或不等于 $2^{60}-1$。输出一种方案即可。 这题一开始往反图方向考虑去了,后面发现这样做位数应该不够。 按染色思路,相邻边异色,染成两色,设较少的颜色为 0,多的为 1,这样显然可以构造互补关系。 先对每个颜色为 0 的点,分配一位,置 0,其它位置 1。再为了防止同色互连,把一个标记位置 0。 这样颜色为 1 的与它互补,标记位置 1,分配给与它相邻的各点的位置 1,其它置 0。 最多 51 位,加上颜色为 1 的各点共同的零(剩下 9 位都是,需要 1 位),共需 52 位。 M Cook Pancakes! 题意:经典问题,平底锅煎煎饼,两面都要煎,3 个饼能同时煎 2 个,最少 3 次可以煎完。求 $n$ 个饼能同时煎 $k$ 个,最少几次能煎完,$n,k > 0$。 现在我们应该很容易想到,$2n/k$ 向上取整的方案肯定是存在的。 然后,当 $n < k$ 时需要煎 2 次,而上面的式子可能会得到 1 的错误答案。 因此,$\max(2, (2n+k-1)/k)$ 即为答案。

2020/12/29
articleCard.readMore

2020 CCPC 秦皇岛站感想和部分题解

第一次参加 CCPC,开局不到一小时愉快地签完了 A、G、F,rank 银区偏高,然后就开始了三小时多的自闭调试,队友我分别开了 C 和 E 两题,都在出问题。最后两题调完还剩二十多分钟,K 这种傻逼题都搞不出来,最后耻辱地打铜了(之前敲错成银了🌚)。 由于第一次打比较正式的比赛,之前没有对 codeblocks 等 IDE 进行适应也是一个问题(我日常 Emacs + 大量插件,一个队友日常 VS),感觉时间上面多少吃了些亏。 A A Greeting from Qinhuangdao 签到题,取两个,都是红色,$\frac{C_r^2}{C_{r+b}^2}$ 即可,注意约分,红球少于两个则为 $0$。 C Cameraman 题意:Bob 的房间为矩形,长宽已知;里面有若干私人物品,Bob 自身和私人物品均视为点,位置已知固定。Alex 给 Bob 拍照,位置自选,角度范围自定(可超过 180°)。要求拍到 Bob,不拍到私人物品,求可以拍到的最大墙壁总长度。如下图绿色部分。 这题大清再次背锅,只能靠样例来猜测 Alex 和 Bob 在同一个位置时最优,否则似乎不可做。 然后,利用极角排序,找到一个方向,让直线和它两边的点紧贴,计算出结果。 由于队伍内计算几何比较薄弱,这题虽然不算难但还是在细节上出了不少问题,再加上平台上 C 题的自测是坏的(估计是因为自测没有 spj),导致比赛期间怀疑评测机有问题,浪费了大量时间。 E Exam Results 题意:有若干学生考试,每人有可能获得一高一低两个成绩(可能相等),且无法预测。本次考试的及格线为 $最高分 \times ratio$,求所有情况中,可能的最多及格人数。 对于一个人的成绩 $x$,在最高分不高于 $x/ratio$ 时,他可以及格。当然,最高分不可能低于 $x$,否则他自己的成绩将成为最高分,又因为他可能有两种成绩 $a, b$,所以当最高分位于 $[a,a/ratio]\cup[b,b/ratio]$ 时,他可以及格。 然后,我们把所有人的可能的较低分数取一个最大值,最高分至少等于它,而比它高的每个成绩也都可能作为最高分。然后枚举这个最高分,判断它在多少个人的可及格区间内,找出最大值即为答案。 数区间数量可以用线段树或者树状数组,区间更新+单点查询解决。 由于输入是百分比整数,且所有人成绩是整数,可以成绩乘 $100$(int 溢出警告)再除,向下取整作为右端点。区间范围太大,需要离散化。 本人由于在开始写代码时忘记,“所有人的可能的较低分数取一个最大值,最高分最小时就等于它”的性质,导致耗费大量时间。 F Friendly Group 题意:组内的若干学生参加学术会议。全组的友好值初始为 $0$。组内有若干对朋友,如果一对朋友同时参加,则友好值加 $1$,如果有且仅有其中一人参加,则减 $1$。最后,每个参加的人都会让友好值减 $1$。从中选择任意一部分人(可能一个都不选)参加,求可能的最高友好值。 把学生看作点,关系看作边,则友好值等于 $内部边数-跨内外边数-内部点数$。假如对于一个联通块,我们选了一部分人,那么连通块的其他部分的 $内部点数-内部点数$ 最少为 $-1$,加上它与已选部分之间的边,把整个连通块选进来结果一定不会比只选当前部分差。 因此,可以把整个连通块视为整体,用上面的公式计算,然后根据其值正负决定是否选择,最后对所有正结果取和即可得出最终结果。 G Good Number 题意:不大于 $n$ 的正整数中,有多少个可以被它的 $k$ 次方根(向下取整)整除。 对于每一个数 $i$,$[i^k,(i+1)^k)$ 开根向下取整均为 $i$,所以可以用该区间长度除以 $i$ 即可得到可被 $i$ 整除的个数。由于区间的第一个数就可被整除,所以有余数时向上取整。 由于 $n \leq 10^9$,当 $k \geq 30$ 时,所有数开根都是 $1$。 I Interstellar Hunter 题意:在一个无限制的二维空间内,Alex 一开始在原点,他会不断获得按照一个二维向量进行移动的能力,利用每个能力可以正向或反向移动无限次。询问是否可以到达特定的位置。存在多次询问,获得能力和询问可交替出现,每次询问附带价值,最后输出可到达的询问的价值之和。 对于二维向量 $\boldsymbol a$ 和 $\boldsymbol b$,利用它们能走到的位置,也就是它们的线性组合。进行变换 $\boldsymbol a = \boldsymbol a - \boldsymbol b$,它们的线性组合并不会改变。因此我们可以对它们进行辗转相除。如 $\boldsymbol a = (x_0,y_0), \boldsymbol b = (x_1,y_1)$,对其 $x$ 进行辗转相除,计算(加减乘)时使用整个向量。这样就会得到 $\boldsymbol a = (gcd(x_0,y_0), y_0’), \boldsymbol b = (0,y_1’)$ 。这样我们就可以利用询问的 $x$ 坐标轻易得出 $\boldsymbol a$ 的系数,或是不能整除直接得出不可达,然后再判断 $y$。然后出现新的向量的话,我们先将它与 $\boldsymbol a$ 进行辗转相除,使其 $x$ 为 $0$,再与 $\boldsymbol b$ 对 $y$ 辗转相除,这样它就变成无用的零向量了。 为了使当 $a$ 与新的向量进行辗转相除时在 $y$ 方向上最优,需要在 $y_0’ \geq y_1’$ 时对其相减,也就是取模。另外本题部分写法需要对 $0$ 进行特殊考虑。 K Kingdom’s Power 题意:在一个战略游戏中,地图为树形,国王在根部(点 $1$),有无限的士兵,开始全部在点 $1$。每周可以让一个军队移动到一个相邻点。求他们走一遍所有地区(可重复)至少需要的周数。 首先,对于每个非叶子节点,它都在从根节点到它对应子树的各点的必经之路上。因此只需考虑遍历所有叶子节点。 一个必然可行的方案是,对每个叶子节点,由一支军队从根部走过去。所需时间是所有叶子节点深度之和,以此作为基准值,考虑如何节省时间。 如果对某个有多个子节点的节点 $X$,如果 $X$ 的某个军队在走到了某个叶子节点 $A$ 后回来,再进入 $X$ 下面的另一个子树,则对另一个子树的某一个叶子节点来说,从根(记作 $R$,下同)到 $X$ 的路径 $d_{RX}$ 被替换为了 $d_{AX}$,节省的时间为 $d_{AX} - d_{RX}$。 对于某个节点 $X$,如果它下面的某个子树下有两个叶子节点 $A,B$,有两个军队分别从根部走到 $A,B$ 再回到 $X$,则可节省的时间为 $d_{RX} - d_{AX} + d_{RX} - d_{BX}$。此时,设 $A,B$ 的 LCA 为 $Y$,则改用如下方案:一个士兵走到 $A$ 后回到 $Y$,再进入 $B$,然后回到 $X$,则可节省的时间是 $d_{RY} - d_{AY} + d_{RX} - d_{BX}$。而 $Y$ 在 $X$ 和 $A$ 之间,$d_{AY} < d_{AX}$,$d_{RY} > d_{RX}$,后者节省的时间更多。该情况可类推到更多叶子节点的情况,从而可得到性质:对于每个子树,最多只会有一个军队从它返回到它的根节点的父节点。 然后,对于刚刚的情况,假如都要返回 $X$,则交换 $A,B$ 的位置,总的结果不变。但如果最后不返回 $X$,那么设最后的点为 $B$,设返回 $X$ 的情况下总共节省的时间为 $T$,则不返回的情况就要减去那一部分,则为 $T + d_{BX} - d_{RX}$($B$ 越深,该值越大),如果它优于 $T$,则不用返回。因此,我们在考虑子树时,把最深的放到最后,在返回父节点的情况下不影响结果,在不返回父节点的情况下结果优于其他顺序。因此,我们应在子树内计算时优先考虑较浅节点,最后把最深节点的深度用于父节点判断是否需要该子树返回。 这样我们可以对整个树进行一次 dfs,dfs 过程中判断军队是否要从当前点下面的各个子树返回到当前点。根据上一段的分析,用各子树的最深节点判断,则在 dfs 过程中获取子节点的高度(叶子为 $1$,记作 height)即可。同时传一个参数表示遍历深度(根为 $0$,记作 dep)。则对于每个子树,如果要返回,节省的时间为 $dep - height$,该值大于 $0$ 则返回,把结果减去该值。 最后,如果某节点下的所有子树都选择返回,则意味着不存在最后进入的叶子节点,这样得到的结果显然是不合理的,需要让某一个不返回。很显然,让减少的时间最少(深度最大)的一个不返回即为最优。 核心部分代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int dfs(int pos = 1, int dep = 0) { if (ch[pos].empty()) { ans += dep; return 1; } int height = 0; bool flag = 1; for (auto p : ch[pos]) { auto tmp = dfs(p, dep + 1); height = max(height, tmp); if (tmp < dep) ans -= dep - tmp; else flag = 0; } if (flag) ans += dep - height; return height + 1; }

2020/10/20
articleCard.readMore

在 KDE(Xorg)中获得完美的平铺桌面体验

前言 平铺窗口管理器是否好用这个问题就不说了,不喜欢它的用户可能永远用不惯,但喜欢它的用户则很容易对它形成依赖。在 Linux 的各个桌面环境中,KDE 可以说是最受欢迎的一款,无论是外观、功能还是易用性都很出色,全局菜单等功能也做得很好。 但是,KDE 的窗口管理器没有原生的平铺模式。虽然它提供脚本扩展的能力,也有不少实现平铺的脚本可以使用,但效果远远不如一个原生的平铺窗口管理器。经常会有一些情况没有被脚本控制到的情况,且它们的可定制性也远不如其他的 Tiling WM。 本文将介绍我尝试过一些缝合 KDE 和 Tiling WM 的方案,并在最后给出目前使用的 patched i3。 原版 i3 或 i3-gaps 存在问题 说起平铺窗口管理器,最著名的无疑是 i3,在关于 KDE 搭配平铺窗口管理器这个讨论量并不大的话题中,i3 占了大部分。我也曾经使用 KDE + i3 近一年的时间,但存在大量的问题。 KDE 的大量组件在 i3 上不会自动被设置为 floating,需要自己写规则。而部分组建即使 floating 也不行,比如桌面,只能 kill 掉然后用独立的壁纸设置工具(如 feh)代替,再比如往 panel 添加部件的侧边窗口会全屏(除 panel 区域)显示,并有一定几率崩掉,需要 kill plasmashell 再重新运行,等等。 通知窗口的问题最让人头疼,KDE 的通知窗口会被直接显示在屏幕的正中心。通常推荐的方法是设置规则向右上方移动固定的像素,这样做效果可想而知,不同尺寸的通知窗口移动后间距显然不同,特别长的还会超出屏幕,再加上多个窗口有时会叠在一起的问题。而自己写一个脚本处理这些问题的话,对于多个通知窗口的处理也很麻烦。 如果更换通知 daemon,首先目前没有太多独立的通知 daemon,很多都是要么太简陋,要么和桌面环境耦合。好看一点的界面 + 桌面托盘图标 + 少量的自定义都很难满足。一般 i3 常用的是 dunst,它的通知按钮、历史通知等功能都只能通过快捷键。linux_notification_center 看起来不错,但如果尝试在 KDE 上使用的话,由于 KDE 的通知 daemon 无法关闭,只能让其他的通知 daemon 抢占 dbus。而这个工具我试过很多种方法都不能抢占到(dunst 用 systemd user unit 可以)。 bspwm 优缺点 最近我也考虑其他窗口管理器,由于不想去搞我不熟悉的 lua、haskell 等,尝试了配置简单的 bspwm,发现它有以下几个优点: 会自动 floating 部分窗口,且对 KDE 的各种窗口有很好的兼容性,无需额外规则,即可正常显示所有窗口,上面提到的几个兼容问题都没有出现 有独立的键绑定工具,而且所有操作通过命令实现,你甚至完全可以用别的快捷键工具代替 不过它还是有一些美中不足的地方: 全屏后,其他窗口会出现在该窗口之下,桌面之上,影响透明效果。由于我比较喜欢全局透明的效果,所以这个问题对我影响很大 调度器中会显示所有虚拟桌面,即使其中没有窗口,导致 panel 中的虚拟桌面控件过长 另外,bspwm 本身很多地方个人觉得是不如 i3 顺手的。不过,如果你不常使用透明,bspwm 也是一个比较可用的方案。下面是具体的使用方法 如何使用 bspwm 基本配置 更换窗口管理器的方法同 i3,把环境变量中 i3 改为 bspwm 即可 bspwm 本身并不需要太多的配置,唯一必要的一条就是根据你的 KDE panel 设置边距。比如用了 28px 的 top panel,那么就需要加入规则 bspc config top_padding 28。值得注意的是,bspwm 的配置全部是命令,它的配置文件就是一个 bash 脚本,因此你可以动态地添加各种规则,经测试之后再加入 bspwmrc。 快捷键 bspwm 的快捷键程序是一个独立的二进制程序 sxhkd。bspwm 的各种功能调用基于命令,你可以不用 sxhkd,改用 KDE 自带的快捷键工具。不过,把这些快捷键一条一跳加到 KDE 的快捷键中工作量很大(不知道有没有方便的方法),所以还是建议 sxhkd,注意把冲突排除掉,否则 sxhkd 可能不生效。 sxhkdrc 的格式非常简洁,基本上看一眼就会知道怎么写。 混成器 推荐使用 picom,具体介绍看 patched i3 部分。 搭配 bspwm 使用时如果出现设置透明后,部分窗口失去焦点就不透明的情况,可设置 mark-ovredir-focused = false。 patched i3 介绍 这是我目前使用的方案,本部分也是本文的最主要部分。 KDE + i3 的主要问题 i3 是不能很好地 handle KDE 的一些窗口,针对这个问题,存在一些 fork,不过没有进入主线(连 gaps 都进不了主线你还想进?),热度也不高。 目前还处于活跃维护状态的分支是 PJK136/i3,它和它之前的 fork 的 kde-master 分支是基于原版 i3 的,PJK136 自己搞了一个合并了 i3-windows-icons 分支(标题栏显示图标等)的分支。 我喜欢无窗口边框的设计,因此间距肯定是必要的。我尝试了合并它和 i3-gaps,目前使用没有发现大问题,使用效果非常好。后面我会尽量维护更新这个分支(绝对不咕.jpg)。 项目地址 h0cheung/i3-gaps-kde,AUR 已打包:i3-gaps-kde-git。当然你也可以使用 PJK136 的版本,他的 kde-wm-icons-master 分支 repo 中提供了 PKGBUILD。 下面是一个简单的使用方法 如何使用 patched i3 基本配置 首先安装 KDE 和 i3,i3 的 patched 版本上面已介绍。 KDE 提供了一个环境变量 KDEWM 来指定窗口管理器,当然默认是 kwin。我们只需在 KDE 桌面启动前设置它为 i3 即可。最简单的方法(per user),写一个设置环境变量的脚本,比如用 sh 就像这样: 1 2 #!/bin/sh export KDEWM=/usr/bin/i3 给予执行权限,然后在 KDE 的设置 → 开机与关机 → 自动启动中加入该脚本,并设置为在会话启动之前即可。 从 5.20 版本开始,该功能似乎已经被移除,可以根据使用 pam、dm、xinit 等方法设置该环境变量,详见各发行版或相关工具的文档。当然你也可以写一个 session,这样可以在 dm 里面选择是否用 i3。 经过 patch 的 i3 可以直接用于 KDE,不再需要各种 wiki、论坛上推荐的那堆配置,KDE 的桌面壁纸也可正常使用。对于通知等窗口偷焦点等小问题,简单地加入少量配置即可,比如: 1 2 3 4 5 6 7 8 # disable focus no_focus [class="plasmashell"] no_focus [window_role="pop-up"] no_focus [window_type="notification"] focus_on_window_activation none focus_follows_mouse no mouse_warping none 快捷键 i3 的快捷键是直接由 i3 主程序控制的,建议用 i3 设置快捷键,不在 KDE 中设置。 i3 默认的快捷键基本都是比较好用的,像我就只改了 dmenu 为 krunner,方向键设置为 hjkl,再把冲突的 h 处理了一下。 具体可见我的 dotfiles。 混成器 首先,KDE 自带的混成器是集成在 kwin 中的,不用 kwin 就别考虑了。i3 和 bspwm,以及绝大多数 Xorg 下的窗口管理器也都没有自带混成器。(Wayland 下 WM 必须带混成功能) Linux 下独立的混成器其实并不多,主流的就一个多次被 fork 改名的,它最新最主流的版本叫 picom,这个混成器主要的优势就是自定义较为强大。 个人的设置主要是关闭 shadow,backend 用 glx,设置了一些透明效果的规则(除了一小部分单独设置,其余窗口都 15% 透明),等等。 当然这部分配置个人喜好不同,可以自行找相关资料(关于 compton 的资料也能参考,大部分兼容)并编写自己的配置。 后记 虽然我做了很多折腾,本文中也提到了多种方法,但是最希望出现的情况还是 kwin 可以支持 Tiling 模式。 另外,StumpWM 等窗口管理器据说也可以较好地配合 KDE,可以考虑尝试。

2020/7/28
articleCard.readMore

fcitx5 简评和使用方法

简单介绍 其实 fcitx5 已经被偷偷开发了很久了,但是进度比较缓慢。不过,现在的 fcitx5 也已经基本可用。cn 源里的 fcitx5-config-qt-git 包和官方源里的 kcm-fcitx5 都解决了对 KDE 过度依赖的问题(自己编译的话依赖还是很多)。日常使用也没有太大的问题,有兴趣的可以尝试。 安装使用 Arch Linux 用户可以使用 cn 源里的版本(-git 结尾),或官方源里的版本,前者更新打包极其频繁,后者则相对少一些。另外,后者没有 qt4 模块,cn 源和 AUR 也没有可以和它一起使用的 qt4 模块,或许可以自己改 fcitx5-qt4-git 的依赖试试(那干嘛不直接用前者)。 需要安装的包基本上和 fcitx 的类似,我自己安装了的有 fcitx5-chinese-addons-git,fcitx5-git,fcitx5-gtk-git,fcitx5-qt4-git,fcitx5-qt5-git,fcitx5-rime-git,kcm-fcitx5-git。 主要区别就配置工具换成了 kcm-fcitx5(git 版本非 KDE 用户用 fcitx5-config-qt-git)。 其他发行版用户先看源里有没有,没有的话可以尝试找第三方源,或者自己编译/解包后打包并安装。 装上之后用你喜欢的方式设置如下环境变量(由于漏洞的原因,.pam_environment 将不再默认读取,可以根据你的情况考虑 .xprofile、.profile、.zshenv 等): 1 2 3 GTK_IM_MODULE=fcitx QT_IM_MODULE=fcitx XMODIFIERS=@im=fcitx 拼音输入 fcitx5 的自带拼音有一定改进,同时也提供了 rime。rime 仍然不能使用云拼音。至于想使用搜狗输入法的,建议点击右上角的$\times$。 对于两个拼音输入的比较,个人认为新的自带拼音好于 rime 明月拼音简化字的默认配置,并且也提供了不少常用的自定义选项。如果你不怎么在乎隐私,或者对云拼音提供商(目前支持咕果/咕果CN/百毒)很信任的话,使用云拼音会有不错的体验。不过重度折腾下还是 rime 可定制性最好,并且跨平台同步折腾好之后效果也不错。 拼音推荐设置: 启用预测 启用颜文字 云拼音看自己需要 Show preedit within application(单行显示) 快捷键自己设置 词典:可适当导入搜狗词库 rime 的话 fcitx5 提供的额外设置就一个单行显示,建议打开。 主题美化 fcitx5 的主题有一定变化,fcitx 的主题不能直接使用,而 fcitx5 的主题很少。 一种选择是使用 kimpanel,不使用的话,推荐 hosxy/Fcitx5-Material-Color,比较简洁,风格类似微软拼音,配色为 Material,建议配合单行模式,建议打开“按屏幕 DPI 使用”,然后把字体调小到合适的程度。 fcitx5-remote fcitx5-remote 的行为与 fcitx-remote 没有明显区别,使用此功能的工具只需修改调用的命令即可。如果命令是硬编码的,不想改源码可以直接 ln -s。

2020/4/29
articleCard.readMore

使用 clash 和路由表实现透明代理

本文内容如有错误,请在评论区指出 需要的工具 要进行全局代理,常见的方式是转发到特定端口、使用 tun 虚拟设备和使用 TPROXY。clash 有 redir port 可以直接转发实现代理,但是只支持 TCP,IPv6 的支持也尚未合并。TPROXY 是 v2ray-core 的推荐方式。而另一种方式则是 tun。本文介绍的是使用 tun 的方式。 要使用这种方式,首先需要一个使用 tun 转发流量的工具,目前 clash 主线并不支持 tun,clash 作者有一个支持 tun 的闭源版本,另外 comzyh 有一个支持 tun 的分支,性能不错,稳定性一般。然后有一个通用的工具 tun2socks,稳定性较好但性能一般。后两者都不错。 comzyh 的 clash 分支 go-tun2socks 需要的知识 基本的路由表、iptables 操作 设置转发路由 首先需要使用 ip tuntap add <tun name> mode tun user <tun user> 创建一个 tun 设备,并使用 ip link set <tun name> up 启用。 然后设置路由 ip address replace <tun address> dev <tun name>,tun address 取一个不常用的 IP 段,比如 kr328 的脚本中使用 172.31.255.253/30,又比如 go-tun2socks 默认的 10.255.0.1/24。 然后设置路由规则 1 2 ip route replace default dev <tun name> table <route table id> ip rule add fwmark <fwmark id> lookup <route table id> 把有特定 fwmark 标记的流量路由到 tun 其中 fwmark 号和 route 表号可以自行设定(不要和其他的规则重复)。 然后用 iptables 在 mangle 表上,把需要代理的流量打上 fwmark 标记,使用 -j MARK --set-mark <fwmark id> 即可 绕过部分流量 利用 iptables 支持的丰富规则,我们可以灵活地绕过各种流量,列出几个比较常用的: -m owner --uid-owner <username or uid> 匹配某个用户的流量,类似的还有 --gid-owner -p <'tcp', 'udp' or 'icmp'> 匹配某种类型流量 --dport <port num> 匹配目的端口,类似的 --sport 匹配源端口,需要和 -p tcp 等连用 -d <network name, hostname, subnet(IP CIDR) or IP> 匹配目的网络/主机名/子网/IP -m set --match-set <ipset name> <'dst' or 'src'> 匹配 ipset,可以用于简化规则,ipset 用法自行搜索 -m cgroup --cgroup <cgroup id> 匹配 cgroup 有较多规则时可以创建规则链,并把 OUTPUT 或 PREROUTING 链中的流量转入该链(ip6tables 也一样): 1 2 3 iptables -t mangle -N <chain name> iptables -t mangle -F <chain name> iptables -t mangle -I OUTPUT -j <chain name> 然后在规则链上编写规则即可,需要打标记走代理就 -j MARK --set-mark <fwmark id>,需要绕过就 -j RETURN,一般建议先写绕过规则,最后无条件地打标记。注意一定要绕过 clash 的流量(建议 uid/gid 或 cgroup) 使用 cgroup net_cls cgroup 是 Linux 内核的一个功能,这里需要用到它的 net_cls 子系统。使用起来简单,我们首先运行以下命令创建一个组: 1 2 3 mkdir -p /sys/fs/cgroup/net_cls/<cgroup name> echo <cgroup id> > /sys/fs/cgroup/net_cls/bypass_proxy/net_cls.classid chmod 666 /sys/fs/cgroup/net_cls/<cgroup name>/tasks 上述命令可以在开机时运行 这样,如果一个进程的 pid 在 /sys/fs/cgroup/net_cls/<cgroup name>/tasks 中,它就会被 iptables 的 -m cgroup --cgroup <cgroup id> 规则匹配到,并且这些进程的子进程 pid 会自动被添加。如果对安全比较敏感,你可以对该文件进行适当的权限控制。 可以使用一个脚本,以它作为 wrapper 来运行需要的命令 1 2 3 #!/bin/bash echo $$ > /sys/fs/cgroup/net_cls/<cgroup name>/tasks exec "$@" 单独转发 DNS 与 v2ray 不同,clash 不能通过其规则把流量转到 clash 的内置 DNS,且 clash 不支持 sni 解析域名,需要内置 DNS 反查域名。 因此,需要使用 nat 表把 DNS 流量转发到 clash 的 DNS 端口,对 UDP 53 端口的流量,设置适当的绕过规则过滤后,-j REDIRECT --to-ports <clash dns port> 即可。 tun 工具配置 comzyh 的 clash 分支需要在配置文件中设置以下内容 1 2 3 tun: enable: true device-url: dev://<tun name> go-tun2socks 用以下命令启动 tun2socks -tunName <tun name> -proxyServer <clash socks server> -tunAddr <tun address> -tunGw <tun gateway> -tunMask <tun mask> -tunPersist -blockOutsideDns 选一个不常用的 subnet,并取该 subnet 内两个不同地址作为 tun gateway 和 tun address。 比如要使用 172.31.255.253/30 子网。则 tun mask 为 255.255.255.252,tun gateway 可以用 172.31.255.253,tun address 可以用 172.31.255.254,默认参数对应的子网是 10.255.0.1/24

2020/4/28
articleCard.readMore

记2019第二轮暑期集训

八月,炎热的天气已有退缩之意,我们的激情却才刚刚开始。经过了第一轮集训后的短暂休息,我们很快便迎来了更为激烈的第二轮集训。这里我们不再是单枪匹马地战斗,而是开始了三人一队,模拟ICPC比赛的训练。训练规则很简单,参加集训的27名新人被分成9支队伍,每天一场模拟赛,根据排名计算rating,总rating的计算规则似乎是参照Topcoder,最后预计有6个名额给我们(4个ICPC+2个CCPC,可能会多或者少一两个,新人每队只能拿一个)。按照三人的第一轮集训rating,我们队伍的排名应该在第7。早期我们算是进入状态比较快的队伍,再加上我们三人也算是各有所长,所以成绩还算不错,虽然有过一次垫底,但最好的时候有两次第一。当时信心十足的我们一直在努力争取ICPC的机会。   训练虽然有时很累,常常下午比赛,晚上学习算法知识,深夜时不时还有codeforces。但我们都一直在坚持,谁都不想让自己和队友付出的努力白费。训练似乎已经成为一种习惯,成为我们这个暑期生活中的一部分。   但是,和我们一起训练的同学们真的都非常强,很多队伍的成绩都一直在上升,而我们队却似乎有些停滞不前,最为关键的最后一段时间表现也很一般。在倒数第二场训练中,由于对文件输入输出的不熟悉和字符串的软肋等,我们再次垫底。最后我们的排名到了能否拿CCPC资格的边缘。现在我们已经停止了每天一场的训练赛,开始休息和准备后面的几场更为关键的网络赛。   我在这段时间的集训中,虽然状态有了一些提高,读题切题的速度也有了一些进步,但是自身的很多软肋方面一直进步非常缓慢。像字符串、数论等领域,我虽然最近都在学习,但还是完全处在非常差的水平。对于后面的几场网络赛,我也感到自己已经没有第一轮集训结束时的信心了,但我既然已经走到这一步,无论是为了队友还是自己,都应该尽力把自己能做的事做好。   其实现在回头想想,从大一下开始学算法,到现在短短不到半年时间就开始接触比赛,这节奏的确非常快,快到让我有些难以接受。也许我还需要时间,去静下心来慢慢学习很多知识、慢慢完善自己,以取得更好的成绩,但对于眼前的训练、比赛,我会拼尽自己的能力,努力做到最好。加油吧,失败不属于我们!

2019/8/26
articleCard.readMore

这一次要把自己以前所有没用到的倔强都用完,把所有的半途而废都给弥补上 ── 记2019第一轮暑期集训

我还记得刚进入大学时,我几乎只会“Hello World”,当时在学校组织的ACM趣味赛中也被打到自闭。到了下学期之后,我才勉强理解简单的递归。在刚开始上ACM通识课那会儿,我甚至问过老师能不能等大二再考虑接触暑期前集训。到了校赛的时候,我看着紫书越看越自闭,三个星期的准备收效甚微,最后基本靠队友,只拿了最简单的签到题。那时我看着很多复杂的算法,一度怀疑自己是否能够学下去,能否追上别人的脚步,也一度怀疑自己花的时间是否值得,做别的选择会不会更好。   还好我后面还是参加了暑期前集训,当时每天都在举步维艰地学习各种基础知识,每次都被很多有基础的同学碾压。很多内容对我来说难到让人想要放弃,其中有一部分我到现在都还没有学会。在这个过程中我也常常担心进不了暑期集训,担心自己做过的一切都会付诸东流。这段时间我也开始接触codeforces,刚开始打的时候一直在掉分,好在掉到1375之后终于开始了回升。   在这个暑假,我顺利地进入了集训,开始了每日的训练赛。我尽力调整好状态,尽量找签到题和自己比较擅长的思维方面的题目入手,有时取得了还算不错的成绩,但有时状态不佳稍有失误就被挤到了40+名,更多的时候则是成绩平平。在整个训练过程中,无论是过题数还是罚时,我都很少能取得优势。这段时间,我几乎每天都很晚睡觉,一方面我有时会一直写题到晚上,另一方面我也开始经常失眠。在这段时间的训练中,我感觉自己解题的思维逐渐变得清晰,编码能力也在逐步提高,codeforces打到了1644,并且还可能继续上涨。我的名次到了中间靠前的位置,我也开始憧憬我以前从来没有敢想的第二轮集训,但与此同时,我开始越来越担心自己会不会最后又掉出去,尤其是最后一场,几乎整个人都在高度紧张中,直到以很低的罚时过了能做的题之后才长舒了一口气。最后,我还是幸运地保持了自己的名次。   我很高兴自己能够有机会继续参加集训,继续做自己想做的事,继续努力奋斗。我不会再去怀疑自己的能力,也不会再去怀疑自己所做的事。以后的训练也不再是一个人的战斗,而是一个队伍的共同进步。在以前,现在,和以后的训练中,也许最后我们很可能留不下什么成就,也不会被别人记住,但至少对我们自己来说,无论结果如何,这都是青春中一段美好的回忆吧。我曾浑浑噩噩地度过了近一年的时间,ACM训练让我重新燃起了斗志,让我愿意付出自己的时间和努力,去学习,去拼搏。能够进入第二轮的训练,是我最幸运的事,我也很希望我能够走得更长、更远。

2019/7/28
articleCard.readMore

实用刷题、线上赛C++配置(VSCode + ccls)

写不动题了,来写篇小文章放松一下,记录一下自己的配置,也供读者参考。 注:运行环境为Arch Linux 需要安装的包 gcc 如果不用pbds等,可以直接用clang编译,和gcc的差异基本上只有少量UB,__int128也是支持的,不过Linux基本上不能不装gcc llvm(clang) 除了作为一个编译器之外,还提供强大的自动补全、代码检查、代码格式化等相关工具,clion、qtcreator等都依赖它 ccls 基于clang的LSP(language server protocol),完成度比clang的类似组件clangd更高。注意:更新clang后需要用新版本的clang重新编译ccls,否则可能会出现各种问题(包括完全挂掉) visual-studio-code 体验不错,插件生态非常好的编辑器 插件列表 ccls 核心插件,提供语法检查、补全、高亮、变量重命名、code lens等等 clang-format ccls的提供的格式化偶尔会抽风,单独用这个比较稳定 Code Runner 一键编译运行 easy-snippet 非常方便的snippet管理工具,可以用来存板子 CodeLLDB 可选,整合lldb,偏好gdb的用户可以不装 Vim 可选,vim模拟 ICIE 拉取codeforces上面的题目和样例,并根据通用模板生成文件,并且可以一键测试,通过后直接submit,现在已经支持直接拉取正在进行或即将进行的比赛,除了hack和看榜之外可以不用打开浏览器 Better Online Judge 和前者类似,用于vjudge上的比赛,功能较弱,拉取不了样例 vscode-clangd 备用,比如在睡前更新clang后忘记重编译ccls,第二天开始打了发现ccls炸了,可以把clangd拉出来救急(亲身经历)。目前它的功能略弱于ccls,但毕竟是llvm官方的组件,不排除以后会赶超 注:如果使用第三方调试工具的话,不用安装微软官方的cpptools插件 建议修改的设置 ccls 可以调整自动补全的case sensitivity(大小写敏感)和detailed label(补全时显示标准库函数的详细功能)。高亮部分可以根据自己喜好调整,建议打开type aliases等 Code Runner 可以自己改运行命令,以下供参考: 1 2 "c": "cd $dir && gcc -g $fileName -o /tmp/ctest && time /tmp/ctest", "cpp": "cd $dir && g++ -g $fileName -o /tmp/cpptest && time /tmp/cpptest", Vim VSCode的vim插件功能挺多的,尤其是支持neovim做后端,以下设置供参考 1 2 3 4 5 6 7 8 9 10 11 12 "vim.easymotion": true, "vim.enableNeovim": true, "vim.sneak": true, "vim.sneakUseIgnorecaseAndSmartcase": true, "vim.statusBarColorControl": true, "vim.autoSwitchInputMethod.enable": true, "vim.autoSwitchInputMethod.defaultIM": "1", "vim.autoSwitchInputMethod.obtainIMCmd": "/usr/bin/fcitx-remote", "vim.autoSwitchInputMethod.switchIMCmd": "/usr/bin/fcitx-remote -t {im}", "vim.foldfix": true, "vim.neovimPath": "/usr/bin/nvim", "vim.useSystemClipboard": true, ICIE "icie.template.list"下写你的通用模板的文件路径 Better Online Judge 这个插件的通用模板是硬编码的,直接修改~/.vscode/extensions/yiyangxu.better-oj-<版本号>/out/utils/template.js

2019/7/24
articleCard.readMore

2019年电子科技大学ACM暑期前集训图论专题解题报告

A 这题要求调整部分边的方向,把有环的有向图中部分边反向,使其变成无环图,我们要求的是调整的边的最大权值最小的方案。首先我们要知道一个原理,如果无环图加入$A \to B$会使其变为有环图,那么$B\to A$一定是连通的,那么加入边$B\to A$不会使其成环。那么,我们删掉某些边得到无环图之后,必然可以加回来得到无环图。而如果我们删除权值不大于ans的所有边,我们可以很快地算出是否有环。如果有环,则ans一定小于答案,如果无环,则ans不小于答案。根据这一性质,我们可以二分套判环解决,判环使用拓扑方法,复杂度为$O((m+n)logM)M$可以是最大边权,也可以是边权的中位数($O(n)$找中位数要多写不少代码,并且这题不必要) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct E { int v, w; }; int main() { ios::sync_with_stdio(0); cin.tie(0); int n, m, M = 0; cin >> n >> m; vector<E> path[n + 1]; FOR(i, 0, m) { int u, v, w; cin >> u >> v >> w; M = max(M, w); path[u].push_back({v, w}); } int ans = M / 2; int tmp[2] = {0, M}; do { vector<E> pathcpy[n + 1]; int cnt[n + 1]; RST(cnt); FOR(i, 1, n + 1) for (auto p : path[i]) if (p.w > ans) { pathcpy[i].push_back(p); ++cnt[p.v]; } queue<int> q; FOR(i, 1, n + 1) if (!cnt[i]) q.push(i); int c = 0; while (!q.empty()) { ++c; int now = q.front(); q.pop(); for (auto p : pathcpy[now]) { --cnt[p.v]; if (!cnt[p.v]) q.push(p.v); } } tmp[c == n] = ans + (c != n);//有环时答案大于ans,可以从ans+1开始 ans = ((ll)tmp[0] + tmp[1]) / 2; } while (tmp[1] != tmp[0]); cout << ans; } B 这题是求免费一条边的最小生成树权值和,以及要使权值和等于该值的情况下,可以选择的免费边的数量。首先,最小生成树的算法这里不再累述,kruskal算法即可。然后根据贪心的思想易知第一个问题的答案为最小生成树总权值-最小生成树上最大边权值。然后难点就在于第二个问题。树上权值最大的边(可能有多条相等)如果能被另一条边替代,那么替代后免费新的边,也能得到相同的结果。所以我们要找到可以替换树上最大边的边。我们可以把最大边去掉(第一次并查集记录最大边条数,第二次并查集参考G题),这样树就出现了中断,然后我们需要寻找能够把中断补上的边。我们沿用kruskal算法的并查集,即可轻松判断一条边是否能补上中断。遍历一遍边即可,由最小生成树的性质,小边不可能替代大边,所以权值小于树上最大边的边可以直接略过。 最大部分的时间复杂度就是kruskal的时间复杂度,即$O(mlogm+mA(n)+n)$,其实复杂度最大的地方就是排序边的O(mlogm) 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) int fa[100001], depth[100001]; int find(int x) { if (fa[x] == x) return x; return fa[x] = find(fa[x]); } void connect(int u, int v) { u = find(u), v = find(v); if (depth[u] > depth[v]) fa[v] = u; else { fa[u] = v; if (depth[v] == depth[u]) ++depth[v]; } } struct E { int u = 0, v = 0, w = 0; bool operator<(E x) const { return w < x.w; } }; E edge[200005]; int main() { ios::sync_with_stdio(0); cin.tie(0); int M = 0, n, m, i = 0, cm = 0; ll S = 0; cin >> n >> m; for (int i = 1; i <= n; ++i) fa[i] = i; for (int i = 0; i < m; ++i) { int u, v, w; cin >> u >> v >> w; edge[i] = {u, v, w}; } sort(edge, edge + m); for (auto p : edge)//第一次并查集 if (i == n - 1) break; else if (find(p.u) == find(p.v)) continue; else { ++i; connect(p.u, p.v); S += p.w; if (p.w > M) { M = p.w; cm = 1; } else if (p.w == M) ++cm;//记录树上最大边的条数 } for (int i = 1; i <= n; ++i) fa[i] = i; RST(depth); int cnt = i = 0; for (auto p : edge) if (i == n - 1 - cm)//跳过树上最大边 break; else if (find(p.u) == find(p.v)) continue; else { ++i; connect(p.u, p.v); } for (auto p : edge)//如果想进一步优化,可以保留第二次并查集的最后位置,然后从该位置-cm开始 if (p.w >= M && find(p.u) != find(p.v)) ++cnt; cout << S - M << ' ' << cnt; } C 该题判断是否有欧拉路径,然后输出字典序最小的欧拉路径。判断欧拉路径根据出入度,所有点入度等于出度则有欧拉回路,入度比出度大一的点和出度比入度大一的点各一个(必须两者都有一个)则有欧拉路径(非回路)。然后是输出部分,如果是回路,要从有路的最小点作为起点。然后可以用一个dfs,从小的边开始搜,回溯的时候即可得到反向的欧拉路径,再反向输出即可。由于路径可能很长,搜索深度可能很深,dfs递归会发生栈溢出,可以加inline内联交给GNU的强大力量去解决(这样写甚至跑起来更快),也可以写非递归版的dfs(码量稍大)。复杂度$O(m)$ 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) int n, m, ans[2000005], pos = 0, cnt = 0; vector<int> path[1000005]; inline void dfs(int s) {//递归版 while (!path[s].empty()) { int p = path[s].back(); path[s].pop_back(); dfs(p); ans[cnt++] = p; } } void dfs(int s) {//非递归版 int stack[2000005], top = 0; stack[++top] = s; while (top) { int x = stack[top]; if (path[x].empty()) { top--; ans[cnt++] = x; } else { stack[++top] = path[x].back(); path[x].pop_back(); } } } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; int i[n + 1], o[n + 1]; RST(i); RST(o); FOR(j, 0, m) { int u, v; cin >> u >> v; path[u].push_back(v); ++i[v], ++o[u]; } int s = 0, t = 0; FOR(j, 1, n + 1) { if (o[j] == i[j]) continue; else if (o[j] - i[j] == 1) { if (s) goto shame; s = j; } else if (o[j] - i[j] == -1) { if (t) goto shame; t = j; } else goto shame; } if ((bool)s ^ (bool)t) goto shame; for (auto &p : path) sort(p.begin(), p.end(), greater<int>()); if (!s) while (!o[++s]) ; dfs(s); //递归版起点要单独输出一下 cout << s << ' '; while (cnt) cout << ans[--cnt] << ' '; return 0; shame: cout << "What a shame!"; } F 这题要求的是在一个有向,可能有环的正点权图上获取最大值。如果它是无环的,那么以终点的权作为边权求最长路即可(无环图不会到达一个点多次),因此,我们就考虑怎么处理环。这样就引入了缩点的概念(据说暴力也可以过?),为了获取最大权,进入环中一个点后必然会把整个环吃完,然后从中选择合适的点出环。根据这一性质,我们可以找到图中的环(不一定是单层的环,标准的名称为强联通分量),把每一个环缩成一个点,权值为各点权值之和,环内点与环外点的路径都加在缩成的点上。实现的方法有Kosaraju、Tarjan(没错,又是这位图灵奖巨佬)等。本题以Kosaraju为例,从起点开始进行一次dfs,回溯时按顺序记录点,再在反向图上,按之前的记录逆序一次dfs,在第i次反向dfs中初次找到的点(当然,正向dfs中不可达的点要排除)就构成了第i个强联通分量。然后建立新图,按一开始说的无环图做法即可,正权图最长路相当于负权图最短路,使用spfa算法求解。Kosaraju的复杂度通常为$O(n+m)$,时间瓶颈在于spfa,复杂度最坏可达$O(mn)$ 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct E { int v, l; }; vector<int> path[3001], repath[3001]; int m, n, l[3001], dfn[3001], pos = 0, vis[3001], revis[3001], f[3001], point[3001], s, p[3001], dis[3001]; vector<E> fpath[3001]; inline void dfs(int now) {//正向dfs vis[now] = 1; for (auto &p : path[now]) if (!vis[p]) dfs(p); dfn[pos++] = now; } inline void redfs(int now, int pos) {//反向dfs if (!vis[now] || revis[now]) return; revis[now] = 1; f[now] = pos; point[pos] += l[now]; for (auto &p : repath[now]) redfs(p, pos); } void spfa() { RST(dis); queue<int> q; dis[f[s]] = point[f[s]]; q.push(f[s]); bool has[3001] = {0}; has[f[s]] = 1; while (!q.empty()) { int now = q.front(); q.pop(); has[now] = 0; for (auto p : fpath[now]) if (dis[p.v] < dis[now] + p.l) { dis[p.v] = dis[now] + p.l; if (!has[p.v]) { q.push(p.v); has[p.v] = 1; } } } } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; int u[m], v[m]; FOR(i, 0, m) { cin >> u[i] >> v[i]; path[u[i]].push_back(v[i]); repath[v[i]].push_back(u[i]);//反向图 } FOR(i, 1, n + 1) cin >> l[i]; int np; cin >> s >> np; FOR(i, 0, np) cin >> p[i]; RST(vis); dfs(s); int tmp = pos; pos = 0; RST(revis); for (int i = tmp - 1; i >= 0; --i) redfs(dfn[i], pos++); FOR(i, 0, m) if (f[u[i]] != f[v[i]]) fpath[f[u[i]]].push_back({f[v[i]], point[f[v[i]]]});//建立缩点后的新图 spfa(); int M = 0; FOR(i, 0, np) M = max(M, dis[f[p[i]]]); cout << M; } G 这是本次的签到题,解题方法是根据贪心的思想,按权值从小到大取边,同时采用并查集判断边是否可取,这种算法被称为 kruskal算法,复杂度为$O(mlogm+mA(n)+n)$,瓶颈是边排序的$O(mlogm)$ 代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 #include <bits/stdc++.h> using namespace std; int fa[1001], depth[1001]; int find(int x) { if (fa[x] == x) return x; return fa[x] = find(fa[x]); } void connect(int u, int v) { u = find(u), v = find(v); if (depth[u] > depth[v]) fa[v] = u; else { fa[u] = v; if (depth[v] == depth[u]) ++depth[v]; } } struct E { int u, v, w; bool operator<(E x) const { return w < x.w; } }; E edge[500000]; int main() { ios::sync_with_stdio(0); cin.tie(0); int n, m, k, pos = 0; cin >> n >> m >> k; for (int i = 1; i <= n; ++i) fa[i] = i; for (int i = 0; i < m; ++i) { int u, v, w; cin >> u >> v >> w; edge[i] = {u, v, w}; } sort(edge, edge + m); int result = 0; for (auto p : edge) if (n == k) break; else if (find(p.u) == find(p.v)) continue; else { connect(p.u, p.v); --n; result += p.w; } cout << result; } H 这题的思路没有什么好说的,很明显的网络流板题。下面简单说一下网络流(以后有时间可能会单独写一篇博客,也很可能会鸽) 网络流是一个有若干条带权(容量)边组成的,固定源点和汇点的有向图。本题要解决的是,从源点产生的流(本题中水流是个很形象的例子),经过各边汇集到汇点,这样的流的最大流量。显然,对于每个点,流入流出的量相等,总流量守恒;同时,每条边上的流量均不能超过该条边的容量。在思考怎么求出最大流的过程中,我们要了解一个非常重要的定理──增广路定理。当我们找到网络流中一条从源点到汇点的通路时,可以把这条通路的各段边的容量减少一个值 $a$,最好的情况下下,这样处理后的网络的最大流就减少了 $a$,这样的网络我们称之为残量(残留容量)网络,当残量网络中没有上述通路时,残量网络的最大流为0,原网络的最大流为$\sum a$,我们可以利用dfs找通路,$a$ 取路径上的最小残量,不断循环得出结果。 然而,这样做会存在一些问题 1.每次找到的路径可以是最好的路连接径吗?似乎不是。这种情况我们在数据结构专题的K题(点此进入)中遇到过,当时我们在删点的时候加入新的点来模拟反悔的操作。这里我们也可以加入反向边来模拟反悔的操作。 2.这样反复的dfs很耗时,可能会反复搜某一条边,尤其是各边容量的极差很大时。该算法称为Ford-Fulkerson算法,复杂度为$O(mC)$,通常只建议用来处理容量很小的图,如单位容量简单网络最大流的快速算法(其实我并不会)的其中部分步骤可以用它。可以采用bfs,即Edmonds-Karp算法来改进,复杂度为$O(nm^2)$。但是EK算法过这题似乎有点紧,有没有更快的做法?dinic、ISAP、等(其实两者原理很像,优化魔改之后界限并不是那么清晰)。 我们把所有的节点根据与源点的距离分层,找增广路时只取依层递进的路径,然后再重新分层,反复循环。复杂度最大为$O(mn^2)$(较松,还能加优化),具体实现如下(注意有注释的地方) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 #include <bits/stdc++.h> using namespace std; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct edge { int e, c; }; int n, m, cur[201], tail = 0, depth[201]; bool visited[201]; edge edges[20001]; vector<int> path[201]; inline void addedge(int s, int e, int c) {//建图时把反向边位置也分配好,并且分配到正向边位置^1,便于快速找反向边 edges[tail] = {e, c}; path[s].push_back(tail++); edges[tail] = {s, 0}; path[e].push_back(tail++); } bool dinic_bfs() {//通过bfs,逐层外扩,实现分层 RST(depth); RST(visited); visited[1] = 1; queue<int> q; q.push(1); while (!q.empty()) { int now = q.front(); q.pop(); for (auto p : path[now]) { if (!visited[edges[p].e] && edges[p].c) { depth[edges[p].e] = depth[now] + 1; q.push(edges[p].e); visited[edges[p].e] = 1; } } } return visited[n];//如果汇点已不可达,说明已无增广路,则结束循环,否则继续 } int dinic_dfs(int now, int cap) { if (now == n || !cap) return cap; for (auto &i = cur[now]; i < path[now].size(); ++i) {//加入cur数组实现当前弧优化,搜到每条点时之前已被搜过的点不会搜出新的增广路路径,因此可以直接跳过。 auto &p = path[now][i]; if (depth[edges[p].e] == depth[now] + 1 && edges[p].c) { int tmp = dinic_dfs(edges[p].e, min(cap, edges[p].c)); edges[p].c -= tmp; edges[p ^ 1].c += tmp;//反向边加上相同的值,后面可以走这条反向边来反悔之前选的边 if (tmp) return tmp;//找到有值的增广路,返回 } } depth[now] = -1; return 0; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; while (m--) { int s, e, c; cin >> s >> e >> c; addedge(s, e, c); }//首先建图 int result = 0; while (dinic_bfs()) {//然后开始跑分层 RST(cur); int tmp; while ((tmp = dinic_dfs(1, INT_MAX >> 2))) result += tmp;//再在图中跑符合要求的增广路,找不到了就重新分层 } cout << result; } I 这是一道很裸的有向图(无向图拆边,然后做法也一样)K短路问题,我们可以采用一个方向最短路算法初始化(dijkstra,spfa什么的都可以,时间很松),一个方向A*启发式搜索(这类题是$h(x)=h*(x)$的特例)的方法解题。最短路算法不再多说,对于A*算法,我们用一个优先队列,从起点开始,每次扩展其直接可达的边入队,这样的过程中,跑出i次第i短路时,它可以扩展出i+1次第i+1短路,由此,当某条边出队K次时可以终止,此时的路径长度就是第k短路的长度。复杂度(貌似)$O(kmlogm)$ 代码及注释 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #include <bits/stdc++.h> using namespace std; using ll = long long; struct edge { int v; ll w; }; struct status { int p; ll l; bool operator<(status b) const { return l > b.l; } }; struct astar { int v; ll g, f; bool operator<(astar b) const { return f == b.f ? g > b.g : f > b.f; } }; int main() { ios::sync_with_stdio(0); cin.tie(0); int n, m, k, s, t; cin >> n >> m >> k >> s >> t; vector<edge> path[n + 1], repath[n + 1]; ll dis[n + 1]; int visited[n + 1]; for (int i = 0; i < m; ++i) {//建立正向图和反向图 int u, v, w; cin >> u >> v >> w; path[u].push_back({v, w}); repath[v].push_back({u, w}); } memset(dis, 0x3f, sizeof(dis)); memset(visited, 0, sizeof(visited)); priority_queue<status> q; dis[t] = 0; q.push({t, 0}); while (!q.empty()) {//反向dijkstra算法求最短路,预处理出h(x) status now = q.top(); q.pop(); if (visited[now.p]) continue; visited[now.p] = 1; for (auto &p : repath[now.p]) { if (dis[p.v] > dis[now.p] + p.w) { dis[p.v] = dis[now.p] + p.w; q.push({p.v, dis[p.v]}); } } } memset(visited, 0, sizeof(visited)); priority_queue<astar> paths;//然后正向通过A*算法得出结果 paths.push({s, 0, dis[s]}); while (!paths.empty()) { astar now = paths.top(); paths.pop(); if (++visited[now.v] == k && now.v == t) { cout << now.f; return 0; }; if (visited[now.v] > k) continue; for (auto p : path[now.v]) paths.push({p.v, p.w + now.g, p.w + now.g + dis[p.v]}); } cout << -1;//跑完之后仍找不到,则说明没有 } J 这道题目给了我们一个二分图,然后希望求一个最小的点的数量,来覆盖这一个二分图的所有边。我们求出最大匹配,在每个匹配的其中一个点上放置一个点即可覆盖,因此该题实际上是求最大匹配数,我们把二分图各边的容量设为1,然后加入源点s并依次连至左边的元素,右边的元素依次连至汇点t,该问题便转化为了一个网络流问题,而且是单位容量的简单网络流。对于这种特殊情况的网络流,可使用Hopcroft算法达到$O(m\sqrt n)$的复杂度,使用常规的dinic算法也可以平稳通过。以下代码为dinic算法(具体做法见H题) 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 #include <bits/stdc++.h> using namespace std; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct edge { int e, c; }; int a, b, m, cur[200002], tail = 0, depth[200002], End; bool visited[200002]; edge edges[500000]; vector<int> paths[200002]; inline void addedge(int s, int e, int c) { edges[tail] = {e, c}; paths[s].push_back(tail++); edges[tail] = {s, 0}; paths[e].push_back(tail++); } bool dinic_bfs() { RST(depth); RST(visited); *visited = 1; queue<int> q; q.push(0); while (!q.empty()) { int now = q.front(); q.pop(); for (auto p : paths[now]) { if (!visited[edges[p].e] && edges[p].c) { depth[edges[p].e] = depth[now] + 1; q.push(edges[p].e); visited[edges[p].e] = 1; } } } return visited[End]; } int dinic_dfs(int now, int cap) { if (now == End || !cap) return cap; for (auto &i = cur[now]; i < paths[now].size(); ++i) { auto &p = paths[now][i]; if (depth[edges[p].e] == depth[now] + 1 && edges[p].c) { int tmp = dinic_dfs(edges[p].e, min(cap, edges[p].c)); edges[p].c -= tmp; edges[p ^ 1].c += tmp; if (tmp) return tmp; } } depth[now] = -1; return 0; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> a >> b >> m; End = a + b + 1;//0为源点,1到a+b为二分图中点,a+b+1为汇点 while (m--) { int s, e; cin >> s >> e; addedge(s, e + a, 1); } FOR(i, 1, a + 1) addedge(0, i, 1); FOR(i, 1, b + 1) addedge(i + a, End, 1);//建好图,后面就搬H题过来了 int result = 0; while (dinic_bfs()) { RST(cur); int tmp; while ((tmp = dinic_dfs(0, INT_MAX >> 2))) result += tmp; } cout << result; } K 这题求的是免费K条边的最短路。我们的一个容易想到的直观的思路是做分层图,把已经免费的次数作为一个维度建图,但是如果完整的建完整张图,会导致内存超出,而分层图中存在大量的重复边,可以都从第一层取边,然后再加上跨层的边。但我觉得这题更好的思路是,最短路算法本质上是一个一维状态的动态规划(dijkstra偏向于贪心,是bfs的扩展,但其本质上仍然是动态规划的一种),而这题我们需要的是一个二维的动态规划。我们可以按照最短路算法思路,在其基础上使用位置+已免费边数的状态替换位置状态,在状态转移的过程中,由最短路算法的$dis[q]=min{dis[p]+w(p\to q)}$,拓展为$dis[q][h]=min({dis[p][h]+w(p\to q)},{dis[p][h-1]})$,p为所有存在边$p\to q$的点。然后该方程的性质仍然满足dijkstra的要求(转移过程中$dis[q][h]$不小于$dis[p][h]$和$dis[p][h-1]$),因此可以按照dijkstra的思路,用修改版的dijkstra求解。复杂度$O(kmlogkn)$。spfa的话,直接交貌似会TLE,SLF优化貌似对这种情况效果比较好。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) int s, t, k; ll dis[10005][25]; struct status {//原版dijstra是位置+当前位置的最短路,这里增加来b表示免费的次数 int a = 0, b = 0; ll dis; bool operator<(status x) const { return dis > x.dis; }//注意这里dis要静态保存,去掉dis然后动态计算(return dis[a][b] > dis[x.a][x.b])会导致顺序出错,WA on 10 }; struct edge { int v = 0, w = 0; }; vector<edge> path[10005]; ll dijkstra() { RSTV(dis, 0x7f); priority_queue<status> q; for (auto &p : dis[s]) p = 0; q.push({s, 0}); bool visited[10005][25]; while (!q.empty()) { status now = q.top(); q.pop(); if (visited[now.a][now.b]) continue; visited[now.a][now.b] = 1; for (auto &p : path[now.a]) { if (dis[p.v][now.b] > dis[now.a][now.b] + p.w) { dis[p.v][now.b] = dis[now.a][now.b] + p.w; q.push({p.v, now.b, dis[p.v][now.b]}); }//第一种转移,同正常的dijkstra if (now.b < k && dis[p.v][now.b + 1] > dis[now.a][now.b]) { dis[p.v][now.b + 1] = dis[now.a][now.b]; q.push({p.v, now.b + 1, dis[p.v][now.b + 1]}); }//第二种转移,跨层,dis值不增加 } } return dis[t][k]; } signed main() {//建图,跑最短路,输出 ios::sync_with_stdio(0); cin.tie(0); int n, m; cin >> n >> m >> k >> s >> t; while (m--) { int u, v, w; cin >> u >> v >> w; path[u].push_back({v, w}); path[v].push_back({u, w}); } cout << dijkstra(); } L 这题有些复杂,购买物品需要花费,购买某个集合的所有物品可获得相应的奖励,答案要求是两者差最大,而不是限制最大花费。这样看起来比动态规划中01背包问题复杂得多。这种问题称为最大权闭合子图问题,我们从源点连接所有的物品,所有集合连向汇点,容量均取正。然后根据包含关系,用无限容量的边连接物品和集合。这样一来,就可以用割去某个物品到源点的边模拟购买此物品,某个集合被买完之后其到终点的流被切断。这样一来我们求一个割,即使得所有的边都被割断,所花费的最小代价,这样对于每个集合,要么获得并且花费了相应的代价,要么放弃了该集合。所有集合的总的压岁钱数量减去此时的割,就是他可以剩下的压岁钱。我们要求最大的剩余压岁钱,就要求最小割。显然,我们按照最大流的路径去割是最佳方案,这样最小割就等于最大流(最大流做法见H题)。复杂度$O((n+m)^2\sum k)$ 代码及注释 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct E { int e, c; }; int n, m, t, depth[20002], tail = 0, cur[20002]; E edges[250000]; bool visited[20002]; vector<int> paths[20002]; inline void addedge(int s, int e, int c) { edges[tail] = {e, c}; paths[s].push_back(tail++); edges[tail] = {s, 0}; paths[e].push_back(tail++); } bool dinic_bfs() { RST(depth); RST(visited); visited[0] = 1; queue<int> q; q.push(0); while (!q.empty()) { int now = q.front(); q.pop(); for (auto p : paths[now]) { if (!visited[edges[p].e] && edges[p].c) { depth[edges[p].e] = depth[now] + 1; q.push(edges[p].e); visited[edges[p].e] = 1; } } } return visited[t]; } int dinic_dfs(int now, int cap) { if (now == t || !cap) return cap; for (auto &i = cur[now]; i < paths[now].size(); ++i) { auto &p = paths[now][i]; if (depth[edges[p].e] == depth[now] + 1 && edges[p].c) { int tmp = dinic_dfs(edges[p].e, min(cap, edges[p].c)); edges[p].c -= tmp; edges[p ^ 1].c += tmp; if (tmp) return tmp; } } depth[now] = -1; return 0; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; FOR(i, 1, m + 1) { int tmp; cin >> tmp; addedge(0, i, tmp); } t = m + n + 1; int sum = 0; FOR(i, m + 1, t) { int c, k; cin >> c >> k; sum += c;//计算所有集合的压岁钱数量总和 addedge(i, t, c); while (k--) { int tmp; cin >> tmp; addedge(tmp, i, INT_MAX >> 1); } }//建图完,0为源点,1到m为物品,m+1到m+n为集合,m+n+1为汇点,然后跑最大流 int result = 0; while (dinic_bfs()) { RST(cur); int tmp; while ((tmp = dinic_dfs(0, INT_MAX >> 1))) result += tmp; } cout << sum - result;//输出结果 } M 这题是一道差分约束,某两个量存在一个大于关系,可以用一条权值为1(它们至少相差1)的有向边连接(小于则相反),等于就是权值为0的双向边,这样,然后从源点向每条边添加权值为最小值1的边,这样源点到每个点的最长路长度就是它的最小值,题目要求输出的就是这些值的和。如果有环就说明存在矛盾。带判环的spfa可以用来解决这个问题。复杂度$O(nm)$ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 #include <bits/stdc++.h> using namespace std; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct edge { int v, l; }; vector<edge> path[10001]; int dis[1001]; int spfa() { RSTV(dis, 0x80); queue<int> q; *dis = 0; int cnt[1001] = {0}; q.push(0); bool has[1001] = {1}; ++*cnt; while (!q.empty()) { int now = q.front(); q.pop(); has[now] = 0; for (auto p : path[now]) if (dis[p.v] < dis[now] + p.l) { dis[p.v] = dis[now] + p.l; if (!has[p.v]) { q.push(p.v); has[p.v] = 1; ++cnt[p.v]; if (cnt[p.v] > 1000) return 0; } } } return 1; } int main() { int n, m; ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; while (m--) { int a, b, o; cin >> o >> a >> b; switch (o) { case 1: path[b].push_back({a, 1}); break; case 2: path[a].push_back({b, 1}); break; case 3: path[a].push_back({b, 0}); path[b].push_back({a, 0}); } }//建图,过程如前面的描述 FOR(i, 1, n + 1) path->push_back({i, 1}); int result = 0; if (spfa()) { FOR(i, 1, n + 1) result += dis[i]; cout << result; } else//如果spfa跑失败了就输出-1 cout << -1; } N 这题,最少花费,可以让人联想到图上面的最短路,而问题的关键就是怎么建图,即找到满足要求的点(状态)和转移路径。状态的描述方式为二进制(参考状压dp。然后跑dijkstra即可。(点和边的判断方式见注释),复杂度貌似为$O(2^n+2^{2(n-\overline m)}+2^{n-\overline m}C_{n-\overline m}^{k-\overline m})$,$\overline m$是$m$减去$m$对cp关系中环的数量,似乎$n$很大(15),$m$很小(1)的数据可以卡掉(如有错误请指正) 代码及注释 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 #include <bits/stdc++.h> using namespace std; using ll = long long; using pii = pair<int, int>; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) struct E { int v, l; }; int pos = 0, n, m, k, point[2 << 16];//末位表示洁姐姐,然后从右到左依次表示其他人 vector<E> path[2 << 16]; pii against[15]; bool judge_p(int x) {//判断点(状态) if ((x & 1)) return 1;//如果洁姐姐在当然没问题 FOR(i, 0, m) if (x >> against[i].first & 1 && x >> against[i].second & 1) return 0;//如果不在就要判断有没有cp,没有才过关 return 1; } int judge_e(int x, int y) {//判断边(转移) int det = x ^ y, cnt = 0; if (max(x, y) - min(x, y) != det) return 0;//去掉既有有人上,又有人下的情况 if (!(det & 1)) return 0;//去掉洁姐姐没有参加的情况 FOR(i, 0, 16) cnt += det >> i & 1; return cnt > k ? 0 : cnt;//参加的人不能超过k,满足条件的输出返回人数,作为增加的边的边权。 } void addedge(int u, int v, int l) { path[u].push_back({v, l}); path[v].push_back({u, l}); } int dijkstra() { priority_queue<pii> q; bool visited[2 << 16] = {0}; int dis[2 << 16]; RSTV(dis, 0x3f); *dis = 0; q.push({0, 0}); while (!q.empty()) { pii now = q.top(); q.pop(); if (visited[now.first]) continue; visited[now.first] = 1; for (auto &p : path[now.first]) { if (dis[p.v] > dis[now.first] + p.l) { dis[p.v] = dis[now.first] + p.l; q.push({p.v, dis[p.v]}); } } } return dis[(1 << (n + 1)) - 1] == 0x3f3f3f3f ? 0 : dis[(1 << (n + 1)) - 1]; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m >> k; FOR(i, 0, m) {//记录cp关系 int u, v; cin >> u >> v; against[pos++] = make_pair(u, v); } pos = 0; int Max = (1 << (n + 1)) - 1; FOR(i, 0, 1 << (n + 1)) if (judge_p(i) && judge_p(Max - i)) point[pos++] = i;//必须两边都ok,才可以 int tmp; FOR(i, 0, pos) FOR(j, i, pos) if ((tmp = judge_e(point[i], point[j]))) addedge(point[i], point[j], tmp);//遍历点的所有组合,判断并添加边 if ((tmp = dijkstra()))//跑最短路,到不了就输出"mole" cout << tmp; else cout << "mole"; } O 这题是在三维空间上面取一棵生成树,求这棵树的$\frac{\sum h}{\sum d}$的最小值。我们假设这个值为ans,则${\sum h}\geq ans\sum d$,记最小生成树上的边集合为$A$,则$\sum (e.h-ans \cdot e.d)=0,e\in A$。当ans过大时,$\sum (e.h-ans \cdot e.d) < 0,e\in A$,过小则反之。这样一来,我们就可以用二分套最小生成树来求解。最小生成树部分用prim跑得飞快,用kruskal要慢一些,注意常数别太差还是能过。复杂度$O(Alognz)$,$A$为最小生成树的复杂度 代码及注释:(kruskal) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = a; i < b; ++i) int fa[1001], depth[1001]; int find(int x) { if (fa[x] == x) return x; return fa[x] = find(fa[x]); } void connect(int u, int v) { u = find(u), v = find(v); if (depth[u] > depth[v]) fa[v] = u; else { fa[u] = v; if (depth[v] == depth[u]) ++depth[v]; } } struct E { int u, v; double w; inline bool operator<(E x) const { return w < x.w; } }; struct P { int x, y, z; }; P points[1001]; E edge[500001]; int pos = 0; int main() { ios::sync_with_stdio(0); cin.tie(0); int n; cin >> n; FOR(i, 0, n) cin >> points[i].x >> points[i].y >> points[i].z; double sum, ans = 10000000; bool a = 0; double tmp[2] = {0, 10000000};//中间开始二分 do { ans = (tmp[0] + tmp[1]) / 2; pos = sum = 0; FOR(i, 0, n) FOR(j, i + 1, n) edge[pos++] = {i, j, abs(points[i].z - points[j].z) - ans * sqrt(((ll)points[i].x - points[j].x) * (points[i].x - points[j].x) + ((ll)points[i].y - points[j].y) * (points[i].y - points[j].y))}; for (int i = 0; i < n; ++i) fa[i] = i;//每次要重新加边 RST(depth); int i = 0; sort(edge, edge + pos); for (auto &p : edge) if (i == n - 1) break; else if (find(p.u) == find(p.v)) continue; else { ++i; sum += p.w; connect(p.u, p.v); } tmp[sum < 0] = ans; } while (abs(tmp[0] - tmp[1]) >= 0.0001);//精度1e-4 cout << fixed << setprecision(3) << ans; }

2019/6/7
articleCard.readMore

2019年电子科技大学ACM暑期前集训动态规划专题解题报告

A https://acm.uestc.edu.cn/problem/oyhuan-you-shi-jie 这题是一个遍历所有节点的最小总距离问题。首先把每两个节点之间的距离存入一个矩阵(虽然好像并不能节省多少时间)。由于是无向图距离,可以用下三角矩阵。求解方法是状压dp,状态定义为当前位置和到过的点。可以用一个数(二进制)表示到过的点,多次循环,对于每一个状态,找到所有可以从其他状态一步进入该状态的路径(也就是枚举任意两个不同的到达过的点),选出其中总权最小的一条路径。根据二进制大小的特点,直接从1(只到过1号位置)枚举到$(1«n)-1$(到过所有n个位置)即可保证求值的顺序正确。枚举循环结束之后,在到过所有点的状态中找出最小总权,即为所求答案 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 #include <bits/stdc++.h> using namespace std; using ll = long long; int n, maps[17][2]; ll dp[17][1 << 17]; ll path[17 * 18 / 2]; inline int tri(int m, int n) {//计算三角矩阵下标 if (m < n) swap(m, n); return m * (m + 1) / 2 + n; } int main() { ios::sync_with_stdio(0); cin.tie(0); int s; cin >> n >> s; for (int i = 0; i < n; ++i) cin >> maps[i][0] >> maps[i][1]; swap(maps[0][0], maps[s - 1][0]); swap(maps[0][1], maps[s - 1][1]);//把起点交换到开头位置 for (int i = 0; i < n; ++i) for (int j = i + 1; j < n; ++j) path[tri(i, j)] = abs((ll)maps[i][0] - maps[j][0]) + abs((ll)maps[i][1] - maps[j][1]);//没卵用的预处理 int end = (1 << n) - 1; memset(dp, 0x3f, sizeof(dp)); dp[0][1] = 0; for (int status = 1; status <= end; ++status)//循环,找点,判断修改 for (int i = 0; i < n; ++i) if (status >> i & 1) for (int j = i + 1; j < n; ++j) if (status >> j & 1) dp[j][status] = min(dp[j][status], dp[i][status ^ (1 << j)] + path[tri(i, j)]), dp[i][status] = min(dp[i][status], dp[j][status ^ (1 << i)] + path[tri(i, j)]); ll result = LLONG_MAX; for (int i = 0; i < n; ++i) result = min(result, dp[i][end]);//在终点路径中找最小 cout << result; } B https://acm.uestc.edu.cn/problem/wa-kuang-gong-lue 这道题有一个很神奇的特点,要使某个点最终走向西/北边,则其西/北边的点最终都会走向西/北边,因此,如果知道西北角一块矩形范围(从$(0,0)$到$(x,y)$,记作$dp[x][y]$内的最优解,则可以根据这一规律往东或者往南扩展得到$dp[x+1][y]$(假设取红矿)和$dp[x][y+1]$(假设取黑矿),反过来,$dp[x][y]$可以由$dp[x-1][y]$(取红矿)或$dp[x][y-1]$(取黑矿)得到。为取最大值,有$dp[x][y]=max(dp[x-1][y]+red[x][y],dp[x][y-1]+black[x][y])$,根据该式循环递推可得出结果 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <bits/stdc++.h> using namespace std; int main() { int n, m; ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; long long red[n][m], black[n][m]; for (int i = 0; i < n; ++i) for (int j = 0; j < m; ++j) {//预处理 cin >> red[i][j]; if (j) red[i][j] += red[i][j - 1]; } for (int i = 0; i < n; ++i) for (int j = 0; j < m; ++j) { cin >> black[i][j]; if (i) black[i][j] += black[i - 1][j]; } long long dp[n][m]; memset(dp, 0, sizeof(dp)); **dp = max(**red, **black); for (int i = 1; i < n; ++i)//先算西北边上的 dp[i][0] = max(dp[i - 1][0] + red[i][0], black[i][0]); for (int i = 1; i < m; ++i) dp[0][i] = max(red[0][i], dp[0][i - 1] + black[0][i]); for (int i = 1; i < n; ++i)//递推求解 for (int j = 1; j < m; ++j) dp[i][j] = max(dp[i - 1][j] + red[i][j], dp[i][j - 1] + black[i][j]); cout << dp[n - 1][m - 1]; } C https://acm.uestc.edu.cn/problem/shou-ban 这题是我瞎jb写WA得最多的一题。首先这题可以分为两个子问题,即取出最多 $n$ 个元素的最佳方案问题;以及把它们插入合适的位置的最佳方案问题。第二个是简单的贪心问题,都插到相同高度的元素旁边,如果找不到,就使混乱度+1,插入到首尾或者两个不同元素间均可,最后增加的混乱度等于在第一个子问题中被全部取出的元素种数。而第一个子问题则是一个复杂的背包问题,状态可以定义为:当前处理到的位置 $i$,前i个中留下的个数 $v$,上一个的高度 $lst$,留下的字母中存在的高度集 $status$(二进制),共四维。总数据范围不算大,可以刷一遍所有状态求解,时间复杂度$O(n^2)$(常数有$8\times2^8$)。而问题的关键就在于状态的转移,要根据状态进行较为复杂的分类处理。详见以下注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 #include <bits/stdc++.h> using namespace std; using ll = long long; int h[100]; ll dp[2][100][8][1 << 8]; bool arrivable[2][100][8][1 << 8]; int all = 0; inline int c(int x) {//求被全部取出的元素种数,解决第二个子问题 x = all - x; int ans = 0; while (x) { ans += x & 1; x >>= 1; } return ans; } int main() { int n, k; ios::sync_with_stdio(0); cin.tie(0); int Case = 0; while (cin >> n >> k && n + k) { memset(dp, 0x3f, sizeof(dp)); /*for (int lst = 0; lst < 8; ++lst) { dp[1][0][lst][0] = 0; dp[0][0][lst][0] = 0; }*///后面写好后这段初始化已经不必要了 all = 0; for (int i = 0; i < n; ++i) { int tmp; cin >> tmp; h[i] = tmp - 114514; all |= 1 << h[i]; }//记录总元素集合(二进制) if (n - k <= c(0)) {//特判,不写也不会错,不过写了应该可以提速 cout << "Case " << ++Case << ": " << c(0) << endl << endl; continue; } for (int i = 0; i < n; ++i) { if (i + 1 - k <= 1) { for (int lst = 0; lst < 8; ++lst) dp[i % 2][1][lst][1 << lst] = dp[!(i % 2)][1][lst][1 << lst]; dp[i % 2][1][h[i]][1 << h[i]] = 1;//这里单独处理v=1的情况,不需要初始化v=0的情况 } for (int v = max(i + 1 - k, 2); v <= i + 1; ++v)//由于最少要留下n-k个,循环下界为i + 1 - k,即在后面全选的情况下可以至少选到n-k个 for (int status = 1; status < (1 << 8); ++status) if ((status | all) == all) {//不可达的状态直接跳过,执行下方else语句,赋值LLONG_MAX >> 2,防止出现后面加数后变负数的情况 dp[i % 2][v][h[i]][status] = LLONG_MAX >> 2; for (int lst = 0; lst < 8; ++lst) if (status >> lst & 1) {//与上一个元素相等,则无论是否保留该元素,状态和混乱度都不变,在两者中选较小值 if (h[i] == lst) dp[i % 2][v][h[i]][status] = min(dp[i % 2][v][h[i]][status], min(dp[!(i % 2)][v - 1][lst][status], dp[!(i % 2)][v][lst][status])); else { dp[i % 2][v][lst][status] = dp[!(i % 2)][v][lst][status];//这些lst只能不保留h[i],混乱度和上一次循环相同 if ((status >> h[i]) & 1)//考虑从不同状态加入h[i]后进入该状态的情况,选择最小值并加上1 dp[i % 2][v][h[i]][status] = min( dp[i % 2][v][h[i]][status], min(dp[!(i % 2)][v - 1][lst][status], dp[!(i % 2)][v - 1][lst][status ^ (1 << h[i])]) + 1); } } else dp[i % 2][v][lst][status] = LLONG_MAX >> 2; } } ll Min = LLONG_MAX;//从所有可达的,满足题目限制的点中找到最优解 for (int v = n - k; v <= n; ++v) for (int lst = 0; lst < 8; ++lst) for (int status = 0; status < (1 << 8); ++status) if ((status | all) == all) Min = min(Min, dp[!(n % 2)][v][lst][status] + c(status)); cout << "Case " << ++Case << ": " << Min << endl << endl; } } D https://acm.uestc.edu.cn/problem/xu-lie K题中的数列是两个方向上升子序列的叠加。对于上升子序列,我们可以维护一个单调递增的数组$dp[]$,用$dp[i]$表示已有的值最小的长度为 $i+1$的子序列末尾值,这样$dp$数组也是单调上升的,对于后面新增的每一个数$num[i]$,$dp[]$中比它小的数和它组成了以$num[i]$结尾的最长上升子序列,用$num[i]$替换第一个比它大的数,不断维护这个数组即可。我们用这个方法,从所有两个方向,对每个数求以其结尾的最长上升子序列$dpl[i]$和$dpr[i]$,以每个数为中心的最长oy序列长度$=min(dpl[i],dpr[i])*2-1$,最后遍历找最大值即可。 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 #include <bits/stdc++.h> using namespace std; int main() { int n; scanf("%d", &n); int num[n], dpl[n], dpr[n]; for (int i = 0; i < n; ++i) { scanf("%d", num + i); dpl[i] = dpr[i] = INT_MAX; } int rpos = 0, lpos = 0; *dpl = *num, *dpr = num[n - 1]; int llen[n], rlen[n];//加入两端值 *llen = 1, rlen[n - 1] = 1; for (int i = 1; i < n; ++i) { int ir = n - 1 - i;//从右端遍历 if (num[i] > dpl[lpos]) {//处理左端 dpl[++lpos] = num[i]; llen[i] = lpos + 1; } else { int p = lower_bound(dpl, dpl + lpos + 1, num[i]) - dpl; dpl[p] = num[i]; llen[i] = p + 1; } if (num[ir] > dpr[rpos]) {//处理右端 dpr[++rpos] = num[ir]; rlen[ir] = rpos + 1; } else { int p = lower_bound(dpr, dpr + rpos + 1, num[ir]) - dpr; dpr[p] = num[ir]; rlen[ir] = p + 1; } } int result = 1; for (int i = 0; i < n; ++i) result = max(result, min(llen[i], rlen[i]) * 2 - 1);//遍历节点找出最大结果 printf("%d", result); } E https://acm.uestc.edu.cn/problem/shen 该题可以先用一组集合$A[]$处理每组含有的字母有哪些,然后该题把状态定义为当前处理到第i段,以及最后一个字母j,通常情况下有$dp[i][j]=min(dp[i-1][x]+A[i].size()), x\in A[i-1]$.然后本题的重点是特判,通过观察和假设不难得出,如果$A[i]$中含有$x$并且满足:$j \neq x$或$A[i].size()=1$则该值减一(先减再取最值),然后在dp数组的最后一行取最小值,即可得出答案。 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(0); cin.tie(0); int t; cin >> t; while (t--) { int k; string s; cin >> k >> s; set<int> a[s.length() / k]; int dp[s.length() / k][26]; memset(dp, 0x7f, sizeof(dp)); for (int i = 0; i < s.length(); ++i) a[i / k].insert(s[i] - 'a');//将字母处理为 0~25 ,避免浪费空间 for (int p : a[0]) dp[0][p] = a[0].size();//第1组不存在特判 for (int i = 1; i < s.length() / k; ++i) {//循环,特判,转移 for (auto p : a[i]) { for (auto q : a[i - 1]) { int ans = dp[i - 1][q] + a[i].size(); if (a[i].find(q) != a[i].end() && (a[i].size() == 1 || p != q)) --ans; dp[i][p] = min(dp[i][p], ans); } } } int Min=INT_MAX; for (auto p : dp[s.length() / k - 1]) Min = min(Min, p);//找出最终答案 cout << Min << endl; } } F https://acm.uestc.edu.cn/problem/wei-ming-ou-yi-lang 这也是一道状压dp的题目。二进制数存储当前到达过的点,然后循环刷表,刷过去就完事儿了,也没有必要全部预处理,在循环内部遍历一下到过的点,得到可以到的点,然后枚举更新值即可,$O(2^n nT)$时间也完全足够。刷表完之后,$dp[(i«n)-1]$的值即为答案。另外,本题可用的小知识点:bitset用于二进制输入 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #include <bits/stdc++.h> using namespace std; int main() { int t; ios::sync_with_stdio(0); cin.tie(0); cin >> t; for (int ca = 1; ca <= t; ++ca) { int n; cin >> n; bitset<16> init, add[n]; cin >> init; for (int i = 0; i < n; ++i) cin >> add[n - 1 - i];//用bitset变量,可以直接cin输入二进制数,然后用其成员函数转换成其他类型 unsigned long long dp[1 << n]; memset(dp, 0, sizeof(dp)); *dp = 1;//初始化,求方案数的题常常为1而不是0,因为只有一个点也能构成一个方案 for (int i = 0; i < (1 << n); ++i)//开始刷表 if (dp[i]) { unsigned long path = init.to_ulong(); for (int j = 0; j < n; ++j) if (i >> j & 1) path |= add[j].to_ulong(); for (int j = 0; j < n; ++j) if (!(i >> j & 1)) if (path >> j & 1) dp[i ^ (1 << j)] += dp[i]; } cout << "Case " << ca << ": " << dp[(1 << n) - 1] << endl; } } G https://acm.uestc.edu.cn/problem/zi-chuan 这题的状态定义很浅显易懂,即在前 $i$ 个数中,除k的余数为 $j$ ,这样的字串个数记为 $dp[i][j]$。转移方程也很简单,$j$ 乘10,再加上下一个数后求余,把个数加到对应的状态,即 $dp[i][j]$ 的值应加到 $dp[i+1][(j*10+num[i+1])%k]$ 上,再考虑下个数单独做一个字串的情况即可。最终答案为 $\sum_{i=0}^{n-1}dp[i][0]$,因此循环中可以维护一下sum值。而这题 $dp[i]$ 只与 $dp[i-1]$ 有关,所以采用滚动数组优化,节省空间 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <bits/stdc++.h> using namespace std; int main() { int n, k; ios::sync_with_stdio(0); cin.tie(0); cin >> n >> k; char num[n + 1]; cin >> num; for (auto &p : num) p -= '0';//方便后面直接做数组下标 unsigned long long dp[2][k]; int a[n]; memset(dp, 0, sizeof(dp)); unsigned long long sum = 0; bool now = 0; for (int i = 0; i < n; ++i) { for (int j = 0; j < k; ++j) dp[now][(j * 10 + num[i]) % k] += dp[!now][j];//把每一个量向后转移 ++dp[now][num[i] % k];//num[i]单独成字串 memset(dp[!now], 0, sizeof(dp[!now])); sum += dp[now][0]; now = !now; } cout << sum; } H https://acm.uestc.edu.cn/problem/vanyou-xi 这是一道树形D(深)P(搜)的题目,首先是有向图建树,这个只要根据输入把节点用一个邻接链表连起来就能用,再顺便维护一下入度(是否为零即可),找到根节点。然后就是写一个深搜,在递归调用返回值中选择最值来转移状态,由于两人策略不同(重载运算符后,一人要最大值,另一人要最小值),可以用一个bool变量作为参数记录当前轮到谁,每次调用时更换该参数即可。最终返回到main()函数的值即为结果。虽然深搜但实际上时间复杂度也就 $O(n)$ 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 #include <bits/stdc++.h> using namespace std; using ll = long long; list<int> a[1000000]; int num[1000000]; bool fa[1000000]; struct sc {//保存两人分数,重载运算符 ll e, m; sc(){}; sc(ll e, ll m) { this->e = e; this->m = m; } friend bool operator<(sc a, sc b) { return a.e == b.e ? a.m > b.m : a.e < b.e; } }; sc dp(int pos, bool moe) { sc ans(0, 0); bool init = 1; if (moe) { for (auto &p : a[pos]) {//第一次赋值,之后比较再更新 if (init) { ans = dp(p, !moe); init = 0; continue; } ans = max(ans, dp(p, !moe)); } a[pos].clear();//强迫症清内存。。。 return sc(ans.e, ans.m + num[pos]); } for (auto &p : a[pos]) {//思路与上面类似 if (init) { ans = dp(p, !moe); init = 0; continue; } ans = min(ans, dp(p, !moe)); } a[pos].clear(); return sc(ans.e + num[pos], ans.m); } int main() { char start[4]; int n; ios::sync_with_stdio(0); cin.tie(0); cin >> start >> n; bool moe = *start == 'm'; for (int i = 0; i < n; ++i) cin >> num[i]; for (int i = 0; i < n - 1; ++i) {//建立邻接链表 int x, y; cin >> x >> y; a[x - 1].push_back(y - 1); fa[y - 1] = 1;//记下该点入度不为0 } int root; for (int i = 0; i < n; ++i) if (!fa[i]) {//找根节点 root = i; break; } sc result = dp(root, moe); cout << result.e << ' ' << result.m; } I https://acm.uestc.edu.cn/problem/gong-lue-mei-zhi 这道题首先看起来很复杂,但实际就是一个背包问题。每个元素的属性可以简化成价值a,花费c,和达到先决条件所需花费d,其中在d上面的花费是公共的,这样就是两个子问题:分配先决条件d花费的最优解;以及在问题一的条件下,用剩余的钱获得最大价值的最优解。第一个子问题可以直接枚举,把元素按d排序,则第二个子问题是前几个元素的01背包问题,两个问题都很好解决。然而直接在循环枚举中写01背包时间复杂度高达$O(m n^2)$,结果当然是TLE。再分析问题,会发现存在很多被重复计算的值。所以只需跑一次01背包,在过程中导出问题一的各种情况下的最优解,即可在$O(mn)$内得到总的最优解。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 #include <bits/stdc++.h> using namespace std; using ll = long long; using ull = unsigned long long; struct M { int a; ll c, d; M() {} M(int a, ll c, ll d) { this->a = a; this->c = c; this->d = d; } bool operator<(M x) { return this->d < x.d; }//重载,按d排序 } target[5000]; int m; int main() { int n, k, x, y; ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m >> k >> x >> y; for (int i = 0; i < n; ++i) { int tmp; cin >> target[i].a >> tmp >> target[i].c >> target[i].d; target[i].c = max((target[i].c - tmp) * y, 0LL); target[i].d = max((target[i].d - k) * x, 0LL); }//输入并简化各值 sort(target, target + n); ll dp[m + 1]; memset(dp, 0, sizeof(dp)); ll Max = 0; for (int i = 0; i < n; ++i) {//开始跑0-1背包 if (m < target[i].d) break; for (int j = m; j >= target[i].c; --j)//倒序可以直接一维数组 dp[j] = max(dp[j], dp[j - target[i].c] + target[i].a); Max = max(Max, dp[m - target[i].d]);//导出子问题最优解,与全局最优解比较更新 } cout << Max; } K https://acm.uestc.edu.cn/problem/chou-qia 首先,从数据范围可以看出,要求复杂度$O(logn)$进行递推,这很容易想到矩阵快速幂。这道题的难点就在这个矩阵上面。我之前想了很久,但是思维局限在二维或三维矩阵上,一直没有推出一个常矩阵。事实上这是一个m+1阶方阵,用一个列向量表示第i次碎片数量从0到m的概率。记作$dp[i]=(dp[i][0],dp[i][0]…dp[i][m])^\top$,然后就可以很容易的递推写出整个矩阵。其中可以把直接抽中定义为直接进入$dp[i+1][m]$状态,即在第最后一行各元素全部加上直接抽中的概率。这样得到的矩阵求快速幂,然后乘初始状态。由于初始状态(一次也没抽)的$dp[0]=(1,0,0,0…)^\top$,最后的结果即为矩阵n次幂最后一行的第一个数。做这题的时候写了一下矩阵的操作,暂时只写了需要的乘法操作,并且没有优化,准备以后有空再把它完善一下作为模板。 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 #include <bits/stdc++.h> const long long M = 1000000007; using namespace std; struct martix_int { int w, h; long long **data; martix_int(int w, int h) {//这里的构造方式,既能data[i][j]访问,也能 memset(*data, 0, sizeof(long long) * h * w); 置零。 this->w = w; this->h = h; data = new long long *[h]; *data = new long long[h * w]; for (int i = 1; i < h; ++i) data[i] = data[i - 1] + w; memset(*data, 0, sizeof(long long) * h * w); } }; martix_int operator*(martix_int x, martix_int y) {//无优化的三层循环 martix_int result = martix_int(x.h, y.w); for (int i = 0; i < x.h; ++i) for (int j = 0; j < x.w; ++j) for (int k = 0; k < y.w; ++k) result.data[i][k] = (result.data[i][k] + x.data[i][j] * y.data[j][k]) % M; return result; } martix_int Pow(martix_int x, int n) {//简单的递归快速幂 martix_int ans(x.h, x.w); if (!n) for (int i = 0; i < x.w; ++i) ans.data[i][i] = 1; else if (n == 1) ans = x; else { martix_int tmp = Pow(x, n / 2); ans = tmp * tmp; if (n % 2) ans = ans * x; } return ans; } int main() { int n, m; ios::sync_with_stdio(0); cin.tie(0); cin >> n >> m; double tmp1, tmp2; cin >> tmp1 >> tmp2; long long p1 = round(tmp1 * 1000), p = round(tmp2 * 1000);//然而对于之前存在精度误差问题的数据,round()并没有卵用 martix_int P(m + 1, m + 1); for (int i = 0; i <= m; ++i) P.data[i][i] = 1000 - (m - i) * p - p1; for (int i = 0; i <= m - 1; ++i) P.data[i + 1][i] = (m - i) * p; for (int i = 0; i <= m; ++i) P.data[m][i] += p1; P = Pow(P, n); cout << P.data[m][0]; } L https://acm.uestc.edu.cn/problem/zhde-jiang-bei 这道题是一个比较标准的斜率优化dp,首先看到静态的区间和,先把前缀和弄出来。 状态转移方程很简单,$dp[i]=min{dp[j]+(a-L)^2},j<i,a=sum[i]-sum[j]+i-j-1$ 但是直接遍历,$O(n^2)$的时间复杂度显然会TLE,所以需要一个快速找出$min{dp[j]+(a-L)^2}$的算法。我们把整个式子拆开,可以定义 $h(x)=sum[x]+x$ $g(x)=dp[x]+h^2(x)+2xL$ 则点$(h(x),g(x))$可表示第x个元素在该题中判断优先级的一个依据。对于每一个i,找到第一个左斜率小于$k[i]=2h(i)$,右斜率大于$k[i]$的点,即为取得$min{dp[j]+(a-L)^2}$的点,即可算出$dp[i]$。最后的$dp[n-1]$即为所求。在这个过程中,左斜率大于右斜率的点显然不可能被取,可以直接去掉,又因为 $k[i]$ 和 $i$ 成正相关,所以 $i$ 从小到大循环时,右斜率小于 $k[i]$ 的点也可以直接删掉,后面不会再取到。这样就成了一个单调队列(和数据结构专题的问题D类似)。后来看了模板通常都喜欢用数组模拟双端队列,我这次写的deque(时间开销较大),不知道这类会不会有卡STL的题。 代码及注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 #include <bits/stdc++.h> using namespace std; using ll = long long; int n, l; ll sum[10000000], dp[10000000]; inline ll Pow(ll a) { return a * a; } inline ll h(int i) { return sum[i] + i; } inline ll g(int x) { return dp[x] + Pow(h(x)) + 2 * h(x) * l + 2 * h(x); } inline bool compare1(int p, int q, int r) { return (g(p) - g(q)) * (h(q) - h(r)) < (h(p) - h(q)) * (g(q) - g(r)); } inline bool compare2(int p, int q, int i) { return (g(q) - g(p)) < 2 * h(i) * (h(q) - h(p)); }//定义各个函数,简化后面操作。 int main() { ios::sync_with_stdio(0); cin.tie(0); cin >> n >> l; cin >> *sum; for (int i = 1; i < n; ++i) {//前缀和 cin >> sum[i]; sum[i] += sum[i - 1]; } deque<int> rest; rest.push_front(0); dp[0] = Pow(h(0) - l);//第一组单独处理 // for(int &p:rest)cout<<p<<endl; for (int i = 1; i < n; ++i) { while (rest.size() >= 2 && compare2(rest[rest.size() - 1], rest[rest.size() - 2], i))//加判断防止越界 // K(rest[rest.size() - 1], rest[rest.size() - 2]) < k(i)) rest.pop_back(); dp[i] = min(Pow(h(i) - l), dp[rest.back()] + Pow(h(i) - h(rest.back()) - 1 - l));//更新值 while ( rest.size() >= 2 && compare1(i, rest[0], rest[1])) // K(i, rest[0]) < K(rest[0], rest[1])) rest.pop_front();//加入新的点 rest.push_front(i); } cout << dp[n - 1]; }

2019/5/25
articleCard.readMore

2019 年电子科技大学 ACM 暑期前集训数据结构专题解题报告

https://acm.uestc.edu.cn/problem/fang-chai (请先看 n题) 这题也是一道线段树的题目,题目中的方差可以拆成和、平方和两个数据来维护,这样合并就很方便。而数据变化有加、乘、抹平两种操作。根据乘法的分配率等定理,多次加乘最后可以化简为一次乘和一次加,为避免出现分数,整合为先乘后加比较方便。而抹平操作则可理解为乘 0再加。这样则有两个标记。这题与 n题相比最关键的区别在于,该题先乘后加的二项式操作不具有结合律,须在更新下层元素之前 pushdown。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 #include <bits/stdc++.h> using namespace std; using ll = long long; const int mod = 1000000007; inline ll M(ll n) { return (n + mod) % mod; } int num[100000]; ll ans_sum, ans_s2; struct tnode {//对节点需要赋初值、更新值、清除标记合并等操作,其中平方和的更新比 n题稍复杂,还有乘标记的默认值为 1,标记的合并也值得注意。 int l, r; ll sum, s2, mark1 = 1, mark2 = 0; inline tnode() {} inline tnode(int l, int r) { this->l = l, this->r = r; if (l == r) { this->sum = num[l - 1]; this->s2 = M(this->sum * this->sum); } } void change(int multipie, int add) { this->s2 = M(M(this->s2 * M((ll)multipie * multipie)) + 2 * M(M((ll)add * multipie) * this->sum) + M(M((ll)add * add) * this->len())); this->sum = M(this->sum * multipie + (ll)add * this->len()); this->mark1 = M(this->mark1 * multipie); this->mark2 = M(this->mark2 * multipie + add); } inline void markdel() { this->mark1 = 1; this->mark2 = 0; } inline int mid() { return (this->l + this->r) >> 1; } inline void merge(tnode lc, tnode rc) { this->sum = M(lc.sum + rc.sum); this->s2 = M(lc.s2 + rc.s2); } inline int len() { return r - l + 1; } }; tnode node[400000]; void buildtree(int l, int r, int pos = 1) {//由于特殊点都写进了成员函数,后面按标准的线段树写就好 tnode &n = node[pos]; n = tnode(l, r); if (l == r) return; int lc = pos << 1, rc = pos << 1 | 1; buildtree(l, n.mid(), lc); buildtree(n.mid() + 1, r, rc); n.merge(node[lc], node[rc]); } inline void pushdown(int pos) { tnode &n = node[pos]; node[pos << 1].change(n.mark1, n.mark2); node[pos << 1 | 1].change(n.mark1, n.mark2); n.markdel(); } void query(int l, int r, int pos = 1) { tnode &n = node[pos]; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) { ans_sum = M(ans_sum + n.sum); ans_s2 = M(ans_s2 + n.s2); } else { if (n.mark1 != 1 || n.mark2 != 0) pushdown(pos); query(l, r, pos << 1); query(l, r, pos << 1 | 1); } } void update(int op, int l, int r, int k, int pos = 1) { tnode &n = node[pos]; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) switch (op) {//三种操作,加可以认为是先乘 1,乘可以认为加 0,抹平则是先乘 0 case 1: n.change(1, k); break; case 2: n.change(k, 0); break; case 3: n.change(0, k); } else { int lc = pos << 1, rc = pos << 1 | 1; pushdown(pos);//更新子节点前先处理标记 update(op, l, r, k, lc); update(op, l, r, k, rc); n.merge(node[lc], node[rc]); } } int main() { int n, q; scanf("%d%d", &n, &q); for (int i = 0; i < n; ++i) scanf("%d", num + i); buildtree(1, n); while (q--) { int op; int l, r; scanf("%d%d%d", &op, &l, &r); if (op == 4) { ans_sum = ans_s2 = 0; query(l, r); printf("%lld\n", M(ans_s2 * (r - l + 1) - M(ans_sum * ans_sum))); } else { int k; scanf("%d", &k); update(op, l, r, k); } } } B https://acm.uestc.edu.cn/problem/tun-tu-liang 这是一道典型的 LCA,另外建树部分使用邻接表(邻接矩阵内存爆炸,边读边建树想了很久也没想到可行方法)。查阅一些资料后,本来对 Tarjan 比较有想法,但是写代码的时候出了一些问题,后来就去写倍增了。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 #include <bits/stdc++.h> using namespace std; using ll = long long; struct edge { int num, l; }; list<edge> path[100001]; int dep[100001], U[100001][18], L[100001][18]; void buildtree(int pos = 1) {//根节点为 1,从根开始对每个节点遍历相邻的边,遇到未加入树的就添加,并且递归操作。遍历结束后 clear()释放无用内存 for (edge p : path[pos]) if (!U[p.num][0]) { U[p.num][0] = pos; dep[p.num] = dep[pos] + 1; L[p.num][0] = p.l; buildtree(p.num); } path[pos].clear(); } int find(int u, int v) {//核心思路就是类似于二分的方法,通过指数的变化逼近取值,将复杂度降到 O(logn) if (u == v) return 0; int ans = INT_MAX; if (dep[u] < dep[v]) swap(u, v); while (dep[u] - dep[v] > 1) {//把两者的深度调至相同,由于有已知的深度差,通过对数计算确定跳的步数,理论上效率应该比像后面那样从最大值开始循环略高 int lo = log(dep[u] - dep[v]) / log(2); ans = min(ans, L[u][lo]), u = U[u][lo]; } if (dep[u] != dep[v]) ans = min(ans, L[u][0]), u = U[u][0]; if (u == v) return ans; for (int i = 17; i >= 0; --i) {//目标深度未知,只能循环判断了 if (i && U[u][i] == U[v][i]) { continue; } ans = min(ans, min(L[u][i], L[v][i])); u = U[u][i], v = U[v][i]; } if (u == v) return ans; return min(ans, min(L[u][0], L[v][0])); } int main() { int n, q; scanf("%d%d", &n, &q); for (int i = 1; i < n; ++i) { int u, v, w; scanf("%d%d%d", &u, &v, &w); path[u].push_back({v, w}); path[v].push_back({u, w}); } U[1][0] = 1;//建树前用邻接表添加边,并把根节点 1加入树, buildtree(); for (int i = 1; 1 << i < n; ++i)//递推预处理 for (int j = 1; j <= n; ++j) U[j][i] = U[U[j][i - 1]] [i - 1], L[j][i] = min(L[j][i - 1], L[U[j][i - 1]] [i - 1]); while (q--) { int u, v; scanf("%d%d", &u, &v); printf("%d\n", find(u, v)); } } C https://acm.uestc.edu.cn/problem/ren-zai-di-shang-zou-guo-cong-tian-shang-lai 这道题处理一条线段上面联通块的个数,并且是动态的。我们可以把各个联通块按顺序排列起来,这样查找更新都比较方便,而 set 容器很好地提供了排列和去重的功能,优先队列虽然效率不错但不提供删除功能。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #include <bits/stdc++.h> using namespace std; struct guo {//每个黑锅的左右端点作为一个节点 int l, r; guo(int l, int r) { this->l = l, this->r = r; } bool operator<(guo ans) const { return this->r < ans.l; }//不加 const 过不了编译,重载运算符。当 a<b、b<a 均为假时,系统会认为 a==b,因此只需重载一个比较运算符。 }; inline guo merge(guo a, guo b) { return guo(min(a.l, b.l), max(a.r, b.r)); }//合并两个黑锅 int main() { int n; scanf("%d", &n); set<guo> a; while (n--) { int l, r; scanf("%d%d", &l, &r); guo hei = guo(l, r); auto it = a.find(hei); while (it != a.end()) {//找到所有和新的黑锅联通的黑锅,合并,删除 hei = merge(hei, *it); a.erase(it); it = a.find(hei); } a.insert(hei); printf("%lu", a.size()); if (n) putchar(' '); } } D https://acm.uestc.edu.cn/problem/mi-ma-1 这是我做的最憋屈的一题,因为k==0的情况WA了十多次。。。好在最后善良的出题人在note里给了提示。 首先这题中要求输出各元素的相对位置不变,这符合队列的性质,而同时又要求代表的十六进制值最大,所以使用单调队列来解决这个问题。我在找到WA的原因之前,想了很久,改了不少次,折腾了很久。好像也可以线段树,就是会多个log,也应该不会卡。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <deque> #include <iostream> using namespace std; int main() { ios::sync_with_stdio(false); int n, k; while (cin >> n >> k) { deque<char> nums; char tmp; if (!k) {//k 为0 时直接读完输入进入下一组数据,最后 AC 的关键,其实 getline 可能更好,但是影响很小,所以不重交了 while (n--) cin >> tmp; //这里有无 cout<<endl;均可 continue; } cin >> tmp; nums.push_front(tmp); for (int i = 0; i < n - k; ++i) {//前半部分直接按单调队列的规则来 cin >> tmp; while (!nums.empty() && nums.front() < tmp) nums.pop_front(); nums.push_front(tmp); } for (int i = 1; i < k; ++i) {//后半部分一边读取新元素,一边输出并删除最优元素,可确保个数足够。把两个循环拆开,常数应该比循环内加判断小 1(其实是因为我之前写错误代码的时候拆了,然后就不想改了) cout << nums.back(); cin >> tmp; nums.pop_back(); while (!nums.empty() && nums.front() < tmp) nums.pop_front(); }; cout << nums.back() << endl;//第二个循环少输出了一个字符,现在补上,导致 k为 0时也会输出一个字符,从而 WA } } E https://acm.uestc.edu.cn/problem/dong-ma-he-sha-tian-xia-di-yi/description 首先,任意个不同的数都可以构成一个单调序列,因此,这题实际上是要求区间中出现最多的数出现的次数,即区间众数。区间众数是一个很经典的难题,常见的做法有分块、莫队(离线)等等。这道题要用上一个询问的答案来处理下一次询问,因此离线做法不可行。 不管是那种做法,我们首先都可以把数列离散化一下,然后考虑分块做法,把数列分为$\sqrt{n}$大小的块,然后我们可以求一个前缀和,即从第一块到每一块的每个元素的总出现次数,对每个块都暴力遍历一遍即可,$O(n\sqrt{n})$。然后再以块为单位枚举起点和终点,从第i块到第j块的区间众数要么是从第i块到第j-1块的众数,要么就是出现在第j块中的数,这样利用前缀和可以在$O(\sqrt{n})$内求出每两个块的值,这样复杂度为$O((\sqrt{n})^2 \sqrt{n})$预处理就结束了,然后查询就很方便,我们可以直接得到区间中整块部分的众数,再在两端的不完整的块中一个一个地暴力统计即可,复杂度也是$O(n\sqrt{n})$。 还有一些别的写法,比如预处理各块自己的众数,然后用n个vector存各点的出现位置,然后用lowerbound差分找答案,这种写法常数非常小,对于随机数据比较快,但是复杂度多了一个$O(log(众数出现次数))$,数据大量重复时就会很慢。 参考代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = (a); i < (b); ++i) const int INF = 0x3f3f3f3f; int n, m, Size; int a[40001], pos[40001]; int sum[201][40001]; int res[201][201]; int S[201]; void init() { int bs = n / Size; FOR(i, 0, bs) { S[i] = Size; FOR(j, 0, Size * (i + 1)) ++sum[i][a[j]]; } if (n % Size) { S[bs] = n % Size; FOR(j, 0, n) ++sum[bs][a[j]]; } FOR(i, 0, S[0]) res[0][0] = max(res[0][0], sum[0][a[i]]); FOR(rblock, 1, bs) { res[0][rblock] = res[0][rblock - 1]; FOR(i, rblock * Size, rblock * Size + S[rblock]) res[0][rblock] = max(res[0][rblock], sum[rblock][a[i]]); } FOR(lblock, 1, bs) FOR(rblock, lblock, bs) { res[lblock][rblock] = res[lblock][rblock - 1]; FOR(i, rblock * Size, rblock * Size + S[rblock]) res[lblock][rblock] = max(res[lblock][rblock], sum[rblock][a[i]] - sum[lblock - 1][a[i]]); } } int in_int() { char c = getchar(); int ans = 0; while (c > '9' || c < '0') c = getchar(); while (c >= '0' && c <= '9') { ans = (ans << 3) + (ans << 1) + c - '0'; c = getchar(); } return ans; } int cnt[40001]; int query(int l, int r) { RST(cnt); int maxc = 0; int lblock = l / Size, rblock = r / Size; if (lblock == rblock) FOR(i, l, r + 1) { ++cnt[a[i]]; maxc = max(maxc, cnt[a[i]]); } else { // if (l % Size == 0) --lblock; // if ((r + 1) % Size == 0) ++rblock; FOR(i, l, Size * (lblock + 1)) { if (!cnt[a[i]]) cnt[a[i]] = sum[rblock - 1][a[i]] - sum[lblock][a[i]]; ++cnt[a[i]]; maxc = max(maxc, cnt[a[i]]); } FOR(i, Size * rblock, r + 1) { if (!cnt[a[i]]) cnt[a[i]] = sum[rblock - 1][a[i]] - sum[lblock][a[i]]; ++cnt[a[i]]; maxc = max(maxc, cnt[a[i]]); } if (lblock + 1 < rblock) maxc = max(maxc, res[lblock + 1][rblock - 1]); } return maxc; } int main() { ios::sync_with_stdio(0); cin.tie(0); n = in_int(); m = in_int(); Size = sqrt(n); FOR(i, 0, n) a[i] = in_int(); memcpy(pos, a, sizeof(int) * n); sort(pos, pos + n); auto lst = unique(pos, pos + n); FOR(i, 0, n) a[i] = lower_bound(pos, lst, a[i]) - pos; init(); int result = 0; FOR(i, 0, m) { int l = in_int(), r = in_int(); l = (l + result - 1) % n; r = (r + result - 1) % n; if (l > r) swap(l, r); printf("%d\n", result = query(l, r)); } } F https://acm.uestc.edu.cn/problem/wo-yong-yuan-xi-huan-dong-ma-he-sha/description 这题在上题的基础上,可以使用离线算法,但是对复杂度、常数的要求都较高。上题的思路无法满足本题的要求,本题可以使用莫队算法来解决问题。我们已知一个区间的状态(各数出现的次数,众数出现的次数,以及出现次数为任意值的元素各有多少个),可以很快地得出其一个端点加一(或减一)后的状态,根据增加或减少的数维护上面三个变量都是$O(1)$的。然后我们就要考虑一个顺序,依次得到各个询问的区间结果,而且复杂度较低。如果简单地以主次关键字排序,次要的端点可能会反复来回长距离移动,复杂度最坏可到$O(n^2)$。这里我们可以对一个端点进行分块,同一块内的询问按另一个端点排序,这样该端点只会在$\sqrt{n}$范围内来回移动(跨块移动是单调的),另一个端点只会来回移动$\sqrt{n}$次,这样即可保证复杂度为$O(n\sqrt{n})$。 参考代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 #include <bits/stdc++.h> using namespace std; using ll = long long; #define RST(a) memset(a, 0, sizeof(a)) #define RSTV(a, v) memset(a, v, sizeof(a)) #define FOR(i, a, b) for (auto i = (a); i < (b); ++i) const int INF = 0x3f3f3f3f; int n, m, Size; int a[200001], pos[200001]; int in_int() { char c = getchar(); int ans = 0; while (c > '9' || c < '0') c = getchar(); while (c >= '0' && c <= '9') { ans = (ans << 3) + (ans << 1) + c - '0'; c = getchar(); } return ans; } int pp = 0, result[200001]; struct Q { int l, r, p, b; void get() { l = in_int() - 1; r = in_int() - 1; p = pp++; b = l / Size; } bool operator<(const Q& x) { return b ^ x.b ? b < x.b : b & 1 ? r > x.r : r < x.r; } } query[200001]; int cnt[200001], sum[200001], res = 0; inline void addr(int& R) { --sum[cnt[a[++R]]++]; ++sum[cnt[a[R]]]; res = max(res, cnt[a[R]]); } inline void eraser(int& R) { --sum[cnt[a[R]]]; if (cnt[a[R]] == res && !sum[cnt[a[R]]]) --res; ++sum[--cnt[a[R--]]]; } inline void addl(int& L) { --sum[cnt[a[--L]]++]; ++sum[cnt[a[L]]]; res = max(res, cnt[a[L]]); } inline void erasel(int& L) { --sum[cnt[a[L]]]; if (cnt[a[L]] == res && !sum[cnt[a[L]]]) --res; ++sum[--cnt[a[L++]]]; } void solve() { int L = 0, R = -1; FOR(i, 0, m) { auto& q = query[i]; while (R < q.r) addr(R); while (L > q.l) addl(L); while (R > q.r) eraser(R); while (L < q.l) erasel(L); result[q.p] = res; } } int main() { ios::sync_with_stdio(0); cin.tie(0); n = in_int(); m = in_int(); Size = sqrt(n); FOR(i, 0, n) a[i] = in_int(); memcpy(pos, a, sizeof(int) * n); sort(pos, pos + n); auto lst = unique(pos, pos + n); FOR(i, 0, n) a[i] = lower_bound(pos, lst, a[i]) - pos; FOR(i, 0, m) query[i].get(); sort(query, query + m); solve(); FOR(i, 0, m) printf("%d\n", result[i]); } : G https://acm.uestc.edu.cn/problem/pai-zhao 这题输入时进行一遍求和预处理,然后就可以直接做差得子序列和,也可以判断最优解也只需根据前缀和,可用单调队列的思想找最优解,线性复杂度内解决问题(这题时限应该是有意没卡暴力)。由于只需要一个最优值,所以可以直接把多余的值pop(stl的priority_queue无此功能),保持队列内元素的单调性,超出长度限制的元素也要pop掉。可以两端pop的容器有list和deque,本来当时想由于不需要随机访问,用链表应该比较好的,但是对比了一下时间就打脸了。 (注:该方法也可以求矩形范围的最值,对每行求一次,得到一个最值矩阵,再对其每列求一次即可,参考codeforces-1195E) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <bits/stdc++.h> using namespace std; int main() { int n, m; scanf("%d%d", &n, &m); long long sum[n + 1]; for (int i = 1; i <= n; ++i) {//初始化求和,保留 sum[0],可以直接使用 sum[i]-sum[0]表示从第一个到第 i个的和,无需单独加代码 scanf("%lld", &sum[i]); sum[i] += sum[i - 1]; } deque<int> begin; long long result = LLONG_MIN; begin.push_front(0); for (int i = 1; i <= n; ++i) { int t = begin.size();//避免多次调用 begin.size(),讲道理 deque 的size()复杂度为 1,但是加了这句之后还是快了一丢丢 while ( --t && sum[begin.front()] > sum[i]) begin.pop_front();//排出 sum 值大的元素,至少保留一个元素 if (i - begin.back() > m) begin.pop_back();//如果长度超限,pop 末尾元素 long long dsum = sum[i] - sum[begin.back()]; if (dsum > result) result = dsum;//计算并更新最大值 if (sum[begin.front()] > sum[i]) begin.pop_front();//在之前保留的元素和 i之间选择下一步的最优解 begin.push_front(i); } printf("%lld\n", result); } I https://acm.uestc.edu.cn/problem/pai-ming 这是一个动态计算排名的题。各队成绩放数组里就好,但是排名,在堆里面反复find队1肯定复杂度肯定是过大的。不过,好在我们只关心队1的名次,因此可以只排序成绩比队1好的队伍,不断增删维护,使的队1一直在排序的末尾,名次可直接用size读取。STL中multiset是最合适的容器,priority_queue虽然更快但是功能不足。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 #include <bits/stdc++.h> using namespace std; using ll = long long; struct T {//存储过题数与罚时,重载运算符以比较成绩 ll pe = 0; int so = 0; inline void add(int n) { ++this->so, this->pe += n; } bool friend operator<(T a, T b) { return a.so == b.so ? a.pe < b.pe : a.so > b.so; } }; int main() { int n, m; scanf("%d%d", &n, &m); T team[n + 1]; multiset<T> trank; trank.insert(team[1]);//默认加入队 1 while (m--) { int t, p; scanf("%d%d", &t, &p); T tmp = team[t]; team[t].add(p); auto itend = trank.end(); if (t == 1) { auto it = trank.lower_bound(team[t]); do trank.erase(it++); while (it != trank.end());//队 1名次上升后,删除后面的队伍 trank.insert(team[t]); } else if (team[t] < team[1]) { trank.insert(team[t]); if (tmp < team[1]) { trank.erase(trank.lower_bound(tmp));//删除过期的数据 } } printf("%lu\n", trank.size()); } } J https://acm.uestc.edu.cn/problem/chong-hai-dai 这题是在一个环中取互不相邻的数,求其最大值。如果直接采用贪心的策略,取最大值a[tmp]后删除a[tmp]、a[a[tmp].l]和a[a[tmp].r],则可能存在为了一个最大的元素放弃两个稍小元素的情况,并不能得到最优解。因此我们需要在贪心的过程中有“反悔”,选择之前元素的两个相邻元素的机会。因此,我们可以加入a[a[tmp].l].value+a[a[tmp].r].value-a[tmp].value,下一次选择该元素即相当于反悔选择a[tmp],改为选择a[a[tmp].l]和a[a[tmp].r],按这个策略贪心,取完m元素求和即可。找最大值可以使用priority_queue。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #include <bits/stdc++.h> using namespace std; const int visited = 10001;//取范围外的值作为已删去的标记 struct node { int value, l, r; node() {} node(int value, int pos) { this->value = value; this->l = pos - 1, this->r = pos + 1; } }; struct q { int value, pos; bool operator<(q m) const { return this->value < m.value; } };//数组存储各点的值和左右元素下标,队列存储值和对应的下标 int main() { int m, n; scanf("%d%d", &n, &m); if (n < m * 2) {//为了取 m个不相邻元素,至少需要 2 * m 个元素 puts("Error!"); return 0; } node a[n]; priority_queue<q> pq; for (int i = 0; i < n; ++i) { int tmp; scanf("%d", &tmp); a[i] = node(tmp, i); pq.push({tmp, i}); } a[0].l = n - 1, a[n - 1].r = 0; int ans = 0; while (m--) { q tmp; do { tmp = pq.top(); pq.pop(); } while (a[tmp.pos].value == visited);//在队列中跳过已删除的元素 ans += tmp.value; tmp.value = a[tmp.pos].value = a[a[tmp.pos].l].value + a[a[tmp.pos].r].value - a[tmp.pos].value; pq.push(tmp); a[a[tmp.pos].l].value = a[a[tmp.pos].r].value = visited; a[tmp.pos].l = a[a[tmp.pos].l].l, a[tmp.pos].r = a[a[tmp.pos].r].r; a[a[tmp.pos].l].r = a[a[tmp.pos].r].l = tmp.pos;//删除元素,调整相关元素的左右元素下标,形成新的环 } printf("%d", ans); } K https://acm.uestc.edu.cn/problem/dui-da-an 该题是在线地判断一些数之间的关系是否矛盾,判断条件为奇偶。每个数有两种可能,每行数据也对应两种可能。对此可以把每个数的两种可能作为两个点,显然该两点是矛盾的。然后根据每行数据成立的两种可能,对并查集进行两次连接操作,再检查矛盾的两点是否被连在同一个并查集中,如果在,则说明条件存在矛盾。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 #include <bits/stdc++.h> using namespace std; const int maxn = 1000001; int fa[2 * maxn]; int dep[2 * maxn]; //简单的秩优化并查集 int find(int x) { if (fa[x] == x) return x; return fa[x] = find(fa[x]); } void connect(int x, int y) {//函数名 union 被stdc++.h 占了。。 x = find(x), y = find(y); if (dep[x] > dep[y]) fa[y] = x; else { fa[x] = y; if (dep[x] == dep[y]) ++dep[y]; } } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 0; i < 2 * maxn; ++i)//初始化父节点数组 fa[i] = i; int i; for (i = 0; i < m; ++i) { int a, b; char l[5]; scanf("%d%d%s", &a, &b, l); if (*l == 'e') {//判断字符串,为偶说明相同,同侧相连即可,为奇则两侧交叉相连。 connect(a - 1, b); connect(a - 1 + maxn, b + maxn); if (find(a - 1) == find(a - 1 + maxn)) { printf("%d", i); return 0; } } else { connect(a - 1, b + maxn); connect(a - 1 + maxn, b); if (find(a - 1) == find(a - 1 + maxn)) { printf("%d", i); return 0; } } } puts("ORZQHQH"); } L https://acm.uestc.edu.cn/problem/wo-de-ti-mian-zui-jian-dan-kuai-lai-zuo 这题是一道比较复杂的线段树题目。要求求最长等差数列的长度,既然是等差,我们就可以维护各数之间的差,这样询问区间l到r的最长等差序列长度,也就是询问l+1到r的最长等值序列长度+1。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 #include <bits/stdc++.h> using namespace std; struct tnode {//除了最长相等序列长度之外,还要保存节点两端的值以及两端的相等序列长度,以便在合并时判断两边的等值序列是否会相连形成更长的等值数列 int l, r, ll = 1, rl = 1, ml = 1; long long lv = 0, rv = 0, mark=0; inline tnode() {} inline tnode(int l, int r) { this->l = l, this->r = r; } inline void change(int add) { this->lv += add; this->rv += add; this->mark += (long long)add; } inline void markdel() { this->mark = 0; } inline int mid() { return (this->l + this->r) >> 1; } void merge(tnode lc, tnode rc) {//关键步骤,要考虑各种情况,确保得到的各变量正确(除了 l=1 的节点的左端点,因为查询时找的是 l+1 到r 的最长等值序列,不会涉及到该值) this->lv = lc.lv, this->rv = rc.rv; this->ll = lc.ll, this->rl = rc.rl; this->ml = max(lc.ml, rc.ml); if (lc.rv == rc.lv) { if (this->ml < lc.rl + rc.ll) this->ml = lc.rl + rc.ll; if (lc.len() == lc.ll) this->ll += rc.ll; if (rc.len() == rc.rl) this->rl += lc.rl; } } inline int len() { return r - l + 1; } }; tnode node[400000]; tnode ans; bool ans_add; void buildtree(int l, int r, int pos = 1) { tnode &n = node[pos]; n = tnode(l, r); if (l == r) return; int lc = pos << 1, rc = pos << 1 | 1; buildtree(l, n.mid(), lc); buildtree(n.mid() + 1, r, rc); n.merge(node[lc], node[rc]); } inline void pushdown(int pos) { tnode &n = node[pos]; node[pos << 1].change(n.mark); node[pos << 1 | 1].change(n.mark); n.markdel(); } void query(int l, int r, int pos = 1) { tnode &n = node[pos]; int lc = pos << 1, rc = pos << 1 | 1; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) {//递归查找过程中左边的点一定先被找到,所以可以把找到的第一个点存下来,后面找到的节点依次并上去,得到最终结果 if (ans_add) ans.merge(ans, n); else { ans = n; ans_add = 1; } } else { if (n.mark) pushdown(pos); query(l, r, pos << 1); query(l, r, pos << 1 | 1); } } void update(int l, int r, int add, int pos = 1) { tnode &n = node[pos]; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) n.change(add); else { int lc = pos << 1, rc = pos << 1 | 1; if (n.mark) pushdown(pos); update(l, r, add, lc); update(l, r, add, rc); n.merge(node[lc], node[rc]); } } int main() { int n, q; scanf("%d%d", &n, &q); buildtree(1, n); while (q--) { int op; int l, r; scanf("%d%d%d", &op, &l, &r); if (op) { if (l == r) { printf("1\n"); continue; } ans_add = 0;//重置标记 query(l + 1, r); printf("%d\n", ans.ml + 1); } else {//区间 l~r+1 会受到影响,各段受到的影响不同,为了方便,分多次调用 update int a, k, p; scanf("%d%d%d", &a, &k, &p); update(l, l, a); if (p > l) update(l + 1, p, k); if (p < r) update(p + 1, r, -k); update(r + 1, r + 1, -a - (2 * p - l - r) * k); } } } N https://acm.uestc.edu.cn/problem/shu-li-tong-ji 这题是最基本的带标记线段树,基本的加法操作,维护和与最值(极差为最值之差)。由于基础比较差,之前对基本的树形结构都没有动手完整地写过,对递归和DFS的理解也不深。这次线段树写得非常难受。反复看过很多教程,又重新仔细看了一下基本的二叉树,踩了很多坑才动手把这题写出来。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 #include <bits/stdc++.h> using namespace std; using ll = long long; ll ans_sum, ans_max, ans_min; struct tnode {//对于节点的基本操作:初始化,更新,删除标记,合并等。出于方便还写了区间长度和中点。延迟更新标记是比较一个有些抽象的概念,但是在优化复杂度的过程中不可或缺,当操作施加于整个区间时,由于加法完全满足结合律,如果后面不会查询到其子区间,就不用把更新往下传,一组数据中往往大部分的节点更新都是可以不用传到底的。但是如果查询到子节点,就必须传递更新信息,否则结果将错误。于是在不知道是否会查询子节点的情况下,暂时先保存更新信息,后面查询的时候再按需传递更新 ll l, r, sum = 0, max = 0, min = 0, mark = 0; inline tnode() {} inline tnode(ll l, ll r) { this->l = l, this->r = r; } inline tnode(ll sum, ll max, ll min) { this->sum = sum, this->max = max, this->min = min; } inline void add(ll add) { this->sum += add * (this->r - this->l + 1); this->max += add; this->min += add; this->mark += add; } inline void markdel() { this->mark = 0; } inline ll mid() { return (this->l + this->r) >> 1; } inline void merge(tnode lc, tnode rc) { this->sum = lc.sum + rc.sum + this->mark * (this->r - this->l + 1); this->max = std::max<ll>(lc.max, rc.max) + this->mark; this->min = std::min<ll>(lc.min, rc.min) + this->mark; } }; tnode node[4000000]; inline void buildtree(ll l, ll r, ll pos = 1) {//递归建树,以 1为根节点 tnode &n = node[pos]; n = tnode(l, r); if (l == r) return; ll lc = pos << 1, rc = pos << 1 | 1; buildtree(l, n.mid(), lc); buildtree(n.mid() + 1, r, rc); } inline void pushdown(ll pos) {//把当前节点的标记推送到下层节点,然后清除标记,在查询时调用 tnode &n = node[pos]; node[pos << 1].add(n.mark); node[pos << 1 | 1].add(n.mark); n.markdel(); } void query(ll l, ll r, ll pos = 1) {//容易写错的一步。查询操作,从大区间逐层向下递归,最终得出结果 tnode &n = node[pos]; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) { ans_sum += n.sum; ans_max = max<ll>(ans_max, n.max); ans_min = min<ll>(ans_min, n.min); } else { if (n.mark) pushdown(pos); query(l, r, pos << 1); query(l, r, pos << 1 | 1); } } void update(ll l, ll r, ll add, ll pos = 1) {//加法操作有结合律,更新时不需要 pushdown tnode &n = node[pos]; if (l > n.r || r < n.l) return; if (l <= n.l && r >= n.r) n.add(add); else { ll lc = pos << 1, rc = pos << 1 | 1; update(l, r, add, lc); update(l, r, add, rc); n.merge(node[lc], node[rc]); } } int main() { ll n, q; scanf("%lld%lld", &n, &q); buildtree(1, n); while (q--) { int op; ll l, r; scanf("%d%lld%lld", &op, &l, &r); if (op == 1) { ll k; scanf("%lld", &k); update(l, r, k); } else { ans_sum = 0; ans_max = LLONG_MIN; ans_min = LLONG_MAX; query(l, r); if (op == 2) printf("%lld\n", ans_sum); else printf("%lld\n", ans_max - ans_min); } } } O https://acm.uestc.edu.cn/problem/zhan-zheng 这是一个典型的用01字典树解决抑或最值问题的题目。我们把所需的数字的二进制从高到低位地保存在字典树中,查询最值的时候根据贪心思想,即可轻松地找到最值。01字典树可以是一棵二叉树,但是这样写要占用n个int的内存,超出了允许的范围,因此我们只添加存在的节点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 #include <bits/stdc++.h> using namespace std; const int MAXN = 500000; int poi[30 * MAXN], ch[30 * MAXN][2], co[30 * MAXN][2], num = 0;//按照最大需求量开数组,另开一个数组记录每个节点下数字的个数,还有一个数组记录最后的节点对应的整个数字(这个数组不要也行,可在查询函数中边查边算) void insert(int x) {//插入数,从高到低 int u = 0; for (int i = 29; i >= 0; --i) { int tmp = x >> i & 1; ++co[u][tmp]; if (!ch[u][tmp]) { ch[u][tmp] = ++num; } u = ch[u][tmp]; } poi[u] = x; } void erase(int x) {//这里只操作记录个数的数组,并以该数组作为判断是否有数的依据 int u = 0; for (int i = 29; i >= 0; --i) { int tmp = x >> i & 1; --co[u][tmp]; u = ch[u][tmp]; } } int find_max(int x) {//贪心,优先不同值 int u = 0; for (int i = 29; i >= 0; --i) { int tmp = x >> i & 1; if (co[u][tmp ^ 1]) u = ch[u][tmp ^ 1]; else u = ch[u][tmp]; } return poi[u]; } int find_min(int x) {//贪心,优先相同值 int u = 0; for (int i = 29; i >= 0; --i) { int tmp = x >> i & 1; if (co[u][tmp]) u = ch[u][tmp]; else u = ch[u][tmp ^ 1]; } return poi[u]; } int main() { int n; scanf("%d", &n); while (n--) { int o, v; scanf("%d%d", &o, &v); switch (o) { case 1: insert(v); break; case 2: erase(v); break; case 3: printf("%d %d\n", find_min(v) ^ v, find_max(v) ^ v); } } }

2019/5/25
articleCard.readMore