C++学习笔记:双重指针与二维数组

哈喽,同学们!今天我们来聊聊C++里两个听起来有点“绕”但实际很重要的概念:双重指针二维数组。别担心,我会用最直白的方式,从学生的角度来给大家讲清楚!

一、双重指针(指针的指针)

1. 什么是双重指针?

我们知道,一个普通指针(比如 int* p)是用来存储一个变量的地址的。那双重指针呢?顾名思义,它就是用来存储指针的地址的!

想象一下,你有一个房间(变量),房间里放着一个纸条,纸条上写着另一个房间的地址(普通指针)。现在,双重指针就是另一个纸条,上面写着你手里这个纸条的地址。是不是有点像“套娃”?

在C++中,我们用两个星号 ** 来声明一个双重指针。

举个例子:

1
2
3
int a = 10;      // 定义一个整型变量 a
int* p = &a; // 定义一个指向 a 的指针 p,p 存储 a 的地址
int** pp = &p; // 定义一个指向 p 的双重指针 pp,pp 存储 p 的地址

2. 怎么理解双重指针?

我们来画个图,帮助大家理解内存中的关系:

1
2
3
graph TD
A[变量 a: 10] -->|地址 &a| B(指针 p: &a)
B -->|地址 &p| C(双重指针 pp: &p)
  • a:存储值 10
  • p:存储 a 的地址。
  • pp:存储 p 的地址。

3. 如何通过双重指针访问数据?

  • pp:存储的是 p 的地址。
  • *pp:通过 pp 解引用一次,得到的是 p 的值,也就是 a 的地址。所以 *pp 等价于 p
  • **pp:通过 pp 解引用两次,第一次得到 p 的值(a 的地址),第二次再解引用 a 的地址,就得到了 a 的值。所以 **pp 等价于 *p,也等价于 a

代码验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

int main() {
int a = 10;
int* p = &a;
int** pp = &p;

std::cout << "变量 a 的值: " << a << std::endl; // 输出 10
std::cout << "通过指针 p 访问 a 的值: " << *p << std::endl; // 输出 10
std::cout << "通过双重指针 pp 访问 a 的值: " << **pp << std::endl; // 输出 10

std::cout << "变量 a 的地址: " << &a << std::endl;
std::cout << "指针 p 存储的地址 (a 的地址): " << p << std::endl;
std::cout << "通过双重指针 pp 解引用一次得到的值 (p 的值,即 a 的地址): " << *pp << std::endl;

std::cout << "指针 p 的地址: " << &p << std::endl;
std::cout << "双重指针 pp 存储的地址 (p 的地址): " << pp << std::endl;

return 0;
}

4. 双重指针的常见用途

  • 函数参数: 想象一下,你有一个空箱子(int* myArr = nullptr;),你希望一个函数(allocateMemory)帮你把这个箱子装满(让它指向一块新分配的内存)。

    如果函数只接收 int* ptr(普通指针),那么它只能修改 ptr 指向的内容,而不能改变 ptr 本身指向哪里。就像你把箱子里的东西拿出来给别人看,别人只能看,不能把你的箱子换成另一个箱子。

    但是,如果你把箱子的地址&myArr)传给函数,函数就能通过这个地址找到你的箱子,然后把箱子里的“纸条”(myArr 存储的地址)换成新的“纸条”(新分配内存的地址)。

    所以,当函数需要修改外部指针变量本身(让它指向新的内存地址)时,就需要传入双重指针。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    void allocateMemory(int** ptr) { // ptr 是一个双重指针,它存储了外部指针的地址
    // *ptr 表示通过 ptr 找到外部指针变量本身
    // new int[5] 分配了一块新的内存,并返回这块内存的首地址
    // 这一行代码的意思是:把新分配内存的首地址,赋值给外部指针变量
    *ptr = new int[5];
    }

    int main() {
    int* myArr = nullptr; // 声明一个空指针 myArr,它现在不指向任何有效的内存
    std::cout << "调用 allocateMemory 前,myArr 的值 (指向的地址): " << myArr << std::endl; // 此时为 0x0 或 nullptr,表示空

    allocateMemory(&myArr); // 传入 myArr 的地址,让函数能修改 myArr 本身的值(即它指向的地址)

    std::cout << "调用 allocateMemory 后,myArr 的值 (指向的地址): " << myArr << std::endl; // 此时为新分配内存的地址

    // 重点来了!修改空指针的地址有什么作用?我们继续用超市的例子来理解:
    // 你有一个空的购物袋 (myArr),它现在是空的 (nullptr),不指向任何有用的东西。
    // 你想让一个朋友 (allocateMemory 函数) 帮你去超市买 5 样东西 (new int[5] 分配内存)。

    // 情况一:如果你只把购物袋本身给朋友 (void allocateMemory(int* ptr))
    // 朋友拿到你的购物袋,他可以在里面装东西,但这个购物袋还是你原来的那个。
    // 朋友不能把你的购物袋扔掉,然后给你换一个全新的、更大的购物袋。
    // 也就是说,函数内部 `ptr = new int[5];` 只是修改了函数内部 `ptr` 这个局部变量的指向,
    // 外部的 `myArr` 仍然是空的,没有指向新买到的东西。
    // 这就是普通指针作为函数参数的局限性。
    // 那么,双重指针(情况二)就是为了解决这个问题而存在的。
    // 朋友通过“你放购物袋的那个位置”,就可以直接把你的旧购物袋(空指针)换成一个装满东西的新购物袋(新分配的内存地址),
    // 这样你就能在函数外部使用这块新内存了。

    // 情况二:你把“你放购物袋的那个位置”告诉朋友 (传入 &myArr,用双重指针 int** ptr 接收)
    // 朋友通过你告诉他的“位置”(`&myArr`),找到了你放购物袋的地方。
    // 朋友就可以直接在你放购物袋的位置,把旧的空购物袋(`nullptr`)拿走,
    // 然后把一个装满 5 样东西的全新购物袋(`new int[5]` 返回的新内存地址)放到那个位置。
    // 这样,当朋友回来后,你直接去那个位置拿购物袋,拿到的就是那个装满东西的新购物袋了!

    // 所以,修改空指针的地址,就是为了让函数能够把在它内部动态分配的内存,
    // “传递”给函数外部的指针变量,让外部的指针能够真正地“拥有”并使用这块新内存。
    // 简单来说,就是通过修改 `myArr` 这个空指针的地址,让它从“空”变为“指向新分配的内存”,
    // 从而 `main` 函数才能使用这块由 `allocateMemory` 函数分配的内存。

    // 补充:如果这里的 `nullptr` 换成一个不为空的数组的指针,是不是也能成立,通过双重指针修改内存的大小?
    // 答案是:**能成立!**
    // 双重指针的作用是修改它所指向的那个“一级指针”的值(也就是地址)。
    // 无论这个“一级指针”原来是 `nullptr`(空),还是已经指向了一块内存,
    // 只要你把这个“一级指针”的地址传给双重指针,函数内部就可以通过双重指针来修改这个“一级指针”的指向。
    //
    // 但是,这里需要澄清一个概念:**不是直接修改“内存的大小”**。
    // `new` 和 `delete` 是用来**分配**和**释放**内存的。如果你需要一块不同大小的内存,
    // 通常的做法是:
    // 1. 先 `delete[]` 释放掉旧的内存(如果 `myArr` 之前指向了内存)。
    // 2. 再 `new` 一块新的、所需大小的内存。
    // 3. 然后把新内存的地址赋值给 `myArr`。
    //
    // 就像超市的例子:朋友可以把你的旧购物袋(即使里面有东西)拿走,然后给你换一个全新的、更大或更小的购物袋。
    // 但他不能直接把你的旧购物袋“变大”或“变小”。
    //
    // 这种“重新分配”的场景,双重指针同样非常有用,因为它允许函数在完成“释放旧内存”和“分配新内存”后,
    // 将新内存的地址“更新”到外部的指针变量上。

    // 在这里,myArr 已经指向了新分配的内存,我们就可以像操作普通数组一样使用它了:
    myArr[0] = 100;
    myArr[1] = 200;
    std::cout << "myArr[0] = " << myArr[0] << std::endl; // 输出 100
    std::cout << "myArr[1] = " << myArr[1] << std::endl; // 输出 200

    // 记得释放内存!动态分配的内存用完一定要释放,否则会造成内存泄漏!
    delete[] myArr;
    myArr = nullptr; // 养成好习惯,释放后将指针置为 nullptr,避免成为“野指针”
    return 0;
    }
  • 管理指针数组: 双重指针经常用于管理“指针的数组”,最经典的例子就是 main 函数的参数 char** argv

    • int main(int argc, char** argv)
      • argc (argument count):表示命令行参数的数量。
      • argv (argument vector):这是一个双重指针。它指向一个数组,这个数组的每个元素都是一个 char* 类型的指针(即指向字符串的指针)。
    • 怎么理解 char** argv
      • 想象一下,你有一张清单argv)。这张清单本身存储在内存中。
      • 这张清单上,每一行都写着一个地址
      • 这些地址指向的,是一个个独立的字符串(比如你运行程序时输入的命令行参数)。
      • 所以,argv 就是一个指向“存放字符串地址的清单”的指针。
    • 内存布局描述:
      char** argv 的内存布局中,可以想象栈区有一个 argc 变量存储命令行参数数量,以及一个 argv 指针变量,它存储着一个“清单”的起始地址。这个“清单”通常位于堆区或数据区,它是一个指针数组,每个元素都是一个 char* 类型的指针。这些 char* 指针依次指向内存中实际存储的各个命令行参数字符串(例如 ./myprogramhelloworld)。argv 数组的最后一个元素通常是 nullptr,表示参数列表的结束。
      • “清单”这个数组的每个元素(argv[0], argv[1], argv[2])又都是一个 char* 类型的指针。
      • 这些 char* 指针分别指向内存中实际存储的字符串("./myprogram", "hello", "world")。
      • 注意:argv 数组的最后一个元素通常是 nullptr,表示参数列表的结束。
    • 代码示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      #include <iostream>

      // 编译后运行:./myprogram arg1 arg2
      int main(int argc, char** argv) {
      std::cout << "命令行参数数量: " << argc << std::endl;
      std::cout << "所有命令行参数:" << std::endl;
      for (int i = 0; i < argc; ++i) {
      std::cout << "参数 " << i << ": " << argv[i] << std::endl;
      }
      // 也可以通过 while 循环遍历,利用最后一个 nullptr 结束
      // char** current_arg = argv;
      // while (*current_arg != nullptr) {
      // std::cout << "参数 (while): " << *current_arg << std::endl;
      // current_arg++;
      // }
      return 0;
      }
      • 如果你运行 myprogram hello world,输出会是:
        1
        2
        3
        4
        5
        命令行参数数量: 3
        所有命令行参数:
        参数 0: ./myprogram
        参数 1: hello
        参数 2: world
      • 通过这个例子和图示,你可以看到 argv 如何让你方便地访问每一个命令行参数字符串。

二、二维数组

1. 什么是二维数组?

我们学过一维数组,它就像一排整齐的格子。二维数组呢,你可以把它想象成一个表格,有行有列。

在C++中,二维数组的声明方式是 类型 数组名[行数][列数];

举个例子:

1
int matrix[3][4]; // 声明一个 3 行 4 列的整型二维数组

2. 二维数组在内存中如何存储?

虽然我们把它想象成表格,但计算机内存是线性的,一维的。二维数组在内存中是按行连续存储的。

比如 matrix[3][4],它会先存储 matrix[0][0]matrix[0][3],然后是 matrix[1][0]matrix[1][3],最后是 matrix[2][0]matrix[2][3]。所有元素紧密排列,形成一个连续的内存块。

3. 如何访问二维数组的元素?

通过 数组名[行索引][列索引] 来访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

int main() {
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

std::cout << "matrix[0][0] = " << matrix[0][0] << std::endl; // 输出 1
std::cout << "matrix[1][2] = " << matrix[1][2] << std::endl; // 输出 6

// 遍历二维数组
for (int i = 0; i < 2; ++i) { // 遍历行
for (int j = 0; j < 3; ++j) { // 遍历列
std::cout << matrix[i][j] << " ";
}
std::cout << std::endl;
}

return 0;
}

4. 二维数组与指针的关系

这是最容易混淆的地方,但理解了双重指针,这里就容易多了!

  • 数组名作为指针:

    • matrix:代表整个二维数组的首地址,它是一个指向“包含3个整型元素的一维数组”的指针。它的类型是 int (*)[3]
    • matrix[0]:代表第一行的首地址,它是一个指向 int 的指针,等价于 &matrix[0][0]
    • matrix[1]:代表第二行的首地址,等价于 &matrix[1][0]
  • 通过指针访问二维数组:

    • *matrix:解引用 matrix,得到的是第一行这个一维数组。所以 *matrix 等价于 matrix[0]
    • **matrix:解引用 *matrix,得到的是第一行的第一个元素,即 matrix[0][0]
    • *(matrix[i]):等价于 matrix[i][0]
    • *(*(matrix + i) + j):这是通过指针算术来访问 matrix[i][j] 的通用方式。
      • matrix + i:跳过 i 行,得到第 i 行的首地址。
      • *(matrix + i):解引用得到第 i 行这个一维数组。
      • *(matrix + i) + j:在第 i 行的基础上,跳过 j 个元素,得到 matrix[i][j] 的地址。
      • *(*(matrix + i) + j):最终解引用得到 matrix[i][j] 的值。

代码验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

int main() {
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

// matrix 的类型是 int (*)[3],指向一个包含3个int的数组
// matrix[0] 的类型是 int*,指向一个int
// &matrix[0][0] 的类型是 int*,指向一个int

std::cout << "matrix 的地址: " << matrix << std::endl;
std::cout << "matrix[0] 的地址: " << matrix[0] << std::endl;
std::cout << "&matrix[0][0] 的地址: " << &matrix[0][0] << std::endl;
std::cout << "-----------------------------------" << std::endl;

std::cout << "*matrix (等价于 matrix[0]) 的值: " << *matrix << std::endl; // 输出 matrix[0] 的地址
std::cout << "**matrix (等价于 matrix[0][0]) 的值: " << **matrix << std::endl; // 输出 1
std::cout << "-----------------------------------" << std::endl;

// 访问 matrix[1][2] 的几种方式
std::cout << "matrix[1][2] = " << matrix[1][2] << std::endl;
std::cout << "*(matrix[1] + 2) = " << *(matrix[1] + 2) << std::endl;
std::cout << "*(*(matrix + 1) + 2) = " << *(*(matrix + 1) + 2) << std::endl;

return 0;
}

5. 动态分配二维数组(重点!)

固定大小的二维数组在编译时就确定了大小,不够灵活。在实际开发中,我们经常需要动态分配二维数组。这通常会用到双重指针!

方法一:使用指针数组(每行是独立的一维数组)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>

int main() {
int rows = 2;
int cols = 3;

// 1. 首先,分配一个指针数组,用来存放每一行的首地址
int** dynamicMatrix = new int*[rows];

// 2. 然后,为每一行分配一个一维数组
for (int i = 0; i < rows; ++i) {
dynamicMatrix[i] = new int[cols];
}

// 3. 填充数据
int count = 1;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
dynamicMatrix[i][j] = count++;
}
}

// 4. 打印数据
std::cout << "动态分配的二维数组 (方法一):" << std::endl;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
std::cout << dynamicMatrix[i][j] << " ";
}
std::cout << std::endl;
}

// 5. 释放内存 (非常重要!)
// 先释放每一行的一维数组
for (int i = 0; i < rows; ++i) {
delete[] dynamicMatrix[i];
}
// 再释放指针数组
delete[] dynamicMatrix;
dynamicMatrix = nullptr; // 养成好习惯,置为 nullptr

return 0;
}

这种方法的好处是每行可以有不同的列数(不规则数组),但内存不连续。

方法二:模拟二维数组(内存连续)

这种方法更接近静态二维数组的内存布局,所有元素在内存中是连续的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>

int main() {
int rows = 2;
int cols = 3;

// 1. 分配一块大的连续内存,足以存放所有元素
int* data = new int[rows * cols];

// 2. 创建一个指针数组,让每个指针指向对应行的起始位置
int** dynamicMatrix = new int*[rows];
for (int i = 0; i < rows; ++i) {
dynamicMatrix[i] = data + i * cols; // 每行指针指向 data 中对应行的起始地址
}

// 3. 填充数据
int count = 1;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
dynamicMatrix[i][j] = count++;
}
}

// 4. 打印数据
std::cout << "动态分配的二维数组 (方法二):" << std::endl;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
std::cout << dynamicMatrix[i][j] << " ";
}
std::cout << std::endl;
}

// 5. 释放内存 (非常重要!)
delete[] data; // 只需释放一次大的内存块
delete[] dynamicMatrix; // 释放指针数组
data = nullptr;
dynamicMatrix = nullptr;

return 0;
}

这种方法内存连续,访问效率更高,但每行的列数必须相同。

总结

  • 双重指针是存储指针地址的指针,通过 ** 访问最终数据。它在需要修改函数外部指针指向时非常有用,也是动态分配二维数组的关键。
  • 二维数组可以看作是表格,在内存中是按行连续存储的。数组名本身可以看作是指针,通过指针算术可以灵活访问元素。
  • 动态二维数组的分配通常需要双重指针,有两种常见方法:一种是每行独立分配,另一种是分配一块连续内存再用指针数组指向各行。

希望这篇笔记能帮助大家更好地理解双重指针和二维数组!多敲代码,多画图,很快就能掌握它们啦!