负二进制这个考点在 LeetCode 上其实不算高频,但 1017 这道题绝对值得单独拎出来说。我做完它大概花了 100 分钟——这不是因为它代码长,而是因为我在"为什么余数要这样处理"上反复绕圈子,最后把所有错误写法都试了一遍才彻底打通。这篇就好好复盘一下这道题,从数学原理到代码实现,包括我踩过的坑,一次说清楚。
1. 负进制这道题的直觉陷阱:为什么越是"简单题"越容易卡住
先说一下题目本身:给定一个整数 n,要求返回它的负二进制表示,也就是基数为 -2 的进制形式,结果只包含 0 和 1。给定范围是 n >= 0 还是包含负数?实际上 LeetCode 原题里 n 的范围是 -10^9 <= n <= 10^9,整数范围内都可以,而且结果是唯一确定的。
如果你和我一样,第一反应是先回忆"正进制怎么做",大概率会写出这样的伪代码:
text复制while (n > 0) {
res.push_back(n % 2);
n /= 2;
}
reverse(res.begin(), res.end());
这套逻辑在十进制转二进制时完全正确,因为每一步我们都把 n 分解成 2 * q + r,其中 r 是 0 或 1,商 q 继续迭代。核心前提是:除数是正数时,余数天然落在 [0, 除数) 之间,不会有任何意外。
但把除数换成 -2 之后,问题就来了。C++ 里 -3 % -2 的结果是 -1(向零取整),不是 1。余数变成了负数,这意味着每一位的数字可能不是 0 或 1,而是 -1、0、1 三种取值。负二进制根本不允许出现 -1 这种数字,所以直接照搬正进制套路必然出错。
这里要先明确一个基本定义:一个数的负二进制表示,是把它写成 d_k * (-2)^k + d_{k-1} * (-2)^{k-1} + ... + d_0 * (-2)^0 的形式,每个 d_i 只能是 0 或 1。也就是说,每一位的系数必须是非负的 0/1,但权重是正负交替的偶数幂。因为 (-2)^0 = 1,(-2)^1 = -2,(-2)^2 = 4,(-2)^3 = -8……权重一正一负,所以数字没有正负数限制,都可以用 0/1 系数组合出来。
明白这一点之后,你就能理解这道题为什么会被归为"看似简单但一写就错"的典型:它看起来是套进制模板,实际上考的是你对"带余除法在负除数下"的理解。很多人的错误不是不会写代码,而是数学直觉没有跟上——以为整数除法 + 取余在任何基数下都自动成立,实际上必须手动修正余数。
这道题适合谁看?适合所有刷 LeetCode 想巩固位运算、进制转换、数学推导的读者,也适合准备面试时想展现"会分析边界条件"的候选人。因为这道题只要你能把余数修正的推导过程讲清楚,面试官对你的评价会比做十道双指针高得多——它考察的是你能否跳出直觉、回归定义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数学原理拆解:余数为什么必须落在 [0,1],以及如何修正商
要彻底搞懂这道题,必须回到带余除法的定义。我们希望在每一步都能把当前的数 n 写成:
text复制n = q * (-2) + r
其中 q 是下一步继续迭代的商,r 是当前位要写入的余数。为了保证每一位只能是 0 或 1,我们必须让 r 满足 0 <= r < 2,也就是说 r 只能是 0 或 1。
问题在于,当 n < 0 或者 n 是正数但除数是负数时,C++ 的取余运算并不保证余数落在 [0, 2) 区间。比如:
n = -3,n % -2在 C++ 里等于-1(因为-3 / -2向零取整得到 1,1 * (-2) = -2,-3 - (-2) = -1)n = 1,1 % -2也等于1,这个反而正常n = 2,2 % -2 = 0,商为-1,余数 0 也没问题
所以问题往往出在余数为负的情况下。我们来看一个具体的数学推导过程。
假设在某一步我们得到:
text复制n = q * (-2) + r
如果 r < 0,说明这一步的余数不合法(不是 0/1),需要把余数往上调。怎么调呢?我们给等式两边同时减 2 再加 2,并不改变结果:
text复制n = q * (-2) + r
= q * (-2) + r + 2 - 2
= (q + 1) * (-2) + (r + 2)
验证一下:(q + 1) * (-2) + (r + 2) = q*(-2) - 2 + r + 2 = q*(-2) + r,完全相等。
经过这个变换,新的余数是 r' = r + 2。因为原来的 r 是负数(最小为 -1),加 2 之后 r' 就落在 1 或 0,也就是我们想要的 0/1 之一。新的商是 q' = q + 1。
注意这里的关键点:新的商是加 1,不是减 1,也不是不变。 这个很容易搞混,因为很多人会想"为了抵消余数加 2,应该让商减 2 倍",但由于除数是负数,(q+1) * (-2) 比 q * (-2) 小 2,正好抵消余数的增加。如果除数是正数,那才是减 1。一句话总结:余数越界时,往正方向调整余数,商要加 1(因为除数是负数)。
这个推导一旦写出来,整个解题思路就清晰了。每轮循环只需要:
- 用 n 对 -2 求商和余数
- 如果余数 < 0,余数加 2,商加 1
- 记录当前余数(0 或 1)
- 把 n 更新为新的商,继续直到 n == 0
我们再来看一个手工推导的例子,用 n = 2 验证:
text复制第一轮:2 % (-2) = 0,2 / (-2) = -1,余数为 0,合法,记录 "0",n = -1
第二轮:-1 % (-2) = -1,-1 / (-2) = 0,余数为 -1,不合法
修正:余数 -1 + 2 = 1,商 0 + 1 = 1,记录 "1",n = 1
第三轮:1 % (-2) = 1,1 / (-2) = 0,余数 1,合法,记录 "1",n = 0
结束,reverse 得到 "110"
验证:1*(-2)^2 + 1*(-2)^1 + 0*(-2)^0 = 4 - 2 + 0 = 2,正确。这个例子很经典,因为 2 的负二进制表示是"110",不是"10",很多人第一次猜都会猜错,就是因为 (-2)^1 这一位的权重是 -2,必须配合高位才能表示正数。
数学上还有一种等价的理解方式:如果用 -2 作基数,那么一个数 n 的负二进制表示实际上可以用"不断除以 -2 并确保余数为 0 或 1"的算法来生成。这个算法和正进制几乎一样,唯一多出来的就是这个余数修正步骤。理解了这一点,代码就只是把推导翻译成语言而已。
3. 我踩过的几个坑:三种错误写法逐个拆解
这部分我想重点讲一下实际编码过程中的错误。如果你也是那种"数学推导没问题,但一写代码就挂"的人,下面几个坑你大概率也踩过。
3.1 直接取余不修正:结果对一半,负数案例全挂
最直接的错误是下面这种:
cpp复制class Solution {
public:
string baseNeg2(int n) {
if (n == 0) return "0";
string res = "";
while (n != 0) {
int r = n % (-2);
res.push_back('0' + r);
n /= (-2);
}
reverse(res.begin(), res.end());
return res;
}
};
问题出在哪?n % (-2) 可能为 -1,'0' + (-1) 变成字符 /,结果直接乱套。比如 n = 2 时,第一轮 r = 0,n = -1;第二轮 r = -1,n = 0,输出字符串里混进了 / 字符,当然 WA(Wrong Answer)。
这个错误的原因就是没理解负基数下余数不保证落在 [0, 1]。正进制时编译器已经是向零取整,但在正除数下余数永远不会是负数,所以没人会察觉。换成负除数,这个隐藏假设立刻崩溃。
3.2 试图用位运算 n & 1 取最低位
这是我浪费最多时间的坑。因为看到题目带"Base -2",我第一反应是"能不能用位运算加速"。毕竟普通二进制转换里,n & 1 可以快速取出最低位,然后 n >>= 1。但负二进制里,n & 1 的结果在 Python 和 C++ 中对负数不一致。
在 C++ 中,-3 & 1 等于 1,因为补码表示下 -3 的最低位确实是 1。但 -3 的负二进制最低位是多少?我们来算一下:
text复制-3 的第一位(2^0 位)必须是 1,因为只有奇数才贡献 1 的权重。
实际上在负二进制里,最低位 ((-2)^0 = 1) 确实等于 n 的奇偶性,也就是 n 如果是奇数,这一位是 1;偶数则是 0。这部分用 n & 1 是没问题的。
问题出在除以 -2 的步骤。如果我用 n >>= 1 代替 n /= -2,那就完全错了,因为 n >> 1 是除以 2,不是除以 -2。负二进制的高一位权重是 -2,所以"去掉最低位"应该除以 -2,而不是右移一位。这个混用在普通二进制里毫无影响,但在负二进制里会导致每次迭代的商完全不对。
总之,不要试图用位运算优化这道题的除法。现代编译器对除法的优化已经足够好,而且这里除以 -2 属于常数除法,编译器会自己优化成移位和乘法组合,你手动写移位反而容易写错。
3.3 递归写法里对 n / -2 和 n % -2 的顺序处理不当
递归思路本身没问题,但很多人在递归里先取余再递归,导致余数处理顺序不同,结果也跟着错。来看一个容易出错的递归版本:
cpp复制class Solution {
public:
string baseNeg2(int n) {
if (n == 0) return "0";
if (n == 1) return "1";
int q = n / -2;
int r = n % -2;
return baseNeg2(q) + to_string(r);
}
};
这个版本在 n = 3 时输出是什么?3 / -2 = -1(向零取整),3 % -2 = 1,递归计算 baseNeg2(-1),得到 -1 的结果……因为 r 是 1 没问题,但子问题的结果和余数拼接时,由于除法向零取整,q = -1 并不是真正的数学商。数学上 3 = (-2)*(-1) + 1,这里商 -1 是可以的,因为余数合法,但需要继续处理 -1。baseNeg2(-1) 又会怎样?-1 / -2 = 0,-1 % -2 = -1,余数 -1 不合法,递归返回的 to_string(r) 变成 "-1",拼接结果直接错误。
这个递归版本的问题在于:它没有在递归前修正余数,导致负余数一路往下传。正确的递归写法应该把修正逻辑放在取余之后:
cpp复制class Solution {
public:
string baseNeg2(int n) {
if (n == 0) return "0";
int r = n % (-2);
n /= (-2);
if (r < 0) {
r += 2;
n += 1;
}
string left = (n == 0) ? "" : baseNeg2(n);
return left + to_string(r);
}
};
这里的关键是必须先修正余数,再递归处理商。顺序反了,就会导致负数余数污染更高位的结果。
3.4 特殊值 n = 0 的处理细节
还有一个非常隐蔽的坑:如果 n 为 0,直接返回 "0",而不是进入循环。因为如果 n = 0 进入循环,0 % -2 = 0,商也是 0,循环条件 while (n != 0) 在第一轮执行后立刻退出,但你已经把一个 '0' push 进了结果字符串,reverse 后仍然是 "0",看似没问题。但如果 n 初始为 0 且我们不单独处理,某些写法在返回前会做 res.empty() 判断,逻辑会乱掉。更稳的方式是在函数开头直接特判 if (n == 0) return "0";,这样后面循环只需要处理非零情况,也避免了拼接时出现前导零的问题。
4. 最终正确实现与逐行验证:一份可以直接抄的代码
了解了所有坑之后,最终代码其实非常简洁。这里给出 C++ 实现,并逐行说明为什么这样写。
cpp复制class Solution {
public:
string baseNeg2(int n) {
if (n == 0) return "0";
string res = "";
while (n != 0) {
int remainder = n % (-2);
n /= (-2);
if (remainder < 0) {
remainder += 2;
n += 1;
}
res.push_back('0' + remainder);
}
reverse(res.begin(), res.end());
return res;
}
};
逐行解读:
if (n == 0) return "0";:处理零的特殊情况。否则循环根本不会执行,返回空字符串,但题目要求返回 "0"。while (n != 0):负二进制的每一位都要处理到商为 0 为止。注意这个循环可以处理负数 n,因为商有可能变成负数再变正,比如 -1 经过第一轮得到商 -0? 实际我们要看下面的步骤。int remainder = n % (-2);:向零取余,可能得到 0、1 或 -1。出现 -1 说明需要修正。n /= (-2);:先更新商,注意此时 n 可能变成负数。这里有个顺序问题:是先除再修正商,还是先修正再除?都能得到正确结果,但先除再修正更贴近"除法 + 修正"的语义,不容易乱。if (remainder < 0):如果余数为 -1,就进入修正分支。remainder += 2;:把余数从 -1 调整到 1。n += 1;:上面推导过,余数加 2 等价于商加 1。这里把修正后的商写回 n。res.push_back('0' + remainder);:把当前位追加到结果字符串。因为是逐位追加,最后需要 reverse。reverse(res.begin(), res.end());:反转字符串,得到正确的负二进制表示。
我测试过的几个关键用例:
| n | 输出 | 验证展开式 | 说明 |
|---|---|---|---|
| 0 | "0" | 0 | 特判 |
| 1 | "1" | 1 | 单个位 |
| 2 | "110" | 4 - 2 + 0 = 2 | 经典例子 |
| 3 | "111" | 4 - 2 + 1 = 3 | 纯 1 组合 |
| 4 | "100" | 4 + 0 + 0 = 4 | 刚好是 4 |
| -1 | "11" | -2 + 1 = -1 | 负数 |
| -2 | "10" | -2 + 0 = -2 | 负数 |
| -3 | "1101" | -8 + 4 + 0 + 1 = -3 | 负数多位数 |
建议你自己拿 n = -3 手工推导一遍。过程如下:
text复制第一轮:n = -3,-3 % -2 = -1(因为 -3 / -2 向零取整为 1,1*(-2) = -2,-3 - (-2) = -1)
n = -3 / -2 = 1,余数 -1 < 0,修正:余数 = 1,商 = 1 + 1 = 2,记录 "1"
第二轮:n = 2,2 % -2 = 0,n = 2 / -2 = -1,余数 0 合法,记录 "0"
第三轮:n = -1,-1 % -2 = -1,n = -1 / -2 = 0,修正:余数 = 1,商 = 1,记录 "1"
第四轮:n = 1,1 % -2 = 1,n = 1 / -2 = 0,余数 1 合法,记录 "1"
结束,得到的字符串是 "1011",reverse 后是 "1101"
验证一下 "1101" 展开:1*(-2)^3 + 1*(-2)^2 + 0*(-2)^1 + 1*(-2)^0 = -8 + 4 + 0 + 1 = -3,完全正确。注意每一次循环记录的是当前位,但顺序是最低位先记录,所以最后必须 reverse。
代码的时间复杂度是 O(log|n|),因为每次除法把 n 的绝对值大致缩小一半(可能略微波动),空间复杂度 O(log|n|) 用于存储字符串。在 LeetCode 的输入范围内,循环次数最多 30 多次,性能完全不是问题。
5. 从这一题延伸出来的通用结论:任意负基数转换怎么处理
做完了这道题,我发现它的价值并不止于 AC 本身。它其实给了一个处理任意负基数的通用框架,以后遇到 base = -3、-8 甚至任意负数,都能套同一套逻辑。
5.1 通用算法模板
假设基数 base 是一个小于 -1 的整数,我们想把 n 转换成 base 进制表示,每一位数字取值 0 到 |base|-1。通用循环如下:
cpp复制string toNegativeBase(int n, int base) {
if (n == 0) return "0";
string res = "";
while (n != 0) {
int remainder = n % base;
n /= base;
if (remainder < 0) {
remainder += abs(base);
n += 1;
}
res.push_back(digits[remainder]); // digits 表示 0-9 + 字母
}
reverse(res.begin(), res.end());
return res;
}
注意这里的 base 是负数,所以 remainder < 0 时,remainder += abs(base) 把余数修正到合法区间。为什么要 n += 1?还是那个推导:因为新的余数比原来大了 |base|,而除数是负数,商加 1 会让整体减少 |base|,正好抵消。这个推导对所有负基数都适用,非常漂亮。
举个例子,把 n = 10 转换成 base = -3:
text复制第一轮:10 % -3 = 1,10 / -3 = -3(向零取整),余数 1 合法,记录 "1",n = -3
第二轮:-3 % -3 = 0,-3 / -3 = 1,余数 0 合法,记录 "0",n = 1
第三轮:1 % -3 = 1,1 / -3 = 0,记录 "1",n = 0
reverse 得到 "101"
验证:1*(-3)^2 + 0*(-3)^1 + 1*(-3)^0 = 9 + 0 + 1 = 10,正确。
这个通用模板在 LeetCode 上还有一道姊妹题:1073. Adding Two Negabinary Numbers(负二进制相加)。那道题会用到位运算和进位逻辑,建议做完 1017 后趁热打铁去做一下,会加深对负二进制每一位权重的理解。两道题一起刷下来,负二进制这个考点你基本上就吃透了。
5.2 负二进制和正二进制的一个直观类比
很多人刷完 1017 还是觉得"负二进制"很拗口。这里分享一个帮助理解的类比。
正二进制你可以想象成用砝码称重,砝码重量是 1、2、4、8……全部放在同一边,数字的每一位就是一个砝码放或不放。负二进制呢?砝码重量变成 1、-2、4、-8……也就是说 2、8、32 这些砝码要放在另一侧,或者更准确地说,奇数位(2^0、2^2、2^4……)是正向砝码,偶数位(2^1、2^3……)是反向砝码。你需要在这样一个"正反交替"的砝码组合里,用最少的砝码凑出给定的重量。
用这个类比去看题目,你就不会奇怪为什么负数也能有唯一的 0/1 表示——因为你可以把反向砝码往反方向加,自然就能称出负重量。每个数在正二进制里唯一,在负二进制里也是唯一,不存在二义性。理解了这个,再看代码里的"余数修正",其实就是"当前正反向砝码放不下,需要借一个更大的砝码并调整方向"的操作,本质上和二进制里的进位是一回事。
5.3 面试中如何向面试官解释这道题
如果你在面试中遇到这道题(或者类似的自定义进制题目),我建议按这个顺序表达:
第一,先说结论:这道题核心是"带余除法的余数修正"。因为负数除法不保证余数为正,所以需要把负余数调整到 [0, 1],同时调整商。
第二,给出数学推导:n = q * (-2) + r,如果 r < 0,则把式子改写成 (q+1) * (-2) + (r+2)。
第三,写代码:按照"取余、更新商、修正余数、记录、反转"的顺序实现。
第四,举一个例子验证,比如 n = 2 得到 "110"。这样一套下来,面试官就能看到你既懂结论,又懂推导过程,还能动手实现。这比闷头写代码然后说"我过了"要加分得多。
我在做完这道题后最大的感受是:很多看似基础的题目,真正的难点并不在代码,而在你是否有意识地去检查语言本身给你埋下的假设。 C++ 和大多数语言一样,整数除法是向零取整的,这在正除数下完美工作,但除数变成负数后,一切需要重新审视。你不亲手踩一遍这些坑,仅凭直觉很难预料到输出会混进 / 这种奇怪字符。
最后分享一个小技巧:遇到进制转换类题目,先在纸上手工推导几个典型值(正数、负数、零),再对照代码跑一遍。我在这道题上花掉的 100 分钟,至少有 70 分钟是在手推各种案例,真正写代码反而只用了 10 分钟左右。这个习惯让我后来做任何进制相关题目都稳了很多,也推荐给你。
