最近遇到一道有趣的算法题,想和大家分享一下:
问题描述:给定 N 个商品(每个商品只能选一次),从中找出总价最小的 K 个不同购买方案。每个方案是商品的一个子集,目标是返回总价格最小的前 K 个组合。
最直观的想法是暴力枚举------遍历所有 N! 种可能的子集,计算每种组合的总价,再排序取前 K 个。然而,当 N 较大时,这种方法在时间和空间上都不可行。
因此,我们需要一种更高效的策略。关键观察点在于:我们并不需要生成所有组合,而只需逐步"探索"出最小的 K 个方案 。这启发我们使用优先队列(最小堆)+ 剪枝 + 去重的方法,按需扩展最有希望的候选方案。
具体思路如下:
- 将每个单商品组合(即只买一个商品)作为初始方案加入最小堆;
- 每次从堆中取出当前总价最小的方案,将其加入结果集;
- 然后以该方案为基础,尝试加入尚未包含的商品,生成新的组合;
- 使用哈希集合记录已生成的组合(通过标准化表示去重),避免重复入队;
- 重复上述过程,直到收集到 K 个方案或队列为空。
这种方法避免了全量枚举,显著提升了效率,尤其适用于 K 远小于 2N 的场景。
优化后的 Java 代码
java
import java.util.*;
/**
* 最小花费商品组合问题
* 给定 N 个商品的价格,找出总花费最小的 K 个不同购买方案。
* 每个方案是商品的一个子集,每个商品最多被选择一次。
*/
public class MinCostCombinations {
/**
* 表示一个购买方案
*/
static class Solution implements Comparable<Solution> {
Set<Integer> indices; // 商品编号集合(从 1 开始)
long totalPrice; // 方案总价格
Solution(Set<Integer> indices, long totalPrice) {
this.indices = new HashSet<>(indices);
this.totalPrice = totalPrice;
}
@Override
public int compareTo(Solution other) {
if (this.totalPrice != other.totalPrice) {
return Long.compare(this.totalPrice, other.totalPrice);
}
// 价格相同时,按集合的哈希值排序以保证比较一致性(虽非完美,但可接受)
return Integer.compare(this.indices.hashCode(), other.indices.hashCode());
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
try {
int N = sc.nextInt();
int K = sc.nextInt();
int[] prices = new int[N];
for (int i = 0; i < N; i++) {
prices[i] = sc.nextInt();
}
List<Solution> results = findMinCostCombinations(prices, K);
for (Solution sol : results) {
List<Integer> sorted = new ArrayList<>(sol.indices);
Collections.sort(sorted);
for (int i = 0; i < sorted.size(); i++) {
if (i > 0) System.out.print(",");
System.out.print(sorted.get(i));
}
System.out.println(":" + sol.totalPrice);
}
} finally {
sc.close();
}
}
/**
* 使用优先队列(最小堆)按需生成最小的 K 个组合
*
* @param prices 商品价格数组
* @param k 需要返回的方案数量
* @return 按总价升序排列的前 K 个方案
*/
private static List<Solution> findMinCostCombinations(int[] prices, int k) {
PriorityQueue<Solution> pq = new PriorityQueue<>();
Set<String> visited = new HashSet<>();
List<Solution> result = new ArrayList<>();
// 初始化:将每个单商品方案入队
for (int i = 0; i < prices.length; i++) {
Set<Integer> set = new HashSet<>();
set.add(i + 1); // 编号从 1 开始
Solution sol = new Solution(set, prices[i]);
String key = getCanonicalKey(set);
pq.offer(sol);
visited.add(key);
}
while (result.size() < k && !pq.isEmpty()) {
Solution current = pq.poll();
result.add(current);
// 扩展:尝试加入每一个未包含的商品
for (int i = 0; i < prices.length; i++) {
int idx = i + 1;
if (!current.indices.contains(idx)) {
Set<Integer> newSet = new HashSet<>(current.indices);
newSet.add(idx);
String key = getCanonicalKey(newSet);
if (!visited.contains(key)) {
long newPrice = current.totalPrice + prices[i];
pq.offer(new Solution(newSet, newPrice));
visited.add(key);
}
}
}
}
return result;
}
/**
* 生成方案的规范字符串表示,用于去重
* 例如 {3, 1, 2} → "1 2 3"
*/
private static String getCanonicalKey(Set<Integer> indices) {
List<Integer> list = new ArrayList<>(indices);
Collections.sort(list);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < list.size(); i++) {
if (i > 0) sb.append(' ');
sb.append(list.get(i));
}
return sb.toString();
}
}
补充说明
- 时间复杂度:最坏情况下仍可能接近 O(K⋅Nlog(K⋅N)),但由于剪枝和去重,实际运行通常远优于暴力法。
- 空间复杂度:主要由优先队列和去重集合决定,约为 O(K⋅N)。