C++里最基础的数据结构是什么?我面试过不少写C++的候选人,几乎每个人都能脱口而出“数组”。可真让把一维数组和二维数组讲透、用明白,很多人就卡住了——初始化方式说不全,二维数组传参时函数形参为什么必须要写列数说不清,一提到排序只会想起vector,稍微追到数组名和指针的关系,更是含糊其辞。
这篇文章不重复教科书上那种“数组是相同类型元素的集合”的老话,而是把一维数组和二维数组在实际开发里真正绕不开的考点、用法和坑,一起捋一遍。从内存本质到初始化细节,从函数传参到排序查找,最后落到用二维数组做棋盘游戏和处理图像数据这类真实场景。适合正在刷C++数组练习题的人、准备C++面试的人,以及想快速上手写C++小游戏但又被数组卡住的读者。
1. 先搞清楚数组名是什么:一段连续内存,不是一个“容器变量”
很多初学者把数组理解成一个能装多个值的盒子,这个直觉方向没错,但它没有揭示数组最核心的本质:数组是一段连续内存,数组名不过是指向这堆内存首地址的符号。搞清楚这一点,后面百分之八十的坑都能避开。
1.1 数组名什么时候“退化成指针”,什么时候不退化
C++里有个很关键的行为,叫做“数组退化”(array decay)。简单说,在绝大多数表达式中,数组名都会被隐式转换为指向其第一个元素的指针。比如:
int arr[5] = {1, 2, 3, 4, 5}; int* p = arr; // 数组名arr退化成int*,等价于&arr[0]这条规则意味着arr + 1并不是“数组内存地址加1字节”,而是“向后移动一个int的距离”。我见过不少新手写p = arr + 1想取第二个字节,结果取出来是第二个元素,这就是没理解指针运算是以元素大小为单位。
但有三种情况下数组名不会退化成指针:sizeof(arr)、&arr、以及数组作为引用绑定时。sizeof(arr)返回的是整个数组占用的字节数,而不是指针大小。这是面试爱考的:
int arr[5] = {1, 2, 3, 4, 5}; std::cout << sizeof(arr); // 输出20(5 * 4字节) std::cout << sizeof(&arr[0]); // 输出8(64位下指针大小)&arr的类型是int(*)[5],指向整个数组,而不是指向首元素。虽然它们的地址值一样,但步长完全不同:&arr + 1会越过整整5个int的长度。这些细节平时写业务代码可能用不上,但一旦涉及内存操作、缓冲区读写、或者跟C接口打交道,理解不扎实就会写出隐蔽的越界bug。
1.2 初始化方式的几个细节:{}不只是“赋初值”
一维数组的初始化,表面上有好几种写法,但底层逻辑是一致的:要么显式给出所有元素,要么由编译器帮你补零或推断长度。
int a[5] = {1, 2, 3, 4, 5}; // 全部指定 int b[5] = {1, 2, 3}; // 剩余元素自动置0:{1,2,3,0,0} int c[] = {1, 2, 3}; // 编译器推断长度为3 int d[5] = {}; // 全部置0(强烈推荐这种写法) int e[5]; // 危险!只读不写时值是未定义的int e[5];只在栈上分配了空间,但没有初始化,里面的值是上次这块内存残留的“垃圾”。我最开始学C++时,在这上面吃过亏:局部数组明明打印出来是0,换台机器或换个编译器选项就变成了随机大数。局部变量不会自动清零,全局和静态数组才会默认零初始化。如果你写数组的目的就是让它初始时全为0,直接写int arr[100] = {};,省心又安全。
还有一种常见做法是用memset清零:
int arr[100]; memset(arr, 0, sizeof(arr)); // 把arr的100*4字节全部置0注意memset是按字节填充的,所以对int数组来说,第二个参数只能填0、-1这类每个字节都相同的数据结构。填1的话,不会得到全是1的int数组,而是每个int变成0x01010101,也就是16843009,这是个经典坑。
1.3 越界访问:编译器放过了你,但代价可能极其隐蔽
数组最大的问题就是越界。C++不像Java、Python那样在运行时做边界检查,数组越界是未定义行为(UB)。更坑的是,编译器经常不报错,程序看起来也正常跑,直到某个夜深人静的版本上线,线上服务崩溃了,你才追查到是半年前的一行越界写。
我做过一次非常典型的排查:一个负责处理配置数据的模块,偶尔崩溃,频率极低,堆栈完全无规律。最终用地址消毒器(AddressSanitizer,编译时加-fsanitize=address)跑出来,问题就是对一个局部数组越界写了一个元素。越界写踩到的内存恰好属于相邻的栈变量,数据被悄悄篡改,等那个变量被使用时才炸。这类bug随机性极强,不用工具极难复现。
所以学数组阶段就该养成习惯:循环边界多看一眼,i < n写成i <= n是最常见的越界来源。如果你用的是较新的C++标准(C++20之后),可以优先考虑std::span或std::array管理定长数组,访问时用.at()方法会做边界检查,虽然牺牲一点点性能,但换来的是调试阶段的安全感,很值得。
2. 二维数组的内存布局和初始化细节:它不是表格,而是“数组的数组”
二维数组这个概念,从名字上很容易被误解成一张二维表格,但计算机内存是一维的线性地址空间。int a[3][4] 的本质,是“一个包含3个元素、每个元素又是一个包含4个int的数组”的数组,即数组的数组。
2.1 行优先存储:内存里依然是连续的一维排列
int a[2][3] = { {1, 2, 3}, {4, 5, 6} };这6个int在内存中的排列是:1, 2, 3, 4, 5, 6,完全连续。第一行三个元素紧挨着,第一行结束之后紧接着第二行的三个元素。这种布局叫行优先(row-major),和数学上矩阵按行展开的习惯一致。
理解行优先布局为什么重要?因为它在多处影响着代码性能。遍历同样的二维数组,你写循环的顺序不同,实际内存跳转距离完全不同:
// 方式1:按行优先遍历(缓存友好) for (int i = 0; i < rows; ++i) for (int j = 0; j < cols; ++j) sum += a[i][j]; // 方式2:按列优先遍历(每次跳跃一整行) for (int j = 0; j < cols; ++j) for (int i = 0; i < rows; ++i) sum += a[i][j];两种写法的算术结果完全一样,但方式2的内存访问是跳跃的:访问完a[0][0]后访问a[1][0],这两个地址之间隔了cols * sizeof(int)个字节。当数组很大时,CPU缓存命中率会明显下降,性能差距能达到数倍。我做过一个图形算法,只是改了一下循环顺序,耗时降了将近一半。这不是玄学,是内存访问模式对缓存的影响。
2.2a[i][j]到底是怎么算出来的
二维数组的下标访问a[i][j],其实是一个两步指针解引用的语法糖:
a[i][j] 等价于 *(*(a + i) + j)拆开来看:a是int[3][4]类型,退化成指针后类型是int(*)[4],也就是“指向包含4个int的数组的指针”。a + i表示跳过i个长度为4的int数组。*(a + i)拿到的是第i行这个数组本身(同样会退化成它的首元素地址,类型是int*)。再+ j就是在这一行内偏移j个int,最后解引用就是元素值。
这也解释了为什么二维数组的列数在编译期就必须已知:因为a + i要跳过“i行”这么多内存,怎么算“一行”有多长?只能靠列数乘上每个元素大小。列数不固定,编译器根本算不出任何人的地址。
顺便说一下,sizeof(a)对整个二维数组也有效:
int a[3][4] = {}; std::cout << sizeof(a); // 输出48(3*4*4字节) std::cout << sizeof(a[0]); // 输出16(一行4个int) std::cout << sizeof(a[0][0]); // 输出4(一个int)2.3 二维数组的初始化:按行给、平铺给、还是只给第一维
// 方式1:按行初始化,最直观 int arr[2][3] = { {1, 2, 3}, {4, 5, 6} }; // 方式2:平铺初始化,编译器按行填充 int arr[2][3] = {1, 2, 3, 4, 5, 6}; // 方式3:部分初始化,剩余填0 int arr[2][3] = {{1, 2}, {4}}; // 结果是:{1,2,0}和{4,0,0} // 方式4:只省略第一维长度,编译器根据总元素数推断 int arr[][3] = {1, 2, 3, 4, 5, 6}; // 6个元素,每行3个,编译器推出第一维是2方式4有个容易疑惑的点:为什么只能省略第一维?这很好解释。第二维决定了行内到底多大,编译器要靠它来排地址;第一维只决定一共有几行,而行的数量是可以从总元素数和列数推出来的。按照“数组的数组”来理解,第二维实际上是对内层数组的完整定义,少了它整个布局就塌了。这个规则在函数传参时同样生效,下一节详细说。
还有一个我见过很多次的迷惑行为:用for (int i = 0; i <= rows; ++i)遍历二维数组,内外层循环边界都写错一个,导致第二行数据没遍历到,而是读了第一行开头之后几行之外的垃圾内存。这类问题用上面的行优先视角特别容易查:每次a[i][j]的地址偏移是(i * cols + j) * 元素大小,你把i或j的边界在草稿纸上展开算一遍,立刻能发现问题。
3. 二维数组传参:为什么函数形参必须写列数,以及怎么设计才不容易出错
如果说数组初始化是入门,那二维数组作为函数传参就是分水岭。很多人写的代码一编译就报错,或者传进去之后函数里的行列搞反了,根本原因还是没想清楚“函数形参里到底需要什么信息才能完成地址偏移计算”。
3.1 一个常见报错场景
你写了一个打印函数,试图用二维数组作参数:
void printArray(int arr[][], int rows) { // 编译报错!列数缺失 for (int i = 0; i < rows; ++i) { for (int j = 0; j < ???; ++j) // 编译器不知道每行多少个元素 ... } }编译器看到int arr[][]直接拒绝:第二维长度未知,它无法确定arr[i]的地址偏移。原因就是第2.2节说的那个计算式:*(*(arr + i) + j)中,arr + i需要知道每一行占据多少字节。这个信息不在运行时参数里,而必须在类型中体现出来,所以必须在函数签名中把列数量写清楚。
3.2 三种正确写法,以及它们的等价关系
假设数组是int a[3][4],传给函数时,合法写法有三类:
// 写法1:直接写明列数(最直观) void print1(int arr[3][4], int rows) { for (int i = 0; i < rows; ++i) for (int j = 0; j < 4; ++j) std::cout << arr[i][j] << " "; } // 写法2:省略第一维,保留列数(推荐) void print2(int arr[][4], int rows) { for (int i = 0; i < rows; ++i) for (int j = 0; j < 4; ++j) std::cout << arr[i][j] << " "; } // 写法3:指针形式,int(*)[4] 指向“包含4个int的数组” void print3(int (*arr)[4], int rows) { for (int i = 0; i < rows; ++i) for (int j = 0; j < 4; ++j) std::cout << arr[i][j] << " "; }这三种写法完全等价,编译器眼中的类型一模一样:形参arr的类型都是int(*)[4]。int arr[][4]写起来更像数组,但它只是int(*)[4]的语法糖。如果你心里有“数组的数组”这个概念,写法3反而最接近本质:指向一个含有4个int的数组的指针。
需要注意:这里的rows必须由调用方传入,函数内部并不知道数组实际有几行。因为数组退化后,第一维的信息彻底丢失了。这也是为什么很多生产级的C++代码更愿意用std::vector<std::vector<int>>或std::array<std::array<int, 4>, 3>而不是裸二维数组传参——容器的尺寸信息保存在对象里,不用手动额外传递。
3.3 用vector和array替代裸数组,什么时候比数组更好
我写项目的习惯是:
- 元素个数编译期就固定、且需要极高性能时,用
std::array<int, N>或二维std::array<std::array<int, C>, R>。 - 元素个数运行时才能确定,或者需要动态扩容,用
std::vector<std::vector<int>>。 - 只有做内存极敏感的系统编程、对接C接口、或者刻意避免动态内存分配时,才直接用语言内置的裸数组。
举个例子,两种传参接口的对比:
#include <vector> void printMatrix(const std::vector<std::vector<int>>& matrix) { size_t rows = matrix.size(); for (size_t i = 0; i < rows; ++i) { for (size_t j = 0; j < matrix[i].size(); ++j) { std::cout << matrix[i][j] << " "; } std::cout << "\n"; } }vector的二维结构不是一个连续内存块,而是“外层vector存了若干内层vector的指针,内层每个vector各自管理一块堆内存”。所以它的性能通常不如裸二维数组,但胜在尺寸信息完整、不会越界、拷贝和扩容方便。对大多数业务场景来说,std::vector是更稳妥的选择。
如果你既要连续内存,又想动态宽度:
std::vector<int> data(rows * cols); // 访问 data[i * cols + j],自己算索引这是一个实践中非常常见的优化技巧:用一维vector模拟二维数组。内存连续、缓存友好、传参只需传一个vector和cols,接口简单又安全。很多图形图像库底层就是这种表示。
3.4 另一种坑:行数和列数在传参时顺序写反
写接口时我见过好几个人犯这个错:内部数据处理按行优先,但传参时把一个“cols”当成“rows”传了。比如:
void process(int arr[][4], int rows) { for (int i = 0; i < rows; ++i) for (int j = 0; j < 4; ++j) ... } // 调用时 int data[3][4] = {}; process(data, 4); // 这里rows传成4,列数是4,本来应该是3行表面看不报错,但函数会访问到data[3],越界读取第一行往后的内存。这类错误最有效的防御方法有三个:定义清晰的变量名,如rows和cols不要混用;在函数开头打印一下sizeof(arr[0])/sizeof(arr[0][0])校验列数;或者干脆封装成结构体,把数组和维度绑在一起。
4. 数组上最高频的三类操作:排序、查找、字符处理
学完定义和传参,接下来就是实际干活。针对热搜里学C++的人最常搜的“数组练习题”“冒泡排序”“二分查找”,我把这三块一次性讲清楚,附带我看代码时最关注的细节。
4.1 冒泡排序:为什么这种O(n²)算法依然是学习第一课
冒泡排序的思路很朴素:重复走访要排序的数组,一次比较两个元素,顺序不对就交换,直到没有需要交换的元素为止。每轮都会把当前未排序区间的最大值“冒”到最后面。
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; ++i) { bool swapped = false; // 优化点:如果这轮没交换,说明已经有序 for (int j = 0; j < n - 1 - i; ++j) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; } }为什么学了基础语法之后,老师总喜欢让写冒泡?因为它能最直观地锻炼你对“数组下标”和“双层循环边界”的感觉。j < n - 1 - i这个边界就是经典考点:每排好一个数,内层比较次数就减1,因为最后i个数已经有序,不必再比。
冒泡排序的时间复杂度是O(n²),空间复杂度O(1),是稳定排序。实际工程里基本不会用它,因为std::sort的平均复杂度是O(n log n),而且优化极好。但做练习题、理解“交换”和“遍历”本身,写一遍冒泡非常值得。
4.2 std::sort:C++官方给的最强通用排序工具
排序这件事,写业务代码请忘掉自己手写的排序算法,直接用标准库:
#include <algorithm> #include <iostream> int main() { int arr[] = {5, 2, 8, 1, 9}; int n = sizeof(arr) / sizeof(arr[0]); std::sort(arr, arr + n); // 默认升序 // 结果是 1,2,5,8,9 }注意std::sort接收的是迭代器区间,左闭右开:arr是首地址,arr + n是“最后一个元素之后的位置”。对裸数组来说,arr + n是一个合法但不可解引用的指针,它存在的意义就是作为右边界。
自定义排序规则可以用 lambda。比如降序:
std::sort(arr, arr + n, [](int a, int b) { return a > b; });如果给二维数组排序,可以按行排序。比如每行都是一个长度为3的数组,希望按第一列优先、第二列次之排序:
int data[][3] = {{3, 1, 2}, {1, 5, 6}, {2, 0, 9}}; int rows = sizeof(data) / sizeof(data[0]); std::sort(data, data + rows, [](const int a[], const int b[]) { if (a[0] != b[0]) return a[0] < b[0]; return a[1] < b[1]; });这里lambda参数我故意写成了const int a[],等价于const int* a,在排序场景没问题。不过严谨一点应该写成const int (&a)[3]以引用方式绑定数组,但那样lambda就需要知道数组长度,写法上更啰嗦。实际用std::vector<std::array<int,3>>会方便很多:
std::vector<std::array<int, 3>> data2 = {{3,1,2},{1,5,6},{2,0,9}}; std::sort(data2.begin(), data2.end(), [](const auto& a, const auto& b) { return a[0] < b[0]; });4.3 二分查找:有序数组上的分治思维
二分查找的原理一句话就能说清:在有序数组里,取中间元素和目标比较,如果目标小于中间值,就只在左半边找;大于,就在右半边找。每步把搜索范围缩小一半,时间复杂度是O(log n)。
int binarySearch(const int arr[], int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; // 防溢出写法 if (arr[mid] == target) return mid; else if (arr[mid] < target) left = mid + 1; else right = mid - 1; } return -1; }这里有个细节:mid我写的是left + (right - left) / 2,而不是直接(left + right) / 2。原因是当left和right都很大时,left + right可能超过int范围导致溢出。这种写法在面试里是加分项,在真实大型数组查找时也确实必要。
手写二分容易出错的边界条件包括:while(left <= right)还是while(left < right);left = mid + 1还是left = mid。我的经验是:记牢一套固定写法,每次都按“闭区间查找最后一个可能位置”来推演,不要每次临时想。上面这个闭区间版本是我个人最推荐的。
如果你不需要返回下标,只想确认“有没有”,直接用标准库:
int arr[] = {1, 3, 5, 7, 9}; bool found = std::binary_search(arr, arr + 5, 5);注意std::binary_search的输入必须是有序的,否则结果是未定义的。可以用std::sort先排序,或者保证数据来源本身有序。
4.4 字符数组和字符串处理
C语言风格字符串就是字符数组的经典应用。"hello"这个字符串字面量在内存里实际上是6个字符:'h','e','l','l','o','\0',最后那个\0是字符串结束符,不算显示内容但占用空间。
char str1[] = "hello"; // 正确,长度是6(含'\0') char str2[6] = "hello"; // 正确,显式指定长度刚好容纳 char str3[] = {'h','i'}; // 错误!没有'\0',打印时会越界读到垃圾字符我早期写代码,定义char str[5] = "hello";直接编译就报错,因为没有给\0留位置。这类字符数组题目在C++笔试里出现频率很高,一定要记得那个隐形的结束符。
处理字符数组我一般建议:
- 如果只是存字符串、拼接、查找子串,直接用
std::string,不要碰裸字符数组。std::string会自动管理内存,也不用担心\0。 - 只有在调用C接口(比如文件操作、网络库函数)需要
const char*时,用str.c_str()转换。 - 如果真的用
char[],就用strcpy_s、strncpy这类带长度限制的危险函数替代方案,并确保\0被正确写入。
5. 把数组用在实际项目中:二维数组做棋盘游戏与图像处理的核心思路
很多学C++的同学在数组学完之后就不知道该往哪用了,尤其是想自己写个C++小游戏,却不知道怎么下手。这里用二维数组最常见、也最有直观感的应用——棋盘类游戏和图像处理——把前面的知识串起来。
5.1 棋盘游戏:一个二维数组就是一张完整地图
扫雷、五子棋、2048、俄罗斯方块,绝大多数棋盘游戏的核心数据,都是一张二维数组。拿五子棋举例:
const int BOARD_SIZE = 15; int board[BOARD_SIZE][BOARD_SIZE] = {}; // 0表示空,1表示黑棋,2表示白棋 // 落子 bool placePiece(int row, int col, int player) { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; // 越界 if (board[row][col] != 0) return false; // 已有棋子 board[row][col] = player; return true; } // 渲染棋盘到控制台 void drawBoard() { for (int i = 0; i < BOARD_SIZE; ++i) { for (int j = 0; j < BOARD_SIZE; ++j) { char ch = '.'; if (board[i][j] == 1) ch = 'O'; else if (board[i][j] == 2) ch = 'X'; std::cout << ch << " "; } std::cout << "\n"; } }棋盘的渲染逻辑就是双层循环遍历二维数组,再加上判断落子是否合法的边界检查。这就是一个完整小游戏的地图部分的雏形。妻子游戏地图用二维数组,地图规模大了之后自然要优化成“稀疏存储”(比如只存有棋子的位置),但这个起步思路是我们进入游戏开发的必经之路。
判断五子棋胜负的核心,实际上也是在二维数组上做连续方向的扫描:从落子点出发,检查水平、垂直、两条对角线四个方向是否有连续5个相同棋子。这就是典型的二维数组遍历加边界防护问题,看起来好像很复杂,拆到最底层还是“把相邻格子取出来,一格格比”。
5.2 图像处理:灰度图本质上就是一张二维数组
图像处理是二维数组的另一类典型应用。一张灰度图像素宽为W、高为H,用代码表示就是一个宽度为W、高度为H的二维数组,数组中每个值表示该像素点的亮度,通常0到255。彩色图像则可以用三个二维数组分别表示R、G、B通道,甚至用一个三维数组[H][W][3]表示。
举个简单的亮度调整算法:“提升图像亮度”本质就是给二维数组的每个值加一个增量,再限制在0到255之间:
void brighten(int image[][256], int rows, int delta) { for (int i = 0; i < rows; ++i) { for (int j = 0; j < 256; ++j) { image[i][j] += delta; if (image[i][j] > 255) image[i][j] = 255; if (image[i][j] < 0) image[i][j] = 0; } } }“图像反色”则是image[i][j] = 255 - image[i][j]。所以,二维数组的图像处理就是“双层循环 + 像素运算”。理解了这一点,你再看任何图像滤镜、模糊算法,都会发现它们的核心都只是数组的访问和运算次数不同、范围不同、计算方式不同。
5.3 大数组不要轻易放栈上:内存分配与性能的取舍
用二维数组做棋盘的规模通常不大,15x15的int数组只有900字节,随便放。但如果做一个1000x1000的二维数组,那就出问题了:
int bigGrid[1000][1000]; // 占 1000*1000*4 = 4MB 栈空间在Windows默认栈大小1MB、Linux默认8MB的环境下,4MB的局部数组很容易直接栈溢出。解决思路有几种:
- 声明为全局变量或静态变量(放入静态存储区)
- 在堆上动态分配:
int* grid = new int[1000 * 1000]; // 用完要delete[]- 用
std::vector自动管理:
std::vector<int> grid(1000 * 1000);其中vector是生产环境最简单的方案,它内部其实也是在堆上分配内存,同时帮你处理了析构释放。另一个性能相关的点:对二维数组做循环遍历时,尽量让内层循环沿“连续内存方向”走,这就回到第2.1节说的行优先布局——当你设计数据结构时,把访问最频繁、最内层循环的那个维度设计成连续维,性能收益会非常明显。
5.4 我踩过的一个真实“二维数组游戏”的坑
之前自己写过一个类似2048的控制台小游戏,核心棋盘是int board[4][4]。有个版本在移动和合并逻辑里,统计空白格子、生成新数字时,循环里写了这个边界:
for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { if (board[i][j + 1] == 0) { ... } // 当j=3时,越界! } }当j取到3时,board[i][4]访问的是下一行开头或栈上的其他数据,游戏偶尔会出现凭空多出一个数字或者移动逻辑错乱的情况。我当时排查了快两天,直到打印完整的内存布局才意识到是列越界。从那以后我所有棋盘类游戏代码的循环条件,都会在草稿纸上把下标范围重算一遍,或者在drawBoard里把每个格子的实际值打印出来对照。
如果你也正在写类似的小游戏,最推荐的调试方式就是:定义一个“可视化棋盘状态”的函数,每次操作后打印一遍二维数组。肉眼看到状态异常,比看一堆断点变量更快定位问题。这也是数组类逻辑bug排查最朴实但最有效的方法。
最后的经验小结
数组在C++里是地基中的地基。一维数组的连续内存模型、二维数组的“数组的数组”本质、传参时列数必须明确的底层原因——这三件事看起来简单,但把它们想透之后,看很多复杂代码都会轻松很多。遇到越界,先怀疑循环边界;遇到传参奇怪,先画内存布局;遇到性能问题,先检查内存访问顺序。这些习惯,都是我一次次调试踩出来的,也希望能帮你少走一些弯路。