OI从普及组到NOIP一等奖|信息学竞赛|OI|CSP|NOIP
信息学竞赛算法学习完整手册
从算法理解、题型识别、代码实现,到复盘进步。
这不是一份只给自己看的做题记录,而是一份可以拿来查、拿来学、拿来复盘的 OI 算法学习手册。
公众号:进步有法
微信:17875487211
QQ:1802019416

写在前面:这份资料为什么值得读
很多同学学习信息学竞赛,会经历一个很典型的过程:
听课的时候觉得算法能听懂;看题解的时候觉得思路也不难;模板复制下来也能跑过样例。可是到了自己独立做题,题面稍微换一种说法,就不知道该从哪里下手。比赛时更明显:不是完全不会,而是想不到、写不稳、调不出来、复盘也复盘不出真正的缺口。
这就是 OI 学习里最常见的问题:学到的算法,没有变成做题时可以调用的能力。
这份手册想解决的,不是“把所有算法都列出来”,而是把算法学习拆成一条更清晰的链路:
题目识别 → 模型判断 → 思路构造 → 代码实现 → 调试验证 → 复盘沉淀
一份真正有用的算法资料,不应该只是题解堆叠,也不应该只是模板仓库。它至少要回答几个问题:
- 这个算法到底解决哪一类问题?
- 做题时,题面出现什么信号,应该想到它?
- 这类题常见的转化方式是什么?
- 代码里最容易错的地方在哪里?
- 做完一道题之后,应该沉淀出什么东西,才能让下一道题真的变简单?
所以,这份手册在保留原始题目、题解、代码和技巧记录的基础上,重新补充了导读、章节定位、学习方法和复盘提示。你可以把它当成一份长期迭代的 OI 学习资料库:先用它建立算法地图,再用它查缺补漏,最后用它做题后复盘。
关于作者和「进步有法」
我最早在初中接触信息学竞赛,后来担任学校信息奥赛队队长。高中阶段继续学习 OI,拿到广东省一等奖,也获得了中山大学相关招生优惠。
但我真正想分享的,不只是“我拿过什么奖”。更重要的是:我也经历过刚开始不得要领、看题解觉得懂但自己做不出、模板细节反复写错、刷了题却没有形成体系的阶段。
后来我慢慢意识到,竞赛学习真正重要的不是“题目数量”本身,而是每道题能不能沉淀出一个清晰、可复用、可验证的进步点。一个算法、一个模型、一个技巧、一个代码细节,只要真的变成了你下次能调用的东西,它就是有效进步。
我会在公众号 「进步有法」 里,持续、免费分享初高中学习心得、信息学竞赛学习心得,以及我整理过、验证过的各类学习资料。
我希望这个公众号不是一个单纯堆内容的地方,而是一个能帮同学们解决真实问题的地方。你现在遇到的迷茫、卡点、低效、焦虑、不知道怎么学,我大概率也经历过。正因为走过这些弯路,我更希望把自己摸索出来的方法、资料和复盘讲清楚、整理好,变成你能直接拿去用的东西。
这也是我做这份资料、做这个公众号最核心的原因:真诚地帮助大家少走弯路,多解决一个实际问题,持续给大家创造价值。
如果你希望获取后续更新版资料、题单补充、竞赛学习路线、分享课通知,或者想交流自己当前卡在哪里,可以添加微信:17875487211。建议备注:OI资料。
这份手册应该怎么用
这份手册内容很多,不建议从第一页硬读到最后一页。更好的使用方式,是把它当成一张地图和一个工具箱。
1. 刚入门:先建立地图,不要急着背模板
如果你刚开始学竞赛,先看每一章前面的“本章定位”和“题目识别信号”。你现在最重要的不是把所有代码都背下来,而是知道:
- 高精度在解决什么;
- 离散化为什么要做;
- 贪心和 DP 的区别在哪里;
- 图论题为什么第一步是建图;
- 数据结构到底是在维护什么信息。
先建立算法地图,后面刷题才不会变成一团乱麻。
2. 正在刷题:每道题都要沉淀一个“补丁”
刷题不是把题目做完就结束。真正有效的复盘,至少要写出一句话:
下次遇到类似题,我要多想到什么?
例如:
- “区间贪心优先考虑右端点。”
- “最短路题先判断边权是否可能为负。”
- “状态压缩 DP 先确认状态数量是否能承受。”
- “线段树不是先背模板,而是先确定维护的信息和合并方式。”
这句话越具体,越能在下一道题里被调用。
3. 比赛前:看易错点、模板和典型模型
比赛前不要大篇幅重新学新东西。更适合看:
- 模板代码;
- 常见坑;
- 题目识别信号;
- 自己曾经错过的细节;
- 每个模块的复盘重点。
竞赛临场最怕的不是完全不会,而是本来会的东西因为细节写崩。模板稳定性就是分数稳定性的底层条件。
4. 赛后复盘:不要只写“不会”
赛后复盘最忌讳一句“这题不会”。这句话没有任何训练价值。你需要把“不会”继续拆开:
| 卡点 | 应该追问 |
|---|---|
| 读不懂题 | 哪个条件没翻译成算法语言? |
| 想不到算法 | 题面缺少哪个识别信号? |
| 想到但不会证 | 贪心/DP/图论模型的正确性缺哪一步? |
| 会写但写错 | 是边界、数组、初始化、复杂度还是数据范围问题? |
| 调不出来 | 是否有最小样例、对拍、断点输出、暴力验证? |
复盘到这个程度,题目才真正变成你的能力。
一套通用的 OI 进步闭环
信息学竞赛不是单纯记知识。它更像一套不断压缩问题、修补能力、验证进步的系统。
第一步:检测——找到真正的进步空间
不是所有不会的题都值得立刻死磕。你要优先抓住三类题:
- 考试常见、训练价值高;
- 你有部分思路,但卡在关键转化;
- 看完题解后能理解,并且能总结出可复用规则。
这类题最适合变成进步点。
第二步:记录——把模糊感觉压缩成明确补丁
不要只写“这题用 DP”“这题是贪心”。要写成更可调用的话:
看到区间覆盖,先按端点排序,再考虑当前最必须满足的约束。
如果要求最大的最小值或最小的最大值,先检查答案是否具有单调性,考虑二分答案。
树形 DP 的状态通常要表达“以当前节点为根的子树已经处理完后,向父亲交付什么信息”。
这才是下次能用得上的东西。
第三步:复习——把补丁变成反射
一个技巧写进笔记,不等于你掌握了。真正掌握,至少要经过三次调用:
- 看着笔记能复述;
- 不看笔记能在类似题里想到;
- 比赛或限时训练中能稳定使用。
所以每个模块都应该配合同类题验证,而不是只停留在“看懂题解”。
第四步:验证——用题目证明自己真的会了
最后要回到题目。学习算法的终点不是“知道定义”,而是能独立完成:
读题 → 建模 → 推导 → 实现 → 调试 → 通过 → 复盘
这也是本手册所有章节真正服务的目标。
阶段训练路线
入门阶段:先把基础工具写稳
建议优先掌握:
- 高精度;
- 离散化;
- 基础搜索;
- 基础 DP;
- 基础图论;
- 基础数据结构。
这个阶段不要过度追求难题。重点是把读题、写代码、调试、复盘这些基本动作做稳。
提高阶段:开始建立模型识别能力
建议重点训练:
- 贪心证明;
- 区间 DP;
- 树形 DP;
- 最短路建图;
- 线段树与树状数组;
- 单调队列、单调栈、优先队列。
这个阶段的核心,是从“我知道这个算法”升级到“我能判断什么时候用这个算法”。
冲奖阶段:强化专题和综合题
建议重点训练:
- 状压 DP;
- 数位 DP;
- Tarjan 与连通性;
- 线段树进阶维护;
- 图论建模综合题;
- 搜索剪枝与状态设计;
- 数学与组合计数。
这个阶段的关键,不是盲目堆题量,而是高质量复盘:每一道题都要沉淀模型、证明、实现细节和易错点。
目录与题目索引
下面的题目索引按原文出现顺序整理。它的作用不是让你机械刷完所有题,而是方便你在学习某个模块时快速定位到对应例题、题解和模板。
完整题目与资料索引
以下索引按原文出现顺序整理。部分链接在原文中标注为“无链接”“无数据”或相对文本,本版保持原样,便于后续继续补全。
- 结构体赋值可用的三种方法(均已验证)|位置:高精度
- 卡常题|位置:离散化
- 火烧赤壁|位置:离散化
- 最近点对|位置:分治
- 订单问题|位置:贪心 > 比较基础的贪心题
- 集装箱填物品问题|位置:贪心 > 比较基础的贪心题
- 排队接水|位置:贪心 > 比较基础的贪心题
- 任务安排|位置:贪心 > 比较基础的贪心题
- 合并果子|位置:贪心 > 贪心进阶
- 荷马史诗|位置:贪心 > 贪心进阶
- 分组|位置:贪心 > 贪心进阶
- 跳石头|位置:贪心 > 贪心进阶
- Sgu171 Sarov zones|位置:贪心 > 贪心进阶
- 矩形|位置:贪心 > 贪心进阶
- 交换相邻元素|位置:贪心 > 贪心进阶
- 划船|位置:贪心 > 贪心进阶
- Z.zigzag|位置:贪心 > 贪心进阶
- 连接多位数|位置:贪心 > 贪心进阶
- 国王游戏|位置:贪心 > 贪心进阶
- Pairs|位置:贪心 > 贪心进阶
- 重构|位置:贪心 > 贪心进阶
- sgu207|位置:贪心 > 贪心进阶
- sgu179|位置:贪心 > 贪心进阶
- 最轻的语言|位置:贪心 > 贪心进阶
- 最轻的语言|位置:贪心 > 贪心进阶
- 区间图的着色问题|位置:贪心 > 贪心进阶
- 数的划分|位置:动态规划 > 计数dp > 组合数学dp
- 放苹果|位置:动态规划 > 计数dp > 组合数学dp
- LIS|位置:动态规划 > 计数dp > 组合数学dp
- 最长公共子序列|位置:动态规划 > 计数dp > 组合数学dp
- 选课|位置:动态规划 > 背包 > 树形(依赖)背包
- 具体解法|位置:动态规划 > 背包 > 树形(依赖)背包
- to be max|位置:动态规划 > 线性dp
- 子串|位置:动态规划 > 线性dp
- 法1|位置:动态规划 > 线性dp
- 法2加维度|位置:动态规划 > 线性dp
- Max K-subarray problem|位置:动态规划 > 线性dp
- CSP-S2021弱化版|位置:动态规划 > 区间dp
- 乘法游戏|位置:动态规划 > 区间dp
- 回文串|位置:动态规划 > 区间dp
- i+1,j-1|位置:动态规划 > 区间dp
- 关路灯|位置:动态规划 > 区间dp
- sue的小球题解(现在行动对未来价值有影响的类型的dp的研究)|位置:动态规划 > 区间dp
- 决斗|位置:动态规划 > 区间dp
- 方块消除|位置:动态规划 > 区间dp
- No Need|位置:动态规划 > 区间dp
- Chopsticks|位置:动态规划 > 贪心dp
- LSGDOJ 青蛙的烦恼|位置:动态规划 > 贪心dp
- NOIP2021方差|位置:动态规划 > 贪心dp
- Tower(AT_dp_x)|位置:动态规划 > 贪心dp
- Game on tree 3|位置:动态规划 > 树形dp
- 骑士|位置:动态规划 > 树形dp > 基环树dp
- A simple task|位置:动态规划 > 状压dp
- 绿豆蛙的归宿|位置:动态规划 > 期望dp
- Chopsticks|位置:动态规划 > 期望dp
- Sushi|位置:动态规划 > 期望dp
- 换教室|位置:动态规划 > 期望dp
- CSP-S2021括号序列|位置:动态规划 > 常见的dp中的trick
- 具体解法|位置:动态规划 > 常见的dp中的trick
- 具体解法|位置:动态规划 > 常见的dp中的trick
- Knapsack 2|位置:动态规划 > 常见的dp中的trick
- Grid2|位置:动态规划 > 常见的dp中的trick
- 子序列|位置:动态规划 > 常见的dp中的trick
- NOIP2021数列|位置:动态规划 > 常见的dp中的trick
- 积木游戏|位置:动态规划 > 常见的dp中的trick
- 协议|位置:动态规划 > 常见的dp中的trick
- candles|位置:动态规划 > 常见的dp中的trick
- Divide, XOR, and Conquer|位置:动态规划 > 常见的dp中的trick
- k|位置:动态规划 > 常见的dp中的trick
- Completely Searching for Inversions|位置:动态规划 > 有向无环图上的dp
- 数字计数|位置:动态规划 > 数位dp
- 树的直径模板|位置:图论 > BFS&DFS
- ABC281G|位置:图论 > 最短路(重点)
- 最短路计数|位置:图论 > 最短路(重点) > Dijkstra
- HDU3790|位置:图论 > 最短路(重点) > Dijkstra
- Travel in Desert|位置:图论 > 最短路(重点) > Dijkstra
- 花费非常数|位置:图论 > 最短路(重点) > Dijkstra
- 圣诞树|位置:图论 > 最短路(重点) > Dijkstra
- 打车问题|位置:图论 > 最短路(重点) > Dijkstra
- CF1005F|位置:图论 > 最短路(重点) > Dijkstra
- Small multiple|位置:图论 > 最短路(重点) > 构图技巧练习
- 跳楼机|位置:图论 > 最短路(重点) > 构图技巧练习
- Xanadu|位置:图论 > 最短路(重点) > 构图技巧练习
- Strong Defence|位置:图论 > 最短路(重点) > 构图技巧练习
- Shortest Path|位置:图论 > 最短路(重点) > 构图技巧练习
- earth hour|位置:图论 > 最短路(重点) > 构图技巧练习
- 沙滩防御|位置:图论 > 最短路(重点) > 构图技巧练习
- Okabe and City|位置:图论 > 最短路(重点) > 构图技巧练习
- new island|位置:图论 > 最短路(重点) > 构图技巧练习
- Tax|位置:图论 > 最短路(重点) > 构图技巧练习
- arc061C|位置:图论 > 最短路(重点) > 构图技巧练习
- Low cost air travel|位置:图论 > 最短路(重点) > 构图技巧练习
- Recover path|位置:图论 > 最短路(重点) > 构图技巧练习
- wandering queen|位置:图论 > 最短路(重点) > 构图技巧练习
- Jzzhu and Cities|位置:图论 > 最短路(重点) > 构图技巧练习
- Edge Deletion|位置:图论 > 最短路(重点) > 构图技巧练习
- 最小环|位置:图论 > 最短路(重点) > Bellman-ford
- 中间的球|位置:图论 > 最短路(重点) > Bellman-ford
- Intervals|位置:图论 > 最短路(重点) > 差分约束系统(常考)
- 糖果|位置:图论 > 最短路(重点) > 差分约束系统(常考)
- Destroying Roads|位置:图论 > 最短路(重点) > 最短路综合练习
- Pair of Balls|位置:图论 > 拓扑排序
- fox and name|位置:图论 > 拓扑排序
- Teleporter|位置:图论 > 拓扑排序
- ABC277F|位置:图论 > 拓扑排序
- 序列合并|位置:图论 > 拓扑排序
- 车站分级|位置:图论 > 拓扑排序
- 排水系统|位置:图论 > 拓扑排序
- STORE - Store-keeper|位置:图论 > 图的连通性与tarjan算法 > 与点2连通分量,边2连通分量相关的树形结构:
- 解题报告link|位置:图论 > 图的连通性与tarjan算法 > 点双连通分量练习
- USACO06JAN|位置:图论 > 图的连通性与tarjan算法 > 割边练习
- STORE - Store-keeper|位置:图论 > 图的连通性与tarjan算法 > 无向图连通性相关练习
- Redundant Paths|位置:图论 > 图的连通性与tarjan算法 > SCC:强连通分量。
- 数的划分|位置:搜索
- 反素数|位置:搜索
- 取数游戏|位置:搜索
- life line|位置:搜索
- 生日蛋糕|位置:搜索
- Squre|位置:搜索
- 小木棍|位置:搜索
- NOIPTG2017宝藏|位置:搜索
- CF上的T形骨牌|位置:搜索
- 虫食算|位置:搜索
- CF27E|位置:搜索
- 靶形数独|位置:搜索
- 算符破译( bzojlink)|位置:搜索
- NOI导刊+搜索顺序的选取与剪枝策略|位置:搜索
- 最多的方案|位置:搜索
- link|位置:搜索
- squre destory|位置:搜索
- Mayan游戏|位置:搜索
- gym100825C|位置:搜索
- APRIME - Anti-prime Sequences|位置:搜索
- 扔鸡蛋问题|位置:搜索
- False Hope|位置:搜索
- P2698|位置:数据结构 > 单调队列
- 找第一个比当前元素大的下标|位置:数据结构 > 单调栈
- 日志分析|位置:数据结构 > 单调栈
- 和至少为k的子数组|位置:数据结构 > 单调栈
- 奶牛排队|位置:数据结构 > 单调栈
- 逛画展|位置:数据结构 > 单调栈 > 拓展:区间问题的另一个常见解法——双指针
- 中位数|位置:数据结构 > 优先队列
- 序列合并|位置:数据结构 > 优先队列
- 题解+证明|位置:数据结构 > 优先队列
- JFCA|位置:数据结构 > ST表
- 线段树1|位置:数据结构 > 线段树
- Range Update Queries|位置:数据结构 > 线段树
- Forest Queries|位置:数据结构 > 线段树
- 线段树2|位置:数据结构 > 线段树
- Sasha and Array|位置:数据结构 > 线段树 > 线段树维护矩阵
- Addition Robot|位置:数据结构 > 线段树 > 线段树维护矩阵
- sgu177.square|位置:数据结构 > 线段树 > 线段树维护矩阵
- 帖子|位置:数据结构 > 线段树 > 线段树维护矩阵
- 堵塞的交通|位置:数据结构 > 线段树 > 线段树维护矩阵
- 游戏|位置:数据结构 > 线段树 > 线段树维护矩阵
- 最大子段和|位置:数据结构 > 线段树 > 线段树经典题
- 最大前缀和|位置:数据结构 > 线段树 > 线段树经典题
- 区间修改等差数列|位置:数据结构 > 线段树 > 线段树经典题
- 查pos并删掉输出序列|位置:数据结构 > 线段树 > 线段树经典题
- 逆序对|位置:数据结构 > 树状数组(BIT)
- MooFest|位置:数据结构 > 树状数组(BIT)
- Intervals|位置:数据结构 > 树状数组(BIT)
- 和谐子序列计数问题|位置:数据结构 > 树状数组(BIT)
- A Sequence of Numbers|位置:数据结构 > 树状数组(BIT)
- poj2763|位置:lca > 欧拉序转RMQ
- NOIP2022T1种花|位置:数学 > 组合数学
正文:算法、题目、题解与模板
从这里开始,是完整的算法手册正文。正文部分保留原资料中的题目、题解、代码、链接、技巧、易错点和个人复盘,并在每个大章节前补充了学习定位、题目识别信号和复盘提示。
阅读时建议遵循这个顺序:
- 先看本章导读,知道这一章解决什么问题;
- 再看技巧和题目描述,理解题目如何转化为模型;
- 最后看代码,把代码当成检验理解的工具;
- 学完后,用本章复盘重点写出自己的“一句话补丁”。
第一章 高精度:把大整数运算变成可控的竖式模拟
本章定位:掌握大整数的存储、比较、加减乘除,以及模板中最容易导致 WA/RE 的细节。
题目识别信号:题目出现几十位、几百位甚至更长的整数;答案可能超过 long long;输入以字符串形式给出整数。
学习方法:不要把高精度当作新算法,它本质上是“数组模拟小学竖式”。先把存储方向、进位/借位、去前导零、比较函数这几个基本件写稳,再看重载运算符模板。
复盘重点:复盘时重点记录:是否特判 0、是否倒序存储、乘法是否写成 +=、比较函数是否从高位开始、局部数组是否可能爆栈。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是大整数模板、构造函数、倒序存储、进位/借位、比较函数和高精除法细节。阅读时优先掌握“数组模拟竖式”的统一思想,再检查每个易错点。
重载运算符板子,多复习,
细节:
1.高精除高精有可能被除数比除数多一位,且最多多一位\(e.g.9899/99\)
2.乘法一定要写\(+=\)
3.构造函数一定要写,结构体不一定初值为0(局部变量)
4.构造函数只有第一次定义的时候有用,之后再赋不同类型的值给高精度数要额外写函数,尽量在定义的时候赋值
5.比较函数要从高位向低位比较
6.写构造函数时记得倒序存储
7.开数组开到结构体下面
8.数字转高精的构造函数特判0
9.如果高精度位数特别多考虑能否开局部变量(是否爆栈),如果不能要自己赋值。
#include<cstdio>
#include<cstring>
#include<string>
#include<iostream>
#define int long long
using namespace std;
const int maxn=100+10;
bool flag=0;
struct Number{
int a[maxn],len;
void cl(){memset(a,0,sizeof(a)),len=1;}
Number(){cl();}
Number(const Number &x){
cl(); //每一个都不要漏了cl()
len=x.len;
for(int i=0;i<len;i++) a[i]=x.a[i];
}
Number(string str){
cl();
len=str.length();
for(int i=0;i<(int)str.length();i++){
a[i]=str[str.length()-1-i]-'0';
}
}
Number(int x){
cl();
if(x!=0){
len=0;
while(x){
a[len++]=x%10,x/=10;
}
}
}
};
void Print(Number x){
if(flag) printf("-");
for(int i=x.len-1;i>=0;i--) printf("%lld",x.a[i]);
printf("\n");
}
bool operator>=(Number x,Number y){
if(x.len>y.len) return 1;
else if(x.len<y.len) return 0;
else{
for(int i=0;i<x.len;i++)
if(x.a[i]>y.a[i]) return 1;
else if(x.a[i]<y.a[i]) return 0;
}
return 1;
}
Number operator+(Number x,Number y){
Number ret;
ret.len=max(x.len,y.len)+1;
for(int i=0;i<ret.len;i++) ret.a[i]=x.a[i]+y.a[i];
for(int i=0;i<ret.len;i++){
ret.a[i+1]+=ret.a[i]/10;
ret.a[i]%=10;
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
Number operator-(Number x,Number y){
Number ret,tmp;
if(y>=x) tmp=x,x=y,y=tmp,flag=1;
ret.len=x.len;
for(int i=0;i<x.len;i++){
if(x.a[i]<y.a[i]){
x.a[i]+=10,x.a[i+1]--;
}
ret.a[i]=x.a[i]-y.a[i];
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
Number operator*(Number x,Number y){
Number ret;
ret.len=x.len+y.len;
for(int i=0;i<x.len;i++)
for(int j=0;j<y.len;j++){
ret.a[i+j]+=x.a[i]*y.a[j];
ret.a[i+j+1]+=ret.a[i+j]/10;
ret.a[i+j]%=10;
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
Number operator*(Number x,int y){
Number ret;
ret.len=x.len+10ll;
for(int i=0;i<ret.len;i++){
ret.a[i]+=x.a[i]*y;
ret.a[i+1]+=ret.a[i]/10;
ret.a[i]%=10;
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
Number operator/(Number x,int y){
Number ret;
ret.len=x.len;
int t=0;
for(int i=x.len-1;i>=0;i--){
ret.a[i]=(t*10+x.a[i])/y;
t=(t*10+x.a[i])%y;
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
bool dydy(Number x,Number y,int pos){// >=
if(x.a[pos+y.len]) return 1;
for(int i=y.len-1;i>=0;i--)
if(x.a[pos+i]>y.a[i]) return 1;
else if(x.a[pos+i]<y.a[i]) return 0;
return 1;
}
Number operator/(Number x,Number y){
Number ret,yu=x;
ret.len=x.len;
for(int i=x.len-y.len;i>=0;i--){
while(dydy(yu,y,i)){
for(int j=0;j<=y.len-1;j++){
if(y.a[j]>yu.a[i+j]) yu.a[i+j+1]--,yu.a[i+j]+=10;
yu.a[i+j]-=y.a[j];
}
ret.a[i]++;
}
}
while(!ret.a[ret.len-1]&&ret.len>1) ret.len--;
return ret;
}
signed main(){
int a=100,b=99;
Number x=a;
Number y=b;
Print(x);
Print(y);
Print(x+y);
Print(x*y);
return 0;
}
相关题目/资料: 结构体赋值可用的三种方法(均已验证)
第二章 离散化:把巨大的值域压缩成可处理的下标
本章定位:理解离散化解决的是“值域太大、真正有用的点很少”的问题。
题目识别信号:坐标或数值范围极大,但实际出现的元素数量有限;需要用数组、树状数组、线段树维护这些点。
学习方法:先收集所有可能被用到的值,再排序去重并建立映射。尤其要判断是否需要去重,以及离散化后的一个下标到底代表“点”还是“线段”。
复盘重点:复盘时重点写清:收集了哪些值、为什么要/不要去重、离散化后下标的语义是什么。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是离散化的预处理、去重、点与线段含义转换,以及 map 常数问题。核心不是会写 lower_bound,而是搞清离散后每个编号代表什么。
注意:用map离散化时要以预处理的方式处理好所有要离散化元素的值,避免过多次数调用map,导致常数被卡
例如:卡常题
特别注意:离散化后一定要确定是否去重(一般要去重),去重后搞清每一个元素的含义,例如(原本的点看成线)
例如:火烧赤壁
将原来的点看成一段线段,要去重,保证一条线段只有一个端点可以代表,并且一个点的加减标记只用处理一次。
第三章 分治:把大问题拆成同构小问题
本章定位:建立分治的三个必要条件,并学会分析合并部分的复杂度。
题目识别信号:问题可以按区间、平面、序列分成若干互不干扰的部分;暴力 \(O(n^2)\) 需要优化到 \(O(n\log n)\)。
学习方法:分治的难点通常不在“拆”,而在“合”。思考时先假设左右两部分已经解决,再专门处理跨越两部分的答案。
复盘重点:复盘时重点写:子问题是否同构、子问题是否独立、合并的代价是多少、公共答案如何统计。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是分治三要素、合并答案的设计,以及从 \(O(n^2)\) 优化到 O(n log n) 时的常见思考入口。
三要素:
1.子与原形式相同
2.子与子之间相互独立(没有交集)
3.能缩成可以直接求解的小问题
技巧:一维分组我们常常二分,二维上我们常常在x,y轴上都二分,将整个平面一分为四的求解
在二分法中,我们将合并和分解的总时间与O(n)比较如果相等则复杂度为$$O(n\log n)$$否则取\(O(n)\)与\(O(f(合并和分解))\)中较大的
所以我们想用分治将$$O(n^2)$$的优化成$$O(n\log n)$$一定要想出一种较快的($$O(n\log n)\(或O(n)之类的\))方法合并分解,否则达不到优化效果
技巧:一个问题用$$O(n^2)$$的方法TLE要优化成$$O(n\log n)$$时(通常不会有做法比$$O(n\log n)$$更优),可以往排序、二分、分治等方面想,满足分治要素通常都可以用分治优化
解题方法:
分治算法一般假设两个部分已经处理好,思考的难点在于统计两个部分都在的答案的方法,合并时答案通常为左部分+右部分+公共(公共难想)
例如:最近点对
第四章 贪心:从局部最优到全局最优的证明训练
本章定位:掌握贪心题的识别信号、常见模型、证明方法和反例意识。
题目识别信号:数据范围很大;要求最大/最小;排序、区间、字典序、二进制高位、覆盖、合并、选择顺序等信息很突出。
学习方法:贪心不是凭感觉选一个看起来顺眼的策略,而是“先猜策略,再证明策略不会损失最优性”。常用证明有交换法、规约法、归纳法、反证法。
复盘重点:复盘时至少写出:我贪的是什么、为什么这样贪不会变差、能否构造反例、证明属于交换还是规约。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是贪心类型、局部最优策略、交换法/规约法证明,以及大量题目中的策略发现过程。贪心不要只背结论,要看“为什么这样贪不亏”。
总方法:大胆猜测小心求证。需要直觉、经验。
贪心的大致类型(不绝对,只是大致的模式和思考方向,有可能一道题的策略同时满足几种类型的特点)
- 二分+贪心最优化问题转判定性问题(一般是求最大的最小值,或者最小的最大值,特点就是直接贪局部不好解决,因为局部不一定影响整体。要求的必要特征就是答案具有单调性,这个要求基本上大部分问题都满足,但是在使用二分答案的时候要证明。),转化成满足类贪心问题。
- 直接贪
- 满足类(问题一般带着比较强的约束需要满足,如装箱,覆盖整个区间,每个点的贡献都相同一般也是满足类(如订单问题每个区间对答案的贡献都是1))。一般考虑第一步怎么贪,即满足当前必须满足的第一部分后选择尽量对后面优的,一般用交换法证明。(证法一般是最优解第一步调整成贪心策略仍然合法。)
- 求值类(问题一般求最值并且不带着较强的约束),一般要保证当前第i步弄出来的是前i步中最优的,要考虑最后一步即上个状态到这个状态怎么贪才能最优。可能用到数据结构维护。(证法一般是任意解调整一步变成贪心策略会变得更优Cost(S0)>=Cost(S1)>=…>=Cost(Sk)=Cost(G))
- 特殊的,可以规约解决(原问题用一次策略之后得到的子问题和原问题本质相同,相反也是一种本质相同)的问题一般考虑第一步怎么处理,然后用同样的方法或者相反的方法解决子问题。
找局部最优解的常见技巧(贪心:先找局部最优解,再证明):
1.考虑第一步怎么贪,并且重点考虑对后面的影响(区间覆盖问题,订单问题。)
2.考虑最后一步怎么贪,重点考虑保证在这种贪心策略下前i步是最优、合法的,可能需要考虑上一步到这一步的转移(毛虫树,最轻的语言)
3.上述两种情况一般是问题可以规约解决的技巧。
4.还可以直接将整体的需求在局部满足作为策略,再证明局部满足得出的解是最优解。(连接多位数,国王游戏,排序类的题,这类题的答案通常都需要一个顺序,属于贪心中比较常见的一类,通常可以往交换两个相邻元素会对答案产生什么影响方面想。)
特征:
1.无后效性、快捷、程序容易实现。(不易证明)
2.贪心的证明远比想出贪心策略难,需要数学能力。
- 数据范围大。
- 部分分(50-80)
- 几个贪心取min,max
- 字典序,二进制贪高位,这些高位决定大小的情况。
比较基础的贪心题
技巧:
1.区间类型的题优先讨论右端点的情况(订单问题、最少点支配区间问题)
2.(覆盖满足类)要占满区间、填满箱子等满足类问题(优先满足,才要求花费尽量少),可以从约束入手,(越强的约束应该优先考虑),往能满足当前约束的方案中选取花费最小,对后面收益最大的,一般能将问题规模缩小,从而用相同的策略满足更小规模的问题并且花费最少(区间覆盖问题、集装箱填物品问题)
3.结束时间最早类问题,优先选当前最早结束的(排队接水,任务安排)
4.区间类型的问题有可能不会直接给你区间,而是要将原问题转化为区间问题。
5.满足类的贪心问题一般都采用满足当前并且对未来最优的策略,另一个常见的贪心思路是贪当前以及之前最优(权值最大)的情况,对于当前这个状态取最优。如果贪心选择每个状态的权值相同那么一般也要考虑对未来更优的。(订单问题)
6.满足类问题一般需要选一个好的满足顺序以便可以满足第一部分之后缩小问题规模,用类似的解法解决(可能相同,可能相反(毛虫树))。
7.如果可以用数学式子表示,可以采用数学方法证明。
8.如果第一步贪心策略决策之后可以得到状态和形式与原问题完全一致的子问题。可以通过证明一定有一种最优策略第一步是按贪心方法取的。(合并果子)这种思想叫做规约思想。
9.关于规约思想,进一步的,若能找到一个合适的顺序处理问题,就可以采用规约的思路解决某些约束。
规约思想:按某种策略解决原问题可以以合适的顺序得到比原问题规模小的子问题,子问题又可以用同样的策略(本质相同,可能相反)规约到更小的子问题。(一般找别人对自己没影响的优先处理)
贪心进阶
Huffman问题
Huffman树正确性证明:
核心:证明大的Huffman树是由小的Huffman树经过一步贪心选择得来的,即证明大的Huffman树是由少了两个最小的叶子节点的小的Huffman树加上最小的两个叶子节点得到的。只需要证明大的Huffman树的wpl等于小的Huffman树的wpl加上最小的两个叶子节点的值即可。细节上,通过一些假设利用Huffman树是所有数中最小的“<=”号来证上下界即可,可以假定最小的两个是兄弟,因为在最优解中他们一定在同一层,交换成兄弟即可。最后可以证明最优的树是由贪心策略从小问题构造出来的。
这种将大问题规约的小问题的规约证明的思想要学习
相关题目/资料: 合并果子
题目描述:每次找两堆果子合并,花费为他们的和,最小化花费
正确性证明同Huffman树的正确性证明
单调队列做法:先将一开始的序列排序,然后压入队列中每次从两个队列中选队首最大的,压入第二个队列,可以证明第二个队列也是单调的,因此每次取的都是最值。
本质上是维护了两个有序的序列,用二路归并排序。
多路归并的本质是把多个不同种类的需要从前向后处理的序列使用多个指针维护,从而达到按某种需要的顺序处理的目的,支持有序处理,删除(取)最值和插入(最值)插入如果和前面的构不成需要的顺序那么就需要新开一个序列。
证明:第二个队列中压入了一个比之前队尾小的元素,这种情况是不可能的,因为先假定第二个队列到队尾为止是单调递增的,第一个队列由于排列序也是单调递增的,因此如果新压进来的比较小一定在更早就出现了,所以这种情况不可能发生,证毕。
这种做法结合桶排序,基数排序或者给定序列就是有序的可以做到\(O(n)\)
k进制Huffman问题:荷马史诗
#include<cstdio>
#include<queue>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=1000010;
int a[maxn],n,m,k,ans,dp[maxn];
struct node{
int id,val;
node(int id_,int val_){
id=id_,val=val_;
}
node(){}
};
bool operator<(node x,node y){
if(x.val==y.val) return dp[x.id]>dp[y.id];//要使得最长s最短,
//即树高要尽量小,因此这里必须比较高度
return x.val>y.val;
}
priority_queue<node>q;
signed main(){
scanf("%lld%lld",&m,&k);
n=m;
while((n-1)%(k-1)!=0) n++;//最后要留一个,其余的一次减少k-1个,保证了最后一次合并刚好k个,这条语句保证了去除一个后刚好分成很多个k-1的组,最后一个k-1的组与一开始去除的1组成一个k的组
printf("n=%lld\n",n) ;
for(int i=m+1;i<=n;i++) q.push(node(i,0));
for(int i=1;i<=m;i++) scanf("%lld",&a[i]),q.push(node(i,a[i]));
int cnt=n;
while(q.size()!=1){
int tmp=0;
cnt++;
for(int i=1;i<=k;i++) tmp+=q.top().val,dp[cnt]=max(dp[cnt],dp[q.top().id]+1),q.pop();
q.push(node(cnt,tmp));
ans+=tmp;
}
printf("%lld\n%lld",ans,dp[cnt]);
}
关于k叉哈夫曼树
以下选自lyd大佬的话:
对于k叉哈夫曼树的求解,直观的想法是在贪心的基础上,改为每次从堆中去除最小的k个权值合并。然而,仔细思考可以发现,如果在执行最后一次循环时,堆的大小在(2~k-1)之间(不足以取出k个),那么整个哈夫曼树的根的子节点个数就小于k。这显然不是最优解————我们任意取哈夫曼树中一个深度最大的节点,改为树根的子节点,就会使∑w[i]*l[i]变小。
采用规约的方法解决问题一定要考虑问题减少不同规模规约到最后是否可以得出确定小规模可以解决的问题,并且解决完全部问题是最优解,否则要考虑通过假如虚拟点等手段保证任意原问题规约到最后得到的最小子问题都一样。
采用规约的方法解决问题要保证子问题与原问题完全一致。加入的虚拟结点就做到了。
二分+贪心
技巧:利用二分+贪心可以将具有单调性的最优性问题转为判定性问题,从而更容易的求解
相关题目/资料: 分组
题目描述:给定一些整数,如果两两相差为1可以在同一组,使得最短的组最大
可以直接贪心:每次能加之前组的尽量加之前最短的组,否则开新组,用堆维护。
证明:
1.如果新进来的能加之前组的开新组,交换一下不会变差
2.如果新进来的不加最短的组,交换一下不会变差
也可以二分+贪心;
原问题变成:给定一个x判断是否能使得最小区间的长度>=x
我们可以思考,怎样的情况才可能出现更小的区间呢?显然是接不上之前的组要开新组的时候才可能产生更小的区间,我们考虑怎么样才会开新组
定义:\(f(i)=a[i]\)出现的频次(去重之后)
不难发现当\(f(i)-f(i-1)>0\)会以i开头新开\(f(i)-f(i-1)\)个组
在[i,i+x-1]的频次都-1如果出现<0的情况就说明现有的元素不够开新组了return false
这里要做区间减法,可以用差分数组优化保证时间复杂度为\(O(n)\)
技巧:离线区间减法可以采用差分数组优化到\(O(n)\)
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=2e5+10;
int ans,f[maxn],n,cnt,a[maxn],b[maxn],t[maxn],d[maxn];
bool check(int x){
b[0]=b[1]-1;
for(int i=1;i<=cnt;i++) t[i]=f[i],d[i]=0;
for(int i=1;i<=cnt;i++){
t[i]=f[i];
int s=max(f[i]-f[i-1],b[i]-b[i-1]==1?0:f[i]);
d[i]-=s,d[i+x]+=s;
if(s&&b[i+x-1]-b[i]!=x-1) return 0;//这里由于去重之后两个元素之间至少相差1,所以可以这样判断连不连续
d[i]+=d[i-1];
}
for(int i=1;i<=cnt;i++){
t[i]+=d[i];
if(t[i]<0) return 0;
}
return 1;
}
inline int read(){
int flag=1,x=0;
char ch=getchar();
while(ch>'9'||ch<'0'){if(ch=='-') flag=-1;ch=getchar();}
while(ch>='0'&&ch<='9') x=x*10+ch-'0',ch=getchar();
return x;
}
int main(){
n=read();
for(register int i=1;i<=n;i++) a[i]=read();
sort(a+1,a+1+n);
for(register int i=1;i<=n;i++)
if(i==1||a[i]!=a[i-1])
cnt++,f[cnt]=1,b[cnt]=a[i];
else f[cnt]++;
register int l=1,r=n;
while(l<=r){
int mid=(l+r)>>1;
if(check(mid)) l=mid+1,ans=mid;
else r=mid-1;
}
printf("%d",ans);
return 0;
}
另一道二分+贪心的简单题:跳石头
难题:Monitor
题目描述:给定一些区间,覆盖[0,10000],每个区间可以左右移动不超过x,最小化x,保证有解
本题属于覆盖区间类问题可以往贪心基础2(优先满足最左边的,保留对后面帮助大的)类似的思路方向想贪心策略
技巧:比赛中,许多问题可以用二分答案+贪心解决。
先二分x,考虑check怎么写。
贪心策略:每次选可以覆盖当前左端点的右端点最小的进行覆盖,能覆盖到左端点的情况下尽量靠右,更新左端点,直到覆盖完成
直观的想:如果让右端点靠右的往左移动来覆盖这个端点,则牺牲了这个区间的潜力,所以我们要让能满足覆盖当前左端点的区间中右端点最小的(最不具有潜力)进行覆盖,从而对后面帮助更大
这题与之前的区间覆盖问题不同,最大的特点在于可以移动
技巧:覆盖/满足类问题也可以往这个方面想:在能满足最左的端点中选一个最不具有潜力的,将潜力大的留到后面,尽量对后面帮助大(有点类似田忌赛马)
这种问题通常都是如果让潜力大的来覆盖当前左端点往往会牺牲它的潜力,或者造成一些花费,否则若没有产生额外花费通常直接选最优的即可
这种问题一般都用调整法(好用)证明有一个解可以用贪心策略满足,这也是证明的核心
证明:假设有一个解s第一步用了D2来盖A,则证明的关键的是存在一个解ss是用D1来覆盖A,其中D1是所有能盖到A右端点最小的
为了不是一般性,我们一般将一个不满足要求的解(s)设为尽量接近原来的解的情况,即D2的右端点要尽量靠右
设现在的左端点为A
1.D1移动后到不了D2与A对齐后的右端点,这时D2包含D1直接不调整D2位置将D1覆盖A点仍然是一个可行的解
2.D1移动后到得了D2与A对齐后的右端点设为B,可以假设第二个用了D1来覆盖B,因为假设s中利用了D3覆盖B,分情况讨论:(1)D3的右端点比D1小,与D1定义矛盾舍去,(2)D3的右端点比D1大,那么用D1覆盖B将D3移动到更右边不会变差,仍然是一个解。
设D1覆盖B时的右端点为C,我们要调整S中的D1,D2使得D1覆盖A,D2覆盖C且[A,C]这段区间仍然被覆盖。
这是肯定的。
显然D1可以覆盖到A,假设中可以看出
D2也可以覆盖到C因为D1右<D2右,且|D1|+|D2|>=[A,C],故存在一个解用D1覆盖A,证毕。
实现需要用到分类讨论的技巧。
对于未使用的区间分三类:
1.Born:左端点>A+delta。
2.Active:与[A-delta,A+delta]相交。也就是移动之后能覆盖到A点。
3.Dead:右端点<A-delta。
由于A不断变大,Dead的区间永远Dead。
算法要再Acive中找右端点最小的。
用堆可以解决。
技巧:分类讨论需要安排的元素,并且采用数学,区间集合等方式可以得出高效易实现的实现方法。
相关题目/资料: Sgu171 Sarov zones
题目描述有k个赛区每个赛区要n个人,有一个难度值,有N个选手,每个选手有一个权值一个实力值,若实力>难度则累加其权值到ans中,最大化ans,求一个分配方案,保证选手人数与所有赛区一一对应
简化问题:将k个赛区每个赛区n个人拆成n个赛区每个赛区一个人,使得每个人都匹配一个赛区,这样从多个人进一个赛区的复杂问题转换为了一个人一个赛区的简单问题,要学习这种简化问题的思维
题解:这题与Monitor有些类似,都属于满足、覆盖类题,可以采用怎么样的分配方式可以使得当前获得的收益大,且对后面要尽量好的策略(田忌赛马),能用这种策略的原因分配了当前的选手会占用某个赛区,对后面产生影响,所以要保证对现在好的同时还要对未来好。
技巧:遇到权值可以往现将权值排序的方面想,这其实就是要让现在尽量好。并且用权值排序出的贪心策略很容易用交换法证明正确性。
贪心策略:按权值从大到小排序,当前能晋级让他晋级(对现在好),但是要再能晋级的前提下选最难的赛区,若不能晋级则直接取最难的赛区(对未来好)
用交换法容易证明正确性
code中的坑点:一定要记录赛区和选手的编号,答案要求输出编号但是排了序之后编号就乱了所以遇到排序和编号一般要记录好原来的编号,记住这个细节可以在以后减少错误
相关题目/资料: 矩形
给出平面上的n个点,请找出一个边与坐标轴平行的矩形,使得它的边界上有尽量多的点
暴力枚举s四条边TLE,考虑优化
贪心可以在某些枚举题中起到优化暴力的作用
这题可以用贪心省掉一个for变成三次方可以通过,以省略最下一条边为例,考虑到只有新的可以做最下一条边的价值比之前的边+两侧与之前最下面横边和现在枚举到的横边的两段之和<枚举到横边的点数才能选这条,用数学式表示出来可以证明正确性
交换相邻元素(无数据)
题目描述:给定一个排列,每次可以交换相邻的两个元素问不超过k步使得排列字典序最小的方案。
贪心策略:
从字典序的定义不难想出
第一步交换1-min(n,k)中最小的到第一位k-=j
第二步交换2-min(n,k)中最小的到第二位k-=j
......
引理:每次只会交换逆序对(有通用性,在交换相邻元素为约束的题目中都可以用)
证明引理:
1.若交换一次顺序对后后面某一步交换回来则删掉这两步,放到最后面交换一个相邻逆序对,不会变差
2.若交换一个逆序对后面某一步不交换回来则删掉这一步,放到最后面交换一个相邻逆序对,不会变差
证明正确性:若每次只交换逆序对,设逆序对个数为T
1.k>=T直接交换成最小字典序排序即可
2.k<T设G为最优解则他一定把最小的交换到了第一位,根据引理每一步都将x1往前移动,我们调整G中的步骤吧交换第一位的都提到最前面来,这样最优解G就与贪心解对于的前面关于交换第一个元素的步骤都相同了,之后用归纳法可以证明,最优解可以调整成贪心解,且最优性不受影响。
归纳法证明的思路和只在交换相邻元素的题中交换逆序对的技巧可以积累。
强偏序的问题一般都是贪心解的,因为可以逐位满足,并且问题可以规约到更小问题。
相关题目/资料: 划船
给定n个人名由姓+名组成,同名或者同姓的人可以做同一搜船,求最少需要多少船。
解法:
性质:最终答案的安排方案有多种。(同名或者同姓的要求并不强,并且本质相同。)
方法1;带权图0,1,表示同名、同姓。现在考虑如何满足图中关系的n个点的乘船方案。
首先不在同一连通分量中的点一定不可能乘同一搜船。现在考虑一个连通分量如何安排。
贪心策略:
找点数最小的环,把和其他环无关的相邻的两个点删掉。最后这个小环如果只剩一个点那么把他和更大的环上相邻的点一起删掉,如果更大的环原来是奇数个点,现在变成偶数链,如果更大的环是偶数个点那么总点数是奇数个最终一定最多剩一个删不掉的点。如果是偶数个则刚好删完。
结论:同一连通分量中的点一定能完全配对。
上面的策略不严谨,图有很多种情况,不好得出结论。
方法2:
由于最终方案有多种,并且同名或者同姓本质上结果都是可以两两相连的删掉,对答案的贡献本质相同,并且同姓的同姓还是同姓,满足父亲的父亲还是父亲这种同类传递性的关系,可以用二叉树来表示全部有用的关系。
树是能保证点的连通信息的最简洁的图,越简洁就越容易得出充分必要条件。
以任意一个点为根,左同名右同姓建二叉树。
一个点只有两类关系和自己有关并且关系具有传递性可以建立二叉树。
贪心策略:找最下面一个有两个儿子的父亲,这个父亲结点在根的左子树,就把他和右儿子删掉,左儿子接到自己的父亲的左儿子。(类似儿子兄弟表示法的两边版。)问题转换成和原问题完全一致的子问题(规约)。
右边是对称的。
技巧:答案有多种构造方案(图表示的信息是连通性可以解决问题),边权对答案的贡献本质相同,相同类型边权具有传递性(父亲的父亲是父辈),可以考虑构造一个树来表示这个图,省略了很多边,很简洁(由于同类传递性。)
相关题目/资料: Z.zigzag
给定一个序列求最长上升下降子序列长度。
将点图形化在坐标系上表示出来不难发现每次都选拐点是最优的
序列问题中我们往往可以DP求解,但是对于某些问题,将序列图形化之后可能可以通过某些性质利用贪心求解,图形化的思维要掌握
证明:第一个点和最后一个点(如果不在只有两个元素且相等)一定选,否则可以分情况讨论,以第一个点为例,如果选了后面的某个点作为第一个点,调整为选第一个点不会变差,由于两拐点直接的函数具有单调性,如果一个点在拐点之前选去掉他改成拐点不会变差,如果拐点没选,改成选拐点不会变差
相关题目/资料: 连接多位数
排序类的题,这类题的答案通常都需要一个顺序,属于贪心中比较常见的一类,通常可以往交换两个相邻元素会对答案产生什么影响方面想。
类似的题还有国王游戏
贪心方案:按s+t<t+s排序s,t都是字符串,直观的想接上两个字符串谁字典序小谁在前面
证明:
传递性可以将字符串看成多位数利用自然数具有传递性证明
答案序列一定没有逆序对,如果有交换一下会更优,即最优解是按新定义排序后的序列,最小的一定在第一个根据结合传递性和归纳法,证毕
相关题目/资料: Pairs
题目描述:一个pair能满足两数之和是2的幂则ans++,每个数只能用一次,最大化ans
可以建一个图把两两有二的幂的连边
转化为图的最大匹配问题,可以用带花树解决,但是复杂且时间复杂度超了
用贪心优化问题
观察图,发现了选度为1的点进行匹配,即一个叶子唯一匹配某个点(这里只两个数字的唯一匹配关系,如果有相同大小的可以看成点权)之间选这两个匹配,用交换法可以证明:如果不选叶子一个非叶子与另外一个匹配,改成和叶子匹配不会变差
现在问题变为找度为1的点,首先度唯一可以想到和最大、最小值有关,最小值不可能,因为能匹配到成为最小值的两个数不一定只有一种可能,但是最大值只能唯一匹配,其次要证明一定有度为1的点,容易证明,出现了唯一匹配在图上就对应着度为1的点。
证明:设\(i+j=2^k\),k是能组成的最大的2的幂(证唯一匹配)
假设\(i>=j\)那么\(i>=2的k-1次方\)
假设有另外一个数k可以与i组成2的幂
那么\(i+k=2^k 或 pow(2,(k-1))<=i+k<2^k\)
即要么不存在k和i组成\(2^k\)要么\(k=j\)
所以具有一一对应的关系
代码实现:从大到小枚举k
代码优化,
给定一个数在一个序列中找两个数使得这两个的和为这个数
暴力枚举$$O(n^2)$$,考虑先排序运用单调性优化
双指针优化:
i+j=k可以化为i=k-j
令i为自变量,不难发现当i增大时j单调递减
因此i从小到大,j从大到小,当两数之和>k时j--,<k时i++,=k时统计答案
证明:j单调性容易证明,因为比较小的i加上某个大的j都>k那么更大的i加上之前的大j仍然>k因此j有单调性
引用某神犇博客中的一句话:尺取法就是在对于枚举每一个ll的时候,另一个坐标rr维护的答案也是单调的时候可以使用,能够均摊枚举的时间从而把时间复杂度降到\(O(n)\).
当你发现所求的问题存在类似的单调性的时候,不妨思考一下尺取法.只是有些时候尺取法推动\(l,r\)两个指针的复杂度达到了\(O(n)\),这种时候便要另当别论了.
技巧:优先考虑边界情况,度为1,最大最小,约束最强最弱。解决完一步特殊的问题后原问题往往能简化或者转化,进而迎刃而解。优先考虑边界情况就是一种贪心。
相关题目/资料: 重构
题目描述:给定n个点的权值,连n-1条边,答案为sigma,d[i]*a[i]
d[i]为结点i的度。
直观的想,权值越大的要使得度越少越好
结论:给定任意的d序列只要满足d[i]>=1&&sum(d[i])=2*(n-1),就一定能构造出一颗树
看到点的度我们可以想到这个公式:各顶点度之和=2*边数,这个公式时常用在和度相关的题的证明上,建立了度与边的关系
有了这两个约束构造树可以将度为1的点和当前度最大的点连边,归纳的可以构造一颗树
现在的问题转换为了一个数学问题,一开始花费肯定是所有点度为1的情况,接下来用优先队列左一个多路归并的问题:每次增加的花费是\(delta=((j+1)^2-(j)^2)*a[i]\)由于j递增这个数肯定在每一路都是单调递增的,因此多路归并可以解决
相关题目/资料: sgu207
题目描述:给定n,n个x[i],m,y,求一个序列k使得S最小,令\(S=sum(abs(x[i]/y-x[i]/m))\),所有k的和为m
这一题由于带绝对值,不具有单调性,所以这种安排一个序列的题用比较常用的多路归并行不通。
多路归并排序的每一个序列必须是有序的。
技巧:贡献相同的题目采用均摊的方式是最好的,因为不容易出现极限情况处理不了。
这个绝对值也是对我们的提示,列式不难发现S的大小在所有都大于零的减法中每做一次,对S的贡献都是\(1/m\),所以可以分成>=0和<0的情况,>=0的情况仅需将\(k[i]=(x[i]*m)/y\),即可(由于要下取整,直接考虑等于0的情况列式交叉相乘可以得出),现在考虑<0的情况,这一部分才需要用到贪心,我们要使得总花费最小,只需要按题目要求的式子排序,将最小的一些的k++即可
因为我们由Ki=m∗Xi/Y得KiKi只要再增加1,Ki就要考虑绝对值了,
所以Ki的最终值肯定为m∗Xi/Y或m∗Xi/Y+1。
#include<cstdio>
#include<algorithm>
#include<cmath>
using namespace std;
const int maxn=1010;
struct node{
int id,k;
double x;
}a[maxn];
int n,m,y,ans[maxn];
bool cmp(node A,node B){
return fabs(1.0*A.x/y-1.0*(A.k+1)/m)<fabs(1.0*B.x/y-1.0*(B.k+1)/m);
}
int main(){
scanf("%d%d%d",&n,&m,&y);
int tot=m;
for(int i=1;i<=n;i++) scanf("%lf",&a[i].x),a[i].id=i,a[i].k=0;
for(int i=1;i<=n;i++) a[i].k=a[i].x*m/y,tot-=a[i].k;//下取整距离真实值最多差1,因此tot最大为一个小于n的数
sort(a+1,a+1+n,cmp);
for(int i=1;i<=tot;i++) a[i].k++;
for(int i=1;i<=n;i++) ans[a[i].id]=a[i].k;
for(int i=1;i<=n;i++) printf("%d ",ans[i]);
return 0;
}
启示:观察性质,能确定的先确定,确定不了再贪心。
相关题目/资料: sgu179
给定一个合法的括号序列,找到比这个括号序列字典序大的合法括号序列中字典序最小的。
解法:
首先,我们第一步不会进行右括号改左括号的操作,因为这样只会让字典序变小。
现在考虑第一步左括号改右括号怎么操作。
贪心,我们希望改动的最高位尽肯能低,并且新序列字典序要比现在大。
那么从低位开始,一步步向高位解决,尽肯能不向更高位尝试就是容易想到的贪心策略。
把括号序列看成二进制数,‘(’=0;‘)’=1。
如果找二进制数下一个显然+1即可,要考虑进位。
括号序列最右边一定是右括号,不能改掉。
那么贪从右起第一个左括号,改成右括号,然后再向左边改直到这个括号也匹配到一个左括号。
可以证明不会存在左边的任意一个0可以使得改动后碰到的高位0->1比最右边的0还低,这个定理若成立,正确性就成立。
定理的正确性显然,因为更左边的0改成1(可以证明不会出现更高位1->0的情况因为这样只会让字典序更小)之后一定会匹配到更高位的0,那么0->1只会在更高位的块(一块合法的括号序列,左右刚好匹配)中,显然不是字典序最小的。
那么这个贪心策略正确性成立。
改掉最右边的0->1后一定会有一个左边的1->0,那么问题规约到在左边再找第一个0->1。这样匹配下去直到每一个0->1都匹配好了就停下,保证不会动到更高位。
这里用到了贪心中的规约思想,并且利用了字典序高位影响大的性质。
一个值得学习的分析问题的思路:很多无从下手的问题我们一般先考虑第一步怎么做,分类讨论,分析性质,(技巧:用数字代替括号、字符来找性质)。有趣的是,很多问题根据性质简化一下,转化一下,或者想清楚第一步之后,就很容易解决了。尤其是对于贪心中可以规约解决的问题,这种方法可以解决很多问题。
想不到贪心策略还可以打表(写暴力)找规律解决这题。
相关题目/资料: 最轻的语言
题目描述:你需要在字符集大小为 kk 的所有字符串中选择 nn 个字符串出来,满足没有任何一个字符串是另一个字符串的前缀,且使得权值之和最小。一个字符串的权值为其中所有字符的权值之和(重复计算)。每个字母的权值事先给定。 输出这个最小值。
解法:问题有点像huffman树问题,但是huffman的编码个数是确定为n的,因此只需要n个叶子合并即可,但是这题不同,他给定了可以扩展的叉数k和需要的编码个数n,Huffman的问题只需要对给定的权值编码即可,但是这题需要更多的编码,因此需要迭代出n个,可以看成在一颗高为 n 的满 trie中选一些结点为叶子,构成一颗Huffman树。
构造答案的操作是选一个字符串往后加k个字母迭代出新的k个字符串,贪心策略就选局部最优的,即当前权值最小的字符串迭代出来的是局部最优的。
技巧:根据得到答案的操作贪当前得到局部最优的,是通过迭代(或者其他的某种操作)解决问题构造答案,的常见贪心策略思考方向。
注意:第一次迭代出的解不一定是最优解,因为还可能用比较小的再迭代把大的淘汰掉,但是给定的k个字母每个最多迭代一次,因为他迭代之后因为一个字母迭代后新的一定比原来对应的大,如果再迭代的还是用上个字母迭代的,那么用其他字母一定不优,迭代结束,最坏的情况是每次用新的没用过的字母迭代都比原来优最多k次
因此时间复杂度\(O(k*nlogn)\)
这里也得到了一个优化策略,这次迭代不比上次优直接退出,因为再迭代下去只会越来越差
正确性证明:选最小的迭代,如果不是交换一下更好,
容器中只用保留最小的n个,因为其他不可能作为答案,否则交换一下会更好
要插入删除取最小、大,用平衡树维护
#include<cstdio>
#include<set>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=10010;
multiset<int>s;
int n,k,a[maxn],ans=0x3f3f3f3f,now;
signed main(){
scanf("%lld%lld",&n,&k);
for(int i=1;i<=k;i++) scanf("%lld",&a[i]),s.insert(a[i]),now+=a[i];
while(now<ans){
if(s.size()==n) ans=min(ans,now);
auto it=s.begin();
int tmp=*it; // 把要用的值存下来,尽量不要用迭代器解引用,避免迭代器指向的地址的值发生变化
s.erase(it),now-=*it;
for(int i=1;i<=k;i++) s.insert(a[i]+tmp),now+=a[i]+tmp;
while(s.size()>n) now-=*(--s.end()),s.erase(--s.end());
}
printf("%lld",ans);
return 0;
}
细节:把要用的值存下来,尽量不要用迭代器解引用,避免迭代器指向的地址的值发生变化
技巧:想贪心策略时可以先想一个比较容易想到的,考虑在这基础上能不能再优化,可能优化之后的就是正确的贪心策略(贪了更贪)。
有 \(N<=3e5\) 个怪物,编号为 \(1\) 至 \(N\).编号为 \(i\) 的怪物有一个正整数攻击力 \(A_i\) 和一个 \(1\) 至 \(M\) 之间的类型 \(B_i\).
你的初始血量为 \(H\),并且有 \(M\) 个不同的护符,编号为 \(1\) 至 \(M\).
每次冒险中,你都要从 \(M\) 个护符中选择若干个携带,然后按照编号从 \(1\) 到 \(N\) 依次对战每一只怪物.在对战怪物 \(i\) 时,如果你没有携带护符 \(B_i\),你的血量会扣 \(A_i\) 点.若此时血量小于等于 \(0\) 你会死亡,否则视为你击败了怪物.
你需要对于每个整数 \(K=0,1,\dots,M\),求出携带恰好 \(K\) 个护符时,在不死亡的前提下能击败的最多怪物数量.每次冒险相互独立.
保证对于每个 \(i=1,2,\dots,M\),至少有一个类型为 \(i\) 的怪物.
解法:
直接维护k个护符最远可以到达哪个点不好维护,是一个整体的信息。
并且k-1的答案,与k的没有关联,无法转移,单个单个求容易TLE,很难得到AC的算法。
转化问题:(常用思想)考虑要维护信息的反面,将整体要维护的信息转化成单点要维护的信息,从而在单点与单点信息之间考虑转移,再由单点信息构造出整体答案。整体->单点。
技巧:设计状态之后每一步根据贪心策略转移最后得出答案,这种方法一般用于状态之间选择的元素改变只有少数个的可以状态转移的题。
难点在于设计合理的可以转移(状态与状态之间改变的量很少)的状态,而不是每一步根据贪心策略转移。这个状态往往不是题目直接让你求的,而是要通过分析性质简化问题、转化问题得出的。
考虑维护 \(l_i\) 冲过前i个点最少带的护符数,很容易发现 \(l\) 有单调递增的性质。
如果可以求出每个点的l,那么原问题解决。
考虑动态维护当前最小值。
要记录m的每个类到当前i的伤害前缀和。
然后使得当前选的类的个数最小,并且未选的类的每一个数的累加和小于h。
满足这个条件可以贪心。
首先可以得到性质:未选类和已选类互为补集。求出一个集合就可以得到另一个集合。
当前类只有个数最小的约束,而当前未选类有选的个数最多且累加和小于h的两个约束。
技巧:优先考虑约束最强或者最弱的,从特殊到一般的思想。这是解决所有问题通用的解决方式,类似于拓扑排序中先找度为1的点,先把边界(叶子)解决,可以达到意想不到的转化问题的效果。在规约解决的贪心问题中也是一个常见的确定解决问题顺序的方法。
未选的显然从当前的每个种类的累加和中最小的选,并且选尽量多的是最优的。
然后每次i+1,改变的仅为集合中的某一类的前缀和的值。
而其他之前在未选集里的值都不变,
技巧:动态维护最小的一些数,并且单点修改或插入,可以用对顶堆、set。
这里用对顶set维护即可,满足这些操作:查最大最小值,查集合个数,单点修改。
维护一些最大,最小的一些值(和偏序有关),往往采用将维护的和其他的排序,利用偏序的性质更好用数据结构维护。
用set维护贪心的另一题最轻的语言
这题搜索的解法很容易就想到,在树上搜索,找一些点,一条链上只能找一个点。
但是指数级TLE。考虑贪心。如果考虑每次将所有点都拓展一个字母作为前缀,那么题目要求的互不为前缀的约束难以实现。转换角度,考虑贪一个点扩展所有字母。容易想到贪心策略是当前权值最小的点拓展所有字母。
用归纳法很容易证明这个贪心策略的正确性。
考虑最多拓展多少次?
拓展到不能更新答案即可。因为当前最小的已经无法替换掉原来n个的值,那么下一个最小的一定比当前的还大,出来的结果更不能替换了。
在有n个单词的前提下。最多拓展n次每次至少多出来一个,拓展n次足以用两个单词把所有其他单词替换掉。即这个次数最多为n。
引理:两颗点个数一样的树,叶子点权和权值更小的和更大的用相同策略拓展,小的总会更优。(权值为正,正确性显然。)
于是,贪心正确性证明的核心是在任意树的状态下,不会有不用贪心策略创造出的新树的对应结点比用贪心策略的小。
这题核心是想出贪心策略需要转换到最小点的k个替换,而不是陷入每个点都拓展,由k个字母从权值小到大试。
技巧:一个角度行不通考虑另外一个角度,整体不行考虑局部,或者换一个主体。就像DP的两种转移形式,考虑自己怎么拓展,和某种拓展方式怎么作用于所有状态。
相关题目/资料: 区间图的着色问题
给定一些区间,每个区间都要一个颜色,相交的区间的颜色不相同。求最小需要的颜色个数和任意一个可行的染色方案。
解法:这种区间问题很常见的解法是贪心。
两种贪心方案:
方案一:左端点排序,选择没有图的颜色中最小的,如果没法选就新开。
实现可以用set,把n个颜色给压进set,使用一个区间就erase掉set的第一个,然后再r端点标记一个使用过这个颜色,开2*n个vector,然后把端点都离散化,扫到做端点就在set中取,扫到右端点就将vector中的全部insert进set。
证明:根据举例子观察可以发现,答案都不超过最多重叠在一起的区间个数。
即记一个点有多少个区间为这个点的深度。最大深度记为D。
那么ans=D可以证明。
按左端点排序,假设有一个区间已经用到了D个那么不可能再有一个区间用到D+1,否则那个区间的左端点一定是深度超过D的点,与题意矛盾,假设不成立,原结论成立。
左端点排序有一个用于证明的重要性质:就是超过最优解的那个区间的左端点,一定是包括在前面那些区间内的点。用这个条件很容易得到反证法。右端点排序则没用这个性质。左端点容易作为第一个不合法的点,利用的是多个区间信息最容易重合的地方。
方案二:右端点排序,每有一个新区间就把他染色成可以接到的区间的右端点最右的区间,并且染成同一个颜色。
直观的想,右端点排序扫的特征就是前面的区间都没了,出来完了,不会再有右端点伸过来影响了。所以这个区间接在右端点最靠右的后面,并且涂成同一个颜色,因为这样可以把右端点靠左的区间留给后面可能出现的左端点更左的区间用。(类似田忌赛马)
证明:若在最优解中,将这个区间没有放到可以放的最右的,那么交换不一样的那个区间的后面的整个整体和这个区间包括后面的整个整体。仍然是一个可行解。一直交换下去,按贪心策略给出的也是最优解。
右端点排序的性质是前面的区间不会有右端点伸过来,这个区间可以采用类似田忌赛马的策略,在能满足自己的前提下把更好的位置留给后面,由于更好的位置自己本身就可以占,所以很容易用交换法证明。多举几个例子就想出来了。
第五章 动态规划:把选择过程压缩成状态转移
本章定位:建立 DP 的状态设计、转移、边界、遍历顺序、答案位置五个基本问题。
题目识别信号:题目要求最优值、方案数、可行性;问题有阶段性选择;当前选择会影响后续,但可以被某些状态概括。
学习方法:不要一上来套模板。先问“状态表示什么”,再问“最后一步从哪里来”,最后才写转移和优化。
复盘重点:复盘时重点记录:状态定义、转移来源、边界条件、遍历顺序、是否重复/漏算、能否滚动或数据结构优化。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是状态设计、转移方程、分类讨论、复杂度优化,以及各种 DP 模型之间的迁移。阅读时要特别关注每道题中“状态为什么这样定义”。
计数DP
结合容斥
技巧:在计数问题中,正难则反(容斥)的思想经常用到。有时候问题的反面很好求,类型一:求是整个问题的反面。类型二:在DP转移的时候枚举不合法的状态,用组合数学+DP的方法完成转移,通过枚举哪些不合法使得问题规约到子问题解决。
技巧:一个序列中若出现某个数大于其中总和的一半,那么这样的数只有一个,如果大于1/3,至多有两个,以此类推。鸽巢原理也是计数问题中的常用思想。
技巧:若在构造答案时需要累积满足某个下标大小关系的所有情况,那么无需用下标存下具体的数,从绝对数值改存相对大小,相对位置是DP优化(减省状态)的常用策略。
记录相对位置时要考虑新变量的值域,如果可能到负数域那么要加上偏移量。
组合数学DP
相关题目/资料: 数的划分
给定n个一样的小球,装入m个一样的盘子里,不能有盘子为空,求方案数。
相关题目/资料: 放苹果
给定n个一样的小球,装入m个一样的盘子里,可以有盘子为空,求方案数。
以上两题都要O(nm)的做法。
解法:
两题是同一个类型的问题,之前定义状态是题目要求的值。
这类组合数学一样小球一样盘子的本质上都一样的题是不能用传统的按盘子或者小球划分阶段DP,因为阶段是要按顺序处理的,这里题面要求的是没有顺序关系。
所以要从组合数学加乘原理出发,对于答案的结构分类,按照不同类相加得出最终答案(加法原理)。
为了分更少的类我们一般会考虑包不包含最小的情况,因为更大的情况一定会讨论到小的情况,从而分更多的类,也能做,就是更复杂。
以放苹果为例。
先考虑n,m的大小关系,如果n<m那么直接转化为求DP[n][n]。
在以下的情况m<=n。
DP[n][m]分成两类一类是包含空盘子的方案:DP[n][m-1]
另一类是不包含空盘子的方案:DP[n-m][m](先全部位置填上1,然后转化为可以空可以不空的问题。)
DP[n][m]=DP[n][m-1]+DP[n-m][m]。
整数划分和这题的区别就是讨论是否有只放1个的盘子,
如果有:DP[n-1][m-1]
如果没有:DP[n-m][m]
技巧:对于所有状态变量都无序的组合数学求值DP问题,要考虑的不是怎么按某个标准划分阶段,因为阶段和阶段之间有序,会造成信息冗余导致超时。而正确的做法应该是讨论答案是否包含最小的情况,因为这样分类最简单,再用加法原理计算即可。
细节:要先考虑问题中n和m的关系,考虑是否有nm大小不一样导致的边界情况没有考虑。
技巧:当状态之间没有本质顺序区别的的时候,最后一步分类一般都是根据考虑整体中有没有出现某种情况而分类。
相关题目/资料: LIS
技巧1:构成答案的元素具有单调性可以将答案作为阶段,将元素作为DP的值,利用单调性可能可以优化。
注:输出方案不能用二分优化,因为DP数组存的是长度为i的某位最小的值,保证这个值会作为某个末尾,但不保证是前面DP数组组成的序列。
处理二分优化,也可以用离散化+线段树做。
相关题目/资料: 最长公共子序列
输入两个长为n<=1e5的排列,每个数出现并且仅出现一次,求最长公共子序列。
解法:
直接求TLE。
将一个序列作为顺序,另一个序列求LIS。
技巧:排列可以作为偏序,公共可以转换成按某种偏序对一个序列的操作。
朴素解LCS问题的key是关注最后一步,即这个当前状态能被哪些上一步的状态组成。
背包
树形(依赖)背包
相关题目/资料: 选课
可以看成对于每颗子树做泛化物品的分组背包。
\(DP_{i,j,k}\):以i为根的子树考虑前j个儿子(组),容量为k的最大价值
\(DP_{i,j,k}=\max(DP_{i,j-1,k-t}+DP_{son[i][j],son[j].size,t})\)
相当于将第j组体积为t,价值为\(DP_{son[i][j],son[j].size,t}\) 的物品考虑是否放入背包。
利用分组背包的优化空间策略可以优化掉第二个维度,注意倒序枚举体积。
dfs序法:求后序遍历,这样保证了处理一个树时先处理儿子后处理根。
这个优化的核心在于确定了某种序可以表示使用该节点的所有转移\(O(1)\) 相较于直接DP,定了一种序处理同一层节点,实现了同一层前面的兄弟与该节点的儿子共同考虑的 \(O(1)\) 转移法。而上面的DP没有确定序,完全是拆成了多个完全背包,而这种方法将阶段很好的划分以至于能 \(O(1)\) 转移。
相关题目/资料: 具体解法
技巧:重新定义需要枚举转移的状态,使得需要枚举转移的情况在新的巧妙状态下可以 \(O(1)\) 实现
技巧:加虚根是处理依赖背包的好方法。
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=301;
int dp[maxn][maxn],w[maxn],n,m,siz[maxn],head[maxn],cnt=1,dfn[maxn],tot;
struct edge{
int to,nxt;
}e[maxn<<1];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
void dfs(int u,int fa){
siz[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
siz[u]+=siz[v];
}
dfn[++tot]=u;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
int s;
scanf("%d%d",&s,&w[i]);
add(i,s),add(s,i);
}
dfs(0,-1);
for(int i=1;i<=n;i++){
for(int j=0;j<=m;j++){
if(j>=1) dp[i][j]=max(dp[i-1][j-1]+w[dfn[i]],dp[i-siz[dfn[i]]][j]);
else dp[i][j]=dp[i-siz[dfn[i]]][j];
}
}
printf("%d",dp[n][m]);
return 0;
}
线性DP
相关题目/资料: to be max
给定n*n的矩阵,求最大子矩形。
n<=500.
解法:
前最和+四次方枚举很容易想到,但是TLE。
考虑用DP优化。
先考虑优化一个方向,即枚举行的起始点和终点,然后把行压成一维的,用最大子段和的方式对列进行最优化的DP。
三次方可以AC。
为什么没想到DP优化的做法?
没有将问题转化为一维,从而用DP优化。
先分析题目性质,发现矩形的行一定是一样的,根据这个性质可以将问题转化成一维的问题。
技巧:通过分析问题性质,找准每个答案相同的部分,先枚举一个方向,可能可以用DP优化枚举过之后的问题。即先转化问题,先考虑用DP优化一个最简单的问题再考虑优化更复杂的情况(从特殊到一般)。
技巧:观察答案的性质,发现共同的特性,可能能将问题转换(如二维转一维),转化之后可能才能DP。二维转一维(线性)更好DP,二维或者更高维的问题可能很难用DP解决。
# P3558 [POI2013]BAJ-Bytecomputer
题目描述:给定一个长度为 n 的只包含 -1,0,1 的数列 a,每次操作可以使 \(a_i\gets a_i+a_{i-1}\),求最少操作次数使得序列单调不降。如果不可能通过该操作使得序列单调不降,请输出 BRAK。
数据范围:\(1\le n\le 10^6\)。
线性DP
思维难点:
要考虑到不降序列中可能有哪些元素,举例子之后可以发现只会出现0/1、-1三种,如果出现其他的一定不优。
以线性DP的常见状态定义,用前缀划分阶段,如果一维无法转移,考虑加维度,加维度时可以从这次有的情况需要用到上次的哪些情况的方向考虑,把这次转移需要用到上一阶段的情况写进状态分类讨论即可
相关题目/资料: 子串
题目描述:
有两个仅包含小写英文字母的字符串 A 和 B。
现在要从字符串 A 中取出 k 个互不重叠的非空子串,然后把这 k 个子串按照其在字符串 A 中出现的顺序依次连接起来得到一个新的字符串。请问有多少种方案可以使得这个新串与字符串 B 相等?
解法:一开始可能会认为是kmp或者AC自动机,但是一看数据范围还有题目要求不同位置也算不同方案、在A中选按顺序接起来,这个按顺序无疑是在提醒我们DP中的无后效性
难点1:定义状态,以前缀作为阶段一般在线性DP中都可行,一开始想以选几个子串做阶段也是可行的,但是复杂一些。
如果只定义三位按题目要求,DP[i][j][p]在A前i个中匹配上B的前j个共有p段,其中A[i]必选(这里如果不定以最后一个必选无法转移),然后就有一个枚举最后一个是直接接在前面还是开新的,如果接在前面,最后一段多长,都需要考虑TLE 70pts
考虑最后一段多长真的需要枚举吗,我们只关心能不能接上,接上之后又几段,所以考虑加一个维度优化掉枚举最后一段有多长
定义状态有两种本质上是一样的方法
相关题目/资料: 法1
这种方法又定义了一个最后一个可选可不选的方案数(如果一开始直接只定义这个无法转移),然后和最后一个必选相互转移
相关题目/资料: 法2加维度
我们转移时要不要新开或者接上只关心上一个的选择情况,上一个如果必选那么就可以接上,如果不选那么不可能接上,所以加一位0/1表示这一i选/不选
注意不选要直接累加上一阶段选或不选的方案,不要漏掉
#include<cstdio>
#define int long long
using namespace std;
const int maxn=1010;
const int mod=1000000007;
int n,m,k,dp[2][201][201][2];
char a[maxn],b[maxn];
signed main(){
scanf("%lld%lld%lld",&n,&m,&k);
scanf("%s%s",a+1,b+1);
//for(int i=0;i<=n;i++) dp[i%2][0][0][0]=1; 初始化法1
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
for(int p=1;p<=k;p++){
dp[i%2][j][p][1]=dp[i%2][j][p][0]=0;
if(j==1&&p==1&&a[i]==b[j]) dp[i%2][1][1][1]=1;//初始化法2
else if(a[i]==b[j]) dp[i%2][j][p][1]=(dp[(i-1)%2][j-1][p-1][0]+dp[(i-1)%2][j-1][p-1][1]+dp[(i-1)%2][j-1][p][1])%mod;
dp[i%2][j][p][0]=(dp[(i-1)%2][j][p][1]+dp[(i-1)%2][j][p][0])%mod;
//printf("dp[%lld][%lld][%lld][%lld]=%lld dp[%lld][%lld][%lld][%lld]=%lld\n",i,j,p,1,dp[i%2][j][p][1],i,j,p,0,dp[i%2][j][p][0]);
}
printf("%lld",(dp[n%2][m][k][1]+dp[n%2][m][k][0])%mod);
return 0;
}
这题要加滚动数组,写代码时先写朴素的再加优化,如果朴素的初值为0那么在使用滚动数组时也要记得清零,这里要清零对应的是如果末尾必选又出现字符不相等的情况那么DP值为0
难点2:初始化
搞清楚边界状态中有没有合法的,或者直接把要用到边界状态的状态算出来,两种方法在代码中都有,二选一即可
写DP时尽量把不和法赋值为0也写出来,加滚动数组时可能要用
题目描述:给定一个长为n的数列,找k个互不相交的子数组,和最大
解法:看到找可以连续可以不连续的一些段时DP状态常常加一位[0/1]表示最后一位选不选从而更新连续关系。或者开两个DP数组一个表示最后一位必选的最优解,一个表示最后一位可选可不选的最优解,然后一般先更新必选的,再拿必选的前i个的最优解更新可选可不选的最优解
也可以定义只有两维最后一步可选可不选的状态,不过要枚举最后一个段的长度,从所有能更新的里面取最大的,无疑增加了时间复杂度$$O(n^2)$$
//法1:dp[i][j][0/1] 前i个数找j个子数组,最后一位不选/选的最大价值
//状态转移方程:
dp[i][j][0]=max(dp[i-1][j][0],dp[i-1][j][1]);
dp[i][j][1]=max(dp[i-1][j][1]+a[i],dp[i-1][j-1][1]+a[i],dp[i-1][j-1][0]+a[i]);//其中分别表示直接接上前一个,可以接上前一个但是新开一组,不能接上前一个新开一组
//法2:G[i][j] 结尾必选的最大价值,F[i][j] 结尾可选可不选的最大价值
G[i][j]=max(G[i-1][j]+a[i],F[i-1][j-1]+a[i]);
F[i][j]=max(G[i][j],F[i-1][j]);
//法3:dp[i][j]前i个数找j个子数组,最后一位选、不选都可以的最大价值
dp[i][j]=max(dp[i][j],dp[i-1][j]]);
for(int k=i-1;k>=0;k--)
dp[i][j]=max(dp[i][j],dp[k][j-1]+sum[i]-sum[k]);
//其中sum为前缀和
这题和上一题子串很相似。技巧:看到找可以连续可以不连续的一些段时DP状态常常加一位[0/1]表示最后一位选不选从而更新连续关系。或者开两个DP数组一个表示最后一位必选的最优解,一个表示最后一位可选可不选的最优解,然后一般先更新必选的,再拿必选的前i个的最优解更新可选可不选的最优解
加维度的优化实质上是我们不关心当前最后一个段长度是多少,有哪些情况,我们只关心能不能接上,不能接上是一种情况能接上是一种情况,优化的原理在于加一个维度或者开两个数组把大规模的问题更加细化了(大状态->小状态,小状态知道的信息更多,从而可以实现转移),从而可以完成或优化转移,从而优化了时间复杂度。
区间DP
相关题目/资料: CSP-S2021弱化版
就是被小w一眼秒掉的模板题
题目描述:一个长度为 n 且符合规范的括号序列,其有些位置已经确定了,有些位置尚未确定,求这样的括号序列一共有多少个。
尚未确定的位置用*表示,答案对1e9+7取模
解法:
定义\(f[i][j]\) 区间\([i,j]\)中指定\(*\)之后合法的方案数,其中\(str[i]、str[j]\)不一定匹配的
容易想到状态转移方程\(f[i][j]=f[i-1][j-1]+sum(f[i][k]*f[k+1][j])\)
其中\(f[i-1][j-1]\)表示\((A)\)的情况\(sum(f[i][k]*f[k+1][j])\)表示\(AB\)的情况,其中AB和合法的括号串
但是这样出锅了,锅出在如果出现对称的情况,由于f数组定义为i不一定匹配上j所以中间有一段可能在靠近j处算了一次,靠近i处算了一次,(实际上是每个断点都会计算)这种重复计算是不允许的。
考虑对匹配情况进行不重不漏的分类,考虑所有情况可以分成枚举k和i必须匹配的多种情况
因此状态转移方程可以写成这样
\(f[i][j]=f[i-1][j-1]+sum(f[i+1][k-1]*f[k+1][j])\)其中i必须与k匹配
为了更好的理解,我们可以定义\(g[i,j]\),区间\([i,j]\)中指定\(*\)之后合法的方案数,其中\(str[i]、str[j]\)一定匹配的
转移方程变为
g[i][j]=f[i-1][j-1] ( str[i]!=')' && str[j]!='(' && i+1!=j )
边界g[i][j]= [ str[i]!=')' && str[j]!='(' && i+1==j ]
状态转移方程变成\(f[i][j]=f[i-1][j-1]+sum(g[i][k]*f[k+1][j])\)
其实等价于只有f的方程
区间DP中们要考虑状态中左右端点的情况,必要而且重要,直接定义左右端点可取可不取可能会出现重复计算的情况,因此我们在分情况列状态转移方程时,可能需要更进一步的把选左右端点的情况,选不选都可以的情况都考虑,从而进行不重不漏的分类
这有点像线性DP里考虑最后一个端点选不选,到了区间DP则变成了两端点选不选
类似的要考虑左右端点情况的还有乘法游戏
状态定义则为:\(f[i][j]\)左右端点被保留\([i+1,j-1]\)全部被删掉的最小花费,转移时需要用到
相关题目/资料: 回文串
题目描述:给定一个串,每次可以任意插入一个字符
问最少插入多少字符变成回文串
解法:简单区间DP
\(DP[i,j]\)表示区间[i,j]变成回文串的最小花费
\(DP[i,j]=DP[i+1,j-1](str[i]==str[j])\)
\(DP[i,j]=min(DP[i+1,j]+1,DP[i,j-1]+1)(str[i]!=str[j])\)
这里和传统的枚举断点的区间DP不太一样
区间DP一般有两个区间合并可以合并或者拆分,或者可以在一个串中间做操作,例如插入删除等,用线性DP无法做到无后效性的表示状态或者无法转移,并且区间DP的数据范围一般比较小几百的量级(枚举断点),或者几千(不用枚举断点)
相关题目/资料: 关路灯
坑点,一开始想到定义区间的最小化费,搞出一个像DP实则贪心的做法70pts
这启示我们在用两个数组更新时一般两个数组相互更新而不是一个用来记录令一个的数据
检验的时候我可以考虑到一个状态如果出现目前花费相同的情况对后面是否真的相同
之前的假做法假就假在这一步看上去花费最少了实际上浪费了时间,所以不一定是最优的
而真的做法遇到相同的情况对后面更新状态不会因为追求这一步优而导致后面变差了
DP要满足追求这步优的同时,考虑后面,不要一味的满足这步优而牺牲了后面的,遇到这种情况时大概率搞出来的是假做法
要考虑更改状态定义
例如这题,反过来想,DP[i][j]表示了区间[i,j]被取走的时刻所有灯的最小功耗,从只最小化区间功耗(假做法),变成了满足所有灯最小功耗
假做法代码
#include<cstdio>
#include<cstring>
#include<cmath>
#include<algorithm>
using namespace std;
const int maxn=60;
int c,w[maxn],n,dis[maxn],dp[maxn][maxn][2],g[maxn][maxn][2];
int dist(int x,int y){
return abs(dis[x]-dis[y]);
}
int main(){
memset(dp,0x3f,sizeof(dp));
scanf("%d%d",&n,&c);
for(int i=1;i<=n;i++) scanf("%d%d",&dis[i],&w[i]);
for(int i=1;i<=n;i++) dp[i][i][0]=dp[i][i][1]=dist(i,c)*w[i],g[i][i][0]=g[i][i][1]=dist(i,c);
for(int d=2;d<=n;d++)
for(int i=1;i<=n-d+1;i++){
int j=i+d-1;
if(dp[i+1][j][0]+(dist(i+1,i)+g[i+1][j][0])*w[i]<dp[i+1][j][1]+(dist(i,j)+g[i+1][j][1])*w[i])
g[i][j][0]=(dist(i+1,i)+g[i+1][j][0]),dp[i][j][0]=dp[i+1][j][0]+g[i][j][0]*w[i];
else if(dp[i+1][j][0]+(dist(i+1,i)+g[i+1][j][0])*w[i]>dp[i+1][j][1]+(dist(i,j)+g[i+1][j][1])*w[i])
g[i][j][0]=(dist(i,j)+g[i+1][j][1]),dp[i][j][0]=dp[i+1][j][1]+g[i][j][0]*w[i];
else if(dist(i+1,i)+g[i+1][j][0]<dist(i,j)+g[i+1][j][1])
g[i][j][0]=(dist(i+1,i)+g[i+1][j][0]),dp[i][j][0]=dp[i+1][j][0]+g[i][j][0]*w[i];
else g[i][j][0]=(dist(i,j)+g[i+1][j][1]),dp[i][j][0]=dp[i+1][j][1]+g[i][j][0]*w[i];
if(dp[i][j-1][1]+(dist(j-1,j)+g[i][j-1][1])*w[j]<dp[i][j-1][0]+(dist(i,j)+g[i][j-1][0])*w[j])
g[i][j][1]=(dist(j-1,j)+g[i][j-1][1]),dp[i][j][1]=dp[i][j-1][1]+g[i][j][1]*w[j];
else if(dp[i][j-1][1]+(dist(j-1,j)+g[i][j-1][1])*w[j]>dp[i][j-1][0]+(dist(i,j)+g[i][j-1][0])*w[j])
g[i][j][1]=(dist(i,j)+g[i][j-1][0]),dp[i][j][1]=dp[i][j-1][0]+g[i][j][1]*w[j];
else if(dist(j-1,j)+g[i][j-1][1]<dist(i,j)+g[i][j-1][0])
g[i][j][1]=(dist(j-1,j)+g[i][j-1][1]),dp[i][j][1]=dp[i][j-1][1]+g[i][j][1]*w[j];
else g[i][j][1]=(dist(i,j)+g[i][j-1][0]),dp[i][j][1]=dp[i][j-1][0]+g[i][j][1]*w[j];
}
printf("%d",min(dp[1][n][0],dp[1][n][1]));
return 0;
}
真做法代码
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
using namespace std;
const int maxn=60;
int s[maxn],dis[maxn],w[maxn],dp[maxn][maxn][2],n,c;
int dist(int x,int y){
return abs(dis[x]-dis[y]);
}
int sum(int l,int r){
return s[l-1]+s[n]-s[r];
}
int main(){
memset(dp,0x3f,sizeof(dp));
scanf("%d%d",&n,&c);
for(int i=1;i<=n;i++) scanf("%d%d",&dis[i],&w[i]),s[i]=w[i]+s[i-1];
for(int i=1;i<=n;i++) dp[i][i][0]=dp[i][i][1]=dist(i,c)*s[n];
//dp[c][c][0]=dp[c][c][1]=0;
for(int d=2;d<=n;d++)
for(int i=1;i<=n-d+1;i++){
int j=i+d-1;
dp[i][j][0]=min(dp[i+1][j][0]+dist(i+1,i)*sum(i+1,j),dp[i+1][j][1]+dist(j,i)*sum(i+1,j));
dp[i][j][1]=min(dp[i][j-1][1]+dist(j-1,j)*sum(i,j-1),dp[i][j-1][0]+dist(i,j)*sum(i,j-1));
}
printf("%d",min(dp[1][n][0],dp[1][n][1]));
return 0;
}
加维度时考虑最后一步更新过来需要什么状态,什么状态要记录
相关题目/资料: sue的小球题解(现在行动对未来价值有影响的类型的DP的研究)

相关题目/资料: 决斗
难点
如何减省状态,很好的表示状态
解法
这题首先状态表示很不常规
如果一开始直接把答案存进数组肯定不行,因为这题一个区间可能有很多人战胜
所以考虑判定性问题,\(DP[i][j][k]\)表示\([i,j]\)中k胜利有没有可能,状态很复杂转移花费也是至少\(O(n)\)妥妥TLE
考虑去除冗余的状态
我们最后需要知道哪个点战胜
我们要考虑战胜的充分必要条件是什么,只有知道充分必要条件,才能很好的减省状态,如果一个人把他能遇到的人都打败了那么他可以活到最后
我们先破环为链,一个点如果能遇到自己则可以活到最后,因为他满足了把所有他能遇到的人都打败。
二维简单区间DP判断i是否能遇到j即可
一些难题
相关题目/资料: 方块消除
难点:
状态表示,如果直接二维无法转移,无法表示消掉一段后相同颜色的接上,
解法
先做一个小简化:由于连续一段颜色相同的肯定放在一起消掉好,所以直接压成两个数组color和len(不做简化也能做,方法相同)
加一位表示连上j的后面与j颜色相同有有k个,k必须表示个数,有可能多段压在一起,不只是后面一段
考虑最后一步消掉哪一段,首先最后一段肯定需要消掉
考虑如何消掉最后一段
1.直接连通k一起消掉
2.接在前面某个位置
不难列出转移方程
这题要解决的矛盾是接上去的矛盾,为了使得价值更大我们应该想办法接上前面某些区间组成更长的区间,为了表示多段接上的区间加一个维度表示区间的长度
这题是稀疏问题,记忆化搜索过2e10,有记忆化搜索不用怕吓人的计算量
相关题目/资料: No Need
建模是难点
要转换成去掉一个物品满足什么体积的判定性背包
通过数学建模发现如果一个物品有用,则出去他一定可以装满[K-1,K-a(i)]的背包
一个暴力的做法,枚举一个去掉的物品,然后去掉他做一次背包
\(O(n^2k) TLE\)
考虑优化
常见的优化方法
1.二分
发现排序完之后答案有单调性,于是\(O(nlognk)\)
2.分治
问题需要求的是去掉一个物品的背包
去掉不同的物品有大量重复计算的两
考虑分治
分成两个区间,两个DP数组一个左边的贡献到右边一个右边的贡献左边
最后答案缩小到l==r的时候,由于二分一定是只有最后一个没有贡献到那个数组里
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=5010;
int n,k,a[maxn],dp[maxn][maxn],d[maxn];
void solve(int l,int r,int dp1[]){
if(l==r){
dp[l][0]=1;
for(int i=1;i<=k;i++) dp[l][i]=dp1[i]+dp[l][i-1];
return;
}
int mid=(l+r)>>1;
int dp2[maxn];
dp1[0]=dp2[0]=1;
for(int i=0;i<=k;i++) dp2[i]=dp1[i];
for(int i=l;i<=mid;i++)
for(int j=k;j>=a[i];j--)
dp1[j]|=dp1[j-a[i]];
for(int i=mid+1;i<=r;i++)
for(int j=k;j>=a[i];j--)
dp2[j]|=dp2[j-a[i]];
solve(l,mid,dp2),solve(mid+1,r,dp1);
}
int main(){
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
solve(1,n,d);
int ans=0;
for(int i=1;i<=n;i++){
if(a[i]>=k) ans++;
else
if(dp[i][k-1]-dp[i][k-a[i]-1]>0)
ans++;
}
printf("%d",n-ans);
return 0;
}
\(O(nk)\)的背包做法待补充
贪心DP
相关题目/资料: Chopsticks
贪心结论:前两根筷子必相邻
困难:顺着结合结论DP要三维即以i为前缀j个三元组其中要补充k个第三根的最小花费
其中第三个维度是为了消除两维所带来的正着推不知道第三根在后面的哪里的后效性而加的维度
TLE这种做法
考虑如何消除后效性
既然需要用到后面的信息,我们不妨考虑状态定义为一个后缀
这样二维可以AC本题
LSGDOJ 青蛙的烦恼(无oj)
池塘中有n片荷叶恰好围成了一个凸多边形,有一只小青蛙恰好站在1号荷叶上,小青蛙想通过最短的路程遍历所有的荷叶(经过一个荷叶一次且仅一次),小青蛙可以从一片荷叶上跳到另外任意一片荷叶上。
因为凸多边形性质:最短路径一定不相交时,所以i-k的路径中只会往i-1或k+1扩展(三角形三边关系证明)
有了贪心结论,可知我们依次遍历的点一定是连续的,于是我们可以用已经遍历的点的长度划分阶段。
F[i][j][0]表示从i出发遍历ii+j-1的最短路径,F[i][j][1]表示从i+j-1出发遍历到ii+j-1的最短路径
F[i][k][0]=min(F[i+1][k-1][0]+dis[i][i+1],F[i+1][k-1][1]+dis[i+k-1][i]);
F[i][k][1]=min(F[i][k-1][1]+dis[i+k-2][i+k-1],F[i][k-1][0]+dis[i][i+k-1]);
这题给出了圈DP问题的一个好的解决方案,如果定义另一个方向是i,i-1...i-k+1那么会出现负数的问题,所以我们不妨定义成从另一个顶点调回来,即上面的定义。
相关题目/资料: NOIP2021方差
难点:发现两个性质,DP时对于状态的缩减。
通过画数轴手玩样例我们发现操作的本质是交换差分数组。
举例子,简化版问题 最小化\(sum(a_{i})\) 不难想到贪心策略是从小到大排差分数组,而方差多加了一个到某个点的距离,可以猜想差分成单谷。
技巧:这种结论题数据范围不大的得到结论之后一般要DP。
先将差分数组中心定在0,开两个维度记录上下界,一个维度记录和,容易得到一个四维DP。
负数不好处理,我们考虑将所有差分填在正半轴,去掉下界的维度,三维DP列方程时,发现记录上界的一位就是前缀和,不用记录在状态里。
于是得到了正确的二维DP。
分析时间复杂度时我们发现最多只可能存在 \(\max(a_{i})\) 个非0差分。
于是可以通过此题。
单谷证明咕。
相关题目/资料: Tower(AT_DP_x)
你有 \(n\) 个箱子,编号从 \(1\) 到 \(n\),每个箱子有三个属性,以第 \(i\) 个箱子为例,分别是重量 \(w_i\),承重能力 \(s_i\),价值 \(v_i\)。
你想建一座塔,因此需要将一些箱子堆叠起来,但是每个箱子必须满足下面的条件:
- 这个箱子上面的所有箱子重量和要小于这个箱子的承重能力。
定义一个塔的价值为它所用的所有箱子的价值和。
最大化这个塔的价值并输出它。
数据范围
\(n \le 10^3, 1 \le w_i, s_i \le 10^4, 1 \le v_i \le 10^9\)。
难点:直接DP难以确定顺序,要找一个对于所有解的序并且不会漏掉最优解。
法1:设一个(最优)解X中 \(i,j\) 同时被选且 \(i\) 的下方是 \(j\)。
假设物体 \(i\) 上方的总体积为 \(A\)。
必然有:
- \(A<=S_{i}\) 且 \(A+W_{i}<=S_{j}\) (1)
如果交换 \(i,j\) 同样可行,也是最优解那么还要满足。
- \(A<=S_{j}\) 且 \(A+W_{j}<=S_{i}\) (2)
我们的人物是找到某个序结合(1)可以推出(2),这样就可以保证在所有元素有某个序的关系并且不漏掉最优解。
显然,当 \(A<=S_{j}-W_{i}<=S_{i}-W_{j}\) 时用(1)可以推出(2)。
那么将所有元素按照上面的序排序,不会漏掉最优解,并且找到了一个确定的序,可以DP。
法2:设一个任意情况下所有元素按照任意顺序排列,存在两个相邻元素,考察什么情况下交换两个元素不会变差。
我们令 \(t=W_{1}+W_{2}+...+W_{i-1}\),两个相邻的元素为 \(i,i+1\)。
如果原来满足 \(t+W_{i}>S_{i+1}\) 并且 \(S_{i}>=t+W_{i+1}\) 时一定不差,因为这时原来不满足的现在可能满足,原来满足的现在一定满足,是严格编号的,这时综合上面两个条件,可以得出 \(S_{i}+W_{i}>S_{i+1}+W_{i+1}\) 交换不会变差,这是一个观察和思考的过程,证明还需要证反方向。
即 \(S_{i}+W_{i}>S_{i+1}+W_{i+1}\) 讨论两个元素之前的情况,用这个条件结合两个元素之前的情况,简单推一下不等式可以推出不坏的情况一定满足,或者坏的情况一定不会发生。
证毕。
两个方法对应了贪心的两种证法,都值得理解体会,第一种一般是比较简单,因为限定了最优解的情况,排除了很多一般解的讨论。
树形DP
2023.9.11
相关题目/资料: Game on tree 3
技巧:树上问题一般考虑从叶子往上解决(无论是贪心还是DP),如果解决不了再考虑从上到下。因为树是递归定义的,从下往上一般比较容易证明正确性。确保正确性的树形DP优于无法证明正确性的贪心。
基环树DP
相关题目/资料: 骑士
技巧:n个点n条边是基环树。基环树上DP可以转化为换上每个点所在的树上DP,然后再在环上解决问题。
尤其注意处理基环树森林和两个点的环的情况。(给边打标记)
状压DP
相关题目/资料: A simple task
技巧:在计数问题中,如果有多次重复计算同一种情况发生,那么可以钦定只在包含特殊点的情况下计数(如将最小点,最大点作为代表),并将特殊点作为原来导致等效冗余发生的枚举上,可以减少时间复杂度。
在枚举一些种类时,思考原来的状态中是否可以直接取一种特殊的点作为一种情况的代表,再利用偏序,从而无需枚举。
期望DP
相关题目/资料: 绿豆蛙的归宿
技巧:期望DP的方向一般是到终点的步数便于递推和确定终点就是边界为0步。
可以考虑将期望DP的转移建模成图论问题,用每条出边连向的点的期望*这条边起点到终点的概率+这条边起点到终点的花费。

同时更换DP方向有时候能达到减少状态,利用后面的信息推前面减少维度,从而优化时空复杂度,完成转移的效果。
例如:Chopsticks
相关题目/资料: Sushi
类似绿豆蛙归宿的基础期望DP题。
技巧:状态中有可以合并的同类项需要合并在一起设计状态。
相关题目/资料: 换教室
- 给定一个v个点e条边的无向图。
- 给定n,表示要上n节课,其中m节课可以申请换到另一间给定的教室,概率为\(p_i\)。原教室是\(c_i\)可以换到\(d_i\)。
- 并且对于不同课程的申请,被通过的概率是互相独立的。
- 必须一次性决定是否申请更换每节课的教室,而不能根据某些课程的申请结果来决定其他课程是否申请。
- 求从头到尾依次上完n节课的最小期望,保留两位小数点输出。
- 保证 1≤n≤2000,0≤m≤2000,1≤v≤300,0≤e≤90000。
解法:
期望DP经典解法:期望的线性性+全概率公式。
直接定义状态:\(DP[i][j]\)表示上完前i节课用掉了j次换教室的次数的最小期望距离。
根据:
- 期望的线性性\(E(X+Y)=E(X)+E(Y)\),本质上就是计算整体的期望等于每步期望单独计算再把每步累加起来。
- 全概率公式:单独计算每步期望分类讨论所有情况,单步期望=每种情况的概率*权值累加起来。
以上两种方法是期望DP的常见方法。
思考最后一步转移,如果要完成转移,需要知道每一步是在c,还是d教室上课。
那么加一个维度\(DP[i][j][0/1]\)表示是在d还是c。
如果在d只有一种情况,申请换教室并且成功,概率为p_i。
如果在c有两种情况,申请换教室没成功或者未申请换教室未申请换教室的概率不知道,所以这样设计状态难以完成转移,其实可以转移但是也是错的。
不妨根据已知的概率的条件即是否更换教室分类,设计状态为\(DP[i][j][0/1]\),表示不申请,申请换教室。
- 不申请换教室,一定在c并且概率为100%
- 申请换教室,p的概率在d,1-p的概率在c。
根据以上情况可以分类讨论列状态转移方程,在上一步是0还是1中取min即可。
技巧:期望DP根据已知概率的种类分类讨论,定义状态。
注意:不要分成申请成功,申请不成功,不申请三种,因为不存在独立的申请不通过状态,因为申请不通过如果独立出现,就会有根据申请不通过的概率来动态调整是否申请后面的教室。与题意“必须一次性决定是否申请更换每节课的教室,而不能根据某些课程的申请结果来决定其他课程是否申请。”不符。
取min的时候尽量根据题目分大类,如果大类下面再分小类并且并联取min的时候要尤其注意正确性以及和题意是否相符的判断。尽量分成同层级并列、平行的大类。
与绿豆蛙的归宿的区别,绿豆蛙的转移方程f[i]=∑p[i→j]f[j]+p[i→j]w[i→j]。
绿豆蛙也是运用了期望的线性性算每一小步,但是他的分类都一样比较简单。
这里f也要乘概率的原因是前面倒过推来的权值的概率原本就要算上这一步,只是利用了乘法分配律边算最新的一步,同时更新之前步数需要的概率,而不是同一个阶段的状态一步到位,省去了另一个要计算的概率函数g是一种写代码优化的手法。
而换教室题目中“ 并且对于不同课程的申请,被通过的概率是互相独立的”这句话就说明当前阶段的概率不收到之前的影响。如果没有这句话就可以像绿豆蛙一样将f数组也乘上对应的概率。
期望DP正推和倒推都是常见的方法,可以根据题目要求的状态和初始时已知的状态灵活调整,初始时知道第一个阶段的值就顺推,知道最后一个阶段的值就倒推。一般情况下两种写法都可以,但是可能有一种方法需要另外同时计算当前阶段的概率。
常见的DP中的技巧
相关题目/资料: CSP-S2021括号序列
根据超级括号序列的构成结合数据范围可以判断是区间DP。
根据构成容易列方程。
但是这题的难点的 \(AB\) 和 \(ASB\) 的情况容易算重,会在每个断点出都算一次,要解决这个问题是本题的难点之一。
用一个DP问题常用的技巧加维度,可以解决这个问题。
技巧:DP中如果出现算重的情况,可以增加维度,一般为会算重的那种情况出不出现的方案数,从而可以经过特定的方法避免算重。
这种DP去重的特定讨论的套路值得学习
这题 \(DP_{i,j,0/1}\) 表示,区间 \([i,j]\) 通不通过 \(AB\) 和 \(ASB\) 转移得到的方案数,转移时相当于确定最左边的区间不通过 \(AB\) 和 \(ASB\) 转移得到,而右边可以通过 \(AB\) 和 \(ASB\) 转移得到,这样就相当于规定了只在第一个端点算或者是最左边括号序列的最左的括号一定匹配到最左括号序列最右的右括号。
相关题目/资料: 具体解法
当然我们也可以多加一个数组,大同小异的解决问题,一样是限定只在第一个断点算或者理解成最左边的区间两端点括号必须匹配。
相关题目/资料: 具体解法
这题的第二个难点是枚举 \(ASB\) 时要枚举两个断点,这里观察状态转移方程,可以发现对于一个断点,他的另一个断点是连续且可以提前确定的,即 S 中最右边不超过当前 \([i,j]\) 的范围,右因为是做加法运算,我们可以前缀和优化,即可通过此题。
这题的去重和优化思想都值得理解和学习。
#include<cstdio>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=510;
const int mod=1e9+7;
int f[maxn][maxn],n,t,g[maxn][maxn],tail[maxn],s[maxn];
char str[maxn];
bool ok[maxn][maxn];
signed main(){
scanf("%lld%lld\n",&n,&t);
scanf("%s",str+1);
for(int i=1;i<=n;i++){
if(str[i]!='*'&&str[i]!='?') continue;
for(int j=i;j<=i+t-1;j++){
if(str[j]!='*'&&str[j]!='?') break;
ok[i][j]=1;
tail[i]=j;
}
// printf("tail[%lld]=%lld\n",i,tail[i]);
}
for(int i=1;i<n;i++)
if((str[i]=='?'||str[i]=='(')&&(str[i+1]=='?'||str[i+1]==')'))
f[i][i+1]+=1;
for(int d=3;d<=n;d++)
for(int i=1;i<=n-d+1;i++){
int j=i+d-1;
if((str[i]=='?'||str[i]=='(')&&(str[j]=='?'||str[j]==')')){
f[i][j]+=ok[i+1][j-1];
f[i][j]+=f[i+1][j-1]+g[i+1][j-1];
f[i][j]%=mod;
for(int k=i+2;k<j;k++)
if(ok[i+1][k-1])
f[i][j]=(f[i][j]+f[k][j-1]+g[k][j-1])%mod;
for(int k=j-2;k>i;k--)
if(ok[k+1][j-1])
f[i][j]=(f[i][j]+f[i+1][k]+g[i+1][k])%mod;
for(int k=i;k<j;k++)
g[i][j]=(g[i][j]+f[i][k]%mod*(f[k+1][j]+g[k+1][j])%mod)%mod;
for(int k=i-1;k<=j;k++) s[k]=0;
for(int k=i;k<=j;k++) s[k]=(f[k][j]+g[k][j]+s[k-1])%mod;
for(int k1=i;k1<j;k1++){
int l=k1+2,r=min(tail[k1+1]+1,j);
if(l>r) continue;
g[i][j]=(g[i][j]%mod+f[i][k1]%mod*(s[r]-s[l-1])%mod)%mod;
}
}
}
printf("%lld",(f[1][n]+g[1][n])%mod);
return 0;
}
相关题目/资料: Knapsack 2
技巧:答案作为参数,放进状态。
其他常见的 技巧:
相关题目/资料: NOIP2021数列
难点:直接涉及状态难以完成进位的转移。
简单的可以状压,但是时空都超。
巧妙的解决方法:从小到大枚举值为 \(0-m\) 的 \(a\) 数组,多开一维记录进位,并且记录当前的1的个数。
技巧:合理的枚举顺序可以处理转移时可能出现需要状压才能解决的问题,例如从小到大枚举位数,记录进位。
如果按现在枚举到 \(a_{i}\) 的 \(i\) 来划分阶段要实现转移状态是 \(n^{4}m\) 的,状态定义为 \(DP_{i,j,k,c,s}\) 到 \(a_{i}\) 的前缀中, \(a_{i}=j\) 且共有k个连续的 \(a=j\) 进位为 \(c\),共 \(s\) 个 \(1\) 的权值和。
我们发现本质上是按 \(2^j\) 的 \(j\) 从小到大枚举,而记录 \(a_{i}=j\) 的连续个数的k维度没有意义因为按 \(j\)的大小从小到大枚举可以一次把这个二的幂考虑完,无需分成多次。
于是我们把状态改成按当前枚举的2的幂为阶段。
技巧:枚举时有多个维度可以作为阶段的考虑更改一下阶段,可能可以减省维度。
对于要累加相乘和为权值的,直接在每一个阶段分别完成加和乘即可,公用的乘的地方由于分配律会考虑到,加的地方本来就是不同的类,到最后本来就不应该考虑在一起。
相关题目/资料: 积木游戏
对于不用连续的理解:1. 转移时可以跨过一些物品不用。 2. 对于一个物品可以用可以不用。
相关题目/资料: 协议
将 \(k\) 个互不相同的数字构成的长度为 \(m\) 的序列中不超过 \(l\) 个连续相同的数的数列的方案数为 \(cnt\)。 输出 \(log_{2}(cnt^{\frac{n}{m} })\) (向下取整)。
朴素DP:
\(DP_{i,j,k}\) 表示前i个数最后k个为j的方案数
\(DP[i][j][k]+=DP[i-1][j][k-1]\)
\(DP[i][j][1]+=DP[i-1][t][k]\)
时间复杂度 \(O(mlk^{2})\)
考虑进一步减省状态。
技巧:对于每一新元素对于前面阶段的各种元素的相同情况的贡献一样可以减省掉状态中每一元素的值,改为单个元素对于前面整体的贡献。
具体地:
用 \(DP[i][j]\) 表示前i个元素有j个连续相同的方案数。(最后一个数可以是各种情况。)
将最后一个数可以是所有的情况都考虑进来因为最后一个无论是什么相对于i-1(上一阶段)的所有情况所取的情况数是固定的,直接乘上即可无需考虑最后一个和上一个具体是什么即可完成转移。
每个数字如果和之前一样要接上,对于任意一个数新开河上一个阶段的任意数都要相同所以方案数直接为 \(DP[i-1][j-1]\)。
如果要新开,那么对于之前i-1状态的每个数都有 k-1 种选择直接乘上之前的方案数即可。(乘法分配律可以解决具体对于每种数)。这里就是上面说的对于每种情况的贡献相同。
优秀的DP:
\(DP[i][j]=DP[i−1][j−1]\)
\(DP[i][1] = (k - 1)\sum_{j = 1}^{l - 1}DP[i - 1][j]\)
复杂度 \(O(ml)\)。
2023.9.1
相关题目/资料: candles
给定n<=300根蜡烛的长度和坐标,一开始在数轴原点,每碰到一根蜡烛就会熄灭它,每个蜡烛每秒减短1单位长度,每移动一个单位长度耗时一秒,求最终蜡烛长度累加和的最大值。
解法:
和关路灯类似。属于当前决策对未来价值有影响,并且这个价值要用于计算答案。 类型的DP。
如果直接存时间计算当前这个状态的价值,会因为状态数太大TLE。
解决方法:把未来决策放到当前状态计算,那么当前状态的价值可以直接用初始权值计算,因为它的变化量已经在之前的步骤中算进去了。
性质1:路途中碰到的蜡烛一定会熄灭。
性质2:有一些蜡烛在到达它的时刻之前一定长度为0。
由于性质1,可以得到每个状态熄灭的蜡烛一定构成一个区间,所以采用区间DP。
由于性质2,在每种决策中,都可能存在可有可无的蜡烛,因为熄灭或者不熄灭它的两种决策都对答案没有影响。这些可有可无的蜡烛带来的负价值一定不会算到最优解里即最优解熄灭的蜡烛不一定是n个,不熄灭一些蜡烛可能会更优。
所以它的价值不应该计算到答案里。
因此,我们加一个维度,表示还有k个蜡烛没有熄灭,一开始的k就是在这种初始状态下最终决策一共熄灭的蜡烛数。
根据k可以计算每一步的未来价值,问题得到解决。
拓展1:我们思考的时候可能会想到枚举一开始有用的状态数,然后在DP数组中记录当前已经熄灭的蜡烛数。
但是这样实现是O(n^4)的TLE。
技巧:对于枚举一个数作为DP的某个状态的上限,并且对于每一个枚举的数算出来的DP数组都是一样的,那么可以倒过来表示状态,直接把这个上限作为状态(即倒过来枚举),可以大大减少时间复杂度。
拓展2:考场为什么没有思考出【性质2:存在可有可无的蜡烛】的解决方案?
思路1:
忽略掉长度减到0的约束,问题变成了关路灯。
通过写状态转移方程或者手动模拟一些决策可以发现,某些蜡烛的贡献是负数,采用贪心的思想这些情况一定不会考虑熄灭这些蜡烛。
进而增加维度。
技巧:解决问题时,若每个元素的价值计算方式无法统一,那么可以通过增加忽略某些约束得到简化版问题,通过解决简化版问题的启发,进而可以得到一些性质,结合简化版问题的解法解决原问题。
对于本题:忽略某些约束,从而使得问题中对于每个元素价值的计算方案统一。这时可以得到简化版问题,对于简化版问题中采用贪心思想得出某些性质,进而通过添加维度等方式可以解决原问题。
思路2:
通过手动模拟一些决策(需要自己多举几个例子),发现一些蜡烛可有可无,或者说他们对答案没有正的价值。
技巧:对于对答案没有正价值的元素,一般的处理方式是在最终决策中忽略掉它,即不要计算这些元素的任何贡献。导向的操作一般是添加维度,表示最终一共考虑k个元素。
(用时2h,包括写代码)
2023.9.2
相关题目/资料: Divide, XOR, and Conquer
给定n<=1e4个数,每次可以划分成一段非空前缀和非空后缀。丢弃异或和较小的部分,如果异或和相同则可以任意丢弃一个部分。求对于每一个元素是否能留存到最后。
解法:
耗时6h,思考正解1h以内,优化空间1h,调代码细节4h。(log函数丢失精度。)
- 对longlong类型取对数不要直接用log或者log2函数,哪怕强转long double可能也会丢精度,最好手写二分。
- bool类型的数组输出不要直接用%d或者%lld最好先强转。
- 用1<<如果是longlong类型要强转,1ll<<60。
性质1:能存活的区间一定是连续的。
因此可以以区间划分状态考虑DP。
暴力转移,从区间大到小O(n^3)TLE,考虑优化。
考虑观察异或的性质,思考一个区间如果存活,不失一般性的考虑包含它的存活的大区间的左端点和他一致,右端点大于他。
那么大区间包含两个部分,一个是我们需要判断是否存活的区间,另一个是大区间与这个区间的补。
在一个大区间中,如果其中一个区间能在一次舍弃中留存下来,那么大区间异或和最高位1的位置时两个小区间第一个不相同的位置。
采用贪心的思想,找到两个数第一个不相同的位置,那个位置为1的数为较大数。
大区间的异或和刚好可以用于求第一个(最高)不相同的位置,采用二分找大区间异或和最高位1即可。
技巧:位运算比大小可以从二进制贪高位1来考虑。
剩下的解法就是考虑每个区间右端点的右边是否存在r',首先满足[l,r']可以存活,其次所有r'的sum[l,r']的二进制最高位构成的位置集合中sum[l,r]的二进制数至少在这些位置上的一位为1。
这是考虑右边的做法,再记一个相同的变量表示左边即可。
特殊的,如果存在一个sum[l,r']为0,那么lr一定合法。
为什么没想出来?
没想到异或性质来优化DP。
通过思考位运算的性质可能可以优化DP的转移。
位运算大小的性质一般和贪最高位有关。
拓展1:
思考如何优化转移可以从两个方向考虑,一个是当前状态如果合法,子问题要满足什么条件才合法。
或者一个状态如果合法,包含它的更大问题要满足什么条件。
技巧:对于两个方向的DP转移,不失一般性可以只思考一个方向,另外一个方向对称来做。思考如何优化转移可以考虑能够构成转移的情况需要满足什么充分必要条件,可以从贪心的角度思考。思考DP常见的两种转移方式(可以从合法的点转移到子问题,或者哪些更大问题包含这个子问题。)
难点2:空间n^2->n。
对于区间从大到小依次转移的数组,我们可以考虑是否可以动态表示,即定下来左端点,只需要从大到小枚举右端点就可以实现转移,并且可以保证当前区间的需要值可以直接表示出来即可完成优化。
技巧:对于前缀后缀单方向转移的一般可以实现二维优化到一维,因为可以定下来一个端点,倒序枚举另一个端点,滚动转移。
核心是找到能当前状态的状态的变量的值(判断是否能更新的值)与当前状态的关系,从而可以考虑优化。(数据结构)
DP[i][j]|=DP[i]k,核心是找到\(s(i,k)\)与\(s(i,j)\)的关系,从而可以优化状态转移方程。
空间优化就考虑对某个定区间端点是否能边更新并且可以表示当前状态。(数组下标记的是定的,而不是动态更新的端点的值。)
(回顾上面两题23min)
有向无环图上的DP
相关题目/资料: Completely Searching for Inversions
给定一个n<=1e5,m<=2e5的有向无环图,从1可以到达任意点。给定如下一段搜索程序,求按搜索顺序走过的边经过的边权(只有0,1)构成的序列的逆序对数。
// dfs from vertex i
void dfs(int i) {
// iterate each edge of vertex i that is directed away from it
for(int j = 1; j <= S[i]; j++) {
Z.push_back(W[i][j]); // add the integer in the edge to the end of Z
dfs(L[i][j]); // recurse to the next vertex
}
}
解法:
耗时约5h。
考场思维非常混乱,因为没有想到具体解法,(重点是没有确定求解问题的顺序,记录的变量的含义没有搞清。)导致浪费大量时间。
场下调代码由于分类讨论漏掉一类导致花费大量时间。
具体解法:
有向无环图上DP。先topo排序,倒着访问topo序列可以保证求一个点的值时它的儿子都已经解决。
求三个DP值,一个是从该点出发所构成序列的0的个数,1的个数,和该点出发子问题的答案。
不难列出DP方程。
启示:
1.think twice,code once。把求解顺序,解法,每个变量的含义想想清楚再写代码,否则会浪费大量调试时间。
2.有向无环图DP首选topo排序做法,其次也可以选记忆化搜索的做法。
3.树形DP划分子问题要后序遍历求解,保证子问题已经解决。
4.对于多个DP值的求解,往往一次dfs求解会更多细节(要考虑顺序),分成多次会更简单,有把握确定顺序后可以考虑在一次dfs中求出来。
数位DP
相关题目/资料: 数字计数
给定L,R,求[L,R]中0~9的个数。
如果直接DP会发现计算某个数的贡献要*后面的size,于是除了DP数组还要存size数组,很容易写错。
可以考虑加一个维度sum,表示到当前第len位前面有几个要计算的数字x。最后在边界的时候把sumreturn回去可以很方便的处理*size的问题。
技巧:如果数位DP计数需要*size,一个简单的方法就是加一个维度,在最后return的时return回去即可。
将当前的状态的贡献先记录,到最后再计算。
第六章 图论:把关系转化为点和边
本章定位:掌握从题面建图、选择图算法、验证图模型的能力。
题目识别信号:题面存在关系、依赖、路径、连通、先后顺序、限制条件、最短代价等信息。
学习方法:图论题的第一关是建图。先确定点是什么、边是什么、边权是什么,再判断需要最短路、拓扑序、连通性、二分图还是树上算法。
复盘重点:复盘时重点写:点的定义、边的含义、是否有向、是否有权、是否存在负权、算法适用条件是否满足。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是图的建模、BFS/DFS、最短路、生成树、Tarjan、连通性和图上 DP。图论题的关键通常是把题意转成正确的图结构。
常见类型的图
二分图
判定:染色法。
性质:
- 可以二着色。
- 无奇圈。
BFS&DFS
相关题目/资料: 树的直径模板
两遍dfs/bfs,证明时反证法的核心是用假设推出矛盾。
设1是一开始随机选的点,s是与其最远的点,证明s是直径的一端。
反证:假设s不是直径的一端,ss是直径的一端。
现在要做的就是证明ss是直径的一端是错误的,从而不存在s的反面的情况即可完成证明。
要证ss是直径的一端是错误的,那么要将ss所在的最长的径与直径比较,这里在证明时卡顿,要提升推理速度。
要将ss所在的最长的径与直径比较,就要讨论1和s所在的径与ss的全部情况。
如果全部情况都能证明ss是直径是错误的,那么就能完成证明。
分类讨论是证明的关键。
我们可以分为1在or不在ss所在的最长径上。
(1)1在。
(2)1不在。
2.1:1到s的径与ss的“直径”有公共部分。
2.2:无公共部分。
这样就讨论完了全部情况。
最短路(重点)
图概念
点:图论中的基本元素,表示多种意义。
边:点与点之间存在某种联系的桥梁。
本质:图可以表示不同点之间多对多的关系。
一个有用的公式:
点的度数之和=边数*2。(无向图)
特别地,在树中,点的度数之和=(n-1)*2。
运用:将点的度数之和与边数建立了联系。
一个在最短路问题中证明常常用到的实用工具:BFS生成树。(尤其是边权为1的无向图)
- bfs生成树可以在奇偶性相关的证明中利用层次关系证明(e.g.二分图两个性质互推)。
- bfs生成树也是边权为1的无向图的一个点的最短路径树,揭示了其他点到根的距离信息。
例如:ABC281G
构造一张 \(N\) 个点的无向连通图,边权都是 \(1\)。记图中 \(1\) 到 \(u\) 的最短路径长度为 \(d_u\),你需要保证 \(\max\{d_1,d_2,...,d_{N-1}\}\) 严格小于 \(d_N\)。求构造方案数模 \(M\) 的值,方案区分节点编号。
难点:将题目转化为构造bfs生成树数量,利用bfs生成树的层次关系计数。
问题转换为:找bfs生成树,其中1在第一层,n在最下面一层。
这是一个计数问题,可以用DP+组合数学解决。
\(DP_{i,j}\)表示除去1和n,一共放了i个点,最后一层j个的方案数。
省略掉bfs生成树层次这个冗余信息,不将它放到状态,而用已经安排i个点划分阶段同样是个难点。
转移:与bfs生成树上一层的方案数+这一层之间的方案数+上一阶段的方案数。
细节:mi初始化不要算少了。
#include<cstdio>
#define int long long
using namespace std;
const int maxn=510;
int mi[maxn*maxn],n,m,dp[maxn][maxn],ans,C[maxn][maxn],mii[maxn][maxn];
signed main(){
scanf("%lld%lld",&n,&m);
mi[0]=1;
for(int i=1;i<=n*n;i++) mi[i]=2*mi[i-1]%m;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
mii[i][0]=1;
mii[i][j]=(mi[i]-1+m)%m*mii[i][j-1]%m;
}
C[0][0]=1;
for(int i=1;i<=n;i++){
C[i][0]=1;
for(int j=1;j<=i;j++)
C[i][j]=(C[i-1][j]+C[i-1][j-1])%m;
}
for(int i=1;i<=n-2;i++) dp[i][i]=mi[i*(i-1)/2]*C[n-2][i]%m;
for(int i=2;i<=n-2;i++)
for(int j=1;j<i;j++){
for(int k=1,t=1;k<=i-j;k++){
dp[i][j]=(dp[i][j]%m+dp[i-j][k]%m*mii[k][j]%m*C[n-2-(i-j)][j]%m)%m;
}
dp[i][j]=dp[i][j]%m*mi[j*(j-1)/2]%m;
}
for(int j=1;j<=n;j++)
ans=(ans +dp[n-2][j]%m*(mi[j]-1+m)%m)%m;
printf("%lld",ans);
return 0;
}
无向图生成树
方法:并查集。
应用:如仅需表示无向图的连通性,可以减省信息。
Dijkstra
应用一:在最短路(bfs)途中记录额外信息。
例如:最短路计数
加一个cnt数组记录。
应用二:双参数最短路。
P1:HDU3790
长度和花费两个参数,都是加和,优先长度,长度一样选花费小的。
P2: Travel in Desert
温度和长度两个参数,温度取max,优先温度低的,温度一样选长度小的。
本质区别:有无后效性。
P1的加和不管后面怎么加,都不会影响当前一步的选择,直接改堆的比较函数即可。
P2的取温度中的最大值当前的温度收到后面温度的影响,当前贪温度小的可能后面有一条非常热的边导致前功尽弃,而且还不能保证长度小。
解决方法是先把有后效性的元素在全局解决,再解决无后效性的长度。
解决温度:
- 法1:先sort然后从小到大加边并查集查连通性,在并查集的新图上跑长度的最短路。
- 法2:二分最大的温度,跑log次最短路,跑最短路时只跑温度<=最大温度的边。
启示:有后效性的问题和无后效性的问题同时要求解决,优先在全局中先解决有后效性的问题,后解决无后效性的问题。
小技巧:解决全局的有后效性的问题通常可以利用贪心,或者二分答案(有单调性)
应用三:反跑最短路。
相关题目/资料: 花费非常数
知道边的属性和终点的信息,求起点。
反跑最短路即可。
值得一提的是,最短路的边权不仅可以事先就确定,利用跑到某个结点的信息再确定也行,e.g.边权是到某点的最短路百分之几。
总之可以在它更新其他点时知道边权即可。
应用四:最短路径树
相关题目/资料: 圣诞树
每个节点有重量W,每条边有价格C。
需要找到一颗代价最小的树。
每条边的子孙重量之和乘上它的价格是这条边的代价。
一颗树的代价是所有边代价之和。
技巧:树中边的问题往往要转移到点上解决。
综合:打车问题
给定无向图。某个点可以花费用\(c_{i}\),去距离不超过\(t_{i}\)的点。
和前面带温度的双参最短路有点像,最后答案问的是最小花费。
但是是否能到达只解决这个问题之前就需要解决的问题。
先跑距离最短路,再将能花费ci到的点重新构图。
多个参数带来多个问题的题,要解他就像剥洋葱,分清问题之前的包含关系,先后顺序,一个图对应一个参数带来的问题解决即可。
应用五:许多DP方程可以用最短路优化,状态看成点。(DP方程满足最短路松弛的式子)
相关题目/资料: CF1005F
打印k个最短路径树。
对于一棵树,可以看成除了根以外每个点与它的父亲连一条边。
回溯法搜索即可。
构图技巧练习
同余最短路
题目特点:
- 数据范围(值域)巨大,与位数相关或者大到O(n)都实现不了。
- 以某个数的同余类为点(不同的集合/概念)。
相关题目/资料: Small multiple
输入k,找k的某个倍数,这个数各位数字之和最小。
将每个同余类看成点,为每个同余类求最小花费。
用两种边可以表示各个同余类之间全部的关系:
- 边权为1的边表示加1(i->(i+1)%k)。
- 边权为0的边表示乘10(i->(10*i)%k)。
计算时,ans=从1到0的最短路+1;
证明可以证上下界。
东华中学2022普及组T4:电话号码
与同余相关的题,但是是bfs。
0,1,2,…,n-1组成的自然数中,能被m整除的最小数。
0为根,每一层代表一位数,第一层有0,1....n-1。
由于要求数的大小最小,所以定义vis[0...m-1](同余类问题的共通之处)表示mod m的余数是否访问过,取第一个访问过的为答案,由于bfs可以保证是最优解。
实现时给bfs生成树上的点标号,记录前驱编号,每一步填的是什么,用于输出。
假设到点x的余数为r,则新的点y的余数为(r*10+t)%m,t是枚举0~n-1的这步填的数。
时间复杂度\(O(mn)\)。
#include<cstdio>
#include<stack>
using namespace std;
const int maxn=1010;
bool vis[maxn];
struct node{
int r,val,pre;
}a[maxn];
int n,m,st,ed;
stack<int> s;
int main(){
freopen("phone.in","r",stdin);
freopen("phone.out","w",stdout);
scanf("%d%d",&n,&m);
while(st<=ed){
for(int i=st==0?1:0;i<n;i++)
if(!vis[(a[st].r*10+i)%m]){
vis[(a[st].r*10+i)%m]=1,ed++;
a[ed].r=(a[st].r*10+i)%m,a[ed].val=i,a[ed].pre=st;
if(!a[ed].r){
s.push(a[ed].val);
for(int j=a[ed].pre;j;j=a[j].pre)
s.push(a[j].val);
while(!s.empty()){
printf("%d",s.top());s.pop();
}
return 0;
}
}
st++;
}
return 0;
}
类比上面的电话号码和Small multiple。
我们可以得到一个同样可以用同余最短路解决的问题:
0,1,2,…,n-1组成的自然数中,能被m整除的各位之和相加最小的数。
相当于给Small mutiple加个一个各位组成的数不超过n的限制。
解决方案是对于同余类x,我们建边的时候枚举0~n-1的t,向点(x*10+t)%m加边权为t的边即可。
难点:保证数的组成,每走一步加数字t的边之前都要先*10。
相关题目/资料: 跳楼机
给定h,x,y,z输出1~h中所有由a,b,c加起来组成的数的个数。
\(1≤h≤2^{63}−1,1 \le x,y,z \le 10^5\)
数据范围巨大的货币系统,显然不能直接判断每个数能否被组成。
计数肯定要分类计,而x,y,z的大小可以在$$O(n\log n)$$的时间下解决。
数据范围特别大的题考虑用数学优化。
观察可以发现,对于1~h中的数,到了某一个数x,所有>=x的数都可以组成。
从同余的角度考虑,假设有0~x-1的同余类,那么只通过y和z相加得到的某个数t,t所处的同余类中所有数都可以通过t+kx得到。
那么我们找到通过y,z相加得到的x每个同余类的最小数,就可以算出有哪些数可以得到了,分类统计了所有数。
具体的:
- 第t个同余类向(t+y)%x建一条边权为y的边。
- 第t个同余类向(t+z)%x建一条边权为z的边。
难点:h范围巨大,无法判断每一个数是否可以得到。
分类计数是这题的核心思想。
和上面的small multiple一样,都是运用了某个数的同余类作为点构图,其他可以在同余类中用于转移的信息作为边,按同余类分类是这类问题的核心思想。
我们类比的想一个同样需要数学的题,给定巨大的h,a数组,问h中有几个数可以由a数组中不同元素相乘得到(1a[]....)。
h巨大,我们也用数学进行优化。
a[i]作为某个数的因数,哪些1~h的数有因数a[i],直接h/a[i]即可。
有些是公倍数的数会被重复计算。
用容斥原理解决。
ans=n/奇数个数的gcd+n/偶数个数的gcd。
例子,h=1000,a[]={2,3,5,7}。()表示gcd
ans=h/2+h/3+h/5+h/7-h/(2,3)-h/(3,5)-h/(5,7)-h/(2,5)-h/(2,7)-h/(3,7)+h/(2,3,5)+h/(2,5,7)+h/(3,5,7)-h/(2,3,5,7)
定理:\(\binom{n}{1}-\binom{n}{2}+\binom{n}{3}-\binom{n}{4}+...=1\)。
引理:\(\binom{n}{1}+\binom{n}{3}+\binom{n}{5}+...=\binom{n}{0}+\binom{n}{2}+\binom{n}{4}+...\)
证明引理:
分类讨论n的奇偶性:
- n为奇数,易证。
- n为偶数,利用\(\binom{n}{m}=\binom{n-1}{m}+\binom{n-1}{m-1}\)(可以用杨辉三角理解,也可以代数证明)证明即可。
根据引理易证定理。
实现上,多个数的gcd:
- gcd(a,b,c,d)=gcd(a,gcd(b,gcd(c,d)))。
但是这是一个指数阶的劣质算法。
用同余最短路也可以解,到第x个同余类的距离为dis[x](代表一个数字的大小),那么枚举t,t是a数组中的某个元素,x到(dis[x]t)%m连一条边长为dis[x]t-dis[x]的边。
虽然不一定实用,但是这里可以让我们联系到前面非常花费数的百分率做边权的启示:边权也可以是根据到某点的最短路结合参数算出的数,只要在用到边权更新之前能算出边权即可。
其他构图问题:
相关题目/资料: Xanadu
给定n*n的01矩阵,第i行左边第一个1的位置为j,则存在一条i->j的有向边。
现在可以删掉一些1,每删一个花费1块钱,从起点到终点最少花费多少钱。
给定了每行的i到每列的点的一些关系,根据图的定义,我们可以用边表示不同点之间的关系,并且用边权表示花费,对于第i行,所有为1的点都可能成为i可以到的点。
做法,第i行向第j个1连边权j-1的边,跑最短路。
相关题目/资料: Strong Defence
给定无向图,起点终点,将边分成尽量多的子集,使得任意两个子集不相交,删掉任意一个子集图不连通。
观察可以发现,如果尽量多,那么bfs生成树(最短路径树)中每一层连接下一层的边集一定划分到同一个集合中,才能最多。
答案就是有最短路长度d个数个集合。
证明:
反证:
- 假如比d个集合少,设最短路中第i条边没有单独成集合,如果让它单独成集合,那么也能保证删掉任意集合图不连通,每条边在一个集合,比原答案多一个集合,即原答案不是最优的。
- 假如比d个集合还多,那么一定可以找到一个集合不包含d中任意一条边,因为每条边最多在一个集合中。(鸽舍原理)
总上,最多分成d个集合并且满足要求。
本质上这题还是最短路径树的应用。
技巧:最短路生成树的层级之间的边集是保证图连通的最小充分条件。
实现时注意:t不一定是离s最短的点,即不一定是bfs生成树最下面一层。
所以分层时t的层级之间的按上述规则分,其余的随便分。
#include<cstdio>
#include<vector>
#include<queue>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=410;
int dis[maxn],n,m,s,t,cnt=1,head[maxn];
bool vis[maxn];
queue<int>q;
vector<int>vv[maxn*maxn<<1],tmp;
struct edge{
int to,nxt,u;
}e[maxn*maxn<<1],ed[maxn*maxn<<1];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
int main(){
scanf("%d%d%d%d",&n,&m,&s,&t);
for(int i=1;i<=m;i++){
int u,v;scanf("%d%d",&u,&v);
add(u,v),add(v,u);
ed[i].u=u,ed[i].to=v;
}
q.push(s),vis[s]=1;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!vis[v]){
vis[v]=1,dis[v]=dis[u]+1;
q.push(v);
}
}
}
int ans=0;
for(int i=1;i<=n;i++) ans=max(ans,dis[i]);
for(int i=1;i<=m;i++){
int u=ed[i].u,v=ed[i].to;
if(dis[u]!=dis[v]){
if(max(dis[v],dis[u])<=dis[t]) vv[max(dis[v],dis[u])].push_back(i);
else tmp.push_back(i);
}
else tmp.push_back(i);
}
printf("%d\n",dis[t]);
for(int i=1;i<=dis[t];i++){
if(i==1){
printf("%d ",vv[i].size()+tmp.size());
for(auto j:vv[i]) printf("%d ",j);
for(auto j:tmp) printf("%d ",j);
printf("\n");
continue;
}
printf("%d ",vv[i].size());
for(auto j:vv[i])
printf("%d ",j);
printf("\n");
}
return 0;
}
相关题目/资料: Shortest Path
- 给定无向图,n<=3000,m<=20000。
- 给定k个三元组,k<=10000。
- 不能按顺序访问三元组中的元素。
- 求1到n的最短路。
难点:要表示三点关系。
传统的图中一条边只能表示两点关系,那么我们化边为点,第一条边表示(x,y)的关系,第二条边表示(y,z)的关系,那么再用一条边(化边为点的图中)就能表示原图三点的关系。
技巧:表示三点关系可以化边为点重新构图。
本质上还是利用了图用边表示两点关系的定义。
为了方便和避免MLE我们可以直接在原图上实现。
实现时,我们还是可以在原图上跑最短路,把边加入优先队列,由于有顺序的存在,我们先将无向边变成双向有向边(便于确定一条边最终到哪个点,即使三元组不存在顺序也最好这样做)。
dis[i]表示最后一条边是e[i]并且落在e[i]到的点的最短路。
先排序预处理出no[i][c]表示第i条边不能更新到c点。
注意:一条边可能对应多个c,一个c只能对应一条边。
一开始先将所有从1出发的点加入优先队列,更新时根据第i条边到的点结合no数组判断是否将当前准备更新的边加入队列即可。
#include<cstdio>
#include<vector>
#include<algorithm>
#include<queue>
#include<cstring>
using namespace std;
const int maxn=3010;
const int maxm=2e4+10;
int n,m,head[maxm<<1],cnt=1,k,dis[maxm<<1],pre[maxm<<1];
bool no[maxm<<1][maxn],vis[maxm<<1];
queue<int>q;
struct node{
int a,b,c;
}jin[maxm*5];
struct edge{
int u,v,nxt,id;
}e[maxm<<1],ed[maxm<<1];
void add(int u,int v){
e[cnt].u=u,e[cnt].v=v,e[cnt].nxt=head[u];head[u]=cnt++;
}
bool cmp(edge x,edge y){
if(x.u==y.u) return x.v<y.v;
return x.u<y.u;
}
bool cmpp(node x,node y){
if(x.a==y.a){
if(x.b==y.b) return x.c<y.c;
return x.b<y.b;
}
return x.a<y.a;
}
void print(int x){
if(x==0) return;
print(pre[x]);
printf("%d ",e[x].v);
}
int main(){
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++){
int u,v;scanf("%d%d",&u,&v);
add(u,v);ed[i].id=cnt-1;
add(v,u);ed[i+m].id=cnt-1;
ed[i].u=u,ed[i].v=v;
ed[i+m].u=v,ed[i+m].v=u;
}
for(int i=1;i<=k;i++) scanf("%d%d%d",&jin[i].a,&jin[i].b,&jin[i].c);
sort(ed+1,ed+cnt,cmp);
sort(jin+1,jin+1+k,cmpp);
int now=1;
for(int i=1;i<=k;i++){
while((ed[now].u<jin[i].a||(ed[now].u==jin[i].a&&ed[now].v<jin[i].b))&&now<=2*m) now++;
if(ed[now].u==jin[i].a&&ed[now].v==jin[i].b&&now<=2*m){
no[ed[now].id][jin[i].c]=1;
int t=ed[now].id;
}
}
memset(dis,0x7f,sizeof(dis));
for(int i=head[1];i;i=e[i].nxt)
q.push(i),dis[i]=1,vis[i]=1;
while(!q.empty()){
int now=q.front();
q.pop();
int v=e[now].v;
for(int i=head[v];i;i=e[i].nxt){
int vv=e[i].v;
if(no[now][vv]||vis[i]) continue;
vis[i]=1,dis[i]=dis[now]+1,pre[i]=now;
q.push(i);
}
}
int ans=0x7f7f7f7f,t=-1;
for(int i=1;i<cnt;i++)
if(e[i].v==n)
if(ans>dis[i]){
ans=dis[i];
t=i;
}
if(ans==0x7f7f7f7f){
printf("-1");
return 0;
}
printf("%d\n",ans);
printf("1 ");print(t);
return 0;
}
相关题目/资料: earth hour
给定n<=200个圆(圆心+半径),实际上是n个灯,开灯为圆关灯为点,求连通三点最少需要经过几个点。
难点:
- 简化问题,观察分析出有用的点必然是开着的。
- 解决在图中经过最少的点使得三个点连通的问题。
解决方法:
构图:将两个有公共部分的圆连边(权为1)。一个点包含在一个圆内但是不开灯的点没必要经过,对于连通其他点没有价值,并且没有必要经过这个点。
解决经过最少的点使得三点连通的问题:首先三个点连通一定无环,否则去掉环会更优。
那么三个点肯定在一颗树内,根据树的性质n个点n-1条边,树中包含最少点即为经过最少边。
问题转换为找一个点d使得他到三点距离之和最小,先跑n次最短路预处理全源最短路即可。
核心考点还是简化问题(被一条边连着的两点一定是都开着的),构图,树的性质,最短路。
一道有相似之处却截然不同的题:沙滩防御。
给定n<=1000个点的圆心和0~m<=800个列找到一个最小的使得m个列被完全覆盖的所有半径中最大的半径r。
最小瓶颈路的模板题,这里的半径只是图中的最大边,而和上道题的连通两个开着的灯的边意义完全不同。
相关题目/资料: Okabe and City
给定n*m的方格,\(n,m,k<=10^4\),求(1,1)到(n,m)的最小花费。
给定k个原来就亮着灯的格子,无时无刻都得站在亮灯的格子上。
保证(1,1)亮灯但不保证(n,m)亮灯。
可以进行多次如下操作:
- 站在某个本来就亮灯的格子上可以花费一块钱指定任意一行或列亮灯。
- 在亮灯的格子上可以花费0块钱直接走到上下左右的四个格子上。
方法1:根据分析我们的一个原来就亮的点那么他上下左右和他自己所在的行列都可以直接花费1到达。
据此可以构图,最坏被卡成时空都是平方,MLE。
但是可以通过不建图,在每次跑spfa的时候来一遍1~k的循环,考虑可以走向哪个点。
时间其实是假的,但是可以卡过这题,算是考场求生的奇技淫巧了:建图MLE时跑spfa,通过循环考虑到哪个点,由于spfa可能在小常数*n的时间复杂度跑完最短路,有时可以卡过一些题。
正解:
由于方法1的图边数太多,导致MLE,所以我们可以用加中间节点或是虚拟点的技巧来减少边数。
考虑一个原来就亮灯的点他使用1块钱使得某行或列亮灯一定只会使得自己的行列和上下左右四个行列亮灯,一共只有6种关系,所以我们将行和列也当做点,构图,大大减少了边数。
具体的:点x是原来就亮灯的点。
- x上下左右四个点如果原来就亮灯,直接连一条边权为0的边。
- x的6个有关系的行列向x连权为0的边,x向他们连边权为1的边。(讨论x是花钱点亮某行某列还是从被点亮的某行某列到的点的两种情况,而避免了x到某行或列再讨论这个行列能连到哪些其他点,这样增加了写代码的难度)这种讨论一个点分别处于不同情况的思想值得学习
难点:将行列也当做点达到减少原图边数的效果。
关键还是抓一个点能分几种情况到其他位置的信息,选少情况的构图。
相关题目/资料: new island
给定简单无向连通图,n<=200个点。
删掉第i条边获得\(2^i\)的收益,在满足删掉去掉一些边并保证现在图的各点最短路不超过原图两倍,最大化收益。
我们先考虑要删掉哪些边,\(2^i\)是给我们的提示,观察样例我们也不难发现,先删最大的边。因为如果不把最大的删掉,哪怕把其余的全删掉都没有删最大的收益大。
\(2^+...+2^{n-1}=2^n-1\)
然后考虑原来的条件怎么简化。
简化问题:通过画图观察,删掉某一条边之后,最难满足的就是这条边原来连的两个点,他们原来的最短路是1,现在要求删掉边之后最短路长度为2。进一步思考,如果删掉的这条边是其他某两个点的最短路,那么他们这段原来在最短路上长度为1的边现在有长度为2的代替了,那么这一段肯定合法了,我们最低满足的限度就是在那两个点所有段都至少有长度为2的可以代替。
所以我们可以得到:如果删掉某条边使得初始图中有边相连的两个点都有长度<=2的边。
现在要思考如何实现:如何判断任意时刻某两点是否有长度为2或1的边且能表示哪些边存在,哪些不存在。
定义G[x][y]
- 0表示本来无边
- 1表示现在仍有边
- -1表示最初有边但是删掉了(要与最初的最短路比较,所以必须将本来就无边和删掉的区分开)。
定义:num[x][y]表示存在长度为2的路的个数。
由于长度为2的边不一定和最短路有关,所以我们容易惯性想到记录删掉边后会影响哪些的最短路行不通。
num数组十分巧妙,一方面容易记录,另一方面删边之后容易更新。
现在考虑删掉(x,y)这条边,看看哪些点的num会被减少。
由于(x,y)长度已经为1,要被影响点z一定和x或者y现在有边相连,如果a,b两点都和x,y无关那么肯定影响不到他们,他们之中一点离x或者y的距离至少为三,画图不难证明。
,
- 如果G[x][z]==1那么num[y,z]--,num[z,y]--
- 如果G[y][z]==1那么num[x,z]--,num[z,x]--
并且删掉(x,y)要将G[x,y]=G[y,z]=-1;
还要判断哪些边能删,首先,x,y必须有长度为2的路,其次要考虑会被影响num的z。如果z与x,y是初始有边在前面删掉了,那么一定要保证他们之间至少还有1条长度为2的路径,如果要减少num的有他们,那么一定不能删。
以下情况不能删。
num[x,y]<1G[x,z]==1&&G[y,z]==-1&&num[y,z]<=1G[y,z]==1&&G[x,z]==-1&&num[x,z]<=1
难点:简化问题,和想到G和num数组。
比较常规的是贪心和最后的分类讨论,画图都不难想到。
核心考点:观察简化问题的思想,对于动态更新的量记录的信息要精准,不然可能要额外浪费时间(num数组),分类讨论哪些被影响。
知识点:最短路,bfs生成树(讨论被影响的点和求num),贪心。
相关题目/资料: Tax
给出一个 \(n\) 个点 \(m\) 条边的无向图,经过一个点的代价是进入和离开这个点的两条边的边权的较大值,求从起点 \(1\) 到点 \(n\) 的最小代价。起点的代价是离开起点的边的边权,终点的代价是进入终点的边的边权。
本质上一个点的花费是两条边之间的关系。
化无向图为有向图,再化边为点,重新构图。
化为有向图?
- 有向边的终点可以与原图一一对应,从而证明构图的正确性。
- 而无向图无法确定最终在哪一端。
下面称一开始输入的图为原图,化边为点之后的图为新图。
暴力加边用新图点权(其实就是原图边权)取max加边容易得到。
但是这种做法TLE了,边数太大。
我们考虑加虚点减少边数。
假设点u(原图中某点)的所有边(包括进入u和从u出去的边)的边边权各不相同。
如果原来有相同的边权,我们可以看成两个是不同的,假装有次序。
对应u点所有的边的不同权值,构建虚点。
其中虚点 \((u,w)\) 表示关于u的边权值为w的原图中的正反边的中转站。
可以看成这个虚点对应原图的一条边,并且每个虚点连有两个新图真点和一个虚点。
建边及各边含义:
对于u,每个虚点之间差分建边,以满足取最大值的要求。
对于原图进入 \(u\) 权为 \(w\) 的边我们想这个虚点连边权为 \(w\) 的有向边,它的意义是原来可以通过进入u这个方向的边可以通过w的花费到达u,并且借助这个虚点连接的其他虚点可以到达从u出发的所有边。
对于原图从 \(u\) 出的边权为 \(w\),蓄电向这条边连权为 \(0\) 的边,表示别的进入u的边可以通过这条边出去。
新图中点的个数大约为:\(8*10^5\)。
边的个数约为:\(32*10^5\)。
用Dijkstra算法可以AC。
#include<cstdio>
#include<queue>
#include<algorithm>
#include<vector>
#include<map>
#include<utility>
#include<cstring>
#define int long long
#define mkp(x,y) make_pair(x,y)
using namespace std;
const int maxn=8e5+10;
int head[100010],cnt=2,n,m,tot,cntt=1,h[maxn],dis[maxn];
map<pair<int,int>,int>mp;
struct edge{
int nxt,w,to,id;
}e[400010],ee[24000010];
struct ed{
int to,w,id;
ed(int to_,int w_,int id_){
to=to_,w=w_,id=id_;
}
ed(){}
};
vector<ed>G[100010];
bool operator <(ed x,ed y){
return x.w<y.w;
}
inline void add(int u,int v,int w){
e[cnt].to=v,e[cnt].w=w,e[cnt].nxt=head[u],head[u]=cnt++;
}
inline void ad(int u,int v,int w){
ee[cntt].to=v,ee[cntt].w=w,ee[cntt].nxt=h[u],h[u]=cntt++;
}
struct node{
int u,w;
node(int u_,int w_){
u=u_,w=w_;
}
node(){}
};
bool operator<(node x,node y){
return x.w>y.w;
}
priority_queue<node>q;
bool vis[maxn];
inline int read(){
int flag=1,x=0;
char ch=getchar();
while(ch>'9'||ch<'0') (ch=='-')?flag=-1:flag=flag,ch=getchar();
while(ch>='0'&&ch<='9') x=x*10+ch-'0',ch=getchar();
return x*flag;
}
signed main(){
n=read(),m=read();
for(int i=1;i<=m;++i){
int u,v,w;u=read(),v=read(),w=read();
add(u,v,w),G[u].push_back(ed(v,w,cnt-1));
add(v,u,w),G[v].push_back(ed(u,w,cnt-1));
}
tot=2*m+2;
for(int i=1;i<=n;++i){
if(G[i].size()==0) continue;//实现上这里有个坑
sort(G[i].begin(),G[i].end());
tot++;
mp[mkp(i,G[i][0].w)]=tot;
for(int j=1;j<G[i].size();++j){
++tot;
mp[mkp(i,G[i][j].w)]=tot;
ad(tot-1,tot,G[i][j].w-G[i][j-1].w);
ad(tot,tot-1,0);
}
}
int s=++tot,t=++tot;
for(int i=1;i<=n;++i)
for(int j=head[i];j;j=e[j].nxt){
int u=i,v=e[j].to,w=e[j].w;
ad(mp[mkp(u,w)],j,0);
ad(j,mp[mkp(v,w)],w);
if(u==1) ad(s,j,w);
if(v==n) ad(j,t,w);
}
memset(dis,0x3f,sizeof(dis));
q.push(node(s,0)),dis[s]=0;
while(!q.empty()){
node now=q.top();
q.pop();
int u=now.u;
if(vis[u]) continue;
vis[u]=1;
for(int i=h[u];i;i=ee[i].nxt){
int v=ee[i].to;
if(dis[v]>dis[u]+ee[i].w){
dis[v]=dis[u]+ee[i].w;
q.push(node(v,dis[v]));
}
}
}
printf("%lld",dis[t]);
return 0;
}
技巧:对于取最大、小值的情况可以差分建边实现,并且配合对应某个值的虚点可以使得边数较少
技巧:考虑每个点的花费是否为边与边的关系,如果是考虑化边为点重新构图。
这题的难点是差分建边从而使的一条进入u边能很好的利用u所有的出边。
还可以对于u的每一条出边差分建边,然后正反边连原来边权的双向边从而使得当中一点充当另一点的上述的虚点。原理就是进入u的边要利用的一定是出u的边,而这条边的反向边正是出u的边,扫u的所有边差分建边恰好能满足。
[ABC232G] Modulo Shortest Path
题目描述:给定有\(n<=2*10^5\)个点的完全图,和模数\(m\),每个点有\(A_{i},B_{i}\)两个属性,边\((u,v)\)的边权是\((A_u+B_v)\mod m\)。
解法:
取模即与余数相关,边权肯定不超过m,因此可以将所有状态用0...m-1的m的同余系表示。
涉及到余数相关的(包括因数、倍数、取模)是同余最短路的一大特征。
边权由两个部分组成,即包含起点u又包含终点v,直接构图时平方级别的TLE。
因此要优化构图边数,所有取模的公共特征是边权不超过,那么我们不妨选一个点进行分割点,在分割点之前走mod m意义下的\(A_u\)步,在分割点之后走mod m意义下的\(B_v\) 步,那么对于每个边权,都可以通过mod m的余数上的变化来完整的表示。那么为了方便计算,不妨选0来作为分界点,建立点0...,m-1,对于每个点i,建立他到\((0-A_i)%m\)作为i出发的前半部分花费(此时i充当的是u),点\(B_i\)连向i作为到点i的后半部分的花费(此时i充当的是v)即可表示所有花费。
保留环上用到的点恰好2n个,边数也是2n可以AC。0...m-1之前的相邻的点互相转移的代价是1。
这里优化复杂度利用的重复条件是到点i的代价恒为\(B_i\),利用同余关系很容易表示这个代价。
考虑利用重叠优化有以下两个角度:
- u到的边有哪些可以利用重叠减少的。
- 到v的边有哪些可以利用重叠减少的。
相关题目/资料: arc061C
如果乘客只乘坐同一公司的铁路,他只需要花费一元,但如果更换其他公司的铁路需要再花一元。当然,如果你要再换回原来的公司,你还是要花一元。
同样是边与边的关系,用上述做法就是将边权相同的正、反、邻边都连上权值为0的边,然后将每一个原图中的点都建一个虚点,将权不同的边中任意一边向虚点连权值为1的入边和权值为0的出边。
这题其实核心的是连通性,更好的做法是,利用并查集将同一个连通性分量中的点建立虚点,所有分量中的点与虚点连权为0.5的双向边。
实现时将边权*2最后再/2即可。
#include<cstdio>
#include<map>
#include<algorithm>
#include<vector>
#include<queue>
#include<cstring>
using namespace std;
const int maxn=1e5+2e5+10;
int head[100010],cnt=1,fa[100010],n,m,tot,cl[100010],pre=1,f[200010],h[maxn],cntt=1,dis[maxn];
bool vis[maxn];
vector<int>v;
queue<int>q;
map<int,int>mp;
struct edge{
int to,nxt,id,u,w;
edge(int u_,int to_,int id_,int w_){
u=u_,to=to_,id=id_,w=w_;
}
edge(){}
}e[400010],ed[200000],ee[800010];
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
void Union(int x,int y){
x=find(x),y=find(y);
if(x==y) return;
fa[x]=y;
}
void add(int u,int v,int id){
e[cnt].to=v,e[cnt].nxt=head[u],e[cnt].id=id,head[u]=cnt++;
}
void ad(int u,int v){
ee[cntt].to=v,ee[cntt].nxt=h[u],h[u]=cntt++;
}
bool cmp(edge x,edge y){
return x.w<y.w;
}
int hx(int x){
if(mp.find(x)==mp.end()) mp[x]=++tot;
return mp[x];
}
void wk(int now){
for(auto i:v)
cl[i]=hx(find(i));
for(int i=pre;i<=now;i++){
int u=ed[i].u,v=ed[i].to,id=ed[i].id;
if(find(u)==find(v))
f[id]=cl[u];
}
}
void cle(){
for(auto i:v) cl[i]=0,fa[i]=i;
v.clear(),mp.clear();
}
int main(){
scanf("%d%d",&n,&m);
tot=n;
for(int i=1;i<=n;i++) fa[i]=i;
for(int i=1;i<=m;i++){
int u,v,w;scanf("%d%d%d",&u,&v,&w);
add(u,v,i),add(v,u,i);
ed[i]=edge(u,v,i,w);
}
sort(ed+1,ed+1+m,cmp);
for(int i=1;i<=m+1;i++){
if(i!=1&&ed[i].w!=ed[i-1].w){
wk(i-1);
cle();
pre=i;
}
Union(ed[i].u,ed[i].to);
v.push_back(ed[i].u),v.push_back(ed[i].to);
}
for(int i=1;i<=n;i++)
for(int j=head[i];j;j=e[j].nxt){
ad(i,f[e[j].id]),ad(f[e[j].id],i);
}
memset(dis,0x3f,sizeof(dis));
dis[1]=0,q.push(1);
while(!q.empty()){
int u=q.front();
q.pop();
if(vis[u]) continue;
vis[u]=1;
for(int i=h[u];i;i=ee[i].nxt){
int v=ee[i].to;
if(dis[v]>dis[u]+1){
dis[v]=dis[u]+1;
q.push(v);
}
}
}
printf("%d",dis[n]==0x3f3f3f3f?-1:dis[n]/2);
}
相关题目/资料: Low cost air travel
题意翻译:
很多航空公司都会出售一种联票,要求从头坐,上飞机时上缴机票,可以在中途任何一站下飞机。比如,假设你有一张“城市1->城市2->城市3”的联票,你不能用来只从城市2飞到城市3(因为必须从头坐),也不能先从城市1飞到城市2再用其他票飞到其他城市玩,回到城市22后再用原来的机票飞到城市3(因为机票已经上缴)。
和上题有点像,联票就向与连通性相关的。
但是却截然不同。
我们要解决的问题是满足某个游玩顺序的最短路,而直接求起点终点的不会满足题意,为了玩一些城市可能绕远。
分别求每两个城市之间最短路,也不一定是最优解,有可能在某一个城市的联票多往后走几个城市能用更优惠的票。
这题的难点就在于解决按行程表跑最短路的问题。
由于要求每个点作为第几个到,中间还可能有非必要的点。
那么对于每个点,我们加一个维度,代表这个点是作为第几个到的点。
如果一张联票中有可以不连续个满足行程单的点那么是可以将后面的点作为当前枚举的点的下个到的点的状态的。
在新图中跑最短路即可。
这题的核心是每个点都可以作为第任意个到的点,但是能通过1,1走到的才是合法的。
并且构图的时候还用了贪心思想,能在这条线到的不放到下一条线到。
虽然这题看似和连通性相关,但是第几个到(状态)是关键。
代码细节有点多自己的还没调好,先放个题解的。
#include <algorithm>
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <string>
#include <vector>
#include <cmath>
#include <ctime>
#include <queue>
#include <map>
#include <set>
using namespace std;
#define ull unsigned long long
#define pii pair<int, int>
#define uint unsigned int
#define mii map<int, int>
#define lbd lower_bound
#define ubd upper_bound
#define INF 0x3f3f3f3f
#define IINF 0x3f3f3f3f3f3f3f3fLL
#define DEF 0x8f8f8f8f
#define DDEF 0x8f8f8f8f8f8f8f8fLL
#define vi vector<int>
#define ll long long
#define mp make_pair
#define pb push_back
#define re register
#define il inline
#define N 10000
struct Edge {
int next, from, to, w, id;
}e[2000000];
int ticketCnt, routeCnt, nodeCnt, cityCnt;
int price[250], cities[250];
vi tickets[250];
map<pii, int> nodeId;
mii cityId;
pii originNode[N+5];
int head[N+5], eid;
int d[N+5], pre[N+5];
bool inq[N+5];
int stk[N+5], tp;
queue<int> q;
void addEdge(int u, int v, int w, int id) {
e[++eid] = Edge{head[u], u, v, w, id};
head[u] = eid;
}
void spfa() {
memset(d, 0x3f, sizeof d);
memset(inq, 0, sizeof inq);
memset(pre, 0, sizeof pre);
int S = nodeId[mp(1, cities[1])];
d[S] = 0;
q.push(S);
while(!q.empty()) {
int u = q.front(); q.pop();
inq[u] = 0;
for(int i = head[u]; i; i = e[i].next) {
int v = e[i].to, w = e[i].w;
if(d[v] > d[u]+w) {
d[v] = d[u]+w;
pre[v] = i;
if(!inq[v]) inq[v] = 1, q.push(v);
}
}
}
}
void mark(int u) {
if(!pre[u]) return ;
stk[++tp] = e[pre[u]].id;
mark(e[pre[u]].from);
}
int main() {
int kase = 0;
while(~scanf("%d", &ticketCnt) && ticketCnt) {
++kase;
nodeCnt = cityCnt = 0;
nodeId.clear();
cityId.clear();
for(int i = 1, cnt; i <= ticketCnt; ++i) {
scanf("%d%d", &price[i], &cnt);
tickets[i].clear();
for(int j = 1, x; j <= cnt; ++j) {
scanf("%d", &x);
if(!cityId.count(x)) cityId[x] = ++cityCnt;
tickets[i].pb(cityId[x]);
}
}
scanf("%d", &routeCnt);
for(int t = 1, len; t <= routeCnt; ++t) {
memset(head, 0, sizeof head);
eid = 0;
scanf("%d", &len);
for(int c = 1; c <= len; ++c) {
scanf("%d", &cities[c]);
if(!cityId.count(cities[c])) cityId[cities[c]] = ++cityCnt;
cities[c] = cityId[cities[c]];
}
for(int ticket = 1; ticket <= ticketCnt; ++ticket) {
for(int i = cities[1] == tickets[ticket][0]; i <= len; ++i) {
int cnt = i;
pii cur = mp(i, tickets[ticket][0]);
if(!nodeId.count(cur)) nodeId[cur] = ++nodeCnt, originNode[nodeCnt] = cur;
for(int j = 1; j < tickets[ticket].size(); ++j) {
if(cnt+1 <= len && cities[cnt+1] == tickets[ticket][j]) cnt++;
pii newState = mp(cnt, tickets[ticket][j]);
if(!nodeId.count(newState)) nodeId[newState] = ++nodeCnt, originNode[nodeCnt] = newState;
addEdge(nodeId[cur], nodeId[newState], price[ticket], ticket);
}
}
}
spfa();
printf("Case %d, Trip %d: Cost = %d\n", kase, t, d[nodeId[mp(len, cities[len])]]);
printf(" Tickets used: ");
tp = 0;
mark(nodeId[mp(len, cities[len])]);
for(int i = tp; i > 1; --i) printf("%d ", stk[i]);
printf("%d\n", stk[1]);
}
}
return 0;
}
相关题目/资料: Recover path
给定无向图G(\(m,n<=10^5\))。给定k个点,求一条路径包含这些点,是其中两个端点直之间的最短路,保证存在。
根据最短路的最优子结构性质,可以推出从k中任意找一点,跑最短路,最远的且是k中的一定是一个端点。
若最短路没有最优子结构,那么可以将较劣质的子结构改成更优的从而和最短路矛盾。
技巧:最短路具有最优子结构性质,即路中经过的任意两点都是最短路。
然后加下来确定了两个端点考虑如何输出路径。
这个路径一定是包含最多个k的路径。
我们可以在其中一个端点的最短路径树(bfs生成树)上做树形DP,或者将最短路径树重新构图跑拓扑。
技巧:找最短路如果有额外限制实际上可以看成在最短路径树上做树形DP
细节有点多,先贴题解代码,自己的还没调好。
#include <stdio.h>
#include <iostream>
#include<queue>
#include <string.h>
#include <algorithm>
#define maxn 200009
using namespace std;
typedef pair<int,int>pii;
int head[maxn],nxt[maxn],point[maxn],value[maxn],now=0;
int n,m,maxx=-1,po,dist[maxn],x,y,z,k,v[maxn],dp[maxn],egz[maxn];
int an=0,pp[maxn],final[maxn],oo=0;
int num[maxn],route[maxn];
void add(int x,int y,int v,int zz)/*456465*/
{
nxt[++now]=head[x];
head[x]=now;
point[now]=y;
value[now]=v;
num[now]=zz;
}
struct cmp
{
bool operator()(pii a,pii b)
{
return a.first>b.first;
}
};
void dijkstra(int s)
{
memset(dist,-1,sizeof(dist));
dist[s]=0;
priority_queue<pii,vector<pii>,cmp>q;
q.push(make_pair(0,s));
while(!q.empty())
{
pii u=q.top();
q.pop();
if(u.first>dist[u.second])continue;
for(int i=head[u.second];i!=0;i=nxt[i])
{
int k=point[i];
if(dist[u.second]+value[i]<dist[k] || dist[k]==-1)
{
dist[k]=dist[u.second]+value[i];
q.push(make_pair(dist[k],k));
}
}
}
}
int dfs(int s)
{
if(dp[s]!=-1)return dp[s];
int ans=0;
for(int i=head[s];i!=0;i=nxt[i])
{
int u=point[i];
if(dist[u]!=dist[s]+value[i])continue;
int val=dfs(u);
if(val>ans)
{
ans=val;
pp[s]=u;
route[s]=num[i];
if(val==k-1)break;
}
}
return dp[s]=ans+(egz[s]==1?1:0);
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&x,&y,&z);
add(x,y,z,i);
add(y,x,z,i);
}
scanf("%d",&k);
for(int i=1;i<=k;i++){scanf("%d",&v[i]);egz[v[i]]=1;}
dijkstra(v[1]);
for(int i=1;i<=k;i++)if(dist[v[i]]>maxx)
{
maxx=dist[v[i]];
po=v[i];
}
dijkstra(po);
memset(dp,-1,sizeof(dp));
dfs(po);
an=po;
while(pp[an]!=0)
{
final[++oo]=route[an];
an=pp[an];
}
printf("%d\n",oo);
for(int i=1;i<=oo-1;i++)printf("%d ",final[i]);
printf("%d",final[oo]);
return 0;
}
相关题目/资料: wandering queen
暴力拆8个点建八个方向的边\(O(64mn)\)会T。
于是考虑状态与方向无关,|—/这四种即可。
于是可以优化成\(O(16mn)\)可以通过。
更快的是将上下左右四个连通分量作为点,这样点数和边数更少了,但是本质上和直接存n*m个点四个方向是一样的。
由于老题需要卡常,先不卡了。
#include<cstdio>
#include<queue>
#include<algorithm>
#include<map>
#include<cstring>
using namespace std;
const int maxn=1e3+1;
const int dx[]={-1,0,1,1,1};
const int dy[]={-1,1,0,1,-1};
int fa[maxn*maxn],n,m,dis[maxn*maxn*4],tot,tott,a[maxn][maxn],G[maxn][maxn],d[maxn][maxn][5],head[maxn*maxn*4],cnt=1,rk[maxn*maxn*4];
bool vis[maxn*maxn*4];
struct node{
int x,y;
node(int x_,int y_){
x=x_,y=y_;
}
node(){}
}S,T,b[maxn*maxn];
map<int,int>mp;
queue<int>q;
int insert(int x){
if(mp.find(x)==mp.end()) mp[x]=++tott;
return mp[x];
}
struct edge{
int to,nxt;
}e[16*maxn*maxn];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
char str[maxn][maxn];
/*
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
void Union(int x,int y){
x=find(x),y=find(y);
if(x==y) return;
if(rk[x]>rk[y]) fa[y]=x;
else if(rk[x]<rk[y]) fa[x]=y;
else rk[y]++,fa[x]=y;
}
void w1(){
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
if(G[i][j-1]&&G[i][j])
Union(a[i][j-1],a[i][j]);
for(int i=1;i<=tot;i++){
int x=b[i].x,y=b[i].y;
if(!G[x][y]) continue;
d[x][y][1]=insert(find(i));
}
mp.clear();
for(int i=1;i<=tot;i++) fa[i]=i,rk[i]=0;
}
void w2(){
for(int j=1;j<=m;j++)
for(int i=1;i<=n;i++)
if(G[i-1][j]&&G[i][j])
Union(a[i-1][j],a[i][j]);
for(int i=1;i<=tot;i++){
int x=b[i].x,y=b[i].y;
if(!G[x][y]) continue;
d[x][y][2]=insert(find(i));
}
mp.clear();
for(int i=1;i<=tot;i++) fa[i]=i,rk[i]=0;
}
void w3(){
for(int j=1;j<=m;j++){
int x=1,y=j;
while(x<n&&y>1){
x++,y--;
if((G[x-1][y+1]||y+1>m)&&G[x][y])
Union(a[x-1][y+1],a[x][y]);
}
}
for(int i=2;i<=n;i++){
int x=i,y=m;
while(x<n&&y>1){
x++,y--;
if((G[x-1][y+1]||y+1>m)&&G[x][y])
Union(a[x-1][y+1],a[x][y]);
}
}
for(int i=1;i<=tot;i++){
int x=b[i].x,y=b[i].y;
if(!G[x][y]) continue;
d[x][y][3]=insert(find(i));
}
mp.clear();
for(int i=1;i<=tot;i++) fa[i]=i,rk[i]=0;
}
void w4(){
for(int j=1;j<=m;j++){
int x=1,y=j;
while(x<n&&y<m){
x++,y++;
if(G[x-1][y-1]&&G[x][y])
Union(a[x-1][y-1],a[x][y]);
}
}
for(int i=2;i<=n;i++){
int x=i,y=1;
while(x<n&&y<m){
x++,y++;
if(G[x-1][y-1]&&G[x][y])
Union(a[x-1][y-1],a[x][y]);
}
}
for(int i=1;i<=tot;i++){
int x=b[i].x,y=b[i].y;
if(!G[x][y]) continue;
d[x][y][4]=insert(find(i));
}
mp.clear();
for(int i=1;i<=tot;i++) fa[i]=i,rk[i]=0;
}*/
int main(){
int tt;
scanf("%d",&tt);
while(tt--){
cnt=1;
for(int i=1;i<=tott;i++) head[i]=0;
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%s",(str[i]+1));
}
tot=tott=0;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
G[i][j]=(str[i][j]=='X')?0:1;
if(str[i][j]=='S') S=node(i,j);
if(str[i][j]=='F') T=node(i,j);
d[i][j][1]=d[i][j][2]=d[i][j][3]=d[i][j][4]=0;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
if(!G[i][j]) continue;
for(int t=1;t<=4;t++){
if(!d[i][j][t]) d[i][j][t]=++tott;
int nx=i+dx[t],ny=j+dy[t];
if(nx>=1&&nx<=n&&ny>=1&&ny<=m&&G[nx][ny]){
d[nx][ny][t]=d[i][j][t];
}
}
}
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
if(!G[i][j]) continue;
for(int k=1;k<=4;k++)
for(int kk=k+1;kk<=4;kk++){
int x=d[i][j][k],y=d[i][j][kk];
add(x,y),add(y,x);
}
}
for(int i=1;i<=tott;i++) dis[i]=0x3f3f3f3f,vis[i]=0;
for(int i=1;i<=4;i++){
int x=d[S.x][S.y][i];
vis[x]=1,dis[x]=1,q.push(x);
}
int ans=0x3f3f3f3f;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!vis[v]&&dis[v]>dis[u]+1){
dis[v]=dis[u]+1;
q.push(v);
}
}
}
for(int i=1;i<=4;i++){
int x=d[T.x][T.y][i];
ans=min(ans,dis[x]);
}
printf("%d\n",ans==0x3f3f3f3f?-1:ans);
}
return 0;
}
用DP扫连通分量可以比并查集快一点。
相关题目/资料: Jzzhu and Cities
题意简述 \(n\)个点,\(m\)条带权边的无向图,另外还有\(k\)条特殊边,每条边连接\(1\)和\(i\)。
问最多可以删除这\(k\)条边中的多少条,使得每个点到\(1\)的最短距离不变。
数据范围:
\(1 ≤ u_i,v_i,s_i ≤n ≤ 10^5\)
\(1 ≤ k ≤ 10^5\)
\(1 ≤ m ≤ 3\times 10^5\)
\(1 ≤ x_i,y_i ≤ 10^5\)
转换成以1为根的最短路径树,统计在k中的边是必要的的个数。
不用真的减数,令ct[i]为上一步可以有几个u以最短的长度到i。
然后对每个k分类讨论一下:
- \(y_{i}>dis_{i}\) 这条特殊边无用,对最短路没影响直接删掉。
- \(y_[i]==dis_{i}\)
- 如果有别的非特殊边就能以最短路到i那么这条边没用
- 如果多条特殊边以最短路到i且不存在不走特殊边到i是最短路,就留一条。
第二种情况实现时将ct--,看最后是否为0即可。
#include<cstdio>
#include<queue>
#include<algorithm>
#include<cstring>
#define int long long
using namespace std;
const int maxn=8e5+10;
int head[100010],n,m,k,ans,cnt=1,dis[100010],ct[100010];
bool vis[100010];
struct node{
int v,w;
node(int u_,int w_){
v=u_,w=w_;
}
node(){}
}a[100010];
bool operator<(node x,node y){
return x.w>y.w;
}
struct edge{
int to,nxt,w;
}e[maxn];
void add(int u,int v,int w){
e[cnt].to=v,e[cnt].w=w,e[cnt].nxt=head[u],head[u]=cnt++;
}
priority_queue<node>q;
signed main(){
scanf("%lld%lld%lld",&n,&m,&k);
for(int i=1;i<=m;i++){
int u,v,w;scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w),add(v,u,w);
}
for(int i=1;i<=k;i++){
int s,w;scanf("%lld%lld",&s,&w);
a[i]=node(s,w);
add(1,s,w),add(s,1,w);
}
memset(dis,0x3f,sizeof(dis));
dis[1]=0,q.push(node(1,0));
while(!q.empty()){
node now=q.top();
q.pop();
int u=now.v;
if(vis[u]) continue;
vis[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(dis[v]>dis[u]+e[i].w){
dis[v]=dis[u]+e[i].w;
ct[v]=1;
q.push(node(v,dis[v]));
}
else if(dis[v]==dis[u]+e[i].w)
ct[v]++;
}
}
for(int i=1;i<=k;i++){
if(a[i].w==dis[a[i].v]){
ct[a[i].v]--;
}
}
for(int i=1;i<=k;i++){
if(a[i].w==dis[a[i].v]&&ct[a[i].v]==0){
ans++;
ct[a[i].v]++;
}
}
printf("%lld",k-ans);
}
注意讨论重边的情况。
相关题目/资料: Edge Deletion
给一个nn个点,mm条边的无向简单带权连通图, 要求删边至最多剩余kk条边.
定义"好点"是指删边后, 1号节点到它的最短路长度仍然等于原图最短路长度的节点.
最大化删边后的好点个数.
和上题非常类似,只是把条件和要求调换了。
还是最短路径树的运用,如果在最短路径树上的边能留则留。
#include<cstdio>
#include<cstdlib>
#include<queue>
#include<cstring>
#include<algorithm>
#include<vector>
#define int long long
using namespace std;
const int maxn=3e5+10;
vector<int>ans;
int head[maxn],cnt=1,dis[maxn],n,m,k;
bool vis[maxn];
struct edge{
int to,nxt,w,id;
}e[maxn<<1];
struct node{
int u,w;
node(int u_,int w_){
u=u_,w=w_;
}
node(){}
};
bool operator<(node x,node y){
return x.w>y.w;
}
priority_queue<node>q;
void add(int u,int v,int w,int id){
e[cnt].to=v,e[cnt].nxt=head[u],e[cnt].w=w,e[cnt].id=id,head[u]=cnt++;
}
void dfs(int u){
if(ans.size()==k) return;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(dis[v]!=dis[u]+e[i].w||vis[v]) continue;
vis[v]=1;
ans.push_back(e[i].id);
if(ans.size()==k) return;
dfs(v);
if(ans.size()==k) return;
}
}
signed main(){
scanf("%lld%lld%lld",&n,&m,&k);
for(int i=1;i<=m;i++){
int u,v,w;scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w,i),add(v,u,w,i);
}
memset(dis,0x3f,sizeof(dis));
q.push(node(1,0)),dis[1]=0;
while(!q.empty()){
node now=q.top();
q.pop();
int u=now.u;
if(vis[u]) continue;
vis[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(dis[v]>dis[u]+e[i].w){
dis[v]=dis[u]+e[i].w;
q.push(node(v,dis[v]));
}
}
}
memset(vis,0,sizeof(vis));
vis[1]=1;
dfs(1);
int t=ans.size();//细节
printf("%lld\n",t);
for(auto i:ans) printf("%lld ",i);
return 0;
}
Bellman-ford
引理:若原图无负环,最短路总是简单道路。
相关题目/资料: 最小环
n<=100,m<=1e4。
首先,容易想到枚举两个点做端点然后dij的做法,TLE。
考虑怎么优化每次枚举端点都要跑最短路。
考虑到floyd算法是用不超过k的点作为中转点,那么枚举两个点这两点之间有k然后这两点的另一边是借助不超过k的点构成的环。
这样就解决了每次枚举两个点都要跑最短路的情况。
为了不重复不超时的枚举每个环,我们不妨将k定义成环中最大的点,一边floyd一边找最小环。
在n^3可以解决。
细节有点多:
- 由于是三个花费相加,考虑初始化成0x3f3f3f3f会爆int,所以除个3.
- floyd记录中间点输出路径就像中序遍历一颗树,不要误认为记录的是最后一个中转点,从而写成只访问一边,或者错写成后序遍历。
#include<cstdio>
#include<algorithm>
#include<vector>
using namespace std;
const int maxn=110;
const int INF=0x3f3f3f3f;
int dp[maxn][maxn],n,m,G[maxn][maxn],path[maxn][maxn];
vector<int>anss;
void print(int u,int v){
if(path[u][v]==0) return;
int k=path[u][v];
print(u,k);
anss.push_back(k);
print(k,v);
}
vector<int>::iterator it;
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
dp[i][j]=INF/3,G[i][j]=(i==j)?0:INF/3;
for(int i=1;i<=m;i++){
int u,v,w;scanf("%d%d%d",&u,&v,&w);
G[u][v]=min(G[u][v],w);
G[v][u]=min(G[v][u],w);
dp[u][v]=dp[v][u]=G[u][v];
}
int ans=INF/3;
for(int i=1;i<=n;i++) dp[i][i]=0;
for(int k=1;k<=n;k++){
for(int i=1;i<=k;i++)
for(int j=1;j<=k;j++){
if(i!=j&&i!=k&&j!=k)
ans=min(ans,G[i][k]+G[k][j]+dp[i][j]);
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]);
}
if(ans==INF/3){
printf("No solution.");
return 0;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
dp[i][j]=G[i][j];
for(int k=1;k<=n;k++){
for(int i=1;i<=k;i++)
for(int j=1;j<=k;j++){
if(i!=j&&i!=k&&j!=k)
if(G[i][k]+G[k][j]+dp[i][j]==ans){
anss.push_back(i);print(i,j);anss.push_back(j),anss.push_back(k);
for(it=anss.begin();it!=anss.end();it++) printf("%d ",*it);
return 0;
}
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
if(dp[i][j]>dp[i][k]+dp[k][j]){
dp[i][j]=dp[i][k]+dp[k][j];
path[i][j]=k;
}
}
}
return 0;
}
相关题目/资料: 中间的球
给定奇数n个球并且给定直接的轻重关系,问可以确定多少个球不是重量居中的。
传递闭包问题:即将有传递性的有向直接关系用floyd传递。
给定直接关系根据图的定义构图,有向边u->v表示u比v重。
如果比某个点重的或轻的超过一半这个点一定不是居中的,否则可以找到某种构造法使他居中。
if(dp[i][k]&&dp[k][j]) dp[i][k]=1
差分约束系统(常考)
原理,用最短路松弛的不等式性质将差分约束问题转换成最短路问题。
证明:
- 一方面,构图满足差分约束
- 令一方面,钦定x0=d0后用最短路径树的顺序可以证明\(x_{i}<=d_{i}\)。
记住最短路对应最大值,用松弛不等式连边,有负权边。
如果要求min:
\(min(x_{a}-x_{b})=-max(x_{b}-x_{a})\)
相关题目/资料: Intervals
给定n个[a,b]和c。
要求[a,b]中至少包含c个整数。
求整数集最少几个元素。
0<=值域,n<=5e4。
区间类问题:
- 常用算法:前缀和,区间DP...
- 常用数据结构:st表,线段树...
用\(x_{i}\)表示从0到i-1有几个数。
对于每个[a,b],\(x_{b+1}-x_{a}>=c\)。
建图跑spfa即可,答案是x[50001]-x[0]。
特别注意:仅仅凭借以上约束图有可能不连通,我们往往还要从题目,定义,值域等方面寻找另一个差分约束的条件。
一般都是相邻的两个之间的关系。(方便,边数少)
\(0<=x_{i+1}-x_{i}<=1\)
相关题目/资料: 糖果
给定了n<=1e5个正整数之间k<=1e5个差分约束关系。
求\(\min(x_1+x_2+...+x_n)\)。
难点:和传统的差分约束系统求相减的数不同,而且是多个数,从而不能直接钦定某个数的初值,不知道上界。
这题的突破口在于正整数也提供了\(x_i>=1\)的差分约束关系。
建立虚点\(x_{0}=0\),令\(t_i=-x_i\)。
注意t的差分约束关系要与x的反过来(不等式两边同乘-1)。
那么问题转换为求\(\max((t_1-x_0)+(t_2-x_0)+...+(t_n-x_0))\)。
根据差分约束原理里的对于每一个\(dis_{i}<=x_i\)可以将上述式子转为最短路长度之和。
这题巧妙运用了正整数对于0的一类整体的隐性的差分约束关系,建立虚拟点,并用了差分约束的原理,将多个max转为求单个的max。
技巧:题目的隐性差分约束关系往往是两两之间的关系或者整体数域之间的关系。通常可以是实际问题中的非负整数一类的限制。
拓展思考:如果求\(max(x_a+x_b)\)呢。
和上面用数学方法一样,令\(t_b=-x_b\)。
原式化为\(max(x_a-t_b)\)。
令\(d_{t_b}=0\),建立所有元素负的对应点,原点也保存,现在两边是分散的。
如果我们在有哪些是正数或者负数,或者共同与某个数字(一般为0,作为隐性约束给出)有差分约束关系,那么可以解决。
实现:同样是本题的难点。
直接spfa会被卡。
观察这个图的性质,对于2,4的差分约束,会有边权为1的边,对于正环,由于这个差分约束系统做的是最长路,无解。对于135的差分约束,会有边权为0的边,对于边权为0的环,缩成一个点,Tarjan跑图的最长路之和则为答案,topo排序。
当然,按最长路构图>=1的差分约束限制仍然需要。
最短路综合练习
相关题目/资料: Destroying Roads
给定简单无向图,给定两个起点终点和长度限制。
找两条路,使得路长不超过对应的长度限制,并且要求这两条路占最少的边。
知识点:分类讨论思想,最短路。
难点:讨论两条路的位置关系。
一开始容易想到分别求两条最短路,但是不一定正确。
我们先确定一条路,考虑另一条路和这位置关系。
画图不难发现,要么不交,要么相交与同一段公共部分。
不然如果有环肯定不是最优解。
和之前找最少的边使得三点连通有点像,同样是先确定一条边,然后考虑额外元素和这调边的位置关系分类讨论,这里不能直接一个树根四段最短路相加,有相交部分。
bfs求全源最短路,枚举公共部分两端点即可。
#include<cstdio>
#include<algorithm>
#include<queue>
#include<cstring>
using namespace std;
const int maxn=3001;
int head[maxn],dis[maxn][maxn],n,m,s1,s2,l1,l2,t1,t2,cnt=1;
bool vis[maxn];
queue<int>q;
struct edge{
int to,nxt;
}e[maxn<<1];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
void bfs(int s){
memset(vis,0,sizeof(vis));
dis[s][s]=0,q.push(s),vis[s]=1;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!vis[v]){
dis[s][v]=dis[s][u]+1,vis[v]=1,q.push(v);
}
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int u,v;scanf("%d%d",&u,&v);
add(u,v),add(v,u);
}
scanf("%d%d%d%d%d%d",&s1,&t1,&l1,&s2,&t2,&l2);
memset(dis,0x3f,sizeof(dis));
for(int i=1;i<=n;i++) bfs(i);
if(dis[s1][t1]>l1||dis[s2][t2]>l2){
printf("-1");
return 0;
}
int ans=dis[s1][t1]+dis[s2][t2];
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
for(int k=1;k<=2;k++){
if(k==1){
if(dis[s1][i]>=0x3f3f3f3f||dis[j][t1]>=0x3f3f3f3f||dis[s2][i]>=0x3f3f3f3f||dis[j][t2]>=0x3f3f3f3f||dis[i][j]>=0x3f3f3f3f) continue;
if(dis[s1][i]+dis[i][j]+dis[j][t1]>l1||dis[s2][i]+dis[i][j]+dis[j][t2]>l2) continue;
ans=min(ans,dis[s1][i]+dis[j][t1]+dis[s2][i]+dis[j][t2]+dis[i][j]);
}
else{
if(dis[s1][i]>=0x3f3f3f3f||dis[j][t1]>=0x3f3f3f3f||dis[s2][j]>=0x3f3f3f3f||dis[i][t2]>=0x3f3f3f3f||dis[i][j]>=0x3f3f3f3f) continue;
if(dis[s1][i]+dis[i][j]+dis[j][t1]>l1||dis[s2][j]+dis[i][j]+dis[i][t2]>l2) continue;
ans=min(ans,dis[s1][i]+dis[j][t1]+dis[s2][j]+dis[i][t2]+dis[i][j]);
}
}
printf("%d",m-ans);
return 0;
}
拓扑排序
将有向图G排成一个线性序列使得任何结点的直接前驱排在自己之前。
可以用于:
- 确定偏序关系。(往往还要结合偏序的传递性构图)
- 解决有向图构造的博弈模型中的一些问题。
- 利用有向无环图的偏序关系解决图上DP问题
相关题目/资料: Pair of Balls
上面的一定比下面先取,构成偏序关系,又要求同色的同时取,构图确定有没有环即可,暴力模拟也可以。
相关题目/资料: fox and name
输入n个字符串,确定合法的新字典序使得n个字符串按字典序排列。
n,len<=100
简化问题:问题其实是确定字母的偏序关系,由于相邻两字符串都是按顺序排的根据偏序传递性,只需要满足每个相邻的都合法即可满足全部合法。
解法:按照字典序的定义讨论建图即可,字母为点,跑拓扑排序。(每次回顾时自己再想一遍,所以这里先不写解法)。
技巧:确定相邻两个的顺序即可确定全体顺序的简化思想。
相关题目/资料: Teleporter
有n<=1e5个城市,每个城市有一个传送点,都可以传送到唯一的另外一个城市(可以是自己),保证从任何位置出发经过若干次传送之后能够到达1号城市。现在希望修改一些点的目的地,使得从任何一点出发在传送K次之后恰好都能到达1号城市,求最少要改变目的地的城市的数量。
首先通过刚好一个结点与一个结点连有向边,n个结点n条边很接近一颗树。
其次,举例子和猜想不难发现,如果1指向自己是可以很方便的满足很多种情况的,而且1不指向自己,总能找到一个点怎么改都不合法。
考虑有结论1(根)总指向自己。
反证:
为了得到矛盾,假设1不指向自己且所有点都能恰好传送k次到1。
那么一定有一个点连向1,记为a。
(倒着想,其实证明1在k-1次一定会到1即可)
a点传送k次一定到1,a第一次直接到1还剩k-1次,那么一定要满足1传送k-1次到1,并且已知1传送k次到1。
1传送k-1次到1,又由于第k步从1出发且不到1,与1传送k次到1矛盾,故假设不成立原结论成立1。
现在问题转换到了一个有向树形图上的问题,而有向树形图具有非常好的性质,所以大大简化了问题。
接下来,问题可以转换成用最小的步数使得每个点的深度都<=k+1,根的深度为1。
动态规划似乎不容易解决这个最优化问题,并且这是一颗边指向父亲的有向树形图,也提示我们从叶子开始思考。
考虑贪心。
首先,要改一定是直接指向根最好,不到k步由于1指向1也合法。
如果是从根往下改,那么遇到超过不多的情况反而没有改掉他们合法的父亲步数少,所以从叶子往上改。
拓扑排序可以在这个有向树形图中很好的实现这一贪心算法,记录每个点到他后继最深的叶子的距离如果==k-1并且他的父亲不是1那么改掉他,并且他的父亲到叶子的距离改成0。
证明:不是一般性的假设处理的顺序是先从不满足的深度最深的。那么如果存在最优解对于这个节点改动的不是最上面k-1步的点,改成那个点仍然是最优解。证毕。
这题运用到了两次从特殊情况优先思考的策略。
直接上来这个问题十分难解决,我们先从特殊的点入手即首都,把特殊的情况考虑清楚了往往可以发掘一些性质和结论,从而简化,转化问题。一个结论一般先猜想再证明或者程序验证。
step1:从特殊入手,分析出一个结论,进而转化成有向树形图上的问题,有向树形图就是一个性质。1走k-1步到的点一定要改成指向1,那么走k-1步到的点走k步一定到自己,所以这个走k-1步的点一定是1,要把一指向一,否则总存在一个不合法的点。
有向树形图是特殊的有向无环图,有n个点n-1条边,每个点有唯一的父亲。
step2:问题变成了如何修改的问题,DP不好解决,优先考虑贪心。同样从最难满足的深度最深的入手,不难猜到尽量把靠上的修改对后面更优,因为可能有其他的点也因为这次变成了满足条件的点。
这里涉及的贪心思想是从边界、特殊(叶子,度为0,度为1)状态考虑,尽量让策略满足自己的同时对后面更优。属于满足类贪心问题的常用思路,证明一般用交换法。
在这里,topo排序变成了处理DAG上问题的一种工具,而不是利用处理偏序的性质。
第一次是考虑每个城市到首都,那么首都也要k次到首都,非常特殊。
#include<cstdio>
#include<queue>
#include<algorithm>
using namespace std;
const int maxn=1e5+10;
int dis[maxn],head[maxn],cnt=1,a[maxn],ans,k,in[maxn],n;
queue<int>q;
struct edge{
int to,nxt;
}e[maxn];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
int main(){
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
if(i==1) ans+=(a[1]!=1),a[1]=1;
else add(i,a[i]),in[a[i]]++;
}
for(int i=1;i<=n;i++)
if(in[i]==0) q.push(i);
while(!q.empty()){
int u=q.front();
q.pop();
if(dis[u]==k-1&&a[u]!=1) ans++,dis[u]=-1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
dis[v]=max(dis[v],dis[u]+1);
if(--in[v]==0) q.push(v);
}
}
printf("%d",ans);
return 0;
}
技巧;n个结点n条边的有向图可以考虑转换成有向树形图(有向无环),常用拓扑排序解决。
技巧:n个结点n条边的无向图考虑基环树,环是问题的突破口,所以常常配合dfs找环。
相关题目/资料: ABC277F
思考交换的性质和本质上的意义可以发现,原本在同一行的数,交换后依然在同一行。列同理。这是由于都是整行整列的交换。
转化问题:考虑维护偏序的实质是维护行和列的偏序,而不是点之间的偏序。
验证一些偏序关系是否成立时,可以将偏序关系当成边构图,通过跑拓扑排序检查有没有环。
技巧:构图时如果偏序关系具有传递性仅需将相邻的两个偏序的点集连边即可。
例如:A<B<C<D<E。
在构图时仅需将点集A与B,B与C,C与D,D与E连边即可,无需将A于B,C,D,E分别连边。
因为偏序关系具有传递性。
先处理行, 容易发现如果第i行的最小值大于等于i-1行最大值恒成立,那么在行上是有解的,排序扫一遍即可解决,行与行之间是存在直接偏序关系的,所以可以直接排序验证,而列与列直接的偏序关系取决于是否同时满足每行的各元素之间的偏序关系,而整体一列之间没有直接的偏序关系,所以不能排序解决。
现在只用处理列上的的偏序,如果元素各不相同,很好解决,大小相邻的直接连边即可,因为偏序有传递性,所以仅需连相邻的两个。
但是出现值相同的就需要两两相连,可能被卡成平方,值相同的之间建一个虚拟点即可处理。
验证偏序是否成立尤其要注意分类的对象,是否会出现值相同的两大类之间有很多点,导致边数变成平方。这时需要用到加虚拟点的技巧。
技巧:点集中如果元素过多可以在构图时在相邻两个点集直接添加虚拟点减少边数。
一般相等(不存在偏序)的元素放在同一个点集。
构图时,搞清楚点的含义,在每个部分将点加边建立新的偏序关系时,都需要用到新的虚拟点。
加虚拟点记得开够空间,注意点数、边数。
#include<cstdio>
#include<vector>
#include<algorithm>
#include<stack>
using namespace std;
const int maxn=3e6+10;
int n,m,f[maxn],head[maxn],cnt=1,in[maxn],k;
struct edge{
int to,nxt;
}e[maxn];
void add(int u,int v){
e[cnt].nxt=head[u],e[cnt].to=v,head[u]=cnt++;
}
vector<bool>vis[maxn];
struct nod{
int x,id;
nod(int x_,int id_){
x=x_,id=id_;
}
nod(){}
};
vector<nod>a[maxn];
stack<int>s;
struct node{
int mx,mn;
}val[maxn];
bool cmp(node x,node y){
if(x.mx==y.mx) return x.mn>y.mn;
return x.mx>y.mx;
}
bool cmpp(nod x,nod y){
return x.x<y.x;
}
bool topo(){
int tmp=0,mb=2*m*n+n;
for(int i=1;i<=mb;i++)
if(!in[i]) s.push(i);
while(!s.empty()){
tmp++;
int u=s.top();
s.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
in[v]--;
if(!in[v])
s.push(v);
}
}
return tmp==mb;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
val[i].mx=-0x3f3f3f3f,val[i].mn=0x3f3f3f3f;
for(int j=1;j<=m;j++){
int x;scanf("%d",&x);
if(!x) continue;
a[i].push_back(nod(x,j));
val[i].mx=max(val[i].mx,x),val[i].mn=min(val[i].mn,x);
}
}
sort(val+1,val+1+n,cmp);
for(int i=1;i<n;i++){
if(val[i].mn==0x3f3f3f3f||val[i+1].mx==-0x3f3f3f3f) continue;
if(val[i].mn<val[i+1].mx){
printf("No");
return 0;
}
}
k=m;
for(int i=1;i<=n;i++){
sort(a[i].begin(),a[i].end(),cmpp);
k+=2;
for(int j=0;j<a[i].size();j++){
if(j!=0&&a[i][j].x!=a[i][j-1].x) k++;
add(k,a[i][j].id),add(a[i][j].id,k+1),in[a[i][j].id]++,in[k+1]++;
}
}
if(!topo()){
printf("No");
return 0;
}
printf("Yes");
return 0;
}
法二:多路归并排序。
每一列也用排序的方式判断是否存在偏序,维护两个量。
考虑当前谁可以作为最小的排在前面,将可以的列加入队列或栈中维护。
\(sum_i\) 表示第i列满足该数是该行最小的或者为0的个数。若\(sum_i=n\)那么该列可以作为最小的,把列排到最前面,由于只需要维护是否存在偏序,不需要输出结果,实际实现上将该列从队列中弹出即可。
\(now_i\)表示第i行最小的一列在排好序的vector中的下标,如果当前这列删掉了要移动now_i到第一个没有删掉的地方。
如果now移动到了一个新的值,那么要将该行的vector中所有数值和now相同的列所在的sum++。
不断操作,最后check有没有没有删掉的列即可。
如果不算每一列排序时间复杂度\(O(nm)\)
技巧:确定是否存在题目要求的偏序的两种思路:是否能排序(总能找到最小值,往往要用到多路归并和堆来维护),建图是否为有向无环图(跑拓扑排序)。
技巧:对于一个涉及到n个有序序列元素之间的偏序关系操作的矩阵,如果是对列进行排序,或者找最小最大之类的和偏序相关的问题,往往是一个不断访问各行最小值并且进行操作的过程,这是可以使用每一列维护一个指针的技巧高效访问需要访问的量,因为列的最小与每一行各元素的值以及他们所在的列有关,所以要事先对每一行排序,或者用优先队列维护。
另外一些多路归并排序的题序列合并、
相关题目/资料: 车站分级
难点:转化条件“如果这趟车次停靠了火车站 x,则始发站、终点站之间所有级别大于等于火车站x 的都必须停靠。”
在不知道任何车站的等级的条件下这个题目给出的关键约束是基本上无法满足的。
思考这个条件的对立面(补集),如果构造一个序列使得约束的补集满足那原约束也满足。这是OI解题转化问题的常用思路,思考如何直接满足约束,如果不好满足,思考如何满足约束的补。
这题要在有虚拟点的图上跑最长路,或者统计层数。
法1,统计层数时要避免虚拟点与真实点在同一层,每次先处理虚拟点再处理真实点,让队列中一直只有真实点。
法2:将图的层数转换为最长路,证明最长路等于图的层数仅需将边转移到点上即可,证明两个问题是相同的。在有虚拟点的DAG上topo要将权值加在虚拟点的出的一测。
因为可能出现只入不出的情况,这样虚拟点的值就错了。
可以理解成原来的点到虚拟点的距离为0。
本质上都是消除虚拟点的影响。
加虚拟点要考虑是否在不用加虚拟点的情况下加了只进不出,或者只出不进的虚拟点,如第一个,最后一个,二分图的一部分为空的情况。避免这些虚拟点对答案的影响(尽量只加有用的虚拟点,不然可能出错),加虚拟点要考虑边界细节。
给定含n<=2e5个成语的字典。
由52个大小写字母组成的长为3-8的成语。
两人玩词语接龙的游戏,规则如下:
- 只能用字典里的成语,可以反复用同一成语。
- S的长为3的后缀等于T长为3的前缀那么T可以接在S后说。
- 最后谁说不了就输。
判断对于输入的每个成语,先手从该成语开始,双方都采取最优策略,谁获胜。
Takahashi是先手,Aoki是后手,平局输出Draw。
博弈类问题,可以建有向无环图构成博弈模型。
常见的解决算法有:数学,拓扑排序,DP。
这题用有向无环图确定每个点的胜负关系。
如果将单词作为点,暴力连边表示一个单词可以接到另一单词,那么可以被卡成平法,考虑减少边数。
直接构图,边数可能达到平方级别TLE。
思路1:加虚拟点(前面很多题减少边数都用到了这个思想)。
- 加虚拟点保证连通性,若有边权,不改变边权的作用。
- 将同一类的加入某一类有含义的虚点,用不同类的虚点表示不同类之间的关系,从而避免了不同类之间每个点两两连边,减少边数。
这道题考虑将每个前三字母后三字母建立虚拟点,减少了边数,但是判断虚拟点的胜负难以和真的分开,难以处理,并且还保留了冗余信息。
思路2:减省冗余化点为边,重新构图。
把问题给出的多余条件给去掉,仅仅保留和解决问题相关的条件,保留的条件越少解决问题就越容易。有时候有冗余条件的问题是无法解决的,但是简化后的问题就可以解决。
每个单词其实对应了一对前三后三的关系,将每个单词的前三向后三建有向边,用边表示一个单词,其中边的终点的胜负为这个单词的胜负。
首先,走到出度为0的点对手负。
一点有后继为负,则为胜。
后继均为胜则为负。
根据这个就可以得到每个能算出来的点的胜负关系,算不出来的为平。
注意:不一定是有环才算不出来,有环如果环的某点连向负那么也是可以算的,所有这个算法和普通的拓扑排序不太一样。
为了方便可以减反图,在反图中,每个节点只入队访问一次,记录每个点的入度和前驱是胜的个数即可。
特别注意:由于是得到拓扑序列,所以每个点只能访问一次。
类似拓扑排序的做法(bfs),把能确定状态的点给入队,用它的状态去更新还没有确定状态的点,如果那个点的状态确定了也将他入队。注意:每个点只入队一次。
#include<cstdio>
#include<map>
#include<utility>
#include<queue>
#include<algorithm>
#include<cstring>
#include<string>
#include<iostream>
#define mkp(x,y) make_pair(x,y)
using namespace std;
const int maxn=1e6+10;
int head[maxn],cnt=1,in[maxn],num[maxn],zt[maxn],n,f[maxn];
bool vis[maxn];
string str;
map<string,int>hx;
map<pair<int,int>,int>mp;
struct edg{
int to,nxt;
}e[maxn];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
int ins(string s){
if(hx.find(s)==hx.end()) hx[s]=++n;
return hx[s];
}
queue<int>q;
int main(){
int t;
scanf("%d",&t);
for(int i=1;i<=t;i++){
cin>>str;
int len=str.length();
string tmp="";
for(int i=0;i<=2;i++) tmp+=str[i];
int u=ins(tmp);
tmp="";
for(int i=len-3;i<=len-1;i++) tmp+=str[i];
int v=ins(tmp);
f[i]=v;
if(mp.find(mkp(v,u))==mp.end()){
mp[mkp(v,u)]=1;
add(v,u),in[u]++;
}
}
for(int i=1;i<=n;i++)
if(!in[i]) q.push(i),zt[i]=-1;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(zt[u]==1&&!vis[v]){
num[v]++;
if(num[v]==in[v]) zt[v]=-1,q.push(v),vis[v]=1;
}
if(zt[u]==-1&&!vis[v]){
zt[v]=1;
q.push(v),vis[v]=1;
}
}
}
for(int i=1;i<=t;i++){
if(zt[f[i]]==-1) printf("Takahashi\n");
else if(zt[f[i]]==1) printf("Aoki\n");
else printf("Draw\n");
}
return 0;
}
相关题目/资料: 排水系统
拓扑排序模板题。
但是分析可以得到答案最大是lcm的11次方。
先乘后除,要高精。
分数运算可以先将一开始的数弄的很大。
然后除,最后再和一开始的数比上约分。
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int maxn=1e5+10;
int head[maxn],cnt=1,n,m,in[maxn],ot[maxn];
queue<int>q;
struct edge{
int to,nxt;
}e[maxn*5];
void add(int u,int v){
e[cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt++;
}
struct Number{
int a[50],len,yu;
void cl(){
memset(a,0,sizeof(a)),len=1,yu=0;
}
Number(){cl();}
}b,val[maxn];
void print(Number x){
for(int i=x.len-1;i>=0;i--) printf("%d",x.a[i]);
}
Number operator*(Number x,int y){
Number ret=x;
ret.len+=20;
for(int i=0;i<=ret.len;i++)
ret.a[i]=ret.a[i]*y;
for(int i=0;i<=ret.len;i++){
ret.a[i+1]+=ret.a[i]/10;
ret.a[i]%=10;
}
while(ret.len>1&&!ret.a[ret.len-1]) ret.len--;
return ret;
}
Number operator/(Number x,int y){
Number ret;
ret.len=x.len;
int t=0;
for(int i=x.len-1;i>=0;i--){
t=t*10+x.a[i];
ret.a[i]=t/y;
t%=y;
}
ret.yu=t;
while(ret.len>1&&!ret.a[ret.len-1]) ret.len--;
return ret;
}
Number operator+(Number x,Number y){
Number ret;
ret.len=max(x.len,y.len)+1;
for(int i=0;i<ret.len;i++){
ret.a[i]+=x.a[i]+y.a[i];
ret.a[i+1]+=ret.a[i]/10;
ret.a[i]%=10;
}
while(ret.len>1&&!ret.a[ret.len-1]) ret.len--;
return ret;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
int t;scanf("%d",&t);
while(t--){
int v;scanf("%d",&v);
add(i,v),in[v]++,ot[i]++;
}
}
b.a[0]=1;
for(int i=1;i<=11;i++) b=b*60;
for(int i=1;i<=n;i++)
if(!in[i]) q.push(i),val[i]=b;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
val[v]=val[v]+val[u]/ot[u];
if(--in[v]==0) q.push(v);
}
}
for(int i=1;i<=n;i++)
if(!ot[i]){
Number x=val[i],y=b;
Number xx=x/10,yy=y/10;
while(xx.yu==0&&yy.yu==0){
x=xx,y=yy;
xx=x/10,yy=y/10;
}
xx=x/2,yy=y/2;
while(xx.yu==0&&yy.yu==0){
x=xx,y=yy;
xx=x/2,yy=y/2;
}
xx=x/3,yy=y/3;
while(xx.yu==0&&yy.yu==0){
x=xx,y=yy;
xx=x/3,yy=y/3;
}
xx=x/5,yy=y/5;
while(xx.yu==0&&yy.yu==0){
x=xx,y=yy;
xx=x/5,yy=y/5;
}
print(x);
printf(" ");
print(y);
printf("\n");
}
return 0;
}
图的连通性与Tarjan算法
桥
无向图中,删掉这条边图变得不连通,求出这些边。
解法:
需要借助的工具DFS树。
无向图中DFS树只有树边和回边两种边。
性质:除树边就是回边。
反证,存在连线其他非祖先的点,那么这个点会在搜到这条边时搜到,而不是出现再其他非祖先的地方,因为子孙搜到的边搜完了才会回到祖先。
如何找桥。
首先回边一定不是桥。
定义:low[u],表示u所在的子树(包括u),能回到的最小深度。
low[u]<=dep[u]
若边u,v则low[v]=dep[v],时,这条边是桥。
如何求low[u]。
若是回边,v是u的祖先:low[u]=min(low[u],dep[v])。
若是树边,v是u的子孙:low[u]=min(low[u],low[v])。
注:若有重边,要标记树边,如果不是树边那么就是回边。(用链式前向星^1的技巧标记正反两条边,或者用结构体打编号。)
边-2-连通分量。
边-k连通:如果一个图删掉k-1条边仍然连通,那么它是边k连通的。
定义:无向图中的极大子图满足删掉任意1条边图仍然连通那么这个连通分量是边2连通的。
性质:每个点仅在一个边2连通分量中,否则可以将两个分量合并。(无桥)
求法:由于实现的时候是一个点及其一些子孙构成边2连通分量判定的,(桥出现的时候伴随着连通分量的出现,把桥删掉剩下的是边2连通分量。)所以比较巧妙的做法是利用后序遍历时从深往浅输出。
用一个栈可以记录,搜到一个点压栈,遇到一个点dep[u]=low[u],判定为桥就把栈中的点弹出,直到u(u也要弹出)。
如果实现时是同时求出割边和边2连通分量,那么在回溯时dep[v]=low[v]输出,最后栈中还会剩一个根所在的边2连通分量,也要输出。
割点
定义:无向图删掉这个点就不连通。
求法:
考虑对于点u:
如果存在一个儿子它不能回到u祖先的点,那么u是割点。
存在\(c,low[c]>=dep[u]\),则u为割点。
特殊的:对于根如果有两个以上的儿子为割点。
点-2-连通分量
点-k-连通:无向图删掉k-1个点仍然连通,则这个无线图是点k连通的。
点-2-连通分量:图中的一个极大子图如果删掉1个点仍然是连通的则这个极大子图是点-2-连通分量。
定义:任意不同两点间至少存在两条点不重复的路径。(简单路径+交集为空)
一个近乎等价的定义是:不存在割点的图。
这个定义只在图中只有两个点,一条连接它们的边时失效。它没有割点,但是并不能找到两条不相交的路径,因为只有一条路径。
(也可以理解为那一条路径可以算两次,的确没有交,因为不经过其他点)
虽然原始的定义的确是前者,但是为了方便,我们规定点双图的定义采用后者。
性质1:每条边仅属于1个点-2-连通分量。否则可以合并。
性质2:桥连接的一定是割点,割点连的边不一定是桥。
性质3:假设图G 包含2个点x和y(x!=y)。可证,最多只有一个块(块=点2连通分量)它包含了x与y。
因为一个点可能作为多个极大子图的叶子,作为叶子一定不会成为割点,不会影响之前分量的点2连通性质。
求法:和边-2-连通分量类似,把边的编号压入栈即可。
对于每个连图分量,如果最后栈中还有元素也要作为连通分量弹出。
这种情况出现在根不是割点但是属于某个连通分量,是第一个割点上面的连通分量。
与点2连通分量,边2连通分量相关的树形结构:
桥树
1.与桥相关的树。仅保留桥作为边,其他连通分量缩点,一定构成一棵树。
桥一定无环,所以是树。
作用:求任意两点间必经过的边。
圆方树
2.圆方树。(与点2连通分量相关)
很重要,常与DP结合。(数是一个很好的性质)NOIP2023T3。
用一个方块代替一个点2连通分量,保留割点与圆,方与圆相连构成的树。也是一个二分图。
为什么一定是树?如果存在某个方连圆构成环,那么一定存在环上的一段圆方链上的圆不再是割点,都变成方,然图中的这个方一定会和另外某个方合并。因为多了这条边一定会产生一个更大的点2连通分量。
出现环一定可以合并一些方,直到没有环。
性质:叶节点一定是方块。
性质:叶子结点一定是圆,圆一定连着方。
圆方树上的距离不会超过原图距离的二倍。
只有割点会出现在多个点2连通分量。(在多个连通分量->把图分开->是割点,其余的点是叶子。)根据这个性质求点2连通分量可以将点入栈,每次找到割点不断出栈直到它儿子,但不把割点出栈。
根据二分图+树,可以用二分图的构造方法证明方不超过n个。
对于求点双的tajan无需判断根的子树是否超过2,可以直接用相同的判定方法,哪怕根的儿子只有一个那根所在的也是深度最小割点连接的一个点双。
看到连通分量,可以考虑缩点。
为了方便解决问题,我们一般在表示圆方树的时候把原图的点全部带上。
原图中的点保留,为圆点。原图中的点双连通分量为方点。
方点向每一个在该方点表示的强连通分量中点连边,连成菊花图。多个菊花图由割点连接。(割点是点双的分割点)
圆点一定连向方点,并为叶子:因为方不可能连向方,圆不可能连向圆。(两圆之间至少可以构造一个方。)
圆方树中的点的个数小于2n,因为割点数小于n?
因为不会有两个点同时出现在两个不同的点2连通分量,不同的点2连通分量之间的交集的元素数量不会超过1。
所以一个割点最多对
如果原图有k个连通分量,构造出的是k颗圆方树森林。
如果树中只有一个点,要根据具体情况讨论。
与点2连通分量,边2连通分量相关的定理。
Manger's定理:
边2连通图中,对于任意两点都存在两条无公共边的路径。
点2连通图中,对于任意两点都存在两条无公共点的路径。(并且也没有公共边,这两条路径没有交集(除了起点和终点),这个性质比较优秀。)
这个定理可以拓展到k。
k连通的本质是无向图上表示有k条某些元素(点或边)不相交路径的意思。
Robin定理:
边2连通图中,存在一种给边定向的方法,使得定向之后的有向图强连通图。
证明:归纳法构造证明。
边2连通图必然不是树,必然有环。
先找一个环,然后顺时针连起来,再找环上的某个点连出去的非环上一点,将连这点的那条边定向连到环上。然后再找个点在的环,顺时针连起来。
这个构造下去直到没有点。
由于点双,必然可以找到环。用搜索找。
构造的时候一定要注意与已经构造好的连上。
实现:强连通图缩点后再找环。或找链定向。
以上总结时间:1h14min。
例题:
相关题目/资料: STORE - Store-keeper
点双连通分量练习
点双连通分量在迷宫题中的应用,推箱子。
相关题目/资料: 解题报告link
割边练习
相关题目/资料: USACO06JAN
给定一张无向连通图,问至少加几条边使得图中无桥。
先把边双连通分量缩成点,可以得到一颗树,问题变成一棵树最少加几条边使得树中无桥。
答案可以通过猜想和找规律得到 \(k/2\) 取上整。
这里证明要用到一种常用的,值得借鉴的证明思想:先假设不存在对证明无用的且不影响整体性质的信息。
这里可以假设不存在度为 \(2\) 的点因为度为 \(2\) 的点只连接的树的两个部分,且去掉它不影响树的叶子相关的结构,这样大大减少了讨论的情况。
进一步我们可以将一个直接连着叶子结点的非叶子结点看成分界点,将树分成多个部分(按叶子的位置),当叶子个数大于四时,总能找到不同部分的点,让他们相连减少两个叶子结点,再缩点,将问题规模减少2,用归纳法可以继续证明。
难点:证明时要发现矛盾的点在于太近的点连起来会在减少两个叶子的同时增加一个叶子,就做不到答案那么小了,这样不是我们想要的,于是我们考虑连接距离远的叶子。
自己证明时,没有发现问题的矛盾点,举得例子不够具有象征性,举得例子太普通了,导致了考虑不全问题,并且没有想到简化问题的思想,假设不存在无用的信息点。
无向图连通性相关练习
2023.9.6
[ABC318G] Typical Path Problem
给定n个点m条边的无向图。n,m<=2e5。给定ABC,问是否存在从A->B->C的简单路径。
解法:
路径是否经过某个点的问题一般和点双连通分量有关。圆方树是分析路径和点双的利器。
不妨考虑把点双缩成一个点。
通过题目约束可以得到若A->B的必经之路与B->C的必经之路有交集那么一定没解。
必经之路就对应圆方树上的非叶子圆点。
树上路径唯一,找出A->C的路径,如果路径上某个方点可以直接到B那么就可以到B。(不能再经过某些圆点再到B,否则会不到路径上的那个方点,从而到不了C)
否则不行。
这里利用了一个圆方树的性质:如果是与方点相连的点那么存在除已经定下来的两点路径外还有至少有另外一条不经过哪些点的路径。(点2连通分量。)
技巧:路径上是否经过某个点的问题或者删掉一个点是否可以连通的问题可以考虑用点2连通分量或者圆方树分析。
把起点和终点定下来考虑途中是否经过某些点是比较好的做法,反之不一定能保证按顺序到终点。
一个巧妙的实现方法是不妨将起点或者终点作为圆方树的根。
这样找他们之间的路径只需要跳父亲即可。
相关题目/资料: STORE - Store-keeper
给定100*100的网格图,有些点不能走,给定起点(人,箱子),给定终点,每推一次箱子人体力花费1,否则不花费体力,求推倒终点的最小体力花费。
解法:
首先可以考虑DP[x][y][xx][yy],最暴力的做法,把人和箱子的位置都存在状态里,MLE+TLE。
减省状态,首先箱子的位置时必要的,考虑简化人的位置。
通过观察可以发现,只需要知道人是在箱子上下左右就可以知道是否向下一个箱子的位置转移。
所以只需要存人在箱子上、下、左、右的相对位置。
现在问题的难点变成了判断一个箱子的一个方向的点是否能不经过箱子所在的点到达下一个位置。
不经过某点是否存在路径的问题,不难想到点双连通分量。
结论:如果那两个点不在同一个点双中,则不行,否则可以。
证明,有不经过x的路和x,y必在同一个点双是充分必要条件。
可以用反证法很容易的证明。(由于箱子的位置是两点之间的一条路径唯一经过的点,这个条件非常强。)
或者证那两个点如果不在同一个点双,路径上的割点必然是箱子的位置,这个用反证法也很好证明。
法2:圆方树。
如果那两个点在圆方树上的路径中x不是其中的割点,那么一定存在不经过x的路径。
由于x和那两个点是有边相连的,所以可以证这三个点一定是相邻的(距离不超4),也可以在规定时间内AC。
这个做法的可迁移性更高,但是在这题中不是最简便的。
如果推广到距离箱子隔k个格子可以推,四个方向转移只要k不大,都可以暴力在圆方树上跑。
SCC:强连通分量。
强连通图:如果一个有向图任意两个点有路径可以互相到达那么说这个有向图。
强连通分量:极大的强连通子图。
一个点仅在一个SCC中,否则可以合并。
一个点在一个连通分量中,很像求边2连通分量。
考虑改进边2连通分量的算法,从而解决这个问题。
SCC与边2连通分量的区别就在于,由于是有向边,所以可能会出现交叉边的情况,即u指向以访问过的点不一定是u的祖先。
引入:
dfn[u]:u在dfs序列中的位置。
low[u]:u的子孙(包括u)能回到的在dfs序列中最靠前的点。
onstack[u]:u是否在栈中。(如果在栈中即还没有确定强连通分量,未输出,否则以确定在某个强连通分量中。)
由于交叉边的影响,如果不用dfn而中dep会出错。

比如在上面这个例子中会把4输出来,实际上1,2,3,4才是一个SCC。
dfn的好处是不仅包含了深度的信息,还包含了先后的信息,直接维护了在dfs序即栈中的位置,dfn小的在栈下面,从而可以解决交叉边的问题。
本质上还是判断是否能回去,回到某个点更之前的点,回不去就出现了SCC。
如果在栈中看的话如果low能回去dfn更小的点即栈中更靠下的点,那么就不用输出。
如果回不去就输出它,直至栈中输出到自己。
还要考虑交错边连已经输出的SCC的问题,已经输出的SCC说明回不到根,也说明了根访问到这个产生横叉边的点是无法从已经确认的SCC到达的,第一个SCC确定的一定不会错,因为不用考虑交错边。
所以如果连向一个点已经不是Onstack那么忽略掉这条边,否则在下面这个例子中会出错。

代码:
void DFS(u){
dfn[u]=low[u]=cnt++;
stack[top++]=v;onstack[u]=true;
枚举(u,v){
if(!dfn[v]){
DFS(v);low[u]=min(low[u],low[v]);
}
else if(onstack[v]) low[u]=min(low[u],dfn[v]);//判断是否onstack是关键。
}
if(dfn[u]==low[u]){
输出直到u。onstack[u]=0;
}
}
相关题目/资料: Redundant Paths
给定一个连通无向图,加一些边使得图中无桥。最小化加边数。
首先容易想到将原图先缩点,然后得到一棵树。
考虑如何将树上的点连边变成无桥的图。
如果有链显然连链上两个点不如连与链上两个点靠的近的叶子结点。不然不会减少不边2连通的结点数。
每次连两个叶子并缩点。手玩样例可以发现如果连比较远的叶子每次总可以减少两个叶子。
然后通过观察可以发现链上度为2的点总可以通过连接叶子结点同时解决。
所以可以直接把度为2的点删掉。
技巧:与树上连通分类相关的问题可能可以省略链上度为2的点不考虑。
然后每次不连同一个菊花(仅有两个叶子的菊花)上的叶子总可以使叶子个数减少2,从而得出ans=numleaves/2上取整。
如果连同一个菊花(仅有两个叶子的菊花)上的叶子就会多出一个新叶子,不优。
链接两个叶子直接的那条链会在把两个叶子相连的时候同时解决。所以无需考虑非叶子的情况。这是本题最重要的轻重。
因为随便找一个叶子为根,其余叶子到根的树链能覆盖整棵树,并且每次与根相邻再缩点将缩的点作为新根。
并且把每个叶子都考虑完之后原图也解决了。
1.无需考虑非叶子的点,因为根到叶子的树链能覆盖整棵树顺带解决其他结点。
2.连只有两个叶子的菊花上的叶子会使得多出一个叶子不优,要连距离较远的叶子。
先猜结论后证明。
第七章 搜索:在状态空间中有策略地试探
本章定位:掌握 DFS、BFS、剪枝、迭代加深、状态压缩等搜索思想。
题目识别信号:状态空间不大或可以剪枝;题目需要枚举方案、寻找路径、构造答案;难以直接写出 DP 或贪心。
学习方法:搜索的核心不是“暴力写递归”,而是设计状态、动作、边界、剪枝和访问标记。先写出正确搜索,再根据瓶颈剪枝。
复盘重点:复盘时重点写:状态是什么、下一步有哪些选择、如何判重、剪枝依据是否安全、复杂度瓶颈在哪里。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
部分分利器。
原理,在解空间一一尝试。
优化方法:剪枝,缩小解空间。
如果想到出题人没想到的奇怪剪枝,会有意想不到的收获。
先写框架,再加剪枝,注意不要把最优解或者要统计的有用的解给减掉了。
步骤:
- 先明确要搜什么和题目有哪些约束。
- 搭建搜索框架。
- 加剪枝。
剪枝类型:
- 可行性剪枝。根据题目约束剪枝。
- 排除等效冗余。(可以通过确定顺序等方式)
- 最优性剪枝。贪心估计搜索树当前点未来的最大、小值,与已知答案比较,看看有没有搜下去的意义。
- 顺序:在尽早的时刻找到最接近最优解的解。(增加剪枝效率)
- 记忆化剪枝。
相关题目/资料: 数的划分
将整数 \(n\) 分成 \(k\) 份,且每份不能为空,任意两个方案不相同(不考虑顺序)。
问有多少种不同的分法。
\(n,k\) (\(6<n \le 200\),\(2 \le k \le 6\))
直接搜索,框架是现在划分是是第t个部分,for现在填哪个数,为了不重复,我们钦定一个从小到大或者从大到小的顺序枚举。
最无脑的剪枝是当前数+sum是否比n大。
如果我们钦定从大到小的顺序,那么我们可以加后面几个元素如果都填1和现在的i和sum的和是否大于n如果是,剪枝。
这样有一个弊端就是,后面的数能填比当前部分小的和比1大的,我们搜索的原理是在后面能填的里面估计最小的,判断是否相加大于n。从大到小导致估计后面的只能估1,十分宽松。
我们可以考虑换一种顺序从小到大填,这样后面的最小就变成了当前的元素,比原来紧了,可以减掉更多无用情况。
本质上是将现在枚举的数last这一信息也用上了。
技巧:换一种枚举顺序可能可以利用更多信息,从而减掉更多无用状态,搜索的顺序很重要。
通过某些约束可以剪枝,如这题用到的值域。
相关题目/资料: 反素数
知识点:唯一分解定理、因数个数定理、搜索。
一看到数据范围非常大,而且定义与因数相关。
考虑用唯一分解定理进行分析。
根据因数个数定理:
设\(x=\prod{p_i}^{k_i}\),则显然\(d(x)=\prod(k_i+1)\)。
我们思考反质数的定义,什么样的数是反质数:
- 如果i是反质数那么\(d(i)\)一定是\(d(1)到d(i)\)中最大的。
- 对于每一个\(d(x)=d(i)\)如果i是反质数,i应该是所有d值相同中的最小数。
题目可以转换为:求因数个数最多的数中的最小数。
我们考虑这个数唯一分解之后最大用到的质数是哪个。
我们举例子感性体会一下,如果前面有某个质数没选,那么后面选一个大的不会让在因数个数相同的数中,是最小的数。
所以我们猜想:一定是选了连续的质数。
数学证明也不难,初中方法就行。
所以我们只可能包含10个质数的乘积,并且可以分配给他们的指数非常小,在小几十的范围(10~63左右)。
可以用搜索找这个数。
难点在于用数学分析简化问题,并且解空间非常小,可以搜索。
#include<cstdio>
#define int long long
using namespace std;
int n,ans=2e9,mx,p[]={-1,2,3,5,7,11,13,17,19,23,29};
void dfs(int t,int num,int cnt){
if(t>10){
return;
}
if(cnt<mx&&num>ans) return;
if(cnt>mx){
mx=cnt;ans=num;
}
if(cnt==mx)
ans=ans>num?num:ans;
int tmp=num;
for(int i=0;i<=32;i++){
if(i>0) tmp=tmp*p[t];
if(tmp>n) return;
dfs(t+1,tmp,cnt*(i+1));
}
}
signed main(){
scanf("%lld",&n);
dfs(1,1,1);
printf("%d",ans);
return 0;
}
相关题目/资料: 取数游戏
一个N×M的由非负整数构成的数字矩阵,你需要在其中取出若干个数字,使得取出的任意两个数字不相邻(若一个数字在另外一个数字相邻8个格子中的一个即认为这两个数字相邻),求取出数字和最大是多少。
可以判断n*n的最多取几个,加个最优化剪枝。
并且这题也可以用状压DP解,状压DP的题有些可以用搜索+剪枝高效解决。

一个男人在12点到达一个公共汽车站。12点到12点59分他一直在那里。许多公共汽车路线都使用这个车站。这个人记下了来往的公共汽车的时间。公共汽车到达的时间是已知的。
- 同一路线的公共汽车在12点到12点59分之间定时到站。
- 时间以整分钟为单位,从0到59。
- 每条巴士路线至少停靠2次。
- 测试示例中的总线路由数量将<=17。
- 不同路线的公共汽车可能同时到达。,几条巴土路线可以有相同的首次到达时间和/或时间间隔。如果两条公交线路有相同的出发时间和间隔,它们是不同的,都要显示。
找到公交线路数量最少的时刻表,这些线路必须停靠在公交车站才能满足输入数据。对于每条总线路线,输出起始时间和间隔。
转化问题:由于是定时到站,问题转为为在给定数中选最少的完整的等差数列,使得每个数都包含在某一个选定的等差数列中。
首先预处理,注意这里要求定时到站,即首项一定出现在公差之前,枚举公差,找首先,对于每个等差数列记录几个参数,首项,末项,公差,元素个数。
这里最多是30*30=900个等差数列。
预处理是题目的难点之一。好的预处理可以缩小解空间,并且满足题意。
一开始做题时没有注意定时到站的条件导致预处理了300*300=90000个等差数列直接爆炸。
我们把最小的等差数列数量作为搜索目标,设计搜索框架。
dfs(t,num,l)现在搜到第t个等差数列,共覆盖了num个数,选了l个等差数列。
两个细节:(其实是利用题目已知条件)
- 由于可能有完全相同的等差数列,即第t个可能可以重复使用
- 给定最大17个,所以ans初始化要初始化成17.(key)
技巧:越好的答案要优先搜到,提高最优化剪枝的效率。
900个解空间,不加剪枝TLE。
考虑剪枝:(先考虑,最粗暴的剪枝)
- 可行性剪枝:如果某个数覆盖完了,后面包含这个数的都不能选。
- 最优化剪枝:当前解>=最优解return。
上面两个剪枝非常粗暴,不足以AC。
高效的剪枝应该是利用贪心估算出一个解的最优化剪枝。
思考时,可以从约束入手。
法1:从一定要n数都用完个入手。以下剪枝由弱到强。
if(l==ans-1&&num+a[t].num<n) return;//考虑最后一个是否加上他能满足条件。
if(num+s[tot]-s[t-1]<n) return;//利用s是数列的前缀和,考虑后面的都用上是否满足条件。
if(s[ans-l]&&num+s[ans-l]<n) return;//利用前缀和和当前最多还能用多少线路,考虑利用前面ans-l个是否满足条件。
if(s[t+(ans-l)]&&num+s[t+(ans-l)]-s[t-1]<n) return;//利用当前可用数量和前缀和。
if(num+a[t].num*(ans-l)<n) return;//利用当前可用数量和当前数列的长度进行估计。
一个细节:用前缀和一定要先排序,并且判断是否越界,由于要考虑越界所以不可能顶级高效,但也比较高效。
观察上述可以发现,剪枝效率与利用当前解和最优解的关系紧密相关,如果约束适用范围越广,用的次数越多(即不用考虑越界一类情况不能使用),约束越强,效率越高。
法2:从当前解<=ans入手。
if(l>=ans) return;//粗暴
if(l+(n-num)/a[t].num>=ans) return;//同上面最后一条
考场能想到并且确定对的剪枝最好都加上去。
最后,遵循最优解优先搜到的原则按等差数列长度从大到小排序。
知识点:搜索,剪枝(解空间、可行性、最优性+贪心)。
相关题目/资料: life line
dfs的一大应用,洪水填充,一般普及组考。
讨论注意细节,有细节或者复杂的分类讨论养成好习惯,写注释。
#include<cstdio>
#include<utility>
#include<cstring>
#include<vector>
#define pii pair<int,int>
#define mkp(x,y) make_pair(x,y)
using namespace std;
const int maxn=70;
const int dx[]={-1,-1,0,0,1,1};
const int dy[]={0,-1,-1,1,0,1};
int vis[maxn][maxn],tot,n,k,a[maxn][maxn],cl[maxn][maxn],num[maxn],vv[maxn],siz[maxn];
vector<pii>v;
void dfs(int x,int y,int c){
cl[x][y]=c,vis[x][y]=1,siz[c]++;
for(int i=0;i<6;i++){
int nx=x+dx[i],ny=y+dy[i];
if(nx>=1&&nx<=n&&ny>=1&&ny<=nx&&!vis[nx][ny]&&a[nx][ny]==a[x][y])
dfs(nx,ny,c);
}
}
int main(){
while(~scanf("%d%d",&n,&k)){
if(!n&&!k) break;
tot=0;
v.clear();
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++){
scanf("%d",&a[i][j]);
if(!a[i][j]) v.push_back(mkp(i,j)),cl[i][j]=0;
}
memset(vis,0,sizeof(vis)),memset(siz,0,sizeof(siz));
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++){
if(!vis[i][j])
dfs(i,j,++tot);
}
memset(num,0,sizeof(num));
for(int tt=0;tt<v.size();tt++){
pii j=v[tt];
memset(vv,0,sizeof(vv));
int x=j.first,y=j.second;
for(int i=0;i<6;i++){
int nx=x+dx[i],ny=y+dy[i];
if(nx>=1&&nx<=n&&ny>=1&&ny<=nx&&!vv[cl[nx][ny]])
vv[cl[nx][ny]]=1,num[cl[nx][ny]]++;
}
}
int ans=-0x3f3f3f3f;
for(int tt=0;tt<v.size();tt++){
pii j=v[tt];
int x=j.first,y=j.second;
memset(vv,0,sizeof(vv));
int tmp=0,t=0;
for(int i=0;i<6;i++){
int nx=x+dx[i],ny=y+dy[i];
if(nx>=1&&nx<=n&&ny>=1&&ny<=nx&&!vv[cl[nx][ny]]){
vv[cl[nx][ny]]=1;
if(a[nx][ny]!=k){//是异色
if(num[cl[nx][ny]]==1) tmp+=siz[cl[nx][ny]];//并且只有一口气,即填上这格就没气了
}
else{
t=cl[nx][ny];//是同色记录位置
if(num[t]==1&&!num[cl[x][y]]) tmp-=siz[t];//填上这格就没气了,既不能块向格子借气,也不能格子向块借气
}
}
}
if(!num[cl[x][y]]&&(t==0||num[t]==1)) tmp--;//自己没气并且不存在可以借气的块。
ans=max(ans,tmp);
}
printf("%d\n",v.size()?ans:0);
}
return 0;
}
相关题目/资料: 生日蛋糕
搜索好题,多项剪枝技巧综合运用。
搜索框架要什么作为参数记着就行,非常好设计。TLE20pts。
加点朴素剪枝30pts。
#include<bits/stdc++.h>
#define int long long
using namespace std;
int mxv[200010],mns[200010];
int n,m,ans;
void dfs(int t,int h,int r,int v,int s){
if(t<=m&&v>=n) return;//朴素剪枝1
if(s>=ans) return;//朴树剪枝2
if(h+t<=m||r+t<=m) return;//朴素剪枝3
if(v+mxv[max(h,r)]-mxv[max(max(h,r)-(m-t)-1ll,0ll)]<n) return;
if(v+mxv[m-t+1]>n) return;
if(s+2*(n-v)/r>=ans)//最强的剪枝,没想出来。
if(v+(ans-s)/2*r<n) return;//最强的剪枝,没想出来。
if(s+mns[m-t+1]>=ans) return;
if(t>m){
if(v==n) ans=min(ans,s);
return;
}
for(int nh=m-t+1;nh<h;nh++)//能减少循环次数尽量减少
for(int nr=m-t+1;nr<r;nr++){
dfs(t+1,nh,nr,v+nr*nr*nh,s+2*nr*nh);
}
}
signed main(){
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++)
mxv[i]=mxv[i-1]+i*i*i,mns[i]=mns[i-1]+2*i*i;
ans=(sqrt(n)+1)*2+n;
for(int h=1;h<=n;h++)
for(int r=1;r*r*h<=n;r++){
if(min(h,r)>=m)
dfs(2,h,r,r*r*h,2*r*h+r*r);
}
printf("%lld",(ans==(sqrt(n)+1)*2+n)?(int)-1:ans);
return 0;
}
然后我们可以预处理一个前缀的估计值,就是把h,r看成一样的。
然后利用前缀和取对应个数个大或者小的数进行剪枝。
可以30pts->90pts。效果比较显著。
技巧:预处理前缀和估计值剪枝(最优化剪枝)。
但是不是最强的剪枝。
面对一定要与题目给的的值的相等的参数剪枝可以从+最小值比规定的大和+最大值比规定的小两方面考虑。
就比如这题的v。
最强的剪枝往往都利用了单调性,和最优解与题目要求参数计算的值。
比如说上面the bus这题。
if(num+a[t].num*(ans-l)<n) return;//利用当前可用数量和当前数列的长度进行估计。
这条就是因为事先按a[].num从大到小排序,并且也利用了ans答案和l参数计算的估计值。
这题运用同样的技巧可以设计非常强的剪枝:
if(s+2*(n-v)/r>=ans)//最强的剪枝,没想出来。
if(v+(ans-s)/2*r<n) return;//最强的剪枝,没想出来。
这里利用了r,h题目要求的从大到小的的性质,正好符合了最优性剪枝要求的+最小、最大还比什么大、小的性质。
并且也利用了搜索状态中的表面积s和体积v,并且运用了题目要求的v==n,s<ans的要求。
本质上是体积公式和面积公式的转换:
\(V=r^2h\)
\(S=2rh\)
\(V_{mx}=S/2*r_{mx}\) (1)
\(S_{mn}=2V/r_{mx}\) (2)
直上乘除mx是对的,数学证明:
本质上是证明等式左边是最大、小的。
只证(2),(1)同理。
转证\(S_{mn}\)是最小的。
首先(2)中的V是根据n-v算出来的,肯定是真的(没有估计成分的)。
对于\(V_{真}=r_1^2h_1+....+r_x^2h_x。\)
由单调性除掉的r一定是\(r_1\)到\(r_x\)中最大的。
那么此时算出来的S一定是最小的。
难点:想出来用已知条件计算出未来能用的值,并且利用某些dfs函数中的参数计算出利用与剪枝的最大最小的某个值。
如何向出?多观察公式,先直观猜测后证明。利用单调性和已知参数,输入的值计算,贪心剪枝。
这个剪枝有多夸张:n=2e4 40ms轻松通过。
就算n=1e5也能通过。
但是要求循环层数m不能超过20,不然搜索中两个for很容易就炸了。
并且dfs中尽量减少循环次数,会有显著的优化效果,这题3-4倍。
相关题目/资料: Squre
给定n<=20根长度不超过1w的木棒,问能否拼成正方形。
首先,明确搜的是每个木棒如何用,搜索的目的一定要明确。(就是搜能不能)。
边长直接全部加起来/4就行。
搜索框架容易搭建,考虑剪枝。
首先我们排序,从大到小,使得最优解优先得到。
(1)然后dfs函数中多记一个参数last,表示这根边上次用到哪根小棒。
应为是在拼凑同一边,所以前面不能用的一些大的在这也不能用,保证了正确性。
(2)搜到解了直接return,但要记得先清除vis标记(有大用)。(判定性的搜索都要这步,类似于最优性剪枝)
(3)然后考虑剩余的一部分(一些短的)中能否组成当前木棒需要的,提前DP背包预处理出来。
以上三个剪枝是自己想的,可以ac。但是200多ms不快(这题数据弱)。
(1)这类减少dfs中循环次数的剪枝一般非常强甚至能从TLE->AC,常用的技巧是排除等效冗余,或者前面不行的这里也不尝试,通常是由排序的单调性加一个last参数。
(3)的虽然宽松(剩余的短棒很多),但是也由于是贪心估计的,也有点用。(主要是12)
技巧:先排序后搜索是很多剪枝的前提。(排除等效冗余或者利用单调性进行贪心最优化剪枝)
//#include<bits/stdc++.h>
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
const int maxn=30;
int T,n,a[maxn],len,dp[maxn][50010];
bool flag,vis[maxn];
bool cmp(int x,int y){
return x>y;
}
void dfs(int t,int sum,int last){
if(t>3){
flag=1;
return;
}
if(flag) return;
//if(!dp[last][len-sum]) return;
for(int i=last+1;i<=n;i++)
if(!vis[i]){
vis[i]=1;
if(sum+a[i]==len)
dfs(t+1,0,0);
if(sum+a[i]<len)
dfs(t,sum+a[i],i);
vis[i]=0;
if(flag) return;
}
}
int main(){
scanf("%d",&T);
while(T--){
int sum=0,mx=0;
flag=0;
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]),sum+=a[i],mx=max(a[i],mx);
if(sum%4) printf("no\n");
else{
len=sum/4;
sort(a+1,a+1+n,cmp);
/*memset(dp,0,sizeof(dp));
for(int i=1;i<=n+1;i++) dp[i][0]=1;
for(int i=n+1;i>=1;i--)
for(int j=0;j<=len;j++){
if(j+a[i]<=len&&i<=n) dp[i-1][j+a[i]]|=dp[i][j];
dp[i-1][j]|=dp[i][j];
}*/
dfs(1,0,0);
if(flag) printf("yes\n");
else printf("no\n");
}
}
return 0;
}
看某个大神0msAC学了几个干货剪枝。
对于这类提前给定一些元素问能不能凑成什么的搜索,由于先排序的单调性,往往可以配合二分解决。
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int read()
{
int x=0,f=1;char c=getchar();
while (c<'0' || c>'9'){if (c=='-')f=-1;c=getchar();}
while (c>='0'&&c<='9'){x=(x<<3)+(x<<1)+c-48;c=getchar();}
return x*f;
}
const int MAXN=30;
int n,sum,len;
bool flag;
int stick[MAXN],nx[MAXN];
bool used[MAXN];
bool cmp(int a,int b)
{
return a>b;
}
int find(int l,int r,int weight)
{
int mid;
while (l<r)
{
mid=(l+r)>>1;
if (stick[mid]<=weight)r=mid;
else l=mid+1;
}
return l;
}
void dfs(int t,int rest,int last)
{
if (rest==0)
{
if (t==1)
{
flag=true;return ;
}
int x=0;
for (int i=1;i<=n;i++)
if (!used[x=i])break;
used[x]=true;
dfs(t-1,len-stick[x],x);
used[x]=false;
return ;
}
int l=find(last+1,n,rest);//这个二分是找第一个<=rest的数的位置
for (int i=l;i<=n;i++)
{
if (!used[i])
{
used[i]=true;
dfs(t,rest-stick[i],i);
used[i]=false;
if (flag)return ;
if (rest==stick[i])return ;
i=nx[i];
}
}
}
int main()
{
int cas=0;cas=read();
while (cas--)
{
sum=0;flag=false;len=0;
memset(stick,0,sizeof(stick));
memset(nx,0,sizeof(nx));
n=read();
for (int i=1;i<=n;i++)
{
stick[i]=read();
sum+=stick[i];
}
sort(stick+1,stick+n+1,cmp);
nx[n]=n;
for (int i=n-1;i>=1;i--)
{
if (stick[i]==stick[i+1])nx[i]=nx[i+1];
else nx[i]=i;
}
if (sum%4==0 && stick[1]<=sum/4)
{
len=sum/4;
used[1]=true;
dfs(4,len-stick[1],1);
used[1]=false;
}
if (flag)puts("yes");
else puts("no");
}
return 0;
}
非常强的剪枝——二分,从而减少dfs函数中循环次数。
技巧:一般利用排序的单调性二分找lower_bound之类的位置,进行减少循环次数的剪枝
所有数组、结构体二分的mid都指的是下标而不是值。
并且这里还有一个比较强的剪枝,就是在后面的棒有问题要回溯改前面的时候,如果拼凑某个边时最后一根刚好==rest无需在回溯的时候再for下去找新的了,直接return回去改倒数第二根。
因为在下面肯定找不到与rest不等还能拼好的棍子了。
本质上还是减少dfs函数中循环次数的强剪枝。
技巧:拼凑类问题中往往可以考虑最后一根的回溯时是否有继续for下去找比他更小、大的必要,一般一定没有因为他是刚好的(因为if的条件就满足他是刚好的,不然他成不了最后一根)。
还有一个排除等效冗余的剪枝,就是在排序之后计算nxt,为该位置后面第一个与当前位置不等的位置,注意最后一个nxt不要搞出死循环。
其实更好实现的nxt的含义应该是一串等的中最后一个的位置。
相关题目/资料: 小木棍
用上上面的策略即可通过,是squre的加强版。
//#include<bits/stdc++.h>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=100;
int n,a[maxn],len,mx,m,vis[maxn],nxt[maxn];
bool flag;
bool cmp(int x,int y){
return x>y;
}
int find(int l,int r,int x){
int ret=n+1;
while(l<=r){
int mid=(l+r)>>1;
if(a[mid]<=x){
ret=mid;r=mid-1;
}
else l=mid+1;
}
return ret;
}
void dfs(int t,int sum,int last){
if(t>=m){
flag=1;
return;
}
if(flag) return;
int l=find(last+1,n,len-sum);
for(int i=l;i<=n;i++)
if(!vis[i]){
vis[i]=1;
if(sum+a[i]<len)
dfs(t,sum+a[i],i);
if(sum+a[i]==len){
dfs(t+1,0,0);
vis[i]=0;
return;
}
vis[i]=0;
i=nxt[i];
if(!sum) return;
if(i==n) return;
if(flag) return;
}
}
int main(){
while(~scanf("%d",&n)){
if(n==0) break;
mx=-0x3f3f3f3f;
int sum=0;
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
if(a[i]>50) a[i]=0;
sum+=a[i];mx=max(a[i],mx);
}
sort(a+1,a+1+n,cmp);
nxt[n]=n;
for(int i=n-1;i>=1;i--){
if(a[i]==a[i+1]) nxt[i]=nxt[i+1];
else nxt[i]=i;
}
for(len=mx;len<=sum;len++)
if(sum%len==0){
m=sum/len;
flag=0;
dfs(1,0,0);
if(flag){
printf("%d\n",len);
break;
}
}
}
return 0;
}
加了一个这个剪枝:
if(!sum) return;
意思是如果后面某个整根棒都改掉了说明问题出现在前面,而无需尝试后面的某个作为这根新棒的第一根,因为如果它有用一定就直接在更早的时候出解了,直接return回去改起那么有问题的部分即可。
技巧:回溯时考虑哪些情况无需再试后面的,问题出在前面,直接return
相关题目/资料: NOIPTG2017宝藏
给定一个无向图,可能有重边。n<=12,m<=1e3。任选一个树根,找一颗生成树使得花费最小。
树根没有花费。
花费=到每个点的边权*树根到这个点经过的点数(包括树根)。
数据范围n非常小,提示我们可以往状压、dfs剪枝等方面考虑。
搜索框架记录当前的经过个数,哪些点访问过(可以数组可以状压),当前花费。
容易写出一下暴力代码,70pts,luogu hack数据全T。
#include<cstdio>
#include<algorithm>
#include<queue>
#include<cstring>
#include<map>
using namespace std;
const int maxn=20;
int head[maxn],n,m,cnt=1,ans=0x3f3f3f3f,dis[maxn],tot,G[maxn][maxn],d[maxn],mn[maxn],ss[maxn],vv[maxn],ct[maxn];
bool vis[maxn];
void dfs(int s,int num,int cst){
if(num==n){
ans=min(ans,cst);
return;
}
if(cst>=ans) return;
int tmp=0;
for(int i=1;i<=n;i++)
if(((1<<i)&s)==0)
tmp+=mn[i];
if(cnt+ss[n-num]>=ans) return;
if(cst+tmp>=ans) return;
for(int u=1;u<=n;u++){
if(((1<<u)&s)==0) continue;
if(ct[u]==vv[u]) continue;
for(int v=1;v<=n;v++){
if(((1<<v)&s)==0&&u!=v&&G[u][v]!=0x3f3f3f3f){
vv[u]++;
s|=(1<<v);
dis[v]=dis[u]+1;
dfs(s,num+1,cst+G[u][v]*dis[v]);
dis[v]=0;
s^=(1<<v);
vv[u]--;
}
}
}
}
queue<int>q;
void bfs(int s){
memset(vis,0,sizeof(vis));
d[s]=0,q.push(s),vis[s]=1;
while(!q.empty()){
int u=q.front();
q.pop();
for(int v=1;v<=n;v++)
if(G[u][v]!=0x3f3f3f3f&&u!=v&&!vis[v]){
vis[v]=1,d[v]=d[u]+1,q.push(v);
}
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(i!=j&&G[i][j]!=0x3f3f3f3f)
ct[i]++;
for(int i=1;i<=n;i++){
int mnn=0x3f3f3f3f;
for(int j=1;j<=n;j++)
mnn=min(mnn,G[i][j]);
mn[i]=mnn*d[i];
ss[i]=mn[i];
}
sort(ss+1,ss+1+n);
for(int i=1;i<=n;i++)
ss[i]+=ss[i-1];
}
int main(){
scanf("%d%d",&n,&m);
memset(G,0x3f,sizeof(G));
for(int i=1;i<=m;i++){
int u,v,w;scanf("%d%d%d",&u,&v,&w);
G[u][v]=min(G[u][v],w);
G[v][u]=min(G[v][u],w);
}
for(int i=1;i<=n;i++){
memset(dis,0,sizeof(dis));
memset(vv,0,sizeof(vv));
bfs(i);
dfs(1<<i,1,0);
}
printf("%d",ans);
return 0;
}
自己加了几个用处不大的剪枝,只加>=ans的一眼剪枝也70pts。
难点:找到高效剪枝(有用的最优化剪枝和排除等效冗余的运用)。
我的剪枝:
(1)对于每个点,如果选定的点的个数==该点的度,不用搜他。
(2)提前计算出每个点最短路*入边最小边权。用前缀和统计
问题在哪:
对于(1),很难出现个数==该点的度,因为图的边数非常多,一般都是完全图,这个剪枝很难用到,并且这个搜索有非常多的等效冗余。
对于(2),提前把计算花费的两个元素都预处理出来了,太固定了,效率低下。
这题的答案是一颗dfs生成树。
排除等效冗余的必要条件:找到一个合适的顺序,并记录last,使得下次for循环从last开始,减少一模一样的等效无用搜索。
技巧:对于图上搜索,dfs生成树可能是排除等效冗余好的顺序(尤其是要用在树上到某点路的长度是计算花费的某个元素的问题)
于是我们得到了一个排除等效冗余的强大方法:弄一个数组(类似队列)将搜到的点按dfs生成树的顺序放进去,依次搜,每个只搜一次,实现时记录lastu。
并且每个u的领接点没必要在下次搜索时再搜一遍,因为对于回溯法,只需要在循环里出现一遍就足以枚举全集合,重复出现为等效冗余。
实现方法:对于每个u记录一个lastv每次从lastv开始搜。
注意:新的u的lastv要初始成一开始的0,否则漏解。
加上这两个强大的排除等效冗余的剪枝策略,可以ac 全部数据。2.18s。
这种确定顺序用数组排除等效冗余的方法是好用的,但是用vector记录有用的没用的直接删掉是容易出问题的(不要用)。
取vector元素的指针、迭代器、引用,再改变vector的大小导致的失效。类似数组的容器都会失效,但是set等平衡树容器不会失效(除非将当前元素删除)。导致RE\WA
原理:vector是在元素超过容量时再申请2倍内存并将元素全迁移到新内存中,再将旧内存释放。而原本指向旧内存的迭代器就失效了。
所以想排除等效冗余老老实实找顺序,并且用普通数组记录。
为了更快我们还可以加上一个高效的最优化剪枝。
技巧:好的最优化剪枝应该是在dfs过程中再可实现,可接受的运行时间花费上尽量多的使用动态估计的元素,而不是一开始将所有计算花费的元素都预处理出来,定死了理论最小值。
一开始我的剪枝就是将所有理论最小的都预处理出来导致效率很低。
如果我们只预处理花费的下界,利用dfs生成树的顺序搜索也可以得到长度的下界,因为长度是由短到长的。
这样就利用了动态的长度元素的下界+固定计算的边权的下界得到了高效的最优化剪枝。
之前一些高效的剪枝例如the bus中的强最优化剪枝也是尽可能多的用动态的元素。
动态的元素就是搜索中的参数,随搜索改变的值。
2s->118ms,十分有用,提高20倍速度。
#include<cstdio>
#include<algorithm>
#include<queue>
#include<cstring>
#include<map>
using namespace std;
const int maxn=20;
int head[maxn],n,m,ans=0x3f3f3f3f,dis[maxn],tot,val[maxn][maxn],q[maxn],g;
struct node{
int v,w;
node(int v_,int w_){
v=v_,w=w_;
}
node(){}
};
vector<node>G[maxn];
void dfs(int s,int num,int cst,int lastu,int lastv,int cnt){
if(num==n){
ans=min(ans,cst);
return;
}
if(cst>=ans) return;
for(int i=lastu;i<=cnt;i++){
int u=q[i];
if(cst+g*dis[u]>=ans) return;
for(int j=lastv;j<G[u].size();j++){
int v=G[u][j].v,w=G[u][j].w;
if(((1<<v)&s)==0){
s|=(1<<v),dis[v]=dis[u]+1,q[cnt+1]=v,g-=G[v][0].w;
dfs(s,num+1,cst+w*dis[v],i,j+1,cnt+1);
dis[v]=0,q[cnt+1]=0,s^=(1<<v),g+=G[v][0].w;
}
}
lastv=0;//lastv 记的是当前u搜到第几个,对于搜下一个u这个剪枝要从头开始,不然会漏解。
}
}
bool cmp(node x,node y){
return x.w<y.w;
}
int main(){
scanf("%d%d",&n,&m);
memset(val,0x3f,sizeof(val));
for(int i=1;i<=m;i++){
int u,v,w;scanf("%d%d%d",&u,&v,&w);
val[u][v]=min(val[u][v],w);
val[v][u]=min(val[v][u],w);
}
if(n==1){
printf("0");
return 0;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(i!=j&&val[i][j]!=0x3f3f3f3f)
G[i].push_back(node(j,val[i][j]));
for(int i=1;i<=n;i++){
sort(G[i].begin(),G[i].end(),cmp);
if(G[i].size()) g+=G[i][0].w;
}
for(int i=1;i<=n;i++){
memset(dis,0,sizeof(dis));
if(G[i].size()){
q[1]=i,g-=G[i][0].w;
dfs(1<<i,1,0,1,0,1);
q[1]=0,g+=G[i][0].w;
}
}
printf("%d",ans);
return 0;
}
相关题目/资料: CF上的T形骨牌
n,m<=9,问做多填几个T。
n,m很小,考试时遇到可以优先写个暴搜,跑几个小时把表打出了,AC。
但是还可以加一些玄学剪枝:
(1)now+还剩的格子/8上取整<=ans。剪枝(很玄学,IOI赛制可能才会用到)
(2)记录\(f_{i,j}\)表示当前到i,j这个格子操作完最多填几个。如果\(now+1<=f_{i,j}\),那么这个答案没有搜下去的必要。也十分玄学。(这种记录到某个地方的最值然后看对于某个答案有没有必要继续搜下去的思想似乎有用。(过去时和最优化剪枝的估价刚好相反))
相关题目/资料: 虫食算
给定一个做n<=26进制加法的未知的式子,相同数字用相同字母表示。
搜索即可。
剪枝:(不是最优化问题,只有确定有利于剪枝的搜索顺序和可行性剪枝两种策略)
(1)从大到小填数字,提高剪枝效率,更多出现进位。(玄学)
(2)记录now和jw,一遍搜一遍判断式子是否合法只加这剪枝90pts。
(3)由于确定了一些数对很多竖着的三个位置都有判定作用,所以扫一遍判断(a+b)%n!=c&&(a+b+1)%n!=c,剪枝。
搜索时,在每个函数中跑一个不会调用新的dfs的for循环剪枝是可以的,几十到几百都行。
剪枝不仅要考虑对眼前的情况,还要考虑是否对未来某些情况可以剪枝。
相关题目/资料: CF27E
找因数个数为n<=1e3的最小数<=1e18.
反素数的弱化版,用唯一分解定理搜索,加几个朴素剪枝即可。
注意判爆ll,用是否为负数判或者除法判都行。
对于唯一分解定理,因数相关的搜索题有以下通用性结论。

12显然,3交换指数更优可证明。
相关题目/资料: 靶形数独
给定一个残缺的数独,填一个合法的数独,要求得分最大。
合法:行列九宫格1-9只出现一次。
得分:位置分*填的数总和。位置分是靶形的,中间分高,四周分低。
搜索框架很好写,考虑剪枝。
- 位运算进行可行性剪枝,常数小,方便写。
- 优先搜要填数少的行或列。
- 预处理待填格子的可以用的值。
技巧:能预处理出一个状态可以用哪些可能就预处理,应为回溯时可能多次遇到这个状态,这是一个很不错的常数优化。
技巧:vis数组能改成位运算尽量改成位运算,优化常数。
技巧:确定好的搜索顺序是提高剪枝效率的关键。
这题好的搜索顺序是先搜要填数少的列,因为这样的列可填的可能非常少,并且如果确定一列大大提高可行性剪枝效率。对于可行性难以达成的问题(例如数独),那么搜索顺序应该优先为提高可行性剪枝的效率服务,如果在最优性要求严格的题(例如可行性只要求不重复访问)的题那么,搜索顺序要优先为最优性剪枝服务。
好的搜索顺序TLE->AC。
预处理可用的值,快半秒搜到。
相关题目/资料: 算符破译( bzojlink)
建议去bzoj提交,你谷测评机有点慢,要卡常,并且有一个点数据错误。
多个剪枝技巧综合应用,好题。
学搜索剪枝的好东西NOI导刊+搜索顺序的选取与剪枝策略
难点:框架中要用的算法结合(表达式求值、判断前导0,一一对应关系),剪枝设计。代码调试能力(急需提高)
搜索框架和判断和法的函数结合了几个算法,算比较麻烦的搜索框架了。
首先搜匹配关系,这个框架不难,再来一个数组判断每个古梅字符对应了哪些现代字符,判断最后搜出了的合不合法用前导零和表达式求值的方法就行。
- 前导0(0前必无数0后必有数)
- 表达式求值(注意随后栈中还有符号直接拿出来算)
考虑剪枝:
先看几个技巧。

这几个技巧非常有用。
这题的剪枝也充分体现了这几个技巧。
(1)先搜符号,在搜数字。(对应约束力大的先搜,静态调整搜索顺序)
- 符号确定后,这里还有个拓展的正确性不确定的剪枝。就是先全填1、9估计上下界,如果两边的取值范围不相交就直接调整符号位置。
(2)一边搜一边判断是否合法。(类似虫食算,但是这里不能像虫食算把每个式子都枚举一下,因为n>=1000很大)
(3)先搜涉及不同字符少的串。(对应取值范围小的)

例如这题确定先搜符号就是静态优化搜索顺序。

#include<cstdio>
#include<cstring>
#include<vector>
#include<stack>
#include<algorithm>
#include<set>
#include<utility>
using namespace std;
const int maxn=1010,mx=20;
char st[maxn][15];
bool vis[mx][mx],v[mx],vl[mx],flag;
int n,sx[mx],cnt,ton[mx],ok[mx][mx],val[mx],d[mx],used[mx],str[maxn][15],pp[maxn],ans[mx];
set<int>gm[mx],xd[mx],lt[mx],rt[mx];
set<int>::iterator it;
stack<int>f,s;
pair<int,int>p[maxn];
inline int cal(int i,int l,int r){
int num=0;
for(int j=l;j<=r;j++){
if(val[str[i][j]]<=10){
num=num*10+(val[str[i][j]]-1);
}
else{
s.push(num);
num=0;
if(val[str[i][j]]==11){
while(!f.empty()&&f.top()>=11){
int t1=s.top();s.pop();
int t2=s.top();s.pop();
if(f.top()==11) s.push(t1+t2);
else s.push(t1*t2);
f.pop();
}
f.push(11);
}
else{
while(!f.empty()&&f.top()>=12){
int t1=s.top();s.pop();
int t2=s.top();s.pop();
s.push(t1*t2);
f.pop();
}
f.push(12);
}
}
}
if(!f.empty()){
s.push(num);
while(!f.empty()){
int t1=s.top();s.pop();
int t2=s.top();s.pop();
if(f.top()==11) s.push(t1+t2);
if(f.top()==12) s.push(t1*t2);
f.pop();
}
num=s.top();
}
while(!s.empty()) s.pop();
return num;
}
inline int chk(int i,int dh){
int pos=-1;
for(register int j=1;j<str[i][0];j++){
if(val[str[i][j]]==-1) return -1;
if(val[str[i][j]]>10&&val[str[i][j+1]]>10) return 0;
if(val[str[i][j]]==1&&val[str[i][j+1]]>=2&&val[str[i][j+1]]<=10&&(j==1||val[str[i][j-1]]>10)) return 0;
}
if(val[str[i][str[i][0]]]==-1) return -1;
for(register int j=1;j<=str[i][0];j++)
if(str[i][j]==dh){
pos=j;break;
}
if(cal(i,1,pos-1)!=cal(i,pos+1,str[i][0])&&cal(i,1,pos-1)!=-1) return 0;
return 1;
}
inline void dfs(int t,int dh,int now){
while(now<=n){
int tt=chk(now,dh);
if(tt==0) return;
if(tt==-1) break;
now++;
}
if(t>cnt){
if(now==n+1){
for(int i=1;i<=cnt;i++)
if(ans[sx[i]]==-1) ans[sx[i]]=val[sx[i]];
else if(ans[sx[i]]!=val[sx[i]]) ans[sx[i]]=-2;
flag=1;
}
return;
}
if(sx[t]==dh||v[sx[t]]){
dfs(t+1,dh,now);
return;
}
bool qyf=0;
for(it=lt[sx[t]].begin();it!=lt[sx[t]].end();it++){
int j=*it;
if(val[j]>10){
qyf=1;
break;
}
}
for(register int i=1;i<=ok[sx[t]][0];i++){
int tmp=ok[sx[t]][i];
if(qyf&&tmp>10) continue;
if(!used[tmp]&&!vis[sx[t]][tmp]){
used[tmp]=1;
v[sx[t]]=1;
val[sx[t]]=tmp;
vis[sx[t]][tmp]=1;
dfs(t+1,dh,now);
used[tmp]=0;
v[sx[t]]=0;
val[sx[t]]=-1;
vis[sx[t]][tmp]=0;
}
}
}
inline bool okk(int dh){
for(register int i=1;i<=n;i++){
int pos=-1;
for(int j=1;j<str[i][0];j++){
if(val[str[i][j]]>10&&val[str[i][j+1]]>10) return 0;
if(str[i][j]==dh)
pos=j;
}
for(register int j=1;j<=str[i][0];j++)
if(val[str[i][j]]==-1)
val[str[i][j]]=2;
int dl=cal(i,1,pos-1),dr=cal(i,pos+1,str[i][0]);
for(register int j=1;j<=str[i][0];j++)
if(val[str[i][j]]==2)
val[str[i][j]]=10;
int ul=cal(i,1,pos-1),ur=cal(i,pos+1,str[i][0]);
for(register int j=1;j<=str[i][0];j++)
if(val[str[i][j]]==10)
val[str[i][j]]=-1;
if(ul<dr||ur<dl) return 0;
}
return 1;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",st[i]+1);
str[i][0]=strlen(st[i]+1);
int ct=0;
memset(vl,0,sizeof(vl));
for(int j=1;j<=str[i][0];j++){
str[i][j]=st[i][j]-'a'+1;
if(j==1) rt[str[i][j]].insert(str[i][j+1]);
else if(j==str[i][0]) lt[str[i][j]].insert(str[i][j-1]);
else lt[str[i][j]].insert(str[i][j-1]),rt[str[i][j]].insert(str[i][j+1]);
if(str[i][j]<1||str[i][j]>13){
printf("noway");
return 0;
}
if(!vl[str[i][j]])
vl[str[i][j]]=1,ton[str[i][j]]++,ct++;
else vis[str[i][j]][13]=1;
}
p[i]=make_pair(ct,i);
for(int t=11;t<=13;t++)
vis[str[i][str[i][0]]][t]=vis[str[i][1]][t]=1;
pp[i]=i;
}
sort(p+1,p+1+n);
for(int i=1;i<=n;i++)
if(pp[p[i].second]!=i){
swap(str[pp[i]],str[p[i].second]);
swap(pp[i],pp[p[i].second]);
}
for(int i=1;i<=n;i++)
for(int j=1;j<=str[i][0];j++)
if(!v[str[i][j]])
v[str[i][j]]=1,sx[++cnt]=str[i][j];
for(int i=1;i<=cnt;i++){
val[sx[i]]=-1,ans[sx[i]]=-1;
if(ton[sx[i]]!=n){
vis[sx[i]][13]=1;
}
else if(!vis[sx[i]][13])
d[++d[0]]=sx[i];
}
if(!d[0]){
printf("noway");
return 0;
}
for(int i=1;i<=cnt;i++)
for(int j=13;j>=1;j--)
if(!vis[sx[i]][j])
ok[sx[i]][++ok[sx[i]][0]]=j;
memset(v,0,sizeof(v));
for(int i=1;i<=d[0];i++){
val[d[i]]=13,v[d[i]]=1,vis[d[i]][13]=1,used[13]=1,flag=0;
for(int j=0;j<=cnt;j++){
if(j!=0&&(vis[sx[j]][12]||v[sx[j]])) continue;
if(j!=0) val[sx[j]]=12,v[sx[j]]=1,vis[sx[j]][12]=1,used[12]=1;
for(int k=0;k<=cnt;k++){
bool qyf=0;
for(it=lt[sx[k]].begin();it!=lt[sx[k]].end();it++){
int j=*it;
if(val[j]>10){
qyf=1;
break;
}
}
if(qyf) continue;
if(k!=0&&(vis[sx[k]][11]||v[sx[k]])) continue;
if(k!=0) val[sx[k]]=11,v[sx[k]]=1,vis[sx[k]][11]=1,used[11]=1;
if(okk(d[i])){
dfs(1,d[i],1);
}
if(k!=0) val[sx[k]]=-1,v[sx[k]]=0,vis[sx[k]][11]=0,used[11]=0;
}
if(j!=0) val[sx[j]]=-1,v[sx[j]]=0,vis[sx[j]][12]=0,used[12]=0;
}
val[d[i]]=-1,v[d[i]]=0,vis[d[i]][13]=0,used[13]=0;
if(flag){
gm[d[i]].insert(13),xd[13].insert(d[i]);
if((int)xd[13].size()!=1){
printf("noway");
return 0;
}
}
}
bool no=1;
sort(sx+1,sx+1+cnt);
for(int i=1;i<=cnt;i++){
if(ans[sx[i]]!=-2&&ans[sx[i]]!=-1){
char t;
if(ans[sx[i]]<=10) t='0'+ans[sx[i]]-1;
else if(ans[sx[i]]==11) t='+';
else if(ans[sx[i]]==12) t='*';
else t='=';
printf("%c%c\n",sx[i]+'a'-1,t),no=0;
}
}
if(no) printf("noway");
return 0;
}
这个代码比较长,想在理想的时间完成需要很强的代码能力,这是我需要锻炼的部分。
写代码顺序原则:优化越多写法越复杂的代码越难调。优先保证快速写对最朴素的写法(并且保留这份代码在另一个cpp后面可能要用)。
朴素代码保留下来用于数据分治,先保证小分拿到。
技巧:用namespace,避免重名。

- 对于代码较长的题目(尤其是搜索),先写朴素框架,能多简单些就多简单写,不要怕后面改,不然一开始就写带优化的或者为优化做准备写法会大大增加调试框架的难度,从而浪费大量时间。(这一步要尽量快,并且一定要测试大量数据,确保最朴素的写法是正确的,不然后面的优化都是无用功。)
- 然后加优化,加一个优化调一下,不要全部加完再调。(确定每个优化都是不影响正确性的)
- 模块化编程,一定要多写函数,一个功能一个函数,方便调试,不要大块代码都堆在一个函数里。
- 对于每个功能的函数,都要分别调试,再整体测试。
- 对于每个功能,在写之前快速想有什么细节要注意,写在注释或者草稿纸上。
- 对于调试语句也写几个函数,要重复调试的时候直接调用,避免写大量调试语句堆在代码中,提交时把函数删掉,利用CE删残余的调试语句。
- 一定要先静态查,再动态查,养成好习惯。
Benq注释:
- 爆int?数组越界?
- 特殊情况导致代码TLE,MLE,RE?
- 时刻保持清醒的思路,写了总比不写强。
相关题目/资料: 最多的方案
每一项都可以称为斐波那契数。
现在给一个正整数 \(n\),它可以写成一些斐波那契数的和的形式。如果我们要求不同的方案中不能有相同的斐波那契数,那么对一个 \(n\) 最多可以写出多少种方案呢?
暴力背包DP很好想,但是MLE+TLE。
这题某些元素很大,并且部分前缀和很小。
感觉非常稀疏。
可以考虑记忆化剪枝+可行性剪枝。
有由于一个数如果某个前缀s[i]和都比他小那么i前面的都没必要试了。
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pii pair<int,int>
#define mkp(x,y) make_pair(x,y)
const int maxn=100;
const int mx=1e18;
int a[maxn],n,cnt=2,s[maxn];
map<pii,int>dp;
int dfs(int sum,int last){
if(sum==0) return 1;
if(dp.find(mkp(sum,last))!=dp.end()) return dp[mkp(sum,last)];
int ans=0;
for(int i=last;i>=2;i--){
if(sum-s[i]>=0) break;
if(sum-a[i]>=0) ans+=dfs(sum-a[i],i-1);
}
return dp[mkp(sum,last)]=ans;
}
signed main(){
scanf("%lld",&n);
a[1]=a[2]=1ll;
for(cnt=3;a[cnt-1]+a[cnt-2]<=n;cnt++)
a[cnt]=a[cnt-1]+a[cnt-2];
cnt--;
for(int i=1;i<=cnt;i++)
s[i]=s[i-1]+a[i];
printf("%lld",dfs(n,cnt));
return 0;
}
tirck:感觉稀疏的问题记忆化搜索有奇迹。
还有一道类似的题link。
相关题目/资料: squre destory
好题,多个技巧的综合应用。
一个 \(n \times n\) 的网格,共 \(2 \times n \times (n + 1)\) 条边,现在已经删除了一些边,问至少还需删去多少边,可以使得剩下的边不能构成正方形。
\(n \leq 5\)
最优化问题,数据范围小可以考虑用搜索解。
直接以类似二进制的方式考虑每一条边用不用,再加可行性剪枝和最优性剪枝可以得到以下代码。
#include<cstdio>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=70;
struct edg{
int u,v;
edg(int u_,int v_){
u=u_,v=v_;
}
edg(){}
}e[70*70*10];
int num[maxn],T,n,cnt,k,mb[maxn],ans=0x3f3f3f3f,an[maxn],ct,G[maxn][maxn];
bool no[maxn],nd[maxn];
vector<int>v[maxn];
vector<int>::iterator it;
int id(int x,int y){// 第x行 第y个 横着的
return (n+1)*(x-1)+n*(x-1)+y;
}
int idd(int x,int y){//竖着的
return x*n+(x-1)*(n+1)+y;
}
void wk(int x,int y,int k){
for(int i=x;i<=x+k;i++){
if(i==x||i==x+k)
for(int j=y;j<=y+k-1;j+=(!(k-1)?1:k-1)){
if(no[id(i,j)]) continue;
num[cnt]++;
v[id(i,j)].push_back(cnt);
}
if(i<x+k)
for(int j=y;j<=y+k;j+=k){
if(no[idd(i,j)]) continue;
num[cnt]++;
v[idd(i,j)].push_back(cnt);
}
}
}
void dfs(int t,int sum,int lv){
if(sum+lv/2>=ans) return;
if(t>n*(n+1)*2){
for(int i=1;i<=cnt;i++)
if(num[i]==mb[i])
return;
ans=min(ans,sum);
return;
}
dfs(t+1,sum,lv);
if(!no[t]){
int fl=0;
for(it=v[t].begin();it!=v[t].end();it++){
int tmp=*it;
if(num[tmp]==mb[tmp]) fl++;
num[tmp]--;
}
if(fl){
an[sum+1]=t;
dfs(t+1,sum+1,lv-fl);
an[sum+1]=0;
}
for(it=v[t].begin();it!=v[t].end();it++){
int tmp=*it;
num[tmp]++;
}
}
}
bool cmp(vector<int>x,vector<int>y){
return x.size()>y.size();
}
int main(){
scanf("%d",&T);
while(T--){
memset(no,0,sizeof(no)),memset(num,0,sizeof(num));
ans=0x3f3f3f3f,cnt=0;
for(int i=1;i<=n*(n+1)*2;i++) v[i].clear();
scanf("%d%d",&n,&k);
for(int i=1;i<=k;i++){
int x;scanf("%d",&x);
no[x]=1;
}
for(k=1;k<=n;k++){
for(int i=1;i+k-1<=n;i++)
for(int j=1;j+k-1<=n;j++){
cnt++,mb[cnt]=k*4;
wk(i,j,k);
}
}
sort(v+1,v+1+(n*(n+1)*2),cmp);
int tot=0;
for(int i=1;i<=cnt;i++)
if(num[i]==mb[i])
tot++,nd[i]=1;
for(int i=1;i<=n*(n+1)/2;i++)
for(int j=0;j<v[i].size();j++){
int u=i,vv=v[i][j];
if(!G[u][vv]) e[++ct]=edg(u,vv),G[u][vv]=1;
}
ans=0;
while(1){
bool flag=0;
for(int i=1;i<=ct;i++){
int u=e[i].u,vv=e[i].v;
if(nd[u]&&nd[vv]){
ans++,tot-=2;
nd[u]=nd[vv]=0;
flag=1;
}
}
if(!flag) break;
}
while(1){
bool flag=0;
for(int i=1;i<=ct;i++){
int u=e[i].u,vv=e[i].v;
if(nd[u]||nd[vv]){
ans++,tot--;
nd[u]=nd[vv]=0;
flag=1;
}
}
if(!flag) break;
}
dfs(1,0,tot);
printf("%d\n",ans);
}
return 0;
}
这份代码搜索树特别深,并且剪枝效率不高,连n=4都跑不过去。
这题的核心目的是破坏每个正方形,所以只有没被破坏的正方形的边才有意义删掉,所以我们每层都破坏一个棍子,并且确保了每层都至少有一个正方形被破坏,相当于确定了一个正方形的破坏顺序,排除了大量等效冗余,可以AC。
技巧:以题目的要求(必须要满足的)作为搜索的对象会排除更多的等效冗余,更早的得到最优解。(这题用最少棍子的目的还是破坏全部正方形,棍子是很灵活的可以换顺序用的,但是正方形是每个必须的,可以认为定义一个顺序排除大量等效冗余。)
上面拼凑大木棒,每个大木棒都是必要被凑成的人为确定一个顺序也是类似的道理。还有宝藏每个点都是要经过的,人为的按dfs生成树的顺序也是这个道理。
对于题目出现必须要满足的条件如果能确定一个顺序搜索就能排除大量等效冗余,是非常高效的剪枝。
125msAC。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=100;
int T,n,m,cnt,ans;
int size[maxn],fsize[maxn];
int in[maxn][maxn],b[maxn];
int gh(int x,int y){return y+2*n*x+x-2*n-1;}
int gl(int x,int y){return y+2*n*x+x-n-1;}
int find(){
for(int i=1;i<=cnt;i++)
if(fsize[i]==size[i])
return i;
return -1;
}
void dfs(int t){
if(t>=ans) return;
int now=find();
if(now==-1){
ans=min(ans,t);
return;
};
for(int i=1;i<=n*2*(n+1);i++){
if(in[now][i]){
for(int j=1;j<=cnt;j++){
if(in[j][i]) size[j]--;
}
dfs(t+1);
for(int j=1;j<=cnt;j++)
if(in[j][i]) size[j]++;
}
}
}
int main(){
scanf("%d",&T);
while(T--){
cnt=0;
memset(size,0,sizeof(size));memset(fsize,0,sizeof(fsize));memset(in,0,sizeof(in));
for(int i=1;i<=maxn-5;i++) b[i]=1;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int tt; scanf("%d",&tt); b[tt]=0;
}
for(int a=1;a<=n;a++)
for(int x=1;x<=n-a+1;x++)
for(int y=1;y<=n-a+1;y++){
fsize[++cnt]=a*4;
for(int i=0;i<a;i++){
int b1=gh(x,y+i);
int b2=gh(x+a,y+i);
int b3=gl(x+i,y);
int b4=gl(x+i,y+a);
in[cnt][b1]=in[cnt][b2]=in[cnt][b3]=in[cnt][b4]=1;
size[cnt]+=b[b1]+b[b2]+b[b3]+b[b4];
}
}
ans=n*n;
dfs(0);
printf("%d\n",ans);
}
return 0;
}
还可以更快。
技巧:对于最优化问题,BFS一般比DFS快。
因为dfs肯能走的很深仍然是找不到最优解的分支。
但是普通bfs搜索树非常满,如果给bfs加上剪枝无疑对最优化问题是十分高效的。
这里可以加上一个最优化剪枝:
如果将一个正方形没有被破坏,将预期要删的木棍个数+1并且把他四条边都删去,这样估计出来的,对于当前正方形局面一定是一个下界。
如果当前的用的木棍数+预估的下界>ans直接return。
我们可以将用了几个木棍作为搜索的参数,这样层数就是答案,一层至少破坏一个正方形(不妨设还没破坏中的第一个),一定只删掉一根木棍,这就是广搜的思想,层数就是最短路。
技巧:动态确定搜索树走哪个分支的剪枝一般比较高效。
它还有个名字叫做A算法,用于解决最优化问题,bfs和Dijkstra用的有A的思想。
但是bfs要记录当前正方形破坏的状态,十分耗费空间,我们不妨用dfs的方法实现bfs。
枚举ans也就是答案并且也是层数,每一层都从根搜,由于没增加一层都是指数级增长复杂度的,所以重复搜的时间可以忽略。
这种用dfs搞bfs省空间的做法还有一个名字叫做迭代加深。
将动态剪枝和dfs搞bfs和在一起的做法还有一个名字叫做IDA*。
32msAC。
#include<cstdio>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=1e5;
int num[maxn],T,n,cnt,k,mb[maxn],ans,an[maxn],ann;
bool no[maxn],flag,vis[maxn];
vector<int>v[maxn],g[maxn];
int id(int x,int y){// 绗瑇琛?绗瑈涓?妯潃鐨?
return (n+1)*(x-1)+n*(x-1)+y;
}
int idd(int x,int y){//绔栫潃鐨?
return x*n+(x-1)*(n+1)+y;
}
void wk(int x,int y,int k){
for(int i=x;i<=x+k;i++){
if(i==x||i==x+k)
for(int j=y;j<=y+k-1;j++){
if(no[id(i,j)]) continue;
num[cnt]++;
v[cnt].push_back(id(i,j));
g[id(i,j)].push_back(cnt);
}
if(i<x+k)
for(int j=y;j<=y+k;j+=k){
if(no[idd(i,j)]) continue;
num[cnt]++;
v[cnt].push_back(idd(i,j));
g[idd(i,j)].push_back(cnt);
}
}
}
void ds(int x){
for(int i=0;i<(int)g[x].size();i++)
num[g[x][i]]--;
}
void dss(int x){
for(int i=0;i<(int)g[x].size();i++)
num[g[x][i]]++;
}
int f(){
int ret=0;
vector<int>ttmp;
for(int i=1;i<=cnt;i++)
if(num[i]==mb[i]){
ret++;
for(int j=0;j<(int)v[i].size();j++){
int x=v[i][j];
ds(x);
}
ttmp.push_back(i);
}
for(int i=0;i<(int)ttmp.size();i++){
int t=ttmp[i];
for(int j=0;j<(int)v[t].size();j++){
int x=v[t][j];
dss(x);
}
}
return ret;
}
void dfs(int t){
int tmp=f();
if(!tmp){
flag=1;
return;
}
if(tmp+t-1>ans) return;
if(t>ans) return;
for(int sq=1;sq<=cnt;sq++){
if(mb[sq]==num[sq]){
for(int j=0;j<(int)v[sq].size();j++){
int i=v[sq][j];
if(!vis[i]&&!no[i]){
vis[i]=1,ds(i);
an[++ann]=i;
dfs(t+1);
ann--;
vis[i]=0,dss(i);
if(flag) return;
}
}
return;
}
}
}
int main(){
scanf("%d",&T);
while(T--){
memset(no,0,sizeof(no)),memset(num,0,sizeof(num));
for(int i=1;i<=cnt;i++) v[i].clear();
for(int i=1;i<=(n+1)*n*2;i++) g[i].clear();
cnt=0,ans=0,flag=0;
scanf("%d%d",&n,&k);
for(int i=1;i<=k;i++){
int x;scanf("%d",&x);
no[x]=1;
}
for(k=1;k<=n;k++)
for(int i=1;i+k-1<=n;i++)
for(int j=1;j+k-1<=n;j++){
cnt++,mb[cnt]=k*4;
wk(i,j,k);
}
ans=f();
dfs(1);
while(!flag){
ans++;
dfs(1);
}
printf("%d\n",ans);
}
return 0;
}
相关题目/资料: Mayan游戏
模拟+剪枝。
注意回溯法记录数组要用局部变量。
搜索框架容易搭建,模拟记得多写函数,调试语句剪切到另一个cpp,可能待会还要用。
注意有无可能某一个i的高度大于初始时的等细节问题,想好了再写代码。
每发现一个、几个问题改完代码先静态查,养成好习惯。
剪枝:
- 左移除非左边是0不然不可能出现。(基于观察的可行性剪枝)效率高(TLE60->AC)
- 如果有元素的个数<=2直接返回。由于每次调用dfs都要多70的计算量,可能处理不好是负优化。
- 先尝试右移,让最优解出现在尽早的时刻。
#include<bits/stdc++.h>
#define pii pair<int,int>
#define mkp(x,y) make_pair(x,y)
using namespace std;
const int maxn=10;
int a[maxn][maxn],h[maxn],n,num[40];//a[i][j]第i列第j行,h[i]第i列的高度
bool flag;
struct node{
int x,y,op;
node(int x_,int y_,int op_){
x=x_,y=y_,op=op_;
}
node(){}
}ans[maxn];
void dw(){
for(int i=1;i<=5;i++)
for(int j=2;j<=7;j++){
int t=j;
while(a[i][t]&&t>=2&&!a[i][t-1])
swap(a[i][t],a[i][t-1]),t--;
}
}
bool cl(){
int ret=0;
set<pii>st;
for(int i=1;i<=5;i++)
for(int j=1;j<=7;j++){
if(a[i][j]&&j+2<=h[i]&&a[i][j]==a[i][j+1]&&a[i][j]==a[i][j+2])
st.insert(mkp(i,j)),st.insert(mkp(i,j+1)),st.insert(mkp(i,j+2)),ret++;
if(a[i][j]&&i+2<=5&&a[i][j]==a[i+1][j]&&a[i][j]==a[i+2][j])
st.insert(mkp(i,j)),st.insert(mkp(i+1,j)),st.insert(mkp(i+2,j)),ret++;
}
for(set<pii>::iterator it=st.begin();it!=st.end();it++){
pii i=*it;
a[i.first][i.second]=0;
}
return ret;
}
void wk(int x,int y,int op){//1 下 -1 上
if(op==1) swap(a[x][y],a[x+1][y]);
if(op==-1) swap(a[x][y],a[x-1][y]);
dw();
while(cl()) dw();
}
bool chk(){
for(int i=1;i<=5;i++)
for(int j=1;j<=7;j++)
if(a[i][j]) return 0;
return 1;
}
bool ct1(){
memset(num,0,sizeof(num));
for(int i=1;i<=5;i++)
for(int j=1;j<=7;j++)
if(a[i][j])
num[a[i][j]]++;
for(int i=1;i<=5;i++)
for(int j=1;j<=7;j++)
if(a[i][j]&&num[a[i][j]]<=2)
return 1;
return 0;
}
void dfs(int t){
if(ct1()) return;
if(t>n){
if(chk()) flag=1;
return;
}
int b[10][10];
memcpy(b,a,sizeof(a));
for(int i=1;i<=5;i++)
for(int j=1;j<=7;j++)
if(a[i][j]){
if(i!=5){
ans[t]=node(i-1,j-1,1);
wk(i,j,1);
dfs(t+1);
if(flag) return;
memcpy(a,b,sizeof(b));
}
if(i!=1&&!a[i-1][j]){
ans[t]=node(i-1,j-1,-1);
wk(i,j,-1);
dfs(t+1);
if(flag) return;
memcpy(a,b,sizeof(b));
}
}
}
int main(){
scanf("%d",&n);
memset(a,0,sizeof(a));
for(int i=1;i<=5;i++){
int x;
while((~scanf("%d",&x))&&x)
a[i][++h[i]]=x;
}
dfs(1);
if(!flag){
printf("-1");
return 0;
}
for(int i=1;i<=n;i++) printf("%d %d %d\n",ans[i].x,ans[i].y,ans[i].op);
return 0;
}
相关题目/资料: gym100825C
给定一些连着的格子的坐标,要求同一行同一列不能有想同的数,最多10个格子,值域是[1,9],目标是t,指定是+或者*的运算,问方案数。
10个格子可以考虑搜索。
剪枝1:最优化剪枝,估计全是0/n的上下界。
剪枝2:加和乘分开两个函数写,常数在搜索算法中很重要。
剪枝3:位运算。
#include<bits/stdc++.h>
using namespace std;
const int maxn=20;
int n,m,t,l[maxn],h[maxn],ans;
long long mi[maxn],dw[maxn];// h--x l--y
char op;
struct node{
int x,y;
node(int x_,int y_){
x=x_,y=y_;
}
node(){}
}a[maxn];
void dfsc(int tt,int tot){
if(tot>t) return;
if(tot*mi[m-tt+1]<t||tot*dw[(m-tt+1)/2]>t) return;
if(t%tot!=0) return;
if(tt>=m){
int lt,x=a[tt].x,y=a[tt].y;
lt=t/tot;
if((h[x]&(1<<lt))==0&&(l[y]&(1<<lt))==0) ans++;
return;
}
for(int i=1;i<=n;i++){
int x=a[tt].x,y=a[tt].y;
if((h[x]&(1<<i))==0&&(l[y]&(1<<i))==0){
h[x]|=(1<<i),l[y]|=(1<<i);
dfsc(tt+1,tot*i);
h[x]^=(1<<i),l[y]^=(1<<i);
}
}
}
void dfsj(int tt,int tot){
if(tot>t) return;
if(tot+(m-tt+1)*n<t||tot+m-tt+1>t) return;
if(tt>=m){
int lt,x=a[tt].x,y=a[tt].y;
lt=t-tot;
if((h[x]&(1<<lt))==0&&(l[y]&(1<<lt))==0) ans++;
return;
}
for(int i=1;i<=n;i++){
int x=a[tt].x,y=a[tt].y;
if((h[x]&(1<<i))==0&&(l[y]&(1<<i))==0){
h[x]|=(1<<i),l[y]|=(1<<i);
dfsj(tt+1,tot+i);
h[x]^=(1<<i),l[y]^=(1<<i);
}
}
}
signed main(){
scanf("%d%d%d",&n,&m,&t);
cin>>op;
dw[0]=mi[0]=1ll;
for(int i=1;i<=m;i++){
int x,y;scanf("%d%d",&x,&y);
a[i]=node(x,y);
mi[i]=1ll*mi[i-1]*n,dw[i]=dw[i-1]*2ll;
}
if(op=='-'){
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(i!=j&&abs(i-j)==t)
ans++;
printf("%d",ans);
return 0;
}
if(op=='/'){
for(int i=1;i<=n;i++)
for(int j=i+1;j<=n;j++)
if(j/i==t&&j%i==0)
ans+=2;
printf("%d",ans);
return 0;
}
if(op=='+') dfsj(1,0);
else dfsc(1,1);
printf("%d",ans);
return 0;
}
相关题目/资料: APRIME - Anti-prime Sequences
由n for m这些构成的序列,使用其中任意2 for d个连续数的和为合数。并在所有方案在按输出最小序列的那种方案,两数之以“,”分隔;若不存在方案,输出“No anti-prime sequence exists.”
直接暴搜+可行性剪枝即可。
技巧:条件比较苛刻的题可以考虑dfs。
相关题目/资料: 扔鸡蛋问题
DP,状态将小的实验次数鸡蛋个数放到状态,检验一个楼层用最少鸡蛋,对应用鸡蛋检验最多的楼层,因为检验楼层数高用的鸡蛋多,用的鸡蛋多检验的楼层就高。
难点:
- 状态定义:根据鸡蛋和楼层的关系,交换两个变量的位置,将答案放在状态,从而完成转移。
- 状态转移:由于状态定义中有最多,转移时可以不用取max,根据最优子结构性质,最后原问题一定是最多的。
DP[i][j]做i次实验,扔j个鸡蛋最多检验的楼层。
定义里有最多,在列状态方程时一般不用去max,利用定义+最优子结构性质即可得出最终答案的最多。
画图i,j的情况考虑最后一步。
假设最后一步扔了pos位置,分类讨论:
- 如果鸡蛋破了,则对应图中下半部分,可以用DP[i-1][j-1]表示。
- 如果鸡蛋没破,则对应图中上半部分,可以用DP[i-1][j]表示。
#include<cstdio>
#include<algorithm>
#define int unsigned long long
using namespace std;
const int maxn=110;
int n,k,dp[maxn][maxn],h,ans;
signed main(){
for(int i=1;i<=100;i++) dp[1][i]=1;
for(int i=1;i<=63;i++) dp[i][1]=1;
for(int i=2;i<=63;i++)
for(int j=1;j<=100;j++){
dp[i][j]=dp[i-1][j-1]+dp[i-1][j]+1;
}
while(~scanf("%lld%lld",&k,&h)){
if(k==0) break;
ans=1e18;
for(int i=1;i<=63;i++){
if(dp[i][k]>=h){
ans=i;
break;
}
}
if(ans==1e18) printf("More than 63 trials needed.\n");
else printf("%lld\n",ans);
}
return 0;
}
相关题目/资料: False Hope
技巧:概率问题本质上是计数问题,常见的计数方法都可以考虑(DP,dfs,组合数学)。
第八章 数据结构:为算法维护必要的信息
本章定位:理解单调队列、单调栈、优先队列、ST 表、线段树、树状数组等结构分别维护什么。
题目识别信号:需要动态维护最值、区间信息、前缀信息、排名、历史状态或多次查询。
学习方法:数据结构不是为了炫技,而是为了把“每次重新算”优化成“维护必要信息”。先明确要支持的操作,再选择结构。
复盘重点:复盘时重点写:维护的信息是什么、支持哪些操作、每次修改如何更新、不变量是什么、复杂度降在哪里。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是单调队列、单调栈、优先队列、链表、ST 表、线段树、树状数组等结构。数据结构的本质是维护某类信息并支持高效更新/查询。
单调队列
相关题目/资料: P2698
题目描述,给定一些矩形,有横坐标x,高度h
求一个最小的窗口,可以使得有一个窗口中的最大高度减最小高度>=d
输入\(d、n 每个x y\)
解法:二分答案,判断用一个递增单调队列求滑动窗口最大值,一个递减单调队列求最小值
启示
1.答案满足单调性,即本题中窗口变大一定不会使得窗口内最大值-最小值变小,即答案在某个拐点一测check()=0,另一测check()=1,这个拐点就是答案
2.单调队列滑动窗口不只是可以下标<=窗口大小,对于指定的x也可以做差,使得一个维度控制所有元素在窗口内,另一个维度取最值
Code
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=1e5+10;
struct node{
int x,y;
}a[maxn];
int hx=1,tx,hn=1,tn,mx[maxn],mn[maxn],n,d,ans=-1;
bool cmp(node x,node y){
return x.x<y.x;
}
bool check(int k){
hx=1,tx=0,hn=1,tn=0;
for(int i=1;i<=n;i++){
while(hx<=tx&&a[mx[hx]].x<a[i].x-k) hx++;
while(hn<=tn&&a[mn[hn]].x<a[i].x-k) hn++;
while(hx<=tx&&a[mx[tx]].y<=a[i].y) tx--;
while(hn<=tn&&a[mn[tn]].y>=a[i].y) tn--;
mx[++tx]=i,mn[++tn]=i;
if(a[mx[hx]].y-a[mn[hn]].y>=d) return 1;
}
return 0;
}
int main(){
scanf("%d%d",&n,&d);
for(int i=1;i<=n;i++) scanf("%d%d",&a[i].x,&a[i].y);
sort(a+1,a+1+n,cmp);
int l=1,r=d;
while(l<=r){
int mid=(l+r)>>1;
if(check(mid)) ans=mid,r=mid-1;
else l=mid+1;
}
printf("%d",ans);
return 0;
}
单调栈
应用1:找第一个比当前元素大/小的下标
相关题目/资料: 找第一个比当前元素大的下标
解法:与单调队列的思考方式相同,考虑怎么样的\(i,j\)中会出现没有用的
\(若i>j并且a[i]<=a[j]则i没用\) 根据这个原则维护一个单调递减栈,倒序循环,将没有用的排除掉,栈顶即为所求
或者顺序循环,一个元素比原来栈中元素大,弹出栈中比该元素小的元素,那些被弹出的元素的\(f()\)就是这个元素(不通用,不推荐)
应用2:
维护一个单调的序列(常用,常配合二分进行优化)
这题其实是在主栈的基础上为了实现取最大值的功能加了一个辅助的单调栈,记录最大值,pop与主栈同步。
相关题目/资料: 日志分析
题面:维护一个栈支持入栈出栈,找栈中元素最值
维护一个单调递增的栈,第一个出现的大值有用,后面的值若不必栈顶的大,则没用\(若i>j并且a[i]<=a[j]则i没用\)这里和第一题的区别是第一题倒序,所以维护栈的增减性不同,本质上是一样的,都是下标越小值越大越好。
#include<cstdio>
#include<utility>
#include<stack>
using namespace std;
stack<pair<int,int> >s1,s2;
int n,cnt;
int main(){
scanf("%d",&n);
while(n--){
int op;
scanf("%d",&op);
if(op==0){
int t;scanf("%d",&t);
s1.push(make_pair(t,++cnt));
if(!s2.empty()&&t<=s2.top().first) continue;
else s2.push(s1.top());
}
else if(op==1){
if(s1.empty()) continue;
while(!s2.empty()&&s1.top().second<=s2.top().second) s2.pop();
s1.pop();
}
else{
printf("%d\n",s2.empty()?0:s2.top().first);
}
}
return 0;
}
不满足要求的不允许进栈
应用3:单调栈维护单调区间+二分优化
相关题目/资料: 和至少为k的子数组
题目描述:找一个子数组和至少为k要连续,原数组中正负都有,最小化子数组长度
解法:若没有负数可以利用单调性双指针\(O(n)\)
技巧:面对有一段连续的区间(删除,求和...)的问题,通常可以考虑前后缀
利用这个技巧我们不难想到可以利用前缀和,通过观察前缀和我们可以发现,如果去更多的负数会使得子数组更长不会更优,观察得出好像能成为最优解的前缀和都满足某种单调性
技巧:分析一个区间时我们往往先定右端点讨论左端点的情况
\(sum[l,r]=s[r]-s[l-1]\)
当我们确定右端点为\(s[r]\)时可以采用单调队列/栈的思维考虑那些点是没用的,从而对进一步优化有帮助
\(s[i],s[j]是s[r]的两个不同的左端点,i<j,若s[r]-s[i-1]<s[r]-s[j-1]那么i没用,进一步如果i<j并且s[i-1]>s[j-1]那么i没用\)
这样我们就可以利用单调栈维护一个单调递增的栈,栈中的元素都是合法的左端点,枚举右端点,二分在单调栈中找左端点即可
#include<cstdio>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=1e5+10;
int n,a[maxn],s[maxn],sta[maxn],top,k,ans=0x3f3f3f3f;
signed main(){
scanf("%lld%lld",&n,&k);
for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
for(int i=1;i<=n;i++) s[i]=s[i-1]+a[i];
for(int i=0;i<=n;i++){
if(top){
int l=1,r=top,t=-1;
while(l<=r){
int mid=(l+r)>>1;
if(s[i]-s[sta[mid]]>=k) t=sta[mid],l=mid+1;
else r=mid-1;
}
if(t!=-1) ans=min(ans,i-t);
}
while(top&&s[sta[top]]>=s[i]) top--;
sta[++top]=i;
}
printf("%lld",ans);
return 0;
}
如何想到单调递增栈:下标小值大的元素我们不要,也就是说我们要下标大值小的元素,所以我们接受下标小值小,下标大值大的元素,继而可以确定是单调递增栈
技巧:区间问题的常见解法是双指针和前后缀,在有单调性的情况下首选双指针
技巧:单调性+二分的情况通常可以被贪心将二分的log给优化掉
优化:维护单调性的除了单调栈还有单调队列,我们可以贪心的想,给一个右端点能和最小的左端点构成的子数组之和>=k那么这个点右边若还有更右的右端点能与最小的匹配肯定没有第一个可以匹配的优,即一个左端点唯一匹配一个右端点,除了这个右端点之外别的右端点跟他匹配都不会成为最优解,因此面对每个右端点每次取队首的若能匹配队首出队更新答案.。时间复杂度:\(O(n)\)
code \(O(n)\)
#include<cstdio>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=1e5+10;
int n,a[maxn],s[maxn],sta[maxn],top,k,ans=0x3f3f3f3f;
int q[maxn],head=1,tail;
signed main(){
scanf("%lld%lld",&n,&k);
for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
for(int i=1;i<=n;i++) s[i]=s[i-1]+a[i];
for(int i=0;i<=n;i++){
while(head<=tail&&s[i]-s[q[head]]>=k) ans=min(ans,i-q[head]),head++;
while(head<=tail&&s[q[tail]]>=s[i]) tail--;
q[++tail]=i;
}
printf("%lld",ans);
return 0;
}
应用4:单调栈动态(在线)查前/后缀中的最值
相关题目/资料: 奶牛排队
给的一个序列,求l\(,r\)使得\(a[l]\)是\([l,r]\)中最小的\(a[r]\)是最大的
最大化区间长度
解法:枚举右端点(技巧)找最优的左端点,首先最优的左端点一定在第一个大于右端点的位置k右边,否则右端点不合法,其次左端点要取\([k+1,r]\)中最小的,第一是保证了可行性,第二是在他左边的点不可能合法的与这个右端点匹配,其次在他右边的点不会更优
实现:单调栈找第一个大于右端点的位置,用st表找最小的(单调栈也行)
#include<cstdio>
#include<algorithm>
#include<stack>
#include<cmath>
const int maxn=1e5+10;
int a[maxn],n,f[maxn],ans;
stack<int>s;
struct node{
int val,pos;
}F[maxn][20];
int que(int l,int r){
int k=log(r-l+1)/log(2);
return F[l][k].val<F[r-(1<<k)+1][k].val?F[l][k].pos:F[r-(1<<k)+1][k].pos;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]),F[i][0].val=a[i],F[i][0].pos=i;
for(int j=1;j<20;j++)
for(int i=1;i<=n-(1<<j)+1;i++){
F[i][j]=min(F[i][j-1],F[i+(1<<(j-1))][j-1]);
if(F[i][j-1].val<F[i+(1<<(j-1))][j-1].val) F[i][j].val=F[i][j-1].val,F[i][j].pos=F[i][j-1].pos;
else F[i][j].val=F[i+(1<<(j-1))][j-1].val,F[i][j].pos=F[i+(1<<(j-1))][j-1].pos;
}
for(int i=1;i<=n;i++){
while(!s.empty()&&a[i]>=a[s.top()]) s.pop();
if(s.empty()) f[i]=0;
else f[i]=s.top();
s.push(i);
}
for(int r=n;r>1;r--){
int l=f[r]+1;
int t=que(l,r);
if(r-t+1>=2) ans=max(ans,r-t+1);
}
printf("%d",ans);
return 0;
}
实现2:两个单调栈(应用4)
原理:\(i<j且a[i]>a[j]得出i没用\)而维护最小值,单调栈排除了不可能是最值的情况,使得整个前缀中的元素具有单调性,从而可以二分查找优化,序列中的都可能是某个前缀中要查的子区间的最值。
注意单调栈排除元素取不取等号认真分析其含义,不要想当然
#include<cstdio>
#include<stack>
#include<algorithm>
using namespace std;
const int maxn=1e5+10;
int mn[maxn],top,ans,n,a[maxn];
stack<int>s;
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int r=1;r<=n;r++){
while(!s.empty()&&a[s.top()]<a[r]) s.pop();//这里相等的也要留在栈用,因为右端点要严格最大
while(top&&a[mn[top]]>=a[r]) top--;
int l=1,rr=top,k=-1,pos=s.empty()?1:s.top()+1;
while(pos!=-1&&l<=rr){
int mid=(l+rr)>>1;
if(mn[mid]>=pos) k=mn[mid],rr=mid-1;
else l=mid+1;
}
if(k!=-1) ans=max(ans,r-k+1);
s.push(r),mn[++top]=r;
}
printf("%d",ans);
return 0;
}
拓展:区间问题的另一个常见解法——双指针
相关题目/资料: 逛画展
题目描述:给定一个长为n的数组和k,找一个最短的子数组使得其中包含1-k所有元素
解法:l=1,r=2一开始,统计区间中出现的元素,不合法移动右指针,直到合法,移动左指针直到不合法,对于每个可能合法且可能成为最优解的右指针,都会统计到,利用单调性易证,因为固定右端点后左指针总会到一个临界处再往右就不合法了(即有单调性),所以可以双指针
Code
#include<cstdio>
using namespace std;
const int maxn=1e6+10;
bool vis[maxn];
int n,m,a[maxn],last[maxn],ans=0x3f3f3f3f,al,ar;
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
int i=1,cnt=1;
last[a[1]]=1;
for(int j=2;j<=n;j++){
if(last[a[j]]<i) cnt++;
vis[last[a[j]]]=1,last[a[j]]=j;
while(vis[i]) i++;
if(cnt==m){
if(ans>j-i+1){
al=i,ar=j,ans=j-i+1;
}
i++,cnt--;
}
}
printf("%d %d\n",al,ar);
return 0;
}
优先队列
应用1:对顶堆
相关题目/资料: 中位数
开两个堆,动态维护区间第k大,大根存比中位数元素小的,小根存比中位数大的,插入时和大根顶或小根顶比较一下确定插到哪个堆,如果两个堆的size的差值在新插入一个元素后变得不满足要求了,则将一个对顶的元素换到另一个$$O(n\log n)$$动态查排名
应用2:多路归并优化
相关题目/资料: 序列合并
技巧1:排序问题可以分析哪些东西是有序的,如果有多个有序的序列时,可以考虑归并
技巧2:当我们遇到很多有序序列要合并成一个较长的有序序列时,可以采用归并策略,取最值时可以用堆优化
实现时模拟归并顺序即可
Code:
#include<cstdio>
#include<queue>
using namespace std;
const int maxn=1e5+10;
int a[maxn],b[maxn],n;
struct node{
int x,y,sum;
node(int x_,int y_,int sum_){
x=x_,y=y_,sum=sum_;
}
node(){}
};
bool operator<(node x,node y){
return x.sum>y.sum;
}
priority_queue<node>q;
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=n;i++) scanf("%d",&b[i]);
for(int i=1;i<=n;i++) q.push(node(1,i,a[1]+b[i]));
for(int i=1;i<=n;i++){
printf("%d ",q.top().sum);
node now=q.top();
q.pop();
q.push(node(now.x+1,now.y,a[now.x+1]+b[now.y]));
}
return 0;
}
解法2:调和级数枚举
面对超时的枚举,有可能出现(乘积,倍数等特征时可以利用调和级数优化)
相关题目/资料: 题解+证明
应用3:找n个数中最大、小的m个数\(O(nlogm)\),现将前m个压入堆,后面每扫到一个能替换就和小根堆中的最小元素替换,最后堆内的元素就是最大的m个元素
链表
小技巧:
a[0].nxt=1,a[n+1].pre=n;
数组模拟双向链表时可以加以上这句,方便找头和判断是否出界
遇到多次插入、删除、合并两个数组操作首选链表
查找能直接下标尽量下标,避免浪费时间
写链表要注意的点:
1.链表的第0,1,n,n+1个元素的链接关系,初值千万不要忘记赋,仔细检查这几个元素和其他元素的链接关系修改后会不会越界
2.链表要删/增与合并同时进行时,一定要搞清先后关系,尽量拆分成清晰的多个步骤,不同步骤之间不要有重合部分(如:合并后再删去新的大的中的元素,又在大的中再合并),不要有,非常容易错。一般先增删完成之后再考虑合并操作
3.多造数据检查,不要过了样例想当然,测大样例时观察出错部分有什么结构,自己可以根据这个结构构造
ST表
必要条件:
- 区间满足可重复贡献的性质。(即重叠的部分计算两次对答案没影响)
- 运算满足结合律。
原理,由于最后计算用的是可相交的性质,所以有以上要求。由于可以直接用两个段表示,所以可以O(1)查询。
相交的性质是st比线段树快的关键。
线段树都是不相交的。
不支持修改$$O(n\log n)$$建表,\(O(1)\)查询。
优势相对于线段树在于:
- 常数小。
- 查询快。
不光可以计算RMQ,还可以计算区间gcd,lcm,按位与和,按位或和等。
都要用二元运算,不要算lcm的时候用多个数乘积。
有趣的是,这些计算往往和RMQ有关,lcm和gcd和唯一分解定理的指数取maxmin,按位与或是按位取minmax。
特别的,区间gcd,lcm查询不会比线段树优,因为gcd需要log的时间。
ST表+二分妙用
由于区间扩大最值肯定单调递增/减,利用这个性质可以在序列问题上要查最值、最近等信息的时候,配合二分可以找到最近能满足的
例如:JFCA
一种有通用性的思想:一个区间内如果最值满足其他一定满足,例如找一个位置使得数组左边都比他大,那么找左边的最小值,如果最小值都比他大了那么肯定是符合要求的
线段树
最基础的线段树:单点修改,区间查询。(带修ST表)
证明区间查询是log(n):
找到第一个分两个叉查的位置,在他下面的两个叉如果还有两个叉,必定有一个是直接返回的。
完整版线段树:区间修改区间查询。
写代码方便:将n变成一个最小的大于n的二的幂。
线段树有2*n-1个结点。所有>=n的结点都是叶子结点。
模板题:线段树1
- 区间+k。
- 区间求和。
解法:
法一:区间修改这种操作非常适合用差分。
定义:\(\Delta_1=a_1,\Delta_i=a_i-a_{i-1}\)。
区间求和可以转化为求前缀和。
$\sum_{i=1}{n}\sum_{j=1}\Delta_j=\sum_{j=1}^{n}\Delta_j\times (n-j+1)=(n+1)\sum_{j=1}{n}\Delta_j-\sum_{j=1}\Delta_j-j $
由于我们最基础的线段树可以实现查询前缀和的操作,所以根据差分推式子要往推成单个sigema与delta的式子。
根据这个式子用线段树维护\(\Delta_j\)和\(\delta_j=\Delta_j-j\)即可。
区间修改由于差分的定义变成两次单点修改,单点修改时两个值都要改变。
注意将n->2^k方便写代码时用差分要注意做单点修改时的边界条件。很容易搞错。
相关题目/资料: Range Update Queries
区间+u,单点查询。
最简单的做法:树状数组。
如果我们只会最基础的线段树,那么可以使用差分数组,将区间修改变成单点修改,单点查询变成前缀和(区间查询)。
注意:如果修改超过n的地方一定不要做。
技巧:使用差分数组可以将区间和单点的意义调换。
#include<cstdio>
#define int long long
using namespace std;
const int maxn=3e5+10;
int dat[maxn*2],n,q,aa[maxn],s[maxn];
void build(int t,int l,int r){
if(l==r){
dat[t]=s[l];
return;
}
int mid=(l+r)>>1;
build(t<<1,l,mid);
build(t<<1|1,mid+1,r);
dat[t]=dat[t<<1]+dat[t<<1|1];
}
void update(int i,int x){//简洁的单点修改代码
i=i+n-1,dat[i]+=x;
while(i>1){
i/=2;
dat[i]=dat[i<<1]+dat[i<<1|1];
}
}
int que(int t,int l,int r,int a,int b){
if(l>=a&&r<=b) return dat[t];
if(r<a||l>b) return 0;
int mid=(l+r)>>1,ret=0;
if(a<=mid) ret+=que(t<<1,l,mid,a,b);
if(b>mid) ret+=que(t<<1|1,mid+1,r,a,b);
return ret;
}
signed main(){
scanf("%lld%lld",&n,&q);
for(int i=1;i<=n;i++) scanf("%lld",&aa[i]);
for(int i=1;i<=n;i++) s[i]=aa[i]-aa[i-1];
int t=0;
while((1<<t)<n) t++;
n=1<<t;
build(1,1,n);
while(q--){
int op;scanf("%lld",&op);
if(op==1){
int a,b,u;scanf("%lld%lld%lld",&a,&b,&u);
update(a,u);
if(b+1<=n) update(b+1,-u);
}
else{
int k;scanf("%lld",&k);
printf("%lld\n",que(1,1,n,1,k));
}
}
return 0;
}
应用一:带修二分查找。
给定n个元素,q个询问2e5。
每次一个询问输入x,找第一个>=x的位置输出,并将此位置-=x。
解法:由于RMQ具有单调性,即越长的区间RMQ一定比他的子区间大。
所以可以二分找第一个元素。
由于带修,利用线段树进行二分查找,并且用单点修改即可。
这个问题本质上是区间查询和单点修改的问题。线段树常用于解决区间上的问题。
#include<bits/stdc++.h>
using namespace std;
const int maxn=262144+10;
int mx[maxn<<1],n,a[maxn],q;
void build(int t,int l,int r){
if(l==r){
mx[t]=a[l];
return;
}
int mid=(l+r)>>1;
build(t<<1,l,mid);
build(t<<1|1,mid+1,r);
mx[t]=max(mx[t<<1],mx[t<<1|1]);
}
void upda(int i,int x){
i+=n-1;mx[i]+=x;
while(i>1){
i>>=1;
mx[i]=max(mx[i<<1],mx[i<<1|1]);
}
}
int find(int t,int x){
if(t>=n) return t-n+1;
if(mx[t<<1]>=x) return find(t<<1,x);
else return find(t<<1|1,x);
}
int main(){
scanf("%d%d",&n,&q);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
int t=0;while((1<<t)<n) t++;n=1<<t;
build(1,1,n);
while(q--){
int x;scanf("%d",&x);
if(mx[1]<x) printf("0 ");
else{
int t=find(1,x);
printf("%d ",t);
upda(t,-x);
}
}
return 0;
}
应用2:二维线段树。
相关题目/资料: Forest Queries
给定n*n的矩形n<=1e3查询q次每次查一个矩形有几个1。
#include<bits/stdc++.h>
using namespace std;
const int maxn=2100;
int sum[maxn][maxn],n,q,a[maxn][maxn],x,y,xx,yy;
char str[maxn][maxn];
void buix(int t,int l,int r,int o){
if(l==r){
sum[o][t]=a[l][o-n+1];//细节 o-n+1
return;
}
int mid=(l+r)>>1;
buix(t<<1,l,mid,o);
buix(t<<1|1,mid+1,r,o);
sum[o][t]=sum[o][t<<1]+sum[o][t<<1|1];
}
void conb(int x){for(int i=1;i<=2*n;i++) sum[x][i]=sum[x<<1][i]+sum[x<<1|1][i];}
void buiy(int t,int l,int r){
if(l==r){
buix(1,1,n,t);
return;
}
int mid=(l+r)>>1;
buiy(t<<1,l,mid);
buiy(t<<1|1,mid+1,r);
conb(t);
}
int que(int t,int l,int r,int o){
if(l>=x&&r<=xx) return sum[o][t];
int mid=(l+r)>>1,ret=0;
if(x<=mid) ret+=que(t<<1,l,mid,o);
if(xx>mid) ret+=que(t<<1|1,mid+1,r,o);
return ret;
}
int query(int t,int l,int r){
if(l>=y&&r<=yy) return que(1,1,n,t);
int mid=(l+r)>>1,ret=0;
if(y<=mid) ret+=query(t<<1,l,mid);
if(yy>mid) ret+=query(t<<1|1,mid+1,r);
return ret;
}
int main(){
scanf("%d%d",&n,&q);
for(int i=1;i<=n;i++) scanf("%s",str[i]+1);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
a[i][j]=(str[i][j]=='*')?1:0;
int t=0;while((1<<t)<n) t++;n=1<<t;
buiy(1,1,n);
while(q--){
scanf("%d%d%d%d",&x,&y,&xx,&yy);
printf("%d\n",query(1,1,n));
}
return 0;
}
应用三:多个lazy-tag
相关题目/资料: 线段树2
-
将某区间每一个数乘上x
-
将某区间每一个数加上x
-
求出某区间每一个数的和
需要用到两个tag,一个记乘法一个记加法。
注意:一个tag的更新可能会影响另外一个tag的值,tag之间的运算顺序会影响结果。
例如对于某个结点,之前有+某个数的tag,现在又要*某个数,那么对于加的tag也要乘上这个数。(乘法分配律)
但是之前有乘的tag现在又要+是不会影响的。
pushdown下去的时候如果先算加的tag因为上面已经处理过了乘与加的tag之间的关系,就会重复计算乘的贡献导致出错。
然而先乘后加则不会。
多个tag的时候要尤其注意其中相互影响的情况与pushdown之后的计算顺序。
线段树维护矩阵
前置知识:矩阵乘法加速递推。
单位方阵:左上角到有下角斜对角线为1其他全部为0的n*n的矩阵。
\(\begin{pmatrix} 1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & 1 \end{pmatrix}\)
用一个长度为n的一维向量成单位矩阵等于原来的向量。即单位矩阵相当于乘法中的1。
矩阵乘法的运算\(C_{i,j}=\sum_{k=1}^{m}A_{i,k}*B_{k,j}\)即第一个矩阵的行与第二个矩阵对应的列每个元素相乘再相加。
矩阵乘法满足结合律不满足分配律。
矩阵乘法加速递推的问题一般有以下特征:
- 可以抽象出一个长度为n的一维向量(状态矩阵),该向量在每个单位时间发生一次变化;
- 变化的形式是一个线性递推(只有若干“加法”或“乘一个系数”的运算);
- 该递推式在每个时间可能作用于不同的数据上,但本身保持不变;
- 向量变化时间(即递推的轮数)很长,但向量长度n不大。
步骤:
- 列出原理的状态转移方程。
- 等号右边涉及n变量就在单位矩阵中设计n个对应的变量,排好顺序,设计出1*n的状态矩阵。
- 设计n*n的转移矩阵,根据上一个状态矩阵对应元素与转移矩阵中第i列的相乘再相加对应转移后矩阵的第i个变量。注意转移方程和系数。
- 用矩阵快速幂可以优化到log求单个\(F_n\)的值。
设计的转移矩阵一般格式:新向量=旧向量(\(1*n\))\(\times\)单位矩阵(\(n*n\))。
相关题目/资料: Sasha and Array
- 在本题中,我们用 \(f_i\) 来表示第 \(i\) 个斐波那契数(\(f_1=f_2=1,f_i=f_{i-1}+f_{i-2}(i\ge 3)\))。
- 给定一个 \(n\) 个数的序列 \(a\)。有 \(m\) 次操作,操作有两种:
- 将 \(a_l\sim a_r\) 加上 \(x\)。
- 求 \(\displaystyle\left(\sum_{i=l}^r f_{a_i}\right)\bmod (10^9+7)\)。
- \(1\le n,m\le 10^5\),\(1\le a_i\le 10^9\)。
如果线段树每个节点都存fab那么区间修改会花费大量时间(变成单点才能解决),如果每个节点存fab的项数,那么区间查询变单点,花费大量时间。
技巧:矩阵可以快速求一个递推式的某一项log的时间
本质上是利用矩阵乘法求递推的第n项将函数的和转换为单位矩阵特定幂次的和,即把递推的函数给去掉了,简化成了维护矩阵的问题(本质上和维护一个数没有区别)
并且矩阵实质上是将一个递推式的某一项的计算变成了一个转移矩阵的某次幂乘上状态矩阵。(有点类似于将大小不同的数,得到某一项需要每次加或乘不同的数改成了统一的基础矩阵的项数次方。)
这也就是能用矩阵快速幂优化递推的实质。
关于矩阵优化递推式的一般流程:
确定初始矩阵要记录递推式的哪些量(可能有变量,有之前的某一项,有常量。)
初始矩阵一般是1*n的矩阵
利用矩阵乘法的定义,结合要转移到的递推式的包含下一项的目标矩阵(和初始矩阵的),设计基础矩阵,这个基础矩阵要达到转移的效果。(一般是初始矩阵元素个数的方阵n*n)
利用矩阵快速幂求解。
矩阵乘法的性质:(注意没有交换律)
- 结合律(能用快速幂优化的另一个依据)
- 分配律
利用“改成统一”这一特点可用用线段树维护。
设M为基础矩阵。
线段树每个节点维护\(M^{a[i]}\)。
如果对于a的某个区间+x可以转换成对于M的对于区间\(*M^x\)
对于查询某个区间的值,将矩阵求和即可,因为具有分配律。
在实现时任何和乘有关的矩阵操作一定要保证先后顺序,因为矩阵乘法没有交换律。
技巧:在区间上求函数值如果函数是一个线性变换(只有加和乘系数)可以用线段树维护矩阵。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int mod=1e9+7;
struct mat{
int jz[3][3];
mat(){memset(jz,0,sizeof(jz));}
}a,tr[262160],ba,tag[262160],ta;
int n,m,aa[270010],ll,rr;
mat operator*(mat x,mat y){
mat ret;
for(int i=1;i<=2;i++)
for(int j=1;j<=2;j++)
for(int k=1;k<=2;k++)
ret.jz[i][j]=(ret.jz[i][j]+x.jz[i][k]%mod*y.jz[k][j]%mod)%mod;
return ret;
}
mat operator+(mat x,mat y){
mat ret;
for(int i=1;i<=2;i++)
for(int j=1;j<=2;j++)
ret.jz[i][j]=(x.jz[i][j]+y.jz[i][j])%mod;
return ret;
}
mat ksm(int y,mat t){
mat ret;
ret.jz[1][1]=ret.jz[2][2]=1;
while(y){
if(y&1) ret=ret*t;
t=t*t;
y>>=1;
}
return ret;
}
void bui(int t,int l,int r){
if(l==r){
tr[t]=ksm(aa[l],a);
return;
}
tag[t].jz[1][1]=tag[t].jz[2][2]=1;
int mid=(l+r)>>1;
bui(t<<1,l,mid);
bui(t<<1|1,mid+1,r);
tr[t]=tr[t<<1]+tr[t<<1|1];
}
void pushdown(int t,int l,int r){
tag[t<<1]=tag[t<<1]*tag[t];
tr[t<<1]=tr[t<<1]*tag[t];
tag[t<<1|1]=tag[t<<1|1]*tag[t];
tr[t<<1|1]=tr[t<<1|1]*tag[t];
tag[t].jz[1][1]=tag[t].jz[2][2]=1,tag[t].jz[1][2]=tag[t].jz[2][1]=0;
}
void upd(int t,int l,int r){
if(l>=ll&&r<=rr){
tag[t]=tag[t]*ta;
tr[t]=tr[t]*ta;
return;
}
int mid=(l+r)>>1;
pushdown(t,l,r);
if(ll<=mid) upd(t<<1,l,mid);
if(rr>mid) upd(t<<1|1,mid+1,r);
tr[t]=tr[t<<1]+tr[t<<1|1];
}
mat que(int t,int l,int r){
if(l>=ll&&r<=rr) return tr[t];
int mid=(l+r)>>1;
mat ret;
pushdown(t,l,r);
if(ll<=mid) ret=ret+que(t<<1,l,mid);
if(rr>mid) ret=ret+que(t<<1|1,mid+1,r);
tr[t]=tr[t<<1]+tr[t<<1|1];
return ret;
}
signed main(){
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++) scanf("%lld",&aa[i]),aa[i]--;
a.jz[1][2]=a.jz[2][1]=a.jz[2][2]=1,ba.jz[1][1]=ba.jz[1][2]=1;
int t=0;
while((1<<t)<n) t++;
n=1<<t;
bui(1,1,n);
while(m--){
int op,x;
scanf("%lld",&op);
if(op==1){
scanf("%lld%lld%lld",&ll,&rr,&x);
ta=ksm(x,a);
upd(1,1,n);
}
else{
scanf("%lld%lld",&ll,&rr);
mat tmp=ba*que(1,1,n);
printf("%lld\n",tmp.jz[1][1]);
}
}
return 0;
}
相关题目/资料: Addition Robot
给出一个长度为 \(N\),只含有大写字母 A 和 B 的字符串 \(S=S_1S_2S_3...S_n\),定义如下函数 \(f(L,R,A,B)\):
function f(L, R, A, B):
FOR i from L to R
if S[i] = 'A'
A = A + B
else
B = A + B
return (A, B)
给出初始字符串 \(S\),你需要执行以下 \(Q\) 次操作:
1 L R:将 \(S_L,S_{L+1},...,S_{R-1},S_r\) 取反(A 变为 B,B 变为 A)。
2 L R A B:输出函数调用 \(F(L,R,A,B)\) 的结果,对 \(10^9+7\) 取模。
\(1\le N,Q\le 10^5\)
解法:
和上题一样,由于(A,B)这个状态向量的变换是线性变换,所以用线段树维护矩阵即可。
取法是线段树常用的维护手段,最简单的问题就是维护01序列,统计0/1的个数,update是将一段区间取反。
技巧:线段树update是取反的一般性方法,记录这个节点(不包括自己)的子孙是否要取反,这个节点维护的答案要的值,维护的值的取反后的值。维护这三个变量即可。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int maxn=262144+10;
const int mod=1e9+7;
struct mat{
int jz[3][3];
mat(){memset(jz,0,sizeof(jz));}
}A,B,trz[maxn],trf[maxn],dw;
int n,q,x,y;
bool tag[maxn];
char str[maxn];
mat operator+(mat x,mat y){
mat ret;
for(int i=1;i<=2;i++)
for(int j=1;j<=2;j++)
ret.jz[i][j]=(x.jz[i][j]+y.jz[i][j])%mod;
return ret;
}
mat operator*(mat x,mat y){
mat ret;
for(int i=1;i<=2;i++)
for(int j=1;j<=2;j++)
for(int k=1;k<=2;k++)
ret.jz[i][j]=(ret.jz[i][j]+x.jz[i][k]%mod*y.jz[k][j]%mod)%mod;
return ret;
}
void print(mat xx){
for(int i=1;i<=2;i++,printf("\n"))
for(int j=1;j<=2;j++)
printf("%lld ",xx.jz[i][j]);
}
void bui(int t,int l,int r){
if(l==r){
if(str[l]=='A') trz[t]=A,trf[t]=B;
else if(str[l]=='B') trz[t]=B,trf[t]=A;
else trz[t]=trf[t]=dw;
return;
}
int mid=(l+r)>>1;
bui(t<<1,l,mid);
bui(t<<1|1,mid+1,r);
trz[t]=trz[t<<1]*trz[t<<1|1];
trf[t]=trf[t<<1]*trf[t<<1|1];
}
void pd(int t,int l,int r){
if(tag[t]){
tag[t<<1]=1-tag[t<<1];
swap(trz[t<<1],trf[t<<1]);
tag[t<<1|1]=1-tag[t<<1|1];
swap(trz[t<<1|1],trf[t<<1|1]);
tag[t]=0;
}
}
void upd(int t,int l,int r){
if(l>=x&&r<=y){
tag[t]=1-tag[t];
swap(trz[t],trf[t]);
return;
}
pd(t,l,r);
int mid=(l+r)>>1;
if(x<=mid) upd(t<<1,l,mid);
if(y>mid) upd(t<<1|1,mid+1,r);
trz[t]=trz[t<<1]*trz[t<<1|1];
trf[t]=trf[t<<1]*trf[t<<1|1];
}
mat que(int t,int l,int r){
if(l>=x&&r<=y) return trz[t];
pd(t,l,r);
int mid=(l+r)>>1;
mat ret=dw;
if(x<=mid) ret=ret*que(t<<1,l,mid);
if(y>mid) ret=ret*que(t<<1|1,mid+1,r);
trz[t]=trz[t<<1]*trz[t<<1|1];
trf[t]=trf[t<<1]*trf[t<<1|1];
return ret;
}
signed main(){
A.jz[1][1]=A.jz[2][1]=A.jz[2][2]=B.jz[1][1]=B.jz[1][2]=B.jz[2][2]=dw.jz[1][1]=dw.jz[2][2]=1;
scanf("%lld%lld",&n,&q);
scanf("%s",str+1);
int t=0;while((1<<t)<n) t++;n=1<<t;
bui(1,1,n);
while(q--){
int op;scanf("%lld%lld%lld",&op,&x,&y);
if(op==1){
upd(1,1,n);
}
else{
int a,b;scanf("%lld%lld",&a,&b);
mat tmp;
tmp.jz[1][1]=a,tmp.jz[1][2]=b;
tmp=tmp*que(1,1,n);
printf("%lld %lld\n",tmp.jz[1][1],tmp.jz[1][2]);
}
}
return 0;
}
相关题目/资料: sgu177.square
- 给定n*n个格子,一开始全部为白色。
- 着色m次,每次着色一个矩形的范围,可以着色为黑色或白色。
- 最后询问白色有多少个。
- n<=1000,m<=5000。
解法:
法一:四分树,解决二维平面内问题的常用数据结构,类似线段树。
法二:一列一列考虑,倒着考虑染色,每个格子只会被染色一次。
暴力染色,需要一个数据结构快速找到任意格子右边第一个没有染色的位置,即可以快速跳过连续的已经染色的位置。
连续已经染色的位置可以合并在一个集合,将多个元素合并在同一个集合的数据结构是并查集,并且这个快速找右边第一个没有染色的位置是并查集的一个常见的应用。
考虑在每个集合的root加一个nxt指针,代表这个集合右边第一个没有染色的位置。
对于一个新的位置,将他染色,先更新它的nxt为右边的的集合的nxt。
然后如果它右边和左边的两个位置有染色的就合并在一起,先合并右边,再合并左边,并且要更新左边的nxt的值。
#include<bits/stdc++.h>
#define pb push_back
using namespace std;
const int maxn=1010;
int n,m,nxt[maxn],fa[maxn],ans,num[maxn];
bool vis[maxn];
struct node{
int l,r,c;
node(int l_,int r_,int c_){
l=l_,r=r_,c=c_;
}
node(){}
}v[maxn][5010];
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
void Union(int x,int y){
x=find(x),y=find(y);
if(x==y) return ;
fa[x]=y;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int a,b,c,d;scanf("%d%d%d%d",&a,&b,&c,&d);
char ch;cin>>ch;
if(a>c) swap(a,c);
if(b>d) swap(b,d);
for(int j=a;j<=c;j++) v[j][++num[j]]=node(b,d,ch=='w'?1:2);
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n+1;j++) fa[j]=j,nxt[j]=j,vis[j]=0;
int tmp=0;
for(int j=num[i];j>=1;j--){
int l=v[i][j].l,r=v[i][j].r,c=v[i][j].c;
for(int k=nxt[find(l)];k<=r;k=nxt[find(k)]){
if(vis[k]) break;
vis[k]=1;
if(c==2) tmp++;
nxt[k]=nxt[find(k+1)];
if(vis[k+1]) Union(k,k+1);
if(vis[k-1]) nxt[find(k-1)]=nxt[find(k)],Union(k-1,k);
}
}
ans+=n-tmp;
}
printf("%d",ans);
return 0;
}
时间复杂度\(O(nm\alpha)\)
法三:一列一列考虑,在区间上染色用线段树修改就行。
开两个变量:
- 这个区间是否已经染色tag,如果被染色就从此开始不继续往下递归了。
- 这个区间白色的有多少个。
维护好这两个变量即可。
拓展:考虑一个更复杂的问题。
- 给定一个长度为n的区间,初始时无色。
- 1,l,r,c,表示将区间[l,r]染成颜色c。后染的颜色可以覆盖先染的颜色。
- 2,l,r,c,表示查询区间[l,r]中被染成颜色c的区间长度和。
c小可以直接用珂朵莉树,但是可能被卡爆。
c小也可以用线段树维护好这个区间的什么颜色有多少个,即维护一个颜色个数为下标值域的桶。lazy_tag就是除自己以外子孙要被染成什么颜色。
c大可以用分块。详见帖子。
相关题目/资料: 堵塞的交通
简化题意:
- 给定一个有 \(2n\) 个点的无向图,其中 \(n\) 个点在第一行,另外 \(n\) 个点在第二行。一开始图上没有边。
- 操作一,输入两个相邻的点,加边。
- 操作二,输入两个相邻的点,删边。
- 操作三,任意输入两个点,查连通性。
- 保证操作合法,\(1≤n≤100000,1≤操作条数≤100000\)。
解法:
难点在于操作数和点数很大,直接用一般的连通性的做法无法在时限内解决。
突破口:点数只有两层,类似一个区间。并且可以在 $$O(n\log n)$$ 的时间解决。结和这连点,考虑使用线段树。
线段树常用于解决区间修改和查询的问题,并且每个节点存储的信息要可以合并。
查询的是连通性,所以每个点存的信息应该是连通性。
线段树存的值表示:区间 \([l,r]\) 中其中第 \(l\) 列和第 \(r\) 列的四个点连通状态。即一个区间四个角上的点的连通状态。
具体存的是所有点两两的连通状态,可以采用状态压缩的方式。
为了方便,线段树的一个结点表示原图的两列。
细节:为了完成修改,叶子结点要额外存一个值表示的仅仅是两两点之间有边相连的状态,否则在四条都有的情况下删掉一条边可能构成最小连通子树,如果仅用是否连通表示将无法完成修改。
连通状态是可以合并的。
合并预处理即可,修改是单点修改。
查询时不能仅仅查一个区间,有可能从区间外部绕到同一列的另一个点,然后连通。所以将查询分成三段,\([1,l]\)\([l,r]\)\([r,n]\)其中最左和最右两段仅用于查询上下两个点是否可以从区间lr外的左或者右绕到。**
这题真正的难点在于快速把代码调对和查询线段树时要考虑上下绕的细节。
快速写对代码的要点有两个:
- 多写数据结构题的代码。
- 把线段树每个节点存的值的意义弄清楚,写在草稿纸上。分模块用函数完成编程。
有时候为了完成修改每个节点可能要额外存一个专门用来修改的值。
线段树优化DP
相关题目/资料: 游戏
在某个游戏中,你需要控制角色从某个 W × H 的网格的最下端走到最
上端。格子从下到上,从左到右编号,左下角为 (0, 0),右上角为 (W−1, H−1)。
你可以从 (a, 0) 开始,走到 (b, H − 1) 结束,其中 a, b 为任意在 0 到 W − 1
之间的整数。你的角色每次可以从 (x1, y) 走到 (x2, y + 1)。
你的角色的初始得分为 0。每次横向从 (x1, y) 走到 (x2, y + 1) 会使得
分减少 A ∗ |x1 − x2|;有一些特殊格子 (xi, yi),经过时得分会增加 zi(并不
保证 zi 非负,且此类格子经过时得分一定受到影响,不可避免)。
你希望最大化你的角色的得分。输出这个最大值(可能为负)。
1 ≤ N, W ≤ 60000,1 ≤ H ≤ 109,1 ≤ A ≤ 10, 0 ≤ |zi | ≤ 10000,0 ≤ xi < W,0 ≤ yi < H。
解法:
如果数据范围小很容易想到一个直接用一个暴力的DP解决。
\(DP_{a,i}\)表示从第一行任意位置到第i行的第a个位置,这个位置的得分已经计算的最大价值,不难列出状态转移方程。
\(DP_{a,i}=\underset{b}{\max} (DP_{b,j-1}-A*\left | b-a \right | +z)\)
可以发现如果直接暴力转移时间复杂度是\(O(hw^2)\)的。
技巧:每个阶段的所有状态构成一个区间的转移可以考虑用线段树优化。
一个用数学式子分析优化方法的技巧:(在草稿纸上可以写成这样非常方便)\(\underset{i}{\max}\)表示枚举 \(i\) 并且在与 \(i\) 相关的变量中取最大的。(\(\underset{i}{\sum}\)同理,枚举i把i相关的变量加起来。)
用规范的数学式子表示DP的转移,再化简数学式子(去掉绝对值,max等),往往可以发现一些性质,或者把与要枚举的变量相关的弄到了一起,可以很方便的采用数据结构维护从而优化转移时枚举变量的时间。
以\(\underset{i}{\max}\)为例,如果max后面的式子关于很多变量,那么我们要考虑优化掉枚举j的复杂度,要考虑化简式子,把max后面涉及到i的变量全部弄到一起。
常见的麻烦的,难以弄到一起的情况以及解决办法:
- 带绝对值,将i分类讨论即可。(如果不是i在绝对值中而是\(a_i\)那么\(a_i\)往往要在数据结构中作为下标处理)。
- 带max,min,考虑排序,按一定的顺序处理。
如果\(\sum\)在数学式子最外层就是累加,\(\max\)在最外层一般是RMQ问题。
化简式子,将绝对值去掉,并且把b无关的弄到max外面。

根据这个化简之后的式子,我们用线段树维护\(DP_{b,i-1}+A*b\)和\(DP_{b,i-1}+-A*b\)这两个值,然后a是已知的,转移方程变成了一个RMQ的问题。
区间查询,单点修改。
对于每个状态转移的复杂度\(O(w)->O(logw)\),总时间复杂度\(O(hwlogw)\)。
具体实现上,由于j阶段的转移只和上一个阶段有关,所以保留一个区间即可(用线段树存),把要修改的先存起来,整个阶段的所有转移完成了再统一修改。
线段树优化之后还是过不了,问题是冗余的状态和转移太多了。
首先,如果直接竖直到下一个阶段是不用花费得分的。如果没有特殊的带得分的点,那么不同阶段之间是不需要转移的,因为换列之后会造成额外花费,且没有任何收益,所有最优解一定不可能出现这种情况。
所以只在有特殊点的阶段转移即可,这样状态数从\(O(hw)->O(nw)\)还是过不了。
对于每一行,即一个有意义的阶段。本质上是特殊点才让这个阶段有意义。
我们不妨考虑如果只转移特殊点(减省冗余状态)是否能得到最优解,满足所有可能出现最优解的转移情况。
- 对于特殊点的值为正,最优解是要保证尽量经过更多这样的点的。所以我们只需要考虑怎么表示尽量经过它的情况。如果只转移它,那么到未来阶段的特殊点的转移是可以被这个点的状态完全包含的。因为这个点左右的普通点到未来特殊点的花费即\(A*\left | b-a \right |\)这个abs只与竖着的y坐标有关,所以只有普通点的列维护他的DP值没有意义,因为最优解只会收到特殊点的列的影响,并且未来的转移特殊列可以表示一切需要转移到新列的权值。即不存在每一列对于其他列有特殊权值的贡献,列与列之间的权值是确定并且均等的。
- 对于特殊点的值为负,那么其实最优解是要绕开他的。我们考虑如何表示绕开他的操作。即如果一个值为负的特殊点,他左右相邻两个格子没有特殊点,为了绕开他,我们人为的加上值为0的特殊点,实现绕开的情况,并且对未来转移没有影响,如果只考虑左或者右就会有影响。
所以只有特殊点才回转移,状态数优化到n的量级\(O(nlogw)\)可以AC。
这题是好题。
涉及到了化简转移的数学式子从而用线段树进行优化;减省DP冗余状态,优化时间复杂度;分正负讨论不同情况的精练状态下的转移是否能达到最优解可能出现的情况。
具体就是面对正的最优解希望更多的经过它,面对负的最优解希望绕开他,把这两种情况全部表示清楚不遗留就可以达到最优解的所有情况都不遗留并且状态精简。
减省状态的方式是考虑最优解收到哪些情况的影响,并且要根据影响类型的不同分情况处理,可能需要用到添加虚拟特殊点的技巧。
培养写和化简数学式子的习惯,便于优化。
线段树经典题
相关题目/资料: 最大子段和
输入n<=2e5,求最大子段和,要求可以为空(0),并且支持单点修改。
解法:
就想一个动态的区间DP,考虑一个结点要维护哪些值(根据题目最终要求的值可能有哪些情况得出),并且维护的这些值只与结点内部相关,小区间和并成大区间像区间DP一样思考即可。
相关题目/资料: 最大前缀和
输入n<=2e5,单点修改,区间查最大前缀和,允许为空(0)。
解法:
统计两个量是不难的,算区间的时候可以利用扫到的区间的顺序是从前往后的 这个性质来算,开全局变量,一遍维护前缀信息,一遍利用新扫到的点来算答案。
技巧:线段树区间查询有从前到后的性质,可以用这个性质计算涉及前缀信息结合单点信息统计答案的查询。
相关题目/资料: 区间修改等差数列
输入n<=2e5,两种操作,查[l,r]的和,将[l,r]每个数依次相加首项为1,公差为1的数。
解法:
法一:看到和差有关的,等差数列,或者差分是等差数列的数列,即和差有关的,都可以考虑用差分,因为等差序列的性质,可以保证一段等差序列修改的数仅仅只有两个,因为区间加同一个数,公差为0,所以可以转换为单点修改问题,进而用前缀和解决。
如果只考虑一阶差分,发现还要在差分序列上做区间修改,如果要求原序列的前缀和,那么还要在\(\Delta_i*i\)上进行等差数列修改,没有起到转化问题的目的。
考虑二阶差分,发现在二阶差分上仅为单点修改,考虑求原序列的前缀和如何用二阶差分表示。
\(\sum_{i}^{n}\sum_{j}^{i}\sum_{k}^{j}\lambda_k\)
需要把这个式子推成前缀和的形式。
然后就可以解决求和问题,并且还要同时维护\(\lambda_k*k,\lambda_k*k*k\)。
注意:对于一阶差分序列要修改的值在l...r+1,但是对于二阶差分序列,还要修改r+2。
为什么花了2.5小时才把式子推出来并AC?
- 不熟练推式子的技巧,推式子马虎出错。
- 没有注意到差分序列修改的细节。
收获了什么,遇到同类题有什么通用的技巧?
技巧:与差相关的序列都可以采用差分数组,等差->公差为0->单点。
本质上是公差为0的修改在它的差分序列上变成了单点修改。
这种差分序列转化问题的思想值得学习。
技巧:推式子技巧:如果想把两个sigma合并成一个大的(前提是小sigma的结束条件与大sigma的变量有关),那么就要找每一个元素在原来两个sigma中加了几次,把那个几次变成乘的系数即可。结论:有n个sigma叠加就会有一个关于最小变量的n-1次多项式,因为每多一个sigma其实就是多加了k个变量,在乘的概念中就升了一次。
有哪些注意的点?
推式子如果式子里面的sigma有系数那么最好一个系数一个系数来,不要出现多项式*多项式的情况,容易错。
对于原序列,如果有一些元素修改那么这些元素的下一个在差分序列中同样要修改,不要漏掉。
并且要注意不要用浮点数来替换longlong计算,容易丢精度,就算用了也要long double。
#include<bits/stdc++.h>
#define int long long
#define lb(x) (x&(-x))
using namespace std;
const int maxn=2e5+10;
int n,T,a[maxn],b[maxn][3];
void ad(int x,int v,int y){
while(x<=n){
b[x][y]+=v;
x+=lb(x);
}
}
int q(int x,int y){
int ret=0;
while(x>0){
ret+=b[x][y];
x-=lb(x);
}
return ret;
}
int cal(int x){
int ret=((x*x+x)*q(x,0)-q(x,2)+q(x,1))/2;
int rett=ret-(x+1)*q(x,1)+q(x,2)+(x+1)*q(x,0)-q(x,1);
return rett;
}
signed main(){
scanf("%lld%lld",&n,&T);
for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
for(int i=n;i>=2;i--) a[i]=a[i]-a[i-1];
for(int i=n;i>=2;i--) a[i]=a[i]-a[i-1];
for(int i=1;i<=n;i++) ad(i,a[i],0),ad(i,a[i]*i,1),ad(i,a[i]*i*i,2);
while(T--){
int op,l,r;scanf("%lld%lld%lld",&op,&l,&r);
if(op==1){
ad(l,1,0),ad(r+1,(l-r-2),0),ad(r+2,(r-l+1),0);
ad(l,l,1),ad(r+1,(l-r-2)*(r+1),1),ad(r+2,(r-l+1)*(r+2),1);
ad(l,l*l,2),ad(r+1,(l-r-2)*(r+1)*(r+1),2),ad(r+2,(r-l+1)*(r+2)*(r+2),2);
}
else printf("%lld\n",cal(r)-cal(l-1));
}
return 0;
}
法二:打标记。
由于等差数列的要素只有首项,公差,项数。
并且加多个等差数列可以直接合并,由于区间大小已知,所以标记记录首项,公差即可。
为什么3h才能AC?
调代码浪费过多时间。
没有想清楚累加等差数列具体是怎么加,要把之前的贡献之和给加上,并且左右区间如果是公差不为1的等差数列右区间首项要累加上做区间的和,而不是size。
如何加速做题:
把每种操作通过举例子想清楚,再写代码,而不是直接上来就写,举例子要考虑一般性的情况,不能太特殊。
收获是什么?
区间加可以合并的贡献可以直接用打标记的做法,并且记录要加的序列的必要的要素。
相关题目/资料: 查pos并删掉输出序列
题意如题。
输入n,a,pos两个序列,n<=2e5,输出第posi个并且删掉,保证输入能得到合法的输出。
技巧:线段树能借助维护的区间信息实现找满足某些条件的位置,即log实现单点查找,支持修改。(动态二分查找),维护的一般可以叶子是合法即为1不合法为0,区间加和维护。
0/1表示已删除/未删除的方法是常用的。要学会灵活使用
树状数组(BIT)
高效、简洁。线段树更灵活,可以解决更多问题如阶段是区间的DP问题的优化,用树状数组能解决的问题线段树一般都能解决。但是反过来不一定。
线段树的内核是子区间作用于父区间,BIT的内核和单点作用于区间。一次修改单点,把所有管理这个单点的区间全部修改,而不会进行区间与区间之间的更新操作。
BIT适合解决求前缀和(区间和)的问题。
考虑解决单点修改和求前缀和的问题。
将线段树优化一下,把所有右儿子去掉,因为右=父-左子。
树状数组的结构是没有右儿子的线段树。 这样每层的结点少了一半。
一些特点:
- 每一个值域内的值恰好被一个区间的右端点表示,包含这个值的右端点只在该区间。以一个值为右端点的区间恰好只有一个。
- 一个区间存的值的个数是lowbit(x)个,即\(c_i=a_{i-lowbit(i)+1}+...+a_i\)
- 下标从1开始。
操作一:求前缀和。
根据树状数组的定义可以发现,将\(c_n,c_{n-lowbit(n)}...c_0\)累加起来就是答案。
树状数组的定义中\(c_i\)一定包含\(a_i\)非常巧妙,使得这个问题可以通过\(i-lobit(i)\)变成一个规模更小并且一模一样的问题。
这样分段累加的思路有点像数位DP的思路。
操作二:单点修改。
首先\(c_i\)一定包含\(a_i\)并且根据定义,\(c_i\)是包含\(a_i\)里面最小的。那么同理如果我们能找到一个规模更大的,并且是更大规模里最小的且包含\(a_i\)的把他修改掉,那么问题规模变小就可以继续用同样的方法解决。
根据树状数组的定义可以推出,+lowbit可以到自己的父亲,满足上面的需求。不断+lowbit直到超过n即可。
根据树状数组的代码我们可以发现,超过n的我们根本访问不到,所以直接开n的大小即可,无需将n变成二进制数。
操作三:特殊的RMQ。(前缀)
树状数组不适合解决RMQ问题,但是特殊情况下也能正常解决RMQ问题。它相较于线段树不适合解决的原因是他减省了一般的结点,有一些信息再更新维护的时候查不到(在同一个线段树区间的其他点)。其实不是查不到,就是不能像线段树O(1)查左右子树那样查到,它要花费O(logn)的时间。
当单点修改对RMQ的影响值与修改之后的值可能有关时就可以不改变树状数组\(O(logn)\)的时间像求前缀和那样完成修改和查询,如果不只是与当前修改的有关,那么就要花\(O(logn^2)\)的时间把该节点的每个父亲的每个儿子都更新一遍。
即查min只能改小,查max只能改大。
相关题目/资料: 逆序对
求\(\underset{i}{\sum}\underset{j}{count}(a_j>a_i)\)
数学式子涉及到count相关的一般的技巧是把对于的值作为下标在树状数组中+1表示个数,根据需求可以转换为求前缀和的问题。
第一次没想出来的原因,不会列数学式子,对优化count的方法不熟悉。
枚举i,查下标在\(a_i\)之后的后缀和即可。
离散化用map太慢了TLE。
相关题目/资料: MooFest
输入x,v,n<=5e4。
求\(\underset{i}{\sum}\underset{j<i}{\sum}\max\{v_i,v_j\}\times |x_i − x_j |\)
首先,i的枚举顺序不固定对结果不影响。右边有两个难搞的东西,一个max一个绝对值。
按v从小到大排序枚举i即可解决max,现在考虑解决绝对值。
分类讨论把绝对值拆开。
\(\underset{i}{\sum}v_i\underset{j}{\sum}|x_i − x_j |\)。
考虑数据结构优化\(\underset{j}{\sum}|x_i − x_j |\)。
\(\underset{j<i \wedge x_j<x_i}{\sum}(x_i-x_j)+\underset{j<i \wedge x_j>x_i}{\sum}(x_j-x_i)\)
两个东西本质上一样,考虑加号左边的就行。
\(\underset{j<i \wedge x_j<x_i}{\sum}(x_i-x_j)\)
\(x_i\underset{j<i \wedge x_j<x_i}{\sum}1+\underset{j<i \wedge x_j<x_i}{\sum}x_j\)
\(x_i\)与枚举的j无关提到sigma外面,变成逆序对问题,用一个树状数组在对应位置+1即可。
\(x_j\)就用另外一个树状数组,每次更新就把对应位置\(x_i\)加上i即可。
sigma下面的是下标条件,sigma后面的是要维护的数。
相关题目/资料: Intervals
多组数据。
第一行的一个整数T表示数据个数。对于每组数据,第一行包含一个整数\(n\)(\(1\)<=\(n\)<=\(50000\))表示区间数。以下\(n\)行描述区间。输入的第(i+1)行包含三个整数ai,bi,ci,由空格分开。其中0<=ai<=bi<=50000,1<=ci<=bi-ai+1。
对于每组数据,输出一个对于n个区间[ai,bi] 至少取ci个不同整数的数的总个数。
解法:
直接设一个前缀和,跑差分约束也行。
经典区间贪心问题。考虑c=1,那么选最右边的点即可。
贪心策略:右端点排序,找到第一个未满足的区间,选尽量靠右的点。
证明,如果最优解中没有选最右的,调成最右的仍满足题意。调整到最后即为贪心策略。
实现:
法一:线段树,每个点存这个区间最右的还未选的点。还要存一个区间有多少个点已经选了。
法二:并查集+树状数组。
并查集实现每个格子只扫一次,维护pre指针表示这个格子所在的集合(开始时只有自己,选完之后要合并。)的左边最靠右的未选格子的位置。
数组数组用来查这个区间内有多少个点已经选了。
直接以值域为下标,如果选了就+1,查区间和即可。
一开始没想到的原因:没有转换角度,考虑一个个区间处理,如果这样考虑问题就变成看一个区间内有多少个已经选了的,查区间和很容易解决。
陷入了如何用树状数组查第一个还没满足的区间的误区。
线段树是用来维护每个点的信息的,而不容易找满足一些条件的区间区间,但是找某些区间中的点很容易。
#include<bits/stdc++.h>
#define lb(x) (x&(-x))
using namespace std;
const int maxn=50010;
struct node{
int l,r,c;
}a[maxn];
int n,T,b[maxn],fa[maxn],pre[maxn];
bool vis[maxn];
bool cmp(node x,node y){
return x.r<y.r;
}
void ad(int x){
while(x<50001){
b[x]++;
x+=lb(x);
}
}
int q(int x){
int ret=0;
while(x>0){
ret+=b[x];
x-=lb(x);
}
return ret;
}
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
void Union(int x,int y){
x=find(x),y=find(y);
if(x==y) return;
fa[x]=y;
}
int main(){
scanf("%d",&T);
while(T--){
int ans=0;
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d%d%d",&a[i].l,&a[i].r,&a[i].c),a[i].l++,a[i].r++;
sort(a+1,a+1+n,cmp);
for(int i=1;i<50001;i++) fa[i]=i,pre[i]=i,vis[i]=0,b[i]=0;
for(int i=1;i<=n;i++){
int t=q(a[i].r)-q(a[i].l-1);
if(t>=a[i].c) continue;
for(int j=pre[find(a[i].r)];j;j=pre[find(j-1)])
if(!vis[j]){
vis[j]=1,t++,ans++,ad(j);
pre[j]=pre[find(j-1)];
if(vis[j+1]) Union(j,j+1),pre[find(j+1)]=pre[j];
if(vis[j-1]) Union(j,j-1);
if(t>=a[i].c) break;
}
}
printf("%d\n",ans);
}
return 0;
}
相关题目/资料: 和谐子序列计数问题
已知数列 \(A_1,\ A_2,\ ...,\ A_N\) 和整数 \(K\)。
\(B\) 是 \(A\) 的子序列(可以不连续),相邻任意 \(B\) 的数的差的绝对值都小于等于 \(K\)。
输出数列 \(B\) 长度的最大值。
n<=5e5
解法:
很容易想到朴素的DP解法:
\(DP_i\)以\(a_i\)结尾的和谐序列的数量。
\(DP_i=1+\underset{j<i \wedge |a_j-a_i<=k|}{\sum} DP_j\)
直接枚举j转移是平方。
可以发现\(a_j\)的取值为\([a_i-k,a_i+k]\)。
维护一个树状数组,下标时值域,元素i表示当前的前i个DP值中结尾的数为i的和。
直接查,可以log转移。
如果值域很大要将a离散化,二分找区间端点的位置。或者将\(a_i,a_i-k,a_i+k\)全部都离散化。
核心是搞清楚每个节点维护的信息是什么。如何设计每个节点的信息达到高效找到满足条件的DP值。
技巧:树状数组可以优化转移是将一段区间的DP值累加的DP问题。
相关题目/资料: A Sequence of Numbers
给定一个包含 \(n\) 个整数的序列(每个整数在区间 \([0,2^{16}-1]\) 内),和 \(p\) 个操作。为解答这个问题,你需要按以下要求执行这些操作。
你需要执行的操作共有两种:
-
修改 - 给定一个非负整数 \(T\),你需要把序列中的每个数都增加 \(T\)。如果某个数增加 \(T\) 之后比 \(2^{16}-1\) 大,那么你需要将其对 \(2^{16}\) 取余数。
-
查询 - 给定一个非负整数 \(T\),询问序列里有多少个数跟 \(2^T\) 的按位与的结果非零。(T<=15)
为了简便起见,你只需要输出所有询问的答案之 和(\(<10^{10}\))。
解法:
提高组第二题难度。
简化问题: 修改对于整体,直接拿一个变量记着就行。查询,只差2^15以内,所以把比T大的位置都去掉,因为不影响结果。
技巧:对结果不影响的条件应该去掉,起到简化问题的作用,否则问题可能过于复杂难以解决。
即累加和为S。
题目要求的是,满足\((S+a_i) \& 2^t==1\)的个数。
那么只保留\(a_i,S\)的后t位,然后根据S的第t为是0还是1,决定满足条件的是否要进位可以讨论出,\(a_i\)的取值范围,因为2^t,S是已经知道的定值,讨论后面的位置进位需要达到什么条件即可。
定义16个树状数组,表示保留后i为的值a_i的个数,根据范围求个数转换为求前缀和。
讨论S+a_i是进位或者不进位到t+1位,这样可以穷尽所有情况,然后根据要求求a_i的取值,这是一种简单且巧妙的做法。
还有更复杂的做法,就是记两类树状数组,一类表示最高位为1,一类表示最高位为0,然后根据S的第t位讨论,这种讨论的就比较多,但是也可以做,是一种比较笨的方法,因为分类更多的类。
技巧:分类标准不一定要与题目要求相关,只需要穷尽所有情况,并且类少好分即可,根据不同的分类标准同样可以求题目要求的范围。
核心和简化问题和根据题目要求看\(a_i\)的取值范围,发现连续的性质,用树状数组维护个数即可。
技巧:树状数组的常见应用(知取值是连续的一段区间,求个数):将值作为下标,可以很方便的维护查一个取值区间的个数,转换为前缀和,修改就对应位置+1。
一开始没想出来的原因:简化问题这一步没做好,简化问题要简化到满足题目需要的条件并且剩下的要素尽可能少,即找到重复必要条件,否则就会因条件的冗杂而做不出来。
#include<bits/stdc++.h>
#define int long long
#define lb(x) (x&(-x))
using namespace std;
const int maxn=1e5+10;
const int maxa=1<<16;
int n,a[maxn],s,ans,b[(1<<16)+10][17],c[(1<<16)+10][17],m0,m1,cas;
void adb(int x,int y){
while(x<=maxa){
b[x][y]++;
x+=lb(x);
}
}
void adc(int x,int y){
while(x<=maxa){
c[x][y]++;
x+=lb(x);
}
}
int qb(int x,int y){
int ret=0;
while(x>0){
ret+=b[x][y];
x-=lb(x);
}
return ret;
}
int qc(int x,int y){
int ret=0;
while(x>0){
ret+=c[x][y];
x-=lb(x);
}
return ret;
}
signed main(){
while(~scanf("%lld",&n)){
if(n==-1) return 0;
ans=s=m0=m1=0;
for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
memset(b,0,sizeof(b)),memset(c,0,sizeof(c));
for(int i=0;i<=16;i++){
for(int j=1;j<=n;j++){
if(i==0){
if(a[j]&1) m1++;
else m0++;
continue;
}
int t=0;
t=a[j]%(1<<i);
if(a[j]&(1<<i)) adc(t+1,i);
else adb(t+1,i);
}
}
char ch;int t;
while(cin>>ch){
if(ch=='E') break;
scanf("%lld",&t);
if(ch=='C') s=(s+t)%(1<<17);
else{
if(t==0){
if(s&1) ans+=m0;
else ans+=m1;
}
else{
int ts=s%(1<<t);
if(s&(1<<t)){
ans+=qc(maxa,t)-qc((1<<t)-ts,t);
ans+=qb((1<<t)-ts,t);
}
else{
ans+=qb(maxa,t)-qb((1<<t)-ts,t);
ans+=qc((1<<t)-ts,t);
}
}
}
}
printf("Case %lld: %lld\n",++cas,ans);
}
return 0;
}
第九章 LCA:树上路径问题的基本支点
本章定位:掌握最近公共祖先、欧拉序转 RMQ、倍增等树上基础工具。
题目识别信号:题目在树上询问两点路径、祖先关系、距离、子树或路径合并信息。
学习方法:树上问题先把根、深度、父亲、欧拉序等基础信息预处理好,再看询问需要哪种信息。
复盘重点:复盘时重点写:树如何建、根如何选、深度如何处理、LCA 后路径信息如何组合。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是 LCA 的欧拉序/RMQ 思路与树上路径问题。阅读时注意把“树上路径”转化成“若干段可维护区间”。
欧拉序转RMQ
方法欧拉序:dfs出来的序列,包括回溯的情况,点允许重复出现多次。
欧拉序长度2m+1,一般问题中往往只会用到2m,最后到根可以不用。
LCA转换为欧拉序中第一次到两个结点之间深度最小的点。
设pos_u为第一次出现的位置,pos_u<pos_v。
证明:树中两点仅有唯一路径。u到v的路径一定经过LCA。那么pos_u后面一定会到的LCA(u,v),才会到pos_v,因为是dfs搜索,如果这中间出现了深度最小的不是LCA,那么一定是从LCA走到它的,如果LCA都回溯到上面了那么是不可能再到v的与条件矛盾,原结论成立。
树上(路径)问题转化为序列问题的利器。
相关题目/资料: poj2763
给定一颗树,n<=2e5,执行两种操作:
- 从s->o点,输出路径长。
- 修改某条边的长度。
解法:
欧拉序,深度小的点到深度大的正向边变权为正,回溯边为负。
在记欧拉序的同时记录val_i,表示欧拉序中第i个到第i+1个的距离。
那么这样很容易求得深度小的点到深度的点的路径长度。
那么只需要求LCA分别到两个点的距离加起来即可。
修改边就单点修改即可。
学到了什么?
欧拉序的本质是所有dfs经过路径的点的记录,可以把路径转换到序列上解决问题。
面对有对称性的问题可以采用正负数的思想抵消掉不需要的部分。
为什么快?把一直不变的dfs相对顺序给记录下来了,并且用数据结构进行维护。
为什么没想出来?
一开始的思路是找欧拉序中LCA到u后面最近的LCA,这样所有边算了两次,并且是一来一回,但实际上实现不了,而且是错的,因为别的分叉的贡献也会被记录进去。
本质上是没有掌握好正负抵消无需的量的思想。
细节?加速写代码?
欧拉序求一定是从LCA到下面的点,而不是直接取一段,因为直接取一段的前半段到LCA的值是负的与需要产生的正贡献不符。
线段树把要查询的区间范围写到函数参数里面,不然容易乱。
但是从深度小到深度大由于深度大的一定是第一次到,所以查询可以保证后半段回溯的负贡献不计算,其他全部无用的值可以抵消。
这种利用记录dfs序列将树上问题转化为序列上的问题的思想还用于依赖背包等问题。
#include<cstdio>
#include<map>
#define int long long
using namespace std;
const int maxn=262144+20;
int elx[maxn],n,q,s,head[maxn],cnt=2,tr[maxn*2],pos[maxn],ct,val[maxn],id[maxn],d[maxn],mn[maxn*2],lca[maxn*2],lc,mnn;
bool vis[maxn],zf[maxn];
struct edge{
int to,nxt,w;
}e[maxn<<1];
void add(int u,int v,int w){
e[cnt].to=v,e[cnt].nxt=head[u],e[cnt].w=w;head[u]=cnt++;
}
void dfs(int u,int fa,int dp){
d[u]=dp;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
elx[++ct]=u;
if(!vis[u]) vis[u]=1,pos[u]=ct;
val[ct]=e[i].w,id[i]=ct,zf[ct]=1;
dfs(v,u,dp+1);
elx[++ct]=v;
if(!vis[v]) vis[v]=1,pos[v]=ct;
val[ct]=-e[i].w,id[i^1]=ct,zf[ct]=0;
}
}
void bui(int t,int l,int r){
if(l==r){
tr[t]=val[l],mn[t]=d[elx[l]],lca[t]=elx[l];
return;
}
int mid=(l+r)>>1;
bui(t<<1,l,mid);
bui(t<<1|1,mid+1,r);
tr[t]=tr[t<<1]+tr[t<<1|1];
if(mn[t<<1]<mn[t<<1|1]) mn[t]=mn[t<<1],lca[t]=lca[t<<1];
else mn[t]=mn[t<<1|1],lca[t]=lca[t<<1|1];
}
void upd(int t,int v){
t+=ct-1;tr[t]=v;
t>>=1;
while(t>=1){
tr[t]=tr[t<<1]+tr[t<<1|1];
t>>=1;
}
}
int que(int t,int l,int r,int x,int y){
if(x>y) return 0;
if(l>=x&&r<=y) return tr[t];
int mid=(l+r)>>1,ret=0;
if(x<=mid) ret+=que(t<<1,l,mid,x,y);
if(y>mid) ret+=que(t<<1|1,mid+1,r,x,y);
return ret;
}
void qlca(int t,int l,int r,int x,int y){
if(l>=x&&r<=y){
if(mn[t]<mnn) mnn=mn[t],lc=lca[t];
return;
}
int mid=(l+r)>>1;
if(x<=mid) qlca(t<<1,l,mid,x,y);
if(y>mid) qlca(t<<1|1,mid+1,r,x,y);
}
int wk(int ss,int tt){
if(ss==tt) return 0;
if(pos[ss]>pos[tt]) swap(ss,tt);
mnn=0x3f3f3f3f,lc=0;
qlca(1,1,ct,pos[ss],pos[tt]);
return que(1,1,ct,pos[lc],pos[ss]-1)+que(1,1,ct,pos[lc],pos[tt]-1);
}
signed main(){
scanf("%lld%lld%lld",&n,&q,&s);
for(int i=1;i<n;i++){
int u,v,w;scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w),add(v,u,w);
}
dfs(s,-1,1);
int t=0;while((1<<t)<ct) t++;ct=1<<t;
bui(1,1,ct);
while(q--){
int op;scanf("%lld",&op);
if(op==0){
int o;scanf("%lld",&o);
printf("%lld\n",wk(s,o));
s=o;
}
else{
int o,w;scanf("%lld%lld",&o,&w);
o<<=1;
if(zf[id[o]]) upd(id[o],w),upd(id[o^1],-w);
else upd(id[o],-w),upd(id[o^1],w);
}
}
return 0;
}
第十章 数学:把计数、组合与性质转化成可计算表达
本章定位:掌握组合数学、数论与常见数学化转化。
题目识别信号:题目要求计数、取模、组合方案、排列选择、递推、概率期望或需要证明某个性质。
学习方法:数学题不要只盯公式。先把对象、限制、等价转化、重复计数和边界条件讲清楚,再上公式。
复盘重点:复盘时重点写:公式从哪里来、是否重复/漏计、模数条件、边界样例、能否用小数据验证。
下面进入本章的题目、题解、模板和细节整理。正文中保留了原始做题记录里的题目链接、代码和技巧总结,但在标题层级和部分表述上做了统一整理,方便后续继续扩展。
本节导读:本节重点是组合数学、计数、前缀和、乘法原理等数学化处理。竞赛数学题要特别重视不重不漏、边界与对拍。
组合数学
计数问题
常见方法:DP,组合数学。
相关题目/资料: NOIP2022T1种花
利用乘法原理+前缀和计算即可。
有位置的加加减减的题,除非特别有把握,不然要对拍。
而求连续一段0或者1的问题,要找连续一段的某个端点,用DP求连续的长度再算位置是比直接求位置细节更少,更好写,更不容易写错的选择。
前缀和全都写成一个方向的好,不要一个下减上一个上减下之类的。
#include<cstdio>
#include<iostream>
#include<cstring>
#define int long long
using namespace std;
const int maxn=1010;
const int mod=998244353;
int a[maxn][maxn],nxh[maxn][maxn],nxl[maxn][maxn],sum[maxn][maxn],n,m,c,f,T,id,sumf,sumc,summ[maxn][maxn];
signed main(){
scanf("%lld%lld",&T,&id);
while(T--){
memset(a,0,sizeof(a)),memset(nxh,0,sizeof(nxh)),memset(nxl,0,sizeof(nxl)),memset(sum,0,sizeof(sum)),memset(summ,0,sizeof(summ));
sumf=sumc=0;
scanf("%lld%lld%lld%lld",&n,&m,&c,&f);
memset(a,0,sizeof(a));
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
char ch;cin>>ch;
a[i][j]=ch-'0';
}
for(int i=1;i<=n;i++)
for(int j=m;j>=1;j--){
nxh[i][j]=a[i][j]?0:nxh[i][j+1]+1;
}
for(int j=1;j<=m;j++)
for(int i=n;i>=1;i--){
nxl[i][j]=a[i][j]?0:nxl[i+1][j]+1;
}
for(int j=1;j<=m;j++)
for(int i=1;i<=n;i++){
if(a[i][j]) sum[i][j]=sum[i-1][j];
else sum[i][j]=(sum[i-1][j]+nxh[i][j]-1)%mod;
}
for(int j=1;j<=m;j++)
for(int i=1;i<=n;i++){
if(a[i][j]) summ[i][j]=summ[i-1][j];
else summ[i][j]=(summ[i-1][j]+(nxh[i][j]-1)*(nxl[i][j]-1)%mod)%mod;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
if(a[i][j]) continue;
if(nxl[i][j]>=3) sumc=(sumc+(sum[i+nxl[i][j]-1][j]-sum[i+1][j])*(nxh[i][j]-1)%mod)%mod;
if(nxl[i][j]>=3) sumf=(sumf+(summ[i+nxl[i][j]-1][j]-summ[i+1][j])*(nxh[i][j]-1)%mod)%mod;
}
printf("%lld %lld\n",sumc*c,sumf*f);
}
return 0;
}
结语:资料的价值,最终要落实到你的进步上
这份手册内容很多,但它真正的价值,不在于“看起来很全”,而在于你能不能从里面拿走一个又一个具体的进步点。
你今天看懂一个高精度的细节,比赛时少一次 WA;你今天真正理解一个贪心证明,下次看到排序类贪心不再凭感觉;你今天把一道 DP 题的状态设计写清楚,以后遇到同类题就更容易找到入口。
竞赛能力就是这样长出来的:不是突然开窍,而是不断把模糊的问题变清楚,把清楚的东西变熟练,把熟练的东西变成临场稳定发挥。
如果这份资料对你有帮助,欢迎关注公众号 「进步有法」。我会持续、免费更新初高中学习方法、信息学竞赛经验、题单资料和复盘方法。
也可以添加微信:17875487211。建议备注:OI资料。后续如果有资料更新、题单补充、分享课或答疑安排,我也会优先同步给需要的同学。
我希望这份资料不是你收藏夹里又一份再也不会打开的文件,而是真的能在你某一次卡住、某一道题想不到、某一场比赛前复习时,帮你解决一个具体问题。
进步不是一句口号。进步应该有方法,也应该被你一次次做出来。
浙公网安备 33010602011771号