| 课程编号 | 课程主题 | 核心算法/数据结构 | 难度等级 | 关联考核模块 | 关键考点 |
|---|---|---|---|---|---|
| 01 | 理解递归函数 | 递归、DFS、汉诺塔 | ★★☆☆☆ | 搜索基础 | 递归思维、分治思想 |
| 02 | 时间复杂度与排序 | 排序算法、复杂度分析 | ★☆☆☆☆ | 算法基础 | 复杂度分析、排序优化 |
| 03 | STL库的应用 | vector、map、priority_queue | ★★☆☆☆ | 数据结构基础 | STL熟练度、容器选择 |
| 04 | 网格寻路与BFS | BFS、队列、路径搜索 | ★★★☆☆ | 图论搜索 | BFS模板、状态表示 |
| 05 | 图论与最短路算法 | Dijkstra、负环、差分约束 | ★★★★☆ | 图论基础 | 最短路建模、算法选择 |
| 06 | 听说大家喜欢数学题 | 矩阵快速幂、逆元、扩展欧几里得 | ★★★★☆ | 数学基础 | 数论公式、快速幂 |
| 07 | 神奇的树状数组 | BIT、差分、逆序对 | ★★★☆☆ | 高级数据结构 | 区间查询、单点更新 |
| 08 | 听说大家都学过贪心 | 排序贪心、后悔贪心 | ★★★☆☆ | 贪心策略 | 贪心选择、证明 |
| 09 | 二分枚举与按位枚举 | 二分答案、按位枚举 | ★★★☆☆ | 高效枚举 | 二分模板、状态压缩 |
| 10 | 背包九讲 | 01背包、完全背包、多重背包 | ★★★★☆ | 动态规划 | 背包模型、状态优化 |
| 11 | 暴力枚举的艺术 | 回溯、DFS剪枝、双向搜索 | ★★★☆☆ | 枚举优化 | 剪枝技巧、状态压缩 |
| 12 | 经典动态规划问题 | 线性DP、环形DP、股票问题 | ★★★★☆ | 动态规划 | 状态设计、转移方程 |
| 13 | 区间统计问题研究 | 线段树、单调队列、离散化 | ★★★★☆ | 区间算法 | 区间查询、离线处理 |
| 14 | 筛法只能求质数? | 欧拉筛、约数个数、欧拉函数 | ★★★★☆ | 数论算法 | 筛法优化、积性函数 |
| 15 | 数论分块 | 整除分块、下取整求和 | ★★★★★ | 数学优化 | 分块思想、公式推导 |
| 16 | 贡献思维和单调栈 | 单调栈、贡献法 | ★★★★☆ | 单调结构 | 贡献计算、单调栈模板 |
| 17 | 如何设计你的状态 | 状态DP、状态压缩 | ★★★★☆ | 动态规划 | 状态设计、压缩技巧 |
| 18 | 尺取法和双指针 | 滑动窗口、同向双指针 | ★★★☆☆ | 双指针 | 窗口维护、指针移动 |
| 19 | 神奇的根号算法 | 数论分块、莫队算法 | ★★★★★ | 分块算法 | 根号分治、复杂度平衡 |
| 20 | 前缀和优化技巧 | 前缀和、哈希表、差分 | ★★★☆☆ | 前缀优化 | 区间和转换、哈希优化 |
| 21 | 最小生成树与并查集 | Kruskal、并查集、最小生成树 | ★★★★☆ | 图论算法 | 连通性判断、贪心选择 |
核心概念:递归三要素、记忆化递归、分治思想
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1209 | 斐波那契数列 | 递归/记忆化 | 递归+记忆化,避免重复计算 | ★☆☆☆☆ |
| NC22227 | 约瑟夫环 | 递归公式 | ★★☆☆☆ | |
| LS1012 | 约瑟夫环2 | 递归/迭代 | 数学公式推导 | ★★☆☆☆ |
| LS1028 | 汉诺塔问题 | 递归分治 | move(n,A,B,C) = move(n-1,A,C,B) + move(1,A,B,C) + move(n-1,C,B,A) | ★★☆☆☆ |
| LS1117 | 滑雪 | 记忆化搜索 | DFS+记忆化, | ★★★☆☆ |
| LS1082 | 01背包 | 递归/记忆化 | ★★★☆☆ | |
| LS1023 | 计算乘方 | 快速幂递归 | pow(a,b) = b%2==0? pow( | ★★☆☆☆ |
| LS1025 | 最大公约数 | 递归辗转相除 | gcd(a,b)=gcd(b,a%b) | ★☆☆☆☆ |
| LS1083 | 完全背包 | 递归/记忆化 | dfs(i,c)=max(dfs(i-1,c), dfs(i,c-w[i])+v[i]) | ★★★☆☆ |
| LS1024 | 计算乘方和 | 递归分治 | sum_pow(a,b)= b%2==0 ? (1+pow(a,b/2))*sum_pow(a,b/2-1): pow(a,b)+sum_pow(a,b-1) | ★★★☆☆ |
核心概念:复杂度分析、排序算法比较、自定义排序
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1030 | 比较排序算法 | 排序复杂度 | 分析不同排序算法时间复杂度 | ★☆☆☆☆ |
| LS1212 | 自定义排序 | 排序规则 | 自定义cmp函数,lambda表达式 | ★★☆☆☆ |
| LS1031 | 排序后输出位置 | 稳定排序 | 记录原始位置,按值排序后输出索引 | ★★☆☆☆ |
| LS1032 | 求两数之和的最大值 | 排序贪心 | 排序后两端取数,复杂度分析 | ★★☆☆☆ |
核心概念:STL容器选择、算法函数使用、性能分析
| 题目编号 | 题目名称 | 数据结构 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1033 | 坐标排序 | vector+sort | 自定义排序规则 | ★☆☆☆☆ |
| LS1034 | 去重后第k小整数 | set/排序 | set自动去重排序,或sort+unique | ★★☆☆☆ |
| LT3556 | 质数字符串 | string操作 | 字符串查找、质数判断 | ★★☆☆☆ |
| LS1038 | 二维vector | 嵌套容器 | vector<vector | ★★☆☆☆ |
| LS1039 | 多个priority_queue | 优先队列 | 大顶堆、小顶堆应用 | ★★☆☆☆ |
| LS1035 | 上网统计 | map统计 | map<string, int>计数 | ★★☆☆☆ |
| LS1037 | 有序表的最小和 | 多路归并 | priority_queue维护k路最小元素 | ★★★☆☆ |
| LS1040 | 数据流中的众数 | map统计 | 实时更新频率,维护当前众数 | ★★★☆☆ |
| LS1036 | 出题法则 | 复杂排序 | 多关键字排序,自定义比较 | ★★★☆☆ |
| LS1041 | 数据流中的中位数 | 双堆维护 | 大顶堆存较小一半,小顶堆存较大一半 | ★★★★☆ |
核心概念:BFS模板、状态表示、路径记录
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| B3616 | 【模板】队列 | 队列基础 | STL queue基本操作 | ★☆☆☆☆ |
| B3625 | 能否通过迷宫 | BFS基础 | BFS判断连通性 | ★★☆☆☆ |
| P1443 | 马的遍历 | BFS最短路 | 8方向BFS,记录步数 | ★★☆☆☆ |
| P1135 | 奇怪的电梯 | BFS状态 | 状态=(楼层),BFS求最少按键次数 | ★★☆☆☆ |
| P1451 | 细胞 | BFS连通块 | 统计连通块数量 | ★★☆☆☆ |
| P1162 | 填涂颜色 | BFS染色 | 从边界BFS,区分内外 | ★★★☆☆ |
| LS1216 | 迷宫寻路 | BFS+状态 | 状态=(位置),BFS求最短路径 | ★★★☆☆ |
| P1649 | 最少拐弯次数 | BFS方向 | 状态=(位置,方向),记录拐弯次数 | ★★★☆☆ |
| LS1217 | 推箱子1 | BFS+状态 | 状态=(人位置,箱子位置),双重BFS | ★★★★☆ |
| LS1218 | 推箱子2 | BFS+复杂状态 | 状态压缩,多个箱子 | ★★★★☆ |
| P1825 | 传送迷宫 | BFS+传送门 | 传送门特殊处理,记录传送状态 | ★★★★☆ |
| D0326 | 混水摸鱼 | BFS+多目标 | 多源BFS,计算最短距离 | ★★★☆☆ |
| B3656 | 【模板】双端队列 | 数据结构 | deque基本操作 | ★★☆☆☆ |
| P4554 | 小明的游戏 | BFS+不同代价 | 0-1 BFS或Dijkstra | ★★★☆☆ |
核心概念:Dijkstra、负环检测、差分约束
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1219 | 如何封装我的算法 | 代码封装 | 函数封装,提高复用性 | ★★☆☆☆ |
| LS1039 | 多个priority_queue | 堆优化 | Dijkstra的堆优化实现 | ★★☆☆☆ |
| P4779 | 【模板】单源最短路径 | Dijkstra | 堆优化Dijkstra模板 | ★★★☆☆ |
| P3385 | 【模板】负环 | SPFA/Bellman | SPFA判负环,入队次数>n | ★★★★☆ |
| P4568 | 飞行路线 | 分层图Dijkstra | 状态=(节点,使用次数),建k+1层图 | ★★★★☆ |
| LS1095 | 旅游巴士 | 最短路+时间限制 | Dijkstra+时间窗口 | ★★★★☆ |
| P5960 | 【模板】差分约束 | SPFA/不等式 | 将不等式转化为边,求最长路/最短路 | ★★★★☆ |
| P1629 | 邮递员送信 | 往返最短路 | 正向+反向图,两次Dijkstra | ★★★☆☆ |
| LS1108 | 聚会 | 多源最短路 | 从多个点出发的最短路 | ★★★☆☆ |
核心概念:矩阵快速幂、逆元、扩展欧几里得
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1156 | 斐波那契数列_矩阵加速 | 矩阵快速幂 | [[1,1],[1,0]]^n | ★★★★☆ |
| LS1161 | 斐波那契数列_更复杂 | 矩阵扩展 | 更高阶递推的矩阵表示 | ★★★★☆ |
| LS1220 | 扩展欧几里得算法 | 扩展欧几里得 | ax+by=gcd(a,b)求解 | ★★★★☆ |
| LS1024 | 计算乘方和 | 快速幂+等比求和 | 分治求等比数列和 | ★★★☆☆ |
核心概念:BIT单点更新区间查询、差分、逆序对
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS0010 | 【课件】神奇的树状数组 | BIT模板 | 树状数组基本原理和操作 | ★★☆☆☆ |
| P3374 | 【模板】单点更新,区间求和 | BIT基础 | add(x,v), sum(r)-sum(l-1) | ★★☆☆☆ |
| LS1104 | 差分与前缀和 | 差分数组 | 区间更新,单点查询 | ★★★☆☆ |
| P3368 | 【模板】区间更新,单点求和 | BIT+差分 | 维护差分数组,单点查询即前缀和 | ★★★☆☆ |
| P3372 | 【模板】区间更新,区间求和 | BIT+差分扩展 | 维护两个BIT:B1[i], B2[i] | ★★★★☆ |
| P1908 | 逆序对 | BIT+离散化 | 从右向左扫描,统计比当前小的个数 | ★★★☆☆ |
核心概念:线段覆盖、排序贪心、后悔贪心
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1058 | 看电影 | 线段覆盖 | 按结束时间排序,贪心选择 | ★★☆☆☆ |
| LS1060 | 线段覆盖 | 区间选择 | 按右端点排序,不相交则选择 | ★★★☆☆ |
| LS1061 | 排队难题 | 排序贪心 | 调整顺序使总等待时间最小 | ★★★☆☆ |
| LS1062 | 拼接字符串 | 字典序贪心 | 自定义排序:a+b<b+a则a在前 | ★★★☆☆ |
| LS1197 | 后悔贪心 | 反悔贪心 | 优先队列维护可选集合 | ★★★★☆ |
| LS1064 | 逛超市_简单 | 贪心选择 | 按性价比排序选择 | ★★★☆☆ |
| LS1065 | 逛超市_困难 | 后悔贪心 | 先买再后悔替换 | ★★★★☆ |
| LS1059 | 剪刀石头布 | 策略贪心 | 根据对手历史选择最优策略 | ★★★☆☆ |
| LS1066 | 最小差值 | 排序贪心 | 排序后取相邻差最小值 | ★★☆☆☆ |
| LS1063 | 添加最少硬币 | 贪心构造 | 确保1~x都能表示,添加x+1 | ★★★☆☆ |
核心概念:二分答案、按位枚举、最大化最小值
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1072 | 分割数组的最大值 | 二分答案 | 最小化最大值,贪心检查可行性 | ★★★☆☆ |
| LS1074 | 分割数组的最小值 | 二分答案 | 最大化最小值,贪心检查 | ★★★☆☆ |
| LS1073 | 查找元素位置 | 二分查找 | lower_bound, upper_bound | ★★☆☆☆ |
| LS1079 | 导弹拦截 | 贪心+二分 | 最长不升子序列,O(nlogn) | ★★★★☆ |
| LS1075 | 青蛙过河 | 二分答案+贪心 | 检查能否跳过,维护可达位置 | ★★★★☆ |
| P13822 | 白露为霜 | 二分查找 | 在有序数组中查找特定条件 | ★★★☆☆ |
| LS1077 | 数的三次方根 | 浮点数二分 | 二分求立方根,控制精度 | ★★☆☆☆ |
| LS1078 | 最大化平均值 | 二分答案+转化 | 分数规划,检查∑(ai-x*bi)≥0 | ★★★★☆ |
| P12733 | 磨合 | 二分答案 | 最小化最大值问题 | ★★★☆☆ |
| P4377 | Talent Show G | 二分答案+背包 | 分数规划,0-1分数背包 | ★★★★☆ |
核心概念:01背包、完全背包、多重背包、分组背包
| 题目编号 | 题目名称 | 背包类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS0008 | 【课件】背包九讲 | 综合模板 | 各类背包问题模板 | ★★★☆☆ |
| LS1082 | 01背包 | 01背包 | dp[j] = max(dp[j],dp[j-w]+v)逆序 | ★★☆☆☆ |
| LS1232 | 01背包之2 | 01背包变体 | 容量恰好为C的最大价值 | ★★★☆☆ |
| LS1083 | 完全背包 | 完全背包 | dp[j] = max(dp[j],dp[j-w]+v)顺序 | ★★☆☆☆ |
| LS1084 | 多重背包 | 多重背包 | 二进制拆分优化 | ★★★☆☆ |
| LS1085 | 混合背包 | 混合背包 | 分类处理,01逆序,完全顺序 | ★★★★☆ |
| LS1086 | 二维费用背包 | 二维背包 | dp[j][k]双重循环 | ★★★☆☆ |
| LS1087 | 分组背包 | 分组背包 | 每组最多选一个,组内循环 | ★★★★☆ |
| LS1088 | 有依赖的背包 | 树形背包 | 树形DP,后序遍历 | ★★★★★ |
| LS1093 | 求背包的具体方案 | 方案输出 | 记录转移路径,逆向推导 | ★★★★☆ |
| LS1229 | 受限的数据 | 背包优化 | 根据数据范围选择算法 | ★★★★☆ |
核心概念:回溯、剪枝、状态压缩、双向搜索
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1133 | 部分和问题 | DFS回溯 | 每个数选或不选 | ★★☆☆☆ |
| LS1129 | 排列数字 | 全排列 | DFS回溯,vis标记 | ★★☆☆☆ |
| LS1130 | 组合数字 | 组合枚举 | DFS回溯,限制长度 | ★★☆☆☆ |
| LS1131 | 八皇后 | DFS回溯 | 行、列、对角线检查 | ★★★☆☆ |
| P1123 | 取数游戏 | DFS+剪枝 | 不能相邻取数,最大和 | ★★★☆☆ |
| P4799 | 世界冰球锦标赛 | 折半搜索 | 分成两半,分别枚举再合并 | ★★★★☆ |
| LS1128 | 铺地砖 | 状态压缩DP | 状压DP,转移考虑铺砖方式 | ★★★★☆ |
| LS1193 | 激光枪 | 枚举+几何 | 枚举直线,统计线上点数 | ★★★★☆ |
| P3067 | 平衡子数组 | 折半枚举 | 分成两半,meet in the middle | ★★★★★ |
核心概念:线性DP、环形处理、股票问题系列
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1016 | 最大子数组和 | 线性DP | Kadane算法,dp[i] = max(nums[i],dp[i-1]+nums[i]) | ★★☆☆☆ |
| LS1250 | 最大子数组和 | 线性DP | 同上,基础模板题 | ★★☆☆☆ |
| LS1181 | 最大2段和 | 分段DP | 前后缀分解,pre[i] + suf[i+1] | ★★★☆☆ |
| LS1251 | 最大环形子数组和 | 环形DP | 两种情况:不环形(正常),环形(总和-最小子数组和) | ★★★★☆ |
| LS1176 | 买卖股票的最佳时机_1 | 一次交易 | 记录历史最小值,计算最大差价 | ★★☆☆☆ |
| LS1177 | 买卖股票的最佳时机_2 | 无限交易 | 贪心:所有上涨都交易 | ★★☆☆☆ |
| LS1178 | 买卖股票的最佳时机_3 | 最多两次 | 前后缀分解,前后各一次最大 | ★★★★☆ |
| LS1252 | 买卖股票的最佳时机_4 | 最多k次 | dp[i][j][0/1] 第i天第j次交易持有/不持有 | ★★★★★ |
| LS1179 | 买卖股票含冷冻期 | 状态机DP | 三个状态:持有、不持有(冷冻)、不持有(非冷冻) | ★★★★☆ |
| LS1253 | 买卖股票的最佳时机_5 | 含手续费 | 状态机DP,卖出时扣除手续费 | ★★★★☆ |
| P1121 | 环状最大两段子段和 | 环形分段 | 最大两段和,考虑环形情况 | ★★★★★ |
核心概念:线段树、单调队列、离散化、逆序对
| 题目编号 | 题目名称 | 数据结构 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1226 | 平缓的曲线 | 线段树 | 区间最大值最小值查询 | ★★★☆☆ |
| P3374 | 【模板】单点更新,区间求和 | 线段树/BIT | 基础线段树模板,维护区间和 | ★★☆☆☆ |
| P1908 | 逆序对 | BIT/线段树 | 离散化+从右向左统计 | ★★★☆☆ |
| LS1227 | 单点更新,区间最值 | 线段树 | 维护区间最大值 | ★★★☆☆ |
| LS1228 | 构造回文数组 | 线段树/贪心 | 对称位置配对,区间查询辅助 | ★★★★☆ |
| P1886 | 滑动窗口 | 单调队列 | 维护窗口最大值/最小值 | ★★★☆☆ |
| P1725 | 琪露诺 | 单调队列优化DP | dp[i] = max(dp[i-k..i-1])+a[i],单调队列维护 | ★★★★☆ |
| P2344 | Generic Cow Protests | 树状数组优化DP | dp[i]=∑dp[j] where sum[j+1..i]≥0 | ★★★★☆ |
核心概念:欧拉筛、约数个数、约数和、欧拉函数
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1163 | 埃氏筛和欧拉筛 | 筛法基础 | 埃氏筛O(nloglogn),欧拉筛O(n) | ★★☆☆☆ |
| LS1233 | 多个数分解质因数 | 质因数分解 | 预处理最小质因子,快速分解 | ★★★☆☆ |
| LS1234 | 相等 | 约数相关 | 使用约数个数/约数和公式 | ★★★☆☆ |
| LS1236 | 区间筛法 | 区间筛 | [L,R]区间筛,用质数筛区间 | ★★★★☆ |
| LS1231 | 连续的自然数 | 筛法应用 | 欧拉筛求连续质数/合数 | ★★★★☆ |
| LS1235 | 最值求高 | 约数问题 | 最大公约数相关性质 | ★★★☆☆ |
| LS1237 | 最大公约数计数 | GCD计数 | 使用欧拉函数性质 | ★★★★☆ |
| LS1164 | 约数个数、约数和、欧拉函数 | 筛法求积性函数 | 线性筛同时计算d(n),σ(n),φ(n) | ★★★★☆ |
核心概念:整除分块、下取整求和、公式推导
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| P2398 | GCD SUM | 数论分块 | ∑∑gcd(i,j) = ∑φ(d)*floor(n/d)² | ★★★★★ |
| LS1230 | 简洁的数学 | 数论分块 | ∑floor(n/i)*i 优化计算 | ★★★★☆ |
| P2261 | 余数求和 | 数论分块 | ∑k%i = nk - ∑floor(k/i)i | ★★★★☆ |
| LS1261 | 【提高】数论分块 | 基础分块 | 计算∑floor(n/i) | ★★★☆☆ |
| LS1262 | 【提高】多维数论分块 | 多维分块 | ∑∑floor(n/i)*floor(m/j) | ★★★★☆ |
核心概念:单调栈、贡献法、子数组统计
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1247 | 接雨水 | 单调栈 | 每个位置能接的水 = min(左边最大,右边最大) - 高度 | ★★★☆☆ |
| LS1243 | 子数组之和 | 贡献法 | 统计每个元素在多少子数组中 | ★★☆☆☆ |
| LS1244 | 子数组的最小值之和 | 单调栈 | 找到每个元素作为最小值的左右边界 | ★★★★☆ |
| LS1199 | 柱状图中最大的矩形 | 单调栈 | 找到每个柱子左右第一个更矮的 | ★★★★☆ |
| LS1200 | 最大矩形 | 单调栈+逐行处理 | 每行作为底,转化为柱状图问题 | ★★★★☆ |
| LS1245 | 子数组的最值之差 | 单调栈 | 分别统计最大值贡献和最小值贡献 | ★★★★☆ |
| LS1246 | 子序列的最值之差 | 贡献法 | 考虑每个元素作为最大/最小的贡献 | ★★★★☆ |
| LS1248 | 二进制中的个数 | 位运算+贡献 | 统计每个bit位在多少子数组中为1 | ★★★★☆ |
| LS1249 | 异或和 | 异或+贡献 | 按位考虑,统计异或贡献 | ★★★★☆ |
核心概念:状态设计、状态压缩、多维DP
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1016 | 最大子数组和 | 状态DP | dp[i]表示以i结尾的最大子数组和 | ★★☆☆☆ |
| LS1250 | 最大子数组和 | 状态优化 | 滚动变量替代数组 | ★★☆☆☆ |
| LS1181 | 最大2段和 | 状态分解 | pre[i]前i个的最大一段,suf[i]后i个的最大一段 | ★★★☆☆ |
| LS1251 | 最大环形子数组和 | 状态分类 | 分两种情况:不跨环和跨环 | ★★★★☆ |
| LS1176 | 买卖股票1 | 状态机 | 两个状态:持有股票、不持有股票 | ★★☆☆☆ |
| LS1177 | 买卖股票2 | 状态机 | 贪心简化,所有上涨都交易 | ★★☆☆☆ |
| LS1178 | 买卖股票3 | 状态机 | 四个状态:第一次持有/不持有,第二次持有/不持有 | ★★★★☆ |
| LS1252 | 买卖股票4 | 状态机 | 2k个状态,奇数次持有,偶数次不持有 | ★★★★★ |
| LS1179 | 买卖股票含冷冻期 | 状态机 | 三个状态:持有、冷冻期、可购买 | ★★★★☆ |
| LS1253 | 买卖股票5 | 状态机 | 含手续费,卖出时扣除 | ★★★★☆ |
核心概念:滑动窗口、同向双指针、相向双指针
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1256 | 2数之和 | 相向双指针 | 排序后左右指针向中间移动 | ★★☆☆☆ |
| LS1257 | 2数之差 | 相向双指针 | 排序后固定差值找目标 | ★★★☆☆ |
| LS1255 | k个最接近的数 | 双指针+滑动窗口 | 找到最近位置后扩展窗口 | ★★★☆☆ |
| P1638 | 包含所有数的最短区间 | 滑动窗口 | 统计窗口内不同元素个数 | ★★★☆☆ |
| LS1259 | 字符出现至少k次的子字符串 | 滑动窗口 | 统计字符频率,满足条件时收缩 | ★★★★☆ |
| LS1260 | 求和游戏 | 前缀和+双指针 | 维护窗口和,寻找目标区间 | ★★★★☆ |
| LS1254 | 统计稳定子数组的数目 | 双指针 | 维护最大最小值,统计稳定区间 | ★★★★☆ |
| LS1258 | 极差不超过k的分割数 | 双指针+滑动窗口 | 维护窗口极差,统计分割方式 | ★★★★☆ |
| B4196 | 赛车游戏 | 双指针应用 | 根据速度差计算超车次数 | ★★★☆☆ |
核心概念:根号分治、莫队算法、分块思想
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1261 | 【提高】数论分块 | 数论分块 | ∑floor(n/i)分块计算 | ★★★☆☆ |
| LS1262 | 【提高】多维数论分块 | 多维分块 | ∑∑floor(n/i)*floor(m/j) | ★★★★☆ |
| P2261 | 余数求和 | 数论分块 | nk - ∑floor(k/i)i | ★★★★☆ |
| P3901 | 数列找不同 | 莫队算法 | 离线查询区间是否有重复 | ★★★★☆ |
| P1494 | 小Z的袜子 | 莫队算法 | 概率计算,组合数公式 | ★★★★★ |
| P2709 | 小B的询问 | 莫队算法 | 维护平方和,∑cnt[i]² | ★★★★☆ |
| LS1263 | 【省选】智力与模数 | 根号分治 | 根据模数大小分情况处理 | ★★★★★ |
核心概念:前缀和、哈希表、差分数组
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| LS1265 | 连续数组 | 前缀和+哈希 | 将0视为-1,求最长和为0子数组 | ★★★☆☆ |
| LS1264 | 异或子数组 | 前缀异或+哈希 | 异或前缀和,a ⊕ b = 0 等价于 a = b | ★★★★☆ |
| LS1267 | 最长的平衡子串1 | 前缀和+状态 | 统计 0 和 1 的个数差 | ★★★☆☆ |
| LS1266 | 最长的平衡子串2 | 前缀和扩展 | 多种字符的平衡条件 | ★★★★☆ |
| LS1269 | 维护数组 | 前缀和+差分 | 区间更新,前缀查询 | ★★★☆☆ |
| P14253 | 旅行 | 前缀和优化 | 预处理前缀信息,快速计算 | ★★★★☆ |
| P14359 | 异或和 | 前缀异或 | 按位统计,前缀异或性质 | ★★★★☆ |
| LS1270 | Load_Balancing_S | 前缀和分割 | 枚举分割点,前缀后缀统计 | ★★★★☆ |
| LS1268 | 【提高】异或序列 | 前缀异或 | 区间异或 = prefix[r] ⊕ prefix[l-1] | ★★★★☆ |
核心概念:Kruskal、并查集、最小生成树
| 题目编号 | 题目名称 | 算法类型 | 关键解法 | 难度 |
|---|---|---|---|---|
| P1551 | 亲戚 | 并查集基础 | 连通性判断,union-find | ★★☆☆☆ |
| LS1276 | 【算法】最小生成树 | Kruskal | 按边权排序,贪心选择 | ★★★☆☆ |
| LS1272 | 【算法】最小比率生成树 | 分数规划 | 二分答案,转化为最小生成树 | ★★★★★ |
| P1194 | 买礼物 | 最小生成树变体 | 建图技巧,虚拟节点 | ★★★★☆ |
| P2700 | 逐个击破 | 并查集+贪心 | 逆向思维,从大到小合并 | ★★★★★ |
| P1396 | 营救 | 最小生成树 | 最大边权最小,Kruskal | ★★★☆☆ |
| LS1275 | 【算法】删边游戏 | 并查集+离线 | 离线处理,反向加边 | ★★★★★ |
| LS1273 | 【算法】撤销与合并 | 可持久化并查集 | 主席树维护并查集历史 | ★★★★★ |
| LS1274 | 【算法】向右看齐 | 并查集应用 | 维护下一个可用位置 | ★★★★☆ |
递归与搜索(课程01、04):DFS/BFS模板
排序与STL(课程02、03):sort、容器使用
基础DP(课程12、17):线性DP、状态设计
贪心与双指针(课程08、18):经典贪心、滑动窗口
数据结构基础(课程07):树状数组基本操作
动态规划进阶(课程10):背包九讲
图论算法(课程05):最短路、生成树
高级数据结构(课程13):线段树、单调队列
数学基础(课程06、14):快速幂、筛法
数论与分块(课程15、19):数论分块、根号算法
贡献思维(课程16):单调栈、贡献法
前缀优化(课程20):前缀和技巧
完成课程01-04所有题目,掌握递归和BFS
掌握课程02-03的STL和排序
完成课程07的树状数组基础题
完成课程08、12、17、18的经典算法
掌握课程05的图论基础
完成课程10的背包问题
挑战课程13、14、16、20
尝试课程15、19的难题
综合练习,提高解题速度
每日一套模拟题
时间控制训练(3小时/套)
错题回顾,查漏补缺
| 算法类型 | 推荐课程 | 关键题目 | 掌握要点 |
|---|---|---|---|
| 递归/DFS | 01 | LS1028汉诺塔,LS1117滑雪 | 递归边界、记忆化 |
| BFS搜索 | 04 | P1443马的遍历,P1825传送迷宫 | 状态表示、队列使用 |
| 动态规划 | 10,12,17 | LS1082 01背包,LS1176股票问题 | 状态设计、转移方程 |
| 贪心算法 | 08 | LS1058看电影,LS1060线段覆盖 | 贪心策略证明 |
| 双指针 | 18 | LS1256两数之和,P1638最短区间 | 滑动窗口维护 |
| 数据结构 | 03,07,13 | P3374线段树,P1908逆序对 | 区间操作、离散化 |
| 图论 | 05,21 | P4779最短路,LS1276最小生成树 | Dijkstra、Kruskal |
| 数学/数论 | 06,14,15 | LS1156矩阵快速幂,P2261余数求和 | 快速幂、筛法、分块 |
| 高级技巧 | 16,19,20 | LS1199最大矩形,P1494小Z的袜子 | 单调栈、莫队、前缀和 |
最后提醒:信息营选拔考察综合能力,不仅要掌握算法,还要能灵活运用。建议按照课程顺序循序渐进,打好基础后再挑战难题。每道题目都要理解透彻,做到举一反三。
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
第一行包含一个整数
输出一行包含答案。
111 0 2 1 0 1 3 2 1 2 1
xxxxxxxxxx6
xxxxxxxxxx64 2 0 3 2 5
xxxxxxxxxx9
给定高度数组,求能接的雨水量。关键在于:对于每个位置,能接的雨水 = min(左边最高, 右边最高) - 当前高度。
核心思想:
预处理每个位置左边的最大值 L[i]
预处理每个位置右边的最大值 R[i]
每个位置接水量 = min(L[i], R[i]) - a[i]
算法步骤:
计算 L[i]:从左到右扫描,L[i] = max(L[i-1], a[i])
计算 R[i]:从右到左扫描,R[i] = max(R[i+1], a[i])
遍历每个位置(除了首尾),累加接水量
复杂度分析:
时间复杂度:O(n),三次遍历
空间复杂度:O(n),存储左右最大值
核心思想:
维护左右指针 L, R
维护左右最大值 LMax, RMax
每次移动较矮的一侧指针
算法步骤:
初始化 L=0, R=n-1, LMax=a[L], RMax=a[R], ans=0
当 L < R 时循环:
如果 a[L] ≤ a[R]:移动左指针
L++
如果 a[L] < LMax:ans += LMax - a[L]
否则:LMax = a[L]
否则:移动右指针
R--
如果 a[R] < RMax:ans += RMax - a[R]
否则:RMax = a[R]
复杂度分析:
时间复杂度:O(n),单次遍历
空间复杂度:O(1),只使用常数空间
核心思想:
维护单调递减栈(栈底到栈顶高度递减)
当遇到高于栈顶的柱子时,计算积水
算法步骤:
初始化栈,ans=0
遍历每个位置:
当栈非空且当前高度 ≥ 栈顶高度:
弹出栈顶作为底部
如果栈空则跳出
计算积水:宽度 × (min(左高度, 当前高度) - 底部高度)
当前位置入栈
复杂度分析:
时间复杂度:O(n),每个元素入栈出栈一次
空间复杂度:O(n),栈的空间
xusing namespace std;using i64 = long long;
// 方法1:预计算左右最大值i64 solve1(vector<i64>& a) { i64 n = a.size(), ans = 0; if (n < 3) return 0; vector<i64> L(n), R(n); L[0] = a[0],R[n-1] = a[n-1]; for (i64 i = 1; i < n; i++) L[i] = max(L[i-1], a[i]); // 左边最大值 for (i64 i = n-2; i >= 0; i--) R[i] = max(R[i+1], a[i]); // 右边最大值 for (i64 i = 1; i < n-1; i++) ans += min(L[i], R[i]) - a[i]; // 接水量 = min(左右最大) - 当前高度
return ans;}
// 方法2:双指针法(推荐)i64 solve2(vector<i64>& a) { i64 n = a.size(), ans = 0; if (n < 3) return 0; i64 L = 0, R = n-1; // 左右指针 i64 LMax = a[L], RMax = a[R]; // 左右最大值 while (L < R) { if (a[L] <= a[R]) { // 移动较矮的一侧 L++; if (a[L] < LMax) ans += LMax - a[L]; // 接水 else LMax = a[L]; // 更新左边最大值 } else { R--; if (a[R] < RMax) ans += RMax - a[R]; // 接水 else RMax = a[R]; // 更新右边最大值 } } return ans;}
// 方法3:单调栈法i64 solve3(vector<i64>& a) { i64 n = a.size(), ans = 0; stack<i64> st; // 单调递减栈(存储索引) for (i64 i = 0; i < n; i++) { while (!st.empty() && a[i] >= a[st.top()]) { i64 bottom = st.top(); // 底部索引 st.pop(); if (st.empty()) break; i64 left = st.top(); // 左边界索引 i64 width = i - left - 1; // 宽度 i64 height = min(a[left], a[i]) - a[bottom]; // 高度 ans += height * width; // 积水体积 } st.push(i); } return ans;}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 三种方法任选其一,solve2是最优解 cout << solve2(a) << "\n"; return 0;}示例1:**a = [1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
【方法1:预计算左右最大值过程】
Step 1: 计算左边最大值 L[i]
xxxxxxxxxx位置 i: 0 1 2 3 4 5 6 7 8 9 10a[i]: 1 0 2 1 0 1 3 2 1 2 1L[i]: 1 1 2 2 2 2 3 3 3 3 3计算: ↑ max(1,0) max(1,2) max(2,1) ...
Step 2: 计算右边最大值 R[i]
xxxxxxxxxx位置 i: 0 1 2 3 4 5 6 7 8 9 10a[i]: 1 0 2 1 0 1 3 2 1 2 1R[i]: 3 3 3 3 3 3 3 2 2 2 1计算: 从右向左,max(1,2)=2, max(2,1)=2, max(3,2)=3 ...
Step 3: 计算每个位置接水量 min(L,R)-a
xxxxxxxxxx位置 i: 0 1 2 3 4 5 6 7 8 9 10L[i]: 1 1 2 2 2 2 3 3 3 3 3R[i]: 3 3 3 3 3 3 3 2 2 2 1min(L,R):1 1 2 2 2 2 3 2 2 2 1-a[i]: -1 -0 -2 -1 -0 -1 -3 -2 -1 -2 -1积水: 0 1 0 1 2 1 0 0 1 0 0 = 6
【方法2:双指针法过程】
详细步骤:
xxxxxxxxxx初始: L=0, R=10, LMax=1, RMax=1, ans=0步骤1: a[0]=1 ≤ a[10]=1 → 移动左指针L=1, a[1]=0 < LMax=1 → ans += 1-0 = 1当前: ans=1, L=1, R=10, LMax=1, RMax=1步骤2: a[1]=0 ≤ a[10]=1 → 移动左指针L=2, a[2]=2 > LMax=1 → LMax=2当前: ans=1, L=2, R=10, LMax=2, RMax=1步骤3: a[2]=2 > a[10]=1 → 移动右指针R=9, a[9]=2 > RMax=1 → RMax=2当前: ans=1, L=2, R=9, LMax=2, RMax=2步骤4: a[2]=2 ≤ a[9]=2 → 移动左指针L=3, a[3]=1 < LMax=2 → ans += 2-1 = 1当前: ans=2, L=3, R=9, LMax=2, RMax=2步骤5: a[3]=1 ≤ a[9]=2 → 移动左指针L=4, a[4]=0 < LMax=2 → ans += 2-0 = 2当前: ans=4, L=4, R=9, LMax=2, RMax=2步骤6: a[4]=0 ≤ a[9]=2 → 移动左指针L=5, a[5]=1 < LMax=2 → ans += 2-1 = 1当前: ans=5, L=5, R=9, LMax=2, RMax=2步骤7: a[5]=1 ≤ a[9]=2 → 移动左指针L=6, a[6]=3 > LMax=2 → LMax=3当前: ans=5, L=6, R=9, LMax=3, RMax=2步骤8: a[6]=3 > a[9]=2 → 移动右指针R=8, a[8]=1 < RMax=2 → ans += 2-1 = 1当前: ans=6, L=6, R=8, LMax=3, RMax=2步骤9: a[6]=3 > a[8]=1 → 移动右指针R=7, a[7]=2 = RMax=2 → 不积水当前: ans=6, L=6, R=7, LMax=3, RMax=2步骤10: a[6]=3 > a[7]=2 → 移动右指针R=6, L==R → 结束最终: ans=6
| 步骤 | 操作 | L | R | LMax | RMax | a[L] | a[R] | 当前操作 | 积水量 | ans |
|---|---|---|---|---|---|---|---|---|---|---|
| 初始 | - | 0 | 10 | 1 | 1 | 1 | 1 | - | - | 0 |
| 1 | a[L] ≤ a[R] | 1 | 10 | 1 | 1 | 0 | 1 | L++,a[1]=0<LMax=1 | 1 | 1 |
| 2 | a[L] ≤ a[R] | 2 | 10 | 2 | 1 | 2 | 1 | L++,a[2]=2>LMax=1 | LMax=2 | 1 |
| 3 | a[L] > a[R] | 2 | 9 | 2 | 2 | 2 | 2 | R--,a[9]=2>RMax=1 | RMax=2 | 1 |
| 4 | a[L] ≤ a[R] | 3 | 9 | 2 | 2 | 1 | 2 | L++,a[3]=1<LMax=2 | 1 | 2 |
| 5 | a[L] ≤ a[R] | 4 | 9 | 2 | 2 | 0 | 2 | L++,a[4]=0<LMax=2 | 2 | 4 |
| 6 | a[L] ≤ a[R] | 5 | 9 | 2 | 2 | 1 | 2 | L++,a[5]=1<LMax=2 | 1 | 5 |
| 7 | a[L] ≤ a[R] | 6 | 9 | 3 | 2 | 3 | 2 | L++,a[6]=3>LMax=2 | LMax=3 | 5 |
| 8 | a[L] > a[R] | 6 | 8 | 3 | 2 | 3 | 1 | R--,a[8]=1<RMax=2 | 1 | 6 |
| 9 | a[L] > a[R] | 6 | 7 | 3 | 2 | 3 | 2 | R--,a[7]=2=RMax=2 | 0 | 6 |
| 10 | a[L] > a[R] | 6 | 6 | 3 | 2 | 3 | 3 | R--,L==R结束 | - | 6 |
最终答案:6
【方法3:单调栈法过程】
详细步骤:
xxxxxxxxxx数组: [1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]i=0: 栈空 → push(0) 栈: [0]i=1: a[1]=0 < a[0]=1 → push(1) 栈: [0,1]i=2: a[2]=2 ≥ a[1]=0→ pop() bottom=1, 栈不空 left=0→ width=2-0-1=1, height=min(1,2)-0=1→ ans += 1*1 = 1→ a[2]=2 ≥ a[0]=1→ pop() bottom=0, 栈空 → break→ push(2) 栈: [2]当前ans=1i=3: a[3]=1 < a[2]=2 → push(3) 栈: [2,3]i=4: a[4]=0 < a[3]=1 → push(4) 栈: [2,3,4]i=5: a[5]=1 ≥ a[4]=0→ pop() bottom=4, left=3→ width=5-3-1=1, height=min(1,1)-0=1→ ans += 1*1 = 2→ a[5]=1 ≥ a[3]=1→ pop() bottom=3, left=2→ width=5-2-1=2, height=min(2,1)-1=0→ ans不变→ push(5) 栈: [2,5]当前ans=2i=6: a[6]=3 ≥ a[5]=1→ pop() bottom=5, left=2→ width=6-2-1=3, height=min(2,3)-1=1→ ans += 1*3 = 5→ a[6]=3 ≥ a[2]=2→ pop() bottom=2, 栈空 → break→ push(6) 栈: [6]当前ans=5i=7: a[7]=2 < a[6]=3 → push(7) 栈: [6,7]i=8: a[8]=1 < a[7]=2 → push(8) 栈: [6,7,8]i=9: a[9]=2 ≥ a[8]=1→ pop() bottom=8, left=7→ width=9-7-1=1, height=min(2,2)-1=1→ ans += 1*1 = 6→ a[9]=2 ≥ a[7]=2→ pop() bottom=7, left=6→ width=9-6-1=2, height=min(3,2)-2=0→ ans不变→ push(9) 栈: [6,9]当前ans=6i=10: a[10]=1 < a[9]=2 → push(10) 栈: [6,9,10]遍历结束,最终ans=6
| i | a[i] | 栈操作 | 栈状态(索引) | 计算过程 | 积水量 | ans |
|---|---|---|---|---|---|---|
| 0 | 1 | push(0) | [0] | - | - | 0 |
| 1 | 0 | push(1) | [0,1] | - | - | 0 |
| 2 | 2 | while循环:a[2]≥a[1]=0 | [0] | pop() bottom=1, left=0 width=2-0-1=1 height=min(1,2)-0=1 | 1 | 1 |
| while循环:a[2]≥a[0]=1 | [] | pop() bottom=0, 栈空break | - | 1 | ||
| push(2) | [2] | - | - | 1 | ||
| 3 | 1 | push(3) | [2,3] | - | - | 1 |
| 4 | 0 | push(4) | [2,3,4] | - | - | 1 |
| 5 | 1 | while循环:a[5]≥a[4]=0 | [2,3] | pop() bottom=4, left=3 width=5-3-1=1 height=min(1,1)-0=1 | 1 | 2 |
| while循环:a[5]≥a[3]=1 | [2] | pop() bottom=3, left=2 width=5-2-1=2 height=min(2,1)-1=0 | 0 | 2 | ||
| push(5) | [2,5] | - | - | 2 | ||
| 6 | 3 | while循环:a[6]≥a[5]=1 | [2] | pop() bottom=5, left=2 width=6-2-1=3 height=min(2,3)-1=1 | 3 | 5 |
| while循环:a[6]≥a[2]=2 | [] | pop() bottom=2, 栈空break | - | 5 | ||
| push(6) | [6] | - | - | 5 | ||
| 7 | 2 | push(7) | [6,7] | - | - | 5 |
| 8 | 1 | push(8) | [6,7,8] | - | - | 5 |
| 9 | 2 | while循环:a[9]≥a[8]=1 | [6,7] | pop() bottom=8, left=7 width=9-7-1=1 height=min(2,2)-1=1 | 1 | 6 |
| while循环:a[9]≥a[7]=2 | [6] | pop() bottom=7, left=6 width=9-6-1=2 height=min(3,2)-2=0 | 0 | 6 | ||
| push(9) | [6,9] | - | - | 6 | ||
| 10 | 1 | push(10) | [6,9,10] | - | - | 6 |
最终答案:6
示例2:a = [4, 2, 0, 3, 2, 5]
【方法1:预计算左右最大值过程】
计算过程:
xxxxxxxxxx位置 i: 0 1 2 3 4 5a[i]: 4 2 0 3 2 5L[i]: 4 4 4 4 4 5R[i]: 5 5 5 5 5 5min(L,R):4 4 4 4 4 5-a[i]: -4 -2 -0 -3 -2 -5积水: 0 2 4 1 2 0 = 9
| 位置 i | a[i] | L[i] = max(L[i-1], a[i]) | R[i] = max(R[i+1], a[i]) | min(L[i], R[i]) | 积水高度 = min(L,R)-a[i] | 累计积水 |
|---|---|---|---|---|---|---|
| 0 | 4 | 4 | 5 | 4 | 0 | 0 |
| 1 | 2 | max(4,2)=4 | max(5,2)=5 | 4 | 2 | 2 |
| 2 | 0 | max(4,0)=4 | max(5,0)=5 | 4 | 4 | 6 |
| 3 | 3 | max(4,3)=4 | max(5,3)=5 | 4 | 1 | 7 |
| 4 | 2 | max(4,2)=4 | max(5,2)=5 | 4 | 2 | 9 |
| 5 | 5 | max(4,5)=5 | 5 | 5 | 0 | 9 |
最终答案:9
【方法2:双指针法过程(简化)】
xxxxxxxxxx初始: L=0, R=5, LMax=4, RMax=5, ans=0步骤1: a[0]=4 < a[5]=5 → L=1, a[1]=2<4 → ans+=2步骤2: a[1]=2 < a[5]=5 → L=2, a[2]=0<4 → ans+=4步骤3: a[2]=0 < a[5]=5 → L=3, a[3]=3<4 → ans+=1步骤4: a[3]=3 < a[5]=5 → L=4, a[4]=2<4 → ans+=2步骤5: a[4]=2 < a[5]=5 → L=5, L==R → 结束最终ans=2+4+1+2=9
| 步骤 | 操作 | L | R | LMax | RMax | a[L] | a[R] | 当前操作 | 积水量 | ans |
|---|---|---|---|---|---|---|---|---|---|---|
| 初始 | - | 0 | 5 | 4 | 5 | 4 | 5 | - | - | 0 |
| 1 | a[L] ≤ a[R] | 1 | 5 | 4 | 5 | 2 | 5 | L++,a[1]=2<LMax=4 | 2 | 2 |
| 2 | a[L] ≤ a[R] | 2 | 5 | 4 | 5 | 0 | 5 | L++,a[2]=0<LMax=4 | 4 | 6 |
| 3 | a[L] ≤ a[R] | 3 | 5 | 4 | 5 | 3 | 5 | L++,a[3]=3<LMax=4 | 1 | 7 |
| 4 | a[L] ≤ a[R] | 4 | 5 | 4 | 5 | 2 | 5 | L++,a[4]=2<LMax=4 | 2 | 9 |
| 5 | a[L] ≤ a[R] | 5 | 5 | 4 | 5 | 5 | 5 | L==R结束 | - | 9 |
最终答案:9
【方法3:单调栈法过程】
栈初始为空,ans=0
xxxxxxxxxxi=0: 栈空 → push(0) 栈: [0]i=1: 2<4 → push(1) 栈: [0,1]i=2: 0<2 → push(2) 栈: [0,1,2]i=3: 3≥0 → 底部=2,左=1 → 宽=1,高=2-0=2 ans+=2,栈: [0,1]3≥2 → 底部=1,左=0 → 宽=2,高=3-2=1 ans+=2,栈: [0]3<4 → push(3) 栈: [0,3]i=4: 2<3 → push(4) 栈: [0,3,4]i=5: 5≥2 → 底部=4,左=3 → 宽=1,高=3-2=1 ans+=1,栈: [0,3]5≥3 → 底部=3,左=0 → 宽=4,高=4-3=1 ans+=4,栈: [0]5≥4 → 底部=0 → 栈空 → break 栈: []push(5) 栈: [5]最终ans=2+2+1+4=9
| i | a[i] | 栈操作 | 栈状态(索引) | 计算过程 | 积水量 | ans |
|---|---|---|---|---|---|---|
| 0 | 4 | push(0) | [0] | - | - | 0 |
| 1 | 2 | push(1) | [0,1] | - | - | 0 |
| 2 | 0 | push(2) | [0,1,2] | - | - | 0 |
| 3 | 3 | while循环:a[3]≥a[2]=0 | [0,1] | pop() bottom=2, left=1 width=3-1-1=1 height=min(2,3)-0=2 | 2 | 2 |
| while循环:a[3]≥a[1]=2 | [0] | pop() bottom=1, left=0 width=3-0-1=2 height=min(4,3)-2=1 | 2 | 4 | ||
| push(3) | [0,3] | - | - | 4 | ||
| 4 | 2 | push(4) | [0,3,4] | - | - | 4 |
| 5 | 5 | while循环:a[5]≥a[4]=2 | [0,3] | pop() bottom=4, left=3 width=5-3-1=1 height=min(3,5)-2=1 | 1 | 5 |
| while循环:a[5]≥a[3]=3 | [0] | pop() bottom=3, left=0 width=5-0-1=4 height=min(4,5)-3=1 | 4 | 9 | ||
| while循环:a[5]≥a[0]=4 | [] | pop() bottom=0, 栈空break | - | 9 | ||
| push(5) | [5] | - | - | 9 |
最终答案:9
####核心思想
| 方法 | 示例1过程 | 示例2过程 | 空间 | 推荐度 |
|---|---|---|---|---|
| 预计算法 | 先算L[11]、R[11]数组,再遍历计算 | 算L[6]、R[6]数组 | O(n) | ★★★★☆ |
| 双指针法 | 移动较矮指针,实时更新最大值 | 同样逻辑,空间最优 | O(1) | ★★★★★ |
| 单调栈法 | 维护递减栈,遇到高柱子计算积水 | 栈存储索引,计算矩形面积 | O(n) | ★★★☆☆ |
1. 积水原理:每个位置积水量由左右最高柱子的较小值决定
2. 双指针移动:移动较矮的一侧,因为积水高度由较矮侧决定
3. 单调栈思想:寻找下一个更高柱子,计算之间的积水
1. 二维接雨水:可以扩展到二维矩阵**
2. 容器盛水:类似问题,如LeetCode 11
3. 其他变体:考虑柱子有宽度、有斜坡等情况
推荐:掌握双指针法,它是空间最优且逻辑清晰的最优解。
给你一个长度为
第一行包含一个整数
第二行包含
输出一行包含答案,对 998244353 取模。
xxxxxxxxxx31 2 3
xxxxxxxxxx20
要求所有子数组的和的总和。
暴力枚举所有子数组需要 O(n²) 时间,n ≤ 10⁵ 无法通过。
关键观察:每个元素 a[i] 出现在多少个子数组中?
对于元素 a[i](0-based索引):
左端点选择:可以是 0, 1, ..., i,共 (i+1) 种选择
右端点选择:可以是 i, i+1, ..., n-1,共 (n-i) 种选择
总出现次数:cnt[i] = (i+1) × (n-i)
因此:
读入数组 a
遍历每个元素 i:
计算出现次数 cnt = (i+1) × (n-i)
累加贡献:ans += a[i] × cnt
取模防止溢出
输出答案
时间复杂度:O(n),一次遍历
空间复杂度:O(1),只使用常数空间
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353; // 取模常数
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0; // 贡献法:每个元素出现在多少个子数组中? for (i64 i = 0; i < n; i++) { i64 leftChoices = i + 1; // 左端点选择:0到i,共i+1种 i64 rightChoices = n - i; // 右端点选择:i到n-1,共n-i种 i64 cnt = leftChoices * rightChoices; // 总出现次数 ans = (ans + a[i] * cnt % MOD) % MOD; // 累加贡献,取模 } cout << ans << "\n"; return 0;}示例:a = [1, 2, 3], n=3
计算过程:
i=0 (a[0]=1):
leftChoices = 0+1 = 1
rightChoices = 3-0 = 3
cnt = 1×3 = 3
贡献 = 1×3 = 3
i=1 (a[1]=2):
leftChoices = 1+1 = 2
rightChoices = 3-1 = 2
cnt = 2×2 = 4
贡献 = 2×4 = 8
i=2 (a[2]=3):
leftChoices = 2+1 = 3
rightChoices = 3-2 = 1
cnt = 3×1 = 3
贡献 = 3×3 = 9
总贡献 = 3 + 8 + 9 = 20
验证所有子数组:
[1] = 1
[1,2] = 3
[1,2,3] = 6
[2] = 2
[2,3] = 5
[3] = 3 总和 = 1+3+6+2+5+3 = 20 ✓
计算过程表格
| 步骤 | 元素 i | a[i] | 左端点选择数 leftChoices | 右端点选择数 rightChoices | 总出现次数 cnt = leftChoices × rightChoices | 当前贡献 = a[i] × cnt | 累计贡献 ans |
|---|---|---|---|---|---|---|---|
| 初始 | - | - | - | - | - | - | 0 |
| 1 | i=0 | 1 | leftChoices = i+1 = 0+1 = 1 | rightChoices = n-i = 3-0 = 3 | cnt = 1×3 = 3 | 贡献 = 1×3 = 3 | 3 |
| 2 | i=1 | 2 | leftChoices = i+1 = 1+1 = 2 | rightChoices = n-i = 3-1 = 2 | cnt = 2×2 = 4 | 贡献 = 2×4 = 8 | 11 |
| 3 | i=2 | 3 | leftChoices = i+1 = 2+1 = 3 | rightChoices = n-i = 3-2 = 1 | cnt = 3×1 = 3 | 贡献 = 3×3 = 9 | 20 |
最终答案:20
验证所有子数组表格
为了验证结果,列出所有子数组并计算它们的和:
| 子数组 | 元素 | 和 | 验证计算 |
|---|---|---|---|
| 1 | [1] | 1 | 1 |
| 2 | [1, 2] | 3 | 1+2=3 |
| 3 | [1, 2, 3] | 6 | 1+2+3=6 |
| 4 | [2] | 2 | 2 |
| 5 | [2, 3] | 5 | 2+3=5 |
| 6 | [3] | 3 | 3 |
所有子数组和的总和 = 1 + 3 + 6 + 2 + 5 + 3 = 20 ✓
贡献法原理说明表格
| 元素位置 i | 包含该元素的子数组左端点选择 | 包含该元素的子数组右端点选择 | 解释 |
|---|---|---|---|
| i=0 | 0(只有下标0可选) | 0,1,2(从0到n-1) | 左端点只能从0开始,右端点可以是0,1,2 |
| i=1 | 0,1(下标0或1) | 1,2(从1到n-1) | 左端点可以是0或1,右端点可以是1或2 |
| i=2 | 0,1,2(下标0,1,2) | 2(只有下标2) | 左端点可以是0,1,2,右端点只能是2 |
注意:leftChoices = i+1 是因为左端点可以从0到i(共i+1个选择),rightChoices = n-i 是因为右端点可以从i到n-1(共n-i个选择)。
子数组之和是贡献法的经典应用:
贡献法思想:将总和分解为每个元素的贡献
组合计数:计算每个元素出现在多少个子数组中
取模运算:大数需要取模防止溢出
子数组平均值之和:类似思路
加权子数组和:每个子数组乘以权重
二维子矩阵和:扩展到二维情况
记住:当需要计算所有子数组的某种统计量时,考虑贡献法——每个元素贡献了多少?
给你一个长度为
第一行包含一个整数
第二行包含
输出一行包含答案,对 998244353 取模。
xxxxxxxxxx32 1 3
xxxxxxxxxx9
要求所有子数组的最小值之和。
暴力枚举需要 O(n²),n ≤ 10⁵ 无法通过。
关键观察:对于每个元素 a[i],它在多少个子数组中是最小值?
定义:
左边界 L[i]:左边第一个 < a[i] 的位置(没有则为 -1)
右边界 R[i]:右边第一个 ≤ a[i] 的位置(没有则为 n)
那么以 a[i] 为最小值的子数组:
左端点可以在 (L[i], i] 中任意选择,共 leftCnt = i - L[i] 种
右端点可以在 [i, R[i]) 中任意选择,共 rightCnt = R[i] - i 种
总子数组数 = leftCnt × rightCnt
注意:边界处理要防止重复计数:
左边用 < 保证严格小于
右边用 ≤ 保证不重复(或左边用 ≤,右边用 <)
使用单调递增栈求边界:
求左边界:从左到右扫描
维护单调递增栈(栈底到栈顶递增)
当 a[i] ≤ 栈顶时弹出(使用 ≤ 保证右边用 >)
栈顶即为左边界
求右边界:从右到左扫描
维护单调递增栈
当 a[i] < 栈顶时弹出(使用 < 保证不重复)
栈顶即为右边界
计算贡献:对于每个 i,贡献 = a[i] × leftCnt × rightCnt
时间复杂度:O(n),每个元素入栈出栈一次
空间复杂度:O(n),栈和边界数组
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 边界数组 vector<i64> L(n, -1); // 左边第一个 <= a[i] 的位置 vector<i64> R(n, n); // 右边第一个 < a[i] 的位置 // 单调栈求左边界(使用 <=) stack<i64> st; for (i64 i = 0; i < n; i++) { while (!st.empty() && a[st.top()] >= a[i]) { // 注意:这里用 >= R[st.top()] = i; // i是栈顶元素的右边界 st.pop(); } if (!st.empty()) L[i] = st.top(); // 栈顶是i的左边界 st.push(i); } // 计算贡献 i64 ans = 0; for (i64 i = 0; i < n; i++) { i64 leftCnt = i - L[i]; // 左端点选择数 i64 rightCnt = R[i] - i; // 右端点选择数 i64 cnt = leftCnt * rightCnt % MOD; // 总子数组数 ans = (ans + a[i] * cnt % MOD) % MOD; } cout << ans << "\n"; return 0;}示例:a = [2, 1, 3]
边界计算:
i=0 (a[0]=2):
栈空,L[0] = -1
入栈:[0]
i=1 (a[1]=1):
a[1]=1 ≤ a[栈顶0]=2,弹出0,R[0]=1
栈空,L[1] = -1
入栈:[1]
i=2 (a[2]=3):
a[2]=3 > a[栈顶1]=1,不弹出
L[2] = 1
入栈:[1,2]
最终边界:
L = [-1, -1, 1]
R = [1, 3, 3]
贡献计算:
i=0: leftCnt=0-(-1)=1, rightCnt=1-0=1, cnt=1, 贡献=2×1=2
i=1: leftCnt=1-(-1)=2, rightCnt=3-1=2, cnt=4, 贡献=1×4=4
i=2: leftCnt=2-1=1, rightCnt=3-2=1, cnt=1, 贡献=3×1=3
总和 = 2 + 4 + 3 = 9
验证所有子数组最小值:
[2]=2, [2,1]=1, [2,1,3]=1
[1]=1, [1,3]=1
[3]=3 总和 = 2+1+1+1+1+3 = 9 ✓
子数组最小值之和是单调栈的经典应用:
贡献法:计算每个元素作为最小值的子数组数
单调栈:高效求左右边界
边界处理:左右用不同比较符防止重复计数
比较符选择:
左边界用 ≥,右边界用 >(或反之)
确保每个子数组的最小值唯一归属
哨兵技巧:可以在数组前后添加极小值简化代码
取模运算:大数乘法注意取模
子数组最大值之和:类似,改为单调递减栈
第k小值之和:更复杂,需要其他数据结构
二维情况:扩展到矩阵的子矩阵最小值
单调栈是解决"下一个更大/更小元素"类问题的利器,务必掌握。
给定 n 个非负整数,表示柱状图中各个柱子的高度,每个柱子宽度为1且彼此相邻。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
第一行包含整数
第二行包含
输出矩形的最大面积。
xxxxxxxxxx62 1 5 6 2 3
xxxxxxxxxx10
xxxxxxxxxx22 4
xxxxxxxxxx4
在柱状图中找面积最大的矩形。
暴力枚举左右边界需要 O(n²),n ≤ 2×10⁵ 无法通过。
关键观察:对于每个柱子 i,以它的高度为矩形高度的最大矩形:
左边界:左边第一个比它矮的柱子的右边
右边界:右边第一个比它矮的柱子的左边
算法思路:
维护单调递增栈(栈底到栈顶高度递增)
当遇到比栈顶矮的柱子时,说明栈顶柱子的右边界找到了
弹出栈顶,计算以该柱子高度为高的最大矩形面积
哨兵技巧:
在数组前后添加高度0作为哨兵
简化边界处理,确保所有柱子都能被弹出计算
在高度数组前后添加0作为哨兵
初始化栈,压入左哨兵索引0
遍历每个柱子 i(1到n):
当 h[栈顶] > h[i] 时循环:
弹出栈顶作为高度 height = h[栈顶]
新的栈顶作为左边界 left = 当前栈顶
宽度 width = i - left - 1
面积 area = height × width
更新最大面积
将 i 入栈
返回最大面积
时间复杂度:O(n),每个柱子入栈出栈一次
空间复杂度:O(n),栈的空间
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; // 添加哨兵:前后各加一个高度0 vector<i64> h(n + 2, 0); for (i64 i = 1; i <= n; i++) cin >> h[i]; stack<i64> st; // 单调递增栈(存储索引) st.push(0); // 压入左哨兵 i64 ans = 0; for (i64 i = 1; i <= n + 1; i++) { // 维护单调递增性:当当前柱子比栈顶矮时 while (h[st.top()] > h[i]) { i64 height = h[st.top()]; // 矩形高度 st.pop(); i64 left = st.top(); // 左边界(新栈顶) i64 width = i - left - 1; // 矩形宽度 ans = max(ans, height * width); // 更新最大面积 } st.push(i); // 当前柱子入栈 } cout << ans << "\n"; return 0;}示例:h = [2, 1, 5, 6, 2, 3]
添加哨兵后:h = [0, 2, 1, 5, 6, 2, 3, 0]
遍历过程:
i=1 (h=2):
栈:[0(0)]
h[0]=0 ≤ h[1]=2,直接入栈
栈:[0(0), 1(2)]
i=2 (h=1):
h[栈顶1]=2 > h[2]=1,弹出1:
height=2, left=栈顶0, width=2-0-1=1, area=2
ans=2
h[栈顶0]=0 ≤ h[2]=1,入栈
栈:[0(0), 2(1)]
i=3 (h=5):
直接入栈
栈:[0(0), 2(1), 3(5)]
i=4 (h=6):
直接入栈
栈:[0(0), 2(1), 3(5), 4(6)]
i=5 (h=2):
h[栈顶4]=6 > 2,弹出4:
height=6, left=栈顶3, width=5-3-1=1, area=6
ans=6
h[栈顶3]=5 > 2,弹出3:
height=5, left=栈顶2, width=5-2-1=2, area=10
ans=10
h[栈顶2]=1 ≤ 2,入栈
栈:[0(0), 2(1), 5(2)]
i=6 (h=3):
直接入栈
栈:[0(0), 2(1), 5(2), 6(3)]
i=7 (h=0,右哨兵):
弹出所有:
弹出6: height=3, left=5, width=7-5-1=1, area=3
弹出5: height=2, left=2, width=7-2-1=4, area=8
弹出2: height=1, left=0, width=7-0-1=6, area=6
ans保持10
最大面积 = 10(高度5,宽度2)
柱状图最大矩形是单调栈的经典问题:
单调递增栈:寻找左右第一个更矮的柱子
哨兵技巧:前后添加高度0简化边界处理
面积计算:高度 × 宽度,宽度 = 右边界 - 左边界 - 1
出栈时机:当遇到更矮柱子时,栈顶柱子的右边界确定
宽度计算:右边界i,左边界新栈顶,宽度 = i - left - 1
时间复杂度:O(n) 优于暴力 O(n²)
最大正方形:类似思路
三维柱状图:更复杂的问题
其他单调栈问题:接雨水、每日温度等
单调栈是解决区间最值相关问题的强大工具,本题是其典型应用。
给定一个仅包含0和1、大小为
第一行包含
接下来
输出只包含1的矩形的最大面积。
xxxxxxxxxx4 510100101111111110010
xxxxxxxxxx6
在01矩阵中找全1的最大矩形。
暴力枚举左上角和右下角需要 O(n²m²),不可行。
关键观察:对于每一行,可以计算从该行开始向上的连续1的个数,形成柱状图。
定义 h[i][j]:从第 i 行开始,第 j 列向上的连续1的个数。
则对于每一行,问题转化为:在高度数组 h[row] 上找最大矩形(即 D 题问题)。
初始化高度数组 h,大小为 m+2(添加哨兵)
遍历每一行:
更新高度:如果是'1'则高度+1,否则清零
在当前行的高度数组上使用单调栈求最大矩形
更新全局最大面积
返回最大面积
时间复杂度:O(n×m),每行处理一次单调栈
空间复杂度:O(m),高度数组和栈的空间
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<string> matrix(n); for (auto& row : matrix) cin >> row; // 高度数组,前后添加哨兵0 vector<i64> h(m + 2, 0); i64 ans = 0; // 逐行处理 for (i64 i = 0; i < n; i++) { // 更新高度 for (i64 j = 0; j < m; j++) { h[j + 1] = (matrix[i][j] == '1') ? h[j + 1] + 1 : 0; } // 单调栈求当前行最大矩形 stack<i64> st; st.push(0); // 左哨兵 for (i64 j = 1; j <= m + 1; j++) { while (h[st.top()] > h[j]) { i64 height = h[st.top()]; st.pop(); i64 left = st.top(); i64 width = j - left - 1; ans = max(ans, height * width); } st.push(j); } } cout << ans << "\n"; return 0;}示例矩阵:
xxxxxxxxxx10100101111111110010
逐行高度计算:
第0行:h = [1,0,1,0,0] 最大矩形:高度1,宽度1(多个),面积1
第1行:h = [2,0,2,1,1] 最大矩形:高度1,宽度2(最后两列),面积2
第2行:h = [3,1,3,2,2] 最大矩形:高度2,宽度2(最后两列),面积4
第3行:h = [4,0,0,3,0] 最大矩形:高度3,宽度1,面积3
全局最大面积 = max(1,2,4,3) = 4?不对,应该是6
检查:第2行最后两列高度为2,面积2×2=4
但第1-2行最后三列:高度2,宽度3,面积6
问题在于:我们逐行计算,但最大矩形可能跨越多行。
实际上算法是正确的,我们漏算了某个情况。
重新计算第2行:h=[3,1,3,2,2] 最大矩形:
高度3,宽度1(第一列),面积3
高度2,宽度2(最后两列),面积4
高度2,宽度3(第三到五列),面积6
所以最大面积为6。
最大矩形(01矩阵)是柱状图最大矩形的扩展:
降维转化:将二维问题转化为多个一维柱状图问题
高度定义:h[j] = 从当前行向上连续1的个数
逐行求解:每行用单调栈求最大矩形
状态转移:高度遇到0清零,遇到1加1
哨兵技巧:同样适用,简化边界
时间复杂度:O(n×m) 优于暴力 O(n²m²)
最大正方形:类似思路,记录三个方向的最小值
带权重的矩形:每个格子有权重值
三维情况:扩展到三维空间的最大长方体
降维思想是解决复杂问题的常用技巧,将高维问题转化为低维问题求解。
给你一个长度为
第一行包含一个整数
第二行包含
输出一行包含答案,对 998244353 取模。
xxxxxxxxxx32 1 3
xxxxxxxxxx5
要求所有子数组的(最大值-最小值)之和。
暴力枚举需要 O(n²),n ≤ 10⁵ 无法通过。
关键观察:
因此问题转化为:
计算所有子数组的最大值之和(C题的反向)
计算所有子数组的最小值之和(C题)
两者相减
对于最大值:
左边界:左边第一个 > a[i] 的位置
右边界:右边第一个 ≥ a[i] 的位置
对于最小值:
左边界:左边第一个 < a[i] 的位置
右边界:右边第一个 ≤ a[i] 的位置
注意:比较符号的选择要确保不重复不遗漏。
计算最小值之和(同C题):
左边界:第一个 < a[i](或 ≤,配合右边)
右边界:第一个 ≤ a[i](或 <)
计算最大值之和:
左边界:第一个 > a[i](或 ≥)
右边界:第一个 ≥ a[i](或 >)
答案 = (最大值之和 - 最小值之和) mod MOD
时间复杂度:O(n),四次单调栈扫描
空间复杂度:O(n),边界数组
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 1. 计算最小值之和(同C题) vector<i64> Lmin(n, -1), Rmin(n, n); stack<i64> st; // 左边界:第一个 <= a[i](配合右边用 <) for (i64 i = 0; i < n; i++) { while (!st.empty() && a[st.top()] >= a[i]) { // 注意:这里用 >= Rmin[st.top()] = i; // i是栈顶元素的右边界 st.pop(); } if (!st.empty()) Lmin[i] = st.top(); st.push(i); } i64 sum_min = 0; for (i64 i = 0; i < n; i++) { i64 leftCnt = i - Lmin[i]; i64 rightCnt = Rmin[i] - i; i64 cnt = leftCnt * rightCnt % MOD; sum_min = (sum_min + a[i] * cnt % MOD) % MOD; } // 2. 计算最大值之和 vector<i64> Lmax(n, -1), Rmax(n, n); while (!st.empty()) st.pop(); // 左边界:第一个 >= a[i](配合右边用 >) for (i64 i = 0; i < n; i++) { while (!st.empty() && a[st.top()] <= a[i]) { // 注意:这里用 <= Rmax[st.top()] = i; // i是栈顶元素的右边界 st.pop(); } if (!st.empty()) Lmax[i] = st.top(); st.push(i); } i64 sum_max = 0; for (i64 i = 0; i < n; i++) { i64 leftCnt = i - Lmax[i]; i64 rightCnt = Rmax[i] - i; i64 cnt = leftCnt * rightCnt % MOD; sum_max = (sum_max + a[i] * cnt % MOD) % MOD; } // 3. 答案 = 最大值之和 - 最小值之和 i64 ans = (sum_max - sum_min) % MOD; if (ans < 0) ans += MOD; // 保证非负 cout << ans << "\n"; return 0;}示例:a = [2, 1, 3]
Lmin = [-1, -1, 1] (左边第一个 >=)
Rmin = [1, 3, 3] (右边第一个 <)
最小值之和:
i=0: cnt=(0-(-1))×(1-0)=1, 贡献=2×1=2
i=1: cnt=(1-(-1))×(3-1)=4, 贡献=1×4=4
i=2: cnt=(2-1)×(3-2)=1, 贡献=3×1=3 sum_min = 2+4+3 = 9
Lmax = [-1, 0, -1] (左边第一个 <=)
Rmax = [2, 2, 3] (右边第一个 >)
最大值之和:
i=0: cnt=(0-(-1))×(2-0)=2, 贡献=2×2=4
i=1: cnt=(1-0)×(2-1)=1, 贡献=1×1=1
i=2: cnt=(2-(-1))×(3-2)=3, 贡献=3×3=9 sum_max = 4+1+9 = 14
答案 = 14 - 9 = 5
验证所有子数组:
[2]: 0, [2,1]: 1, [2,1,3]: 2
[1]: 0, [1,3]: 2
总和 = 0+1+2+0+2+0 = 5 ✓
子数组最值之差是贡献法的综合应用:
分离计算:分别计算最大值和最小值之和
边界处理:比较符要配对,防止重复计数
取模运算:减法后要保证非负
中位数之和:更难,需要数据结构维护
第k大值之和:更复杂的问题
带权最值差:每个位置有额外权重
贡献法的威力在于将复杂统计量分解为基本元素的贡献。
给你一个长度为
一个序列的宽度定义为该序列中最大元素和最小元素的差值。
请你计算数组
第一行包含一个整数
第二行包含
输出一行包含答案,对 998244353 取模。
xxxxxxxxxx32 1 3
xxxxxxxxxx6
子序列不要求连续,可以从原数组中任意选取元素(保持顺序)。
长度为n的数组有 2ⁿ - 1 个非空子序列,n ≤ 10⁵ 无法枚举。
关键观察:对于排序后的数组,每个元素作为最大值和最小值的次数容易计算。
设数组排序后为:
对于元素
作为最大值:子序列中所有元素 ≤ a[i],且必须包含a[i]
左边有 i 个元素,每个可选可不选:2ⁱ 种选择
右边不考虑(因为如果选更大的元素,最大值就不是a[i]了)
总次数:2ⁱ
作为最小值:子序列中所有元素 ≥ a[i],且必须包含a[i]
右边有 n-i-1 个元素,每个可选可不选:2ⁿ⁻ⁱ⁻¹ 种选择
左边不考虑
总次数:2ⁿ⁻ⁱ⁻¹
因此,元素
作为最大值贡献:+a[i] × 2ⁱ
作为最小值贡献:-a[i] × 2ⁿ⁻ⁱ⁻¹
总贡献:a[i] × (2ⁱ - 2ⁿ⁻ⁱ⁻¹)
答案 =
对数组排序(从小到大)
预处理2的幂次:pow2[i] = 2ⁱ mod MOD
遍历排序后的数组,计算每个元素的贡献
累加所有贡献,取模
时间复杂度:O(n log n),排序占主导
空间复杂度:O(n),存储幂次
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 排序 sort(a.begin(), a.end()); // 预处理2的幂次 vector<i64> pow2(n + 1); pow2[0] = 1; for (i64 i = 1; i <= n; i++) { pow2[i] = pow2[i - 1] * 2 % MOD; } // 计算贡献 i64 ans = 0; for (i64 i = 0; i < n; i++) { i64 leftPow = pow2[i]; // 2^i i64 rightPow = pow2[n - i - 1]; // 2^(n-i-1) // 贡献 = a[i] × (2^i - 2^(n-i-1)) i64 contribution = a[i] * ((leftPow - rightPow) % MOD) % MOD; ans = (ans + contribution) % MOD; } // 保证非负 if (ans < 0) ans += MOD; cout << ans << "\n"; return 0;}示例:a = [2, 1, 3]
排序后:a = [1, 2, 3]
幂次:pow2[0]=1, pow2[1]=2, pow2[2]=4, pow2[3]=8
i=0 (a[0]=1):
leftPow = 2⁰ = 1
rightPow = 2² = 4
贡献 = 1 × (1-4) = -3
i=1 (a[1]=2):
leftPow = 2¹ = 2
rightPow = 2¹ = 2
贡献 = 2 × (2-2) = 0
i=2 (a[2]=3):
leftPow = 2² = 4
rightPow = 2⁰ = 1
贡献 = 3 × (4-1) = 9
总和 = -3 + 0 + 9 = 6
取模后:6 mod MOD = 6
验证所有子序列宽度:
[1]:0, [2]:0, [3]:0
[1,2]:1, [1,3]:2, [2,3]:1
总和 = 0+0+0+1+2+1+2 = 6 ✓
子序列最值之差需要数学推导而非数据结构:
排序后,元素
排序必要性:只有排序后才能确定元素的排名
组合计数:左边i个元素可选可不选:2ⁱ种
取模处理:减法可能产生负数,要保证非负
第k大值之和:需要更复杂的组合数学
带权宽度:每个元素有权重
其他统计量:中位数、众数等
数学推导有时比算法技巧更有效,特别是对于子序列问题。
给定整数
输入一行包含 2 个正整数
输出一行包含答案。
xxxxxxxxxx4 3
xxxxxxxxxx4
计算 0 到 n 所有数与 m 按位与后的 popcount 之和。
n, m ≤ 2⁶⁰,不能遍历。
关键观察:popcount(k & m) = m 的每个为1的位在 k 中也为1的位数。
对于 m 的第 b 位(从0开始):
如果 m 的该位为 0:对答案无贡献
如果 m 的该位为 1:贡献 = 0到n中该位为1的数的个数
因此:
考虑第 b 位为1的数的规律:
周期:每 2^{b+1} 个数为一个周期
每个周期:前 2^b 个数该位为0,后 2^b 个数该位为1
所以对于 0 到 n(共 n+1 个数):
完整周期数:full = (n+1) / 2^{b+1}
剩余个数:rem = (n+1) % 2^{b+1}
count_b(n) = full × 2^b + max(0, rem - 2^b)
遍历 b = 0 到 59:
如果 m 的第 b 位为1:
计算周期 period = 1 << (b+1)
完整周期 full = (n+1) / period
剩余 rem = (n+1) % period
count = full × (1 << b)
如果 rem > (1 << b):count += rem - (1 << b)
累加到答案
输出答案取模
时间复杂度:O(60) = O(1)
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; i64 ans = 0; // 遍历每个二进制位(0-59位) for (i64 b = 0; b < 60; b++) { if ((m >> b) & 1) { // m的第b位为1 i64 period = 1LL << (b + 1); // 周期长度:2^(b+1) i64 onesPerPeriod = 1LL << b; // 每个周期中1的个数:2^b i64 total = n + 1; // 0到n共n+1个数 i64 fullPeriods = total / period; // 完整周期数 i64 remainder = total % period; // 剩余个数 // 计算该位为1的数的个数 i64 cnt = fullPeriods * onesPerPeriod; if (remainder > onesPerPeriod) { cnt += remainder - onesPerPeriod; } ans = (ans + cnt) % MOD; } } cout << ans << "\n"; return 0;}示例:n=4, m=3
m=3的二进制:11,第0位和第1位为1
b=0(第0位):
period = 2¹ = 2
onesPerPeriod = 2⁰ = 1
total = 5 (0到4共5个数)
fullPeriods = 5/2 = 2
remainder = 5%2 = 1
cnt = 2×1 + max(0,1-1)=2 贡献 = 2
b=1(第1位):
period = 2² = 4
onesPerPeriod = 2¹ = 2
total = 5
fullPeriods = 5/4 = 1
remainder = 5%4 = 1
cnt = 1×2 + max(0,1-2)=2 贡献 = 2
总贡献 = 2+2 = 4
验证:
popcount(0&3)=0
popcount(1&3)=1
popcount(2&3)=1
popcount(3&3)=2
popcount(4&3)=0 总和 = 0+1+1+2+0=4 ✓
二进制中1的个数是按位贡献法的典型应用:
分离位:将popcount分解为每个位的贡献
周期规律:二进制位有明确的周期性
数学计算:用除法代替遍历
对于第b位:
周期长度:
每个周期中1的个数:
0到n中该位为1的个数:
其他位运算:或运算、异或运算等
范围查询:区间[l,r]的popcount和
多维情况:多个数的位运算
位运算问题常考虑按位处理,利用二进制的周期性。
给定长为
第一行包含 1 个正整数
第二行包含
输出一行包含答案。
xxxxxxxxxx30 2 3
xxxxxxxxxx6
计算所有数对(i≤j)的异或值之和。
暴力枚举需要 O(n²),n ≤ 10⁵ 无法通过。
关键观察:异或运算可以按位处理。
对于第 b 位:
如果两个数在该位相同(0,0或1,1):异或结果为0
如果两个数在该位不同(0,1或1,0):异或结果为1
设第 b 位:
有 ones 个数的该位为1
有 zeros = n - ones 个数的该位为0
那么数对在该位贡献1的情况:一个为0,一个为1
有序数对(i≤j)个数:zeros × ones
该位的贡献值:1 << b
总贡献:
注意:这里 zeros × ones 已经包含了所有 i≤j 和 i>j 的情况,但题目要求 i≤j。 实际上,对于异或运算,a⊕b = b⊕a,且当 i=j 时 a⊕a=0。 所以:
无序不同数对:zeros×ones
有序不同数对:2×zeros×ones(因为(i,j)和(j,i)都算)
但题目要求 i≤j,所以就是 zeros×ones(因为当 i<j 时统计一次)
更准确:对于 i≤j:
i=j:贡献0(异或为0)
i<j:贡献 zeros×ones
所以总贡献 = zeros×ones × 2^b
遍历每个位 b = 0 到 30(因为
统计该位为1的个数 ones
zeros = n - ones
贡献 = ones × zeros × (1 << b)
累加贡献,取模
时间复杂度:O(31×n) ≈ O(n)
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 998244353;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0; // 遍历每个二进制位(0-30位) for (i64 b = 0; b < 31; b++) { i64 ones = 0; for (i64 i = 0; i < n; i++) { if ((a[i] >> b) & 1) ones++; } i64 zeros = n - ones; // 贡献 = (0的个数) × (1的个数) × 2^b // 因为i=j时异或为0,i<j时每个不同位贡献1 i64 contribution = zeros * ones % MOD * ((1LL << b) % MOD) % MOD; ans = (ans + contribution) % MOD; } cout << ans << "\n"; return 0;}示例:a = [0, 2, 3]
二进制:
0: 000
2: 010
3: 011
b=0(最低位):
ones: a[2]=3的第0位为1,共1个
zeros: 3-1=2
贡献 = 2×1×1 = 2
b=1:
ones: a[1]=2的第1位为1,a[2]=3的第1位为1,共2个
zeros: 3-2=1
贡献 = 1×2×2 = 4
b=2:
ones: 0个
zeros: 3个
贡献 = 3×0×4 = 0
总贡献 = 2+4+0 = 6
验证数对异或:
(0,0):0, (0,2):2, (0,3):3
(2,2):0, (2,3):1
(3,3):0 总和 = 0+2+3+0+1+0 = 6 ✓
注意:(2,3)的异或=1,对应二进制01,即第0位贡献1,正是我们计算的。
异或和是按位贡献法的又一应用:
对于第b位:
异或性质:相同为0,不同为1
组合计数:0和1配对的组合数
位分离:独立处理每个二进制位
其他位运算:与、或运算的和
区间异或和:需要前缀异或
子序列异或和:更复杂的问题
位运算问题的通用解法:按位处理,统计0和1的个数。
核心:将整体求和问题分解为每个元素贡献了多少。
常见形式:
元素出现在多少个子数组/子序列中
元素作为最值出现在多少区间中
元素对位运算结果的贡献
关键步骤:
分析元素的贡献方式
计算元素的贡献次数
累加贡献:ans += 值 × 次数
核心:维护单调性,高效找到左右边界。
常见应用:
下一个更大/更小元素
柱状图最大矩形
子数组最值问题
关键步骤:
确定单调性(递增/递减)
处理出栈时机
计算相关信息
| 问题 | 核心技巧 | 时间复杂度 |
|---|---|---|
| 子数组之和 | 直接贡献法 | O(n) |
| 子数组最小值之和 | 单调栈+贡献法 | O(n) |
| 子数组最值之差 | 单调栈分别求最值 | O(n) |
| 子序列最值之差 | 排序+组合数学 | O(n log n) |
| 问题 | 核心技巧 | 时间复杂度 |
|---|---|---|
| 柱状图最大矩形 | 单调递增栈 | O(n) |
| 01矩阵最大矩形 | 转化为柱状图 | O(n×m) |
| 接雨水 | 双指针/单调栈 | O(n) |
| 问题 | 核心技巧 | 时间复杂度 |
|---|---|---|
| 二进制中1的个数 | 按位周期统计 | O(位数) |
| 异或和 | 按位统计0/1个数 | O(n×位数) |
子数组出现次数:(i+1) × (n-i)
作为最值的次数:左边界长度 × 右边界长度
位运算贡献:0的个数 × 1的个数
严格单调:用 < 或 >
非严格单调:用 ≤ 或 ≥
边界处理:哨兵技巧简化代码
比较符配对:防止重复计数
第b位的周期:2^{b+1}
每个周期中1的个数:2^b
统计公式:完整周期×每周期1数 + 剩余部分
| 算法 | 平均复杂度 | 适用场景 |
|---|---|---|
| 直接贡献法 | O(n) | 简单统计问题 |
| 单调栈 | O(n) | 区间最值、边界问题 |
| 排序+数学 | O(n log n) | 子序列问题 |
| 按位统计 | O(n×位数) | 位运算问题 |
理解本质:
贡献法:化整为零,分而治之
单调栈:维护单调,高效查找
掌握模板:
单调栈的四种变体
贡献计算的通用公式
灵活应用:
识别问题类型
选择合适方法
注意边界条件
举一反三:
一维到二维的扩展
数组到序列的推广
固定模式到变体的适应
取模运算:
加、减、乘都要取模
减法后要保证非负
边界处理:
数组索引从0还是1开始
哨兵的使用
循环终止条件
比较符选择:
严格与非严格
左右边界的配对
溢出问题:
使用i64(long long)
乘法前取模
记住:贡献法的核心是"每个元素贡献了多少",单调栈的核心是"维护单调性以高效查找"。掌握这两种思想,能解决一大类区间统计问题。
多练习、多思考,才能在遇到新问题时快速识别模式,选择正确解法!
给定一个长度为
子数组是数组中的一个连续部分。
第一行包含一个整数
第二行包含
输出一个整数,表示最大子数组和。
xxxxxxxxxx6-2 1 -3 4 -1 2 1 -5 4
xxxxxxxxxx6
xxxxxxxxxx1-10
xxxxxxxxxx-10
本题要求在数组中找到一个连续子数组,使得其和最大。这是一个经典的动态规划问题。
动态规划(Kadane算法):维护以当前位置结尾的最大子数组和。
状态转移:要么从当前元素重新开始,要么接上前面的子数组。
空间优化:只需前一个状态,因此可以使用单个变量代替数组。
设 dp[i] 表示以第 i 个元素结尾的最大子数组和,则有:
如果 dp[i-1] + a[i] < a[i],说明前面的和是负贡献,不如从 a[i] 重新开始。
全局最大和即为所有 dp[i] 中的最大值。
初始化 cur = a[0], ans = a[0]
遍历 i 从 1 到 n-1:
cur = max(a[i], cur + a[i])
ans = max(ans, cur)
输出 ans
时间复杂度:O(n),只需一次遍历。
空间复杂度:O(1),只使用常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; i64 cur = a[0], ans = a[0]; // cur: 以当前位置结尾的最大和,ans: 全局最大和 for (i64 i = 1; i < n; i++) { cur = max(a[i], cur + a[i]); // 状态转移:重新开始或接上前面的子数组 ans = max(ans, cur); // 更新全局最大值 } cout << ans << "\n"; return 0;}[-2, 1, -3, 4, -1, 2, 1, -5, 4]遍历过程:
| i | a[i] | cur (更新前) | cur (更新后) | ans (更新后) |
|---|---|---|---|---|
| 0 | -2 | - | -2 | -2 |
| 1 | 1 | -2 | max(1, -2+1) = 1 | 1 |
| 2 | -3 | 1 | max(-3, 1-3) = -2 | 1 |
| 3 | 4 | -2 | max(4, -2+4) = 4 | 4 |
| 4 | -1 | 4 | max(-1, 4-1) = 3 | 4 |
| 5 | 2 | 3 | max(2, 3+2) = 5 | 5 |
| 6 | 1 | 5 | max(1, 5+1) = 6 | 6 |
| 7 | -5 | 6 | max(-5, 6-5) = 1 | 6 |
| 8 | 4 | 1 | max(4, 1+4) = 5 | 6 |
最大子数组:[4, -1, 2, 1],和为 6。
本题是动态规划最经典的入门题之一,Kadane 算法高效且优雅。
状态定义:cur 表示以当前位置结尾的最大子数组和。
状态转移:cur = max(a[i], cur + a[i]),决定是重新开始还是延续。
全局维护:使用 ans 记录遍历过程中出现的最大值。
高效简洁:O(n) 时间,O(1) 空间。
一次遍历:无需额外数组,边读边计算。
通用性强:该思想可扩展至二维或带权问题。
如果要求输出该子数组的起止位置?
在更新 cur 时记录起始点,当 cur == a[i] 时说明重新开始,更新起始点为 i。
在更新 ans 时记录起始和结束位置。
算法(Kadane算法-带区间追踪)
初始化:
cur_sum = a[0]
max_sum = a[0]
当前子数组起点 cur_start = 0
最佳子数组起点 best_start = 0, 终点 best_end = 0
遍历 i = 1 到 n-1:
如果 cur_sum + a[i] < a[i],说明从 i 重新开始更优:
cur_sum = a[i]
cur_start = i 否则,延续当前子数组:
cur_sum = cur_sum + a[i]
如果 cur_sum > max_sum:
max_sum = cur_sum
best_start = cur_start
best_end = i
初始化:cur = -2, ans = -2, start = 0, best_start = 0, best_end = 0
| i | a[i] | cur = max(a[i], cur+a[i]) | 是否重置起点? | ans = max(ans, cur) | 当前子数组范围 [start, i] | 最大子数组 [best_start, best_end] |
|---|---|---|---|---|---|---|
| 0 | -2 | -2 | - | -2 | [0,0] | [0,0] (和=-2) |
| 1 | 1 | max(1, -2+1)= 1 | 是 (cur=a[i]) | 1 | [1,1] | [1,1] (和=1) |
| 2 | -3 | max(-3, 1-3)= -2 | 否 | 1 | [1,2] | [1,1] |
| 3 | 4 | max(4, -2+4)= 4 | 是 | 4 | [3,3] | [3,3] (和=4) |
| 4 | -1 | max(-1, 4-1)= 3 | 否 | 4 | [3,4] | [3,3] |
| 5 | 2 | max(2, 3+2)= 5 | 否 | 5 | [3,5] | [3,5] (和=5) |
| 6 | 1 | max(1, 5+1)= 6 | 否 | 6 | [3,6] | [3,6] (和=6) |
| 7 | -5 | max(-5, 6-5)= 1 | 否 | 6 | [3,7] | [3,6] |
| 8 | 4 | max(4, 1+4)= 5 | 否 | 6 | [3,8] | [3,6] |
结果说明
最大和:ans = 6
对应子数组:从索引 3 到索引 6,即 [4, -1, 2, 1]
算法核心:当 cur + a[i] < a[i] 时重置起点,否则延续;每次 ans 更新时记录最佳区间。
判断“是否重置起点”的规则来自 Kadane 算法的逻辑,具体判断条件如下:
起点是否重置-判断条件
当 cur + a[i] < a[i] 时,重置起点。
化简这个不等式:
cur + a[i] < a[i] → cur < 0
也就是说:
如果 cur < 0:那么当前累加和已经为负数,加上 a[i] 还不如直接从 a[i] 重新开始,所以重置起点为 i。
如果 cur >= 0:即使 a[i] 是负数,也可能后面有更大的正数让总和更大,所以延续当前子数组。
给你一个整数数组
子数组是数组中的一个连续部分。
注意:题目保证答案在 int 范围内。
第一行一个整数
第二行有
输出一行,包含答案。
xxxxxxxxxx42 3 -2 4
xxxxxxxxxx6
xxxxxxxxxx3-2 0 -1
xxxxxxxxxx0
xxxxxxxxxx55 6 -3 4 -3
xxxxxxxxxx1080
本题要求在一个整数数组中找到乘积最大的连续子数组。
与最大子数组和不同,乘积需要考虑:
负数相乘:负负得正,可能得到更大的正数
零的影响:遇到零会使乘积归零
正负交替:需要同时维护最大和最小乘积
双状态动态规划:同时维护以当前位置结尾的 最大乘积 和 最小乘积。
状态转移:当前元素、最大乘积×当前元素、最小乘积×当前元素,三者取最大/最小。
空间优化:只需前一个状态,因此用变量维护。
设 max_dp[i] 和 min_dp[i] 分别表示以第 i 个元素结尾的子数组的最大和最小乘积,则有:
max_dp[i] 中的最大值。
初始化 ma = a[0], mi = a[0], ans = a[0]
遍历 i 从 1 到 n-1:
计算 x = ma * a[i], y = mi * a[i]
更新 ma = max(a[i], max(x, y))
更新 mi = min(a[i], min(x, y))
更新 ans = max(ans, ma)
输出 ans
时间复杂度:O(n),只需一次遍历。
空间复杂度:O(1),只使用常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ma = a[0], mi = a[0], ans = a[0]; // ma: 当前最大乘积, mi: 当前最小乘积, ans: 全局最大乘积 for (i64 i = 1; i < n; i++) { i64 x = a[i] * ma, y = a[i] * mi; // 计算两个可能的乘积值,考虑a[i]有可能是负数的情况 ma = max(a[i], max(x, y)); // 更新当前最大乘积 mi = min(a[i], min(x, y)); // 更新当前最小乘积 ans = max(ans, ma); // 更新全局答案 } cout << ans << "\n"; return 0;}[2, 3, -2, 4]详细计算过程:
| i | a[i] | 计算 x=a[i]*ma, y=a[i] * mi | ma (更新后) ma=max(a[i],max(x,y)) | mi (更新后) mi=min(a[i],min(x,y)) | ans ans=max(ans,ma) |
|---|---|---|---|---|---|
| 0 | 2 | 初始化 | 2 | 2 | 2 |
| 1 | 3 | x= 3 * 2=6 y= 3 * 2=6 | max(3,6,6)=6 | min(3,6,6)=3 | max(2,6)=6 |
| 2 | -2 | x=-2 * 6=-12 y=-2 * 3=-6 | max(-2,-12,-6)=-2 | min(-2,-12,-6)=-12 | max(6,-2)=6 |
| 3 | 4 | x= 4 * (-2)=-8 y= 4 * (-12)=-48 | max(4,-8,-48)=4 | min(4,-8,-48)=-48 | max(6,4)=6 |
最终结果:最大乘积为 6,对应子数组 [2, 3]
[-2, 0, -1]详细计算过程:
| i | a[i] | 计算 x=a[i]*ma, y=a[i] * mi | ma (更新后) ma=max(a[i],max(x,y)) | mi (更新后) mi=min(a[i],min(x,y)) | ans ans=max(ans,ma) |
|---|---|---|---|---|---|
| 0 | -2 | 初始化 | -2 | -2 | -2 |
| 1 | 0 | x= 0 * (-2)=0 y= 0 * (-2)=0 | max(0,0,0)=0 | min(0,0,0)=0 | max(-2,0)=0 |
| 2 | -1 | x=-1 * 0=0 y=-1 * 0=0 | max(-1,0,0)=0 | min(-1,0,0)=-1 | max(0,0)=0 |
最终结果:最大乘积为 0,对应子数组 [0]
[5, 6, -3, 4, -3]详细计算过程:
| i | a[i] | 计算 x=a[i]ma, y=a[i]mi | ma (更新后) ma=max(a[i], max(x,y)) | mi (更新后) mi=min(a[i], min(x,y)) | ans ans=max(ans, ma) |
|---|---|---|---|---|---|
| 0 | 5 | 初始化 | 5 | 5 | 5 |
| 1 | 6 | x= 6 * 5=30 y= 6 * 5=30 | max(6,30,30)=30 | min(6,30,30)=6 | max(5,30)=30 |
| 2 | -3 | x=-3 * 30=-90 y=-3 * 6=-18 | max(-3,-90,-18)=-3 | min(-3,-90,-18)=-90 | max(30,-3)=30 |
| 3 | 4 | x= 4 * (-3)=-12 y= 4 * (-90)=-360 | max(4,-12,-360)=4 | min(4,-12,-360)=-360 | max(30,4)=30 |
| 4 | -3 | x=-3 * 4=-12 y=-3 * (-360)=1080 | max(-3,-12,1080)=1080 | min(-3,-12,1080)=-12 | max(30,1080)=1080 |
最终结果:最大乘积为 1080,对应子数组 [5, 6, -3, 4, -3](乘积为 5 × 6 × (-3) × 4 × (-3) = 1080)
推导说明:
在 i=4 时,由于前一步的 mi = -360(很大的负数),乘上 a[i] = -3 得到 y = 1080,超过了之前的最大值。
这正是最大乘积子数组问题中,负数乘负数可能得到更大正数的典型情况,因此必须同时记录 ma(最大值)和 mi(最小值)。
本题是双状态动态规划的典型应用,关键在于同时维护最大和最小乘积。
双状态维护:ma 和 mi 分别记录以当前位置结尾的最大和最小乘积。
负负得正:最小乘积乘以负数可能变成最大乘积。
三种候选:状态转移时考虑 a[i]、ma*a[i]、mi*a[i] 三者。
处理符号变化:完美应对正负交替和零值。
高效简洁:O(n) 时间,O(1) 空间。
一次遍历:实时更新,无需预处理。
如果要求输出该子数组的起止位置?
需要记录每个状态的起始位置,当 ma 或 mi 更新为 a[i] 时,起始位置重置为 i。
当 ans 更新时,记录当前的起始和结束位置。
最大乘积子数组(带起止位置)算法推导
一. 算法步骤(带起止位置)
初始化:
ma = a[0], mi = a[0], ans = a[0]
ma_start = mi_start = ans_start = ans_end = 0
遍历 i = 1 到 n-1:
计算 x = a[i] * ma, y = a[i] * mi
更新:
ma_new = max(a[i], x, y)
mi_new = min(a[i], x, y)
更新起点:
如果 ma_new == a[i] :ma_start = i(重置起点)
否则如果 ma_new == x :ma_start 不变(延续 ma 的起点)
否则(ma_new == y):ma_start = mi_start(延续 mi 的起点)
如果 mi_new == a[i] :mi_start = i(重置起点)
否则如果 mi_new == x :mi_start = ma_start_old(延续 ma 的起点)
否则(mi_new == y):mi_start 不变(延续 mi 的起点)
如果 ma_new > ans:
ans = ma_new
ans_start = ma_start
ans_end = i
二. 对数组 [5, 6, -3, 4, -3] 的完整推导
初始状态:
xxxxxxxxxxma = 5, mi = 5, ans = 5ma_start = 0, mi_start = 0ans_start = 0, ans_end = 0
第 1 步 (i=1, a[1]=6)
xxxxxxxxxxx = 6*5 = 30, y = 6*5 = 30ma_new = max(6, 30, 30) = 30 ← 来自 xmi_new = min(6, 30, 30) = 6 ← 来自 a[i]
起点更新:
ma 来自 x → ma_start 不变 (0)
mi 来自 a[i] → mi_start = 1
答案更新:
ma=30 > ans=5 → ans=30, ans_start=0, ans_end=1
当前状态:
ma=30, mi=6, ma_start=0, mi_start=1, ans=30, ans_range=[0,1]
第 2 步 (i=2, a[2]=-3)
xxxxxxxxxxx = -3*30 = -90, y = -3*6 = -18ma_new = max(-3, -90, -18) = -3 ← 来自 a[i]mi_new = min(-3, -90, -18) = -90 ← 来自 x
起点更新:
ma 来自 a[i] → ma_start = 2
mi 来自 x → mi_start = ma_start_old = 0
答案未更新(-3 < 30)
当前状态:
ma=-3, mi=-90, ma_start=2, mi_start=0, ans=30, ans_range=[0,1]
第 3 步 (i=3, a[3]=4)
xxxxxxxxxxx = 4*(-3) = -12, y = 4*(-90) = -360ma_new = max(4, -12, -360) = 4 ← 来自 a[i]mi_new = min(4, -12, -360) = -360 ← 来自 y
起点更新:
ma 来自 a[i] → ma_start = 3
mi 来自 y → mi_start 不变 (0)
答案未更新(4 < 30)
当前状态:
ma=4, mi=-360, ma_start=3, mi_start=0, ans=30, ans_range=[0,1]
第 4 步 (i=4, a[4]=-3)
xxxxxxxxxxx = -3*4 = -12, y = -3*(-360) = 1080ma_new = max(-3, -12, 1080) = 1080 ← 来自 ymi_new = min(-3, -12, 1080) = -12 ← 来自 x
起点更新:
ma 来自 y → ma_start = mi_start_old = 0
mi 来自 x → mi_start = ma_start_old = 3
答案更新:
ma=1080 > ans=30 → ans=1080, ans_start=0, ans_end=4
最终状态:
ma=1080, mi=-12, ma_start=0, mi_start=3, ans=1080, ans_range=[0,4]
三. 总结表格
| i | a[i] | x, y | ma_new(来源) | mi_new(来源) | ma_start | mi_start | ans | ans_start | ans_end |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 5 | - | 5 | 5 | 0 | 0 | 5 | 0 | 0 |
| 1 | 6 | 30,30 | 30(x) | 6(a[i]) | 0 | 1 | 30 | 0 | 1 |
| 2 | -3 | -90,-18 | -3(a[i]) | -90(x) | 2 | 0 | 30 | 0 | 1 |
| 3 | 4 | -12,-360 | 4(a[i]) | -360(y) | 3 | 0 | 30 | 0 | 1 |
| 4 | -3 | -12,1080 | 1080(y) | -12(x) | 0 | 3 | 1080 | 0 | 4 |
四. 最终结果
最大乘积:1080
对应子数组:[5, 6, -3, 4, -3](索引 0 到 4)
关键机制:第 4 步中,最大乘积来自 mi_old * a[i](负数×负数得正数),因此继承了 mi_start=0 作为起点。
对于给定的整数序列
输入的第一行包含一个正整数
输入的第二行包含
输出一行包含一个整数,表示题目询问的答案。
xxxxxxxxxx101 -1 2 3 -3 4 -4 5 -5
xxxxxxxxxx13
本题要求找到两个不重叠的连续子数组,使得它们的和最大。
关键点:
两个子段不重叠
子段可以是任意长度(至少包含一个元素)
子段位置可以任意
前后缀分解:枚举分割点,将问题分解为左半部分的最大子段和与右半部分的最大子段和。
预处理优化:预先计算每个位置的前缀最大子段和和后缀最大子段和。
枚举分割点:遍历所有可能的分割点,计算左右两部分最大子段和之和,取最大值。
设:
pre[i] 表示 a[0..i] 中的最大子段和。
suf[i] 表示 a[i..n-1] 中的最大子段和。
对于分割点 i(0 ≤ i < n-1),左段在 [0, i],右段在 [i+1, n-1],则最大两段和为:
计算前缀最大子段和 pre[i]:
从左到右遍历,使用 Kadane 算法。
pre[i] = max(pre[i-1], 以 i 结尾的最大子段和)。
计算后缀最大子段和 suf[i]:
从右到左遍历,使用 Kadane 算法。
suf[i] = max(suf[i+1], 以 i 开始的最大子段和)。
枚举分割点:
遍历 i 从 0 到 n-2,计算 pre[i] + suf[i+1],更新答案。
时间复杂度:O(n),三次遍历数组。
空间复杂度:O(n),存储前缀和后缀数组。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; vector<i64> pre(n), suf(n); // pre[i]: a[0..i]的最大子段和, suf[i]: a[i..n-1]的最大子段和 // 从左到右计算前缀最大子段和 i64 dp = a[0]; pre[0] = a[0]; for (i64 i = 1; i < n; i++) { dp = max(a[i], dp + a[i]); // 以i结尾的最大子段和 pre[i] = max(pre[i-1], dp); // 更新前缀最大值 } // 从右到左计算后缀最大子段和 dp = a[n-1]; suf[n-1] = a[n-1]; for (i64 i = n-2; i >= 0; i--) { dp = max(a[i], dp + a[i]); // 以i开始的最大子段和 suf[i] = max(suf[i+1], dp); // 更新后缀最大值 } // 枚举分割点,求最大两段和 i64 ans = LLONG_MIN; for (i64 i = 0; i < n-1; i++) { ans = max(ans, pre[i] + suf[i+1]); // 左边取a[0..i],右边取a[i+1..n-1] } cout << ans << "\n"; return 0;}[1, -1, 2, 3, -3, 4, -4, 5, -5](按10个数但示例给9个,这里按9个处理)步骤1:计算前缀最大子段和 pre[i]
| i | a[i] | dp (以i结尾的最大子段和) | pre[i] (前缀最大值) |
|---|---|---|---|
| 0 | 1 | 1 | 1 |
| 1 | -1 | max(-1, 1-1)=0 | max(1,0)=1 |
| 2 | 2 | max(2, 0+2)=2 | max(1,2)=2 |
| 3 | 3 | max(3, 2+3)=5 | max(2,5)=5 |
| 4 | -3 | max(-3, 5-3)=2 | max(5,2)=5 |
| 5 | 4 | max(4, 2+4)=6 | max(5,6)=6 |
| 6 | -4 | max(-4, 6-4)=2 | max(6,2)=6 |
| 7 | 5 | max(5, 2+5)=7 | max(6,7)=7 |
| 8 | -5 | max(-5, 7-5)=2 | max(7,2)=7 |
步骤2:计算后缀最大子段和 suf[i]
| i | a[i] | dp (从i开始的最大子段和) | suf[i] (后缀最大值) |
|---|---|---|---|
| 8 | -5 | -5 | -5 |
| 7 | 5 | max(5, -5+5)=5 | max(-5,5)=5 |
| 6 | -4 | max(-4, 5-4)=1 | max(5,1)=5 |
| 5 | 4 | max(4, 1+4)=5 | max(5,5)=5 |
| 4 | -3 | max(-3, 5-3)=2 | max(5,2)=5 |
| 3 | 3 | max(3, 2+3)=5 | max(5,5)=5 |
| 2 | 2 | max(2, 5+2)=7 | max(5,7)=7 |
| 1 | -1 | max(-1, 7-1)=6 | max(7,6)=7 |
| 0 | 1 | max(1, 6+1)=7 | max(7,7)=7 |
步骤3:枚举分割点
| 分割点 i | pre[i] | suf[i+1] | 和 |
|---|---|---|---|
| 0 | 1 | 7 | 8 |
| 1 | 1 | 7 | 8 |
| 2 | 2 | 5 | 7 |
| 3 | 5 | 5 | 10 |
| 4 | 5 | 5 | 10 |
| 5 | 6 | 5 | 11 |
| 6 | 6 | 5 | 11 |
| 7 | 7 | -5 | 2 |
最大值为 11,但题目输出是 13,说明数组可能是 [1, -1, 2, 2, 3, -3, 4, -4, 5, -5](10个数),重新计算后最大两段和可达13。
本题通过前后缀分解将复杂问题转化为两个独立的子问题,是处理分段最值问题的典型方法。
分割思想:枚举分割点,将数组分为左右两部分。
预处理优化:预先计算每个位置的前缀最大子段和和后缀最大子段和,使枚举时能 O(1) 获取。
独立求解:左右两部分的最大子段和可独立计算,互不干扰。
思路清晰:将两段问题分解为两个单段问题。
高效可行:O(n) 时间复杂度,O(n) 空间复杂度。
扩展性强:可推广到 k 段最大和问题。
如果要求三段最大和?
可以继续分解:枚举两个分割点,将数组分为三段。
预处理前缀、中缀、后缀的最大子段和。
时间复杂度 O(n²) 或通过更巧妙的预处理优化到 O(n)。
给你一个长度为
子数组是数组中的一个连续部分。
环形数组: 意味着数组的末端将会与开头相连呈环状。形式上,
第一行一个整数
第二行有
对于每组数据输出一行,包含答案。
xxxxxxxxxx41 -2 3 -2
xxxxxxxxxx3
xxxxxxxxxx35 -3 5
xxxxxxxxxx10
xxxxxxxxxx3-3 -2 -3
xxxxxxxxxx-2
本题要求在环形数组中找到最大子数组和。与普通数组不同,环形数组允许子数组跨越数组的首尾。
分类讨论:将环形问题转化为两个非环形问题。
情况一:最大子数组不跨越首尾,即普通的最大子数组和。
情况二:最大子数组跨越首尾,此时相当于总和减去中间的最小子数组和。
特殊情况:全负数时,最大子数组和就是最大的单个元素。
设:
max_sum:普通最大子数组和(Kadane算法)。
min_sum:普通最小于数组和(类似Kadane,但取min)。
total_sum:数组所有元素之和。
则环形最大子数组和可能为:
max_sum(不跨越首尾)。
total_sum - min_sum(跨越首尾,即总和减去中间被跳过的部分)。
特殊情况:当 max_sum < 0(全负数)时,情况二的结果为0(因为 min_sum = total_sum),但0不是有效子数组和(子数组至少包含一个元素),此时应取 max_sum。
初始化 cur_max = a[0], cur_min = a[0], max_sum = a[0], min_sum = a[0], total_sum = a[0]。
遍历 i 从 1 到 n-1:
更新 cur_max = max(a[i], cur_max + a[i]),max_sum = max(max_sum, cur_max)。
更新 cur_min = min(a[i], cur_min + a[i]),min_sum = min(min_sum, cur_min)。
累加 total_sum += a[i]。
环形最大和候选 cir_max = total_sum - min_sum。
若 max_sum < 0(全负数),则输出 max_sum;否则输出 max(max_sum, cir_max)。
时间复杂度:O(n),一次遍历完成所有计算。
空间复杂度:O(1),只使用常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 初始化 i64 cur_max = a[0], cur_min = a[0]; // 当前最大/最小子数组和 i64 max_sum = a[0], min_sum = a[0]; // 全局最大/最小子数组和 i64 total_sum = a[0]; // 数组总和 // 一次遍历计算所有值 for (i64 i = 1; i < n; i++) { // 更新当前最大子数组和 cur_max = max(a[i], cur_max + a[i]); max_sum = max(max_sum, cur_max); // 更新当前最小子数组和 cur_min = min(a[i], cur_min + a[i]); min_sum = min(min_sum, cur_min); // 累加总和 total_sum += a[i]; } // 环形最大和 = 总和 - 最小子数组和 i64 cir_max = total_sum - min_sum; // 特殊情况:如果所有数都是负数,取最大的单个元素 // 判断标准:max_sum < 0(最大子数组和为负数) i64 ans = (max_sum < 0) ? max_sum : max(max_sum, cir_max); cout << ans << "\n"; return 0;}[1, -2, 3, -2]计算过程:
max_sum(普通最大和):子数组 [3],和为 3
min_sum(普通最小和):子数组 [-2] 或 [1, -2, 3, -2],和为 -3
total_sum(总和):1 + (-2) + 3 + (-2) = 0
cir_max(环形最大和):0 - (-3) = 3
最终答案:max(3, 3) = 3
解释:环形情况下,可以取 [3] 或 [3, -2, 1](跨越首尾),和都是3。
[5, -3, 5]计算过程:
max_sum(普通最大和):子数组 [5] 或 [5, -3, 5],和为 7
min_sum(普通最小和):子数组 [-3],和为 -3
total_sum(总和):5 + (-3) + 5 = 7
cir_max(环形最大和):7 - (-3) = 10
最终答案:max(7, 10) = 10
解释:环形最大子数组为 [5, 5](跨越首尾),和为 10。
[-3, -2, -3]计算过程:
max_sum(普通最大和):子数组 [-2],和为 -2
min_sum(普通最小和):子数组 [-3, -2, -3],和为 -8
total_sum(总和):-3 + (-2) + (-3) = -8
cir_max(环形最大和):-8 - (-8) = 0
最终答案:由于 max_sum < 0,取 max_sum = -2
解释:所有数都是负数,最大子数组为 [-2]。
本题通过分类讨论和问题转化,将环形数组问题巧妙地转化为熟悉的非环形问题。
两种情况:不跨越首尾(普通最大和)和跨越首尾(总和减最小和)。
转化思想:跨越首尾的最大子数组 = 总和 - 中间被跳过的部分(最小子数组)。
边界处理:全负数时,环形情况计算结果为0,但子数组不能为空,需取普通最大和。
全面覆盖:涵盖环形数组所有可能情况。
高效简洁:一次遍历完成,O(n) 时间,O(1) 空间。
鲁棒性强:正确处理全负数等边界情况。
如果要求输出该子数组的起止位置?
需要记录最大子数组的起止位置和最小子数组的起止位置。
对于环形情况,跨越首尾的子数组由最小子数组的左右边界决定(取补集)。
注意全负数时的特殊情况。
给定一个数组
你只能选择某一天买入这只股票,并选择在未来的某一个不同的日子卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回0。
第一行包含1个正整数
第二行包含
输出最大利润
xxxxxxxxxx67 1 5 3 6 4
xxxxxxxxxx5
xxxxxxxxxx57 6 4 3 1
xxxxxxxxxx0
本题要求只能进行一次交易(一次买入和一次卖出),求最大利润。【低买高卖获取最大利润】
关键限制:
只能交易一次
必须先买入后卖出
买入日必须在卖出日之前
状态机动态规划:定义两个状态,buy(买入后)和 sell(卖出后)。
状态转移:
buy:要么之前已买入,要么今天买入(花费 -price)。
sell:要么之前已卖出,要么今天卖出(利润 = buy + price)。
空间优化:只需前一个状态,用变量维护。
定义:
buy:到当前位置为止,买入股票后的最大利润(买入价为负,表示花费)。
sell:到当前位置为止,卖出股票后的最大利润。
状态转移:
buy = -prices[0], sell = 0。
最终答案即为 sell。
初始化 buy = -a[0], sell = 0。
遍历 i 从 1 到 n-1:
buy = max(buy, -a[i])
sell = max(sell, buy + a[i])
输出 sell。
时间复杂度:O(n),一次遍历。
空间复杂度:O(1),常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 第一天就开始买入,buy: 当前买入后的最大利润, sell: 当前卖出后的最大利润 i64 buy = -a[0], sell = 0; for (i64 i = 1; i < n; i++) { buy = max(buy, -a[i]); // 更新买入状态:今天买入或保持之前买入 sell = max(sell, buy + a[i]); // 更新卖出状态:今天卖出或保持之前卖出 } cout << sell << "\n"; return 0;}[7, 1, 5, 3, 6, 4]遍历过程:
| i | price | buy (更新前) | sell (更新前) | buy (更新后) buy = max(buy, -a[i]) | sell (更新后) sell = max(sell, buy + a[i]) |
|---|---|---|---|---|---|
| 0 | 7 | -7 (初始化) | 0 (初始化) | -7 | 0 |
| 1 | 1 | -7 | 0 | max(-7, -1) = -1 | max(0, -1+1) = 0 |
| 2 | 5 | -1 | 0 | max(-1, -5) = -1 | max(0, -1+5) = 4 |
| 3 | 3 | -1 | 4 | max(-1, -3) = -1 | max(4, -1+3) = 4 |
| 4 | 6 | -1 | 4 | max(-1, -6) = -1 | max(4, -1+6) = 5 |
| 5 | 4 | -1 | 5 | max(-1, -4) = -1 | max(5, -1+4) = 5 |
最终利润:5,在第 2 天买入(价格1),第 5 天卖出(价格6)。
[7, 6, 4, 3, 1]遍历过程:
| i | price | buy (更新前) | sell (更新前) | buy (更新后) buy = max(buy, -a[i]) | sell (更新后) sell = max(sell, buy + a[i]) |
|---|---|---|---|---|---|
| 0 | 7 | -7 | 0 | -7 | 0 |
| 1 | 6 | -7 | 0 | max(-7, -6) = -6 | max(0, -6+6) = 0 |
| 2 | 4 | -6 | 0 | max(-6, -4) = -4 | max(0, -4+4) = 0 |
| 3 | 3 | -4 | 0 | max(-4, -3) = -3 | max(0, -3+3) = 0 |
| 4 | 1 | -3 | 0 | max(-3, -1) = -1 | max(0, -1+1) = 0 |
最终利润:0,价格持续下跌,无法获利。
本题是状态机动态规划的入门题,通过定义两个状态清晰地描述了交易过程。
状态定义:buy 和 sell 分别表示买入后和卖出后的最大利润。
状态转移:
买入:buy = max(buy, -price)(今天买入或保持之前买入)。
卖出:sell = max(sell, buy + price)(今天卖出或保持之前卖出)。
初始化:第一天只能买入,buy = -prices[0];第一天不能卖出,sell = 0。
一次遍历:O(n) 时间,O(1) 空间。
状态清晰:两个状态恰好描述一次交易。
易于扩展:此框架可扩展至多次交易。
如果要求输出买入和卖出日期?
在更新 buy 时,若 buy 更新为 -price,记录买入日期 i。
在更新 sell 时,若 sell 更新为 buy + price,记录卖出日期 i。
注意买入日期可能随 buy 更新而更新,但卖出日期依赖于当前的 buy 状态。
给定一个数组
在每一天,你可以决定是否购买和/或出售股票。你在任何时候最多只能持有一股股票。你也可以先购买,然后在同一天出售。
返回你能获得的最大利润。
第一行包含 1 个正整数
第二行包含
输出最大利润
xxxxxxxxxx67 1 5 3 6 4
xxxxxxxxxx7
xxxxxxxxxx51 2 3 4 5
xxxxxxxxxx4
本题允许无限次交易,但限制:
任何时候最多只能持有一股股票
必须先买入才能卖出
可以在同一天买入并卖出(相当于不操作)
贪心算法:只要后一天价格高于前一天,就在前一天买入、后一天卖出。
利润分解:总利润等于所有正的价格差之和。
正确性证明:对于任何连续上涨区间,多次交易利润等于一次交易的利润。
对于任意连续上涨区间 [a, b, c, d](价格递增):
一次交易:a 买入,d 卖出,利润 = d - a。
多次交易:a->b, b->c, c->d,利润 = (b-a) + (c-b) + (d-c) = d - a。
因此,总利润可以分解为所有相邻正差之和。
初始化 ans = 0。
遍历 i 从 1 到 n-1:
如果 a[i] > a[i-1],则 ans += a[i] - a[i-1]。
输出 ans。
时间复杂度:O(n),一次遍历。
空间复杂度:O(1),常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0; // 总利润 for (i64 i = 1; i < n; i++) { if (a[i] > a[i-1]) { // 如果今天价格高于昨天 ans += a[i] - a[i-1]; // 累加利润 } } cout << ans << "\n"; return 0;}[7, 1, 5, 3, 6, 4]遍历过程:
| i | price | 前一天价格 | 是否上涨 | 利润增加 | 累计利润 |
|---|---|---|---|---|---|
| 1 | 1 | 7 | 否 | 0 | 0 |
| 2 | 5 | 1 | 是 | 4 | 4 |
| 3 | 3 | 5 | 否 | 0 | 4 |
| 4 | 6 | 3 | 是 | 3 | 7 |
| 5 | 4 | 6 | 否 | 0 | 7 |
总利润 = 4 + 3 = 7
交易策略:
第2天买入(价格1),第3天卖出(价格5),利润4
第4天买入(价格3),第5天卖出(价格6),利润3
[1, 2, 3, 4, 5]遍历过程:
| i | price | 前一天价格 | 是否上涨 | 利润增加 | 累计利润 |
|---|---|---|---|---|---|
| 1 | 2 | 1 | 是 | 1 | 1 |
| 2 | 3 | 2 | 是 | 1 | 2 |
| 3 | 4 | 3 | 是 | 1 | 3 |
| 4 | 5 | 4 | 是 | 1 | 4 |
总利润 = 1+1+1+1 = 4
等价于:第1天买入(价格1),第5天卖出(价格5),利润4
本题的贪心算法简洁高效,是无限次交易问题的标准解法。
利润分解:总利润等于所有相邻正价格差之和。
贪心正确性:对于任何上涨区间,分段交易与一次性交易利润相同。
实现简单:只需一次遍历,累加正差值。
极其高效:O(n) 时间,O(1) 空间。
代码简洁:逻辑清晰,易于实现。
直观易懂:符合“低买高卖”的直觉。
如果加上交易手续费怎么办?
贪心算法不再适用,因为频繁交易可能因手续费而得不偿失。
需要使用动态规划,状态设计类似有限次交易,但状态转移时考虑手续费。
给定一个数组
设计一个算法来计算你所能获取的最大利润。你最多可以完成 2笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
第一行包含 1 个正整数
第二行包含
输出最大利润
xxxxxxxxxx83 3 5 0 0 3 1 4
xxxxxxxxxx6
xxxxxxxxxx51 2 3 4 5
xxxxxxxxxx4
本题限制最多进行 2笔交易,且不能同时持有两支股票。
状态机动态规划:定义四个状态,分别表示第一次买入后、第一次卖出后、第二次买入后、第二次卖出后的最大利润。
状态转移:每个状态可以由保持原状态或今天操作转移而来。
空间优化:只需前一个状态,用变量维护。
定义四个状态:
buy1:第一次买入后的最大利润。
sell1:第一次卖出后的最大利润。
buy2:第二次买入后的最大利润。
sell2:第二次卖出后的最大利润。
状态转移:
buy1 = buy2 = -prices[0], sell1 = sell2 = 0。
最终答案为 sell2。
初始化 buy1 = buy2 = -a[0], sell1 = sell2 = 0。
遍历 i 从 1 到 n-1:
buy1 = max(buy1, -a[i])
sell1 = max(sell1, buy1 + a[i])
buy2 = max(buy2, sell1 - a[i])
sell2 = max(sell2, buy2 + a[i])
输出 sell2。
时间复杂度:O(n),一次遍历。
空间复杂度:O(1),常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 初始化四个状态 i64 buy1 = -a[0], sell1 = 0; // 第一次交易状态 i64 buy2 = -a[0], sell2 = 0; // 第二次交易状态 for (i64 i = 1; i < n; i++) { // 更新第一次交易状态 buy1 = max(buy1, -a[i]); // 第一次买入 sell1 = max(sell1, buy1 + a[i]); // 第一次卖出 // 更新第二次交易状态 buy2 = max(buy2, sell1 - a[i]); // 第二次买入 sell2 = max(sell2, buy2 + a[i]); // 第二次卖出 } cout << sell2 << "\n"; // 最终答案在第二次卖出状态 return 0;}[3, 3, 5, 0, 0, 3, 1, 4]遍历过程(简化):
初始化:buy1=-3, sell1=0, buy2=-3, sell2=0
i=1: buy1=-3, sell1=0, buy2=-3, sell2=0
i=2: buy1=-3, sell1=2, buy2=-3, sell2=2
i=3: buy1=-3, sell1=2, buy2=2, sell2=2
i=4: buy1=-3, sell1=2, buy2=2, sell2=2
i=5: buy1=-3, sell1=2, buy2=2, sell2=5
i=6: buy1=-3, sell1=2, buy2=2, sell2=5
i=7: buy1=-3, sell1=3, buy2=2, sell2=6
最终利润:6
交易策略:
第4天买入(价格0),第6天卖出(价格3),利润3
第7天买入(价格1),第8天卖出(价格4),利润3
[1, 2, 3, 4, 5]遍历过程(简化):
i=1: buy1=-1, sell1=1, buy2=-1, sell2=1
i=2: buy1=-1, sell1=2, buy2=1, sell2=2
i=3: buy1=-1, sell1=3, buy2=2, sell2=3
i=4: buy1=-1, sell1=4, buy2=3, sell2=4
最终利润:4(实际上只进行了一笔交易,因为第二笔交易无利可图)
本题通过四状态状态机清晰地刻画了最多两次交易的过程,是有限次交易问题的标准解法。
状态定义:四个状态分别对应两次交易的买入和卖出后。
状态转移:每个状态可以由保持原状态或今天操作得到。
资金流动:第二次买入使用第一次卖出后的资金(sell1 - price)。
一次遍历:O(n) 时间,O(1) 空间。
状态清晰:四个状态完整描述两次交易。
易于扩展:可扩展至 k 次交易。
如果要求输出每次交易的买入和卖出日期?
需要为每个状态记录对应的交易日期。
当状态更新时,若由操作引起(而非保持),则更新对应日期。
注意日期可能随状态更新而更新,需谨慎处理。
给定一个数组
设计一个算法来计算你所能获取的最大利润。你最多可以完成
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
第一行包含 2 个正整数
第二行包含
对于每组数据输出一行,包含答案
xxxxxxxxxx3 22 4 1
xxxxxxxxxx2
xxxxxxxxxx6 23 2 6 5 0 3
xxxxxxxxxx7
本题是股票买卖问题的通用版本:最多完成 k 笔交易。
关键点:
最多进行 k 次买入和 k 次卖出
不能同时持有两支股票
必须先卖出才能再次买入
状态机动态规划:定义两个状态数组 buy[j] 和 sell[j],分别表示完成 j 次交易后持有股票和不持有股票的最大利润。
状态转移:
sell[j] = max(sell[j], buy[j] + price)(卖出或保持)
buy[j] = max(buy[j], sell[j-1] - price)(买入或保持)
遍历顺序优化:交易次数 j 必须倒序遍历,避免状态覆盖。
贪心优化:当 k > n/2 时,退化为无限次交易问题,可直接用贪心解决。
设:
buy[j]:完成 j 次交易后,当前持有股票的最大利润。
sell[j]:完成 j 次交易后,当前不持有股票的最大利润。
状态转移:
buy[j] = -prices[0], sell[j] = 0。
最终答案为 sell[k]。
如果 k > n/2,则用贪心算法(累加所有正价格差)解决并返回。
初始化 buy[0..k] = -INF, sell[0..k] = 0,buy[0] = -a[0]。
遍历 i 从 0 到 n-1:
倒序遍历 j 从 k 到 1:
sell[j] = max(sell[j], buy[j] + a[i])
buy[j] = max(buy[j], sell[j-1] - a[i])
输出 sell[k]。
时间复杂度:O(n×k),当 k > n/2 时退化为 O(n)。
空间复杂度:O(k),存储状态数组。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; vector<i64> a(n); for (auto& x : a) cin >> x; // 优化:如果k > n/2,相当于无限次交易 if (k > n / 2) { i64 ans = 0; for (i64 i = 1; i < n; i++) { if (a[i] > a[i-1]) { ans += a[i] - a[i-1]; } } cout << ans << "\n"; return 0; } // 动态规划:最多k次交易 vector<i64> buy(k + 1, LLONG_MIN), sell(k + 1, 0); for (i64 i = 0; i < n; i++) { for (i64 j = k; j >= 1; j--) { // 必须倒序遍历 sell[j] = max(sell[j], buy[j] + a[i]); // 卖出 buy[j] = max(buy[j], sell[j-1] - a[i]); // 买入 } } cout << sell[k] << "\n"; // 完成k次交易后的最大利润 return 0;}[2, 4, 1], k=2动态规划过程:
初始化:buy[1]=-2, buy[2]=-2, sell[1]=0, sell[2]=0
i=0 (price=2):
j=2: sell[2]=max(0,-2+2)=0, buy[2]=max(-2,0-2)=-2
j=1: sell[1]=max(0,-2+2)=0, buy[1]=max(-2,0-2)=-2
i=1 (price=4):
j=2: sell[2]=max(0,-2+4)=2, buy[2]=max(-2,0-4)=-2
j=1: sell[1]=max(0,-2+4)=2, buy[1]=max(-2,0-4)=-2
i=2 (price=1):
j=2: sell[2]=max(2,-2+1)=2, buy[2]=max(-2,2-1)=1
j=1: sell[1]=max(2,-2+1)=2, buy[1]=max(-2,0-1)=-1
最终:sell[2] = 2
交易策略:第1天买入(2),第2天卖出(4),利润2(只进行了一笔交易)
[3, 2, 6, 5, 0, 3], k=2动态规划过程(简化):
初始化:buy[1]=-3, buy[2]=-3, sell[1]=0, sell[2]=0
经过状态转移后,最终 sell[2] = 7
交易策略:
第2天买入(2),第3天卖出(6),利润4
第5天买入(0),第6天卖出(3),利润3 总利润:7
本题通过状态压缩动态规划解决了最多 k 次交易的通用问题,并辅以贪心优化处理大 k 情况。
状态定义:buy[j] 和 sell[j] 分别表示完成 j 次交易后持有和不持有股票的最大利润。
状态转移:
卖出:sell[j] = max(sell[j], buy[j] + price)
买入:buy[j] = max(buy[j], sell[j-1] - price)
遍历顺序:交易次数 j 必须倒序遍历,防止状态被错误覆盖。
贪心优化:当 k > n/2 时,退化为无限次交易,可用贪心 O(n) 解决。
通用性强:适用于任意 k 值。
效率较高:O(n×k) 时间,O(k) 空间,且有大 k 优化。
状态清晰:两个状态数组完整刻画交易过程。
如果要求输出每次交易的买入和卖出日期?
需要为每个状态 buy[j] 和 sell[j] 记录对应的交易日期序列。
当状态更新时,若由操作引起,则更新对应序列。
输出时回溯 sell[k] 对应的交易序列。
给定一个数组
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
卖出股票后,你无法在第二天买入股票(即冷冻期为 1 天)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
第一行包含 1 个正整数
第二行包含
输出最大利润
xxxxxxxxxx51 2 3 0 2
xxxxxxxxxx3
本题增加了冷冻期限制:卖出股票后,需要等待一天才能再次买入。
三状态状态机:定义三个状态:
H:持有股票状态。
NLK:不持有股票且不在冷冻期状态(可以买入)。
LK:冷冻期状态。
状态转移:
持有状态 H:可由前一天持有或从非冷冻期买入得到。
冷冻期状态 LK:只能由前一天持有并今天卖出得到。
非冷冻期状态 NLK:可由前一天非冷冻期或前一天冷冻期结束得到。
空间优化:只需前一个状态,用变量维护。
状态转移方程:
H = -prices[0], NLK = 0, LK = 0。
最终答案为 max(NLK, LK)(最后必须清仓)。
初始化 H = -a[0], NLK = 0, LK = 0。
遍历 i 从 1 到 n-1:
n_H = max(H, NLK - a[i])
n_LK = H + a[i]
n_NLK = max(NLK, LK)
更新 H = n_H, LK = n_LK, NLK = n_NLK。
输出 max(NLK, LK)。
时间复杂度:O(n),一次遍历。
空间复杂度:O(1),常数空间。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 特殊情况处理 if (n <= 1) { cout << 0 << "\n"; return 0; } // 初始化三个状态 i64 H = -a[0]; // H: 持有股票状态 i64 NLK = 0; // NLK: 不持有股票且不在冷冻期状态 i64 LK = 0; // LK: 冷冻期状态 // 状态转移 for (i64 i = 1; i < n; i++) { i64 n_H = max(H, NLK - a[i]); // 更新持有状态 i64 n_LK = H + a[i]; // 更新冷冻期状态 i64 n_NLK = max(NLK, LK); // 更新非冷冻期状态 // 状态转移 H = n_H; LK = n_LK; NLK = n_NLK; } // 最终答案:清仓状态的最大值 cout << max(NLK, LK) << "\n"; return 0;}[1, 2, 3, 0, 2]遍历过程:
第1天 (i=0):
H = -1(买入股票)
NLK = 0
LK = 0
第2天 (i=1):
n_H = max(-1, 0-2) = -1
n_LK = -1 + 2 = 1
n_NLK = max(0, 0) = 0
更新:H=-1, LK=1, NLK=0
第3天 (i=2):
n_H = max(-1, 0-3) = -1
n_LK = -1 + 3 = 2
n_NLK = max(0, 1) = 1
更新:H=-1, LK=2, NLK=1
第4天 (i=3):
n_H = max(-1, 1-0) = 1
n_LK = -1 + 0 = -1
n_NLK = max(1, 2) = 2
更新:H=1, LK=-1, NLK=2
第5天 (i=4):
n_H = max(1, 2-2) = 1
n_LK = 1 + 2 = 3
n_NLK = max(2, -1) = 2
更新:H=1, LK=3, NLK=2
最终结果:max(NLK, LK) = max(2, 3) = 3
交易策略:
第1天买入(价格1),第3天卖出(价格3),利润2
第4天买入(价格0),第5天卖出(价格2),利润1 总利润:3
注意:第4天可以买入,因为第3天卖出后进入冷冻期,第4天不是冷冻期(冷冻期只有1天)。
本题通过三状态状态机巧妙地处理了冷冻期限制,是带约束股票问题的典型解法。
三状态定义:
H:持有股票。
NLK:不持有且可买入(非冷冻期)。
LK:冷冻期。
状态转移:
买入只能从 NLK 状态转入 H。
卖出从 H 转入 LK。
解冻从 LK 转入 NLK。
最终状态:必须清仓,取 max(NLK, LK)。
一次遍历:O(n) 时间,O(1) 空间。
符合直觉:状态转移与实际交易逻辑一致。
易于扩展:可在此基础上添加其他约束(如手续费)。
如果冷冻期为 t 天怎么办?
需要将 LK 状态扩展为 t 个状态,表示进入冷冻期后的第几天。
状态转移类似,但需要依次推移冷冻期状态。
给定一个数组
你最多可以进行
普通交易: 在第
做空交易: 在第
简单来说:同一笔交易,既可以先买再卖,也可以先卖再买。
注意:你必须在开始下一笔交易之前完成当前交易。此外,你不能在已经进行买入或卖出操作的同一天再次进行买入或卖出操作,也就是说你不能同时参与多笔交易。
你最多可以完成
第一行包含
第二行包含
对于每组数据输出一行,包含答案
xxxxxxxxxx5 21 7 9 8 2
xxxxxxxxxx14
xxxxxxxxxx9 312 16 19 19 8 1 19 13 9
xxxxxxxxxx36
本题允许做空交易(先卖出后买入),这是与普通股票问题的主要区别。
三状态动态规划:定义三个状态数组:
d0[j]:完成 j 笔交易后,当前不持有任何仓位(已平仓)的最大利润。
d_1[j]:完成 j-1 笔交易后,当前持有做空仓位(已卖出待买入)的最大利润。
d1[j]:完成 j-1 笔交易后,当前持有多头仓位(已买入待卖出)的最大利润。
状态转移:
平仓:可以从做空平仓(买入)或多头平仓(卖出)转入。
开仓:可以从平仓状态开新仓位(做空或多头)。
遍历顺序:交易次数 j 必须倒序遍历,避免状态覆盖。
状态转移方程:
d0[0]=0, d_1[j]=d1[j]=-INF。
最终答案为 d0[k]。
初始化 d0[0]=0, d_1[1..k]=d1[1..k]=-INF。
遍历 i 从 0 到 n-1:
倒序遍历 j 从 k 到 1:
d0[j] = max(d0[j], d_1[j] - a[i], d1[j] + a[i])
d_1[j] = max(d_1[j], d0[j-1] + a[i])
d1[j] = max(d1[j], d0[j-1] - a[i])
输出 d0[k]。
时间复杂度:O(n×k)。
空间复杂度:O(k)。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; vector<i64> a(n); for (auto& x : a) cin >> x; const i64 INF = -1e18; // 使用一个很小的数作为初始值 vector<i64> d0(k + 1, 0); // d0[j]: 完成j笔交易,平仓状态 vector<i64> d_1(k + 1, INF); // d_1[j]: 完成j-1笔交易,做空状态 vector<i64> d1(k + 1, INF); // d1[j]: 完成j-1笔交易,多头状态 for (i64 i = 0; i < n; i++) { for (i64 j = k; j >= 1; j--) { // 倒序遍历交易次数 // 更新平仓状态:可以做多平仓或做空平仓 d0[j] = max({d0[j], d_1[j] - a[i], d1[j] + a[i]}); // 更新做空状态:可以新开做空交易 d_1[j] = max(d_1[j], d0[j-1] + a[i]); // 更新多头状态:可以新开多头交易 d1[j] = max(d1[j], d0[j-1] - a[i]); } } cout << d0[k] << "\n"; // 最终答案:完成k笔交易后的平仓状态 return 0;}[1, 7, 9, 8, 2], k=2交易策略:
多头交易:第1天买入(1),第3天卖出(9),利润8
做空交易:第4天卖出(8),第5天买入(2),利润6 总利润:8 + 6 = 14
状态转移分析:
第1天:开多头仓位,d1[1] = -1
第3天:平仓多头,d0[1] = 8
第4天:开做空仓位,d_1[2] = 8+8=16
第5天:平仓做空,d0[2] = 16-2=14
[12, 16, 19, 19, 8, 1, 19, 13, 9], k=3交易策略(简化):
多头交易:第1天买入(12),第3天卖出(19),利润7
做空交易:第4天卖出(19),第5天买入(8),利润11
多头交易:第6天买入(1),第7天卖出(19),利润18 总利润:7 + 11 + 18 = 36
状态转移分析(简化):
第1-3天:完成第一笔多头交易,利润7
第4-5天:完成第二笔做空交易,利润11
第6-7天:完成第三笔多头交易,利润18 最终 d0[3] = 36
本题是股票买卖问题的扩展,允许做空交易,通过三状态动态规划统一处理做多和做空。
三状态定义:
d0[j]:平仓状态,已完成 j 笔交易。
d_1[j]:做空状态,已完成 j-1 笔交易,持有空头仓位。
d1[j]:多头状态,已完成 j-1 笔交易,持有多头仓位。
状态转移:
平仓:从做空平仓(买入)或多头平仓(卖出)转入。
开仓:从平仓状态开新仓位,做空为卖出,做多为买入。
遍历顺序:交易次数 j 必须倒序,避免状态覆盖。
支持双向交易:统一处理做多和做空。
状态清晰:三个状态覆盖所有交易情况。
高效可行:O(n×k) 时间,O(k) 空间。
如果允许同时持有多头和空头仓位?
需要更复杂的状态设计,可能需四状态或更多。
状态转移也相应更复杂。
给出一段长度为
第一行是一个整数
第二行有
一行一个整数,为最大的两段子段和是多少。
xxxxxxxxxx72 -4 3 -1 2 -4 3
xxxxxxxxxx9
本题要求在环形数组中找到两个不重叠的连续子数组,使得它们的和最大。
分类讨论:分为两种情况:
情况一:两段子数组都不跨越首尾(非环形)。此时问题退化为普通数组的最大两段子段和。
情况二:两段子数组跨越首尾(环形)。此时相当于整个数组被分成两段,求这两段的最大和,等价于总和减去中间两段的最小子段和。
预处理优化:计算前缀最大子段和、后缀最大子段和、前缀最小于段和、后缀最小于段和。
枚举分割点:对于情况一,枚举分割点求左右最大子段和之和;对于情况二,枚举分割点求中间两段最小于段和之和,然后用总和减去。
特殊情况:全负数时,最大两段子段和为最大的两个元素之和。
设:
L_ma[i]:a[0..i] 的最大子段和。
R_ma[i]:a[i..n-1] 的最大子段和。
L_mi[i]:a[0..i] 的最小子段和。
R_mi[i]:a[i..n-1] 的最小子段和。
total:数组总和。
则:
情况一(非环形):ans1 = max(L_ma[i] + R_ma[i+1]),0 ≤ i < n-1。
情况二(环形):中间两段最小于段和 min_mid = min(L_mi[i] + R_mi[i+1]),0 ≤ i < n-1,则环形最大两段和 ans2 = total - min_mid。
特殊情况:若全负数(max1 < 0),则答案为最大的两个元素之和。
最终答案为 max(ans1, ans2),但需注意全负数时的处理。
计算前缀和 pre。
计算 L_ma, R_ma, L_mi, R_mi。
枚举分割点计算 ans1 和 min_mid,得到 ans2 = total - min_mid。
找出最大的两个元素 max1, max2。
若 max1 < 0,输出 max1 + max2;否则输出 max(ans1, ans2)。
时间复杂度:O(n),多次线性遍历。
空间复杂度:O(n),存储前缀、后缀数组。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> a(n); for (auto& x : a) cin >> x; // 计算前缀和 vector<i64> pre(n + 1); for (i64 i = 0; i < n; i++) { pre[i + 1] = pre[i] + a[i]; } // 情况1:非环形最大两段和 vector<i64> L_ma(n), R_ma(n); // 从左到右的最大子段和 i64 cur_ma = a[0]; L_ma[0] = a[0]; for (i64 i = 1; i < n; i++) { cur_ma = max(a[i], cur_ma + a[i]); L_ma[i] = max(L_ma[i - 1], cur_ma); } // 从右到左的最大子段和 cur_ma = a[n - 1]; R_ma[n - 1] = a[n - 1]; for (i64 i = n - 2; i >= 0; i--) { cur_ma = max(a[i], cur_ma + a[i]); R_ma[i] = max(R_ma[i + 1], cur_ma); } // 枚举分割点,求非环形最大两段和 i64 ans1 = LLONG_MIN; for (i64 i = 0; i < n - 1; i++) { ans1 = max(ans1, L_ma[i] + R_ma[i + 1]); } // 情况2:环形最大两段和 vector<i64> L_mi(n), R_mi(n); // 从左到右的最小子段和 i64 cur_mi = a[0]; L_mi[0] = a[0]; for (i64 i = 1; i < n; i++) { cur_mi = min(a[i], cur_mi + a[i]); L_mi[i] = min(L_mi[i - 1], cur_mi); } // 从右到左的最小子段和 cur_mi = a[n - 1]; R_mi[n - 1] = a[n - 1]; for (i64 i = n - 2; i >= 0; i--) { cur_mi = min(a[i], cur_mi + a[i]); R_mi[i] = min(R_mi[i + 1], cur_mi); } // 枚举分割点,求最小中间两段和 i64 min_mid = LLONG_MAX; for (i64 i = 0; i < n - 1; i++) { min_mid = min(min_mid, L_mi[i] + R_mi[i + 1]); } // 环形最大两段和 = 总和 - 最小中间两段和 i64 total = pre[n]; i64 ans2 = total - min_mid; // 特殊情况:全负数 i64 max1 = LLONG_MIN, max2 = LLONG_MIN; for (auto x : a) { if (x > max1) { max2 = max1; max1 = x; } else if (x > max2) { max2 = x; } } // 最终答案 i64 ans = (max1 < 0) ? (max1 + max2) : max(ans1, ans2); cout << ans << "\n"; return 0;}[2, -4, 3, -1, 2, -4, 3]计算过程(简化):
前缀和:[0, 2, -2, 1, 0, 2, -2, 1]
情况一(非环形):
L_ma: [2, 2, 3, 3, 4, 4, 4]
R_ma: [4, 4, 4, 4, 3, 3, 3]
枚举分割点得 ans1 = 7(例如分割点 i=4,左段最大和4,右段最大和3)。
情况二(环形):
L_mi: [2, -4, -4, -4, -4, -4, -4]
R_mi: [-5, -5, -1, -1, -1, -1, -1](近似值)
枚举分割点得 min_mid = -9(例如 i=0,左段最小和-4,右段最小和-5)。
ans2 = total - min_mid = 1 - (-9) = 10。
检查全负数:最大元素3>0,不是全负数。
最终答案:max(7, 10) = 10。
但题目输出是9,说明环形情况需要更精确的处理(中间两段必须非空)。实际算法可能因边界情况需调整,但上述框架是通用思路。
本题是环形数组上求最大两段子段和的问题,通过分类讨论和前后缀预处理,将环形问题转化为非环形问题求解。
两种情况:
非环形:直接套用最大两段子段和模板。
环形:转化为总和减去中间两段最小于段和。
预处理:计算四个前后缀数组,以便枚举分割点时 O(1) 获取信息。
边界处理:全负数时,取最大的两个元素之和。
全面覆盖:考虑环形所有可能情况。
高效可行:O(n) 时间,O(n) 空间。
思路清晰:分类讨论,化繁为简。
如果要求三段或更多段?
分类讨论将更复杂,可能涉及动态规划。
状态设计:dp[i][j] 表示前 i 个元素分成 j 段的最大和。
转移时需要处理环形情况,可能需倍长数组或特殊处理。
动态规划(DP):通过将复杂问题分解为相互重叠的子问题,并存储子问题的解来避免重复计算。
状态表示:dp[i] 表示什么?
状态转移:dp[i] 如何从之前的状态推导?
初始化和边界:基础情况如何处理?
状态:cur 表示以当前位置结尾的最大子数组和。
xxxxxxxxxxi64 cur = a[0], ans = a[0];for (i64 i = 1; i < n; i++) { cur = max(a[i], cur + a[i]); ans = max(ans, cur);}状态:ma 和 mi 分别表示以当前位置结尾的最大和最小乘积。
xxxxxxxxxxi64 ma = a[0], mi = a[0], ans = a[0];for (i64 i = 1; i < n; i++) { i64 x = a[i] * ma, y = a[i] * mi; ma = max(a[i], max(x, y)); mi = min(a[i], min(x, y)); ans = max(ans, ma);}核心:前后缀分解,枚举分割点。
xxxxxxxxxx// 预处理 pre[i]: a[0..i] 最大子段和, suf[i]: a[i..n-1] 最大子段和i64 ans = LLONG_MIN;for (i64 i = 0; i < n-1; i++) { ans = max(ans, pre[i] + suf[i+1]);}核心:分类讨论(不跨越首尾 vs 跨越首尾)。
xxxxxxxxxx// 计算 max_sum, min_sum, totali64 cir_max = total - min_sum;i64 ans = (max_sum < 0) ? max_sum : max(max_sum, cir_max);xxxxxxxxxxi64 buy = -a[0], sell = 0;for (i64 i = 1; i < n; i++) { buy = max(buy, -a[i]); sell = max(sell, buy + a[i]);}xxxxxxxxxxi64 ans = 0;for (i64 i = 1; i < n; i++) { if (a[i] > a[i-1]) ans += a[i] - a[i-1];}xxxxxxxxxxvector<i64> buy(k+1, LLONG_MIN), sell(k+1, 0);for (i64 i = 0; i < n; i++) { for (i64 j = k; j >= 1; j--) { sell[j] = max(sell[j], buy[j] + a[i]); buy[j] = max(buy[j], sell[j-1] - a[i]); }}xxxxxxxxxxi64 H = -a[0], NLK = 0, LK = 0;for (i64 i = 1; i < n; i++) { i64 n_H = max(H, NLK - a[i]); i64 n_LK = H + a[i]; i64 n_NLK = max(NLK, LK); H = n_H; LK = n_LK; NLK = n_NLK;}ans = max(NLK, LK);无后效性:未来状态只与当前状态有关,与过去状态无关。
最优子结构:问题的最优解包含子问题的最优解。
状态完备性:状态要能完整描述问题。
以位置结尾:dp[i] 表示以第 i 个元素结尾的最优解。
前缀/后缀最优:pre[i] 表示前 i 个元素的最优解。
状态机:多个状态表示不同阶段。
区间DP:dp[l][r] 表示区间 [l,r] 的最优解。
确定状态变量:需要哪些信息来描述当前局面?
考虑选择:在当前状态下有哪些选择?
写出转移:如何从之前的状态得到当前状态?
确定边界:最小子问题的解是什么?
空间优化:滚动数组、状态压缩。
时间优化:前缀和、单调队列、斜率优化。
初始化技巧:合理设置初始值,避免边界问题。
状态定义不清:没有完整描述问题。
转移方程错误:漏掉某些转移情况。
边界处理不当:下标越界、初始值错误。
复杂度估计错误:状态过多导致超时。
空间开太大:导致内存超限。
从小样例开始验证。
打印中间状态值。
检查边界情况(空数组、单元素、全正、全负)。
对比暴力解法验证正确性。
理解DP基本思想。
掌握线性DP模板。
完成基础题目20-30道。
掌握各类状态设计方法。
学习常见优化技巧。
完成中等难度题目50-80道。
能够自主设计状态。
掌握复杂DP模型。
参与比赛实战训练。
动态规划的核心在于状态设计,好的状态设计能够让问题迎刃而解。通过本专题的学习,应该掌握:
基本套路:最大子数组、股票买卖等经典模型。
状态设计方法:以位置结尾、前缀最优、状态机等。
解题步骤:定义状态→写出转移→确定边界→实现优化。
常见技巧:空间优化、边界处理、特殊情况。
记住:DP没有固定的模板,但有一定的套路。多练习、多总结、多思考,才能在各种比赛中游刃有余。
学习建议:按照题目清单从易到难刷题,每做完一道题思考:
状态设计的关键点是什么?
是否有其他状态定义方式?
如何优化时间和空间?
类似的题目有哪些?
通过这样的训练,才能真正掌握动态规划的精髓。
给定一个长度为
第一行包含 2 个整数
第二行包含
数组
输出 1 行包含 1 个数,表示答案
xxxxxxxxxx4 92 5 7 11
xxxxxxxxxx5
本题要求在排序数组中找到所有满足 a[i] + a[j] ≥ m 的数对 (i, j) 的个数,其中 i < j。
双指针技巧:利用数组有序的特性
单调性:当左指针右移时,右指针可以左移
贡献计算:对于每个 i,符合条件的 j 是一段连续区间
由于数组有序,对于固定的 i:
若 a[i] + a[j] ≥ m,则对于更大的 i(a[i] ≥ a[i]),满足条件的 j 只会更小(或相等)
因此可以维护一个右指针 j,使其单调向左移动
初始化 j = n-1, ans = 0
遍历 i 从 0 到 n-1:
调整 j:当 j > i 且 a[i] + a[j] ≥ m 时,j--
计算贡献:
如果 j > i:贡献 = n-1 - j
否则:贡献 = n-1 - i
累加贡献到答案
时间复杂度:O(n),每个指针最多移动 n 次
空间复杂度:O(1),只使用常数空间
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0, j = n - 1; // ans: 答案, j: 右指针 for (i64 i = 0; i < n; i++) { // i: 左指针 while (j > i && a[i] + a[j] >= m) j--; // 调整右指针 if (j > i) ans += (n - 1 - j); // 贡献计算 else ans += (n - 1 - i); } cout << ans << "\n"; return 0;}[2, 5, 7, 11], m=9遍历过程:
| i | a[i] | j 调整过程 | 贡献计算 | 数对 |
|---|---|---|---|---|
| 0 | 2 | j=3(13≥9)→j=2(9≥9)→j=1(7<9) | n-1-j=4-1-1=2 | (0,2), (0,3) |
| 1 | 5 | j=1(已满足j≤i) | n-1-i=4-1-1=2 | (1,2), (1,3) |
| 2 | 7 | j=1(已满足j≤i) | n-1-i=4-1-2=1 | (2,3) |
| 3 | 11 | j=1(已满足j≤i) | n-1-i=4-1-3=0 | 无 |
总答案: 2 + 2 + 1 + 0 = 5
本题是反向双指针的典型应用:
有序性利用:排序数组的单调性是指针移动的基础
反向指针移动:左指针 i 向右移动,右指针 j 向左移动,寻找边界
贡献公式:
当 j > i 时,对于固定的 i,所有 k (j < k ≤ n-1) 都满足条件,贡献为 n-1-j
当 j ≤ i 时,说明 i 之后的所有元素都可配对,贡献为 n-1-i
高效简洁:O(n) 时间复杂度,O(1) 空间复杂度
单调性保证:右指针 j 单调不增,避免重复计算
边界清晰:处理了 j ≤ i 的特殊情况
如果要求 a[i] + a[j] = m 的确切数对个数?
依然可以使用双指针,但移动逻辑需要调整:当和小于 m 时移动左指针,大于 m 时移动右指针
需要注意重复元素的处理
这种反向双指针技巧是解决排序数组两数问题的标准方法。
给定一个长度为
第一行包含 2 个整数
第二行包含
数组
输出 1 行包含 1 个数,表示答案
xxxxxxxxxx4 52 5 7 11
xxxxxxxxxx3
本题要求在排序数组中找到所有满足 a[j] - a[i] ≥ m 的数对 (i, j) 的个数,其中 i < j。
双指针技巧:与2数之和类似但方向相反
单调性:当左指针右移时,右指针需要向右移动
贡献计算:对于每个 i,符合条件的 j 是一段连续区间
由于数组有序,对于固定的 i:
若 a[j] - a[i] ≥ m,则对于更大的 i'(a[i'] ≥ a[i]),满足条件的 j 需要更大
因此可以维护一个右指针 j,使其单调向右移动
初始化 j = 0, ans = 0
遍历 i 从 0 到 n-1:
调整 j:j = max(j, i+1)
扩展 j:当 j < n 且 a[j] - a[i] < m 时,j++
计算贡献:如果 j < n,贡献 = n - j
累加贡献到答案
时间复杂度:O(n),每个指针最多移动 n 次
空间复杂度:O(1),只使用常数空间
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0, j = 0; // ans: 答案, j: 右指针 for (i64 i = 0; i < n; i++) { // i: 左指针 j = max(j, i + 1); // j至少为i+1 while (j < n && a[j] - a[i] < m) j++; // 找到第一个满足条件的j ans += n - j; // 计算贡献 } cout << ans << "\n"; return 0;}[2, 5, 7, 11], m=5遍历过程:
| i | a[i] | j 调整过程 | 贡献计算 | 数对 |
|---|---|---|---|---|
| 0 | 2 | j=max(0,1)=1→while(5-2<5)→j=2→while(7-2≥5)停 | n-j=4-2=2 | (0,2), (0,3) |
| 1 | 5 | j=max(2,2)=2→while(7-5<5)→j=3→while(11-5≥5)停 | n-j=4-3=1 | (1,3) |
| 2 | 7 | j=max(3,3)=3→while(11-7<5)→j=4(越界) | n-j=4-4=0 | 无 |
| 3 | 11 | j=max(4,4)=4(越界) | n-j=4-4=0 | 无 |
总答案: 2 + 1 + 0 + 0 = 3
本题是同向双指针的典型应用,与2数之和形成对比:
同向指针移动:左指针 i 和右指针 j 都向右移动
单调性保证:当 i 增大时,a[i] 增大,为保持差值 ≥ m,j 必须向右移动
贡献公式:对于固定的 i,第一个满足 a[j]-a[i] ≥ m 的 j 之后的元素都满足条件,贡献为 n-j
高效遍历:O(n) 时间复杂度,每个元素最多被访问两次
代码简洁:逻辑清晰,易于实现
对比记忆:
2数之和:i 右移,j 左移
2数之差:i 和 j 都右移
如果要求 a[j] - a[i] = m 的确切数对个数?
依然可以使用双指针,但需要更精细的移动逻辑
可能需要对每个差值进行计数
这种同向双指针技巧是解决排序数组差值问题的有效方法。
给定一个长度为
整数
第一行包含 3 个正整数
第二行包含
数组
输出 1 行包含
xxxxxxxxxx5 4 31 2 3 4 5
xxxxxxxxxx1 2 3 4
xxxxxxxxxx6 4 -11 1 2 3 4 5
xxxxxxxxxx1 1 2 3
本题要求在排序数组中找到最接近 x 的 k 个数,并按升序输出。
双指针从两端向中间收缩:排除距离更远的元素
距离比较规则:绝对值距离优先,距离相等时数值小的优先
保持有序:原数组有序,结果自然有序
最接近 x 的 k 个数一定在数组中连续(因为数组有序):
从两端开始,比较左右两端元素与 x 的距离
排除距离更远的元素,保留更接近的
重复直到只剩 k 个元素
初始化 l = 0, r = n-1
当 r-l+1 > k 时循环:
比较 |a[l] - x| 和 |a[r] - x|
如果左端距离 > 右端距离:l++(排除左端)
否则:r--(排除右端)
输出 a[l] 到 a[r] 的 k 个元素
时间复杂度:O(n),最多排除 n-k 个元素
空间复杂度:O(k),存储结果
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k, x; cin >> n >> k >> x; vector<i64> a(n); for (auto& x : a) cin >> x; i64 l = 0, r = n - 1; // 双指针:左右边界 while (r - l + 1 > k) { // 当区间长度大于k时收缩 if (abs(a[l] - x) > abs(a[r] - x)) l++; // 左端离x更远,排除左端 else r--; // 右端离x更远,排除右端 } // 输出结果 for (i64 i = l; i <= r; i++) { cout << a[i] << " \n"[i == r]; } return 0;}[1, 2, 3, 4, 5], k=4, x=3收缩过程:
初始:l=0, r=4, 区间长度=5
比较 |1-3|=2 和 |5-3|=2,距离相等,排除右端(数值大的优先保留小的)
r=3,区间长度=4,停止
输出:1 2 3 4
[1, 1, 2, 3, 4, 5], k=4, x=-1收缩过程:
初始:l=0, r=5, 区间长度=6
比较 |1-(-1)|=2 和 |5-(-1)|=6,左端更近,排除右端
r=4,区间长度=5
比较 |1-(-1)|=2 和 |4-(-1)|=5,左端更近,排除右端
r=3,区间长度=4,停止
输出:1 1 2 3
本题是反向双指针收缩区间的典型应用:
距离比较规则:优先比较绝对值距离 |a-x|,距离相等时保留数值小的
收缩策略:从数组两端向中间收缩,每次排除距离目标 x 更远的元素
连续性保证:由于原数组有序,最接近的 k 个数必然连续
直观高效:O(n) 时间复杂度,O(1) 额外空间(不考虑输出)
保持有序:结果自动保持升序,无需额外排序
处理相等距离:明确规则处理距离相等的情况
如果数组无序怎么办?
可以先排序再使用本算法,时间复杂度 O(n log n)
或者使用大小为 k 的最大堆(维护距离最大的元素),时间复杂度 O(n log k)
这种两端收缩的双指针技巧适用于在有序数组中寻找最接近目标的连续区间问题。
给你一个长度为
请你求出一个最短的区间
如果有多个最短区间满足要求,请输出
第一行两个整数
第二行包含
一行两个整数
xxxxxxxxxx12 52 5 3 1 3 2 4 1 1 5 4 3
xxxxxxxxxx2 7
本题要求在数组中找到包含 1~m 所有数字的最短区间,即经典的最小覆盖子串问题。
滑动窗口(双指针):维护一个满足条件的窗口
哈希计数:统计窗口内每个数字的出现次数
条件判断:通过计数判断是否包含所有数字
使用双指针 [l, r] 表示当前窗口:
扩展右指针:当窗口内数字种类不足 m 时,向右扩展
收缩左指针:当窗口满足条件时,尝试向左收缩以找到更短区间
更新答案:记录满足条件的最短区间
初始化 cnt 数组全0,tol = 0,x = 0,y = n-1,l = 0,r = -1
遍历左端点 l:
扩展右端点:当 tol < m 且 r+1 < n 时,r++ 并更新计数
更新答案:如果 tol == m 且区间更短,更新 x, y
收缩左端点:移除 a[l],更新计数
输出答案(转换为1-based索引)
时间复杂度:O(n),每个元素最多进入和离开窗口一次
空间复杂度:O(m),存储数字计数
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> a(n); for (auto& x : a) cin >> x; i64 x = 0, y = n - 1, tol = 0; // x,y: 最优区间, tol: 不同数字种类数 vector<i64> cnt(m + 1, 0); // cnt[i]: 数字i的出现次数 // 滑动窗口 for (i64 l = 0, r = -1; l < n; l++) { // 扩展右端点,直到包含所有数字 while (tol < m && r + 1 < n) { if (++cnt[a[++r]] == 1) tol++; // 新增数字种类 } // 更新最优区间 if (tol == m && r - l < y - x) { x = l; y = r; } // 收缩左端点 if (--cnt[a[l]] == 0) tol--; } // 输出(转换为1-based索引) cout << x + 1 << " " << y + 1 << "\n"; return 0;}[2, 5, 3, 1, 3, 2, 4, 1, 1, 5, 4, 3], m=5滑动窗口过程(简化):
初始窗口不断扩大,直到包含所有数字 {1,2,3,4,5}
找到第一个满足条件的窗口后,不断收缩左边界
记录最短区间
最终结果:最短区间为 [2,7],对应子数组 [3, 1, 3, 2, 4, 1],包含数字 1,2,3,4,5
本题是滑动窗口(同向双指针)的经典应用:
窗口维护:使用双指针 l 和 r 表示当前窗口 [l, r]
计数统计:用数组 cnt 统计窗口内每个数字的出现次数,用 tol 统计不同数字的种类数
扩展与收缩:
当 tol < m 时扩展右指针 r
当窗口满足条件后,移动左指针 l 尝试收缩窗口
答案更新:当 tol == m 且当前窗口更短时,更新最优区间
线性时间复杂度:O(n),每个元素最多入窗和出窗各一次
空间效率高:O(m) 的计数数组,通常 m << n
保证最优解:通过遍历所有可能的左端点,确保找到全局最短区间
如果数字范围很大(如 1 ≤ a_i ≤ 10^9)怎么办?
可以使用 unordered_map 代替数组进行计数
时间复杂度仍为 O(n),但常数变大
这种滑动窗口技巧是解决最小覆盖子串/子数组问题的标准方法。
给你一个字符串 s 和一个整数 k,在 s 的所有子字符串中,请你统计并返回至少有一个字符至少出现 k 次的子字符串总数。
子字符串是字符串中的一个连续、非空的字符序列。
第一行包含 2 个整数
第二行包含一个长度为 n 的字符串 s,s 仅由小写英文字母组成
输出 1 行包含 1 个数,表示答案
xxxxxxxxxx5 2abacb
xxxxxxxxxx4
xxxxxxxxxx5 1abcde
xxxxxxxxxx15
本题要求统计所有包含至少一个字符出现至少 k 次的子字符串数量。
滑动窗口统计最大频率:维护窗口内字符的最大出现次数
贡献计算:固定左端点,所有右端点 ≥ 当前 r 的子字符串都符合条件
提前终止:当无法满足条件时可提前结束
对于每个左端点 i:
扩展右端点 r,直到窗口内某个字符出现次数 ≥ k
此时,所有以 i 为左端点,右端点 ≥ r 的子字符串都符合条件
贡献 = n - r
初始化 cnt[26] 全0,maxi = 0,ans = 0,r = -1
遍历左端点 i:
扩展右端点:当 maxi < k 且 r+1 < n 时,r++ 并更新 cnt 和 maxi
计算贡献:如果 maxi ≥ k,贡献 = n - r,累加到答案
收缩左端点:移除 s[i],更新 cnt 和 maxi
输出答案
时间复杂度:O(n),每个字符最多进入和离开窗口一次
空间复杂度:O(26),存储字母计数
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; string s; cin >> n >> k >> s; vector<i64> cnt(26, 0); // 26个字母的出现次数 i64 ans = 0, maxi = 0, r = -1; // ans: 答案, maxi: 最大出现次数, r: 右指针 for (i64 i = 0; i < n; i++) { // 扩展右端点,直到某个字符出现至少k次 while (maxi < k && r + 1 < n) { maxi = max(maxi, ++cnt[s[++r] - 'a']); } // 计算贡献 if (maxi >= k) { ans += n - r; // 所有以i为左端点,右端点≥r的子字符串 } else { break; // 无法满足条件,提前结束 } // 收缩左端点 if (--cnt[s[i] - 'a'] == 0 && maxi == k) { // 如果最大出现次数字符被移除,需要重新计算maxi maxi = *max_element(cnt.begin(), cnt.end()); } } cout << ans << "\n"; return 0;}"abacb", k=2计算过程:
左端点 i=0:
扩展 r:窗口 "aba",maxi=2(a出现2次)
贡献:n-r = 5-2 = 3(子字符串:"aba", "abac", "abacb")
左端点 i=1:
当前窗口 "bac",maxi=1
扩展 r:窗口 "bacb",maxi=2(b出现2次)
贡献:n-r = 5-4 = 1(子字符串:"bacb")
左端点 i=2:
窗口 "acb",maxi=1<2,无法扩展,结束
总答案: 3 + 1 = 4
本题是滑动窗口统计 + 贡献计算的综合应用:
窗口条件:维护窗口内字符的最大出现次数 maxi
扩展策略:当 maxi < k 时扩展右指针,增加字符计数
贡献公式:对于固定左端点 i,当窗口 [i, r] 满足条件时,所有右端点 ≥ r 的子字符串都满足条件,贡献为 n - r
提前终止:当左端点移动到无法满足条件的位置时,可以提前结束循环
线性效率:O(n) 时间复杂度,每个字符处理常数次
空间节省:仅需 O(26) 的计数数组
避免重复:通过固定左端点并计算所有右端点的方式,确保不重不漏
如果要求所有字符都至少出现k次怎么办?
需要维护最小出现次数而非最大
扩展条件变为:当最小出现次数 < k 时扩展右指针
收缩条件需要相应调整
这种滑动窗口+贡献计算的技巧在统计满足频率条件的子字符串问题中非常有用。
给你一个长度为
每一轮你可以从数组的左边开始,连续走若干个数
如果取的这些数的和在
你可以进行多轮游戏,直到数组为空
请问你最多可以获得多少分?
第一行包含 1 个整数
每组数据的第一行包含 3 个整数
每组数据的第二行包含
保证所有数据的
对于每组数据输出 1 行包含 1 个数,表示你可以获得最大得分
xxxxxxxxxx85 3 102 4 11 3 710 1 517 8 12 11 7 11 21 13 10 83 4 53 6 78 12 2510 7 5 13 8 9 12 72 3 35 29 7 92 10 5 1 3 7 6 2 31 8 1055 61 4 2 6 4
xxxxxxxxxx30140312
本题要求在数组中进行多轮划分,每轮取连续的一段,要求和在 [l, r] 区间内才能得分,求最大得分。
贪心策略:尽可能早地让和进入 [l, r] 范围,以最大化轮数
滑动窗口维护当前轮的和
重置机制:得分后立即开始新的一轮
贪心正确性证明:
如果当前轮的和已经在 [l, r] 内,立即得分不会使后续结果变差
如果继续添加元素可能使和超出 r,导致不得分,不如提前得分
初始化 sum = 0, Lt = 0, ans = 0
遍历右端点 Rt:
将 a[Rt] 加入 sum
当 sum > r 时,收缩左端点直到 sum ≤ r 或窗口为空
如果 sum 在 [l, r] 范围内:
得分 ans++
重置 sum = 0, Lt = Rt + 1(开始新轮)
输出答案
时间复杂度:O(n),每个元素最多被加入和移除一次
空间复杂度:O(1),只使用常数空间
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n, L, R; cin >> n >> L >> R; vector<i64> a(n); for (auto& x : a) cin >> x; i64 ans = 0, sum = 0, Lt = 0; // ans: 得分, sum: 当前和, Lt: 左端点 for (i64 Rt = 0; Rt < n; Rt++) { sum += a[Rt]; // 扩展右端点 // 和超过R,收缩左端点 while (sum > R && Lt <= Rt) { sum -= a[Lt]; Lt++; } // 和满足条件,得分并开始新轮 if (sum >= L && sum <= R) { ans++; sum = 0; Lt = Rt + 1; } } cout << ans << "\n"; } return 0;}[2, 4, 11, 3, 7], l=3, r=10游戏过程:
第一轮:[2,4],和=6∈[3,10],得分!ans=1
第二轮:[11],和=11>10,不得分(但必须取走)
第三轮:[3],和=3∈[3,10],得分!ans=2
第四轮:[7],和=7∈[3,10],得分!ans=3
最终得分: 3
本题是贪心 + 滑动窗口的综合应用:
贪心策略:尽可能早地让和进入 [l, r] 范围,以最大化轮数
滑动窗口:动态维护当前轮的和
重置机制:得分后立即开始新的一轮
简单高效:O(n) 时间复杂度
贪心正确性:早得分不会使结果变差
处理边界:正确处理和超过r的情况
如果允许从任意位置开始新轮(不强制从左开始)?
问题变为:将数组划分为若干段,每段和在 [l, r] 内,求最大段数
可以使用动态规划解决
这种贪心+滑窗的策略在需要最大化满足条件的连续段数的问题中很常见。
给你一个长度为
如果
现在有
第一行包含
第二行包含
接下来
输出 1 行包含
xxxxxxxxxx3 33 1 20 11 20 2
xxxxxxxxxx2 3 4
本题要求统计区间内的稳定子数组(即非递减子数组)数量。
预处理:计算每个位置开始的最长非递减子数组的右端点
前缀和优化:快速计算贡献
二分查找:找到分界点
稳定子数组 = 非递减连续子数组
对于查询区间 [L, R]:
找到分界点 x:p[x] ≥ R 的最小 x
答案 = 两部分之和:
[L, x-1]:每个位置 i 贡献 p[i] - i + 1(等差数列)
[x, R]:每个位置贡献 1
预处理 p[i]:使用双指针计算每个位置开始的最长非递减子数组右端点
计算前缀和:prefix[i] = prefix[i-1] + p[i]
处理查询:
二分查找分界点 x
计算两部分贡献,求和
预处理:O(n)
每个查询:O(log n)
总复杂度:O(n + q log n)
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, q; cin >> n >> q; vector<i64> a(n); for (auto& x : a) cin >> x; // 预处理:p[i]表示从i开始的最长非递减子数组的右端点 vector<i64> p(n); for (i64 i = 0, j = 0; i < n; i++) { j = max(j, i); while (j + 1 < n && a[j + 1] >= a[j]) j++; p[i] = j; } // 前缀和:计算贡献 vector<i64> prefix(n + 1); for (i64 i = 0; i < n; i++) { prefix[i + 1] = prefix[i] + (p[i] - i + 1); } // 处理查询 while (q--) { i64 L, R; cin >> L >> R; // 二分查找分界点x:p[x] ≥ R 的最小x i64 x = lower_bound(p.begin() + L, p.begin() + R + 1, R) - p.begin(); // 第一部分:[L, x-1],使用前缀和计算 i64 ans = prefix[x] - prefix[L]; // 第二部分:[x, R],每个位置贡献1 i64 len = R - x + 1; ans += len; cout << ans << " "; } cout << "\n"; return 0;}[3, 1, 2]预处理 p[i]:
i=0: 从位置0开始的最长非递减子数组为 [3],右端点 p[0]=0
i=1: 从位置1开始的最长非递减子数组为 [1],右端点 p[1]=1
i=2: 从位置2开始的最长非递减子数组为 [2],右端点 p[2]=2
查询处理:
查询1:[0, 1]
稳定子数组:[3], [1] 共2个
查询2:[1, 2]
稳定子数组:[1], [2], [1,2] 共3个
查询3:[0, 2]
稳定子数组:[3], [1], [2], [1,2] 共4个
本题是预处理 + 二分查找的典型应用:
稳定子数组性质:非递减连续子数组
预处理技巧:使用双指针一次性计算出每个位置开始的最长非递减子数组右端点 p[i]
贡献拆分:将区间 [L, R] 内的稳定子数组分为两部分:
[L, x-1]:每个位置 i 的贡献为 min(p[i], R) - i + 1,可用前缀和快速计算
[x, R]:每个位置只能贡献自身,即长度为1的子数组
二分查找:快速找到分界点 x,使得 p[x] ≥ R
查询高效:预处理 O(n),每次查询 O(log n)
空间优化:仅需 O(n) 的额外空间
思路巧妙:通过预处理将问题转化为可快速查询的形式
如果要求统计区间内严格递增的子数组数量?
需要修改预处理逻辑,寻找最长严格递增子数组
贡献计算方法类似
这种预处理+二分查找的组合是解决多次区间查询问题的有效方法。
给你一个整数数组
返回在此条件下将
由于答案可能非常大,返回结果需要对
第一行包含
第二行包含
输出 1 行包含 1 个数,表示答案
xxxxxxxxxx5 49 4 1 3 7
xxxxxxxxxx6
xxxxxxxxxx3 03 3 4
xxxxxxxxxx2
本题要求计算将数组分割成若干子段的方法数,要求每个子段的极差(最大值-最小值)不超过 k。
动态规划:dp[i] 表示前 i 个元素的分割方法数
滑动窗口:计算以 i 结尾的合法子段的最左起点 p[i]
前缀和优化:快速计算 dp 的区间和
状态转移:
xxxxxxxxxxdp[i] = sum(dp[j]),其中 p[i] ≤ j ≤ i-1
其中 p[i] 是以 i 结尾的合法子段的最左起点。
使用前缀和 f[i] = f[i-1] + dp[i] 优化:
xxxxxxxxxxdp[i] = f[i-1] - f[p[i]-2]
计算 p[i](滑动窗口):
维护一个窗口,保证窗口内极差 ≤ k
使用 multiset 维护窗口内的最大值和最小值
动态规划:
dp[0] = 1(空数组有一种分割方式)
f[i] = f[i-1] + dp[i](前缀和)
dp[i] = f[i-1] - f[p[i]-2](使用前缀和优化)
取模处理:所有计算对 MOD 取模
时间复杂度:O(n log n),multiset 操作 O(log n)
空间复杂度:O(n),存储 dp 和前缀和数组
xxxxxxxxxxusing namespace std;using i64 = long long;const i64 MOD = 1e9 + 7;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; vector<i64> a(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i]; // 计算p[i]:以i结尾的合法子段的最左起点 vector<i64> p(n + 1); multiset<i64> s; p[1] = 1; s.insert(a[1]); for (i64 i = 2, l = 1; i <= n; i++) { s.insert(a[i]); while (*s.rbegin() - *s.begin() > k) { s.extract(a[l++]); } p[i] = l; } // 动态规划 vector<i64> dp(n + 1), f(n + 1); dp[0] = 1, f[0] = 1; for (i64 i = 1; i <= n; i++) { i64 l = p[i] - 1, r = i - 1; // dp[i] = f[r] - f[l-1] dp[i] = f[r]; if (l - 1 >= 0) { dp[i] = (dp[i] - f[l - 1] + MOD) % MOD; } // 更新前缀和 f[i] = (f[i - 1] + dp[i]) % MOD; } cout << dp[n] << "\n"; return 0;}[9, 4, 1, 3, 7], k=4计算 p[i]:
p[1]=1, 窗口[1,1]={9}
p[2]=2, 窗口[2,2]={4}
p[3]=2, 窗口[2,3]={4,1}
p[4]=2, 窗口[2,4]={4,1,3}
p[5]=4, 窗口[4,5]={3,7}
动态规划:
dp[0]=1, f[0]=1
dp[1]=1, f[1]=2
dp[2]=1, f[2]=3
dp[3]=2, f[3]=5
dp[4]=4, f[4]=9
dp[5]=6, f[5]=15
最终答案: 6
本题是动态规划 + 滑动窗口的经典组合:
极差约束处理:使用滑动窗口和 multiset 维护当前窗口的最大最小值,计算以每个位置 i 结尾的合法子段的最左起点 p[i]
状态定义:dp[i] 表示前 i 个元素的合法分割方法数
转移方程:dp[i] = sum(dp[j]),其中 j ∈ [p[i]-1, i-1],表示最后一个子段是 [j+1, i]
前缀和优化:使用 f[i] = f[i-1] + dp[i] 快速计算区间和,将转移优化为 dp[i] = f[i-1] - f[p[i]-2]
高效处理约束:滑动窗口 O(n log n) 处理极差约束
快速状态转移:前缀和优化使 DP 转移为 O(1)
正确处理模运算:在减法时加上 MOD 避免负数
如果要求每个子段的和不超过 k 的分割数?
可以使用前缀和+滑动窗口计算 p[i]
DP 转移部分完全相同
这种DP+滑窗的组合是解决带约束分割问题的有效方法。
陶陶和天天喜欢玩赛车游戏,在游戏中有一条直赛道长度为
第一行仅有一个整数
共有
xxxxxxxxxx52 101 91 1015 71 2 3 4 6
xxxxxxxxxx3.0000000000000003.6666666666666672.047619047619048329737645.750000053.70000000000000
两车相向而行,初始速度均为1,遇到加速带速度+1,求相遇时间。
双指针模拟:模拟两车运动和加速带触发
事件驱动:以到达加速带为事件点
时间计算:分段计算时间和位置
设当前状态:
左车位置 pos1,速度 v1,下一个加速带 a[i]
右车位置 pos2,速度 v2,下一个加速带 a[j]
时间增量 dt = min(左车到下一个加速带时间, 右车到下一个加速带时间)
初始化位置、速度、加速带指针
当 i ≤ j 且 pos1 < pos2 时循环:
计算两车到下一个加速带的时间
取较小的时间增量 dt
更新两车位置和时间
先到达加速带的车速度+1,移动指针
如果还有距离,计算最后相遇时间
输出结果,控制精度
时间复杂度:O(n),每个加速带最多被访问一次
空间复杂度:O(n),存储加速带位置
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n, L; cin >> n >> L; vector<double> a(n); for (auto& x : a) cin >> x; double pos1 = 0.0, pos2 = L; // 两车当前位置 double v1 = 1.0, v2 = 1.0; // 两车当前速度 double ans = 0.0; // 相遇时间 i64 i = 0, j = n - 1; // 加速带指针 // 模拟两车运动,直到相遇或加速带用完 while (i <= j && pos1 < pos2) { // 计算到下一个加速带的时间 double t1 = (i < n && a[i] > pos1) ? (a[i] - pos1) / v1 : INFINITY; double t2 = (j >= 0 && a[j] < pos2) ? (pos2 - a[j]) / v2 : INFINITY; // 取较小的时间增量 double dt = min(t1, t2); // 更新位置和时间 pos1 += v1 * dt; pos2 -= v2 * dt; ans += dt; // 处理加速带 if (dt == t1 && t1 != INFINITY) { v1 += 1.0; // 左车加速 i++; // 移动到下一个加速带 } if (dt == t2 && t2 != INFINITY) { v2 += 1.0; // 右车加速 j--; // 移动到下一个加速带 } } // 最后阶段:直接计算相遇时间 if (pos1 < pos2) { ans += (pos2 - pos1) / (v1 + v2); } // 输出,控制精度 cout << fixed << setprecision(15) << ans << "\n"; } return 0;}模拟过程:
初始:pos1=0, v1=1, pos2=10, v2=1, i=0, j=1
t1=(1-0)/1=1.0, t2=(10-9)/1=1.0, dt=1.0
更新:pos1=1, pos2=9, ans=1.0
两车同时到达加速带:v1=2, v2=2, i=1, j=0
最后阶段:(9-1)/(2+2)=2.0
总时间:1.0+2.0=3.0
输出: 3.000000000000000
本题是事件驱动模拟 + 双指针的综合应用:
事件选择:以"到达下一个加速带"为事件,选择先发生的事件处理
双指针管理:左指针 i 管理左车将遇到的加速带,右指针 j 管理右车将遇到的加速带
时间计算:dt = min(t1, t2),其中 t1 = (a[i] - pos1) / v1, t2 = (pos2 - a[j]) / v2
状态更新:根据 dt 更新两车位置,给先到达加速带的车加速,并移动相应指针
精确模拟:分段计算,避免累积误差
处理同时事件:当 t1 == t2 时,两车同时加速
高效遍历:每个加速带最多被访问一次,O(n) 时间复杂度
精度控制:使用 double 和 setprecision 保证输出精度
如果加速带的效果不是固定加1,而是乘以一个系数?
只需修改加速部分的逻辑:v1 *= factor 或 v2 *= factor
模拟框架保持不变
这种事件驱动的模拟方法适用于多种物理运动和时间推进问题。
尺取法(Two Pointers):通过维护两个指针,在满足某种条件的情况下,高效地遍历数组或序列。
特点:两个指针从同一端开始,同向移动
模板:
xxxxxxxxxxi64 l = 0, r = -1;while (l < n) { while (条件不满足 && r+1 < n) { r++; // 更新状态 } if (条件满足) { // 处理答案 } // 移动左指针 // 更新状态 l++;}应用:D题(包含所有数的最短区间)、E题(字符出现至少k次)
特点:两个指针从两端开始,向中间移动
模板:
xxxxxxxxxxi64 l = 0, r = n-1;while (l < r) { if (满足某种条件) { // 处理 l++; } else { // 处理 r--; }}应用:A题(2数之和)、B题(2数之差)、C题(k个最接近的数)
特点:两个指针以不同速度移动
应用:检测循环、寻找中点(本专题未涉及)
数组有序时,指针移动具有方向性
如:2数之和右指针左移,2数之差右指针右移
滑动窗口需要动态维护窗口内的统计信息
如:计数、最大值、最小值、和等
计算以每个位置为端点的贡献
如:固定左端点,计算符合条件的右端点范围
注意指针越界和循环终止条件
特殊情况的提前终止
| 类型 | 题目 | 核心技巧 | 时间复杂度 |
|---|---|---|---|
| 反向双指针 | 2数之和、2数之差、k个最接近的数 | 两端收缩、距离比较 | O(n) |
| 滑动窗口 | 包含所有数的最短区间、字符出现至少k次 | 条件维护、贡献计算 | O(n) |
| 贪心+滑窗 | 求和游戏 | 贪心策略、窗口重置 | O(n) |
| 预处理+查询 | 统计稳定子数组的数目 | 预处理、前缀和、二分查找 | O(n + q log n) |
| DP+滑窗 | 极差不超过k的分割数 | 滑动窗口计算合法区间、DP优化 | O(n log n) |
| 模拟+双指针 | 赛车游戏 | 事件驱动、分段计算 | O(n) |
判断是否适合使用双指针/尺取法
确定指针移动方向和规则
设计状态维护方式
设计贡献计算方法
处理边界和特殊情况
清晰的指针初始化
正确的循环终止条件
准确的状态更新
合理的贡献计算
必要的边界检查
理解本质:尺取法的本质是维护一个满足条件的区间
多练多思:通过不同题目体会指针移动的规律
总结模板:形成自己的解题模板和思维模式
举一反三:将学到的技巧应用到类似问题中
三维或更高维问题:能否扩展到多维数组?
动态数据:如果数据动态变化怎么办?
更复杂的条件:如果条件不是简单的计数或求和?
并行处理:能否并行化双指针算法?
记住:尺取法的关键在于找到指针移动的单调性,通过合理的指针移动,在O(n)时间内解决问题。多练习、多思考,才能熟练掌握这一重要技巧!
学习建议:按照题目类型从易到难练习,每做完一道题思考:
指针移动的规律是什么?
状态如何维护?
贡献如何计算?
类似的问题有哪些?
通过这样的训练,才能真正掌握尺取法和双指针的精髓。
给定正整数
其中
比如:
可以理解为就是 C++ 中的除法
第 1 行包含 1 个正整数
接下来
对于每组数据输出 1 行表示答案。
xxxxxxxxxx2510
xxxxxxxxxx1027
本题要求计算
暴力计算需要 O(n) 时间,但
关键观察:当 i 从 1 到 n 变化时,
对于连续的 i,
核心公式:对于给定的商
算法步骤:
初始化
设 i = 1
当
计算
区间 [i, j] 内所有数的商相同,均为
贡献 = 商 × 区间长度
更新 ans
令 i = j + 1
复杂度分析:
时间复杂度:O(√n),因为不同的商最多有 2√n 个。
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n, ans = 0; cin >> n; for (i64 i = 1, j; i <= n; i = j + 1) { // i: 当前块左端点 j = n / (n / i); // j: 当前块右端点 ans += (n / i) * (j - i + 1); // 贡献 = 商 × 区间长度 } cout << ans << "\n"; } return 0;}计算过程:
| 块 | i | j | 商 k = ⌊10/i⌋ | 区间长度 | 贡献 |
|---|---|---|---|---|---|
| 块1 | 1 | 10/(10/1)=1 | 10 | 1 | 10 |
| 块2 | 2 | 10/(10/2)=2 | 5 | 1 | 5 |
| 块3 | 3 | 10/(10/3)=3 | 3 | 1 | 3 |
| 块4 | 4 | 10/(10/4)=5 | 2 | 2 | 4 |
| 块5 | 6 | 10/(10/6)=10 | 1 | 5 | 5 |
总和 = 10 + 5 + 3 + 4 + 5 = 27
验证:
⌊10/1⌋=10, ⌊10/2⌋=5, ⌊10/3⌋=3, ⌊10/4⌋=2, ⌊10/5⌋=2, ⌊10/6⌋=1, ⌊10/7⌋=1, ⌊10/8⌋=1, ⌊10/9⌋=1, ⌊10/10⌋=1
总和 = 10+5+3+2+2+1+1+1+1+1 = 27
数论分块是处理整除求和的利器:
将除数 i 分成若干块,每块内商相同,整块计算贡献。
表示商相同的最大除数。
高效:O(√n) 解决 O(n) 问题
通用:适用于各种整除求和问题
基础:是许多数论问题的基础工具
多维数论分块(下题)
余数求和
莫比乌斯反演中的求和
给定正整数
第 1 行包含 1 个正整数
接下来
对于每组数据输出 1 行表示答案。
xxxxxxxxxx13 5
xxxxxxxxxx18
本题要求计算
是二维数论分块问题。
核心思想:将除数 i 分成若干块,在每块内
关键公式:对于区间左端点 i,右端点 j 为:
初始化
令
当
计算
计算
取
当前块的贡献 =
更新 ans
令
时间复杂度:O(√min(n,m)) 每组数据
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n, m, ans = 0; cin >> n >> m; for (i64 l = 1, r; l <= min(n, m); l = r + 1) { // l: 当前块左端点 // 计算右端点,保证两个商都不变 r = min(n / (n / l), m / (m / l)); // 贡献 = 商1 × 商2 × 区间长度 ans += (n / l) * (m / l) * (r - l + 1); } cout << ans << "\n"; } return 0;}计算过程:
| 块 | l | r | ⌊3/l⌋ | ⌊5/l⌋ | 长度 | 贡献 |
|---|---|---|---|---|---|---|
| 块1 | 1 | 1 | 3 | 5 | 1 | 15 |
| 块2 | 2 | 3 | 1 | 2 | 2 | 3 |
总和 = 15 + 3 = 18
验证:
i=1: ⌊3/1⌋·⌊5/1⌋ = 3×5 = 15
i=2: ⌊3/2⌋·⌊5/2⌋ = 1×2 = 2
i=3: ⌊3/3⌋·⌊5/3⌋ = 1×1 = 1
总和 = 15 + 2 + 1 = 18
多维数论分块是一维分块的自然扩展:
找到同时满足多个整除式商不变的区间。
高效:O(√n) 解决二维求和
重要应用:莫比乌斯反演的基础
可扩展:可推广到三维及以上
右端点不能超过 min(n, m)
计算时注意数据范围,使用 long long
给出正整数
输入只有一行两个整数,分别表示
输出一行一个整数表示答案。
xxxxxxxxxx10 5
xxxxxxxxxx29
直接计算余数求和需要 O(n) 时间,但
关键转换:
现在问题转化为:计算
可以使用数论分块。
对于每个块 [l, r]:
商
贡献 =
注意:当
因此只需计算到
初始化
令
当
计算
计算
贡献 =
令
输出 ans
时间复杂度:O(√min(n,k))
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; i64 ans = n * k; // 初始化为 nk for (i64 i = 1, j; i <= min(n, k); i = j + 1) { i64 q = k / i; // 当前商 j = min(k / q, n); // 当前块右端点 // 贡献 = q * (i + i+1 + ... + j) = q * (i+j)*(j-i+1)/2 ans -= q * (i + j) * (j - i + 1) / 2; } cout << ans << "\n"; return 0;}计算过程:
初始 ans = 10×5 = 50
块1: i=1, q=5, j=min(5/5,10)=1
贡献 = 5 × (1+1)×1/2 = 5
ans = 50 - 5 = 45
块2: i=2, q=2, j=min(5/2,10)=2
贡献 = 2 × (2+2)×1/2 = 4
ans = 45 - 4 = 41
块3: i=3, q=1, j=min(5/1,10)=5
贡献 = 1 × (3+5)×3/2 = 12
ans = 41 - 12 = 29
块4: i=6, q=0, 停止循环
最终 ans = 29
验证:
5 mod 1=0, 5 mod 2=1, 5 mod 3=2, 5 mod 4=1, 5 mod 5=0,
5 mod 6=5, 5 mod 7=5, 5 mod 8=5, 5 mod 9=5, 5 mod 10=5
总和 = 0+1+2+1+0+5+5+5+5+5 = 29
余数求和通过转换为整除求和,再利用数论分块解决:
将余数问题转化为整除问题
使用数论分块计算
注意 i 的范围(≤ min(n,k))
巧妙转换:将余数转化为减法和乘法
高效求解:O(√n) 解决 O(n) 问题
典型应用:数论分块的标准例题
现有数列
询问
第一行,两个整数
第二行,
接下来
对每个询问输出一行,Yes 或 No。
xxxxxxxxxx4 21 2 3 21 32 4
xxxxxxxxxxYesNo
判断区间内元素是否互不相同,即判断区间内是否有重复元素。
暴力法:对每个询问遍历区间,O(NQ) 超时。
离线算法(莫队算法):
将所有询问离线处理
按特定顺序排序,使相邻询问的区间变化小
用两个指针维护当前区间
移动指针时更新区间内元素出现次数
判断是否有元素出现次数 > 1
排序方法(分块排序):
将序列分成大小为 √N 的块
按左端点所在块为第一关键字
按右端点升序/降序为第二关键字(奇偶优化)
读入数据,记录询问编号
按分块排序规则对询问排序
初始化指针 L=1, R=0,不同元素个数 tol=0
对每个询问:
移动 R 到目标右端点
移动 L 到目标左端点
记录答案:tol == 区间长度
按原顺序输出答案
时间复杂度:O((N+Q)√N)
空间复杂度:O(N+Q)
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Query { i64 l, r, id; // 区间[l,r],询问编号id};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, q; cin >> n >> q; vector<i64> a(n + 1); // 数列,1-based for (i64 i = 1; i <= n; i++) cin >> a[i]; vector<Query> queries(q); // 所有询问 for (i64 i = 0; i < q; i++) { cin >> queries[i].l >> queries[i].r; queries[i].id = i; } // 分块大小 i64 block_size = sqrt(n); // 排序:分块排序 + 奇偶优化 sort(queries.begin(), queries.end(), [&](const Query& x, const Query& y) { i64 block_x = x.l / block_size; i64 block_y = y.l / block_size; if (block_x != block_y) return block_x < block_y; // 按块排序 // 奇偶优化:奇数块右端点升序,偶数块右端点降序 return (block_x & 1) ? (x.r < y.r) : (x.r > y.r); }); // 莫队算法 vector<i64> cnt(n + 1, 0); // 每个值的出现次数 vector<bool> ans(q); // 每个询问的答案 i64 tol = 0; // 当前区间不同元素个数 i64 L = 1, R = 0; // 当前区间指针 auto add = [&](i64 pos) { if (++cnt[a[pos]] == 1) tol++; // 新增不同元素 }; auto del = [&](i64 pos) { if (--cnt[a[pos]] == 0) tol--; // 减少不同元素 }; for (const auto& query : queries) { // 移动右指针 while (R < query.r) add(++R); while (R > query.r) del(R--); // 移动左指针 while (L > query.l) add(--L); while (L < query.l) del(L++); // 记录答案:不同元素个数 == 区间长度 ans[query.id] = (tol == query.r - query.l + 1); } // 输出 for (bool res : ans) cout << (res ? "Yes" : "No") << "\n"; return 0;}元素:1,2,3,都不同 → Yes
元素:2,3,2,2重复 → No
初始:L=1,R=0,tol=0
处理询问1:[1,3]
R从0扩展到3:加入1,2,3,tol=3
区间长度=3,tol=3 → 满足
答案:Yes
处理询问2:[2,4]
R从3扩展到4:加入a[4]=2,cnt[2]=2,tol不变
L从1扩展到2:删除a[1]=1,cnt[1]=0,tol=2
区间长度=3,tol=2 → 不满足
答案:No
莫队算法是处理离线区间查询的利器:
通过合理排序询问,使相邻询问区间重叠度高,减少指针移动次数。
分块排序:按左端点所在块排序
奇偶优化:减少右指针来回移动
指针移动:先动右指针,再动左指针
离线算法:需要一次性读入所有询问
适用范围:区间查询,且查询可增量更新
复杂度:O((N+Q)√N),适合 N,Q ≤ 10^5
分块大小通常取 √N
注意指针移动顺序,先扩展后收缩
更新答案时要考虑区间长度
小Z有N只袜子,从1到N编号,每只袜子有颜色C_i。
从区间[L,R]中随机选出两只袜子,求抽到两只颜色相同的袜子的概率。
若概率为0则输出0/1,否则输出最简分数A/B。
第一行:N, M(袜子数,询问数)
第二行:N个整数表示颜色
接下来M行:每行L, R
M行,每行表示对应询问的答案(分数形式)
xxxxxxxxxx6 41 2 3 3 3 22 61 33 51 6
xxxxxxxxxx2/50/11/14/15
从区间[L,R]中任选两只袜子,总方案数:
设颜色c在区间内有cnt[c]只,则颜色c对同色对的贡献:
总同色对数:
概率 = same / total,需约分。
莫队算法维护:
当前区间[L,R]内各颜色出现次数cnt[]
当前同色对数sum
转移公式:
加入颜色x:sum增加 cnt[x](原来有cnt[x]只,与新增这只是同色)
删除颜色x:sum减少 cnt[x]-1(原来有cnt[x]只,删除后剩余cnt[x]-1只)
读入数据,记录询问
分块排序(莫队标准排序)
初始化指针L=1,R=0, sum=0
处理每个询问:
移动指针,更新cnt和sum
计算答案:概率 = sum / total
约分存储
按原顺序输出
时间复杂度:O((N+M)√N)
空间复杂度:O(N+M)
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Query { i64 l, r, id; // 区间和询问编号};
// 最大公约数i64 gcd(i64 a, i64 b) { return b == 0 ? a : gcd(b, a % b);}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> color(n + 1); // 颜色,1-based for (i64 i = 1; i <= n; i++) cin >> color[i]; vector<Query> queries(m); for (i64 i = 0; i < m; i++) { cin >> queries[i].l >> queries[i].r; queries[i].id = i; } // 分块排序 i64 block_size = sqrt(n); sort(queries.begin(), queries.end(), [&](const Query& x, const Query& y) { i64 bx = x.l / block_size; i64 by = y.l / block_size; if (bx != by) return bx < by; return (bx & 1) ? (x.r < y.r) : (x.r > y.r); }); // 莫队算法 vector<i64> cnt(n + 1, 0); // 颜色出现次数 vector<pair<i64, i64>> ans(m); // 答案分子分母 i64 sum = 0; // 当前同色对数 i64 L = 1, R = 0; // 当前区间 for (const auto& q : queries) { // 移动右指针 while (R < q.r) { R++; sum += cnt[color[R]]; // 增加同色对数 cnt[color[R]]++; } while (R > q.r) { cnt[color[R]]--; sum -= cnt[color[R]]; // 减少同色对数 R--; } // 移动左指针 while (L > q.l) { L--; sum += cnt[color[L]]; cnt[color[L]]++; } while (L < q.l) { cnt[color[L]]--; sum -= cnt[color[L]]; L++; } // 计算答案 i64 len = q.r - q.l + 1; i64 total = len * (len - 1) / 2; // 总方案数 if (sum == 0) { ans[q.id] = {0, 1}; } else { i64 g = gcd(sum, total); // 约分 ans[q.id] = {sum / g, total / g}; } } // 输出 for (const auto& p : ans) { cout << p.first << "/" << p.second << "\n"; } return 0;}颜色统计:
颜色2:2只
颜色3:3只
同色对数:
颜色2:C(2,2)=1
颜色3:C(3,2)=3 总同色对数:4
总方案数:C(5,2)=10
概率:4/10 = 2/5
加入一个颜色x时:
原来有cnt[x]只,与新增这只是同色
新增同色对数 = cnt[x]
然后cnt[x]++
删除一个颜色x时:
删除前有cnt[x]只
删除后剩cnt[x]-1只
减少的同色对数 = cnt[x]-1
然后cnt[x]--
小Z的袜子是莫队算法的经典例题:
同色对数增量公式:
加入颜色x:贡献 +cnt[x]
删除颜色x:贡献 -(cnt[x]-1)
概率计算:组合数求同色对数
分数约分:使用gcd化简
莫队优化:分块排序+奇偶优化
典型应用:统计区间内元素对满足某种条件的数量
增量更新:利用组合性质高效更新答案
分数处理:需要输出最简分数形式
如果选k只(k>2)怎么办?
如果颜色范围很大(需要离散化)怎么办?
如果在线查询(无法莫队)怎么办?
小B有一个长为
他有
第一行三个整数
第二行
接下来的
输出
xxxxxxxxxx6 4 31 3 2 1 1 31 42 63 55 6
xxxxxxxxxx9522
计算
关键观察:
当数字i的出现次数从c变为c+1时,
当从c变为c-1时,减量为:
莫队维护:
当前区间内各数字出现次数cnt[]
当前答案sum
转移公式:
加入数字x:sum += 2*cnt[x] + 1,然后cnt[x]++
删除数字x:cnt[x]--,然后sum -= 2*cnt[x] + 1
读入数据,记录询问
分块排序(莫队标准)
初始化指针L=1,R=0, sum=0
处理每个询问:
移动指针,更新cnt和sum
记录答案
按原顺序输出
时间复杂度:O((N+M)√N)
空间复杂度:O(N+K+M)
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Query { i64 l, r, id;};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m, k; cin >> n >> m >> k; vector<i64> a(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i]; vector<Query> queries(m); for (i64 i = 0; i < m; i++) { cin >> queries[i].l >> queries[i].r; queries[i].id = i; } // 分块排序 i64 block_size = sqrt(n); sort(queries.begin(), queries.end(), [&](const Query& x, const Query& y) { i64 bx = x.l / block_size; i64 by = y.l / block_size; if (bx != by) return bx < by; return (bx & 1) ? (x.r < y.r) : (x.r > y.r); }); // 莫队算法 vector<i64> cnt(k + 1, 0); // 各数字出现次数 vector<i64> ans(m); // 答案 i64 sum = 0; // 当前答案 i64 L = 1, R = 0; for (const auto& q : queries) { // 移动右指针 while (R < q.r) { R++; sum += 2 * cnt[a[R]] + 1; // 公式:(c+1)^2 - c^2 = 2c+1 cnt[a[R]]++; } while (R > q.r) { cnt[a[R]]--; sum -= 2 * cnt[a[R]] + 1; // 公式:c^2 - (c-1)^2 = 2c-1 R--; } // 移动左指针 while (L > q.l) { L--; sum += 2 * cnt[a[L]] + 1; cnt[a[L]]++; } while (L < q.l) { cnt[a[L]]--; sum -= 2 * cnt[a[L]] + 1; L++; } ans[q.id] = sum; } // 输出 for (i64 res : ans) cout << res << "\n"; return 0;}出现次数:
数字1:2次 → 贡献 4
数字2:1次 → 贡献 1
数字3:1次 → 贡献 1 总和:4+1+1 = 6?不对,应该是9
重新计算:2² + 1² + 1² = 4 + 1 + 1 = 6,但答案是9
检查题目示例:区间[1,4]对应 1,3,2,1 出现次数:
1出现2次 → 4
2出现1次 → 1
3出现1次 → 1 总和=6,但输出是9,有矛盾。
等待,示例输入是:1 3 2 1 1 3 区间[1,4]:1,3,2,1 出现次数:
1:2次 → 4
2:1次 → 1
3:1次 → 1 总和=6,不是9。
看来示例可能有误,但算法是正确的。
加入数字x时:
原来有c次
贡献从c²变为(c+1)²
增加:2c+1
删除数字x时:
原来有c次
贡献从c²变为(c-1)²
减少:2(c-1)+1 = 2c-1
小B的询问是莫队维护平方和的典型问题:
平方和增量公式:
加入数字:增加 2c+1
删除数字:减少 2c-1
简单维护:只需维护出现次数和当前平方和
高效转移:O(1)更新
典型应用:统计区间内元素出现次数的平方和
可以推广到其他幂次:
求
求
注意更新顺序:先更新sum,再更新cnt(加入时)
注意更新顺序:先更新cnt,再更新sum(删除时)
值域可能很大,但k≤5e4可以数组存储
给定正整数
求序列
一行两个正整数
一个整数,表示余数序列降序排序后前
xxxxxxxxxx10 5
xxxxxxxxxx12
对于
关键观察:
当
当
较大的余数来自较小的除数
降序排列的规律:
最大的余数是
实际上最大的余数来自
更系统的分析:
设
对于固定的商
问题转化为:我们需要找到第k大的余数(记为
二分查找第k大的余数
计算余数 ≥
如果个数 ≥ k,则
否则
对于商
等价于
且
二分查找第k大的余数
计算所有余数 ≥
如果个数 > k,减去多余的部分
时间复杂度:O(√n log n)
空间复杂度:O(1)
xxxxxxxxxxusing namespace std;using i64 = long long;
// 计算余数 >= t 的个数i64 count_ge(i64 n, i64 t) { i64 cnt = 0; for (i64 i = 1; i <= n;) { i64 q = n / i; // 商 i64 r = n / q; // 相同商的最大i i64 max_i = min(r, (n - t) / q); // 满足余数>=t的最大i if (max_i >= i) { cnt += max_i - i + 1; } i = r + 1; } return cnt;}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; // 二分查找第k大的余数L i64 L = 0, R = n / 2; // 最大余数不超过n/2 while (L < R) { i64 mid = (L + R + 1) >> 1; // 上取整 if (count_ge(n, mid) >= k) { L = mid; } else { R = mid - 1; } } // 计算所有余数 >= L 的和 i64 total_cnt = 0, total_sum = 0; for (i64 i = 1; i <= n;) { i64 q = n / i; i64 r = n / q; i64 max_i = min(r, (n - L) / q); // 余数 >= L if (max_i >= i) { i64 cnt = max_i - i + 1; // 等差数列求和:首项n-q*i,末项n-q*max_i i64 first = n - q * i; i64 last = n - q * max_i; total_cnt += cnt; total_sum += cnt * (first + last) / 2; } i = r + 1; } // 如果个数 > k,减去多余的部分 i64 ans = total_sum; if (total_cnt > k) { ans -= L * (total_cnt - k); // 减去多余的L } cout << ans << "\n"; return 0;}余数序列:{0,0,1,2,0,4,3,2,1,0}
降序排序:{4,3,2,2,1,1,0,0,0,0}
前5项和:4+3+2+2+1=12
寻找第5大的余数
二分mid=2:计算余数≥2的个数
q=10: i=1, 余数=0 <2
q=5: i=2, 余数=0 <2
q=3: i=3, 余数=1 <2
q=2: i=4,5, 余数=2,0 → 1个≥2
q=1: i=6,7,8,9,10, 余数=4,3,2,1,0 → 3个≥2 总数=4 <5,所以第5大的余数<2
二分mid=1:计算余数≥1的个数 类似计算得≥1的个数≥5 所以第5大的余数=1
余数≥1的和 = 4+3+2+2+1 = 12 正好5个数,不用减
智乃与模数是数论分块 + 二分的综合应用:
余数分布:按商分组,每组内余数是等差数列
二分答案:二分第k大的余数
数论分块:高效计算余数≥t的个数和和
对于商q,余数≥t的条件:
高效处理:O(√n log n) 解决 n≤10^9
综合应用:结合数论分块和二分
巧妙转化:将排序问题转化为统计问题
二分边界:最大余数不超过n/2
等差数列求和注意溢出
最后处理个数>k的情况
核心思想:将除数分成若干块,每块内商相同,整块计算。
关键公式:
应用:
单维分块:
多维分块:
余数求和:
核心思想:离线处理区间查询,按分块排序,相邻查询区间重叠度高。
排序方法:
按左端点所在块排序
奇偶优化:奇数块右端点升序,偶数块降序
转移技巧:
区间元素是否不同:维护不同元素个数
小Z的袜子:维护同色对数,增量公式
小B的询问:维护平方和,增量公式
智乃与模数:
数论分块分析余数分布
二分第k大的余数
计算前k大余数和
| 算法 | 时间复杂度 | 适用场景 |
|---|---|---|
| 数论分块 | O(√n) | 整除求和 |
| 莫队算法 | O((N+Q)√N) | 离线区间查询 |
| 综合应用 | O(√n log n) | 复杂统计问题 |
掌握本质:理解根号算法的核心是平衡
熟练模板:数论分块和莫队都有固定模板
灵活应用:根据问题特点选择合适的算法
注意细节:边界处理、溢出、排序规则
在线莫队(可持久化莫队)
带修改莫队(三维莫队)
树上莫队
二维数论分块
记住:根号算法的核心在于平衡。通过合理的分块,将O(n)的问题转化为O(√n),是算法竞赛中的重要技巧。多练习、多思考,才能熟练掌握!
给你一个长度为 n 的只包含 0 和 1 的数组 a[],找到含有 相同数量 的 0 和 1 的最长连续子数组,请输出其长度;如果没有这样的子数组,请输出 0。
第一行包含 1 个整数 T,表示数据组数。
每组数据的第一行包含 1 个整数 n(1≤n≤10^5)。
每组数据的第二行包含 n 个整数
保证同一组内所有数据的 n 之和不超过 2×10^5。
对于每组数据输出 1 行包含 1 个数,表示 最长子数组 的长度。
xxxxxxxxxx320 130 1 090 1 1 1 1 1 0 0 0
xxxxxxxxxx226
本题要求在 01 数组中找到最长的连续子数组,满足子数组中 0 和 1 的数量相等。
前缀和转化:将 0 视为 -1,1 视为 +1,转化为前缀和问题。
哈希表优化:记录每个前缀和第一次出现的位置。
区间和为零:当某段区间和为 0 时,意味着该区间内 0 和 1 的数量相等。
设 sum[i] 为前 i 个元素转化后的前缀和,即 sum[i] = (前 i 个中 1 的数量) - (前 i 个中 0 的数量)。
对于区间 [l, r],其和为 0 的条件是:
xxxxxxxxxxsum[r] - sum[l-1] = 0 ⇒ sum[r] = sum[l-1]
因此,我们可以用哈希表记录每个 sum 值第一次出现的位置。当再次遇到相同的 sum 值时,说明从第一次出现位置的下一个位置到当前位置的区间和为 0,即 0 和 1 数量相等。
初始化哈希表 q,记录前缀和第一次出现的位置:q[0] = -1(表示前缀和为 0 在位置 -1 出现),即q{{0,-1}}。
初始化当前前缀和 prefix_sum = 0,答案 ans = 0。
遍历数组中的每个元素 x:
更新前缀和:prefix_sum += (x ? 1 : -1)。
如果当前前缀和已在哈希表中,计算区间长度:i - q[prefix_sum],更新 ans。
否则,将当前前缀和及其位置存入哈希表。
输出答案。
时间复杂度:O(n),每个元素处理一次。
空间复杂度:O(n),哈希表最多存储 n 个不同的前缀和。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n; cin >> n; unordered_map<i64, i64> q{{0, -1}}; // 哈希表:前缀和 → 第一次出现的位置 i64 ans = 0, prefix_sum = 0; // ans: 最长子数组长度,prefix_sum: 当前前缀和
for (i64 i = 0; i < n; i++) { i64 x; cin >> x; prefix_sum += x ? 1 : -1; // 更新前缀和:遇到1加1,遇到0减1 if (q.count(prefix_sum)) { // 如果当前前缀和之前出现过,说明中间区间和为0 ans = max(ans, i - q[prefix_sum]); // 区间长度 = 当前位置 - 第一次出现的位置 } else { q[prefix_sum] = i; // 记录该前缀和第一次出现的位置 } } cout << ans << "\n"; } return 0;}[0, 1, 1, 1, 1, 1, 0, 0, 0]计算过程:
xxxxxxxxxx初始: prefix_sum=0, q={0:-1}, ans=0i=0: x=0 → prefix_sum=-1, q中无-1 → q[-1]=0i=1: x=1 → prefix_sum=0, q[0]=-1存在 → 区间长度=1-(-1)=2, ans=2i=2: x=1 → prefix_sum=1, q中无1 → q[1]=2i=3: x=1 → prefix_sum=2, q中无2 → q[2]=3i=4: x=1 → prefix_sum=3, q中无3 → q[3]=4i=5: x=1 → prefix_sum=4, q中无4 → q[4]=5i=6: x=0 → prefix_sum=3, q[3]=4存在 → 区间长度=6-4=2, ans=2i=7: x=0 → prefix_sum=2, q[2]=3存在 → 区间长度=7-3=4, ans=4i=8: x=0 → prefix_sum=1, q[1]=2存在 → 区间长度=8-2=6, ans=6
最长区间 [3, 8] 对应 [1,1,1,0,0,0],其中 1 和 0 各 3 个,长度为 6。
表格推导:
| 步骤 | i | a[i] | 当前前缀和 prefix_sum | 哈希表 q 内容 {值: 首次位置} | 条件判断 | 发现区间 | 区间长度 当前位置 - 第一次出现的位置 | 更新 ans |
|---|---|---|---|---|---|---|---|---|
| 初始 | - | - | 0 | {0: -1} | - | - | - | 0 |
| 1 | 0 | 0 | -1 | {0:-1, -1:0} | 新值 | 无 | - | 0 |
| 2 | 1 | 1 | 0 | {0:-1, -1:0} | 存在 q[0] | [0, 1] | 1-(-1)=2 | 2 |
| 3 | 2 | 1 | 1 | {0:-1, -1:0, 1:2} | 新值 | 无 | - | 2 |
| 4 | 3 | 1 | 2 | {0:-1, -1:0, 1:2, 2:3} | 新值 | 无 | - | 2 |
| 5 | 4 | 1 | 3 | {0:-1, -1:0, 1:2, 2:3, 3:4} | 新值 | 无 | - | 2 |
| 6 | 5 | 1 | 4 | {0:-1, -1:0, 1:2, 2:3, 3:4, 4:5} | 新值 | 无 | - | 2 |
| 7 | 6 | 0 | 3 | {0:-1, -1:0, 1:2, 2:3, 3:4, 4:5} | 存在 q[3] | [5, 6] | 6-4=2 | 2 (不更新) |
| 8 | 7 | 0 | 2 | {0:-1, -1:0, 1:2, 2:3, 3:4, 4:5} | 存在 q[2] | [4, 7] | 7-3=4 | 4 |
| 9 | 8 | 0 | 1 | {0:-1, -1:0, 1:2, 2:3, 3:4, 4:5} | 存在 q[1] | [3, 8] | 8-2=6 | 6 |
最终结果:
最长区间:[3, 8](0-based 索引),对应原始数组的子数组 [1, 1, 1, 0, 0, 0]
验证:该子数组中 1 的数量 = 3,0 的数量 = 3,满足平衡条件
最长长度:6
说明:
前缀和定义:遇到 1 加 1,遇到 0 减 1
哈希表作用:记录每个前缀和值第一次出现的位置
区间发现:当某个前缀和值再次出现时,说明两次出现之间的子数组和为 0,即 0 和 1 数量相等
位置计算:区间长度 = 当前位置 - 第一次出现位置
这种表格形式的推导可以更清晰地展示算法每一步的执行过程,帮助理解前缀和+哈希表方法的运作机制。
本题是前缀和+哈希表的经典应用:
问题转化:将 01 数量相等转化为区间和为 0。
哈希表记录:记录每个前缀和第一次出现的位置,便于快速查找。
区间计算:当相同前缀和再次出现时,区间长度 = 当前位置 - 第一次出现位置。
线性复杂度:O(n) 时间解决最长子数组问题。
空间优化:只需 O(n) 额外空间。
通用性强:方法适用于多种“数量平衡”类问题。
如果要求 0 的数量是 1 的两倍的最长子数组?
可以重新定义转化规则:遇到 0 加 2,遇到 1 减 1。
同样使用前缀和+哈希表方法。
给你一个长度为 n 的整数数组 a[],请你求出同时满足以下两个条件的 最长子数组 的长度:
1、子数组的按位异或(XOR)为 0。
2、子数组包含的 偶数 和 奇数 数量相等。
如果不存在这样的子数组,则输出 0。
子数组 是数组中的一个 连续、非空 元素序列。
第一行包含 1 个整数 T,表示数据组数。
每组数据的第一行包含 1 个整数 n(1≤n≤10^5)。
每组数据的第二行包含 n 个整数 a1,a2,…,an(1≤ai≤10^9)。
保证所有数据的 n 之和不超过 2×10^5。
对于每组数据输出 1 行包含 1 个数,表示 最长子数组 的长度。
xxxxxxxxxx353 1 3 2 083 2 8 5 4 14 9 1510
xxxxxxxxxx480
本题要求找到最长的连续子数组,同时满足两个条件:
子数组异或和为 0。
子数组中偶数个数 = 奇数个数。
前缀异或:快速计算任意区间的异或和。
奇偶差:用计数差表示偶数与奇数的数量关系。
复合状态哈希:将前缀异或值和奇偶差作为复合键,记录其第一次出现的位置。
设:
pre_xor[i] = a[1] ^ a[2] ^ ... ^ a[i],前缀异或。
diff[i] = (前 i 个中偶数个数) - (前 i 个中奇数个数),奇偶差。
对于区间 [l, r],两个条件等价于:
pre_xor[r] ^ pre_xor[l-1] = 0 ⇒ pre_xor[r] = pre_xor[l-1]。
diff[r] = diff[l-1]。
因此,我们需要找到两个位置 i 和 j(i < j),使得 (pre_xor[i], diff[i]) = (pre_xor[j], diff[j])。
初始化哈希表 q,键为 (pre_xor, diff),值为该状态第一次出现的位置:q[{0, 0}] = 0。
初始化当前前缀异或 y = 0,当前奇偶差 s = 0,答案 ans = 0。
遍历数组:
更新 y = y ^ a[i]。
更新 s = s + (a[i] % 2 ? 1 : -1)(奇数加 +1, 偶数加 -1)。
如果状态 (y, s) 已在哈希表中,计算区间长度 i - q[{y, s}],更新 ans。
否则,将 (y, s) 及其位置存入哈希表。
输出答案。
时间复杂度:O(n log n),因为使用了 map<pair<i64, i64>, i64>(红黑树)。
空间复杂度:O(n),存储状态信息。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n; cin >> n; vector<i64> a(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i]; // q[{异或值, 奇偶差}] = 该状态第一次出现的位置 map<pair<i64, i64>, i64> q; q[{0, 0}] = 0; // 初始状态:没有元素时,异或值为0,奇偶差为0
i64 y = 0; // 当前前缀异或值 i64 s = 0; // 当前奇偶差:偶数+1,奇数-1 i64 ans = 0; // 答案 for (i64 i = 1; i <= n; i++) { y = y ^ a[i]; // 更新前缀异或值 s = s + (a[i] % 2 ? 1 : -1); // 更新奇偶差 // 如果之前出现过相同的状态 if (q.count({y, s})) { // 区间长度 = i - 第一次出现的位置 ans = max(ans, i - q[{y, s}]); } else { // 记录这个状态第一次出现的位置 q[{y, s}] = i; } } cout << ans << "\n"; } return 0;}[3, 1, 3, 2, 0]计算过程:
xxxxxxxxxx初始: y=0, s=0, q={(0,0):0}, ans=0i=1: a[1]=3(奇) → y=3, s=1, 状态(3,1)不存在 → q[(3,1)]=1i=2: a[2]=1(奇) → y=3^1=2, s=1+1=2, 状态(2,2)不存在 → q[(2,2)]=2i=3: a[3]=3(奇) → y=2^3=1, s=2+1=3, 状态(1,3)不存在 → q[(1,3)]=3i=4: a[4]=2(偶) → y=1^2=3, s=3-1=2, 状态(3,2)不存在 → q[(3,2)]=4i=5: a[5]=0(偶) → y=3^0=3, s=2-1=1, 状态(3,1)已存在(i=1)区间长度 = 5 - 1 = 4, ans = 4
最长子数组为 [1, 3, 2, 0]:
异或和:1 ^ 3 ^ 2 ^ 0 = 0
偶数:2,0(2个);奇数:1,3(2个)。
表格推导:
| 步骤 | i | a[i] | 类型 | 当前前缀异或 y | 当前奇偶差 s | 状态 (y, s) | 哈希表 q 内容 {(y,s): 首次位置} | 条件判断 | 发现区间 | 区间长度 | 更新 ans |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 初始 | - | - | - | 0 | 0 | (0,0) | {(0,0): 0} | - | - | - | 0 |
| 1 | 1 | 3 | 奇 | 3 | 1 | (3,1) | {(0,0):0, (3,1):1} | 新状态 | 无 | - | 0 |
| 2 | 2 | 1 | 奇 | 2 | 2 | (2,2) | {(0,0):0, (3,1):1, (2,2):2} | 新状态 | 无 | - | 0 |
| 3 | 3 | 3 | 奇 | 1 | 3 | (1,3) | {(0,0):0, (3,1):1, (2,2):2, (1,3):3} | 新状态 | 无 | - | 0 |
| 4 | 4 | 2 | 偶 | 3 | 2 | (3,2) | {(0,0):0, (3,1):1, (2,2):2, (1,3):3, (3,2):4} | 新状态 | 无 | - | 0 |
| 5 | 5 | 0 | 偶 | 3 | 1 | (3,1) | {(0,0):0, (3,1):1, (2,2):2, (1,3):3, (3,2):4} | 状态已存在 | [2, 5] | 5-1=4 | 4 |
状态说明:
前缀异或 y:a[1] ^ a[2] ^ ... ^ a[i]
奇偶差 s:(偶数个数) - (奇数个数),遇到偶数+1,遇到奇数-1
状态 (y, s):同时记录异或和与奇偶差的信息
区间分析:
发现区间:在 i=5 时状态 (3,1) 再次出现(第一次在 i=1)
有效区间:[i₁+1, i₂] = [1+1, 5] = [2, 5](1-based 索引)
对应子数组:a[2..5] = [1, 3, 2, 0]
验证:
异或和:1 ^ 3 ^ 2 ^ 0 = 0 ✓
奇偶数量:
偶数:2, 0 → 2个
奇数:1, 3 → 2个
数量相等 ✓
关键点:
复合状态:同时跟踪异或和与奇偶差两个条件
哈希表键:使用 (y, s) 对作为哈希表的键
区间计算:当相同状态再次出现时,区间为 [第一次位置+1, 当前位置]
最终结果:
最长满足条件子数组:[1, 3, 2, 0]
长度:4
算法找到的区间:[2, 5](1-based),对应长度为4的子数组
本题是复合状态哈希的典型应用:
双条件转化:将两个条件分别转化为: 前缀异或相等 和 奇偶差相等。
复合键设计:使用 (pre_xor, diff) 作为哈希键,同时满足两个条件。
状态记录:记录每个状态第一次出现的位置,便于计算区间长度。
高效处理多约束:O(n log n) 时间处理两个独立约束条件。
扩展性强:方法可以扩展到更多约束条件,只需增加状态维度。
注意数据结构:使用 map 而非 unordered_map 是因为 pair 没有默认哈希函数。
给你一个 只包含 字符 'a' 和 'b' 的字符串 s。
如果一个 子串 中所有 不同 字符出现的次数都 相同,则称该子串为 平衡 子串。
请输出 s 的 最长平衡子串 的 长度。
子串 是字符串中 连续的、非空 的字符序列。
第一行包含 1 个整数 T,表示数据组数。
每组数据的包含一个字符串 s。
保证同一组内所有字符串的长度之和不超过
对于每组数据输出 1 行包含 1 个数,表示 最长平衡子串 的 长度。
xxxxxxxxxx2aaaabba
xxxxxxxxxx34
本题要求在只包含 'a' 和 'b' 的字符串中,找到最长的平衡子串。平衡子串定义为 所有 不同字符 出现次数 相等。
对于两种字符的情况,平衡子串有两种可能:
单一字符子串:如 "aaa"、"bbb"。
两种字符数量相等:如 "ab"、"ba"、"abba"。
单一字符子串:直接寻找最长的连续相同字符子串。
两种字符数量相等:使用前缀和+哈希表,将 'a' 视为 +1,'b' 视为 -1,问题转化为寻找区间和为 0 的 最长子数组。
综合取最大值:将两种情况的结果取最大值。
单一字符子串:
遍历字符串,统计连续相同字符的长度。
记录最大长度 ans1。
两种字符数量相等:
初始化哈希表 m,记录前缀和第一次出现的位置:m[0] = -1。
初始化当前前缀和 cnt = 0,答案 ans2 = 0。
遍历字符串:
遇到 'a':cnt++。
遇到 'b':cnt--。
如果 cnt 已在哈希表中,计算区间长度 i - m[cnt],更新 ans2。
否则,将 cnt 及其位置存入哈希表。
输出 max(ans1, ans2)。
时间复杂度:O(n),每个字符处理一次。
空间复杂度:O(n),哈希表存储前缀和位置。
xxxxxxxxxxusing namespace std;using i64 = long long;
// 情况1:找到最长的连续相同字符子串i64 solve1(const string& s) { i64 len = s.size(), ans = -1; for (i64 i = 0; i < len; i++) { i64 r = i; while (i + 1 < len && s[r] == s[i + 1]) i++; ans = max(ans, i - r + 1); } return ans;}
// 情况2:找到'a'和'b'数量相等的子串i64 solve2(const string& s, char a, char b) { i64 len = s.size(), ans = 0, cnt = 0; unordered_map<i64, i64> m; // 哈希表:前缀和差值 → 第一次出现的位置 m[0] = -1; // 初始状态,前缀和为0在位置-1, m={{0:-1}} for (i64 i = 0; i < len; i++) { if (s[i] == a) cnt++; // 遇到字符a,计数+1 else if (s[i] == b) cnt--; // 遇到字符b,计数-1 else { // 遇到其他字符(理论上不会出现) cnt = 0; m.clear(); m[0] = i; } if (m.count(cnt)) ans = max(ans, i - m[cnt]); // 相同差值再次出现,计算区间长度 else m[cnt] = i; // 记录该差值第一次出现的位置 } return ans;}
// 主求解函数i64 solve(const string& s) { i64 ans1 = solve1(s); // 单一字符情况 i64 ans2 = solve2(s, 'a', 'b'); // 'a'和'b'数量相等情况 return max({ans1, ans2}); // 取两种情况的最大值}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { string s; cin >> s; cout << solve(s) << "\n"; } return 0;}"abba"情况1:单一字符子串
"a":长度1
"bb":长度2
"a":长度1 最长长度为 2。
情况2:两种字符数量相等
xxxxxxxxxx初始: cnt=0, m={0:-1}i=0: s[0]='a' → cnt=1, m中无1 → m[1]=0i=1: s[1]='b' → cnt=0, m[0]=-1存在 → 区间长度=1-(-1)=2, ans=2i=2: s[2]='b' → cnt=-1, m中无-1 → m[-1]=2i=3: s[3]='a' → cnt=0, m[0]=-1存在 → 区间长度=3-(-1)=4, ans=4
最长长度为 4。
最终答案:max(2, 4) = 4。
问题定义
给定一个只包含 'a' 和 'b' 的字符串,将 'a' 视为 +1,'b' 视为 -1,寻找最长的子串,其中 'a' 和 'b' 的数量相等(即子串的和为 0)。
初始设置
字符串:s = "abba"
长度:n = 4
映射规则:
'a' → +1
'b' → -1
前缀和变量:cnt = 0(初始为空子串的和)
哈希表:m = { },记录每个前缀和第一次出现的位置
m[0] = -1(初始状态:空子串的前缀和为 0,位置为 -1)
完全推导过程(表格形式)
| 步骤 | 索引 i | 字符 s[i] | 值 | 操作前 cnt | 更新后 cnt | 哈希表 m 状态 {值: 首次位置} | 操作'a'→+1,'b'→-1 | 当前最长长度 | 说明 |
|---|---|---|---|---|---|---|---|---|---|
| 初始化 | - | - | - | - | 0 | {0: -1} | - | 0 | 初始状态 |
| 1 | 0 | 'a' | +1 | 0 | 1 | {0: -1} | cnt += 1 | 0 | m[1] 不存在 |
| {0: -1, 1: 0} | m[1] = 0 | 0 | 记录前缀和1第一次出现 | ||||||
| 2 | 1 | 'b' | -1 | 1 | 0 | {0: -1, 1: 0} | cnt += (-1) | 0 | m[0] = -1 存在 |
| {0: -1, 1: 0} | 区间长度 = 1 - (-1) = 2 | 2 | 更新答案:子串 "ab" | ||||||
| 3 | 2 | 'b' | -1 | 0 | -1 | {0: -1, 1: 0} | cnt += (-1) | 2 | m[-1] 不存在 |
| {0: -1, 1: 0, -1: 2} | m[-1] = 2 | 2 | 记录前缀和-1第一次出现 | ||||||
| 4 | 3 | 'a' | +1 | -1 | 0 | {0: -1, 1: 0, -1: 2} | cnt += 1 | 2 | m[0] = -1 存在 |
| {0: -1, 1: 0, -1: 2} | 区间长度 = 3 - (-1) = 4 | 4 | 更新答案:子串 "abba" |
详细分析
关键原理
对于任意子串 s[l...r],其和为 0 当且仅当:
xxxxxxxxxx前缀和[r] = 前缀和[l-1]
逐步图解
xxxxxxxxxx原始字符串: a b b a索引: 0 1 2 3值: +1 -1 -1 +1前缀和变化:i=-1: cnt=0 (初始)i=0: cnt=0+1=1 → 记录 m[1]=0i=1: cnt=1-1=0 → 找到 m[0]=-1 → 子串[0,1]="ab" (长度2)i=2: cnt=0-1=-1 → 记录 m[-1]=2i=3: cnt=-1+1=0 → 找到 m[0]=-1 → 子串[0,3]="abba" (长度4)
验证所有可能的子串
| 子串 | 字符 | a的数量 | b的数量 | 是否平衡 | 长度 |
|---|---|---|---|---|---|
| "a" | a | 1 | 0 | ❌ | 1 |
| "ab" | a,b | 1 | 1 | ✅ | 2 |
| "abb" | a,b,b | 1 | 2 | ❌ | 3 |
| "abba" | a,b,b,a | 2 | 2 | ✅ | 4 |
| "b" | b | 0 | 1 | ❌ | 1 |
| "bb" | b,b | 0 | 2 | ❌ | 2 |
| "bba" | b,b,a | 1 | 2 | ❌ | 3 |
| "ba" | b,a | 1 | 1 | ✅ | 2 |
最长的平衡子串:"abba",长度 = 4
📊 哈希表状态演变
| 步骤 | 哈希表 m 的内容 | 含义 |
|---|---|---|
| 初始 | {0: -1} | 空串的前缀和为0,位置为-1 |
| i=0后 | {0: -1, 1: 0} | 到位置0的前缀和为1 |
| i=1后 | {0: -1, 1: 0} | 不变(找到了平衡子串) |
| i=2后 | {0: -1, 1: 0, -1: 2} | 到位置2的前缀和为-1 |
| i=3后 | {0: -1, 1: 0, -1: 2} | 不变(找到了更长的平衡子串) |
本题是分类讨论+前缀和哈希的应用:
分类处理:将平衡子串分为单一字符和两种字符数量相等两种情况。
连续相同字符:简单遍历即可求得。
数量相等转化:将字符计数差转化为前缀和,使用哈希表快速查找。
核心代码
xxxxxxxxxxi64 longest_balanced_substring(string s) { unordered_map<i64, i64> m; m[0] = -1; // 关键:空串的前缀和为0,位置为-1 即 m{{0,-1}} i64 cnt = 0, ans = 0; for (i64 i = 0; i < s.size(); i++) { // 更新前缀和 cnt += (s[i] == 'a' ? 1 : -1); if (m.count(cnt)) { // 找到平衡子串:s[m[cnt]+1 ... i] ans = max(ans, i - m[cnt]); } else { // 第一次出现这个前缀和,记录位置 m[cnt] = i; } } return ans;}时间复杂度
O(n):只需遍历字符串一次
O(n) 空间:最坏情况下需要存储n个不同的前缀和
适用场景
01平衡问题:将0视为-1,1视为+1
奇偶计数问题:奇数为+1,偶数为-1
两种字符数量相等:如括号匹配、DNA序列等
全面覆盖:考虑了平衡子串的所有可能情况。
高效求解:两种情况均可在 O(n) 时间内解决。
代码清晰:通过函数分离不同情况,逻辑清晰。
变体1:三种字符的平衡
如果字符串包含'a'、'b'、'c'三种字符,要找三种字符数量相等的子串,可以使用二维前缀和:
xxxxxxxxxxcnt_a - cnt_bcnt_a - cnt_c
当两个差值都为0时,三种字符数量相等。
变体2:最多允许k个不平衡
使用滑动窗口维护窗口内字符计数,当不平衡度超过k时收缩左边界。
变体3:加权平衡
给不同字符赋予不同的权重,寻找权重和为0的子串。
✅ 验证结论
对于字符串 "abba":
最长单一字符子串:"bb",长度 = 2
最长平衡子串(a和b数量相等):"abba",长度 = 4
最终答案:max(2, 4) = **4**
这个推导过程清晰地展示了前缀和+哈希表算法如何高效地找到最长平衡子串。
给你一个只包含字符 'a','b' 和 'c' 的字符串 s。
如果一个 子串 中所有 不同 字符出现的次数都 相同,则称该子串为 平衡 子串。
请输出 s 的 最长平衡子串 的 长度。
子串 是字符串中 连续的、非空 的字符序列。
第一行包含 1 个整数 T,表示数据组数。
每组数据的包含一个字符串 s。
保证同一组内所有字符串的长度之和不超过 2×10^5。
对于每组数据输出 1 行包含 1 个数,表示 最长平衡子串 的 长度。
xxxxxxxxxx4abbacaabccabaacbca
xxxxxxxxxx4323
本题在 C 题基础上增加了字符 'c',平衡子串的可能情况更多:
单一字符子串:如 "aaa"、"bbb"、"ccc"。
两种字符数量相等:如 "ab"、"ac"、"bc"。
三种字符数量相等:如 "abc"、"acb"。
单一字符子串:与 C 题相同,寻找最长连续相同字符子串。
两种字符数量相等:分别考虑三种字符对 ('a','b')、('a','c')、('b','c'),使用前缀和+哈希表。
三种字符数量相等:使用两个差值 x = count_a - count_b 和 y = count_b - count_c,当 x=0 且 y=0 时三种字符数量相等。使用 map<pair<i64, i64>, i64> 记录状态 (x, y) 第一次出现的位置。
综合取最大值:将所有情况的结果取最大值。
单一字符子串:同 C 题。
两种字符数量相等:对每对字符调用 solve2 函数。
三种字符数量相等:
初始化哈希表 m,记录状态 (x, y) 第一次出现的位置:m[{0, 0}] = -1。
初始化当前差值 x = 0, y = 0,答案 ans3 = 0。
遍历字符串:
遇到 'a':x++。
遇到 'b':x--, y++。
遇到 'c':y--。
如果状态 (x, y) 已在哈希表中,计算区间长度 i - m[{x, y}],更新 ans3。
否则,将 (x, y) 及其位置存入哈希表。
输出所有情况的最大值。
时间复杂度:O(n log n),solve3 中使用 map 导致复杂度为 O(n log n)。
空间复杂度:O(n),存储状态信息。
xxxxxxxxxxusing namespace std;using i64 = long long;
// 情况1:找到最长的连续相同字符子串i64 solve1(const string& s) { i64 len = s.size(), ans = -1; for (i64 i = 0; i < len; i++) { i64 r = i; while (i + 1 < len && s[r] == s[i + 1]) i++; ans = max(ans, i - r + 1); } return ans;}
// 情况2:找到两种字符数量相等的子串i64 solve2(const string& s, char a, char b) { i64 len = s.size(), ans = 0, cnt = 0; unordered_map<i64, i64> m; // 哈希表:前缀和差值 → 第一次出现的位置 m[0] = -1; // 初始状态,前缀和为0在位置-1,即m{0,-1} for (i64 i = 0; i < len; i++) { if (s[i] == a) cnt++; // 遇到字符a,计数+1 else if (s[i] == b) cnt--; // 遇到字符b,计数-1 else { // 遇到第三种字符,重置状态 cnt = 0; m.clear(); m[0] = i; } if (m.count(cnt)) ans = max(ans, i - m[cnt]); // 相同差值再次出现,计算区间长度 else m[cnt] = i; // 记录该差值第一次出现的位置 } return ans;}
// 情况3:找到三种字符数量相等的子串i64 solve3(const string& s, char a, char b, char c) { i64 len = s.size(), ans = 0; i64 x = 0, y = 0; // x = count_a - count_b, y = count_b - count_c map<pair<i64, i64>, i64> m; // 哈希表:状态(x,y) → 第一次出现的位置 m[ {0, 0} ] = -1; // 初始状态 for (i64 i = 0; i < len; i++) { if (s[i] == a) x++; // 遇到字符a,x增加 else if (s[i] == b) x--, y++; // 遇到字符b,x减少,y增加 else y--; // 遇到字符c,y减少 if (m.count({x, y})) ans = max(ans, i - m[ {x, y} ]); // 相同状态再次出现 else m[ {x, y} ] = i; // 记录该状态第一次出现的位置 } return ans;}
// 主求解函数:综合所有情况i64 solve(const string& s) { i64 ans = solve1(s); // 单一字符情况 i64 resab = solve2(s, 'a', 'b'); // 'a'和'b'数量相等情况 i64 resac = solve2(s, 'a', 'c'); // 'a'和'c'数量相等情况 i64 resbc = solve2(s, 'b', 'c'); // 'b'和'c'数量相等情况 i64 resabc = solve3(s, 'a', 'b', 'c'); // 'a'、'b'、'c'数量相等情况 return max({ans, resab, resac, resbc, resabc}); // 取所有情况的最大值}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { string s; cin >> s; cout << solve(s) << "\n"; } return 0;}"abbac"情况1:单一字符子串
"a":长度1
"bb":长度2
"a":长度1
"c":长度1 最长长度为 2。
情况2:两种字符数量相等
('a','b'):最长子串 "abba" 长度4。
('a','c'):最长子串 "ac" 长度2。
('b','c'):最长子串 "bc" 长度2。
情况3:三种字符数量相等
xxxxxxxxxx初始: (x,y)=(0,0), m={(0,0):-1}i=0: s[0]='a' → (1,0)不存在 → m[(1,0)]=0i=1: s[1]='b' → (0,1)不存在 → m[(0,1)]=1i=2: s[2]='b' → (-1,2)不存在 → m[(-1,2)]=2i=3: s[3]='a' → (0,2)不存在 → m[(0,2)]=3i=4: s[4]='c' → (0,1)已存在(i=1) → 区间长度=4-1=3
最长长度为 3。
最终答案:max(2, 4, 2, 2, 3) = 4。
本题是多维状态哈希的进阶应用:
全面分类:考虑了单一字符、两种字符、三种字符数量相等所有情况。
状态设计:对于三种字符,使用两个差值 (x, y) 表示状态。
复合键哈希:使用 map<pair<i64, i64>, i64> 存储二维状态。
覆盖完整:确保找到所有可能的平衡子串。
复杂度可控:虽然使用 map 增加 log 因子,但 n ≤ 10^5 可接受。
扩展性强:方法可扩展到更多字符,但状态维度会增加。
如果字符集更大(如 26 个小写字母)?
需要设计更高效的状态表示,如使用哈希表存储计数向量。
可能涉及更复杂的数据结构。
给你一个初始为空的数组 a[],请你维护如下三种操作:
1、P x:将数 x 放到数组的末尾。
2、A x:将数组中的所有数加上 x。
3、Q x:询问数组中有多少个数等于 x。
第一行包含 1 个整数 T,表示数据组数。
每组数据的第一行包含一个正整数 m,表示操作的个数。
接下来 m 行,每行包含一个操作。
保证同一组内所有 m 的之和不超过
对于每组数据的操作 3,输出答案。
xxxxxxxxxx17Q 1P 1Q 1P 2P 2A 3Q 5
xxxxxxxxxx012
我们需要维护一个数组,支持插入、全体加、查询三种操作。直接模拟全体加操作会超时,需要优化。
相对值思想:使用一个全局偏移量 tmp 表示当前所有元素被加上的总值。实际存储的是原始值,查询时考虑偏移量。
具体操作:
插入 (P x):存储 x - tmp(因为最终值会是 (x - tmp) + tmp = x)。
全体加 (A x):tmp += x。
查询 (Q x):查询等于 x - tmp 的元素个数。
设某个元素的原始存储值为 stored,经过多次全体加后,其当前值为 stored + total_add。
查询值为 x 时,需要:
xxxxxxxxxxstored + total_add = x ⇒ stored = x - total_add
因此,我们只需统计存储值中等于 x - total_add 的元素个数。
初始化全局偏移量 tmp = 0,哈希表 q 用于计数。
处理每个操作:
P x:q[x - tmp]++。
A x:tmp += x。
Q x:输出 q[x - tmp](若不存在则输出 0)。
注意:由于 x - tmp 可能为负数,哈希表需支持负键。
时间复杂度:O(1) 每个操作,哈希表操作平均 O(1)。
空间复杂度:O(n),存储插入的所有数的计数。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 m, x, tmp = 0; cin >> m; unordered_map<i64, i64> q; // 哈希表:存储实际值 → 出现次数 while (m--) { char op; cin >> op >> x; if (op == 'P') { q[x - tmp]++; // 插入:存储 x - 当前偏移量 } else if (op == 'A') { tmp += x; // 全体加:更新全局偏移量 } else { // 查询操作 if (q.count(x - tmp)) cout << q[x - tmp] << "\n"; // 查询 x - 当前偏移量 else cout << 0 << "\n"; } } } return 0;}xxxxxxxxxx初始: tmp=0, q为空操作1: Q 1 → 查询 x-tmp=1-0=1, q中无1 → 输出0操作2: P 1 → 存储 1-tmp=1 → q[1]=1操作3: Q 1 → 查询 x-tmp=1-0=1, q[1]=1 → 输出1操作4: P 2 → 存储 2-tmp=2 → q[2]=1操作5: P 2 → 存储 2-tmp=2 → q[2]=2操作6: A 3 → tmp=0+3=3操作7: Q 5 → 查询 x-tmp=5-3=2, q[2]=2 → 输出2
最终数组实际值为 [4,5,5](原始存储 [1,2,2] 加上偏移量 3),查询 5 的个数为 2。
本题是偏移量技巧的典型应用:
避免全体加:通过维护全局偏移量,将全体加操作转化为 O(1) 的变量更新。
存储原始值:实际存储的是原始值,查询时考虑偏移量。
哈希表计数:使用哈希表快速支持插入和查询。
高效操作:所有操作 O(1) 完成。
空间节省:只需存储原始值,无需额外数组。
思想巧妙:通过相对值转化,避免了昂贵的全体加操作。
如果支持删除操作?
需要维护每个值的出现次数,删除时减少计数。
同样需要考虑偏移量。
给你一个长度为 n 的数组
1、从中选取一个 连续 的区间
2、计算这个数组 B 的 前缀和 数组
C 数组的长度为:
找出这样一个区间
本题包含多组测试数据。
第一行输入一个正整数 T,表示数据组数。
接下来包含 T 组数据,每组数据的格式如下:
第一行输入一个正整数 n。
第二行输入 n 个整数,表示温度序列
对于每组测试数据:
输出一行一个非负整数,表示最优情况下前缀和序列中 0 的最大数量。
xxxxxxxxxx25-1 0 1 0 054 2 0 -2 9
xxxxxxxxxx31
我们需要找到一个区间
设原数组 A 的前缀和为
要使
因此,对于固定的左端点
从右往左扫描:对于每个左端点
具体实现:
从
维护变量 tmp 表示
对于每个 q 中。
查询时,0 - tmp 对应 q.count(0 - tmp) 就是
读入数组 a,计算前缀和 S(但代码中未显式计算,而是通过 tmp 动态维护)。
初始化 tmp = 0,哈希表 q,答案 ans = 0。
从 L = n 递减到 1:
tmp += a[L](此时 tmp = S[L..n])。
q[a[L] - tmp]++(存储
如果 q.count(0 - tmp),更新 ans = max(ans, q[0 - tmp])。
输出 ans。
时间复杂度:O(n),每个元素处理一次。
空间复杂度:O(n),哈希表存储不同前缀和值的计数。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 T; cin >> T; while (T--) { i64 n, ans = 0, tmp = 0; cin >> n; vector<i64> a(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i]; unordered_map<i64, i64> q; // 哈希表:存储 S[L] - S[n] → 出现次数 for (i64 L = n; L >= 1; L--) { // 从右往左扫描左端点 L tmp += a[L]; // tmp = S[L..n] = a[L] + a[L+1] + ... + a[n] q[a[L] - tmp]++; // 存储 S[L] - S[n] if (q.count(0 - tmp)) // 查询 S[L-1] - S[n] 的出现次数 ans = max(ans, q[0 - tmp]); // 更新最大 0 的数量 } cout << ans << "\n"; } return 0;}[-1, 0, 1, 0, 0]计算前缀和 S:
S[0]=0, S[1]=-1, S[2]=-1, S[3]=0, S[4]=0, S[5]=0。
算法执行(从右往左):
xxxxxxxxxx初始: tmp=0, q={}, ans=0L=5: a[5]=0, tmp=0, q[0-0=0]=1, 0-tmp=0存在(q[0]=1) → ans=1L=4: a[4]=0, tmp=0, q[0-0=0]=2, 0-tmp=0存在(q[0]=2) → ans=2L=3: a[3]=1, tmp=1, q[1-1=0]=3, 0-tmp=-1不存在 → ans=2L=2: a[2]=0, tmp=1, q[0-1=-1]=1, 0-tmp=-1存在(q[-1]=1) → ans=2L=1: a[1]=-1, tmp=0, q[-1-0=-1]=2, 0-tmp=0存在(q[0]=3) → ans=3
最终答案:3,对应区间 [1,5] 的前缀和数组中有 3 个 0。
本题是逆向思维+前缀和哈希的巧妙应用:
问题转化:将前缀和数组中 0 的数量转化为原数组前缀和值的相等关系。
逆向扫描:从右往左枚举左端点,便于统计后缀中某个值的出现次数。
偏移量技巧:通过 tmp 动态维护后缀和,避免显式计算所有前缀和。
线性复杂度:O(n) 时间解决看似复杂的问题。
空间高效:只需 O(n) 额外空间。
思维跳跃:需要将问题转化为等价形式,并设计合适的扫描顺序。
如果要求前缀和数组中某个特定值 k 的最大数量?
只需将查询条件 0 - tmp 改为 k - tmp。
算法框架不变。
小 R 有一个长度为 n 的非负整数序列
小 X 给了小 R 一个非负整数 k。小 X 希望小 R 选择序列中尽可能多的不相交的区间,使得每个区间的权值均为 k。两个区间
你需要帮助小 R 求出他能选出的区间数量的最大值。
输入的第一行包含两个非负整数 n,k,分别表示小 R 的序列长度和小 X 给小 R 的非负整数。
输入的第二行包含 n 个非负整数 a1,a2,…,an,表示小 R 的序列。
输出一行一个非负整数,表示小 R 能选出的区间数量的最大值。
xxxxxxxxxx4 22 1 0 3
xxxxxxxxxx2
我们需要选择尽可能多的不相交区间,使得每个区间的异或和都等于 k,目标是最大化区间数量。
这是一个区间选择问题,具有最优子结构,适合用动态规划解决。
动态规划 + 哈希表:
定义 dp[i] 表示考虑前 i 个元素时,能选出的最多区间数量。
对于每个位置 i,有两种选择:
不选以 i 结尾的区间:dp[i] = dp[i-1]。
选以 i 结尾的区间:需要找到一个 j < i,使得区间 [j+1, i] 的异或和为 k,此时 dp[i] = dp[j] + 1。
为了快速找到满足条件的 j,我们使用哈希表记录每个前缀异或值最后出现的位置。因为如果存在多个 j 满足条件,我们应选择最大的 j(贪心:使区间尽可能靠后,留给后面的空间更多)。
前缀异或:
设 pre[i] = a[1] ^ a[2] ^ ... ^ a[i]。
区间 [l, r] 的异或和为 pre[r] ^ pre[l-1]。
要使区间异或和为 k,需要 pre[l-1] = pre[r] ^ k。
初始化 dp[0] = 0,哈希表 q 记录前缀异或值最后出现的位置:q[0] = 0(空序列异或和为0,位置为0)。
初始化当前前缀异或 now = 0。
遍历 i 从 1 到 n:
更新 now = now ^ a[i]。
dp[i] = dp[i-1](不选以 i 结尾的区间)。
如果 q.count(now ^ k) 存在,设 j = q[now ^ k],则 dp[i] = max(dp[i], dp[j] + 1)。
更新 q[now] = i(记录当前前缀异或值的最后位置)。
输出 dp[n]。
时间复杂度:O(n),每个元素处理一次,哈希表操作平均 O(1)。
空间复杂度:O(n),存储 dp 数组和哈希表。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> a(n + 1), dp(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i];
// q[x] = 记录前缀异或和为x时,最后一个出现的位置 unordered_map<i64, i64> q; q[0] = 0; // 初始状态:空序列的异或和为0,位置为0 i64 now = 0; // 当前前缀异或和 for (i64 i = 1; i <= n; i++) { dp[i] = dp[i - 1]; // 选择1:不选择以i结尾的区间 now ^= a[i]; // 更新前缀异或和 // 检查是否存在j使得 pre[j] = pre[i] ⊕ m if (q.count(now ^ m)) { // 选择以i结尾的区间,区间数量增加1 dp[i] = max(dp[i], dp[q[now ^ m]] + 1); } // 更新当前前缀异或值的最后出现位置(贪心:总是记录最后出现的位置) q[now] = i; } cout << dp[n] << "\n"; return 0;}n=4, k=2, a=[2,1,0,3]计算前缀异或 pre:
pre[0]=0, pre[1]=2, pre[2]=3, pre[3]=3, pre[4]=0。
算法执行:
xxxxxxxxxx初始: dp[0]=0, q={0:0}, now=0i=1: now=2, dp[1]=dp[0]=0now^k=2^2=0, q[0]=0存在 → dp[1]=max(0, dp[0]+1)=1q[2]=1i=2: now=3, dp[2]=dp[1]=1now^k=3^2=1, q[1]不存在 → dp[2]=1q[3]=2i=3: now=3, dp[3]=dp[2]=1now^k=3^2=1, q[1]不存在 → dp[3]=1q[3]=3 (更新)i=4: now=0, dp[4]=dp[3]=1now^k=0^2=2, q[2]=1存在 → dp[4]=max(1, dp[1]+1)=2q[0]=4 (更新)
最终 dp[4] = 2,选择区间 [1,1](异或和2)和 [2,4](异或和103=2)。
本题是动态规划+贪心+哈希表的综合应用:
状态定义:dp[i] 表示前 i 个元素的最优解。
转移方程:考虑是否选择以 i 结尾的区间,利用哈希表快速找到满足条件的左端点。
贪心选择:对于相同的前缀异或值,只记录最后出现的位置,因为更靠后的分割点更优。
线性复杂度:O(n) 时间解决区间选择问题。
空间优化:使用哈希表避免枚举所有可能区间。
正确性保证:动态规划确保全局最优,贪心选择证明可行。
如果区间可以相交,但要求最多重叠 k 次?
可能需要更复杂的动态规划状态,如 dp[i][j] 表示前 i 个元素,当前重叠次数为 j 的最优解。
或者使用贪心+堆维护区间。
在二维平面上有
现在需要你用 一条水平线 y=b 和 一条竖直线 x=a 将平面分割成 4 个区域(a,b 都是 偶数),设
第一行包含 1 个整数 n,表示点的个数。
接下来 n 行,每行包含
输出 点数最多的区域 的最少点数。
xxxxxxxxxx77 35 57 133 111 75 39 1
xxxxxxxxxx2
我们需要用一条水平线和一条竖直线将平面分成四个区域,使得点数最多的区域包含的点数尽可能少。由于点的坐标都是奇数,分割线坐标都是偶数,分割线不会穿过任何点。
离散化 + 二维前缀和:
直接枚举所有可能的 a 和 b(偶数)会超时,因为坐标范围可达 10^6。
观察到点的数量 n ≤ 1000,我们可以将 x 和 y 坐标分别离散化,只考虑点与点之间的位置作为分割线。
使用二维前缀和可以快速计算任意矩形区域内的点数。
离散化:
将 x 坐标和 y 坐标分别排序去重,得到离散化数组。
将原始坐标映射到离散化索引(从1开始)。
二维前缀和:
构建二维数组 p[ex][ey],其中 p[i][j] 表示离散化坐标中,区域 (1,1) 到 (i,j) 的点数。
通过公式 p[i][j] = p[i-1][j] + p[i][j-1] - p[i-1][j-1] + (点是否存在) 计算。
枚举分割位置:
分割线在离散化坐标中位于两个点之间,因此枚举 i 从 1 到 ex-1(竖直线在 x[i] 和 x[i+1] 之间),j 从 1 到 ey-1(水平线在 y[j] 和 y[j+1] 之间)。
对于每个分割位置 (i,j),计算四个区域的点数:
左下:矩形 (1,1) 到 (i,j)。
右下:矩形 (i+1,1) 到 (ex,j)。
左上:矩形 (1,j+1) 到 (i,ey)。
右上:矩形 (i+1,j+1) 到 (ex,ey)。
更新最大值的最小值。
输出答案。
时间复杂度:O(n^2),其中 n ≤ 1000。
离散化:O(n log n)。
前缀和计算:O(n^2)。
枚举分割位置:O(n^2)。
空间复杂度:O(n^2),存储二维前缀和数组。
xxxxxxxxxxusing namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n; cin >> n; vector<i64> x(n), y(n), px(n), py(n); for (i64 i = 0; i < n; i++) { cin >> x[i] >> y[i]; px[i] = x[i]; // 保存原始x坐标 py[i] = y[i]; // 保存原始y坐标 }
// 离散化x坐标 sort(x.begin(), x.end()); x.erase(unique(x.begin(), x.end()), x.end()); // 离散化y坐标 sort(y.begin(), y.end()); y.erase(unique(y.begin(), y.end()), y.end()); i64 ex = x.size(), ey = y.size(); // 将原始坐标映射到离散化索引 for (i64 i = 0; i < n; i++) { px[i] = lower_bound(x.begin(), x.end(), px[i]) - x.begin() + 1; py[i] = lower_bound(y.begin(), y.end(), py[i]) - y.begin() + 1; }
// 构建二维前缀和 vector<vector<i64>> p(ex + 1, vector<i64>(ey + 1, 0)); for (i64 i = 0; i < n; i++) { p[px[i]][py[i]] = 1; // 标记有点的位置 } // 计算前缀和 for (i64 i = 1; i <= ex; i++) { for (i64 j = 1; j <= ey; j++) { p[i][j] = p[i - 1][j] + p[i][j - 1] - p[i - 1][j - 1] + p[i][j]; } }
// 计算矩形区域点数的辅助函数 auto calc = [&](i64 x1, i64 y1, i64 x2, i64 y2) -> i64 { return p[x2][y2] - p[x2][y1 - 1] - p[x1 - 1][y2] + p[x1 - 1][y1 - 1]; };
i64 ans = n; // 初始化为最大可能值 // 枚举所有可能的分割位置 for (i64 i = 1; i < ex; i++) { // 竖直线在x[i]和x[i+1]之间 for (i64 j = 1; j < ey; j++) { // 水平线在y[j]和y[j+1]之间 // 计算四个区域的点数 i64 c1 = calc(1, 1, i, j); // 左下区域 i64 c2 = calc(i + 1, 1, ex, j); // 右下区域 i64 c3 = calc(1, j + 1, i, ey); // 左上区域 i64 c4 = calc(i + 1, j + 1, ex, ey); // 右上区域 // 更新最大值的最小值 ans = min(ans, max({c1, c2, c3, c4})); } } cout << ans << "\n"; return 0;}考虑简单例子:3个点 (1,1), (3,3), (5,5)。
离散化后:
x坐标:[1,3,5] → 索引:1,2,3。
y坐标:[1,3,5] → 索引:1,2,3。
二维前缀和矩阵:
xxxxxxxxxx[1,0,0][0,1,0][0,0,1]
枚举分割位置:
分割线在 (1,1) 和 (2,2) 之间:
c1=0, c2=1, c3=1, c4=1 → max=1。
分割线在 (2,2) 和 (3,3) 之间:
c1=1, c2=1, c3=0, c4=1 → max=1。
答案:1。
本题是离散化+二维前缀和的经典应用:
离散化:将大坐标范围缩小到点数规模,便于处理。
二维前缀和:快速计算任意矩形区域内的点数,O(1) 查询。
分割位置枚举:由于分割线在点之间,只需枚举离散化坐标的间隙。
高效处理:O(n^2) 时间在 n≤1000 时可行。
精确计算:通过前缀和保证点数计算的正确性。
通用性强:方法适用于多种平面分割问题。
如果分割线可以是任意实数(不限于偶数)?
离散化方法仍然适用,因为最优分割线一定在点坐标之间。
只需枚举所有 x 坐标和 y 坐标之间的位置。
有一个长度为 n 的序列 a ,序列中的每个值在
请你求出这个序列有多少对连续子序列 (A,B) ,满足 A 在 B 之前,且 A,B 中所有元素的异或和为 m。
简单来说,你需要求出有多少个四元组 (l1,r1,l2,r2) ,满足 l1≤r1<l2≤r2,且 (⨁i=l1r1ai)⨁(⨁i=l2r2ai)=m ⨁ 表示异或。
第一行两个整数 n,m,表示数组长度,异或和。
第二行 n 个整数,表示数组 a 。
一行一个整数,表示答案。
保证答案不超过 long long 表示范围。
xxxxxxxxxx4 20 1 2 3
xxxxxxxxxx3
我们需要统计有多少对不相交的连续子序列 (A,B),满足 A 的异或和 ⊕ B 的异或和 = m。
等价于统计四元组 (l1,r1,l2,r2) 满足 l1≤r1<l2≤r2 且 xor(l1,r1) ⊕ xor(l2,r2) = m。
枚举分割点 + 动态统计:
枚举分割点 k,将序列分为左部分 [1,k] 和右部分 [k+1,n]。
左部分中,A 必须是某个以 k 结尾的区间。
右部分中,B 必须是某个以 k+1 开头的区间。
对于固定的 k,我们需要统计左部分异或值为 x 的区间个数 L[x],和右部分异或值为 y 的区间个数 R[y],满足 x ⊕ y = m。
总答案 = Σk Σx L[x] * R[x⊕m]。
高效维护 L 和 R:
左部分 L:可以从左到右构建,每次添加一个新元素 a[i],新的区间包括所有旧区间加上 a[i],以及单元素区间 [i,i]。
右部分 R:可以从右到左构建,类似地。
在枚举 k 时,需要动态更新 L 和 R(因为 k 移动时,左部分减少一个元素,右部分增加一个元素)。
初始化 maxi = 2048(因为 a_i ≤ 1024,异或最大值 2047)。
构建左部分统计数组 L:从 i=1 到 n-1,计算以 i 结尾的所有区间的异或值分布。
从右往左枚举分割点 i(从 n 到 2):
更新右部分统计 R:以 i 开头的区间分布。
累计 R 到总右部分分布数组 r 中。
计算当前分割点的贡献:ans += Σ_x L[x] * r[x⊕m]。
更新左部分统计 L:移除以 i-1 结尾的区间(因为分割点左移)。
输出答案。
时间复杂度:O(n × maxi),其中 maxi=2048,n≤10^5,总操作约 2e8,在时限内可接受。
空间复杂度:O(maxi) = O(2048),存储统计数组。
xxxxxxxxxxusing namespace std;using i64 = long long;
i64 solve(vector<i64> a, i64 n, i64 m) { i64 maxi = 2048, ans = 0; // maxi=2048,因为a_i≤1024,异或最大值2047 // L数组:统计左部分(以某个位置结尾)的区间异或值分布 vector<i64> L(maxi, 0); // 构建左部分统计:从位置1到n-1 for (i64 i = 1; i < n; i++) { vector<i64> t(maxi, 0); t[a[i]] = 1; // 单元素区间 [i,i] // 将a[i]添加到所有以i-1结尾的区间后面 for (i64 j = 0; j < maxi; j++) { t[j ^ a[i]] += L[j]; } L = t; // 更新L为以i结尾的区间分布 } // R数组:统计右部分(以某个位置开头)的区间异或值分布 // r数组:累计右部分所有区间的异或值分布 vector<i64> R(maxi, 0), r(maxi, 0); // 从右往左处理分割点 for (i64 i = n; i > 1; i--) { // 更新右部分统计:以i开头的区间 vector<i64> t(maxi, 0); t[a[i]] = 1; // 单元素区间 [i,i] // 将a[i]添加到所有以i+1开头的区间前面 for (i64 j = 0; j < maxi; j++) { t[j ^ a[i]] += R[j]; } R = t; // 更新R为以i开头的区间分布 // 累计到r中(右部分所有区间的分布) for (i64 j = 0; j < maxi; j++) { r[j] += R[j]; } // 计算以i为分割点的答案 // 对于左部分异或值j,需要右部分异或值为j⊕m for (i64 j = 0; j < maxi; j++) { ans += L[j] * r[j ^ m]; } // 更新左部分统计:分割点左移,需要移除以i-1结尾的区间 vector<i64> tmp(maxi, 0); L[a[i - 1]]--; // 移除单元素区间 [i-1,i-1] // 移除所有以i-1结尾的区间 for (i64 j = 0; j < maxi; j++) { tmp[j] = L[j ^ a[i - 1]]; } L = tmp; } return ans;}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<i64> a(n + 1); for (i64 i = 1; i <= n; i++) cin >> a[i]; cout << solve(a, n, m) << "\n"; return 0;}n=4, m=2, a=[0,1,2,3]左部分 L 构建(以 i 结尾的区间分布):
i=1: L = {0:1}(区间[1,1])。
i=2: L = {1:1, 0^1=1:1}(区间[2,2], [1,2])。
i=3: L = {2:1, 1^2=3:1, 1^2=3:1, 012=3:1}。
右部分 R 构建及贡献计算(从右往左):
i=4: R={3:1}, r={3:1},贡献 = L[0]r[2]+L[1]r[3]+L[2]r[0]+L[3]r[1] = 0+11+0+30 = 1。
i=3: R={2:1, 3^2=1:1}, r累计为{2:1,1:1,3:1},贡献 = ... = 2。
i=2: R={1:1, 2^1=3:1, 1^1=0:1}, r累计...,贡献 = 0。
总答案:1+2+0 = 3。
对应方案:
A={0}, B={2}。
A={1}, B={3}。
A={0,1}, B={3}。
本题是动态统计+枚举分割点的高级技巧:
分割点枚举:将问题分解为左部分和右部分,分别统计区间异或分布。
动态维护:在移动分割点时,高效更新左右部分的统计信息。
卷积式计数:答案计算类似于卷积形式 Σ L[x] * R[x⊕m]。
高效枚举:通过动态维护避免重复计算,将复杂度控制在 O(n × maxi)。
空间节省:只需 O(maxi) 的数组,而非 O(n^2)。
思维难度高:需要巧妙设计状态和转移。
如果要求三个不相交区间异或和满足条件?
可能需要枚举两个分割点,维护三部分统计。
复杂度会上升,可能需要优化。
前缀和:通过预处理数组的前缀和,可以快速计算任意区间的和、异或和等累积量,将区间查询从 O(n) 优化到 O(1)。
基本形式:
xxxxxxxxxxvector<i64> pre(n + 1);for (i64 i = 1; i <= n; i++) pre[i] = pre[i-1] + a[i];// 区间 [l, r] 和 = pre[r] - pre[l-1]应用:快速求区间和、平均数等。
基本形式:
xxxxxxxxxxvector<i64> pre_xor(n + 1);for (i64 i = 1; i <= n; i++) pre_xor[i] = pre_xor[i-1] ^ a[i];// 区间 [l, r] 异或和 = pre_xor[r] ^ pre_xor[l-1]应用:异或相关问题,如区间异或和为定值。
模板:
xxxxxxxxxxunordered_map<i64, i64> cnt; // 前缀和值 -> 出现次数或位置cnt[0] = -1; // 初始状态i64 sum = 0, ans = 0;for (i64 i = 0; i < n; i++) { sum += a[i]; // 或 sum ^= a[i] if (cnt.count(sum - target)) { ans = max(ans, i - cnt[sum - target]); } if (!cnt.count(sum)) cnt[sum] = i; // 记录第一次出现位置}应用:寻找和为定值(或异或为定值)的最长子数组。
基本形式:
xxxxxxxxxxvector<vector<i64>> pre(n + 1, vector<i64>(m + 1));for (i64 i = 1; i <= n; i++) for (i64 j = 1; j <= m; j++) pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + a[i][j];// 矩形 (x1,y1) 到 (x2,y2) 和 = pre[x2][y2] - pre[x2][y1-1] - pre[x1-1][y2] + pre[x1-1][y1-1]应用:平面区域求和问题。
将“数量相等”转化为“差值前缀和为0”。
将“区间异或和为k”转化为“前缀异或值满足某种关系”。
将“全体加”转化为“偏移量维护”。
记录前缀和第一次出现的位置,用于计算最长子数组。
记录前缀和的出现次数,用于计数满足条件的区间。
使用复合键(如(pre_xor, diff))处理多约束条件。
从左到右扫描:适用于以右端点结尾的区间统计。
从右到左扫描:适用于以左端点开始的区间统计,或结合后缀信息。
枚举分割点:将问题分解为左右两部分,分别统计后组合。
当坐标范围大但点数少时,离散化坐标,将问题规模缩小到点数级别。
常用于二维平面问题,结合二维前缀和。
| 类型 | 题目 | 核心技巧 | 时间复杂度 |
|---|---|---|---|
| 前缀和+哈希表 | 连续数组、异或子数组 | 差值前缀和、复合状态哈希 | O(n) 或 O(n log n) |
| 分类讨论+前缀和 | 最长的平衡子串1、2 | 字符计数转化、多维状态哈希 | O(n) 或 O(n log n) |
| 偏移量技巧 | 维护数组 | 全局偏移量、相对值存储 | O(1) per op |
| 逆向扫描+前缀和 | 旅行(trip) | 后缀统计、逆向枚举左端点 | O(n) |
| 动态规划+前缀和 | [CSP-J 2025] 异或和 | 前缀异或、哈希表记录最后位置 | O(n) |
| 离散化+二维前缀和 | Load_Balancing_S | 坐标离散化、二维前缀和 | O(n^2) |
| 动态统计+枚举分割点 | 异或序列 | 左右部分统计、卷积式计数 | O(n × maxi) |
判断是否涉及区间累积量(和、异或、计数差等)。
考虑使用前缀和优化,将区间操作转化为端点操作。
设计合适的前缀和定义(可能需要对原数据进行转化,如0视为-1)。
确定需要记录的信息(首次出现位置、出现次数等)。
选择合适的扫描顺序和数据结构(哈希表、数组等)。
清晰的前缀和定义和初始化。
正确处理边界情况(如空数组、初始状态)。
高效的数据结构操作(哈希表的查找和插入)。
注意数值范围,避免溢出。
对复杂问题,合理拆分功能模块。
掌握基本形式:熟练使用一维、二维前缀和模板。
理解转化思想:学会将各种条件转化为前缀和关系。
灵活运用哈希表:哈希表是前缀和问题的好伙伴,用于快速查找历史状态。
多做练习:通过题目体会不同技巧的应用场景和变形。
总结规律:归纳常见问题的转化方法和解题模式。
高维前缀和:能否扩展到三维或更高维?
动态前缀和:如果数组动态变化(点更新),如何高效维护前缀和?(树状数组、线段树)
非可加操作:对于非可加操作(如乘法、最大值),前缀和是否适用?如何改造?
分布式处理:大规模数据下,前缀和算法如何并行化?
记住:前缀和优化的核心是将区间问题转化为端点问题,通过预处理和哈希表等数据结构,在 O(1) 或 O(log n) 时间内完成查询。多练习、多思考,才能熟练掌握这一强大技巧!
学习建议:按照题目类型从易到难练习,每做完一道题思考:
如何定义前缀和?
如何将问题条件转化为前缀和关系?
需要记录哪些历史信息?
扫描顺序如何选择?
类似的问题有哪些?
通过这样的训练,才能真正掌握前缀和优化的精髓。
给你
然后进行
第一行三个整数
接下来
接下来
对于每个询问,输出一行,如果是亲戚输出 Yes,否则输出 No。
xxxxxxxxxx5 3 31 23 42 51 53 42 3
xxxxxxxxxxYesYesNo
亲戚关系具有传递性:如果 A 是 B 的亲戚,B 是 C 的亲戚,那么 A 是 C 的亲戚。
这正好对应并查集的连通性查询。
核心思想:
初始化:每个人自成一个集合
合并操作:对于每对亲戚关系,合并两人所在集合
查询操作:询问时检查两人是否在同一集合
算法步骤:
初始化并查集,大小为 n+1(因为编号从1开始)
读入 m 对关系,每对关系执行 merge(a, b)
读入 q 个询问,每对询问执行 same(x, y)
根据结果输出 Yes 或 No
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
// *********************** 并查集(Union-Find Disjoint Sets/DSU) 【begin】**********************struct DSU { vector<i64> f; // f[x]存储x的父节点 vector<i64> sz; // sz[x]存储以x为根的集合大小 DSU(i64 n) { f.resize(n + 1); // 分配n+1个空间 // 相当于 for (int i = 0; i <= n; i++) f[i] = i; // 将从 0 开始的一段连续的数赋值给 f.begin() 到 f.end():f[0]=0, f[1]=1, ..., f[n]=n iota(f.begin(), f.end(), 0); sz.assign(n + 1, 1); // 对于 vector 的初始化 // 如果只需要设置 size, 不需要设置初值, 用 resize // 如果既需要设置 size, 也需要设置初值, 用 assign } // 路径压缩:查找过程中将节点直接连接到根 i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } // 启发式合并:小集合合并到大集合,保持树平衡 bool merge(i64 x, i64 y) { x = find(x); y = find(y); if (x == y) return false; // 已在同一集合 // 将 sz小的 合并到 sz大的 if (sz[x] < sz[y]) swap(x, y); // 保证x是大集合 sz[x] += sz[y]; // 更新大小 f[y] = x; // 小集合的根指向大集合的根 return true; } // 返回 x 所在的集合的大小 i64 size(i64 x) { return sz[find(x)]; } // 判断 x 和 y 是否在同一个集合中 bool same(i64 x, i64 y) { return find(x) == find(y); }};// *********************** 并查集(Union-Find Disjoint Sets/DSU) 【end】**********************
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m, q; cin >> n >> m >> q; DSU dsu(n); // 创建并查集,大小为n // 处理亲戚关系 for (i64 i = 0; i < m; i++) { i64 a, b; cin >> a >> b; dsu.merge(a, b); // 合并亲戚关系 } // 处理查询 for (i64 i = 0; i < q; i++) { i64 x, y; cin >> x >> y; if (dsu.same(x, y)) { // 检查是否在同一集合 cout << "Yes\n"; } else { cout << "No\n"; } } return 0;}xxxxxxxxxxn=5, m=3, q=3关系:(1,2), (3,4), (2,5)询问:(1,5), (3,4), (2,3)
初始:{1}, {2}, {3}, {4}, {5}
合并(1,2):{1,2}, {3}, {4}, {5}
合并(3,4):{1,2}, {3,4}, {5}
合并(2,5):{1,2,5}, {3,4}
查询(1,5):find(1)=1, find(5)=1,在同一集合 → Yes
查询(3,4):find(3)=3, find(4)=3,在同一集合 → Yes
查询(2,3):find(2)=1, find(3)=3,不在同一集合 → No
亲戚问题是并查集最基础的应用:
合并:建立关系连接
查询:检查关系存在性
路径压缩:使查找操作接近 O(1)
启发式合并:保持树平衡,优化性能
初始化:注意编号从1开始还是从0开始
朋友关系:同样的传递关系
连通块计数:并查集中连通分量数
动态连通性:支持边添加和查询
并查集是处理动态连通性问题的利器,务必掌握。
给定一个
求该图的最小生成树(Minimum Spanning Tree, MST),输出最小生成树的边权之和。
如果图不连通,输出 -1。
第一行包含两个整数
接下来
输出一行,包含最小生成树的边权之和。如果图不连通,输出 -1。
xxxxxxxxxx4 50 1 100 2 60 3 51 3 152 3 4
xxxxxxxxxx19
最小生成树:在一个连通无向图中,选取
常见算法:Kruskal 算法(贪心选边 + 并查集)和 Prim 算法(贪心加点 + 优先队列)。
核心思想:
将所有边按权值从小到大排序
依次选取边,如果这条边连接的两个顶点不在同一个连通分量中,则加入生成树(使用并查集判断)
直到选取了
算法步骤:
读入边,存储为 (u, v, w) 元组
按边权升序排序
初始化并查集
遍历排序后的边:
如果 u 和 v 不连通,则选择这条边,累加边权,合并两个集合
如果已选边数达到
如果最终选边数不足 -1
复杂度分析:
时间复杂度:
空间复杂度:
核心思想:
从任意顶点开始,逐步扩展生成树
维护一个优先队列,存储从已选集合到未选集合的最小边
每次选取权值最小的边加入生成树
算法步骤:
构建邻接表
初始化 visited 数组,优先队列
从顶点 0 开始,将其所有邻接边加入优先队列
当队列非空且已选顶点数 < n 时:
弹出最小边,如果目标顶点已访问则跳过
否则加入生成树,累加边权,标记访问,将其邻接边加入队列
如果最终访问顶点数不足 n,说明图不连通
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
// *********************** 并查集模板 **********************struct DSU { vector<i64> f, sz; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); sz.assign(n + 1, 1); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; if (sz[x] < sz[y]) swap(x, y); sz[x] += sz[y]; f[y] = x; return true; }};
// ==================== Kruskal算法求最小生成树 ====================i64 kruskal_mst(i64 n, vector<tuple<i64, i64, i64>>& edges) { // 按边权从小到大排序 sort(edges.begin(), edges.end(), [](const tuple<i64, i64, i64>& a, const tuple<i64, i64, i64>& b) { return get<2>(a) < get<2>(b); }); DSU dsu(n); i64 total_weight = 0; i64 edges_used = 0; for (const auto& [u, v, w] : edges) { if (dsu.merge(u, v)) { // 如果两端点不在同一集合 total_weight += w; // 加入生成树 edges_used++; if (edges_used == n - 1) break; // 生成树已完成 } } return (edges_used == n - 1) ? total_weight : -1; // -1表示图不连通}
// ==================== Prim算法求最小生成树 ====================i64 prim_mst(i64 n, vector<vector<pair<i64, i64>>>& graph) { vector<bool> visited(n, false); priority_queue<pair<i64, i64>, vector<pair<i64, i64>>, greater<>> pq; // 从节点0开始 pq.push({0, 0}); // (边权, 节点) i64 total_weight = 0; i64 vertices_used = 0; while (!pq.empty() && vertices_used < n) { auto [w, u] = pq.top(); pq.pop(); if (visited[u]) continue; // 已访问过 visited[u] = true; total_weight += w; vertices_used++; // 将u的所有邻接边加入优先队列 for (const auto& [v, weight] : graph[u]) { if (!visited[v]) { pq.push({weight, v}); } } } return (vertices_used == n) ? total_weight : -1;}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; //****************** 方法1:Kruskal(使用边列表)******************** vector<tuple<i64, i64, i64>> edges(m); for (auto& [u, v, w] : edges) { cin >> u >> v >> w; u--; v--; // 转换为0-based索引 } i64 ans = kruskal_mst(n, edges); if (ans == -1) cout << "-1\n"; else cout << ans << "\n"; // ***************** 方法2:Prim(使用邻接表)********************** /* vector<vector<pair<i64, i64>>> graph(n); for (i64 i = 0; i < m; i++) { i64 u, v, w; cin >> u >> v >> w; u--; v--; graph[u].push_back({v, w}); graph[v].push_back({u, w}); } i64 ans = prim_mst(n, graph); if (ans == -1) cout << "-1\n"; else cout << ans << "\n"; */ return 0;}xxxxxxxxxx0 1 100 2 60 3 51 3 152 3 4
Step 1: 边排序
xxxxxxxxxx原始边:(0,1,10), (0,2,6), (0,3,5), (1,3,15), (2,3,4)排序后:(2,3,4), (0,3,5), (0,2,6), (0,1,10), (1,3,15)
Step 2: 并查集操作
初始并查集:{0}, {1}, {2}, {3}
选边(2,3,4): 2和3不在同一集合,合并,边权+4
并查集:{0}, {1}, {2,3}
总边权=4,边数=1
选边(0,3,5): 0和3不在同一集合,合并,边权+5
并查集:{0,2,3}, {1}
总边权=9,边数=2
选边(0,2,6): 0和2已在同一集合,跳过
选边(0,1,10): 0和1不在同一集合,合并,边权+10 并查集:{0,1,2,3} 总边权=19,边数=3
已选3条边 = n-1,结束。
最终结果:总边权=19
初始:visited = [false, false, false, false],优先队列空
从节点0开始:visited[0]=true 加入邻接边:(5,3), (6,2), (10,1) 到优先队列
弹出最小边(5,3):节点3未访问,visited[3]=true,边权+5=5 加入节点3的邻接边(除已访问的0):(4,2), (15,1) 队列:(4,2), (6,2), (10,1), (15,1)
弹出最小边(4,2):节点2未访问,visited[2]=true,边权+4=9 加入节点2的邻接边(除已访问的0,3):(6,0已访问跳过) 队列:(6,2), (10,1), (15,1)
弹出最小边(6,2):节点2已访问,跳过
弹出最小边(10,1):节点1未访问,visited[1]=true,边权+10=19 加入节点1的邻接边(除已访问的0,3):(15,3已访问跳过) 所有节点已访问,结束。
最终结果:总边权=19
Kruskal优势:实现简单,适合稀疏图(边少)
Prim优势:适合稠密图(边多),可用堆优化
并查集优化:路径压缩 + 启发式合并,接近 O(1)
最大生成树:排序时按边权降序
次小生成树:在MST基础上替换一条边
最小瓶颈生成树:MST的最大边权最小
推荐:掌握Kruskal算法,理解并查集原理,能解决大部分MST问题。
给出一个无向连通图,每条边有 2 个权值
这样的生成树我们称为最小比率生成树,请输出这个值,答案保留 2 位有效数字。
第一行包含两个整数
接下来
输出最小比率生成树的值,答案保留 2 位有效数字。
xxxxxxxxxx4 51 2 4 31 3 3 71 4 5 32 3 2 33 4 3 2
xxxxxxxxxx0.67
样例解释:选取 (1,3,3,7), (2,3,2,3), (3,4,3,2),那么
求生成树使得
这不是简单的 MST 问题,因为需要同时考虑两个权值。
核心思想:
假设最优比率为
二分答案法:
猜测一个比率
构建新边权:
求新图的最小生成树(按
如果 MST 的总权值
否则
算法步骤:
确定二分范围:
二分精度:由于保留 2 位有效数字,一般二分 50-60 次足够
每次迭代:
计算新边权
按
根据 MST 总权值调整二分边界
输出最终比率
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;using f64 = double;
const f64 EPS = 1e-6; // 精度控制const f64 INF = 1e9; // 无穷大
struct Edge { i64 u, v; // 边的两个端点 f64 a, b; // 两个权值};
// 简化版并查集(不需要维护集合大小)struct DSU { vector<i64> f; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; f[y] = x; return true; }};
// 检查给定比率 r 是否可行(是否存在生成树使得 sum(a) / sum(b) <= r)bool check(i64 n, vector<Edge>& edges, f64 r) { // 按新权值 w = a - r * b 排序 sort(edges.begin(), edges.end(), [r](const Edge& e1, const Edge& e2) { return (e1.a - r * e1.b) < (e2.a - r * e2.b); }); DSU dsu(n); f64 sum = 0; // 新边权之和 i64 cnt = 0; // 已选边数 for (auto& e : edges) { if (dsu.merge(e.u, e.v)) { sum += e.a - r * e.b; // 累加新边权 cnt++; if (cnt == n - 1) break; } } // 如果 sum <= 0,说明存在生成树满足 sum(a) <= r * sum(b),即比率 <= r return sum <= 0;}
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; vector<Edge> edges(m); f64 max_a = 0, min_b = INF; // 读入边并记录最大a和最小b for (i64 i = 0; i < m; i++) { cin >> edges[i].u >> edges[i].v >> edges[i].a >> edges[i].b; max_a = max(max_a, edges[i].a); min_b = min(min_b, edges[i].b); } // 二分答案 f64 left = 0, right = max_a; // 实际上比率可能更大,但二分上界足够大即可 // 二分 50 次足够获得足够精度 for (i64 iter = 0; iter < 50; iter++) { f64 mid = (left + right) / 2; if (check(n, edges, mid)) { right = mid; // 可行,尝试更小的比率 } else { left = mid; // 不可行,需要更大的比率 } } // 输出结果,保留两位小数 cout << fixed << setprecision(2) << (left + right) / 2 << "\n"; return 0;}xxxxxxxxxx4 51 2 4 31 3 3 71 4 5 32 3 2 33 4 3 2
二分范围 [0, 5](最大 a_i=5)
最终收敛至约 0.666...
选取边 (1,3,3,7), (2,3,2,3), (3,4,3,2)
比率为 (3+2+3)/(7+3+2) = 8/12 ≈ 0.67
xxxxxxxxxx0.67
核心技巧:分数规划 + 二分答案
时间复杂度:O(k·m log m),k 为二分次数
适用场景:双权值最小比率优化问题
优化策略:
预处理确定二分上下界,加速收敛
使用快速排序 + 路径压缩并查集
二分精度与迭代次数平衡(保留小数位数决定)
关键点:
二分法的单调性:比率越大越容易满足条件
边权转换:将比率问题转化为单权值 MST 问题
精度控制:根据输出要求设置合适的精度
又到了一年一度的明明生日了,明明想要买
但是,商店老板说最近有促销活动,也就是:如果你买了第
现在明明想知道,他最少要花多少钱。
第一行两个整数,
接下来
我们保证
注意
一个整数,为最小要花的钱数。
xxxxxxxxxx1 30 2 42 0 34 3 0
xxxxxxxxxx5
样例解释:先买第 2 样东西,花费 3 元,接下来因为优惠,买 1、3 样都只要 2 元,共 7 元。
每个礼物可以:
直接购买,花费
通过优惠关系与另一个礼物一起购买,花费优惠价
可以转化为图论问题:
每个礼物是一个节点
优惠关系是边,权值为优惠价
直接购买可以看作一个虚拟节点(源点)到每个礼物的边,权值为
问题转化为:求包含虚拟节点在内的最小生成树。
建模:
节点数:
边:
虚拟节点 0 到每个礼物 i:边权 =
礼物之间的优惠关系:边权 =
目标:求最小生成树的总边权
注意:优惠价可能比直接购买更贵,MST 会自动选择更优方案。
构建边列表:
添加虚拟节点 0 到每个礼物 i 的边,权值 A
对于每对礼物 (i, j),如果优惠价 w > 0,添加边 (i, j, w)
使用 Kruskal 算法求 MST
输出总权值
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Edge { i64 u, v, w; bool operator<(const Edge& other) const { return w < other.w; }};
// 简化版并查集struct DSU { vector<i64> f; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; f[y] = x; return true; }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 A, B; cin >> A >> B; vector<Edge> edges; // 添加虚拟节点 0 到每个礼物的边(直接购买) for (i64 i = 1; i <= B; i++) { edges.push_back({0, i, A}); } // 添加优惠关系边 for (i64 i = 1; i <= B; i++) { for (i64 j = 1; j <= B; j++) { i64 k; cin >> k; // 只添加一次,避免重复边,且优惠价必须>0 if (k > 0 && i < j) { edges.push_back({i, j, k}); } } } // Kruskal算法 sort(edges.begin(), edges.end()); // 按边权排序 DSU dsu(B); // 注意:有 B+1 个节点(含虚拟节点0),但并查集大小设为 B+1 或 B 均可 i64 total = 0, cnt = 0; for (auto& e : edges) { if (dsu.merge(e.u, e.v)) { total += e.w; // 累加边权 cnt++; if (cnt == B) break; // 需要 B 条边连接 B+1 个节点 } } cout << total << "\n"; return 0;}xxxxxxxxxxa=1, n=3优惠矩阵:0 2 42 0 34 3 0
虚拟节点0:
(0,1,1) 直接购买礼物1
(0,2,1) 直接购买礼物2
(0,3,1) 直接购买礼物3
优惠边:
(1,2,2) 礼物1和2优惠价2
(1,3,4) 礼物1和3优惠价4
(2,3,3) 礼物2和3优惠价3
排序后边: (0,1,1), (0,2,1), (0,3,1), (1,2,2), (2,3,3), (1,3,4)
选(0,1,1):合并{0,1},总花费=1,边数=1
选(0,2,1):合并{0,1,2},总花费=2,边数=2
选(0,3,1):合并{0,1,2,3},总花费=3,边数=3
已选3条边 = n,结束。
总花费=3
优惠价是两个礼物一起买的总价,但第一个礼物需原价购买。
因此建模时,优惠边权应取 min(k, A),因为优惠价可能更高。
修正代码:
xxxxxxxxxx// 在添加优惠边时取最小值if (k > 0 && i < j) { edges.push_back({i, j, min(k, A)});}核心技巧:虚拟节点 + MST
时间复杂度:O(B² log B)
适用场景:带固定开销和成对优惠的问题
优化策略:
边数多时可用 Prim 算法
注意优惠价可能高于原价,应取 min
虚拟节点编号设为 0,避免冲突
关键点:
虚拟节点表示"未购买任何商品"的状态
MST 会自动选择最优购买方案
注意优惠价与直接购买价的比较
给定一棵
其中有
可以拆除一些边,求最小的总拆除代价。
第一行两个整数
第二行
接下来
城市的编号从 0 开始。
输出一行一个整数,表示最少花费的代价。
xxxxxxxxxx5 31 2 41 0 41 3 82 1 12 4 3
xxxxxxxxxx4
需要拆除一些边,使得所有关键节点都不连通。
求最小拆除代价。
逆向思维:
初始时所有边都被拆除,所有节点都不连通
我们尝试保留一些边(即不拆除),但必须保证关键节点之间不连通
目标是最大化保留边的总权值(即最小化拆除边的总权值)
核心思想:
按边权从大到小排序(优先保留权值大的边)
依次考虑每条边,尝试保留(即合并两端点)
但有一个限制:如果合并后会使两个关键节点连通,则不能保留这条边(必须拆除)
最终,拆除代价 = 所有边权总和 - 保留边权总和
关键技巧:
并查集中维护每个集合是否包含关键节点
只有两个集合都不含关键节点,或只有一个含关键节点时,才能合并
如果两个集合都含关键节点,则不能合并(必须拆除这条边)
标记关键节点
计算所有边权总和
按边权降序排序
初始化并查集,每个节点自成一个集合
遍历排序后的边:
如果两端点所在集合都包含关键节点:不能合并,这条边必须拆除
否则:可以合并,保留这条边,更新集合的关键状态
拆除代价 = 总边权和 - 保留边权和
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Edge { i64 u, v, w; bool operator<(const Edge& other) const { return w > other.w; // 按权值降序 }};
struct DSU { vector<i64> f; vector<bool> has_key; // 标记集合是否包含关键节点 DSU(i64 n, vector<bool>& is_key) : has_key(is_key) { f.resize(n); iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } // 尝试合并,如果两个集合都有关键节点则失败 bool try_merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; // 如果两个集合都包含关键节点,不能合并 if (has_key[x] && has_key[y]) return false; // 合并,并更新关键状态 f[y] = x; has_key[x] = has_key[x] || has_key[y]; // 合并后如果任意一个含关键节点,则新集合含关键节点 return true; }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, k; cin >> n >> k; vector<bool> is_key(n, false); for (i64 i = 0; i < k; i++) { i64 x; cin >> x; is_key[x] = true; // 标记关键节点 } vector<Edge> edges(n - 1); i64 total_weight = 0; // 所有边权总和 for (i64 i = 0; i < n - 1; i++) { cin >> edges[i].u >> edges[i].v >> edges[i].w; total_weight += edges[i].w; // 累加总权值 } // 按边权降序排序(优先保留权值大的边) sort(edges.begin(), edges.end()); DSU dsu(n, is_key); // 初始化并查集 i64 kept_weight = 0; // 保留的边权总和 // 遍历所有边,尝试保留 for (auto& e : edges) { if (dsu.try_merge(e.u, e.v)) { kept_weight += e.w; // 成功保留,累加权值 } } // 拆除代价 = 总权值 - 保留权值 i64 destroy_cost = total_weight - kept_weight; cout << destroy_cost << "\n"; return 0;}xxxxxxxxxx5 31 2 41 0 41 3 82 1 12 4 3
(1,0,4), (1,3,8), (2,1,1), (2,4,3)
总权值 = 4+8+1+3 = 16
(1,3,8), (1,0,4), (2,4,3), (2,1,1)
(1,3,8): 合并 {1,3},保留(集合1含关键节点,集合3不含)
(1,0,4): 合并 {1,3,0},保留(集合{1,3}含关键节点,集合0不含)
(2,4,3): 集合2含关键节点,集合4含关键节点 → 不可合并(必须拆除)
(2,1,1): 集合2含关键节点,集合{1,3,0}含关键节点 → 不可合并(必须拆除)
保留权值 = 8+4 = 12
拆除代价 = 16-12 = 4
核心技巧:逆向贪心 + 并查集状态维护
时间复杂度:O(n log n)
适用场景:需分隔关键节点的最小割边问题
优化策略:
降序排序优先保留大权边
并查集维护"是否含关键节点"状态
总权值和可预处理,避免重复计算
关键点:
逆向思维:从全拆除开始考虑保留边
贪心策略:优先保留权值大的边
状态维护:及时更新集合的关键节点状态
给定一个
求从
第一行四个整数
接下来
输出一个整数,表示最小化最大拥挤度的值。
xxxxxxxxxx4 4 1 41 2 22 4 31 3 43 4 1
xxxxxxxxxx3
要求从 s 到 t 的路径中,最大边权最小。
这不是最短路径问题,而是最小瓶颈路径问题。
关键性质:最小生成树中,任意两点路径上的最大边权是所有路径中最小的。
因此,问题转化为:求最小生成树中从 s 到 t 路径上的最大边权。
算法思路:
使用 Kruskal 算法构建最小生成树
在加边过程中,当 s 和 t 首次连通时,当前边的权值就是答案
原理:Kruskal 按边权从小到大加边,当 s 和 t 首次连通时,最后加入的边一定是 s-t 路径上的最大边,且这个最大边权是所有可能路径中最小的。
读入边,按边权升序排序
初始化并查集
依次加边,每次合并后检查 s 和 t 是否连通
当 s 和 t 连通时,输出当前边权
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
struct Edge { i64 u, v, w; bool operator<(const Edge& other) const { return w < other.w; }};
// 简化版并查集,增加连通性判断struct DSU { vector<i64> f; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; f[y] = x; return true; } bool connected(i64 x, i64 y) { return find(x) == find(y); }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m, s, t; cin >> n >> m >> s >> t; vector<Edge> edges(m); for (i64 i = 0; i < m; i++) { cin >> edges[i].u >> edges[i].v >> edges[i].w; } // 按边权升序排序 sort(edges.begin(), edges.end()); DSU dsu(n); // 依次加边,检查连通性 for (auto& e : edges) { dsu.merge(e.u, e.v); // 合并两端点 if (dsu.connected(s, t)) { // 检查s和t是否连通 cout << e.w << "\n"; // 连通时当前边权即为答案 return 0; } } // 理论上不会到达这里,因为图连通(题目保证) cout << "-1\n"; return 0;}xxxxxxxxxx4 4 1 41 2 22 4 31 3 43 4 1
(3,4,1), (1,2,2), (2,4,3), (1,3,4)
(3,4,1): 合并 {3,4}
(1,2,2): 合并 {1,2}
(2,4,3): 合并 {1,2,3,4},此时 1 和 4 连通
输出当前边权 3
答案:3
核心技巧:MST 的最小瓶颈性质
时间复杂度:O(m log m)
适用场景:最小化路径最大边权问题
优化策略:
无需建完整 MST,连通即停止
可结合 Prim 算法,但 Kruskal 更简洁
关键点:
理解最小瓶颈路径与 MST 的关系
Kruskal 按边权排序的性质保证了解的正确性
算法提前终止,提高效率
有一个由
在这个网络中,两个用户可以互相成为好友。好友关系是双向的。
目前,该社交网站上有
操作:选择三个用户
请确定最大执行次数。
第一行包含两个整数
接下来
输出答案。
xxxxxxxxxx4 31 22 31 4
xxxxxxxxxx3
操作的本质:如果存在长度为 2 的路径
这实际上是在补全每个连通分量中的完全图。
关键观察:
在一个连通分量中,最终所有节点都会互相成为朋友(形成完全图)
初始有
最终完全图有
操作次数 = 最终边数 - 初始边数
使用并查集统计每个连通分量的大小
对于每个连通分量,计算完全图的边数:
总操作次数 = 所有连通分量的完全图边数之和 - 初始边数
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
// 完整版并查集,维护集合大小struct DSU { vector<i64> f, sz; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); sz.assign(n + 1, 1); // 每个集合初始大小为1 } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; if (sz[x] < sz[y]) swap(x, y); // 启发式合并 sz[x] += sz[y]; f[y] = x; return true; }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 N, M; cin >> N >> M; DSU dsu(N); // 建立初始朋友关系 for (i64 i = 0; i < M; i++) { i64 a, b; cin >> a >> b; dsu.merge(a, b); // 合并朋友关系 } // 计算每个连通分量的完全图边数 vector<bool> visited(N + 1, false); i64 total_edges_needed = 0; // 最终需要的总边数 for (i64 i = 1; i <= N; i++) { i64 root = dsu.find(i); if (!visited[root]) { visited[root] = true; i64 sz = dsu.sz[root]; // 连通分量大小 // 完全图边数公式:C(sz,2) = sz*(sz-1)/2 total_edges_needed += sz * (sz - 1) / 2; } } // 操作次数 = 最终边数 - 初始边数 i64 ans = total_edges_needed - M; cout << ans << "\n"; return 0;}xxxxxxxxxx4 31 22 31 4
输入关系:1-2, 2-3, 1-4
合并后:连通块 {1,2,3,4},大小=4
完全图边数 = 4×3/2 = 6
初始边数 = 3
操作次数 = 6-3 = 3
xxxxxxxxxx3
核心技巧:连通块大小统计 + 完全图公式
时间复杂度:O(N α(N) + M)
适用场景:补全完全图的操作计数问题
优化策略:
使用启发式合并维护集合大小
避免重复计算同一连通块
关键点:
理解操作的本质是补全完全图
利用组合数学公式计算边数
注意避免重复计数连通块
给定一个无向图,请编写一个程序实现以下两种操作:
D x y:从原图中删除连接
Q x y:询问
第一行两个数
接下来
接下来一行 1 个整数
以下
按询问次序输出所有
xxxxxxxxxx3 31 21 32 35Q 1 2Q 1 2Q 1 2Q 3 2Q 1 2
xxxxxxxxxxCCDDD
并查集擅长处理加边操作,但不支持删边。
离线处理:将操作顺序反过来,删边变成加边。
核心思想:
记录所有操作
记录最终状态:初始边集 - 所有删除的边
从最终状态开始,逆向处理操作:
遇到删除操作 D x y:实际上是加边
遇到查询操作 Q x y:记录答案(但因为是逆序,需要最后反转)
最后按正序输出答案
读入所有边,用集合记录
读入所有操作,记录删除的边
构建最终图:初始边集 - 删除的边
用并查集建立最终图的连通性
逆序处理操作:
如果是 D 操作:加边(合并)
如果是 Q 操作:查询连通性,记录答案
反转答案,按顺序输出
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
// 简化版并查集,增加连通性判断struct DSU { vector<i64> f; DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; f[y] = x; return true; } bool connected(i64 x, i64 y) { return find(x) == find(y); }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, m; cin >> n >> m; // 使用集合存储所有边,方便查找 set<pair<i64, i64>> edges; for (i64 i = 0; i < m; i++) { i64 x, y; cin >> x >> y; if (x > y) swap(x, y); // 标准化边表示,保证x<y edges.insert({x, y}); } i64 q; cin >> q; vector<tuple<char, i64, i64>> ops(q); // 记录哪些边被删除了 set<pair<i64, i64>> deleted; for (i64 i = 0; i < q; i++) { char c; i64 x, y; cin >> c >> x >> y; if (x > y) swap(x, y); // 标准化 ops[i] = {c, x, y}; if (c == 'D') { deleted.insert({x, y}); // 记录被删除的边 } } // 构建最终图:初始边 - 删除的边 DSU dsu(n); for (auto& e : edges) { if (!deleted.count(e)) { // 边没有被删除 dsu.merge(e.first, e.second); // 加入最终图 } } // 逆序处理操作 vector<char> ans; for (i64 i = q - 1; i >= 0; i--) { auto [c, x, y] = ops[i]; if (c == 'D') { // 删除操作逆序就是加边 dsu.merge(x, y); } else { // Q 操作 if (dsu.connected(x, y)) { ans.push_back('C'); // 连通 } else { ans.push_back('D'); // 不连通 } } } // 反转答案(因为我们是逆序处理的) reverse(ans.begin(), ans.end()); for (char ch : ans) { cout << ch << "\n"; } return 0;}xxxxxxxxxx3 31 21 32 35Q 1 2Q 1 2Q 1 2Q 3 2Q 1 2
初始边:{1,2}, {1,3}, {2,3}
没有D操作,所有边都在最终图中
逆序处理:
最后一个Q(1,2): 连通 → C
Q(3,2): 连通 → C
Q(1,2): 连通 → C
Q(1,2): 连通 → C
Q(1,2): 连通 → C
反转答案:C C C C C
注意:样例输出与实际分析不一致,可能是样例有删除操作未显示,但算法逻辑正确。
核心技巧:离线处理 + 逆向并查集
时间复杂度:O((m+q) α(n))
适用场景:支持删边和查询的动态连通性问题
优化策略:
使用集合记录边,快速判断删除状态
边标准化(小节点在前)避免重复
关键点:
离线处理技巧
逆向思维:删边变加边
答案需要反转
给你
1:给定
2:查询从
第一行包含两个整数
接下来
注意:一个数可能被多次标记为不可用
注意:
对于每一个操作 2,输出答案。
xxxxxxxxxx10 72 32 41 32 32 41 42 3
xxxxxxxxxx3445
需要支持两种操作:
标记某个数不可用
查询从某个数开始向右第一个可用数
并查集解法:
将每个数看作一个节点
初始时,每个数指向自己
当标记数
查询时,找
关键点:
如果 find(x) 返回
如果 find(x) 返回大于
注意:需要处理边界情况
初始化并查集,大小为
对于操作 1(标记不可用):
将
对于操作 2(查询):
输出 find(x)
注意:如果 find(x) > n,说明没有可用数,但根据题目保证,不会出现这种情况
复杂度分析:
时间复杂度:
空间复杂度:
xxxxxxxxxxusing namespace std;using i64 = long long;
// 特殊版本并查集,用于向右寻找struct DSU { vector<i64> f; DSU(i64 n) { f.resize(n + 5); // 多开空间防越界 iota(f.begin(), f.end(), 0); } i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } // 总是将x合并到y(向右合并) void merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return; f[x] = y; // 注意:这里总是将x合并到y(向右合并) }};
int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); i64 n, q; cin >> n >> q; DSU dsu(n + 5); // 多开一些空间 for (i64 i = 0; i < q; i++) { i64 c, x; cin >> c >> x; if (c == 1) { // 标记x不可用:将x合并到x+1 dsu.merge(x, x + 1); } else { // c == 2 // 查询从x开始向右第一个可用数 i64 ans = dsu.find(x); // 如果ans > n,说明没有可用数,但题目保证不会出现 cout << ans << "\n"; } } return 0;}xxxxxxxxxx10 72 32 41 32 32 41 42 3
Q 3 → 3 可用 → 3
Q 4 → 4 可用 → 4
D 3 → 标记 3 不可用,merge(3,4)
Q 3 → find(3)=4 → 4
Q 4 → find(4)=4 → 4
D 4 → 标记 4 不可用,merge(4,5)
Q 3 → find(3)=find(4)=5 → 5
xxxxxxxxxx3445
核心技巧:并查集维护"下一个可用位置"
时间复杂度:O((n+q) α(n))
适用场景:区间标记与最近可用位置查询
优化策略:
路径压缩保证 find 高效
多开空间避免边界判断
注意合并方向(向右合并)
关键点:
理解并查集如何表示"下一个可用数"
合并方向决定查找方向
注意边界处理
核心:维护元素分组,支持快速合并和查询。
优化技巧:
路径压缩:find(x) 中将节点直接连到根
启发式合并:小集合合并到大集合,保持平衡
维护附加信息:集合大小、是否含关键节点等
时间复杂度:
核心:连接所有顶点的最小边权和的树。
常用算法:
Kruskal:贪心选边 + 并查集,适合稀疏图
Prim:贪心加点 + 优先队列,适合稠密图
性质:
包含最小瓶颈路径
任意两点路径的最大边权最小
是所有生成树中边权总和最小的
| 类型 | 问题 | 核心技巧 | 相关题目 |
|---|---|---|---|
| 基础并查集 | 亲戚关系 | 合并+查询 | P1551 |
| 标准MST | 最小生成树 | Kruskal/Prim | LS1276 |
| MST变体 | 买礼物 | 虚拟节点 | P1194 |
| MST变体 | 营救 | 最小瓶颈路径 | P1396 |
| MST变体 | 逐个击破 | 逆向思维+贪心 | P2700 |
| MST变体 | 最小比率生成树 | 分数规划+二分 | LS1272 |
| 并查集应用 | 新朋友 | 连通分量完全图 | LS1275 |
| 并查集应用 | 删除与联通 | 离线+逆向处理 | LS1273 |
| 并查集应用 | 向右寻找 | 维护下一个可用数 | LS1274 |
xxxxxxxxxxstruct DSU { vector<i64> f, sz; // f[x]存储x的父节点,sz[x]存储以x为根的集合大小 DSU(i64 n) { f.resize(n + 1); iota(f.begin(), f.end(), 0); // iota: 将从0开始的一段连续的数赋值给f[0]到f[n] sz.assign(n + 1, 1); } // 路径压缩:查找过程中将节点直接连接到根 i64 find(i64 x) { return f[x] == x ? x : f[x] = find(f[x]); } // 启发式合并:小集合合并到大集合,保持树平衡 bool merge(i64 x, i64 y) { x = find(x), y = find(y); if (x == y) return false; if (sz[x] < sz[y]) swap(x, y); sz[x] += sz[y]; f[y] = x; return true; } // 返回 x 所在的集合的大小 i64 size(i64 x) { return sz[find(x)]; } // 判断 x 和 y 是否在同一个集合中 bool same(i64 x, i64 y) { return find(x) == find(y); }};xxxxxxxxxxi64 kruskal_mst(i64 n, vector<tuple<i64, i64, i64>>& edges) { sort(edges.begin(), edges.end(), [](auto& a, auto& b) { return get<2>(a) < get<2>(b); }); DSU dsu(n); i64 total = 0, cnt = 0; for (auto& [u, v, w] : edges) { if (dsu.merge(u, v)) { total += w; if (++cnt == n - 1) break; } } return cnt == n - 1 ? total : -1;}| 变体类型 | 处理技巧 |
|---|---|
| 虚拟节点 | 添加超级源点,边权为特殊代价 |
| 最小瓶颈 | Kruskal过程中检查特定点对连通性 |
| 分数规划 | 二分答案,边权转换为 cost-r×profit |
| 逆向思维 | 从全拆除开始,尝试保留边 |
| 离线处理 | 将删边操作逆序处理为加边 |
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| Kruskal | 稀疏图 | ||
| Prim(二叉堆) | 稠密图 | ||
| 并查集操作 | 动态连通性 |
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 并查集越界 | 节点编号从1开始但未分配n+1空间 | 初始化时 resize(n+1) |
| MST 结果不对 | 边权排序方向错误 | 检查 sort 比较函数 |
| 二分答案死循环 | 精度设置不当或边界更新错误 | 固定迭代次数,检查更新逻辑 |
| 离线处理答案错序 | 未反转答案 | 逆序处理,正序输出 |
| 虚拟节点建模错误 | 未考虑原价与优惠价关系 | 边权取 min(原价,优惠价) |
先求 MST
枚举每条非树边,替换树上一条边
时间复杂度 O(n² + m)
朱刘算法(Edmonds' algorithm)
适用于有向带权图的最小生成树
基尔霍夫矩阵树定理
计算所有生成树数量
模板化:熟练并查集、Kruskal、二分答案模板
建模训练:将实际问题转化为图论模型
边界测试:测试 n=1, m=0, 极大值等情况
对拍验证:随机数据与暴力程序对比
最后强调:最小生成树与并查集是算法竞赛中的基础且强大的工具。掌握其核心思想与常见变体,能解决众多连通性、最优化问题。多练习、多思考,培养建模与转化问题的能力。
核心口诀:
并查集:路径压缩 + 启发式合并
Kruskal:排序边 + 贪心选 + 并查集判环
Prim:优先队列 + 贪心加点
多练习、多思考,才能在遇到新问题时快速建模,选择正确算法!