题目
有若干个文件,用刻录光盘的方式进行备份,假设每张光盘的容量是 500MB,求使用光盘最少的文件分布方式。所有文件的大小都是整数的 MB,且不超过 500MB;文件不能分割、分卷打包。
输入描述
一组文件大小的数据
输出描述
使用光盘的数量
不用考虑输入数据不合法的情况;假设最多 100 个输入文件。
用例1
输入:
100,500,300,200,400
输出:
3
说明:
(100,400),(200,300),(500) 3 张光盘即可。
用例2
输入:
1,100,200,300
输出:
2
思考一(双指针)
先对文件列表按文件大小倒序排序,先装最大的,如果还有剩余空间,尝试用最小的填充。比如文件列表 [350,350,200, 400],这个一眼看上去应该需要 4 张光盘。先按文件大小降序得: [400, 350, 350, 200];第一步:400用一张光盘存,还余下 100MB 空间,这时候最小的 200MB 存不下,此时耗费一张盘,count = 1; 第二步:350 用一张光盘存,余下150MB,显然也装不下最小的 200MB,count = 2;第三步和第二步相同耗费一张盘,count = 3;最后剩下 200MB 文件,用一张盘存,总共需要 4 张盘,答案正确。测试用例1:[100,500,300,200,400],排序:[500,400,300,200,100],第一张盘存 500,第二张盘存 400+100,第三张盘存 300+200,最少需要 3 张盘。测试用例2:[1,100,200,300],排序 [300,200,100,1],第一张盘存 300 + 1 + 100,第二张盘存 200,最少需要 2 张盘。这个用双指针怎么写?如果l + r < 500,执行 r-- ? 比如, 300 + 1 < 500,显然还可以继续存其它文件。r 指向 1,现在 r–,r 指向 100,300 + 1 + 100 < 500,每次小于500 都移动右指针指向次小的,一旦遇到 300+1+100+200 > 500时,count++,此时count = 1,然后怎么做?移动 l ?此时 r 已经指向 200 了, l++ 后 l == r,此时 200 存一个盘,count++,循环结束。好吧,有点乱。但总感觉双指针不一定靠谱,对于 [300,200,100,1],显然 {(300+200),(100+1) } 比 {(300+100+1),(200)}看起来更合理些,虽然都耗费2张盘,双指针可能过分依赖于右指针左移不断选取零碎的小文件来填充磁盘存放一个大文件余下的空隙从而导致忽略了更优的组合。但我还没找到反例,谁找到反例可用提供下哦。
算法过程
1. 排序预处理
- 步骤:将文件大小数组按 降序排序(大文件优先处理)。
- 时间复杂度:O(n log n)
2. 初始化指针
- 步骤:
left = 0
(指向当前最大文件)right = files.length - 1
(指向当前最小文件)count = 0
(记录光盘数)
3. 贪心分配策略
- 步骤:
- 检查能否组合:
- 若
files[left] + files[right] ≤ 500
,则:- 占用一张光盘存放这两个文件。
- 计算剩余空间
remaining = 500 - (files[left] + files[right])
。 - 移动
right--
,尝试用剩余空间装入更小的文件(while (remaining ≥ files[right])
)。
- 若
- 无法组合时:
- 单独用一张光盘存放
files[left]
。
- 单独用一张光盘存放
- 移动指针:
- 无论是否组合,
left++
处理下一个大文件。
- 无论是否组合,
- 检查能否组合:
- 时间复杂度:O(n)(每个文件仅被访问一次)
4. 终止条件
- 步骤:当
left > right
时停止。
5. 返回结果
- 步骤:输出
count
(最少光盘数)。
总时间复杂度
- 排序:O(n log n)
- 双指针遍历:O(n)
- 合计:O(n log n)(排序主导)
关键点
- 降序排序:确保优先处理大文件,减少空间浪费。
- 贪心组合:尽量用一张光盘装下「最大 + 最小」文件,剩余空间再装更小文件。
- 线性遍历:通过双指针高效完成分配。
参考代码
function solution() {
const files = readline().split(',').map(Number);
files.sort((a, b) => b - a); // 降序排序
let count = 0;
let left = 0;
let right = files.length - 1;
while (left <= right) {
if (files[left] + files[right] <= 500) {
// 能放下最大和最小文件,尝试继续放更小的文件
let remaining = 500 - files[left] - files[right];
right--; // 已经放入最小文件,移动右指针
while (left <= right && remaining >= files[right]) {
remaining -= files[right];
right--;
}
}
// 无论如何,最大文件都要占用一张光盘
count++;
left++;
}
console.log(count);
}
const cases = [
`100,500,300,200,400`, // 3
`1,100,200,300`, // 2
`200,400,300`, // 2
`350,350,350,350`, // 4
`1,1,1,1`, // 1
`1,1,100,200,300`, // 2
`250, 250, 250, 200, 100`, // 3
`200, 200, 200,200, 100,100,100, 100,100,100` // 3
];
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();
});
思考二(二分查找+DFS)
每个文件大小不超过 500MB 且不可分割,每张光盘的容量也是 500MB。如果有 N 个文件,最多需要 N 张光盘备份。如果每个文件都很小,题目中限制最多 100 个文件,那么每个文件 1MB,总共 100MB,一张 500MB 的光盘就够了。因此,对随机输入的文件数量 N,最少需要的光盘数量应该在区间 [1, N] 之间,可用通过二分查找来猜测这个最少光盘数量 x,然后定义一个check函数递归+回溯选取所有文件的组合判断是否可以用 x 张光盘存储。
算法过程
-
输入处理
- 读取输入文件大小并转换为数字数组
- 时间复杂度:O(n)
-
排序预处理
- 将文件按大小降序排序
- 时间复杂度:O(n log n)
-
二分查找框架
- 确定光盘数量的搜索范围 [1, n]
- 时间复杂度:O(log n) 次 check 调用
-
check 函数(DFS回溯)
- 尝试用 n 张光盘装载所有文件
- 使用深度优先搜索回溯:
- 为每个文件尝试放入当前可用的光盘
- 递归尝试后续文件
- 回溯恢复状态
- 最坏时间复杂度:O(n!)(全排列情况)
-
整体复杂度
- 总时间复杂度:O(n! log n)(最坏情况)
- 空间复杂度:O(n)(递归栈和状态存储)
关键点说明
- 该算法通过二分查找确定最小光盘数
- check 函数使用 DFS 回溯验证可行性
- 排序优化了 DFS 的搜索顺序
- 算法在最坏情况下性能较差(n=100 时不可行)
参考代码
function solution() {
const files = readline().split(',').map(Number);
files.sort((a,b) => b-a);
const len = files.length;
const check = function(n) {
const disks = Array(n).fill(500);
const dfs = function(i, used) {
for (let j = 0; j < n; j++) {
if (disks[j] >= files[i]) {
disks[j] -= files[i];
used.push(i);
if (used.length === len) return true; // 所有文件都用完,光盘够用,返回true
for (let k = 0; k < len; k++) {
if (used.includes(k)) continue;
if (dfs(k, used)) return true;
}
disks[j] += files[i];
used.pop();
break;
}
}
};
for (let i = 0; i < len; i++) {
let used = [];
if (dfs(i, used)) {
return true;
}
}
return false;
};
let l = 1, r = files.length;
while (l <= r) {
let mid = l + Math.floor((r-l)/2);
if (check(mid)) {
r = mid-1;
} else {
l = mid+1;
}
}
console.log(l);
}
const cases = [
`100,500,300,200,400`, // 3
`1,100,200,300`, // 2
`200,400,300`, // 2
`350,350,350,350`, // 4
`1,1,1,1`, // 1
`1,1,100,200,300`, // 2
`250, 250, 250, 200, 100`, // 3
`200, 200, 200,200, 100,100,100, 100,100,100` // 3
];
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();
});
思考三(Best Fit Decreasing)
除了双指针和二分查找,本题还可以尝试用启发式算法BFD求解,BFD(Best Fit Decreasing)是一种经典的装箱问题近似算法,其核心思想是将物品按降序排列后,为每个物品寻找最适合的容器。
算法过程
-
降序排序文件
- 将文件按大小从大到小排序,优先处理大文件以减少空间碎片。
- 时间复杂度:O(n log n)
-
初始化光盘列表
- 维护一个数组
disks
,记录每张光盘的剩余空间。 - 初始时
disks = []
(没有光盘)。
- 维护一个数组
-
遍历所有文件
- 对于每个文件
file
,执行以下操作:
a. 寻找最佳光盘(Best Fit):- 在现有光盘中,找到 能放下
file
且剩余空间最小 的光盘(即disks[i] ≥ file
且disks[i]
最小)。 - 目的:尽量利用已有空间,避免浪费。
b. 放入文件: - 如果找到合适的光盘,将
file
放入该光盘,并更新剩余空间disks[i] -= file
。 - 如果没有合适的光盘,则新增一张光盘,剩余空间
500 - file
。
- 在现有光盘中,找到 能放下
- 时间复杂度:O(n²)(最坏情况下,每次遍历所有光盘)
- 对于每个文件
-
返回光盘数量
disks.length
即为最少需要的光盘数。
-
为什么 BFD 能保证最优?
- 降序排序:优先处理大文件,减少空间碎片。
- Best Fit 策略:尽量利用已有光盘的剩余空间,避免浪费。
- 数学证明:BFD 在装箱问题(Bin Packing)中近似最优,实际测试中往往能得到最优解。
参考代码
function solutionBFD() {
const files = readline().split(',').map(Number);
files.sort((a, b) => b - a); // 降序排序
const disks = []; // 存储每张光盘的剩余空间
for (const file of files) {
let bestDisk = -1;
let minRemaining = Infinity;
// 寻找能放下 file 的剩余空间最小的光盘(Best Fit)
for (let i = 0; i < disks.length; i++) {
if (disks[i] >= file && disks[i] < minRemaining) {
minRemaining = disks[i];
bestDisk = i;
}
}
if (bestDisk !== -1) {
disks[bestDisk] -= file; // 放入已有光盘
} else {
disks.push(500 - file); // 新增光盘
}
}
console.log(disks.length); // 最少需要的光盘数
}