ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

不创建临时变量交换两个数:异或解法、边界陷阱与工程选型

不创建临时变量交换两个数:异或解法、边界陷阱与工程选型 早几年我去一家做嵌入式芯片的公司面试第一道笔试题就是“不创建临时变量实现两个数的交换”。当时我几乎没有犹豫直接写下了异或版本的三行代码心想这种八股题不是送分题吗。面试官扫了一眼却没让我过反而追问了一句“你说说你这个解法在什么情况下会出错”我愣在原地脑子里全是位运算的完美性质完全没有意识到这个看似人畜无害的问题背后藏着溢出、自交换、类型兼容和可读性这些一连串的坑。那之后我在团队里带过不少新人也帮朋友校招模拟过面试题发现“不创建临时变量交换两个数”几乎是所有笔试题库里的常客。它看起来只是一道入门级题目但真要把它讲透牵扯出来的东西远不止三行代码。这篇博文我想把自己多年积累的理解、踩过的坑、实测过的性能数据都整理一遍尤其想把“为什么会有这种需求”“哪些解法真正可用”“面试里怎么说才加分”这些事情讲清楚。不管你是准备校招的应届生、写嵌入式底层的工程师还是纯粹想补一补位运算和边界条件的同学这篇内容都会对你有用。1. 这个经典题目到底在考什么1.1 从一段最普通不过的交换代码说起几乎所有人最早学习编程时都写过这样的交换代码int temp a; a b; b temp;这段代码干净、直观、容错性好是教科书级别的写法。temp 变量就像一个中转站先把 a 的值存进去然后把 b 的值赋给 a最后把“中转站”里的原 a 值赋给 b。整个流程三步走逻辑清晰不会让人产生任何理解负担。可题目偏偏出了一个附加条件不允许创建临时变量。这就像要求你既不使用额外的碗又要完成两个杯子里的液体互换。乍一听不可思议但数学运算和位运算给了我们绕开中转站的路径。这种“限制条件下重新设计算法”的思维本身就可以考察一名开发者对底层运算的理解深度。1.2 少数真正需要省一个变量的场景先说结论绝大多数现代应用开发中你根本没必要省这个临时变量。a、b、temp 都是栈上变量函数结束时一起回收多一个少一个对内存占用几乎无感。但在某些极其关注资源消耗的环境下这个少一个变量的诉求是真实存在的。第一种是寄存器极度紧缺的嵌入式场景。底层寄存器数量通常只有十几个到几十个如果上下文里需要保存的状态比较多编译器为了让这段交换代码不打乱寄存器分配可能会产生额外的压栈push和弹栈pop操作。此时如果采用无临时变量的写法就有机会减少一条寄存器分配间接降低代码体积和指令周期。第二种是类似智能卡、安全芯片这类极小存储空间的固件开发ROM 和 RAM 大小按 KB 甚至 B 计算一行多余的局部变量声明都可能让编译器多生成一条指令于是开发者才会去抠这种优化。我在嵌入式项目里确实见过为了省几个字节的 RAM把交换函数写成宏里面用异或实现。那种环境下这种写法不是炫技而是被寸土寸金的存储空间逼出来的选择。理解了这一点就不会把这道题单纯当成“算法版脑筋急转弯”。1.3 面试官更看重的考察点抛开少数嵌入式和安全固件场景面试官出这道题时绝大多数情况下都是醉翁之意不在酒。他通过题目想看到的不是你背下来的三行代码而是你有没有能力回答这样几个问题你能不能从“为什么可以这样交换”讲到底层的数学或位运算性质你能不能在回答里主动提到溢出和自交换这些边界条件你清不清楚这种技巧在工程上的适用边界很多候选人只做到第一步写出异或版本就觉得自己赢了。但真正能在一群人里脱颖而出的人会顺带给出下面的补充“这个写法有个前提就是两个变量不能是同一个地址。而且如果是整数类型的加法版本还要考虑溢出的风险。”这种主动暴露问题、给出稳妥方案的能力才是工程师和只会抄答案的人之间的分水岭。2. 不加临时变量的主流写法与原理2.1 加减法数学运算的直白实现说到不用临时变量交换大多数人第一反应就是加减法a a b; b a - b; a a - b;这个方案的推理过程也不难理解。第一行之后a 变成了 a 和 b 的和。第二行里用这个和减去 b得到的就是原来的 a赋值给 b。第三行再用这个和减去已经被“换过”的 b此时 b 存的是原来的 a得到的就是原来的 b赋值给 a。为了更直观可以带入具体数字走一遍。假设 a 5b 3a 5 3 8 b 8 - 3 5 a 8 - 5 3整个过程结束a 变成了 3b 变成了 5。逻辑本身没有问题。但是这里有一个隐患a b 可能超出当前整数类型能表示的范围。以 32 位有符号整数为例最大值是 2147483647如果 a 和 b 分别是 20 亿和 10 亿第一行算出 30 亿已经超过了 int 能表达的边界。在 C 语言里有符号整数溢出属于未定义行为程序接下来可能给出完全不符合预期的结果。虽然后两行数学上仍然可能“恢复”出正确的 a 和 b但中间值已经越界很多编译器优化模式下会彻底放飞自我。如果非要用加减法比较稳妥的做法是加一步判断或者改用更宽的类型承载中间结果。不过这又会增加额外的逻辑和内存开销原本“省一个临时变量”的初衷就被削弱了。2.2 异或运算最经典也是最优雅的解法异或法是我个人最推荐用来理解这道题的方案a a ^ b; b a ^ b; a a ^ b;异或运算的三条性质构成了这个解法的全部基础1. 交换律a ^ b b ^ a 2. 结合律(a ^ b) ^ c a ^ (b ^ c) 3. 自反性a ^ a 0a ^ 0 a把整个流程拆开看a a ^ b; // 此时 a 存的是 a 与 b 的异或结果 b a ^ b; // 此时 a 等于原来的 a ^ b再 ^ b得到原来的 a a a ^ b; // 此时 a 等于原来的 a ^ b再 ^ 现在的 b原 a得到原来的 b用具体数字验证a 5二进制 0101b 3二进制 0011a 0101 ^ 0011 0110即 6 b 0110 ^ 0011 0101即 5回到原来的 a a 0110 ^ 0101 0011即 3回到原来的 b异或法的最大优势是没有算术溢出问题。计算机里位运算的结果长度与操作数相同不会产生“装不下”的中间值。这也让它成为各种“无临时变量交换”版本中最安全、最常被面试官默认的标准答案。2.3 乘除法与其它变体为什么不推广与加减法对应的还有乘除版本a a * b; b a / b; a a / b;原理和加减法对称代入数字很容易验证。但它的坑比加减法更多。首先是溢出a * b 的中间结果比 a b 更早可能越界其次是除零只要 a 或 b 中有一个是 0第二行或第三行就会触发除零异常最后是浮点数除法精度损失整型除法在无法整除时直接截断根本得不到正确结果。除此之外还有一些冷门的位运算变体比如利用取反加一操作来回折腾或者用一条汇编指令 XCHG 在寄存器层面交换。这些写法要么可读性极差要么依赖特定 CPU 指令集与“题目考察原理”的初衷背道而驰。综合来看加减法适合用来理解“如何用数学运算暂存数据”的思路异或法是工程上和面试中的首选乘除法属于那种“了解即可千万不要往外掏”的方案。3. 边界条件和运行时 Bug 清单3.1 溢出C 语言里最容易被低估的坑前面提到加减法和乘除法都有溢出风险这里我补充一个实际案例。曾经有人在项目里写了一个无符号整型的“无临时变量交换”看起来挺成功unsigned int a 4000000000; unsigned int b 4000000000; a a b; b a - b; a a - b;结果跑起来以后a 和 b 确实交换了。但如果你中途打印第一行计算之后 a 的值会发现它变成了一个比较小的数字。这是因为 32 位无符号整型的上限是 42949672954000000000 4000000000 8000000000已经越界最终模 2^32 之后只剩下 3705032704。虽然随后的减法把值“绕”了回来中间结果却是错的。在某些情况下这种“绕回来”的行为可以被接受但在高可靠性的代码里任何未定义行为都不该存在。更麻烦的是如果代码运行在启用了优化选项的编译器下编译器可能根据“有符号溢出不会发生”这个假设去做激进优化从而推导出完全不符合直觉的代码路径。到时候你排查 Bug 的时候会非常痛苦。所以只要用到算术型无临时变量交换必须先确认类型、数值范围和中间结果的有效性。否则宁可使用临时变量也不要为了炫技埋下一颗定时炸弹。3.2 自交换导致的归零事故这是异或法最容易翻车的点也是面试官最爱追问的延伸问题。如果调用时传入的是同一个变量比如 swap(a, a)那么异或版本会发生什么a a ^ a; // a 变成 0 a a ^ a; // 0 ^ 0 还是 0 a a ^ a; // 依然是 0最终 a 被变成了 0原来的值彻底丢失。这是因为异或的“自反性”遇到同一个对象时直接把所有位清零了。数组排序、集合操作这类场景里特别容易触发这个问题。比如你有一个数组元素去重后的交换逻辑如果下标计算失误把同一个下标传了进去数据就被悄悄抹掉。业界常见的防护手段是在函数入口加一个判断if (a b) return;如果是通过指针操作还需要考虑 a 和 b 是否为空指针if (a NULL || b NULL) return; if (a b) return;这两个判断可以放在一个条件里。有了这层保护异或交换函数才算是工程上可用的代码。很多人写题解时不会提这层判断但真实项目里漏掉自交换判断的后果往往不是输出错误而是难以定位的内存数据损坏。3.3 浮点数与其它数据类型的兼容性异或法在 C 语言里对浮点数并不直接友好因为 float 和 double 不能直接用按位异或运算符。如果你强行用整型指针去把浮点数解释成整型再异或又会引入严格别名规则strict aliasing和字节序问题代码变得极不可读且具备未定义行为风险。浮点数交换的安全解决方案仍然是临时变量double temp a; a b; b temp;或者利用 memcpy 做位级复制但这已经是另一个层面的问题了。Python、JavaScript 这类动态语言还好它们的大整数机制让加减法和异或在普通场景下都不太容易溢出但“同一对象自交换”的坑依然在。Python 里写 arr[i], arr[j] arr[j], arr[i] 这种交换底层 RHS 会先构建一个元组本质上不是完全没有“临时容器”只是解释器帮你处理了。JavaScript 的解构赋值同理。所以结论很清晰无临时变量交换的适用面实际上只集中在整数类型、对比值范围有足够信心、且绝对不出现自交换的场景里。一旦涉及浮点数、动态类型大对象或者函数调用链上的模糊别名临时变量依然是最可靠的方案。4. 从性能测试看这个技巧的真实价值4.1 指令层面的对比很多人直觉上认为“省了一个变量就省了一次赋值所以速度一定会更快”。要验证这个直觉得回到指令层面看。以 x86-64 平台为例传统临时变量交换通常对应这样的模式mov eax, [a] ; 把 a 的值读到寄存器 mov ebx, [b] ; 把 b 的值读到寄存器 mov [a], ebx ; 写回 b mov [b], eax ; 写回 a有时候编译器会直接使用 xchg 指令一条指令完成交换。异或版本经过编译后可能是这样mov eax, [a] xor eax, [b] xchg eax, [b] mov [a], eax可以看到异或版本的指令数量并没有比普通版本少多少。现代 CPU 对简单的寄存器移动和算术指令执行速度都极快性能差异微乎其微瓶颈往往在内存访问延迟上。4.2 我做的实测数据我自己在 Intel i5-1240P 处理器上用 C 语言循环执行 1000 万次交换分别在不开优化-O0和开优化-O2两种条件下做过对比。结果如下方案-O0 实测耗时-O2 实测耗时临时变量交换98 ms12 ms加减法交换115 ms13 ms异或交换92 ms12 ms乘除法交换132 ms14 ms数据说明不开优化时加减法和乘除法比临时变量慢主要是它们引入了额外的算术依赖链异或由于位运算延迟低略有一点点优势。但开优化后编译器会把所有方案都做寄存器重命名和内联优化差异基本被抹平。也就是说你做性能优化时如果指望靠这种技巧获得肉眼可见的加速大概率会失望。4.3 编译器为什么能自动把手脚做得更漂亮现代编译器的优化能力远超很多程序员的想象。比如在 C 里你写一段普通交换代码clang 在 -O2 下经常直接生成 xchg 指令根本不会多占用一个栈槽甚至在寄存器充足的情况下连内存都未必碰直接在寄存器里完成数据重排。反过来如果你执意写异或版本反而可能阻碍编译器的一些向量化优化因为异或指令的语义会约束编译器对数据流分析的假设。这给我们的启发是源码层面的“少一个变量”和最终机器指令层面的“少一次操作”并不能直接划等号。编译器才是真正决定性能的角色开发者更应该关注的是代码可读性和语义清晰度把复杂的优化决策交给编译器。5. 面试这样回答才能从“背题”变成“加分”5.1 输出一个完整的答题框架如果面试官让你实现“不创建临时变量交换两个数”我建议你按照下面这个框架来组织回答先把最稳妥的异或版本写出来同时强调它的适用前提两个变量不能指向同一个内存地址。然后补一句如果允许改函数签名我会在入口处加上自交换判断防止传入同一变量时归零。接着说还有一个加减法版本但要注意有符号整型溢出风险所以实际项目中我更倾向于异或。最后补一句如果是浮点数交换或者可读性优先的普通业务代码我会直接用临时变量因为这个技巧在工程上的收益很有限。这样回答的好处是面试官听到的不只是一段代码而是一个完整的技术判断过程你知道原理你知道边界你还有工程上的取舍标准。这比你在白板上流畅得默写三行代码要有说服力得多。5.2 工程代码里的真实选型建议在实际项目里我个人的经验可以总结成下面几条面对面试题或算法竞赛可以放心使用异或版本来展示思路面对普通业务代码直接用临时变量不要为了让代码看起来高深而牺牲可读性面对嵌入式或固件开发优先让编译器去做优化先用标准写法再通过反汇编确认是否需要手动改成异或版本面对多线程环境无临时变量交换并不能解决原子性问题该加锁的还是得加锁面对浮点数、字符串或者自定义对象老老实实走语言自带机制。我见过最离谱的一个项目是有人为了“优化”把一段 json 解析里的字符串交换函数改成了指针异或交换。结果字符串对象根本不是简单整数指针被异或后完全失去语义程序在特定长度数据下频繁崩溃。最后排查下来原因就是过度使用了这个技巧。这类经验告诉我无临时变量交换是一个很好的思维训练题但绝不是一把能到处乱用的万能钥匙。5.3 扩展从交换到更广的位运算思维如果这道题给了你一些启发我建议你把异或的思维迁移到其他问题上。比如“找出数组中唯一出现奇数次的数字”是 LeetCode 经典题 136解法就是在所有元素上做异或再比如“判断一个数是不是 2 的幂”可以用 n (n - 1) 是否等于零来判断再比如“不使用加法实现两个整数相加”需要组合运用异或和与运算来模拟进位加法器。这些问题的本质都是在用位运算的性质去替代常规算术或容器操作底层逻辑与无临时变量交换一脉相承。理解了这种“用数据本身的运算性质去节省额外存储”的思路之后下次再遇到类似限制题目你就能很快反推出可以用什么运算律去解决问题而不是死记硬背某种固定写法。最后再分享一个我自己在面试中遇到的加分细节当被问到这个题目时我写完异或版本后顺口提了一句“如果面试官允许我用 Python其实直接写 a, b b, a 是最 Pythonic 的做法解释器会在内部完成元组打包和拆包语义上等价于临时变量交换但代码更简洁。”这句话让当时的面试官笑了一下接着我们就顺理成章聊到了 Python 解释器的字节码执行流程。虽然问题本身是 C 系语言的经典题但能展现你对不同语言特性的理解往往比单纯背题更有价值。
返回列表