Python中为什么“四舍五入”函数round(1.5)等于2,而round(2.5)也等于2?!round()函数详解
注:因为微信公众号的修改很不友好,本文首发于今日头条的 “天马行空的大杂烩” 头条号下。
初学Python的同学会碰到一个很奇怪的问题 :Python中,为什么 round(1.5)
等于2,而 round(2.5)
也等于2?!😮😮😮
round()
函数是 Python 中的一个内置函数,用于将一个数字修约到最接近的整数或指定的小数位数。
该函数遵循特定的规则来确定修约后的值。跟 C/C++/Java 语言中的不一样,它不是简单的“四舍五入”,初学者很容易被它搞蒙,希望下面这篇文章,能让你彻底搞明白这些规则。
1、一道二级Python真题
先来看一道全国计算机等级考试(NCRE)二级 Python 中的真题,这题比较简单,没有坑:
1.1 题目:x=2.6,表达式 round(x) 的结果是______。
A. 2.6 B. 2 C. 3 D. 2.0
参考答案: C. 3
1.2 解析:
对于二级考试来说,关于 round()
函数,大家只要记住 “四舍六入” 就够了,注意!是“六”入,而不是中学所学的“五”入。
这题中,round(2.6)
只有一个参数,没有表示精度的第二个参数,那就是对第一个参数 取整数,即:小数点后保留0位。因为小数点后是"6",按照我们中学时就学过的 “四舍五入” 的原则,0.6大于等于0.5,则直接进一位,就成了:2+1 ,结果为 3 。
而对于指定精度位数后面跟的是 “五” 的情况下,到底是应该 “入”,还是 “舍”,相对复杂,我把详细解析放在下面,感兴趣的可以进一步深入学习、思考。不想深究的,二级考试如果 “中大奖” 碰到 “五” 的情况,又不明白的话,就随便蒙一个吧。😅😅😅
2、“四舍六入五取偶”的中庸之道
Python中的 round()
函数,在做修约(round)时,采用的策略叫做“四舍六入五取偶”,真有点合乎中华文化中的 “中庸之道”、“不偏不倚”,为啥这么说呢?请继续往下看。
2.1 官方文档
先看下截止今天最新的 Python 稳定版 Python 3.12.4 的官方文档怎么说。
round(number, ndigits=None)
返回 number 舍入到小数点后 ndigits 位精度的值。如果 ndigits 被省略或为 None,则返回最接近输入值的整数。
对于支持
round()
方法的内置类型,结果值会舍入至最接近的 10 的负 ndigits 次幂的倍数;如果与两个倍数同样接近,则选用偶数。因此,round(0.5)
和round(-0.5)
均得出 0 而round(1.5)
则为 2。ndigits 可为任意整数值(正数、零或负数)。如果省略了 ndigits 或为 None ,则返回值将为整数。否则返回值与 number 的类型相同。对于一般的 Python 对象 number, round 将委托给 number.__ round __。
备注: 对浮点数执行
round()
的行为可能会令人惊讶:例如,round(2.675, 2)
将给出 2.67 而不是期望的 2.68。这不算是程序错误:这一结果是由于大多数十进制小数实际上都不能以浮点数精确地表示。请参阅 tut-fp-issues 了解更多信息。
官方文档解释得比较形式化,不太容易理解,我们来举例说明,先以小数点后只有1位小数的情况为例。
2.2 修约规则
2.2.1 当被修约的数字小于5时,该数字被舍去。
当被修约的数字(1位的小数部分)小于 0.5 时,小数部分为 0.1、0.2、0.3、0.4中的一个,round()
函数直接舍弃小数部分,取前面的整数部分。
举例:
round(8.3)
→ 8(3小于5,舍去0.3,得8)round(-4.2)
→ 4(2小于5,舍去0.2,得-4)
用数形结合的思维看,修约后的值,都往原点方向靠近。
2.2.2 当被修约的数字大于5时,则考虑进位。
当被修约的数字(1位的小数部分)大于 0.5 时,小数部分为 0.6、0.7、0.8、0.9中的一个,需要进位,最终修约后的数值将是“更靠近的整数”。
举例:
round(1.7)
→ 2(7大于5,距离1.7最近的整数分别是1和2,而1.7距离2比距离1更近,因此得2)round(-3.9)
→ -4(9大于5,距离-3.9最近的整数分别是-4和-3,而-3.9距离-4比距离-3更近,因此得-4)
用数形结合的思维看,修约后的值,都往原点方向远离。
2.2.3 当被修约的数字等于5时,则需根据5前面的数字来决定
如果5前面的数字是奇数,则进位,修约后末尾数字都成为偶数; 如果5前面的数字是偶数,则将5舍掉,修约后末尾数字都成为偶数; 如果5的后面还有不为“0”的任何数,则此时无论5的前面是奇数还是偶数,均应进位。
举例:
round(41.5)
→ 42(保留小数点后0位,即取整,5前面的数字是奇数1,则进位,修约后得到42,其最后一个数字是2,为偶数)round(3.25, 1)
→ 3.2(保留小数点后1位,5前面的数字是偶数2,则将5舍掉,修约后得到3.2,其最后一个数字是2,为偶数)round(3.25001, 1)
→ 3.3(保留小数点后1位,则看2后面是啥,因为2后面是5,且5后面还有数“001”,此时,不管5前面的数字是奇数还是偶数,都要进位,因此变成3.2+0.1,得3.3)
上面3条规则,前两条都很好理解:从数轴看,修约后的结果都是 -- 转成距离最近的整数。
而第3条规则是:当需要修约的数值恰好位于两个数中间时(即被修约的数字是5开头),Python的 round()
函数采用的这种策略叫做 银行家舍入法 (Banker's Rounding),也称为 “四舍六入五取偶” 法(也称为:“四舍六入五留双”、“偶数舍入法”),即在这种情况下,会舍入到最近的偶数。
2.3 为什么是“四舍六入五取偶”这个规则?
这是为了符合计算机中的 IEEE 754 浮点运算标准,现在大多数较新的编程语言的 round()
函数,都默认使用这种方法,如:Go、Rust、Python 等。
这种舍入法的优点是,与通常的四舍五入相比,它在平均数方面更能保持原有数据的特性。因此,银行家舍入法在很多需要更小误差的科学和计算机系统中得到了广泛应用。它也被称为 统计学家舍入(statistician's rounding)或无偏舍入(unbiased rounding)。
为什么说这种舍入法在平均数方面更能保持原有数据的特性呢?还是举例说明。
假设有两个客户去银行提款,一个账上剩1.5分,一个账上剩2.5分,那对银行家来说,他总共要付给两位客户:1.5+2.5=4分钱,但如果都按照“四舍五入”的方法来给客户付钱,银行家要付:2+3=5分,跟4分比亏了1分;而按照“四舍六入五取偶”的方法,银行家要付:2+2=4分,不亏不赚刚刚好!👍 银行家么,就是要精明!
好吧,我承认:上面都是我一本正经瞎扯的。🤣🤣🤣
银行家舍入法之所以被认为在平均数方面更能保持原有数据的特性,主要是因为它减少了一般四舍五入方法中可能引入的系统性偏差。
在传统的四舍五入中,每当数字达到5时,总是进行进位。这意味着,对于一系列随机分布的数值(0、1、2、3、4、5、6、7、8、9的几率都相同),其中,1、2、3、4这4种情况会舍去,6、7、8、9这4种情况会进位。然而,因为5总是导致进位,所以实际上进位的次数会稍微多于舍去的次数。这种偏差在处理大量数据时可能会导致结果的总体较大的偏差。
而银行家舍入法则采用了一种“取偶”的策略:当数字正好是5,且5前面是偶数时,它选择舍去5,而不是进位。这种方法确保了在数值恰好是5时,舍去和进位的次数大致相等。这样,长期来看,进位和舍去的操作在数量上更加平衡,从而减少了总体上的偏差。
因此,银行家舍入法在处理大量数据时,能够更好地保持原有数据的统计特性,特别是平均数。这在需要高精度计算的金融、科学和工程领域尤为重要。
现在,你理解为什么说它跟“中庸之道”、“不偏不倚”有联系了吧?🙂
3、特殊情况说明
需要注意的是,由于浮点数的表示方式,某些看似简单的舍入操作可能会产生意想不到的结果。例如:
round(2.675, 2)
对数字 2.675 取小数点后保留 2 位的近似值,如果按照我们前面 “四舍六入五取偶” 的规则, 5 的前面是 7 , 7 是奇数,那么要进位把 7 变成偶数 8,最后的结果应该是 2.68 。
而实际运行结果确是 2.67 !
这又是为啥呢?不得不说,Python 中 round()
的坑真是多啊!😅😅😅
原来:目前计算机中的数据,都是采用 二进制 的方式进行存储和运算的;而程序中的 2.675 ,则是写给人看的,则是 十进制 的。
每一个十进制的整数,都可以表示成一个有限的二进制数。如十进制的 13 ,可以表示成二进制的 1101 ;但不是每一个十进制的小数,都能转换成一个有限的二进制小数;只有当十进制小数能够表示为2的负幂次之和时,它才能被表示为有限的二进制小数。否则,它将是一个无限循环的二进制小数。以十进制的0.1为例,它在二进制中表示为0.0 0011 0011 0011 0011…(其中“0011”这个序列无限循环)。因为没有一个有限的二进制数能够精确地等于十进制的0.1,所以它无法用有限的二进制数表示。
而目前的计算机都是有精度限制的(32位、64位之类),因此,这些数字在计算机中存储的就不是刚刚好,而是一个近似的值,或者比实际想表达的十进制数略小,或者略大。
比如, 2.675 在计算机内部的实际表示,是略小于 2.675 的:
import decimal
print(decimal.Decimal(2.675))
# `输出 2.67499999999999982236431605997495353221893310546875`
所以,round(2.675, 2)
实际上相当于 round(2.67499999999999982236431605997495353221893310546875, 2)
,对于 2.674999... 这个数,取小数点后保留2位的数值,按照“四舍六入五取偶”的原则,小数点后第3位实际存储在计算机中的是 4 ,“四舍”后,当然得到的就是 2.67 啦!🙂🙂🙂
4、高级篇
最后,对于还想进一步深入挖掘、研究的同学,再给大家一些底层的线索,感兴趣的(喜欢刨根问底的😂),可以进一步去探索。
下载 CPython 的源码包,解压缩后,在 Python\bltinmodule.c 中,builtin_round_impl()
是内置函数 round()
的具体实现;其中,又涉及到了更底层的调用,想要搞明白的,可以继续看下去,写得太累,我是暂时不想再写下去了😂😂😂
/*[clinic input]
round as builtin_round
number: object
ndigits: object = None
Round a number to a given precision in decimal digits.
The return value is an integer if ndigits is omitted or None. Otherwise
the return value has the same type as the number. ndigits may be negative.
[clinic start generated code]*/
static PyObject *
builtin_round_impl(PyObject *module, PyObject *number, PyObject *ndigits)
/*[clinic end generated code: output=ff0d9dd176c02ede input=275678471d7aca15]*/
{
PyObject *round, *result;
if (!_PyType_IsReady(Py_TYPE(number))) {
if (PyType_Ready(Py_TYPE(number)) < 0)
return NULL;
}
round = _PyObject_LookupSpecial(number, &_Py_ID(__round__));
if (round == NULL) {
if (!PyErr_Occurred())
PyErr_Format(PyExc_TypeError,
"type %.100s doesn't define __round__ method",
Py_TYPE(number)->tp_name);
return NULL;
}
if (ndigits == Py_None)
result = _PyObject_CallNoArgs(round);
else
result = PyObject_CallOneArg(round, ndigits);
Py_DECREF(round);
return result;
}
没想到这个简单的知识点、二级Python中最多只有1分的 Python 选择题,竟然费了这么大的篇幅来写😂……
能耐着性子,把这篇长文看完的,一定是有着坚韧不拔的毅力,独立思考的习惯,和深入挖掘的精神,干啥啥成,给你点赞!👍👍👍
看完这篇,对round()函数或者其它Python知识点还有疑惑的,欢迎留言!