这是一道经典的排序题目,没什么特别困难的点,写下这篇笔记的重点是记录我在解题过程中遇到的一些问题以及思考。
1. 题目解析
题目给了四个条件:赛车数量n
、运行时间t
、赛车的初始位置p[i]
和初始速度v[i]
。我们需要求解经过t
个单位时间后,有多少赛车的排名发生了上升。
整理基本思路:
- 求赛车经过
t
个单位时间后的位置; - 利用新位置,求赛车经过
t
个单位时间后的排序; - 比较新排序与旧排序,求出排序上升的赛车的数量。
2. 思路解析
既然涉及到数组比较,那就非常顺理成章地想到使用排序算法。这里选择使用快速排序(也可以使用归并排序、堆排序等,不考虑时间复杂度的话也可以使用简单选择排序、插入排序、冒泡排序、希尔排序等)。
- 原
p[i]
数组可以直接反映出原始排名,因此直接定义order[i]
来标记赛车的原始排名; - 使用快速排序对
t
个单位时间后的位置进行排序,同时对order[i]
进行相应的对应排序,对快速排序算法略微修改即可; - 利用新的
p[i]
,求新的排名数组newOrder[i]
; - 比较
order[i]
和newOrder[i]
,计数并返回排名上升的赛车数。
3. 代码实现
先写快速排序的函数,加入order[i]
相关的部分:
public static void quickSort(int[] p, int start, int end, int[] order) {
if (start >= end)
return;
int pivot = p[start]; // 选择首元素作为排序的基准
int pivot_o = order[start];
int left = start;
int right = end;
while (left < right) {
while (left < right && p[right] > pivot) {
right--;
}
while (left < right && p[left] <= pivot) {
left++;
}
if (left < right) {
int temp = p[left];
p[left] = p[right];
p[right] = temp;
int temp_o = order[left];
order[left] = order[right];
order[right] = temp_o;
}
}
p[start] = p[right];
p[right] = pivot;
order[start] = order[right];
order[right] = pivot_o;
quickSort(p, start, left - 1, order);
quickSort(p, right + 1, end, order);
}
注意:该代码中两个while
缩小边界的部分,必须先缩小右边界,且与基准值比较时的=
条件一定要放到左边界检查中,原因会在后文细说。
剩下就是完成solution
代码,按照思路解析实现即可:
public static int solution(int n, int t, int[] p, int[] v) {
int[] order = new int[n]; // 原排位数组
for (int i = 0; i < n; i++) {
order[i] = i; // 为了便于编写,规定数值高的排位高
}
for (int i = 0; i < n; i++) {
p[i] += v[i] * t; // 求新的位置数组
}
quickSort(p, 0, n - 1, order); // 快速排序
int[] newOrder = new int[n]; // 新排位数组
for (int i = 0; i < n; i++) {
if (i != 0 && p[i] == p[i - 1]) newOrder[i] = newOrder[i - 1]; // 根据题目要求,位置相同的赛车排位也相同
else newOrder[i] = i;
}
int mem = 0; // 排位上升的赛车计数
for (int i = 0; i < n; i++) {
if (newOrder[i] > order[i]) // 若新排位>原排位,说明该赛车排位上升,计数
mem++;
}
return mem;
}
根据测试用例分析,排位相同的赛车取低位,其余赛车正常排序(例如样例3,新位置数组经排序后为{ 8, 17, 17, 18 },则对应的排位数组为{ 0, 1, 1, 3 })。
4. 性能分析
4.1 时间复杂度
本例中,时间复杂度主要分两部分进行分析:
quickSort()
:快速排序每次递归都将规模缩小 ,设经过 次递归后到达递归出口,即 ,解得 ,即需要 的时间,而每个规模内遍历排序所需的时间为 ,因此快速排序算法的时间复杂度为 ;solution()
:仅存在 规模的for
循环。
综上,该算法的时间复杂度为 。
4.2 空间复杂度
空间复杂度同样分两部分进行分析:
quickSort()
:递归调用所需的栈空间大小为 ,其余均为常数级变量;solution()
:数组order[n]
和newOrder[n]
所需空间规模为 ,其余均为常数级变量。
综上,该算法的空间复杂度为 。
5. 一些问题与思考
实际上,我的解题过程并没有那么顺利,其中遇到了一些有趣的问题,有的经过我查阅资料等成功解决,有的仍未理解,故在此留下记录。
5.1 关于本文中的快速排序算法
一开始我是打算凭着之前的印象手撕快排的,但很不幸失败了,于是还是查了一下,然后由于习惯问题,关于两个while
缩短边界的部分,最初我是这样写的:
while (left < right && p[left] < pivot) {
left++;
while (left < right && p[right] > pivot) {
right--;
}
是的,很粗心地没有=
条件的判断,于是进入了死循环,当然这个debug一下就发现问题然后解决了。但紧接着新的问题又来了:不论=
条件的判断加在哪个边界的while
中,都会导致最后的结果出现问题:本应进行排序的数组会出现有的元素消失,而有的元素重复出现的情况。
于是我又开始打断点调试,走完一轮排序后发现基准元素并没有出现在它该出现的位置上(快速排序的每一轮都会将本轮的基准元素放到它应在的位置上),但我不知道为什么,并没有看出问题和规律。
抱着疑问,我询问了AI,它的回答瞬间让我醍醐灌顶:
- 如果先执行
while (left < right && p[left] <= pivot) { left++; }
,那么left
指针可能会越过pivot
值,导致left
指针左侧有大于pivot
的元素。 - 接着执行
while (left < right && p[right] > pivot) { right--; }
时,right
指针可能会越过pivot
值,导致right
指针右侧有小于或等于pivot
的元素。
结合循环条件里还要检查left
和right
是否越界,第一个问题便有了答案:=
条件的检查要放到第二个while
里,由第一个不含=
条件的while
进行单边缩界,可以保证第一个检查不会越界,而第二个检查则借助left < right
的检查来保证其同样不会越界。
好的,想通这个问题后再来运行,这次应该过了吧————
当然不是。
元素消失与重复的问题并没有解决。
这下懵了,因为我实在找不出第二个可能有问题的地方了,对着代码仔细地反复检查,仍然没有新的发现。
没办法,再调试看看吧,说不定能看出什么来吧,没想到还真发现了——首元素(基准元素)在最后一次归位之前就被换位了,那么自然最后的归位也就不是预想的结果了。
于是一切便通透了——因为选择了首元素作为基准元素,所以这个元素在归位之前是不能动的,左边界的>=
则恰好完成了这个工作,在第一次缩边检查的时候就跳过了首元素。
那么按理来说,如果非要交换左右边界检查的顺序,先对左边界进行检查,是不是应该选择末元素作为基准元素呢?
答案还真是,试了一下能够正常运行。
至此,问题解决。经过这一次小插曲,我对快速排序代码的理解又多了一分提升。
5.2 关于排位
关于新排位的遍历比较,最初我并没有用新的排位数组,而是直接将排序后的排位数组与i
进行比较。理论上来说这没问题,因为其本质和新排位数组的比较思想是一样。
但偏偏就是这样的比较方式出现了问题:某些用例中,在一切数据都正常的情况下,排位变动的赛车数目并不符合预期。
观察后发现,这些用例都是新位置数组中包含重复元素的用例(即存在排位相同的赛车),马上意识到了问题所在,于是用了新的比较方式,用符合题意的排位数组来检查,答案就正确了。
但重点在下文:最初我手撕快排的时候,因为记忆不清,给算法打了这么一个补丁:
if (p[right] < pivot) {
p[start] = p[right];
p[right] = pivot;
order[start] = order[right];
order[right] = pivot_o;
}
即对基准元素和对应位置的元素进行比较,只有基准元素较大才会移动。
然而我第一次通过提交的代码,就是保留了这一个补丁,并且没有考虑重复元素的版本。
其效果相当于:对于排位相同的元素,保留其原本的相对次序。
为什么这个条件也可以通过提交?这点是我暂时没有想通的。