前言:
我从大一下学期开始接触算法,从基础排序到现在,LeetCode、洛谷前前后后刷了六百多道题目,前前后后也打过几场算法比赛,蓝桥杯走到了国赛,也算有一点点成绩。目前准备上大二,在牛客的周赛里碰见了一道很有思维量的题目(如标题),至少对于我这种水平一般的人来说。如果你也跟我一样,那么我觉得你可以看看这道题目,这道题目从暴力解法到优化通过,再到通过数学证明极快通过这道题,每一步都让我醍醐灌顶、茅塞顿开。话不多说,跟着我的思路来吧。
题目: 登录—专业IT笔试面试备考平台_牛客网牛客网是互联网求职神器,C++、Java、前端、产品、运营技能学习/备考/求职题库,在线进行百度阿里腾讯网易等互联网名企笔试面试模拟考试练习,和牛人一起讨论经典试题,全面提升你的技术能力
https://ac.nowcoder.com/acm/contest/89510/E思考:
看到题目,发现题目递推式 a[i] = a[i + 1] mod i (1 <= i < n),不难推出,要从数组的后面往前面推,这是基于模运算的性质。于是不难想到,我们可以将nums数组的最后一位设成0到n,每一次设置都根据递推式往前设置到第1位,看每一次设置不同的数字个数有没有到达m,如果到达m了,答案数目cnt++。不难看出,只要我们每个结尾都尝试一次,我们必然能够得到所有结果,于是这个方法的正确性是必然的,但是不可避免的就是时间复杂度较高,如果测试组数是t,数组长度是n,那么时间复杂度就是O(t * n ^ 2),于是我们很容易可以写出代码,但是也很容易超时。
#include <iostream>
#include <set>
using namespace std;
int judge(int n, int m) {
int* nums = new int[n + 1];
int cnt = 0;
for (int choose = 0; choose <= n; choose++) {
nums[n] = choose; //将最后一位设置成choose
set<int> collections; //collections用于去重,因为set本身会去重
collections.insert(choose); //最后一位数字,当然是第一个不同的数字
for (int i = n - 1; i >= 1; i--) {
nums[i] = nums[i + 1] % i; //递推式求值
collections.insert(nums[i]); //直接放进去,set会帮你去重
}
if (collections.size() >= m) {
cnt++; //答案符合条件,cnt++
}
}
return cnt;
}
int main() {
int t;
cin >> t;
while (t--) {
int n, m;
cin >> n >> m;
cout << judge(n, m) << endl;
}
return 0;
}
恭喜你超时了,但同时恭喜你也离成功更进一步。如果我们再看一眼递推式,会发现整个递推式只涉及到两个变量,通俗点来说,就是这个值、下一个值、这个值的角标,于是我们又会想,这个数组nums是必要的吗?于是我们打算用一个变量来优化掉这个数组。于是我们完成了空间压缩,但时间复杂度任然没降级,于是还是超时了。
#include <iostream>
#include <set>
using namespace std;
int judge(int n, int m) {
int cnt = 0;
for (int choose = 0; choose <= n; choose++) {
int num = choose; //直接记录数值,而不用数组去记录
set<int> collections;
collections.insert(num);
for (int i = n - 1; i >= 1; i--) {
num = num % i; //更新这个num,跟之前数组表示效果一样
collections.insert(num);
}
if (collections.size() >= m) {
cnt++;
}
}
return cnt;
}
int main() {
int t;
cin >> t;
while (t--) {
int n, m;
cin >> n >> m;
cout << judge(n, m) << endl;
}
return 0;
}
OK啊,结束了吗?难道不能优化了吗?走到这了你决定放弃了吗?每次我们超时,我们都应该去分析为什么超时,进而想想能不能优化思路,而不是放弃了就万事大吉了,因为下次你碰到你依旧超时,你依旧没有分析问题的能力,这也是我写这篇文章的原因。回到judge函数,我们发现我们把从0到n的每个数都试了一遍,看可不可以,这个过程可不可以优化?
试想一下,当n固定的时候,如果让你选一个数值,我们怎么选才能尽可能让不同的数字数量超过m呢?当然是选更大的数字,因为在模数固定的情况下,更大的数字取模后能剩余更多,甚至是直接变成0了,而更小的数字只会让结果更少,于是我们就想,答案会不会具有单调性?会不会在一个数值过后,所有的大于它的数值都能使得不同的数字数目大于等于m,而在这个数字之前,所有的小于它的数值都能够使得不同的数字数目小于m?答案是会的,只需要打个表看看正确答案的区间就可以了(其实当你打出表的时候,你就可以猜到,这个题目是可以直接用数学做法做出来的,而不需要模拟),于是我们想到了二分答案法。
#include <iostream>
#include <set>
using namespace std;
bool check(int choose, int n, int m) { //check函数也就是跟之前一样,看一个choose下能不能获得m个不同的数字
int num = choose;
set<int> collections;
collections.insert(num);
for (int i = n - 1; num != 0 && i >= 1; i--) {
num = num % i;
collections.insert(num);
}
return collections.size() >= m;
}
int judge(int n, int m) {
int left = 0, right = n, mid, ans = -1;
while (left <= right) {
mid = (left + right) >> 1;
if (check(mid, n, m) == true) { //如果可以,尝试更小的数
right = mid - 1;
ans = mid;
} else { //如果不行,尝试更大的数
left = mid + 1;
}
}
return ans == -1 ? 0 : n - ans + 1; //正解就是n - ans + 1,但前提是有正解
}
int main() {
int t;
cin >> t;
while (t--) {
int n, m;
cin >> n >> m;
cout << judge(n, m) << endl;
}
return 0;
}
这个时候时间复杂度就打打降低了,直接从O(t * n * n)变成了O(t * logn * n),但是很不幸,这个复杂度还是过高,无法通过这道题。怎么办呢?临门一脚了!就差一点了!什么地方超时了呢?竟然枚举已经很快了,那么我们就考虑check函数,发现check函数还是不可避免的枚举了这个数组的所有位置,但是这个枚举有必要吗,试想一下,如果你在100000的下标时,num已经变成1了,那么这次循环就需要一直跑到1为止,才有可能出现新的不同的数字,为什么?因为1取模于任何比他大的数字都是1,所以这个枚举就是不必要的。于是我们想到了怎么优化,如果出现了当前数字小于下标的情况,也就是num < i的时候,我们直接让i跳到num的位置,我们便急速优化了这个for循环的时间(事实上,这个for循环执行的次数不会超过四次),于是我们完成了优化,看看最终代码吧。
#include <iostream>
#include <set>
using namespace std;
bool check(int choose, int n, int m) {
int num = choose;
set<int> collections;
collections.insert(num);
for (int i = n - 1; num != 0 && i >= 1;) {
if (num < i) {
i = num; //如果发现当前数据小于角标,那么直接跳过去
} else {
num = num % i;
collections.insert(num);
i--; //如果并不是,那么取模后i--即可
}
}
return collections.size() >= m;
}
int judge(int n, int m) {
int left = 0, right = n, mid, ans = -1;
while (left <= right) {
mid = (left + right) >> 1;
if (check(mid, n, m) == true) {
right = mid - 1;
ans = mid;
} else {
left = mid + 1;
}
}
return ans == -1 ? 0 : n - ans + 1;
}
int main() {
int t;
cin >> t;
while (t--) {
int n, m;
cin >> n >> m;
cout << judge(n, m) << endl;
}
return 0;
}
于是我们很愉快的,也很茅塞顿开地通过了这道题,这也是我做这道题的思路。时间复杂度是多少呢其实是O(t * logn),你会问为什么,为什么优化了跳动的过程会直接把n给优化没了,其实是因为跳动的过程实在很多很大,这个跳动过程会直接跳到很小的位置,以至于这个循环几乎就是常数时间,可以忽略。
你以为到这里就结束了吗,其实不然,这场比赛结束后,我看到了一篇题解,这篇题解告诉我当这个m>3的时候是无解的,当m == 3的时候只有一个解,当m == 2的时候是有n - 1个解的,而当m == 1 || m == 0的时候,同样只有一个解,顿时我感觉茅塞顿开,为什么加上跳跃优化的过程可以急速优化时间,是因为可以很快跳到3位置,于是这个for循环几乎就是常数时间。
结语:
即使我的解法不是最优解,我还是想把这道题分享给大家,分享给跟我一样对算法有所热爱的,水平正在慢慢提高的同学们。优化的过程本身不难,难在怎么想出这个优化过程,这个过程必不可少,是我们不断成长的最好粮食。直接想出最优解似乎是不太现实的,更加现实的且有意义的是不断优化自己想法的过程,这个过程能利用你之前学习到的知识,同时也能为你带来源源不断的信心,当你以后遇到超时的题目时,想想怎么优化,也许就能过了呢,如果过不了,想想是不是优化错了呢,还是题目本身就不能这样解。我觉得这个过程是很重要的,这个过程比AC一道题目有意义得多,这也是我写这篇题解的原因。
最后,谢谢你能看到这里,这不只是对我的一种支持,更是我的动力来源,希望大家能在算法的道路上越走越远,拿到自己满意的offer!!!