☰
数组就是整齐的格子:从下标到内存的底层逻辑
2026/10/15 4:53:36 网站建设 项目流程

从第一次在教材上看到“数组”这个概念时,我就被那排画得整整齐齐的小格子吸引住了。每个格子里装一个数字,像宿舍楼里一排一模一样的储物柜,上面还贴着编号。当时我天真地以为,这不过就是把一堆变量排成了队。直到后来写代码写多了,被各种诡异的 bug 折腾过之后,才慢慢明白,“整齐”这两个字才是数组最值钱的地方。这篇内容就是围绕“数组——整齐的格子”这个主题展开的,我会从它到底解决了什么问题讲起,一直讲到那些最容易踩的坑。不管你是刚摸到编程门槛的小白,还是已经写了几个月代码、想回过头把基础再夯实一遍的同学,这篇都值得你花十几分钟读完。

很多教材讲数组直接甩概念、画内存图、上代码,但我更想先带你站在“设计者”的角度想一想:如果没有数组,这个世界会变成什么样?理解了这个问题,后面所有关于下标、内存、遍历的知识点都会变得顺理成章,根本不用死记。

1. 没有数组的时候,代码能有多拧巴

1.1 十个变量就把人逼疯了

想一个特别普通的场景:统计一个班 5 个同学的考试成绩。不用数组的话,你会怎么写?

int score1 = 78; int score2 = 85; int score3 = 92; int score4 = 67; int score5 = 88;

然后求平均分:

int sum = score1 + score2 + score3 + score4 + score5; double average = sum / 5.0;

写是能写,但你已经感觉到不对劲了。如果这个班有 50 个人呢?你就得写 50 行变量声明,求和的时候手指头得在键盘上敲半天。我当时试过一次,敲到第 20 几个变量名的时候脑子里就一片浆糊了,score23 和 score32 傻傻分不清楚。

更麻烦的事情还在后面。如果我想让程序自己算“全班有多少人及格了”,你用一长串if语句一个个判断,代码会膨胀得没法看。而且这些变量的名字是固定的,程序写死了,你想通过“循环”挨个处理它们?做不到。你得手写每一个变量名,这是最原始的“体力劳动”,一点技术含量都没有。

1.2 数组把“编号”变成了程序里的东西

数组是怎么解决这个问题的?它把那一堆零散的变量归拢成了一个整体——你只需要给它取一个名字,然后用“编号”去访问里面的任何一个元素。

还是刚才那个考试分数的例子,改写成数组:

int scores[5] = {78, 85, 92, 67, 88}; int sum = 0; for (int i = 0; i < 5; i++) { sum += scores[i]; } double average = sum / 5.0;

看到了吗?scores[i]这个写法里,i是一个变量,是可以变化的。循环跑一遍,i从 0 到 4,每个元素都被访问到了。这就是数组最本质的价值:把“编号”从人的习惯里“解放”出来,变成了程序可以计算、可以循环、可以动态变化的东西。

这跟储物柜是一个道理。没有编号的 50 个柜子,你取东西的时候得挨个打开看;有了编号之后,你说“取 32 号柜子里的东西”,一步就到。数组就是这个“带编号的储物柜群”,而编号在程序里就叫下标(或者叫索引,英文是 index)。

1.3 数组的三个基本特征,一次说清

顺着这个思路,数组的三个基本特征就很好理解了:

  1. 相同类型:一个格子里装的东西是一个类型的。整数数组里装的全是整数,字符数组里装的全是字符。为什么要这样?因为只有类型相同,每个格子占的空间大小才一样,这样才能用“柜子编号”精确算出某个格子的位置。生活里你可以把大箱子和小盒子混放在不同楼层,但在紧凑的数组世界里,统一规格是效率的基础。
  2. 连续存放:数组的元素在内存里是挨着的,一个接一个,中间不留空位。这也是“整齐的格子”最直接的体现。连续存放带来了一个巨大的好处——随机访问极快,这个后面展开说。
  3. 大小固定:在 C 这类传统语言里,数组一旦创建,它的长度就定死了。哪怕你只用了 3 个格子,另外 97 个格子也占着位置。这一点和后面要说的“增删操作”有关,也是个经典大坑。

类型相同、连续存放、大小固定,这三条合在一起,构成了“数组”的完整定义。接下来就可以聊聊那个让无数新手挠头的问题了:为什么下标从 0 开始?

2. 下标为什么从 0 开始,“整齐”背后的内存逻辑

2.1 下标是“偏移量”,不是“序号”

很多人在刚学数组时不理解:为什么第一个元素是arr[0],而不是arr[1]?从小到大数数,不都是从一开始吗?这个问题我在教一个学弟的时候被他问过,当时我愣了一下——因为我早就习惯了 0 起点,反而忘了第一次接触时那种别扭的感觉。

答案要从内存的视角去看。数组在内存里是一段连续的空间,比如int arr[5],如果这块空间的起始地址是 1000,每个int占 4 个字节,那么 5 个元素占据的内存地址就是:

  • arr[0]在地址 1000
  • arr[1]在地址 1004
  • arr[2]在地址 1008
  • arr[3]在地址 1012
  • arr[4]在地址 1016

规律很明显:第i个元素的地址 = 起始地址 + i × 每个元素的大小。用公式表达就是:

地址(arr[i]) = 起始地址 + i * sizeof(类型)

这里的i本质上不是“第几个”的序号,而是“离起点隔了多远”的偏移量。第一个元素就在起点上,离起点 0 步,所以它的下标是 0。这样计算机访问arr[i]的时候,只需要做一次“加法和乘法”,一步到位,没有任何多余计算。如果硬要下标从 1 开始,那地址公式就得写成:

地址(arr[i]) = 起始地址 + (i - 1) * sizeof(类型)

每次访问都得多算一次减法。别小看这一下,在几十亿次循环里,这白白浪费的运算量就很可观了。所以“从 0 开始”不是故意跟你作对,而是让“访问”这件事变得最直接、最省事。

2.2 随机访问为什么是 O(1)

我记得很清楚,第一次在数据结构课上听到“随机访问 O(1)”这个概念时,整个人是懵的。什么叫随机访问?不是“随便乱访问”的意思,而是说:不管数组有多大,只要给你一个下标,你都能立刻、直接、以同样的速度访问到那个元素。

还是刚才那个地址公式。你想访问arr[3],根本不需要从arr[0]开始,一个一个往后走三步,而是直接拿起始地址加上 3 × 4,算出来 1012,然后去 1012 这个地址读数据。整个过程就是一次算术运算加一次内存访问。

对比一下链表就明白了。链表是每个节点存着“下一个节点的地址”,你要访问第 10 个节点,必须从第一个节点开始,沿着“下一个”的箭头走 9 步。数据越多,走得越慢,这就是 O(n)。数组因为有“整齐连续”这个物理条件,不需要从头走,所以是 O(1)。这就是“整齐”的力量,它让你可以跳过中间所有元素,直达目标。

2.3 各种语言的下标差异,其实是同一个道理

学到后面你会发现,很多语言并不完全一样:

  • C、C++、Java 的数组下标从 0 开始,这个概念和历史、底层内存模型直接相关。
  • Python 的list下标也从 0 开始,但它还支持负数下标,比如arr[-1]是最后一个元素。这不代表“下标从 -1 开始”,它只是 Python 的一种语法糖,翻译一下就是arr[len(arr) - 1]。
  • Lua、MATLAB 这些语言的下标从 1 开始,它们的底层实现会帮你处理那个偏移的换算。

理解 0 起始的本质之后,无论你切到什么语言,只要看到“下标”,脑子里就应该浮现出“偏移量”三个字。这样到了新语言里,你只需要搞清楚它的语法怎么换算,核心逻辑始终是一样的。

3. 数组的基本操作:创建、遍历、修改与“界限感”

3.1 声明和初始化的几种姿势

数组的基本操作,说白了就是围绕着“格子”做文章:建格子、往格子里放东西、把格子里的东西拿出来看、换掉格子里的东西。先说怎么建。

在 C 语言里,声明一个数组最常见的有几种方式:

int arr1[5]; // 只声明不初始化,里面是随机值 int arr2[5] = {1, 2, 3, 4, 5}; // 完全初始化 int arr3[5] = {1, 2}; // 部分初始化,其余自动填 0 int arr4[] = {1, 2, 3, 4, 5}; // 编译器根据初始化列表自动推断长度

这里有个新手经常忽略的坑:int arr1[5];只分配了空间,并没有帮你把格子清干净。它里面存的是这块内存之前留下的旧数据,也就是常说的“垃圾值”。如果你没赋值就去读,读出来的可能是一个莫名其妙的数字。我在学习时有一次声明了一个数组没初始化就直接求和,结果算出了一个负几千的“成绩平均分”,排查了半天才发现是垃圾值在捣乱。所以声明数组后一定要初始化,或者在使用前把所有元素赋值一遍。

在 Java 里类似:

int[] arr = new int[5]; // 默认全 0 int[] arr2 = {1, 2, 3, 4, 5}; // 直接初始化

在 Python 里,平时用的list其实是一个变长数组,你可以这样创建:

arr = [1, 2, 3, 4, 5]

但要注意,Python 的list内部存的是“对象的引用”,虽然用法上像数组,底层跟 C 那种紧密排列的纯数据数组是有区别的。这个细节现在不懂没关系,先记住“不要把它们当成完全一样的东西”就行。

3.2 遍历:循环和数组是天作之合

数组最经典的搭档就是循环。可以这么说,数组的出现让循环有了用武之地,循环的存在让数组有了灵魂。

遍历就是“按顺序把每个元素访问一遍”,标准写法长这样:

int arr[5] = {10, 20, 30, 40, 50}; for (int i = 0; i < 5; i++) { printf("%d\n", arr[i]); }

如果你只想把每个元素过一遍,不在乎下标是多少,C++ 里可以用范围 for 循环:

for (int x : arr) { cout << x << endl; }

Python 里更是干净:

for x in arr: print(x)

但我想强调一点:很多新手写着写着,就只知道顺着 0 到 n-1 遍历一种写法了。实际上围绕“遍历”你能做的事情非常多:

  • 倒着遍历:for (int i = 4; i >= 0; i--)
  • 跳跃遍历:每隔一个取一个
  • 边遍历边统计:求和、求最大值、数出符合条件的个数

这些不是花哨的技巧,而是以后写各种算法题的底层基本功。我见过不少同学一看到“统计数组里有多少个元素大于 10”就卡住了,其实把它拆开来看就是“遍历 + if 判断 + 计数器”,三件事都很简单,组合起来才是问题。

一个完整的示例,求最大值和平均值:

int arr[5] = {78, 85, 92, 67, 88}; int max = arr[0]; // 先假定第一个是最大值 int sum = 0; for (int i = 0; i < 5; i++) { if (arr[i] > max) { max = arr[i]; } sum += arr[i]; } printf("最大值: %d, 平均值: %.2f\n", max, sum / 5.0);

max = arr[0]这个写法也值得琢磨一下。为什么不从 0 开始赋值?因为拿一个确定存在的元素做初始基准,比用一个随便拍脑袋的小数当起点要稳妥得多。这个“用第一个元素当基准”的思路,后面在很多算法里都会反复出现。

3.3 修改元素:可读可写,但要有“界限感”

数组里的元素不光能读,还能写:

int arr[3] = {1, 2, 3}; arr[0] = 100; // 把第 1 个格子里的 1 换成 100 arr[2] = arr[0] + arr[1]; // 用别的元素的值来赋值

这跟普通变量的赋值本质上没有区别,只不过你是在“通过下标”找到那个格子再操作。真正值得警惕的是“界限感”。

什么叫界限感?就是时刻记得一个数组的长度是多少,下标最大能到几。一个长度为 5 的数组,下标范围是 0~4,你写arr[5]就“越界”了。在 C 语言里,越界访问不会立刻报错,程序可能照样跑,但读到的或是隔壁别的变量的值,或者写进了一个不该动的内存区域,轻则数据错乱,重则程序直接崩溃。这种 bug 最恐怖的地方在于,它不一定每次都能复现,有时候运气好跑一天都没事。

我自己的习惯是:凡是访问数组下标的地方,动笔之前先在脑子里过一遍“这个下标最大可能是多少”,并且尽量在循环条件里留出余量。比如遍历就写成i < n,而不是i <= n。这看起来是个不起眼的习惯,但在大型项目里能帮你省下好几个通宵。

4. 数组的增删操作:为什么“整齐”也意味着“费力”

4.1 尾部插入和删除:唯一的轻松时刻

“数组大小是固定的”——那增删操作怎么办?这是刚学数组时最常让人困惑的问题。

首先明确一点:在 C 这类语言里,数组声明后,大小就不能变了。所谓“增删”,其实是用两种方式实现的:

  • 在逻辑上模拟增删:维护一个“当前有效长度”变量,假装数组变长变短了。
  • 在物理上新建一个更长的数组,把旧数据搬过去。

先说逻辑模拟。假设你声明了一个能装 100 个元素的数组,但当前只用了 5 个,那么用一个变量count记录“当前用了几个”,尾部添加元素就是:

int arr[100]; int count = 0; // 尾部添加一个元素 arr[count] = 6; count++;

尾部删除就是count--,虽然那个格子里的旧值还在,但按逻辑你不再“认为”它是有效数据了。这种方式又简单又快,时间复杂度是 O(1)。

4.2 中间插入和删除:搬家是逃不掉的

如果在中间位置插入元素,事情就没那么轻松了。原因是:数组要求连续、整齐,你不能在中间插一个空位,必须把从插入位置往后的所有元素,整体往后挪一格,给新元素腾地方。

比如数组[1, 2, 3, 4, 5],我要在下标 2 的位置插入 99:

原本:1 2 3 4 5 第一步:1 2 3 3 4 5 (把 3、4、5 往后挪) 第二步:1 2 99 3 4 5 (把 99 放到空出来的位置)

用代码实现就是:

int arr[6] = {1, 2, 3, 4, 5, 0}; int count = 5; int pos = 2; // 要在下标 2 的位置插入 int value = 99; // 从后往前挪,否则前面的会把后面的覆盖掉 for (int i = count; i > pos; i--) { arr[i] = arr[i - 1]; } arr[pos] = value; count++;

注意循环的方向:必须从后往前搬。如果从前往后,你会先把arr[3]的位置覆盖掉,后面的数据就找不到了。这是新手特别容易犯的错,我当年写成从前往后,调试了足足半小时才反应过来。

删除中间某个位置的元素也是一样的道理,只是方向反过来,往前挪覆盖掉要删的位置:

for (int i = pos; i < count - 1; i++) { arr[i] = arr[i + 1]; } count--;

这样一次插入或删除,最坏情况下要移动 n 个元素,时间复杂度是 O(n)。数组越“整齐”,这种搬运的代价就越明显。这也暗示了一件事:如果你的程序要频繁在中间插入删除,数组可能不是最好的选择,可以考虑链表之类的其他数据结构。但每次访问又比链表快,所以到底用哪个,取决于你的程序更常做哪件事。

4.3 容量耗尽怎么办:扩容的逻辑

当你用“逻辑长度”的方式往数组里塞元素,塞着塞着,count终于到了 100,数组满了,再塞就要溢出了。这时候有两种选择:

  1. 报错,告诉调用者“装满了”。
  2. 重新申请一个更大的数组(比如长度为 200),把所有元素搬过去,然后把原来的数组“丢弃”。

第二种就是动态扩容。很多语言里的ArrayList、vector、Python 的list,底层都是这个思路:用一个数组存数据,快满了就偷偷换一个更大的。

扩容的细节里有大学问:每次扩多少?如果每次只多扩一个,需要频繁复制数据,浪费大量时间。稳妥的做法是“倍增”,比如容量到了就说翻倍,这样平摊下来,每次插入的代价仍然是 O(1)。这就是“均摊复杂度”的思想,等你看懂这个,往后学动态数据结构会轻松很多。

不过作为初学者,你现在不需要自己写一个ArrayList,只需要理解:数组本身定长,但通过逻辑长度和新数组替换两次操作,程序可以假装自己有一个“无限长”的整齐格子群。

5. 二维数组:格子里再套格子,矩阵是怎么铺平的

5.1 为什么需要二维数组

表格长什么样?有行有列。比如一个 3 行 4 列的矩阵:

1 2 3 4 5 6 7 8 9 10 11 12

这种数据用一维数组也能存,但表述起来很拧巴,得费劲把“第几行第几列”换算成“第几个”。于是就有了二维数组——其实就是“数组的数组”,每个元素又是一个一维数组。声明方式:

int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} };

访问某个元素时,matrix[行下标][列下标]即可。比如matrix[1][2]就是第二行第三列,值是 7。这种“行列”的直觉表示,跟数学里的矩阵几乎完全对应,做图像处理、数值计算的时候会大量用到。

嵌套循环遍历二维数组是必须练熟的基本功:

for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%d ", matrix[i][j]); } printf("\n"); }

外层循环控制行,内层循环控制列,i 和 j 的分工一定要清楚。我见过很多人写二维遍历时把两个循环写反了,结果整个矩阵输出成了竖着的。这个不要紧,多调试几次就明白了,关键是明白每一层循环在管什么。

5.2 内存里其实是“一行一行铺开的”

抽象层面,二维数组是“行和列”的格子群;到了内存层面,可没有真正的二维结构,内存本质上是一维的长条。那二维数组怎么存放?

答案是行优先:先放第一行的所有元素,再放第二行,再放第三行。上面那个矩阵在内存里其实是这样的顺序:

1 2 3 4 5 6 7 8 9 10 11 12

为什么要知道这个?因为它解释了访问的两种方式。按行连续访问(比如一行一行地算总和),在内存里是顺序往前走的,CPU 有缓存帮忙,速度极快。如果反着来,一列一列地访问,每次都要跳一大段内存,效率会明显差一截。这一点在做高性能计算或图像遍历的时候尤其明显。

我本科的时候跑一个二维数组的求和程序,一开始按列遍历,数据量是 10000 × 10000,跑了快一秒钟。后来改成按行遍历,时间直接降到原来的零头。当时老师轻描淡写了一句:“缓存友好。”我后来才慢慢理解了这句话的分量。所以虽然你目前写的程序可能很小,体会不到这层差异,但把这个习惯刻在脑子里:遇到二维数组,优先按行写循环。

5.3 更高维的数组:三维甚至更多

有了二维,三维、四维也就顺理成章了。三维数组可以理解成“一本本二维表格叠在一起”,比如要表示一个立方体每个格子的温度数据,就可以用三维数组。声明方式差不多,只是多了一对括号:

int cube[2][3][4];

访问cube[x][y][z]的时候,逻辑也是嵌套的三层循环。说实话,超过三维的数组在业务代码里用得不频繁,但在科学计算、图像体数据、游戏地图(比如三维砖块世界)里都是基础。只要你把二维数组的“行优先”本质理解了,高维也只是按同样的套路继续铺平而已。

6. 数组在真实场景里到底怎么用:不是只能存成绩

6.1 查表法:用下标代替一堆 if 判断

数组最有实用价值的应用之一,就是“查表”。什么意思?把数据和它的“编号”对应起来,用下标直接得到结果,省掉一整串复杂的判断。

举个例子:星期几的中英文对照。假设输入一个数字 1~7,要输出对应的英文。不用数组你会写七个if,或者一个很长很长的switch。用数组就非常干净:

char *weekdays[] = { "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; int day = 3; printf("%s\n", weekdays[day]); // 输出 Wednesday

一个下标访问就把七个分支全替代了。类似的场景还有:月份的天数、星座的日期范围、游戏里道具的等级名称,甚至字符编码转换。写业务代码时,数组经常能帮你在写一堆分支之前停下来想一想:用下标能不能直接解答?很多情况下答案是能的,代码会简洁得让你自己都觉得意外。

6.2 计数与频次统计:数组当“计数器矩阵”

另一个常见套路是用数组做“计数器”,尤其适用于数据范围不大、要统计出现频率的场景。

比如有一个数组存放 0~9 的数字,你想统计每个数字出现了多少次。普通的双重循环会慢到没法看,而用数组做计数器是线性时间:

int data[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}; int count[10] = {0}; // 下标 0~9 分别统计对应数字 for (int i = 0; i < 11; i++) { count[data[i]]++; // 把数字当作下标 } for (int i = 0; i < 10; i++) { printf("%d 出现了 %d 次\n", i, count[i]); }

核心魔法在count[data[i]]++这一行:把数组元素的值直接当作另一个数组的下标使用。这个思路特别像往编号格子里投石子,数字 5 出现一次,就往 5 号格子里丢一颗石子。

这就是计数排序的雏形,也是大数据量下统计频率的经典手段。理解了这一招,以后再看到类似的“统计频率”题,你会比拿哈希表硬套的人多一种非常顺手的直觉。当然,前提是数据范围不要大得离谱,如果你有一个范围十几亿的整数,那就不能用这种数组方案了,直接上哈希表。

6.3 字符串与字符数组:绕不开的 C 语言关系

C 语言里没有真正的字符串类型,所谓字符串本质上就是一个以\0(空字符)结尾的字符数组。比如:

char str[] = "hello";

它在内存里实际上是'h' 'e' 'l' 'l' 'o' '\0'六个字符,最后那个\0是结束标记。理解这一点,你才会明白为什么用printf("%s", str)打印字符串时程序知道在哪儿停下来——它一直读到\0才停。

字符串很多操作也因此变成了“数组操作”。例如自己实现一个 strlen 统计长度:

int my_strlen(char s[]) { int len = 0; while (s[len] != '\0') { len++; } return len; }

又比如把字符串里的字母全部转成大写,本质上就是遍历字符数组并修改。这看起来基础,但很多高级语言里的字符串函数,底层思路都和字符数组遍历脱不开关系。学完之后你对“字符串是不可变类型”这类概念的理解也会更深一层:所谓不可变,往往只是因为底层数组长度固定,改不了,只能新建一个新的字符串实现“变化”。

6.4 数组做缓存:用空间换时间

还有一种非常有意思的用法:拿数组当“缓存”,把算过的结果存下来,避免重复计算。最经典的例子是斐波那契数列。如果只用递归:

int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }

能算,但 n 稍微大一点就慢得令人发指,因为同一个子问题会被反复计算无数遍。改进方案就是用数组把已经算出来的值存起来,后面要用时直接查:

long long fib[100] = {0, 1}; for (int i = 2; i < 100; i++) { fib[i] = fib[i - 1] + fib[i - 2]; }

一次性把整个数组从 0 算到 99,后面无论问哪一个数,都是 O(1) 的查表操作,比重复递归快出不知多少个量级。这就是“动态规划”在最简单形态下的样子。你现在先记住这个模式:数组存储中间结果,用空间换时间。等你以后学动态规划,一定会再次跟它重逢,而且到那个时候你会感到格外亲切。

7. 数组操作最容易翻车的五个现场

7.1 越界访问:你的程序不报错,不代表没出事

这是数组第一大坑,也是所有 C 系语言入门者都必须牢记的规则。访问arr[i]时,i超过了数组长度减一,就已经越界了。关键是有时候编译器根本不会提醒你,程序也能“正常”运行一段时间,直到某一天它破坏了别的重要数据,才突然崩溃。

我印象很深的一次:写了一个初学者常见的程序,要往数组的 0~4 下标存值,结果循环条件写成了i <= 5,把下标 5 也写了进去。当时程序跑得欢,没有任何报错,我还挺得意。没想到另一个函数里有几个变量,内存位置刚好在被写坏的地方附近,数值被悄悄改掉了,整个程序的结果一直不稳定。最后通过调试器单步跟踪,才发现在那个不起眼的越界赋值里。

从此我养成一个习惯:涉及数组下标的边界,一定要掰着指头数清楚。比如大小为 n 的数组,合法下标是 0 到 n-1,小于 n 的最后一个合法值就是arr[n-1]。写循环就写i < n,绝对不要写i <= n。

7.2 只声明不初始化的后果

数组在 C 语言中分配内存时,并不会替你擦掉那块内存里以前的旧数据。如果不初始化就拿来读,你得到的是垃圾值。在一次统计学生成绩的小程序里,我犯过这个错:数组声明后直接开始累加,忘记逐个赋初值,结果求出来一个超级离谱的总分。

所以强调一遍:要么声明时带上初值,要么在使用前用循环把所有元素赋一遍。比如:

int arr[10] = {0}; // 全 0 int arr[10] = {1, 2, 3}; // 前三个是 1、2、3,其余自动补 0

这看起来像 C 语言的语法细节,但对很多语言的数组初始化都有提醒意义:分配空间和赋初值,是两件独立的事情,不要默认它们一定会一起发生。

7.3 复制数组:别被“=”骗了

新手最容易踩的另一个坑,是把数组直接“赋值”给另一个数组,以为这样就复制了一份。比如:

int a[] = {1, 2, 3}; int b[3]; b = a; // 错误,C 里数组不能直接整体赋值

在 C 语言中,数组名一旦出现,在很多情况下会退化成指向首元素的指针。你写b = a,其实是想让 b 和 a”是同一个地址”,并不是复制内容。你得一个元素一个元素地拷贝:

for (int i = 0; i < 3; i++) { b[i] = a[i]; }

在 Java 里,int[] b = a;也是让 b 和 a 指向同一个数组对象,改 b 里的元素,a 也会跟着变。真要复制,得用Arrays.copyOf或System.arraycopy。Python 里也一样,b = a只是让 b 引用了同一个列表,深拷贝需要a.copy()或copy.deepcopy。

这个问题的本质是“引用”和“值”的区别。数组变量存的是数据的地址,而不是数据本身。你复制变量,复制的是地址;想复制数据,必须靠显式地遍历。

7.4 长度和容量傻傻分不清

很多语言里数组的“容量”(最多能装多少)和“长度”(当前用了多少)是两个概念。拿 C 语言举例子:

int arr[100]; // 容量 100 int count = 0; // 当前有效长度是 0

你用arr的时候,只要i < count的范围是“有效数据”,但没有一个内置机制告诉你count是多少。你需要手动维护它。如果别人不小心把count改错了,数组里就会出现“该读的数据没读到,不该读的垃圾值当有效数据在用”的状况。

而在 Java 里,数组的length是固定的性质,Python 里len(arr)动态跟随,就省心一些。但理解“容量 vs 长度”的区分仍然重要,因为很多底层数据结构正常工作的前提就是这个。下次再看到某一段代码里很别扭地同时维护一个size和一个capacity,你就明白它们在干什么了。

7.5 可变长数组的“骗局”与谨慎

C99 之后 C 语言支持了变长数组(VLA),即用变量声明数组长度:

int n; scanf("%d", &n); int arr[n]; // 变长数组

这个功能看着方便,但有一些陷阱。最大的问题是,变长数组的内存分配发生在栈上,栈空间一般是有上限的,一旦 n 非常大,比如几百兆,程序直接崩溃——这比堆内存分配失败更难排查。还有,变长数组不能用于全局变量,类型支持也有限制。

后来写的很多生产代码里,我基本还是用明确的常量长度,或者用动态内存分配(malloc)。不是说 VLA 不能碰,而是你需要清楚地知道自己为什么用它,也要随时警惕它带来的风险。工程上最怕的不是一个功能“有可能出问题”,而是你根本没意识到它有问题存在。

一个贯穿始终的小习惯

数组的内容写到这里,基本把“整齐的格子”这个比喻里里外外拆了一遍。最后分享一个我个人的习惯:无论是刷题还是写业务,只要代码里出现了数组,我就会先确认三个问题——数组长度是多少?当前下标范围是什么?循环结束之后,最后一次访问的会不会越界?这三个问题想清楚,数组相关的 bug 至少能少一大半。你如果刚开始学,不用急着一口气全记住,但至少记住那条最要紧的:访问之前想边界。这个习惯,越早养成越省钱。

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

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

立即咨询