获取单值网格的最小操作数 图解题解
这道题到底在问什么
- 输入
- grid=[[2,4],[6,8]], x=2
- 输出
- 4
- 输入
- grid=[[1,2],[3,4]], x=2
- 输出
- -1(1、3 是奇数,2、4 是偶数,除 2 余数不同)
先想最直接的笨办法
取正中间那个数当目标。9 个数,下标从 0 到 8,中间是下标 4,值是 6。参考代码里就写成 nums 长度右移一位,也就是 9 除以 2 取整等于 4。把 6 定为大家要一起走到的目标值。下面挨个算,每个数走到 6 要几步。(动画第 17 步)
最优解:为什么这么做
一句话答案:LeetCode 2033 获取单值网格的最小操作数:先查所有数除以 x 是否同余、不同就返回 -1,同余则排序取中位数当目标、每个数到中位数的距离除以 x 累加就是答案,而中位数让距离和最小,时间 O(mn log(mn))。
把整张网格变成同一个数,最少动几次
给一个 m 行 n 列的网格 grid 和整数 x,一次操作给任意一个格子加 x 或减 x,问最少几次能让所有格子相等,做不到返回 -1。题面第一个例子 grid=[[2,4],[6,8]]、x=2,答案是 4;第二个例子 grid=[[1,2],[3,4]]、x=2,答案是 -1——1、3 是奇数、2、4 是偶数,除以 2 余数不同,谁也走不到一块。
选平均值当目标、拿极差估次数,两条路都会崴
把 2、4、6、8 聚成一个数,平均值 5 每次只能加减 2、从偶数永远落不到奇数 5 上,何况它还常是小数,当不了目标。换成拿极差凑:最大 8 减最小 2 除以 2 得 3,可这只算了两端的账,中间的 4、6 要挪几步全漏了,凑不出总操作数。
余数对不齐就无解,对齐了中位数最省
先过生死关:一个格子每次变动都是 x 的整数倍,它能到达的值和自己除以 x 的余数永远一样。两个格子想最终相等,除以 x 的余数就必须相同。把所有数除以 x,余数只要有一个不同,这网格就没救、返回 -1;余数全相同,才有得聊。
余数齐了再挑目标。把所有数排好序,取正中间那个,也就是中位数,它到各数的绝对距离之和最小。道理是:目标从中位数往任一边挪一小步,那一边的数离目标近一点、另一边远一点,而中位数两侧数目相当,挪动只会让数多一侧涨得比数少一侧减得多,总距离不降反升。所以定它当目标,每个数到它的距离除以 x 就是要动的次数,加起来即答案。
四步走:拍扁、查余数、排序取中位、距离求和
整套流程照参考代码走四步。第一步拍扁,把二维网格一行行读下来接成一维数组,之后只看每个数离目标多远。第二步查余数,拿第一个数除以 x 的余数当基准 mod,扫每个数,谁的余数不等于 mod 就立刻返回 -1。第三步排序,把数组从小到大排好,取下标为长度右移一位那个当中位数,右移一位就是长度除以 2 向下取整。第四步求和,逐个算每个数与中位数的差、取绝对值再除以 x,累加就是最少操作数。
grid=[[2,4],[6,8]]、x=2 这组,4 从哪来
拍扁 grid=[[2,4],[6,8]] 得到 2、4、6、8。查余数:2 除以 2 余 0,当基准 mod;4、6、8 除以 2 都余 0,全部对齐,不返回 -1。
排序后还是 2、4、6、8,长度 4 右移一位得下标 2,取 nums[2]=6 当目标。挨个折算:2 到 6 差 4,除以 2 是 2 次;4 到 6 差 2,除以 2 是 1 次;6 到 6 差 0,不动;8 到 6 差 2,除以 2 是 1 次。加起来 2+1+0+1=4,正是题面给的 4。
复杂度卡在排序,还有几处边界要想清
复杂度上,拍扁、查余数、求距离和各扫一遍是 O(mn),m 行 n 列共 mn 个数,真正拖后腿的是中间那次排序 O(mn log(mn)),这就是总时间;空间要存拍扁后的一维数组,是 O(mn)。边界也顺带想清:网格只剩一个格子本就相等,答案 0;所有数已全相等,距离和为 0,同样是 0;余数对不齐则数字再规整也返回 -1。偶数个数不必纠结取上取下,取下标长度右移一位那个即可,两个中位数总距离相同。
▶ 动画逐步走查(共 25 步)——想跟着动画一帧帧对照就展开
- 3记牢这套路:第一步查同余定生死,第二步排序取中位数当目标,第三步把每个数到中位数的距离除以 x 累加。下面从拍扁网格开始。
- 4要把 9 个格子变成同一个值这就是我们的输入,一张 3 行 3 列的网格,右上角记着 x 等于 2。每次操作能给任意一个格子加 2 或减 2。你先扫一眼这 9 个数,全是偶数。接下来第一件事,是把这张二维网格拍扁成一条一维的数,处理起来更顺手。
- 5把网格一行一行读下来,拉成一条一维数组:8、2、6、4、10、8、6、4、2,一共 9 个数。位置怎么排其实无所谓,因为最后只关心它们各自离目标有多远。拍扁之后,先别急着算,得先回答一个生死问题:这些数到底能不能凑到同一个值上。
- 6从第 0 个数 8 开始。参考代码拿它除以 x 的余数当作基准:8 除以 2 余 0,于是把 mod 定成 0。之后每一个数都要拿来和这个 0 对比,只要有一个余数不是 0,整张网格就没救了。
- 7轮到第 1 个数 2,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 8轮到第 2 个数 6,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 9轮到第 3 个数 4,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 10轮到第 4 个数 10,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 11轮到第 5 个数 8,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 12轮到第 6 个数 6,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 13轮到第 7 个数 4,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 14轮到第 8 个数 2,除以 2 余 0,正好等于基准 mod 的 0,过关。它和 8 之间相差的是 2 的整数倍,理论上能靠若干次加减 2 走到一块去。绿色表示这个数已经通过同余检查。
- 159 个数全部检查完,除以 2 的余数清一色是 0,同余成立,这张网格一定能变成单值,不用返回 -1。生死关过了,接下来才是正题:选一个共同目标,让总操作数最少。
- 16把这 9 个数从小到大排好,变成 2、2、4、4、6、6、8、8、10。排序是为了下一步好取中位数。你可能会问,为什么不直接取平均值当目标,先按住这个疑问,等会儿有一帧专门算给你看,平均值反而更亏。
- 17取正中间那个数当目标。9 个数,下标从 0 到 8,中间是下标 4,值是 6。参考代码里就写成 nums 长度右移一位,也就是 9 除以 2 取整等于 4。把 6 定为大家要一起走到的目标值。下面挨个算,每个数走到 6 要几步。
- 18看下标 0 这个数 2。它和目标 6 相差 4,而每次操作动 2,所以 要加 2 次 2,折合 2 次操作。把 2 加进累计,现在 ops 等于 2。蓝色的都是已经结算过的数。
- 19看下标 1 这个数 2。它和目标 6 相差 4,而每次操作动 2,所以 要加 2 次 2,折合 2 次操作。把 2 加进累计,现在 ops 等于 4。蓝色的都是已经结算过的数。
- 20看下标 2 这个数 4。它和目标 6 相差 2,而每次操作动 2,所以 要加 1 次 2,折合 1 次操作。把 1 加进累计,现在 ops 等于 5。蓝色的都是已经结算过的数。
- 21看下标 3 这个数 4。它和目标 6 相差 2,而每次操作动 2,所以 要加 1 次 2,折合 1 次操作。把 1 加进累计,现在 ops 等于 6。蓝色的都是已经结算过的数。
- 22看下标 4 这个数 6。它和目标 6 相差 0,而每次操作动 2,所以 本身就是 6,不用动,折合 0 次操作。把 0 加进累计,现在 ops 等于 6。蓝色的都是已经结算过的数。
- 23看下标 5 这个数 6。它和目标 6 相差 0,而每次操作动 2,所以 本身就是 6,不用动,折合 0 次操作。把 0 加进累计,现在 ops 等于 6。蓝色的都是已经结算过的数。
- 24看下标 6 这个数 8。它和目标 6 相差 2,而每次操作动 2,所以 要减 1 次 2,折合 1 次操作。把 1 加进累计,现在 ops 等于 7。蓝色的都是已经结算过的数。
- 25看下标 7 这个数 8。它和目标 6 相差 2,而每次操作动 2,所以 要减 1 次 2,折合 1 次操作。把 1 加进累计,现在 ops 等于 8。蓝色的都是已经结算过的数。
- 26看下标 8 这个数 10。它和目标 6 相差 4,而每次操作动 2,所以 要减 2 次 2,折合 2 次操作。把 2 加进累计,现在 ops 等于 10。蓝色的都是已经结算过的数。
- 279 个数全部结算完,把它们各自走到 6 需要的次数加起来,一共是 10。这就是把整张网格变成单值网格的最少操作数。回头看整条链路:先判同余保证有解,再排序取中位数当目标,最后把每个数的距离折算成次数求和,答案 10。
⚠️ 容易写错的地方
✗ 错:直接取平均值当共同目标
✓ 对:排序后取中位数当目标
让绝对距离之和最小的是中位数不是平均值,平均值还可能是小数、不满足同余,根本当不了目标
✗ 错:不判同余就直接开算
✓ 对:先确认所有数除以 x 的余数相同,否则返回 -1
余数不同的两个数,靠加减 x 永远走不到一起,不先拦住就会算出一个没意义的数
✗ 错:用最大值减最小值估操作数
✓ 对:必须逐个数到中位数的距离折算次数再累加
操作数是每个元素各自移动次数之和,不是极差,极差只反映了两端、漏掉中间所有数
✗ 错:偶数个元素时纠结中位数取哪个
✓ 对:排序后直接取 nums[n >> 1]
偶数个时上中位数和下中位数给出的总距离完全相同,取长度右移一位那个下标即可,不必特殊处理
完整代码(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, grid: List[List[int]], x: int) -> int:
nums = []
mod = grid[0][0] % x
for row in grid:
for v in row:
if v % x != mod:
return -1
nums.append(v)
nums.sort()
mid = nums[len(nums) >> 1]
return sum(abs(v - mid) // x for v in nums)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:
int minOperations(vector<vector<int>>& grid, int x) {
int m = grid.size(), n = grid[0].size();
int mod = grid[0][0] % x;
int nums[m * n];
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] % x != mod) {
return -1;
}
nums[i * n + j] = grid[i][j];
}
}
sort(nums, nums + m * n);
int mid = nums[(m * n) >> 1];
int ans = 0;
for (int v : nums) {
ans += abs(v - mid) / x;
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int minOperations(int[][] grid, int x) {
int m = grid.length, n = grid[0].length;
int[] nums = new int[m * n];
int mod = grid[0][0] % x;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] % x != mod) {
return -1;
}
nums[i * n + j] = grid[i][j];
}
}
Arrays.sort(nums);
int mid = nums[nums.length >> 1];
int ans = 0;
for (int v : nums) {
ans += Math.abs(v - mid) / x;
}
return ans;
}
}复杂度
时间
O(mn log(mn))
m 行 n 列共 mn 个数。同余检查和距离求和都是扫一遍 O(mn),真正主导的是中间那次排序,量级 O(mn log(mn))
空间
O(mn)
按峰值算。要把网格拍扁存进一维数组,占 O(mn)。排序内部另有栈开销:C++ 与 Java 约 O(log(mn)) 递归栈,Python 的 Timsort 最坏 O(mn),都不超过数组本身量级
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 获取单值网格的最小操作数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么共同目标一定要取中位数,不能取平均值?+
把所有数排好序,让目标从中位数往任意一边挪一小步:挪向的那一边,每个数到目标的距离都少一步;另一边每个数都多一步。中位数正好把数分成两侧数目相当的两拨,一旦挪动,数多那一侧增加的总量会压过数少那侧减少的总量,总距离只增不减,所以绝对距离之和在中位数处最小。平均值不但常是小数、还可能和大家除以 x 的余数对不齐,根本当不成目标。偶数个数时,两个中位数之间任取一个值,总距离都相同。
什么情况下返回 -1?+
只有一种:存在两个数除以 x 的余数不同。每个格子每次只能加减 x,它能到达的所有值都和自身除以 x 同余,余数是它一辈子改不掉的身份。两个余数不同的数,不管加减多少次 x 都碰不到同一个值,所以整张网格永远统一不了,直接返回 -1。代码里拿第一个数的余数当基准,只要扫到一个不一样的就立刻退出。
复杂度为什么是 O(mn log(mn))?+
设网格一共 mn 个数。拍扁、查余数、逐个求距离再累加,这几步都是把 mn 个数扫一遍,各是 O(mn)。中间那次把一维数组从小到大排序才是瓶颈,是 O(mn log(mn)),它压过了线性的几步,成为总时间。空间上主要花在存拍扁后的一维数组,是 O(mn),排序自身的递归或栈开销不超过这个量级。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 获取单值网格的最小操作数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。