这几天在指导一个学员调试 C++ 程序的时候,遇到一个非常典型的问题:循环里用 int 做递增,结果数值超过 21 亿后直接变成负数,程序陷入死循环。这让我想写一篇真正把“整数在计算机中的表示”讲透的文章,而不是停留在“int 是 4 字节”这样的表面认知。
无论你是写 C/C++、Java、Python、MySQL 脚本,还是 Bash 脚本,整数都是最常打交道的类型。但越是常见的东西,越容易忽略它背后的表示原理。你理解的是数学意义上有理数集合的子集,计算机存储的却是有限状态机里的二进制序列,这两者之间有一道隐形的墙。搞懂这堵墙,很多算法题边界条件、线上 bug、数据库字段选型的问题,都会豁然开朗。
1. 二进制基石:从十进制到计算机思维
1.1 为什么计算机偏偏选中二进制
小时候学计算机基础,老师会告诉你“计算机内部采用二进制”。但深究一层,为什么不是十进制?我们数数天生用十根手指,按理说做成十进制的计算器更符合直觉。
真正的原因是物理器件实现的代价。计算机的核心部件是晶体管,它工作在两种极其稳定的状态:导通和截止,对应电压的高和低。如果我们想用电压的高低直接表示 0 到 9 的十个状态,就需要把电压分成十个精确的区间,这不仅对电源稳定性要求极高,在高速开关时还极易出错。反过来,只用高/低两种电平,抗干扰能力极强,电路设计也极其简单。所以二进制不是一种选择,而是一种物理约束下的最优解。
这带来的直接后果是,任何信息——整数、浮点数、文本字符、图片像素——最终都必须编码成一串 0 和 1。整数因为天然离散,是编码中最直观的一种,也是理解其他一切编码的起点。
1.2 位、字节与换算速成
二进制的基本单位是位(bit),单个比特只能表示 0 或 1。8 个比特组成一个字节(byte),这是内存寻址和大多数编程语言中最小可操作的基本单位。
你需要在脑子里快速建立一张换算表:
| 单位 | 大小 | 取值范围(无符号整数) |
|---|---|---|
| 1 bit | 1 位 | 0 ~ 1 |
| 1 Byte | 8 位 | 0 ~ 255 |
| 1 KB | 1024 B | — |
| 1 MB | 1024 KB | — |
接下来是进制转换的实用技巧。二进制转十进制很简单,按权展开即可,比如 1101 就是 1*8 + 1*4 + 0*2 + 1*1 = 13。反过来,十进制转二进制用除 2 取余法,直到商为 0,余数倒序排列。比如 13 除以 2,余数依次是 1、0、1、1,倒过来就是 1101。
还有一个更贴近实战的技巧:由于 16 = 2^4,每四位二进制恰好对应一位十六进制。所以我们写代码调试时,常用十六进制来观察内存中的位模式。比如二进制 1111 1110,可以直读为 0xFE,比写一长串 0 和 1 轻松太多了。在 gdb 里用 p/x 查看某个整数的内存,呈现出的就是紧凑的十六进制,这时候如果你对十六进制和二进制的映射足够熟练,就能一眼看出哪些位是 1、哪些位是 0。
1.3 数据宽度:为什么会有 8/16/32/64 位之分
计算机处理整数时,并不是无限长的一串比特,而是固定长度的一截,这个长度称为位宽。一个 8 位的整数只有 8 个比特位可用,一个 32 位的整数有 32 个比特位可用。
位宽的选择直接影响两个方面:能表示的数值范围,以及操作的性能。越宽能表示的数越大,但占用的内存和寄存器空间也越多,CPU 处理时需要更多时间。所以你会发现,早期处理器是 8 位的,后来发展到 16 位、32 位,现在是 64 位主流。编程语言中的 char、short、int、long,本质就是在不同位宽间做出的取舍。
理解了“固定长度”这个前提,你就能明白为什么整数运算会溢出:因为可用的比特位是有限的,超过上限的结果被直接丢弃,留下来的只是低位截断后的值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 有符号与无符号:整数表示的核心
2.1 无符号整数:最简单的地图
先从最简单的情况说起。无符号整数把所有比特位都用来表示数值大小,不做正负区分。对于 n 位无符号整数,它的取值范围是 0 到 (2^n - 1)。8 位无符号整数范围就是 0 到 255,32 位无符号整数范围是 0 到 4294967295。
无符号整数在底层就是一个普通的二进制数,0000 0001 表示 1,1111 1111 表示 255。它是我们自己脑子里最自然的“二进制数”概念,没有歧义。
现实编程中,无符号整数常见于数组下标、指针运算、位掩码、哈希值,以及数据库里的自增主键。C/C++ 的 unsigned int、Java 虽然没有原始无符号 int,但提供了 Integer.toUnsignedLong() 这样的工具方法,MySQL 则有 UNSIGNED INT。无符号的核心优势,是把原本用于表示负数的比特位也释放出来扩展正数范围,所以 32 位无符号数比 32 位有符号数能表示的正数上限大了一倍。
2.2 有符号整数:原码、反码与补码的演变
到了有符号整数,事情就开始变得复杂了。总得有办法表示负数,最简单的想法是:拿出一个最高位当符号位,0 代表正、1 代表负,剩下位表示绝对值。这种表示法叫原码。8 位原码中,0000 0001 表示 1,1000 0001 表示 -1。
但原码在计算机内部非常不好用。首先是 0 的表示不唯一,0000 0000 和 1000 0000 都表示 0,这会导致判断两个数是否相等时产生歧义。其次是加减法运算非常麻烦,如果我要计算 1 + (-1),CPU 不能直接把两个二进制数相加,因为符号位和数值位混在一起,得额外设计一套规则去判断谁大谁小、谁减谁。
于是人们想到了反码。反码规则是:正数和原码一样,负数的数值位逐位取反。比如 -1 的 8 位反码是 1111 1110。反码解决了部分符号位参与运算的问题,但 0 依然有两种表示,补码就应运而生了。
补码的规则非常简洁:正数不变,负数在反码基础上再加 1。所以 -1 的 8 位补码是 1111 1111,-128 的补码是 1000 0000。
2.3 为什么全世界的 CPU 都采用补码
补码最惊艳的地方在于:它让“加法器”这一个硬件电路就够了,减法和负数相加都被统一成了加法。
举个例子,用 8 位补码算 5 + (-2):
- 5 补码:
0000 0101 - -2 补码:
1111 1110 - 直接相加得:
1 0000 0011 - 最高位的进位 1 直接丢弃,剩下 8 位是
0000 0011,即十进制的 3,完全正确。
在这个运算过程中,CPU 不需要做任何符号判断,只是机械地把每一位相加。这就是补码的威力。再来看 0 的唯一性:0000 0000 的补码是它本身,而 1000 0000 被解释为 -128,不再代表 0。因此 n 位有符号整数的取值范围是 -2^(n-1) 到 (2^(n-1) - 1)。负数比正数多了一个,原因就在于此。
我个人理解补码时,喜欢用一个模运算的类比。想象一个只显示 0 到 9 的汽车里程表,倒车 1 公里时表盘从 0 变成 9,那 9 就可以表示 -1。补码就是这样一种“溢出了就当负数”的默契。所有负数在本质上,是对应正数“模 2^n 减一次”的结果。
3. 位宽与取值范围:为什么 32 位是 -2147483648 到 2147483647
3.1 手把手算一遍取值范围
看完了补码,下面把公式套到实际位宽上。
以 32 位有符号整数为例:
- 最大值:符号位为 0,其余 31 位全部是 1,即
0111 1111 1111 1111 1111 1111 1111 1111,等于 2^31 - 1 = 2147483647。 - 最小值:符号位为 1,其余位全部为 0,即
1000 0000 0000 0000 0000 0000 0000 0000,等于 -2^31 = -2147483648。
这个不对称性经常让人困惑,为什么负数能多表示一个。根源在于补码定义中的“反码加 1”,导致负数的表示没有对称的正数对应。你只要记住这个结论,它在 C/C++、Java、Go、Rust 中全部成立。
同样,64 位有符号整数的范围是 -9223372036854775808 到 9223372036854775807。这个数大约是 92 亿亿,绝大多数业务场景都很难触达,但仍有一些场景例如文件大小统计、时间戳纳秒计算、数据库自增主键等,会逼近它。
3.2 整数溢出:那个让程序“变质”的瞬间
理解取值范围后,最大的价值就是能预判溢出。当你把一个超出上限的正数放进 32 位有符号整数时,它不会自动变成更大的数,而是绕回负数那一端。
看一段 C 代码:
c复制#include <stdio.h>
#include <limits.h>
int main() {
int a = INT_MAX;
printf("a + 1 = %d\n", a + 1); // 输出 -2147483648
printf("a + 2 = %d\n", a + 2); // 输出 -2147483647
return 0;
}
INT_MAX + 1 直接变成了 INT_MIN,这在数学上荒谬,但在计算机内部是必然。由于浮点运算和整数运算是一个 CPU 状态机,位模式 0111...1 加 1 变成 1000...0,解释成有符号数就是 -2147483648。
这种溢出在现实中引发过许多严重的事故。早期拳皇 97 里,如果连击伤害突破 32 位有符号上限,角色血量会变成负值,出现一格血打到死也不死的局面,这在游戏圈里被当成 bug 津津乐道。在金融系统中,溢出的后果就严重得多,某券商系统曾因为客户资产净值超过 21 亿后显示为负数,引发过交易异常。所以,凡是涉及连续累加或乘法扩大数值的代码,必须提前评估数值量级,必要时升级到 64 位甚至高精度整数。
3.3 各语言整数类型选型怎么选
知道了位宽,选型就变得有据可依。
C/C++ 中,int 在多数现代平台上是 32 位,long 在 Linux 64 位环境下是 64 位,但在 Windows 上是 32 位,这个平台差异非常坑。为了可移植,推荐直接使用 <cstdint> 里的 int32_t、int64_t、uint64_t,明确定义位宽,避免歧义。
Java 比较老实,int 永远是 32 位有符号,long 永远是 64 位有符号,没有平台差异。Python 则完全不同,它的 int 是任意精度大整数,理论上可以无限扩展,所以 Python 里基本不用担心溢出,但相应地,运算性能比原生 64 位整数差一个数量级。
MySQL 数据库里,常见整数类型如下:
| 类型 | 存储大小 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| 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 就别用 BIGINT,4 字节和 8 字节在千万行数据上能差出几十 MB 的索引体积;但如果预估峰值可能超过 21 亿,直接上 BIGINT,别卡着边界用 INT UNSIGNED,因为一旦有符号负数和无符号正数混用,SQL 中比较会出很多幺蛾子。
Bash 脚本里,整数操作本身不区分类型,所有整数默认按 64 位有符号处理。你可以用 declare -i 声明一个整数变量,这样赋值时如果传入非法数字,Bash 会报错而不是静默处理:
bash复制declare -i num=10
num=$((num + 5))
echo $num # 输出 15
Julia 在数值类型上做得很精细,原生区分 Int8、Int16、Int32、Int64,还有高精度的 BigInt。强调性能时用 Int64,做精确数学计算或数论时直接上 BigInt,在类型设计上比 Python 更显严谨。
4. 算法题和工程实战中的整数陷阱拆解
4.1 填坑问题:为什么必须用 long long 而不是 int
看一道典型的 C++ 题目:给定 n 个数,对每个数 a[i],如果它比前面所有数中的某个数小,则把 a[i] 和 m 同时加 1。初始 m = 0,问最终 m 的值。
第一次看到这种题,很容易直接写:
cpp复制#include <iostream>
using namespace std;
int main() {
int n;
cin >> n;
int m = 0;
int max_val = -1e9;
for (int i = 0; i < n; i++) {
int a;
cin >> a;
if (a < max_val) {
m++;
}
max_val = max(max_val, a);
}
cout << m << endl;
return 0;
}
这里有个边界误区。题目是本意是“当前位置低于前面所有位置,就把当前位置填高到前面所有位置没有比它高”,从维护最大值角度讲,if (a[i] < max_val) 这个判断是对的。但如果数据范围不告诉你说 a[i] 绝对值在 int 范围内,m 在极端情况下可能累加到 n 的规模,n 如果达到 10^7,m 也会达到 10^7,int 完全够用。可是如果 n 是 10^9,m 就会超过 21 亿吗?不会,但 int max_val = -1e9 在 a[i] 全为正数时是安全的,一旦 a[i] 本身可以达到 -10^9 且 n 很大,这个初始化可能不够用。
这里我想强调一个更隐蔽的坑:if (a < max_val) 判断条件并不是永远正确。因为题目描述有一种“后面某个数比前面所有数中的某个数小”的歧义,但结合爬山场景,正确含义是“当前位置比前面所有位置的最大值还要低时”,才需要填高。所以维护前缀最大值就够了。真正需要小心的是 m 用 int 还是 long long,最好直接声明为 long long,避免在 n 达到 10^5、10^6 时因为某次数据构造导致计数过多。
这是我多次打竞赛养成的习惯:只要题目出现“n 个整数”且 n 没有明确限定在 10^5 以内,就默认所有中间变量用 long long。多浪费 4 个字节,换来的是少排查一个溢出 bug,非常划算。
4.2 从 1 到 n 中出现数字“1”的个数
题目:给定十进制正整数 n,写下从 1 到 n 的所有整数,数一下其中出现的数字“1”的个数。
如果你第一时间想到的是暴力枚举,那么当 n = 10^9 时,直接循环 10 亿次大概率会超时。但很多初学者忽略的另一件事是:即使允许暴力,也需要用一个足够宽的整数类型存储答案。数字“1”出现的次数上限有多大?n 为 10 位数时,每个数平均含有约 1 个“1”,总计数在 10^10 量级,已经超出 32 位有符号范围。
所以这个题的正确打开方式是数位 DP 或者按位统计。按位统计的思路是:对每一位,分别统计它在个位、十位、百位…… 出现过的 1 的个数。例如,数字 abcde,统计百位上 1 出现的次数时,把数分成高位 ab、当前位 c、低位 de。如果 c == 0,百位贡献 ab * 100;如果 c == 1,贡献 ab * 100 + de + 1;如果 c > 1,贡献 (ab + 1) * 100。
这种按位统计不需要遍历 1 到 n 的所有整数,时间复杂度 O(位数),完全不受溢出影响。但要注意,统计过程要用的中间变量是“当前位左边的数字乘上当前位的基数”,同样可能非常大,比如 n 是 9999999999,这个值就会接近 10 位数,还是建议全部用 long long 或 Python 的 int。
4.3 求 n 个整数的最小公倍数:先除后乘防溢出
在 C++ 里求 n 个数的最小公倍数,常规做法是两两求:
cpp复制long long lcm(long long a, long long b) {
return a / gcd(a, b) * b;
}
初学者很容易写成 return a * b / gcd(a, b);,这个顺序在 a 和 b 都是 10^9 量级时会直接溢出,因为 a * b 达到 10^18,刚好逼近 64 位有符号整数的上限 9.22 * 10^18,运气好不溢出,但如果 a 和 b 再大一点点,结果就错了。
正确写法是先除后乘,a / gcd(a, b) * b,这样中间结果控制在最小公倍数范围内,不会出现中间量爆炸。如果你已经知道这些数可能非常大,连 64 位都装不下,那就应该直接用 __int128(GCC 扩展)或者切到 Python,避开整数溢出的整个话题。
4.4 输入一个整数打印字符图形:边界与输入校验
题目:输入一个整数 n(0 < n < 10),打印对应的字符图形。
这种题看起来毫无难度,但它考验的是对输入边界的严谨度。一个常见错误是,题目只说了 0 < n < 10,但测试数据里可能混入 n <= 0 或 n >= 10 的非法输入。如果不对 n 做校验,直接用它作为循环上界,轻则打印出错误图形,重则数组越界。
稳妥的做法是在读取 n 后立刻做一次判断:
cpp复制int n;
cin >> n;
if (n <= 0 || n >= 10) {
return 0;
}
还有一点值得注意:既然是字符图形,它的行数、列数都受 n 控制,n 本来就只有个位数,所以用 int 足够。这是少数几个不需要考虑 64 位溢出的场景,但该做的输入约束校验一步也不能省。
4.5 奶牛排数轴:排序场景下的整数范围与坐标偏移
再来看这个场景:n 头奶牛在一条数轴的不同整数位置上,通过移动,让它们……
这种“数轴上移动”的问题,通常有两种解法。一种是直接排序后,比较相邻元素的差值,本质是整数排序。另一种是二分答案,把位置转换成距离判断。
这里真正需要留意的坑是坐标偏移。如果牛的位置可以是负数,比如 -10^9 到 10^9,直接用 int 存储位置没问题,但计算两点距离时,如果用 a - b 会得到负数,此时如果用了无符号整数 uint32_t 来存距离,瞬间就会溢出成一个巨大的正数,导致排序结果全乱。所以,线段距离计算务必使用有符号 64 位整数,并且用 llabs(a - b) 或 long long 中间变量来承接。
4.6 整数排序与计数排序的边界
“整数排序”这个话题在算法题里也常见。常规的快速排序、归并排序都支持随机访问和比较,时间复杂度 O(n log n)。但当整数范围非常有限时,比如所有数都在 0 到 1000 之间,计数排序可以做到 O(n + k),而且不需要大量额外空间。
只是有个前提:计数数组的下标不能是负数。如果输入包含负整数,就需要先把整体偏移到非负区间。例如数值范围是 -500 到 500,就先把所有数加上 500,得到 0 到 1000 的映射,再开一个 1001 大小的数组计数。这个偏移量本身如果超过 21 亿,就必须用 64 位整型,否则偏移计算也会溢出。
5. 避坑清单与调试整数问题的方法论
5.1 一张表速查核心概念
| 概念 | 表示 | 8 位示例 | 核心要点 |
|---|---|---|---|
| 无符号整数 | 0 ~ 255 | 1111 1111 = 255 |
全部位表示大小 |
| 原码 | 符号位 + 绝对值 | 1000 0001 = -1 |
0 有两种表示,不适合运算 |
| 反码 | 正同原码,负取反 | 1111 1110 = -1 |
0 仍有两种表示 |
| 补码 | 正同原码,负取反加 1 | 1111 1111 = -1 |
0 唯一,加减统一 |
| 有符号整数 | 范围不对称 | 1000 0000 = -128 |
负数比正数多一个 |
5.2 调试整数问题的 3 个实战技巧
第一,用十六进制观察内存。当你在 gdb 里打 p/x variable,变量会显示成十六进制。比如看到一个 64 位长整型变量显示为 0xffffffffffffffff,你马上要知道这是 -1 的补码,而不是一个巨大的正数。如果它显示为 0x7fffffffffffffff,那就是 LLONG_MAX,离溢出只剩一步。
第二,开启编译器警告与 sanitizer。GCC/Clang 支持 -Wall -Wextra -Wconversion -Wsign-conversion,能在编译阶段就捕捉很多符号转换和位宽转换的隐患。运行时用 -fsanitize=undefined 可以自动检测 signed integer overflow,一旦溢出它会直接报错并指出变量名和代码行号,这对排查隐藏 bug 太有用了。
第三,构造边界测试数据。凡是写整数运算的代码,一定要专门测 INT_MAX、INT_MIN、0、-1 这四个值。比如你写了一个把字节数组拼成整数的函数,能正确处理 INT_MIN 的补码边界,基本就不会漏了。
我个人在踩过无数次溢出的坑后,养成了一个习惯:在定义变量时,先问自己“这个数有可能超过 21 亿吗?”如果答案不确定,那就直接定义成 long long。虽然有些场景下确实浪费内存,但用一次内存换一次熬夜排查 Bug 的时间,成本实在低太多了。另外,在写无符号数和有符号数的比较时,强制显式转换,绝对不要相信隐式转换,尤其是在数组下标和循环变量混用时,这是无数隐蔽 bug 的温床。
