题目
某购物城有 m 个商铺,现决定举办一场活动选出人气最高店铺。
活动共有 n 位市民参与,每位市民只能投一票,但 1 号店铺如果给该市民发放 q 元的购物补贴,该市民会改为投 1 号店铺。
请计算 1 号店铺需要最少发放多少元购物补贴才能成为人气最高店铺(即获得的票数要大于其他店铺),如果 1 号店铺本身就是票数最高店铺,返回 0。
输入描述
第一行为小写逗号分割的两个整数 n,m,其中:
第一个整数 n 表示参与的市民总数
第二个整数 m 代表店铺总数
1 ≤ n,m ≤ 3000
第 2 到 n + 1 行,每行为小写逗号分割的两个整数 p,q,表示市民的意向投票情况,其中每行的:
第一个整数 p 表示该市民意向投票给 p 号店铺
第二个整数 q 表示其改投 1 号店铺所需给予的 q 元购物补贴
1 ≤ p ≤ m
1 ≤ q ≤ 10^9
不考虑输入的格式问题
输出描述
1 号店铺需要最少发放购物补贴金额
用例
输入 | 输出 | 说明 |
---|---|---|
5,5 2,10 3,20 4,30 5,40 5,90 | 50 | 有 5 个人参与,共 5 个店铺。 如果选择发放 10 元 + 20 元 + 30 元 = 60 元的补贴来抢 2,3,4 号店铺的票,总共发放了 60 元补贴(5 号店铺有 2 票,1 号店铺要 3 票才能胜出) 如果选择发放 10 元 + 40 元 = 50 元的补贴来抢 2,5 号店铺的票,总共发放了 50 元补贴(抢了 5 号店铺的票后,现在 1 号店铺只要 2 票就能胜出) 所以最少发放 50 元补贴 |
5,5 2,10 3,20 4,30 5,80 5,90 | 60 | 有 5 个人参与,共 5 个店铺。 如果选择发放 10 元 + 20 元 + 30 元 = 60 元的补贴来抢 2,3,4 号店铺的票,总共发放了 60 元补贴 (5 号店铺有 2 票,1 号店铺要 3 票才能胜出) 如果选择发放 10 元 + 80 元 = 90 元的补贴来抢 2,5 号店铺的票,总共发放了 90 元补贴 (抢了 5 号店铺的票后,现在 1 号店铺只要 2 票就能胜出) 所以最少发放 60 元补贴 |
思考一(暴力解法)
首先统计每个店铺的人气值(投票数),然后计算除去第一个店铺外其它店铺中的最大人气值。然后判断如果一号店铺的人气值严格大于最大人气值,则返回 0,表示一号店铺已经是人气最高的店铺,不用再发放补贴了。否则,把用户列表看成树进行递归搜索来统计每一条路径上能取得最大人气值所需要付出的补贴额,并更新全局结果变量取最小值。递归操作所产生的每一条路径都可以看成树中的一条路径,不知道怎么写递归代码时想想什么东西可用来当这棵树来搜索。在用户列表中搜索会开许多岔路(不同路径),一条路径结束会回溯到原来的岔路重新开另一条路径,这里就可能涉及某些状态的重置问题。比如每次花钱拉选票时店铺1的投票数会加1,而被拉票的用户原来投票的店铺的投票数会减1,而当我回溯到上一次选择之前,店铺1和其它店铺的投票数也应该还原到那一次选择之前而不受下一层递归操作的影响。这里就涉及到状态重置,或者某些情形下可以复制一份参数从而避免重置操作。递归搜索可能进行大量重复或无意义的计算,比如计算的补贴金额比当前补贴金额大,就应该返回避免没有意义搜索,这是剪枝。
算法过程
1. 输入解析与初始化
- 读取输入:解析市民总数
n
、店铺总数m
,以及每个市民的投票意向p
和补贴金额q
。 - 统计初始票数:
- 若市民投 1 号店铺,直接计入
votes[1]
。 - 否则,计入对应店铺
votes[p]
,并将补贴金额存入prices
数组。
- 若市民投 1 号店铺,直接计入
- 计算初始最大竞争票数:遍历除 1 号店外的所有店铺,找到初始票数最大值
maxVotes
。
2. 提前终止判断
- 若 1 号店的初始票数
votes[1]
严格大于maxVotes
,说明无需购买任何市民,直接输出0
并终止程序。
3. 递归搜索(DFS)
-
递归函数定义:
dfs(userIndex, cost, curVotes)
userIndex
:当前处理的市民在prices
数组中的索引(确保按顺序处理,避免重复选择)。cost
:当前累计的补贴金额。curVotes
:当前各店铺的票数状态(通过数组复制保证每次递归独立)。
-
递归终止条件:
- 满足条件:若当前 1 号店票数
curVotes[1]
严格大于其他所有店铺的最大票数currentMax
,更新最小补贴金额ans
。 - 剪枝:若当前累计成本
cost
已大于等于已知最优解ans
,跳过该路径(避免无效计算)。
- 满足条件:若当前 1 号店票数
-
递归过程:
- 遍历剩余市民:从
userIndex + 1
开始(确保每个市民仅被考虑一次),避免重复选择。 - 处理当前市民:
- 若市民原本投 1 号店(
p === 1
),跳过(无需购买)。 - 否则,复制当前票数状态
curVotes
,模拟购买该市民:- 目标店铺
p
的票数减 1,1 号店票数加 1。 - 递归调用
dfs(j, cost + q, nextVotes)
,继续处理下一个市民j
。
- 目标店铺
- 若市民原本投 1 号店(
- 遍历剩余市民:从
4. 遍历所有起始市民
- 在递归前,遍历所有市民作为首次购买的起点:
- 若市民投非 1 号店(
p !== 1
),模拟购买该市民,初始化递归过程。 - 通过复制初始票数状态
votes
,确保每次递归的独立性。
- 若市民投非 1 号店(
5. 输出结果
- 若递归结束后
ans
仍为无穷大(Infinity
),说明无需购买(初始状态已满足条件),输出0
;否则输出最小补贴金额ans
。
关键逻辑说明
-
状态管理:
- 通过复制票数数组
[...curVotes]
避免递归过程中状态污染,确保每次递归的票数状态独立。 - 通过
userIndex
按顺序处理市民,隐式避免重复选择(每个市民最多被选一次)。
- 通过复制票数数组
-
剪枝优化:
- 在递归开始前,若当前成本
cost
已不小于最优解ans
,直接跳过该路径,减少无效递归。 - 仅处理非 1 号店的市民,减少不必要的分支。
- 在递归开始前,若当前成本
-
正确性保证:
- 递归遍历所有可能的市民组合,确保找到全局最优解。
- 通过严格判断
curVotes[1] > currentMax
,确保 1 号店票数严格领先。
算法优缺点
- 优点:
- 逻辑直观,易于理解,通过递归穷举所有可能的购买组合。
- 剪枝优化有效减少计算量,适用于中小规模数据(
n ≤ 20
时表现较好)。
- 缺点:
- 时间复杂度高(最坏情况下为指数级
O(2^n)
),当n
较大时(如n=3000
)会超时或栈溢出。 - 空间复杂度较高(递归栈深度可达
n
),可能导致内存问题。
- 时间复杂度高(最坏情况下为指数级
适用场景
- 适用于 小规模输入(如
n ≤ 20
),或需要通过递归直观展示逻辑的场景。 - 对于大规模输入(题目中
n ≤ 3000
),需改用贪心算法或动态规划等更高效的方法。
参考代码
function solution() {
const [n, m] = readline().split(',').map(Number);
const votes = Array(m+1).fill(0);
const prices = [];
for (let i = 0; i < n; i++) {
const [p, q] = readline().split(',').map(Number);
votes[p]++;
prices.push([p,q]);
}
// 计算初始时除1号店外的最大票数
let maxVotes = 0;
for (let i = 2; i <= m; i++) {
maxVotes = Math.max(maxVotes, votes[i]);
}
// 如果1号店已经是人气最高,直接返回0
if (votes[1] > maxVotes) {
console.log(0);
return;
}
let ans = Infinity;
const dfs = function(mailIndex, cost, curVotes, userIndex, path) {
if (cost > ans) { // 剪枝
return;
}
curVotes[mailIndex]--;
curVotes[1]++;
let currentMax = 0;
for (let i = 2; i <= m; i++) {
currentMax = Math.max(currentMax, curVotes[i]);
}
if (curVotes[1] > currentMax) {
ans = Math.min(ans, cost);
// console.log('path: ', path, 'cost: ', cost);
return;
}
for (let j = userIndex + 1; j < n; j++) {
const [p, q] = prices[j];
if (p === 1) continue; // 跳过原本就投1号店的市民
dfs(p, cost + q, [...curVotes], j, path.concat(j));
}
};
for (let i = 0; i < n; i++) {
let [p, q] = prices[i];
if (p !== 1) {
dfs(p, q, [...votes], i, [i]);
}
}
console.log(ans);
}
const cases = [
`5,5
2,10
3,20
4,30
5,40
5,90`,
`5,5
2,10
3,20
4,30
5,80
5,90`
];
let caseIndex = 0;
let lineIndex = 0;
const readline = (function () {
let lines = [];
return function () {
if (lineIndex === 0) {
lines = cases[caseIndex]
.trim()
.split("\n")
.map((line) => line.trim());
}
return lines[lineIndex++];
};
})();
cases.forEach((_, i) => {
caseIndex = i;
lineIndex = 0;
solution();
});
思考二(贪心策略)
问题本质是在满足 “1 号店票数严格领先” 的条件下,用最少成本购买市民。
- 核心观察:购买补贴低的市民比高的更划算,应优先选低成本选项。
- 关键条件:若某店铺票数≥目标票数T,必须减少其票数至\(T-1\),否则无法满足条件。
- 策略设计:
- 按补贴升序排序每个店铺的市民,确保每次购买成本最低。
- 遍历所有可能的目标票数T,对每个T:
- 先强制购买票数≥T的店铺中最便宜的市民,使其票数 <T。
- 再从剩余市民中选最便宜的,补足 1 号店到T票。
- 贪心合理性:每次优先选低成本市民,全局累计成本必然最小。
算法过程
1. 输入解析与预处理
- 读取输入:解析市民数 n、店铺数 m,以及每个市民的投票意向 p 和补贴金额 q。
- 统计初始票数:
- 1 号店初始票数为
sumA
,其他店铺票数存入votes
数组(索引为店铺编号)。 - 收集所有非 1 号店市民的补贴金额,按店铺分组后排序(每组内按补贴金额升序排列)。
- 1 号店初始票数为
2. 确定目标票数范围
- 目标票数 T 的取值范围为:
[sumA, n]
(1 号店最多可获得全部 n 票)。 - 对于每个 T,需确保 1 号店票数为 T,且其他所有店铺票数均 < T。
3. 计算必须购买的市民(处理票数≥T的店铺)
- 遍历除 1 号店外的所有店铺 i:
- 若店铺 i 的初始票数
votes[i] ≥ T
,则必须购买k = votes[i] - T + 1
个市民(使其票数变为 \(T-1\))。 - 从店铺 i 的补贴列表中选择前 k 个最小的金额(已排序),累加到总成本
sumB
,并记录已购买的市民数bought
。 - 剩余未购买的市民(补贴列表中第 k 个之后的)加入 “可选池”
remaining
。
- 若店铺 i 的初始票数
4. 计算可选购买的市民(补足 T 票)
- 计算 1 号店还需购买的市民数:
need = T - sumA - bought
。- 若
need < 0
,说明无需额外购买(need
置为 0)。
- 若
- 从 “可选池”
remaining
中选择前need
个最小的补贴金额(排序后),累加到sumB
。 - 若 “可选池” 中市民数量不足
need
,则跳过该 T(无法达到目标票数)。
5. 选择最小成本
- 遍历所有合法的 T,记录满足条件的最小
sumB
。 - 若初始状态已满足条件(1 号店票数 > 其他店铺最大票数),直接返回 0。
关键逻辑说明
-
排序的作用:
- 对每个店铺的补贴金额升序排序,确保每次购买时优先选择成本最低的市民,符合贪心策略。
-
强制购买与可选购买的分离:
- 强制购买:针对票数≥T的店铺,必须减少其票数至 \(T-1\),否则无法满足 “1 号店票数> 该店铺” 的条件。
- 可选购买:补足 1 号店票数至 T,从所有剩余市民中选择成本最低的,确保总成本最小。
-
复杂度优化:
- 排序时间为 \(O(n \log n)\)(每个店铺内的补贴列表排序)。
- 遍历 T 的时间为 \(O(n)\),每次遍历内的操作均为线性时间,总时间复杂度为 \(O(n^2)\),适用于 \(n \leq 3000\) 的规模。
参考代码
function solution() {
const [n, m] = readline().split(",").map(Number);
const votes = Array(m + 1).fill(0); // 各店铺初始票数
const prices = Array.from({ length: m + 1 }, () => []); // 存储每个店铺的市民补贴金额
let sumA = 0; // 1号店铺初始票数
for (let i = 1; i <= n; i++) {
const [p, q] = readline().split(",").map(Number);
if (p === 1) {
sumA++;
} else {
votes[p]++;
prices[p].push(q);
}
}
// 对每个店铺的补贴金额排序
for (let i = 2; i <= m; i++) {
prices[i].sort((a, b) => a - b);
}
let minCost = Infinity;
// 遍历所有可能的目标票数 T
for (let T = sumA; T <= n; T++) {
let sumB = 0; // 总补贴金额
let bought = 0; // 已购买的市民数
const remaining = []; // 剩余可购买的市民补贴金额
// 处理每个店铺(除1号外)
for (let i = 2; i <= m; i++) {
const cnt = votes[i];
if (cnt >= T) {
// 必须购买 cnt - T + 1 个市民
const k = cnt - T + 1;
sumB += prices[i].slice(0, k).reduce((acc, val) => acc + val, 0);
bought += k;
// 剩余的市民可以选择性购买
if (k < prices[i].length) {
remaining.push(...prices[i].slice(k));
}
} else {
// 所有市民都可以选择性购买
remaining.push(...prices[i]);
}
}
// 计算还需要购买的市民数
let need = T - sumA - bought;
if (need < 0) need = 0;
// 检查是否有足够的市民可供购买
if (need > remaining.length) continue;
// 从剩余市民中选择补贴最小的 need 个
remaining.sort((a, b) => a - b);
sumB += remaining.slice(0, need).reduce((acc, val) => acc + val, 0);
// 更新最小成本
if (sumB < minCost) {
minCost = sumB;
}
}
console.log(minCost !== Infinity ? minCost : 0);
}
const cases = [
`5,5
2,10
3,20
4,30
5,40
5,90`,
`5,5
2,10
3,20
4,30
5,80
5,90`,
];
let caseIndex = 0;
let lineIndex = 0;
const readline = (function () {
let lines = [];
return function () {
if (lineIndex === 0) {
lines = cases[caseIndex]
.trim()
.split("\n")
.map((line) => line.trim());
}
return lines[lineIndex++];
};
})();
cases.forEach((_, i) => {
caseIndex = i;
lineIndex = 0;
solution();
});