前两天有位准备蓝桥杯的同学问我:数组这东西太基础了,还有必要单独花时间复习吗?我当时回了一句:你能在一分钟之内写出一个正确处理边界情况的一维前缀和模板吗?他愣了一下。这个问题其实击中了大多数备赛选手的痛点——数组谁都会用,但真正到了赛场上,因为数组出的问题偏偏是最多的。数组从来不只是C++语言里的一个语法点,它是几乎所有算法的载体。这章“数组”要解决的问题,不是让你学会a[10]怎么用,而是把数组相关的竞赛考点、性能陷阱和算法模板一次性梳理清楚。不管你是一堆题没刷的大一新生,还是卡在省赛线附近的冲刺选手,这章内容都值得你对照着练一遍。
1. 为什么数组是蓝桥杯的“隐形考点”
1.1 数据结构的基石:没有数组,大部分算法都无从落地
蓝桥杯的题单里,单独把“数组”作为标签的题目其实不多,但数组几乎渗透在每一道题里。模拟题需要用数组存牌、存坐标、存方向;贪心题需要标记数组记录哪些元素已经被访问过;搜索题的visited数组是标配;动态规划需要一个多维数组当作状态表;图论里邻接矩阵本身就是一个二维数组。可以这么说,数组是这些所有东西的地基,地基不牢,后面全部白搭。
我见过不少同学,思路完全正确,但代码提交上去总是报错。查到最后,问题根本不是算法设计,而是数组的下标越界、初始化遗漏、维度开小了。这两种问题在平时练习时还能慢慢调试,到了正式比赛的紧张环境下,几乎等于白送一道题。数组的核心价值有两个:一是高效随机访问,二是作为复杂数据结构的存储底座。理解这两点,你就知道为什么数组是算法竞赛的必修第一课。
这里打个比方:数组就像是打游戏时的背包格子。你所有的装备、道具都得往格子里放。格子开少了放不下,格子访问错了会拿到错误的东西,甚至可能直接把程序搞崩。学会数组,你才算真正打开了算法竞赛的大门。而蓝桥杯省赛的难度分布里,大约有三成题目是直接或者间接考察数组基本功的,这部分分数丢了非常可惜。
1.2 数组在内存中的秘密:理解底层才能理解性能差距
数组是唯一一个保证连续内存分配的数据结构。这带来三个重要的性质。第一个是随机访问,因为a[i]的地址可以通过首地址加偏移量直接算出来,也就是base + i * sizeof(元素),所以任何下标的访问操作时间复杂度都是O(1)。第二个是缓存友好,因为内存是连续的,CPU加载一个元素时会把相邻的元素一起读入高速缓存,按顺序遍历时缓存命中率很高。第三个是空间利用率高,相比链表,数组不需要额外的指针存储空间。
二维数组在内存里其实是按行优先存储的。比如int a[N][M],内存中先放第0行的M个元素,再放第1行的M个元素。所以a[i][j]在内存中的位置是base + (i * M + j) * 4。这就是为什么遍历二维数组时,外层循环行、内层循环列效率更高。如果你反着来,内存访问跨度变大,缓存命中率急剧下降,数据量大的时候性能差距能接近十倍。竞赛数据往往规模巨大,这种看似不起眼的遍历顺序,有时候就是超时与通过的差别。
再往深一层说,数组在栈上还是全局区也有讲究。栈空间通常只有几MB,你在函数内部定义一个int a[1000000]大概率直接爆栈。所以竞赛代码里,大数组必须开成全局变量。全局数组放在静态存储区,空间大得多,而且默认每一位都是0,省去手动初始化的麻烦。这个习惯要在平时就养成,不要等爆栈了才追悔莫及。
1.3 竞赛里数组开法的讲究:不是越大越好,但必须够大
数组开多大,是一个很实在的问题。开小了,越界访问直接RE;开大了,内存超限直接MLE。普通int数组每个元素占4字节,long long占8字节。估算内存的时候,用元素个数乘以单个元素大小就能得到。比如int a[1000000]就是4MB,int a[1000][1000]也是4MB。蓝桥杯常见的内存限制是256MB,理论上可以开6千多万的int数组,但实际代码里还有其他变量和栈空间占用,所以不要卡着上限开。
我的习惯是定义一个大常量做缓冲:const int MAXN = 100005;,表示最大数据规模加5。多加出来的这几个位置不是浪费,而是用来防止越界访问的缓冲区。很多题目的边界判断会出现访问a[i+1]或a[i-1]的情况,多开几位就能避免在最边缘处发生意外越界。这个技巧看起来微不足道,但在正式比赛里经常能救命。
另一个常见问题是下标从0开始还是从1开始。我个人强烈建议在算法题里统一用从1开始的下标。因为很多题目的区间表示都是1到n,从1开始可以让前缀和、差分、树状数组这些模板的下标直接对齐,不用来回做减一的转换。虽然会浪费a[0]这一个位置,但换来的代码简洁度和心智负担的降低,绝对值回票价。
2. 一维数组:定义、初始化与越界陷阱
2.1 四种初始化写法对比:memset没那么简单
一维数组的初始化看起来太简单了,但恰恰是这里藏着很多新手的坑。先看四种主流写法:
// 写法一:定义时赋初值 int a[10] = {0}; // 所有元素为0 int b[10] = {1, 2, 3}; // 后面的元素自动补0 // 写法二:memset按字节填充 memset(a, 0, sizeof(a)); // 正确,结果全0 memset(a, -1, sizeof(a)); // 正确,结果全-1 // 写法三:std::fill填充任意值 fill(a, a + 10, 1); // 所有元素为1 // 写法四:循环赋值 for (int i = 0; i < 10; i++) a[i] = 0;关键的坑在memset上。memset是按字节逐个填充的,对于int这种四字节类型,把每个字节都设为某个值。memset(a, 0, sizeof(a))的结果是全0,memset(a, -1, sizeof(a))的结果是全-1,这两个都没问题。但如果你写memset(a, 1, sizeof(a)),结果是每个字节是0x01,整个int就变成了0x01010101,换算成十进制是16843009,根本不是1。这是新手最容易踩的雷。
那么什么时候用memset,什么时候用fill?答案是:需要初始化为0或-1时用memset,因为效率极高;需要初始化为其他值时用fill或循环。另外,在算法题里经常需要把数组初始化为一个很大的数来表示“无穷大”,比如0x3f3f3f3f,这个值约等于10亿多一点,比1e9略大,但加起来不会溢出int,是竞赛里的经典选择。用memset(a, 0x3f, sizeof(a))就能实现,因为int的四个字节都是0x3f。
使用C++标准库时,vector a(n, 0)也很方便,还能动态扩容,但性能比原生数组差一些。竞赛追求极致效率,建议在关键路径上用原生数组,vector只用于不确定大小或者需要频繁增删的场景。
2.2 越界是最大隐患:本地跑得好好的,OJ上就崩
数组越界是未定义行为(UB)。在C++里,越界读可能返回一个垃圾值,越界写可能覆盖旁边变量的内存。最麻烦的是,你的电脑内存布局可能刚好没让程序崩溃,本地跑一万遍都没事,但OJ的评测环境内存布局不同,越界写覆盖了关键数据,答案就错了。更严重的直接触发段错误,程序崩溃。
看一个经典错误:
int a[1000000]; int main() { int n; cin >> n; for (int i = 1; i <= n; i++) { cin >> a[i]; } // 如果n大于1000000,直接越界 }边界条件处理错误更隐蔽。比如在i=0时访问a[i-1],相当于访问a[-1],这个地址完全非法。很多同学在写从某个位置开始回溯的算法时,忘了在开头加边界判断,结果就是莫名其妙的报错。我的建议是:涉及i-1、i+1、j-1这类越界风险的代码,先写一个if判断,再写业务逻辑。宁可多写一次条件判断,也不要赌评测数据的下限一定在安全范围内。
多一组数据没清空也是常见问题。题目如果要求处理多组测试用例,上一组的数组残留值会污染下一组的计算。正确做法是在每组数据开始前,对用到的数组重新初始化。这里还有一个技巧:如果数组原本就是全0的前缀和或差分,完全可以用memset(a, 0, sizeof(a))快速清空,不要用循环一个个赋值,浪费的时间在多次测试里会被放大。
2.3 数组作为函数参数:传进去的是指针,不是数组
数组名在表达式里会退化成指向首元素的指针。这意味着当你把数组传给函数时,函数内部拿到的只是一个地址,sizeof(a)的结果是8(64位系统下的指针大小),而不是数组占用的总字节数。很多新手在函数里写sizeof(a)/sizeof(a[0])企图获取数组长度,得到的结果完全错误。
解决办法有三条。第一条是在函数里额外传一个长度参数,这是竞赛里最通用的做法,比如void solve(int a[], int n)。第二条是使用模板,在编译期推导数组长度,适合C++时可用。第三条是直接使用std::array或者std::vector,它们自带.size()方法,但性能和底层逻辑不如原生数组直观,竞赛里需要权衡。
二维数组作为函数参数时规则更严格,第二维的大小必须显式指定。比如void solve(int a[][10])表示传入一个列数为10的二维数组,第二维是10。如果你写成void solve(int a[][]),编译器直接报错,因为它无法确定每行的步长。如果第二维是变量,那就更麻烦了,只能用int*指针自己算偏移,或者使用vector<vector >。
3. 二维数组与字符数组专场
3.1 二维数组的三个高频出场场景
二维数组在蓝桥杯里出场率极高,常见场景有三个。第一个是矩阵运算,比如矩阵乘法、转置、蛇形填数、螺旋矩阵。第二个是邻接矩阵,用于存稠密图的边权,g[u][v]表示从u到v的边权,写起来简单直接。第三个是动态规划的状态表,比如最长公共子序列的dp[i][j]表示第一个字符串前i个字符和第二个字符串前j个字符的最优解。
蛇形填数是一道很经典的入门题,它考的就是二维数组坐标的更新逻辑。核心思路是:初始位置在右上角,方向依次向下、向左、向上、向右循环,遇到边界或者已经填过的格子就转弯。用通俗的话说,就像一条蛇在方格里游走,不能撞墙也不能踩到自己的身体。这类题如果你用if-else硬写,代码会很冗长且容易错。正确做法是把方向变化写进数组里,用方向变量控制移动,每走一步检查下一步是否合法。
3.2 方向数组:把移动写进数组里,代码瞬间清爽
方向数组是搜索题必备技巧,也是二维数组操作中最重要的抽象。上下左右四个方向可以这样定义:
int dx[4] = {1, -1, 0, 0}; int dy[4] = {0, 0, 1, -1}; // dx, dy一一对应,组合起来就是四个方向向量比如dx[0]=1, dy[0]=0,表示向下走一步;(dx[1]=-1, dy[1]=0)表示向上;(dx[2]=0, dy[2]=1)表示向右;(dx[3]=0, dy[3]=-1)表示向左。如果题目要求八个方向,就在后面加四个对角线方向。
有了方向数组,移动代码变成一条循环:
int nx = x + dx[i]; int ny = y + dy[i]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (visited[nx][ny]) continue; // 执行移动这样就不用写四个方向的 if 判断了,代码结构清爽很多,也不容易漏掉某个方向。这个思想的核心是把“移动”这一行为数据化,用数组和循环驱动逻辑。蓝桥杯的迷宫题、地图题、搜索题里,方向数组几乎是无脑必用的技巧。
还要提一个细节:方向数组的四个方向顺序是有讲究的。如果你需要保证搜索顺序是“上、右、下、左”,就把对应的方向向量按照这个顺序放进数组。很多题目的输出顺序依赖搜索方向,如果你随便排,答案可能和标准输出不一致,导致WA。
3.3 字符数组与字符串:小心藏在最后的'\0'
C风格字符数组和字符串是密不可分的。char s[100]定义一个字符数组,但如果你把它当作字符串用,就必须保证以'\0'结尾。'\0'是ASCII码为0的字符,表示字符串结束。scanf("%s", s)会自动在末尾补上'\0',但如果你自己逐字符赋值,就一定要手动在末尾加上s[i] = '\0',否则后续的strlen、printf("%s")都会一直往后读到野地址,产生随机输出。
读入字符串时,scanf("%s")遇到空格、换行就会停止,所以它读不了带空格的整行。这时要用cin.getline(s, 100)读入一行,最多读99个字符,因为最后一位要留给'\0'。在C++里更推荐直接用std::string,用getline(cin, str)读取一行,用str.size()获取长度,用+拼接字符串,用str.substr(start, len)取子串,总体上比字符数组安全很多,也不需要关心结束符。
字符数组也有关键优势:访问常数级快、内存利用率高、不需要析构负担,在大量字符串排序、哈希的场景下,char数组配合strcmp和strcpy的性能明显优于string。常见字符串函数的坑有两个。第一个是strcmp的返回值不是简单的0或1,而是负数、零、正数,分别表示小于、等于、大于,千万别当作布尔值用。第二个是strcpy不检查目标数组大小,容易造成缓冲区溢出,更稳妥的做法是strncpy并指定最大复制长度。顺便说一句,gets函数因为无法限制输入长度已经被标准库移除了,别再用。
4. 数组上的算法模板:从暴力到高效
4.1 前缀和:把区间查询变成O(1)的经典套路
前缀和的本质是预处理,用一个新数组s[i]存储原数组前i个元素的和。定义s[0]=0,递推式是s[i] = s[i-1] + a[i]。有了前缀和数组以后,查询区间[l, r]的和只需要做一次减法:s[r] - s[l-1]。为什么能这样算?因为s[r]是a[1]到a[r]的和,s[l-1]是a[1]到a[l-1]的和,两者相减,中间重叠的部分全部抵消,剩下的正好是a[l]到a[r]。整个过程把每次查询从O(n)的暴力遍历优化到了O(1),查询次数多了以后效率提升非常明显。
一维前缀和的模板代码:
const int MAXN = 100005; int a[MAXN], s[MAXN]; int main() { int n, m; cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> a[i]; s[i] = s[i - 1] + a[i]; // 边读边构建前缀和 } while (m--) { int l, r; cin >> l >> r; cout << s[r] - s[l - 1] << endl; } return 0; }二维前缀和稍微复杂一点,但核心也是容斥原理。设s[i][j]表示从(1,1)到(i,j)这个子矩阵的所有元素和,递推公式是s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]。查询左上角(x1,y1)到右下角(x2,y2)的矩阵和时,用s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1],加回被重复减去的左上角部分,这就是二维容斥。前缀和的难点在于理解容斥思想,代码本身非常固定,背熟模板就能套用大量题目。
4.2 差分数组:区间修改的离线神器
差分数组与前缀和是一对互逆操作。定义差分数组d[i] = a[i] - a[i-1],其中d[1] = a[1]。对差分数组求前缀和,就能还原出原数组。差分数组的核心技巧是:要在原数组的某个区间[l, r]上统一加x,只需要在差分数组上做两次更新,d[l] += x,d[r+1] -= x,最后求一次前缀和,所有区间修改就都生效了。这个操作把区间修改从O(n)降到了O(1)。
这样的场景在蓝桥杯里非常多见。比如有一列N个座位的公交车,给出M个区间上车人数的信息,最后要求每个座位上了多少人。最朴素的做法是每给一个区间就循环累加一次,M个区间最坏是O(N*M)的复杂度,数据一大会直接超时。用差分数组,每次只需要改两个位置,最后一次性前缀和还原,总复杂度降为O(N+M),性能提升是质的飞跃。
差分模板代码:
const int MAXN = 100005; int d[MAXN]; int main() { int n, m; cin >> n >> m; while (m--) { int l, r, x; cin >> l >> r >> x; d[l] += x; d[r + 1] -= x; } int cur = 0; for (int i = 1; i <= n; i++) { cur += d[i]; // 相当于对差分数组做前缀和 cout << cur << (i == n ? "\n" : " "); } return 0; }这里有几点经验。第一,差分数组是离线的,也就是说它适合“先做一堆修改、最后统一查结果”的场景。如果修改和查询交替出现,差分数组就不合适,需要线段树或树状数组出马了。第二,要注意区间边界在端点处的处理,d[r+1]可能会用到数组的最后一个位置的下一位,所以数组要多开一位,防止越界。第三,负数的差分处理同样有效,因为加法和减法是完全对称的。
4.3 双指针与滑动窗口:数组上的线性时间魔法
数组的连续存储特性让双指针技术有了发挥空间。双指针分三种常见模式。第一种是快慢指针,常用于原地去重、链表成环检测等场景。第二种是左右指针,适用于有序数组的查找,比如两数之和。第三种是滑动窗口,维护一个区间的最优解。
有序数组去重的经典写法是快慢指针。慢指针j指向不重复数组的结尾,快指针i从头到尾遍历。因为数组有序,重复元素一定相邻,所以每次nums[i]和nums[j]不同,就把元素搬到j+1的位置:
int removeDuplicates(vector<int>& nums) { if (nums.empty()) return 0; int j = 0; for (int i = 1; i < nums.size(); i++) { if (nums[i] != nums[j]) { nums[++j] = nums[i]; } } return j + 1; }滑动窗口的核心是维护一个区间[left, right],通过移动右端点扩展窗口,通过移动左端点收缩窗口,让窗口始终满足题目要求。比如寻找最长无重复字符子串,窗口内不允许有重复字符。当右端点遇到重复字符时,左端点就不断右移,直到窗口中不再包含重复字符。每移动一次右端点,就统计一次当前窗口长度,记录最大值。这个模板的复杂度是O(n),因为左右指针都只向一个方向移动,不会回溯。
双指针能用的前提是“单调性”。数组必须有序,或者窗口的扩张和收缩必须具有明确的单向性。一旦指针的回退方向不确定,双指针就会退化甚至出错。所以用双指针前先问自己一句:右指针向右移动时,左指针的移动方向是唯一的吗?如果答案是否定的,那这道题大概率需要其他方法。
5. 我踩过的数组相关坑位:常见问题与排查实录
5.1 数组开了MAXN却还是RE或WA?先查这四件事
当你的代码提交后返回运行错误(RE)或答案错误(WA),而且你确信算法思路没问题时,八成问题出在数组上。按顺序排查这四个地方。
第一个是数组大小是否符合题目数据上限。有些题目数据范围写的是n <= 10^5,但实际测试数据可能恰好卡在边界上,数组开成100005还是100000的差别,在极端情况下就会越界。我的建议是把MAXN定义为上限加5或加10,不要卡得那么死。
第二个是下标起点是否统一。如果你一会儿用从1开始的下标,一会儿用从0开始的下标,最直接的后果是前缀和、差分的计算结果整体偏移,答案全错。检查方法是看for循环的起点和数组访问是否一致。
第三个是初始化是否遗漏。多组数据测试时,上一组留下的状态会污染新一组的结果。尤其是标记数组visited、计数数组cnt、前缀和数组,每组数据开始前必须清空。排名靠前的线上赛经常用多组数据来卡不及时清空的选手。
第四个是字符数组的终止符。用char数组存字符串时,每次手动构造字符串后没有补'\0',或者strcpy把数据写到数组末尾之外,都会产生不可预期的结果。检查相关代码,确保所有手动构造的字符串都以s[len]='\0'结尾。
5.2 memset把它初始化为0x3f3f3f3f的真实原因
竞赛代码里频繁出现0x3f3f3f3f,这个值到底是什么?它等于十进制约1.06e9,比1e9略大,是int正数范围内不算太大的一个数值。用memset(a, 0x3f, sizeof(a))初始化后,每个int都变成0x3f3f3f3f。这个值有两个好处:一是足够大,可以当作“无穷大”用于求最短路径的最小值初始比较;二是且不会溢出,两个0x3f3f3f3f相加约2.1e9,还在int范围内。如果你用0x7fffffff(int最大值)做无穷大,两个数一加直接变成负数,整个算法全崩。这就是为什么不用更大值的原因。
另一个相关技巧是判断是否访问过某点时,可以直接用a[i] == 0x3f3f3f3f来判断“尚未更新”,省去单独设计visited数组。很多最短路径、动态规划的模板都依赖这个约定,认准0x3f3f3f3f,你在读别人代码时会顺畅很多。
5.3 二维数组行列写反时的排查方法
二维数组a[n][m],n是行数,m是列数。访问a[i][j]时,i的范围是0到n-1,j的范围是0到m-1。行列写反是特别容易犯的低级错误,而且是隐性的,因为只要i和j的取值范围恰好都能落在各自的合法区间内,编译器不会报错,但计算结果完全错误。
我的排查方法是:在关键节点把打印变量的代码加上“坐标”信息,例如printf("i=%d j=%d a[i][j]=%d\n", i, j, a[i][j]),然后构造一个3行4列的小数据,手动模拟一遍结果,对比实际输出。如果发现a[1][2]和a[2][1]的值对调,那说明行列搞反了。另外,养成命名习惯可以有效减少这类错误。比如用int rowCount和int colCount代替r和c,用grid[x][y]代替不好分辨的a[i][j]。清晰的名字在调试时能省下大量时间。
5.4 空间复杂度估算速查表
算法题里经常遇到“明明答案对但提交MLE”的情况。这时候你需要快速估算自己开了多少内存。下面这张表是常用数组的内存占用速查,直接背下来,临场不慌:
| 数组声明 | 占用内存 |
|---|---|
| int a[1000] | 约4KB |
| long long a[1000000] | 约8MB |
| int a[1000000] | 约4MB |
| int a[1000][1000] | 约4MB |
| char a[1000000] | 约1MB |
| bool visited[1000000] | 约1MB |
| int a[5000][5000] | 约100MB |
注意,bool虽然理论上只占1字节,但在某些编译器实现里一个bool数组元素可能占用实际1个字节。如果需要极致的空间节省,可以用bitset或者在char数组里存0和1。千万别以为bool只占1位,那是误区。平时练习时养成查空间复杂度的习惯,提交前先算一遍内存占用,MLE的问题就能避免一大半。
5.5 调试数组的五个硬核技巧
调试数组问题和调试普通变量不一样,你需要看到整片数据的分布规律。这里分享五个实战技巧。
第一个是“打印法”。用小数据量把整个数组打出来,观察排列是否符合预期。注意在调试完输出删除前,先确认这些输出不会影响数据读取格式。
第二个是“边界法”。每次测试多组小边界数据,比如n=0的空数组、n=1的单元素数组、n=2的最短情况。数组越界问题在边界处最容易暴露。
第三个是“断言法”。在关键位置插入assert(idx >= 0 && idx < MAXN)。如果越界发生,程序会立刻崩溃并提示具体行号,帮你快速定位。
第四个是“对拍法”。写一个朴素暴力的版本和你优化后的版本,用随机小数据反复对比输出。数组下标和初始化问题会随着数据量的增加高频暴露。
第五个是“手动模拟法”。拿一张草稿纸,画出数组的格子,自己一步步模拟代码的运行过程。这个方法看似笨拙,但找出隐藏边界问题非常有效,尤其是二维数组。
个人体会:数组这一关,稳住了就赢了一半
说实话,数组这一章是蓝桥杯备赛里最容易被忽视、又最能拉开差距的部分。很多人觉得它简单,跳过去直接刷贪心和动态规划,结果每次比赛都在数组上翻车。我个人的习惯是:每次上机做题之前,先花30秒想清楚三个问题——这个数组最大要开多大,下标从0开始还是从1开始,需不需要初始化。这三个问题想清楚,足以省下30分钟的调试时间。最后再分享一个小技巧:写完代码提交前,把数组大小临时改成题目数据范围的上限,本地试跑一次,如果没有崩,说明你的边界基本安全。把这个习惯保持下去,数组这一关就稳了。