移动所有球到每个盒子所需的最小操作数 图解题解
这道题到底在问什么
- 输入
- boxes="110"
- 输出
- [1,1,3]
- 输入
- boxes="001011"
- 输出
- [11,8,5,4,3,4]
- 输入
- boxes="011"
- 输出
- [3,1,1]
先想最直接的笨办法
两遍都扫完了。left = [0,0,0,1,2,4] 是每个盒子左半边的代价,right = [11,8,5,3,1,0] 是右半边的代价。现在只要把两张表对应位置相加,每个盒子的最终答案就出来了。一个一个来。(动画第 17 步)
最优解:为什么这么做
一句话答案:LeetCode 1769 移动所有球到每个盒子的最小操作数用两遍前缀扫描:相邻盒子的答案只差左右两侧的球数,左往右滚出左半代价、右往左滚出右半代价再逐位相加,把逐个盒子从头重算的 O(n²) 压到 O(n)。
每个盒子的 answer,究竟在数什么
给你一个长度 n 的二进制字符串 boxes,第 i 位是 1 表示这盒子有球、0 表示空。一步能把一个球挪到相邻盒子。answer[i] 是把所有球集中到第 i 个盒子最少走的步数,也就是所有球到 i 的距离之和;一个球从下标 j 到 i 走 abs(i 减 j) 步。题面 boxes="001011",三个球分别在下标 2、4、5。
对每个盒子从头加一遍,账算得起吗
最直接的做法是对每个盒子单独算:遍历整串,遇球就把它到当前盒子的距离加起来。可每个盒子都要重扫全部 n 位,n 个盒子叠成两层循环,时间 O(n²)。相邻盒子间那一大堆重复加法其实是白做的——下一个盒子的答案跟这一个只差一点点。
相邻两个盒子的答案,差在哪里
把视线从单个盒子挪到相邻两个。设第 i 个盒子的步数已知,目标右移一格到第 i+1 个盒子:左边每个球离目标远一格、多走一步,右边每个球近一格、少走一步。新答案比旧答案多出「左侧球数减右侧球数」——增量不是固定加一,而是一整批球数。
左加右减方向相反,正好拆两遍分开滚:左半代价从左往右累,计数器 cnt 记目标左边有几个球,每挪一格就在上一格左半代价上再加 cnt;右半代价对称地从右往左累。两遍各扫一次,同一盒子左右两半相加就是答案。
两遍扫描里,cnt 该在哪一步更新
左遍从下标 0 起,最左没有左邻,左半代价和 cnt 都是 0。往右每到一格,先看刚跨过的前一格 boxes[i-1]:若是球,它已落到目标左边,cnt 先加一;再用更新后的 cnt 把上一格左半代价累上来。右遍对称,从下标 n-1 起往左,先看后一格 boxes[i+1] 更新 cnt 再累。
这里唯一会做反:新跨过那格的球必须先计进 cnt 再累加,因为目标挪到这格时它已站到对应一侧;先累加再更新,这格就少算一个球,整张表跟着错位。
left 和 right 两张表各长什么样
左遍从左往右,cnt 记左边球数:首格 0;下标 0、1 空着,第 1、2 格仍 0;跨过下标 2 的球后 cnt 变 1,第 3 格 = 0+1 = 1;下标 3 空,第 4 格 = 1+1 = 2;跨过下标 4 的球后 cnt 变 2,第 5 格 = 2+2 = 4。得 left = [0,0,0,1,2,4]。
右遍对称地从右往左滚一遍,得 right = [11,8,5,3,1,0]——它正是左表的镜像,可自验。两张表逐位相加:0+11、0+8、0+5、1+3、2+1、4+0 = [11,8,5,4,3,4],与题面一致。
cnt 记的是某侧当前球数,累起来才是代价
两遍扫描各走 n 格、逐位相加又 n 次,三趟线性,时间 O(n);left、right 加输出数组,空间 O(n)——比逐个盒子从头累加的 O(n²) 快得多。最想不清的是 cnt 的身份:它只是某一侧当前有几个球、是每挪一格答案要涨的增量,不是答案本身,真正代价是把这些 cnt 累起来的 left 或 right。边界也无需特判:单盒时没有任何一侧、两遍停在 0;全空盒的串 cnt 始终为 0、结果全 0。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢:目标右移一格,左边每个球多走一步、右边每个球少走一步。左遍 left[i]=left[i-1]+左边球数,右遍 right[i]=right[i+1]+右边球数,合起来 answer[i]=left[i]+right[i]。
- 4先看清画面。上面这排是盒子 boxes,值是 0 或 1,绿色的三格下标 2、4、5 里各有一个小球,其余是空盒。右边这张表要填的是 left,表示每个盒子左半边球搬过来的代价,现在还空着。我们先从左往右扫一遍把 left 填满,再从右往左扫一遍算 right,最后两个相加就是答案。先看第 0 个盒子。
- 5左遍从第 0 个盒子开始。它是最左边,左边一个盒子都没有,自然没有球需要从左边搬过来,所以 left[0] 就是 0。计数器 cnt 记的是当前盒子左边有几个球,现在也是 0。把 0 记进 left 表的第 0 格。
- 6目标挪到第 1 个盒子。先看刚跨过的下标 0 这一格,它是空盒,没有新球加入,cnt 还是 0。目标从 0 右移到 1,左边这 0 个球每个都要多走一步,所以在上一格 left[0] = 0 的基础上再加 cnt = 0,得到 left[1] = 0。
- 7目标挪到第 2 个盒子。先看刚跨过的下标 1 这一格,它是空盒,没有新球加入,cnt 还是 0。目标从 1 右移到 2,左边这 0 个球每个都要多走一步,所以在上一格 left[1] = 0 的基础上再加 cnt = 0,得到 left[2] = 0。
- 8目标挪到第 3 个盒子。先看刚跨过的下标 2 这一格,它是个球,现在它落到了目标的左边,cnt 加一变成 1。目标从 2 右移到 3,左边这 1 个球每个都要多走一步,所以在上一格 left[2] = 0 的基础上再加 cnt = 1,得到 left[3] = 1。
- 9目标挪到第 4 个盒子。先看刚跨过的下标 3 这一格,它是空盒,没有新球加入,cnt 还是 1。目标从 3 右移到 4,左边这 1 个球每个都要多走一步,所以在上一格 left[3] = 1 的基础上再加 cnt = 1,得到 left[4] = 2。
- 10目标挪到第 5 个盒子。先看刚跨过的下标 4 这一格,它是个球,现在它落到了目标的左边,cnt 加一变成 2。目标从 4 右移到 5,左边这 2 个球每个都要多走一步,所以在上一格 left[4] = 2 的基础上再加 cnt = 2,得到 left[5] = 4。
- 11左表填满后,换个方向。右遍从最右的第 5 个盒子开始,它右边没有盒子了,没有球要从右边搬过来,所以 right[5] 是 0,记右边球数的 cnt 也从 0 起。这回我们从右往左走。
- 12目标挪到第 4 个盒子。看刚跨过的右邻居下标 5,它是个球,现在落到目标右边,cnt 加一变成 1。目标从 5 左移到 4,右边这 1 个球每个都要多走一步,所以在 right[5] = 0 上加 cnt = 1,得到 right[4] = 1。
- 13目标挪到第 3 个盒子。看刚跨过的右邻居下标 4,它是个球,现在落到目标右边,cnt 加一变成 2。目标从 4 左移到 3,右边这 2 个球每个都要多走一步,所以在 right[4] = 1 上加 cnt = 2,得到 right[3] = 3。
- 14目标挪到第 2 个盒子。看刚跨过的右邻居下标 3,它是空盒,cnt 保持 2。目标从 3 左移到 2,右边这 2 个球每个都要多走一步,所以在 right[3] = 3 上加 cnt = 2,得到 right[2] = 5。
- 15目标挪到第 1 个盒子。看刚跨过的右邻居下标 2,它是个球,现在落到目标右边,cnt 加一变成 3。目标从 2 左移到 1,右边这 3 个球每个都要多走一步,所以在 right[2] = 5 上加 cnt = 3,得到 right[1] = 8。
- 16目标挪到第 0 个盒子。看刚跨过的右邻居下标 1,它是空盒,cnt 保持 3。目标从 1 左移到 0,右边这 3 个球每个都要多走一步,所以在 right[1] = 8 上加 cnt = 3,得到 right[0] = 11。
- 17两遍都扫完了。left = [0,0,0,1,2,4] 是每个盒子左半边的代价,right = [11,8,5,3,1,0] 是右半边的代价。现在只要把两张表对应位置相加,每个盒子的最终答案就出来了。一个一个来。
- 18第 0 个盒子:左半边代价 left[0] = 0,右半边代价 right[0] = 11,相加得 11。意思是把三个球全搬到最左边的 0 号盒子,一共要走 11 步。
- 19第 1 个盒子:左半边代价 left[1] = 0,右半边代价 right[1] = 8,相加得 8。记进 answer 的第 1 位。
- 20第 2 个盒子:左半边代价 left[2] = 0,右半边代价 right[2] = 5,相加得 5。记进 answer 的第 2 位。
- 21第 3 个盒子:左半边代价 left[3] = 1,右半边代价 right[3] = 3,相加得 4。记进 answer 的第 3 位。
- 22第 4 个盒子:左半边代价 left[4] = 2,右半边代价 right[4] = 1,相加得 3。记进 answer 的第 4 位。
- 23第 5 个盒子:左半边代价 left[5] = 4,右半边代价 right[5] = 0,相加得 4。最后一个也算好了。
- 24六个盒子的答案都填好了,answer = [11,8,5,4,3,4]。回看整套办法:不去为每个盒子从头加一遍,而是抓住相邻答案的差是左右两侧的球数,左扫一遍算 left、右扫一遍算 right,再逐位相加。总共只扫了三趟数组,线性时间就全部拿下。
⚠️ 容易写错的地方
✗ 错:对每个盒子 i,都把所有球到 i 的距离从头加一遍,写成两层循环
✓ 对:利用相邻答案的差,左右各扫一遍前缀累加,再逐位相加
相邻两个盒子的答案只差左右两侧的球数,不必重算。目标右移一格,左边每个球多一步、右边每个球少一步,这个增量能一遍扫出来。从头重加会让时间退化成 O(n^2)
✗ 错:把 cnt 当成「到目前为止的距离」,直接拿它当答案
✓ 对:cnt 是当前盒子一侧的球数;答案是在上一格代价上再加这个球数
cnt 记的是某一侧有几个球,它是每一步答案要增加的量,不是答案本身。真正的代价是把这些 cnt 一路累加起来的 left[i] 或 right[i]
✗ 错:累加 cnt 的时机搞反,先算 left[i] 再更新 cnt,或反过来
✓ 对:左遍先看 boxes[i-1] 更新 cnt,再写 left[i];右遍先看 boxes[i+1] 再写 right[i]
走到 i 时,新跨过的那格球(左遍是 i-1,右遍是 i+1)已经落到目标这一侧了,必须先把它计入 cnt,再用更新后的 cnt 累加。顺序错一步,球数会差一个
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def minOperations(self, boxes: str) -> List[int]:
n = len(boxes)
left = [0] * n
right = [0] * n
cnt = 0
for i in range(1, n):
if boxes[i - 1] == '1':
cnt += 1
left[i] = left[i - 1] + cnt
cnt = 0
for i in range(n - 2, -1, -1):
if boxes[i + 1] == '1':
cnt += 1
right[i] = right[i + 1] + cnt
return [a + b for a, b in zip(left, right)]C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
vector<int> minOperations(string boxes) {
int n = boxes.size();
int left[n];
int right[n];
memset(left, 0, sizeof left);
memset(right, 0, sizeof right);
for (int i = 1, cnt = 0; i < n; ++i) {
cnt += boxes[i - 1] == '1';
left[i] = left[i - 1] + cnt;
}
for (int i = n - 2, cnt = 0; ~i; --i) {
cnt += boxes[i + 1] == '1';
right[i] = right[i + 1] + cnt;
}
vector<int> ans(n);
for (int i = 0; i < n; ++i) ans[i] = left[i] + right[i];
return ans;
}
};Java
import java.util.*;
class Solution {
public int[] minOperations(String boxes) {
int n = boxes.length();
int[] left = new int[n];
int[] right = new int[n];
for (int i = 1, cnt = 0; i < n; ++i) {
if (boxes.charAt(i - 1) == '1') {
++cnt;
}
left[i] = left[i - 1] + cnt;
}
for (int i = n - 2, cnt = 0; i >= 0; --i) {
if (boxes.charAt(i + 1) == '1') {
++cnt;
}
right[i] = right[i + 1] + cnt;
}
int[] ans = new int[n];
for (int i = 0; i < n; ++i) {
ans[i] = left[i] + right[i];
}
return ans;
}
}复杂度
时间
O(n)
n 是盒子数。左扫一遍 n 次、右扫一遍 n 次、最后逐位相加又 n 次,总共三趟线性扫描,是 O(n)。相比之下,若对每个盒子都把所有球的距离从头加一遍,是两层循环 O(n^2),数据一大就慢。本题 n 最大 2000,线性做法轻松
空间
O(n)
按峰值算。额外开了 left、right 两个长度 n 的数组,还有输出数组 answer,都是 O(n)。若把 left 累加进结果、右遍时边扫边把 right 直接加到 answer 上,可以省掉一个数组,但量级仍是 O(n)(要交出去的 answer 本身就 O(n))。计数器 cnt 是常数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 移动所有球到每个盒子所需的最小操作数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题为什么能从相邻盒子递推,不用每个都重算?+
answer[i] 是所有球到第 i 个盒子的距离和,逐个盒子从头加是 O(n²)。但相邻两个盒子的答案是有关系的:目标右移一格,左边每个球多走一步、右边每个球少走一步,两个答案只差「左侧球数减右侧球数」。抓住这个差,就能从一个盒子的结果推出下一个。为了让加和减分开累积(一个从左滚、一个从右滚),把代价拆成左右两半各扫一遍,最后相加,整体降到 O(n)。
为什么非得分左右两遍,一遍不能搞定吗?+
一个盒子的代价由两部分组成——左边的球往右搬、右边的球往左搬。左半代价越往右的盒子越大(左边积累的球越来越多),得从左往右滚;右半代价越往左的盒子越大,得从右往左滚。两者累加方向正相反,一遍扫描没法同时把两个方向都滚对,所以分两遍、再逐位相加。真要省空间,可以只留一个结果数组:第一遍把左半写进去,第二遍把右半加上来。
cnt 更新和累加的先后,为什么不能调换?+
走到某一格时,刚跨过的那格球(左遍是前一格、右遍是后一格)已经站到了目标对应的一侧,必须先把它计进 cnt,再用更新后的 cnt 去累加这一格的代价。要是先累加再更新,这一格就漏掉了这个新加入的球,往后每一格都跟着少算一个,整张表偏掉。顺序只错一步,结果就差一批球的距离。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 移动所有球到每个盒子所需的最小操作数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。