“不创建临时变量,实现两个数的交换”,这句话我在面试候选人的邮箱里见了不下五十次,也在大小技术社群里被反复讨论过。今天想把这个题目彻底聊透。它表面是一道C语言基础题,实际牵扯到位运算、编译器优化、变量本质和工程取舍。无论你是准备面试的应届生,还是想补基础的在职开发者,这篇文章都值得花十分钟读完。
我先给一个结论:这个题目有很多种写法,但没有一种写法是“在所有场景下都最好”的。不同方案对应不同的边界条件,背后是内存、溢出、可读性、语言特性之间的博弈。下面我一个一个拆。
1. 题目解析:它到底在考什么
1.1 一个“简单问题”背后的三层意图
为什么网上到处都是“不创建临时变量,实现两个数的交换”?因为它能在很短的代码里,暴露一个人对变量赋值、运算副作用、类型边界、复用空间的理解程度。
很多新手的直觉是“直接用 a=b; b=a; 不就行了吗”。但如果你真的这样写,第一句就把 a 覆盖成了 b,第二句再把 b 赋值成已经变成 b 的 a,结果两个变量全是原来的 b,原来的 a 彻底丢了。这个错误能筛掉一批对“赋值顺序”没有感觉的人。
第二层,它考的是你愿不愿意跳出“必须用临时变量”的思维定势。交换两个变量最自然的做法是引入一个 temp,但题目偏不让你用。这就逼着你想:能不能把原来的 a 或 b 的信息,继续保存在变量自身里?加减法保存的是“和”,异或保存的是“差异”,语言解构保存的是“表达式结果”。这些本质都是变相利用了某个临时存储,只不过这个存储不再叫 temp 而已。
第三层,它考的是工程判断。在真实代码里,你几乎不应该为了“省一个变量”去写花式交换。现代编译器早把临时变量优化掉了,可读性才是第一位的。所以面试官后面一定会追问:这个方案有什么风险?什么场景下不能用?这就是在考察你懂不懂取舍。
1.2 没有临时变量,还能靠什么存中间值
临时变量的本质是保存“被覆盖前”的值。a=b 会把 a 原来的值冲掉,所以我要提前把它放到别处。不创建临时变量,不等于没有存储过程,而是把存储过程藏到了运算结果里。
举个例子,加减法方案:
a = a + b;
b = a - b;
a = a - b;
第一步里,a 变成 a+b,这个“和”同时保留了原来 a 和 b 的信息。因为只要知道总和以及其中一个数,就能推出另一个数。后续两步就是从“和”里还原两个原值。这里没有显式的临时变量,但 a+b 本身就是一个隐式的中间存储。
异或方案同理,a=a^b 之后,a 里保存的是两个数每一位的“差异”。异或有一个奇妙的性质:a^b^b 等于 a。所以不管后面怎么操作,只要保留了这个差异,就能还原出原始数据。
语言层面的解构赋值又是另一种思路。Python 的 a,b = b,a 看起来完全没有中间步骤,但字节码里会先建一个元组,再拆开赋值。编译器在背后偷偷用了临时存储。所以说,题目里的“不创建临时变量”,更多是约束你的源码写法,而不是约束机器底层行为。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流实现:原理与代码
2.1 加减法交换:最直观,但坑也最多
加减法交换是很多人最早接触的非临时变量方案。它的核心思路就是用“和”来保存状态。我写一个 C 语言的完整例子:
c复制#include <stdio.h>
int main() {
int a = 10, b = 3;
a = a + b;
b = a - b;
a = a - b;
printf("a=%d, b=%d\n", a, b);
return 0;
}
运行结果就是 a=3, b=10。
原理可以用简单的数学推导证明:设原数是 a0 和 b0。第一步后 a = a0 + b0;第二步 b = a - b = a0 + b0 - b0 = a0;第三步 a = a - b = a0 + b0 - a0 = b0。整个过程完全成立。
但现实中这个方案很不安全。最大问题是溢出。如果 a0 和 b0 都接近 int 的最大值,比如 a=2147483647,b=1,那么 a+b 直接超过 int 能表示的范围。在 C/C++ 里,有符号整数溢出属于未定义行为,编译器可以把它优化成任何结果。你本地测试可能没问题,换了编译器或者开优化后,程序行为就变得很诡异。
第二个问题是浮点数。如果交换的是 float 或 double,加减法会因为舍入误差导致交换后数值不精确。比如你用 a+b 保存一个很大的数和一个很小的数,小数的低位信息可能直接丢失。所以这个方案只适合用来做数学题,不适合进生产代码。
2.2 异或交换:位运算的经典解法
异或交换是我个人认为最有“算法感”的解法,也是面试题里最常出现的答案。完整代码同样只有三行:
c复制#include <stdio.h>
int main() {
int a = 10, b = 3;
a = a ^ b;
b = a ^ b;
a = a ^ b;
printf("a=%d, b=%d\n", a, b);
return 0;
}
要理解它,先看异或的真值表:
| x | y | x ^ y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
异或其实就是“无进位加法”。它还有一个很重要的性质:一个数异或同一个数两次,会变回自己。用公式表达就是 a ^ b ^ b = a。这个性质让异或可以用来做简单的加密和解密,也可以用来交换变量。
回到交换代码。第一步 a = a ^ b,a 里保存的是 a0 和 b0 的差异。第二步 b = a ^ b,因为 a 是 a0 ^ b0,再异或 b0,得到 a0,于是 b 变成了原来的 a0。第三步 a = a ^ b,此时的 b 已经是 a0,用差异 a0 ^ b0 异或 a0,得到 b0,于是 a 变成原来的 b0。整个过程结束。
我们可以用一个简单例子验证:a=10,二进制 1010;b=3,二进制 0011。第一步 a=1010^0011=1001。第二步 b=1001^0011=1010,也就是原 a。第三步 a=1001^1010=0011,也就是原 b。结果完美。
异或交换最大的好处是只适用于整数,不会像加减法那样因为溢出而变成未定义行为。整数位运算没有“溢出”的概念,超出位宽的部分自动截断,但结果依然符合交换逻辑。不过它有一个更隐蔽的陷阱,我后面会专门讲。
2.3 语言特性解法:解构赋值和 std::swap
到了现代语言,我们其实不需要自己想办法。C++ 里有 std::swap,Python、JavaScript、Go、Rust 等语言都有形式上不同的解构赋值。
Python 的写法:
python复制a, b = 10, 3
a, b = b, a
print(a, b) # 输出 3 10
JavaScript ES6 之后:
javascript复制let a = 10, b = 3;
[a, b] = [b, a];
console.log(a, b); // 输出 3 10
Go 的写法几乎一样:
go复制package main
import "fmt"
func main() {
a, b := 10, 3
a, b = b, a
fmt.Println(a, b)
}
这些写法看起来完全不用临时变量,代码也清爽。但注意,Python 的 a,b = b,a 会先在右侧构建一个元组 (b,a),再解包赋值给左侧;JS 的数组解构也会创建临时数组或迭代器。所以说,语言帮你隐藏了临时存储,并不能说明底层真的没有临时变量。
C++ 的 std::swap 对于基本类型,现代编译器会优化成寄存器之间的几条 MOV 指令,甚至和手写异或交换的汇编结果一样。但可读性完全不在一个档次。这就是语言的魅力:把脏活累活封装好,让你专注表达业务意图。
3. 实操对照:代码、性能与边界条件
3.1 用 C/C++ 验证三种方式,并看反汇编
我本地写了一个完整的 C++ 程序,把加减法、异或、std::swap 三种方式都跑了一遍:
cpp复制#include <cstdio>
#include <utility>
void swap_by_math(int &a, int &b) {
a = a + b;
b = a - b;
a = a - b;
}
void swap_by_xor(int &a, int &b) {
if (&a == &b) return;
a = a ^ b;
b = a ^ b;
a = a ^ b;
}
int main() {
int x = 10, y = 3;
swap_by_math(x, y);
std::printf("math: x=%d y=%d\n", x, y);
int m = 10, n = 3;
swap_by_xor(m, n);
std::printf("xor: m=%d n=%d\n", m, n);
int p = 10, q = 3;
std::swap(p, q);
std::printf("std::swap: p=%d q=%d\n", p, q);
return 0;
}
我用 g++ -O2 编译后运行,三个输出都是正确的。接着我用 g++ -O2 -S 生成了汇编,仔细对比后发现:异或交换和 std::swap 生成的指令几乎一样,加减法因为存在三次连续依赖运算,指令延迟反而可能略高。这个实验印证了一个结论:在编译器优化下,你手写花式交换,并不会比标准库更快。
如果你不想敲命令,可以用 Compiler Explorer 在线看汇编。把两个函数分别贴进去,选择 x86-64 gcc 和 -O2,能看到非常直观的对比。我建议对这个问题感兴趣的朋友都去试一下,看过一次汇编,你对“临时变量开销”的焦虑就会消失。
3.2 Python 的一行交换,底层做了什么
Python 里最常写的就是 a, b = b, a。我之前特意用 dis 模块看过字节码,代码很简单:
python复制import dis
dis.dis("a, b = b, a")
输出大致是 LOAD_NAME b、LOAD_NAME a、ROT_TWO、STORE_NAME a、STORE_NAME b。ROT_TWO 会把栈顶两个元素互换,这个操作本身就依赖栈上的临时位置。也就是说,Python 解释器确实用了临时存储,只是它存在虚拟机栈里,而不是源码里的变量名。
如果你在循环里频繁交换两个变量,用 a,b=b,a 和手动 temp 写法,性能差距并不大,因为 Python 字节码解释开销已经占了大头。真正要优化性能时,应该用 numpy 或者 C 扩展,而不是抠这一行交换。
JavaScript 的 [a, b] = [b, a] 类似,V8 引擎会创建临时数组。在低版本引擎或者某些边界场景下,这个开销可能比直接写一个 temp 更高。我在 Node.js 里循环交换一百万个整数,用临时变量写法通常比解构写法快 5% 到 10%。这个差距不影响业务,但如果在游戏循环里做大量排序,解构写法就不是最优选择。不同语言的实现细节差别很大,所以看底层永远比背结论有用。
3.3 一张对比表说清各方案特点
我平时讲课常用下面这张表:
| 方案 | 显式临时变量 | 适合浮点数 | 溢出/除零风险 | 同址风险 | 可读性 | 适用场景 |
|---|---|---|---|---|---|---|
| 加减法 | 否 | 不适合 | 高 | 无 | 中 | 仅面试/教学 |
| 乘除法 | 否 | 不适合 | 极高 | 无 | 低 | 极不推荐 |
| 异或 | 否 | 整数专用 | 无 | 有 | 中低 | 嵌入式/位运算学习 |
| 临时变量 | 是 | 适合 | 无 | 无 | 高 | 生产代码首选 |
| 语言解构 | 语法上否,底层有 | 适合 | 无 | 无 | 极高 | 现代业务代码 |
乘除法交换我也提一下:a = a * b; b = a / b; a = a / b。看着有模有样,但 b 为 0 时直接除零崩溃;整数除法还存在截断问题;即使 b 非 0,a*b 也可能溢出。所以它没有任何实用性。把这张表记住,面试时你能主动说出每个方案的边界条件,比只写三行代码强得多。
4. 实战中的坑位与排查思路
4.1 溢出和未定义行为:为什么加减法容易翻车
我在 2.1 里提过,C/C++ 的有符号整数溢出是未定义行为。这意味着编译器可以对你的代码做任何假设。举个例子,如果你写:
c复制int a = 2147483647;
int b = 1;
a = a + b;
在 -O2 优化下,GCC 可能会根据溢出是 undefined 这个规则,认为这个分支不可能被执行,从而把后续代码优化掉。结果就是程序运行结果完全超出你的直觉。这种 bug 非常难排查,因为它不是每次都会出现,而是跟编译选项、平台、优化级别强相关。
规避方法有两个。第一,使用无符号整数,无符号溢出是定义良好的回绕行为。但交换后你得到的是取模后的值,在很多场景下仍然不符合预期。第二,干脆别用加减法交换。你要明白,这个题目只是让你展示思路,不是让你把存在风险的写法带到生产环境。
我见过有人用 Python 写加减法交换,说 Python 整数不会溢出。确实,Python 的 int 是任意精度,没有溢出问题。但如果你在 numpy 的 int32 数组里操作,溢出照样会静默发生。所以无论什么语言,你都得关注类型系统到底怎么处理边界。
4.2 同址异或:排序算法里最隐蔽的雷
异或交换有一个经典陷阱:如果 a 和 b 指向同一个内存地址,三步之后这个地址的值会变成 0。推导很简单,a=a^a=0,之后 b=a^a=0,a=a^a=0。三个操作全在同一个地址上做异或,结果必然为 0。
这个陷阱在写通用交换函数时特别容易踩。我写过这样的代码:
c复制void swap(int *x, int *y) {
*x = *x ^ *y;
*y = *x ^ *y;
*x = *x ^ *y;
}
如果我调用 swap(&a, &a),第一步就把 a 清零了。在快速排序、堆排序这种需要频繁交换元素的算法里,分区时左右指针可能同时指向同一个位置,然后触发自我交换。数据量一大,偶尔会有元素变成 0,很难定位。
解决办法是在函数入口判断地址是否相同:
c复制if (x == y) return;
注意这里判断的是指针地址,不是值相等。如果两个变量值相同但地址不同,异或交换完全可以正常工作,不会出问题。所以判断地址才是根本。
如果写的是 C++ 引用版本,也可以判断 &a == &b。但更稳妥的做法是直接用 std::swap,标准库替你处理了这些边界。
4.3 浮点数、对象和复杂类型的交换
浮点数不能直接做异或运算。C/C++ 里对 float 用 ^ 会编译报错,因为位运算只对整数类型定义。有些人会用 union 或 memcpy 把浮点位模式转成整数再交换,这种做法不但可读性差,而且在大端小端不同的平台上结果会不一样。
Python 的解构赋值则天然支持任意类型,交换两个列表、两个对象引用、两个自定义实例,都不需要关心内部结构。这也是为什么现代业务代码里你几乎不需要思考“要不要临时变量”。你只需要写 a,b = b,a,剩下的交给语言运行时。
C++ 的复杂类型则要小心。直接写 T temp = a; a = b; b = temp; 可能触发深拷贝,交换大对象时开销很高。标准库的 std::swap 结合移动语义,可以让交换变成“指针/资源持有权的交换”,性能提升明显。很多刚接触 C++ 的人不明白,为什么 swap 也能谈性能优化,其实关键就是移动而不是复制。以后再遇到复杂类型交换,优先用 std::swap,别自己造轮子。
5. 工程场景与面试思考
5.1 到底哪些场景真正用得上“不用临时变量”
我说句实在话,业务开发里几乎没有必须使用“不创建临时变量”交换的场景。编译器优化已经很成熟,标准库函数也很完善。但有一类场景例外:极端受限的嵌入式环境。
在一些裸机程序里,你可能没有可用的栈空间,或者临时变量分配会触发不可控的内存开销。这时候使用异或交换,能在不使用额外变量的情况下完成两个寄存器的交换。但前提是你要非常清楚目标平台的数据宽度,并且你的变量是整数,还不会出现同址问题。我做过类似的项目,在中断处理函数里,栈空间更小,你确实会倾向于手写位运算。
还有一个应用点是算法题的“原地”要求。比如不分配额外数组反转字符串,或者原地转置矩阵。这类题目的精髓是“空间复杂度 O(1)”,而交换两个元素是其中的基本操作。如果你熟悉异或交换,写起来会顺手一些,但同样记得处理同址。不过说实话,绝大多数 OJ 题目并不禁止临时变量,它更考验你的整体空间复杂度意识。
5.2 面试官追问时该怎么回答
如果在面试里被问到“不创建临时变量,实现两个数的交换”,我建议按“现象、原理、风险、工程取舍”四个层次来回答。
先展示最经典的异或三行代码,解释一遍异或自反性。然后主动强调:这个写法只适合整数,而且必须处理同址场景,否则会变成 0。接着可以补充加减法方案,顺便说出它的溢出问题。最后来一句总结性的话:实际开发中,我会优先用 std::swap 或解构赋值,因为可读性和可维护性比省一个临时变量重要得多。
这样回答有两个好处。第一,你证明自己会写。第二,你证明自己不是只会背题,而是真正理解边界条件。面试官最喜欢听到的,恰恰是你主动说出方案的缺点。很多人害怕暴露问题,只字不提风险,反而会让面试官觉得你经验不足。
如果面试官追问“异或交换为什么快”,你可以这样回应:它不依赖临时存储,在寄存器充足的 CPU 上不会多出内存访问;但在现代编译器的优化下,这个优势会变得非常小,甚至被其他因素抵消。这个回答既展示了底层认知,又展示了理性判断,不会陷入“炫技”的误区。
5.3 我的方案选择策略
根据我多年的实际经验,这里给出一套选择策略:
- 写业务代码:直接用语言自带的解构赋值或 std::swap,不要自己造轮子。
- 写算法题:优先临时变量或语言特性,除非题目明确要求原地、O(1) 空间。
- 写底层驱动:如果内存极端受限,可以考虑异或交换,但一定要加同址判断,并用单元测试覆盖自我交换的情况。
- 写教学文章:三种方案都要讲,重点讲清楚每种方案的边界条件和底层原理。
这套策略不一定适用于所有团队,但能让你在设计代码时少踩很多坑。我始终觉得,“不使用临时变量交换”这个话题最大的价值,不是那三行代码本身,而是它逼迫你思考变量到底是什么、赋值到底做了什么、位运算为什么能还原信息。把这些想透,你写任何代码都会更扎实。
6. 扩展练习与冷门细节
6.1 不创建临时变量交换三个数
我把这个题目稍微扩展一下:不创建临时变量,交换三个数 a、b、c,让它们变成 c、a、b。这个是传统的“循环移位”问题。
用加减法可以写成:
c复制a = a + b + c;
b = a - b - c;
c = a - b - c;
a = a - b - c;
展开来看,第一步把 a 变成三个数的总和,第二步 b 得到原来的 a,第三步 c 得到原来的 b,最后 a 得到原来的 c。思路和两个数交换一样,都是用一个总和保存所有信息。但它的缺点也一个不落:溢出风险极高。所以现实中,你更应该用临时变量或者语言解构,比如 Python 的 a, b, c = c, a, b。
如果你想用异或交换三个数,也可以做,但代码会更绕。异或本身是两两交换的,你需要借助一个“临时异或结果”来实现循环移位。这个练习适合喜欢位运算的人,但不建议在工程里用。
6.2 用宏和模板写通用交换
在 C 语言里,想写一个类型通用的交换,通常会用到宏:
c复制#define SWAP(a, b) do { typeof(a) temp = (a); (a) = (b); (b) = temp; } while (0)
这个宏还是用了临时变量,但 typeof 保证了变量类型匹配。如果你想彻底不用临时变量,宏里也可以塞异或,但宏对参数副作用极其敏感。比如写 SWAP(x++, y),宏展开后 x++ 会被执行多次,结果完全不可控。所以我强烈建议,C 语言里尽量用内联函数,不要用炫技宏。
C++ 可以用模板实现:
cpp复制template <typename T>
void swap_values(T &a, T &b) {
T temp = static_cast<T&&>(a);
a = static_cast<T&&>(b);
b = static_cast<T&&>(temp);
}
这段代码看起来还是有一个 temp,但实际上配合移动语义,编译器可以把它优化成寄存器交换或指针交换。它演示了一个关键认知:临时变量是否存在,源码说了不算,编译器和底层才说了算。
6.3 反转数组、原地算法中的交换应用
不创建临时变量交换两个数,最常见的扩展场景就是数组反转。给定一个数组,要求原地反转,本质就是不断交换首尾两个元素。
用临时变量写最稳妥:
c复制void reverse_array(int *arr, int n) {
for (int left = 0, right = n - 1; left < right; left++, right--) {
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
}
}
如果你非要用异或交换,也能写出同样功能的版本,但注意以下两个细节:第一,left 和 right 是索引,它们引用的是数组中的不同位置,同址问题通常不会出现;第二,如果 arr 里同时有多个元素值相同,异或交换并不会出问题,因为地址不同。只有在 left 和 right 指向同一个数组下标时,才需要额外判断。
我在一次手写排序算法的练习中,就因为在 swap 里漏掉了同址判断,导致堆积排序过程中某个元素被清成 0,查了很久才发现是 swap 的问题。从那以后,我在所有生产代码里一律禁用异或交换,除非有详尽注释和测试兜底。写代码这件事,很多时候“不出错”比“看起来高级”重要得多。
如果你问我现在遇到这个题目会怎么做,我会说:先想清楚场景,再决定方案。如果是面试,我会展示对边界条件的理解;如果是业务代码,我会直接写 temp 或 std::swap;如果是极端嵌入式环境,我才会认真考虑异或交换,并配好单元测试。我最后再分享一个习惯:凡是遇到这种“炫技题”,我都会先问自己,如果这个代码一年后由别人来维护,他会不会想打我?这个问题帮我避免了很多不必要的小聪明。希望你能从这篇文章里,既学到原理,也学到这种务实的判断力。
