动手练C语言的人,应该都撞过菜鸟教程C经典100例这套题。前面52题练的无非是循环、数组、函数这些常规操作,到了第53题画风突变——题目要你“学习使用按位异或 ^”,然后甩给你一段极简代码。我第一次跑这段代码的时候,屏幕上安安静静打出两个数字:60 和 59。说实话,当时我盯着那个077看了半天,心想这不是77吗?怎么异或一个3就变成了60?后来又异或一个7,又变成59?那一瞬间我才意识到,自己对八进制、二进制、位运算这些概念还停留在“背过但没真正用过的状态”。这一题等于直接把我按在原地,逼我把二进制的底裤翻出来看。
这篇文章就把练习53的完整拆解、异或运算的核心原理、以及从这道题延伸出去的实战用法全部捋一遍。如果你正在刷这套C经典100例,或者刚刚接触位运算,这篇应该能帮你省不少时间。
1. 先跑一遍:练习53的原始代码和输出
1.1 题目原文与配套源码
在菜鸟教程C经典100例中,第53题位于位运算专题的中间位置。第51题讲的是按位与&,第52题讲的是按位或|,到了第53题,就开始碰按位异或^了。题目描述极简:
题目:学习使用按位异或 ^。
程序分析:0^0=0; 0^1=1; 1^0=1; 1^1=0
配套源码也经典到不能再经典:
#include <stdio.h> int main() { int a, b; a = 077; b = a ^ 3; printf("b = %d \n", b); b ^= 7; printf("b = %d \n", b); return 0; }就这么几行,没有函数封装、没有输入交互、没有注释。它摆明了是一道“读代码、跑代码、理解代码”的练习。
1.2 本机实跑结果
我本机是 Ubuntu 22.04 + GCC 11.4,直接gcc ex53.c && ./a.out编译运行,输出:
b = 60 b = 59如果你用的是 Windows + MinGW,或者 macOS + Clang,结果完全一样。因为这几个整数都很小,无论int是16位还是32位,结果都符合C标准定义,不依赖操作系统和编译器实现。
1.3 输出结果逐字拆解:077 不是77
这道题最大的坑,也是很多新手最容易卡住的地方,就是077这个写法。
C语言里有一条容易被忽略的规则:整数常量如果以0开头,表示它是八进制数。也就是说,077不是一个写了前导零的77,它表示的是八进制的77,换算成十进制是:
7 * 8 + 7 = 63再写成二进制,就是0b00111111,也就是低6位全是1。
而和它异或的3,是十进制3,二进制写作0b00000011。
现在把两个二进制数逐位对齐,按位异或:
0011 1111 ^ 0000 0011 ------------ 0011 1100规则只有一条:相同为0,不同为1。高位部分两个数都是0,结果还是0;低两位左边是1、右边是0,不相同,结果1。于是得到0011 1100,换算回十进制:
2^5 + 2^4 + 2^3 + 2^2 = 32 + 16 + 8 + 4 = 60所以第一次输出b = 60就这么来的。
接着执行b ^= 7,也就是b = b ^ 7。此时 b 是60,二进制0011 1100;7 的二进制是0000 0111:
0011 1100 ^ 0000 0111 ------------ 0011 1011低三位原本是100,和111异或后变成011;其余高位没动。结果是0011 1011,十进制:
2^5 + 2^4 + 2^3 + 2^1 + 2^0 = 32 + 16 + 8 + 2 + 1 = 59第二次输出b = 59也就对上了。
1.4 如果只记住答案,这题就白做了
练习53这类题目,表面上是让你输出两个数。但如果你只看答案,知道是60和59,然后直接下一题,那基本等于白做。
真正值得琢磨的是下面几个问题:
- 为什么题目用
077而不是直接写63?因为题目想顺带考你八进制字面量的识别。 - 为什么第一次异或3,第二次异或7?因为这两个数在二进制里恰好落在不同的位上,能让你看到“翻转特定位”的效果。
- 如果把
3改成5,结果会变成多少?建议自己推一遍,再上机验证。
这些想透了,你对位运算的直觉才算真正建立起来。
2. 拆穿异或:为什么相同为0,不同为1
2.1 从“或”到“异或”:一字之差,两种逻辑
异或的全称是“互斥或”(exclusive OR),和日常语言里的“或者”不太一样。
普通“或”(OR)的逻辑是:只要至少一个条件成立,整体就成立。比如你去饮品店点单,“咖啡或者茶”,意思是两个都可以要。
但“异或”强调的是“互斥”:两个选项只能二选一,不能同时成立。同样是“咖啡或者茶”,在异或语义下你只能选一个。映射到二进制:
- 0 XOR 0 = 0:两个都是假,结果假
- 0 XOR 1 = 1:一假一真,结果真
- 1 XOR 0 = 1:一真一假,结果真
- 1 XOR 1 = 0:两个都真,但互斥,结果假
所以那句顺口溜“相同为0,不同为1”,本质就是这四行真值表。
在数字电路里,这个逻辑叫 XOR 门,它是加法器的核心组件。CPU 做加减法时,半加器的“和位”输出就是两个输入位的异或。换句话说,异或在计算机底层是极其常见的运算,执行速度非常快。
2.2 异或的四条代数性质
二进制层面的规则只是表面,真正让异或在算法题里大放异彩的,是下面四条代数性质:
- 交换律:
a ^ b = b ^ a - 结合律:
(a ^ b) ^ c = a ^ (b ^ c) - 恒等律:
a ^ 0 = a - 自反律:
a ^ a = 0
由结合律和自反律,立刻能推出一个更实用的结论:
a ^ b ^ a = b也就是说,一个数和另一个数异或两次,会还原成原来的数。这就像给数据做了两遍“翻转”:第一遍是加密,第二遍是解密。
2.3 用生活类比理解四条性质
如果你觉得这些性质太抽象,我给你两个类比。
第一个是“双控开关”。走廊两头各有一个开关,共同控制一盏灯。你来回拨动两个开关,灯的状态规律是:两个开关在同一侧时灯灭,在不同侧时灯亮。这里“位置相同/不同”就是异或的比较逻辑。你多次来回拨动,灯的亮灭变化本质上就是连续的异或操作。
第二个是“翻硬币”。假设你手里一枚硬币,正面记0,反面记1。你每做一次“翻转”,就相当于和1做异或:0变1,1变0。而连续做两次翻转,硬币一定回到原来的面。这就是a ^ 1 ^ 1 = a的形象版本。异或一个数两次等于回到原点,本质就是这样。
2.4 异或的隐藏视角:特定位翻转开关
异或还有一个容易忽略的用法:x ^ 1相当于对 x 的某一位做取反,x ^ 0则保持不变。所以,如果用一个掩码 mask 和某个整型变量做异或,那么 mask 中为1的那些位会被翻转,mask 中为0的那些位保持原样。
练习53里第一次077 ^ 3得到60,也可以从这个角度理解:077 的二进制是0011 1111,3 的二进制是0000 0011。异或之后,低两位原本都是1,和1异或后都变0,其他位全没动,于是得到0011 1100。这样解释比死记竖式更直观,以后遇到“翻转特定位”的需求,马上就能想到用异或。
3. 从题目到实战:异或运算最常见的应用场景
3.1 不使用临时变量交换两个整数
网上经常能看到这种代码:
int a = 5, b = 9; a = a ^ b; b = a ^ b; a = a ^ b;逐步拆解:
- 第一步后,a 存的是 a 和 b 的“差异”。
- 第二步,
b = a ^ b = (a ^ b) ^ b = a ^ (b ^ b) = a ^ 0 = a,b 被还原成原来的 a。 - 第三步,
a = a ^ b = (a ^ b) ^ a = b,a 被还原成原来的 b。
最终两个变量完成交换,而且没有引入第三个临时变量。
不过我必须泼一盆冷水:面试喜欢考,不代表生产环境就该这么写。这种写法可读性差,而且有一个隐蔽的大坑——如果两个操作数指向同一个内存位置,比如数组里的swap(arr[i], arr[j])且i == j,上面的三次异或会把值变成0。因为a ^ a = 0,你本来想原地不动,结果把数据清零了。所以现在很多项目的编码规范都明确要求:交换变量就用临时变量。异或交换更适合拿来锻炼思维,而不是直接塞进业务代码里炫耀。
3.2 找出整型数组中唯一出现奇数次的数字
这是异或最经典的一道算法题:
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
解法极其优雅:
int findSingle(int arr[], int n) { int result = 0; for (int i = 0; i < n; i++) result ^= arr[i]; return result; }把所有元素从头到尾异或一遍,最终结果就是那个唯一出现单次的数。原理很简单:
- 出现偶数次的数,两两异或后归零,比如
3 ^ 3 = 0 - 归零之后继续和其他数异或,等价于没参与运算
- 最后,那个唯一的单次数字和0异或,得到它本身
这个解法额外空间复杂度O(1),时间复杂度O(n),比哈希表省内存,比排序省时间。我第一次看到这个解法时是真的被震到了——它完全不是靠背模板能想出来的,而是建立在对a ^ a = 0这个底层性质的理解之上。
3.3 轻量级数据加密与校验
异或的自反性质决定了它可以做“自我还原”的加密。最简单的例子:
#include <stdio.h> int main() { unsigned char data = 'A'; unsigned char key = 0x5A; unsigned char encrypted = data ^ key; printf("encrypted = %#x\n", encrypted); unsigned char decrypted = encrypted ^ key; printf("decrypted = %c\n", decrypted); // 输出 A return 0; }输出:
encrypted = 0x1b decrypted = A这种方案的优点是快、简单、可逆;缺点是同一个 key 下,相同的明文会得到相同的密文,很容易被分析出规律。所以它只适合做“轻度混淆”,比如通信协议里的简单校验、调试固件时临时加个壳,替代不了正经的加密算法。
在数据校验领域,异或更常见的是作为 checksum 叠加运算。比如一串数据{0x01, 0x02, 0x03},可以计算0x01 ^ 0x02 ^ 0x03 = 0x00,把结果附在数据帧尾部。接收方再对所有字节做一次异或,如果结果是0,基本可以认为传输过程中没有发生位翻转。这比单纯求和实现的校验更能捕捉单位比特翻转的情况。
3.4 寄存器与权限系统中的“翻转位”
嵌入式开发里,操作寄存器经常用位运算。异或特别适合“翻转特定位”。比如要把一个8位寄存器reg的第3位翻转,其他位不动:
reg = reg ^ (1 << 3);如果不想保留旧值,可以直接写reg ^= (1 << 3);。这个操作在 GPIO 翻转、状态机切换、LED 指示翻转这些场景非常常用。
权限系统也能用到异或。假设用一个整数每一位代表一种权限:读=1<<0、写=1<<1、执行=1<<2,那么异或可以用来切换权限。只是实际系统里更多用|来增加权限、用&~来去掉权限,而异或更适合“状态翻转”这个动作。比如一个里程碑式的权限位,每次用户触发一次操作就翻转一次,这个时候^就比读改写简洁得多。
4. 位运算的“兄弟运算符”:& | ~ << >> 怎么配合
4.1 练习51、52、53到底在让你练什么
在菜鸟教程经典100例里,第51、52、53三题紧挨着,代码结构几乎一样,都是先把077和一个数字做位运算,再用复合赋值运算符继续运算。这三题是业内出了名的“用同一组数据讲清三个基础位运算符”。
我把&、|、^放到一张表里对比:
| 运算符 | 名称 | 真值规则 | 077和3运算的结果 | 核心记忆 |
|---|---|---|---|---|
| & | 按位与 | 全1才1 | 077 & 3 = 3 | 只有1&1是1 |
| | | 按位或 | 有1就1 | 077 | 3 = 63 | 任意一个是1则1 |
| ^ | 按位异或 | 不同才1 | 077 ^ 3 = 60 | 相同0、不同1 |
从结果能直观看出三者差别:同样是077和3运算,&得到3,|得到63,^得到60。把三个结果放在一起比较,就能理解每种运算符对“同为1的低两位”的不同处理方式:&只保留同为1的位,|把有1的位全留下,^则把相同的位清掉、不同的位保留。
后面还有第54题取整数指定位、第55题按位取反~,以及移位<<、>>。它们相互搭配,几乎能组合出所有位操作。
4.2 位运算符优先级踩坑实录
优先级是C语言里最容易掉进去的坑。我记得自己第一次写奇偶判断:
if (x & 1 == 0) // 错编译不会报错,但运行结果完全不是想要的。原因很简单:==的优先级高于&,所以上面的表达式实际是:
if (x & (1 == 0)) // 相当于 x & 0,永远为0正确写法:
if ((x & 1) == 0)类似的还有a ^ b == 0,实际会被解析成a ^ (b == 0)。
我的建议非常粗暴:凡是用到&、|、^、~、<<、>>,并且和==、!=、&&、||、=出现在同一个表达式里的时候,无条件加括号。不要指望自己能背熟优先级表,也不要让读你代码的人跟着背。加括号不丢人,不加括号坑队友。
4.3 复合赋值运算符的隐蔽细节
练习53代码里的b ^= 7是复合赋值运算符,等价于b = b ^ 7。
真正容易搞错的是:b ^= 7 + 1会被解析成b = b ^ (7 + 1),也就是先把右边整体7+1=8算出来,再和 b 异或。复合赋值运算符右边永远是作为一个整体参与运算。如果你在重构时把表达式改复杂了,很容易中招。
另一个细节:b ^= 7和b &= 7、b |= 7一样,都会原地修改 b。如果你打算保留 b 的原值,需要提前存到另一个变量里。
4.4 什么时候该用哪种位运算:一张决策清单
其实在嵌入式、算法、底层协议这些场景里,用哪个运算符非常清晰:
- 清零某些位:
x & ~mask - 置位某些位:
x | mask - 翻转某些位:
x ^ mask - 判断某几位是否全为1:
(x & mask) == mask - 取低n位:
x & ((1 << n) - 1) - 判断奇偶:
x & 1,结果为1是奇数
把这个清单记住,日常开发基本不用犹豫。
5. 实操建议:代码在哪写、怎么调试、有哪些坑
5.1 VSCode + GCC:一套舒服的C语言环境
很多人在热搜里搜“vscode配置c语言环境”,我这里给一套足够用的步骤。
Windows 下:
- 安装 MinGW-w64,推荐用 MSYS2 或 WinLibs 发行版
- 把
.../mingw64/bin加到系统 PATH - VSCode 装 C/C++ 扩展(Microsoft官方)
- 新建
.vscode/launch.json,用gcc -g编译
Ubuntu/WSL 下更简单:
sudo apt update sudo apt install build-essential gdb装好后分别验证:
gcc --version gdb --version5.2 用辅助函数把二进制摊开看
练习53这种位运算题,最有效的调试方式不是看十进制输出,而是看二进制。我建议你加一个打印函数:
#include <stdio.h> #include <limits.h> void printBinary(int n) { int bits = sizeof(n) * CHAR_BIT; for (int i = bits - 1; i >= 0; i--) putchar((n >> i) & 1 ? '1' : '0'); putchar('\n'); }然后在每次异或前后调用:
int a = 077; printBinary(a); // 0000000000111111 printBinary(3); // 0000000000000011 printBinary(a ^ 3); // 0000000000111100这样你能亲眼看到哪些位被翻转了,哪些位没动。注意CHAR_BIT来自limits.h,所以这个辅助函数顺带把limits.h也用上了,和你想练“stdio.h + limits.h”的题目正好吻合。
5.3 新手最容易栽的五个坑
- 混淆
077和77。前者八进制63,后者十进制77,运算结果完全不同。 - printf 格式符写错。
%d十进制、%o八进制、%x十六进制,混用会得到莫名其妙的结果。 - 把位运算和逻辑运算混淆。
&&、||、!是逻辑运算,&、|、~是位运算,a && b和a & b不是一回事。 - 忘记加括号。位运算符和
==、=混用时,优先级问题难以凭直觉记忆。 - 对有符号负数做右移。算术右移和逻辑右移有区别,
-1 >> 1在不同平台可能不同。涉及负数位运算时,尽量用unsigned。
6. 顺手把5×5鞍点问题也解决:练习之外的进阶
6.1 鞍点问题描述
练习53本身只讲异或,但很多C语言学习者刷经典100例时,会同时遇到“5×5鞍点问题”。题目是这样的:
找出一个5×5矩阵中的鞍点。鞍点是指该位置上的元素在其所在行中最大,同时在其所在列中最小。矩阵中可能不存在鞍点,也可能存在多个鞍点。
这道题和位运算没有直接关系,但锻炼的是二维数组遍历、极值追踪、多重循环的配合能力,正好可以和位运算组成一套“C语言练手进阶组合”。
6.2 核心思路:先锁定行最大,再验证列最小
做鞍点问题最忌讳一上来就想着“同时找一个满足两种条件的元素”,然后陷入四层循环。正确思路是拆成两步:
- 对每一行,找出该行的最大值,以及它所在的列。
- 检查这个值是否是该列的最小值。
这样做逻辑清晰,代码也不容易出错。如果一行有多个并列最大值,需要逐个检查,否则可能漏掉鞍点。
6.3 参考实现
下面这个版本用stdio.h做输入输出、limits.h里的INT_MAX辅助判断,正好呼应你搜索时看到的stdio.h和limits.h组合:
#include <stdio.h> #include <limits.h> #define N 5 int main() { int matrix[N][N]; int i, j, k; printf("请输入%d行%d列的矩阵:\n", N, N); for (i = 0; i < N; i++) for (j = 0; j < N; j++) scanf("%d", &matrix[i][j]); int found = 0; for (i = 0; i < N; i++) { int rowMax = matrix[i][0]; int colOfMax = 0; for (j = 1; j < N; j++) { if (matrix[i][j] > rowMax) { rowMax = matrix[i][j]; colOfMax = j; } } int isColMin = 1; for (k = 0; k < N; k++) { if (matrix[k][colOfMax] < rowMax) { isColMin = 0; break; } } if (isColMin) { printf("找到鞍点:matrix[%d][%d] = %d\n", i, colOfMax, rowMax); found = 1; } } if (!found) printf("矩阵中没有鞍点\n"); return 0; }这段代码的思路:遍历每一行,先锁定行最大值;再检查该列所有元素是否都不小于这个值。如果是,就是一个鞍点。
6.4 注意“并列最大值”的处理
上面的实现有一个小缺陷:如果一行有多个相等的最大值,它只检查第一个遇到的。严格来说,如果矩阵里两个位置都是行最大,其中一个是列最小、另一个不是,程序可能会漏解。处理办法是把每行所有的最大值位置收集起来,逐个检查对应列;或者在判断时把matrix[i][j] > rowMax改成>=并维护多个候选坐标。大多数笔试面试的测试数据不会拿并列最大值刁难你,但如果你追求严谨,可以再改进。
6.5 为什么把鞍点问题放在练习53后面讲
我的想法很简单:经典100例做到第53题,你的指针、循环、数组、函数已经基本见过了。这时候如果只盯着某一题练,容易产生“做题感”,而缺少“解决问题的感觉”。鞍点问题看起来是个二维数组题,但它的核心是“先锁定一个局部最优,再验证另一个维度的条件”。这种思维非常接近真实开发中的规则判断、数据筛选、约束校验逻辑。把它和异或放在同一周练,你会同时强化位运算和二维数组两方面的手感。
做练习53的时候,我最初也是对着60和59发了好一会儿呆。后来画了几遍二进制竖式,又把第51、52、55题连在一起做,才慢慢意识到:位运算不是那种需要背很多花样才能学会的东西,它更像一组积木,规则极少,组合起来却有无穷变化。如果你刚做到这题,建议别急着往下翻答案,先自己手写一遍077转二进制、算一遍异或结果,再上机验证。这个过程比记住任何一个输出结果都值钱。