最接近二的幂次分数倍数

4

有没有一种优化的、高效的方法将一个double值舍入到离给定二次幂分数最近的精确值?

换句话说,将.44舍入到最接近1/16的值(换句话说,将其舍入为可以表示为n/16的值,其中n是整数)将会是.4375。注意:这很重要,因为二次幂分数可以无需舍入误差存储,例如。

public class PowerOfTwo {
  public static void main(String... args) {
    double inexact = .44;
    double exact = .4375;

    System.out.println(inexact + ":   " + Long.toBinaryString(Double.doubleToLongBits(inexact)));
    System.out.println(exact + ": " + Long.toBinaryString(Double.doubleToLongBits(exact)));
  }
}

输出:

0.44:   11111111011100001010001111010111000010100011110101110000101001
0.4375: 11111111011100000000000000000000000000000000000000000000000000

你希望存储结果与普通的双精度值有何不同?由于双精度数的指数部分,所有双精度数都是2^x的倍数。你希望获得什么?你想让你的结果恰好表示2^x的值吗? - midor
2
这里最好的选择是经过一个长的掩码,然后再转换回双精度。 - fge
@fge 我同意,但我不确定如何做到这一点。特别是当分数的整数部分不为零时,例如 12345.4375 - durron597
这是什么问题?你只改变了分数,而没有改变指数。 - fge
@fge 因为指数较大时需要掩盖的位数较少。 - durron597
6个回答

3

如果您想选择2的幂次方,最简单的方法是乘以例如16,四舍五入到最接近的整数,然后除以16。请注意,如果结果是正常数字,则对2的幂次方进行除法运算是精确的。对于次正规数,它可能会导致舍入误差。

以下是使用此技术的示例程序:

public class Test {
  public static void main(String[] args) {
    System.out.println(roundToPowerOfTwo(0.44, 2));
    System.out.println(roundToPowerOfTwo(0.44, 3));
    System.out.println(roundToPowerOfTwo(0.44, 4));
    System.out.println(roundToPowerOfTwo(0.44, 5));
    System.out.println(roundToPowerOfTwo(0.44, 6));
    System.out.println(roundToPowerOfTwo(0.44, 7));
    System.out.println(roundToPowerOfTwo(0.44, 8));
  }

  public static double roundToPowerOfTwo(double in, int power) {
    double multiplier = 1 << power;
    return Math.rint(in * multiplier) / multiplier;
  }
}

输出:

0.5
0.5
0.4375
0.4375
0.4375
0.4375
0.44140625

我已经给你点赞了。我想知道这个答案是否比我的位掩码答案更快,以及它是否适用于大整数部分。 - durron597
@durron597 关于大值的观点很好 - 如果乘法结果溢出long,它将失败。 - Patricia Shanahan
我已经通过使用 Math.rint 而不是 Math.round 来纠正了 @durron597 提出的溢出问题。现在只有当乘法的结果大于 Double.MAX_VALUE 时才会发生溢出。 - Patricia Shanahan
我对你的答案和我的答案进行了谷歌卡尺比较,发现这个答案明显更快。请打勾确认。 - durron597
让我们在聊天中继续这个讨论 - Patricia Shanahan
显示剩余2条评论

1
在所有边角情况下都做到正确有些棘手。如果我必须解决这样的任务,我通常会从一个天真的实现开始,我可以相当确定它是正确的,然后才开始实现一个优化版本。在这样做的同时,我总是可以与天真的方法进行比较,以验证我的结果。
天真的方法是从1开始,并将其乘以/除以2,直到我们将输入的绝对值括起来。然后,我们将输出更接近的边界。实际上,这还有点复杂:如果该值为NaN或无穷大,则需要特殊处理。
以下是代码:
public static double getClosestPowerOf2Loop(final double x) {
    final double absx = Math.abs(x);
    double prev = 1.0;
    double next = 1.0;
    if (Double.isInfinite(x) || Double.isNaN(x)) {
        return x;
    } else if (absx < 1.0) {
        do {
            prev = next;
            next /= 2.0;
        } while (next > absx);
    } else if (absx > 1.0) {
        do {
            prev = next;
            next *= 2.0;
        } while (next < absx);
    }
    if (x < 0.0) {
        prev = -prev;
        next = -next;
    }
    return (Math.abs(next - x) < Math.abs(prev - x)) ? next : prev;
}

我希望代码可以清晰易懂,无需进一步解释。自Java 8以来,您可以使用!Double.isFinite(x)替换Double.isInfinite(x)|| Double.isNaN(x)
让我们看一个优化版本。正如其他答案已经建议的那样,我们应该看一下位表示法。Java要求使用IEE 754来表示浮点值。在该格式中,double(64位)精度的数字表示为:
  • 1位符号,
  • 11位指数和
  • 52位尾数。
我们将再次特殊处理 NaN 和无穷大(它们由特殊的位模式表示)。然而,还有另外一个例外:尾数的最高位是 隐含 的1,并不在位模式中出现 - 除了非常小的数字,这里使用所谓的亚规范表示,其中最高有效位不是尾数的最高位。因此,对于正常的数字,我们将简单地将尾数的位设置为所有0,但对于亚规范,我们将其转换为一个数字,其中保留了除最高有效的1位之外的其他位。该过程总是向零舍入,因此要得到另一个边界,我们只需乘以2。

让我们看看这些如何协同工作:

public static double getClosestPowerOf2Bits(final double x) {
    if (Double.isInfinite(x) || Double.isNaN(x)) {
        return x;
    } else {
        final long bits = Double.doubleToLongBits(x);
        final long signexp = bits  & 0xfff0000000000000L;
        final long mantissa = bits & 0x000fffffffffffffL;
        final long mantissaPrev = Math.abs(x) < Double.MIN_NORMAL
            ? Long.highestOneBit(mantissa)
            : 0x0000000000000000L;
        final double prev = Double.longBitsToDouble(signexp | mantissaPrev);
        final double next = 2.0 * prev;
        return (Math.abs(next - x) < Math.abs(prev - x)) ? next : prev;
    }
}

我不确定我是否涵盖了所有的角落情况,但以下测试确实可以运行:

public static void main(final String[] args) {
    final double[] values = {
        5.0, 4.1, 3.9, 1.0, 0.0, -0.1, -8.0, -8.1, -7.9,
        0.9 * Double.MIN_NORMAL, -0.9 * Double.MIN_NORMAL,
        Double.NaN, Double.MAX_VALUE, Double.MIN_VALUE,
        Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY,
    };
    for (final double value : values) {
        final double powerL = getClosestPowerOf2Loop(value);
        final double powerB = getClosestPowerOf2Bits(value);
        System.out.printf("%17.10g  -->  %17.10g  %17.10g%n",
                          value, powerL, powerB);
        assert Double.doubleToLongBits(powerL) == Double.doubleToLongBits(powerB);
    }
}

输出:

      5.000000000  -->        4.000000000        4.000000000
      4.100000000  -->        4.000000000        4.000000000
      3.900000000  -->        4.000000000        4.000000000
      1.000000000  -->        1.000000000        1.000000000
      0.000000000  -->        0.000000000        0.000000000
    -0.1000000000  -->      -0.1250000000      -0.1250000000
     -8.000000000  -->       -8.000000000       -8.000000000
     -8.100000000  -->       -8.000000000       -8.000000000
     -7.900000000  -->       -8.000000000       -8.000000000
 2.002566473e-308  -->   2.225073859e-308   2.225073859e-308
-2.002566473e-308  -->  -2.225073859e-308  -2.225073859e-308
              NaN  -->                NaN                NaN
 1.797693135e+308  -->   8.988465674e+307   8.988465674e+307
 4.900000000e-324  -->   4.900000000e-324   4.900000000e-324
        -Infinity  -->          -Infinity          -Infinity
         Infinity  -->           Infinity           Infinity

关于性能如何?

我运行了以下基准测试

public static void main(final String[] args) {
    final Random rand = new Random();
    for (int i = 0; i < 1000000; ++i) {
        final double value = Double.longBitsToDouble(rand.nextLong());
        final double power = getClosestPowerOf2(value);
    }
}

其中getClosestPowerOf2将被替换为getClosestPowerOf2LoopgetClosestPowerOf2Bits。 在我的笔记本电脑上,我得到了以下结果:

  • getClosestPowerOf2Loop:2.35秒
  • getClosestPowerOf2Bits:1.80秒

这真的值得努力吗?


在我的实际应用中,我知道输入永远不会是Infinity、NaN或负数,因此我不需要如此小心。 - durron597
此外,在这里永远不需要循环,我的回答和帕特里夏的回答都没有使用循环。 - durron597
1
我的第二个函数也没有使用循环。我展示了循环方法,以提供一个简单的解决方案,我可以相当确定它能正确地覆盖所有情况。 - 5gon12eder

1
如果问题是将任何数字舍入到预定的二进制精度,您需要执行以下操作:
  1. 使用 'Double.doubleToLongBits()'将值转换为long
  2. 检查指数:如果太大(exponent+required precision>51,即有效数字中的位数),则无法进行任何舍入,但您不必这样做:数字已满足您的条件。
  3. 另一方面,如果exponent+required precision<0,则舍入结果始终为0。
  4. 在任何其他情况下,请查看有效数字,并抹掉所有低于exponent+required precision的有效位。
  5. 使用 'Double.longBitsToDouble()'将数字转换回double

我试图实现这个答案。 - durron597

0
这是我对解决方案的第一次尝试,它不能处理@biziclop答案中的所有情况,并且可能会使用“floor”而不是“round”。
public static double round(double d, int precision) {
  double longPart = Math.rint(d);
  double decimalOnly = d - longPart;
  long bits = Double.doubleToLongBits(decimalOnly);

  long mask = -1l << (54 - precision);

  return Double.longBitsToDouble(bits & mask) + longPart;
}

它将朝零舍入,对于正数是floor,对于负数是ceil。我建议使用Math.rint将intPart存储为double,而不是int,因为除了相对较小的double之外,所有其他情况都会溢出。 - Patricia Shanahan

0

如果你要将数字四舍五入到任意2的幂次方,那么你需要一些位运算的技巧。

你需要检查指数:

int exponent = Math.getExponent(inexact);

然后知道尾数中有53位,就可以找到需要四舍五入的位。


或者只需执行:

Math.round(inexact* (1l<<exponent))/(1l<<exponent)

我使用Math.round,因为我期望它对于任务来说是最优的,而不是尝试自己实现。


0
我在解决一个相关问题时遇到了这篇文章:如何高效地找到两个二的幂,以夹住任何给定的常规实数值。由于我的程序处理的不仅仅是双精度浮点数,我需要一个通用的解决方案。想要将数字四舍五入到最接近的二的幂的人可以获取夹紧值并选择最接近的值。在我的情况下,通用解决方案需要使用BigDecimal。这是我使用的技巧。
对于大于1的数字:
            int exponent = myBigDecimal.toBigInteger.bitLength() - 1;
            BigDecimal lowerBound = TWO.pow(exponent);
            BigDecimal upperBound = TWO.pow(exponent+1);

对于大于0且小于1的数字:

            int exponent = -(BigDecimal.ONE.divide(myBigDecimal, myContext).toBigInteger().bitLength()-1); 
            BigDecimal lowerBound = TWO.pow(exponent-1);
            BigDecimal upperBound = TWO.pow(exponent);

我只列出了正数情况。通常你会取一个数字,并对其绝对值使用此算法。如果在原问题中该数字为负数,则将算法的结果乘以-1。最后,原始数字等于0或1在此算法之外处理是微不足道的。这涵盖了整个实数线,除了无穷大和NaN,你需要在调用此算法之前处理它们。


如果你想要寻找小数点后最近的2的幂次方(就像你之前做的坐标四舍五入),你需要将你的数字分成整数部分和小数部分,仅对小数部分进行计算(去除整数部分),使用上述大于0且小于1的情况。然后再将这个结果加回到整数部分上。 - Barry
这个实现比已接受的答案更高效吗? - durron597
@durron597 我认为不会。这是一个通用的解决方案,可以适应其他字节大小等。任何人都可以根据自己的需求进行专业化。我将其留作指针,供寻找类似问题解决方案的人参考。 - Barry

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