C++实现人民币大写转换:华为OD机试经典题解与工程实践

1. 项目概述与核心价值

最近在准备华为OD机试的朋友,应该都绕不开“人民币转换”这道经典题目。它不仅是B卷的常客,更是检验一个程序员基本功和思维严谨性的绝佳试金石。题目本身并不复杂:给你一个不超过两位小数的数字,要求你将其转换为符合中文阅读习惯的大写金额表示。听起来简单,对吧?但真正动手实现,尤其是用C++这种需要精细控制细节的语言,你会发现从“能跑通”到“完全符合规范”之间,隔着无数个“坑”。

我之所以花时间深入研究并实现它,是因为这道题完美融合了字符串处理、逻辑分支、边界条件处理以及对业务规则(金融规范)的深刻理解。它不像纯粹的算法题那样比拼奇技淫巧,更像是一个微型的工程项目,要求你的代码健壮、清晰、无懈可击。无论是为了通过机试,还是为了提升自己处理复杂业务逻辑的编码能力,吃透这道题都大有裨益。接下来,我将结合我的实现,拆解其中的每一个技术细节和思维过程。

2. 问题解析与设计思路

2.1 题目要求深度解读

首先,我们必须百分百明确题目的要求,任何歧义都会导致失分。核心规则如下:

  1. 输入:一个字符串,表示一个数字,例如"15005.08"。题目保证数字在合理范围内,且最多两位小数。
  2. 输出:中文大写金额字符串,例如"壹万伍仟零伍元捌分"
  3. 关键规则
    • 单位:元、角、分。角、分仅在对应数值不为0时输出。例如,0.05元输出“伍分”,而非“零元伍分”。
    • 整数部分:采用“万、亿”为单位分段处理。每四位一组,从低位到高位依次是“元”、“万”、“亿”。单位只在当前段有数值时才出现。
    • 零的读法:这是最易错的部分。
      • 连续的多个0,只读一个“零”。
      • 某一位是0,但其所在的“段”(个、十、百、千)全部为0时,这个“零”不读。(例如,1000读作“壹仟”,而不是“壹仟零”)。
      • 万位和亿位是0时,需要特殊处理。例如,100001000元,应读作“壹亿零壹仟元”,中间的“万”单位因为万位段全零而被省略,但需要用“零”连接亿和千。
    • 开头结尾:不能有多余的“零”,也不能缺少“元”字(当整数部分为0时,如0.12元,输出“壹角贰分”还是“零元壹角贰分”?根据常见规范,是前者)。

2.2 核心算法设计思路

面对这样的规则,直接对数字进行数学运算是非常麻烦的,因为我们需要精确知道每一位的数字。更优雅的做法是:将输入字符串作为核心处理对象

我的设计思路分为以下几个清晰步骤:

  1. 预处理与分割:将输入字符串以小数点.为界,分割为整数部分字符串intPart和小数部分字符串decPartdecPart直接补齐或截断到2位,代表角和分。
  2. 整数部分转换(核心):这是难点。我采用“分治”思想,将整数部分字符串从右至左,每4位分成一组(即个、万、亿段)。对每一组,独立进行“千位到个位”的中文转换,然后根据组的位置(0-元,1-万,2-亿)加上段单位。
  3. 小数部分转换:简单映射,0-1位是角,1-2位是分。注意处理decPart长度不足2的情况。
  4. 拼接与后处理:将整数部分结果、“元”、小数部分结果拼接。然后,应用一系列正则化规则处理多余的“零”、开头结尾的“元”等问题,得到最终结果。

这个思路的优势在于模块化清晰,每一部分职责单一,方便调试和验证。

2.3 数据结构与函数规划

基于上述思路,我们需要以下核心数据与函数:

  • 常量映射表
    const std::vector<std::string> digitMap = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}; const std::vector<std::string> unitMap = {"", "拾", "佰", "仟"}; // 个十百千 const std::vector<std::string> sectionUnit = {"", "万", "亿"}; // 段单位
  • 核心函数
    • std::string convertSection(const std::string& section, bool isLastSection):将一个4位数字符串(或不足4位)转换为中文,isLastSection标识是否为最后一段(个位段),用于处理末尾“零”的规则。
    • std::string convertIntegerPart(const std::string& intPart):协调分段,调用convertSection,并添加段单位。
    • std::string convertDecimalPart(const std::string& decPart):处理角和分。
    • std::string normalizeResult(const std::string& rawResult):对拼接后的原始结果进行清洗,处理多个“零”、开头结尾等问题。

3. 核心模块实现详解

3.1 整数部分分段转换实现

convertSection函数是基石。它接收一个字符串,例如"0301""12",输出如“零叁佰零壹”“壹拾贰”

std::string convertSection(const std::string& section, bool isLastSection) { std::string result; int len = section.length(); bool lastZero = false; // 标记上一位是否是零 bool hasNonZero = false; // 标记当前段是否有非零数字 for (int i = 0; i < len; i++) { int digit = section[i] - '0'; int pos = len - 1 - i; // 0-个位,1-十位,2-百位,3-千位 if (digit == 0) { // 当前位是0 if (!lastZero && hasNonZero) { // 如果上一位不是0,且前面已经出现过非零数字,则添加一个“零” // 避免开头是零,也避免连续多个零 result += digitMap[0]; } lastZero = true; } else { // 当前位非0 hasNonZero = true; // 添加数字和单位。注意:当数字是1且单位是“拾”时,通常省略“壹”,读作“拾X” if (!(digit == 1 && pos == 1 && i == 0)) { // i==0 表示是当前段的第一个字符 result += digitMap[digit]; } result += unitMap[pos]; lastZero = false; } } // 后处理:如果整个段都是0,返回空字符串。 // 如果不是最后一段,且末尾是“零”,需要去掉这个零(因为段单位前不应有零)。 // 例如 “零叁佰零” + “万”,应该变成 “零叁佰” + “万” if (!hasNonZero) { return ""; } // 移除末尾的“零”,除非是最后一段的个位(需要保留,如“壹仟零”) if (!result.empty() && result.back() == '零' && !(isLastSection && len == 4)) { result.pop_back(); // 假设“零”是一个字符。实际是UTF-8,需按字符串处理。 // 更严谨的做法: if (result.size() >= 3 && result.substr(result.size()-3) == "零") { result.erase(result.size()-3); } } // 注意:上面关于“零”字符的处理是简化版。实际中文字符在UTF-8下占3字节,需要用std::string的find/substr处理。 // 为清晰起见,下文会给出处理中文字符串的辅助函数。 return result; }

注意:这里暴露了C++处理中文的一个关键坑点。std::string存储的是字节,一个UTF-8中文字符占3个字节。像result.back() == '零'这样的比较是错误的。我们必须以字符串为单位进行操作。这是机试中极易忽略导致错误的地方。

因此,我们需要一个辅助函数来处理中文“字”:

// 判断字符串str是否以后缀suffix结尾 bool endsWith(const std::string& str, const std::string& suffix) { if (suffix.size() > str.size()) return false; return std::equal(suffix.rbegin(), suffix.rend(), str.rbegin()); } // 移除字符串末尾的指定后缀 void removeSuffix(std::string& str, const std::string& suffix) { if (endsWith(str, suffix)) { str.erase(str.size() - suffix.size()); } }

然后在代码中,将result.back() == '零'替换为endsWith(result, "零"), 将result.pop_back()替换为removeSuffix(result, "零")

3.2 协调分段与添加段单位

convertIntegerPart函数负责将整个整数部分字符串按4位分组,从右向左处理,并调用convertSection

std::string convertIntegerPart(const std::string& intPart) { std::string result; int len = intPart.length(); int sectionIndex = 0; // 0-元段,1-万段,2-亿段 std::string tempIntPart = intPart; // 处理前导零,但需要保留至少一位(如果全部是零) size_t nonZeroPos = tempIntPart.find_first_not_of('0'); if (nonZeroPos == std::string::npos) { return ""; // 整数部分全零 } else if (nonZeroPos > 0) { tempIntPart = tempIntPart.substr(nonZeroPos); } len = tempIntPart.length(); int pos = 0; while (pos < len) { // 确定当前段的结束位置(字符串的左端) int end = len - pos; int start = std::max(0, end - 4); // 每次取最多4位 std::string section = tempIntPart.substr(start, end - start); // 反转section,因为convertSection是按从左到右(高位到低位)处理的,而substr取出来的是高位部分。 // 更好的方法是让convertSection适应从右向左的位次,或者这里先补零对齐。 // 让我们调整一下:保证section是4位,不足左补零。 section = std::string(4 - section.length(), '0') + section; bool isLastSection = (sectionIndex == 0); // 当前段是否是个位段(最后一段) std::string sectionStr = convertSection(section, isLastSection); if (!sectionStr.empty()) { // 段结果 + 段单位 + 之前的累计结果 result = sectionStr + sectionUnit[sectionIndex] + result; } else { // 当前段全零 // 如果亿段为空,万段不为空,且元段不为空,需要在万和元之间加“零” // 这个逻辑比较复杂,我们可以在最后的normalizeResult中统一处理连续的零和段单位缺失。 // 这里暂时不做处理,仅当当前段非空时才加段单位。 } pos += 4; sectionIndex++; } // 如果最终结果为空,说明整数部分为0 if (result.empty()) { result = "零"; } return result; }

这个版本仍有瑕疵,特别是在处理段间零和段单位逻辑时。更稳健的方法是:先按4位一组分割好,存入一个vector,然后从左到右(从高位到低位)处理每一组,这样逻辑更顺。

3.3 小数部分与最终拼接

小数部分处理相对直接:

std::string convertDecimalPart(const std::string& decPart) { std::string result; std::string dec = decPart; // 补齐或截断到2位 if (dec.length() < 2) { dec.append(2 - dec.length(), '0'); } else if (dec.length() > 2) { dec = dec.substr(0, 2); // 题目保证不会,但防御性编程 } int jiao = dec[0] - '0'; int fen = dec[1] - '0'; if (jiao > 0) { result += digitMap[jiao] + "角"; } if (fen > 0) { result += digitMap[fen] + "分"; } return result; }

主函数负责流程串联:

std::string convertCurrency(const std::string& input) { // 1. 分割整数小数 size_t dotPos = input.find('.'); std::string intPart, decPart; if (dotPos != std::string::npos) { intPart = input.substr(0, dotPos); decPart = input.substr(dotPos + 1); } else { intPart = input; decPart = ""; } // 2. 转换各部分 std::string intStr = convertIntegerPart(intPart); std::string decStr = convertDecimalPart(decPart); // 3. 拼接 std::string rawResult; if (intStr == "零" && decStr.empty()) { rawResult = "零元"; } else if (intStr == "零") { rawResult = decStr; // 例如 0.12 -> “壹角贰分” } else { rawResult = intStr + "元" + decStr; } // 4. 规范化 return normalizeResult(rawResult); }

4. 字符串规范化与边界处理

这是将“基本正确”的代码提升到“完全正确”的关键步骤。normalizeResult函数需要处理一系列棘手的规则。

4.1 规范化规则与实现

std::string normalizeResult(const std::string& raw) { std::string result = raw; // 规则1:合并连续的“零” std::string from = "零零"; std::string to = "零"; size_t pos = 0; while ((pos = result.find(from, pos)) != std::string::npos) { result.replace(pos, from.length(), to); pos += to.length(); // 继续查找,避免替换后产生新的连续零 } // 规则2:处理“零万”、“零亿” -> 通常应为“零”,但需结合上下文。 // 例如 “壹亿零万叁仟元” -> “壹亿零叁仟元”。实际上,在convertIntegerPart中应避免生成“零万”。 // 更常见的错误是 “X亿零万Y”,应删除“零万”。 from = "零万"; to = "零"; pos = 0; while ((pos = result.find(from, pos)) != std::string::npos) { // 检查“零万”前面是不是数字,如果是,则替换为“零” result.replace(pos, from.length(), to); } // 同理处理“零亿” from = "零亿"; pos = 0; while ((pos = result.find(from, pos)) != std::string::npos) { result.replace(pos, from.length(), to); } // 规则3:处理开头是“零”的情况,如“零壹拾...”应去掉开头的零(除非整个就是“零元”) if (result.size() >= 3 && result.substr(0, 3) == "零" && result != "零元") { // 但需注意“零元”是合法的(输入为0.00)。而“零壹角”是不合法的,应去掉零。 // 简单判断:如果“零”后面跟的是单位(元、角、分、拾、佰、仟、万、亿),则不能删。 // 这里简化处理:如果以“零”开头且长度>3,且第二个字符不是单位字,则删除。 // 单位字集合:元角分拾佰仟万亿 std::string units = "元角分拾佰仟万亿"; if (result.size() > 3 && units.find(result.substr(3, 3)) == std::string::npos) { result = result.substr(3); } } // 规则4:处理结尾是“零”的情况,如“壹仟零” -> “壹仟” if (endsWith(result, "零") && !endsWith(result, "元零")) { // “元零”如“壹仟元零”是不存在的,应去掉零 removeSuffix(result, "零"); } // 规则5:确保“元”的存在。如果经过上述处理,结果不含“元”,且整数部分不为空(即不是纯小数),则在末尾加“元”。 // 但更常见的情况是,纯小数(如0.12)的结果是“壹角贰分”,没有元,这是正确的。 // 我们需要判断:原始整数部分是否为“零”。这步最好在拼接时处理,这里做最终检查。 // 如果结果以“角”或“分”开头,且前面没有“元”,那是正确的纯小数格式。 // 如果结果包含“万”、“亿”等单位,但结尾没有“元”,则需要补“元”。 // 这个逻辑比较复杂,一个更简单粗暴但有效的办法是:在convertCurrency的拼接阶段就定好格式。 return result; }

4.2 边界条件测试用例

设计全面的测试用例是验证代码正确性的唯一途径。以下是一些关键用例:

输入期望输出测试点
"0""零元"零值处理
"0.00""零元"零值带小数
"0.12""壹角贰分"纯小数,无“元”
"5.6""伍元陆角"一位小数
"10.03""壹拾元零叁分"角为0,分不为0
"1000""壹仟元"整千,末尾无零
"1005""壹仟零伍元"中间有零
"1010""壹仟零壹拾元"中间有零,末尾是十位
"11000""壹万壹仟元"万位段非零
"100001000""壹亿零壹仟元"万位段全零,需用“零”连接
"100000000""壹亿元"中间多个零段
"123456789.12""壹亿贰仟叁佰肆拾伍万陆仟柒佰捌拾玖元壹角贰分"综合大数
"100000001.01""壹亿零壹元零壹分"极端零值测试

在实现过程中,务必用这些用例反复测试,调整convertIntegerPart的分段逻辑和normalizeResult的规则顺序。

5. 常见陷阱与调试心得

这道题在机试环境(如牛客网、华为OJ)中提交,很容易因为一些隐蔽的细节导致无法AC(全部用例通过)。以下是我踩过或见过的坑:

5.1 字符编码与字符串处理

这是最大的坑,没有之一。C++的std::string是字节串,而题目输入输出都是UTF-8编码的中文。

  • 错误做法if (str[i] == '零')。一个中文字符在UTF-8下占3个字节,直接比较单个char毫无意义。
  • 正确做法:所有涉及中文字符的比较、查找、替换,都必须以3个字节为单位进行。这就是为什么前面要定义endsWithremoveSuffix函数。在查找“零零”时,实际要查找的是6个连续的特定字节。

实操心得:在开始编码前,先写好两个辅助函数:bool startsWithCN(const string& str, const string& prefix)bool endsWithCN(const string& str, const string& suffix),以及对应的replaceCN函数。这会节省大量调试时间。

5.2 “壹拾”的简化读法

规则要求,对于“10-19”元中的“10”,应读作“拾”而不是“壹拾”。但注意,这个规则通常只适用于“元”单位前。例如:

  • 10->“拾元”(正确)
  • 110->“壹佰壹拾元”(十位上的1不能省略)
  • 100010->“壹拾万零壹拾元”(十万位上的1不能省略,十位上的1也不能省略?这里“壹拾万”的“拾”前有“壹”,而“零壹拾”的“拾”前没有“壹”?) 实际上,常见的中文金额大写规范是:只有位于整数部分最高位的“十位”上的“1”可以省略。例如:
  • 10: 最高位是十位,省略“壹” ->“拾元”
  • 110000: 最高位是十万位(即“壹拾万”),这个“壹”不能省 ->“壹拾壹万元”?不对,是“壹拾壹万”?让我们分解:11,0000 -> “壹拾壹万”。这里“壹拾”的“壹”是十万位,不能省。 所以,简化规则是:在转换一个4位段(千位到个位)时,如果该段的第一个非零数字出现在十位上,且这个数字是1,则省略这个“壹”。这个逻辑需要在convertSection函数中实现,即我代码中的if (!(digit == 1 && pos == 1 && i == 0))判断。i==0表示这是当前段转换过程中遇到的第一个非零数字。

5.3 段间“零”的处理逻辑

这是逻辑最复杂的地方。当“万”位段全为0时,我们不应该输出“万”,但需要在“亿”和“元”之间加一个“零”。例如:100001000->“壹亿[零]壹仟元”。 我的建议是:在convertIntegerPart中,不要急于将段结果和段单位拼接。而是先收集每一段的转换结果(可能为空),然后从左到右(从高位到低位)扫描这些段结果,智能地添加“零”和段单位。

重构后的convertIntegerPart思路

  1. 将整数部分字符串按4位一组从右向左分割,并存储到vector<string>中,每组4位(左补零)。
  2. 对每一组,调用convertSection得到中文串sectionStr
  3. 创建一个新的vector<string>用于存放最终结果段。
  4. 从高位段向低位段遍历:
    • 如果sectionStr非空,将其加入结果,并加上对应的段单位(“亿”、“万”、“”)。
    • 如果sectionStr为空(即全零段):
      • 如果这不是最后一个非空段之后的第一个全零段,则标记需要加“零”。
      • 更简单的策略:记录上一个加入结果的段是否是非空段。如果是,且当前段全零,则在结果中加入一个“零”,并标记“已加零”,直到遇到下一个非空段才清除标记,防止连续加零。
  5. 最后,将结果段的字符串连接起来。

这个逻辑实现起来需要仔细设计状态机,但一旦实现,就能干净利落地处理所有零值边界。

5.4 输入输出格式

机试系统通常是标准输入输出。

  • 输入:可能包含多组测试用例,直到文件结束(EOF)。你的程序需要能循环读取。
  • 输出:每个结果一行,严格按照要求,不能有多余空格或换行。

一个健壮的主函数框架:

#include <iostream> #include <string> #include <vector> #include <algorithm> // ... 其他头文件和函数声明 int main() { std::string line; while (std::getline(std::cin, line)) { // 可能需要对line做trim,去除首尾空白符 line.erase(0, line.find_first_not_of(" \t\n\r")); line.erase(line.find_last_not_of(" \t\n\r") + 1); if (line.empty()) continue; // 处理空行 std::string result = convertCurrency(line); std::cout << result << std::endl; } return 0; }

6. 完整代码实现与优化建议

综合以上所有讨论,下面给出一个经过重构、相对健壮的实现框架。由于篇幅限制,这里只勾勒核心结构,关键函数已在前文详细阐述。

#include <iostream> #include <string> #include <vector> #include <algorithm> #include <cctype> class RMBConverter { private: const std::vector<std::string> digitMap = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}; const std::vector<std::string> unitMap = {"", "拾", "佰", "仟"}; const std::vector<std::string> sectionUnit = {"", "万", "亿"}; // 辅助函数:处理中文字符串 bool endsWithCN(const std::string& str, const std::string& suffix) { // ... 实现见前文 } void removeSuffixCN(std::string& str, const std::string& suffix) { // ... 实现见前文 } void replaceAllCN(std::string& str, const std::string& from, const std::string& to) { // ... 实现类似,但按3字节单位查找替换 } std::string convertSection(const std::string& section, bool isFirstSection, bool isLastSection) { // 重构:增加 isFirstSection 参数,标识是否是最高位段,用于处理“壹拾”省略 // ... 实现逻辑,特别注意零的处理和“壹拾”规则 } std::string convertIntegerPart(const std::string& intPart) { // 重构:使用vector存储段结果和段单位,智能添加“零” // 1. 去除前导零 // 2. 按4位分组,不足补零 // 3. 遍历每组,调用convertSection // 4. 从高到低组装结果,处理段间零 // 5. 如果最终为空,返回"零" } std::string convertDecimalPart(const std::string& decPart) { // ... 同前 } std::string normalizeResult(const std::string& raw) { // ... 集成前述所有规则,注意调用replaceAllCN处理中文 } public: std::string convert(const std::string& input) { // ... 主流程同前 } }; int main() { RMBConverter converter; std::string line; while (std::getline(std::cin, line)) { // 输入清理 line.erase(line.find_last_not_of(" \n\r\t") + 1); line.erase(0, line.find_first_not_of(" \n\r\t")); if (line.empty()) continue; std::cout << converter.convert(line) << std::endl; } return 0; }

最后的优化建议

  1. 提前构造测试集:将第4.2节的测试用例写成数组,在本地反复测试,确保每个都能通过。
  2. 使用调试输出:在开发阶段,可以在关键函数内打印中间结果,例如打印每一段的转换结果,观察拼接过程。
  3. 关注性能:虽然本题数据量小,但良好的习惯是使用std::stringreserve预分配空间,避免频繁内存分配。
  4. 代码风格:即使机试时间紧,也要保持函数功能单一、命名清晰。这不仅能减少错误,也能让阅卷人(或面试官)留下好印象。

人民币转换这道题,就像一面镜子,照出程序员对细节的掌控力和系统化思维的能力。它没有高深的算法,但想把所有边界条件都处理得滴水不漏,需要耐心、严谨和大量的测试。希望这篇详细的拆解,能帮助你不仅通过这道题,更能理解其中蕴含的软件工程思想。在机试中遇到时,祝你一次AC。