关于c#:如何改进这个平方根方法?

How can I improve this square root method?

我知道这听起来像是一个家庭作业,但事实并非如此。最近,我对用于执行某些数学运算的算法很感兴趣,例如正弦、平方根等。目前,我正在尝试用C语言编写计算平方根的巴比伦方法。

到目前为止,我有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static double SquareRoot(double x) {
    if (x == 0) return 0;

    double r = x / 2; // this is inefficient, but I can't find a better way
                      // to get a close estimate for the starting value of r
    double last = 0;
    int maxIters = 100;

    for (int i = 0; i < maxIters; i++) {
        r = (r + x / r) / 2;
        if (r == last)
            break;
        last = r;
    }

    return r;
}

它工作得很好,每次都会产生与.NET框架的math.sqrt()方法完全相同的答案。不过,正如您可能猜测的那样,它比本机方法慢(大约慢800个节拍)。我知道这个特定的方法永远不会比本机方法更快,但是我只是想知道是否有任何优化我可以做。

我立即看到的唯一优化是计算将运行100次,即使在确定了答案之后(此时,r始终是相同的值)。所以,我添加了一个快速检查,看看新计算的值是否与先前计算的值相同,并中断循环。不幸的是,它在速度上没有太大的差别,但似乎是正确的选择。

在你说"为什么不直接用math.sqrt()来代替呢?"…我这样做是为了学习,并不打算在任何生产代码中实际使用这个方法。


首先,不是检查等式(r==last),而是检查收敛性,其中r接近last,其中close由任意epsilon定义:

1
2
eps = 1e-10  // pick any small number
if (Math.Abs(r-last) < eps) break;

正如你链接到的维基百科文章提到的那样——你不能用牛顿的方法有效地计算平方根——相反,你使用对数。


1
2
3
4
5
6
7
float InvSqrt (float x){
  float xhalf = 0.5f*x;
  int i = *(int*)&x;
  i = 0x5f3759df - (i>>1);
  x = *(float*)&i;
  x = x*(1.5f - xhalf*x*x);
  return x;}

这是我最喜欢的快速平方根。实际上,它是平方根的倒数,但是如果你想倒数,你可以在后面倒数……我不能说如果你想要平方根而不是倒数平方根,它是否更快,但是它还是很酷。http://www.beyond3d.com/content/articles/8/


你要做的是执行牛顿的求根方法。所以你可以使用一些更有效的根查找算法。你可以在这里开始搜索。


使用您的方法,每次迭代都会使正确位的数目加倍。

使用一个表来获取初始的4位(例如),第一次迭代后将有8位,第二次迭代后将有16位,第四次迭代后需要的所有位(因为double存储尾数的52+1位)。

对于表查找,可以从输入中提取[0.5,1]中的尾数和指数(使用frexp之类的函数),然后规范化[64256]中的尾数[使用适当的2的乘方。

1
2
mantissa *= 2^K
exponent -= K

在此之后,您的输入编号仍然是mantissa*2^exponent。k必须是7或8,才能获得偶数指数。您可以从包含尾数整数部分的所有平方根的表中获得迭代的初始值。进行4次迭代,得到尾数的平方根r。结果是r*2^(exponent/2),使用类似ldexp的函数构造。

编辑。我在下面放了一些C++代码来说明这一点。带改进测试的op函数sr1需要2.78s来计算2^24平方根;我的函数sr2需要1.42s,硬件sqrt需要0.12s。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <math.h>
#include <stdio.h>

double sr1(double x)
{
  double last = 0;
  double r = x * 0.5;
  int maxIters = 100;
  for (int i = 0; i < maxIters; i++) {
    r = (r + x / r) / 2;
    if ( fabs(r - last) < 1.0e-10 )
      break;
    last = r;
  }
  return r;
}

double sr2(double x)
{
  // Square roots of values in 0..256 (rounded to nearest integer)
  static const int ROOTS256[] = {
    0,1,1,2,2,2,2,3,3,3,3,3,3,4,4,4,4,4,4,4,4,5,5,5,5,5,5,5,5,5,5,6,6,6,6,6,6,6,6,6,6,6,6,
    7,7,7,7,7,7,7,7,7,7,7,7,7,7,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,9,9,9,9,9,9,9,9,9,9,9,9,9,
    9,9,9,9,9,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,11,11,11,11,11,
    11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,12,12,12,12,12,12,12,12,12,12,12,12,
    12,12,12,12,12,12,12,12,12,12,12,12,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,
    13,13,13,13,13,13,13,13,13,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,
    14,14,14,14,14,14,14,14,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,
    15,15,15,15,15,15,15,15,15,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16 };

  // Normalize input
  int exponent;
  double mantissa = frexp(x,&exponent); // MANTISSA in [0.5,1[ unless X is 0
  if (mantissa == 0) return 0; // X is 0
  if (exponent & 1) { mantissa *= 128; exponent -= 7; } // odd exponent
  else { mantissa *= 256; exponent -= 8; } // even exponent
  // Here MANTISSA is in [64,256[

  // Initial value on 4 bits
  double root = ROOTS256[(int)floor(mantissa)];

  // Iterate
  for (int it=0;it<4;it++)
    {
      root = 0.5 * (root + mantissa / root);
    }

  // Restore exponent in result
  return ldexp(root,exponent>>1);
}

int main()
{
  // Used to generate the table
  // for (int i=0;i<=256;i++) printf(",%.0f",sqrt(i));

  double s = 0;
  int mx = 1<<24;
  // for (int i=0;i<mx;i++) s += sqrt(i); // 0.120s
  // for (int i=0;i<mx;i++) s += sr1(i);  // 2.780s
  for (int i=0;i<mx;i++) s += sr2(i);  // 1.420s
}


将除法2替换为位移位不太可能产生如此大的差异;考虑到除法是一个常量,我希望编译器足够聪明,可以为您做到这一点,但您也可以尝试看看。

通过提前退出循环,您更有可能获得改进,因此要么将新的r存储在变量中并与旧的r进行比较,要么将x/r存储在变量中并在执行加法和除法之前将其与r进行比较。


您可以返回r,而不是中断循环,然后返回r。可能不会显著提高性能。


将"/2"替换为"*0.5"会使我的计算机速度提高约1.5倍,但当然速度不如本机实现快。


为了学习的目的,我也一直在研究这个问题。你可能对我试过的两个修改感兴趣。

第一种方法是在X0中使用一阶泰勒级数近似:

1
2
3
4
5
6
7
8
9
10
11
12
13
    Func<double, double> fNewton = (b) =>
    {
        // Use first order taylor expansion for initial guess
        // http://www27.wolframalpha.com/input/?i=series+expansion+x^.5
        double x0 = 1 + (b - 1) / 2;
        double xn = x0;
        do
        {
            x0 = xn;
            xn = (x0 + b / x0) / 2;
        } while (Math.Abs(xn - x0) > Double.Epsilon);
        return xn;
    };

第二个是尝试三阶(更昂贵),迭代

1
2
3
4
5
6
7
8
9
10
11
    Func<double, double> fNewtonThird = (b) =>
    {
        double x0 = b/2;
        double xn = x0;
        do
        {
            x0 = xn;
            xn = (x0*(x0*x0+3*b))/(3*x0*x0+b);
        } while (Math.Abs(xn - x0) > Double.Epsilon);
        return xn;
    };

我创建了一个助手方法来为函数计时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static class Helper
{
    public static long Time(
        this Func<double, double> f,
        double testValue)
    {
        int imax = 120000;
        double avg = 0.0;
        Stopwatch st = new Stopwatch();
        for (int i = 0; i < imax; i++)
        {
            // note the timing is strictly on the function
            st.Start();
            var t = f(testValue);
            st.Stop();
            avg = (avg * i + t) / (i + 1);
        }
        Console.WriteLine("Average Val: {0}",avg);
        return st.ElapsedTicks/imax;
    }
}

最初的方法更快,但同样有趣的是:)


既然您说下面的代码不够快,请尝试以下操作:

1
2
3
4
    static double guess(double n)
    {
        return Math.Pow(10, Math.Log10(n) / 2);
    }

它应该非常准确,希望速度快。

这里是这里描述的初始估计的代码。看起来不错。使用这段代码,然后您还应该迭代,直到值在差异的epsilon内收敛。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
    public static double digits(double x)
    {
        double n = Math.Floor(x);
        double d;

        if (d >= 1.0)
        {
            for (d = 1; n >= 1.0; ++d)
            {
                n = n / 10;
            }
        }
        else
        {
            for (d = 1; n < 1.0; ++d)
            {
                n = n * 10;
            }
        }


        return d;
    }

    public static double guess(double x)
    {
        double output;
        double d = Program.digits(x);

        if (d % 2 == 0)
        {
            output = 6*Math.Pow(10, (d - 2) / 2);
        }
        else
        {
            output = 2*Math.Pow(10, (d - 1) / 2);
        }

        return output;
    }


定义一个公差,并在随后的迭代落在该公差范围内时尽早返回。


好吧,本机sqrt()函数可能没有用c_实现,它很可能是用一种低级语言实现的,而且肯定会使用一种更有效的算法。因此,尝试匹配它的速度可能是徒劳的。

但是,对于只为heckuvit优化函数,您链接的维基百科页面建议"开始猜测"为2 ^层(d/2),其中d表示数字中的二进制位数。您可以尝试一下,我看不出还有多少其他可以在您的代码中进行显著优化的地方。


你可以试试r = x >> 1;

而不是/2(也可以在另一个地方按2)。它可能会给你一点优势。我也会把100移动到循环中。可能什么都没有,但我们在这里谈论的是虱子。

现在检查一下。

编辑:修正了>到>的问题,但它不适用于双打,所以永远不要。这100辆车的内衬没有给我提速。