贡献法算法思维
Prompt:
贡献法算法思维是怎样的
Response:
非常好的问题👏!你问的这个「贡献法」,是算法竞赛中非常常见、非常重要的一种思维方式,尤其在处理计数问题、期望问题、子区间问题、树上路径问题时常常出现。
我们来循序渐进地讲清楚:
🌟 一、贡献法的核心思想
一句话总结:
贡献法就是把复杂的整体问题,转化为每个元素对答案的贡献之和。
也就是:
我们不再枚举所有情况(例如所有子区间、所有组合),而是反过来,枚举每个元素它“被算进去多少次”,把它的“贡献次数 × 自身的值”加起来。
🧩 二、举个简单的例子
📘例1:所有子数组的和
给定一个长度为 \(n\) 的数组 \(a[1..n]\) ,求所有子数组的和。
例如:
a = [3, 1, 2]
所有子数组:
[3], [3,1], [3,1,2], [1], [1,2], [2]
直接枚举子数组会是 \(O(n^2)\) 的复杂度。
但是用贡献法我们可以做到 \(O(n)\) 。
💡思路:
考虑第 \(i\) 个数 \(a[i]\) 。
它出现在多少个子数组中?
- 左端点可以是 \(1\) 到 \(i\) ,共有 \(i\) 种。
- 右端点可以是 \(i\) 到 \(n\) ,共有 \(n - i + 1\) 种。
所以 \(a[i]\) 出现在 \(i \times (n - i + 1)\) 个子数组中。
于是:
这就是贡献法的核心公式了。
✅代码示例(OI风格)
#include <bits/stdc++.h>
using namespace std;typedef long long ll;
const int N = 1e6 + 5;int n;
ll a[N];int main() {cin >> n;for (int i = 1; i <= n; ++i) cin >> a[i];ll ans = 0;for (int i = 1; i <= n; ++i) {// 每个元素的贡献次数 × 自身的值ans += a[i] * 1ll * i * (n - i + 1);}cout << ans << "\n";return 0;
}
⚙️ 三、再看一个更典型的场景
📘例2:所有子字符串中元音的总数(LeetCode 2063)
给你一个字符串 word
,返回所有子字符串中元音(a,e,i,o,u)的总数。
💡思路:
对于一个位置 \(i\) 上的字符 \(word[i]\) ,如果它是元音,那么它对多少个子串有贡献?
- 左端点可以是 \(1\) 到 \(i\) : \(i\) 种
- 右端点可以是 \(i\) 到 \(n\) : \(n - i + 1\) 种
所以它的贡献次数仍然是 \(i \times (n - i + 1)\) 。
于是:
✅代码实现:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;bool isVowel(char c) {return c=='a'||c=='e'||c=='i'||c=='o'||c=='u';
}int main() {string s; cin >> s;int n = s.size();ll ans = 0;for (int i = 0; i < n; ++i) {if (isVowel(s[i])) {ans += 1ll * (i + 1) * (n - i);}}cout << ans << "\n";
}
🔍 四、贡献法常见应用场景
场景 | 贡献计算思路 |
---|---|
所有子区间的和 / 最大值 / 最小值 | 每个元素在多少个区间中成为“有效元素”(被计入和、成为最值) |
树上路径问题 | 每条边被多少条路径经过(用点分治或树上差分算) |
期望问题 | 每个事件出现的概率 × 它的数值 |
排列计数 / 组合问题 | 每个元素在不同排列中出现次数的期望贡献 |
✨ 五、总结口诀
复杂整体难求和,
拆成贡献各自加。
计算出现的次数后,
一乘一加答案达!
如果你愿意,我可以接着讲:
- 「贡献法 + 单调栈」求所有子区间的最小值之和;
- 或者「贡献法 + 树上差分」计算所有路径经过次数。
要不要我接着举一个「所有子数组最小值之和」的进阶例子?
Prompt:
要
Response:
太棒了👍!你现在已经掌握了贡献法的基本思维,接下来我们来看看一个更进阶、常考的应用场景——
用贡献法 + 单调栈 求 所有子数组的最小值之和
💡题目描述
给你一个长度为 \(n\) 的数组 \(a[1..n]\) ,
请你计算:
也就是——
所有子数组的最小值加起来是多少。
⚙️ 一、暴力做法(太慢)
枚举所有子数组,再枚举最小值。
时间复杂度是 \(O(n^2)\) 或 \(O(n^3)\) ,
显然不行。
🧠 二、贡献法思维
我们反过来思考!
每个元素 \(a[i]\) ,在多少个子数组中成为最小值?
只要我们能知道每个元素“当最小值的次数”,再乘上它的值,就是它的总贡献。
🔍 三、如何找「贡献次数」
设第 \(i\) 个元素为 \(a[i]\) 。
我们要找到:
- 它左边第一个比它小的元素位置 \(L_i\)
- 它右边第一个小于等于它的元素位置 \(R_i\)
(注意这里的不等号方向要分清楚,我们后面会讲为什么)
那么:
- 左边可以选择的起点范围: \((L_i+1, i)\)
- 右边可以选择的终点范围: \((i, R_i-1)\)
子数组个数:
✏️ 举例理解
以 \(a = [3,1,2,4]\) 为例:
i | a[i] | 左边第一个更小L[i] | 右边第一个小于等于R[i] | 贡献次数 | 贡献值 |
---|---|---|---|---|---|
1 | 3 | 0 | 2 | (1−0)×(2−1)=1 | 3×1=3 |
2 | 1 | 0 | 5 | (2−0)×(5−2)=6 | 1×6=6 |
3 | 2 | 2 | 5 | (3−2)×(5−3)=2 | 2×2=4 |
4 | 4 | 3 | 5 | (4−3)×(5−4)=1 | 4×1=4 |
总和 \(3 + 6 + 4 + 4 = 17\) ,
确实是所有子数组的最小值之和。
🧱 四、如何快速求 L[i] 和 R[i]
可以用**单调栈(Monotonic Stack)**来维护:
- 从左往右维护「严格递增栈」,找左边第一个比它小的;
- 从右往左维护「非严格递增栈」,找右边第一个小于等于它的。
✅ 五、代码实现(OI风格 + 详细注释)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;const int N = 1e6 + 5;
int n;
ll a[N];
int L[N], R[N]; // 记录左边/右边界int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n;for (int i = 1; i <= n; ++i) cin >> a[i];// --- 1️⃣ 求左边第一个比它小的位置 ---stack<int> st;for (int i = 1; i <= n; ++i) {while (!st.empty() && a[st.top()] >= a[i]) st.pop();L[i] = st.empty() ? 0 : st.top();st.push(i);}// --- 2️⃣ 求右边第一个小于等于它的位置 ---while (!st.empty()) st.pop();for (int i = n; i >= 1; --i) {while (!st.empty() && a[st.top()] > a[i]) st.pop();R[i] = st.empty() ? n + 1 : st.top();st.push(i);}// --- 3️⃣ 计算总贡献 ---ll ans = 0;for (int i = 1; i <= n; ++i) {ll left = i - L[i];ll right = R[i] - i;ans += a[i] * left * right;}cout << ans << "\n";return 0;
}
🧩 六、复杂度分析
- 单调栈每个元素进出各一次 → \(O(n)\)
- 最终计算贡献 → \(O(n)\)
- 总复杂度: \(O(n)\)
- 空间复杂度: \(O(n)\)
⚠️ 七、注意区分:
问题 | 左边比较符号 | 右边比较符号 |
---|---|---|
最小值贡献 | >= |
> |
最大值贡献 | <= |
< |
这样做的原因是为了避免重复计算(保证相同值只归到一边)。
✨ 八、总结口诀
反向思维求贡献,最值问题用栈稳。
左右边界一相乘,乘上数值加到总。
如果你想更进一步,我可以接着讲:
- 🔸“所有子数组最大值之和” (同理,只改符号)
- 🔸 或者 “最小值+最大值之差的总和”(即所有子数组的区间宽度)
要不要我接着讲「所有子数组的最大值之和」?
Prompt:
最小值+最大值之差的总和
Response:
非常好👏!你现在要学的这一题「所有子数组的最大值与最小值之差的总和」
是贡献法与单调栈的经典综合题。
这题是许多竞赛题和面试题的基础模型,例如「Sum of Subarray Ranges」(LeetCode 2104)。
🧩 一、题目描述
给定一个长度为 \(n\) 的数组 \(a[1..n]\) ,
求所有子数组中:
的总和。
换句话说:
🧠 二、贡献法思路
直接求每个子数组的最大最小太慢( \(O(n^2)\) )。
所以我们要用贡献法拆分成两部分:
也就是:
于是——问题转化为两个“贡献法 + 单调栈”问题!
⚙️ 三、回顾贡献法公式
对于每个元素 \(a[i]\) :
- 它对「最小值」的贡献次数是
\((i - L_{\text{min}}[i]) \times (R_{\text{min}}[i] - i)\) - 它对「最大值」的贡献次数是
\((i - L_{\text{max}}[i]) \times (R_{\text{max}}[i] - i)\)
于是:
🔍 四、边界定义与栈维护方式
求 | 左边界 | 右边界 | 比较方式(为了避免重复) |
---|---|---|---|
最小值 | 找左边第一个 < 的 |
找右边第一个 <= 的 |
左 < ,右 <= |
最大值 | 找左边第一个 > 的 |
找右边第一个 >= 的 |
左 > ,右 >= |
这个“左严格、右不严格”的搭配可以防止相等元素重复计入。
✅ 五、完整实现(OI风格 + 详细注释)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;const int N = 1e6 + 5;
int n;
ll a[N];
int Lmin[N], Rmin[N], Lmax[N], Rmax[N];int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n;for (int i = 1; i <= n; ++i) cin >> a[i];stack<int> st;// --- 1️⃣ 求每个数作为最小值的左右边界 ---for (int i = 1; i <= n; ++i) {while (!st.empty() && a[st.top()] > a[i]) st.pop();Lmin[i] = st.empty() ? 0 : st.top();st.push(i);}while (!st.empty()) st.pop();for (int i = n; i >= 1; --i) {while (!st.empty() && a[st.top()] >= a[i]) st.pop();Rmin[i] = st.empty() ? n + 1 : st.top();st.push(i);}// --- 2️⃣ 求每个数作为最大值的左右边界 ---while (!st.empty()) st.pop();for (int i = 1; i <= n; ++i) {while (!st.empty() && a[st.top()] < a[i]) st.pop();Lmax[i] = st.empty() ? 0 : st.top();st.push(i);}while (!st.empty()) st.pop();for (int i = n; i >= 1; --i) {while (!st.empty() && a[st.top()] <= a[i]) st.pop();Rmax[i] = st.empty() ? n + 1 : st.top();st.push(i);}// --- 3️⃣ 累加贡献 ---ll ans = 0;for (int i = 1; i <= n; ++i) {ll cntMax = (ll)(i - Lmax[i]) * (Rmax[i] - i);ll cntMin = (ll)(i - Lmin[i]) * (Rmin[i] - i);ans += a[i] * (cntMax - cntMin);}cout << ans << "\n";return 0;
}
🧮 六、举例验证
假设:
所有子数组及其 \(\max - \min\) :
子数组 | 最大值 | 最小值 | 差值 |
---|---|---|---|
[1] | 1 | 1 | 0 |
[1,2] | 2 | 1 | 1 |
[1,2,3] | 3 | 1 | 2 |
[2] | 2 | 2 | 0 |
[2,3] | 3 | 2 | 1 |
[3] | 3 | 3 | 0 |
总和 = \(0 + 1 + 2 + 0 + 1 + 0 = 4\) 。
程序也会输出 4 ✅。
⏱ 七、复杂度分析
- 单调栈求边界:每个元素入栈出栈各一次 → \(O(n)\)
- 计算贡献: \(O(n)\)
- 总复杂度: \(O(n)\)
- 空间复杂度: \(O(n)\)
✨ 八、思维总结
💬 贡献法核心:
- 每个元素的“价值” × “出现次数”
- 最大值、最小值各算一次,最后相减
🧮 单调栈作用:
- 快速找到“控制边界”,确定贡献范围
🎯 九、口诀记忆
区间最值两边找,左严右宽防重复;
贡献相减得区间差,栈法一趟就搞定!
如果你想继续深入,我可以讲下一步:
- ✅ 推导「所有子数组的平均值和方差」
- ✅ 或者讲「树上路径的贡献法」——它和这个思想是一样的,只是换成了树形结构。
你想先听哪一个方向?
Prompt:
树上路径的贡献法
Response:
太棒了👏👏👏!
你现在已经从一维数组的贡献法(子区间问题)进阶到更高层次——
👉 树上路径贡献法。
这部分内容属于图论与树论中的“贡献思想”应用,非常常见于题型:
- 求树上所有点对路径权值之和;
- 求树上路径上最大边、最小边的和;
- 树上统计出现次数(例如每条边被多少路径经过);
- 或者在点权问题中统计某种函数的总和。
🌳 一、问题例子
给定一棵 \(n\) 个节点的树(无向连通图),每条边有权值 \(w\) 。
问:所有点对 \((u,v)\) 之间路径上的边权和的总和是多少?
形式化为:
⚙️ 二、暴力思路(不可行)
如果我们真的去枚举所有点对 \((u,v)\) ,
然后找到它们之间的路径并加权,
那是 \(O(n^2)\) 或更慢的复杂度,不可行。
我们要用贡献法优化成 \(O(n)\) 。
💡 三、贡献法思维
“每条边在多少条点对路径中出现?”
如果我们知道每条边被多少路径经过,
那每条边的贡献就是:
于是:
🧮 四、如何计算“经过次数”
对于一条边 \((u, v)\) ,假设我们把树断开,会分成两个部分:
—— 子树1 ——(u)-(v)—— 子树2 ——
假设以 \(v\) 为子节点,断开后:
- \(v\) 子树的大小是 \(\text{size}_v\)
- 总节点数是 \(n\)
那么所有经过这条边的点对,必须是:
- 一个点在 \(v\) 子树中,
- 另一个点在外部(不在 \(v\) 子树中)。
这样的点对数量就是:
✅ 五、公式总结
对于树中每条边 \(e(u,v,w)\) ,设 \(v\) 是子节点:
✏️ 六、C++实现(OI风格 + 详细注释)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;const int N = 200005;
struct Edge {int to, w;
};
vector<Edge> G[N];
int n;
ll ans = 0;
int sz[N];// DFS 计算每个子树的大小并累加贡献
void dfs(int u, int fa) {sz[u] = 1; // 包含自己for (auto [v, w] : G[u]) {if (v == fa) continue;dfs(v, u);// v 子树的大小为 sz[v]ll cnt = 1ll * sz[v] * (n - sz[v]); // 经过次数ans += 1ll * w * cnt;sz[u] += sz[v];}
}int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n;for (int i = 1; i < n; ++i) {int u, v, w;cin >> u >> v >> w;G[u].push_back({v, w});G[v].push_back({u, w});}dfs(1, 0);cout << ans << "\n";return 0;
}
🧩 七、例子演示
例如这棵树:
1
├─(2)
│ └─(3)
└─(4)
边:
1 - 2 (w=3)
2 - 3 (w=4)
1 - 4 (w=2)
计算每条边贡献:
边 | 子树大小 | 经过次数 | 贡献 |
---|---|---|---|
1-2 | size[2]=2 | 2*(4-2)=4 | 3×4=12 |
2-3 | size[3]=1 | 1*(4-1)=3 | 4×3=12 |
1-4 | size[4]=1 | 1*(4-1)=3 | 2×3=6 |
总和 = 30 ✅。
📊 八、复杂度分析
- DFS 计算子树大小: \(O(n)\)
- 每条边只访问一次: \(O(n)\)
- 总复杂度: \(O(n)\)
- 空间复杂度: \(O(n)\)
🌟 九、扩展:树上贡献法还能干什么?
- 树上路径最大值之和
→ 类似上面的思路,只不过“贡献次数”计算更复杂(通常要排序或用并查集)。 - 树上边出现次数统计
→ 在树上差分时,我们也用“经过次数”思想。 - 树上点权路径贡献
→ 若路径和由点权组成,同样可以求“每个点被经过多少次”。
✨ 十、总结口诀
树上贡献最常见,拆边分两边;
子树乘外部,次数算自然;
权值乘次数,总和立马现。
如果你想进阶的话,我可以带你看一个更高难度的版本:
💥「树上所有点对路径上的最大边权之和」
(AtCoder ABC 214 D )
这题用并查集实现树上贡献法,非常经典!
要我接着讲这个例子吗?
Prompt:
继续
Response:
非常好👏👏👏!
你现在已经掌握了「树上路径贡献法(点对路径和)」的基本原理,接下来我们要挑战一个非常经典、进阶的例子:
🌲 求树上所有点对之间路径的最大边权之和
这题是「贡献法 + 并查集」的结合体,也是 AtCoder ABC214 D - Sum of Maximum Weights 原题。
https://www.luogu.com.cn/problem/AT_abc214_d
🧩 一、题目描述
给定一棵 \(n\) 个节点的无向树,每条边有权值 \(w\) 。
对于每一对不同的节点 \((u,v)\) ,设路径上边权的最大值为:
求:
😢 二、暴力方法太慢
- 枚举所有点对 \(O(n^2)\)
- 每次找路径最大值(DFS或LCA)→ \(O(n)\)
- 总复杂度 \(O(n^3)\) ,根本不可做。
所以我们要用贡献法!
💡 三、贡献法核心思路
对于每条边,它作为「路径最大边」的次数是多少?
我们用这条边的权值 × 它被当作“路径最大边”的次数,
求出总贡献即可:
🔍 四、如何计算「贡献次数」
这次比“路径和”那题稍微巧一点:
- 我们按边权从小到大排序;
- 逐步“连接”边权 ≤ 当前值的边;
- 当我们加入一条边 \((u,v,w)\) 时:
- 它把两个连通块(集合)连接起来;
- 之前它们内部的点对路径的最大边都 < w;
- 现在新产生的点对,路径最大边就是 w!
✨ 关键公式
假设:
- 左集合大小 = \(size_u\)
- 右集合大小 = \(size_v\)
则这条边带来的新点对数量为:
于是贡献:
⚙️ 五、算法流程
1️⃣ 将所有边按权值从小到大排序
2️⃣ 用并查集(DSU)维护当前连通块
3️⃣ 对每条边:
- 找到两个端点的祖先;
- 加入边后:
\(\text{ans} += w \times size[u] \times size[v]\) - 再把两集合合并(更新子树大小)
✅ 六、C++实现(OI风格 + 详细注释)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;const int N = 200005;struct Edge {int u, v;ll w;bool operator<(const Edge &e) const {return w < e.w; // 按权值从小到大排序}
};int n;
Edge e[N];// 并查集部分
int fa[N];
ll sz[N];int find(int x) {return fa[x] == x ? x : fa[x] = find(fa[x]);
}int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n;for (int i = 1; i < n; ++i) {cin >> e[i].u >> e[i].v >> e[i].w;}sort(e + 1, e + n); // 按权值排序// 初始化并查集for (int i = 1; i <= n; ++i) {fa[i] = i;sz[i] = 1; // 每个集合初始大小为1}ll ans = 0;for (int i = 1; i < n; ++i) {int u = find(e[i].u);int v = find(e[i].v);if (u != v) {ans += e[i].w * sz[u] * sz[v]; // 贡献计算fa[v] = u; // 合并sz[u] += sz[v]; // 更新子树大小}}cout << ans << "\n";return 0;
}
🧩 七、例子演示
假设:
n = 4
边:
1 - 2 权值 3
2 - 3 权值 2
2 - 4 权值 4
按权值排序:
(2-3,2), (1-2,3), (2-4,4)
过程:
边 | 权值 | 左集大小 | 右集大小 | 新增点对 | 贡献 |
---|---|---|---|---|---|
(2-3) | 2 | 1 | 1 | 1 | 2 |
(1-2) | 3 | 1 | 2 | 2 | 6 |
(2-4) | 4 | 3 | 1 | 3 | 12 |
总和 = 2 + 6 + 12 = 20 ✅
⏱ 八、复杂度分析
操作 | 复杂度 |
---|---|
排序 | \(O(n \log n)\) |
并查集合并 | \(O(n \alpha(n))\) ≈ \(O(n)\) |
总体复杂度 | \(O(n \log n)\) |
非常高效!
🧠 九、思维总结
项目 | 树上路径和 | 树上路径最大值 |
---|---|---|
遍历顺序 | 任意 | 按边权排序 |
核心公式 | \(w \times size_v \times (n - size_v)\) | \(w \times size_u \times size_v\) |
关键结构 | DFS 求子树大小 | 并查集求集合大小 |
贡献对象 | 每条边 | 每条边(连接新点对) |
✨ 十、口诀记忆
小到大连通块,最大边才确定;
左右乘权即贡献,DSU一趟定乾坤。
如果你想继续深入,我可以接着讲:
- 🔹「树上路径最小边权之和」怎么改?
- 🔹 或者讲「树上路径最大值 + 最小值之差之和」这种更复杂的组合题。
你想听哪个?