在计算机里搞清楚“整数怎么存”,是我觉得比学任何框架都值的一门基本功。你可能已经写过无数个 int 变量,但真当面试官问“为什么 32 位有符号整数范围是 -2147483648 到 2147483647”时,还是会有不少人愣住。其实这个问题的起点特别朴素:计算机用二进制记录一切,整数也不例外。这篇内容就是从最底层的二进制开始,把原码、反码、补码、范围、溢出、类型选择这些事一次讲透,顺便结合我在 C/C++、Bash、MySQL、Julia 里实际踩过的一些坑,帮你把“整数表示”这块地基彻底打牢。不管你是刚学编程的新手,还是写了好几年业务代码的老手,这些内容都值得静下心看一遍。
1. 先从二进制的“物理直觉”说起
1.1 为什么是 0 和 1
很多人都知道计算机用二进制,但对“为什么非要用二进制”其实没有深想过。你可以把计算机理解成一大堆开关,每个开关只有两个状态:开或者关。如果用十进制,一个开关就得有 10 个稳定的状态,电压从低到高划分出 10 档,这在物理上很难做到稳定。两个状态就简单多了:高电压代表 1,低电压代表 0,抗干扰能力也强。所以二进制不是“某个人拍脑袋定的”,而是硬件成本、可靠性、数学可推导性共同作用的结果。
更重要的一点是,二进制天然适配布尔代数。逻辑运算只有真和假,正好对应 1 和 0。整数加减乘除最终都能拆成移位和逻辑运算,硬件设计因此变得极其规整。你只要记住一句话:计算机里面没有魔法,只有按规则摆放的 0 和 1。
1.2 位、字节、字长
咱们从最小的单位开始聊。
- 位(bit):一个二进制位,只能表示 0 或 1。
- 字节(byte):8 个 bit 组成一个字节。为什么是 8?早期 IBM 在 System/360 上定下了 8 位一字节的规格,后来整个行业都按这个来了。8 位能表示 256 种不同状态。
- 字长(word size):CPU 一次能处理的二进制位数。32 位 CPU 的寄存器是 32 位,64 位 CPU 的寄存器是 64 位。字长直接决定了一个机器上“int”这类原生整数的天然宽度。
一台 64 位机器并不是说所有整数都必须是 64 位。你依然可以定义 8 位、16 位、32 位的整数类型,只是 CPU 在处理时可能要做额外的拼接或拆分。这也是为什么很多语言里 int 的最小范围是有规定的,但具体位数要看平台。
1.3 位宽决定一切:从 4 位的玩具开始
为了不被 32 位、64 位这种大数字吓到,咱们先玩一个 4 位的玩具模型。4 个 bit 一共有 2 的 4 次方等于 16 种组合:0000 到 1111。如果把这 16 种组合都当成无符号整数,那它表示的就是 0 到 15。
但如果我们想表示负数呢?一种最原始的想法是拿最高位当符号位:0 开头是正数,1 开头是负数。比如 0001 表示 1,1001 表示 -1。这样就出现了两个问题:一是 0 有两种表示,0000 和 1000 都表示 0;二是加减法必须额外判断符号位,硬件设计变得很啰嗦。这个看起来自然的方案,就是下面要聊的“原码”,它的问题直接催生了补码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原码、反码、补码:整数表示的“三兄弟”
2.1 原码:一眼看懂但没人用
原码的定义很简单:最高位是符号位,0 表示正,1 表示负,剩下的位表示绝对值。以 8 位为例:
- +5 的原码是
00000101 - -5 的原码是
10000101
它最大的优点是直观,人一眼就能看出值是多少。但它有两个硬伤。
第一个硬伤是 0 的表示不唯一:00000000 表示 +0,10000000 表示 -0。判断“一个数是否为零”就得同时判断两种编码,很别扭。第二个硬伤是运算太麻烦。你想算 5 + (-5),如果用原码,计算机得先判断符号,再做减法,还要处理结果符号。一套流程走完,电路复杂度直线上升。所以在实际硬件中,原码几乎只用于浮点数的存储和某些特殊场景,整数运算从来不直接用它。
2.2 反码:把减法变成加法的中继站
反码的规则是:正数的反码等于原码;负数的反码是“符号位不变,其余位全部取反”。比如 8 位下:
- +5 的反码是
00000101 - -5 的反码是
11111010
反码解决了一部分“把减法变加法”的问题。5 + (-5) 可以变成 00000101 + 11111010,结果是 11111111,也就是 -0 的反码。这就很尴尬,运算结果确实在反码体系里合法,但“负零”这个概念始终堵在那里。而且做加法时,如果最高位有进位,还得把进位加回到最低位,这个“循环进位”逻辑也让硬件实现变得别扭。
反码没有完全解决原码的痛,但它提供了一个重要思路:用取反操作来表达负数,把符号问题拉到二进制位层面去解决。 这个思路和补码只差一步。
2.3 补码:为什么它是现代计算机的默认答案
补码的定义你可能听过:正数的补码等于原码;负数的补码是“原码取反再加一”。还有一种等价描述:补码 = 反码 + 1。用 8 位来举例:
- +5 的补码是
00000101 - -5 的补码:先看 +5 是
00000101,取反得11111010,再加 1 得11111011
这里的 +1 巧妙地把“负零”清掉了。8 位一共有 256 种组合,用补码表示时,范围是从 -128 到 127,0 只有 00000000 这一种编码。为什么 10000000 表示 -128?因为你从 0 开始不断减 1,会得到:00000000 - 1 = 11111111,这是 -1;继续减到 -128 时,编码正好是 10000000。补码让减法统一成了加法,5 - 5 变成 5 + (-5),只需一个加法器就能搞定,硬件设计被大幅简化。
再往深想一层,补码的价值其实是一种“模运算”。它相当于在一个固定位宽的数轴上转圈。比如 8 位的加法溢出后就会回绕到另一头,这种回绕在物理上非常自然。没有别的方法比补码更适合做整数加法了。
2.4 无符号和有符号:同一串二进制,两种解读
这是新手最容易踩坑的地方。同样的二进制串,你把它解读成无符号数是一种值,解读成有符号数又是一种值。比如 8 位的 11111111:
- 无符号解读:255
- 有符号补码解读:-1
也就是说,同一个内存位模式本身没有含义,含义来自你怎么解释它。 语言里的类型系统做的就是这个解释工作。unsigned char 和 char 的底层都是 8 个 bit,唯一的区别是编译器按哪种规则翻译给你看。
运行时 CPU 并不知道“这个数是不是有符号”,它只是按补码规则做加法。真正出问题的地方在于:比较、除法、右移这些操作需要区分符号。比如 C 语言里 -1 > 0u 这种表达式,结果是真还是假?-1 会被自动转成无符号数,变成 4294967295,然后和 0u 比,结果是真的。这种“符号陷阱”比溢出还隐蔽,后文会详细展开。
3. 范围计算与溢出:int 到底是哪根筋搭错了
3.1 32 位有符号整数范围怎么算出来的
32 位就是 4 字节,总共 2 的 32 次方等于 4294967296 种状态。有符号数用补码表示,一半留给非负数(0 到正数),一半留给负数。
- 最大正数:最高位是 0,其余 31 位全是 1,即
0111...111,等于 2 的 31 次方减 1,也就是2147483647。 - 最小负数:最高位是 1,其余 31 位全是 0,即
1000...000,按照补码规则它等于 -2 的 31 次方,也就是-2147483648。
所以范围是 [-2147483648, 2147483647]。为什么负数比正数多一个?因为 0 占掉了正数侧的一个编码。这一点我当年记了很久,但理解了补码之后其实不需要背:0 的编码决定了对称性被打破,负数总是比正数多一个。
如果你在 32 位系统上用 int 存一个超过 2147483647 的值,结果并不是报错,而是发生“回绕”。比如 2147483647 + 1 会变成 -2147483648。很多事故就是这么来的。
3.2 溢出是静默回绕,不是报错
我最早学 C 语言的时候以为算术溢出会像数组越界一样崩掉,后来发现完全不是。C、C++ 的有符号整数溢出属于未定义行为,编译器可能回绕,也可能做优化产生更离谱的结果;而 Java、C# 这类语言默认回绕;Go 的 int 溢出也会回绕;Python 的整数则无限扩展,不会溢出。行为差异很大,但底层硬件其实都一样:最终都是补码加法器的回绕。
需要特别提醒的是,无符号整数的算术溢出是明确定义的:永远按 2 的位数取模。比如 8 位无符号数 200 + 100 = 300,模 256 等于 44。有符号数溢出则是“未定义行为”,尤其在做编译优化时,程序可能直接按照“不会溢出”的假设进行推导,导致你完全意想不到的结果。
所以别迷信“溢出会报错”这个说法。在底层世界里,溢出就像表盘上的时针走过 12 点,直接从 1 又开始了。问题在于你根本不知道它何时转圈,转完之后数据已经错了,逻辑还继续跑。
3.3 混用陷阱:一个 if 让你翻车
有符号数和无符号数混在一起,是 C/C++ 里最经典的坑。请看这段示例:
c复制#include <stdio.h>
int main(void) {
int a = -1;
unsigned int b = 1;
if (a < b) {
printf("a < b\n");
} else {
printf("a >= b\n");
}
return 0;
}
直觉上 -1 < 1 是对的,但实际输出是 a >= b。原因是 C 的“整数提升规则”:有符号数和无符号数比较时,有符号数会被转换成无符号数。-1 转成无符号后是 4294967295,自然大于 1。
这类问题在循环里尤其隐蔽。比如:
c复制for (unsigned int i = n; i >= 0; i--) {
// ...
}
i >= 0 恒为真,循环永远不会自然退出,因为无符号数减到 0 之后再减 1 会变成 4294967295,又重新开始了。很多线上服务超时、死循环的根子就在这种不起眼的类型混用上。
3.4 排序、求最小公倍数、数数字 1:那些题目里埋的整数坑
讲完理论,看看真实的编程题场景。
先说整数排序。排序本身不复杂,但如果中间计算了下标或者差值,就容易碰到整数问题。比如你写个快排,用 (left + right) / 2 求中点,当 left 和 right 都很大的时候,left + right 可能溢出变成负数,结果就崩了。很多面试题专门考 mid = left + (right - left) / 2 这个写法,原因就是防溢出。
再说n 个整数的最小公倍数。很多人喜欢先求最大公约数,再用公式 lcm(a, b) = a / gcd(a, b) * b 去计算。这个公式本身没问题,但如果你先算 a * b 再除以 gcd,乘积极可能溢出。正确做法是先除后乘,用 a / gcd(a, b) * b 来缩小中间结果。就算这样,当 n 个数都很大时,最终的最小公倍数可能远超 32 位整数范围。所以这类题目在 C++ 里往往要用 long long。你要是只盯在“算法对不对”上,忽略了整数范围,提交后就是白白一个 WA。
还有一道很经典的“给定十进制正整数 n,写下从 1 到 n 的所有整数,数一下其中出现的数字 1 的个数”。如果不假思索地遍历 1 到 n,再逐位统计,那么当 n 是 10 的 9 次方级别时,时间直接爆炸。这个问题的本质是:n 的位数决定了复杂度,而“位数”恰好是十进制整数表示的一个直接应用。你可以按位统计,利用每一位上 1 出现的周期规律去算,而不是真的把每个数都写一遍。这个题目提醒我们,理解整数表示不只是看补码,也要理解它在不同进制下的信息密度。
4. 不同语言和数据库里的整数表示
4.1 C/C++:类型、重载和“ll老师填坑”题背后的 long long
C/C++ 的整数类型很丰富:char、short、int、long、long long,还要配 signed / unsigned。每个类型的最小位宽是标准规定的,但具体大小取决于平台。很多新手在 Windows 上用 long 以为它是 8 字节,其实 MSVC 下 long 是 4 字节,而 Linux 下 long 是 8 字节。这种“平台差异”就是跨平台 bug 的温床。
C++ 还有一个和整数表示相关的坑就是运算符重载。比如你给某个类重载了 operator+,又用了 int 做隐式转换,一旦整数溢出,行为可能和你预期的完全不一样。有人写过一个“c++整数重载要求”的搜索,本质上是指:重载 +、==、<< 这些运算时,必须想清楚操作数是被当作“数学整数”还是“有限位宽的机器整数”。如果类里保存的是 int,那就逃不出补码回绕的边界。
再说一个很典型的编程题场景,就是题目描述里“ll老师填坑”那类问题:给定 n 个数 a_i,还有一个整数 m 初始为 0,如果当前数比前面所有数中的某个数小,就将它和 m 同时加 1,求最后的 m。这个题目本身是模拟,但很多人第一次写的时候会用 int 存 a_i 和 m。大多数确实能过,但如果 n 很大,或者数据构造得比较极端,m 的累加量可能超过 int 范围。正规做法是直接上 long long,把整数范围这个变量从脑子里排除掉,专心做逻辑。你能看到,面试和竞赛题最喜欢在“边界条件”里下套,整数范围就是其中最普遍的一个。
4.2 Bash 脚本:你以为的整数和实际的整数
很多人觉得 Bash 里的变量都是字符串,其实 Bash 也有整数声明方式。你可以用 declare -i 定义整数变量:
bash复制declare -i num=10
num=num+5
echo $num # 输出 15
但要注意,Bash 的算术求值默认使用 64 位有符号整数(大多数平台),所以 num=9223372036854775807 再加 1,结果会回绕成负数。对于一般脚本来说,这个范围已经够用了,但如果你在写脚本时从某个接口读到一个超大的 ID,想把它当整数做运算,就可能翻车。
另一个常见坑是:Bash 中 let、((...))、$((...)) 里的数字如果以 0 开头,会被当成八进制。比如 num=08 会报错,因为 8 不是合法的八进制数字。这也算“整数表示”在脚本语言里的一种体现:同一个写法,不同上下文有完全不同的规则。
4.3 MySQL:整数类型不只是 int(11)
数据库里的整数表示同样值得讲。MySQL 的整数类型有 TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT,还有可选的 UNSIGNED。它们的字节数分别是 1、2、3、4、8,对应的范围如下:
| 类型 | 字节数 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 0 ~ 255 |
| SMALLINT | 2 | -32768 ~ 32767 | 0 ~ 65535 |
| MEDIUMINT | 3 | -8388608 ~ 8388607 | 0 ~ 16777215 |
| INT | 4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
| BIGINT | 8 | -9223372036854775808 ~ 9223372036854775807 | 0 ~ 18446744073709551615 |
很多人第一眼会困惑:int(11) 里的 11 是不是表示能存 11 位数字?其实不是。后面的显示宽度只影响 ZEROFILL 补零显示,不影响存储范围和实际值。业务上常见的错误是:把订单 ID 设成 INT,当用户量或流水量超过 21 亿时,数据库直接爆掉,或者在中途还是好的,某天突然出现负数,让人摸不着头脑。正确的做法是先根据业务量估算峰值,再选 BIGINT。这跟 C 语言里选 long long 一样,都是对整数表示边界做提前管理。
4.4 Julia:高精度整数和浮点数的分界线
Julia 在整数设计上很有意思。普通的 Int 在 64 位机器上是 64 位有符号整数,会在补码范围内回绕;但 Julia 还内置了 BigInt,它是高精度整数,能表示任意大的整数。比如:
julia复制x = BigInt(10)^100
println(x)
这行代码可以输出一个 100 位的数。这个特性在做组合数学、密码学、大数运算时特别舒服,但性能远不如原生 Int。
Julia 另一个容易让新手困惑的点是,10 / 2 得到的是浮点数 5.0,而 10 ÷ 2 才是整数除法。这种“显式区分整数和浮点数”的设计看似啰嗦,实际是防止你在做数值计算时把整数除法当成浮点除法,从而保留类型信息。如果你从 Python 转过来,容易踩 1/2=0.5 还是 0 的坑。理解整数表示,其实就是在理解语言设计者对“精度”和“范围”的取舍。
5. 用位运算亲手验证整数表示
5.1 快速查看补码的几种方式
看书十遍不如动手验证一遍。最简单的方式是用 Python 的 bin 来看整数位模式:
python复制x = -5
print(bin(x & 0xFF)) # 输出 0b11111011,这是 -5 在 8 位下的补码
原理是 & 0xFF 把 -5 截断到低 8 位,转出来的结果就是从低位看过去的补码。如果你想看 32 位补码,可以用 bin(x & 0xFFFFFFFF)。
在 C 语言里也可以用联合体或者移位来观察:
c复制#include <stdio.h>
#include <stdint.h>
int main(void) {
int8_t x = -5;
for (int i = 7; i >= 0; i--) {
putchar((x >> i) & 1 ? '1' : '0');
}
putchar('\n');
return 0;
}
右移有符号数时,C 标准没有规定一定是算术右移,但大部分编译器采用算术右移,所以左边补的是符号位。你观察到的位模式就是补码。这个实验能帮你建立“负数和位串”之间的直观映射。
5.2 常见问题排查速查表
实际开发中,遇到整数相关 bug 时,我建议按下面的顺序排查:
| 现象 | 可能原因 | 快速验证方法 |
|---|---|---|
| 循环死循环 | 无符号变量判断 >= 0 |
看变量类型是否 unsigned |
| 排序结果出现巨大负数 | 下标或中间值溢出 | 把 left + right 改成 left + (right - left) / 2 |
| 计算结果时对时错 | 有符号、无符号混用 | 开启编译器 -Wall -Wsign-compare 警告 |
| MySQL 主键或计数变负数 | INT 达到上限 | 查看 MAX(id),评估改用 BIGINT |
| Bash 算术结果不对 | 八进制前缀问题 | 用 10#${num} 强制按十进制解析 |
| C++ 重载后结果异常 | 隐式转换或溢出 | 检查重载函数签名,显式转换类型 |
排查的时候一定不要只盯着逻辑,先确认每一处运算的类型和范围。很多“玄学问题”其实都能从整数表示里找到根源。
5.3 奶牛数轴问题:整数坐标场景下的数据范围判断
一个特别好的例子是热词里提到的“n 头奶牛在一条数轴的不同整数位置上,通过移动让它们……”这类题目。你第一眼要关注的不是移动策略,而是输入范围。如果坐标绝对值在 1e9 级别,那么坐标差可能达到 2e9,正好卡在 32 位有符号整数边缘。比如 -1000000000 到 1000000000 的距离是 2000000000,小于 2147483647,还安全;但如果坐标范围是 -1e9 到 1e9 且我计算的是两端点坐标之和,就可能溢出。
做这类题有个习惯:拿到题先看数据范围,然后立刻确定每个中间量的类型。坐标用 long long,数量用 long long,中间累积量也用 long long。虽然最后可能用不上那么大的范围,但多花一点点空间换回“不会因为溢出而 WA 的安心感”,非常值。
6. 一些我踩过的坑和总结
6.1 三个真实翻车场景
第一个是我早期写 C 程序统计文件行数,用了 int 存计数器。文件特别大时,超过 21 亿行后计数值变负数,程序照样跑,最后生成报表全是负数。这个 bug 在测试环境下根本发现不了,因为测试文件不会造那么多行。教训就是:任何可能累积到很大值的计数,直接上 long long。
第二个是写 Python 调 C 扩展时,把一个 unsigned long 的返回值直接塞进 Python 的 int,然后在边界上判断结果。由于两边对“负数”的表示规则不同,C 层返回的 0xFFFFFFFF 可能是 -1,到了 Python 却是 4294967295。这类跨语言边界问题,本质上就是整数表示在不同解释器之间的差异。
第三个是 MySQL 表设计时用了 INT 存优惠券批次号,上线一年后突然出现主键冲突和数据错乱。查下来才发现批次号已经超过 INT 上限,数据库开始自增到负数。后来改成 BIGINT 才彻底解决。这类问题一旦发生,修复成本非常高,所以建表阶段就要对业务量有预期。
6.2 选择整数类型时的经验法则
根据我这些年的经验,可以给你几条很实用的选择法则。
- 能用无符号就用无符号?不对。大部分场景建议优先用有符号数,因为无符号数在减法、比较、循环时更容易制造“隐蔽负数”的坑。
- 数量级在 10 亿以内且有明确上限,用 32 位
int;如果涉及累加、差值,或者上限不清,直接用 64 位。 - C/C++ 里写算法题时,数组下标、长度、计数这类东西,直接用
size_t或long long,省得一不小心就溢出。 - 数据库主键、订单号、流水号,别犹豫,直接
BIGINT。虽然INT也能跑好一阵,但系统一旦扩量,迁移成本远超一开始多花的几个字节。 - Bash 脚本里涉及计算时,尽量用
declare -i或$((...)),并且留意前导零对进制的影响。
这些规则不是教科书上的“最佳实践”,而是我反复踩坑之后沉淀下来的经验。以前总觉得“整数嘛,不过就是加减乘除”,直到线上事故教做人,我才老老实实把这一小块知识补齐。
如果你正在学这块内容,建议你亲手跑一遍 printf、bin、declare -i,把 4 位、8 位、32 位的边界都打印出来看看。看着那些数字从正数翻到负数,从 127 变 -128,你会突然对“计算机里的整数只是一个有限循环的圆圈”这句话产生身体记忆。这种理解比死记硬背任何范围都更持久。
