如果你在准备面试,或者正在刷 LeetCode 提升算法能力,那么“数字转十六进制”这道题(力扣第405题)大概率会进入你的视野。它看起来简单,不就是进制转换吗?但很多人在处理负数、边界条件和位运算时,会写出有缺陷的代码,导致面试时被追问细节而卡壳。
这道题真正的价值,远不止于让你学会Integer.toHexString()的调用。它是一道绝佳的“思维体操”,能帮你深入理解计算机中有符号整数的二进制表示、补码运算以及位操作的底层逻辑。很多开发者对“负数如何转十六进制”只有模糊的概念,这道题能帮你把概念彻底夯实。
本文将带你从“会做”到“精通”。我们不仅会给出多种解法,更会深入剖析:
- 为什么处理负数不能简单取绝对值?这背后是原码、反码、补码的核心差异。
- 如何用位运算优雅地“提取”每4位?这是理解计算机处理数据的基础。
- 不同解法(循环、递归、库函数)的优劣与适用场景是什么?帮你建立选择算法的直觉。
读完本文,你将能清晰、自信地解答这道题,并真正掌握其背后的计算机原理,在面试和实际开发中都能受益。
1. 问题重述与核心难点
力扣405. 数字转换为十六进制数
题目描述:给定一个整数num,返回一个字符串,表示该整数的十六进制表示(对于负数,使用补码形式表示)。
注意:
- 十六进制表示中所有字母 (
a-f) 都必须是小写。 - 十六进制字符串中不能包含多余的前导零。如果数字为
0,则用单个字符'0'表示。 - 给定的数字确保在 32 位有符号整数范围内。
示例:
输入: 26 输出: "1a" 输入: -1 输出: "ffffffff"核心难点分析:这道题的“坑”主要在于对负数的处理。一个常见的错误思路是:
// 错误示范! public String toHex(int num) { if (num == 0) return "0"; boolean isNegative = num < 0; long n = isNegative ? -num : num; // 对负数取绝对值 // ... 后续转换逻辑 }为什么这是错的?因为对于 32 位有符号整数-1,其二进制补码是11111111111111111111111111111111(32个1)。如果取绝对值变成1,再转换得到"1",这与题目要求的"ffffffff"完全不符。
题目的关键要求是:对于负数,必须使用其补码形式对应的无符号值来进行转换。这意味着我们需要直接操作num的二进制位,而不是其数学意义上的值。
2. 核心概念:补码、位运算与十六进制映射
在深入代码之前,必须厘清几个基础但至关重要的概念。
2.1 补码:计算机表示负数的基石
在计算机中,有符号整数通常用补码表示。其规则如下:
- 正数:补码等于其原码(即直接的二进制表示)。
- 负数:补码等于其绝对值的原码按位取反后加1。
例如,对于8位整数-1:
1的原码:00000001- 按位取反:
11111110 - 加1:
11111111所以-1的补码是11111111。
一个关键特性:在补码表示下,将一个负数(如-1)的二进制位直接当作无符号整数来解释,会得到一个很大的正数(对于32位的-1,这个值是2^32 - 1 = 4294967295)。题目正是要求我们输出这个无符号值对应的十六进制。
2.2 位运算:提取“四位一组”的利器
十六进制的一位数字,正好对应二进制的四位(因为2^4 = 16)。转换的核心就是从低位到高位,每次取出4个二进制位,然后映射成十六进制字符。
这里用到两个关键的位运算符:
&(按位与):x & 0xf可以获取x最低的4位(因为0xf二进制是1111)。>>>(无符号右移):x >>> 4将x的二进制位整体向右移动4位,高位补0。这与>>(算术右移,高位补符号位)在处理负数时有本质区别,这里我们必须使用>>>。
2.3 映射表
建立一个长度为16的字符数组,用于将0-15的数字映射到'0'-'9'和'a'-'f'。
char[] map = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'}; // map[15] 就是 'f'3. 解法一:循环+位运算(推荐解法)
这是最直观、效率高且易于理解的解法。思路是:只要num不为0,就循环取出其最低4位,转换为字符,然后无符号右移4位。
class Solution { public String toHex(int num) { if (num == 0) { return "0"; } // 十六进制字符映射表 char[] hexChars = "0123456789abcdef".toCharArray(); StringBuilder sb = new StringBuilder(); // 关键:使用 while (num != 0) 而不是 for,因为负数右移最终会变成0 while (num != 0) { // 1. 取出最低4位:num & 0xf int digit = num & 0xf; // 2. 映射为十六进制字符,并插入到结果字符串的头部 sb.insert(0, hexChars[digit]); // 3. 无符号右移4位,处理下一组 num >>>= 4; // 注意是 >>> 不是 >> } return sb.toString(); } }代码逐行解析:
- 边界处理:
num == 0时直接返回"0"。 - 映射表:使用字符串转换字符数组,更简洁。
- 循环条件
while (num != 0):对于正数,右移最终会变成0;对于负数,无符号右移 (>>>) 最终也会变成0。这是循环终止的条件。 num & 0xf:0xf的二进制是1111,按位与操作会保留num最低4位,其余位清零,得到0-15之间的值。sb.insert(0, ...):因为我们是从低位开始取,但最终字符串需要高位在前,所以每次将新字符插入到字符串最前面。虽然insert(0)在时间复杂度上不是最优(每次插入导致后续字符移动),但对于固定32位整数最多循环8次,影响可忽略。追求极致性能可使用数组反向填充。num >>>= 4:这是核心中的核心。使用无符号右移,无论num是正还是负,高位一律补0。这保证了我们能正确地处理负数的补码位,并最终使num变为0退出循环。
复杂度分析:
- 时间复杂度:O(1)。因为整数固定32位,最多右移8次(32/4=8)。
- 空间复杂度:O(1)。除了结果字符串,只使用了固定大小的额外空间。
4. 解法二:使用固定次循环
有些同学可能对while (num != 0)处理负数的边界感到不安,或者想避免insert(0)的操作。可以采用固定循环8次,然后去除前导零的方法。
class Solution { public String toHex(int num) { if (num == 0) return "0"; char[] hexChars = "0123456789abcdef".toCharArray(); char[] res = new char[8]; // 32位整数最多8位十六进制数 int index = 7; // 从数组末尾开始填充 for (int i = 7; i >= 0; i--) { int digit = num & 0xf; res[i] = hexChars[digit]; num >>>= 4; } // 去除前导零 int start = 0; while (start < 8 && res[start] == '0') { start++; } return new String(res, start, 8 - start); } }代码解析:
- 我们预先分配一个长度为8的字符数组
res。 - 循环8次,每次都取出最低4位,从数组末尾向前填充。这样填充完成后,
res[0]就是最高位。 - 循环结束后,去除数组前面的
'0'字符。 - 最后用有效的部分构建字符串。
这种方法逻辑更“稳固”,清晰地展示了32位整数与8位十六进制数的对应关系,且没有insert(0)的性能顾虑。
5. 解法三:递归解法
递归解法的思路与循环类似,但表达更简洁。其核心是:当前数字的十六进制表示 = (更高位的十六进制表示) + (最低4位的字符)。
class Solution { char[] map = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'}; public String toHex(int num) { // 递归终止条件 if (num == 0) { return ""; } // 获取最低4位对应的字符 char currentDigit = map[num & 0xf]; // 递归处理右移4位后的部分 String higherDigits = toHex(num >>> 4); // 拼接:注意顺序,高位在前 return higherDigits + currentDigit; } }注意:上面的递归版本在num==0时返回空字符串,所以主函数需要额外处理:
public String toHex(int num) { if (num == 0) return "0"; return toHexHelper(num); } // 上面定义的递归函数重命名为 toHexHelper递归解法虽然优雅,但存在栈空间开销,且对于“去除前导零”的处理不如循环直观。在面试中,解释递归栈可能增加沟通成本,因此更推荐使用解法一或二。
6. 解法四:利用Java库函数(仅作了解)
Java 的Integer类本身就提供了toHexString(int i)方法。这道题在某种意义上可以“一行解决”:
class Solution { public String toHex(int num) { return Integer.toHexString(num); } }但是,面试中绝对不要只写这个!面试官考察的是你对原理的理解和实现能力。不过,了解库函数的实现可以作为对照和验证。你可以查看Integer.toHexString的源码,会发现其内部实现逻辑与我们上面的解法二非常相似。
7. 关键点剖析与常见“坑”
7.1 为什么必须用>>>而不用>>?
这是本题最大的陷阱。
>>(算术右移):高位用符号位填充。对于负数,符号位是1,所以右移后高位补1。例如-1 >> 4结果还是-1(二进制全1),会导致无限循环。>>>(无符号右移):高位用0填充。无论正负,右移后高位都是0。这保证了数值最终能变为0,循环可以终止,并且是按我们期望的方式处理补码的每一位。
7.2 如何处理前导零?
题目要求不能有多余的前导零。我们的策略是:
- 解法一:因为
while (num != 0)在num为0时根本不会进入循环,所以对于像0这样的输入,我们在开头特判返回"0"。对于其他数字,循环从第一次遇到非零位开始拼接,自然没有前导零。 - 解法二:显式地循环8次得到可能包含前导零的结果,然后再用一个循环跳过开头的
'0'。特别注意:"0"本身是一个合法输出,不能把单个'0'也去掉。
7.3 负数转换的直观理解
对于负数-1(0xffffffff):
- 第一次循环:
-1 & 0xf = 15->'f',-1 >>> 4 = 0x0fffffff(值很大,但仍是正数)。 - 第二次循环:
0x0fffffff & 0xf = 15->'f',>>> 4。 - ... 重复8次,得到
"ffffffff"。 这个过程就是不断将其补码的二进制位分组翻译成十六进制。
8. 测试用例与验证
编写全面的测试用例是验证代码正确性的关键。
public class TestToHex { public static void main(String[] args) { Solution solution = new Solution(); // 基础测试 System.out.println(solution.toHex(26)); // 预期: "1a" System.out.println(solution.toHex(0)); // 预期: "0" System.out.println(solution.toHex(1)); // 预期: "1" System.out.println(solution.toHex(16)); // 预期: "10" // 负数测试 (核心) System.out.println(solution.toHex(-1)); // 预期: "ffffffff" // -1 的补码是 32个1,十六进制就是8个f System.out.println(solution.toHex(-2)); // 预期: "fffffffe" // -2 的补码: ...1110,所以是 fffffffe System.out.println(solution.toHex(-16)); // 预期: "fffffff0" // 边界测试 System.out.println(solution.toHex(Integer.MAX_VALUE)); // 预期: "7fffffff" System.out.println(solution.toHex(Integer.MIN_VALUE)); // 预期: "80000000" // Integer.MIN_VALUE 的二进制是 1000...000,十六进制就是 80000000 } }运行你的解法,确保所有测试用例都能通过。理解每个测试用例的输出,特别是负数和边界值,能极大地加深你对补码和位运算的理解。
9. 扩展与最佳实践
9.1 扩展到其他进制
掌握了十六进制的转换,八进制、二进制就触类旁通。只需修改两个地方:
- 掩码 (Mask):二进制用
0x1(取1位),八进制用0x7(取3位)。 - 移位数量:二进制右移1位 (
>>>1),八进制右移3位 (>>>3)。 - 映射表:二进制映射表是
{'0','1'},八进制是{'0','1','2','3','4','5','6','7'}。
9.2 在工程中的使用
在实际开发中,我们当然优先使用Integer.toHexString()、String.format("%x", num)等库函数。但理解其原理至关重要,例如:
- 调试:当需要查看内存或网络数据包的原始十六进制转储时。
- 协议解析:处理某些自定义二进制协议时,需要手动解析字节流中的整数字段。
- 哈希展示:常见的MD5、SHA1哈希值都是以十六进制字符串呈现的。
- 性能敏感场景:在极端性能要求下,自定义的、无额外对象分配的转换函数可能比库函数更有优势。
9.3 面试回答要点
如果面试中被问到这道题,建议按以下脉络回答:
- 阐述难点:首先指出处理负数的补码是本题关键,不能简单取绝对值。
- 解释原理:简要说明补码、位运算 (
&,>>>) 和十六进制“四位一组”的关系。 - 给出解法:首选描述循环+位运算的解法,并说明使用
>>>的原因。 - 分析复杂度:强调是 O(1) 时间,因为整数位数固定。
- 提及边界:主动说明对
num==0的特判和前导零的处理。 - 对比方案:可以提一下递归和库函数,但说明循环解法的优越性。
- 验证测试:口头给出几个关键测试用例(正数、0、-1、MIN_VALUE)。
这道题虽然标为“简单”,但它像一面镜子,能清晰照出一个开发者对计算机基础知识的掌握程度。花时间彻底弄懂它,不仅是为了通过一道算法题,更是为了构建坚实的技术底层认知。下次再遇到位运算或进制转换的问题,你就能从容应对了。建议将本文的代码和理解收藏,在面试前快速回顾,定能助你一臂之力。