面试题65:不用加减乘除做加法
用异或生成不带进位的和、与运算左移生成进位,在固定宽度无符号位模式上迭代到进位为零。
学习目标
- 能...
- 能...
- 能...
核心思路
理解问题的核心算法。
从“1 ↡ 1 为什么要拆成两个↡”开始
十进制↡把“当前位写什么”和“向↡带多少”分开处理,二进制也一样。单个位0加1的本位是1、没有↡;1加1的本位是0、向左一位进1。只要能分别得到这两个部分,就能把多位加法改写成重复的位运算。
本题是“不用加减乘除做加法”,输入是两个普通整数,不是↡表示的十进制↡。旧页讲链表逐位加法、长度不等和尾↡进位,属于另一道题,与作者面试题65及其七组正负整数测试无关。
对单个位a与b,异或给出不考虑进位的本位结果,与运算标记两位同时为1的位置:
先预测5和7的第一轮:0101异或0111得到0010,按位与得到0101,左移一位后进位是1010。0010还不是答案,因为它与1010仍可能在相同位上再次产生进位。
异或得到不带进位的和
异或只在两个输入位不同的时候输出1,正好对应0加1或1加0的本位结果;两个位都是1时输出0,把需要进位的部分暂时留给另一个变量。因此可以把它称为。
原章概念“异或得到不带进位的和”不是类比,而是四行真值表的逐项等价。它一次同时处理机器字的全部位。
与运算后左移得到进位
按位与只在两个输入位都为1时输出1,这些位置各自产生一个二进制进位。进位属于左边更高一位,所以整体左移一位,得到。
这个等式用于证明,受限函数体里并不写加号。它说明原来两个加数可以替换成“异或和”与“左移进位”,两者总和保持不变。
这就是“与运算后左移得到进位”。左移不能省略;不左移的与结果仍停在产生冲突的原位,相当于把1加1误记为当前位1。
为什么必须重复到进位归零
新进位可能与异或和中的1再次重叠。例如5加7第一轮得到0010与1010,两者在第二位仍同时为1,还会产生新的0100进位。每轮都把当前两个位向量重新拆成异或和与进位,直到第二个向量为0。
作者用do-while,因此即使第二个输入一开始为0,也会执行一次异或与按位与,再发现进位为0。改用普通while从进位非0时进入也能得到相同答案,只是零输入路径少执行一轮。
原章的“直到进位为0”给出终止条件。此时第二个加数为0,第一位向量就是最终和。
这里W是机器字位宽。每轮进位的最低有效1至少向左移动一位;到达最高位后再左移就被固定宽度丢弃,所以最多W轮后进位为0。
忠实还原作者循环
作者把两个int分别作为暂存和与进位,循环体不含四则运算符:
int Add(int num1, int num2) {
int sum;
int carry;
do {
sum = num1 ^ num2;
carry = (num1 & num2) << 1;
num1 = sum;
num2 = carry;
} while (num2 != 0);
return num1;
}时间可写为O(W),额外空间O(1);对固定32位int,W是常数,渐进上也常称O(1)。实际轮数取决于进位链长度,1加2只需一轮,而低位连续为1会让进位逐位传播。
这段源码在常见编译器与二进制补码机器上能通过作者负数测试,但标准C++层面存在风险:num1与num2是有符号int,按位与结果也为有符号int;对负值左移,或把正值左移到不可表示的符号位,行为未定义。
负数为何仍能用同一位循环
在固定宽度中,正负数都只是W位模式。XOR、AND和左移不需要识别符号,只实现模2的W次方加法;若真实数学和位于有符号范围内,最终位模式解释回有符号数就是正确结果。
例如32位的-1是全1,与2相加时进位会沿高位传播,最终得到00000001。-2与-8得到FFFFFFF6,按补码解释为-10。作者七组测试都没有让数学和超出int范围。
若真实和超过有符号最大值或小于最小值,普通C++有符号加法本来就没有定义;无符号位循环则会给出模2的W次方回绕结果。接口必须明确要拒绝溢出、返回更宽结果,还是接受模加法,不能混为一谈。
现代固定32位实现
把核心函数定义为uint32_t上的,所有位移都有定义。需要有符号输入输出时,再用bit_cast保留同一32位对象表示;这要求平台提供int32_t并采用预期的补码表示。
#include <bit>
#include <cstdint>
#include <limits>
std::uint32_t addModulo32(
std::uint32_t left,
std::uint32_t right) {
while (right != 0U) {
const std::uint32_t sum =
left ^ right;
const std::uint32_t carry =
(left & right) << 1U;
left = sum;
right = carry;
}
return left;
}
std::int32_t addInt32(
std::int32_t left,
std::int32_t right) {
const auto leftBits =
std::bit_cast<std::uint32_t>(left);
const auto rightBits =
std::bit_cast<std::uint32_t>(right);
const auto resultBits =
addModulo32(leftBits, rightBits);
return std::bit_cast<std::int32_t>(
resultBits);
}若业务只接受数学和仍处于int32范围的输入,应在外层用更宽类型检测溢出;检测代码可以使用普通算术,因为题目限制的是核心Add函数体。若连验证也禁止加减,可用符号位规则检测:两个同号输入得到异号结果时发生有符号溢出。
固定宽度是契约的一部分。把代码从32位int直接复制到64位类型,最大循环轮数和溢出边界都会改变。
循环不变式与终止证明
每轮开始时,left与right的模和等于原输入模和。XOR和左移AND只是把同一列的本位与进位重新分组,所以更新后不变量仍成立。循环结束时right为0,不变量化为left等于原和的位模式。
设right最低1位的位置为p。left与right的按位与只可能保留right已有的1,再左移使最低可能位置至少成为p加1;若没有公共1则carry直接为0。因此非零carry的最低1位严格向高位移动,有限W位内必然终止。
这个证明比“看起来进位会消失”更强,也解释了最坏W轮上界。最高位进位被无符号左移按模丢弃,正是模加法的一部分。
最坏进位链怎样形成
当一个数的低位连续为1、另一个数只在最低位为1时,进位会一轮只向左推进一位。例如32位无符号的0x7FFFFFFF与1相加,第一轮把最低位冲突变成进位,之后它依次穿过其余30个连续1,最终得到0x80000000。循环轮数接近机器字宽度,而不是由十进制数值大小决定。
0xFFFFFFFF与1更能展示模语义:进位一路穿过全部32位,最高位再左移后被截掉,结果为0。对uint32_t这是定义明确的回绕;若把同一位模式解释成有符号-1再与1相加,数学结果0仍可表示,所以也应得到0。
相反,0x7FFFFFFF与1的位结果0x80000000在int32_t中解释为最小负数,但真实有符号数学和超过最大值。核心位循环没有失败,它正确完成了模加法;错误在于调用方若把回绕结果宣称为未溢出的有符号和。测试必须按接口分别断言“模结果”或“溢出错误”,不能只比较同一串比特。
七组官方测试覆盖什么
作者依次测试1与2、111与899、-1与2、1与-2、3与0、0与-4、-2与-8。它们覆盖无长进位、多位连续进位、两个跨符号方向、零操作数和两个负数。
111加899得到1010能检查多轮进位;3加0验证do-while至少执行一次仍保持原值;负数用例则暴露有符号左移的可移植性问题。
还应增加最高位附近测试:无符号0xFFFFFFFF与1应模回0;有符号最大值与0保持不变;若接口拒绝有符号溢出,则最大值加1必须走错误通道,而不是把最小值当作正常数学和。
#include <cassert>
#include <cstdint>
#include <limits>
void testBitAddition() {
assert(addInt32(1, 2) == 3);
assert(addInt32(111, 899) == 1010);
assert(addInt32(-1, 2) == 1);
assert(addInt32(1, -2) == -1);
assert(addInt32(3, 0) == 3);
assert(addInt32(0, -4) == -4);
assert(addInt32(-2, -8) == -10);
assert(addModulo32(
UINT32_C(0xFFFFFFFF), 1U) == 0U);
assert(addModulo32(0U, 0U) == 0U);
}随机对拍应在uint32_t上与标准无符号加法比较,因为两者都定义为模2的32次方;有符号对拍只选择数学和不溢出的输入,或先明确采用回绕契约。
本章练习
(↡)
练习
问题 1: 请说明...
问题 2: 请说明...
问题 3: 请说明...
概念说明
本章核心概念包括:直到进位为0。理解这些概念是掌握解题方法的关键,需要结合具体示例与边界条件反复练习。
本章回顾
- 不用加减乘除做加法的输入是两个整数,不是十进制链表。
- 异或得到不带进位的和,与运算后左移得到进位。
- 新的暂存和与进位继续执行同样拆分,直到进位为0。
- 循环保持两个位向量的模和不变,最多经过机器字位宽轮。
- 作者signed int版本能通过常见补码机器测试,但负数或符号位左移不具标准可移植性。
- 现代核心应在uint32_t等无符号固定宽度上运行,再明确有符号解释。
- 数学和溢出与模回绕是两种不同接口契约。
- 作者七组测试覆盖正负和零,但还需补最高位、回绕及溢出错误通道。