跳转链接
- 高精度计算入门与immense数据模型精讲(一)
- 高精度计算入门与immense数据模型精讲(二)
- 高精度计算入门与immense数据模型精讲(四)
- 高精度计算入门与immense数据模型精讲(五)
- 高精度计算入门与immense数据模型精讲(六)
- 高精度计算入门与immense数据模型精讲(七)
- 高精度计算入门与immense数据模型精讲(八)
- 高精度计算入门与immense数据模型精讲(九)
- immense大实数数据库
前言:比较运算,所有高级运算的基石
学完归一化与类型转换,我们已经能让一个高精度数保持规范、正确地进出系统。接下来自然要进入运算部分,但在讲加减乘除之前,必须先把比较运算讲透。
很多人觉得比较很简单,不就是逐位比大小吗?但实际上,比较是高精度库中最基础、也最容易藏细节bug的模块。它不只是用来给用户判断大小——除法的二分试商要靠它、开方的迭代终止要靠它、排序去重要靠它、取整取模也要靠它。可以说,所有高级运算的正确性,都建立在比较运算的正确性之上。
本篇我们依然遵循「通用原理先行,代码实现对照」的节奏:先把高精度比较的通用法则讲透,让你明白“为什么要这么比”;再对照immense的源码,看它是如何把四层裁决逻辑落地成具体代码的。读完你会发现,短短几十行的比较函数里,藏着非常多的设计考量。
一、通用原理:高精度比较的核心法则
1.1 比较的本质:高位优先原则
我们人手比较两个数的大小,直觉就是「先看谁位数多,位数一样就从左往右一位一位比」。这个直觉背后,是一条最核心的原则:高位优先原则。
一个数位的权重,越靠左越大。比如百位的 1 比十位的 9 还大。所以只要高位分出了大小,后面低位是什么都不重要了。
对于高精度实数,完整的比较优先级是:
- 符号> 2.整数位数> 3.整数逐位> 4.小数逐位> 5.小数长度
越靠前的裁决,权重越高,能提前分出结果就立刻终止,不用再比后面。这既是正确性的保证,也是性能优化的关键。
教学要点:所有高精度比较算法,无论整数还是实数,无论十进制还是二进制,都严格遵守高位优先。这是一条通用铁律。
1.2 第一层裁决:符号优先
两个数比较,第一步永远是看符号。这是成本最低、区分度最高的判断。
通用规则只有三条:
- 正数 > 负数
- 都是正数,继续比绝对值
- 都是负数,继续比绝对值,但最终结果要翻转(绝对值大的负数反而更小)
绝大多数异号比较,在这一步就能直接出结果,时间复杂度 O(1)。
这里要特别提醒一个经典坑:零值的符号。如果库允许负零存在,那么0和-0符号不同,但数值相等,会导致相等判断失效。这也是第二篇强调「零值符号必须统一为正」的原因——比较运算重度依赖这个不变式。
1.3 第二层裁决:整数位数
符号相同之后,先比整数部分的位数。
道理很简单:两位数一定大于一位数,三位数一定大于两位数。整数位数多的那个,绝对值一定更大。
- 整数位数多 → 绝对值大
- 整数位数少 → 绝对值小
- 位数相等 → 进入下一层逐位比较
教学要点:这一步成立的前提,是整数部分没有前导零。如果带着前导零,
"00123"位数是 5,"456"位数是 3,会误判前者更大。所以归一化是比较正确的前提——没有归一化,比较逻辑全是空中楼阁。
1.4 第三层裁决:整数逐位比对
整数位数相同,就从最高位开始,往最低位逐位比对。
- 遇到第一位不相等的,就能分出大小
- 全部相等,进入小数部分比较
这里有一个非常容易出错的细节:遍历方向由存储顺序决定。
| 存储方式 | 最高位位置 | 遍历方向 |
|---|---|---|
| 正序存储(高位在前) | 字符串下标 0 | 从左往右(i 从 0 到 n-1) |
| 倒序存储(低位在前) | 字符串末尾 | 从右往左(i 从 n-1 到 0) |
很多初学者记不住,写出来的比较逻辑方向反了,结果变成个位决定大小,完全错误。记住一个简单的判断方法:
永远从最高位那一侧开始比。正序最高位在左,就从左往右;倒序最高位在右,就从右往左。
1.5 第四层裁决:小数部分比对
整数部分完全相等,才需要比小数。小数的比较逻辑和整数对称,但方向是从左往右(十分位 → 百分位 → 千分位),因为十分位是小数的最高位。
这里会遇到一个新问题:两个数的小数长度不一样怎么办?
通用处理方法:短的一方补零等效。比如1.2和1.234比较,等价于1.200和1.234比较。
- 先比对公共长度内的每一位
- 如果公共部分全部相等,再看谁的小数更长:更长的那个更大(因为多出来的位都是有效数字,至少有一位非零,归一化保证了尾部没有零)
为什么归一化之后可以这么判断?因为如果小数尾部有零,归一化会删掉。所以只要小数更长,多出来的部分一定以非零数字开头,所以数值一定更大。这又是归一化带来的便利。
1.6 负数的结果翻转
前面所有比出来的都是「绝对值大小」。如果两个数都是负数,最后要把结果反过来:
- 绝对值大的负数,数值更小
- 绝对值小的负数,数值更大
工程上有两种常见写法:
- 先算
abs(a) > abs(b),再根据符号决定是否取反 - 把符号和结果合并成一个逻辑表达式,一步返回
两种写法等价,第二种更简洁,第一种更易读。
1.7 工程最佳实践:六向比较只实现两个核心
完整的比较运算符有六个:==、!=、>、<、>=、<=。
但成熟的库绝不会把六个函数各写一遍逻辑——那样不仅代码冗余,还容易出现逻辑不一致的bug。
通用做法是:只核心实现==和>,剩下四个全部用这两个推导出来。
a != b等价于!(a == b)a < b等价于!(a >= b)a >= b等价于a > b || a == ba <= b等价于!(a > b)
这样做的好处:
- 核心逻辑只有一处,改bug只改一个地方
- 六个运算符逻辑绝对一致,不会出现自相矛盾
- 代码量大幅减少,维护成本低
这是运算符重载的经典设计范式,几乎所有C++数值类库都会遵守。
1.8 常见坑点盘点
初学者写比较运算,高频踩坑集中在这几个地方:
- 前导零干扰位数判断
- 现象:
"0012"被当成 4 位数,比"34"大 - 根源:没有做归一化,比较前没清前导零
- 现象:
- 遍历方向搞反
- 现象:倒序存储的整数,从下标 0 开始比,变成个位决定大小
- 根源:没搞清楚最高位在哪一侧
- 负零导致相等失效
- 现象:
0 == -0返回 false - 根源:零值符号不统一
- 现象:
- 小数长度不同时直接判等
- 现象:
1.2和1.20被判断为不相等 - 根源:忽略了尾部零,或者没做归一化
- 现象:
- 负数忘记翻转结果
- 现象:
-3 > -2返回 true - 根源:只比了绝对值,忘记负数结果相反
- 现象:
理解了这些通用原理和坑点,再去看任何一个高精度库的比较代码,都会一目了然。
二、immense 实现对照:比较运算符逐行精讲
immense的比较运算严格遵循上述通用法则:核心实现operator==和operator>,剩下四个全部是一行推导。我们从最简单的相等判断讲起,再深入核心的大于判断。
2.1 相等判断 operator==:归一化带来的简洁
源码非常短,只有一行:
inline bool immense::operator==(const immense& other)const{ return (this->symbol==other.symbol) && (this->DCMp==other.DCMp) && (this->LTGp==other.LTGp); }三个条件同时满足就相等:符号相同、整数串相同、小数串相同。
为什么可以直接比字符串?因为归一化保证了存储的唯一性:
- 没有前导零,所以同一个整数只有一种字符串表示
- 没有尾部零,所以同一个小数只有一种字符串表示
- 零值符号统一为正,不会出现负零
所以只要字符串相等,数值就一定相等。反过来,数值相等,字符串也一定相等。
这就是归一化的巨大价值——它让相等判断变成了简单的字符串比较,O(n) 但代码极其简洁,几乎不可能写错。
2.2 大于判断 operator>:四层裁决的完整落地
operator>是整个比较体系的核心,所有大小关系都由它派生。我们分段拆解,对应前面讲的四层裁决。
完整源码:
bool immense::operator>(const immense& other)const{ if(*this==other)return false; if(this->symbol>other.symbol)return true; if(this->symbol<other.symbol)return false; bool x=this->symbol,y=true; if(this->LTGn!=other.LTGn){ if(this->LTGn>other.LTGn)y=true; else y=false; goto finish; } for(int i=this->LTGn-1;i>=0;i--){ if(this->LTGp[i]>other.LTGp[i]){ y=true; goto finish; } if(this->LTGp[i]<other.LTGp[i]){ y=false; goto finish; } } for(int i=0;i<std::min(this->DCMn,other.DCMn);i++){ if(this->DCMp[i]-'0'>other.DCMp[i]-'0'){ y=true; goto finish; } if(this->DCMp[i]-'0'<other.DCMp[i]-'0'){ y=false; goto finish; } } if(this->DCMn>other.DCMn)y=true; if(this->DCMn<other.DCMn)y=false; finish: ; return x==y; }我们一层一层对应。
第一步:相等快速返回
if(*this==other)return false;相等的两个数,谁也不大于谁,直接返回 false。这是前置快速路径。
第二步:符号裁决
if(this->symbol>other.symbol)return true; if(this->symbol<other.symbol)return false;symbol是 bool 类型,true代表正、false代表负。在C++里,true > false成立。
- 本号为正、对方为负 → 直接返回 true(正数大于负数)
- 本号为负、对方为正 → 直接返回 false(负数小于正数)
对应第一层裁决,符号不同一步出结果。
第三步:整数位数裁决
bool x=this->symbol,y=true; if(this->LTGn!=other.LTGn){ if(this->LTGn>other.LTGn)y=true; else y=false; goto finish; }符号相同之后,先比整数位数。
y变量存的是「绝对值比较的结果」,y=true表示本数绝对值更大- 位数多 → 绝对值大 → y=true
- 位数少 → 绝对值小 → y=false
分出结果直接goto finish,不用再比后面。这就是高位优先的提前终止优化。
第四步:整数逐位裁决
for(int i=this->LTGn-1;i>=0;i--){ if(this->LTGp[i]>other.LTGp[i]){ y=true; goto finish; } if(this->LTGp[i]<other.LTGp[i]){ y=false; goto finish; } }这里对应第三层裁决,重点看遍历方向:
LTGp是倒序存储,最高位在字符串末尾- 所以循环从
LTGn-1(最后一位)开始,往 0 走 - 遇到第一位不同的,立刻设置 y 并跳出
对照教学要点:倒序存储从右往左比,最高位优先。这行代码完美对应通用原理。
第五步:小数逐位裁决
for(int i=0;i<std::min(this->DCMn,other.DCMn);i++){ if(this->DCMp[i]-'0'>other.DCMp[i]-'0'){ y=true; goto finish; } if(this->DCMp[i]-'0'<other.DCMp[i]-'0'){ y=false; goto finish; } }小数是正序存储,最高位(十分位)在下标 0,所以从 0 开始正序遍历。
- 只遍历公共长度部分
- 第一位不同就分出结果,立刻跳出
完全对应第四层裁决的通用逻辑。
第六步:小数长度裁决
if(this->DCMn>other.DCMn)y=true; if(this->DCMn<other.DCMn)y=false;公共部分全部相等,就看谁的小数更长。
- 本数小数更长 → 绝对值更大 → y=true
- 更短 → y=false
成立的前提依然是归一化:尾部没有零,更长就意味着多出来的部分有有效数字。
第七步:符号与结果合并
finish: ; return x==y;这是整个函数最巧妙的一行。x是本数的符号,y是绝对值比较结果。
- 当
x=true(两数都是正数):y 为 true 就返回 true,符合预期 - 当
x=false(两数都是负数):y 为 true 说明本数绝对值更大,负数绝对值大反而小,所以返回 false
用一个x==y的表达式,同时处理了正数和负数两种情况,不需要写 if-else 分支。非常简洁的技巧。
2.3 四个衍生运算符:极简复用
剩下四个运算符全部是一行推导,严格遵守「核心只写两个」的工程原则:
inline bool immense::operator>=(const immense& other)const{ return (*this>other||*this==other); } inline bool immense::operator<=(const immense& other)const{ return (!(*this>other)); } inline bool immense::operator<(const immense& other)const{ return (!(*this>=other)); } inline bool immense::operator!=(const immense& other)const{ return (!(*this==other)); }对应关系:
>=:大于 或 等于<=:不大于<:不大于等于!=:不相等
全部由==和>派生,逻辑完全一致,没有冗余。
2.4 设计细节点评
- 大量 goto 提前终止:每分出一次结果就
goto finish,避免无效遍历。对于高精度大数来说,提前终止带来的性能收益非常明显。 - 符号与数值分离:先算绝对值结果 y,最后统一和符号 x 合并。逻辑分层清晰,不容易写错负数分支。
- 完全依赖归一化:整个函数没有任何清理前导零、尾部零的逻辑,默认输入都是规范的。这就是「集中归一化」带来的代码简洁性。
三、拓展:比较运算的性能与应用价值
3.1 时间复杂度分析
比较运算的时间复杂度是O(n),n 为总位数。但这是最坏情况——两个数几乎完全一样,要比到最后一位才能分出结果。
实际场景中,绝大多数比较在前几层就终止了:
- 符号不同:O(1)
- 位数不同:O(1)
- 前几位就分出大小:远小于 O(n)
所以比较运算在实际使用中通常非常快。
3.2 为什么说比较是高级运算的基础
后面讲除法的时候你会看到,immense的除法采用「二分试商法」:每一位商都在 0~9 之间二分查找,而二分判断的依据,就是「余数和除数 × 商的大小比较」。
除此之外:
- 取整函数需要和 0.5 比较
- 泰勒级数需要判断余项是否小于精度阈值
- 开方迭代需要判断前后两次结果的差值
- 排序、去重、查找更不用说
可以说,比较运算就是高精度算法的「基础设施」。它写起来不难,但一旦写错,后面所有运算都会跟着错。
3.3 一个验证小练习
学到这里,你可以做一个简单的验证:自己手算几个案例,再对照代码逻辑走一遍,看结果是否一致。
immense("123") > immense("45")→ 位数不同,trueimmense("-123") > immense("-45")→ 都是负数,绝对值大的更小,falseimmense("1.23") > immense("1.234")→ 整数相同,小数公共部分相同,短的更小,falseimmense("0") == immense("-0")→ 归一化后负零转正,true
如果全部算对,说明你已经真正理解了比较逻辑。
四、本篇小结与下篇预告
4.1 核心知识点回顾
通用原理部分:
- 高位优先原则:符号 > 整数位数 > 整数逐位 > 小数逐位 > 小数长度
- 负数比较:先比绝对值,结果翻转
- 工程最佳实践:只核心实现
==和>,其余四个派生复用 - 四大常见坑:前导零、遍历方向、负零、小数尾部零
实现对照部分:
operator==靠归一化保证,直接比字符串即可operator>完整实现了四层裁决,用 goto 做提前终止优化- 最后用
x==y的技巧统一处理正负号结果 - 四个衍生运算符全部由核心两个派生,无冗余逻辑
4.2 下篇预告
比较是基础,加减是进阶。下一篇我们将进入真正的竖式运算——加法与减法。
我们会讲清楚:小数点怎么对齐、进位和借位怎么处理、为什么要做「符号中心化」、带符号加减如何降维成无符号加减。同时对照immense里的Add、Less、Centre_AL三个函数,把加减运算的每一步都拆解透彻。
加减是竖式运算的第一个完整案例,吃透了它,后面学乘法会轻松很多。