秋招、实习 小知识点复习 (C/C++/Linux)—— 碎片时间可看

好久不见,不知道还有没有人记得我,我已经快两年没更新了,这两年内生活步入了迷茫和焦虑,因此断更,现在重拾博客,准备秋招或者实习的小伙伴可以关注我的这篇文章(每日更新),主要写秋招和实习时的小知识点复习,小伙伴们可休闲时看,小点补充小知识点,希望我们都能找到自己想要的工作

目录


第一部分: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. 常见自定义类型还要注意什么?

本节目录

  • 空类

  • [只有一个 int](#只有一个 int)

  • [两个 int](#两个 int)

  • 含指针

空类
复制代码
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::stringstd::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 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 intint 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;

ab 都必须初始化,初始化后不能再赋值:

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* constthis 自身不能改为指向另一个对象;
  • 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++ 函数构成重载的条件

本节目录

一、总结:快速复习

函数重载是指:同一作用域内,函数名相同,但编译器能够根据参数或成员函数限定符区分出不同函数。

普通函数最常见的重载条件是参数列表至少有一项不同:

  • 参数数量不同;
  • 参数类型不同;
  • 参数类型的排列顺序不同。

非静态成员函数还可以根据以下内容构成重载:

  • 成员函数末尾的 constvolatile 限定不同;
  • 引用限定符 &&& 不同。

以下内容不能单独构成重载

  • 返回类型不同;
  • 形参名称不同;
  • 默认参数不同;
  • 值形参的顶层 const 不同;
  • 只有 noexcept 不同;
  • typedefusing 写法不同,但实际类型相同。
先理解:顶层 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

两个声明都能接收普通 intconst 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. typedefusing 不同:别名不会创造新类型

cpp 复制代码
using Number = int;
typedef int Integer;

void calculate(int value);
void calculate(Number value);  // 仍然是 calculate(int)
void calculate(Integer value); // 仍然是 calculate(int)

NumberInteger 只是 int 的其他名字,不是两种新类型。别名展开后三个参数完全相同,因此不能重载。

这和真正定义新类型不同:

cpp 复制代码
struct Number {
    int value;
};

void calculate(int value);
void calculate(Number value); // 可以重载:Number 是独立类型
四项对比速览
差异 能否单独构成重载 最直接的原因
默认参数不同 不能 默认值不改变参数列表
值形参的顶层 const 不同 不能 只限制函数内部的副本,调用方式相同
只有 noexcept 不同 不能 不用于同名函数的重载选择
typedefusing 别名不同 不能 别名展开后仍是同一个类型
指针或引用的底层 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 指针,因此不能使用成员函数末尾的 constvolatile 或引用限定符:

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. 重载函数可以具有不同的访问权限

同一个类中的重载成员函数可以分别位于 publicprotectedprivate 区域。访问权限不决定能否构成重载;函数名和参数列表等重载条件才决定它们是否属于同一个重载集合。

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 本身不用于区分重载
返回类型不同 不可以 返回类型不能单独区分调用
参数名称不同 不可以 参数名不参与重载区分
默认参数不同 不可以 默认参数不改变函数类型
intconst int 值形参 不可以 顶层 const 被忽略
int*int* const 值形参 不可以 顶层 const 被忽略
只有 noexcept 不同 不可以 不能单独用于重载
六、面试一句话回答

函数重载要求同一作用域内函数名相同,并且参数数量、参数类型或参数顺序不同;非静态成员函数还可以通过末尾的 constvolatile 或引用限定符区分。返回类型、参数名、默认参数、值形参的顶层 constnoexcept 不能单独构成重载。重载声明合法也不代表每次调用都合法,实参必须能够选出唯一的最佳匹配。

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 intint 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 intint 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;

回答:

  1. 能否编译?
  2. 为什么普通的 int& ref = 10; 通常不能编译?
  3. 这里临时对象的生命周期发生了什么?

这个地方我掌握不好,你题目问的就已经告诉我这个写法是正确的,但是 我没有完全理解原因,因此你可以讲了再让我尝试回答

题 13:下面的声明为什么错误?
cpp 复制代码
int x = 10;
int& const ref = x;

说明为什么引用不需要再添加"顶层 const"。

顶层const是限制变量本身,引用的特点是无法更改指向的对象,所以不管有没有const,无论进行什么操作结果都一样

题 14:辨析两种参数
cpp 复制代码
void f1(const int& value);
void f2(int& value);

回答:

  1. 哪个函数能够接收普通变量?都可以
  2. 哪个函数能够接收字面量 10?f1
  3. 哪个函数能够通过形参修改实参?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;
};

说明为什么错误,并分别给出以下两种目标对应的修改方案:

  1. increase 本来就应该修改对象;
  2. 某个成员只是缓存,希望在逻辑只读函数中更新。
题 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;
};

回答:

  1. 这两个函数能否同时存在?
  2. 普通对象和常量对象分别调用哪一个?
  3. 为什么返回类型也常分别写成 char&const char&

第六组:返回值与容易误用的 const

本节目录

  • [题 25:返回常量引用是否安全?](#题 25:返回常量引用是否安全?)

  • [题 26:辨析返回类型](#题 26:辨析返回类型)

  • [题 27:基本类型按值返回加 const 是否常见?](#题 27:基本类型按值返回加 const 是否常见?)

题 25:返回常量引用是否安全?
cpp 复制代码
const int& getValue()
{
    int value = 10;
    return value;
}

回答:

  1. 主要问题是什么?
  2. 它与 const 本身是否有关?
  3. 可以怎样安全修改?
题 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;
};

回答:

  1. getName 是否合理?
  2. rename 能否编译?
  3. 应如何修改 rename
  4. getName 返回 const std::string& 对调用者有什么限制?

第八组:面试口述题

本节目录

  • [题 31](#题 31)

  • [题 32](#题 32)

  • [题 33](#题 33)

  • [题 34](#题 34)

  • [题 35](#题 35)

题 31

用一句话说明 const int*int* const 的区别。

题 32

为什么大对象只读传参常用 const T&,而不是 TT&

题 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* pp 是指针,指向 const int
  • int* const ppconst 指针,指向 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;
};
四、面试容易追问
  1. const int*int* constconst int* const 分别表示什么?
  2. 为什么有 const int&,却没有合法的 int& const
  3. 成员函数末尾的 const 修饰什么?为什么它可以参与函数重载?
  4. 为什么 const int* 不能传给参数类型为 int* 的函数?
  5. 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

此时 refx 表示同一个对象:

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&

但对于 intchar、指针等很小的类型,值传递往往更直接,不必机械地全部改成引用。

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*parr[0] 通常表示有身份、可以被定位并在之后继续访问的对象
右值 10a + 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) :用于产生值或初始化对象,例如 10a + bstd::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&&:主要接右值,用来移动。
有名字的右值引用变量,本身是左值表达式。
四、面试容易追问
  1. 什么是左值和右值?它们与能否被修改有什么关系?
  2. T&const T&T&& 分别能绑定什么?
  3. 为什么右值引用变量有名字以后是左值表达式?
  4. std::move 是否真的执行了移动?
  5. 移动构造为什么通常比复制构造快?移动后的对象还能使用吗?
  6. const T& 为什么能够延长临时对象的生命周期?
  7. 纯右值和将亡值有什么区别?

C++ 前置自增与后置自增(++ii++

本节目录

一、总结:快速复习

在算法题里很多人写:

复制代码
++i

而不是:

复制代码
i++

主要有两个原因:

  • 对于普通整数变量,++ii++ 在最终结果上通常没有区别。

  • ++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. ++ii++ 到底有什么区别?

本节目录

假设:

复制代码
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. 一个很重要的面试点:不要说"++ii++ 快"

如果面试官问:

为什么很多 C++ 程序员喜欢写 ++i

不要直接回答:

因为 ++ii++ 快。

这个回答太绝对。

更准确的是:

对于内置整数类型,在现代编译器优化下,++ii++ 在典型 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++ 手搓常见数据结构

本节目录

一、总结:快速复习

本节目录

  • [1. 面试常见实现范围](#1. 面试常见实现范围)

  • [2. 推荐练习顺序](#2. 推荐练习顺序)

1. 面试常见实现范围
数据结构 底层核心 常考操作 关键复杂度
动态数组 连续内存、扩容 增删查改 随机访问 O(1),尾插均摊 O(1)
单链表 节点和指针 插入、删除、反转 已知位置插删 O(1),查找 O(n)
双向链表 prevnext 双向插删 已知节点插删 O(1)
数组或链表 pushpoptop 通常 O(1)
队列 链表或循环数组 pushpopfront 通常 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

面试手写时应优先保证:

  1. 明确空结构和边界条件;
  2. 插入、删除后结构仍然合法;
  3. 动态内存最终能够释放;
  4. 先说复杂度,再写代码;
  5. 不要为了模拟完整 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. 高频面试追问
  1. vector 为什么按倍数扩容?尾插为什么是均摊 O(1)
  2. 如何在 O(1) 时间删除链表中一个已知节点?
  3. 循环队列如何区分空和满?
  4. BST 删除有两个孩子的节点时如何处理?
  5. 建堆为什么可以做到 O(n),而不是 O(n log n)
  6. 哈希冲突有哪些解决方法?什么时候需要扩容?
  7. 并查集的路径压缩和按秩合并分别解决什么问题?
  8. Trie 为什么查询快,空间为什么可能很大?

第三部分:计算机组成与数据表示

数制与二进制运算

本节目录

二进制与其他进制之间的转换

本节目录

一、总结:快速复习

一句话:

进制转换的核心就是:整数部分除基取余、倒序读;小数部分乘基取整、正序读。

常见进制:

进制 基数 使用的数字
二进制 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 位一组。


一个容易混淆的点:0x0b0 是什么?

在 C/C++ 中经常看到:

复制代码
int a = 100;       // 十进制
int b = 0b1100100; // 二进制
int c = 0144;     // 八进制
int d = 0x64;      // 十六进制

它们表示的是同一个数

复制代码
100₁₀
= 1100100₂
= 144₈
= 64₁₆

0x0b 等只是告诉编译器:

"我后面写的这个数字采用什么进制。"

并不会改变这个数字本身的数值。


建议你现在直接做 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 道刚好能检查你是否真正掌握了整数、小数、二进制、十六进制之间的转换

二进制加减法

本节目录

一、总结:快速复习

二进制加减法和十进制本质一样,只不过每一位只有 01

二进制加法最重要的 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
------

**先自己算,再把过程发给我。**我可以不只看最终答案,还能帮你检查你到底是在哪一步进位/借位出了问题。

大小端与字节序

本节目录

一、总结:快速复习

一句话:

大小端(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++)

本节目录

一、总结:快速复习

验证一台机器是大端还是小端,核心思想只有一句话:

把一个多字节数据放进内存,然后观察它最低地址处存的是高位字节还是低位字节。

常见方法有:

  1. 指针强制转换法 ------ 最经典,C/C++都能用,面试首选。
  2. 联合体 union ------ 代码非常短,也是经典面试题。
  3. 逐字节打印法 ------ 直接观察内存,最直观。
  4. 位运算 + 内存观察 ------ 可以进一步理解"数值"和"内存表示"的区别。
  5. 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++ 中 charsigned charunsigned 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)

本节目录

一、总结:快速复习

本节目录

  • [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++ 中常见的 floatdouble 通常采用 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. floatdouble 的区别
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.10.20.3 在二进制浮点数中通常都只能近似表示,参与运算后产生的近似误差可能导致结果与直接表示的 0.3 不完全相同。

floatdouble 谁更精确?

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

负责精度,

复制代码

负责数量级。

对于 float

复制代码
指数 8 bit
Bias = 127

正常情况下:

复制代码
存储指数 = 实际指数 + 127

所以正常指数编码:

复制代码
00000001
↓
实际指数 = 1 - 127 = -126

一直到:

复制代码
11111110
↓
实际指数 = 254 - 127 = 127

所以正常 float 的实际指数范围是:

复制代码
-126 ~ +127

注意这里非常重要:

正常数的最小实际指数是 -126,而不是 -127。

那么问题来了:


2. -1260 之间怎么办?

这是理解"次正规数"的关键。

正常 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

请问:

  1. 这个指数部分表示的无符号数是多少?
  2. 实际指数是多少?
第三组:精度问题
题 7

为什么下面的代码不能保证输出 true

复制代码
double a = 0.1;
double b = 0.2;

std::cout << (a + b == 0.3);

请不要只回答"有精度误差"。

我要你解释:

精度误差是怎么产生的?

题 8

下面两个概念分别由浮点数的哪一部分决定?

  1. 能表示多大的数、多少数量级?
  2. 能精确到多少位有效数字?

请说明:

  • 指数负责什么?
  • 尾数负责什么?
第四组:特殊值
题 9

IEEE 754 中,float 的指数部分如果:

  1. 全部为 0
  2. 全部为 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.mdopenai.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++ 内置整数类型的表示范围时,不依赖 intlong 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

如果这三个理解了,后面的代码基本都是在实现这些数学过程。

怎么判断一个题目/场景是否需要高精度

本节目录

一、总结:快速复习

判断一道题是否需要高精度,核心就看一句话:

看"最终计算过程中可能出现的数值"是否超过当前数据类型的表示范围,或者题目是否要求精确表示超大整数/超高精度小数。

你可以按这个顺序判断:

  1. 先看题目给的数据范围。

    • 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数量级)
  2. 不要只看输入,还要看中间计算结果。

    • 即使输入只有 10^9,连续乘法、阶乘、幂、组合数也可能迅速爆掉。
  3. 如果题目要求精确计算超大整数 ,不能用 double 代替。

  4. Python 中通常直接用 int 就够了 ,因为 Python int 支持任意精度整数。

  5. C++ 中才需要特别考虑

    复制代码
    int
    ↓ 不够
    long long
    ↓ 还不够
    __int128
    ↓ 还不够
    高精度 / 大整数
  6. 最重要的判断口诀:

    先看输入范围,再看运算增长,最后看是否要求精确。


二、详细讲解:深入理解
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。你只需要学和算法题强相关的一小部分。

优先级建议:

  1. 变量与赋值
  2. 整数运算+ - * / // % **
  3. 字符串 str
  4. 列表 list
  5. for / while
  6. if
  7. 函数
  8. 输入输出
  9. 切片
  10. 列表/字符串常用操作
  11. Python 大整数
  12. int ↔ str 转换
  13. 高精度算法中最常用的字符串处理

最关键的一点:

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*bint 阶段就溢出了:

复制代码
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. 11L1LL 到底有什么区别?

这是你需要记住的。

复制代码
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\*blong 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. 商的每一位怎么确定?
  2. 怎么比较两个高精度数?
  3. 怎么做高精度减法?
  4. 怎么找到每一位商?

最经典的方法就是模拟长除法


二、详细讲解:深入理解
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 题会综合前面所有知识。

二、详细讲解:训练安排
① 第一题:高精度加法
题目

给定两个非负整数 AB,它们的长度均不超过 1000 位

求:

复制代码
A + B
输入
复制代码
A
B

例如:

复制代码
12345678901234567890
98765432109876543210
输出
复制代码
111111111011111111100
要求

不能使用 Python 的大整数直接计算。

你需要自己模拟:

复制代码
个位
↓
十位
↓
百位
↓
进位
提示

建议使用:

复制代码
string

或者:

复制代码
vector<int>

先自己决定数据结构。


② 第二题:高精度减法
题目

给定两个非负整数 AB

复制代码
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 位的非负整数
  • B1 <= 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架构 这几个概念联系起来。

相关推荐
charlie1145141912 小时前
深探std::vector:三指针、扩容与迭代器失效
开发语言·c++·开源项目
laplaya2 小时前
ROS常用消息之PointCloud2
c++
沙盘客3 小时前
典型应用与趋势:作战实验、AI 决策与数字孪生
c++·人工智能·经验分享
ShineWinsu4 小时前
对于 C++ :从 const、constexpr、auto、decltype 到 type_traits、SFINAE 与 Concepts的解析
c++
_Narcissus_4 小时前
B树概念及操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
筠筠喵呜喵4 小时前
解决问题:QNX平台调用glReadPixels卡滞
c++·unix
_Narcissus_5 小时前
B+树的概念和操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
aichitang20245 小时前
快乐泛函每一天!内积空间
c++·python·数学·算法·机器学习·ai·泛函分析
Ravikov5 小时前
工具开发-ESP32程序管理系统 | 具体实现(一)
c++