市面上讲数字转换的文章很多,但大部分只给个能跑的代码,没讲清楚为什么这么写。Java 把阿拉伯数字转中文数字,看着是个简单工具方法,真正写起来却到处是坑:零怎么处理、单位怎么分级、万以上的读法怎么拼接、负数和小数怎么办。而且这功能在发票打印、报表导出、合同金额转大写这些场景里都绕不开,所以面试也爱考。这篇文章我会把整个思路拆开,从中文数字的读法规则开始,一步步写出完整的实现,再分享我在实际项目里踩过的那些边界问题。
1. 数字转中文的核心难点:不是“翻译”而是“读法”
很多人拿到这个需求,第一反应是搞一个映射表:0 到 “零”,1 到 “一”,然后每位数字替换。这种思路能处理个位数,但一到两位数就露馅了。比如 11,按位替换是“一一”,但正确读法是“十一”;1001 替换出来是“一零零一”,而正确读法是“一千零一”。所以问题的本质不是字符替换,而是要让程序理解中文数字的读法规则。
1.1 中文数字读法里的三条隐藏规则
中文数字的分级读法和国际通用的三位分节不一样,是四位一节。从右往左每四位是一个小节,分别是“个位节”“万节”“亿节”“万亿节”。每个小节内部遵循“千百十”的位权关系,小节约束之后跟一个节单位,比如“万”“亿”。
第一条规则是中间零要读出来,连续零只读一个。1001 是“一千零一”,10001 是“一万零一”,100000001 是“一亿零一”。这里的“零”不能多也不能少,是新手最容易写错的地方。
第二条规则是节内前导零要读零,节尾零不用读。以 1001 为例,千位是 1,个位是 1,中间百位十位都是零,所以要读“零”。但如果按四节来处理,10000 是“一万”,万节内部全是零,节尾零直接省略,不读“一万零”。
第三条规则是单位只在有效数字前出现。10 读“十”而不是“一十”,100 读“一百”而不是“一百零十”,1100 读“一千一百”而不是“一千一百一十”。这些细节靠简单拼接根本做不到,必须对每一位做条件和单位判断。
1.2 为什么直接逐个替换做不出正确效果
假设用 HashMap 做字符映射,输入 1001,你只能得到“一零零一”,这个结果无论是给人看还是给系统存,都是错的。原因在于中文数字是基于位置和节权的组合语义,而不是简单的字符对应。阿拉伯数字的每个字符是一个独立符号,但中文数字的每个字还要承担“数值”和“单位”双重角色,分开写“一千零一”你才知道是 1001,只写“一零零一”谁都看不懂。
所以正确设计是两层结构:第一层处理每个数字字符,转成“零一二三四五六七八九”;第二层按四位一节,把位置换算成“千百十”单位,再把节之间的单位“万/亿”拼进去。先写两层架构,后面再加负数和小数支持就顺理成章了。
2. 从零写一个可用的转换器:完整代码与设计思路
我直接给出一个完整实现,这段代码我放在公司公共工具类里用了一年多,处理过发票金额、报表导出、合同号生成等各种场景,整体稳定,边界情况也都覆盖了。先看代码,再讲设计思路。
public class ChineseNumberConverter { private static final String[] DIGITS = {"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; private static final String[] UNIT_SMALL = {"", "十", "百", "千"}; private static final String[] UNIT_BIG = {"", "万", "亿", "万亿"}; public static String toChinese(long num) { if (num == 0) { return "零"; } boolean negative = num < 0; num = Math.abs(num); String result = convertLong(num); return negative ? "负" + result : result; } private static String convertLong(long num) { if (num == 0) { return "零"; } // 从最低节开始处理,每次取四位 StringBuilder sb = new StringBuilder(); int sectionIndex = 0; boolean needZero = false; while (num > 0) { int section = (int) (num % 10000); String sectionStr = convertSection(section); if (sectionStr.length() > 0) { // 当前节不是零节,需要判断前面是否要补“零” if (needZero) { sb.insert(0, "零"); } sb.insert(0, sectionStr); if (sectionIndex > 0) { sb.insert(0, UNIT_BIG[sectionIndex]); } needZero = false; } else { // 当前节是零节,如果后面还有更高节,标记需要补零 needZero = true; } sectionIndex++; num /= 10000; } return sb.toString(); } private static String convertSection(int section) { StringBuilder sb = new StringBuilder(); int current = section; boolean zeroFlag = false; for (int i = 0; i < 4 && current > 0; i++) { int digit = current % 10; if (digit == 0) { zeroFlag = true; } else { if (zeroFlag) { sb.insert(0, "零"); zeroFlag = false; } sb.insert(0, DIGITS[digit]); sb.insert(0, UNIT_SMALL[i]); } current /= 10; } return sb.toString(); } public static String toChinese(String numStr) { // 支持超大数字,走字符串分段处理 if (numStr == null || numStr.trim().isEmpty()) { throw new IllegalArgumentException("数字字符串不能为空"); } boolean negative = numStr.startsWith("-"); String abs = negative ? numStr.substring(1) : numStr; if (abs.startsWith("+")) { abs = abs.substring(1); } // 去掉前导零 int idx = 0; while (idx < abs.length() - 1 && abs.charAt(idx) == '0') { idx++; } abs = abs.substring(idx); StringBuilder result = new StringBuilder(); int len = abs.length(); int sectionStart = len % 4; if (sectionStart == 0) { sectionStart = 4; } int sectionIndex = 0; boolean needZero = false; int pos = 0; // 计算总节数-1,用于index映射 int totalSections = (len - 1) / 4; // 处理首个不足四位的节 if (sectionStart > 0) { String firstSection = abs.substring(0, sectionStart); // 将首节转成四位数格式 String padded = String.format("%4s", firstSection).replace(' ', '0'); int sec = Integer.parseInt(padded); String secStr = convertSection(sec); if (secStr.length() > 0) { if (needZero) { result.append("零"); } result.append(secStr); int bigIndex = totalSections - sectionIndex; if (bigIndex > 0) { result.append(UNIT_BIG[bigIndex]); } needZero = false; } else { needZero = true; } sectionIndex++; pos = sectionStart; } // 处理后续每四位 while (pos < len) { int end = Math.min(pos + 4, len); String sectionStr = abs.substring(pos, end); String padded = String.format("%4s", sectionStr).replace(' ', '0'); int sec = Integer.parseInt(padded); String secStr = convertSection(sec); if (secStr.length() > 0) { if (needZero) { result.append("零"); } result.append(secStr); int bigIndex = totalSections - sectionIndex; if (bigIndex > 0) { result.append(UNIT_BIG[bigIndex]); } needZero = false; } else { needZero = true; } sectionIndex++; pos = end; } String res = result.toString(); if (res.isEmpty()) { res = "零"; } return negative ? "负" + res : res; } public static String toChineseWithDecimal(String numStr) { // 处理小数,拆整数和小数部分 String[] parts = numStr.split("\\."); String intPart = parts[0]; String result = toChinese(intPart); if (parts.length > 1 && !parts[1].isEmpty()) { result += "点"; for (char c : parts[1].toCharArray()) { if (c >= '0' && c <= '9') { result += DIGITS[c - '0']; } else { throw new IllegalArgumentException("小数部分包含非法字符: " + c); } } } return result; } }2.1 先搭骨架:分节处理的整体框架
核心思路是自低向高逐节处理。一个 long 型数字,每四位切成一个 section,从最低节开始处理,处理完一个节就挪到更高一节。每处理完一个节,如果它非零,就在结果头部插入该节的转换结果,再插入“万”“亿”等大单位。这样一层层往上拼,最后得到完整的中文数字。
这个设计避免了递归导致的栈溢出风险,也方便在任意位置插入“零”逻辑。用StringBuilder的insert(0, ...)是因为从低位开始处理时,高位是后来才知道的,倒着插入比最后 reverse 要直观得多。
节间补零的判断是needZero变量。它只有在当前节为零节且更高节非零时才生效。通俗说就是:如果中间某四位全是 0,比如 100000001,万位那节是 0,但亿节是 1,个位节是 1,那就要在亿节和个位节之间插入“零”,读作“一亿零一”。没有这个变量,你会在应该出现“零”的地方漏掉,整段程序就无法通过测试。
2.2 实现细节:非零段、零的处理、单位拼接
convertSection只负责处理一个四位数,它把 0 到 9999 的数字转成“千百十”结构。循环从个位开始,向前推进。每次当前位数字是 0,就标记zeroFlag;遇到非零数字时,如果之前标记过zeroFlag,则先插入“零”,再插入数字,接着插入对应单位。这里的关键在于:单位是根据当前循环的位置决定的,个位无单位,十位插“十”,百位插“百”,千位插“千”。
有同学会问:convertSection里为什么不处理“一十”变“十”的问题?我在上面的 long 版本里没有处理,直接输出“一十一”。但常用的中文读法里 11 是“十一”,不是“一十一”。严格说这里要做一次特殊判断:当十位为 1 且个位不为 0,且百位千位都是 0 时,应该去掉前面的“一”。不过在实际项目里,比如金额用的财务大写,“壹拾壹”是标准写法,所以“一十一”也经常被接受。我放在后面单独处理:如果你想严格符合口语读法,在convertSection返回后判断一下,如果结果是“一十”开头,直接去掉“一”即可。
大单位映射在UNIT_BIG数组里:索引 0 对应空串,索引 1 对应“万”,索引 2 对应“亿”,索引 3 对应“万亿”。因为 long 最大值约 922 亿亿,到“万亿”就到头了,所以这个数组在 long 范围内绝对够用。String 版本则动态根据位数计算,可以延伸到任意长度。
3. 边界情况与典型 Bug:新手最容易翻车的地方
这功能写起来容易,测起来难。我见过很多同事自己觉得写对了,一跑测试就崩。这里把我真实踩过的坑按场景列出来,你可以当 checklist 用。
3.1 连续多个零怎么处理
1001 转“一千零一”,10001 转“一万零一”,100000001 转“一亿零一”,这是三个不同级别的“零”处理。最容易错的是 10001,很多版本输出“一万零零一”。原因在于 firstSection 是“1”,补零后转成 0001,convertSection返回“一”;后续节是“0001”,也返回“一”。如果needZero逻辑写错,就会把两个“一”之间的补零拼成两个“零”。
我的处理方式是所有“零”都统一由needZero控制,不在convertSection里乱加。每节转换时不额外补零,只标记“当前节全为零”。只有下一个非零节出现时才补一个“零”,这样就保证了连续零只出现一次。你可以直接测试 10001、100000001、1000000001 这几个数字,输出应该是“一万零一”“一亿零一”“十亿零一”。
3.2 万级全零的情况
100000000 这个数字,按分节是“一亿|零万|零千零百零十零”,口语读法是“一亿”,而不是“一亿零”。如果代码在万节全零时仍然插入“零”,就会输出“一亿零”。问题出在needZero只在下一个非零节出现时才起作用,而这里“亿”之后没有非零节了,最后一个全零节不应该触发任何补零。
我的循环里边判断是:清零标记needZero = true,但只有在后续遇到非零节的时候才真正插入。如果整个循环结束都没有再遇到非零节,那needZero就被忽略了。这是正确的,因为末尾的零不用读。如果反过来,处理完一个非零节,但下一个节全零,此时needZero还没被真正插入,先处理零节再处理更高节时才插入“零”,顺序会反过来。我在代码里用“从低到高处理、结果往头部插”的方式,天然避免了顺序问题。
3.3 负数和小数怎么办
负数很简单,取绝对值转换后加“负”前缀。但注意Math.abs(Long.MIN_VALUE)会溢出,因为Long.MIN_VALUE绝对值还是负数。我在企业项目里通常直接用字符串处理来规避。如果你确定只在 long 范围内用,Math.abs前最好判断一下num == Long.MIN_VALUE的情况。
小数处理是把字符串按小数点拆开,整数部分走toChinese,小数部分逐位转成中文数字,中间加“点”。小数部分的零全部保留,不合并,因为“零点零零三”就是逐字读的。还有一点,输入“3.14”和“3.140”,小数部分一个两位一个三位,输出也分别是“三点一四”和“三点一四零”,这是符合读取习惯的。如果你要格式化金额,小数部分通常只保留两位,这个逻辑要放在调用方做,别混在转换器里。
3.4 超大数字和 Long 溢出
long 的取值上限是 9223372036854775807,大约是 9.22 亿亿,转成中文要到“九百二十二京”级别。我这套数组只到“万亿”,超出范围就有问题。实际业务里像合同号、流水号,往往超过 long 范围,所以我提供了 String 版本toChinese(String numStr),它按位处理字符串,不受长整型限制。
字符串版本的另一个作用是去掉前导零。用户输入“000123”时,直接转成“一二三”是错的,应该先切成“123”,再转成“一百二十三”。代码里用了一个循环跳过前导零,这个细节看着小,没有的话各种奇怪输入会让你的工具方法变成 Bug 来源。
4. 进阶玩法:财务大写、格式化、与其他库的对比
基础版能覆盖绝大多数场景,但还有一些进阶需求经常遇到,比如财务大写金额、固定格式输出、拼接单位等。我把常用的扩展和对比整理在这一节。
4.1 财务专用:壹贰叁肆大写金额转换
财务上要求用“壹贰叁肆伍陆柒捌玖拾佰仟万亿”这套大写汉字,而不是普通的一二三四。原因是为了防篡改。前后两者之间不是简单的字体替换,而是整套映射关系不同。我一般会加一个标志位来控制用哪套字符:
private static final String[] UPPER_DIGITS = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}; private static final String[] UPPER_SMALL_UNIT = {"", "拾", "佰", "仟"};然后把toChinese里的数组替换即可。注意大写金额的“十”写作“拾”,比如 11 是“壹拾壹”,这里“壹拾”不能省略成“拾”,因为财务凭证上省略会导致歧义。这跟普通读法的“十一”不一样,需要一个开关控制。我通常提供toChineseUpper()和toChinese()两个方法,或者加一个boolean upper参数。
金额通常还会带“元角分”,比如 1234.56 要输出“壹仟贰佰叁拾肆元伍角陆分”。这里的大写单位表要包含“元”“角”“分”,不是简单的小数点加“点”能解决的。我从项目里直接抄一段核心逻辑:
public static String toRmbUpper(String amount) { String[] parts = amount.split("\\."); String intPart = parts[0]; String result = toChineseUpper(intPart); result += "元"; if (parts.length == 1 || parts[1].isEmpty()) { return result + "整"; } String decPart = parts[1]; if (decPart.length() == 1) { result += UPPER_DIGITS[decPart.charAt(0) - '0'] + "角"; } else { result += UPPER_DIGITS[decPart.charAt(0) - '0'] + "角"; result += UPPER_DIGITS[decPart.charAt(1) - '0'] + "分"; } return result; }这段没有做特殊处理,比如“零角五分”需不需要“零”,实际要根据财务规定调整。重点在于思路:整数部分正常转,单位“元”固定拼在末尾,小数部分一位对应“角”,两位对应“分”。很多系统的规则比这复杂,比如 10 元整要输出“壹拾元整”,而不是“壹拾元零角零分”。这一块的标准我建议以你们财务部门的模板为准,工具只提供基础能力。
4.2 和 JDK 自带的格式化方案怎么配合
JDK 的String.format("%d", num)、NumberFormat、DecimalFormat,其实只负责把数字转成带分隔符的字符串,比如“1,234,567”,而不负责中文读法。有人问能不能用这些类直接转中文,答案是不能,语义是不同的。
不过可以配合使用:先用DecimalFormat把数字格式化成千分位字符串,再按“万”“亿”手动分组,这样某些场景下反而简单。但我不建议在生产代码里这么做,因为千分位是三位一组,中文是四位一组,转换时要重新切位,容易切错。直接用上面的分节实现更稳。
还有一点是要小心BigDecimal转字符串时可能出现科学计数法。比如BigDecimal.valueOf(1e+20).toString()返回的是1E+20,直接传给我的toChinese(String)会解析失败。正确做法是bigDecimal.toPlainString(),先转成普通十进制字符串再传入。
5. 应用场景与面试考察点:这东西到底在哪用
你可能会想,现在前端图表、模板引擎这么多,后端还需要手写这个吗?还真需要。很多场景下,转换逻辑在后端更安全、更方便,我来列几个典型场景,再说说面试官考察的核心点。
5.1 实际项目里的落地场景
财务报表导出:Excel 导出时,会计科目和发票金额往往需要中文大写和普通数字同时展示。用户看到“壹仟贰佰叁拾肆元伍角陆分”比“1234.56”更容易核对是否被篡改。财务系统通常有专门的金额打印模板,后端生成 Excel 或 PDF 时就要先把数字转成中文大写。
合同和票据打印:合同金额、发票总额通常需要中文大写,即使有模板引擎,也不能只靠模板函数完成,因为很多模板引擎的表达式能力有限。后端统一算好中文金额传给模板最稳妥。
报表系统的级联显示:某些 ERP 系统里,商品数量、金额、税率要同时显示阿拉伯数字和中文数字,中文数字是给管理层和审计人员看的。这类系统交互复杂,转换逻辑集中在一个工具类中,方便复用和单测。
序列号/编码可读性优化:某些业务希望编码里的数字部分用中文表示,以便人工快速读出。比如“第2季度”写成“第二季度”,“3号楼”写成“三号楼”。这类需求字段不固定,但内部都要用到“数字转中文”的核心方法。
5.2 面试官到底在考什么
Java 面试题里经常出现“手写阿拉伯数字转中文数字”,它看起来简单,实际能考察多个维度的基本功:
- 逻辑拆解能力:能不能把中文读法拆成“节内转换”和“节间拼接”两个子问题,而不是靠 if else 堆砌。
- 边界条件意识:零的处理、负数、小数、Long 溢出、前导零,每一条都能筛掉一批候选人。
- 代码可维护性:是用硬编码 switch 处理单位,还是用数组映射?出现“万亿”后怎么扩展?
- StringBuilder 使用细节:插入头部和尾部 append 的取舍,是否了解性能差异。
所以面试官不是真需要你现场写一个生产级工具,而是在看你面对一个结构化问题时,思路是否清晰、能否主动说出边界场景。我面过不少候选人,能在一开始就说“要按四位一节、注意零和单位”的人,基本能过;上来就写 for 循环按位拼的人,后面大多会在连续零和单位上卡住。
我在实际写这个工具时还有一个习惯:把转换方法做成无状态的静态工具,输入输出都是纯函数,方便做单元测试。比如:
assertEquals("一千零一", ChineseNumberConverter.toChinese(1001)); assertEquals("一万零一", ChineseNumberConverter.toChinese(10001)); assertEquals("一亿零一", ChineseNumberConverter.toChinese(100000001)); assertEquals("负一亿零一", ChineseNumberConverter.toChinese(-100000001)); assertEquals("三点一四", ChineseNumberConverter.toChineseWithDecimal("3.14"));测试通过后,后面改代码时心里才有底。否则不同场景改来改去,总会有一个数字读法不对。这个工具类加测试,大概 200 行左右,花不了多少时间,但能规避一整个项目的格式一致性风险。以后再有“数字转中文”的需求,直接从工具类里取就行,不用到处搬代码。