• 二分与平方根求法


    此博客原文地址:https://www.cnblogs.com/BobHuang/p/12654026.html
    Bob老师遇到了3439: 完全平方数这个题目,他现在没啥想法。他选择去循环变量i从0到n看看是不是可以得到答案,于是写下了如下代码:

    #include <bits/stdc++.h>
    using namespace std;
    
    int main()
    {
        int n;
        //读入n
        while (cin >> n)
        {
            //如果n为-1,结束循环
            if (n == -1)
                break;
            //定义一个旗子,初始未插上。即n不是完全平方数
            int flag = 0;
            for (int i = 0; i <= n; i++)
            {
                //枚举的i等于n了结束循环
                if (i * i == n)
                {
                    //插上旗子,表示n是完全平方数
                    flag = 1;
                    break;
                }
            }
            //分存在和不存在的情况
            if (flag == 0)
                cout << "No
    ";
            else
                cout << "Yes
    ";
        }
        return 0;
    }
    

    但是他悲伤得获得了TLE(Time Limit Exceeded),因为如果一个数不是完全平方数循环就要执行n+1次,如果是10^9^那么就要执行0^9^+1次,太慢了不能满足这个题目的要求。我们可以用大写字母O来表示时间复杂度,可以粗略的理解就是循环的次数,上述做法的算法复杂度是O(n),即线性阶(1和n比起来很小,被我们舍去了)。还有T组,那么总复杂度为O(T*n),接下来我们将优化这个做法。

    一、做一些微小的优化

    Alice和Bob说,你这也太傻了,有些循环完全不用运行,既然你知道i * i == n可以结束循环,那不是的时候什么时候可以循环呢。
    如果是8,1 * 1 = 1 ;2 * 2 = 4;3 * 3 = 9。9已经比8大了,在找下去肯定也没有啊。Bob把下面的程序添了一个if,就通过了题目。现在我们的算法复杂度是O(T*sqrt(n)),也就是10^9^的循环我只需要31624次,因为31623 * 31623 > 10^9^。

    #include <bits/stdc++.h>
    using namespace std;
    
    int main()
    {
        int n;
        //读入n
        while (cin >> n)
        {
            //如果n为-1,结束循环
            if (n == -1)
                break;
            //定义一个旗子,初始未插上。即n不是完全平方数
            int flag = 0;
            for (int i = 0; i <= n; i++)
            {
                //枚举的i等于n了结束循环
                if (i * i == n)
                {
                    //插上旗子,表示n是完全平方数
                    flag = 1;
                    break;
                }
                //肯定不存在了,也要结束循环
                if (i * i > n)
                {
                    break;
                }
            }
            //分存在和不存在的情况
            if (flag == 0)
                cout << "No
    ";
            else
                cout << "Yes
    ";
        }
        return 0;
    }
    

    哎呦,不错哦。如果让你求i*i<=n的i最大值呢,那我循环的时候统计ans不就好了。

    #include <bits/stdc++.h>
    using namespace std;
    
    int main()
    {
        int n;
        //读入n
        while (cin >> n)
        {
            //如果n为-1,结束循环
            if (n == -1)
                break;
            //定义答案,如果不存在为-1
            int ans = -1;
            for (int i = 0; i <= n; i++)
            {
                //枚举的i要结束了
                if (i * i > n)
                {
                    break;
                }
                //答案就是当前的i
                ans = i;
            }
            //输出答案
           cout << ans << "
    ";
        }
        return 0;
    }
    

    如果n很大,那么sqrt之后依旧很大,比如10^18^,还需要10^9^次,有没有更好的做法呢。

    二、二分优化

    Bob翻了翻自己的笔记很快想到了可以二分进行求解,他可以在log2(n)次解决,也就是1024的话10次,1024*1024只要20次,1e9也只要30次。为什么可以进行二分,他是否满足二分特性呢?
    y=x^2^ 的函数图像如下所示:

    我们需要的是y值,在[0,+∞]为单调递增函数,满足单调性,所以可以二分。如果你要找[-2,100]就不能二分了,因为区间不单调,但是可以转换[-2,0]和[0,100]两段,如果是y=ax^2^+bx+c进行求y=3,左右是完全对称的,所以我们求任何一侧均可。
    我们可以使用二分去解决这个问题,首先我们可以确定二分区间为0到n,然后就是找到一个点,判断在其左边还是右边的问题了。可以写出如下函数:

    int HalfCalSqrt(int n)
    {
        //有可能<,也有可能=,ans用来存储<的
        int L = 0, R = n, ans=-1;
        //为什么有=,因为我们二分为闭区间
        while (L <= R)
        {
            int Mid = (L + R) / 2;
            if (Mid * Mid == n)
            {
                //正好为完全平方数返回
                return Mid;
            }
            else if (Mid * Mid < n)
            {
                //答案可能在右边,也可能就是Mid
                ans = Mid;
                //继续搜索[Mid+1,R]
                L = Mid + 1;
            }
            else
            {
                //答案在左边,继续搜索[L,Mid-1]
                R = Mid - 1;
            }
        }
        return ans;
    }
    

    但是输入1e9会发生什么呢,你会发现自己Mid*Mid为负数,你需要使用变量类型long long或者直接给R初始值4e4。不过二分的时候(L+R)/2还是有其他潜在bug,L+R有可能超出当前范围,成熟的OIer和ACMer会使用L+(R-L)/2来防溢出。

    三、牛顿迭代优化

    了解过一点微积分的同学可能想用牛顿迭代来做,高次方程没有通解,往往会使用牛顿迭代来做。这里有比较详细的牛顿迭代介绍。牛顿迭代得到的式子为

    [x_{n+1}=x_{n}-{frac {f(x_{n})}{f'(x_{n})}} ]

    这这个题目f'(xn)指的是斜率,二次函数的切线方程为y=2x。所以这个式子为

    [x_{n+1}=x_{n}-{frac {x^2-n}{2x_{n}}} ]

    然后我们可以写出如下代码

    int NewtonMethod(int n)
    {
        //选择x=0和x=1的前后两项
        int pre = 0, nxt = 1;
        while (true)
        {
            /*
            xn+1=xn-(xn^2-n)/(2xn)
            也就是后一项为当前项减去(当前点的值)/(当前点的导数)
            即nxt+(nxt*nxt-n)/(2*nxt)
            化简之后如lst所示
            */
            int lst = (nxt + n / nxt) / 2;
            //已经收敛
            if (lst == pre)
            {
                return pre;
            }
            //进行迭代
            pre = nxt;
            nxt = lst;
        }
    }
    

    四、浮点数改造

    当然如果用小数就可以少用一个变量,小数也能正确。当然有精度误差。你之后再取整可能会出问题,你可以传入x的时候+0.5,二分也可以这样。

    double FloatNewtonMethod(int x)
    {
        //0进行特殊处理,当然负数也无法处理
        if(x==0)return 0;
        //取x=0和x=1这两项
        double pre = 0, nxt = 1;
        //迭代还没结束,当然也可以用eps进行比较
        while (nxt != pre)
        {
            pre = nxt;
            //进行迭代
            nxt = (nxt + x / nxt) / 2;
        }
        return nxt;
    }
    

    当然二分也可以用小数,但是最终的比较可能会有问题。因为浮点数不能精确的表示,double的有效位数是15~16位。所以我们可以使用eps来控制结束

    const double eps = 1e-8;
    double FloatHalfCalSqrt(double n)
    {
        double L = 0, R = n;
        //二分开区间(L,R],最终L和R"相等"。
        while (fabs(L - R) >= eps)
        {
            double Mid = (L + R) / 2;
            if (Mid * Mid >= n)
            {
                //答案在右边,继续搜索(Mid,R]
                R = Mid;
            }
            else
            {
                //答案在左边,继续搜索(Mid+1,R]
                L = Mid;
            }
        }
        return L;
    }
    

    当然也可以通过二分次数,不断地除以2就类似于进制转换,很快就会除完。比如1e9(二进制长度为log2(1e9)=30位)连续除以2进行32次就可以精确到0.25了。二分次数也是一个很常用的做法,因为上面的代码时间复杂度在大数的时候会出问题,不是一定的。

    double FloatHalfCalSqrt(double n)
    {
        double L = 0, R = n;
        //二分100次,足够精确
        for(int i=0;i<100;i++)
        {
            double Mid = (L + R) / 2;
            if (Mid * Mid >= n)
            {
                //答案在右边,继续搜索
                R = Mid;
            }
            else
            {
                //答案在左边,继续搜索
                L = Mid;
            }
        }
        return L;
    }
    

    如果n的数据扩充到long long呢,你会发现这个代码炸了,因为两数相乘还是double,double的有效位数只有15~16位,但是9e18是19位,所以二分求出浮点数然后在取整的方法不灵了。牛顿迭代的比较为值的比较,牛顿迭代依旧可以使用。

    五、雷神之锤3的强大代码

    Carmack在QUAKE3(雷神之锤3)中使用了下面的算法,它第一次在公众场合出现的时候,几乎震住了所有的人。

    float Q_rsqrt( float number )
    {
        long i;
        float x2, y;
        const float threehalfs = 1.5F;
    
        x2 = number * 0.5F;
        y   = number;
        i   = * ( long * ) &y;   // evil floating point bit level hacking
        i   = 0x5f3759df - ( i >> 1 ); // what the fuck?
        y   = * ( float * ) &i;
        y   = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration
        // y   = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
        #ifndef Q3_VM
        #ifdef __linux__
             assert( !isnan(y) ); // bk010122 - FPE?
        #endif
        #endif
        return y;
    }
    

    0x5f3759df这个十六进制值是不是很nb,的确是一个超级的Magic Number(魔法值)。Carmack简直是神人,经过大家的测试,这个确实比math里的sqrt更优秀。
    值得注意的是,在Chris Lomont的演算中,理论上最优秀的常数(精度最高)是0x5f37642f,并且在实际测试中,如果只使用一次迭代的话,其效果也是最好的。但奇怪的是,经过两次牛顿迭代后,在该常数下解的精度将降低得非常厉害。经过实际的测试,Chris Lomont认为,最优秀的常数是0x5f375a86。如果换成64位的double版本的话,算法还是一样的,而最优常数则为0x5fe6ec85e7de30da(又一个非常厉害的Magic Number)。
    用这个魔法值我们可以得到另一个做法,因为雷神之锤3的并不那么完美。

    double Sqrt(float x)
    {
        double xhalf = 0.5 * (double)x;
        //得到x的二进制位
        int i = *(int *)&x;
        //初始化猜的xn
        i = 0x5f375a86 - (i >> 1);
        //把它的二进制为转换为float
        x = *(float *)&i;
        //牛顿迭代, 反复进行提高精确度
        x = x * (1.5f - xhalf * x * x); 
        x = x * (1.5f - xhalf * x * x);
        x = x * (1.5f - xhalf * x * x);
        return 1.0 / (double)x;
    }
    

    六、内置开平方函数

    内置开平方函数为sqrt(),参数为浮点型,返回值也为浮点型。c++11前的参数支持的类型为float和double两种。c++11的类型更丰富。C++调用的是__builtin_sqrtf(x)其实他对应的是是一条内置的CPU指令,我们可以汇编出来,C语言编译出来的为

    call    _sqrt
    

    C++编译出来的为

    call    __ZSt4sqrtIiEN9__gnu_cxx11__enable_ifIXsrSt12__is_integerIT_E7__valueEdE6__typeES3_
    

    C++还是调用了call _sqrt ,所以其实C++编译要比c语言更慢。如果比如使用了unsinged long long类型,不使用c++11就会出现不支持,导致wa。

    七、时间复杂度及时钟周期数比较

    时间复杂度

    1. 及时break sqrt(n)
    2. 二分 log(n)
    3. 牛顿迭代 log(n),运行次数比二分少
    4. 倒平方 常数级别
    5. math函数 常数级别 指令

    时钟周期比较,我用如下代码进行测试。我的CPU是2.3 GHz 双核Intel Core i5-7360U,两核心四线程,动态加速频率为3.6GHz。

    #include <bits/stdc++.h>
    #include <sys/time.h>
    using namespace std;
    const int N = (1 << 28);
    long long a[N];
    inline long long getCurrentTime()
    {
        //用于获取系统微秒时间
        struct timeval tv;
        gettimeofday(&tv, NULL);
        return tv.tv_sec * 1000000 + tv.tv_usec;
    }
    double Sqrt(double x)
    {
        return sqrt(x);
    }
    int main()
    {
        srand(time(NULL));
        //2.3GHz*10^9/10^6,得到每微秒的时钟周期数
        long long t_c = 2300;
        for (int k = 0; k <= 28; k += 2)
        {
            long long v = 1 << k;
            for (int i = 0; i < v; ++i)
            {
                a[i] = rand()*1LL*rand();
            }
            long long t1 = getCurrentTime();
            for (int i = 0; i < v; ++i)
            {
                long long j = Sqrt(a[i]);
            }
            long long t2 = getCurrentTime();
            cout << "time:" << v << "	" << t2 - t1 << " us"
                 << "	clocks:" << t_c * 1. * (t2 - t1) / v << '
    ';
        }
        return 0;
    }
    

    每次调用时钟周期数

    1. 二分 340个时钟周期
    2. 牛顿迭代 1086个时钟周期
    3. 倒平方 常数级别 0.7个时钟周期
    4. math函数 4个时钟周期

    倒平方很快很快,Carmack太神了,但是这个0.7个时钟周期让我百思不得其解,我猜测这个牛顿迭代的过程可能被cpu优化得比较厉害,比如多核、缓存、超级流水线技术。虽然牛顿迭代运行次数与二分相比较少,但是由于二分一直再除2,可能被优化了,所以常数比较小,跑出来比牛顿迭代还要快。不过在大数里可能就不一定,Java大数用二分写可能会卡常(卡常数)。
    上面可能是C++的编译器被优化了,我用C重新测试的每次调用时钟周期数如下所示:

    1. 二分 598个时钟周期
    2. 牛顿迭代 1246个时钟周期
    3. 倒平方 常数级别 68个时钟周期
    4. math函数 15个时钟周期
  • 相关阅读:
    数据结构(四十)平衡二叉树(AVL树)
    数据结构(三十九)二叉排序树
    数据结构(三十八)静态查找表(顺序查找、二分查找、插值查找、斐波那契查找、线性索引查找)
    数据结构(三十七)查找的基本概念
    数据结构(三十六)关键路径
    数据结构(三十五)拓扑排序
    数据结构(三十四)最短路径(Dijkstra、Floyd)
    数据结构(三十三)最小生成树(Prim、Kruskal)
    字符串匹配算法之KMP
    最长公共子序列(Longest common subsequence)
  • 原文地址:https://www.cnblogs.com/BobHuang/p/12654026.html
Copyright © 2020-2023  润新知