将一个双精度数四舍五入到x个有效数字。

83
如果我有一个双精度浮点数(如234.004223),我想在C#中将其舍入为x个有效数字。
目前我只能找到舍入到x位小数的方法,但如果数字中有0,则这样做会丢失精度。
例如,将0.086舍入到一位小数变成0.1,但我希望它保持在0.08。

1
您想保留小数点前0后x位数字吗?例如,如果您希望以下数字保留2位数字 0.00030908 则为 0.00031 还是您想要0.00030?或者其他什么? - lakshmanaraj
2
我不太清楚你在这里的意思。在你的例子中,你是想保留两位小数还是只保留一位数字?如果是后者,那应该是0.09,肯定要将6四舍五入进位... - The Archetypal Paul
还是您在寻找N * 10^X,其中N具有指定数量的数字? - The Archetypal Paul
10
我不同意。将数字四舍五入到有效数字并不意味着应该自动截断而不是四舍五入。例如,参见http://en.wikipedia.org/wiki/Significant_figures。"......如果将0.039舍入到1个有效数字,结果为0.04。" - P Daddy
请注意,我已经提供了以下两种方法。0.039.RoundToSignificantDigits(1)将返回0.04,而0.039.TruncateToSignificantDigits(1)将返回0.03。 - P Daddy
显示剩余4条评论
17个回答

106

这个框架没有内置函数用于将数字四舍五入(或像您的例子一样截断)到指定数量的有效数字。但是,您可以通过调整数字的比例使第一个有效数字位于小数点右侧,然后进行四舍五入(或截断),最后再调整回来。以下代码应该能解决问题:

static double RoundToSignificantDigits(this double d, int digits){
    if(d == 0)
        return 0;

    double scale = Math.Pow(10, Math.Floor(Math.Log10(Math.Abs(d))) + 1);
    return scale * Math.Round(d / scale, digits);
}

如果像你的例子一样需要截断,那么你需要使用:

static double TruncateToSignificantDigits(this double d, int digits){
    if(d == 0)
        return 0;

    double scale = Math.Pow(10, Math.Floor(Math.Log10(Math.Abs(d))) + 1 - digits);
    return scale * Math.Truncate(d / scale);
}

11
好的,这是需要翻译的内容:@leftbrainlogic: 是的,它确实有作用:http://msdn.microsoft.com/en-us/library/75ks3aby.aspx - P Daddy
5
如果d<0,Math.Log10会返回Double.NaN,因此这两种方法都不能处理负数。 - Fraser
4
嗯,你需要检查 d 是否等于 0,因为这也会导致 Double.NaN。这两种方法都需要一些守卫条件,例如:如果(d == 0) { return 0; } 如果(d < 0) { d = Math.Abs(d); } ——否则你最终会在两种情况下得到一个除以 0 的结果。 - Fraser
4
@Fraser:好了,读者练习的机会没了。顺便说一下,Eric在两年前就注意到了负数的问题(尽管没有注意到零的问题)。也许我应该实际修复这段代码,这样人们就不会再因为它而打电话给我了。 - P Daddy
4
@PDaddy,请修复它。如果修好了,我会点赞的。我猜很多人错认为高票答案可以直接复制粘贴。 - Evgeniy Berezovsky
显示剩余17条评论

24

我已经使用了pDaddy的sigfig函数几个月,发现其中有一个bug。当d为负数时,无法对其取对数,因此结果会是NaN。

以下是修正该bug的代码:

public static double SetSigFigs(double d, int digits)
{   
    if(d == 0)
        return 0;

    decimal scale = (decimal)Math.Pow(10, Math.Floor(Math.Log10(Math.Abs(d))) + 1);

    return (double) (scale * Math.Round((decimal)d / scale, digits));
}

2
由于某些原因,这段代码无法将50.846113537656557准确地转换为6个有效数字,有什么想法吗? - Andrew Hancox
5
运行失败,错误信息为 (0.073699979, 7),返回值为 0.073699979999999998。我会尽力将其翻译得更加通俗易懂,但不会改变原来的意思。 - LearningJrDev

23

我觉得您并不想将数字四舍五入到x位小数,而是想将其四舍五入到x个有效数字。因此,在您的示例中,您想将0.086四舍五入为一个有效数字,而不是一位小数。

现在,使用double并舍入到指定数量的有效数字是有问题的,因为double类型的存储方式不同。例如,您可以将0.12四舍五入为接近0.1的数字,但0.1不能完全作为double类型表示。您确定您是否应该实际使用decimal类型?或者,这实际上是用于显示目的吗?如果是用于显示目的,我认为您应该直接将double转换为带有相应数量有效数字的字符串。

如果您能回答这些问题,我可以尝试提供一些适当的代码。虽然听起来很糟糕,将数字转换为“完整”字符串,然后找到第一个有效数字(然后在此之后采取适当的舍入操作)可能是最好的方法。


这只是为了显示的目的,说实话我根本没有考虑过小数点。 如果按照你所说的要求将其转换为字符串并保留相应数量的有效数字,我该怎么做呢?我在Double.ToString()方法规范中找不到相关示例。 - Rocco
5
@Rocco:我知道我晚了4年,但我刚看到你的问题。我认为你应该使用Double.ToString("Gn")。请参考我2012年11月6日的回答 :-) - farfareast

22

如果是为了显示目的(如您在对Jon Skeet的回答评论中所述),您应该使用Gn格式说明符,其中n是有效数字的数量 - 正好是您想要的。

以下是如果您希望显示3个有效数字的使用示例(每行的打印输出在注释中):

    Console.WriteLine(1.2345e-10.ToString("G3"));//1.23E-10
    Console.WriteLine(1.2345e-5.ToString("G3")); //1.23E-05
    Console.WriteLine(1.2345e-4.ToString("G3")); //0.000123
    Console.WriteLine(1.2345e-3.ToString("G3")); //0.00123
    Console.WriteLine(1.2345e-2.ToString("G3")); //0.0123
    Console.WriteLine(1.2345e-1.ToString("G3")); //0.123
    Console.WriteLine(1.2345e2.ToString("G3"));  //123
    Console.WriteLine(1.2345e3.ToString("G3"));  //1.23E+03
    Console.WriteLine(1.2345e4.ToString("G3"));  //1.23E+04
    Console.WriteLine(1.2345e5.ToString("G3"));  //1.23E+05
    Console.WriteLine(1.2345e10.ToString("G3")); //1.23E+10

7
虽然接近,但这并不总是返回有效数字...例如,G4将从1.000中除去零--> 1。另外,它会自行决定是否强制使用科学计数法,无论您是否喜欢。 - u8it
2
在去掉1.0001中的显著零时,可能应该同意您的观点。至于第二个声明——使用科学计数法是基于哪种符号在打印时占用更少的空间来决定的(这是G格式的旧FORTRAN规则)。因此,在某种程度上它是可预测的,但如果有人通常更喜欢科学格式——对他们来说并不好。 - farfareast
这绝对是我问题的最佳解决方案。我向API提交了30/31,精度为28位数字,并且API通过返回一个16位数值来确认它与我的原始值不匹配。为了匹配这些值,我现在正在比较 submittedValue.ToString("G12")returnedValue.ToString("G12")(在我的情况下足够精确)。 - fero
真不敢相信我要滚到这么下面才能看到一个人理解我的问题。谢谢你。 - Behrooz

12

我发现P Daddy和Eric的方法中存在两个bug。这解决了Andrew Hancox在这个问答中提出的精度错误的问题。还有一个四舍五入方向的问题。带有两个有效数字的1050不是1000.0,而是1100.0。使用MidpointRounding.AwayFromZero修复了四舍五入问题。

static void Main(string[] args) {
  double x = RoundToSignificantDigits(1050, 2); // Old = 1000.0, New = 1100.0
  double y = RoundToSignificantDigits(5084611353.0, 4); // Old = 5084999999.999999, New = 5085000000.0
  double z = RoundToSignificantDigits(50.846, 4); // Old = 50.849999999999994, New =  50.85
}

static double RoundToSignificantDigits(double d, int digits) {
  if (d == 0.0) {
    return 0.0;
  }
  else {
    double leftSideNumbers = Math.Floor(Math.Log10(Math.Abs(d))) + 1;
    double scale = Math.Pow(10, leftSideNumbers);
    double result = scale * Math.Round(d / scale, digits, MidpointRounding.AwayFromZero);

    // Clean possible precision error.
    if ((int)leftSideNumbers >= digits) {
      return Math.Round(result, 0, MidpointRounding.AwayFromZero);
    }
    else {
      return Math.Round(result, digits - (int)leftSideNumbers, MidpointRounding.AwayFromZero);
    }
  }
}

3
对于RoundToSignificantDigits(.00000000000000000846113537656557, 6)的操作失败,因为Math.Round函数不允许第二个参数超过15。 - Oliver Bock
我认为,将1050四舍五入到两个有效数字是1000。银行家舍入法是一种非常常见的舍入方法。 - Derrick Moeller

8

这里有一个解决方案,可以用一个词来形容 - 可靠。它总是有效的,原因是它使用字符串格式化进行操作。使用 Log Pow 会导致特殊值问题,无法避免,这很复杂。

示例值表:

value               digits    returns
=========================================================
0.086                    1    "0.09"
1.0                      3    "1.00"
0.1                      3    "0.100"
0.00030908               2    "0.00031"
1239451                  3    "1240000"
5084611353               4    "5085000000"
8.46113537656557E-18     6    "0.00000000000000000846114"
50.8437                  4    "50.84"
50.846                   4    "50.85"
990                      1    "1000"
-5488                    1    "-5000"
-990                     1    "-1000"
7.89E-05                 2    "0.000079"
50.84611353765656        6    "50.8461"
0.073699979              7    "0.07369998"
0                        2    "0"

这里是代码:

public static class SignificantDigits
{
    public static readonly string DecimalSeparator;

    static SignificantDigits()
    {
        System.Globalization.CultureInfo ci = System.Threading.Thread.CurrentThread.CurrentCulture;
        DecimalSeparator = ci.NumberFormat.NumberDecimalSeparator;
    }

    /// <summary>
    /// Format a double to a given number of significant digits.
    /// </summary>
    public static string Format(double value, int digits, bool showTrailingZeros = true, bool alwaysShowDecimalSeparator = false)
    {
        if (double.IsNaN(value) ||
            double.IsInfinity(value))
        {
            return value.ToString();
        }

        string sign = "";
        string before = "0"; // Before the decimal separator
        string after = ""; // After the decimal separator

        if (value != 0d)
        {
            if (digits < 1)
            {
                throw new ArgumentException("The digits parameter must be greater than zero.");
            }

            if (value < 0d)
            {
                sign = "-";
                value = -value;
            }

            // Custom exponential formatting using '#' will give us exactly our digits plus an exponent
            string scientific = value.ToString(new string('#', digits) + "E0");
            string significand = scientific.Substring(0, digits);
            int exponent = int.Parse(scientific.Substring(digits + 1));
            // (significand now already contains the requested number of digits with no decimal separator in it)

            var fractionalBreakup = new StringBuilder(significand);

            if (!showTrailingZeros)
            {
                while (fractionalBreakup[fractionalBreakup.Length - 1] == '0')
                {
                    fractionalBreakup.Length--;
                    exponent++;
                }
            }

            // Place the decimal separator (insert zeros if necessary)

            int separatorPosition;

            if ((fractionalBreakup.Length + exponent) < 1)
            {
                fractionalBreakup.Insert(0, "0", 1 - fractionalBreakup.Length - exponent);
                separatorPosition = 1;
            }
            else if (exponent > 0)
            {
                fractionalBreakup.Append('0', exponent);
                separatorPosition = fractionalBreakup.Length;
            }
            else
            {
                separatorPosition = fractionalBreakup.Length + exponent;
            }

            before = fractionalBreakup.ToString();

            if (separatorPosition < before.Length)
            {
                after = before.Substring(separatorPosition);
                before = before.Remove(separatorPosition);
            }
        }

        string result = sign + before;

        if (after == "")
        {
            if (alwaysShowDecimalSeparator)
            {
                result += DecimalSeparator + "0";
            }
        }
        else
        {
            result += DecimalSeparator + after;
        }

        return result;
    }
}

作为旁注 - 你可能会对我在另一个问题下的工程符号回答感兴趣,在这里

5
我同意Jon所说的评估精神

听起来很糟糕,但是通过将数字转换为“完整”的字符串,并找到第一个有效数字(然后在此之后采取适当的四舍五入动作),将其转换为重要数字字符串可能是最佳选择。

我需要对近似值非性能关键的计算目的进行有效数字四舍五入,通过“G”格式的格式化解析来实现四舍五入也是足够好的:
public static double RoundToSignificantDigits(this double value, int numberOfSignificantDigits)
{
    return double.Parse(value.ToString("G" + numberOfSignificantDigits));
}

3

在double类型上使用Math.Round()存在缺陷(请参见其文档中的“调用者注意事项”)。将四舍五入后的数字乘回其小数指数的后续步骤会在尾数中引入进一步的浮点误差。如@Rowanto所做的另一个Round()也不能可靠地帮助并且存在其他问题。然而,如果您愿意通过decimal进行操作,则Math.Round()是可靠的,乘以和除以10的幂次方也是如此:

static ClassName()
{
    powersOf10 = new decimal[28 + 1 + 28];
    powersOf10[28] = 1;
    decimal pup = 1, pdown = 1;
    for (int i = 1; i < 29; i++) {
        pup *= 10;
        powersOf10[i + 28] = pup;
        pdown /= 10;
        powersOf10[28 - i] = pdown;
    }
}

/// <summary>Powers of 10 indexed by power+28.  These are all the powers
/// of 10 that can be represented using decimal.</summary>
static decimal[] powersOf10;

static double RoundToSignificantDigits(double v, int digits)
{
    if (v == 0.0 || Double.IsNaN(v) || Double.IsInfinity(v)) {
        return v;
    } else {
        int decimal_exponent = (int)Math.Floor(Math.Log10(Math.Abs(v))) + 1;
        if (decimal_exponent < -28 + digits || decimal_exponent > 28 - digits) {
            // Decimals won't help outside their range of representation.
            // Insert flawed Double solutions here if you like.
            return v;
        } else {
            decimal d = (decimal)v;
            decimal scale = powersOf10[decimal_exponent + 28];
            return (double)(scale * Math.Round(d / scale, digits, MidpointRounding.AwayFromZero));
        }
    }
}

2

在.NET 6.0上进行了测试

我认为,由于框架的缺陷和浮点误差的影响,圆整结果不一致。因此,在使用时需要非常谨慎。

decimal.Parse(doubleValue.ToString("E"), NumberStyles.Float);

例子:

using System.Diagnostics;
using System.Globalization;

List<double> doubleList = new();
doubleList.Add(    0.012345);
doubleList.Add(    0.12345 );
doubleList.Add(    1.2345  );
doubleList.Add(   12.345   );
doubleList.Add(  123.45    );
doubleList.Add( 1234.5     );
doubleList.Add(12345       );
doubleList.Add(10  );
doubleList.Add( 0  );
doubleList.Add( 1  );
doubleList.Add(-1  );
doubleList.Add( 0.1);

Debug.WriteLine("");
foreach (var item in doubleList)
{
    Debug.WriteLine(decimal.Parse(item.ToString("E2"), NumberStyles.Float));

    // 0.0123
    // 0.123
    // 1.23
    // 12.3
    // 123
    // 1230
    // 12300
    // 10.0
    // 0.00
    // 1.00
    // -1.00
    // 0.100
}

Debug.WriteLine("");
foreach (var item in doubleList)
{
    Debug.WriteLine(decimal.Parse(item.ToString("E3"), NumberStyles.Float));

    // 0.01235
    // 0.1235
    // 1.234
    // 12.35
    // 123.5
    // 1234
    // 12340
    // 10.00
    // 0.000
    // 1.000
    // -1.000
    // 0.1000
}

2

这个问题与您提出的问题类似:

在C#中使用有效数字格式化数字

因此,您可以执行以下操作:

double Input2 = 234.004223;
string Result2 = Math.Floor(Input2) + Convert.ToDouble(String.Format("{0:G1}", Input2 - Math.Floor(Input2))).ToString("R6");

保留一个有效数字并四舍五入。

返回2340.0004 - 至少带有一些本地化。 - Gustav

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接