80、实战:高精度计算(任意精度浮点数+牛顿迭代)
说实话,浮点数精度问题,是每个C++程序员迟早要面对的坎。
我记得刚入行那会儿,写一个金融计算模块,double类型算利息,结果对不上账。排查了半天,发现是0.1 + 0.2不等于0.3——经典问题。老板差点让我赔钱。从那以后,我对浮点数精度就特别敏感。
今天咱们要聊的,就是怎么在C++里实现任意精度浮点数,并且用牛顿迭代法做高精度开方运算。说白了,就是自己造一个能算几百位小数的轮子。
为什么需要任意精度?
double类型只有53位有效数字,大约15-17位十进制精度。够用吗?大部分场景够了。但有些场景不行:
- 金融计算:一分钱都不能差,double的舍入误差会累积
- 科学计算:某些迭代算法对精度敏感,低精度会导致不收敛
- 密码学:大数运算需要精确到每一位
- 教学演示:想看看牛顿迭代法到底能收敛到多少位
我个人的习惯是:能用double就别自己造轮子。但当你真的需要任意精度时,就得从底层开始设计。
核心思路:用整数模拟浮点数
任意精度浮点数的实现思路其实不复杂:
- 用一个大整数表示有效数字(比如用vector<uint32_t>存每一位)
- 用一个整数指数表示小数点位置
- 符号位单独处理
举个例子:3.141592653589793 可以表示为 3141592653589793 × 10^(-15)。
这样,加减乘除就变成了大整数的运算,再加上指数对齐。嗯,听起来简单,但实现起来坑不少。
关键设计决策:我选择用十进制而不是二进制。为什么?因为十进制对人类更友好,输出调试方便,而且避免了二进制浮点数那种0.1无法精确表示的问题。代价是性能稍差——但既然要任意精度,性能本来就不是首要目标。
代码实现:高精度浮点数类
先看一个精简版实现。我把它叫做 BigFloat:
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cassert>
class BigFloat {
private:
std::vector<int> digits; // 数字位,每个元素0-9
int exponent; // 指数,表示小数点位置
bool sign; // true为正,false为负
// 去除前导零
void trim() {
while (digits.size() > 1 && digits.back() == 0) {
digits.pop_back();
}
if (digits.size() == 1 && digits[0] == 0) {
sign = true; // 正零
exponent = 0;
}
}
public:
BigFloat() : digits({0}), exponent(0), sign(true) {}
BigFloat(const std::string& s) {
// 解析字符串,例如 "123.456" 或 "-0.00123"
size_t pos = 0;
sign = true;
if (s[pos] == '-') { sign = false; pos++; }
if (s[pos] == '+') { pos++; }
std::string intPart, fracPart;
bool hasDot = false;
for (; pos < s.size(); pos++) {
if (s[pos] == '.') {
hasDot = true;
continue;
}
if (!hasDot) intPart += s[pos];
else fracPart += s[pos];
}
// 合并所有数字
std::string all = intPart + fracPart;
// 去除前导零
size_t firstNonZero = all.find_first_not_of('0');
if (firstNonZero == std::string::npos) {
digits = {0};
exponent = 0;
sign = true;
return;
}
all = all.substr(firstNonZero);
digits.clear();
for (char c : all) {
digits.push_back(c - '0');
}
std::reverse(digits.begin(), digits.end());
// 指数 = 小数部分长度
exponent = -static_cast<int>(fracPart.size());
// 如果有整数部分,调整指数
if (!intPart.empty() && intPart != "0") {
exponent += static_cast<int>(intPart.size() - firstNonZero);
}
// 如果整数部分全是0,需要额外处理
if (intPart.find_first_not_of('0') == std::string::npos) {
// 整数部分全是0,指数就是小数部分长度(负的)
// 但要去掉小数部分的前导零
size_t leadingZeros = 0;
while (leadingZeros < fracPart.size() && fracPart[leadingZeros] == '0') {
leadingZeros++;
}
exponent = -static_cast<int>(fracPart.size() - leadingZeros);
}
}
std::string toString() const {
if (digits.empty() || (digits.size() == 1 && digits[0] == 0)) {
return "0";
}
std::string result;
if (!sign) result += '-';
// 从高位到低位
std::string all;
for (int i = static_cast<int>(digits.size()) - 1; i >= 0; i--) {
all += static_cast<char>('0' + digits[i]);
}
int pointPos = exponent + static_cast<int>(digits.size());
if (pointPos <= 0) {
// 纯小数,如 0.00123
result += "0.";
for (int i = 0; i < -pointPos; i++) result += '0';
result += all;
} else if (pointPos >= static_cast<int>(all.size())) {
// 纯整数,如 12300
result += all;
for (int i = 0; i < pointPos - static_cast<int>(all.size()); i++) result += '0';
} else {
// 混合,如 123.456
result += all.substr(0, pointPos);
result += '.';
result += all.substr(pointPos);
}
return result;
}
// 加法(简化版,仅用于演示)
BigFloat operator+(const BigFloat& other) const {
// 实际实现需要对齐指数,逐位相加,处理进位
// 这里省略完整实现,仅展示接口
return BigFloat("0");
}
// 乘法(简化版)
BigFloat operator*(const BigFloat& other) const {
// 实际实现:大整数乘法 + 指数相加
return BigFloat("0");
}
};
我的经验:写这个类的时候,最容易出bug的地方是指数计算。特别是处理前导零和后置零时,指数很容易算错。我建议你写一个完整的单元测试,覆盖"0.00123"、"123.456"、"100"、"0"这些边界情况。
牛顿迭代法求平方根
有了BigFloat,我们就可以实现高精度开方了。牛顿迭代法的公式很简单:
x_{n+1} = (x_n + a / x_n) / 2
其中a是要求平方根的数,x_n是第n次迭代的近似值。
为什么用牛顿迭代?因为它收敛速度极快——每次迭代有效位数翻倍。你想想看,初始值有1位精度,迭代一次变2位,再迭代变4位、8位、16位……10次迭代就能到1024位精度。
BigFloat sqrtNewton(const BigFloat& a, int precision) {
// 处理负数
if (!a.sign) {
throw std::runtime_error("不能对负数开平方");
}
// 初始猜测:用double算一个近似值
double initVal = std::stod(a.toString());
BigFloat x(std::to_string(initVal));
// 迭代直到达到精度
for (int iter = 0; iter < 20; iter++) {
// x = (x + a / x) / 2
BigFloat quotient = a / x; // 需要实现除法
BigFloat sum = x + quotient;
x = sum / BigFloat("2");
// 检查收敛情况(简化版)
// 实际应该比较两次迭代的差值
std::cout << "迭代 " << iter+1 << ": " << x.toString() << std::endl;
}
return x;
}
注意:上面的代码中,除法 a / x 和 sum / 2 需要BigFloat实现除法运算。除法是BigFloat里最复杂的运算,通常用长除法或者牛顿迭代法求倒数来实现。我建议你先实现乘法,然后用牛顿迭代法求倒数,再乘以被除数——这样代码复用性更好。
核心知识体系
我把本章的知识结构整理成了一张图,方便你理解各个模块之间的关系:
避坑指南
我曾经在实现BigFloat的除法时踩过一个坑,花了整整两天才找到bug。这里分享给你:
- 指数溢出:当指数非常大或非常小时,int可能不够用。我建议用
int64_t或者自己实现一个大整数指数。 - 精度截断:每次运算后都要做精度截断,否则数字会无限膨胀。我习惯在每次运算后保留
precision + 10位,最后再统一截断。 - 除零检查:除法前一定要检查除数是否为零。BigFloat的零判断不能直接用
== 0,要写一个isZero()方法。 - 牛顿迭代的初始值:初始值越接近真实值,收敛越快。我通常先用double算一个近似值,再转成BigFloat开始迭代。
性能优化小技巧:如果你只需要几百位精度,可以用std::vector<uint32_t>存储,每个元素存9位十进制数(因为2^32 ≈ 4.3e9)。这样乘法可以用FFT加速,效率能提升一个数量级。
总结
高精度计算的核心就三件事:大整数存储、指数管理、牛顿迭代。
大整数存储是地基,指数管理是框架,牛顿迭代是应用。三者缺一不可。
我建议你从最简单的十进制实现开始,跑通牛顿迭代求平方根,感受一下精度从double的15位扩展到100位、1000位的过程。那种掌控感,说实话,挺爽的。
等你把十进制版本跑通了,再考虑优化性能、改成二进制、加FFT乘法——那时候你已经是个高精度计算的老手了。
公众号:蓝海资料掘金营,微信deep3321