好久不见,不知道还有没有人记得我,我已经快两年没更新了,这两年内生活步入了迷茫和焦虑,因此断更,现在重拾博客,准备秋招或者实习的小伙伴可以关注我的这篇文章(每日更新),主要写秋招和实习时的小知识点复习,小伙伴们可休闲时看,小点补充小知识点,希望我们都能找到自己想要的工作
目录
-
[第一部分:C++ 语言基础](#第一部分:C++ 语言基础)
-
[C++ 常见类型大小与内存布局](#C++ 常见类型大小与内存布局)
-
[C++ const:限定位置、函数与重载](#C++ const:限定位置、函数与重载)
-
[C++ 引用与值类别](#C++ 引用与值类别)
-
[C++ 前置自增与后置自增](#C++ 前置自增与后置自增)
-
-
- [C++ 手搓常见数据结构](#C++ 手搓常见数据结构)
-
-
[Codex Skill、YAML 与 agents 目录](#Codex Skill、YAML 与 agents 目录)
-
[Skill 需求编辑器](#Skill 需求编辑器)
-
第一部分:C++ 语言基础
C++ 常见类型大小与内存布局
本节目录
C++中各种常用变量所占空间 ,包括常见的自定义类型,分为64位下和32位下,还和我解释一下x86是什么,以及linux和windows
一、总结:快速复习
本节目录
-
[1. 常见类型大小](#1. 常见类型大小)
-
[2. 最容易考的几个](#2. 最容易考的几个)
-
[3. 自定义类型](#3. 自定义类型)
-
[4. 64 位到底是什么?](#4. 64 位到底是什么?)
先记住一个最重要的结论:
C++ 基本类型占多少字节,不是单纯由"32 位/64 位"决定,而是由 C++ 标准、编译器、ABI、操作系统平台共同决定。
1. 常见类型大小
下面是最常见的 Windows/Linux、x86/x86-64 平台情况:
| 类型 | 32 位常见 | 64 位常见 | 备注 |
|---|---|---|---|
char |
1 B | 1 B | 标准保证 1 字节 |
bool |
1 B | 1 B | 通常如此 |
short |
2 B | 2 B | |
int |
4 B | 4 B | 32/64 位通常都不变 |
long |
4 B | Windows 4 B / Linux 8 B | 重点区别 |
long long |
8 B | 8 B | |
float |
4 B | 4 B | |
double |
8 B | 8 B | |
指针 T* |
4 B | 8 B | 重点 |
引用 T& |
通常与指针相关 | 通常与指针相关 | 标准不规定必须占多少 |
size_t |
4 B | 8 B | 与地址空间相关 |
2. 最容易考的几个
64 位下:
char 1
short 2
int 4
long long 8
float 4
double 8
指针 8
size_t 8
但是:
Windows 64 位:
long仍然是 4 字节。
而:
Linux 64 位:
long通常是 8 字节。
这是面试非常喜欢问的坑。
3. 自定义类型
struct A {
int a;
char b;
};
不一定是 5 字节。
由于内存对齐(padding),常见情况下:
int = 4
char = 1
padding = 3
sizeof(A) = 8
所以:
结构体大小 = 成员大小 + 内存对齐产生的 padding
4. 64 位到底是什么?
简单理解:
64 位主要指 CPU 的寄存器、指令集/地址空间等体系能够以 64 位宽度进行处理。
所以:
64 位 ≠ 所有变量都变成 8 字节。
二、详细讲解:深入理解
本节目录
-
[1. 首先搞清楚:什么叫"32位"和"64位"?](#1. 首先搞清楚:什么叫“32位”和“64位”?)
-
[2. x86 到底是什么?](#2. x86 到底是什么?)
-
[3. 那 Windows 和 Linux 又是什么?](#3. 那 Windows 和 Linux 又是什么?)
-
[4. 一个非常重要的关系](#4. 一个非常重要的关系)
-
[5. 为什么 Windows 64 位的 long 还是 4 字节?](#5. 为什么 Windows 64 位的 long 还是 4 字节?)
-
[6. 为什么 C++ 不规定 int 一定是4字节?](#6. 为什么 C++ 不规定 int 一定是4字节?)
-
[7. 为什么指针是8字节?](#7. 为什么指针是8字节?)
-
[8. 自定义结构体为什么不是简单相加?](#8. 自定义结构体为什么不是简单相加?)
-
[9. 什么是内存对齐?](#9. 什么是内存对齐?)
-
[10. 调整成员顺序可能改变结构体大小](#10. 调整成员顺序可能改变结构体大小)
-
[11. 常见自定义类型还要注意什么?](#11. 常见自定义类型还要注意什么?)
-
[12. 数组大小怎么算?](#12. 数组大小怎么算?)
-
[13. std::string、std::vector 等常见自定义/库类型](#13. std::string、std::vector 等常见自定义/库类型)
-
[14. sizeof 才是判断实际大小最可靠的方法](#14. sizeof 才是判断实际大小最可靠的方法)
-
[15. 最后把几个概念串起来](#15. 最后把几个概念串起来)
1. 首先搞清楚:什么叫"32位"和"64位"?
你可以把 CPU 想象成一个工作人员。
他手里的"工作台"宽度不同:
32位 CPU:
┌────────────────────────────────┐
│ 32 bit │
└────────────────────────────────┘
64位 CPU:
┌────────────────────────────────────────────────┐
│ 64 bit │
└────────────────────────────────────────────────┘
64 位 CPU 可以更自然地处理 64 位宽的数据、寄存器值和地址。
尤其重要的是:
指针/地址通常跟 CPU 的地址空间和 ABI 有直接关系。
例如:
int x = 10;
int* p = &x;
p 保存的是:
x 所在内存的地址
典型情况下:
32位:
地址 → 32 bit → 4 Byte
64位:
地址 → 64 bit → 8 Byte
所以:
sizeof(int*)
通常:
32位 = 4
64位 = 8
2. x86 到底是什么?
本节目录
- [x86 是什么?](#x86 是什么?)
这是非常容易混淆的地方。
很多人会把:
x86
x64
32位
64位
Windows
Linux
混在一起。
实际上它们不是一个维度的东西。
x86 是什么?
x86 最初指 Intel 8086 系列发展出来的一套 CPU 指令集架构(ISA)家族。
你经常会看到:
8086
80286
80386
80486
因为这些 CPU 名字都以:
86
结尾。
后来大家把这一系列统称为:
x86
其中:
x86
历史上主要指这一系列 32 位 IA-32 架构时非常常见。
而后来出现了:
x86-64
也叫:
x64
AMD64
Intel 64
它是在 x86 基础上扩展出来的 64 位架构。
所以你可以先这样记:
x86
↓
经典 32 位
x86-64 / x64
↓
64 位
3. 那 Windows 和 Linux 又是什么?
本节目录
这是另一个维度。
Windows
是:
操作系统
Linux
也是:
操作系统内核
所以:
CPU架构
↓
x86 / x86-64 / ARM64
↓
操作系统
↓
Windows / Linux
↓
编译器
↓
C++程序
它们不是同一类东西。
4. 一个非常重要的关系
例如你的电脑可能是:
CPU架构:
x86-64
操作系统:
Windows 11 64位
编译器:
MinGW-w64 GCC
C++程序:
你的程序
也可能是:
CPU架构:
x86-64
操作系统:
Linux 64位
编译器:
GCC
C++程序:
你的程序
CPU 都可能是 x86-64,但最终 C++ 类型大小仍然可能存在差异。
最经典的就是:
sizeof(long)
5. 为什么 Windows 64 位的 long 还是 4 字节?
本节目录
这是理解 C++ 类型大小最重要的地方之一。
很多人会想:
64 位 →
long→ 8 字节?
这是错的。
Windows 64 位采用常见的:
LLP64 数据模型
而 Linux 64 位常见的是:
LP64 数据模型
看表:
| 类型 | Windows 64 位(LLP64) | Linux 64 位(LP64) |
|---|---|---|
char |
1 | 1 |
short |
2 | 2 |
int |
4 | 4 |
long |
4 | 8 |
long long |
8 | 8 |
| 指针 | 8 | 8 |
这个表非常值得记。
Windows:
long = 4
pointer = 8
Linux:
long = 8
pointer = 8
因此你以后面试遇到:
"64 位 Linux 下 long 是多少?"
答:
通常是 8 字节。
如果问:
"64 位 Windows 下 long 是多少?"
答:
通常是 4 字节。
6. 为什么 C++ 不规定 int 一定是4字节?
这里要注意:
C++ 标准规定的是最小范围和相对大小关系,并没有简单地规定所有平台必须使用某一个固定字节数。
例如:
sizeof(char) <= sizeof(short)
<= sizeof(int)
<= sizeof(long)
<= sizeof(long long)
并且:
sizeof(char) == 1
这里的 1 是:
一个 C++ byte
而 C++ byte 通常是 8 bit,但标准层面由 CHAR_BIT 决定,不应简单把"byte"和"8 bit"永远画等号。
实际现代 PC 上通常:
1 Byte = 8 bit
7. 为什么指针是8字节?
假设内存地址:
0x00007FF612345678
指针需要保存这个地址。
64 位系统通常使用 64 位宽的地址表示,因此典型情况下:
int* p;
double* q;
char* r;
都是:
8 字节
注意:
int*
double*
char*
虽然指向的数据大小不同:
int 4
double 8
char 1
但是:
int* 8
double* 8
char* 8
因为它们本质上都是:
保存地址。
8. 自定义结构体为什么不是简单相加?
这是 C++ 面试非常高频的问题。
例如:
struct A {
char a;
int b;
};
很多人会算:
char = 1
int = 4
1 + 4 = 5
然后回答:
sizeof(A) == 5
通常是错的。
因为存在:
内存对齐(Memory Alignment)
9. 什么是内存对齐?
假设 int 要求按照 4 字节边界对齐。
那么:
地址:
1000
1001
1002
1003
这组比较适合放一个 int。
如果:
char a;
int b;
直接放:
a b
↓ ↓
1000 1001
那么 b 从1001开始,不满足常见的4字节对齐。
于是编译器会填充:
地址
1000 char a
1001 padding
1002 padding
1003 padding
1004 int b
1005 int b
1006 int b
1007 int b
最终:
sizeof(A) = 8
10. 调整成员顺序可能改变结构体大小
例如:
struct A {
char a;
int b;
char c;
};
典型情况下:
a 1
padding 3
b 4
c 1
padding 3
最终:
sizeof(A) = 12
但是:
struct B {
int b;
char a;
char c;
};
可能变成:
b 4
a 1
c 1
padding 2
最终:
sizeof(B) = 8
所以:
结构体成员排列顺序会影响内存占用。
这在面试中非常常见。
11. 常见自定义类型还要注意什么?
本节目录
空类
class A {
};
很多人以为:
0字节
实际上:
sizeof(A)
通常是:
1
因为不同对象必须具有可区分的地址。
只有一个 int
struct A {
int x;
};
通常:
sizeof(A) = 4
两个 int
struct A {
int x;
int y;
};
通常:
4 + 4 = 8
含指针
struct A {
int x;
int* p;
};
典型 64 位环境:
int 4
padding 4
pointer 8
----------------
16
所以:
sizeof(A) = 16
12. 数组大小怎么算?
这个非常简单:
int a[10];
典型情况下:
10 × 4 = 40 Byte
而:
double a[10];
就是:
10 × 8 = 80 Byte
二维数组:
int a[3][4];
就是:
3 × 4 × 4
= 48 Byte
因为二维数组在内存中仍然是连续存储的。
13. std::string、std::vector 等常见自定义/库类型
本节目录
这里必须特别注意:
它们不是固定标准大小。
例如:
std::vector<int> v;
vector 对象本身通常包含类似:
begin
end
capacity_end
也就是几个指针。
64 位下,一个指针通常8字节。
所以很多实现中:
sizeof(vector<int>) ≈ 24 Byte
但这是:
常见实现,不是 C++ 标准保证值。
std::string
同样不能简单说:
string = XX字节
因为不同:
- 编译器
- 标准库实现
- ABI
- 平台
都可能不同。
而且现代 std::string 通常还有:
SSO(Small String Optimization,小字符串优化)
短字符串甚至可能直接放在 string 对象内部,而不是单独申请堆内存。
所以面试中不要说:
"
std::string永远占24字节。"
应该说:
具体大小取决于标准库实现,64位常见实现中对象本身通常是若干个指针/字段的组合,例如某些实现为24字节;此外还可能使用SSO。
14. sizeof 才是判断实际大小最可靠的方法
如果你在自己的电脑上想知道:
char
short
int
long
long long
float
double
void*
size_t
到底是多少,直接:
#include <iostream>
#include <cstddef>
int main()
{
std::cout << "char: " << sizeof(char) << '\n';
std::cout << "short: " << sizeof(short) << '\n';
std::cout << "int: " << sizeof(int) << '\n';
std::cout << "long: " << sizeof(long) << '\n';
std::cout << "long long: " << sizeof(long long) << '\n';
std::cout << "float: " << sizeof(float) << '\n';
std::cout << "double: " << sizeof(double) << '\n';
std::cout << "void*: " << sizeof(void*) << '\n';
std::cout << "size_t: " << sizeof(std::size_t) << '\n';
}
这比死记某个平台的结果更加可靠。
15. 最后把几个概念串起来
你可以建立这样一张关系图:
CPU架构
│
┌─────────┴─────────┐
│ │
x86 x86-64
常见32位 常见64位
│ │
└─────────┬─────────┘
↓
操作系统
┌─────┴─────┐
│ │
Windows Linux
│ │
ABI ABI
│ │
└─────┬─────┘
↓
编译器
GCC / MSVC
↓
C++程序
↓
sizeof / 内存布局
所以你以后看到:
Windows 64位
不要直接理解成:
所有东西 ×2。
正确理解是:
64位 CPU/平台
↓
指针通常 8B
↓
但 C++ 基本类型大小仍由平台 ABI 等决定
↓
Windows 使用 LLP64
↓
long = 4B
而:
Linux 64位
↓
通常使用 LP64
↓
long = 8B
面试高频题:你现在最应该记住的
本节目录
-
[① 64位系统下 int 是多少?](#① 64位系统下 int 是多少?)
-
[② 64位系统下指针是多少?](#② 64位系统下指针是多少?)
-
[③ 64位 Windows 的 long 是多少?](#③ 64位 Windows 的 long 是多少?)
-
[④ 64位 Linux 的 long 是多少?](#④ 64位 Linux 的 long 是多少?)
-
[⑤ 为什么结构体大小不是成员大小简单相加?](#⑤ 为什么结构体大小不是成员大小简单相加?)
-
[⑥ sizeof(char) 为什么是1?](#⑥ sizeof(char) 为什么是1?)
-
[⑦ x86 和 Linux 是一回事吗?](#⑦ x86 和 Linux 是一回事吗?)
-
[⑧ std::vector 是不是固定24字节?](#⑧ std::vector 是不是固定24字节?)
① 64位系统下 int 是多少?
通常4字节。
② 64位系统下指针是多少?
典型64位平台通常8字节。
③ 64位 Windows 的 long 是多少?
4字节。
④ 64位 Linux 的 long 是多少?
通常8字节。
⑤ 为什么结构体大小不是成员大小简单相加?
因为存在内存对齐和 padding。
⑥ sizeof(char) 为什么是1?
因为 C++ 标准规定 sizeof(char) == 1;这里的1表示一个 C++ byte,现代主流平台通常一个 byte 是8 bit。
⑦ x86 和 Linux 是一回事吗?
不是。
x86 / x86-64 → CPU指令集架构
Linux → 操作系统内核
Windows → 操作系统
⑧ std::vector 是不是固定24字节?
本节目录
不是标准保证。 某些64位标准库实现中常见为24字节,但具体取决于实现。
一句话形成整个知识体系
x86/x64 描述的是 CPU 指令集架构,Windows/Linux 描述的是操作系统;C++ 类型大小最终受到编译器和 ABI 等平台约定影响。64 位最直接的变化通常是指针从4字节变成8字节,而不是所有类型都变成8字节;其中 Windows 64位采用 LLP64,Linux 64位通常采用 LP64,所以
long是最经典的区别。
C++ const:限定位置、函数与重载
本节目录
-
[const 掌握情况自测题](#const 掌握情况自测题)
一、总结:快速复习
一句话:
const修饰它左边的类型;如果左边没有内容,就修饰右边的类型。读复杂定义时,通常从变量名开始向两边读。
| 定义 | 含义 | 能否改数据 | 能否改指向 |
|---|---|---|---|
const int a |
常量整数 | 否 | 不涉及 |
int const a |
常量整数,和上一种完全相同 | 否 | 不涉及 |
const int* p |
指向常量整数的指针 | 不能通过 p 修改 |
可以 |
int const* p |
同上 | 不能通过 p 修改 |
可以 |
int* const p |
常量指针,指向普通整数 | 可以 | 不可以 |
const int* const p |
指向常量整数的常量指针 | 不可以 | 不可以 |
最重要的记忆:
cpp
const int* p; // const 靠近 int:int 不能通过 p 修改
int* const p = &x; // const 靠近 p:p 自己不能改指向
const int与int const等价。- 底层 const: 指向的数据不能通过当前表达式修改,如
const int*。 - 顶层 const: 变量自身不能修改,如
int* const。 - 引用本身不能重新绑定,所以没有
int& const;只需要写const int&。 - 成员函数末尾的
const修饰隐含的this,表示该函数通常不能修改对象状态。
二、详细讲解:深入理解
本节目录
-
[1. 普通变量中的 const](#1. 普通变量中的 const)
-
[2. const int* p:指向常量的指针](#2. const int* p:指向常量的指针)
-
[3. int* const p:常量指针](#3. int* const p:常量指针)
-
[4. const int* const p:两边都不能改](#4. const int* const p:两边都不能改)
-
[5. 从变量名向两边读复杂定义](#5. 从变量名向两边读复杂定义)
-
[6. 引用中的 const](#6. 引用中的 const)
-
[7. 函数参数中的 const](#7. 函数参数中的 const)
-
[8. 成员函数末尾的 const](#8. 成员函数末尾的 const)
-
[9. 返回值中的 const](#9. 返回值中的 const)
-
[10. 顶层 const 与底层 const](#10. 顶层 const 与底层 const)
1. 普通变量中的 const
下面两种写法完全相同:
cpp
const int a = 10;
int const b = 20;
a 和 b 都必须初始化,初始化后不能再赋值:
cpp
// a = 30; // 错误:a 是只读对象
// b = 40; // 错误:b 是只读对象
之所以两种顺序都合法,是因为 const 可以写在基本类型的左边或右边:
text
const int
int const
它们都表示"具有 const 限定的 int"。
2. const int* p:指向常量的指针
cpp
int x = 10;
int y = 20;
const int* p = &x;
// *p = 30; // 错误:不能通过 p 修改 x
p = &y; // 正确:p 自己仍然可以改变指向
这里不能通过 p 修改所指对象,但不代表 x 本身一定是常量:
cpp
x = 30; // 正确:x 原本就是普通 int
下面两种写法完全等价:
cpp
const int* p1 = &x;
int const* p2 = &x;
可以把普通对象的地址交给"指向常量的指针",因为这只是限制当前指针的修改权限:
cpp
int x = 10;
const int* p = &x; // 正确
3. int* const p:常量指针
cpp
int x = 10;
int y = 20;
int* const p = &x; // 常量指针必须在定义时初始化
*p = 30; // 正确:所指的 x 可以修改
// p = &y; // 错误:p 自己不能改变指向
这里 const 修饰的是指针变量 p。可以把它理解为:
text
p 中保存的地址不能改变,但该地址中的 int 可以改变。
4. const int* const p:两边都不能改
cpp
int x = 10;
int y = 20;
const int* const p = &x;
// *p = 30; // 错误:不能通过 p 修改数据
// p = &y; // 错误:p 也不能改变指向
两个 const 分工不同:
text
左边的 const:限制所指的 int
右边的 const:限制指针 p 本身
5. 从变量名向两边读复杂定义
以这三个定义为例:
cpp
const int* p1;
int* const p2 = nullptr;
const int* const p3 = nullptr;
从变量名开始读:
text
p1 是一个指针,指向 const int
p2 是一个 const 指针,指向 int
p3 是一个 const 指针,指向 const int
也可以使用简单口诀:
const在*左边,限制指向的数据;const在*右边,限制指针自身。
6. 引用中的 const
cpp
int x = 10;
const int& ref = x;
// ref = 20; // 错误:不能通过 ref 修改 x
x = 20; // 正确:x 本身不是常量
const int& 表示"对常量整数的引用",常用于避免复制,同时防止函数修改实参:
cpp
void print(const std::string& text)
{
// 通过引用传递,避免复制;const 保证函数不修改 text
std::cout << text;
}
引用一旦初始化就不能重新绑定,因此引用本身天然具有类似"不能改指向"的性质。下面的写法没有意义,也不合法:
cpp
// int& const ref = x; // 错误:引用不能再使用顶层 const
const 引用还能绑定临时对象:
cpp
const std::string& ref = std::string("hello");
临时对象的生命周期会延长到 ref 的生命周期结束。
7. 函数参数中的 const
本节目录
值传递参数
cpp
void func(const int x)
{
// x = 10; // 错误:函数内部不能修改形参副本
}
这里的 const 只限制函数内部的局部副本,不影响调用者的变量。对外部函数类型而言,下面两个声明等价:
cpp
void func(int x);
void func(const int x);
不能仅靠值参数的顶层 const 重载函数。
指针或引用参数
cpp
void read(const int* p); // 不能通过 p 修改所指数据
void show(const int& x); // 不能通过 x 修改实参
这里的 const 会影响调用者对象是否允许被函数修改,因此比值参数上的顶层 const 更重要。
8. 成员函数末尾的 const
本节目录
-
[8.1 末尾的 const 修饰谁?](#8.1 末尾的 const 修饰谁?)
-
[8.2 为什么末尾的 const 可以参与重载?](#8.2 为什么末尾的 const 可以参与重载?)
-
[8.3 调用时选择哪个版本?](#8.3 调用时选择哪个版本?)
-
[8.4 为什么返回值也分别写成 int& 和 const int&?](#8.4 为什么返回值也分别写成 int& 和 const int&?)
-
[8.5 一步判断调用结果](#8.5 一步判断调用结果)
-
[8.6 C++ 函数构成重载的条件](#8.6 C++ 函数构成重载的条件)
cpp
class Student {
public:
int getAge() const
{
// age = 20; // 错误:const 成员函数不能修改普通成员
return age;
}
private:
int age = 18;
};
函数末尾的 const 不是修饰返回值,而是修饰隐含的 this 指针。可以近似理解为:
cpp
// 普通成员函数中的 this 类似:Student* const this
// const 成员函数中的 this 类似:const Student* const this
因此:
const对象只能调用const成员函数;- 普通对象既能调用普通成员函数,也能调用
const成员函数; const成员函数不能修改普通数据成员;- 被
mutable修饰的成员可以在const成员函数中修改。
cpp
class Counter {
public:
int get() const
{
++visitCount; // 正确:mutable 成员不受此处 const 限制
return value;
}
private:
int value = 0;
mutable int visitCount = 0;
};
成员函数可以根据末尾是否有 const 构成重载:
cpp
class Data {
public:
int& get();
const int& get() const;
};
这两个 get() 的显式参数列表虽然都是空的,但它们对当前对象的要求不同,因此可以构成重载:
cpp
int& get(); // 非 const 成员函数
const int& get() const; // const 成员函数
8.1 末尾的 const 修饰谁?
成员函数末尾的 const 修饰的是当前对象,也就是限制隐式传入的 this 指针。
可以近似理解为:
cpp
int& get(); // this 近似为 Data* const
const int& get() const; // this 近似为 const Data* const
这里两个 const 的含义分别是:
Data* const:this自身不能改为指向另一个对象;const Data*:不能通过this修改当前对象的普通数据成员。
需要注意,源码中不能显式写出或重新声明 this 参数。上面的写法只是为了帮助理解底层语义。
8.2 为什么末尾的 const 可以参与重载?
可以把它直观地理解成:末尾的 const 类似于改变了一个隐藏参数,因此两个成员函数能够构成重载。
不过,更准确地说,它改变的不是括号里的普通参数,而是成员函数的隐式对象参数,也就是调用该函数的当前对象。
普通成员函数调用时,当前对象会作为隐含参数传入。虽然源码写的是:
cpp
data.get();
但理解重载选择时,可以近似看成:
cpp
get(&data);
两个版本对这个隐含对象参数的要求不同:
cpp
int& get(); // 接收非 const 的当前对象
const int& get() const; // 接收 const 的当前对象,也能用于非 const 对象
为了帮助理解,可以把它们非常粗略地类比成下面两个普通函数:
cpp
int& get(Data* self); // 对应末尾不带 const
const int& get(const Data* self); // 对应末尾带 const
这两个普通函数的参数分别是 Data* 和 const Data*,参数类型不同,自然可以重载。成员函数末尾的 const 所产生的区别与此类似。
这个写法只是理解模型,并不是编译器真的把成员函数改写成了上述源码;实际规则使用的是隐式对象参数和成员函数类型。
因此,末尾有没有 const 会改变成员函数的类型。这不是只靠返回类型进行重载,所以是合法的。
普通函数不能仅靠返回类型不同构成重载:
cpp
int getValue();
double getValue(); // 错误:调用时无法仅根据返回类型确定选择哪个函数
而上述两个成员函数的区别来自隐含对象参数的 const 属性,并非来自 int& 与 const int& 两种返回类型。
所以,即使两个版本的返回类型完全相同,也仍然可以构成重载:
cpp
class Data {
public:
int& get(); // 非 const 成员函数
int& get() const; // const 成员函数:与上一行可以构成重载
};
是否构成重载只看成员函数末尾的 const 所带来的隐式对象参数差异,与这里是否使用相同返回类型无关。
但是,能构成重载 不代表每个函数体都能按预期实现。假设 value 是普通的非静态数据成员:
cpp
class Data {
public:
int& get()
{
return value; // 正确:非 const 函数可以返回普通成员的 int&
}
int& get() const
{
return value; // 编译错误:const 函数中,value 被当作 const int 访问
}
private:
int value = 10;
};
第二个函数中,this 近似为 const Data* const,所以通过它访问普通成员 value 得到的是只读访问结果,不能直接绑定到 int&。通常应写成:
cpp
const int& get() const
{
return value;
}
少数情况下,const 成员函数仍然可以合法返回 int&,例如返回的对象并不受当前 Data 对象的常量性约束:
cpp
int globalValue = 10;
class Data {
public:
int& get() const
{
return globalValue; // 可以:返回的是外部非 const 对象
}
};
因此需要分开判断:
text
能否构成重载
→ 看参数列表、隐式对象参数及成员函数限定符
函数体能否编译
→ 看返回语句是否符合返回类型、const 约束等规则
8.3 调用时选择哪个版本?
完整示例:
cpp
class Data {
public:
int& get()
{
return value;
}
const int& get() const
{
return value;
}
private:
int value = 10;
};
Data data;
const Data constData;
data.get() = 20; // 调用非 const 版本,返回 int&,可以修改 data.value
int x = data.get(); // 仍优先调用非 const 版本,只读取返回值也不影响重载选择
int y = constData.get(); // 只能调用 const 版本
// constData.get() = 30;
// 错误:const 版本返回 const int&,调用者不能通过它修改 value
选择规则可以简单记为:
| 调用对象 | 非 const 版本 |
const 版本 |
最终选择 |
|---|---|---|---|
Data 普通对象 |
可以调用 | 也可以调用 | 优先选择非 const 版本 |
const Data 常量对象 |
不可以调用 | 可以调用 | 只能选择 const 版本 |
常量对象不能调用非 const 成员函数,是因为编译器不能保证非 const 成员函数不会修改对象。即使某个非 const 函数的函数体实际上没有修改成员,这条限制仍然存在。
8.4 为什么返回值也分别写成 int& 和 const int&?
返回类型不同不是构成这组重载的原因,但它能保持对象的 const 约束:
cpp
int& get(); // 普通对象允许通过返回的引用修改成员
const int& get() const; // const 对象只提供只读引用
如果 const 版本错误地返回普通 int&,调用者就可能通过返回值间接修改常量对象:
cpp
class Data {
public:
int& get() const; // 通常不合理,并且直接 return value 一般不能通过类型检查
private:
int value = 10;
};
在 const 成员函数中,成员 value 会被当作只读对象访问,因此 return value; 得到的引用不能直接绑定到 int&,只能绑定到 const int&。
这种设计常见于容器和字符串:普通对象访问元素时可以获得可修改引用,常量对象访问元素时只能获得只读引用。
8.5 一步判断调用结果
看到这类代码时,可以按以下顺序判断:
text
先看调用者是不是 const 对象
↓
const 对象:只能调用末尾带 const 的版本
普通对象:优先调用末尾不带 const 的版本
↓
再看返回类型是否允许修改返回的对象
一句话回答面试官:
成员函数末尾的
const会改变隐含this参数的类型,所以带const和不带const的成员函数可以构成重载。普通对象优先调用非const版本,常量对象只能调用const版本;返回值分别使用T&和const T&,是为了保持对象的常量性,避免通过返回引用间接修改常量对象。
8.6 C++ 函数构成重载的条件
本节目录
-
[先理解:顶层 const 和底层 const](#先理解:顶层 const 和底层 const)
一、总结:快速复习
函数重载是指:同一作用域内,函数名相同,但编译器能够根据参数或成员函数限定符区分出不同函数。
普通函数最常见的重载条件是参数列表至少有一项不同:
- 参数数量不同;
- 参数类型不同;
- 参数类型的排列顺序不同。
非静态成员函数还可以根据以下内容构成重载:
- 成员函数末尾的
const或volatile限定不同; - 引用限定符
&与&&不同。
以下内容不能单独构成重载:
- 返回类型不同;
- 形参名称不同;
- 默认参数不同;
- 值形参的顶层
const不同; - 只有
noexcept不同; typedef或using写法不同,但实际类型相同。
先理解:顶层 const 和底层 const
1. 顶层 const:限制当前对象本身
顶层 const 表示当前这个变量本身不能被修改:
cpp
const int value = 10; // value 本身不能修改
int* const p = nullptr; // 指针变量 p 本身不能改变指向
判断时可以问:
const限制的是不是当前正在声明的变量本身?
如果是,通常就是顶层 const。
cpp
int number = 10;
int* const p = &number;
*p = 20; // 正确:p 指向的 int 不是 const
// p = nullptr; // 错误:p 自身带顶层 const,不能改变指向
2. 底层 const:限制间接访问的对象
底层 const 出现在指针或引用等复合类型中,限制通过它间接访问的对象:
cpp
const int* p = nullptr; // p 指向的 int 是 const
const int& ref = value; // ref 所绑定的 int 只能通过 ref 只读访问
cpp
int number = 10;
const int* p = &number;
p = nullptr; // 正确:p 自身不是 const
// *p = 20; // 错误:不能通过 p 修改它指向的 int
3. 一个声明可以同时拥有顶层和底层 const
cpp
const int* const p = nullptr;
// ^ ^
// | └─ 顶层 const:p 自身不能改变指向
// └─────────── 底层 const:不能通过 p 修改它指向的 int
速览:
| 声明 | 顶层 const |
底层 const |
限制 |
|---|---|---|---|
const int value |
有 | 无 | value 本身不能修改 |
int* const p |
有 | 无 | p 不能改变指向 |
const int* p |
无 | 有 | 不能通过 p 修改 *p |
const int& ref |
无 | 有 | 不能通过 ref 修改原对象 |
const int* const p |
有 | 有 | p 和通过 p 访问的对象都受限 |
这里把 const int& 的 const 归入底层 const,因为它限制的是引用所绑定对象的访问;引用本身不能再写成合法的 int& const。
4. 怎么快速记住顶层和底层 const?
最稳的记法不是死背 const 写在左边还是右边,而是先找到变量名,再问:
text
const 锁住的是变量本人吗?
├─ 是:顶层 const
└─ 不是,它锁住变量间接访问的对象:底层 const
可以把它记成:
text
顶层 = 本人上锁
底层 = 下一层对象上锁
例如:
cpp
const int value = 10;
这里没有指针和引用,value 本人不能修改,所以是顶层 const。不要因为 const 写在 int 前面,就误以为它一定是底层 const。
再看指针:
cpp
int* const p = nullptr;
从变量名 p 开始看:const 直接锁住指针变量 p,所以是顶层 const。
cpp
const int* p = nullptr;
指针变量 p 可以改变,但沿着 p 到下一层的 int 不能通过它修改,所以是底层 const。
5. 指针声明的"从变量名向外读"方法
cpp
const int* const p = nullptr;
从变量名 p 开始向外读:
text
p
↓
右边紧邻 const:p 是常量 → 顶层 const
↓
再看到 *:p 是一个指针
↓
指向 const int:目标是只读的 → 底层 const
最终读作:
p是一个常量指针,它指向常量整数。
对应限制:
cpp
int number = 10;
const int* const p = &number;
// p = nullptr; // 错误:顶层 const 锁住 p
// *p = 20; // 错误:底层 const 锁住 *p
6. 用"能改谁"反推
如果声明看起来绕,可以直接检查两个动作:
cpp
p = otherAddress; // 测试能不能修改变量 p 自身
*p = newValue; // 测试能不能修改 p 指向的对象
| 哪个操作被禁止 | 对应分类 |
|---|---|
不能修改 p,但能修改 *p |
只有顶层 const |
能修改 p,但不能修改 *p |
只有底层 const |
p 和 *p 都不能修改 |
同时有顶层和底层 const |
这个方法只能用于帮助理解类型约束,不是建议通过编译报错来代替读代码。
7. 引用怎么判断?
cpp
const int& ref = value;
引用不是一个可以重新赋值、重新绑定的普通对象。这里的 const 限制的是通过 ref 对原对象的访问,因此归为底层 const:
cpp
// ref = 20; // 错误:不能通过 ref 修改所绑定对象
下面这种写法不合法:
cpp
int& const ref = value; // 错误:引用本身不能再添加顶层 const
因此引用中最常见的判断是:
text
T& → 可以通过引用修改对象
const T& → 底层 const,只能通过引用只读访问对象
8. 函数参数中再判断一次
cpp
void f1(const int value); // value 本人带 const:顶层 const
void f2(int* const p); // 指针副本 p 本人带 const:顶层 const
void f3(const int* p); // p 指向的 int 带 const:底层 const
void f4(const int& ref); // ref 绑定的 int 带 const:底层 const
在按值形参中,顶层 const 只保护函数内部的形参副本,因此不参与重载区分;底层 const 会影响函数能接收什么指针或引用,所以可以参与重载区分。
9. 口诀及其适用边界
可以使用下面的口诀:
text
变量本人不能改,顶层 const;
顺着指针或引用看,目标不能改,底层 const。
不要单纯背:
text
const 在星号左边就是底层,在星号右边就是顶层。
这条位置口诀只对指针声明比较好用,遇到 const int value 和引用时容易误判。"限制谁"才是最终标准。
10. 五秒判断练习
cpp
const int a = 1; // 顶层:a 本人不能改
int const b = 2; // 顶层:与 const int b 相同
int* const p1 = nullptr; // 顶层:p1 不能改指向
const int* p2 = nullptr; // 底层:不能通过 p2 修改目标
int const* p3 = nullptr; // 底层:与 const int* p3 相同
const int* const p4 = nullptr; // 顶层和底层都有
const int& ref = a; // 底层:不能通过 ref 修改所绑定对象
一句话最终记忆:
先找变量名:锁本人是顶层,锁通过它访问的对象是底层;书写位置只用于辅助。
为什么这些差异不能单独构成重载?
核心判断是:
调用函数时,编译器能否根据调用表达式和实参类型区分两个函数?
1. 默认参数不同:没有改变参数类型
cpp
void print(int value);
void print(int value = 0); // 同一个函数的再次声明,不是新重载
两者真正的参数列表都是 (int)。默认值只是告诉编译器调用时省略实参可以补什么值,并没有创造一种新参数类型。
因此:
cpp
print(10); // 两个声明描述的都是同一个 print(int)
print(); // 使用后来提供的默认值 0
如果两个本来就不同的参数列表因为默认参数同时能够匹配,反而可能产生二义性:
cpp
void print();
void print(int value = 0);
// print(); // 错误:两个重载都能匹配
2. 值形参的顶层 const 不同:只影响函数内部的副本
cpp
void test(int value);
void test(const int value); // 不是新重载,与上一行表示同一种函数类型
值传递时,调用者的值会复制给形参:
text
调用者的实参
↓ 复制
函数内部的形参副本 value
两个声明都能接收普通 int 和 const int:
cpp
int a = 10;
const int b = 20;
test(a);
test(b);
const int value 只表示函数体内不能修改形参副本 value,不影响调用者如何传值,所以不能用于区分重载。
指针值传参也遵循同样规则:
cpp
void use(int* p);
void use(int* const p); // 不能重载:const 只限制函数内部的指针副本 p
这里 p 是按值复制进函数的指针。int* const p 只是不允许函数内部让这份副本改指向,调用者传入的类型仍然都是 int*。
但是底层 const 可以区分重载:
cpp
void use(int* p);
void use(const int* p); // 可以重载:p 指向的类型不同
因为调用者传入 const int* 时,不能选择需要 int* 的版本:
cpp
int value = 10;
const int constValue = 20;
use(&value); // 优先匹配 int*
use(&constValue); // 只能匹配 const int*
一句话记忆:
text
值形参自己的 const:顶层 const,不参与重载。
指针指向对象的 const:底层 const,可以参与重载。
3. 只有 noexcept 不同:异常说明不能单独区分重载
cpp
void run();
void run() noexcept; // 不能作为另一个重载;异常说明不一致还会导致声明冲突
调用代码只有:
cpp
run();
调用者没有提供任何实参或语法来表达"我要会抛异常的版本"或"我要不抛异常的版本",因此 noexcept 不能单独用于重载选择。
从 C++17 开始,noexcept 会影响某些函数类型相关规则,但它依然不能作为两个同名函数的唯一重载差异。不要把"属于函数类型的某些信息"简单等同于"都能用于函数重载"。
4. typedef 或 using 不同:别名不会创造新类型
cpp
using Number = int;
typedef int Integer;
void calculate(int value);
void calculate(Number value); // 仍然是 calculate(int)
void calculate(Integer value); // 仍然是 calculate(int)
Number 和 Integer 只是 int 的其他名字,不是两种新类型。别名展开后三个参数完全相同,因此不能重载。
这和真正定义新类型不同:
cpp
struct Number {
int value;
};
void calculate(int value);
void calculate(Number value); // 可以重载:Number 是独立类型
四项对比速览
| 差异 | 能否单独构成重载 | 最直接的原因 |
|---|---|---|
| 默认参数不同 | 不能 | 默认值不改变参数列表 |
值形参的顶层 const 不同 |
不能 | 只限制函数内部的副本,调用方式相同 |
只有 noexcept 不同 |
不能 | 不用于同名函数的重载选择 |
typedef、using 别名不同 |
不能 | 别名展开后仍是同一个类型 |
指针或引用的底层 const 不同 |
可以 | 实参能够区分可修改对象与只读对象 |
二、可以构成重载的常见情况
1. 参数数量不同
cpp
void print(int value);
void print(int left, int right); // 可以重载:参数数量不同
2. 参数类型不同
cpp
void print(int value);
void print(double value); // 可以重载:参数类型不同
void print(const char* text); // 可以重载:参数类型不同
3. 参数顺序不同
cpp
void show(int number, double decimal);
void show(double decimal, int number); // 可以重载:参数类型的顺序不同
如果实参恰好能明确匹配其中一个版本,调用就没有问题:
cpp
show(1, 2.5); // 调用 show(int, double)
show(2.5, 1); // 调用 show(double, int)
4. 指针或引用所指对象的 const 不同
cpp
void use(int* p);
void use(const int* p); // 可以重载:指向类型不同
void read(int& value);
void read(const int& value); // 可以重载:引用绑定的类型不同
这里的 const 属于底层 const,会影响调用者能够传入什么对象,因此参数类型确实不同。
cpp
int value = 10;
const int constValue = 20;
use(&value); // 优先调用 int* 版本
use(&constValue); // 只能调用 const int* 版本
read(value); // 优先调用 int& 版本
read(constValue); // 只能调用 const int& 版本
5. 非静态成员函数末尾的 const 不同
cpp
class Data {
public:
int& get();
const int& get() const; // 可以重载:隐式对象参数的 const 属性不同
};
可以粗略理解成当前对象对应的隐藏参数分别类似 Data* 和 const Data*。
6. 成员函数的引用限定符不同
cpp
class Data {
public:
void process() &; // 只能由左值对象调用
void process() &&; // 只能由右值对象调用
};
cpp
Data data;
data.process(); // 调用 process() &
Data{}.process(); // 调用 process() &&
这是现代 C++ 中不如 const 重载常见、但面试可能追问的形式。
7. 静态成员函数也可以重载
静态成员函数可以像普通函数一样,根据显式参数列表进行重载:
cpp
class Calculator {
public:
static int add(int a, int b);
static double add(double a, double b); // 可以重载:参数类型不同
static int add(int a, int b, int c); // 可以重载:参数数量不同
};
调用时,编译器仍会根据实参选择最匹配的版本:
cpp
Calculator::add(1, 2); // 调用 add(int, int)
Calculator::add(1.5, 2.5); // 调用 add(double, double)
Calculator::add(1, 2, 3); // 调用三个 int 参数的版本
但是,静态成员函数没有隐式的 this 指针,因此不能使用成员函数末尾的 const、volatile 或引用限定符:
cpp
class Data {
public:
static void show() const; // 错误:静态成员函数不能带末尾 const
static void run() &; // 错误:静态成员函数不能带引用限定符 &
};
另外,不能只依靠"是否有 static"区分两个成员函数:
cpp
class Data {
public:
void process(int value);
static void process(int value); // 错误:不能仅以 static 与否构成重载
};
这两个 process 的名称和显式参数列表相同,而 static 本身不是用于区分重载的条件。
8. 重载函数可以具有不同的访问权限
同一个类中的重载成员函数可以分别位于 public、protected 和 private 区域。访问权限不决定能否构成重载;函数名和参数列表等重载条件才决定它们是否属于同一个重载集合。
cpp
class Printer {
public:
void print(int value); // 公有重载
private:
void print(double value); // 私有重载
};
这两个 print 的参数类型不同,因此可以构成重载。类外调用时:
cpp
Printer printer;
printer.print(10); // 正确:最佳匹配是公有的 print(int)
printer.print(3.5); // 编译错误:最佳匹配是私有的 print(double)
关键顺序是:
text
先收集候选函数并进行重载决议
↓
选出唯一的最佳匹配
↓
再检查该函数是否允许访问
因此,printer.print(3.5) 选中私有的 print(double) 后会因为访问权限报错。编译器不会因为它是私有函数,就自动退回去调用需要类型转换的公有 print(int)。
protected 版本也是同样道理:它可以参加重载决议,但通常只有类自身、友元和符合规则的派生类代码能够访问。
还要区分下面两件事:
cpp
class Data {
public:
void set(int value);
private:
void set(int number); // 错误:这不是新重载,而是同一个函数的重复声明
};
仅仅把声明放到不同访问区域,并不能让两个完全相同的函数签名变成不同重载。访问权限本身不是重载条件。
一句话记忆:
重载函数可以一部分公有、一部分私有或受保护;访问权限不决定是否重载,只决定重载决议选中函数后能不能调用。
三、不能构成重载的常见情况
1. 只有返回类型不同
cpp
int getValue();
double getValue(); // 错误:不能仅根据返回类型重载
调用 getValue(); 时,没有足够信息让编译器仅通过返回类型决定调用哪个版本。
2. 只有形参名称不同
cpp
void set(int value);
void set(int number); // 错误:两个函数的参数类型都是 int
参数名不属于用于区分重载的函数类型。
3. 只有默认参数不同
cpp
void print(int value);
void print(int value = 0); // 可以是同一函数的再次声明,但不是新的重载
默认实参只影响调用时能否省略实参,不会产生新的参数类型。上面两个声明表示同一个函数,第二个声明只是为它补充默认实参。
另一种容易产生歧义的情况是:
cpp
void print();
void print(int value = 0);
// print(); // 错误:两个函数都能匹配,调用产生二义性
这两个声明本身参数列表不同,能够作为两个重载存在;但无参调用同时匹配两个版本,因此无法确定调用哪一个。
4. 值形参只有顶层 const 不同
cpp
void test(int value);
void test(const int value); // 错误:不能与上一个函数构成重载
值传递会创建函数自己的形参副本。调用者传入的都是一个 int 值,形参副本在函数内部能否修改不影响调用方式,所以这里的顶层 const 不用于区分函数。
对指针值形参也一样:
cpp
void test(int* p);
void test(int* const p); // 错误:顶层 const 只限制形参副本 p 自身
但下面可以重载:
cpp
void test(int* p);
void test(const int* p); // 可以:const 修饰的是 p 指向的 int,属于底层 const
5. 只有 noexcept 不同
cpp
void run();
void run() noexcept; // 错误:不能仅靠 noexcept 构成重载
6. 别名写法不同,但实际类型相同
cpp
using Number = int;
void calculate(int value);
void calculate(Number value); // 错误:Number 实际就是 int
四、重载成立不代表调用一定成功
即使多个函数能够组成重载集合,某次调用仍可能产生二义性:
cpp
void convert(long value);
void convert(double value);
convert(10); // 可能产生二义性:int 转 long 和 int 转 double 都是标准转换
编译器会先收集同名候选函数,再判断哪些函数可以接收当前实参,最后选择匹配程度最好的版本。如果不存在唯一的最佳匹配,调用就会报二义性错误。
五、速览表
| 差异 | 能否构成重载 | 原因 |
|---|---|---|
| 参数数量不同 | 可以 | 参数列表不同 |
| 参数类型不同 | 可以 | 参数列表不同 |
| 参数类型顺序不同 | 可以 | 参数列表不同 |
int* 与 const int* |
可以 | 底层 const 不同 |
int& 与 const int& |
可以 | 引用绑定的类型不同 |
成员函数末尾有无 const |
可以 | 隐式对象参数不同 |
成员函数末尾 & 与 && |
可以 | 隐式对象参数的值类别要求不同 |
| 静态成员函数的显式参数列表不同 | 可以 | 静态成员函数可按普通参数规则重载 |
同一成员函数只有 static 与否不同 |
不可以 | static 本身不用于区分重载 |
| 返回类型不同 | 不可以 | 返回类型不能单独区分调用 |
| 参数名称不同 | 不可以 | 参数名不参与重载区分 |
| 默认参数不同 | 不可以 | 默认参数不改变函数类型 |
int 与 const int 值形参 |
不可以 | 顶层 const 被忽略 |
int* 与 int* const 值形参 |
不可以 | 顶层 const 被忽略 |
只有 noexcept 不同 |
不可以 | 不能单独用于重载 |
六、面试一句话回答
函数重载要求同一作用域内函数名相同,并且参数数量、参数类型或参数顺序不同;非静态成员函数还可以通过末尾的
const、volatile或引用限定符区分。返回类型、参数名、默认参数、值形参的顶层const和noexcept不能单独构成重载。重载声明合法也不代表每次调用都合法,实参必须能够选出唯一的最佳匹配。
9. 返回值中的 const
本节目录
-
[9.1 const T function():按值返回常量](#9.1 const T function():按值返回常量)
-
[9.2 const T* function():返回指向常量的指针](#9.2 const T* function():返回指向常量的指针)
-
[9.3 const T& function():返回常量引用](#9.3 const T& function():返回常量引用)
-
[9.4 T* const function():按值返回常量指针](#9.4 T* const function():按值返回常量指针)
-
[9.5 函数开头与函数末尾的 const 对比](#9.5 函数开头与函数末尾的 const 对比)
写在函数声明开头的 const,通常是返回类型的一部分,不是在修饰整个函数:
cpp
const int getValue(); // 按值返回 const int
const int* getPointer(); // 返回"指向 const int 的指针"
const int& getReference(); // 返回"const int 的引用"
它和成员函数末尾的 const 完全不同:
cpp
class Data {
public:
const int& get() const;
// 前面的 const:限制调用者通过返回的引用修改 int
// 后面的 const:限制函数通过 this 修改当前 Data 对象
};
9.1 const T function():按值返回常量
cpp
const int getValue()
{
return 10;
}
函数返回的是一个新值,不是函数内部某个对象的别名。对于 int 等内置类型,返回值前的顶层 const 通常没有实际价值:
cpp
int a = getValue(); // 正确:返回值被复制到普通 int 变量 a
a = 20; // 正确:a 本身不是 const
因此通常直接写:
cpp
int getValue(); // 更常见
类类型按值返回通常也不建议写 const:
cpp
const std::string getName(); // 通常不推荐
std::string getName(); // 推荐
给类类型的按值返回结果添加 const,可能限制对临时对象调用非 const 操作,并在某些情况下妨碍移动语义。现代 C++ 通常直接按值返回 T。
9.2 const T* function():返回指向常量的指针
cpp
const int* getPointer();
这里的 const 修饰 int,表示调用者不能通过返回的指针修改它指向的整数:
cpp
const int* p = getPointer();
// *p = 10; // 错误:不能通过 const int* 修改目标
p = nullptr; // 正确:指针变量 p 自身可以改变
需要注意,函数返回的指针通常是一个副本。限制的是通过该指针访问的目标,而不是调用者用于接收结果的指针变量。
它常用于返回对象内部只读数据,但必须保证指针所指对象在调用者使用期间仍然存活,否则会形成悬空指针。
9.3 const T& function():返回常量引用
cpp
const int& getReference();
这里返回的是原对象的只读别名,不会复制该对象:
cpp
const int& ref = getReference();
// ref = 10; // 错误:不能通过 const 引用修改原对象
这种写法适合返回仍然存在的大对象或对象成员,可以避免复制,同时阻止调用者通过返回值修改对象。
但不能返回局部变量的引用:
cpp
const int& wrong()
{
int value = 10;
return value; // 错误用法:函数结束后 value 被销毁,返回的引用悬空
}
const 只能限制修改,不能延长这里的局部变量生命周期,也不能修复悬空引用。
9.4 T* const function():按值返回常量指针
还可能看到这种少见写法:
cpp
int* const getPointer();
这里的 const 修饰返回的指针本身,而不是它指向的 int。但是指针是按值返回的,调用者会得到自己的指针副本,因此给这个返回值添加顶层 const 通常没有必要:
cpp
int* p = getPointer();
p = nullptr; // p 是调用者自己的变量,通常仍然可以修改
实际代码更常写成:
cpp
int* getPointer(); // 返回可修改目标的指针
const int* getPointer(); // 返回只能只读访问目标的指针
9.5 函数开头与函数末尾的 const 对比
| 写法 | const 修饰什么 |
主要效果 |
|---|---|---|
const int get() |
按值返回的 int |
通常没有必要 |
const int* get() |
指针指向的 int |
不能通过返回指针修改目标 |
const int& get() |
引用所绑定的 int |
不能通过返回引用修改目标 |
int* const get() |
按值返回的指针本身 | 顶层 const,通常没有必要 |
int get() const |
隐含的当前对象 *this |
const 对象可以调用,函数不能修改普通成员 |
一句话记忆:
函数开头的
const跟着返回类型走;成员函数末尾的const跟着当前对象this走。
返回指针或引用时,const 有实际意义:
cpp
const int* getPointer();
const int& getReference();
调用者不能通过返回的指针或引用修改对应对象。
对内置类型进行值返回时,通常没有必要添加 const:
cpp
const int getValue(); // 通常没有必要
int getValue(); // 更常见
因为调用者得到的是一个新值,而不是原对象本身。
10. 顶层 const 与底层 const
cpp
int* const p1 = nullptr; // 顶层 const:p1 自身是常量
const int* p2 = nullptr; // 底层 const:p2 指向的类型带 const
顶层 const 限制对象自身,底层 const 限制通过指针或引用访问的对象。
复制时,顶层 const 通常会被忽略:
cpp
const int a = 10;
int b = a; // 正确:b 得到一份新的 int
底层 const 不能被随意丢弃:
cpp
const int x = 10;
const int* p1 = &x;
// int* p2 = p1; // 错误:这会允许通过 p2 修改 const 对象
允许从"可修改"转换为"只读",但通常不允许反向转换:
cpp
int x = 10;
int* p1 = &x;
const int* p2 = p1; // 正确:权限收紧
面试中容易追问
本节目录
-
[1. const int* 和 int* const 有什么区别?](#1. const int* 和 int* const 有什么区别?)
-
[2. const int 和 int const 有区别吗?](#2. const int 和 int const 有区别吗?)
-
[3. 为什么不能写 int& const?](#3. 为什么不能写 int& const?)
-
[4. 成员函数末尾的 const 修饰谁?](#4. 成员函数末尾的 const 修饰谁?)
-
[5. const 是否意味着对象永远不会改变?](#5. const 是否意味着对象永远不会改变?)
1. const int* 和 int* const 有什么区别?
const int* 不能通过指针修改数据,但可以改变指向;int* const 可以修改数据,但不能改变指向。
2. const int 和 int const 有区别吗?
没有区别,只是书写风格不同。
3. 为什么不能写 int& const?
因为引用初始化后本来就不能重新绑定,不能再给引用本身添加顶层 const。
4. 成员函数末尾的 const 修饰谁?
修饰隐含的 this 指针所指对象,表示函数通常不能修改对象的普通成员。
5. const 是否意味着对象永远不会改变?
不一定。它主要表示不能通过当前 const 访问路径修改对象;原对象如果本身不是常量,仍可能通过其他非 const 路径改变。
const 掌握情况自测题
本节目录
-
[第二组:指针与 const](#第二组:指针与 const)
-
[第三组:引用与 const](#第三组:引用与 const)
-
[第四组:函数参数中的 const](#第四组:函数参数中的 const)
-
[第五组:类与 const 成员函数](#第五组:类与 const 成员函数)
-
[第六组:返回值与容易误用的 const](#第六组:返回值与容易误用的 const)
-
[第七组:顶层 const、底层 const 与综合判断](#第七组:顶层 const、底层 const 与综合判断)
-
[const 综合代码逐行解析](#const 综合代码逐行解析)
作答要求
这套题先不提供答案。每道代码题尽量按照下面的格式回答:
text
能否编译:能 / 不能
原因:
如果能运行,输出或最终结果:
如果不能编译,指出出错语句并给出一种修改方法:
不要只凭感觉回答。重点说明 const 修饰的是对象、指针、引用、参数、返回值,还是隐含的 this。
第一组:基础变量与初始化
本节目录
-
[题 1:判断能否编译](#题 1:判断能否编译)
-
[题 2:判断能否编译](#题 2:判断能否编译)
-
[题 3:判断最终输出](#题 3:判断最终输出)
-
[题 4:两种声明是否等价?](#题 4:两种声明是否等价?)
题 1:判断能否编译
cpp
const int a = 10;
a = 20;
能否编译:不能
原因:a是const的,不可被修改
题 2:判断能否编译
cpp
const int a;
能否编译:能
原因:编译不会报错但是这个变量没用,因为后续无法再给他赋值
题 3:判断最终输出
cpp
int x = 10;
const int y = x;
x = 20;
std::cout << x << ' ' << y << '\n';
说明为什么修改 x 不会影响 y。
x和y是不想管变量,只是y初始化时赋的值是x的值
题 4:两种声明是否等价?
cpp
const int a = 10;
int const b = 20;
说明 const int 和 int const 是否有区别。
没有区别,都是顶部const,变量值无法被修改
第二组:指针与 const
本节目录
-
[题 5:逐行判断](#题 5:逐行判断)
-
[题 6:逐行判断](#题 6:逐行判断)
-
[题 7:逐行判断](#题 7:逐行判断)
-
[题 8:判断转换是否合法](#题 8:判断转换是否合法)
-
[题 9:读出声明含义](#题 9:读出声明含义)
-
[题 10:判断输出](#题 10:判断输出)
题 5:逐行判断
cpp
int x = 10;
int y = 20;
const int* p = &x;
*p = 30; // A
p = &y; // B
分别判断 A、B 能否编译,并说明 const 限制了什么。
A:否
B:是
const限制了 解引用p是无法修改p指向的内存的值
题 6:逐行判断
cpp
int x = 10;
int y = 20;
int* const p = &x;
*p = 30; // A
p = &y; // B
分别判断 A、B 能否编译。
A:是
B:否
const限制了p无法更改指向
题 7:逐行判断
cpp
int x = 10;
int y = 20;
const int* const p = &x;
*p = 30; // A
p = &y; // B
说明两个 const 分别限制什么。
第一个const:限制了 解引用p是无法修改p指向的内存的值
第二个const:限制了p无法更改指向
题 8:判断转换是否合法
cpp
int x = 10;
int* p1 = &x;
const int* p2 = p1; // A
int* p3 = p2; // B
分别判断 A、B 能否编译,并从"权限扩大或收紧"的角度解释。
这个题我属于知道答案,但是对底层实际原因掌握不好,解释不清
A:可,权限不变,只是参与给别人赋值
B:否,权限扩大,
题 9:读出声明含义
不运行代码,直接说明下面每个变量的含义,以及"能不能改指向"和"能不能通过它改值"。
cpp
int* p1;
const int* p2;
int const* p3;
int* const p4 = nullptr;
const int* const p5 = nullptr;
int* p1;整型指针
const int* p2;常量整型指针,p2不可以通过解引用对指向的内存的值进行更改;p2可以更改指向
int const* p3;常量整型指针,p3不可以通过解引用对指向的内存的值进行更改;p3可以更改指向
int* const p4 = nullptr;整型指针常量,p4不可更改指向,因为无法更改指向,因此即使该类型可以通过解引用更改指向的内容,但是因为初始赋值的是空指针,因此也无法改值
const int* const p5 = nullptr;无法改指向,也无法改值
题 10:判断输出
cpp
int x = 10;
const int* p = &x;
x = 40;
std::cout << *p << '\n';
解释为什么 p 指向"只读数据"不代表原对象 x 一定不能改变。
const限制的是p不能通过解引用去更改指向内存的值,不是代表指向的内存无法更改值
第三组:引用与 const
本节目录
-
[题 11:逐行判断](#题 11:逐行判断)
-
[题 12:为什么可以这样绑定?](#题 12:为什么可以这样绑定?)
-
[题 13:下面的声明为什么错误?](#题 13:下面的声明为什么错误?)
-
[题 14:辨析两种参数](#题 14:辨析两种参数)
题 11:逐行判断
cpp
int x = 10;
const int& ref = x;
ref = 20; // A
x = 30; // B
如果 B 合法,说明此时通过 ref 读取到什么。
A不合法,因为ref被定义为常量引用,无法通过ref改值;B合法,x仍然为普通整型
题 12:为什么可以这样绑定?
cpp
const int& ref = 10;
回答:
- 能否编译?
- 为什么普通的
int& ref = 10;通常不能编译? - 这里临时对象的生命周期发生了什么?
这个地方我掌握不好,你题目问的就已经告诉我这个写法是正确的,但是 我没有完全理解原因,因此你可以讲了再让我尝试回答
题 13:下面的声明为什么错误?
cpp
int x = 10;
int& const ref = x;
说明为什么引用不需要再添加"顶层 const"。
顶层const是限制变量本身,引用的特点是无法更改指向的对象,所以不管有没有const,无论进行什么操作结果都一样
题 14:辨析两种参数
cpp
void f1(const int& value);
void f2(int& value);
回答:
- 哪个函数能够接收普通变量?都可以
- 哪个函数能够接收字面量
10?f1 - 哪个函数能够通过形参修改实参?f2
第四组:函数参数中的 const
本节目录
-
[题 15:值传递中的 const](#题 15:值传递中的 const)
-
[题 16:这两个声明能否构成重载?](#题 16:这两个声明能否构成重载?)
-
[题 17:这两个声明能否构成重载?](#题 17:这两个声明能否构成重载?)
-
[题 18:选择更合适的参数类型](#题 18:选择更合适的参数类型)
-
[题 19:找出错误](#题 19:找出错误)
题 15:值传递中的 const
cpp
void f(const int value)
{
value = 20;
}
判断能否编译。这里的 const 限制调用者的实参,还是只限制函数内部的形参副本?
否,只限制函数内部的形参副本
题 16:这两个声明能否构成重载?
cpp
void f(int value);
void f(const int value);
说明顶层 const 为什么会或不会影响函数签名。
能;函数签名是什么;会影响到,因为形参类型不同
题 17:这两个声明能否构成重载?
cpp
void f(int* p);
void f(const int* p);
说明它与上一题的区别。
能,这一题的参数是指针,第一个f函数可以直接更改实参,而上一题两个函数都不能更改实参
题 18:选择更合适的参数类型
有一个只读取 std::string、不修改它的函数:
cpp
void print(________ name);
在下面选项中选择通常更合适的一项,并说明原因:
text
A. std::string
B. std::string&
C. const std::string&
D. const std::string
C;const确保只读取不更改,&是创建别名,不会像参数是普通类型一样需要拷贝、创建临时变量来占用内存
题 19:找出错误
cpp
void reset(const int* p)
{
*p = 0;
}
如果函数确实需要修改调用者的数据,应该怎样调整参数?
p是常量指针,无法通过解引用来更改所指向内存的值;
删除const
第五组:类与 const 成员函数
本节目录
-
[题 20:判断能否编译](#题 20:判断能否编译)
-
[题 21:成员函数末尾的 const 修饰谁?](#题 21:成员函数末尾的 const 修饰谁?)
-
[题 22:找出错误](#题 22:找出错误)
-
[题 23:mutable 的作用](#题 23:mutable 的作用)
-
[题 24:成员函数能否按 const 重载?](#题 24:成员函数能否按 const 重载?)
题 20:判断能否编译
cpp
class Counter
{
public:
int get() const
{
return value;
}
void set(int v)
{
value = v;
}
private:
int value = 0;
};
const Counter c;
int x = c.get(); // A
c.set(10); // B
分别判断 A、B,并说明常量对象能调用哪类成员函数。
A可以:c是const对象,可以调用const成员函数
B可以:c是const对象,不可以调用非const成员函数
题 21:成员函数末尾的 const 修饰谁?
cpp
class Test
{
public:
void show() const;
};
用隐含的 this 指针解释 const 成员函数的含义。
修饰隐藏的this指针,也因此可以构成重载;无法更改成员变量的相关属性
题 22:找出错误
cpp
class Counter
{
public:
void increase() const
{
++value;
}
private:
int value = 0;
};
说明为什么错误,并分别给出以下两种目标对应的修改方案:
increase本来就应该修改对象;- 某个成员只是缓存,希望在逻辑只读函数中更新。
题 23:mutable 的作用
cpp
class Data
{
public:
int get() const
{
++accessCount;
return value;
}
private:
int value = 10;
mutable int accessCount = 0;
};
判断能否编译,并说明为什么 const 成员函数仍能修改 accessCount。
题 24:成员函数能否按 const 重载?
cpp
class Buffer
{
public:
char& at(int index);
const char& at(int index) const;
};
回答:
- 这两个函数能否同时存在?
- 普通对象和常量对象分别调用哪一个?
- 为什么返回类型也常分别写成
char&与const char&?
第六组:返回值与容易误用的 const
本节目录
-
[题 25:返回常量引用是否安全?](#题 25:返回常量引用是否安全?)
-
[题 26:辨析返回类型](#题 26:辨析返回类型)
-
[题 27:基本类型按值返回加 const 是否常见?](#题 27:基本类型按值返回加 const 是否常见?)
题 25:返回常量引用是否安全?
cpp
const int& getValue()
{
int value = 10;
return value;
}
回答:
- 主要问题是什么?
- 它与
const本身是否有关? - 可以怎样安全修改?
题 26:辨析返回类型
cpp
const std::string& name() const;
std::string name() const;
说明两种返回方式在复制、生命周期和调用者修改能力方面的主要区别。
题 27:基本类型按值返回加 const 是否常见?
cpp
const int getValue();
判断这种写法是否通常有实际价值,并说明现代 C++ 中为什么一般直接写:
cpp
int getValue();
第七组:顶层 const、底层 const 与综合判断
本节目录
-
[题 28:区分顶层与底层 const](#题 28:区分顶层与底层 const)
-
[题 29:综合逐行判断](#题 29:综合逐行判断)
-
[题 30:综合找错](#题 30:综合找错)
题 28:区分顶层与底层 const
指出下面每个声明中的 const 属于顶层 const、底层 const,还是二者都有:
cpp
const int value = 10;
const int* p1 = nullptr;
int* const p2 = nullptr;
const int* const p3 = nullptr;
题 29:综合逐行判断
cpp
int x = 10;
const int cx = 20;
int* p1 = &x; // A
const int* p2 = &x; // B
const int* p3 = &cx; // C
int* p4 = &cx; // D
p2 = &cx; // E
*p2 = 30; // F
逐行判断 A~F 是否合法,并解释原因。
题 30:综合找错
cpp
class User
{
public:
const std::string& getName() const
{
return name;
}
void rename(const std::string& newName) const
{
name = newName;
}
private:
std::string name;
};
回答:
getName是否合理?rename能否编译?- 应如何修改
rename? getName返回const std::string&对调用者有什么限制?
第八组:面试口述题
本节目录
-
[题 31](#题 31)
-
[题 32](#题 32)
-
[题 33](#题 33)
-
[题 34](#题 34)
-
[题 35](#题 35)
题 31
用一句话说明 const int* 与 int* const 的区别。
题 32
为什么大对象只读传参常用 const T&,而不是 T 或 T&?
题 33
成员函数末尾的 const 有什么作用?它为什么能够参与函数重载?
题 34
const 是否意味着对象在整个程序运行期间绝对不会变化?请结合"访问路径"回答。
题 35
什么是顶层 const 和底层 const?它们在函数传参或类型转换中有什么不同?
建议作答顺序
text
第一轮:题 1~14
→ 检查变量、指针和引用基础
第二轮:题 15~24
→ 检查函数参数、类和 const 成员函数
第三轮:题 25~35
→ 检查返回值、顶层/底层 const 和面试表达
先完成第一轮再核对答案,更容易发现自己究竟是在哪一类写法上混淆。
const 综合代码逐行解析
本节目录
一、总结:快速复习
const int* p:指向常量的指针 ,不能通过p修改目标,但p可以改指向。int* const p:常量指针 ,可以修改目标,但p不能改指向。const int& a:常量引用,不能通过该引用修改目标。int& const:非法写法。引用一经初始化就不能改绑,因此不能再给引用本身添加顶层const。void test() const:函数内的隐式this可近似理解为const A* const this,不能修改普通成员。const指针或引用不能直接转换为对应的非const指针或引用,否则会丢失底层const。
二、逐行注释与编译判断
下面保留原代码,并在每一行旁边标明含义及能否编译。整段代码最终不能通过编译。
cpp
/***********************************熟悉 const***************************************/ // 本段用于练习 const 变量、指针、引用和成员函数
/*
常量指针和指针常量写法看顺序,常量指针就是 const 在指针前(const int* p),指针常量是指针在前面(int* const p)。
// 这句口诀可辅助记忆,但"常量指针"容易产生歧义,建议直接说"指向常量的指针"和"常量指针"
// 更稳定的判断方法:看 const 修饰谁;const 靠近 int,限制 *p,const 靠近 p,限制 p
*/
void test1(const int* p) // 正确:p 是"指向 const int 的指针";p 按值传递,函数内有一份指针副本
{ // 进入 test1 函数体
int c = 0; // 正确:定义普通 int 变量 c
*p = 1; // 编译错误:不能通过 const int* 修改它指向的整数
p = &c; // 正确:p 本身不是常量,可以让指针副本改为指向 c
} // test1 结束;p 的改向不会影响调用者的指针
void test2(int* const p) // 正确:p 是"指向 int 的常量指针";函数内不能改变 p 的指向
{ // 进入 test2 函数体
int c = 0; // 正确:定义普通 int 变量 c
*p = 1; // 正确:p 指向的是非 const int,可以通过 p 修改目标
p = &c; // 编译错误:p 是 const 指针,初始化后不能改变指向
} // test2 结束
void test3(const int& a) // 正确:a 是 const int 的引用,可绑定普通 int、const int 和临时量
{ // 进入 test3 函数体
a = 0; // 编译错误:不能通过 const 引用修改被引用对象
} // test3 结束
void test4(int& const a) // 编译错误:引用本身不能用 const 修饰;应写成 int& a
{ // 因函数声明非法,函数整体不能通过编译
a = 0; // 若形参改成 int& a,则此行正确,是修改 a 引用的原对象
} // test4 结束
void test5(int& a) // 正确:a 是普通 int 的左值引用,只能绑定非 const 的 int 左值
{ // 进入 test5 函数体
a = 0; // 正确:通过普通左值引用修改调用者传入的原对象
} // test5 结束
class A { // 定义类 A;class 的成员默认是 private
void test() const { // 正确:const 成员函数,不能通过 this 修改普通数据成员
a = 1; // 编译错误:a 是普通成员,但 const 成员函数把当前对象视为 const
b = 0; // 编译错误:b 本身是 const 成员,并且当前函数也是 const 成员函数
} // test 成员函数结束
int a; // 定义普通 int 成员;未显式初始化
const int b; // 定义 const int 成员;必须由构造函数初始化列表或类内初始化器初始化
}; // 类 A 定义结束;由于 b 未初始化,隐式默认构造函数会被定义为删除
int main() // 主函数入口
{ // 进入 main 函数体
const int a = 1; // 正确:定义 const int,声明时完成初始化
a = 0; // 编译错误:const 对象初始化后不能被普通赋值修改
int const a2 = 1; // 正确:与 const int a2 = 1 完全等价
a2 = 0; // 编译错误:a2 也是 const int
const int* p = &a; // 正确:指向 const int 的指针可以指向 const int 对象
*p = 0; // 编译错误:不能通过 p 修改 a
p = &a2; // 正确:p 本身可变,可以由指向 a 改为指向 a2
int* const p2 = &a; // 编译错误:&a 是 const int*,不能赋给 int*,否则会丢失底层 const
*p2 = 0; // 只看 p2 的声明类型时此行可修改目标;但上一行初始化失败,整段仍无法编译
p2 = &a2; // 编译错误:p2 是 const 指针,不能改指向;而 &a2 也不能赋给 int*
const int& b = a; // 正确:const int 引用绑定到 const int 对象 a
b = a2; // 编译错误:这是给 b 所引用的 a 赋值,不是让引用改绑;a 不能被修改
int& const b2 = a; // 编译错误一:引用不能带顶层 const;错误二:int& 不能绑定 const int 对象 a
b2 = a2; // 若 b2 是合法的 int&,此行表示把 a2 的值赋给被引用对象,不是引用改绑
test1(p); // 正确:p 的类型是 const int*,与 test1 的参数类型匹配
test2(p); // 编译错误:const int* 不能转换为 int*,因为会丢失指向对象的 const 属性
test3(a); // 正确:const int& 可以绑定 const int 对象 a
test4(a); // test4 的声明本身非法;即使改成 int&,也不能绑定 const int 对象 a
test5(a); // 编译错误:a 是 const int,不能绑定到可能修改原对象的 int& 形参
int c; // 可以编译:定义未初始化的局部 int;未读取它时不会因未初始化产生运行时问题
A classa; // 编译错误:const 成员 b 没有初始化,A 的隐式默认构造函数被删除
return 0; // main 正常结束时返回 0;但本程序会在编译阶段因前述错误而失败
} // main 函数结束
三、关键原因
本节目录
-
[1. 指针声明看谁被 const 修饰](#1. 指针声明看谁被 const 修饰)
-
[2. 引用不能写成 int& const](#2. 引用不能写成 int& const)
-
[3. const 成员函数的底层理解](#3. const 成员函数的底层理解)
-
[4. const 成员必须初始化](#4. const 成员必须初始化)
1. 指针声明看谁被 const 修饰
cpp
const int* p; // const 修饰 int:不能通过 p 修改 *p
int* const p; // const 修饰 p:不能修改 p 的指向
可以从变量名向外读:
const int* p:p是指针,指向const int;int* const p:p是const指针,指向int。
2. 引用不能写成 int& const
引用在初始化后天然不能改为引用另一个对象,因此 C++ 不允许用顶层 const 再修饰引用本身。需要限制通过引用修改对象时,应写成:
cpp
const int& a;
3. const 成员函数的底层理解
成员函数末尾的 const 约束隐式的 this 指针:
cpp
void test() const;
可近似理解为函数内持有:
cpp
const A* const this;
因此不能通过 this 修改普通成员。若某个缓存或计数成员在逻辑只读操作中确实需要变化,可以将该成员声明为 mutable。
4. const 成员必须初始化
const int b; 不能先默认生成一个不确定值,再在构造函数体中赋值;它必须在对象构造阶段直接初始化。例如:
cpp
class A {
public:
A() : a(0), b(0) {}
private:
int a;
const int b;
};
四、面试容易追问
const int*、int* const和const int* const分别表示什么?- 为什么有
const int&,却没有合法的int& const? - 成员函数末尾的
const修饰什么?为什么它可以参与函数重载? - 为什么
const int*不能传给参数类型为int*的函数? const数据成员应该在哪里初始化?
C++ 引用与值类别
本节目录
一、总结:快速复习
一句话:
引用是已有对象的别名;在常见编译器实现中,需要保存引用关系时通常通过地址实现,但 C++ 标准没有规定引用必须就是指针。
- 引用必须在定义时绑定到一个对象,普通左值引用不能直接悬空定义。
- 引用初始化后不能改绑;给引用赋值,实际修改的是它所引用的对象。
- 使用引用时通常不需要写
*解引用,编译器会按照"访问原对象"的语义处理。 - 函数参数使用引用,可以避免复制对象,也能让函数直接操作实参。
- 常见底层实现与指针相似:传递对象地址,通过地址访问原对象;但这是实现方式,不是语言层面的等价关系。
- 优化后,引用可能只存在于寄存器或被完全消除,因此不能简单断言"引用一定占一个指针大小"。
二、详细讲解:深入理解
本节目录
-
[1. 引用是什么?](#1. 引用是什么?)
-
[2. 引用为什么必须初始化?](#2. 引用为什么必须初始化?)
-
[3. 引用底层通常如何实现?](#3. 引用底层通常如何实现?)
-
[4. 引用和指针为什么不完全一样?](#4. 引用和指针为什么不完全一样?)
-
[5. 引用是否占用内存?](#5. 引用是否占用内存?)
-
[6. 引用传参为什么能避免复制?](#6. 引用传参为什么能避免复制?)
-
[7. 引用也可能悬空](#7. 引用也可能悬空)
-
[8. 面试中容易追问](#8. 面试中容易追问)
1. 引用是什么?
引用为已经存在的对象提供另一个名字:
cpp
int x = 10;
int& ref = x;
ref = 20; // 实际修改 x
此时 ref 和 x 表示同一个对象:
text
x ─┐
├─→ 同一个 int 对象
ref ─┘
因此执行 ref = 20 后,x 也变成 20。
2. 引用为什么必须初始化?
引用一旦创建,就必须表示某个有效对象:
cpp
int& ref; // 错误:没有绑定对象
正确写法:
cpp
int x = 10;
int& ref = x;
引用初始化后不能改绑:
cpp
int x = 10;
int y = 20;
int& ref = x;
ref = y;
最后一行不是让 ref 改为引用 y,而是把 y 的值赋给 ref 所引用的 x。
3. 引用底层通常如何实现?
下面两个函数在语义上都能修改调用者的对象:
cpp
void addByReference(int& value)
{
++value;
}
void addByPointer(int* value)
{
++(*value);
}
调用方式分别是:
cpp
int x = 10;
addByReference(x);
addByPointer(&x);
在常见的编译器和 ABI 中,引用参数通常也会把 x 的地址传入函数。函数内部使用 value 时,生成的机器代码再通过这个地址访问原对象。
可以近似理解为:
text
源代码中的引用参数
↓
编译器生成传递对象地址的代码
↓
函数通过该地址访问调用者的原对象
所以引用能够避免复制,并能修改原对象。
但是要注意:
"通常通过地址实现"不等于"引用就是指针"。
C++ 标准规定的是引用的行为,没有强制规定编译器必须为引用单独保存一个地址。编译器优化后,这个地址可能保存在寄存器中,也可能因为内联等优化而完全不需要实际存在。
4. 引用和指针为什么不完全一样?
| 对比项 | 引用 | 指针 |
|---|---|---|
| 语言含义 | 对象的别名 | 保存地址的对象 |
| 初始化 | 通常必须立即绑定 | 可以暂时不指向有效对象 |
| 改变目标 | 初始化后不能改绑 | 可以改变指向 |
| 使用方式 | 直接按对象使用 | 通常需要 * 解引用 |
| 空值 | 正常引用不应为空 | 可以为 nullptr |
| 自身地址 | &ref 得到被引用对象的地址 |
&p 得到指针对象自身的地址 |
例如:
cpp
int x = 10;
int& ref = x;
std::cout << &x << '\n';
std::cout << &ref << '\n';
两次输出通常相同,因为对 ref 取地址得到的是被引用对象 x 的地址。
5. 引用是否占用内存?
不能脱离具体场景简单回答"引用一定占 8 字节"或"一定不占空间"。
局部引用可能被编译器完全优化掉:
cpp
int x = 10;
int& ref = x;
ref++;
编译器可能直接把它当作 x++,无需为 ref 单独分配存储空间。
但引用作为类的数据成员时,编译器通常需要保存被引用对象的位置:
cpp
struct Holder
{
int& ref;
};
这种情况下,常见实现会在 Holder 对象中保存一个类似地址的数据,因此它通常会占用与指针相近的空间。不过具体大小属于编译器和 ABI 的实现细节,应使用 sizeof(Holder) 在目标环境中确认。
另外,不能直接用 sizeof(ref) 测量"引用本身"的大小:
cpp
int x = 10;
int& ref = x;
sizeof(ref); // 得到的是 sizeof(int)
因为表达式中的 ref 按被引用对象 x 处理。
6. 引用传参为什么能避免复制?
值传递需要创建形参对象:
cpp
void print(User user); // 通常需要复制或移动 User
常量引用传递不复制整个对象:
cpp
void print(const User& user); // 直接读取原对象
底层通常只需要传递原对象的地址,传递成本较小;const 又能限制函数通过该引用修改对象。因此,大对象只读传参经常使用 const T&。
但对于 int、char、指针等很小的类型,值传递往往更直接,不必机械地全部改成引用。
7. 引用也可能悬空
引用语法看起来不能为 nullptr,但它仍可能引用已经失效的对象:
cpp
int& bad()
{
int x = 10;
return x; // 错误:返回局部变量的引用
}
函数返回后,局部变量 x 的生命周期已经结束,返回的引用成为悬空引用。继续使用它会产生未定义行为。
所以"引用不能为空"不代表"引用永远安全"。真正需要检查的是被引用对象的生命周期。
8. 面试中容易追问
本节目录
-
[1. 引用和指针有什么区别?](#1. 引用和指针有什么区别?)
-
[2. 引用是否一定占内存?](#2. 引用是否一定占内存?)
-
[3. sizeof(ref) 得到的是引用大小吗?](#3. sizeof(ref) 得到的是引用大小吗?)
-
[4. 引用能否为空?](#4. 引用能否为空?)
-
[5. 为什么大对象常使用 const T& 传参?](#5. 为什么大对象常使用 const T& 传参?)
1. 引用和指针有什么区别?
引用是对象的别名,必须绑定且不能改绑;指针是保存地址的对象,可以改变指向,也可以为 nullptr。引用底层常通过地址实现,但二者的语言语义不同。
2. 引用是否一定占内存?
不一定。标准没有规定引用必须具有独立存储。局部引用可能被优化掉;引用作为类成员时,常见实现通常需要保存地址,因此经常占用类似指针的空间。
3. sizeof(ref) 得到的是引用大小吗?
不是。sizeof(ref) 得到的是被引用类型的大小,因为表达式中的引用按其绑定对象处理。
4. 引用能否为空?
合法创建的普通引用必须绑定对象,没有类似 nullptr 的正常空引用状态。但引用仍可能因为对象生命周期结束而悬空。
5. 为什么大对象常使用 const T& 传参?
它通常只传递对象地址,避免复制整个对象;同时 const 限制函数通过该引用修改实参。
左值、右值、左值引用与右值引用
本节目录
一、总结:快速复习
| 概念 | 常见写法 | 核心含义 |
|---|---|---|
| 左值 | a、*p、arr[0] |
通常表示有身份、可以被定位并在之后继续访问的对象 |
| 右值 | 10、a + b、临时对象 |
通常表示临时值,或资源可以被转移的对象 |
| 左值引用 | T& |
主要绑定非 const 左值,可通过引用修改原对象 |
| 常量左值引用 | const T& |
既能绑定左值,也能绑定右值;不能通过它修改对象 |
| 右值引用 | T&& |
主要绑定右值,用于移动语义和完美转发 |
必须记住:
- 左值、右值描述的是表达式的值类别,不是变量的数据类型。
- 判断重点不是"能不能放在赋值号左边",而是表达式是否具有可识别的对象身份、资源是否可以安全转移。
T&通常不能绑定右值,const T&可以绑定左值和右值,T&&通常只能绑定右值。- 有名字的右值引用变量,在表达式中是左值。
std::move(x)并不移动数据,只是把x转换为可被移动的右值类别;真正的移动由移动构造或移动赋值完成。
二、详细讲解:深入理解
本节目录
-
[1. 什么是左值?](#1. 什么是左值?)
-
[2. 什么是右值?](#2. 什么是右值?)
-
[3. 左值引用 T&](#3. 左值引用 T&)
-
[4. 常量左值引用 const T&](#4. 常量左值引用 const T&)
-
[5. 右值引用 T&&](#5. 右值引用 T&&)
-
[6. 为什么有名字的右值引用是左值?](#6. 为什么有名字的右值引用是左值?)
-
[7. std::move 的底层作用](#7. std::move 的底层作用)
-
[8. 常见绑定规则速览](#8. 常见绑定规则速览)
-
[9. 函数重载如何区分左值和右值?](#9. 函数重载如何区分左值和右值?)
1. 什么是左值?
左值表达式通常表示一个具有稳定身份的对象。程序可以定位这个对象,并在当前表达式结束后继续访问它。
cpp
int a = 10;
int* p = &a;
a = 20; // a 是左值,表示有名字的对象
*p = 30; // *p 是左值,表示 p 指向的对象
"左值能放在赋值号左边"只是一种不完全准确的入门记法。例如:
cpp
const int c = 10;
表达式 c 仍然是左值,因为它表示一个有身份的对象;只是该对象受 const 限制,不能执行 c = 20。
因此:是不是左值 和能不能被修改是两个不同问题。
2. 什么是右值?
右值通常是计算产生的临时结果,或者一个资源允许被转移的对象。
cpp
int a = 10;
int b = 20;
int c = a + b; // a + b 是临时计算结果,属于右值
int d = 100; // 字面量 100 是右值
std::string s = std::string("abc"); // 临时 string 是右值
现代 C++ 中,右值还可以进一步分成:
- 纯右值(prvalue) :用于产生值或初始化对象,例如
10、a + b、std::string("abc"); - 将亡值(xvalue) :对象仍有身份,但资源可以被转移,例如
std::move(s)。
面试中如果只问左值和右值,说明"左值通常有稳定身份,右值通常是临时结果或资源可被转移"一般已经足够;被追问时再补充纯右值和将亡值。
3. 左值引用 T&
左值引用是已有对象的别名,普通 T& 主要绑定可修改的左值:
cpp
int a = 10;
int& ref = a; // 正确:ref 绑定左值 a
ref = 20; // 修改的是原对象 a
// int& ref2 = 10; // 错误:普通左值引用不能绑定右值 10
底层实现中,编译器通常用对象地址实现引用,但 C++ 语言层面把引用规定为别名。引用必须初始化,且初始化后不能改绑。
4. 常量左值引用 const T&
const T& 可以绑定左值、常量左值和右值,因此经常用于只读传参:
cpp
int a = 10;
const int ca = 20;
const int& r1 = a; // 正确:绑定普通左值
const int& r2 = ca; // 正确:绑定 const 左值
const int& r3 = 30; // 正确:绑定右值,并延长临时对象的生命周期
// r1 = 40; // 错误:不能通过 const 引用修改对象
当局部 const 引用直接绑定临时对象时,该临时对象的生命周期通常延长到引用的生命周期结束:
cpp
const std::string& text = std::string("hello");
// 临时 string 的生命周期延长到 text 离开作用域
生命周期延长存在具体规则,并不是临时对象只要经过引用传递就会永久安全。例如,函数返回对局部对象的引用仍然会产生悬空引用。
5. 右值引用 T&&
C++11 引入右值引用,主要目的是支持移动语义:
cpp
int&& r1 = 10; // 正确:绑定右值
int a = 20;
// int&& r2 = a; // 错误:a 是左值表达式
int&& r3 = std::move(a); // 正确:std::move(a) 是将亡值
右值通常即将销毁,因此程序可以转移它内部的堆内存、文件句柄等资源,避免昂贵的深拷贝。
以 std::string 为例,移动构造通常只需要接管源对象内部的缓冲区指针等状态,而不必重新分配内存并复制全部字符。移动后的源对象仍然有效、可以析构和重新赋值,但其具体值通常不应被假定。
6. 为什么有名字的右值引用是左值?
cpp
std::string&& r = std::string("hello");
变量 r 的声明类型 是 std::string&&,但表达式 r 有名字,可以被重复定位和访问,所以表达式 r 是左值:
cpp
void use(const std::string&); // 接收左值或右值,只读
void use(std::string&&); // 接收右值,可能转移资源
use(r); // r 是左值表达式,调用 const std::string& 版本
use(std::move(r)); // 转换成将亡值,调用 std::string&& 版本
7. std::move 的底层作用
std::move 本身通常不执行资源转移,而是一次类型转换,把表达式转换成可触发移动操作的将亡值:
cpp
std::string source = "hello";
std::string target = std::move(source);
大致过程是:
text
std::move(source) 把 source 转换为将亡值
↓
编译器选择 string 的移动构造函数
↓
移动构造函数接管 source 的内部资源
如果类型没有可用的移动操作,或者移动条件不满足,仍可能调用复制操作。因此不能把 std::move 理解为"保证发生移动"。
8. 常见绑定规则速览
假设存在 int a = 10; 和 const int ca = 20;:
| 引用类型 | a(普通左值) |
ca(const 左值) |
10(右值) |
std::move(a)(将亡值) |
|---|---|---|---|---|
int& |
可以 | 不可以 | 不可以 | 不可以 |
const int& |
可以 | 可以 | 可以 | 可以 |
int&& |
不可以 | 不可以 | 可以 | 可以 |
const int&& |
不可以 | 不可以 | 可以 | 可以,但实际很少使用 |
const int&& 虽然能够绑定右值,但因为 const 限制了修改,通常无法有效转移资源,所以远不如 const int& 和 int&& 常见。
9. 函数重载如何区分左值和右值?
cpp
void process(const std::string& value)
{
// 接收左值,也可以接收右值;只读使用
}
void process(std::string&& value)
{
// 接收右值;可以考虑转移 value 的资源
}
std::string text = "hello";
process(text); // 调用 const std::string& 版本
process(std::string("world")); // 调用 std::string&& 版本
process(std::move(text)); // 调用 std::string&& 版本
这种重载让类可以对仍需保留的左值执行复制,对即将销毁的右值执行移动。
三、一句话记忆
text
左值:有身份,之后还能找到它。
右值:临时结果,或资源可以被转移。
T&:主要接普通左值。
const T&:左右值都能接,只读。
T&&:主要接右值,用来移动。
有名字的右值引用变量,本身是左值表达式。
四、面试容易追问
- 什么是左值和右值?它们与能否被修改有什么关系?
T&、const T&和T&&分别能绑定什么?- 为什么右值引用变量有名字以后是左值表达式?
std::move是否真的执行了移动?- 移动构造为什么通常比复制构造快?移动后的对象还能使用吗?
const T&为什么能够延长临时对象的生命周期?- 纯右值和将亡值有什么区别?
C++ 前置自增与后置自增(++i 与 i++)
本节目录
一、总结:快速复习
在算法题里很多人写:
++i
而不是:
i++
主要有两个原因:
-
对于普通整数变量,
++i和i++在最终结果上通常没有区别。 -
++i是先自增,再返回自增后的值 ;i++是先返回原值,再自增。 -
在
for循环里:for (int i = 0; i < n; ++i)和
for (int i = 0; i < n; i++)对
int来说通常没有实际性能差异。 -
但对迭代器等复杂对象 ,
++i通常更值得优先使用,因为i++可能需要保存自增前的旧值,产生额外的临时对象。 -
所以算法题里写
++i更多是一种通用的良好习惯 ,而不是因为int的++i一定比i++快。
一句话记忆:
只需要"加 1",不需要使用自增前的值时,优先写
++i。
二、详细讲解:深入理解
本节目录
-
[1. ++i 和 i++ 到底有什么区别?](#1. ++i 和 i++ 到底有什么区别?)
-
[2. 为什么 for 循环里两者看起来完全一样?](#2. 为什么 for 循环里两者看起来完全一样?)
-
[3. 那为什么很多算法程序员还是习惯写 ++i?](#3. 那为什么很多算法程序员还是习惯写 ++i?)
-
[4. i++ 为什么可能产生额外工作?](#4. i++ 为什么可能产生额外工作?)
-
[5. 用一个简单类比理解](#5. 用一个简单类比理解)
-
[6. 这就是为什么算法代码里经常看到](#6. 这就是为什么算法代码里经常看到)
-
[7. 那是不是 i++ 就不好?](#7. 那是不是 i++ 就不好?)
-
[8. 一个很重要的面试点:不要说"++i 比 i++ 快"](#8. 一个很重要的面试点:不要说“++i 比 i++ 快”)
-
[9. 再往底层一点:为什么自定义类型差异更明显?](#9. 再往底层一点:为什么自定义类型差异更明显?)
-
[10. 最终你应该形成这个判断](#10. 最终你应该形成这个判断)
1. ++i 和 i++ 到底有什么区别?
本节目录
假设:
int i = 5;
++i
叫做前置自增:
++i;
执行过程:
i = 5
↓
i先加1
↓
i = 6
表达式 ++i 的值也是:
6
例如:
int i = 5;
int x = ++i;
最终:
i = 6
x = 6
i++
叫做后置自增:
i++;
执行过程可以理解成:
i = 5
↓
先保存原来的5
↓
i加1
↓
i = 6
↓
表达式的值 = 5
所以:
int i = 5;
int x = i++;
最终:
i = 6
x = 5
2. 为什么 for 循环里两者看起来完全一样?
例如:
for (int i = 0; i < 5; ++i) {
cout << i << " ";
}
输出:
0 1 2 3 4
换成:
for (int i = 0; i < 5; i++) {
cout << i << " ";
}
还是:
0 1 2 3 4
因为这里根本没有使用:
++i
或者:
i++
这个表达式本身的返回值。
我们只关心:
i加1
所以两者最终效果一样。
3. 那为什么很多算法程序员还是习惯写 ++i?
这里才是重点。
因为这个习惯不仅适用于:
int i
还适用于:
vector<int>::iterator it
这种迭代器。
例如:
for (auto it = v.begin(); it != v.end(); ++it)
通常会优先写:
++it
而不是:
it++
原因与前置自增和后置自增的实现语义有关。
4. i++ 为什么可能产生额外工作?
对于简单的:
int i;
编译器非常聪明。
现代编译器优化之后:
++i;
和:
i++;
在这种循环里通常生成完全一样的机器代码。
所以不要认为:
++i一定比i++快。
对于 int 循环变量,这通常是没有意义的性能差异。
但是假设:
auto it = v.begin();
这里的 it 是一个迭代器对象。
++it 可以理解为:
it自身向后移动
而 it++ 的语义要求:
保存旧的it
↓
it自身向后移动
↓
返回旧的it
因为:
auto old = it++;
要求 old 是自增之前的迭代器。
因此后置自增在语义上需要保留旧值。
5. 用一个简单类比理解
本节目录
假设你站在:
第5格
++i
相当于:
先走到第6格,然后告诉我你现在在哪。
5 → 6
↑
返回6
i++
相当于:
先告诉我你原来在第5格,然后自己走到第6格。
5 → 6
↑
先返回5
所以:
++i
不需要关心旧值。
而:
i++
必须保留"旧值"这个概念,因为它可能被使用。
6. 这就是为什么算法代码里经常看到
for (auto it = v.begin(); it != v.end(); ++it)
而不是:
for (auto it = v.begin(); it != v.end(); it++)
因为这里我们只是:
让迭代器往后移动。
根本不需要:
自增之前的那个迭代器。
所以写:
++it
更加符合语义。
7. 那是不是 i++ 就不好?
不是。
如果你确实需要"自增之前的值",就应该使用:
i++
例如:
int i = 5;
int x = i++;
你明确需要:
x = 5
i = 6
这时候就应该写:
i++
而如果写:
int x = ++i;
结果就变成:
x = 6
i = 6
语义完全不同。
8. 一个很重要的面试点:不要说"++i 比 i++ 快"
如果面试官问:
为什么很多 C++ 程序员喜欢写
++i?
不要直接回答:
因为
++i比i++快。
这个回答太绝对。
更准确的是:
对于内置整数类型,在现代编译器优化下,
++i和i++在典型for循环中通常没有可观察的性能差异。但对于迭代器等自定义类型,后置自增为了返回自增前的值,可能需要创建或保存临时对象,因此在不需要旧值时通常推荐使用前置自增。
这个回答就比较完整。
9. 再往底层一点:为什么自定义类型差异更明显?
假设我们自己写一个类:
class MyInt {
public:
MyInt& operator++() {
++value;
return *this;
}
MyInt operator++(int) {
MyInt old = *this;
++value;
return old;
}
private:
int value;
};
这里:
++x
对应:
operator++()
而:
x++
对应:
operator++(int)
注意这里的:
int
不是说操作的是整数。
它只是 C++ 用来区分:
前置 ++
后置 ++
的一个标记。
后置版本需要:
MyInt old = *this;
然后:
++value;
最后:
return old;
所以它确实可能产生额外的对象复制。
10. 最终你应该形成这个判断
本节目录
看到:
++i
不要理解成:
"这是算法题固定写法。"
而应该理解成:
如果我只需要让
i加 1,不需要使用加 1 之前的值,那么前置自增是更自然、更通用的写法。
因此:
for (int i = 0; i < n; ++i)
是很好的习惯。
但:
int x = i++;
也是完全正确、而且必要的。
面试一句话
++i是前置自增,先自增再返回;i++是后置自增,先返回旧值再自增。对于int循环变量,两者通常没有性能差异;但对于迭代器等自定义类型,在不需要旧值时优先使用++i,因为后置自增可能需要保存旧对象。
第二部分:算法与数据结构
C++ 手搓常见数据结构
本节目录
-
[十一、Trie 前缀树](#十一、Trie 前缀树)
一、总结:快速复习
本节目录
-
[1. 面试常见实现范围](#1. 面试常见实现范围)
-
[2. 推荐练习顺序](#2. 推荐练习顺序)
1. 面试常见实现范围
| 数据结构 | 底层核心 | 常考操作 | 关键复杂度 |
|---|---|---|---|
| 动态数组 | 连续内存、扩容 | 增删查改 | 随机访问 O(1),尾插均摊 O(1) |
| 单链表 | 节点和指针 | 插入、删除、反转 | 已知位置插删 O(1),查找 O(n) |
| 双向链表 | prev、next |
双向插删 | 已知节点插删 O(1) |
| 栈 | 数组或链表 | push、pop、top |
通常 O(1) |
| 队列 | 链表或循环数组 | push、pop、front |
通常 O(1) |
| 二叉搜索树 | 左小右大 | 查找、插入、删除 | 平均 O(log n),最坏 O(n) |
| 二叉堆 | 完全二叉树、数组 | 入堆、出堆 | 堆顶 O(1),调整 O(log n) |
| 哈希表 | 哈希函数、冲突处理 | 插入、查找、删除 | 平均 O(1),最坏 O(n) |
| 并查集 | 父节点数组 | 合并、判同集合 | 优化后接近 O(1) |
| Trie | 字符路径树 | 字符串插入、前缀查询 | O(L),L 为字符串长度 |
2. 推荐练习顺序
text
栈、队列
→ 单链表、双向链表
→ 动态数组、循环队列
→ 二叉搜索树、堆
→ 哈希表
→ 并查集、Trie
面试手写时应优先保证:
- 明确空结构和边界条件;
- 插入、删除后结构仍然合法;
- 动态内存最终能够释放;
- 先说复杂度,再写代码;
- 不要为了模拟完整 STL 而加入无关功能。
二、动态数组
动态数组的核心是:使用一块连续内存保存元素;空间不足时申请更大的内存,搬运旧元素,再释放旧空间。
下面是面试用的 int 简化版:
cpp
#include <cstddef>
#include <stdexcept>
class DynamicArray {
public:
DynamicArray() = default;
~DynamicArray()
{
delete[] data_;
}
// 禁止默认浅拷贝,避免两个对象释放同一块内存。
DynamicArray(const DynamicArray&) = delete;
DynamicArray& operator=(const DynamicArray&) = delete;
void pushBack(int value)
{
if (size_ == capacity_) {
reserve(capacity_ == 0 ? 1 : capacity_ * 2);
}
data_[size_++] = value;
}
void popBack()
{
if (empty()) {
throw std::out_of_range("array is empty");
}
--size_;
}
int& operator[](std::size_t index)
{
return data_[index]; // 模仿 operator[],不做越界检查
}
const int& operator[](std::size_t index) const
{
return data_[index];
}
int& at(std::size_t index)
{
if (index >= size_) {
throw std::out_of_range("index out of range");
}
return data_[index];
}
std::size_t size() const { return size_; }
std::size_t capacity() const { return capacity_; }
bool empty() const { return size_ == 0; }
private:
void reserve(std::size_t newCapacity)
{
if (newCapacity <= capacity_) {
return;
}
int* newData = new int[newCapacity];
for (std::size_t i = 0; i < size_; ++i) {
newData[i] = data_[i];
}
delete[] data_;
data_ = newData;
capacity_ = newCapacity;
}
int* data_ = nullptr;
std::size_t size_ = 0;
std::size_t capacity_ = 0;
};
关键点:
size表示已有元素数量,capacity表示已申请空间能容纳的元素数量;- 扩容一次是
O(n),但按倍数扩容后,连续多次尾插的均摊复杂度是O(1); - 扩容会更换底层内存,旧指针、引用和迭代器可能失效;
- 完整泛型容器还需要处理对象构造、析构、移动、异常安全和拷贝控制。
三、单链表
单链表节点只保存数据和后继指针,内存不要求连续。
cpp
#include <cstddef>
class SinglyLinkedList {
private:
struct Node {
int value;
Node* next;
Node(int v, Node* n = nullptr) : value(v), next(n) {}
};
public:
~SinglyLinkedList()
{
clear();
}
SinglyLinkedList(const SinglyLinkedList&) = delete;
SinglyLinkedList& operator=(const SinglyLinkedList&) = delete;
void pushFront(int value)
{
head_ = new Node(value, head_);
++size_;
}
void pushBack(int value)
{
Node* node = new Node(value);
if (tail_ == nullptr) {
head_ = tail_ = node;
} else {
tail_->next = node;
tail_ = node;
}
++size_;
}
bool eraseFirst(int value)
{
Node* previous = nullptr;
Node* current = head_;
while (current != nullptr && current->value != value) {
previous = current;
current = current->next;
}
if (current == nullptr) {
return false;
}
if (previous == nullptr) {
head_ = current->next; // 删除头节点
} else {
previous->next = current->next;
}
if (current == tail_) {
tail_ = previous;
}
delete current;
--size_;
return true;
}
void reverse()
{
Node* previous = nullptr;
Node* current = head_;
tail_ = head_;
while (current != nullptr) {
Node* next = current->next; // 先保存后继,避免断链后丢失
current->next = previous;
previous = current;
current = next;
}
head_ = previous;
}
void clear()
{
while (head_ != nullptr) {
Node* next = head_->next;
delete head_;
head_ = next;
}
tail_ = nullptr;
size_ = 0;
}
std::size_t size() const { return size_; }
bool empty() const { return head_ == nullptr; }
private:
Node* head_ = nullptr;
Node* tail_ = nullptr;
std::size_t size_ = 0;
};
易错点:删除头节点、删除尾节点、删除唯一节点、反转时提前保存 next、析构时释放全部节点。
四、双向链表
双向链表每个节点同时保存前驱和后继。使用头尾哨兵节点可以减少边界判断。
cpp
class DoublyLinkedList {
private:
struct Node {
int value = 0;
Node* prev = nullptr;
Node* next = nullptr;
};
public:
DoublyLinkedList()
{
head_.next = &tail_;
tail_.prev = &head_;
}
DoublyLinkedList(const DoublyLinkedList&) = delete;
DoublyLinkedList& operator=(const DoublyLinkedList&) = delete;
~DoublyLinkedList()
{
while (!empty()) {
popFront();
}
}
void pushFront(int value)
{
insertAfter(&head_, value);
}
void pushBack(int value)
{
insertAfter(tail_.prev, value);
}
void popFront()
{
if (!empty()) {
erase(head_.next);
}
}
void popBack()
{
if (!empty()) {
erase(tail_.prev);
}
}
bool empty() const
{
return head_.next == &tail_;
}
private:
void insertAfter(Node* position, int value)
{
Node* node = new Node{value, position, position->next};
position->next->prev = node;
position->next = node;
}
void erase(Node* node)
{
node->prev->next = node->next;
node->next->prev = node->prev;
delete node;
}
Node head_; // 哨兵,不保存有效数据
Node tail_; // 哨兵,不保存有效数据
};
双向链表删除节点时要同时修改两条链:
text
node.prev.next = node.next
node.next.prev = node.prev
五、栈
栈满足后进先出(LIFO)。下面使用动态数组实现:
cpp
#include <stdexcept>
#include <vector>
class Stack {
public:
void push(int value)
{
data_.push_back(value);
}
void pop()
{
if (empty()) {
throw std::out_of_range("stack is empty");
}
data_.pop_back();
}
int& top()
{
if (empty()) {
throw std::out_of_range("stack is empty");
}
return data_.back();
}
bool empty() const { return data_.empty(); }
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_;
};
如果题目要求不能使用 vector,可直接复用前面的 DynamicArray,或者用链表头作为栈顶。
六、循环队列
普通数组队列若每次出队都搬移元素,出队会变成 O(n)。循环队列让头尾下标在数组中循环,入队和出队均为 O(1)。
cpp
#include <cstddef>
#include <stdexcept>
class CircularQueue {
public:
explicit CircularQueue(std::size_t capacity)
: data_(new int[capacity]), capacity_(capacity)
{
if (capacity == 0) {
delete[] data_;
data_ = nullptr;
throw std::invalid_argument("capacity must be positive");
}
}
~CircularQueue()
{
delete[] data_;
}
CircularQueue(const CircularQueue&) = delete;
CircularQueue& operator=(const CircularQueue&) = delete;
void push(int value)
{
if (size_ == capacity_) {
throw std::overflow_error("queue is full");
}
data_[tail_] = value;
tail_ = (tail_ + 1) % capacity_;
++size_;
}
void pop()
{
if (empty()) {
throw std::out_of_range("queue is empty");
}
head_ = (head_ + 1) % capacity_;
--size_;
}
int& front()
{
if (empty()) {
throw std::out_of_range("queue is empty");
}
return data_[head_];
}
bool empty() const { return size_ == 0; }
std::size_t size() const { return size_; }
private:
int* data_ = nullptr;
std::size_t capacity_ = 0;
std::size_t head_ = 0; // 队首元素下标
std::size_t tail_ = 0; // 下一个入队位置
std::size_t size_ = 0;
};
这里额外保存 size_,用于区分 head_ == tail_ 时究竟是空队列还是满队列。
七、二叉搜索树
二叉搜索树(BST)满足:左子树都小于当前节点,右子树都大于当前节点。本实现忽略重复值。
cpp
class BinarySearchTree {
private:
struct Node {
int value;
Node* left = nullptr;
Node* right = nullptr;
explicit Node(int v) : value(v) {}
};
public:
BinarySearchTree() = default;
~BinarySearchTree()
{
destroy(root_);
}
BinarySearchTree(const BinarySearchTree&) = delete;
BinarySearchTree& operator=(const BinarySearchTree&) = delete;
void insert(int value)
{
Node** current = &root_;
while (*current != nullptr) {
if (value < (*current)->value) {
current = &((*current)->left);
} else if (value > (*current)->value) {
current = &((*current)->right);
} else {
return;
}
}
*current = new Node(value);
}
bool contains(int value) const
{
Node* current = root_;
while (current != nullptr) {
if (value == current->value) {
return true;
}
current = value < current->value ? current->left : current->right;
}
return false;
}
void erase(int value)
{
root_ = erase(root_, value);
}
private:
static Node* erase(Node* node, int value)
{
if (node == nullptr) {
return nullptr;
}
if (value < node->value) {
node->left = erase(node->left, value);
} else if (value > node->value) {
node->right = erase(node->right, value);
} else {
if (node->left == nullptr) {
Node* right = node->right;
delete node;
return right;
}
if (node->right == nullptr) {
Node* left = node->left;
delete node;
return left;
}
// 两个孩子:用右子树最小节点的值替换当前值,再删除后继节点。
Node* successor = node->right;
while (successor->left != nullptr) {
successor = successor->left;
}
node->value = successor->value;
node->right = erase(node->right, successor->value);
}
return node;
}
static void destroy(Node* node)
{
if (node == nullptr) {
return;
}
destroy(node->left);
destroy(node->right);
delete node;
}
Node* root_ = nullptr;
};
删除分三种情况:叶子节点、只有一个孩子、同时有两个孩子。普通 BST 在有序输入下可能退化成链表;要保证 O(log n),需要 AVL 树或红黑树等平衡机制。
八、大根堆
二叉堆逻辑上是完全二叉树,物理上使用连续数组。下标从 0 开始时:
text
父节点:(i - 1) / 2
左孩子:2 * i + 1
右孩子:2 * i + 2
cpp
#include <stdexcept>
#include <utility>
#include <vector>
class MaxHeap {
public:
void push(int value)
{
heap_.push_back(value);
siftUp(heap_.size() - 1);
}
void pop()
{
if (empty()) {
throw std::out_of_range("heap is empty");
}
heap_[0] = heap_.back();
heap_.pop_back();
if (!empty()) {
siftDown(0);
}
}
int top() const
{
if (empty()) {
throw std::out_of_range("heap is empty");
}
return heap_[0];
}
bool empty() const { return heap_.empty(); }
private:
void siftUp(std::size_t index)
{
while (index > 0) {
std::size_t parent = (index - 1) / 2;
if (heap_[parent] >= heap_[index]) {
break;
}
std::swap(heap_[parent], heap_[index]);
index = parent;
}
}
void siftDown(std::size_t index)
{
while (true) {
std::size_t largest = index;
std::size_t left = index * 2 + 1;
std::size_t right = index * 2 + 2;
if (left < heap_.size() && heap_[left] > heap_[largest]) {
largest = left;
}
if (right < heap_.size() && heap_[right] > heap_[largest]) {
largest = right;
}
if (largest == index) {
break;
}
std::swap(heap_[index], heap_[largest]);
index = largest;
}
}
std::vector<int> heap_;
};
入堆从末尾向上调整,出堆用末尾元素覆盖堆顶后向下调整。
九、哈希表:拉链法
哈希表通过哈希函数把键映射到桶。不同键映射到同一桶称为哈希冲突;拉链法让每个桶保存一条链表。
cpp
#include <cstddef>
#include <functional>
#include <stdexcept>
#include <string>
#include <vector>
class HashMap {
private:
struct Node {
std::string key;
int value;
Node* next;
};
public:
explicit HashMap(std::size_t bucketCount = 16)
: buckets_(bucketCount, nullptr)
{
if (bucketCount == 0) {
throw std::invalid_argument("bucket count must be positive");
}
}
~HashMap()
{
for (Node* head : buckets_) {
while (head != nullptr) {
Node* next = head->next;
delete head;
head = next;
}
}
}
HashMap(const HashMap&) = delete;
HashMap& operator=(const HashMap&) = delete;
void put(const std::string& key, int value)
{
std::size_t index = bucketIndex(key);
for (Node* node = buckets_[index]; node != nullptr; node = node->next) {
if (node->key == key) {
node->value = value;
return;
}
}
buckets_[index] = new Node{key, value, buckets_[index]};
++size_;
}
int* find(const std::string& key)
{
std::size_t index = bucketIndex(key);
for (Node* node = buckets_[index]; node != nullptr; node = node->next) {
if (node->key == key) {
return &node->value;
}
}
return nullptr;
}
bool erase(const std::string& key)
{
std::size_t index = bucketIndex(key);
Node** current = &buckets_[index];
while (*current != nullptr) {
if ((*current)->key == key) {
Node* deleted = *current;
*current = deleted->next;
delete deleted;
--size_;
return true;
}
current = &((*current)->next);
}
return false;
}
private:
std::size_t bucketIndex(const std::string& key) const
{
return std::hash<std::string>{}(key) % buckets_.size();
}
std::vector<Node*> buckets_;
std::size_t size_ = 0;
};
工程版还应根据负载因子扩容并重新散列。桶太少时链表会变长,性能会逐渐退化为 O(n)。
十、并查集
并查集用于维护多个不相交集合,最常见操作是查询根节点和合并两个集合。
cpp
#include <numeric>
#include <utility>
#include <vector>
class DisjointSetUnion {
public:
explicit DisjointSetUnion(int n) : parent_(n), size_(n, 1)
{
std::iota(parent_.begin(), parent_.end(), 0);
}
int find(int x)
{
if (parent_[x] != x) {
parent_[x] = find(parent_[x]); // 路径压缩
}
return parent_[x];
}
bool unite(int a, int b)
{
int rootA = find(a);
int rootB = find(b);
if (rootA == rootB) {
return false;
}
// 小树挂到大树下,降低树高。
if (size_[rootA] < size_[rootB]) {
std::swap(rootA, rootB);
}
parent_[rootB] = rootA;
size_[rootA] += size_[rootB];
return true;
}
bool connected(int a, int b)
{
return find(a) == find(b);
}
private:
std::vector<int> parent_;
std::vector<int> size_;
};
路径压缩配合按大小或按秩合并后,单次操作的均摊复杂度是 O(α(n)),可以近似看作常数时间。常见应用包括连通性判断、Kruskal 最小生成树和动态合并集合。
十一、Trie 前缀树
Trie 把字符串的每个字符作为一层路径,适合前缀查询。下面只处理小写英文字母:
cpp
#include <string>
class Trie {
private:
struct Node {
Node* children[26]{};
bool isWord = false;
};
public:
Trie() : root_(new Node) {}
Trie(const Trie&) = delete;
Trie& operator=(const Trie&) = delete;
~Trie()
{
destroy(root_);
}
void insert(const std::string& word)
{
Node* current = root_;
for (char ch : word) {
int index = ch - 'a';
if (current->children[index] == nullptr) {
current->children[index] = new Node;
}
current = current->children[index];
}
current->isWord = true;
}
bool search(const std::string& word) const
{
const Node* node = findPrefix(word);
return node != nullptr && node->isWord;
}
bool startsWith(const std::string& prefix) const
{
return findPrefix(prefix) != nullptr;
}
private:
const Node* findPrefix(const std::string& text) const
{
const Node* current = root_;
for (char ch : text) {
int index = ch - 'a';
if (current->children[index] == nullptr) {
return nullptr;
}
current = current->children[index];
}
return current;
}
static void destroy(Node* node)
{
if (node == nullptr) {
return;
}
for (Node* child : node->children) {
destroy(child);
}
delete node;
}
Node* root_;
};
Trie 查询复杂度只与字符串长度有关,但每个节点保存多个孩子指针,可能消耗较多空间。字符集较大时可使用哈希表保存实际存在的孩子。
十二、面试手写时的统一检查清单
本节目录
-
[1. 内存与所有权](#1. 内存与所有权)
-
[2. 边界条件](#2. 边界条件)
-
[3. 不变量](#3. 不变量)
-
[4. 高频面试追问](#4. 高频面试追问)
1. 内存与所有权
new出来的节点是否最终delete;- 类是否会被默认浅拷贝;
- 删除节点前是否保存了仍需使用的指针;
- 返回的指针或引用是否可能悬空。
2. 边界条件
- 空结构;
- 只有一个元素;
- 删除头部或尾部;
- 数组容量为零或已满;
- 树只有一个孩子或两个孩子;
- 插入重复键和值。
3. 不变量
所谓不变量,是每次操作完成后必须继续成立的结构规则:
- 动态数组:
size <= capacity; - 链表:尾节点的
next == nullptr; - 循环队列:头尾下标始终处于合法范围;
- BST:左小右大;
- 大根堆:父节点不小于孩子;
- 并查集:根节点的父节点是自身。
4. 高频面试追问
vector为什么按倍数扩容?尾插为什么是均摊O(1)?- 如何在
O(1)时间删除链表中一个已知节点? - 循环队列如何区分空和满?
- BST 删除有两个孩子的节点时如何处理?
- 建堆为什么可以做到
O(n),而不是O(n log n)? - 哈希冲突有哪些解决方法?什么时候需要扩容?
- 并查集的路径压缩和按秩合并分别解决什么问题?
- Trie 为什么查询快,空间为什么可能很大?
第三部分:计算机组成与数据表示
数制与二进制运算
本节目录
二进制与其他进制之间的转换
本节目录
-
[一个容易混淆的点:0x、0b、0 是什么?](#一个容易混淆的点:0x、0b、0 是什么?)
-
[建议你现在直接做 6 道题](#建议你现在直接做 6 道题)
一、总结:快速复习
一句话:
进制转换的核心就是:整数部分除基取余、倒序读;小数部分乘基取整、正序读。
常见进制:
| 进制 | 基数 | 使用的数字 |
|---|---|---|
| 二进制 | 2 | 0、1 |
| 八进制 | 8 | 0~7 |
| 十进制 | 10 | 0~9 |
| 十六进制 | 16 | 0~9、A~F |
最重要的转换规则
① 十进制整数 → 其他进制
不断除以目标进制
↓
记录余数
↓
从最后一个余数往前读
② 十进制小数 → 其他进制
不断乘以目标进制
↓
记录整数部分
↓
从第一个整数部分往后读
③ 二进制 → 八进制
3 bit 一组
④ 二进制 → 十六进制
4 bit 一组
这两个是计算机基础中非常重要的快速转换方法。
二、详细讲解:深入理解
本节目录
-
[1. 为什么不同进制能够表示同一个数?](#1. 为什么不同进制能够表示同一个数?)
-
[2. 二进制 → 十进制 ⭐](#2. 二进制 → 十进制 ⭐)
-
[3. 二进制 → 八进制 ⭐⭐⭐](#3. 二进制 → 八进制 ⭐⭐⭐)
-
[4. 如果二进制位数不是 3 的倍数怎么办?](#4. 如果二进制位数不是 3 的倍数怎么办?)
-
[5. 二进制 → 十六进制 ⭐⭐⭐⭐⭐](#5. 二进制 → 十六进制 ⭐⭐⭐⭐⭐)
-
[6. 为什么计算机特别喜欢十六进制?](#6. 为什么计算机特别喜欢十六进制?)
-
[7. 十进制 → 二进制](#7. 十进制 → 二进制)
-
[8. 十进制 → 十六进制](#8. 十进制 → 十六进制)
-
[9. 十进制小数 → 二进制 ⭐⭐⭐⭐](#9. 十进制小数 → 二进制 ⭐⭐⭐⭐)
-
[10. 为什么 0.1 转二进制会无限循环?](#10. 为什么 0.1 转二进制会无限循环?)
-
[11. 二进制小数 → 十进制](#11. 二进制小数 → 十进制)
-
[12. 十六进制 ↔ 二进制](#12. 十六进制 ↔ 二进制)
-
[13. 八进制 ↔ 二进制](#13. 八进制 ↔ 二进制)
-
[14. 十六进制 → 十进制](#14. 十六进制 → 十进制)
-
[15. 各种转换你可以整理成这一张图](#15. 各种转换你可以整理成这一张图)
1. 为什么不同进制能够表示同一个数?
先看十进制:
1234
它实际上不是简单的四个数字。
而是:
1 × 10³
+ 2 × 10²
+ 3 × 10¹
+ 4 × 10⁰
也就是:
1000 + 200 + 30 + 4
= 1234
二进制也是一样。
例如:
1011₂
实际上:
1 × 2³
+ 0 × 2²
+ 1 × 2¹
+ 1 × 2⁰
所以:
= 8 + 0 + 2 + 1
= 11₁₀
所以所谓"进制",本质就是每一位的权重不同。
2. 二进制 → 十进制 ⭐
本节目录
这是最基础的。例如:
101101₂
从右往左,权重依次是:
2⁰ 2¹ 2² 2³ 2⁴ 2⁵
所以:
101101₂
等于:
1×2⁵
+0×2⁴
+1×2³
+1×2²
+0×2¹
+1×2⁰
计算:
32 + 0 + 8 + 4 + 0 + 1
= 45
所以:
101101₂ = 45₁₀
记忆方法
二进制转十进制:按权展开,然后相加。
3. 二进制 → 八进制 ⭐⭐⭐
这个非常简单。因为:
8 = 2³
所以:
3 个二进制位正好对应 1 个八进制位。
对应关系:
| 二进制 | 八进制 |
|---|---|
000 |
0 |
001 |
1 |
010 |
2 |
011 |
3 |
100 |
4 |
101 |
5 |
110 |
6 |
111 |
7 |
例如:
101101₂
从右往左每三个分组:
101 101
分别:
101₂ = 5
101₂ = 5
所以:
101101₂ = 55₈
4. 如果二进制位数不是 3 的倍数怎么办?
例如:
1101011₂
从右往左分组:
1 | 101 | 011
最左边不够三个怎么办?左边补 0。
001 | 101 | 011
于是:
001 = 1
101 = 5
011 = 3
所以:
1101011₂ = 153₈
注意:
只在最高位补 0,不改变数值。
5. 二进制 → 十六进制 ⭐⭐⭐⭐⭐
这是计算机中最重要的进制转换之一。因为:
16 = 2⁴
所以:
4 个二进制位正好对应 1 个十六进制位。
对应关系:
| 二进制 | 十六进制 |
|---|---|
0000 |
0 |
0001 |
1 |
0010 |
2 |
0011 |
3 |
0100 |
4 |
0101 |
5 |
0110 |
6 |
0111 |
7 |
1000 |
8 |
1001 |
9 |
1010 |
A |
1011 |
B |
1100 |
C |
1101 |
D |
1110 |
E |
1111 |
F |
例如:
10110110₂
每4位分组:
1011 0110
分别:
1011 = B
0110 = 6
所以:
10110110₂ = B6₁₆
6. 为什么计算机特别喜欢十六进制?
因为:
1 个十六进制位 = 4 个二进制位
例如:
1111 1010 0011 1001
可以非常容易写成:
F A 3 9
也就是:
FA39
相比:
1111101000111001
十六进制明显更容易阅读。
所以你以后在:
- 内存地址
- 指针
- 汇编
- 机器码
- 十六进制编辑器
- 网络数据包
- RGB颜色值
- 位运算
中都会大量看到十六进制。
7. 十进制 → 二进制
本节目录
这也是必须掌握的。例如:
13₁₀
不断除以2:
13 ÷ 2 = 6 ... 1
6 ÷ 2 = 3 ... 0
3 ÷ 2 = 1 ... 1
1 ÷ 2 = 0 ... 1
把余数:
1 0 1 1
倒过来读:
1101
所以:
13₁₀ = 1101₂
为什么必须倒着读?
因为第一次得到的是:
13 % 2 = 1
这个 1 实际上对应的是:
2⁰
也就是最低位。
所以余数产生顺序:
低位 → 高位
而我们正常书写数字是:
高位 → 低位
因此必须倒序。
8. 十进制 → 十六进制
原理完全一样,只不过:
除以 16
例如:
255₁₀
255 ÷ 16 = 15 ... 15
15 ÷ 16 = 0 ... 15
余数:
15 = F
倒过来:
FF
所以:
255₁₀ = FF₁₆
9. 十进制小数 → 二进制 ⭐⭐⭐⭐
本节目录
这个和整数转换完全不一样。例如:
0.625₁₀
我们不断乘2:
0.625 × 2 = 1.25
取整数部分:
1
剩:
0.25
继续:
0.25 × 2 = 0.5
取:
0
继续:
0.5 × 2 = 1.0
取:
1
所以:
0.625₁₀ = 0.101₂
为什么小数是"乘基取整"?
因为整数部分是:
除以基数
而小数部分相当于不断提取:
2⁻¹
2⁻²
2⁻³
...
例如:
0.101₂
就是:
1×2⁻¹
+0×2⁻²
+1×2⁻³
也就是:
1/2 + 0 + 1/8
= 0.625
10. 为什么 0.1 转二进制会无限循环?
现在你就应该能理解上一题了。不断乘2:
0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0
...
所以:
0.1₁₀
=
0.00011001100110011...₂
因为出现:
0.2 → 0.4 → 0.8 → 0.6 → 0.2
循环了。这也是你刚刚问的:
为什么
0.1存不准确?
最底层的原因。
11. 二进制小数 → 十进制
反过来就更简单。例如:
0.1011₂
按照权展开:
1×2⁻¹
+0×2⁻²
+1×2⁻³
+1×2⁻⁴
也就是:
1/2 + 1/8 + 1/16
= 0.5 + 0.125 + 0.0625
= 0.6875
所以:
0.1011₂ = 0.6875₁₀
12. 十六进制 ↔ 二进制
这个你以后一定要非常熟。例如:
3A7F₁₆
每一个十六进制位直接换成4位二进制:
3 → 0011
A → 1010
7 → 0111
F → 1111
所以:
3A7F₁₆
=
0011 1010 0111 1111₂
反过来也一样:
1101 1010 1110 0011
每4位:
1101 → D
1010 → A
1110 → E
0011 → 3
所以:
1101101011100011₂
=
DAE3₁₆
13. 八进制 ↔ 二进制
原理一样,只不过:
1个八进制位 = 3个二进制位
例如:
725₈
直接:
7 → 111
2 → 010
5 → 101
得到:
725₈ = 111010101₂
14. 十六进制 → 十进制
例如:
2AF₁₆
展开:
2 × 16²
+ A × 16¹
+ F × 16⁰
其中:
A = 10
F = 15
所以:
2 × 256
+ 10 × 16
+ 15
= 512 + 160 + 15
= 687
因此:
2AF₁₆ = 687₁₀
15. 各种转换你可以整理成这一张图
十进制
↙ ↘
除基取余 乘基取整
↙ ↘
其他进制整数 其他进制小数
二进制 ←────────→ 八进制
↑ ↑
│ 3位一组 │
│ │
└───────────────→
二进制 ←────────→ 十六进制
↑ ↑
│ 4位一组 │
│ │
└───────────────→
真正需要背的其实就4句话:
十进制整数 → N进制:除 N 取余,倒序。
十进制小数 → N进制:乘 N 取整,正序。
二进制 ↔ 八进制:3 位一组。
二进制 ↔ 十六进制:4 位一组。
一个容易混淆的点:0x、0b、0 是什么?
在 C/C++ 中经常看到:
int a = 100; // 十进制
int b = 0b1100100; // 二进制
int c = 0144; // 八进制
int d = 0x64; // 十六进制
它们表示的是同一个数:
100₁₀
= 1100100₂
= 144₈
= 64₁₆
0x、0b 等只是告诉编译器:
"我后面写的这个数字采用什么进制。"
并不会改变这个数字本身的数值。
建议你现在直接做 6 道题
不要查资料,直接算:
cpp
1. 11010110₂ → 十进制 -> 214
2. 11010110₂ → 十六进制 -> D6
3. 347₁₀ → 二进制 -> 1101110111
4. 2F₁₆ → 十进制 -> 47
5. 0.1011₂ → 十进制 -> 11 / 16 = 0.6875
6. 0.625₁₀ → 二进制 -> 0.101
这 6 道刚好能检查你是否真正掌握了整数、小数、二进制、十六进制之间的转换。
二进制加减法
本节目录
一、总结:快速复习
二进制加减法和十进制本质一样,只不过每一位只有 0 和 1。
二进制加法最重要的 4 种情况
| 计算 | 结果 |
|---|---|
0 + 0 |
0 |
0 + 1 |
1 |
1 + 0 |
1 |
1 + 1 |
10 → 写 0,向前进 1 |
如果还有进位:
1 + 1 + 1 = 11
写 1,向前进 1。
二进制减法最重要的情况
| 计算 | 结果 |
|---|---|
0 - 0 |
0 |
1 - 0 |
1 |
1 - 1 |
0 |
0 - 1 |
借位 |
0 - 1 时,要从左边借一个 1:
10₂ - 1₂ = 1₂
也就是当前位得到:
2 - 1 = 1
一句话记忆
加法:逢二进一;减法:不够就借一,而借来的
1在当前位相当于2。
二、详细讲解:深入理解
本节目录
-
[1. 二进制加法](#1. 二进制加法)
-
[2. 例子:1011 + 0110](#2. 例子:1011 + 0110)
-
[3. 二进制减法](#3. 二进制减法)
-
[4. 例子:1010 - 0011](#4. 例子:1010 - 0011)
-
[5. 连续借位是二进制减法最容易错的地方](#5. 连续借位是二进制减法最容易错的地方)
-
[6. 二进制加减法和你刚才学的 IEEE 754 有什么关系?](#6. 二进制加减法和你刚才学的 IEEE 754 有什么关系?)
-
[7. 你现在可以练这 5 道](#7. 你现在可以练这 5 道)
1. 二进制加法
先看最简单的:
0 + 0 = 0
0 + 1 = 1
1 + 0 = 1
1 + 1 = 10
为什么:
1 + 1 = 10₂
因为:
10₂ = 2₁₀
所以和十进制:
9 + 1 = 10
完全类似。
区别只是:
十进制:满 10 进 1
二进制:满 2 进 1
2. 例子:1011 + 0110
本节目录
-
[第 1 位](#第 1 位)
-
[第 2 位](#第 2 位)
-
[第 3 位](#第 3 位)
-
[第 4 位](#第 4 位)
计算:
1011
+ 0110
------
从最低位开始:
第 1 位
1 + 0 = 1
得到:
1
第 2 位
1 + 1 = 10
当前位写:
0
向前进:
1
第 3 位
现在是:
0 + 1 + 1(进位)
= 10
所以写 0,继续进 1。
第 4 位
1 + 0 + 1
= 10
继续进 1。
最终:
1011
+ 0110
------
10001
验证:
1011₂ = 11
0110₂ = 6
11 + 6 = 17
17 = 10001₂
正确。
3. 二进制减法
最基本的:
1 - 0 = 1
1 - 1 = 0
0 - 0 = 0
最麻烦的是:
0 - 1
因为当前位不够减。
这时候需要向左边借 1。
但是注意:
二进制向左边借一个
1,当前位得到的是10₂,也就是十进制的2。
因此:
10₂ - 1₂ = 1₂
4. 例子:1010 - 0011
本节目录
-
[第 1 位](#第 1 位)
-
[第 2 位](#第 2 位)
计算:
1010
- 0011
------
从右边开始。
第 1 位
0 - 1
不够减。
向左边借 1:
10₂ - 1₂ = 1₂
所以当前位:
1
原来的左边那一位借走以后会减少 1。
第 2 位
现在原来这一位是:
1
刚才借给右边 1,所以变成:
0
现在:
0 - 1
又不够。
继续向左借。
于是当前位得到:
10₂ - 1₂ = 1₂
后面
继续:
1 - 0 = 1
0 - 0 = 0
最终:
1010
- 0011
------
0111
验证:
1010₂ = 10
0011₂ = 3
10 - 3 = 7
7 = 0111₂
正确。
5. 连续借位是二进制减法最容易错的地方
例如:
10000
-00001
第一位:
0 - 1
需要借。
但是左边也是 0:
10000
↑
所以需要一直向左找到第一个 1。
过程可以理解成:
10000
↓
01111
然后最低位:
1 - 1 = 0
所以:
10000
- 00001
-------
01111
也就是:
16 - 1 = 15
而:
15 = 01111₂
6. 二进制加减法和你刚才学的 IEEE 754 有什么关系?
这个其实很重要。
你刚刚学习:
13.5
↓
1101.1
↓
1.1011 × 2³
以后学习:
- 浮点数
- 补码
- CPU 运算
- ALU
- 位运算
- 二进制整数
都会经常遇到二进制加减。
尤其是 C/C++ 底层:
int a = 5;
int b = 3;
int c = a + b;
CPU 最终并不是直接"理解十进制的 5 和 3",而是处理它们对应的二进制编码。
7. 你现在可以练这 5 道
本节目录
-
[题 1](#题 1)
-
[题 2](#题 2)
-
[题 3](#题 3)
-
[题 4](#题 4)
-
[题 5](#题 5)
建议不要用计算器,直接手算。
题 1
1011 + 0101
题 2
1101 + 0111
题 3
1101 - 0101
题 4
10000 - 00001
题 5
稍微难一点:
101101
+ 11011
------
**先自己算,再把过程发给我。**我可以不只看最终答案,还能帮你检查你到底是在哪一步进位/借位出了问题。
大小端与字节序
本节目录
-
[大小端检测中 char*、signed char* 与 unsigned char* 的区别](#大小端检测中 char*、signed char* 与 unsigned char* 的区别)
一、总结:快速复习
一句话:
大小端(Endianness)描述的是一个多字节数据在内存中存放时,各个字节的排列顺序。
最常考的是:
| 类型 | 含义 | 低地址存什么 |
|---|---|---|
| 大端(Big-Endian) | 高位字节放低地址 | 高位字节 |
| 小端(Little-Endian) | 低位字节放低地址 | 低位字节 |
例如一个 int:
0x12345678
假设占4字节。
大端
低地址 → 高地址
┌────┬────┬────┬────┐
│ 12 │ 34 │ 56 │ 78 │
└────┴────┴────┴────┘
小端
低地址 → 高地址
┌────┬────┬────┬────┐
│ 78 │ 56 │ 34 │ 12 │
└────┴────┴────┴────┘
面试最重要的几个点
- 大小端只讨论多字节数据的字节排列顺序。
- 一个字节内部的 bit 顺序通常不讨论大小端。
- 主流 x86 / x86-64 CPU 使用小端。
- ARM 现代系统通常也使用小端,但 ARM 架构本身支持不同字节序配置。
- 大小端对
char这种 1字节类型没有区别。 - 网络协议经常规定使用大端(网络字节序)。
- C++ 本身不强制规定你的机器必须使用大端或小端。
二、详细讲解:深入理解
本节目录
-
[1. 先理解:什么叫"高位"和"低位"?](#1. 先理解:什么叫“高位”和“低位”?)
-
[2. 大端到底是什么意思?](#2. 大端到底是什么意思?)
-
[3. 小端是什么意思?](#3. 小端是什么意思?)
-
[4. 为什么会有大小端?](#4. 为什么会有大小端?)
-
[5. C++代码怎么判断机器是大端还是小端?](#5. C++代码怎么判断机器是大端还是小端?)
-
[6. 为什么这里使用 unsigned char*?](#6. 为什么这里使用 unsigned char*?)
-
[7. 为什么 char 不存在大小端问题?](#7. 为什么 char 不存在大小端问题?)
-
[8. 大小端和指针有没有关系?](#8. 大小端和指针有没有关系?)
-
[9. 为什么网络编程特别强调大小端?](#9. 为什么网络编程特别强调大小端?)
-
[10. C++网络编程中的 htonl / ntohl](#10. C++网络编程中的 htonl / ntohl)
-
[11. 大小端和"字节序"是什么关系?](#11. 大小端和“字节序”是什么关系?)
-
[12. 一个特别容易考的陷阱](#12. 一个特别容易考的陷阱)
-
[13. 和你刚才学的"变量占多少空间"联系起来](#13. 和你刚才学的“变量占多少空间”联系起来)
1. 先理解:什么叫"高位"和"低位"?
比如:
int x = 0x12345678;
int 假设是4字节:
0x12 0x34 0x56 0x78
↑ ↑
高位 低位
也就是:
最高有效字节(MSB) 最低有效字节(LSB)
↓ ↓
0x12 0x78
这里一定要分清:
高位/低位说的是数据本身;高地址/低地址说的是内存位置。
这是理解大小端最关键的一步。
2. 大端到底是什么意思?
大端:
把数据的高位字节放到低地址。
例如:
数据:
0x12345678
拆成:
12 34 56 78
如果内存地址从低到高:
地址:
0x1000
0x1001
0x1002
0x1003
大端:
地址 数据
0x1000 0x12
0x1001 0x34
0x1002 0x56
0x1003 0x78
所以:
低地址
↓
12 34 56 78
↓
高地址
记忆:
大端:大的(高位)放前面。
3. 小端是什么意思?
小端:
把数据的低位字节放到低地址。
还是:
0x12345678
低位字节是:
0x78
所以:
地址 数据
0x1000 0x78
0x1001 0x56
0x1002 0x34
0x1003 0x12
也就是:
低地址
↓
78 56 34 12
↓
高地址
记忆:
小端:小的(低位)放前面。
4. 为什么会有大小端?
这其实是一个多字节数据如何映射到连续内存地址的问题。
CPU看到:
int x = 0x12345678;
逻辑上它是一个整体。
但是内存本质上是:
字节
字节
字节
字节
...
所以CPU必须决定:
0x12345678
↓ 拆成4个Byte
12 34 56 78
↓ 怎么放进内存?
有两种主要方式:
大端:
12 → 34 → 56 → 78
小端:
78 → 56 → 34 → 12
所以:
大小端不是数据内容发生变化,而是同一个多字节数据在内存中的字节排列方式不同。
5. C++代码怎么判断机器是大端还是小端?
这是面试非常经典的一道题。
以下代码太新,仅供参考,后续有一部分单独拎出来讲了多种方法,大家可以移步
可以利用:
int x = 0x12345678;
然后查看它的第一个字节。
#include <iostream>
int main()
{
int x = 0x12345678;
unsigned char* p =
reinterpret_cast<unsigned char*>(&x);
std::cout << std::hex
<< static_cast<int>(p[0])
<< '\n';
}
如果输出:
78
说明:
低地址 → 低位字节
所以是:
小端。
如果输出:
12
则是:
大端。
6. 为什么这里使用 unsigned char*?
这里也有一个很重要的 C++ 知识点。
unsigned char* p =
reinterpret_cast<unsigned char*>(&x);
我们不是想把 x 当成另一个整数。
我们是想:
逐字节观察
x在内存中的实际表示。
因为:
sizeof(int)
假设是:
4
所以可以把它看成:
unsigned char[4]
概念上:
x
┌────┬────┬────┬────┐
│ ? │ ? │ ? │ ? │
└────┴────┴────┴────┘
↑
p[0]
然后:
p[0]
p[1]
p[2]
p[3]
依次观察4个字节。
7. 为什么 char 不存在大小端问题?
例如:
char c = 'A';
char:
sizeof(char) == 1
只有一个字节。
没有:
高位字节
低位字节
之间的排列问题。
所以:
大小端只对多字节对象的字节排列有意义。
例如:
char → 1 B → 不涉及
short → 2 B → 涉及
int → 4 B → 涉及
long long → 8 B → 涉及
double → 8 B → 涉及
8. 大小端和指针有没有关系?
本节目录
假设:
int x = 0x12345678;
int* p = &x;
这里其实有两个完全不同的概念。
x
0x12345678
它是一个4字节数据。
所以会涉及:
12 34 56 78
如何排列。
p
p保存的是一个地址。
例如假设:
p = 0x00000123456789AB
那么这个地址本身如果作为一个多字节整数存储,也存在字节序问题。
所以:
指针大小和大小端是两个不同概念。
指针在64位系统通常是8字节,但具体内存中的字节排列仍然取决于平台的字节序。
9. 为什么网络编程特别强调大小端?
这是面试非常高频的关联。
假设:
客户端:小端机器
服务器:大端机器
客户端发送:
0x12345678
如果直接按照本机内存布局发送4个字节:
78 56 34 12
服务器按照大端理解:
78 56 34 12
↓
0x78563412
数据就错了。
所以网络协议通常规定:
网络字节序使用大端。 ---> 其实这就是各个部分会规定各种规则的原因,都是为了更好的沟通共和防止数据失误,根据这个也就能想通为啥有传输协议
于是发送前:
主机字节序
↓
转换
↓
网络字节序(大端)
接收后:
网络字节序
↓
转换
↓
主机字节序
10. C++网络编程中的 htonl / ntohl
在Socket编程中经常看到:
htonl()
htons()
ntohl()
ntohs()
它们就是处理主机字节序和网络字节序的。
| 函数 | 含义 |
|---|---|
htonl |
host → network,32位 |
htons |
host → network,16位 |
ntohl |
network → host,32位 |
ntohs |
network → host,16位 |
例如:
uint32_t x = 0x12345678;
uint32_t y = htonl(x);
这里的目的就是:
主机字节序
↓
网络字节序
注意:
如果你的机器本来就是大端,那么转换可能实际上不需要交换字节;如果是小端机器,则需要进行字节交换。
11. 大小端和"字节序"是什么关系?
你可以把:
大小端
直接理解成:
字节序(Byte Order)
例如:
0x12345678
拆成:
12 | 34 | 56 | 78
然后决定:
哪个字节放低地址
哪个字节放高地址
就是字节序。
12. 一个特别容易考的陷阱
面试官可能给你:
int a = 1;
然后问:
小端机器中内存是什么?
假设 int = 4 B。
1:
0x00000001
小端:
低地址
↓
01 00 00 00
↓
高地址
大端:
低地址
↓
00 00 00 01
↓
高地址
所以如果你看到:
01 00 00 00
通常可以判断:
小端。
13. 和你刚才学的"变量占多少空间"联系起来
你刚刚问了 C++ 各种类型占多少空间,这两个知识点其实可以连起来。
例如:
int x = 0x12345678;
如果:
sizeof(int) = 4
说明它占:
4个字节
但这4个字节怎么排列?
这就由字节序决定:
大端:
地址低 → 地址高
12 34 56 78
小端:
地址低 → 地址高
78 56 34 12
所以要把两个概念区分开:
sizeof解决"占多少字节";大小端解决"这些字节怎么排列"。
这个关系非常重要。
面试中容易追问
Q1:什么是大小端?
大小端描述多字节数据在内存中的字节排列方式。大端把高位字节放在低地址,小端把低位字节放在低地址。
Q2:x86是大端还是小端?
x86/x86-64采用小端字节序。
Q3:char有没有大小端?
没有实际意义,因为
char只有1字节。
Q4:为什么网络字节序使用大端?
为了统一不同主机的字节序,使不同架构的机器能够正确解释网络传输的数据。
Q5:如何用C++判断机器是大端还是小端?
核心思路:
int x = 0x12345678;
unsigned char* p =
reinterpret_cast<unsigned char*>(&x);
if (p[0] == 0x78)
// 小端
else if (p[0] == 0x12)
// 大端
Q6:大小端会影响变量的值吗?
不会直接改变变量的逻辑值。
例如:
0x12345678
在大端和小端机器上,程序正常读取它时都是:
0x12345678
区别只是:
它的各个字节在内存中的排列不同。
这也是大小端最核心的理解。
判断机器大小端的常见方法(C/C++)
本节目录
一、总结:快速复习
验证一台机器是大端还是小端,核心思想只有一句话:
把一个多字节数据放进内存,然后观察它最低地址处存的是高位字节还是低位字节。
常见方法有:
- 指针强制转换法 ------ 最经典,C/C++都能用,面试首选。
- 联合体
union法 ------ 代码非常短,也是经典面试题。 - 逐字节打印法 ------ 直接观察内存,最直观。
- 位运算 + 内存观察 ------ 可以进一步理解"数值"和"内存表示"的区别。
- C++20
std::endian------ C++专用,最规范,但不是纯 C。
最推荐你面试记住前两个:
int x = 0x12345678
↓ 查看最低地址的一个字节
0x78 → 小端
0x12 → 大端
注意:不能通过 printf("%x", x) 直接判断大小端。
因为打印的是变量的数值 ,而大小端讨论的是它在内存中的字节排列。
二、详细讲解:深入理解
本节目录
-
[1. 方法一:指针强制转换法 ⭐⭐⭐⭐⭐](#1. 方法一:指针强制转换法 ⭐⭐⭐⭐⭐)
-
[2. 为什么使用 unsigned char *?](#2. 为什么使用 unsigned char *?)
-
[3. 方法二:union 联合体法 ⭐⭐⭐⭐⭐](#3. 方法二:union 联合体法 ⭐⭐⭐⭐⭐)
-
[4. 为什么 union 能做到?](#4. 为什么 union 能做到?)
-
[5. 指针法和 union 法有什么区别?](#5. 指针法和 union 法有什么区别?)
-
[6. 方法三:直接把4个字节全部打印出来](#6. 方法三:直接把4个字节全部打印出来)
-
[7. 方法四:不仅判断,还可以打印地址](#7. 方法四:不仅判断,还可以打印地址)
-
[8. 方法五:C++20 的 std::endian](#8. 方法五:C++20 的 std::endian)
-
[9. 一个容易混淆的问题:为什么不能这样?](#9. 一个容易混淆的问题:为什么不能这样?)
-
[10. 为什么 printf("%x", x) 也不能判断?](#10. 为什么 printf("%x", x) 也不能判断?)
-
[11. 还有一个非常经典的方法:用 short](#11. 还有一个非常经典的方法:用 short)
-
[12. 为什么不能用 char 判断?](#12. 为什么不能用 char 判断?)
-
[13. 最推荐你背下来的"面试版"](#13. 最推荐你背下来的“面试版”)
1. 方法一:指针强制转换法 ⭐⭐⭐⭐⭐
本节目录
这是我最推荐你掌握的方法。
代码
#include <stdio.h>
int main(void)
{
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
if (p[0] == 0x78)
printf("Little Endian\n");
else if (p[0] == 0x12)
printf("Big Endian\n");
return 0;
}
为什么这个方法可以?
首先:
int x = 0x12345678;
假设 int 是4字节。
数据可以拆成:
高位字节 低位字节
↓ ↓
┌────┬────┬────┬────┐
│ 12 │ 34 │ 56 │ 78 │
└────┴────┴────┴────┘
但是我们还不知道它们在内存中怎么排列。
如果是小端
小端:
低位字节放低地址。
所以:
低地址 → 高地址
┌────┬────┬────┬────┐
│ 78 │ 56 │ 34 │ 12 │
└────┴────┴────┴────┘
↑
p[0]
因此:
p[0] == 0x78
说明:
小端
如果是大端
大端:
高位字节放低地址。
所以:
低地址 → 高地址
┌────┬────┬────┬────┐
│ 12 │ 34 │ 56 │ 78 │
└────┴────┴────┴────┘
↑
p[0]
因此:
p[0] == 0x12
说明:
大端
2. 为什么使用 unsigned char *?
这是这个方法里一个很值得面试讲出来的细节。
unsigned char *p = (unsigned char *)&x;
原本:
&x
类型是:
int *
如果:
p = &x;
那么:
p[0]
代表的是整个一个 int。
而我们现在想看的不是整个 int,而是:
内存中的一个字节。
所以把它转换成:
unsigned char *
这样:
p[0]
p[1]
p[2]
p[3]
分别就是4个字节。
例如小端:
地址 内容
1000 78 ← p[0]
1001 56 ← p[1]
1002 34 ← p[2]
1003 12 ← p[3]
所以这个方法实际上是在做:
int
↓
unsigned char*
↓
逐字节观察内存
3. 方法二:union 联合体法 ⭐⭐⭐⭐⭐
这个方法也非常经典。
#include <stdio.h>
int main(void)
{
union {
int x;
unsigned char c;
} u;
u.x = 0x12345678;
if (u.c == 0x78)
printf("Little Endian\n");
else if (u.c == 0x12)
printf("Big Endian\n");
return 0;
}
4. 为什么 union 能做到?
本节目录
先理解 union。
union {
int x;
unsigned char c;
};
和 struct 不一样。
union 的成员共享同一块内存。
可以想象成:
同一块内存
┌──────────────────────┐
│ │
│ 0x12345678 │
│ │
└──────────────────────┘
↑ ↑
x c
其中:
x
把这块内存解释成:
int
而:
c
把同一块内存的开头解释成:
unsigned char
所以:
u.x = 0x12345678;
之后:
u.c
就可以观察最低地址的那个字节。
小端
内存:
78 56 34 12
↑
u.c
所以:
u.c == 0x78
大端
内存:
12 34 56 78
↑
u.c
所以:
u.c == 0x12
5. 指针法和 union 法有什么区别?
| 方法 | 优点 | 面试推荐 |
|---|---|---|
| 指针转换 | 原理最直观,明确观察内存 | ⭐⭐⭐⭐⭐ |
| union | 代码短,非常经典 | ⭐⭐⭐⭐⭐ |
| 逐字节打印 | 观察结果最直观 | ⭐⭐⭐⭐ |
C++ std::endian |
标准库方式 | ⭐⭐⭐ |
如果面试官问:
"怎么判断机器是大小端?"
你可以直接写:
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
if (p[0] == 0x78)
printf("Little Endian");
else
printf("Big Endian");
然后解释:
因为
unsigned char一次访问一个字节。如果最低地址存的是0x78,说明低位字节放在低地址,是小端;如果最低地址存的是0x12,说明高位字节放在低地址,是大端。
这个回答已经非常完整了。
6. 方法三:直接把4个字节全部打印出来
如果你想亲眼观察内存,可以这样写:
#include <stdio.h>
int main(void)
{
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
for (int i = 0; i < sizeof(x); ++i)
{
printf("%02X ", p[i]);
}
printf("\n");
return 0;
}
如果机器是小端:
78 56 34 12
如果机器是大端:
12 34 56 78
这个方法比只看 p[0] 更直观。
7. 方法四:不仅判断,还可以打印地址
这个方法特别适合你理解:
大小端到底和地址有什么关系。
#include <stdio.h>
int main(void)
{
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
for (int i = 0; i < sizeof(x); ++i)
{
printf("address = %p, value = 0x%02X\n",
(void *)(p + i),
p[i]);
}
return 0;
}
例如小端机器:
address = 0x1000, value = 0x78
address = 0x1001, value = 0x56
address = 0x1002, value = 0x34
address = 0x1003, value = 0x12
这时候你就能非常直观地看到:
低地址
↓
78
56
34
12
↑
高地址
所以:
78 = 低位字节
却放在:
低地址
因此:
小端。
8. 方法五:C++20 的 std::endian
如果是 C++20,可以直接使用:
#include <iostream>
#include <bit>
int main()
{
if (std::endian::native == std::endian::little)
std::cout << "Little Endian\n";
else if (std::endian::native == std::endian::big)
std::cout << "Big Endian\n";
else
std::cout << "Mixed Endian\n";
}
这是标准库提供的方式。
不过如果你是在准备:
C语言 / C++基础面试题
我反而更建议你把:
unsigned char *p = (unsigned char *)&x;
这个方法背熟。
因为它不仅能回答:
"怎么判断大小端?"
还可以继续解释:
"为什么可以这样判断?"
这就能体现你真的理解了内存布局。
9. 一个容易混淆的问题:为什么不能这样?
有人会写:
int x = 0x12345678;
if (x == 0x12345678)
printf("Little Endian");
这是错误的。
因为无论大小端:
x
这个变量的值都是:
0x12345678
大小端不会改变变量的逻辑数值。
它只影响:
这个数的各个字节在内存中的排列。
所以:
读取变量
↓
得到 0x12345678
和:
查看内存
↓
78 56 34 12
是两个不同层面的事情。
10. 为什么 printf("%x", x) 也不能判断?
例如:
int x = 0x12345678;
printf("%x\n", x);
不管机器是:
小端
还是:
大端
正常情况下你都会看到:
12345678
因为 printf 是按照整数的值来输出。
而不是把内存中的字节顺序直接打印出来。
真正要观察大小端:
必须进入"内存视角"
例如:
unsigned char *p = (unsigned char *)&x;
11. 还有一个非常经典的方法:用 short
不一定非要用 int。
例如:
short x = 0x1234;
unsigned char *p = (unsigned char *)&x;
printf("%02X\n", p[0]);
如果:
12
→ 大端。
如果:
34
→ 小端。
原理完全一样:
0x1234
↓
12 34
小端:
34 12
大端:
12 34
12. 为什么不能用 char 判断?
例如:
char c = 0x12;
没有意义。
因为:
char
= 1 Byte
只有一个字节:
12
根本没有:
12 34
这样的字节排列问题。
所以判断大小端一定要选择:
short
int
long
float
double
这类多字节类型。
13. 最推荐你背下来的"面试版"
如果面试官问:
如何判断当前机器是大端还是小端?
你可以这样回答:
我会定义一个多字节变量,例如
int x = 0x12345678,然后把它转换成unsigned char*,逐字节查看它的内存表示。如果最低地址存的是0x78,说明低位字节放在低地址,是小端;如果最低地址存的是0x12,说明高位字节放在低地址,是大端。C语言中也可以通过union实现同样的判断。
代码:
#include <stdio.h>
int main(void)
{
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
if (p[0] == 0x78)
printf("Little Endian\n");
else if (p[0] == 0x12)
printf("Big Endian\n");
return 0;
}
最后把这道题真正理解成一张图
int x
0x12345678
│
│ 转成 unsigned char*
↓
┌────┬────┬────┬────┐
低地址 → │ ? │ ? │ ? │ ? │ ← 高地址
└────┴────┴────┴────┘
↑
p[0]
如果 p[0] = 78
↓
低位字节在低地址
↓
小端
如果 p[0] = 12
↓
高位字节在低地址
↓
大端
这道题真正考的不是代码,而是你是否理解:
变量的值
≠
变量在内存中的字节排列
而大小端讨论的是后者。
大小端检测中 char*、signed char* 与 unsigned char* 的区别
本节目录
一、总结:快速复习
你的理解基本正确,而且这里确实有一个容易被教材写死的地方:
检测大小端的关键不是"必须转换成
unsigned char*",而是必须能够按字节观察同一个多字节对象在内存中的存储顺序。
因此:
unsigned char\*:最推荐、最规范地用于观察对象的原始字节。char\*:也可以观察对象的字节表示,做大小端检测通常完全可以。signed char\*:从"实际读取字节"这个角度也能观察,但不如unsigned char\*合适,因为读取出来的字节可能被解释成负数。- 真正需要注意的不是"有没有
unsigned",而是别通过不合适的类型去解引用导致未定义行为或产生额外问题。
例如最经典的代码:
int x = 1;
char* p = (char*)&x;
if (*p == 1)
printf("小端");
else
printf("大端");
在常见机器上,这种方法确实可以检测大小端。
但是更推荐:
int x = 1;
unsigned char* p = (unsigned char*)&x;
if (*p == 1)
printf("小端");
else
printf("大端");
原因主要是 unsigned char 更适合表示"原始字节"。
cpp
/***********************************测机器大小端***************************************/
/*
1
低地址 高地址(二进制)
小端:0001 0000 0000 0000
大端:0000 0000 0000 0001
0x123456
低地址 高地址(16进制)
小端:56 34 12 00
大端:00 12 34 56
*/
// 错误的,不管底层是大端还是小端,读出来的都是一样的(读取的都是数值低八位数,是低八位 数 (是数值转换, 因此不管是大端还是小端,这样读出来都是一样的),不是低八位 地址 (一个字节))
void test1()
{
int x = 0x123456;
char c = (char)x;
if ((int)c == 0x56)
cout << "小端" << endl;
else
cout << "大端" << endl;
}
// 正确
void test2()
{
int x = 0x123456;
// 正确,读取的是低八位地址(读取的是最低地址处的一个字节)
// 为了让代码在不同机器上更不容易出现符号问题,更好的写法是char写成unsigned char, char*写成unsigned char*
char* p = (char*)&x;
int c = (int)*p;
if (c == 0x56)
cout << "小端" << endl;
else
cout << "大端" << endl;
}
// 正确(用到了联合体中共享内存)
union U
{
int x;
char c;
};
void test3()
{
U u;
u.x = 1;
if (u.c)
cout << "小端" << endl;
else
cout << "大端" << endl;
}
int main()
{
test1();
test2();
test3();
return 0;
}
二、详细讲解:深入理解
本节目录
-
[1. 你说"直接转成 char* 也能检查"------对](#1. 你说“直接转成 char* 也能检查”——对)
-
[2. 为什么很多代码偏偏写 unsigned char*?](#2. 为什么很多代码偏偏写 unsigned char*?)
-
[3. 但是为什么 char* 又没问题?](#3. 但是为什么 char* 又没问题?)
-
[4. 那为什么 unsigned char* 更推荐?](#4. 那为什么 unsigned char* 更推荐?)
-
[5. signed char* 呢?](#5. signed char* 呢?)
-
[6. 所以真正需要区分的是这几个问题](#6. 所以真正需要区分的是这几个问题)
-
[7. 但是有一个更重要的点:不要把"字节"与"字符"混淆](#7. 但是有一个更重要的点:不要把“字节”与“字符”混淆)
-
[8. 甚至可以不用指针:联合体也能检测](#8. 甚至可以不用指针:联合体也能检测)
-
[9. 你现在这个思路其实可以进一步推广](#9. 你现在这个思路其实可以进一步推广)
1. 你说"直接转成 char* 也能检查"------对
例如:
int x = 1;
char* p = (char*)&x;
printf("%d\n", *p);
假设机器是小端:
x = 1
二进制:
00000000 00000000 00000000 00000001
假设 int 占 4 字节。
小端内存:
低地址
↓
┌────┬────┬────┬────┐
│ 01 │ 00 │ 00 │ 00 │
└────┴────┴────┴────┘
所以:
*p
读取第一个字节:
01
也就是:
1
因此可以判断:
if (*p == 1)
// 小端
所以你的这个观点没问题。
2. 为什么很多代码偏偏写 unsigned char*?
重点在于:
C/C++ 中
char、signed char、unsigned char是三种不同的类型。
假设某个字节是:
11111111
如果使用:
unsigned char
解释为:
255
而如果使用:
signed char
在常见二进制补码机器上可能解释为:
-1
所以:
原始字节:
11111111
unsigned char → 255
signed char → -1
如果你的目的只是:
"我要观察内存中的原始字节到底是什么。"
那么:
unsigned char
语义更加合适。
3. 但是为什么 char* 又没问题?
这里有一个非常重要的 C/C++ 特性。
C++ 对对象的**对象表示(object representation)**允许通过:
char*
signed char*
unsigned char*
来访问其底层字节。
所以:
int x = 0x12345678;
char* p = reinterpret_cast<char*>(&x);
然后:
p[0]
p[1]
p[2]
p[3]
可以用来观察 x 的字节表示。
因此:
不是只有
unsigned char\*才能访问对象的字节表示。
你的这个判断是对的。
4. 那为什么 unsigned char* 更推荐?
主要是语义和数值范围。
假设:
int x = 0x123456FF;
小端:
低地址
↓
FF 56 34 12
如果:
unsigned char* p
那么:
p[0] = 255
非常直观。
但:
char* p
这里的 char 是否表现为 signed,在 C++ 实现中是实现相关的。
所以:
p[0]
可能得到:
-1
也可能得到:
255
这就不适合拿来表达"这个字节的数值"。
5. signed char* 呢?
你的第一个观点也有一定道理:
有符号 char 也可以做到。
例如我们检测:
int x = 1;
signed char* p = (signed char*)&x;
if (*p == 1)
printf("小端");
因为我们检测的是:
01
而 01 无论解释成 signed char 还是 unsigned char:
signed char → 1
unsigned char → 1
所以:
signed char*
在这个特定大小端检测例子里完全够用。
但是如果你要写通用的"查看任意对象字节"的代码:
unsigned char* p
明显更加合理。
6. 所以真正需要区分的是这几个问题
本节目录
-
[问题一:能不能通过 char* 观察对象的字节?](#问题一:能不能通过 char* 观察对象的字节?)
-
[问题二:一定要 unsigned char* 吗?](#问题二:一定要 unsigned char* 吗?)
-
[问题三:为什么大家推荐 unsigned char*?](#问题三:为什么大家推荐 unsigned char*?)
你现在可以把它分开:
问题一:能不能通过 char* 观察对象的字节?
可以。
char* p = reinterpret_cast<char*>(&x);
问题二:一定要 unsigned char* 吗?
不是。
char* 和 signed char* 也可以用于访问对象表示。
问题三:为什么大家推荐 unsigned char*?
因为:
字节本质上是 0~255 的无符号数据,
unsigned char最适合表达原始字节。
尤其是:
FF
这种字节:
unsigned char → 255
signed char → 可能是 -1
7. 但是有一个更重要的点:不要把"字节"与"字符"混淆
例如:
char c = 'A';
这里的 char 是:
字符类型
而:
unsigned char
在:
unsigned char* p = reinterpret_cast<unsigned char*>(&x);
这种场景中,我们把它当作:
原始字节容器
这是两个不同的使用场景。
所以你以后看到:
unsigned char*
不要条件反射认为:
"这是为了字符串。"
恰恰相反,它在底层编程中大量用于:
内存
↓
原始字节
↓
网络数据
↓
文件数据
↓
二进制协议
↓
序列化
8. 甚至可以不用指针:联合体也能检测
例如:
union {
int x;
char c[sizeof(int)];
} u;
u.x = 1;
if (u.c[0] == 1)
printf("小端\n");
else
printf("大端\n");
不过这里涉及 union 的类型访问规则和标准细节 ,如果是为了写一份跨标准、最稳妥的 C++ 代码 ,我更推荐用 memcpy。
例如:
#include <cstring>
#include <cstdint>
#include <cstdio>
int main()
{
uint32_t x = 1;
unsigned char bytes[sizeof(x)];
std::memcpy(bytes, &x, sizeof(x));
if (bytes[0] == 1)
std::printf("小端\n");
else
std::printf("大端\n");
}
这个版本的思想非常清楚:
uint32_t x
↓
内存中的4个字节
↓ memcpy
unsigned char[4]
↓
观察第一个字节
9. 你现在这个思路其实可以进一步推广
你刚才说:
"不一定需要转化为
unsigned char*。"
这个思考方向是对的。
不要把"经典写法"误认为"唯一写法"。
你应该问的是:
这个方法真正需要满足什么条件?
对于大小端检测:
核心要求
↓
找到一个多字节对象
↓
观察它在内存中的多个字节
↓
比较低地址处的字节是什么
↓
判断字节排列顺序
至于你用:
char*
unsigned char*
signed char*
memcpy
union
是实现手段的区别。
其中如果考虑可读性、语义和通用性:
unsigned char*
通常是最经典的选择。
所以你的结论可以修正成一句面试回答:
检测大小端并不要求必须转换成
unsigned char\*;通过char\*或signed char\*也可以观察对象的字节表示。但由于unsigned char更适合表示原始字节,且避免符号解释带来的干扰,因此实际代码中通常优先使用unsigned char\*。
浮点数的底层存储(IEEE 754)
本节目录
-
[为什么 0.1 存不准确?](#为什么 0.1 存不准确?)
-
[为什么指数全 0 表示次正规数、指数全 1 表示无穷大或 NaN?](#为什么指数全 0 表示次正规数、指数全 1 表示无穷大或 NaN?)
一、总结:快速复习
本节目录
-
[1. float:32 位](#1. float:32 位)
-
[2. double:64 位](#2. double:64 位)
-
[3. 三部分分别表示什么?](#3. 三部分分别表示什么?)
-
[4. 为什么 0.1 + 0.2 != 0.3?](#4. 为什么 0.1 + 0.2 != 0.3?)
-
[5. 面试最常考](#5. 面试最常考)
一句话:
C++ 中常见的
float和double通常采用 IEEE 754 标准存储,不是像整数一样直接用二进制补码表示,而是拆成 符号位 + 指数部分 + 尾数部分。
1. float:32 位
┌──────┬───────────┬──────────────────────┐
│ 符号 │ 指数 │ 尾数 │
│ 1bit │ 8bit │ 23bit │
└──────┴───────────┴──────────────────────┘
总共:
1 + 8 + 23 = 32 bit = 4 Byte
2. double:64 位
┌──────┬────────────────┬───────────────────────────────┐
│ 符号 │ 指数 │ 尾数 │
│ 1bit │ 11bit │ 52bit │
└──────┴────────────────┴───────────────────────────────┘
总共:
1 + 11 + 52 = 64 bit = 8 Byte
3. 三部分分别表示什么?
| 部分 | 作用 |
|---|---|
| Sign 符号位 | 正数还是负数 |
| Exponent 指数 | 决定数量级 |
| Fraction/Mantissa 尾数 | 决定精度 |
可以粗略理解为:
浮点数 ≈ 符号 × 尾数 × 2^指数
4. 为什么 0.1 + 0.2 != 0.3?
因为:
很多十进制小数无法用有限位的二进制精确表示。
所以计算机只能保存一个非常接近它的值。
5. 面试最常考
你至少要记住:
float
= 1位符号 + 8位指数 + 23位尾数
double
= 1位符号 + 11位指数 + 52位尾数
以及:
指数不是直接存真实指数,而是使用偏置值(Bias)存储。
二、详细讲解:深入理解
本节目录
-
[1. 为什么浮点数不能像整数一样存?](#1. 为什么浮点数不能像整数一样存?)
-
[2. 先理解十进制科学计数法](#2. 先理解十进制科学计数法)
-
[3. IEEE 754 的基本结构](#3. IEEE 754 的基本结构)
-
[4. 符号位 Sign](#4. 符号位 Sign)
-
[5. 指数 Exponent](#5. 指数 Exponent)
-
[6. 为什么需要 Bias?](#6. 为什么需要 Bias?)
-
[7. 尾数 Fraction](#7. 尾数 Fraction)
-
[8. 完整计算一个 float](#8. 完整计算一个 float)
-
[9. 从这个例子反过来怎么还原?](#9. 从这个例子反过来怎么还原?)
-
[10. 为什么 0.1 存不准确?](#10. 为什么 0.1 存不准确?)
-
[11. 这就是为什么会出现](#11. 这就是为什么会出现)
-
[12. float 和 double 的区别](#12. float 和 double 的区别)
-
[13. 一个非常重要的概念:精度 ≠ 范围](#13. 一个非常重要的概念:精度 ≠ 范围)
-
[14. IEEE 754 不只有普通数字](#14. IEEE 754 不只有普通数字)
-
[15. 特别值是怎么实现的?](#15. 特别值是怎么实现的?)
-
[16. 最后把浮点数和你刚学的"大小端"联系起来](#16. 最后把浮点数和你刚学的“大小端”联系起来)
1. 为什么浮点数不能像整数一样存?
整数非常简单。
例如:
int x = 13;
二进制:
13 = 1101
可以直接用二进制表示。
但是浮点数:
float x = 13.5;
它既有:
整数部分:13
小数部分:0.5
而且浮点数还需要表示非常大的数和非常小的数:
1000000000
0.000000001
如果简单地固定分配:
整数部分 + 小数部分
就会遇到一个问题:
到底给整数部分多少位?给小数部分多少位?
所以浮点数采用了类似科学计数法的思想。
2. 先理解十进制科学计数法
例如:
1234.5
可以写成:
1.2345 × 10³
这里:
1.2345
负责表示具体数字。
10³
负责表示数量级。
浮点数也是类似思想,只不过:
计算机使用的是二进制科学计数法。
例如:
1101.1
可以写成:
1.1011 × 2³
这就是理解 IEEE 754 的关键。
3. IEEE 754 的基本结构
以 float 为例:
32 bit
31
↓
┌─┬────────┬───────────────────────┐
│S│Exponent│ Fraction │
└─┴────────┴───────────────────────┘
1 8 23
分别是:
S = Sign
Exponent = 指数
Fraction = 尾数部分
因此:
float
= 1 + 8 + 23
= 32 bit
4. 符号位 Sign
符号位最简单。
S = 0
表示:
正数
S = 1
表示:
负数
例如:
+1.5
符号位:
0
而:
-1.5
符号位:
1
所以浮点数的正负号只需要:
1 bit。
5. 指数 Exponent
指数部分决定:
这个数到底有多大或者多小。
例如:
1.1011 × 2³
这里:
指数 = 3
但是 IEEE 754 不会直接保存 3。
它采用:
Bias(偏置)
6. 为什么需要 Bias?
本节目录
-
[实际指数 0](#实际指数 0)
-
[实际指数 3](#实际指数 3)
-
[实际指数 -3](#实际指数 -3)
对于 float:
Exponent = 8 bit
8位无符号数可以表示:
0 ~ 255
但是实际指数可能是:
-10
-3
0
5
20
如果直接用普通无符号数,就不能表示负指数。
于是使用:
存储值 = 实际指数 + Bias
float 的:
Bias = 127
所以:
实际指数 0
0 + 127 = 127
存:
01111111
实际指数 3
3 + 127 = 130
存:
10000010
实际指数 -3
-3 + 127 = 124
存:
01111100
这样指数就可以用无符号二进制存储。
7. 尾数 Fraction
这是浮点数中最重要、也最容易理解错的部分。
例如:
13.5
转换成二进制:
13 = 1101
0.5 = 0.1
所以:
13.5 = 1101.1
进行规格化:
1101.1
↓
1.1011 × 2³
注意:
规格化以后,最高位一定是
1。
因此 IEEE 754 对正常数没有必要把这个 1 存下来。
只保存:
1011
也就是所谓:
隐藏的 1(implicit leading 1)
所以:
1.1011
实际存储的 Fraction 是:
1011...
而不是:
11011...
8. 完整计算一个 float
本节目录
我们来算:
float x = 13.5;
第一步:转换二进制
13.5
整数部分:
13 = 1101
小数部分:
0.5 = 0.1
所以:
1101.1
第二步:规格化
1101.1
=
1.1011 × 2³
因此:
符号 = 0
指数 = 3
尾数 = 1011...
第三步:计算实际存储的指数
float:
Bias = 127
所以:
3 + 127 = 130
130的二进制:
10000010
第四步:组成32位
符号
↓
0
指数
↓
10000010
尾数
↓
10110000000000000000000
最终:
0 10000010 10110000000000000000000
这就是 13.5 的 IEEE 754 单精度表示。
9. 从这个例子反过来怎么还原?
本节目录
假设得到:
0 10000010 10110000000000000000000
第一步:符号
0
所以:
+
第二步:指数
10000010
转十进制:
130
减去 Bias:
130 - 127 = 3
所以:
实际指数 = 3
第三步:尾数
存储:
1011...
由于正常浮点数有隐藏的 1:
1.1011
所以:
1.1011 × 2³
等于:
1101.1
也就是:
13.5
这样你就完成了一次:
十进制
↓
二进制
↓
规格化
↓
IEEE 754
以及反向解析。
10. 为什么 0.1 存不准确?
以下部分可能不好理解,可跳转到后面的进行理解
这是面试特别喜欢问的。
十进制:
0.1
转换成二进制并不是一个有限的小数。
它会变成类似:
0.000110011001100110011...
后面:
0011
不断循环。
也就是说:
0.1(十进制)
在二进制中是:
无限循环小数
但是 float 只有:
23 bit Fraction
double 也只有:
52 bit Fraction
所以只能:
无限位
↓
截取有限位
↓
近似值
因此:
0.1
实际存进去的是:
一个非常接近0.1的二进制数
而不是数学意义上的精确 0.1。
11. 这就是为什么会出现
#include <iostream>
int main()
{
double a = 0.1;
double b = 0.2;
std::cout << (a + b == 0.3);
}
可能得到:
0
因为实际计算类似:
a ≈ 0.100000...
b ≈ 0.200000...
而:
a + b
得到的结果和:
0.3
在二进制表示上可能不是完全相同的值。
所以:
浮点数不能简单使用
==判断两个计算结果是否相等。
工程中通常需要设置一个误差范围。
例如:
#include <cmath>
bool equal(double a, double b)
{
return std::fabs(a - b) < 1e-9;
}
12. float 和 double 的区别
float |
double |
|
|---|---|---|
| 总位数 | 32 bit | 64 bit |
| 字节 | 4 B | 8 B |
| 符号 | 1 bit | 1 bit |
| 指数 | 8 bit | 11 bit |
| Fraction | 23 bit | 52 bit |
| Bias | 127 | 1023 |
| 精度 | 约7位十进制有效数字 | 约15~16位 |
| 范围 | 较小 | 更大 |
所以:
double不只是"空间更大",它同时拥有更大的指数范围和更高的精度。
13. 一个非常重要的概念:精度 ≠ 范围
本节目录
浮点数有两个维度:
指数决定范围
例如:
2^100
2^-100
指数部分负责表示数量级。
Fraction决定精度
例如:
1.23456789
到底能精确到多少位,主要取决于 Fraction 的位数。
所以可以简单记:
Exponent
↓
范围
Fraction
↓
精度
这是面试非常好的回答点。
14. IEEE 754 不只有普通数字
本节目录
浮点数还专门规定了一些特殊值。
+0 和 -0
可以存在:
+0
-0
正无穷
+∞
例如:
1.0 / 0.0
在符合 IEEE 754 的环境中可能得到正无穷。
NaN
表示:
Not a Number
例如:
0.0 / 0.0
可能产生:
NaN
C++可以:
#include <cmath>
std::isnan(x);
std::isinf(x);
进行判断。
15. 特别值是怎么实现的?
本节目录
-
[Exponent 全0](#Exponent 全0)
-
[Exponent 全1](#Exponent 全1)
以 float 为例:
Exponent = 255
这是一个特殊情况。
Exponent 全0
主要用于:
0- 非规格化数(subnormal)
Exponent 全1
主要用于:
∞NaN
所以:
指数全0
↓
特殊情况
指数全1
↓
特殊情况
正常浮点数则是:
0 < Exponent < 255
16. 最后把浮点数和你刚学的"大小端"联系起来
这是非常重要的一点。
假设:
float x = 13.5;
IEEE 754规定:
这32个bit分别表示符号、指数和Fraction。
但是:
IEEE 754主要规定的是这些bit的意义,不等于规定你的CPU内存必须按照什么字节序存。
例如一个 float 占:
4 Byte
具体这4个Byte在内存中的排列,仍然受到机器大小端影响。
所以一定要区分:
IEEE 754
↓
规定浮点数的bit怎么解释
大小端
↓
规定这些Byte在内存地址中怎么排列
这两个概念不是一回事。
面试中容易追问
本节目录
-
[① float 为什么是4字节?](#① float 为什么是4字节?)
-
[② double 为什么是8字节?](#② double 为什么是8字节?)
-
[③ 为什么浮点数会有精度问题?](#③ 为什么浮点数会有精度问题?)
-
[④ 指数为什么要加 Bias?](#④ 指数为什么要加 Bias?)
-
[⑤ 为什么 0.1 + 0.2 != 0.3?](#⑤ 为什么 0.1 + 0.2 != 0.3?)
-
[⑥ float 和 double 谁更精确?](#⑥ float 和 double 谁更精确?)
-
[⑦ 浮点数和整数最大的区别是什么?](#⑦ 浮点数和整数最大的区别是什么?)
① float 为什么是4字节?
因为常见 IEEE 754 单精度使用:
1 + 8 + 23 = 32 bit
= 4 Byte
② double 为什么是8字节?
1 + 11 + 52
= 64 bit
= 8 Byte
③ 为什么浮点数会有精度问题?
因为二进制浮点数的 Fraction 位数有限,而很多十进制小数无法用有限位二进制精确表示,因此只能保存近似值。
④ 指数为什么要加 Bias?
因为实际指数既有正数也有负数,而指数存储字段本身按无符号形式处理,通过加 Bias 可以把负指数映射到非负整数。
⑤ 为什么 0.1 + 0.2 != 0.3?
因为
0.1、0.2、0.3在二进制浮点数中通常都只能近似表示,参与运算后产生的近似误差可能导致结果与直接表示的0.3不完全相同。
⑥ float 和 double 谁更精确?
double。 因为它的 Fraction 有52位,而 float只有23位,所以通常具有更高的有效精度。
⑦ 浮点数和整数最大的区别是什么?
本节目录
可以这样回答:
整数主要直接表示离散的整数值,而浮点数采用符号位、指数和尾数的形式表示数值,可以在有限存储空间下覆盖非常大的数值范围,但会存在精度损失。
最后形成一个知识链
你现在可以把刚才两个知识点串起来:
C++变量占多少空间
↓
sizeof
↓
float = 4B
double = 8B
↓
这4/8字节里面存什么?
↓
IEEE 754
↓
符号 + 指数 + 尾数
↓
这些字节在内存怎么排列?
↓
大小端
↓
小端 / 大端
这条链对于后面学习 C++内存、指针、类型转换、序列化、网络编程 都非常重要。
为什么 0.1 存不准确?
本节目录
一、总结:快速复习
你没看懂其实很正常,因为这里真正难的不是 IEEE 754,而是**"十进制小数怎么转换成二进制小数"**。
先记住一个核心结论:
十进制的
0.1可以用有限位十进制表示,但不能用有限位二进制表示。
就像:
十进制:
0.1 = 1/10
而二进制的小数每一位代表:
1/2、1/4、1/8、1/16、1/32......
所以我们实际上是在问:
能不能用有限个
1/2、1/4、1/8、1/16...加起来,刚好得到1/10?
答案是:不能。
所以只能无限逼近:
0.1
≈ 0.0001100110011001100110011...
这就是为什么 float / double 存储 0.1 时会有误差。
二、详细讲解:一步一步理解
本节目录
-
[1. 先别管浮点数,先学"十进制小数 → 二进制小数"](#1. 先别管浮点数,先学“十进制小数 → 二进制小数”)
-
[2. 再看 0.25](#2. 再看 0.25)
-
[3. 那 0.1 呢?](#3. 那 0.1 呢?)
-
[4. 为什么会一直循环?](#4. 为什么会一直循环?)
-
[5. 这和"0.1 不能被计算机表示"有什么关系?](#5. 这和“0.1 不能被计算机表示”有什么关系?)
-
[6. 可以把它类比成十进制里的 1/3](#6. 可以把它类比成十进制里的 1/3)
-
[7. 为什么 0.5 就没有这个问题?](#7. 为什么 0.5 就没有这个问题?)
-
[8. 最后把整个逻辑串起来](#8. 最后把整个逻辑串起来)
1. 先别管浮点数,先学"十进制小数 → 二进制小数"
整数转换成二进制你应该比较熟悉:
13 ÷ 2
不断除 2、取余数。
但是小数不一样。
例如:
0.5
我们知道:
0.5 = 1/2
所以二进制就是:
0.1₂
因为二进制小数的:
0.1₂ = 1 × 2⁻¹
= 1/2
= 0.5
2. 再看 0.25
0.25 = 1/4
而:
2⁻² = 1/4
所以:
0.25 = 0.01₂
因为:
0.01₂
= 0 × 2⁻¹ + 1 × 2⁻²
= 0 + 1/4
= 0.25
3. 那 0.1 呢?
现在来看:
0.1
它其实就是:
1/10
而二进制小数只能按照:
1/2
1/4
1/8
1/16
1/32
1/64
...
这些"位置"组合。
所以我们开始尝试:
0.1 × 2 = 0.2
整数部分是:
0
所以二进制小数第一位:
0
继续:
0.2 × 2 = 0.4
整数部分:
0
第二位:
0
继续:
0.4 × 2 = 0.8
第三位:
0
继续:
0.8 × 2 = 1.6
整数部分变成:
1
第四位:
1
把小数部分 0.6 继续:
0.6 × 2 = 1.2
第五位:
1
继续:
0.2 × 2 = 0.4
第六位:
0
然后:
0.4 × 2 = 0.8
第七位:
0
然后:
0.8 × 2 = 1.6
第八位:
1
你会发现:
0.2
↓
0.4
↓
0.8
↓
0.6
↓
0.2
开始循环了!
于是得到:
0.1₁₀
=
0.0001100110011001100110011...₂
你之前看到的:
000110011001100110011...
就是这么来的。
4. 为什么会一直循环?
这是最关键的地方。
我们实际上是在不断把"小数部分 × 2"。
对于 0.1:
0.1
→ 0.2
→ 0.4
→ 0.8
→ 0.6
→ 0.2
→ 0.4
→ 0.8
→ 0.6
→ ...
注意:
0.2
又回来了。
所以后面一定会重复:
0011 0011 0011 0011...
因此:
0.1
=
0.000110011001100110011...
永远写不完。
5. 这和"0.1 不能被计算机表示"有什么关系?
这里要特别严谨。
不要理解成:
计算机完全不能表示
0.1。
更准确的是:
IEEE 754 的二进制浮点数无法用有限的二进制位精确表示十进制
0.1。
因为 float 只有:
32 bit
double 也只有:
64 bit
而 0.1 的二进制展开是:
0.000110011001100110011001100110...
无限循环。
所以计算机只能:
真正的 0.1
↓
取有限位
↓
0.000110011001100110...
↓
存在一点点误差
6. 可以把它类比成十进制里的 1/3
这个类比非常好理解。在十进制中:
1/3 = 0.333333333333333...
也是无限循环。如果你只能保存有限位:
0.3333333333
它就不是精确的 1/3。同样:
十进制:
1/3
↓
0.333333333...
二进制:
1/10
↓
0.00011001100110011...
所以:
0.1对二进制计算机来说,有点像1/3对十进制来说。
这个类比只用于理解,实际浮点数还涉及 IEEE 754 的规格化、舍入等过程。
7. 为什么 0.5 就没有这个问题?
因为:
0.5 = 1/2
而 1/2 正好就是二进制的一个位置:
0.1₂
所以:
0.5
可以精确表示。同理:
0.25 = 1/4 = 0.01₂
也能精确表示。
0.125 = 1/8 = 0.001₂
也能精确表示。所以一个十进制小数能不能被二进制浮点数精确表示,和它能不能写成:
某个整数 / 2ⁿ
有很大关系。
8. 最后把整个逻辑串起来
本节目录
你现在可以把之前学的浮点数知识这样连接:
十进制 0.1
↓
1/10
↓
转换成二进制
↓
0.000110011001100110011...
↓
无限循环
↓
但 float 只有 32 bit
double 只有 64 bit
↓
只能截取 / 舍入有限位
↓
存储的是"非常接近 0.1 的数"
↓
不是数学意义上精确的 0.1
所以:
double a = 0.1;
并不是:
内存里精确存在数学上的 0.1
而是:
内存里存在一个非常接近 0.1 的 IEEE 754 二进制浮点数
这就是为什么:
double a = 0.1;
double b = 0.2;
cout << (a + b == 0.3);
不能简单认为一定为 true。
你现在可以先做一个小检查
不要查资料,直接回答我:
为什么
0.5可以被二进制精确表示,而0.1通常不能?
如果你能解释清楚这个问题,说明你已经真正理解"为什么 0.1 存不准"了。
为什么指数全 0 表示次正规数、指数全 1 表示无穷大或 NaN?
本节目录
一、总结:快速复习
你现在卡住的点其实非常关键:"为什么某个 bit 模式,突然就代表特殊东西?"
答案是:
不是因为"指数全 0"在数学上天然等于次正规数,也不是"指数全 1"天然等于无穷大。
而是 IEEE 754 规定:把这两个指数编码专门保留出来表示特殊情况。
你可以先记成:
指数全 0
├── Fraction 全 0 → ±0
└── Fraction 不全 0 → 次正规数
指数 00000001 ~ 11111110
└── 正常浮点数
指数全 1
├── Fraction 全 0 → ±∞
└── Fraction 不全 0 → NaN
真正理解它,要从一个问题开始:
如果所有指数编码都拿来表示正常数字,会出现什么问题?
二、详细讲解:深入理解
本节目录
-
[1. 先忘掉"特殊值",只看正常浮点数](#1. 先忘掉“特殊值”,只看正常浮点数)
-
[2. -126 和 0 之间怎么办?](#2. -126 和 0 之间怎么办?)
-
[3. 为什么"指数全 0"就表示次正规数?](#3. 为什么“指数全 0”就表示次正规数?)
-
[4. 为什么需要"次正规数"?](#4. 为什么需要“次正规数”?)
-
[5. 那为什么指数全 1 表示无穷大?](#5. 那为什么指数全 1 表示无穷大?)
-
[6. 为什么还要看 Fraction?](#6. 为什么还要看 Fraction?)
-
[7. 现在你应该能理解"全 0"和"全 1"为什么特殊了](#7. 现在你应该能理解“全 0”和“全 1”为什么特殊了)
-
[8. 为什么偏偏选"全 0"和"全 1"?](#8. 为什么偏偏选“全 0”和“全 1”?)
-
[9. 最后用一个"边界"把它彻底串起来](#9. 最后用一个“边界”把它彻底串起来)
1. 先忘掉"特殊值",只看正常浮点数
正常的 float 可以理解成:
符号 × 有效数 × 2^指数
例如:
13.5 = 1.1011 × 2³
其中:
1.1011
负责精度,
2³
负责数量级。
对于 float:
指数 8 bit
Bias = 127
正常情况下:
存储指数 = 实际指数 + 127
所以正常指数编码:
00000001
↓
实际指数 = 1 - 127 = -126
一直到:
11111110
↓
实际指数 = 254 - 127 = 127
所以正常 float 的实际指数范围是:
-126 ~ +127
注意这里非常重要:
正常数的最小实际指数是 -126,而不是 -127。
那么问题来了:
2. -126 和 0 之间怎么办?
这是理解"次正规数"的关键。
正常 float 最小的正数大概是:
1.000000... × 2^-126
也就是说,正常数已经到:
2^-126
附近了。
那么如果你还想表示一个比它更小、但又不是 0 的数怎么办?
例如:
0.5 × 2^-126
它比:
1.0 × 2^-126
更小。
但是正常浮点数要求有效数是:
1.xxxxx
所以这个数不能按照正常规格化形式表示。
如果强行规格化:
0.5 × 2^-126
=
1.0 × 2^-127
可是:
-127
已经超出正常 float 的指数范围了。
怎么办?
IEEE 754 就做了一个非常聪明的设计:
把指数全 0 的编码拿出来,专门表示这些比最小正常数还小的数。
这就是:
次正规数(Subnormal)
3. 为什么"指数全 0"就表示次正规数?
注意:
不是说:
00000000₂ = 次正规数
而是:
IEEE 754 规定
00000000这个指数编码不按照正常指数规则解释,而进入"特殊解释模式"。
正常数:
00000001 ~ 11111110
次正规数:
00000000 + Fraction ≠ 0
为什么这样设计?
为了让:
最小正常数
↓
更小的数
↓
更小的数
↓
更小的数
↓
0
这个过程更加平滑。
4. 为什么需要"次正规数"?
假设没有次正规数。
那么可能出现:
最小正常数 ≈ 1.17 × 10^-38
再小一点:
1.16 × 10^-38
计算机就只能直接变成:
0
也就是说:
1.17 × 10^-38
↓
0
中间突然断掉。
有了次正规数以后,就可以:
正常数
↓
次正规数
↓
更小的次正规数
↓
更小
↓
0
所以:
次正规数的核心作用:填补"最小正常数"和 0 之间的空隙。
你可以把它理解成:
正常数区域
━━━━━━━━━━━━━━━━━━
↓
最小正常数
↓
次正规数缓冲区域
↓
0
5. 那为什么指数全 1 表示无穷大?
这个思路其实和刚才非常类似。
我们正常指数最大的编码是:
11111110
对应:
254 - 127 = 127
所以最大的正常 float 大约是:
1.111111... × 2^127
那么问题来了:
如果计算结果比这个还大怎么办?
例如:
1e38 × 1e38
结果大约:
1e76
float 根本装不下。
那怎么办?
IEEE 754 决定:
把指数全 1 的编码保留下来,表示"超出正常浮点数范围"的特殊情况。
于是:
指数 = 11111111
Fraction = 000000...
被规定表示:
∞
6. 为什么还要看 Fraction?
本节目录
-
[指数全 1 + Fraction 全 0](#指数全 1 + Fraction 全 0)
-
[指数全 1 + Fraction 不为 0](#指数全 1 + Fraction 不为 0)
因为 IEEE 754 还需要区分:
∞
和:
NaN
所以它规定:
指数全 1 + Fraction 全 0
11111111 | 00000000000000000000000
表示:
∞
符号位决定:
0 → +∞
1 → -∞
指数全 1 + Fraction 不为 0
11111111 | 10100000000000000000000
表示:
NaN
也就是:
Not a Number
所以:
指数全 1
│
├── Fraction = 0
│ ↓
│ ∞
│
└── Fraction ≠ 0
↓
NaN
7. 现在你应该能理解"全 0"和"全 1"为什么特殊了
它们实际上是 IEEE 754 故意预留出来的编码。
可以把整个指数空间想象成:
00000000
↓
┌──────────────┐
│ 0 / 次正规数 │
└──────────────┘
00000001
↓
正常数
↓
...
↓
11111110
↓
正常数
11111111
↓
┌──────────────┐
│ ∞ / NaN │
└──────────────┘
这不是数学上的必然,而是:
IEEE 754 的编码设计。
8. 为什么偏偏选"全 0"和"全 1"?
这个设计其实非常巧妙。
8 位指数一共有:
2^8 = 256
种编码:
00000000
00000001
...
11111110
11111111
IEEE 754 没有把 256 种全部用于正常数,而是:
1 个编码:全 0
254 个编码:正常数
1 个编码:全 1
也就是:
256
↓
1 + 254 + 1
这样:
- 全 0 → 处理 0、次正规数
- 中间 254 个 → 正常数
- 全 1 → 处理无穷大、NaN
这就是为什么你看到:
"指数全 0 → 次正规数"
以及:
"指数全 1 → 无穷大"
不要理解成数学公式。
应该理解成:
IEEE 754 规定了特殊的编码规则。
9. 最后用一个"边界"把它彻底串起来
本节目录
你可以把 float 想象成一条从非常小到非常大的数轴:
负数 正数
───────────────┬───────────────────────────────
0
↑
次正规数区域
←──────────→
↑
最小正常数
↑
正常数区域
↑
最大正常数
↓
∞
更准确地说:
指数全 0
↓
0 和次正规数
指数 1~254
↓
正常数
指数全 1
↓
∞ 和 NaN
你现在只需要牢牢记住一句:
指数全 0 和全 1 不是"算出来"的,而是 IEEE 754 专门保留的特殊编码。全 0 用来处理 0 和最小正常数以下的次正规数;全 1 用来处理无穷大和 NaN。
如果你下一步想真正理解**"次正规数到底是怎么算出来的"**,最值得继续看的是:为什么指数全 0 时,那个隐藏的 1 会变成 0 ,以及为什么它的实际指数固定按 -126 来算。这个地方一旦弄懂,IEEE 754 的特殊值就基本彻底通了。
浮点数掌握情况自测题
本节目录
很好。既然你已经重新复习了浮点数,我不给你那种只考"float 是多少字节"的死记题,而是按照秋招面试的难度梯度出题,重点检查你是否真正理解了:
- IEEE 754 的三部分
- Bias(偏置)
- 隐藏的 1
- 为什么会有精度问题
- 浮点数和大小端的关系
先不要看答案。 你可以直接按题号回复你的答案,我会逐题帮你判断哪里理解对了、哪里有误区。
第一组:基础概念(热身)
题 1
float 在常见 IEEE 754 单精度实现中一共多少位?
请分别写出:
- 符号位多少位?
- 指数多少位?
- 尾数(Fraction)多少位?
题 2
下面说法是否正确?说明原因。
浮点数的指数部分直接存储真实指数。
例如真实指数是 3,那么指数部分就直接存二进制 00000011。
题 3
对于一个正常的 IEEE 754 float,规格化后的有效数最高位为什么通常不需要存储?
第二组:真正理解
题 4
将十进制数:
13.5
转换为二进制,并写成二进制科学计数法:
1.xxxxx × 2^?
请写出:
- 二进制形式
- 规格化形式
- 实际指数
题 5
还是 13.5f。
请写出它的 float 三部分:
- 符号位
- 指数部分(8 位二进制)
- 尾数部分(前几位即可)
不要求写完整 23 位。
题 6
假设一个 float 的指数部分是:
10000010
请问:
- 这个指数部分表示的无符号数是多少?
- 实际指数是多少?
第三组:精度问题
题 7
为什么下面的代码不能保证输出 true?
double a = 0.1;
double b = 0.2;
std::cout << (a + b == 0.3);
请不要只回答"有精度误差"。
我要你解释:
精度误差是怎么产生的?
题 8
下面两个概念分别由浮点数的哪一部分决定?
- 能表示多大的数、多少数量级?
- 能精确到多少位有效数字?
请说明:
- 指数负责什么?
- 尾数负责什么?
第四组:特殊值
题 9
IEEE 754 中,float 的指数部分如果:
- 全部为
0 - 全部为
1
分别表示什么类型的特殊情况?
不需要特别细,只要说出主要用途。
第五组:和大小端结合(面试很喜欢)
题 10
假设:
float x = 13.5f;
下面两个说法哪个正确?
A.
IEEE 754 决定
float的 4 个字节在内存中的排列顺序。
B.
IEEE 754 决定这 32 个 bit 分别代表符号、指数和尾数;而这 4 个字节在内存中的排列由机器的大小端决定。
请选择,并解释为什么。
第六组:综合题(最接近面试)
题 11
面试官问你:
请介绍一下 C++ 中
float的底层存储方式。
请你用 4~6 句话完整回答,要求至少包含:
- IEEE 754
- 符号位
- 指数
- 尾数
- Bias
- 精度问题
这道题不是计算题,而是考你能不能组织成一个面试答案。
难度分布
| 题号 | 考察内容 | 难度 |
|---|---|---|
| 1 | 位数 | ★ |
| 2 | Bias | ★★ |
| 3 | 隐藏1 | ★★ |
| 4 | 二进制转换 | ★★ |
| 5 | IEEE 754 编码 | ★★★ |
| 6 | 指数解析 | ★★ |
| 7 | 精度误差 | ★★★ |
| 8 | 精度 vs 范围 | ★★ |
| 9 | 特殊值 | ★★★ |
| 10 | IEEE754 vs 大小端 | ★★★ |
| 11 | 面试表达 | ★★★ |
建议你先做 1~11 题,不查资料。你直接回复例如:
1. ...
2. ...
3. ...
...
11. ...
我会按照"正确 / 部分正确 / 错误"逐题批改,并且重点指出你是否已经真正理解了浮点数,而不仅仅是记住了结论。
第四部分:其他记录(非面试主线)
Codex Skill、YAML 与 agents 目录
本节目录
一、总结:快速复习
- YAML 是一种用于保存结构化数据的配置文件格式,作用类似 JSON,但通常更便于人阅读和编写。
- Skill 中的
agents/openai.yaml保存界面展示信息,例如显示名称、简短介绍和默认提示语。 agents目录不是一个"子 Agent",也不是一个能够独立运行的 AI;它只是存放 Skill 界面元数据的目录。SKILL.md是 Skill 的核心文件,负责定义 Skill 的名称、触发条件和行为规则。agents/openai.yaml通常不是识别 Skill 的必要条件,但建议保留,以便 Skill 在界面中有完整、清晰的展示信息。- 可以把 Skill 文件夹压缩后传输,但 ZIP 压缩包通常不会仅因为存在于电脑中就被 AI 自动识别。需要将其正确解压并安装到 Codex 能够扫描的 Skills 目录中。
二、详细讲解:深入理解
本节目录
-
[1. .yaml 文件是什么?](#1. .yaml 文件是什么?)
-
[2. agents 目录是什么?](#2. agents 目录是什么?)
-
[3. 哪些文件是必要的?](#3. 哪些文件是必要的?)
-
[4. 把 agents 文件夹和 SKILL.md 打包后,能否被 AI 识别?](#4. 把 agents 文件夹和 SKILL.md 打包后,能否被 AI 识别?)
-
[5. 一句话记忆](#5. 一句话记忆)
1. .yaml 文件是什么?
.yaml 是 YAML 格式文件。YAML 是一种用于表达配置和结构化数据的文本格式,作用与 .json、.toml 等配置文件相似。
例如:
yaml
interface:
display_name: "Skill 需求编辑器"
short_description: "将用户需求整理为可执行、可维护的 Skill 规则"
default_prompt: "使用 $skill-requirement-editor,将我的自然语言需求整理为规范的规则,并更新目标 Skill。"
这段配置表达了一个 interface 对象,其中包含三个字段:
display_name:Skill 在界面中显示的名称;short_description:Skill 的简短说明;default_prompt:使用这个 Skill 时建议采用的默认提示语。
YAML 使用缩进表示层级,因此缩进必须保持一致,不能随意混乱。
2. agents 目录是什么?
在这个 Skill 中,目录结构是:
text
skill-requirement-editor/
├── SKILL.md
└── agents/
└── openai.yaml
这里的 agents 不是一个子 Agent,也不表示目录中运行着另一个 AI。
它是 Skill 的界面元数据目录,其中的 openai.yaml 用于描述这个 Skill 在 Codex 界面中的显示方式。
可以简单理解为:
text
SKILL.md
→ 决定这个 Skill 在什么情况下使用,以及 AI 应该怎么做
agents/openai.yaml
→ 决定这个 Skill 在界面中叫什么、如何介绍、默认提示语是什么
3. 哪些文件是必要的?
| 内容 | 是否必要 | 作用 |
|---|---|---|
| Skill 文件夹 | 是 | 容纳一个完整 Skill 的目录 |
SKILL.md |
是 | 定义 Skill 的名称、触发条件和行为规则 |
agents 目录 |
通常不是,但建议保留 | 存放界面元数据 |
agents/openai.yaml |
通常不是,但建议保留 | 定义显示名称、简介和默认提示语 |
| ZIP 压缩包 | 否 | 只用于打包、分享或传输 |
真正决定一个目录能否作为 Skill 使用的核心是 SKILL.md。它通常应包含 YAML 元数据和 Markdown 规则正文,例如:
markdown
---
name: skill-requirement-editor
description: 将用户的自然语言需求整理为规范的 Skill 指令。
---
<a name="toc-anchor-428"></a>
## Skill 需求编辑器
这里填写具体行为规则。
4. 把 agents 文件夹和 SKILL.md 打包后,能否被 AI 识别?
可以把它们打包在一起,但必须保留正确的目录结构:
text
skill-requirement-editor/
├── SKILL.md
└── agents/
└── openai.yaml
压缩包可以采用下面的结构:
text
skill-requirement-editor.zip
└── skill-requirement-editor/
├── SKILL.md
└── agents/
└── openai.yaml
但是,压缩包的作用主要是方便传输。AI 或 Codex 通常不会因为电脑中存在这个 ZIP 文件就自动加载其中的 Skill。
正确流程通常是:
text
打包或下载 ZIP
↓
解压 ZIP
↓
保持 Skill 文件夹的内部结构不变
↓
把整个 Skill 文件夹安装或复制到 Codex 能扫描的 Skills 目录
↓
必要时重新加载 Codex
安装后的结构应类似:
text
<Codex Skills 根目录>/
└── skill-requirement-editor/
├── SKILL.md
└── agents/
└── openai.yaml
不能把 SKILL.md 和 openai.yaml 随意拆散,也不要额外嵌套多余目录。例如下面这种结构可能导致扫描程序找不到 SKILL.md:
text
<Codex Skills 根目录>/
└── skill-requirement-editor/
└── skill-requirement-editor/
├── SKILL.md
└── agents/
└── openai.yaml
5. 一句话记忆
SKILL.md决定"这是不是一个 Skill,以及 AI 应该怎么做";agents/openai.yaml决定"它在界面中怎样显示"。压缩包只方便传输,必须正确解压并安装到 Codex 能扫描的位置后,Skill 才能被识别。
第五部分:低频拓展知识
高精度算法
本节目录
一、总结:快速复习
本节目录
-
[1. 为什么需要高精度?](#1. 为什么需要高精度?)
-
[2. 最核心的思想](#2. 最核心的思想)
-
[3. 常见高精度操作](#3. 常见高精度操作)
-
[4. 面试 / 算法题最重要](#4. 面试 / 算法题最重要)
一句话:
高精度算法 就是:当一个数超过 C/C++ 内置整数类型的表示范围时,不依赖
int、long long,而是用数组 / 字符串模拟整数的每一位,再自己实现加减乘除等运算。
1. 为什么需要高精度?
例如:
long long
通常只能处理大约 10^18 量级的整数。
但如果题目要求:
123456789012345678901234567890123456789
×
987654321098765432109876543210
已经远远超过内置整数范围。这时候就需要:
字符串 / 数组
↓
自己保存每一位
↓
模拟小学数学
↓
实现加减乘除
2. 最核心的思想
以:
123456
为例,可以存成:
高位 → 低位
1 2 3 4 5 6
但实际算法通常低位放在数组下标 0:
a[0] = 6
a[1] = 5
a[2] = 4
a[3] = 3
a[4] = 2
a[5] = 1
即:
a = {6,5,4,3,2,1}
这样做的最大好处是:
加法、减法都从低位开始,进位/借位可以直接向后处理。
3. 常见高精度操作
| 操作 | 核心思想 |
|---|---|
| 高精度加法 | 逐位相加 + 进位 |
| 高精度减法 | 逐位相减 + 借位 |
| 高精度乘低精度 | 每位乘法 + 进位 |
| 高精度乘高精度 | 模拟竖式乘法 |
| 高精度除低精度 | 从高位开始模拟除法 |
| 高精度除高精度 | 通常使用试商 / 二分等方法 |
4. 面试 / 算法题最重要
先掌握:
高精度加法
↓
高精度减法
↓
高精度 × 低精度
↓
高精度 × 高精度
↓
高精度 ÷ 低精度
其中**"为什么低位放在数组前面"**是非常重要的理解点。
二、详细讲解:深入理解
本节目录
-
[1. 高精度到底在解决什么问题?](#1. 高精度到底在解决什么问题?)
-
[2. 为什么通常采用"低位在前"?](#2. 为什么通常采用“低位在前”?)
-
[3. 高精度加法](#3. 高精度加法)
-
[4. 字符串如何转换成高精度数组?](#4. 字符串如何转换成高精度数组?)
-
[5. 高精度减法](#5. 高精度减法)
-
[6. 一个非常重要的问题:谁减谁?](#6. 一个非常重要的问题:谁减谁?)
-
[7. 为什么最后要删除前导0?](#7. 为什么最后要删除前导0?)
-
[8. 高精度乘低精度](#8. 高精度乘低精度)
-
[9. 高精度乘高精度](#9. 高精度乘高精度)
-
[10. 为什么乘法是 i + j?](#10. 为什么乘法是 i + j?)
-
[11. 高精度除法](#11. 高精度除法)
-
[12. 高精度除低精度的基本思路](#12. 高精度除低精度的基本思路)
-
[13. 高精度算法的时间复杂度](#13. 高精度算法的时间复杂度)
-
[14. 为什么不直接使用字符串做运算?](#14. 为什么不直接使用字符串做运算?)
-
[15. 一个完整的高精度加法流程](#15. 一个完整的高精度加法流程)
-
[16. 你现在应该重点掌握的知识](#16. 你现在应该重点掌握的知识)
1. 高精度到底在解决什么问题?
假设:
long long a;
它能表示的范围是有限的。
即使你使用:
unsigned long long
也仍然有上限。
但数学上的整数:
12345678901234567890123456789012345678901234567890
可以非常大。
C++不会自动提供一个无限大的整数类型。
所以我们换一种思路:
既然一个变量装不下,那就用很多个变量一起表示它。
例如:
123456789
可以拆成:
1
2
3
4
5
6
7
8
9
每个数组元素保存一位。
于是:
int a[1000];
理论上就可以保存1000位数字。
这就是高精度最基本的思想。
2. 为什么通常采用"低位在前"?
这是高精度算法最重要的设计。
例如:
123456
如果正常存:
a[0] = 1
a[1] = 2
a[2] = 3
a[3] = 4
a[4] = 5
a[5] = 6
但是我们做加法的时候:
123456
+ 789999
--------
需要:
个位
↓
十位
↓
百位
↓
...
也就是:
从低位 → 高位
所以如果数组这样存:
a[0] = 6
a[1] = 5
a[2] = 4
...
就刚好对应:
a[0] → 个位
a[1] → 十位
a[2] → 百位
...
于是加法就非常自然。
3. 高精度加法
本节目录
例如:
123456
+ 789999
--------
913455
我们从个位开始:
6 + 9 = 15
写:
5
进位:
1
下一位:
5 + 9 + 1 = 15
继续。
这就是:
逐位相加 + 处理进位。
C++实现
#include <iostream>
#include <vector>
#include <string>
using namespace std;
vector<int> add(const vector<int>& a,
const vector<int>& b)
{
vector<int> c;
int carry = 0;
int n = max(a.size(), b.size());
for (int i = 0; i < n || carry; ++i)
{
int sum = carry;
if (i < a.size())
sum += a[i];
if (i < b.size())
sum += b[i];
c.push_back(sum % 10);
carry = sum / 10;
}
return c;
}
这里最关键的是:
c.push_back(sum % 10);
carry = sum / 10;
例如:
sum = 15
那么:
15 % 10 = 5
当前位:
5
而:
15 / 10 = 1
进位:
1
4. 字符串如何转换成高精度数组?
假设输入:
123456
通常通过字符串读入:
string s;
cin >> s;
此时:
s = "123456"
然后倒着放:
vector<int> a;
for (int i = s.size() - 1; i >= 0; --i)
{
a.push_back(s[i] - '0');
}
最终:
s:
1 2 3 4 5 6
a:
6 5 4 3 2 1
也就是:
a[0] = 6
a[1] = 5
a[2] = 4
...
5. 高精度减法
减法稍微复杂一些。
例如:
123456
- 78999
--------
44457
还是从低位开始。
例如个位:
6 - 9
不够减。
所以:
向高位借1
原来的:
6
变成:
16
然后:
16 - 9 = 7
下一位被借走了1:
4 - 9
继续借位。
所以高精度减法的核心就是:
逐位相减 + 借位。
6. 一个非常重要的问题:谁减谁?
本节目录
如果:
a > b
可以:
a - b
如果:
a < b
结果就会是负数。
所以很多基础高精度题会要求:
a >= b
这样可以只处理正数。
C++实现
vector<int> sub(const vector<int>& a,
const vector<int>& b)
{
vector<int> c;
int borrow = 0;
for (int i = 0; i < a.size(); ++i)
{
int x = a[i] - borrow;
if (i < b.size())
x -= b[i];
if (x < 0)
{
x += 10;
borrow = 1;
}
else
{
borrow = 0;
}
c.push_back(x);
}
while (c.size() > 1 && c.back() == 0)
c.pop_back();
return c;
}
例如:
16 - 9
计算:
16 - 9 = 7
如果:
6 - 9
则:
6 - 9 = -3
发现小于0:
-3 + 10 = 7
同时:
borrow = 1
这就实现了借位。
7. 为什么最后要删除前导0?
例如:
123000 - 123000
逐位计算后可能得到:
0 0 0 0 0 0
如果直接保留:
vector = {0,0,0,0,0,0}
后面处理会比较麻烦。
所以:
while (c.size() > 1 && c.back() == 0)
c.pop_back();
删除高位多余的0。最后:
{0}
而不是:
{0,0,0,0,0}
8. 高精度乘低精度
本节目录
例如:
123456 × 8
还是逐位:
6 × 8 = 48
当前位:
8
进位:
4
下一位:
5 × 8 + 4
= 44
当前位:
4
进位:
4
所以核心:
当前位 × 一个整数 + 进位
C++实现
cpp
vector<int> mul(const vector<int>& a, int b)
{
vector<int> c;
int carry = 0;
for (int i = 0; i < a.size() || carry; ++i)
{
long long x = carry;
/*
1LL 是 C++ 中的一个整数字面量,表示:
类型为 long long 的整数 1。
所以:
1LL * a[i] * b
最主要的作用是:
强制让后面的乘法在 long long 类型下进行,避免 int × int 先发生溢出。
*/
if (i < a.size())
x += 1LL * a[i] * b;
c.push_back(x % 10);
carry = x / 10;
}
while (c.size() > 1 && c.back() == 0)
c.pop_back();
return c;
}
这里用了:
long long
是因为:
a[i] * b
可能本身就比较大。
9. 高精度乘高精度
本节目录
这是高精度乘法最重要的形式。
例如:
123
×456
小学竖式:
1 2 3
× 4 5 6
--------------
7 3 8
6 1 5
4 9 2
--------------
5 6 0 8 8
计算机也可以模拟这个过程。
核心思想
假设:
a[i]
是 a 的第 i 位。
b[j]
是 b 的第 j 位。
那么:
a[i] × b[j]
应该贡献到结果的:
i + j
位置。所以:
c[i + j] += a[i] * b[j];
这是高精度乘法最重要的一行。
C++实现
cpp
vector<int> mul(const vector<int>& a,
const vector<int>& b)
{
vector<int> c(a.size() + b.size(), 0);
for (int i = 0; i < a.size(); ++i)
{
for (int j = 0; j < b.size(); ++j)
{
c[i + j] += a[i] * b[j];
}
}
int carry = 0;
for (int i = 0; i < c.size(); ++i)
{
int x = c[i] + carry;
c[i] = x % 10;
carry = x / 10;
}
while (c.size() > 1 && c.back() == 0)
c.pop_back();
return c;
}
10. 为什么乘法是 i + j?
这个地方非常值得理解。
例如:
123
倒序:
a[0] = 3
a[1] = 2
a[2] = 1
也就是说:
a[i] × 10^i
例如:
a[2] = 1
代表:
1 × 10²
另一个数:
456
倒序:
b[0] = 6
b[1] = 5
b[2] = 4
如果:
a[i] × 10^i
乘:
b[j] × 10^j
那么:
(a[i] × 10^i)
×
(b[j] × 10^j)
得到:
a[i] × b[j] × 10^(i+j)
所以它自然应该放到:
c[i+j]
这就是:
c[i + j] += a[i] * b[j];
背后的数学原因。
11. 高精度除法
除法和前面稍微不同。
因为:
加法
减法
乘法
通常可以:
从低位开始
而普通竖式除法是:
从最高位开始。
例如:
123456 ÷ 12
我们从:
123
开始判断。然后:
123 ÷ 12 = 10
再继续处理下一位。
所以高精度除法一般:
从最高位 → 最低位
12. 高精度除低精度的基本思路
例如:
123456 ÷ 7
维护:
remainder
每读入一位:
remainder = remainder * 10 + 当前位
然后:
当前商 = remainder / 7
remainder = remainder % 7
这和小学除法完全一致。
13. 高精度算法的时间复杂度
本节目录
如果数字有:
n 位
加法
O(n)
减法
O(n)
高精度 × 低精度
O(n)
高精度 × 高精度
朴素方法:
O(n²)
因为有两层循环:
for (i)
for (j)
高精度 ÷ 低精度
O(n)
所以算法题中最常见的基础高精度:
加法 O(n)
减法 O(n)
乘低精度 O(n)
乘高精度 O(n²)
除低精度 O(n)
14. 为什么不直接使用字符串做运算?
其实可以。例如:
string a = "123456789...";
字符串适合:
存储数字。
但如果直接进行:
每一位相加
每一位进位
每一位借位
使用:
vector<int>
通常更加方便。
因此经典高精度写法是:
输入
↓
string
↓
vector<int>
↓
进行运算
↓
vector<int>
↓
倒序输出
15. 一个完整的高精度加法流程
例如输入:
12345678901234567890
98765432109876543210
流程:
字符串
↓
分别转成数组
↓
低位放前
↓
逐位相加
↓
处理进位
↓
删除多余前导0
↓
从数组末尾开始输出
例如:
字符串:
123456
转:
[6,5,4,3,2,1]
运算:
[6,5,4,3,2,1]
+
[9,9,9,9,8,7]
最后:
[5,5,4,3,1,9]
倒过来输出:
913455
16. 你现在应该重点掌握的知识
如果你是在准备算法题 / 秋招笔试,高精度不要一开始就把所有实现全部背下来。
建议按这个顺序掌握:
① 为什么需要高精度
↓
② 为什么低位放数组前面
↓
③ 高精度加法
↓
④ 高精度减法
↓
⑤ 高精度 × 低精度
↓
⑥ 高精度 × 高精度
↓
⑦ 高精度 ÷ 低精度
其中最关键的三个思想是:
① 数组模拟每一位
② 低位放在下标0,从低位开始处理进位/借位
③ 乘法中第i位和第j位的结果放到i+j
如果这三个理解了,后面的代码基本都是在实现这些数学过程。
怎么判断一个题目/场景是否需要高精度
本节目录
一、总结:快速复习
判断一道题是否需要高精度,核心就看一句话:
看"最终计算过程中可能出现的数值"是否超过当前数据类型的表示范围,或者题目是否要求精确表示超大整数/超高精度小数。
你可以按这个顺序判断:
-
先看题目给的数据范围。
-
int范围内(10 ^ 9) → 通常不需要高精度 -
long long范围内(−2 ^ 63∼2 ^ 63 − 1,即:
-9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 ) → 通常不需要高精度
- 明确出现"最多 100 位、1000 位、10^10000"等 → 需要(最大数值可能超过10 ^ 18数量级)
-
-
不要只看输入,还要看中间计算结果。
- 即使输入只有
10^9,连续乘法、阶乘、幂、组合数也可能迅速爆掉。
- 即使输入只有
-
如果题目要求精确计算超大整数 ,不能用
double代替。 -
Python 中通常直接用
int就够了 ,因为 Pythonint支持任意精度整数。 -
C++ 中才需要特别考虑:
int ↓ 不够 long long ↓ 还不够 __int128 ↓ 还不够 高精度 / 大整数 -
最重要的判断口诀:
先看输入范围,再看运算增长,最后看是否要求精确。
二、详细讲解:深入理解
1. 首先:什么情况下"一眼就知道"需要高精度?
题目如果出现这种数据范围:
1 ≤ n ≤ 10^100
或者:
n 最多有 1000 位
或者:
计算两个最多 500 位的整数之和
那么不用算了:
这就是高精度题。
因为 C++ 常见整数类型根本放不下。例如:
long long x;
通常只能表示大约:
-9 × 10^18 ~ 9 × 10^18
但:
10^100
已经是:
1 后面 100 个 0
远远超过 long long。
2. 但是最容易犯的错误是:只看输入范围
比如题目:
给定
n ≤ 100,求n!。
你看到:
n ≤ 100
可能觉得:
"100 很小,不需要高精度。"
实际上:
100!
大约有 158 位。
所以:
long long
肯定装不下。
这说明:
判断高精度不能只看输入,还要看计算结果。
3. 最重要的就是判断"数值增长"
你以后看到一道题,可以问自己:
这个变量经过运算之后,最大可能变多大?
例如:
加法
a + b
如果:
a ≤ 10^9
b ≤ 10^9
那么:
a+b ≤ 2×10^9
int 可能刚好够,也可能要根据语言和范围仔细判断。
乘法
如果:
a ≤ 10^9
b ≤ 10^9
那么:
a × b ≤ 10^18
这时候:
int ❌
long long ✅
所以:
乘法比加法更容易让你需要升级数据类型。
4. 幂是高精度的高危区域
例如:
2^100
输入:
2
100
看起来都很小。
但是结果:
1267650600228229401496703205376
已经超过 long long。
再比如:
2^1000
已经大约有:
302 位
所以看到:
a^b
一定要警惕。
5. 阶乘也是高精度高危区域
例如:
n!
增长速度非常快。
10! = 3628800
还很小。
但是:
20! ≈ 2.43 × 10^18
已经接近 long long 上限。
而:
100!
已经是 158 位。
所以看到:
n!
你应该立刻想到:
可能需要高精度。
6. 组合数也是高危区域
例如:
C(n, k)
即:
n! / (k!(n-k)!)
虽然 n 本身可能只有:
100
但是:
C(100, 50)
大约是:
10^29
已经超过 long long。所以:
n ≤ 100
并不意味着不需要高精度。
7. Fibonacci 也需要注意
例如:
F(n)
Fibonacci 增长大致是指数级的。
如果:
F(100)
已经达到:
354224848179261915075
大约 21 位。
所以:
long long
已经不够。
但如果题目:
F(n), n ≤ 100
那么你就应该考虑:
结果本身是不是超过数据类型范围。
8. 有一个特别容易混淆的地方:模运算不需要高精度
例如题目:
求
2^1000000000 mod 1000000007
你可能看到:
2^1000000000
觉得:
"这肯定是高精度!"
不一定。
因为题目最终要求的是:
2^1000000000 % 1000000007
我们根本不需要把:
2^1000000000
完整算出来。
可以使用:
快速幂 + 模运算
整个过程中控制数字范围。
所以判断时一定要问:
"我是不是必须真正得到这个巨大数字本身?"
如果只要求:
mod M
通常就不需要传统意义上的高精度大整数。
9. 同样,除法也可能让你误判
例如:
100!
很大。
但是如果题目最终要求:
100! % 1000000007
就不一定需要存储完整的 100!。
可以:
每一步乘法
↓
立即取模
↓
控制数值范围
例如:
ans = ans * i % MOD;
所以:
"理论结果巨大" ≠ "程序一定需要存储这个巨大结果"。
这是判断高精度非常重要的一点。
10. 还有一种情况:不是"整数太大",而是"精度要求太高"
例如题目要求:
计算 π 到小数点后 1000 位
这也是高精度问题。
但是这里和:
10^1000
不一样。
前者是:
高精度浮点数 / 高精度小数
后者是:
高精度整数
你目前为了算法面试,首先重点掌握:
高精度整数
就够了。
11. double 能不能解决高精度整数?
这是面试非常喜欢问的坑。
比如:
double x = 12345678901234567890;
你不能认为:
"double 范围很大,所以可以代替高精度。"
错。
因为:
范围大 ≠ 精度高。
double 能表示非常大的数,但是它只有有限的有效二进制精度。
所以可能出现:
12345678901234567890
被近似成另一个数。
如果题目要求:
每一位都必须准确
那么 double 不适合。
12. 这和你前面学的浮点数正好联系起来
你之前已经学过:
0.1为什么不能精确表示。
现在可以把两个知识点连起来:
整数高精度
↓
需要每一位准确
↓
int / long long 不够
↓
自己用数组/字符串存储
而:
double
↓
使用 IEEE 754
↓
有效精度有限
↓
虽然范围巨大
↓
但不能保证所有整数都精确
所以:
大范围和高精度是两个不同的概念。
这是非常重要的计算机基础知识。
13. C++ 中怎么判断是否需要高精度?
你可以建立一个简单的"升级路线":
题目数据
↓
能不能放 int?
↓
不能
↓
能不能放 long long?
↓
不能
↓
能不能放 __int128?
↓
不能
↓
高精度
例如:
| 最大可能值 | C++选择 |
|---|---|
10^9 |
int |
10^18 |
long long |
10^18 左右但需要更大范围 |
根据具体情况考虑 __int128 |
10^100 |
高精度 |
10^1000 |
高精度 |
| 1000 位整数 | 高精度 |
注意:
具体边界不能死记成"10^18 一定安全",因为还要看是否有负数、是否发生乘法/加法以及具体类型范围。
14. Python 为什么特别适合解决这个问题?
这就是你前面学 Python 的意义。
例如:
a = 10 ** 1000
print(a)
Python 的:
int
可以自动扩展。
所以:
a = int(input())
b = int(input())
print(a * b)
即使:
a = 500位整数
b = 500位整数
也可以直接计算。
因此你遇到题目:
"两个 1000 位整数相乘"
如果允许 Python:
a = int(input())
b = int(input())
print(a * b)
可能就解决了。
15. 但是面试官可能故意要求"不能直接用大整数"
这时候题目一般会明确:
不允许使用大整数库。
或者:
请自己实现任意长度整数加法。
这时候才需要:
字符串
↓
每一位
↓
进位
↓
结果
例如:
999
+88
---
1087
这种题目考察的其实不是:
Python 有多强。
而是考察:
你是否理解大整数在底层是怎么实现的。
16. 给你一个真正实用的"高精度判断流程"
以后做算法题,看到数字范围后,脑子里走一遍:
① 输入有多大?
↓
② 中间计算会不会变大?
↓
③ 最大结果大约是多少?
↓
④ 是否超过当前类型范围?
↓
⑤ 最终是否要求完整、精确的结果?
↓
⑥ 有没有 mod / 除法 / 截断等操作可以控制范围?
↓
⑦ 最终决定:
int / long long / __int128 / 高精度
17. 给你几个典型场景判断
| 场景 | 是否考虑高精度 | 原因 |
|---|---|---|
n ≤ 10^9,求 n+1 |
❌ | 结果仍在合理范围 |
a,b ≤ 10^9,求 a×b |
❌ | long long 足够 |
n ≤ 100,求 n! |
✅ | 阶乘增长极快 |
n ≤ 100,求 C(n,n/2) |
✅ | 组合数可能很大 |
求 2^1000 |
✅ | 结果约 302 位 |
求 2^10^9 mod 1e9+7 |
❌传统高精度 | 可以快速幂取模 |
| 两个 1000 位整数相加 | ✅ | 基本类型无法存储 |
| 求小数点后 1000 位 | ✅ | 属于高精度小数 |
double 存一个 10^100 的整数 |
❌ | 范围够,但无法保证整数精确 |
18. 最后给你一个你应该真正记住的判断标准
不要形成:
"数字超过 10^18 就用高精度。"
这种死记。应该形成:
高精度不是由输入数字"看起来大"决定的,而是由"需要精确表示的数值是否超过当前类型能准确表示的范围"决定的。
尤其注意三个字:
准确表示。
所以:
数值范围
≠
数值精度
而判断算法题是否需要高精度,最核心的就是:
输入范围
+
运算过程中的最大值
+
最终是否要求完整精确结果
+
是否可以通过取模等方法控制数值
这套判断方法比单纯背"哪些题是高精度题"更重要。
用 Python 解决高精度问题所需的相关语法
一、总结:快速复习
如果你的目标是:
秋招遇到高精度算法题,不想用 C++ 手写大整数,而是快速用 Python 做出来。
那你完全没必要系统学习 Python。你只需要学和算法题强相关的一小部分。
优先级建议:
- 变量与赋值
- 整数运算 :
+ - * / // % ** - 字符串
str - 列表
list - for / while
- if
- 函数
- 输入输出
- 切片
- 列表/字符串常用操作
- Python 大整数
int ↔ str转换- 高精度算法中最常用的字符串处理
最关键的一点:
Python 的
int可以自动处理任意精度整数,所以高精度整数题通常根本不需要你自己实现"大整数"。
例如 C++:
// 可能需要自己实现高精度
string a, b;
Python:
a = int(input())
b = int(input())
print(a * b)
就可以直接计算非常大的整数。
二、详细讲解:深入理解
1. 先理解 Python 和 C++ 最大的区别
你已经会 C++,所以我不会从"什么是变量"这种角度教。
你可以直接建立:
C++ Python
int a = 10; → a = 10
string s = "abc"; → s = "abc"
vector<int> v; → v = []
cout << a; → print(a)
cin >> a; → a = int(input())
Python 最大特点之一:
变量不需要提前声明类型。
C++:
int a = 10;
double b = 3.14;
string s = "hello";
Python:
a = 10
b = 3.14
s = "hello"
Python 根据右边的对象自动确定类型。
2. 高精度最重要:Python 的 int
这是你学习 Python 解决高精度算法时最应该先理解的东西。
C++:
long long a;
通常只有 64 位。
超过范围就不行了。
Python:
a = 123456789012345678901234567890123456789
可以直接存。
甚至:
a = 10 ** 1000
print(a)
也可以。
Python 的整数会根据需要自动扩展存储空间。
所以:
a = int(input())
b = int(input())
print(a + b)
print(a - b)
print(a * b)
这些都可以直接做大整数运算。
3. Python 的整数运算
你必须熟悉这些:
a + b # 加
a - b # 减
a * b # 乘
a / b # 除法,结果通常是 float
a // b # 整除
a % b # 取余
a ** b # 幂
例如:
10 // 3
结果:
3
而:
10 / 3
结果:
3.3333333333333335
高精度整数题里,通常使用 //,不要随便使用 /。
例如求:
12345678901234567890 ÷ 7
应该:
a // 7
4. 输入:算法题最常用的写法
例如输入:
12345678901234567890
Python:
a = int(input())
如果输入:
123 456
C++:
int a, b;
cin >> a >> b;
Python:
a, b = map(int, input().split())
这个你一定要熟。
拆开理解:
input()
得到:
"123 456"
然后:
.split()
变成:
["123", "456"]
然后:
map(int, ...)
把它们转换成整数。
最后:
a, b = ...
分别赋值。
5. 输出
C++:
cout << a << endl;
Python:
print(a)
多个:
print(a, b)
例如:
a = 123
b = 456
print(a, b)
输出:
123 456
6. if:和 C++ 很像
C++:
if (a > b) {
cout << a;
} else {
cout << b;
}
Python:
if a > b:
print(a)
else:
print(b)
最重要区别:
Python 不使用
{},而是通过缩进表示代码块。
例如:
if a > b:
print(a)
print("a更大")
else:
print(b)
这里缩进非常重要。
7. for:Python 算法题的核心
C++:
for (int i = 0; i < 10; i++) {
cout << i;
}
Python:
for i in range(10):
print(i)
输出:
0
1
2
...
9
注意:
range(10)
是:
0 ~ 9
range() 必须熟
range(5)
相当于:
0 1 2 3 4
range(2, 6)
相当于:
2 3 4 5
range(10, 0, -1)
相当于:
10 9 8 7 6 5 4 3 2 1
所以 C++:
for (int i = n; i >= 1; i--)
Python:
for i in range(n, 0, -1):
8. while
C++:
while (n > 0) {
n /= 10;
}
Python:
while n > 0:
n //= 10
注意:
n /= 10
会变成浮点数。
所以整数算法中通常:
n //= 10
9. 字符串:高精度算法非常重要
Python:
s = "123456789"
可以通过下标访问:
s[0]
得到:
'1'
s[1]
得到:
'2'
下标和 C++ 一样:
0 1 2 3 4 ...
↓ ↓ ↓ ↓ ↓
1 2 3 4 5 ...
10. 字符串切片 ⭐⭐⭐⭐⭐
这是 Python 特别好用的地方。
s = "abcdef"
s[0:3]
得到:
"abc"
规则:
s[start:end]
表示:
从 start 开始,到 end-1 结束。
所以:
s[1:4]
是:
bcd
负数下标
s = "abcdef"
s[-1]
是:
f
s[-2]
是:
e
这个在字符串处理题里很好用。
11. 字符串反转 ⭐⭐⭐⭐⭐
C++:
reverse(s.begin(), s.end());
Python:
s = s[::-1]
例如:
s = "12345"
print(s[::-1])
结果:
54321
你以后做:
大整数反转、字符串回文、模拟加法
会经常用到。
12. 字符串和整数转换
这个必须背。
字符串:
s = "123456"
转换成整数:
x = int(s)
整数:
x = 123456
转换成字符串:
s = str(x)
所以:
s = "12345678901234567890"
x = int(s)
print(x + 1)
可以直接完成大整数计算。
13. list:对应 C++ 的 vector
C++:
vector<int> v;
v.push_back(10);
v.push_back(20);
Python:
v = []
v.append(10)
v.append(20)
现在:
v
就是:
[10, 20]
访问:
v[0]
得到:
10
14. 遍历 list
C++:
for (int x : v) {
cout << x;
}
Python:
for x in v:
print(x)
如果需要下标:
for i in range(len(v)):
print(i, v[i])
这里:
len(v)
就是 C++:
v.size()
15. 高精度算法最有用的 list 操作
添加
v.append(x)
相当于:
v.push_back(x);
删除最后一个
v.pop()
相当于:
v.pop_back();
长度
len(v)
相当于:
v.size()
排序
v.sort()
相当于:
sort(v.begin(), v.end());
16. 一个非常重要的东西:字符串也可以遍历
例如:
s = "12345"
for c in s:
print(c)
得到:
1
2
3
4
5
这对于高精度算法非常重要。
因为有些题目即使 Python 可以直接使用大整数,你仍然可能遇到:
要求你模拟高精度运算。
例如:
123456789
+
987654321
这时候可以直接逐位处理字符串。
17. Python 实现"字符串高精度加法"的基本思路
例如:
999
+ 88
-----
1087
Python 中可以:
a = "999"
b = "88"
i = len(a) - 1
j = len(b) - 1
carry = 0
ans = []
while i >= 0 or j >= 0 or carry:
x = int(a[i]) if i >= 0 else 0
y = int(b[j]) if j >= 0 else 0
total = x + y + carry
ans.append(str(total % 10))
carry = total // 10
i -= 1
j -= 1
ans.reverse()
print("".join(ans))
这个例子你现在不用背。
你需要理解的是:
字符串
↓
逐位取数字
↓
加法
↓
进位
↓
保存结果
以后如果面试官问:
"不用大整数库,怎么实现两个超大整数相加?"
你就可以自己写。
18. Python 中 "".join() 是什么?
上面的:
"".join(ans)
非常常见。
假设:
ans = ["1", "0", "8", "7"]
那么:
"".join(ans)
得到:
"1087"
如果:
",".join(ans)
得到:
"1,0,8,7"
所以:
join是把多个字符串拼起来。
19. 函数:算法题必须会
Python:
def add(a, b):
return a + b
调用:
x = add(10, 20)
print(x)
相当于 C++:
int add(int a, int b) {
return a + b;
}
20. Python 没有 C++ 那么多类型声明
例如 C++:
long long add(long long a, long long b)
Python:
def add(a, b):
return a + b
因为 Python 会动态处理类型。
所以你做算法题时写函数非常快。
21. 高精度题你真正应该掌握的 Python 知识
如果按照投入时间 / 面试收益排序:
第一优先级:必须熟练
input()
print()
int()
str()
+
-
*
/
//
%
**
if / elif / else
for
while
range()
len()
list
append()
字符串下标
字符串切片
[::-1]
第二优先级:很有用
split()
join()
sort()
reverse()
max()
min()
sum()
abs()
以及:
def
return
第三优先级:暂时不用急
你现在完全没必要为了高精度算法去学:
class
装饰器
生成器
异步
多线程
GUI
Flask
Django
pandas
numpy
这些和你目前的目标没有直接关系。
22. C++ → Python 对照表
你可以直接把这张表保存下来:
| C++ | Python |
|---|---|
int a = 10; |
a = 10 |
long long |
int |
string |
str |
vector<int> |
list |
cin >> a |
a = int(input()) |
cout << a |
print(a) |
v.push_back(x) |
v.append(x) |
v.size() |
len(v) |
sort(v.begin(),v.end()) |
v.sort() |
reverse(...) |
v.reverse() |
reverse(s.begin(),s.end()) |
s[::-1] |
stoi(s) |
int(s) |
to_string(x) |
str(x) |
&& |
and |
| ` | |
! |
not |
nullptr |
None |
{} 代码块 |
缩进 |
for(int i=0;...) |
for i in range(...) |
while(...) |
while ...: |
else if |
elif |
// 注释 |
# |
有一个特别重要的区别:
C++:
a / b
整数除法。
Python:
a / b
是普通除法,结果可能是浮点数。
Python 整除要:
a // b
23. 对你来说,最实用的学习路线
不要把 Python 当成一门新的编程语言完整学。
你可以按照:
第一阶段
Python基本语法
↓
变量
if
for
while
函数
↓
第二阶段
字符串 + list
↓
第三阶段
输入输出
↓
第四阶段
Python大整数
↓
第五阶段
算法题常用写法
↓
第六阶段
需要时再学库
其中最重要的是第三、四、五阶段。
因为你的目的不是:
"我要成为 Python 程序员。"
而是:
"我遇到高精度题时,可以迅速用 Python 写出来。"
最后给你一个非常重要的认知
你学 Python 高精度时,实际上会遇到两种完全不同的题:
类型 1:只是题目数据特别大
例如:
123456789123456789123456789
×
987654321987654321987654321
这种:
a = int(input())
b = int(input())
print(a * b)
直接结束。
类型 2:题目要求你自己实现高精度
例如面试官说:
"不能使用大整数类型/库,请实现两个任意长度整数相加。"
这时候才需要:
字符串
↓
逐位处理
↓
进位
↓
list
↓
join
所以你现在学 Python,一定不要一上来就学一堆语法。
你真正需要建立的是:
"C++ 我负责正常算法;Python 我作为高精度和某些实现方便时的工具。"
这对于你目前准备计算机基础和算法面试来说,是非常高性价比的路线。
1LL 是什么?
cpp
// 高精度乘法
vector<int> mul(const vector<int>& a, int b)
{
vector<int> c;
int carry = 0;
for (int i = 0; i < a.size() || carry; ++i)
{
long long x = carry;
if (i < a.size())
x += 1LL * a[i] * b;
c.push_back(x % 10);
carry = x / 10;
}
while (c.size() > 1 && c.back() == 0)
c.pop_back();
return c;
}
一、总结:快速复习
1LL 是 C++ 中的一个整数字面量,表示:
类型为
long long的整数 1。
所以:
1LL * a[i] * b
最主要的作用是:
强制让后面的乘法在
long long类型下进行,避免int × int先发生溢出。
例如:
int a = 1000000000;
int b = 1000000000;
long long x = a * b; // ❌ 先int×int,可能已经溢出
long long y = 1LL * a * b; // ✅ 先转成long long再乘
核心记忆:
1 → int
1L → long
1LL → long long
在你这段高精度乘法 代码中,1LL 就是在保证:
a[i] * b
使用 long long 计算。
二、详细讲解:深入理解
1. 先看你代码中最关键的一行
你这里有:
x += 1LL * a[i] * b;
假设:
a[i] = 100000
b = 100000
如果直接写:
a[i] * b
那么:
a[i] int
b int
↓
int × int
↓
结果仍然是 int
也就是说:
赋值给
long long x并不能挽救前面的int溢出。
这是 C++ 一个非常重要的知识点。
2. 为什么 long long x = a * b 还是可能错?
看:
int a = 100000;
int b = 100000;
long long x = a * b;
很多初学者会想:
x 是 long long
↓
所以 a*b 应该用 long long 算
这是错误的。
C++ 实际上先计算右边:
a * b
此时:
a:int
b:int
↓
int × int
↓
得到 int
然后才:
int
↓
转换成 long long
↓
赋给 x
所以如果 a*b 在 int 阶段就溢出了:
int × int
↓
❌ 溢出
↓
long long
已经晚了。
3. 1LL 是怎么解决的?
现在:
long long x = 1LL * a * b;
从左到右看:
1LL → long long
a → int
↓
long long × int
↓
结果是 long long
↓
再 × b
↓
仍然是 long long
也就是:
1LL * a * b
↓
long long 参与运算
↓
整个乘法使用 long long
所以:
1LL * a * b
可以理解成:
"先把运算提升到
long long,然后再乘。"
4. 为什么偏偏写 1LL?
其实你也可以写:
(long long)a * b
或者:
static_cast<long long>(a) * b
例如:
long long x = (long long)a * b;
也是正确的。
甚至:
long long x = 1LL * a * b;
是算法题里非常常见的简写。
因为:
1LL
本身就是:
long long 类型的 1
乘上它之后,类型就会参与整数类型提升。
5. 1、1L、1LL 到底有什么区别?
这是你需要记住的。
1
类型通常是:
int
1L
类型是:
long
1LL
类型是:
long long
所以:
1LL * a * b
本质就是利用:
long long
把整个表达式的计算类型带到 long long。
6. 结合你的高精度代码来看
你的代码:
vector<int> mul(const vector<int>& a, int b)
{
vector<int> c;
int carry = 0;
for (int i = 0; i < a.size() || carry; ++i)
{
long long x = carry;
if (i < a.size())
x += 1LL * a[i] * b;
c.push_back(x % 10);
carry = x / 10;
}
while (c.size() > 1 && c.back() == 0)
c.pop_back();
return c;
}
这里:
a[i]
通常表示一个十进制数字:
0 ~ 9
而:
b
是一个 int。
严格来说,在这个具体代码里:
a[i] ≤ 9
所以:
a[i] × b
即使 b 是普通 int,通常也不容易因为这一步本身溢出。
所以你可能会问:
既然这里
a[i]最大才 9,为什么还写1LL?
这是一个非常好的问题。
7. 在这段代码里,1LL 其实不是"必须"的
假设:
a[i] ≤ 9
b
而:
b
是 int。
那么:
a[i] * b
只要 b 本身没有接近 INT_MAX 到导致乘法超过范围,通常就没有问题。
甚至因为:
a[i] ≤ 9
如果 b 是普通 int,确实存在 a[i] * b 超过 int 的可能。
例如:
a[i] = 9;
b = 1'000'000'000;
那么:
9 × 1,000,000,000
=
9,000,000,000
超过 32 位 int 的最大值:
2,147,483,647
所以:
a[i] * b
确实可能溢出。
于是:
1LL * a[i] * b
就非常有必要。
8. 为什么 x 本身也用 long long?
这里:
long long x = carry;
因为:
x += 1LL * a[i] * b;
这里的中间结果可能超过 int。
例如:
a[i] = 9
b = 1,000,000,000
那么:
9 × 1,000,000,000
= 9,000,000,000
需要:
long long
才能存。
所以整个过程:
a[i] × b
↓
long long
↓
+ carry
↓
x
↓
x % 10 → 当前位
x / 10 → 进位
9. 1LL 和"高精度"不是一回事
这个一定要区分。
你现在学的是高精度算法,很容易产生误解:
1LL是不是高精度?
不是。
1LL 只是:
long long
而 long long 通常只有 64 位。
它仍然是固定宽度整数。
真正的高精度:
10^100
10^1000
10^10000
这种超过 long long 范围的数,需要:
数组
vector
字符串
大整数库
Python int
等方法。
所以:
int
↓
long long
↓
__int128
↓
真正的高精度
这是不同层级。
10. 你以后看到 1LL *,基本可以条件反射
例如:
long long ans = 1LL * a * b;
通常就是作者在告诉你:
"我要确保
a\*b用long long算。"
再比如:
long long area = 1LL * width * height;
假设:
int width;
int height;
直接:
width * height
可能先按照 int 计算。
所以写:
1LL * width * height
就比较安全。
11. 一个非常典型的面试陷阱
问:
int a = 100000;
int b = 100000;
long long x = a * b;
结果安全吗?
不一定。
因为:
a * b
↓
int × int
↓
int
↓
可能溢出
↓
再转换成 long long
正确写法:
long long x = 1LL * a * b;
或者:
long long x = static_cast<long long>(a) * b;
核心不是:
"最后用什么类型接收。"
而是:
"表达式本身是用什么类型计算的。"
这个知识点在 C++ 算法题里非常重要。
为什么基础模板中很少有"高精度除以高精度"?
一、总结:快速复习
高精度除法当然有,而且是高精度算法的重要操作之一。
你之前看到的:
vector<int> div(const vector<int>& a, int b)
是:
高精度整数 ÷ 普通整数
而你说的:
高精度整数 ÷ 高精度整数
是另一种更复杂的情况。
两者最大的区别是:
| 类型 | 例子 | 难度 |
|---|---|---|
| 高精度 ÷ 普通整数 | 123456789... ÷ 7 |
⭐⭐ |
| 高精度 ÷ 高精度 | 123456789... ÷ 123456... |
⭐⭐⭐⭐ |
高精度 ÷ 高精度通常要解决:
- 商的每一位怎么确定?
- 怎么比较两个高精度数?
- 怎么做高精度减法?
- 怎么找到每一位商?
最经典的方法就是模拟长除法。
二、详细讲解:深入理解
1. 先理解为什么"高精度 ÷ 普通整数"比较简单
例如:
123456789 ÷ 7
我们可以从最高位开始:
12 ÷ 7 = 1
剩 5
54 ÷ 7 = 7
剩 5
55 ÷ 7 = 7
剩 6
...
每次只需要处理一个:
余数 + 下一位
而这个"余数"一定小于 b。
所以如果:
b
是普通 int,整个过程可以让 carry/remainder 使用普通整数。
因此实现比较简单。
2. 为什么"高精度 ÷ 高精度"麻烦很多?
假设:
A = 123456789012345678901234
B = 123456789
我们要求:
A / B
现在问题来了。
在长除法的某一步,我们可能需要:
123456789012 / 123456789
这里:
被除数的一部分
本身就是一个很大的数。
所以不能简单写:
int x;
甚至:
long long x;
可能都装不下。
于是你需要:
高精度比较
+
高精度减法
才能完成。
3. 高精度除高精度,本质上就是小学长除法
比如:
1234 ÷ 12
小学的时候你会这样算:
102
-------
12 | 1234
12
--
3
↓
34
24
--
10
所以:
商 = 102
余数 = 10
计算机其实也是这么做。
只不过:
小学:
每一位手算
计算机:
用程序寻找每一位商
4. 最核心的问题:商的每一位是多少?
假设当前:
A = 1234
B = 12
第一步看:
12
那么:
12 ÷ 12 = 1
所以商第一位:
1
然后:
12 - 1×12 = 0
继续拿下一位:
3
因为:
3 < 12
所以商这一位:
0
再拿下一位:
34
那么:
34 ÷ 12 = 2
所以:
商 = 102
5. 程序怎么知道某一位商是几?
这是高精度除法最关键的地方。
因为每一位商都是一个:
0 ~ 9
所以可以直接:
尝试 0
尝试 1
尝试 2
...
尝试 9
找到最大的 x,满足:
B × x ≤ 当前被除数
例如:
当前 = 34
B = 12
尝试:
12 × 0 = 0
12 × 1 = 12
12 × 2 = 24
12 × 3 = 36 ❌
所以:
x = 2
6. 但是高精度除法里还有一个关键技巧
实际上我们不需要真的:
0
1
2
3
...
9
一个个慢慢尝试。
因为商的一位只有:
0~9
所以最多尝试 10 次。
如果我们采用十进制每位存储:
vector<int>
那么复杂度还可以接受。
甚至可以使用二分查找:
0 ~ 9
寻找最大的:
mid
满足:
B × mid ≤ 当前数
7. 这就需要你已经学过的三个高精度基本操作
所以你现在会发现:
高精度除高精度不是一个孤立算法。
它依赖前面的:
高精度比较
↓
高精度乘法
↓
高精度减法
↓
高精度除法
尤其是:
① 高精度比较
判断:
A >= B
通常先比较:
位数
位数相同再从最高位开始比较。
② 高精度乘以一位
例如:
123456 × 7
这是你刚才看的:
mul(a, b)
③ 高精度减法
例如:
1234 - 987
需要处理:
借位
8. 所以完整思路实际上是
假设:
A / B
其中:
A、B
都是任意长度整数。
那么:
A
↓
从最高位开始取
↓
得到当前部分
↓
当前部分是否 ≥ B?
↓
是
↓
寻找最大的 q,使 B×q ≤ 当前部分
↓
当前部分 -= B×q
↓
q 放入商
↓
继续取下一位
最终:
商 = quotient
余数 = remainder
这就是:
高精度整数除以高精度整数。
9. 为什么很多基础高精度模板没有它?
你在算法题里经常看到:
vector<int> add(...)
vector<int> sub(...)
vector<int> mul(...)
vector<int> div(...)
但这里的 div 很多时候其实是:
高精度 ÷ int
而不是:
高精度 ÷ 高精度
原因很简单:
高精度除高精度的实现明显更复杂,而且很多算法题根本不需要它。
例如:
大整数 + 大整数
大整数 - 大整数
大整数 × 普通整数
大整数 ÷ 普通整数
已经可以覆盖大量基础高精度题。
10. 什么时候才会真正需要"高精度 ÷ 高精度"?
例如题目直接给:
A ≤ 10^1000
B ≤ 10^500
要求:
A / B
而且:
不能使用 Python 大整数 / C++ 大整数库。
这时候就必须自己实现。
如果题目只是:
A ≤ 10^1000
B ≤ 100000
那么:
高精度 ÷ int
就够了。
11. 你现在学习高精度,建议按照这个顺序
不要一上来就学高精度除高精度,难度会突然跳起来。
推荐:
① 高精度加法
↓
② 高精度减法
↓
③ 高精度 × 普通整数
↓
④ 高精度 ÷ 普通整数
↓
⑤ 高精度 × 高精度
↓
⑥ 高精度 ÷ 高精度
其中第⑥个才是真正比较综合的高精度题。
你刚刚问"为什么没有高精度除高精度",其实已经碰到了高精度算法里比较核心的一层:它不是不能做,而是因为实现需要把"长除法 + 高精度比较 + 高精度乘法 + 高精度减法"组合起来。
高精度算法练习安排
可以。既然你已经在学高精度加、减、乘、除,我建议不要直接给你一整套模板,而是按**"自己写 → 暴露问题 → 再补知识"**的方式练。
一、总结:快速复习
加
存储:
注意a是更大的那个数;
各个数组大小最小为a和b中最大的size + 1, 高精度最省心的是直接定义为题目能用到最大的数量级位数 + 1(如一个题目得到最大的数是10 ^ 100,那么就可以直接设置每个数组大小都为100 + 1);
不满位数的数剩余空间都补0;
加数a:倒序存数
加数b:倒序存数
和数c:倒序存数
carry:进位(有些人的算法写法不会单独设进位和c数组,而是直接让a数组或者b数组当作c数组用,进位直接进位在i + 1上)
计算:
for (i = 0; i < sz(a的长度); ++i) {
carry += ai + bi
ci = carry % 10
carry /= 10
}
输出:
if (carry)
cout << carry;
for (i = sz - 1; i >= 0; --i)
cout << ci;
减
存储:
各个数组大小最小为a和b中最大的size + 1, 高精度最省心的是直接定义为题目能用到最大的数量级位数 + 1(如一个题目得到最大的数是10 ^ 100,那么就可以直接设置每个数组大小都为100 + 1);
不满位数的数剩余空间都补0;
被减数a:倒序存数
减数b:倒序存数
差数c:倒序存数
borrow:借位(有些人的算法写法不会单独设借位和c数组,而是直接让a数组当作c数组用,借位直接借在i + 1上)
计算:
确保a更大,如果起初a < b,设置flag = 1,输出时多输出一个负号
for (i = 0; i < sz; ++i) {
ci = ai - borrow - bi;
if (ci < 0 ){
ci += 10;
borrow = 1;
}
else
borrow = 0;
}
输出:
if (flag)
cout << "-";
while(i > 0 && !ci)
--i;
for (; i >= 0; --i)
cout << ci;
乘
存储:
乘数a:倒序存数
乘数b:倒序存数
积数c:倒序存数
carry:进位
sz1: 乘数a长度
sz2: 乘数b长度
计算:
确保b更大;
各个数组大小最小为sz1 + sz2 + 1, 最省心的是直接定义为题目能用到最大的数量级位数 + 1(如一个题目能得到最大的数是10 ^ 100,那么就可以直接设置每个数组大小都为100 + 1);
不满位数的数剩余空间都补0;
for (i = 0; i < sz1; ++i) {
for (j = 0; j < sz2; ++j) {
ci + j += ai * bj + carry;
carry = ci + j / 10;
ci + j %= 10;
}
ci + j + 1 = carry;
carry = 0;
}
输出:
if (ci + j + 1)
cout << ci + j + 1;
for (int z = i + j; z >= 0; --z)
cout << cz;
除
存储:
被除数a:正序存数
除数b(正序倒序无所谓,一般题目涉及到除以一个一位数就够了,暂时只做能整除的)
商数c:正序存数
sz1: 被除数a长度
计算:
int temp = a0, j = 1;
for (i = 0; i < sz1 - 1; ++i) {
if (temp < b) {
temp = temp * 10 + ai + 1;
continue;
}
cj++ = temp / b;
temp = (temp - cj - 1 * b) * 10 + ai + 1;
}
}
输出:
for (int z = 1; z < j; ++z)
cout << cz;
给你安排一套 6 题递进训练:
| 题目 | 训练内容 | 难度 |
|---|---|---|
| ① | 高精度加法 | ⭐ |
| ② | 高精度减法 | ⭐⭐ |
| ③ | 高精度 × 普通整数 | ⭐⭐ |
| ④ | 高精度 ÷ 普通整数 | ⭐⭐⭐ |
| ⑤ | 高精度 × 高精度 | ⭐⭐⭐⭐ |
| ⑥ | 高精度 ÷ 高精度 | ⭐⭐⭐⭐⭐ |
规则:
- 我先只给你题目,不直接给代码。
- 你自己写。
- 写完把代码发给我。
- 我会帮你检查思路、边界、代码、复杂度。
- 如果错了,我优先指出问题所在,不直接把完整答案塞给你。
- 第 6 题会综合前面所有知识。
二、详细讲解:训练安排
① 第一题:高精度加法
题目
给定两个非负整数 A 和 B,它们的长度均不超过 1000 位。
求:
A + B
输入
A
B
例如:
12345678901234567890
98765432109876543210
输出
111111111011111111100
要求
不能使用 Python 的大整数直接计算。
你需要自己模拟:
个位
↓
十位
↓
百位
↓
进位
提示
建议使用:
string
或者:
vector<int>
先自己决定数据结构。
② 第二题:高精度减法
题目
给定两个非负整数 A 和 B:
A >= B
且长度不超过 1000 位。
求:
A - B
例如:
12345678901234567890
-
9999999999999999999
结果:
2345678901234567891
这一题重点
你需要自己处理:
借位
例如:
1000
- 1
----
999
你要想清楚:
为什么某一位不够减的时候,需要向高位借 1?
③ 第三题:高精度 × 普通整数
题目
给定一个最多 1000 位 的非负整数 A,以及一个:
0 <= B <= 10^9
求:
A × B
例如:
A = 123456789
B = 12345
结果:
1524074060205
这一题重点
你刚才看的代码就是这一类:
vector<int> mul(const vector<int>& a, int b)
但不要照抄刚才的代码。
自己想:
当前位
× B
+ 上一位进位
↓
当前数字
↓
新的进位
特别注意:
A很大,但B只有int范围。
④ 第四题:高精度 ÷ 普通整数
题目
给定:
A:最多 1000 位的非负整数B:1 <= B <= 10^9
求:
A / B
A % B
输出:
商
余数
例如:
123456789 / 123
输出:
商:1003713
余数:90
这一题非常重要
你需要自己想明白:
为什么高精度除法要从最高位开始?
以及:
当前余数
+
下一位
↓
当前数字
↓
当前数字 / B
↓
得到商的一位
↓
当前数字 % B
↓
作为下一轮余数
这一题做完,你基本就真正理解了小学长除法如何转换成程序。
⑤ 第五题:高精度 × 高精度
题目
给定两个最多 1000 位的非负整数:
A
B
求:
A × B
例如:
123456789
×
987654321
输出:
121932631112635269
这一题不要直接用"每位乘法 + 进位"的模板
你可以先思考:
123
×45
小学计算:
123 × 5
123 × 40
--------------
123×5 + 123×40
对应程序其实就是:
A 的每一位
×
B 的每一位
↓
放到正确的位置
你需要自己解决:
为什么
a[i] × b[j]应该加到c[i+j]?
这个问题想明白以后,高精度乘法就真正理解了。
⑥ 第六题:高精度 ÷ 高精度
这是最终 Boss。
题目
给定两个非负整数:
A
B
其中:
A ≤ 10^1000
B ≤ 10^500
B > 0
求:
A / B
A % B
也就是同时求:
商
余数
例如:
12345678901234567890
/
123456789
输出:
商
余数
这一题暂时只给你一个提示
你需要把之前学过的东西组合起来:
高精度比较
↓
高精度乘法
↓
高精度减法
↓
长除法
核心问题:
对于当前部分
x,怎么找到最大的数字q,使得B × q <= x?
先不要看答案。
三、建议你的练习方式
不要一次把 6 道全做了。
我们按照:
① 加法
↓
② 减法
↓
③ × int
↓
④ ÷ int
↓
⑤ × 高精度
↓
⑥ ÷ 高精度
一题一题来。
而且我建议你每道题都先自己写出:
1. 数据怎么存?
2. 从哪一位开始处理?
3. 每一步计算什么?
4. 进位/借位怎么处理?
5. 最后怎么去掉前导零?
再写代码。
这样比直接背高精度模板有效得多。
现在先做第 ① 题
不要看网上模板,也不要让我直接给你代码。
你先把高精度加法 写出来,哪怕代码写得很乱都没关系,直接发给我。我会按照你的 Skill 要求,重点帮你分析为什么这样写、哪里有问题、怎么改。
ARM是什么
一、总结:快速复习
一句话:
**ARM 是一种 CPU 指令集架构(ISA)及其生态体系。**它规定了 CPU 能执行哪些指令、有哪些寄存器、如何访问内存等。
- ARM 不是某一款 CPU。 ARM 更准确地说是一套架构/指令集规范。
- 常见的两大 CPU 指令集架构可以简单理解为:
- x86/x86-64:Intel、AMD 的 PC/服务器大量使用
- ARM/AArch64:手机、嵌入式设备、Apple Silicon、很多服务器大量使用
- ARM 常见架构:
- AArch32:32 位 ARM
- AArch64:64 位 ARM
- ARM 生态中经常看到:
- ARM 指令集
- ARM Cortex-A / Cortex-M / Cortex-R
- SoC
- Apple M 系列、手机芯片等
- 面试一句话可以说:
ARM 是一种精简指令集架构(RISC),规定了处理器的指令集、寄存器和相关编程模型;具体芯片厂商可以基于 ARM 架构设计自己的 CPU。
二、详细讲解:深入理解
1. 首先搞清楚:ARM 到底是什么?
你可以先把 CPU 理解成一个"执行机器指令的东西"。比如 C++:
int a = 10;
int b = 20;
int c = a + b;
CPU 最终并不能直接理解:
a + b
它需要执行类似:
从内存取数据
↓
放进寄存器
↓
执行加法指令
↓
把结果保存
那么问题来了:
CPU 到底应该支持哪些机器指令?
这就是**指令集架构 ISA(Instruction Set Architecture)**要规定的东西。
ARM 就属于这种 ISA。
2. ARM 和 x86 是什么关系?
可以把它们理解成两套不同的"CPU语言"。例如:
C++代码
↓
编译器
↓
CPU指令
如果目标 CPU 是 x86-64:
C++
↓
x86-64 指令
↓
Intel / AMD CPU
如果目标 CPU 是 ARM64:
C++
↓
ARM64 指令
↓
ARM64 CPU
所以:
C++程序
↓
编译器
↙ ↘
x86-64 ARM64
↓ ↓
Intel/AMD ARM生态CPU
这也是为什么:
同一份 C++ 源代码,可以分别编译成 x86-64 程序和 ARM64 程序。
3. ARM 为什么经常被称为 RISC?
RISC:
Reduced Instruction Set Computer,精简指令集计算机。
ARM 的设计理念属于 RISC 路线。
与之经常拿来对比的是:
CISC(Complex Instruction Set Computer,复杂指令集)
最典型的就是 x86。
可以简单理解为:
| ARM | x86 | |
|---|---|---|
| 指令集理念 | RISC | CISC |
| 指令复杂度 | 相对简单 | 可以非常复杂 |
| 指令长度 | 传统 ARM 指令较规则;AArch64 指令固定 32 位 | x86 指令长度可变 |
| 常见场景 | 手机、嵌入式、Apple Silicon、服务器 | PC、服务器 |
| 代表 | ARM64 | x86-64 |
不过要注意:
"ARM = 简单、x86 = 复杂"只是帮助理解,不能理解成 ARM 的每条指令都简单、x86 的每条指令都复杂。
现代 CPU 内部实现其实都非常复杂。
4. ARM 和具体 CPU 是什么关系?
这是非常容易混淆的地方。例如你可能听过:
ARM Cortex-A
Apple M1
Apple M2
骁龙
联发科
它们不是完全同一个层次的概念。可以这样理解:
ARM 指令集架构
↓
CPU设计方案 / IP
↓
Cortex-A 等
↓
芯片厂商设计自己的SoC
↓
具体芯片
例如:
ARM 可以提供 CPU 架构和 CPU IP。
芯片厂商可以基于 ARM 架构设计自己的芯片。
所以:
ARM 不是"某一个 CPU 品牌型号",而是一个架构体系。
5. Cortex-A、Cortex-M 又是什么?
你以后学 ARM 时非常容易遇到这些名字。
ARM 有不同系列:
Cortex-A
主要面向:
手机
平板
PC
服务器
高性能嵌入式设备
例如:
Cortex-A 系列
一般运行:
Linux
Android
等操作系统。
Cortex-M
主要面向:
单片机
嵌入式控制
传感器
物联网
例如:
STM32
很多 STM32 使用 ARM Cortex-M 内核。
所以你以后如果看到:
STM32
就很容易想到:
STM32
↓
ARM Cortex-M
↓
ARM 架构
Cortex-R
主要面向:
实时系统
汽车
工业控制
高可靠场景
6. ARM 和 ARM64 是什么关系?
这个你现在也应该顺便搞清楚。
以前经常看到:
ARM 32位
现在大量设备使用:
ARM64
更准确地说:
AArch32
AArch64
其中:
AArch32
是 32 位执行状态。
AArch64
是 64 位执行状态。
所以你在软件安装包中经常看到:
x86
x86_64
ARM
ARM64
aarch64
大概就是在告诉你:
这个程序是给什么 CPU 架构编译的。
例如:
Windows x64
通常对应:
x86-64
而:
Linux ARM64
通常对应:
AArch64
7. 为什么 ARM 和你刚才学的"大小端"有关?
这个联系非常重要。
你刚刚学习了:
怎么判断机器是大端还是小端。
ARM 架构本身在历史上支持过不同的字节序模式,包括大端和小端。
但现在你实际接触的大多数 ARM64 Linux / Android / Apple Silicon 环境:
通常使用小端。
所以不能简单说:
"ARM 一定是小端。"
更准确的说法是:
ARM 架构可以支持不同的字节序模式,具体系统和处理器配置决定实际使用的字节序;现代常见 ARM64 平台通常采用小端。
这就是你前面学的:
CPU架构
↓
内存中的多字节数据
↓
大小端
之间的联系。
8. ARM 和你刚刚学习的浮点数有什么关系?
也有关系。比如:
float x = 13.5f;
IEEE 754 规定:
32 bit
↓
符号位
指数
尾数
但是:
IEEE 754 规定的是这些 bit 如何表示浮点数,不等于规定这些 bit 在内存的4个字节如何排列。
内存中的字节排列还涉及:
CPU架构 / 系统的字节序
所以你刚刚学的几个知识点其实已经连起来了:
ARM
│
↓
CPU指令集架构
│
┌──────┴──────┐
↓ ↓
指令执行 数据访问
│
↓
内存
│
↓
大小端
而浮点数又是:
float
↓
IEEE 754
↓
32个bit如何表示数值
↓
放进内存
↓
具体字节如何排列
↓
大小端
9. ARM 为什么在手机上特别常见?
ARM 的一个重要特点是:
非常适合低功耗、高能效的处理器设计。
手机尤其关注:
性能
+
功耗
+
发热
+
电池续航
因此 ARM 架构长期在移动设备领域非常普遍。
现在 ARM 已经不只是手机了。
例如:
手机
↓
ARM
嵌入式
↓
ARM
Apple Silicon Mac
↓
ARM64
部分云服务器
↓
ARM64
所以现在说:
"ARM 就是手机 CPU"
已经太狭窄了。
10. 一个你以后非常容易遇到的概念:SoC
手机芯片经常叫:
SoC
即:
System on Chip,片上系统。
例如一个手机 SoC 中可能包含:
┌────────────────────────────┐
│ SoC │
│ │
│ CPU GPU │
│ │
│ NPU ISP │
│ │
│ 内存控制器 │
│ │
│ 各种 I/O │
└────────────────────────────┘
其中 CPU 可能采用 ARM 架构。所以:
ARM
和:
骁龙 / Apple Silicon / 手机SoC
不是同一个层级。
11. 你可以这样建立计算机底层知识体系
你最近刚好在学习:
进制
↓
二进制
↓
大小端
↓
浮点数
↓
ARM
其实这些知识可以串起来:
C/C++程序
↓
编译器
↓
CPU机器指令
↓
┌─────────┴─────────┐
↓ ↓
ARM / x86 数据
指令集架构 ↓
│ 二进制表示
│ ↙ ↘
│ 整数 浮点数
│ ↓
│ IEEE 754
│
↓
CPU访问内存
↓
多字节数据排列
↓
大小端
如果你现在是在准备计算机基础/秋招,我建议下一步把"ARM 的寄存器 + 指令 + 汇编"和"x86-64"区分开来学习。这会比单独背"ARM是什么"更有用。
ARM 补充:架构差异、字节序与浮点数
如果你这里是在接着刚才的**"大小端"**问 ARM,那么重点是:ARM 到底是大端还是小端?
一、总结:快速复习
一句话:
ARM 架构本身既支持大端,也支持小端;但现代 ARM 设备实际使用中绝大多数是小端。
-
ARM ≠ 固定小端,这是面试容易踩的坑。
-
ARM 架构支持 Little-Endian(小端) 和 Big-Endian(大端)。
-
现代 ARM64 / AArch64 系统通常使用小端。
-
例如常见的:
- Android 手机 → 通常小端
- iPhone / iPad → 通常小端
- Apple Silicon Mac → 小端
- ARM Linux → 通常小端
-
所以面试不要回答:
"ARM 是小端。"
更严谨的回答是:
"ARM 架构支持大小端两种模式,但现代主流 ARM 系统通常采用小端。"
二、详细讲解:深入理解
1. ARM 和 x86 的区别
刚才我们说:
x86 / x86-64
↓
小端
ARM 则不同:
ARM 架构
├── 小端
└── 大端
也就是说:
ARM 架构规范允许两种字节序。
但你实际使用 ARM 电脑、手机时,基本遇到的是:
ARM
↓
Little-Endian
↓
小端
2. 为什么 ARM 要支持两种?
因为 ARM 是一种处理器架构(ISA),它需要适应不同应用场景。
例如某些历史系统、嵌入式设备或者特定通信系统可能要求大端。
因此 ARM 架构设计上允许:
Big-Endian
Little-Endian
而操作系统、启动配置、CPU实现等共同决定最终使用哪一种。
所以要区分:
ARM架构支持什么?
↓
大小端都支持
某台ARM机器实际使用什么?
↓
看具体系统/配置
3. ARM64 为什么通常是小端?
现在你接触到的 ARM64,也就是:
AArch64
主流操作系统基本都采用:
Little-Endian
例如:
Apple Silicon
↓
ARM64
↓
小端
所以如果你以后在:
ARM Linux
Android
树莓派
Apple Silicon
上写:
int x = 0x12345678;
通常内存会是:
低地址 → 高地址
78 56 34 12
4. ARM和浮点数有什么关系?
这就可以和你刚刚问的浮点数底层存储联系起来。
假设:
float x = 13.5f;
IEEE 754规定:
32 bit
符号 指数 尾数
1 8 23
这些 bit的含义与CPU是不是ARM没有直接关系。
但是:
这32 bit最终如何拆成4个Byte放入内存,会受到字节序影响。
所以:
IEEE 754
↓
规定"这32个bit代表什么"
而:
ARM小端
↓
规定"这4个Byte在内存中的排列"
这是两个层次。
5. 一个非常重要的面试区分
面试官可能问:
ARM使用什么字节序?
错误回答:
ARM使用小端。
更好的回答:
ARM架构同时支持大端和小端,但现代主流ARM处理器和操作系统通常采用小端字节序。
如果继续追问:
那ARM和x86在大小端方面有什么区别?
可以回答:
x86/x86-64基本固定采用小端,而ARM架构支持大小端两种模式,不过现代ARM设备通常也是小端。
6. 你现在可以把这几个知识点串起来
你刚才连续问的几个问题,其实正好是一条非常重要的 C++/计算机基础知识链:
C++变量
↓
sizeof
↓
float = 4B
double = 8B
↓
浮点数怎么表示?
↓
IEEE 754
↓
符号 + 指数 + 尾数
↓
这些Byte怎么放进内存?
↓
大小端
↓
x86 → 通常小端
ARM → 支持大小端,但现代系统通常小端
秋招面试最容易考的其实不是单独背"ARM是大端还是小端",而是考你能不能把 数据表示 → 内存布局 → 字节序 → CPU架构 这几个概念联系起来。