☰
C语言数组核心:内存布局、排序查找与二维数组传参
2026/10/13 23:23:37 网站建设 项目流程

1. 为什么要单独把数组拎出来讲:一个成绩统计程序逼出来的题目

网上讲C语言数组的文章很多,但大多数要么只讲语法、要么直接甩代码,读完之后一动手还是会踩坑。前一阵有位同学拿一个成绩统计的小程序来问我,需求很简单:录入全班50个人的C语言成绩,从高到低排序,再做一个查找功能,查某个分数能排到什么名次。

他一开始的做法让我挺哭笑不得——定义了50个int变量,score1、score2、score3一路编到score50,录入时准备用if-else判断该往哪个变量里塞,排序更不知道怎么写。我跟他讲:你这不是程序能力不足,是缺少数组思维。后来他补完数组基础,又被两个问题卡住:函数里为什么算不出数组长度?二维数组传参为什么老是报错?老实说,这些问题几乎每个C语言初学者都会撞上,包括当年的我自己。

所以这篇我把“从一维到多维、从排序到查找”这条线一次讲透,顺带把数组和指针、数组和函数的关系也理清楚。看完你至少能完成三件事:第一,用一维数组存一批数据,写出可用的排序和查找代码;第二,真正理解数组在内存里怎么存放,懂得为什么函数形参里sizeof拿到的是指针大小而不是数组大小;第三,二维数组、字符数组不再靠猜,遇到int (*p)[4]和int *p[4]这类写法能分得清。

这篇不需要任何前沿知识,只要你会写for循环就能读。但有一个认知我希望你从一开始就刻进脑子里:**数组不是一个简单的“带名字的数字集合”,它本质是一段连续内存,下标访问的本质是地址偏移计算。**这句话是整篇文章的地基,后面所有内容都是围绕它展开的。

2. 一维数组的内存布局与声明初始化细节

2.1 三种初始化写法必须分清

先看声明。int scores[50];这行代码干的事情是:在栈上划出50×4=200字节的连续空间。注意,这块空间里的值是随机残留的,如果你不初始化就直接读,读出来的可能是上一个函数遗留下来的垃圾数据。

初始化有几种常见写法,区别很重要:

int a[5] = {0}; // 第一个元素显式置0,其余4个自动补0 int b[] = {1, 2, 3}; // 省略长度,编译器数元素个数,结果是 int[3] int c[5] = {1, 2}; // c[0]=1, c[1]=2, 后面三个补0 int d[5] = {1,2,3,4,5,6}; // 编译报错:初始化值过多

我看到很多初学者喜欢写int a[5] = {0};,这确实是把所有元素清零的最简洁写法。但要清楚:这只保证显式列出的第一个元素是0,其余元素靠“部分初始化自动补0”的规则补成0。如果你想要前三个是1、2、3,后面自动补0,那就得写成{1,2,3},而不能只写{0}。

还有两个细节容易忽略。第一,全局数组和局部数组的默认值不同:全局或静态数组默认全0,局部数组不初始化就是随机值。第二,C99标准引入了变长数组,比如int n; scanf("%d", &n); int a[n];这种写法合法,但变长数组不能初始化,而且有些老旧编译器支持不完整。初学阶段我不建议依赖变长数组,固定长度最稳。

// 正确的输入方式 int n; scanf("%d", &n); int scores[100]; // 固定最大容量 for (int i = 0; i < n && i < 100; i++) { scanf("%d", &scores[i]); }

这个“固定容量+实际数量n”的组合,是入门阶段最稳妥的方案,后面排序和查找都只需要关心前n个元素。

2.2 sizeof、数组名和&a三兄弟的纠葛

这一小节是“数组名是不是指针”这个千古疑难的核心现场。定义一个数组:

int arr[5] = {10, 20, 30, 40, 50};
  • arr:在大多数表达式中会退化为指向首元素的指针,类型是int*,值等于首元素地址。
  • &arr:取整个数组的地址,类型是int(*)[5],数值上恰好等于首元素地址,但含义完全不同。
  • sizeof(arr):得到整个数组占用的字节数,这里就是5×4=20。
表达式类型含义加1跳过的字节数
arrint*首元素的地址4
&arrint(*)[5]整个数组的地址20
arr[0]int第一个元素的值不可加1

想亲自验证的话,跑下面这段代码:

printf("arr = %p\n", (void*)arr); printf("arr+1 = %p\n", (void*)(arr+1)); printf("&arr = %p\n", (void*)&arr); printf("&arr+1= %p\n", (void*)(&arr+1));

arr和arr+1差4个字节,&arr和&arr+1差20个字节。这就是“指针的类型决定步长”最直观的演示。不理解这一点,后面二维数组传参、int(*p)[4]这种写法会一直绕不出来。

顺带说一个高频考点:sizeof(arr) / sizeof(arr[0])是求数组长度的经典技巧,但它只能在数组定义所在的作用域用;一旦数组作为函数参数传进去,arr已经退化成指针,这个公式算出来的就是指针大小除以元素大小,完全不对。

2.3 越界不报错:为什么C语言不去拦你

很多从Java、Python转过来的同学特别不适应:arr[10]明明越界了,程序居然能跑,甚至有时候输出看起来还挺正常。这是因为C语言压根不检查下标,设计哲学是“信任程序员”,不为运行时检查付出性能代价。arr[i]在机器层面就是*(arr + i),编译器只负责算地址,不负责确认地址是否有效。

越界的危险在于“写坏别人家的内存”。你越界写一个值,可能恰好写进了旁边某个变量的空间,程序当场不崩,但数据已经被污染;等用到那个变量时才出现莫名其妙的错误,此时距离灾难现场已经很远了。这就像你在楼道里乱涂乱画,当时没人抓你,但邻居家电线被漆糊住导致短路,排查起来极其费劲。

安全习惯必须养成:循环条件写“小于长度”而不是“小于等于长度”;数组容量和实际使用长度分开管理;调试时用编译器的地址消毒器,例如GCC的-fsanitize=address,它能在越界发生的第一时间报告位置,我强烈建议所有练习项目都开着这个选项跑一遍。

3. 排序实战:冒泡、选择与快速排序的选型

为什么要专门讲排序?因为接下来要讲的二分查找有个硬前提——数组必须有序。另外成绩排名这件事本身就天然需要排序。排序算法很多,对一个入门者来说,冒泡、选择、快速排序这三个掌握好就够用了。

3.1 冒泡排序:最容易上手的交换式排序

冒泡的思路很直白:反复扫描数组,相邻两个元素如果顺序不对就交换,每一轮会把当前范围内最大的元素“冒泡”到末尾。

void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } if (!swapped) { break; // 本轮没有发生交换,说明已经有序 } } }

注意两个地方。第一,内层循环上界是n - 1 - i,因为每一轮结束后,末尾那一个元素已经就位,下一轮完全不用再碰它。第二,用swapped标志做提前退出,如果某一轮从头到尾没发生交换,说明数组已经有序,直接结束。加上这个标志后,最好情况下冒泡排序的时间复杂度可以降到O(n),这对一个接近有序的数组来说收益明显。

冒泡的缺点是元素交换次数多,平均和最坏复杂度都是O(n²)。但它逻辑最简单,是理解排序思想的最佳起点。

3.2 选择排序:每一轮锁定最小值的坐标

选择排序的思路比冒泡更“省动作”:每一轮在剩余区间里找出最小元素的下标,然后把当前轮起点和这个最小元素交换。它每一轮只做一次交换,但比较次数和冒泡一样多。

void selection_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[min_idx]) { min_idx = j; } } if (min_idx != i) { int tmp = a[i]; a[i] = a[min_idx]; a[min_idx] = tmp; } } }

这里有个细节值得说:我记录的是最小元素的下标,而不是最小值本身。因为交换时需要知道位置,而不是数值。很多初学者在循环里直接交换,导致一趟下来可能交换很多次;正确做法是等整轮扫描完成、确定真正的min_idx之后再做一次交换。

选择排序不稳定。举个例子:数组[5a, 3, 5b],第一轮会把最小的3和第一个5a交换,结果变成[3, 5b, 5a],两个相等的5的相对顺序变了。如果后续还要按学号等信息做二次排序,这种不稳定有时会带来麻烦。

3.3 快速排序:分治思想在数组上的落地

快排是竞赛和实际工程里最常用的排序之一,思路是分治:选一个基准值,把数组分成“左边都≤基准,右边都>基准”,然后递归处理左右两半。

void quick_sort(int a[], int left, int right) { if (left >= right) { return; } int pivot = a[left + (right - left) / 2]; int i = left, j = right; while (i <= j) { while (a[i] < pivot) i++; while (a[j] > pivot) j--; if (i <= j) { int tmp = a[i]; a[i] = a[j]; a[j] = tmp; i++; j--; } } if (left < j) quick_sort(a, left, j); if (i < right) quick_sort(a, i, right); }

这个实现是典型的双指针分区(Hoare风格)。要注意为什么基准取中间值:如果基准总是取第一个元素,而数组本身已经是升序或降序,分区会严重失衡,快排会退化到O(n²)。取中点能在很大程度上规避这种“几乎有序”输入导致的最坏情况,工程上还会进一步用三数取中策略。

while (i <= j)这个条件决定了循环结束后j和i的位置关系:左半区是[left, j],右半区是[i, right]。递归时千万不要写错边界,否则要么漏元素,要么无限递归。我见过不下十个初学者把这里写成quick_sort(a, left, i-1)和quick_sort(a, j+1, right),结果越界或死循环。

3.4 三种排序的选择建议

排序算法平均时间复杂度稳定性适合场景
冒泡排序O(n²)稳定入门练习、小规模数据
选择排序O(n²)不稳定交换次数受限的场景
快速排序O(n log n)不稳定大规模数据、通用排序

回到成绩统计的场景:50个学生,任何排序都秒出结果,用哪个都行。但如果数据量上万,冒泡和选择就会明显慢下来,快排的优势才能真正体现。另外,如果两个学生分数相同、需要保持输入时的先后顺序,那就要选稳定排序,或者额外用学号作为第二关键字。

排序代码里最常出现的坑,我列一下:循环边界写错导致漏排或越界;交换时写出a=b; b=c; c=a;这种三连错;把数组长度当成sizeof结果传入;快排递归边界条件写反。每一条我自己都犯过,排查时最有效的手段是小数据量手跑一遍,在纸上画出每一轮数组状态,很快就能定位问题。

4. 查找实战:从线性扫描到二分定位

4.1 线性查找:无序时最可靠的笨办法

线性查找不需要任何前提条件,从头扫到尾,找到就返回下标,找不到就返回-1:

int linear_search(int a[], int n, int target) { for (int i = 0; i < n; i++) { if (a[i] == target) { return i; } } return -1; }

数据量小的时候,线性查找完全够用,甚至比二分更快——因为二分查找有排序的预处理成本。但数据量大、查找频繁时,O(n)的扫描就不可接受了。

有一个细节:线性查找找到的是第一个匹配元素还是最后一个?上面这个实现返回的是第一个。如果数组里有重复值,而你想找最后一个,把循环改成从末尾往前扫即可。这个细节在成绩统计里很实用,比如查找“最后一个分数为90的学生”。

4.2 二分查找:每次砍掉一半的区间

二分查找的前提是数组已经有序。它的核心不是“中间值等于目标就返回”,而是维护一个闭区间[left, right],每一步都确定下一次搜索落在哪一半。

int binary_search(int a[], int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) { return mid; } else if (a[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }

为什么用left + (right - left) / 2而不是(left + right) / 2?因为当left和right都很大时,后者的加法可能溢出。在竞赛和真实项目中,这是一个必须习惯的写法。

为什么是left <= right?因为闭区间里left == right时还剩下一个元素没有检查,如果条件写成left < right,会漏掉这个元素。为什么更新是mid + 1和mid - 1而不是mid?因为a[mid]已经比较过了,直接排除它,否则可能出现left和right永远不会逼近的死循环。

4.3 查找失败时,返回的其实是“插入位置”

二分查找还有一个被很多人忽略的性质:当查找失败时,跳出循环后left恰好是第一个大于等于target的下标。这个值有两个用途:

  • 升序数组中,left就是target应该插入的位置,可以保持数组仍然有序。
  • 如果想知道“有多少个人分数大于等于90”,可以先找到第一个大于等于90的位置,再用n - left得出数量。

利用这个性质,可以写出“查找分数段人数”的代码:

// 返回第一个大于等于 target 的下标 int lower_bound(int a[], int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return left; }

这段代码和标准二分只差一个判断符号,但语义完全不同。它求的是“边界”,不是“是否存在”。如果排序用的是升序,成绩排名就是:升序数组中,分数target的名次等于n - lower_bound(scores, n, target)。排序和查找在这里就真正串起来了。

5. 二维数组:坐标思维与内存现实之间的冲突

5.1 二维数组的真相:数组的数组

很多教材喜欢把二维数组画成“表格”,这没错,但它容易让人误以为内存里也是行列分块的两层结构。真相是:int matrix[3][4]在内存里仍是连续的12个int,按行优先存储,即先存第0行的4个元素,再存第1行的4个元素。

int m[2][3] = {{1,2,3},{4,5,6}}; printf("m = %p\n", (void*)m); printf("&m[0][0] = %p\n", (void*)&m[0][0]); printf("&m[1][0] = %p\n", (void*)&m[1][0]);

&m[1][0]和&m[0][0]之间恰好差12个字节(3个int)。这个“一行接一行”的存储方式和屏幕像素在显存里的排列方式是一样的,很多图像处理程序就是把整幅图当二维数组处理。

理解下标访问的本质也很重要:m[i][j]在编译时被翻译成*(*(m + i) + j)。m作为数组名,在表达式中退化为指向首行的指针int(*)[3];m + i跳到第i行;*(m + i)拿到第i行这个一维数组,再退化成指向该行首元素的int*;再加j,解引用,才是第i行第j列的元素。

5.2 数组指针、指针数组与二级指针别混了

这里是最容易让人崩溃的地方,我当年也栽过。三个写法长得像,含义完全不同:

int (*p)[4]; // 数组指针:p是指针,指向一个含4个int的数组 int *p[4]; // 指针数组:p是数组,数组里每个元素是 int* int **p; // 二级指针:p指向一个 int*
写法是什么典型配合
int (*p)[4]指向数组的指针二维数组传参
int *p[4]存放指针的数组字符串数组
int **p指针的指针动态分配的二维结构

最常见的错误是试图把二维数组int matrix[3][4]赋给int **p。编译器的报错信息会明确指出类型不匹配,因为matrix的类型是int (*)[4],和int**之间没有隐式转换。如果非要动态处理行数和列数都未知的矩阵,正确做法是每一行单独分配内存:

int **create2d(int rows, int cols) { int **p = malloc(rows * sizeof(int*)); for (int i = 0; i < rows; i++) { p[i] = malloc(cols * sizeof(int)); } return p; } void free2d(int **p, int rows) { for (int i = 0; i < rows; i++) { free(p[i]); } free(p); }

这时p[i][j]的访问看起来和二维数组一样,但内存布局不再是连续的,每行可能在内存的不同位置。这个区别会影响缓存命中率和某些需要连续内存的算法。

5.3 二维数组传参的正确姿势

二维数组传参,必须明确“列数”,因为访问a[i][j]时编译器需要知道每行几个元素才能计算偏移量。列数未知,偏移公式就无法落地。三种写法:

void print1(int a[][4], int rows); // 列数写死 void print2(int (*a)[4], int rows); // 数组指针,和上面等价 void print3(int a[3][4], int rows); // 行数写不写都一样,编译器忽略

第一种和第二种写法完全等价,int a[][4]里的第一维度只是形式,编译器把它看作指针int(*)[4]。第三种的行数是形式参数,编译器直接忽略,写不写没区别。

如果列数是运行时才知道的,那就不能用固定列数的写法,得回到动态分配方案,用int**并配合两次下标访问。经验法则是:固定列数用数组指针,动态列数用二级指针配合逐行分配。

6. 数组与函数的纠缠:退化的代价和绕过办法

6.1 为什么函数内部算不出数组长度

声明下面这个函数时,数组形参的真实类型是int *a:

void print_array(int a[]) { // 这里的 sizeof(a) 是指针大小,不是数组大小 int len = sizeof(a) / sizeof(a[0]); // 错误用法 }

编译器对所有数组形参一视同仁:void f(int a[])、void f(int a[10])、void f(int *a)三个签名完全等价。原因很简单:C语言传参是值传递,数组无法整体拷贝,所以传递的是首元素地址。这就像你只告诉朋友“我家在7栋302”,他只能知道门牌地址,不可能知道你家里有几个房间。

新手最常见的翻车现场就是这个:

void show(int a[]) { for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++) { printf("%d ", a[i]); // 只打印了极少的元素 } }

正确做法是显式传长度参数。这也是一种工程规范:所有接收数组的函数,如果函数内部需要知道数组大小,必须由调用方额外传入。

void print_array(int a[], int n) { for (int i = 0; i < n; i++) { printf("%d ", a[i]); } printf("\n"); }

如果你想追求更安全的封装,可以把数组放进结构体,结构体作为函数参数传递时是整体拷贝,不会退化。这个技巧在小规模数据时很好用,但大数组的拷贝成本不能忽略。

6.2 函数“返回数组”的三条出路

C语言不允许函数直接返回数组。函数内部定义的局部数组在函数结束时生命周期就结束了,返回地址只会得到悬空指针。常见的替代方案有三种:

方案一:调用方准备数组,函数往里填数据,返回实际填充个数。这是最推荐的做法,内存所有权非常清晰:

int fill_scores(int out[], int capacity) { int count = 0; for (int i = 0; i < capacity && count < capacity; i++) { if (i % 2 == 0) { out[count++] = i; } } return count; }

方案二:用malloc动态分配内存,函数返回指针,调用方负责free。这个方案灵活,但必须约定谁分配谁释放,内存泄漏的坑极易踩。

方案三:返回包装结构体。因为结构体可以整体返回,数组藏在结构体里就能绕开限制。适合固定大小的小型数据。

我个人的实践习惯是优先选方案一。原因很现实:调用方出内存、函数返回个数,既避免了指针悬挂,也明确了谁负责哪块内存,排错成本低得多。

7. 字符数组:字符串藏在C语言里的真身

7.1 字符串本质是结尾带隐含字符的数组

C语言没有独立的字符串类型,字符串的载体就是字符数组。字面量"hello"的类型是char[6],因为编译器会自动在末尾补一个'\0'。字符数组和字符串字面量的区别非常重要:

char s1[] = "hello"; // 可修改的字符数组,长度6(含'\0') const char *s2 = "hello"; // 指针指向只读区,不能通过s2修改内容

很多初学者写出这样的代码然后崩溃:

char *s = "hello"; s[0] = 'H'; // 运行时报错或程序崩溃

原因是字符串字面量被编译器放在只读数据区,通过指针修改只读区的行为是未定义行为。想修改字符串内容,就必须用字符数组s1。

7.2 字符数组和字符指针的sizeof差异

同样是“hello”,sizeof(s1)得6,sizeof(s2)得指针大小8或4。但一旦作为参数传入函数,s1和s2都会退化成char*,sizeof结果一样,都变成指针大小。这是前一章“数组退化”规则在字符数组上的又一次印证。

处理字符串时,最常见的安全漏洞来自strcpy和strcat不检查目标缓冲区大小。比如:

char name[8]; strcpy(name, "a very long name"); // 缓冲区溢出

正确做法是使用带长度限制的函数,或者自己控制写入边界:

char name[32]; snprintf(name, sizeof(name), "%s", "a very long name");

snprintf自动在末尾补'\0'并限制最多写入sizeof(name)-1个字符,这是处理字符串写入最稳妥的库函数之一。配合scanf时同理,用scanf("%31s", name)限制最大读取宽度,避免用户输入超长内容直接写穿缓冲区。

7.3 字符串比较别用双等号

两个字符数组内容相同,直接用==比较永远得到“不相等”,因为==比较的是两个表达式的值;当数组名退化成指针时,比较的是两个首元素的地址,地址不同结果自然是假。

if (strcmp(s1, s2) == 0) { printf("内容相同\n"); }

strcmp返回0表示相等,这个反直觉的返回值也是初学者最容易搞反的地方。如果还要在成绩统计程序里存学生姓名,字符数组就是绕不开的主题:

typedef struct { int id; char name[32]; int score; } Student;

注意,结构体里的name是数组,不能直接整体赋值给另一个结构体的name,正确写法是strcpy(student2.name, student1.name)。数组不可整体赋值这个限制,在结构体内部依然生效。

我在实践里总结了一个习惯:每次写完涉及数组的代码,先自问三个问题——这个数组的容量是多少?实际有效长度是多少?越界访问时程序会不会立刻暴露问题?这三个问题能回答清楚,数组相关的大部分坑都已经提前绕开了。文章篇幅不小,但核心其实就是一条主线:理解数组是一段连续内存、理解下标本质是地址偏移、理解传参时数组会退化,剩下的都是在这条主线上长出来的分支。如果读的过程中有哪个点卡住了,建议回到第2章把sizeof、数组名和&的区别再跑一遍代码,后面的内容都会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询