当前位置: 首页 > news >正文

深入理解 C 语言指针(二):数组与指针的深度绑定

在上一篇《深入理解C指针(一)》中,我们理解了内存与地址的关系,掌握了指针变量的声明与使用,学会了通过解引用操作内存,并最终用传址调用解决了函数间数据交换的核心难题。

然而,指针的运用远不止于此。在实际编程中,我们更需要处理的是数据集合而非单一变量。数组作为最基本的数据集合,与指针有着千丝万缕、密不可分的关系。你是否曾对以下问题感到困惑:

  • 为什么 arr&arr[0] 的值相同,但 sizeof(arr) 却不像一个指针?
  • 当把数组传递给函数时,为什么在函数内部无法用 sizeof 计算出数组的真实长度?
  • 除了指向普通变量,指针能否指向另一个指针?这种“套娃”式的指针又有什么用?
  • 如果说指针数组是“存放指针的数组”,那它又能为我们解决什么实际问题?

在本篇《深入理解指针(二)》中,我们将探讨这些实用的话题。我们将从数组名的本质出发,揭示其与指针的深厚渊源;学习如何使用指针高效地访问和操作数组;剖析一维数组传参的底层真相;并最终迈向二级指针指针数组的广阔世界,甚至用它们来模拟二维数组。

文章目录

  • 1. 数组名的理解
  • 2. 使用指针访问数组
    • 2.1 指针访问数组的基本方法
    • 2.2 两种方式的对比与选择
  • 3. 一维数组传参的本质
  • 4. 冒泡排序
    • 4.1 概念和思想
    • 4.2 基础实现:双循环结构
    • 4.3 优化冒泡排序
    • 4.4 总结
  • 5. 二级指针
    • 5.1 为什么需要二级指针?
    • 5.2 二级指针的内存模型
    • 5.3 二级指针的运算:解引用的层次
  • 6. 指针数组
  • 7. 指针数组模拟二维数组
  • 总结

1. 数组名的理解

在我们之前的指针学习中,要获取数组第一个元素的地址,我们会这样写:

int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];

但是如果我们直接打印 &arr[0]arr 的值,会发现这样的情况:

#include <stdio.h>
int main() {int arr[10] = {1,2,3,4,5,6,7,8,9,10};printf("&arr[0] = %p\n", &arr[0]);printf("arr     = %p\n", arr);return 0;
}

输出结果:
在这里插入图片描述
&arr[0]arr的值一模一样。这就可以证明我们的第一个核心结论:

数组名在绝大多数情况下,代表的是数组首元素的地址。

既然数组名是地址,那么下面的代码为什么输出结果是 40(假设int为4字节,10个元素总大小为40字节),而不是 48(一个指针变量的大小)呢?

#include <stdio.h>
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };printf("%d\n", sizeof(arr));return 0;
}

输出结果:
在这里插入图片描述
其实数组名就是数组首元素(第⼀个元素)的地址是对的,但是有两个例外

  1. sizeof(数组名)
    • 当数组名单独放在 sizeof 操作符内部时,它代表的不是首元素的地址,而是整个数组
    • 此时,sizeof(arr) 计算的是整个数组所占用的总字节大小
  2. &数组名
    • 当对数组名使用取地址符 & 时,它代表的是整个数组的地址
    • 这里必须区分“首元素的地址”和“数组的地址”。尽管它们的值相同,但含义和操作后的结果截然不同

除此之外,任何地方使用数组名,数组名都表示首元素的地址。

理解这两个例外后,我们可以看下面的代码:

#include <stdio.h>
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };printf("&arr[0] = %p\n", &arr[0]);printf("arr     = %p\n", arr);printf("&arr    = %p\n", &arr);return 0;
}

输出结果:
在这里插入图片描述
三个打印结果⼀模⼀样,这时候就有点疑惑了,那arr和&arr有啥区别?

虽然他们的值相同,但意义不同。让我们通过指针运算来揭示它们的本质区别:

#include <stdio.h>
int main() {int arr[10] = {0};printf("&arr[0]   = %p\n", &arr[0]);printf("&arr[0]+1 = %p\n", &arr[0]+1); printf("arr       = %p\n", arr);printf("arr+1     = %p\n", arr+1);    printf("&arr      = %p\n", &arr);printf("&arr+1    = %p\n", &arr+1);  return 0;
}

输出结果:
在这里插入图片描述

  • &arr[0] + 1arr + 1:它们的操作对象是“一个int元素”,因此 +1 会跳过 一个int的大小(4字节),指向下一个元素。
  • &arr + 1:它的操作对象是“一个拥有10个int的数组”,因此 +1 会跳过 整个数组的大小(40字节),指向下一个逻辑数组的起始位置。

到这里大家应该搞清楚数组名的意义了吧。

数组名是数组首元素的地址,但是有2个例外。

2. 使用指针访问数组

在理解了“数组名即是首元素地址”这一概念后,我们可能会想到:能否可以直接使用指针来操作数组呢?

2.1 指针访问数组的基本方法

既然数组名 arr 是一个地址,我们可以将其赋值给一个指针变量 p,然后通过指针运算来访问数组的每一个元素。

#include <stdio.h>
int main() {int arr[10] = {0};int sz = sizeof(arr) / sizeof(arr[0]); int *p = arr;for (int i = 0; i < sz; i++) {scanf("%d", p + i); // 等价于 &arr[i]}for (int i = 0; i < sz; i++) {printf("%d ", *(p + i)); // 等价于 arr[i]}return 0;
}

在上述代码理解之后,我们再分析一下:数组名arr是数组首元素的地址,可以赋值给指针变量p,其实数组名arrp在这里是等价的。那我们可以使用arr[i]可以访问数组的元素,那p[i]是否也可以直接访问数组呢?

#include <stdio.h>
int main()
{int arr[10] = { 0 };//输⼊int i = 0;int sz = sizeof(arr) / sizeof(arr[0]);//输⼊int* p = arr;for (i = 0; i < sz; i++){scanf("%d", p + i);//scanf("%d", arr+i);//也可以这样写}//输出for (i = 0; i < sz; i++){printf("%d ", p[i]);}return 0;
}

结论:

p[i] 这种写法是完全合法且等价于 *(p + i) 的。

这就意味着,一旦一个指针 p 指向了一个数组的起始位置,你就可以像使用数组名一样使用这个指针!p[i] 会被编译器自动翻译为 *(p + i) 来执行。

现在,我们可以从一个全新的、统一的角度来理解数组访问了:

访问方式编译器实际处理为本质
arr[i]*(arr + i)数组访问是指针运算的语法糖
p[i]*(p + i)指针也可以使用数组语法

这个规则是对称的。无论是数组名还是指针变量,后面跟了下标 [],编译器都会将其转换为 首地址 + 偏移量,再解引用 的形式。

2.2 两种方式的对比与选择

特性指针运算 (*(p+i))下标语法 (p[i])
可读性较低,需要理解指针运算更高,更直观,更像操作数组
灵活性极高,可以进行复杂的指针算术一般,仅限于固定偏移
常用场景底层操作、灵活遍历(如*p++常规的、顺序的数组访问

建议:在大多数需要明确访问数组第 i 个元素的场景下,使用 p[i] 这种形式,因为它意图更清晰,可读性更好。

3. 一维数组传参的本质

在C语言中,我们经常将数组传递给函数进行操作,但其中隐藏着一个至关重要的本质,理解它对于避免常见的编程错误至关重要。这个本质就是:在C语言中,不存在真正的“数组传参”,只有“地址传参”。

我们先看一个典型的问题代码:

#include <stdio.h>void test(int arr[]) {int sz2 = sizeof(arr) / sizeof(arr[0]); printf("sz2 = %d\n", sz2);            
}int main() {int arr[10] = {1,2,3,4,5,6,7,8,9,10};int sz1 = sizeof(arr) / sizeof(arr[0]); printf("sz1 = %d\n", sz1);             test(arr);return 0;
}

输出结果:
在这里插入图片描述

为什么在 main 函数中能正确计算出大小,在 test 函数中就不行?我们先来调试看一下再说结论:
在这里插入图片描述
这就是数组传参的本质了,回顾上一节的结论:数组名是首元素的地址(除了两个例外)。那么,当我们写下 test(arr); 时,实际上发生了什么呢?

真相是

我们所传递的并非整个数组,而仅仅是数组首元素的地址。 这个过程,本质上是一种 “值传递”——只不过这个“值”是一个 地址值

因此,上面的函数调用 test(arr); 实际上完全等价于:

test(&arr[0]); 

所以函数形参的部分理论上应该使用指针变量来接收首元素的地址。那么在函数内部我们写sizeof(arr) 计算的是⼀个地址的大小,而不是数组的大小。正是因为函数的参数部分本质是指针,所以在函数内部是没办法求得数组元素个数的。

void test(int arr[])  // 参数写成数组形式,本质上还是指针
{printf("%d\n", sizeof(arr));
}
void test(int* arr)  // 参数写成指针形式
{printf("%d\n", sizeof(arr));  // 计算⼀个指针变量的⼤⼩
}
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };test(arr);return 0;
}

**总结:**一维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式。

4. 冒泡排序

在理解如何使用指针和数组协同工作后,我们可以浅浅的实践一下,而冒泡排序就是一个完美的案例

4.1 概念和思想

冒泡排序的核心思想非常直观,就像它的名字一样:越小的元素会经由交换慢慢“浮”到数列的顶端(升序排序时)

算法步骤

  1. 比较相邻元素:从数组的第一个元素开始,依次比较相邻的两个元素。
  2. 交换逆序对:如果第一个比第二个大(对于升序排序而言),就交换它们的位置。
  3. 一轮冒泡:对每一对相邻元素重复步骤1和2,从开始第一对到结尾最后一对。一轮结束后,最大的元素会“沉”到数组末尾,就像冒泡一样。
  4. 重复执行:针对所有未排序的元素重复上述步骤,直到整个数组有序。

4.2 基础实现:双循环结构

void bubble_sort(int arr[], int sz) {      // arr就是数组首元素的地址 sz就是数组长度int i = 0;for (i = 0; i < sz - 1; i++) {         // 外层循环:控制排序的轮数int j = 0;for (j = 0; j < sz - i - 1; j++) { // 内层循环:进行一轮中的两两比较if (arr[j] > arr[j + 1]) {     // 比较相邻元素// 交换 arr[j] 和 arr[j+1]int tmp = arr[j];arr[j] = arr[j + 1];arr[j + 1] = tmp;}}}
}

代码分析

  • 外层循环 (i):控制总共需要进行多少轮排序。n 个元素最多需要 n-1 轮。
  • 内层循环 (j):负责在每一轮中进行实际的比较和交换。sz - i - 1 是关键,因为每完成一轮,数组末尾就已经有一个元素就位了,无需再参与比较。
  • if 判断与交换:这是排序逻辑的核心,通过比较和交换来消除“逆序对”。

4.3 优化冒泡排序

基础版本有一个明显的缺点:即使数组已经提前有序,它仍然会机械地完成所有轮次的循环。我们可以通过一个变量 flag 来优化它。

void bubble_sort(int arr[], int sz) {int i = 0;for (i = 0; i < sz - 1; i++) {int flag = 1; // 假设这一趟已经有序int j = 0;for (j = 0; j < sz - i - 1; j++) {if (arr[j] > arr[j + 1]) {flag = 0; // 如果发生了交换,说明数组尚未有序int tmp = arr[j];arr[j] = arr[j + 1];arr[j + 1] = tmp;}}if (flag == 1) { // 如果这一趟没发生任何交换,说明数组已完全有序break;       // 提前终止排序}}
}

优化点分析

  • 我们定义了一个 flag 变量,在每一轮开始前,假设这一轮已经有序 (flag = 1)。
  • 在内层循环中,只要发生了一次交换,就表明此刻数组是无序的,我们就要将 flag 置为0。
  • 在一轮结束后检查 flag的值。如果它仍然是 1,说明这一轮没有进行任何交换,数组已经完全有序,可以立即 break 跳出循环,无需再进行后续无用的比较。

这个优化对于近乎有序的数组来说,性能提升是巨大的

4.4 总结

  1. 算法思想:冒泡排序通过不断比较相邻元素并交换逆序对,使最大(或最小)元素逐渐移动到其正确位置。
  2. 核心实现:双循环结构是骨架,比较和交换是血肉。
  3. 重要优化:使用 flag 标志位实现提前终止,可以显著提升对有序或近乎有序数据的排序效率。这是编写高效算法必须具备的思维。
  4. 与指针/数组的关系
    • 冒泡排序是对一维数组进行原地操作的典型例子,函数内部直接通过指针(数组名)修改了主函数中的数组内容。
    • 它完美诠释了数组传参的本质是传地址,以及如何通过指针/数组语法来操作内存。

虽然冒泡排序的时间复杂度为 O(n²),在实际应用中并不高效,但它作为入门算法,对于理解循环、条件判断、数组操作和基础算法思想有着重要的价值。

5. 二级指针

在掌握了指针与数组的密切关系后,我们开始进行下一步的学习:二级指针
在这里插入图片描述

5.1 为什么需要二级指针?

指针变量也是变量,是变量就有地址,那指针变量的地址存放在哪里?这就是二级指针

  • pa 的类型是 int*,它存储的是 a 的地址,通过 *pa 可以访问或修改 a
  • ppa 的类型是 int**,它存储的是 pa 的地址,通过 *ppa 可以访问或修改 pa,通过 **ppa 可以访问或修改 a

5.2 二级指针的内存模型

理解二级指针最有效的方式是通过内存图(可以参考上述图片)。假设变量 a、指针 pappa 在内存中的地址如下:

变量地址存储的值说明
a0x0012FF5010普通的整型变量
pa0x0012FF480x0012FF50一级指针,值为a的地址
ppa0x0012FF400x0012FF48二级指针,值为pa的地址

这个模型清晰地展示了“指向”的关系:
ppa --> pa --> a

5.3 二级指针的运算:解引用的层次

对二级指针进行解引用操作,需要理解其层次性:

  1. *ppa (第一层解引用)
    • 含义:取出 ppa 中存储的地址(即 pa 的地址 0x0012FF48),然后找到这个地址所对应的内存空间。
    • 结果:找到的就是 pa 这个变量本身。
    • 因此,*ppa 等价于 pa
  2. **ppa (第二层解引用)
    • 含义:先通过 *ppa 找到 pa,再对 pa 进行解引用(即 *(*ppa))。
    • 结果:找到的就是 pa 所指向的变量,也就是 a
    • 因此,**ppa 等价于 *pa,也等价于 a

6. 指针数组

指针数组是指针还是数组?

我们可以通过类比来理解:

  • int arr[5]:这是一个整型数组。它是一个包含5个元素的数组,其中每个元素都是一个 int 类型的整数。
  • char arr[5]:这是一个字符数组。它是一个包含5个元素的数组,其中每个元素都是一个 char 类型的字符。
  • int* arr[5]:这是一个指针数组。它是一个包含5个元素的数组,其中每个元素都是一个 int* 类型的指针(即地址)。
    在这里插入图片描述
    指针数组的每个元素都是用来存放地址(指针)的。

如下图:
在这里插入图片描述

指针数组的每个元素是地址,又可以指向一块区域。

7. 指针数组模拟二维数组

指针数组一个强大而经典的应用是模拟二维数组。这与我们之前提到的“数组名是地址”的概念紧密结合。

#include <stdio.h>
int main() {int arr1[] = {1, 2, 3, 4, 5};int arr2[] = {2, 3, 4, 5, 6};int arr3[] = {3, 4, 5, 6, 7};// 定义一个指针数组,其元素分别指向这三个一维数组的首元素int* parr[3] = {arr1, arr2, arr3}; // 数组名就是首元素地址for (int i = 0; i < 3; i++) {     for (int j = 0; j < 5; j++) {  printf("%d ", parr[i][j]);}printf("\n");}return 0;
}

输出结果:
在这里插入图片描述
在这里插入图片描述
为什么 parr[i][j] 能 work?
这需要运用我们之前学到的所有知识:

  1. parr[i]:这是访问指针数组的第 i 个元素。这个元素是一个指针,它指向第 i 行一维数组的首元素(例如 arr1 的首地址)。
  2. parr[i][j]:根据“数组访问是指针运算的语法糖”规则,它被编译器转换为 *(parr[i] + j)
    • parr[i] + j:计算第 i 行一维数组中第 j 个元素的地址。
    • *(parr[i] + j):对该地址解引用,获取到元素的值。

注意:与真二维数组的区别
虽然语法相似,但这种模拟的二维数组与真正的 int arr[3][5] 有本质区别:

  • 真正二维数组:内存是连续分配的的一大块区域。
  • 模拟二维数组:由指针数组和多个一维数组组成。每一行(即每个一维数组)在内存中是连续的,但行与行之间不保证连续parr 数组本身是连续的,但它里面存放的各个地址是分散的。

总结

至此,我们完成了对C指针第二阶段的深入探索。从数组名的本质出发,我们揭开了其“地址”的真实身份与两个关键例外;通过使用指针访问数组,我们掌握了高效遍历内存的两种等价语法;在剖析一维数组传参的本质时,我们看透了“地址传递”的真相,理解了函数内外sizeof结果差异的根源。

我们不仅用冒泡排序演练了指针与数组的经典协作,还引入了“提前终止”的优化思想,展现了编写高效算法的重要性。进而,我们的视野从管理数据的指针,提升到了管理指针的二级指针,理解了其在函数内修改外部指针的关键作用。最后,我们探索了指针数组这一强大工具,学会了如何用它来模拟二维数组,构建灵活的非连续数据结构。

回顾本系列,指针不再是那个令人畏惧的抽象概念,而是你手中一把精准控制内存的利器。你已经看到了它如何与数组紧密结合,从单一变量操作上升到对复杂数据集合的管理。

但这远非终点,而是一个新的起点。指针的旅程仍在继续:如何利用指针进行动态内存管理(malloc、free)、如何构建链表、树等更复杂的动态数据结构、如何理解函数指针等更高级的主题,都将是我们未来探索的方向。

希望本篇内容能帮助你彻底理解指针与数组的共生关系,打破对它们的恐惧。实践是掌握指针的唯一途径,大胆地去编码,去调试,去感受直接操作内存带来的力量与控制感。

sizeof结果差异的根源。

我们不仅用冒泡排序演练了指针与数组的经典协作,还引入了“提前终止”的优化思想,展现了编写高效算法的重要性。进而,我们的视野从管理数据的指针,提升到了管理指针的二级指针,理解了其在函数内修改外部指针的关键作用。最后,我们探索了指针数组这一强大工具,学会了如何用它来模拟二维数组,构建灵活的非连续数据结构。

回顾本系列,指针不再是那个令人畏惧的抽象概念,而是你手中一把精准控制内存的利器。你已经看到了它如何与数组紧密结合,从单一变量操作上升到对复杂数据集合的管理。

但这远非终点,而是一个新的起点。指针的旅程仍在继续:如何利用指针进行动态内存管理(malloc、free)、如何构建链表、树等更复杂的动态数据结构、如何理解函数指针等更高级的主题,都将是我们未来探索的方向。

希望本篇内容能帮助你彻底理解指针与数组的共生关系,打破对它们的恐惧。实践是掌握指针的唯一途径,大胆地去编码,去调试,去感受直接操作内存带来的力量与控制感。

欢迎在评论区留下你的思考、疑问或实践心得。你对指针还有哪些困惑?又希望接下来深入探讨哪个主题?让我们共同学习,继续在C语言的深邃世界中探索前行。
往期回顾:
深入理解C语言指针(一)| 从内存到传址调用,掌握指针的核心本质


文章转载自:

http://r7GHhIQr.sypby.cn
http://ubQWoY0Q.sypby.cn
http://pP1smA2Z.sypby.cn
http://OMHhSwqT.sypby.cn
http://sFjVKqmt.sypby.cn
http://CEKqPdu3.sypby.cn
http://5clMlKKi.sypby.cn
http://0Q7orJFO.sypby.cn
http://KU7I6xO1.sypby.cn
http://yD7o5C2w.sypby.cn
http://jRQhzlmC.sypby.cn
http://n9yB2KvI.sypby.cn
http://HWTNCd6M.sypby.cn
http://n0SYsd1Y.sypby.cn
http://mY8q4g8K.sypby.cn
http://bl6FAFno.sypby.cn
http://y2N3xDPH.sypby.cn
http://SbNOHAth.sypby.cn
http://POJpt61Y.sypby.cn
http://FGm6DHPx.sypby.cn
http://Vh1nvJ0V.sypby.cn
http://il3sb0tH.sypby.cn
http://hagMoIy6.sypby.cn
http://323QWjVf.sypby.cn
http://SxuVUkka.sypby.cn
http://UNJ5fQt8.sypby.cn
http://sM6bMo8T.sypby.cn
http://1mEZnlpC.sypby.cn
http://o9cRhayp.sypby.cn
http://8DC0T3yG.sypby.cn
http://www.dtcms.com/a/388075.html

相关文章:

  • 算法能力提升之树形结构-(线段树)
  • 小白实测:异地访问NAS所用的虚拟局域网使用感受及部署难度?!
  • js校验车架号VIN算法
  • MongoDB 8.0全面解析:性能提升、备份恢复与迁移指南
  • vue3如何配置不同的地址访问不同的项目
  • 苹果软件代码混淆,iOS混淆、iOS加固、ipa安全与合规取证注意事项(实战指南)
  • SQL-约束
  • [torch] 非线性拟合问题的训练
  • ubuntu设置ip流程
  • 【论文阅读】谷歌:生成式数据优化,只需请求更好的数据
  • 【深度学习】什么是过拟合,什么是欠拟合?遇到的时候该如何解决该问题?
  • CSA AICM 国际标准:安全、负责任地开发、部署、管理和使用AI技术
  • AI 赋能教育:个性化学习路径设计、教师角色转型与教育公平新机遇
  • 科技为老,服务至心——七彩喜智慧养老的温情答卷
  • ​​[硬件电路-237]:电阻、电容、电感虽均能阻碍电流流动,但它们在阻碍机制、能量转换、相位特性及频率响应方面存在显著差异
  • 内网Windows系统离线安装Git详细步骤
  • @Component 与 @Bean 核心区别
  • Rsync 详解:从入门到实战,掌握 Linux 数据同步与备份的核心工具
  • ffmpeg解复用aac
  • 数据结构--3:LinkedList与链表
  • linx 系统 ffmpeg 推流 rtsp
  • 防水淹厂房监测报警系统的设计原则及主要构成
  • RFID技术赋能工业教学设备教学应用经典!
  • Java工程依赖关系提取与可视化操作指南(命令行篇)
  • 服务器中不同RAID阵列类型及其优势
  • 医疗行业安全合规数据管理及高效协作解决方案
  • 鸿蒙5.0应用开发——V2装饰器@Event的使用
  • logstash同步mysql流水表到es
  • Ground Control-卫星通信 (SATCOM) 和基于蜂窝的无人机和机器人物联网解决方案
  • 计算机视觉技术深度解析:从图像处理到深度学习的完整实战指南