1. 数组为什么是C语言的"半壁江山"
聊数组之前,我先说个真实感受:很多初学者学C语言时,指针劝退一批,结构体劝退一批,但数组这个看起来"人畜无害"的东西,反而是后续所有坑的起点。数组没整明白,后面学指针、字符串、排序、查找,全都得返工。
为什么这么说?因为数组在C语言里承担了三个最基础也最核心的职责:批量存储、连续内存布局、下标随机访问。这三个特性看起来简单,但组合在一起,就决定了你在C语言里处理数据的基本姿势。
举个例子。你想存一个班50个人的成绩,没有数组就得定义50个变量:score1、score2、score3……且不说命名累死个人,就算你能忍,后面想求平均分、找最高分,代码量直接爆炸。有了数组,一个int scores[50]就搞定,配合循环遍历,几行代码就把数据全部处理完。
数组的另一个身份是"其他复杂数据结构的基石"。字符串本质是字符数组,栈和队列可以用数组实现,哈希表里的桶也常常是数组。所以你可以把数组理解成C语言世界里的"乐高基础块"——后面搭什么高级玩意儿,都离不开它。
这篇文章我想从最基础的定义和初始化讲起,然后深入到内存细节、指针关系,再到排序查找这些高频算法,最后把实操中容易踩的坑一次性说完。无论你是刚学C语言的新手,还是写了一段时间想查漏补缺的老手,应该都能找到有用的东西。
2. 数组的定义、初始化与内存布局
2.1 定义数组的基本语法和各部分含义
数组定义的标准写法是:
类型说明符 数组名[常量表达式];比如:
int a[10]; char name[20]; double scores[50];这里面有四个要素要讲清楚:
类型说明符决定了数组中每个元素的类型。可以是int、char、double等基本类型,也可以是结构体、指针等复合类型。这意味着数组里存的"东西"可以是简单数字,也可以是复杂对象。
数组名是数组的标识符,同时它还代表数组首元素的地址——一个指向数组第一个元素的指针常量。这一点很多初学者会忽略,但它正是数组和指针纠缠不清的根源。
方括号里的常量表达式表示元素个数。注意两个关键点:
- 必须是常量,不能是变量。C89标准要求数组大小在编译期就必须确定,虽然C99引入了变长数组(VLA)允许运行时指定大小,但很多编译器支持不完整,而且VLA不能用于全局数组。所以老老实实写常量最稳妥。
- 下标从0开始。
int a[10]有效下标是0到9,没有a[10]这个元素。越界访问不报编译错误,但运行时会出各种诡异问题。
我给新手一个建议:定义数组时,如果拿不准需要多大,宁可稍微大一点也不要正好卡边界。虽然这有点浪费内存,但能避免很多"莫名其妙"的崩溃。
2.2 初始化方式对比与缺省规则
数组初始化有几种常见写法,各有各的坑:
完全初始化:给所有元素明确赋值
int a[5] = {1, 2, 3, 4, 5};部分初始化:只赋值前几个元素,其余自动补0
int a[5] = {1, 2, 3}; // a[3] = 0, a[4] = 0这个规则很实用。比如你想初始化一个全0数组,直接写int a[100] = {0};就够了,不用写100个0。但要特别注意,这个补0规则只对"显式初始化"生效。如果你只写了int a[100];没给初值,那么里面是垃圾值,不是0。这个区别我见过无数人搞混。
不指定长度:由编译器根据初始化列表自动推断
int a[] = {1, 2, 3, 4, 5}; // 等价于 int a[5]这种方式方便,但有个隐患:你不太容易一眼看出数组到底多大。在代码审查时,很多人更偏好显式写出长度。
指定长度但列表更长:编译错误
int a[3] = {1, 2, 3, 4}; // 错误:初始值太多二维数组的初始化:
int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} };也可以写成一行int matrix[2][3] = {1, 2, 3, 4, 5, 6};,编译器会自动按行填充。我建议还是用嵌套花括号,可读性好得多,也方便检查每行元素的个数。
二维数组初始化还有个一行搞定全0的妙用:int matrix[2][3] = {0};,全部元素都是0,非常干净。
提示:局部数组如果定义了但没初始化,直接拿去用,读到的值是"不确定的垃圾值"。这在调试时非常折磨人,因为每次运行结果可能还不一样。所以我的习惯是:只要定义数组,顺手就初始化,哪怕先全部置0,后面再逐个覆盖。
2.3 数组在内存中的排列方式
数组在内存中是一段连续的空间。这句话的重要性怎么强调都不为过。
以一维数组int a[5]为例,如果第一个元素a[0]的地址是0x1000,那么a[1]在0x1004,a[2]在0x1008,依次类推。每个元素之间间隔4个字节(int在多数平台占4字节)。也就是说,知道数组首地址,就能算出任意元素的地址:
元素 a[i] 的地址 = 数组首地址 + i * sizeof(元素类型)这段连续空间还分两种存放位置:
- 全局数组/静态数组:存放在静态存储区(数据段),程序启动时分配,生命周期是整个程序运行期,未显式初始化时自动清零。
- 局部数组:存放在栈上,函数调用时分配,函数返回时自动释放,未初始化时是垃圾值。
这个区别直接决定你能定义多大的数组。全局数组可以很大(取决于系统限制),但局部数组如果定义得太大,可能直接栈溢出(stack overflow)。我在实际开发中见过有人直接在函数里定义int a[1000000],程序一运行就崩,因为栈空间通常只有几MB,一个一百万元素的int数组就占4MB。
二维数组在内存里是按行优先连续存放的。比如int matrix[2][3],存完第一行的3个元素,紧接着存第二行的3个元素。这个特性在做图像处理、矩阵运算时至关重要,因为连续内存访问的缓存命中率高,性能比跳跃式访问好很多。
2.4 sizeof与数组长度的推算
求数组长度是C语言里最高频的操作之一。标准做法是用sizeof:
int a[10]; int len = sizeof(a) / sizeof(a[0]);原理很简单:sizeof(a)返回整个数组占用的总字节数,sizeof(a[0])返回单个元素占用的字节数,两者相除就是元素个数。
这里有个非常重要的使用场景区分:在数组定义的同一个作用域内,这个公式有效;一旦数组作为参数传给函数,它就失效了。原因后面指针部分细讲。
我还想提醒一个常见的误解:sizeof(a)返回的是整个数组占用的空间,而不是数组名"指针"的大小。有些人以为数组名就是指针,sizeof(a)应该等于指针大小(8字节,64位平台),这是错的。在同一作用域内,数组名的sizeof确实返回整个数组的大小。
3. 下标访问背后的指针原理
3.1 数组名与指针的"似是而非"
数组名是不是指针?这个问题几乎每个学C语言的人都会纠结。准确地说:数组名是常量,不是变量,它在表达式中会"退化"为指向首元素的指针,但它和真正的指针变量有本质区别。
看这段代码:
int a[5] = {1, 2, 3, 4, 5}; int *p = a; // 合法:a退化为指向a[0]的指针 int *p2 = &a[0]; // 等价写法 p++; // 合法:p是变量 // a++; // 错误:a是常量,不能修改数组名a和指针p的主要区别在于:
- a的类型是"int[5]"(5个int的数组),p的类型是"int*"(指向int的指针)。
- a不能被赋值,p可以被重新指向其他位置。
sizeof(a)是数组大小,sizeof(p)是指针大小。
但在作为函数参数传递时,数组名会完全退化为指针。这也就解释了为什么函数内部无法通过sizeof求出数组长度——因为函数接收到的确实只是一个指针,数组的长度信息在传参时丢失了。
3.2 下标运算的本质:*(a + i)
C语言里a[i]这个写法,编译器会把它翻译成*(a + i)。也就是:先从首地址偏移i个元素的位置,然后解引用取出那个地址上的值。
理解了这一层,很多"奇技淫巧"就说得通了。比如:
a[i] 等价于 *(a + i) 等价于 *(i + a) 等价于 i[a]你没看错,i[a]在C语言里是合法的。因为[]运算符是可交换的(从运算的数学定义角度看),a[i]和i[a]都最终变成*(i + a)。我见过一些老代码里确实有2[a]这种写法,虽然可读性极差,但它能编译通过。这种野路子我建议看看就好,别真这么写,代码是要给人读的。
指针的算术运算也要特别注意:p + 1不是把地址值加1,而是加上"一个元素"的大小。对int*来说,p + 1 地址值加4;对char*来说,p + 1 地址值加1。所以指针算术天然适配数组元素的步长,这就是为什么遍历数组用指针移动比手动算地址方便得多。
3.3 用指针遍历数组的典型写法
指针遍历数组有几种常用写法,我强烈建议新手至少掌握两种:
下标法:
for (int i = 0; i < n; i++) { printf("%d ", a[i]); }指针递增法:
int *p = a; for (int i = 0; i < n; i++) { printf("%d ", *(p + i)); }指针自增法(遍历过程中移动指针):
int *p = a; for (int i = 0; i < n; i++) { printf("%d ", *p); p++; }三种写法的执行效率在现代编译器优化下差别不大,我更看重可读性。如果数组只是顺序遍历,下标法最直观;如果还要在遍历中做复杂的边界判断、需要保留"当前位置"的信息,指针自增法更灵活。
还有一个"指针比较法"也常见:
int *p; for (p = a; p < a + n; p++) { printf("%d ", *p); }用指针和末尾地址比较来确定循环结束。理论上没问题,但注意:a + n指向的是数组最后一个元素之后的位置,这个指针值是合法的存在,但不应该被解引用。循环里只有在p没有到达这个位置时才执行*p,所以没问题。
3.4 数组作函数参数时的退化机制
这是数组和指针关系里最容易被忽视、也最致命的一个点。先看代码:
void printArray(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } }函数定义里写int arr[],看起来是"传一个数组",但编译器实际上把它当成int *arr处理。也就是说,数组作为函数参数时,传进去的只是指向首元素的指针,而不是整个数组的拷贝。
这意味着:
- 函数内部对arr[i]的修改,会直接影响到调用方的原数组。因为操作的是同一块内存。
- 函数内部
sizeof(arr)返回的只是指针大小(8字节),不是数组大小。所以必须额外传一个长度参数n进去。
我见过很多新手在函数里写int len = sizeof(arr) / sizeof(arr[0]);想算出数组长度,结果发现len永远是2(8除以4),然后把整个程序搞蒙了。这个坑几乎人人都踩过。
正确的习惯是:任何接受数组的函数,都配一个长度参数。这个参数可以是显式的int n,也可以是用两个指针(起始和结束)表示范围,如void process(int *begin, int *end)。两种风格我都用过,各有利弊:显式长度参数写起来简单,指针范围更加"通用"(能处理数组中间的一段)。
4. 数组的边界检查与常见越界问题
4.1 为什么C语言不帮你检查越界
很多从Java、Python转过来学C语言的人,第一反应是"数组越界为什么编译器不报错"。
原因很简单:C语言的哲学是"信任程序员"。下标访问在编译阶段被翻译成纯粹的内存地址计算,编译器不会在生成的机器码里插入边界检查逻辑。这不是疏忽,而是C语言刻意保持的高效性——如果每次下标访问都要判断是否越界,性能会受到明显影响,这违背了C语言"接近硬件、追求性能"的设计初衷。
但这带来的后果是:越界访问是典型的未定义行为(undefined behavior)。未定义行为意味着:编译器可以给你任何结果,直接崩、输出垃圾值、碰巧正常,全都有可能。而且同一个程序换个编译器、换个优化级别,行为还可能不一样。这是C语言调试中最恶心的事情之一——问题不是稳定复现的,时好时坏。
4.2 常见的越界场景和典型后果
我总结了几种高频越界场景:
读越界:
int a[5] = {1, 2, 3, 4, 5}; printf("%d", a[5]); // 读到了数组后面的垃圾数据这种不会立刻崩溃(因为那块内存可能还能读),但会输出一个毫无意义的值。
写越界:
int a[5]; for (int i = 0; i <= 5; i++) { // 注意条件是 i <= 5 a[i] = i * 10; }循环结束时i=5的一次写入,把值写到了a[4]之后的内存中。如果那块内存恰好是其他变量的存储位置,就会悄悄修改其他变量的值——这是最危险的类型,程序看起来没崩,但行为完全错了,极难排查。
缓冲区溢出:
char buf[10]; strcpy(buf, "这是一个很长的字符串内容");字符串拷贝没有检查目标缓冲区大小,直接把内容写到了buf后面更远的内存中。这种问题在真实项目中可能导致内存数据被破坏、程序崩溃甚至安全问题。字符串处理相关的越界,是C语言安全漏洞的头号来源。
我在实际调试中遇到过最经典的案例:程序里有一个int变量和一个char数组在栈上挨着存放,某段代码向数组写了过长的数据,结果int变量的值被"污染"了。更迷惑的是,这个值不是每次都变,而是取决于越界写入的那串垃圾数据。排查两三天,最后用调试器看内存布局才定位到——栈上的数组越界把相邻变量覆盖了。
4.3 如何系统性地防止越界
C语言本身不给保护,那就自己养成习惯。我总结了四条铁律:
第一,循环边界写清楚。用i < n而不是i <= n,用i < len而不是i < len - 1。每一个循环变量,心里都要过一遍它的取值范围。
第二,数组大小和下标分开保存。定义一个常量表示数组大小,所有循环和访问都用这个常量:
#define ARRAY_SIZE 100 int a[ARRAY_SIZE]; for (int i = 0; i < ARRAY_SIZE; i++) { a[i] = i; }这样即使要改大小,只改一处,不会漏掉某个硬编码的数字。
第三,对输入的下标做合法性检查。比如用户输入了一个索引,你要访问数组某个元素前,先判断这个索引是否在[0, size-1]范围内:
if (index >= 0 && index < size) { // 安全访问 } else { // 处理非法索引 }这个检查在触碰外部输入的场景(用户交互、文件读取、网络数据)是必须的,不是可选项。
第四,字符串操作优先用安全的函数变体。比如用strncpy代替strcpy、用snprintf代替sprintf,并正确传缓冲区大小参数。这些函数能限制写入长度,避免最严重的缓冲区溢出。
提示:使用静态分析工具(比如一些编译器的警告选项、代码检查工具)能帮你在编译阶段发现部分越界问题。开启警告选项后,很多明显的越界代码会收到警告信息。虽然不能覆盖所有情况,但确实能拦截掉一批低级错误。
5. 数组常见算法:查找、排序、反转与统计
5.1 线性查找与二分查找
线性查找是最简单的查找方式:从第一个元素开始,逐个比较,直到找到目标或遍历完所有元素。
int linearSearch(int arr[], int n, int target) { for (int i = 0; i < n; i++) { if (arr[i] == target) { return i; // 找到,返回下标 } } return -1; // 没找到,返回-1 }时间复杂度O(n)。优点是:不要求数组有序,逻辑极其简单。缺点是:数据量大了以后太慢。
二分查找是更高效的查找方式:要求数组已按升序(或降序)排列,每次取中间元素比较,把搜索区间缩小一半,反复进行。
int binarySearch(int arr[], int n, int target) { int low = 0, high = n - 1; while (low <= high) { int mid = low + (high - low) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { low = mid + 1; } else { high = mid - 1; } } return -1; }几个容易写错的细节:
mid的计算我故意写成low + (high - low) / 2,而不是(low + high) / 2。当数组很大时,low + high可能超过int的表示范围,造成溢出。这个坑在真实项目中发生过,值得现在就注意。- 循环条件是
low <= high,不是low < high。漏掉等号,会导致只剩一个元素时循环根本不执行,永远找不到target恰好就是那个元素的情况。 - 每次更新区间时,
low = mid + 1和high = mid - 1。不能写成low = mid或high = mid,否则当目标不在数组里时,可能出现死循环。
二分查找的时间复杂度O(log n),在百万级数据里查找,最多20次比较就能定位,而线性查找最坏要100万次。实际项目中,只要数据已排序且允许预先排序,二分查找是远优于线性查找的选择。
5.2 冒泡排序:原理、改进与局限
冒泡排序是教学价值最高的排序算法,虽然它时间复杂度O(n²),在大型数据集中性能堪忧,但实现简单、思路直观。
核心思想:每一轮从头到尾比较相邻两个元素,如果顺序错误就交换。一轮结束后,最大的元素就像气泡一样"浮"到了数组末尾。再进行下一轮,直到所有元素有序。
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }内层循环的上界是n - 1 - i,因为每一轮结束后,数组末尾的i个元素已经是排好序的,不需要再参与比较。这是冒泡排序最基础的优化点。
一个更实用的优化是:如果某一轮下来没有任何交换,说明数组已经有序,可以直接终止。加一个标志位:
void bubbleSortWithFlag(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = 1; } } if (swapped == 0) { break; // 没有交换,数组已经有序 } } }这个改进在数组"近乎有序"时效果很好——比如数据只是略微乱序,可能一轮就发现已经有序,直接结束。最坏情况(完全逆序)下依然是O(n²)。
冒泡排序的另一个特点是稳定排序:相等的元素在排序后保持原来的相对顺序。这在按多个字段排序(先按分数、再按姓名)时有实际意义。
5.3 选择排序:思路直观但不如想象中好
选择排序的核心思想:每一轮在未排序部分找到最小值,把它和未排序部分的第一个元素交换。经过n-1轮后,数组就有序了。
void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int minIndex = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; } } if (minIndex != i) { int temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } } }选择排序的特点是交换次数很少:每轮最多一次交换,总共最多n-1次交换。冒泡排序最坏情况下交换次数是O(n²)。所以当"交换成本高"(比如数组元素是大的结构体)而"比较成本低"时,选择排序可能优于冒泡。
但选择排序有一个缺点:它是不稳定的。因为交换可能把相等元素的相对位置打乱。举例:数组[5a, 3, 5b, 1],第一轮找到最小值1,把它和第一个元素5a交换,数组变成[1, 3, 5b, 5a],原来的5a跑到了5b后面,相对顺序变了。如果业务上需要稳定排序,选择排序不合适。
5.4 插入排序:小规模数据实测最稳的选择
插入排序的思想像整理扑克牌:每次把一张牌插入到已排好序的牌堆中正确的位置。
void insertionSort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }从i=1开始,arr[0..i-1]已经是排好序的,把arr[i](存到key里)往前插入到正确位置。插入的办法是:比key大的元素逐个后移一位,腾出空位。
为什么这个算法值得认真掌握?两个原因:
第一,近乎有序的数组上,插入排序非常快。如果数组基本有序,内层while循环很快退出,实际运行时间接近O(n)。这在很多真实场景中有用——你处理的数据往往是"大部分有序、少部分乱序"的状态。
第二,插入排序是很多高级排序算法的"基本盘"。比如快速排序在数据量很小时递归开销过大,很多实现会转用插入排序处理小规模子数组(通常n小于10到20时切换)。因为小规模数据上,插入排序的常数极小,比快排的递归开销更划算。
我实测过一个数据:对10万元素的随机数组,插入排序比冒泡快好几倍;但和快速排序比又慢很多。所以选算法的关键是看场景,没有一个算法是万能的。
5.5 快速排序与归并排序:真正生产级的排序方案
快速排序是目前工程实践中使用最广泛的排序算法之一。核心思想是"分而治之":
- 选一个基准值(pivot)。
- 把数组分成两部分:所有小于基准值的放左边,大于等于基准值的放右边。
- 递归对左右两部分分别排序。
void quickSort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } } int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } } int temp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = temp; return i + 1; }平均时间复杂度O(n log n),最坏情况O(n²)——当每次选的基准值都是当前区间的最大或最小值时,退化成类似冒泡的效率。避免方法:随机选pivot,或者取"三数取中"(起始、中间、末尾三个元素的中位数作为pivot)。
归并排序的思路是先拆分再合并:把数组从中间拆成两半,各自排序,再合并两个有序序列。
void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1; int n2 = right - mid; int L[n1], R[n2]; for (int i = 0; i < n1; i++) L[i] = arr[left + i]; for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j]; int i = 0, j = 0, k = left; while (i < n1 && j < n2) { if (L[i] <= R[j]) { arr[k++] = L[i++]; } else { arr[k++] = R[j++]; } } while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; } void mergeSort(int arr[], int left, int right) { if (left < right) { int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } }归并排序的优势:稳定且时间复杂度稳定在O(n log n),不受输入数据分布影响。缺点是需要O(n)的额外空间存储临时数组。在内存充足但对稳定性有要求(比如数据库排序、外部排序)的场景,归并排序是首选。
快速排序和归并排序怎么选?我的实践经验是:普通场景下快排更快(因为操作局部性好、不需要额外空间);但如果数据量极大、需要稳定排序,或者数据分布可能导致快排退化,那就选归并。另外顺手提一句,很多编译器的标准库排序函数,实际实现是"快排+插入排序"的混合体,小数组用插入、大数组用快排,这就是上面说的"高级算法落地时的工程优化"。
5.6 数组反转与元素统计的经典写法
数组反转是面试和练习中出现频率很高的题。原地反转的核心思路:首尾交换,不断向中间靠拢。
void reverseArray(int arr[], int n) { int left = 0, right = n - 1; while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }两个注意点:循环条件是left < right,而不是left <= right——如果相等,中间元素自己跟自己交换,没有意义但也不出错;每次交换后left加1、right减1,向中间逼近。这个算法的时间复杂度O(n),空间复杂度O(1),非常高效。
元素统计最常见的需求是:求最大值、最小值、平均值、总和。
int getSum(int arr[], int n) { int sum = 0; for (int i = 0; i < n; i++) { sum += arr[i]; } return sum; } int getMax(int arr[], int n) { int max = arr[0]; for (int i = 1; i < n; i++) { if (arr[i] > max) { max = arr[i]; } } return max; }一个容易忽略的细节:求最大值时,如果数组可能为空(n=0),直接int max = arr[0]会访问不存在的元素。更稳妥的做法是:先判断n是否为0,如果为0返回一个特殊值(比如一个约定的错误标志),否则再执行初始化。实际工程中,空数组的处理往往不是一个技术问题,而是一个边界条件的设计问题,体现的是代码的健壮性。
频次统计则经常用"值作为下标"的技巧。比如统计数组中出现次数最多的元素:
// 假设元素值在0到99之间 int count[100] = {0}; for (int i = 0; i < n; i++) { count[arr[i]]++; }把元素的值当作计数数组的下标,一次遍历就能统计完所有元素的出现次数。这个技巧叫计数数组,本质是用空间换时间。但要注意:如果元素值范围很大(比如0到10亿),这个方案就不适用了——因为你要定义一个巨大的数组,浪费严重。此时应该考虑用哈希表(C语言里需要自己实现或用第三方库)。
5.7 二维数组的经典应用:矩阵转置与遍历
二维数组在数学计算、图像处理、游戏开发中非常常见。最经典的入门操作是矩阵转置:把行和列互换。
#define ROWS 3 #define COLS 3 void transpose(int matrix[ROWS][COLS], int result[COLS][ROWS]) { for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { result[j][i] = matrix[i][j]; } } }注意:如果是方阵(行数等于列数),可以原地转置,只交换上三角部分:
void transposeInPlace(int matrix[3][3]) { for (int i = 0; i < 3; i++) { for (int j = i + 1; j < 3; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[j][i]; matrix[j][i] = temp; } } }原地转置的关键是内层循环从j = i + 1开始,否则会重复交换两次,等于没转。二维数组的遍历方式(行优先还是列优先)对性能也有影响,因为数组在内存中按行连续存放,一行一行地访问缓存命中率高;按列访问则每次跳一大段内存,缓存命中率低。数据量大时,这个差异可能放大到数倍性能差距。
6. 数组与字符串:剪不断理还乱
6.1 字符数组与字符串字面量
在C语言里,字符串本质上就是字符数组,但有一个额外约定:以空字符'\0'(ASCII码0)结尾。这个'\0'不是可打印字符,它专门用来标记字符串的结束位置。
两种定义字符串的方式看起来很像,但本质完全不同:
char str1[] = "hello"; // 字符数组:内容可修改 char *str2 = "hello"; // 字符串字面量:内容只读str1是一个6个字符的数组(h、e、l、l、o、'\0'),存储在栈上,可以修改str1[0]='H'。str2是一个指向字符串字面量的指针,字面量存储在只读区(通常在数据段或代码段),修改str2[0]会导致运行时错误(段错误)。这个区别是C语言初学者的经典"送命题",每次讲字符串我都提醒:用数组形式定义字符串,除非你确定不需要修改内容。
计算字符串长度时,'\0'不算在内。sizeof(str1)是6(包括'\0'),而strlen(str1)是5(只到'\0'之前)。这两个值的区别也要记牢。
6.2 常用字符串操作函数的底层逻辑
C语言标准库提供了一批字符串操作函数,理解它们的底层实现逻辑,才知道怎么正确使用。
strlen:计算字符串长度(不包括'\0')。
size_t strlen(const char *s) { size_t len = 0; while (s[len] != '\0') { len++; } return len; }底层就是一个循环遍历,直到遇到'\0'。所以如果字符串没有正确以'\0'结尾,strlen会一直往下读,直到偶然踩到一个'\0',返回一个巨大且完全错误的值。这也是一个经典的越界风险点。
strcpy:复制字符串。
char *strcpy(char *dest, const char *src) { char *p = dest; while ((*p++ = *src++) != '\0'); return dest; }这个实现很精妙:*p++ = *src++既完成了赋值又移动了指针,返回值就是src当前位置的字符,当这个字符是'\0'时循环结束。复制过程连同'\0'一起拷贝。但strcpy不检查dest是否有足够空间,如果src比dest大,就发生了缓冲区溢出。所以实践中更推荐用strncpy(带长度限制)或snprintf。
strcmp:比较两个字符串。
int strcmp(const char *s1, const char *s2) { while (*s1 && *s1 == *s2) { s1++; s2++; } return (unsigned char)*s1 - (unsigned char)*s2; }返回值为0表示两个字符串相等,负数表示s1小于s2,正数表示s1大于s2。注意:不能用==直接比较两个字符串数组名,因为数组名是地址,==比较的是地址是否相同,不是内容是否相同。必须用strcmp。这个错误我在很多代码评审里都看到过。
strcat:拼接字符串到目标末尾。
char *strcat(char *dest, const char *src) { char *p = dest; while (*p) p++; while ((*p++ = *src++) != '\0'); return dest; }先找到dest的末尾(即第一个'\0'的位置),然后从那里开始复制src。同样,它不检查dest的空间是否够,使用时要确保dest有足够容纳两个字符串加上'\0'的空间。一个典型错误是:char s[5] = "abc"; strcat(s, "def");——s只有5字节空间,拼接后字符串占据6字节(abcdef加'\0'),直接越界。
提示:字符串拼接安全写法是
snprintf(dest, sizeof(dest), "%s%s", dest, src);。但注意,如果dest和src有重叠区域,使用snprintf也会有问题,需要额外小心。最保险的做法是自己确定目标缓冲区足够大,再执行拼接操作。
6.3 字符串数组与指针数组的选型
存储多个字符串时,有两种常用方案:
二维字符数组:
char names[5][20] = { "apple", "banana", "cherry", "date", "elderberry" };这个方案的优势:内存连续、结构清晰、可以修改任意字符串内容。劣势:每一行的长度固定为20,如果实际字符串短,浪费空间;如果某个字符串超过19个字符(还要留一个给'\0'),就装不下。存储大量长度差异大的字符串时,空间浪费严重。
字符指针数组:
const char *names[] = { "apple", "banana", "cherry", "date", "elderberry" };这个方案的优势:每个指针指向各自的字符串字面量,长度不固定,没有空间浪费。劣势:字符串内容通常不可修改(指向字面量时);如果需要动态分配字符串内容,还要管理每个字符串的内存释放。
实际开发中我的选型标准是:如果字符串长度差异小且需要修改内容,用二维数组;如果长度差异大或只读,用指针数组。还有一种折中方案:指针数组+动态内存分配,每个指针指向一块malloc出来的空间,按需分配大小——这是最灵活但也最费心的方案,因为要记得逐个free。
7. 数组陷阱排查:动态内存、函数传参与初始化问题
7.1 数组大小用变量定义?不同标准的规则差异
一个很高频的问题是:"为什么int n = 5; int a[n];有时候能编译,有时候报错?"
答案跟C语言标准有关:
- C89/C90标准:数组长度必须是编译期常量,即
int n = 5; int a[n];是非法的。要定义大小由常量变量决定的数组,应该用宏(#define)或枚举常量。 - C99标准:引入了变长数组(VLA),允许运行期指定数组大小。
int n = 5; int a[n];合法。但VLA有一些限制:不能是全局数组(全局数组大小必须在编译期确定);某些编译器默认不支持或支持不完全。 - C11标准:VLA变为可选特性。有些编译器默认关闭,有些开启。
在实际工程中,我建议不要依赖VLA。原因除了标准支持不一,还有:VLA分配在栈上,如果运行时n的值很大,可能导致栈溢出,且因为大小不确定,编译器能做的优化也有限。
如果确实需要运行期确定大小的数组,正确的做法是动态内存分配:
int n = 5; int *a = (int *)malloc(n * sizeof(int)); if (a == NULL) { // 处理内存分配失败 } // 使用完毕后必须释放 free(a);malloc出来的数组在堆上,大小灵活、生命周期可控。但必须配套free,否则内存泄漏——局部数组不需要free,这是两者本质的区别。
7.2 函数形参中数组与指针的等价写法
在函数参数声明时,下面几种写法是等价的:
void f(int arr[]); void f(int arr[10]); // 这里的10会被忽略 void f(int *arr);三种写法编译结果完全一样,arr都是一个int *指针。区别仅在于可读性:int arr[]暗示"这是一个数组",int *arr更精确地表达了"一个指针"的事实。对于二维数组参数,情况稍复杂:
void f(int matrix[][3]); // 正确:第二维必须指定 void f(int matrix[3][3]); // 正确:第一维可省略,第二维必须指定 // void f(int matrix[][]); // 错误:第二维不能省略原因和指针算术有关:二维数组退化后,参数类型是"指向含3个int的数组的指针",即int (*matrix)[3]。只有知道每行有多少个元素,编译器才能计算matrix[i][j]的实际地址。这也是为什么二维数组传参时,第二维必须明确。
7.3 为什么sizeof(arr)在函数里失灵
前面说过,数组作为参数时退化为指针。所以在函数内部:
void f(int arr[]) { printf("%zu\n", sizeof(arr)); // 输出8(64位平台上指针大小) }很多人以为sizeof(arr)是数组大小,实际上它只是指针大小,跟数组长度没有任何关系。这就是为什么几乎所有接收数组的函数都要另带一个长度参数:
void f(int arr[], int n); void process(int *begin, int *end); // 用指针范围表达第一个方案最常见;第二个方案在C++的STL风格里很流行,C语言里用的人也不少。我个人的习惯是:简单的算法函数用int n;涉及集合操作的库函数,用指针范围更自然(因为调用方可以直接传arr和arr + n)。
7.4 数组作为返回值的"骗局"
C语言不能直接返回一个数组类型。如果你写:
int *createArray(void) { int arr[10]; // 填充 arr return arr; // 错误:返回的是局部数组的首地址 }这个函数返回后,arr在栈上的空间已经释放,返回的指针变成悬空指针。后续访问这块内存,行为未定义——可能碰巧值还在,也可能已经被其他函数调用覆盖。这是C语言里面最隐蔽的坑之一。
三种正确做法:
方式一:在调用方定义好数组,通过指针传入函数填充:
void fillArray(int arr[], int n) { for (int i = 0; i < n; i++) { arr[i] = i * i; } } // 调用 int result[10]; fillArray(result, 10);方式二:在函数内部用malloc动态分配,返回指针,由调用方负责释放:
int *createArray(int n) { int *arr = (int *)malloc(n * sizeof(int)); if (arr != NULL) { for (int i = 0; i < n; i++) { arr[i] = i * i; } } return arr; } // 调用 int *result = createArray(10); // 使用完毕后必须 free(result);方式三:使用全局或静态数组(不推荐,除非有明确理由),因为它的生命周期是整个程序运行期。
7.5 初始化与赋值的几个常见误区
误区一:数组整体赋值。C语言不允许直接int b[5] = a;或b = a;。要复制数组内容,必须逐个元素复制,或用memcpy:
memcpy(b, a, sizeof(a));注意sizeof(a)在这里是数组大小,因为a和b在同一个作用域内,这个写法有效。
误区二:int a[3] = {1, 2, 3}; int b[3]; b = a;。这个错误和误区一是一类:数组名是常量不可赋值,所以b = a编译都过不去。正确做法还是memcpy或循环赋值。
误区三:字符数组用=直接赋字符串。char str[10]; str = "hello";是非法的。必须用strcpy(str, "hello");或逐字符复制。
误区四:动态数组忘记初始化。malloc出来的内存内容是不确定的,不是0。需要清零的话,用memset:
int *a = malloc(sizeof(int) * 100); if (a != NULL) { memset(a, 0, sizeof(int) * 100); }注意memset的第三个参数是字节数,不是元素个数。sizeof(int) * 100表示100个int元素占用的总字节数,而不是100。写错的话,可能只清零了前一部分元素,后面全是垃圾值,调试时很难发现。
8. 我实测下来最有用的三个技巧
技巧这个东西,很多书本不写,但实测下来确实能减少大把调试时间。分享三个我常用的。
技巧一:用调试器查看数组内存布局。排查越界问题时,单靠printf打印元素看效果非常局限。直接打开调试器的内存窗口,输入数组首地址,就能看到连续的内存数据。这个方法能快速发现"某个变量被越界写覆盖"的问题,因为你能直观看到栈上各个变量的相邻关系。我在排查"值莫名其妙被改"的问题时,几乎都是靠这一步定位的。
技巧二:写一个通用的数组打印函数。开发时经常需要打印数组内容调试,每次写循环又烦又容易写错。我习惯在一开始就写一个小工具函数:
void printIntArray(const int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); }后面的排序、查找练习全部复用这个函数,一眼看出排序是否正确。自己搭个小工具集,比每次复制粘贴代码高效得多。
技巧三:利用const修饰形参表达"只读"意图。如果函数只是读取数组、不修改内容,把参数类型写成const int arr[]。这样有两个好处:编译器会帮你检查函数体内有没有误改数组内容;调用方也能从函数签名一眼看出"这个函数不会修改传入数据"。在多人协作或回头看自己旧代码时,这个习惯价值很大。
最后一个体会:数组的很多坑,本质上是对"连续内存"和"类型退化"这两件事理解不到位。把数组当成一段连续内存,把数组名当成一个会自动退化成指针的常量,很多概念就能串起来了。遇到越界报错,先问自己是"读越界还是写越界";遇到函数里数组长度算不对,先确认是不是忘记传长度参数。这些问题见多了,下次碰到基本就是条件反射。C语言的数组学习没有捷径,但多写多调试几次,那些看似玄学的现象都会变得理所应当。