
1. 先把数组这层窗户纸捅破它到底解决了什么问题很多刚接触编程的朋友会问一个问题为什么非要学数组我定义十个变量不行吗就算要存100个学生的成绩我写100个变量也总归能存下来吧。能存下来但你得先接受两件事代码量爆炸以及你根本无法对它们做批量操作。你想象一下要写一个求100个学生平均分的功能如果变量叫score1、score2……score100那求和的代码你得手写100行。更可怕的是如果学生人数是动态的这100个变量方案直接报废。数组之所以被所有语言都保留下来核心就一个原因它把“一批同类型数据”打包成一个整体让你能用下标去访问、能用循环去遍历、能作为参数传给函数。数组解决的问题本质上是对“批量同类型数据”的高效管理。你手里有1000个数要排序500个坐标要处理一批字符串要存起来这些都是数组的典型场景。学数组就是学编程的“第一道门槛”跨过去之后循环、函数、指针、算法全都能串起来了。这篇内容适合刚学编程一到两个月、还在跟变量和循环较劲的初学者也适合那些已经有点基础但想回头把数组细节彻底弄明白的人。数组这个概念说穿了就是一个“编号的盒子集合”。每个盒子能放一个数据所有盒子在同一片连续内存里排排坐每个盒子都有一个唯一编号——也就是下标。你在编程里写的任何一个数组背后都是这么个结构。搞懂这个最简单的结构后面什么二维数组、指针数组、动态数组全都能顺藤摸瓜学会。2. 数组的声明与初始化从规则到踩坑2.1 各种主流语言的初始化姿势数组的声明和初始化是每个初学者的第一道坎因为不同语言的语法差异太大了。我接触过不少语言给你梳理一下最常见的几种C语言的经典写法int arr[5]; // 声明一个长度为5的整型数组 int arr2[5] {1, 2, 3, 4, 5}; // 声明并初始化 int arr3[] {1, 2, 3}; // 省略长度编译器根据初值自动推导为3 int arr4[5] {0}; // 全部初始化为0Java的写法int[] arr new int[5]; // 创建长度为5的数组默认值全为0 int[] arr2 {1, 2, 3, 4, 5}; // 声明并初始化 int[] arr3 new int[]{1, 2, 3}; // 通过匿名数组初始化Python的写法——严格来说Python里叫列表但很多场景下充当数组用arr [0] * 5 # 创建长度为5的列表全是0 arr2 [1, 2, 3, 4, 5] # 直接初始化 arr3 list(range(5)) # 生成0到4的列表JavaScript的写法let arr new Array(5); // 创建长度为5的空数组 let arr2 [1, 2, 3, 4, 5]; // 推荐直接量写法 let arr3 Array.from({length: 5}, () 0); // 填0有个经验是数组初始化的核心就两个问题——长度怎么定、初始值是什么。语言语法千差万别但底层逻辑一样。C语言里如果不初始化数组里装的是不确定的“垃圾值”这在调试时最容易栽跟头。Java和C#这种托管语言会自动给默认值数值型是0、布尔型是false、引用型是null反而省心一些。2.2 数组长度怎么算才对sizeof、strlen与length的区别这个东西是C语言初学者问得最多的问题也是热搜里“数组长度计算”这个词的真正痛点。我在带新人的时候几乎每届都会有人写出这种代码char str[] hello; int len sizeof(str); // 猜猜这个是多少结果是6不是5。因为字符串“hello”本质上是一个字符数组末尾还藏着一个字符串结束符\0sizeof把它也算进去了。如果你要的是有效字符个数得用strlen(str)返回5。我见过不少新手在函数里传数组算长度然后翻车void print_len(int arr[]) { int len sizeof(arr) / sizeof(arr[0]); // 这样是错的 }这里arr是函数的形参它已经退化成指针了sizeof(arr)拿到的是一个指针的大小64位系统上是8字节而不是整个数组的大小。所以在函数内拿到的长度是18除以4如果int是4字节。想要正确算长度必须在传参时把长度一并传进来。这个知识点我用一句话给新人讲清楚sizeof关心的是“内存里占了多大地方”strlen关心的是“到第一个\0为止有多少字符”。数组作为参数传给函数时走的是“引用”而非“复制”所以长度信息会丢失。别嫌啰嗦这个坑值得每个写C/C的人提前知道否则排查起来非常痛苦。2.3 下标是数组的灵魂越界是数组的坟数组从0开始计数这件事让很多人一开始不习惯。你定义int arr[5]有效下标是0、1、2、3、4。但很多初学的人会下意识写arr[5]这在C语言里不会报错但访问的是“数组最后一个元素后面那块内存”——轻则读到垃圾数据重则直接崩溃。这类问题我统称为“越界访问”排查起来其实还挺费劲。举个例子我用C写过一段循环给数组赋值int arr[10]; for (int i 0; i 10; i) { // 应该用 10 arr[i] i; }最后循环执行了11次第11次写到了arr[10]——那是数组外面的内存。在嵌入式开发里这种越界写可能会把相邻变量给覆盖掉我以前排查过一个问题一个变量的值总是莫名其妙被改根源就是另一个数组越界写了一位。数组下标是“从0到长度减1”这句话听起来简单但实操中总有人写错边缘条件。还有个容易犯晕的点是“数组长度”和“最后一个下标”的区别。数组长度是元素个数最后一个有效下标是长度减1。这种细节必须形成肌肉记忆写循环的时候尤其要小心。我的建议是能用范围遍历的语言比如Python的for x in arrJava的增强for尽量用减少手动下标访问的机会降低出错率。3. 数组与指针的相爱相杀3.1 指针数组每个元素都是一个指针先澄清一个高频混淆点指针数组和数组指针光看名字就劝退了不少人。其实拆开看没那么吓人。指针数组本质是个数组只是每个元素装的是指针。如果数组里装的是字符串那很自然地就能想到用指针数组来存一批字符串char *names[] {Alice, Bob, Charlie};这个names是一个数组它有三个元素每个元素都是一个char *类型的指针分别指向三个字符串常量。用指针数组的好处是每个字符串长度可以不一样因为存的是“指向字符串的指针”这比定义二维字符数组更省空间。要是用char names[3][20]来存不管字符串多短每行都得占20个字节而指针数组只有三个指针实际字符串占多少算多少。指针数组的选择逻辑其实特别符合生活经验你有一个通讯录里面有几十个联系人每个人的名字长短不一。你肯定希望按实际长度存储而不是每人名字都按最长的那位预留空间。这就是指针数组的价值。3.2 数组指针指向数组的指针数组指针反过来它是一个指针指向的目标是一个数组整体。定义方式比较冷门int (*p)[5]; // p是一个指针指向含有5个int元素的数组你注意括号绝对不能省。写成int *p[5]就变成了指针数组——5个int指针组成的数组。就多一个括号含义天差地别。我在面试中常拿这个考察候选人对指针和数组的理解深度不夸张地说有五年经验的开发者也有人会愣一下。数组指针最典型的应用场景是二维数组的函数传参。你如果写了一个函数要接收int arr[3][4]实际上可以用数组指针来声明形参void process(int (*arr)[4], int rows) { // arr[i][j] 就是第i行第j列 }这里的arr指向“每一行”这个整体每行是一个长度为4的int数组。理解了数组指针二维数组在函数间的传递就不会再晕了。3.3 函数传参时数组为什么会“退化”这个知识点属于必须掌握的“内功”。C语言里把一个数组传给函数时数组名会自动退化成一个指向首元素的指针。数组名在表达式中基本就等价于arr[0]。这就是为什么在函数内部拿不到数组长度的根源。这次退化的好处是传参效率高——不需要复制整个数组只传一个地址就行。坏处就是如果你指望函数内部能通过“sizeof(arr)/sizeof(arr[0])”算长度得到的永远是错的。那怎么办两个方案一是在调用前把长度算好传进去二是用结构体把数组和长度包在一起。C里更推荐用std::array或std::vector它们自带size()方法从根本上杜绝了退化问题。Java、Python、JavaScript这些语言里数组本身就是对象天然知道自己多长所以不存在这个烦恼。但这也带来另一个问题这些语言里数组传参传的是引用你在函数里改了数组元素外面的数组也会跟着变。很多从C转Java的同事第一次被这个坑到以为传进去的是副本。4. 二维数组与多维数组从表格到矩阵4.1 二维数组的内存布局与遍历顺序二维数组听起来高级其实就是一个“数组的数组”。比如int matrix[3][4]你可以理解为有一个长度为3的数组每个元素又是一个长度为4的整型数组。它在内存里依然是连续存放的——先是第0行的4个元素接着第1行的4个元素再接着第2行行与行首尾相接。所以遍历二维数组时按行遍历效率更高因为内存是连续访问的。C语言的二维数组是“行优先”存储而某些数值计算语言比如MATLAB是列优先。实操中我见过有人写多层循环时把行列顺序搞反因为数组太大导致缓存命中率下降性能慢了近一倍。这个在初学者阶段影响不大但如果你以后做图像处理或矩阵运算这个细节会直接变成性能瓶颈。二维数组在生活里就是一张表表头是列行是记录。比如一个班级的成绩表行是学生列是科目用matrix[student][subject]访问任何人的任何科目成绩非常直观。热搜词里那个“matlab数组取出多列”其实就是二维数组的切片提取操作后面细说。4.2 字符数组与字符串数组家族里最特殊的成员C语言没有真正的字符串类型字符串就是用字符数组实现的。char str[] hello;背后的逻辑是自动创建一个长度为6的字符数组最后一位是\0。没有这个\0printf(%s, str)就无法判断字符串在哪儿结束会一路打印出内存里的垃圾数据直到某个字节恰好是0。这个\0就是C字符串的“终止符”也是新手最容易漏的东西。我记得曾经让学生写一个字符串拼接函数有个学生直接用strcat去拼结果源字符串和目标字符串互相覆盖调试了半天才发现是目标空间不够\0被挤掉了。字符串长度、数组容量、\0占位三者的关系一定要拎清楚。C里的std::string、Java的String、Python的str都帮你处理了底层细节但理解底层实现仍然有价值。热搜里那个“数组转字符串”就有典型应用Java里可以用String.joinPython里用.join(list)JavaScript用array.join()。这些语法层面的花活基础都是“字符串本质是字符数组”这条认知。还有个使用频率极高的场景是“数组分割并显示包含某一字符”——比如在一堆用户名里筛出所有包含“张”的名字。用Python写就是一行列表推导式本质上就是遍历数组、逐项判断。你能熟练操作字符数组这类需求都能顺手解决。5. 动态数组从固定到可变5.1 为什么需要动态数组C语言里int arr[10]这种数组长度在编译时就定死了运行时没法改。但实际场景中用户输入多少数据你事先不知道——读文件读到多少行、网络包来了多少字节都是运行时才确定的。这个问题催生了动态数组。动态数组的核心思路是先分配一块内存不够了再重新分配一块更大的把旧数据搬过去释放旧内存。这个“满了就扩容”的动作在C语言里你得自己写但高级语言都已经封装好了。Java的ArrayList、C的std::vector、Python的list底层逻辑都是这个。我最常打的一个比方固定长度数组就像买定了一个固定座位数的房间人多了坐不下动态数组是那种可伸缩的会议室来多少人加多少椅子但加椅子的动作是有成本的——需要重新布置。所以预估扩容次数少的应用直接指定初始容量能省不少性能开销。5.2 各语言的动态数组实操对比C语言手动实现动态数组int *arr malloc(4 * sizeof(int)); // 初始容量4 if (arr NULL) { // 内存分配失败处理 return -1; } int size 0, capacity 4; // 添加元素 if (size capacity) { capacity * 2; // 翻倍扩容 int *new_arr realloc(arr, capacity * sizeof(int)); if (new_arr ! NULL) { arr new_arr; } } arr[size] 42; // 记得最后 free(arr)C标准库std::vectorint v; v.push_back(42); v.size(); // 当前元素个数 v.capacity(); // 当前容量 v.reserve(100); // 预先分配100个元素的空间Python的列表天然动态还能存不同类型arr [] arr.append(42) arr.append(hello) # 甚至可以混合类型Java的ArrayList是引用类型ArrayListInteger list new ArrayList(); list.add(42); list.size();翻倍扩容策略背后的数学很有意思每次扩容×2均摊下来每次追加操作的时间复杂度是O(1)。如果每次只扩大一个元素的空间那追加n个元素的总代价就变成了O(n²)。这也是为什么动态数组在扩容时普遍选择翻倍而不是1的原因别小看这个选择它直接决定了你在大量追加数据时会不会卡到怀疑人生。6. 数组的高频应用排序、去重、切片6.1 数组排序的常规写法和性能考量热度词里的“js数组排序的几种方法”说明这是一个被反复搜索的高频需求。每个语言都内置了排序但细节差异很大JavaScript的Array.prototype.sort()不传参数时按字典序排——数字10会排在2前面。这个坑我踩过好多次正确写法是传一个比较函数let arr [3, 1, 10, 2]; arr.sort((a, b) a - b); // 升序 // 结果是 [1, 2, 3, 10]Python的sorted和list.sort()则没有这个问题数字就是按数值排。C语言要自己调用qsortint cmp(const void *a, const void *b) { return *(int *)a - *(int *)b; } qsort(arr, n, sizeof(int), cmp);关于排序稳定性补充一句稳定排序保留相等元素的原始前后顺序不稳定排序不保证。归并排序是稳定的快排通常不稳定。如果你的数据后面还要按其他字段二次排序稳定与否会影响结果。实际工程中我更倾向于让语言内置排序处理因为它们的实现基本都是高度优化的混合算法比如TimSort比你自己写的快。6.2 数组去重的几种方案对比热度词“数组去重”也是高频搜索项。去重本质上是“判断一个元素是否出现过”方案依数据规模而异。第一种是暴力两层循环内层判断当前元素是否已存在于前面。写法最简单但时间复杂度O(n²)数据量一大就完蛋。第二种是用哈希表或者叫Set/字典遍历一遍用哈希表记录已经出现的元素。Python和JavaScript里都有原生的Setunique list(set(arr)) # Python一行去重let unique [...new Set(arr)]; // JS一行去重这个方案时间复杂度是O(n)。但注意Set去重后顺序可能改变如果要求保留首次出现的顺序就得用“Set 遍历”的组合写法而不是直接set(arr)了事。第三种是排序后去重先排序相同的元素挨在一起再扫描一遍跳过相邻重复项。这个适合大数组而且能用流式处理内存占用比哈希表低。实际场景里哈希法最省心排序法最省内存。6.3 切片与提取不用循环也能玩转数组热搜词里“python数组切片命令”“matlab数组取出多列”“es6提取数组对象一部分”其实都在说一个事如何高效地从数组里抽出一部分数据。切片操作是Python的招牌能力arr [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] arr[2:5] # 下标2到4结果是[2, 3, 4] arr[:4] # 开头到下标3 arr[::2] # 每隔一个取一个 arr[::-1] # 整个数组反转JavaScript的slice和splice音似但作用完全不同slice是提取不影响原数组splice是删除/插入会改变原数组。我见过不少刚转前端的人在这里栽跟头把原数组改坏了还找不到原因。MySQL或者Excel里提取一列也是这个思想热搜里“excel 提取前两列匹配的数据成一个数组”本质上就是二维表中按条件选取列思路与代码里做二维数组按列切片完全一致。提取对象数组里的一部分字段ES6其实很顺手let items [{id: 1, name: a}, {id: 2, name: b}]; let ids items.map(item item.id); // [1, 2]这里用到的map方法就是“提取数组对象一部分”的核心工具你还可以用filter做条件筛选、用reduce做聚合汇总。学会这些函数式方法之后很多数组操作都变得“一键直达”再也不用写笨重的for循环了。7. 数组与算法从前缀和到树状数组的进阶之路7.1 前缀和把区间查询变成两次相减很多初学者觉得数组就是“存数据用的”直到接触算法题才发现数组还能配合算法玩出花来。前缀和是数组应用里最经典的思想之一。假设你有一个数组arr定义一个前缀和数组prepre[i]就是arr[0]加到arr[i-1]的和或者arr[0]加到arr[i]的和看你定义。用pre[i]表示前i个元素之和更标准。这样一来如果要计算arr[l]到arr[r]的区间和直接用pre[r1] - pre[l]就能算出来不需要循环累加。这个思想的意义在于预处理一次O(n)之后每次区间求和都是O(1)。如果有一千次区间查询请求暴力每次遍历区间的总复杂度是O(n×m)前缀和则降为O(nm)。初学者可能觉得这个优化没什么大不了但数据量到十万百万时这个差距可能就是“能跑”和“跑不动”的区别。7.2 树状数组单点修改与前缀和的经典组合前缀和的局限是如果数组里的元素会动态修改每次改完都得重新构建前缀和那就退化成O(n)了。树状数组也叫Fenwick树就是为这个场景准备的它支持单点修改和前缀和查询两个操作都是O(log n)。树状数组的数据结构特别有意思它利用了一个数的二进制最右边的“1”来决定存储范围——这个知识点配上“lowbit”认识是关键。我提一下核心查询和修改逻辑int bit[MAXN], n; // 查询前i个元素的和 int sum(int i) { int s 0; while (i 0) { s bit[i]; i - i (-i); // lowbit操作去掉最右边的1 } return s; } // 单点修改给第i个位置加上delta void add(int i, int delta) { while (i n) { bit[i] delta; i i (-i); // 跳到下一个需要更新的位置 } }热度词里那个“假设以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队...”——这个更偏数据结构跟树状数组不是一回事但它同样展示了“数组不只是一排数据还能模拟复杂结构”的思想。数组既可以用来存数据也可以用来实现栈、队列、堆等结构本质都是利用连续内存加一套操作规则。树状数组的常见应用是“动态求前缀和”典型题是“区间和查询单点修改”。树状数组的模板我建议背下来因为比赛、面试都爱考这个而且写起来比线段树短得多。不过日常业务开发里用得少知道有这个东西、能说出适用场景面试就算过了。7.3 数组中某些数据和等于固定值子集枚举热搜词里有一个特别具体的需求“已知固定数值如何确定数组中的哪些数据和等于固定值”。这正是经典子集和问题Subset Sum。先给暴力方案数组里有n个数每个数有“选/不选”两种可能所以总共有2ⁿ种组合。遍历每一种组合求一下选中的数之和看是不是等于目标值。def subset_sum(arr, target): n len(arr) for mask in range(1 n): # 遍历所有子集 s 0 for i in range(n): if mask (1 i): s arr[i] if s target: # 找到了一个组合 return [arr[i] for i in range(n) if mask (1 i)] return None这个方案在n不超过20时能用。n到40以上就得用拆分折半法Meet in the Middle把复杂度降下来思路是把数组分成两半分别枚举所有子集然后用哈希表把左边的和存起来右边每算出一个和就在哈希表里查target - sum是否存在。适合重量级场景如果n是40暴力2^40约等于一万亿次操作拆半后左边2^20约100万次枚举右边2^20次枚举再加查询实际运行从“跑几小时”变成“秒出结果”。我在给非科班转行的朋友讲这个题时会先问一句你学这个是为了面试刷题还是为了业务开发业务开发里很少真需要子集和的精确解但如果是面试这种题就是高频考点因为它考察的是“状态压缩”和“搜索”两个基本算法素养。反正无论哪种目的先跑通暴力版再谈优化版总没错。8. 30分钟入门零基础手把手跑一个数组程序讲了这么多概念和实例最终还是要落到上手实操。这部分我给一个具体的学习路线你照着敲一遍基本就把数组基础给打通了。第一步选一门语言。新手我推荐从Python入手理由不是Python效率高而是语法最接近“人类语言”可以把注意力集中在数组逻辑本身而不是纠结语法。当然如果你学的是C语言课程直接用C跟着练也是一样的。第二步敲一段包含“增删改查”的完整代码。Python版本# 定义一个成绩列表 scores [68, 92, 75, 88, 96] # 增新增一个成绩 scores.append(59) # 删删除第一个低于60分的成绩 scores [s for s in scores if s 60] # 改把90分以上的都设为满分 scores [100 if s 90 else s for s in scores] # 查打印所有成绩及平均分 for i, s in enumerate(scores): print(f第{i}个成绩{s}) print(f平均分{sum(scores) / len(scores)})这段代码虽然短但把数组的遍历、增删改查、条件筛选全走了一遍。练完这段你基本就具备了“用数组处理一批数据”的能力。第三步挑战一个小任务给定一个整数数组反转它并输出。分别用Python的切片[::-1]、JavaScript的reverse()、C语言的双指针法各实现一遍。三种写法对比一下你就能体会到不同语言对数组操作思维的差异。C语言双指针版本解法如下void reverse(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--; } }第四步去LeetCode或类似平台刷三题两数之和、最大子数组和、移动零。这三道题全是数组基础覆盖了哈希表、动态规划和双指针三个最核心的数组用法。不用贪多把这三题吃透你的数组能力就已经超过大多数初学者了。整个流程大约30分钟到1小时建议每天花半小时练一周效果比一次猛看三小时好得多。写代码这件事跟练肌肉一样短期突击不如长期持续。9. 数组调试心得那些年我踩过的坑从我个人的经验出发最后分享一下数组相关的调试心得。这个内容是我最想对新手说的因为我自己也是从这些坑里爬出来的。第一个坑是数组越界不报错。C/C里越界写不一定会立刻崩溃但可能导致相邻变量被篡改或者到函数返回时栈破坏才崩。这种“延迟崩溃”最坑人。排查方法就是检查所有涉及数组下标的循环确保边界条件正确也可以用编译器的地址消毒器AddressSanitizer跑一遍它会精准报出越界位置。第二个坑是“数组复制”的假象。int arr2 arr1这在C语言里编译都过不了数组名是指针常量在Java里arr2 arr1复制的是引用改arr2也会改arr1Python里list2 list1同样是引用复制要真正复制得list2 list1.copy()或list2 list1[:]。我看过太多人在Python里改了“副本”发现原数据也被改了最后跑了半小时才定位到问题。第三个坑是循环里用arr.length或len(arr)做条件而在循环体内又改变了数组长度。这样会死循环或者漏掉元素。比如Python里一边遍历一边删除元素就会导致元素错位漏删。正确做法是生成一个过滤后的新数组或者从后往前遍历删除。第四个坑是多维数组的行列搞反。做图像处理或者矩阵运算时行优先和列优先的差异会导致输出结果完全错乱。遇到矩阵数据我习惯先打印一下shape和前几行确认行列再往下写。第五个坑是字符串数组的内存管理。C里用指针数组存字符串如果是字符串常量可以直接赋给char *但如果你要修改字符串内容得用二维字符数组或动态分配内存否则一改就段错误。这个坑我读大学时踩过两次至今难忘。数组这关过了以后你会发现循环、指针、函数、字符串这些知识点全都被串联起来了。这就像是学骑自行车——刚上车的时候摇摇晃晃一旦掌握平衡感后面就越来越顺了。越往深学你越会发现数组这个最基本的数据结构在无数场景里反复出现。把基础打牢了后面的路就会好走很多。