备战蓝桥杯Java组的同学,到了第十天,基本已经把基础语法、集合框架、常用算法都过了一遍,这时候最容易卡住的一个点就是高精度计算。这里的“高精度”不是单片机调ADC采样精度,也不是数据处理里的精度校准,而是纯粹意义上的大整数运算——你用long存不下、用double会丢精度、老老实实自己写数组模拟又容易在进位借位前导零这些细节上翻车的那些题。蓝桥杯Java组里,高精度很少单独出成一道压轴难题,但它经常作为递推、组合数学、快速幂的中转站,不会它,很多题做到一半就断档了。这篇文章我把高精度在Java里的三种主流玩法——BigInteger、BigDecimal、手写数组,从原理到真题再到坑点一次讲透,适合正在刷蓝桥杯真题、或者面试前突击Java大数操作的同学直接抄作业。
1. 高精度问题到底是什么?蓝桥杯Java组为什么躲不开
1.1 算不下的数:大整数运算的本质
先聊一个最根本的问题:什么时候需要高精度?
Java里的long类型,最大能表示约9.22乘以10的18次方,这个数字看起来很大,但放到算法题里根本不是个事。题目里随便来一个“求100的阶乘”“求斐波那契数列第500项”“求2的1000次方”,结果立刻膨胀到几十位甚至几百位数字,long直接溢出,double虽然能表示很大范围但精度不够,低位数字全是错的。
高精度计算的本质,就是把人手算竖式的过程交给程序去做:把一个超长的数字拆成一位一位(或者几位一组),存进数组或者字符串里,然后逐位相加、相减、相乘,处理进位和借位。BigInteger在Java底层干的就是这件事,只不过它帮你把竖式运算封装好了,内部用int数组存储数值,每个数组元素保存一段二进制数据,对外提供add、subtract、multiply这些看起来像普通整数运算的方法。
我在带学生刷题时经常说一句话:高精度题考的不是你会不会乘法口诀,而是你能不能把“竖式”这个小学概念,转化成边界条件清晰、不会越界、不会漏进位的代码。很多同学一看数据范围是10的100次方,就开始慌,其实只要理解了存储方式和运算逻辑,这类题反而是送分题。
1.2 高精度在蓝桥杯里的出场方式
蓝桥杯的高精度题,直接出“大数加法”“大数减法”其实很少,因为太直白,区分度不够。真正常见的是下面几种变形:
第一种是作为基础练习出现,比如“高精度减法”,分数10分,输入两个长度不超过100位的正整数,输出差。这种题基本就是送分,但每年都有不少人在前导零和负数处理上丢分。
第二种是藏在递推和动态规划里。斐波那契、卡特兰数、第二类斯特林数这些数列,项数一大,数值就是天文数字。题目本身考的是递推公式或者DP状态转移,但你的变量类型必须是高精度才能装下最终结果。
第三种是结合数论,比如大数取模、快速幂取模、组合数计算。这时候BigInteger的modPow、modInverse这些方法就非常有用,能帮你跳过手写快速幂的麻烦。
还有一类比较偏的,就是进制转换。比如蓝桥杯基础练习里的“十六进制转八进制”,数据范围大到直接用Long会溢出,这时候用BigInteger的toString(8)一行搞定。
把这几种出场方式串起来看,你会发现高精度在蓝桥杯里更像是一个“基础设施”,就像你写工程代码时要用的日志工具一样,单独拿出来不值钱,但没有它很多功能就实现不了。
2. Java选手的高精度三板斧:BigInteger、BigDecimal、数组模拟
2.1 BigInteger 高频方法速查与选择逻辑
BigInteger是Java里处理大整数的官方方案,位于java.math包下,不需要额外导入第三方库。它的用法和我们熟悉的int、long几乎一致,但注意所有运算都返回新的BigInteger对象,因为BigInteger是不可变类。
我整理了一张备战蓝桥杯时最常用的方法表,你直接照着查就行:
| 方法 | 作用 | 使用示例 |
|---|---|---|
| add | 加法 | a.add(b) |
| subtract | 减法 | a.subtract(b) |
| multiply | 乘法 | a.multiply(b) |
| divide | 除法,结果截断 | a.divide(b) |
| remainder | 取余,符号与被除数相同 | a.remainder(b) |
| mod | 取模,结果恒为非负 | a.mod(b) |
| pow | 幂运算 | a.pow(10) |
| modPow | 模幂运算,快速幂取模 | a.modPow(exp, mod) |
| modInverse | 模逆元,要求与mod互质 | a.modInverse(mod) |
| gcd | 最大公约数 | a.gcd(b) |
| compareTo | 比较大小,返回-1、0、1 | a.compareTo(b) |
| toString(radix) | 按指定进制转字符串 | a.toString(16) |
| valueOf | 将long转成BigInteger | BigInteger.valueOf(100) |
| isProbablePrime | 概率素数判断 | a.isProbablePrime(100) |
| shiftLeft / shiftRight | 左移右移 | a.shiftLeft(10) |
| testBit | 判断某一位是否为1 | a.testBit(0) |
实际用的时候有几个细节值得注意。第一,代码里尽量用BigInteger.ZERO、BigInteger.ONE、BigInteger.TEN这些常量,不要每次都valueOf(0)去创建对象,虽然影响不大,但养成好习惯总没错。第二,BigInteger的equals方法比较的是数值是否相等,而compareTo也是比较数值大小,两者在BigInteger这里语义一致,但到了BigDecimal那儿就有坑了,这个后面细说。第三,new BigInteger(String)和valueOf(long)的适用场景不同,前者用来解析输入的大数字符串,后者用来把int、long值转换进去。
2.2 BigDecimal:小数高精度的隐藏考点
BigInteger解决的是整数高精度,BigDecimal解决的是小数高精度。Java里float和double都是浮点数的二进制近似表示,0.1加0.2这种简单运算都会得到一个莫名其妙的结果,比如0.30000000000000004。高精度小数题在蓝桥杯里出现频率不高,但在一些模拟题、计算几何题、概率题里会作为干扰项出现。
BigDecimal的核心方法包括add、subtract、multiply、divide、setScale、compareTo、stripTrailingZeros、toPlainString。其中最容易出问题的是divide,因为小数除法可能除不尽,你必须指定精度和舍入方式,否则会抛ArithmeticException。比如:
BigDecimal a = new BigDecimal("10"); BigDecimal b = new BigDecimal("3"); BigDecimal result = a.divide(b, 10, RoundingMode.HALF_UP); // 保留10位小数,四舍五入这个10代表保留的小数位数,RoundingMode.HALF_UP是我们熟悉的四舍五入,还有HALF_DOWN、CEILING、FLOOR等模式,具体用哪个要看题目要求。
另一个常见的坑是BigDecimal的equals和compareTo不一致。equals不仅比较数值,还比较精度scale,所以new BigDecimal("1.0")和new BigDecimal("1.00")用equals判等是false,但用compareTo判等是true。蓝桥杯里比较小数大小,一律用compareTo,别用equals。
最后别忘了,println一个BigDecimal时,如果数值很大或很小,可能输出科学计数法,比如1E+20。如果题目要求输出完整数字,用toPlainString方法转成不带指数的字符串。
2.3 什么时候别用BigInteger,自己写数组反而更快
说句实在话,BigInteger虽然好用,但性能确实不如手写数组。原因在于BigInteger是不可变对象,每次运算都new一个新对象,而且内部存储用的是int数组表示二进制大整数,加减乘除都要重新分配内存。数据长度只有几十位时区别不大,但一旦数字长度达到几千位,或者循环执行几万次,BigInteger可能直接把你卡到超时。
我遇到过一道题,要求计算10000的阶乘,用BigInteger循环乘,本地跑了两秒多,换成手写数组模拟乘法,不到一百毫秒就跑完了。蓝桥杯Java组的评测机配置不算高,时间限制经常是1秒,这种差距就是AC和TLE的区别。
所以我的建议是:如果题目数据长度在100位以内,或者只需要做几次运算,直接无脑用BigInteger;如果题目要求输出几百上千位的大数,而且涉及大量乘法和加法,自己用int数组写一个简单的高精度模板更稳妥。后面第三章我会给出具体的模板代码,你直接背下来用就行。
3. 真题拆解:从高精度减法到大数递推,一步步AC
3.1 10分的高精度减法:读题、写码、过样例
先看一道最典型的高精度减法题。题目描述很简洁:计算两数之差,输入共两行,第一行是被减数a,第二行是减数b,题目保证a大于b,每个数的长度不超过100位,输出a减b的结果。
这种题用BigInteger写就是三行核心代码:
import java.math.BigInteger; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); BigInteger a = new BigInteger(sc.nextLine().trim()); BigInteger b = new BigInteger(sc.nextLine().trim()); System.out.println(a.subtract(b)); sc.close(); } }注意几个细节:读入时用trim去掉可能存在的首尾空格和换行;题目说a大于b,所以不需要处理负数,但如果你做题时发现评测数据里可能出现a等于b的情况,那结果就是0,BigInteger会正确输出0,不会出问题。
如果用C++的思路手写数组,套路就是倒序存储、逐位相减、处理借位、去掉前导零。Java版模板我放在下面:
public class BigSub { // 高精度减法,前提 a >= b,参数为数字字符串,返回结果为字符串 public static String subtract(String a, String b) { StringBuilder sb = new StringBuilder(); int i = a.length() - 1, j = b.length() - 1; int borrow = 0; while (i >= 0 || j >= 0) { int da = i >= 0 ? a.charAt(i) - '0' : 0; int db = j >= 0 ? b.charAt(j) - '0' : 0; int diff = da - db - borrow; if (diff < 0) { diff += 10; borrow = 1; } else { borrow = 0; } sb.append(diff); i--; j--; } while (sb.length() > 1 && sb.charAt(sb.length() - 1) == '0') { sb.deleteCharAt(sb.length() - 1); } return sb.reverse().toString(); } }这段代码的要点是:被减数的每一位和减数的对应位相减,再减去借位borrow;如果不够减,就向高位借1,相当于当前位加10,borrow置1;最后结果为了处理方便是倒着存的,所以反转回去,同时去掉最高位多余的零。
3.2 大数阶乘与斐波那契:BigInteger的正确打开方式
计算阶乘是大数题的常客。比如输入n,输出n的阶乘,n的范围可能在1000到10000之间。n等于1000时,阶乘有2568位,long连零头都装不下。
用BigInteger实现阶乘很直接:
import java.math.BigInteger; import java.util.Scanner; public class Factorial { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); BigInteger result = BigInteger.ONE; for (int i = 2; i <= n; i++) { result = result.multiply(BigInteger.valueOf(i)); } System.out.println(result); sc.close(); } }但这里我要多说一句:当n很大时,BigInteger的multiply性能问题就会暴露。10000的阶乘,循环10000次,每次的乘数从1变到10000,结果的长度逐渐增长到几万位,整体耗时非常可观。如果你的目标是蓝桥杯拿高分,建议在这道题上直接上手写数组乘法。
手写数组计算阶乘,核心是“大数乘以普通整数”的模板:
public class BigMulInt { // 数组倒序存储大数,每个元素存0~9,乘以int b,返回新数组 public static int[] multiplySmall(int[] a, int b) { if (b == 0) return new int[] {0}; int carry = 0; int[] res = new int[a.length + 20]; for (int i = 0; i < a.length; i++) { int cur = a[i] * b + carry; res[i] = cur % 10; carry = cur / 10; } int k = a.length; while (carry > 0) { res[k++] = carry % 10; carry /= 10; } return Arrays.copyOf(res, k); } }这里数组是倒序存的,res[0]存个位,res[1]存十位,依此类推。每次乘完一位,把结果模10存下来,整除10的部分作为进位往高位传。循环结束后,如果进位还没处理完,就继续往高位写。
斐波那契数列也一样,第500项大概有105位数字,用BigInteger递推非常轻松:
public static BigInteger fib(int n) { if (n <= 1) return BigInteger.valueOf(n); BigInteger a = BigInteger.ZERO; BigInteger b = BigInteger.ONE; for (int i = 2; i <= n; i++) { BigInteger t = a.add(b); a = b; b = t; } return b; }很多同学在递推大数时容易犯一个毛病:用int数组去存储每一项,结果在计算过程中不断扩容,效率很低。其实BigInteger的add运算非常快,只有乘法容易成为瓶颈,所以纯粹的大数递推用BigInteger问题不大。
3.3 进阶套路:快速幂取模与组合数大数
蓝桥杯里有些题表面上看和高精度没关系,比如“计算a的b次方对mod取模”,但b可能大到10的18次方,如果你直接循环乘,循环10的18次方次,跑一辈子也跑不完。这时候就要用快速幂,而BigInteger恰好提供了现成的modPow方法。
BigInteger base = new BigInteger(sc.next()); BigInteger exp = new BigInteger(sc.next()); BigInteger mod = new BigInteger(sc.next()); BigInteger result = base.modPow(exp, mod);这个方法内部实现的就是二进制快速幂,时间复杂度是O(log exp),而且每一步都取模,数字不会无限膨胀。我之前遇到一道题,要求计算组合数C(n, m)对一个大质数取模,当时我手动写了卢卡斯定理加快速幂,后来发现直接组合数公式加modPow也就两行,理解原理之后代码可以极简。
如果你要算的组合数本身不取模,但结果大到离谱,那就只能上高精度了。组合数有一个递推公式:C(n, 0)等于1,C(n, k)等于C(n, k-1)乘以(n-k+1)再除以k。这个递推在BigInteger里很容易实现:
public static BigInteger combination(int n, int k) { BigInteger result = BigInteger.ONE; for (int i = 1; i <= k; i++) { result = result.multiply(BigInteger.valueOf(n - i + 1)) .divide(BigInteger.valueOf(i)); } return result; }注意乘法除法的顺序:先乘后除,这样每一步的结果在数学上仍是整数,不会丢精度。中间结果可能会比较大,但BigInteger正好管够。
再往下扩展,像卡特兰数、错排公式、斯特林数,套路都是一样的:递推公式里出现大整数乘法就上BigInteger,出现除不尽的数就上BigDecimal,出现取模就用modPow。把这些组合起来,10道题里有8道高精度相关题都能解决。
4. 高精度题最容易踩的坑,附排查方法
4.1 不可变对象与循环性能陷阱
BigInteger是不可变类,这意味写一次循环就是创建几百上千个临时对象,内存和时间的开销都不小。我见过一个同学用BigInteger算2的10000次方,直接for循环里一遍遍multiply,本地跑了几秒才出结果,提交就超时。
遇到这种“指数级别增长”的运算,优先用快速幂思想。BigInteger的pow方法其实内部也用类似快速幂的实现,复杂度是O(log n)。比如计算2的10000次方,直接BigInteger.TWO.pow(10000)就能秒出结果,不要自己循环10000次乘2。
如果你的代码确实必须循环几千次做乘法,而且数字长度特别大,那么手写数组比BigInteger不知道快多少倍。此外,循环里尽量复用变量,不要在循环体内部new StringBuilder、new数组,这些临时对象多了也会拖慢速度。
还有个很容易忽略的性能点:System.out.println输出大数字符串时,底层会逐字符写输出流,如果输出内容很大(比如几万位的数字),可以考虑先拼接到StringBuilder再一次性输出,虽然大多数评测环境println也能过,但保险起见养成好习惯。
4.2 输入输出和边界条件的细节
先看输入。很多大数题的输入是一个很长的数字字符串,可能有前导零,比如“00012”。BigInteger会忽略前导零,解析成12,没问题。但如果你自己手写字符串处理,就要在比较大小或去除前导零时小心。
再看输出。BigInteger的toString方法返回十进制字符串,不会有科学计数法,这一点比BigDecimal省心。但注意println可以直接传BigInteger对象,它内部会自动调用toString。
边界条件永远是高精度题的扣分重灾区。代码里要专门考虑这么几种情况:结果等于0时不能输出空串;减法中a等于b时输出0;乘法中某个因数为0时数组长度不能是0;除法中除数为0直接属于非法输入,但题目一般会保证不出现。
手写数组还有一个经典的坑:数组长度开小了。大数相乘的结果位数最多是两数位数之和,所以乘法数组长度要开成a.length加b.length。我以前做乘法题时数组少开了一位,结果高位进位时数组越界,直接运行时异常,排查了好久才找到原因。
4.3 手写数组的进位、借位、前导零三板斧
如果你决定手写数组,那么“进位”“借位”“前导零”这三个坑必须一次性避开。
进位处理的原则是:加法里,当前位结果大于等于10时,模10留下个位,整除10加到下一位;乘法里,逐位乘完后集中处理进位,因为每一位都可能积累多位数的进位,分散处理容易漏。
借位处理是减法独有的。建议用一个borrow变量表示当前是否向高位借了1,每一位的计算公式是:当前位结果等于被减数当前位减减数当前位再减borrow,如果结果为负就加10,同时borrow置1,否则borrow置0。这里最容易出错的是两个数位数不齐的情况,短的数高位补0就行。
前导零处理是“看着简单扣分最狠”的地方。加法和减法结果最高位可能为0,比如100减99等于001,你不去掉前导零输出就是“001”,直接答案错误。我自己的习惯是:输出前用一个循环从数组最高位往前找第一个非0元素,然后从这个位置开始倒序输出;如果整个数组都是0,直接输出0。这个逻辑写成一个函数,所有手写数组题统一调用,省心很多。
还有一个小技巧:如果你用int数组逐位存储十进制数,那么每个元素只存0到9,有些浪费数组空间,也影响效率。进阶做法是压位,也就是每个数组元素存10000以内的数(万进制),这样数组长度缩小四倍,加法乘法效率大幅提升。压位的进位判断从“大于等于10”变成“大于等于10000”,输出时每一位要补0到4位,除了最高位。这个技巧在你将来处理几千位大数乘法时会非常有价值。
最后再聊一点我自己的体会。这几天带大家刷题有一个很明显的感受:高精度题是最容易让人产生“我懂了但写不对”的题。原因在于它的知识点本身不难,难的是把每一步边界都想清楚。我的建议是,不管你用BigInteger还是手写数组,都先把模板在自己电脑上跑通,然后用同一套模板去刷五六道真题,直到你闭着眼都能写出来为止。等你真正进了考场,看到高精度题,直接调用模板,把这部分分数稳稳拿到手,后面的难题才有底气慢慢磨。