使每位学生都有座位的最少移动次数 图解题解
这道题到底在问什么
- 输入
- seats=[3,1,5], students=[2,7,4]
- 输出
- 4
- 输入
- seats=[2,2,6,6], students=[1,3,2,6]
- 输出
- 4
最优解:为什么这么做
一句话答案:LeetCode 2037 使每位学生都有座位的最少移动次数:把 seats 与 students 各自升序排序,同名次一一配对累加绝对差,小位置配小位置最省,时间 O(n log n)。
两排乱序位置,怎么配座位最省
给两个长度都是 n 的数组:seats 里是每个座位的位置,students 里是每名学生的位置。每次能把某名学生的位置加一或减一,走一格算一次移动。要让每名学生都坐上一个座位、且没有两人挤同一个座位,问最少一共挪多少格。题面例子 seats=[3,1,5]、students=[2,7,4],答案是 4。位置允许重复。
先想清楚该让谁去坐哪个座位
n 名学生配 n 个座位,能排的配法有 n 的阶乘种,挨个试到 n 稍大就算不完。而且两个数组给进来都是乱的,照原始下标硬配也不对——例子里第 2 个座位在 1、第 2 个学生在 7,这一对就得跑 6 格,三对加起来 8 格,并不是最少。真正要定的是哪名学生去坐哪个座位,才能把总格数压到最小。
为什么两边排好序、同名次配对就最省
把两排位置各自从小到大排好,然后第 1 小的座位配第 1 小的学生、第 2 小配第 2 小,一路对下去。这样为什么最省?假设某个更优的配法里出现了交叉:一个较小的座位配了较大的学生、一个较大的座位配了较小的学生。把这两名学生的座位对调一下,让小的配小、大的配大,算一下这两对的绝对差之和只会变小或持平,绝不会变大。既然任何交叉都能这样捋直而不吃亏,一路对调下去,最优配法一定收敛成两边同序配对的样子。
配好之后每对怎么算,哪里最容易写错
配对定死后,一名学生从原位挪到座位,走的格数就是两个位置之差的绝对值,把 n 对的绝对差全加起来就是答案。几个地方容易写反:只排一边不行,座位排了、学生没排,名次照样对不齐,配出来还是交叉的;别拿位置的和或平方去算花费,一次移动只改一格,走的格数就是起点终点的绝对差;位置有重复也不用特判,照排照配,绝对差对重复值天然成立。
题面这组数,最少 4 格是怎么配出来的
两排各自排序:座位排成 1、3、5,学生排成 2、4、7。同名次配对:座位 1 配学生 2,差 1;座位 3 配学生 4,差 1;座位 5 配学生 7,差 2。三对相加 1 加 1 加 2,得到 4。
两次排序各是 O(n log n),后面配对累加只多一趟线性扫,合起来 O(n log n);除排序外只用一个累计变量,空间 O(1)。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:两个数组各自排序,然后同名次配对,逐对累加绝对差。下面先把两排位置排好,再一对一对地算。
- 4待排序先看座位这一排,原始顺序是 5、1、8、3、9、6,乱的。算法第一步是把它从小到大排好,这样名次就固定了,方便和学生一一对上。
- 5升序完成排好之后座位变成 1、3、5、6、8、9,从左到右就是第 1 名到第 6 名座位。位置最小的排最左,最大的排最右。座位这边准备完毕。
- 6待排序再看学生这一排,原始顺序是 2、7、4、1、6、3,也是乱的。同样地,把它从小到大排好,好和座位按名次对上。
- 7升序完成排好之后学生变成 1、2、3、4、6、7,从左到右是第 1 名到第 6 名学生。两排都排好了,接下来就该把同名次的座位和学生凑成一对。
- 8配对开始把两排放到一起看:上面主排是排好的座位 1、3、5、6、8、9,右侧栏是排好的学生 1、2、3、4、6、7。它们名次一一对齐,第 1 名座位对第 1 名学生,第 2 名对第 2 名,依此类推。现在累计移动是 0,咱们从第 1 名开始一对一对地算。
- 9座位 1 ↔ 学生 1轮到第 1 名。紫色指到的座位在位置 1,侧栏高亮的第 1 名学生在位置 1。它俩要凑成一对,这名学生就往这个座位挪。这是开局第一对。
- 10本对花费 0 · 累计 0算这一对的花费:座位 1 和学生 1 相差 0 格,这名学生原地就坐,一步都不用挪。把 0 加进累计,现在总数是 0。这一对搞定,染成绿色。
- 11座位 3 ↔ 学生 2轮到第 2 名。紫色指到的座位在位置 3,侧栏高亮的第 2 名学生在位置 2。它俩要凑成一对,这名学生就往这个座位挪。前面 1 对已经配好,染成蓝色。
- 12本对花费 1 · 累计 1算这一对的花费:座位 3 和学生 2 相差 1 格,这名学生挪 1 格就位。把 1 加进累计,现在总数是 1。这一对搞定,染成绿色。
- 13座位 5 ↔ 学生 3轮到第 3 名。紫色指到的座位在位置 5,侧栏高亮的第 3 名学生在位置 3。它俩要凑成一对,这名学生就往这个座位挪。前面 2 对已经配好,染成蓝色。
- 14本对花费 2 · 累计 3算这一对的花费:座位 5 和学生 3 相差 2 格,这名学生挪 2 格就位。把 2 加进累计,现在总数是 3。这一对搞定,染成绿色。
- 15座位 6 ↔ 学生 4轮到第 4 名。紫色指到的座位在位置 6,侧栏高亮的第 4 名学生在位置 4。它俩要凑成一对,这名学生就往这个座位挪。前面 3 对已经配好,染成蓝色。
- 16本对花费 2 · 累计 5算这一对的花费:座位 6 和学生 4 相差 2 格,这名学生挪 2 格就位。把 2 加进累计,现在总数是 5。这一对搞定,染成绿色。
- 17座位 8 ↔ 学生 6轮到第 5 名。紫色指到的座位在位置 8,侧栏高亮的第 5 名学生在位置 6。它俩要凑成一对,这名学生就往这个座位挪。前面 4 对已经配好,染成蓝色。
- 18本对花费 2 · 累计 7算这一对的花费:座位 8 和学生 6 相差 2 格,这名学生挪 2 格就位。把 2 加进累计,现在总数是 7。这一对搞定,染成绿色。
- 19座位 9 ↔ 学生 7轮到第 6 名。紫色指到的座位在位置 9,侧栏高亮的第 6 名学生在位置 7。它俩要凑成一对,这名学生就往这个座位挪。前面 5 对已经配好,染成蓝色。
- 20本对花费 2 · 累计 9算这一对的花费:座位 9 和学生 7 相差 2 格,这名学生挪 2 格就位。把 2 加进累计,现在总数是 9。这一对搞定,染成绿色。
- 21总移动 9六对全部配好,整排染成绿色。逐对花费依次是 0、1、2、2、2、2,加起来正好 9 格。这就是排序后同名次配对得到的总移动次数。
- 22原序配对 21为什么非要排序,看这个反例就懂了。如果不排序、直接按原始下标配对,座位 5 配学生 2、座位 1 配学生 7、座位 8 配学生 4,依此类推,逐对距离是 3、6、4、2、3、3,总共 21 格,比排序后的 9 足足多花 12 格。乱配会让小座位摊上远学生,白白多走路,排序正是为了避免这种交叉。
- 23答案 = 9回放一遍。两排各自排序,同名次一一配对,逐对绝对差累加,最小总移动就是 0 加 1 加 2 加 2 加 2 加 2,等于 9。这就是让每名学生都坐上互不相同座位的最少移动次数,答案 9。
⚠️ 容易写错的地方
✗ 错:不排序,直接按原下标配对求和
✓ 对:两个数组都要先各自升序,再同名次配对
原序配对会让小位置摊上远的对象,总和偏大,本节反例里 21 对 9 就是教训
✗ 错:只排一个数组、另一个不排
✓ 对:两个数组都必须排序
只排一边,名次对不齐,配对仍然是交叉的,和不是最小
✗ 错:担心座位或学生有重复位置会出错
✓ 对:有重复也照排照配,直接算绝对差
题目允许多个座位或多名学生同位置,排序配对对重复值天然成立,不需要特判
✗ 错:用位置的和或平方去衡量花费
✓ 对:每对花费是位置之差的绝对值
一次移动改变一格,一名学生走的格数就是起点和终点的绝对差,不是和也不是平方
完整代码(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 minMovesToSeat(self, seats: List[int], students: List[int]) -> int:
seats.sort()
students.sort()
return sum(abs(a - b) for a, b in zip(seats, students))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 minMovesToSeat(vector<int>& seats, vector<int>& students) {
sort(seats.begin(), seats.end());
sort(students.begin(), students.end());
int ans = 0;
for (int i = 0; i < seats.size(); ++i) {
ans += abs(seats[i] - students[i]);
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int minMovesToSeat(int[] seats, int[] students) {
Arrays.sort(seats);
Arrays.sort(students);
int ans = 0;
for (int i = 0; i < seats.length; ++i) {
ans += Math.abs(seats[i] - students[i]);
}
return ans;
}
}复杂度
时间
O(n log n)
两次排序各是 O(n log n),之后一趟线性扫描配对累加是 O(n),整体由排序主导,记 O(n log n)
空间
O(1) 或 O(n)
除排序外只用常数个变量存累计和,是 O(1)。排序自身的额外开销分语言:C plus plus 与 Java 的排序递归栈约 O(log n),Python 的 Timsort 最坏 O(n),若把排序开销计进去以此为准
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 使每位学生都有座位的最少移动次数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么同名次配对一定最优,不会有更省的配法吗?+
关键在于任何交叉都能被无损地拆掉。设想某个配法里,较小的座位配了较大的学生、较大的座位配了较小的学生,这就是交叉。只要把这两名学生的座位对调,让小的配小、大的配大,这两对的绝对差之和只会变小或持平。既然交叉总能这样拆掉而不多花一格,一路拆下去,配法必然落到两边同序的形态,同序配对因此就是最小值。
座位或学生位置有重复,排序配对还成立吗?+
成立,不需要任何特判。题目本来就允许多个座位或多名学生落在同一位置。排序只是把位置从小到大摆好,遇到相等的值它们相邻排着,同名次配对照样一对一取绝对差。第二个例子 seats=[2,2,6,6]、students=[1,3,2,6] 就带重复,排序后 seats 是 2、2、6、6、students 是 1、2、3、6,逐对差 1、0、3、0,加起来正好 4。
复杂度是多少,位置值域小能不能更快?+
两次排序各 O(n log n),之后配对累加是一趟 O(n) 扫描,整体 O(n log n),空间除排序外是常数。如果位置值域不大,可以用计数排序把排序压到 O(n+值域) 量级,这也是这题挂着计数排序标签的原因。一般数据规模下直接用语言自带排序就够,写起来也短。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 使每位学生都有座位的最少移动次数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。