09 操作符与位运算
1. 一句话总结
位运算是 C 语言最高效的底层操作,本质就是在补码上玩二进制——移位相当于乘除,按位与或非异或各有妙用,整形提升决定了运算结果的正确性。
2. 知识地图
操作符与位运算
├── 进制转换基础
│ ├── 二进制(逢二进一,权重是 2 的次幂)
│ ├── 八进制(0 开头,3 个二进制位 = 1 个八进制位)
│ ├── 十进制(默认写法)
│ └── 十六进制(0x 开头,4 个二进制位 = 1 个十六进制位)
│
├── 整数在内存中的存储
│ ├── 原码:最高位是符号位,其余位表示数值
│ ├── 反码:正数 = 原码,负数 = 符号位不变,其余取反
│ └── 补码:正数 = 原码,负数 = 反码 + 1
│ └── 核心原则:所有计算都在补码上进行
│
├── 整形提升
│ ├── 原因:char/short 等类型在表达式计算时会先提升为 int
│ ├── 有符号类型:高位补符号位
│ └── 无符号类型:高位补 0
│
├── 移位操作符(移动的是补码)
│ ├── << 左移:左边丢弃,右边补 0 → 等效 ×2^n
│ └── >> 右移:右边丢弃,左边补符号位 → 等效 ÷2^n(向下取整)
│
├── 位操作符
│ ├── & 按位与:有 0 就 0,都为 1 才是 1
│ ├── | 按位或:有 1 就 1,都为 0 才是 0
│ ├── ^ 按位异或:相同为 0,不同为 1
│ └── ~ 按位取反:1 变 0,0 变 1
│
├── 位运算经典应用
│ ├── 不借助第三变量交换两个数(异或法)
│ ├── 统计二进制中 1 的个数(n & (n-1) 算法)
│ └── 置位/清零某一位(| 和 & ~)
│
├── 其他操作符
│ ├── 逗号表达式:从左到右依次计算,结果是最后一个
│ ├── [] 下标引用操作符:arr 和 5 是 [] 的两个操作数
│ └── () 函数调用操作符:函数名和参数都是操作数
│
└── 结构体
├── 自定义类型,和 int/double 没有本质区别
├── . 成员访问操作符
├── 支持指定初始化器(C99):{.age=18, .name="wangcai"}
└── 结构体可以嵌套结构体
3. 核心概念深度解析
3.1 补码:为什么负数存储的不是原码?
CPU 只会做加法,如果直接用原码做减法,电路会非常复杂。补码的设计让"减法"变成"加法":
计算 1 - 1,即 1 + (-1)
用原码算:
00000000 00000000 00000000 00000001 ← 1
+ 10000000 00000000 00000000 00000001 ← -1
─────────────────────────────────────
10000000 00000000 00000000 00000010 ← -2 ???错得离谱
用补码算:
00000000 00000000 00000000 00000001 ← 1 的补码
+ 11111111 11111111 11111111 11111111 ← -1 的补码
─────────────────────────────────────
100000000 00000000 00000000 00000000 ← 溢出位丢弃,结果 = 0 ✓
负数补码速算口诀:原码符号位不动,其余取反,再加 1。
以 -10 为例(32 位):
原码: 10000000 00000000 00000000 00001010
↑符号位
反码: 11111111 11111111 11111111 11110101 ← 符号位不动,其余取反
补码: 11111111 11111111 11111111 11110110 ← 反码 + 1
关键认知:你写的代码里所有整数运算,CPU 都是在补码上做的。printf 用 %d 打印时,再把补码转回原码显示给你看。
3.2 移位操作符:左移是翻倍,右移是折半
<< 左移操作符
════════════════════════════════════════════════════════════
a = 6
补码: 00000000 00000000 00000000 00000110
a << 1:
00000000 00000000 00000000 00001100 ← 左移一位,右边补 0
结果: 12 = 6 × 2
规则: 左移 n 位,等效于乘以 2^n(前提是不溢出)
左边丢弃的位如果是 1,结果就不对了——因为溢出了
>> 右移操作符(算术右移)
════════════════════════════════════════════════════════════
a = -10
原码: 10000000 00000000 00000000 00001010
补码: 11111111 11111111 11111111 11110110 ← 负数补码:反码+1
a >> 1:
11111111 11111111 11111111 11111011 ← 右移一位,左边补符号位(1)
转原码: 10000000 00000000 00000000 00000101 ← 打印结果 = -5
-10 / 2 = -5 ✓
规则: 右移 n 位,等效于除以 2^n(向下取整)
左边补的是什么?大部分编译器是算术右移——补符号位
注意:右移时左边补符号位还是补 0,标准说"实现定义"(implementation-defined),但主流编译器(GCC、Clang、MSVC)对有符号数都做算术右移(补符号位)。无符号数右移一定补 0。
3.3 异或交换法:不借助第三变量,三行代码搞定
a = 1, b = 8
Step 1: a = a ^ b
a = 1 ^ 8 = 9
b = 8
Step 2: b = a ^ b
b = 9 ^ 8 = 1
a = 9
Step 3: a = a ^ b
a = 9 ^ 1 = 8
b = 1
最终: a = 8, b = 1 交换完成!
原理拆解(用符号表示):
设原始值为 A, B
Step1: a = A ^ B, b = B
Step2: b = (A ^ B) ^ B = A ^ (B ^ B) = A ^ 0 = A
Step3: a = (A ^ B) ^ A = (A ^ A) ^ B = 0 ^ B = B
核心依赖两个性质:
① x ^ x = 0(任何数异或自己得 0)
② x ^ 0 = x(任何数异或 0 还是自己)
③ 异或满足交换律和结合律
⚠️ 踩坑:异或交换法不能用于同一个变量自身交换,即 swap(a, a) 会直接清零。实际工程中,可读性优先,用临时变量更稳妥。
4. 代码实战
4.1 进制表示与打印
#include <stdio.h>
int main()
{
// 0153 以 0 开头,是八进制
printf("%d\n", 0153); // 输出 107(1×64 + 5×8 + 3 = 107)
// 153 默认十进制
printf("%d\n", 153); // 输出 153
return 0;
}
4.2 移位操作
#include <stdio.h>
int main()
{
// 左移:正数
int a = 6;
int b = (a << 1);
printf("%d\n", b); // 12
a <<= 1; // 复合赋值:a = a << 1
printf("%d\n", a); // 12
// 左移:负数
int c = -3;
// 原码: 10000000 00000000 00000000 00000011
// 反码: 11111111 11111111 11111111 11111100
// 补码: 11111111 11111111 11111111 11111101
// <<1 : 11111111 11111111 11111111 11111010
// 转原码: 10000000 00000000 00000000 00000110 → -6
int d = (c << 1);
printf("%d\n", d); // -6
// 右移:负数
int e = -10;
// 原码: 10000000 00000000 00000000 00001010
// 补码: 11111111 11111111 11111111 11110110
// >>1 : 11111111 11111111 11111111 11111011
// 转原码: 10000000 00000000 00000000 00000101 → -5
int f = (e >> 1);
printf("%d\n", f); // -5
return 0;
}
4.3 按位与、或、异或
#include <stdio.h>
int main()
{
// 按位与 &:有 0 就 0
int a = 3, b = -5;
// 3 补码: 00000000 00000000 00000000 00000011
// -5 补码: 11111111 11111111 11111111 11111011
// & 结果: 00000000 00000000 00000000 00000011 → 3
printf("3 & -5 = %d\n", a & b); // 3
// 按位或 |:有 1 就 1
// 3 补码: 00000000 00000000 00000000 00000011
// -5 补码: 11111111 11111111 11111111 11111011
// | 结果: 11111111 11111111 11111111 11111011
// 转原码: 10000000 00000000 00000000 00000101 → -5
printf("3 | -5 = %d\n", a | b); // -5
// 按位异或 ^:相同为 0,不同为 1
int c = 6, d = -5;
// 6 补码: 00000000 00000000 00000000 00000110
// -5 补码: 11111111 11111111 11111111 11111011
// ^ 结果: 11111111 11111111 11111111 11111101
// 转原码: 10000000 00000000 00000000 00000011 → -3
printf("6 ^ -5 = %d\n", c ^ d); // -3
return 0;
}
4.4 三种交换变量方法
#include <stdio.h>
// 方法一:临时变量(最推荐,清晰稳妥)
void swap_by_temp()
{
int a = 1, b = 8;
int temp = a;
a = b;
b = temp;
printf("临时变量法: a=%d, b=%d\n", a, b);
}
// 方法二:加减法(可能溢出,不推荐)
void swap_by_arithmetic()
{
int a = 1, b = 8;
a = a + b; // a = 9
b = a - b; // b = 1
a = a - b; // a = 8
printf("加减法: a=%d, b=%d\n", a, b);
}
// 方法三:异或法(技巧性强,面试常考)
void swap_by_xor()
{
int a = 1, b = 8;
a = a ^ b; // a = 1 ^ 8
b = a ^ b; // b = (1 ^ 8) ^ 8 = 1
a = a ^ b; // a = (1 ^ 8) ^ 1 = 8
printf("异或法: a=%d, b=%d\n", a, b);
}
int main()
{
swap_by_temp();
swap_by_arithmetic();
swap_by_xor();
return 0;
}
4.5 统计二进制中 1 的个数(三种方法)
#include <stdio.h>
// 方法一:模 2 法(仅适用于正数,负数会出错)
// ⚠️ 有 bug,见第 6 节修正
int count_one_v1(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if ((x % 2) == 1)
cnt++;
// 缺少 x /= 2,导致死循环
}
return cnt;
}
// 方法二:移位法(正负数都适用,逐位检查)
int count_one_v2(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if (((x >> i) & 1) == 1)
cnt++;
}
return cnt;
}
// 方法三:n & (n-1) 算法(最优,只循环 1 的个数次)
// 核心原理:n & (n-1) 会消掉最低位的 1
//
// 演示:n = 6 (二进制 0110)
// n = 0110
// n-1 = 0101
// n&(n-1)= 0100 ← 最低位的 1 被消掉了
//
// n = 0100
// n-1 = 0011
// n&(n-1)= 0000 ← 又消掉一个 1,结果为 0 结束
// 循环了 2 次 → 有 2 个 1
int count_one_v3(int x)
{
int cnt = 0;
while (x)
{
x = x & (x - 1);
cnt++;
}
return cnt;
}
int main()
{
int n = 0;
scanf("%d", &n);
printf("二进制中 1 的个数: %d\n", count_one_v3(n));
return 0;
}
4.6 置位 / 清零指定位
#include <stdio.h>
int main()
{
int a = 13; // 二进制: 00001101
// 将第 5 位(从右往左数,最低位是第 1 位)置为 1
// 1 << 4 = 00010000
// a | (1<<4) = 00001101 | 00010000 = 00011101 = 29
a |= (1 << 4);
printf("置位后: %d\n", a); // 29
// 将第 5 位清零
// ~(1 << 4) = ~00010000 = 11101111
// a & ~(1<<4) = 00011101 & 11101111 = 00001101 = 13
a &= ~(1 << 4);
printf("清零后: %d\n", a); // 13
return 0;
}
4.7 结构体实战
#include <stdio.h>
#include <string.h>
// 结构体是一种自定义类型,和 int、double 没有本质区别
struct stu
{
char name[10];
char id[12];
int age;
};
// 结构体可以嵌套
struct peo
{
int age;
char name[12];
char tele[12];
};
struct ebook
{
struct peo data[100]; // 存放 100 个人的通讯录
int count; // 记录已存放的个数
};
int main()
{
// 普通初始化
struct stu s1 = {"zhangsan", "2025440304", 18};
// C99 指定初始化器:可以按名字赋值,不依赖顺序
struct stu s2 = {.age = 18, .name = "wangcai", .id = "2025440304"};
// 嵌套结构体初始化
struct peo p1 = {18, "wangcai", "2025440304"};
struct ebook eb = {
{{18, "yiyi", "1538466919"}, {17, "dada", "556552"}},
0 // count = 0
};
// 访问成员
printf("%s\n", p1.name); // wangcai
printf("%s\n", eb.data[0].name); // yiyi(注意下标从 0 开始)
printf("%s\n", eb.data[1].name); // dada
return 0;
}
5. 注释提炼
5.1 知识注解类
关于进制转换
二进制转八进制,从右向左三个二进制位可以描述一个八进制位;二进制转十六进制,从右向左四个二进制位可以描述一个十六进制位。
扩展说明:为什么是 3 位和 4 位?因为 2^3 = 8,2^4 = 16。一组二进制位恰好能表示 0~7 或 0~15 的范围。这是计算机底层自然选择的结果,不是巧合。比如二进制 111010,从右往左分组:111 010,八进制就是 72;十六进制分组:0011 1010,就是 3A。
关于整形提升
整形提升和类型有关,如果是 unsigned char 就是无符号位,如果是 signed char,就是有符号位。整形提升都是从高位补符号位或者 0。
扩展说明:C 语言的表达式求值有一个隐式规则——所有比 int 小的整数类型(char、short)在参与运算前,会被自动提升为 int。这么做是因为 CPU 的 ALU(算术逻辑单元)通常按 int 宽度设计,操作 char 反而要额外截断,效率更低。提升时:有符号类型高位补符号位(保持数值不变),无符号类型高位补 0。
关于打印时的符号处理
在打印时看占位符是 %d 还是 %u,如果是无符号,那么最高位就不再是符号位,直接进行计算。
扩展说明:同一块内存,用 %d 解释就是有符号整数,用 %u 解释就是无符号整数。比如 11111111 11111111 11111111 11111111,用 %d 打印是 -1,用 %u 打印是 4294967295。内存里存的都是补码,怎么解释看你的格式控制符。
5.2 个人理解类
我的理解:补码这个设计太精妙了——它让 CPU 不需要区分"加法器"和"减法器",一套电路搞定所有运算。正数的补码等于自己,负数的补码是"取反加一",这样 a + (-b) 就自然等于 a - b。理解了补码,就理解了为什么
~0是 -1(全 1 的补码就是 -1),为什么INT_MIN是-2147483648而INT_MAX是2147483647(负方向多一个,因为 0 占了正数的一个位置)。
我的理解:位运算看起来是"奇技淫巧",实际上它就是硬件最擅长的事。一个
n & (n-1)消掉最低位 1,比循环 32 次快得多。但写代码时,如果不是性能瓶颈,还是优先保证可读性——你写的n % 2别人一眼就懂,n & 1反而要愣一下。
我的理解:结构体本质上就是你把几个相关变量打包在一起,起个名字。
struct stu s1和int a在编译器的视角里,都是"分配一块内存,按某种布局解释"。唯一的区别是 int 的布局是硬件规定的,结构体的布局是程序员定义的。
5.3 踩坑感悟类
⚠️ 踩坑 1:% 取模统计 1 的个数——负数翻车
// 错误写法
int count_one(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if ((x % 2) == 1)
cnt++;
// 忘了 x /= 2!死循环
}
return cnt;
}
两个问题:第一,x % 2 对负数会返回 -1 或 0,永远不会等于 1,负数统计一直为 0。第二,即使换成正数,循环里没有 x /= 2,x 永远不变,死循环。用位运算 (x >> i) & 1 或者 n & (n-1) 算法才是正解。
⚠️ 踩坑 2:右移负数时左边补什么?
大部分编译器对 signed 类型做算术右移(补符号位),但 C 标准规定这是"实现定义"行为。写跨平台代码时,如果要对负数做右移,最好用 unsigned 类型,确保补 0,行为可预测。
⚠️ 踩坑 3:异或交换法不能自己和自己交换
int a = 5;
a = a ^ a; // a = 0
a = a ^ a; // a = 0
a = a ^ a; // a = 0 → 数据丢了!
如果你写一个 #define SWAP(a, b) 宏用异或法,调用 SWAP(arr[i], arr[i]) 就会把元素清零。这属于隐蔽 bug,排查起来很痛苦。
5.4 对比总结类
三种交换变量方法对比
| 方法 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| 临时变量 | 清晰、安全、所有类型通用 | 多用一个变量 | ★★★★★ |
| 加减法 | 不借助第三变量 | 可能溢出,仅适用于数值 | ★★☆☆☆ |
| 异或法 | 不借助第三变量,不会溢出 | 可读性差,同一变量会清零 | ★★★☆☆ |
三种统计 1 的个数方法对比
| 方法 | 时间复杂度 | 适用性 | 推荐度 |
|---|---|---|---|
| 模 2 法 | O(32) | 仅正数(负数有坑) | ★☆☆☆☆ |
| 移位法 | O(32) | 正负数都适用 | ★★★★☆ |
| n & (n-1) | O(k)(k 为 1 的个数) | 正负数都适用 | ★★★★★ |
位操作符速查表
| 操作符 | 名称 | 规则 | 常见用途 |
|---|---|---|---|
& | 按位与 | 有 0 就 0 | 清零指定位、判断奇偶 |
| | 按位或 | 有 1 就 1 | 置位指定位 |
^ | 按位异或 | 相同为 0,不同为 1 | 交换变量、加密 |
~ | 按位取反 | 1 变 0,0 变 1 | 配合 & 清零指定位 |
<< | 左移 | 左丢右补 0 | 乘 2 的幂 |
>> | 右移 | 右丢左补符号位 | 除 2 的幂 |
6. 代码修正
Bug 1:统计 1 的个数——模 2 法死循环
修改前:
int count_one(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if ((x % 2) == 1)
cnt++;
// 缺少 x /= 2,i 在变但 x 不变,无限循环判断同一个条件
}
return cnt;
}
修改后:
int count_one(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if ((x & 1) == 1) // 用 & 1 替代 % 2,正负数都正确
cnt++;
x >>= 1; // 每次右移一位,检查下一个二进制位
}
return cnt;
}
原因:x % 2 对负数返回 -1 或 0,永远不等于 1。而且原代码没有改变 x 的值,循环 32 次全在判断同一个最低位。改用 x & 1 检查最低位,然后 x >>= 1 右移推进,才能正确统计所有 32 位。
Bug 2:移位法统计 1 的个数——移位目标错误
修改前:
int count_one(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if (((x >> 1) & 1) == 1) // 每次都右移 1 位,永远在检查第 2 位
cnt++;
}
return cnt;
}
修改后:
int count_one(int x)
{
int cnt = 0;
for (int i = 0; i < 32; i++)
{
if (((x >> i) & 1) == 1) // 右移 i 位,逐位检查
cnt++;
}
return cnt;
}
原因:x >> 1 永远只右移 1 位,循环 32 次都在检查同一个比特位。应该用 x >> i,每次移动不同的位数,才能逐位检查。
7. 小结
位运算不是炫技,而是理解计算机如何"看见"数字——你写的每一行 a + b,在 CPU 眼里都是补码上的位操作。把原码、反码、补码的转换练熟,把移位和按位逻辑的肌肉记忆建立起来,指针和内存的话题才有地基。
412



被折叠的 条评论
为什么被折叠?



