牛客Round_58_E_好好好数组(一道好题)

前言:

        我从大一下学期开始接触算法,从基础排序到现在,LeetCode、洛谷前前后后刷了六百多道题目,前前后后也打过几场算法比赛,蓝桥杯走到了国赛,也算有一点点成绩。目前准备上大二,在牛客的周赛里碰见了一道很有思维量的题目(如标题),至少对于我这种水平一般的人来说。如果你也跟我一样,那么我觉得你可以看看这道题目,这道题目从暴力解法到优化通过,再到通过数学证明极快通过这道题,每一步都让我醍醐灌顶、茅塞顿开。话不多说,跟着我的思路来吧。

题目: 登录—专业IT笔试面试备考平台_牛客网牛客网是互联网求职神器,C++、Java、前端、产品、运营技能学习/备考/求职题库,在线进行百度阿里腾讯网易等互联网名企笔试面试模拟考试练习,和牛人一起讨论经典试题,全面提升你的技术能力icon-default.png?t=N7T8https://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!!!

  • 11
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值