问题描述
小红一共有 n 个盒子,标号为 1 到 n,小红向盒子里放入小球 m 次,每次进行以下两个操作中的一个:
1. 向编号为 x 的盒子里放入一个小球;
2. 向除了编号为 x 的其他 n−1 盒子里放入一个小球。
小红想知道,第几次操作之后,所有盒子里至少都有一个小球,如果一直无法达到这个目标,输出 −1。输入描述:
第一行两个整数n和m,表示盒子的数量和操作的次数。
接下来的m行,每行两个整数t和x,表示第i次操作的类型和x的值。
输出描述:
输出一个整数,表示第几次操作之后,所有盒子都至少有一个小球,如果一直无法达到这个目标,输出-1.
示例1:
输入例子:3 3
1 1
1 2
1 3
输出例子:3
例子说明:三次操作之后,所有盒子里都至少有一个小球。
问题分析
这道题目是美团2024届秋招笔试第三场编程真题的第二题,其实刚看到这题略作思考便可以想到一个大致可行的方法,正常思维就是构建一个记录数组用于判断当前盒子有没有小球,然后遍历每一个操作,如果操作代号是1,那么就将对应的记录数组设置为true;如果操作代号为2,那么就将记录数组的除该位置的其他位置全部设置为true(当然也可以判断一下当前是不是true),然后每次遍历操作都需要计算一下记录数组里是否全部为true。如果是,那么打印当前操作次数,结束循环;如果不是,继续运行,如果最后都没有使得记录数组全部是true,打印-1。
以上是本人一开始想到的方法,代码编写完毕之后,发现基本过不了多少测试用例,我们来看看它 的时间复杂度。首先外层循环遍历所有操作m次,这边就是O(m)的时间复杂度,然后内层循环,每次遇到操作为2都需要遍历一遍长度为n的数组,这边又是内部的O(n)的时间复杂度,总体上来看是O(m×n)的时间复杂度,因此基本上过不了什么测试。后续我也是在此基础上作了些优化,但是都是治标不治本,不能改变总体时间复杂度。
由此可知,时间复杂度O(m×n)是行的,需要从内层循环入手,因为外部的O(m)一般来说是不会变了。我考虑是否能够采用一个数据结构记录下曾经处理过得盒子,我又想到这边是两个操作,因此我引入了两个HashSet来分别记录两个操作所处理得盒子,我把此方法称为双HashSet法。
问题求解-双HashSet法
算法描述
我们从题目发现,操作2是将除了当前盒子不放之外,其余都放,那么我们可以在首次遇到2操作的时候将没有球的盒子记下来,如果再次遇到操作2,只要这次的盒子和记下来的盒子不一致,就结束循环了;如果出现操作1的时候,倘若记录操作2的集合中包含当前盒子,说明操作1恰好将操作2忽略的盒子补上了,此时也结束循环;如果未能遍历到操作2,那么就将记录操作1的集合中加入盒子。在此处,我们引入了一个变量k,用于表示到当前操作为止,一共有多少个盒子非空。hashset1用于记录操作1处理的盒子,意思是哪些盒子里被放过球;hashset2用于记录操作2处理的盒子,意思是哪个盒子被跳过放球。
代码实现
public class DropBox {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 注意 hasNext 和 hasNextLine 的区别
int n = 0;
int m = 0;
while (in.hasNextInt()) { // 注意 while 处理多个 case
n = in.nextInt();
m = in.nextInt();
break;
}
int count = 1;
int flag = 0;//用于判断是否能在输入给定的操作数内完成目标。
HashSet<Integer> hashset1 = new HashSet<>();//记录操作1处理过的盒子,意思是哪些盒子里被放过球。
HashSet<Integer> hashset2 = new HashSet<>();//记录操作2处理过的盒子,意思是哪些盒子被跳过放球。
int k = 0;//变量k,表示这次操作之后,有多少盒子里至少有一个球。
int inFlag = 0;//用于判断是否出现过操作2.
while (count<=m) {
int t = in.nextInt();
int x = in.nextInt();
if (t == 1 && !hashset1.contains(x - 1)) {//操作1处理逻辑
if (inFlag == 1 && hashset2.contains(x - 1)) {//曾经出现过操作2并且被跳过的盒子这次正好补上了,结束循环。
k++;
flag = 1;
System.out.println(count);
break;
} else if (inFlag == 0) {//曾经没有出现操作2
k++;
}
hashset1.add(x - 1);
} else if (t == 2) {//操作2处理逻辑
inFlag = 1;
if (!hashset2.isEmpty() && !hashset2.contains(x - 1)) {//hashset2非空并且上次操作2处理的盒子和这次不同,结束循环。
flag = 1;
System.out.println(count);
break;
} else if (hashset1.contains(x - 1)) {//曾经出现的操作1补上了这次被跳过的盒子,结束循环。
flag = 1;
System.out.println(count);
break;
} else {//hashset2为空或者上一次操作2处理的是相同盒子,将k设置为n-1.
hashset2.clear();
hashset2.add(x - 1);
k = n - 1;
}
}
if (k == n) {//在遍历完一个操作时判断一下k是否等于n。
flag = 1;
System.out.println(count);
break;
}
count++;
}
if (flag == 0)//不能在给定的操作之下完成目标。
System.out.println(-1);
}
}
复杂度分析
时间复杂度
首先是遍历所有操作的时间复杂度,为O(m);内层循环中,我们引入了HashSet,其中contains()和add()方法的时间复杂度一般情况下为O(1),clear()方法理论上时间复杂度为O(n),但是我们在处理的时候hashset2中的元素不大于1,因此这部分时间复杂度也为O(1),因此算法的时间复杂度为O(m),其中m为操作的总数。
空间复杂度
我们引入了HashSet,在最坏情况下,所有的操作都是1操作,那么需要将所有盒子全部放入HashSet,因此空间复杂度最坏为O(n),其中n为盒子的总数。
总结
首先来说一个可以优化的地方,这边的hashset2可以直接换成一个整型变量z即可,初始化可以为-1,我们可以发现操作为2的逻辑中的hashset2的size大小最多为1,因此判断hashset2是否为空的操作可以转换为判断z是否为-1,判断是否包含的逻辑可以转换为判断z和当前的盒子编号是否相等,后面的clear()和add()直接可以换成z=x-1,意思是将当前跳过的盒子编号赋值给z,替代了上述的两个操作,具体代码修改细节在此处不再赘述。
其次,是关于思路的拓展,一个问题想到暴力算法很容易,想继续优化下去的前提有时候确实是在想到了暴力算法的基础上进行的。当然这次的前后两个算法基本上不太相关,一直在原来的O(m×n)的算法上作修改,始终跳不出总体复杂度的枷锁,因此开辟新道路也是很重要的,这有点不破不立的意思(EDG NB)。这也与我们的人生形成了呼应,身处囹圄,不如打破现有固执。、
最后,这个算法也只是我自己的一个想法,可能还有些缺陷,可能还有能优化改进的地方,这也是我将此分享出来的原因,希望大家能够提出宝贵的意见!!!