
说到数组的相关操作我第一反应不是循环和下标而是一堆自己曾经踩过的坑C语言数组越界不报错、JS的sort()把10排到2前面、Python的切片明明是引用却经常让人误改原数组。这些问题单拎出来都不大连在一起就是数组操作的全部难点。这篇文章我不打算按教科书顺序讲而是按“实际操作中会遇到什么麻烦”来梳理。从初始化、长度计算到增删改查、动态扩容再到排序去重、指针多维数组、Excel/VBA里的数组玩法尽量把高频场景一次说透。适合刚入门的读者建立完整认知也适合写了两年代码但想查漏补缺的朋友按图索骥。1. 数组初始化和长度计算的差异很多Bug从这里开始1.1 各语言的初始化规则真的不一样很多人以为“数组初始化为0”是理所应当的其实不是。C语言里局部数组的初始值是不确定的垃圾值我第一次写int arr[5]然后打印被里面一串随机数骗了好久还以为是内存坏了。实际上只要不是全局变量或者静态变量C/C都不会自动清零。所以C语言里的初始化要养成用{}的习惯int a[5] {0}; // 全部为0 int b[5] {1, 2, 3}; // 前三个有值后面自动补0C11以后还可以写int a[5]{}同样全部初始化为0。但如果是std::vectorint v(5)它默认就放入了5个0这点和C数组完全不同别混在一起记。Java里new int[5]默认全是0但是new String[5]默认全是null处理不好会抛空指针。JS是最容易踩坑的new Array(3)只是创建了一个长度为3的稀疏数组并不会像你以为的那样放3个undefined直接调用map()还会跳过这些空位正确做法是用Array(3).fill(0)或者Array.from({length:3}, () 0)。Python的[0] * 5生成的是5个0的列表但如果你写成[[0] * 3] * 3得到的三行实际上是同一个列表对象的引用改了任意一行另外两行也跟着变。这个坑在矩阵初始化时非常常见正确写法是[[0] * 3 for _ in range(3)]。如果用到NumPy初始化就简单了import numpy as np np.zeros((3, 4)) # 全0 np.ones((3, 4)) # 全1 np.full((3, 4), 7) # 全71.2 数组长度怎么算才不出错C语言里最常见的长度计算就是sizeof(arr) / sizeof(arr[0])但这句话只在定义数组的同一个作用域里有效。一旦数组作为参数传给函数形参里的int arr[]其实退化成指针sizeof(arr)变成指针大小在64位机器上是8除以4得到2遍历长度就错了。一个不依赖“魔法写法”的方案是用模板推导长度template size_t N void printArray(const int (arr)[N]) { for (size_t i 0; i N; i) { std::cout arr[i] std::endl; } }C17以后可以直接用std::size(arr)但只能是真数组不能是指针。Java里arr.length是属性、str.length()是方法、list.size()也是方法这三者经常有人记混。JS里arr.length可以被赋值arr.length 0可以直接清空数组这既是技巧也是隐患。Python统一用len(arr)对列表、元组、NumPy数组都适用反而是最容易记的。1.3 字符串数组和宏定义数组的细节字符串数组有两种常见姿势。一种是“指针数组存放字符串”比如const char *names[] {C, Java, Python};数组里存的是字符串常量的地址适合只读列表。另一种是“二维字符数组”比如char names[][8] {C, Java, Python};每个字符串被拷贝到栈上第二个维度必须大于最长字符串长度否则编译期就报长度溢出。C里更推荐std::vectorstd::string省心得多。宏定义数组本身不复杂#define MAX_SIZE 100然后声明数组但宏不检查类型也不会参与编译期类型推导。我自己更建议用constexpr int MAX_SIZE 100;既能当数组长度还能参与模板推导类型也安全得多。2. 数组的增删改查与动态扩容从定长盒子到能长个儿的数组2.1 删除元素为什么会漏删数组的删除最容易出问题的场景是“边遍历边删除”。比如JS里想删掉所有值为2的元素const arr [1, 2, 2, 3]; for (let i 0; i arr.length; i) { if (arr[i] 2) { arr.splice(i, 1); } } console.log(arr); // [1, 2, 3]第二个2被跳过了因为splice删除元素后后面的元素会向前移动索引i却继续向后走。解决方式有三种倒序遍历、filter生成新数组、或者每次删除后让i--。倒序遍历是我用得最多的因为不用新开变量for (let i arr.length - 1; i 0; i--) { if (arr[i] 2) arr.splice(i, 1); }Java的ArrayList也有同样的问题但JDK8以后可以直接用removeIf(Predicate)一行搞定。Python里如果在循环里用remove()也会漏最稳妥的做法是列表推导式lst [1, 2, 2, 3] lst [x for x in lst if x ! 2]如果要保持原对象引用可以写成lst[:] [x for x in lst if x ! 2]这样在其他地方持有的引用也不会失效。2.2 动态数组是怎么“长大”的静态数组长度是编译期定死的想增加元素只能手动创建更大的数组再把旧数据复制过去。C语言里可以malloc再memcpy但容易忘释放内存。C里直接用std::vector它的扩容策略很有意思当元素个数达到容量上限时会申请一块更大的内存常见策略是扩容到原来的1.5倍或2倍把旧元素移动/拷贝过去再释放旧内存。这也是为什么vector扩容会带来两个经典问题。第一均摊到单次插入时间复杂度是O(1)不用怕扩容慢第二扩容会使所有迭代器、指针、引用失效如果你在遍历的时候顺手push_back迭代器可能就悬空了程序崩溃不是你写错了语法而是迭代器已经指向被释放的内存。Java数组长度固定想扩容只能Arrays.copyOf(arr, newLength)本质也是新数组加拷贝。NumPy的np.append同样返回新数组循环调用代价很高。所以批量处理时最好先算出大致容量一次性申请再填充数据。2.3 合并、去重、提取对象的一部分数组合并去重是高频操作。JS里最简洁的写法是const merged [...new Set([...arr1, ...arr2])];Java里如果只是去重可以用LinkedHashSet保持顺序如果是对象数组按某个字段去重可以用Collectors.toMap大概长这样ListItem list items.stream() .collect(Collectors.toMap(Item::getId, Function.identity(), (a, b) - a, LinkedHashMap::new)) .values().stream().collect(Collectors.toList());这段代码的意思是以id为key如果遇到相同key保留第一个最终拿到按原顺序排列的item列表。如果听不懂先记住“对象去重一般要显式指定字段”这个结论不要指望distinct()帮你处理一切。ES6里从对象数组里提取一部分字段也比较常见const result users.map(({ id, name }) ({ id, name })); const firstTen users.slice(0, 10);PHP接口返回的JSON字符串用json_decode($json, true)会得到关联数组不传第二个参数则得到stdClass对象。很多人纠结到底用哪个我的经验是如果你后续要遍历修改数据用数组如果你只是把接口返回的数据原样传给模板用对象更省事。3. 排序、最值、子集求和数组类算法题的通用套路3.1 排序API的隐藏规则JS的sort()默认把元素转成字符串再按字典序排序所以[10, 2, 1].sort()会得到[1, 10, 2]这大概是新手最容易迷惑的排序问题。正确写法是传入比较函数arr.sort((a, b) a - b);返回负数表示a在前正数表示b在前0表示相等。C语言的qsort和Java的Arrays.sort也都靠比较函数返回值决定顺序规则大同小异。稳定性也是个容易忽视的点。如果先按年级排序再按姓名排序稳定排序能让同年级的人保持姓名顺序不变。Python的sorted和Java对象数组排序都是稳定的但Java的Arrays.sort(int[])底层是双轴快排不稳定。分开记才不容易出问题。3.2 区间最值树状数组、线段树、滑动窗口怎么选数组求区间最大值的题目特别多但解法完全看场景。如果只是查询一次直接遍历就是O(n)。如果数组固定不变但要查很多次区间最值可以预处理ST表O(1)查询。如果数组会单点更新又需要频繁查询区间和树状数组是我的首选代码短常数小class BIT: def __init__(self, n): self.n n self.tree [0] * (n 1) def add(self, i, delta): while i self.n: self.tree[i] delta i i -i def prefix_sum(self, i): res 0 while i 0: res self.tree[i] i - i -i return res但要注意树状数组维护区间最大值会比较别扭这种场景我更推荐线段树。如果是“滑动窗口最大值”这种题不需要树状数组一个双端队列就能做到O(n)from collections import deque def maxSlidingWindow(nums, k): q deque() res [] for i, x in enumerate(nums): while q and nums[q[-1]] x: q.pop() q.append(i) if q[0] i - k: q.popleft() if i k - 1: res.append(nums[q[0]]) return res3.3 三个数的最大乘积、子集和等于固定值这类题“三个数组最大的乘积”通常指的是从一个数组里选三个数让乘积最大。容易漏的情况是负数[-100, -50, 1, 2, 3]最大乘积不是1*2*3而是-100 * -50 * 3 15000。所以排序后取两种候选比较就行nums.sort() candidate1 nums[-1] * nums[-2] * nums[-3] candidate2 nums[0] * nums[1] * nums[-1] print(max(candidate1, candidate2))如果是三个数组各取一个数思路也类似每个数组只要保留最小值和最大值枚举2^3种符号组合正负得正的原则一样。“如何确定数组中的哪些数据和等于固定值”是典型的子集和问题。数组长度小直接回溯def dfs(nums, target, start, path, result): if target 0: result.append(path[:]) return for i in range(start, len(nums)): if nums[i] target: break path.append(nums[i]) dfs(nums, target - nums[i], i 1, path, result) path.pop()如果只是判断能不能凑出来可以用布尔DP状态转移就是dp[j] || dp[j - nums[i]]每个数只能用一次时容量倒序遍历可重复用时正序遍历。4. 指针、多维数组与切片C系选手的特别提醒4.1 数组名退化、指针数组存放字符串C/C里数组名在很多表达式中会退化成指向首元素的指针但有几个例外。sizeof(arr)拿到的还是整个数组的字节数arr拿到的是指向整个数组的指针类型为int (*)[5]。看下面这个代码int arr[5]; printf(%p\n, (void*)(arr 1)); // 地址相差4字节 printf(%p\n, (void*)(arr 1)); // 地址相差20字节因为arr 1跳过一个元素而arr 1直接跳过整个数组。这个区别在指针运算和函数传参时非常重要。指针数组存放字符串本质上是一个数组每个元素是指针const char *langs[] {C, C, Python};这里的字符串都放在只读常量区所以必须用const char*否则不小心写一下就会触发运行时崩溃。与之相对的二维字符数组char langs[][8]存的是字符串副本可以原地修改但内存占用更大。两者没有谁绝对好看你要不要改内容。4.2 多维数组指针的声明语法二维数组int matrix[3][4]的matrix在传参时退化成int (*)[4]所以函数签名可以写void process(int matrix[][4], int rows);三维数组int cube[2][3][4]对应的是int (*)[3][4]void process(int cube[][3][4], int depth);这种声明确实绕但理解了一个规律就能举一反三除了第一维可以省略其他维度的长度必须在类型里完整写出因为编译器需要知道每一“行”有多少个元素才能正确计算偏移。C里为了避免这种痛苦我建议用std::array或std::vector嵌套类型更清晰边界检查也更安全。4.3 Python切片、NumPy切片和三维数组相乘Python列表切片arr[1:4:2]会生成一个全新的列表怎么改都不影响原数组。但NumPy的切片恰恰相反它是原数组的视图共享内存import numpy as np a np.array([[1, 2, 3], [4, 5, 6]]) col a[:, 1] # [2, 5] col[0] 99 print(a[0, 1]) # 99原数组也跟着改了想要独立的副本必须显式.copy()。这个差异是numpy新手最常踩的坑“我明明只改了一列怎么整个表都变了”基本都是这个原因。NumPy三维数组相乘用操作符是对最后两个维度做矩阵乘法前面的维度按batch处理。比如形状(2, 3, 4)和(2, 4, 5)相乘得到(2, 3, 5)。如果第一个维度是1就要求能广播到2广播不了就会直接报维度不匹配错误。MATLAB里取数组多列很直观A(:, [2 4])真正的“数组切片”语法没必要硬背用到哪查哪。4.4 CString转char数组、Qt中const (double [10])的接收CString是MFC/Qt项目里常见的字符串类型直接转char数组要小心编码。简单场景下可以这样CString str _T(hello); char buf[128]; #ifdef UNICODE WideCharToMultiByte(CP_ACP, 0, str.GetString(), -1, buf, 128, NULL, NULL); #else strcpy_s(buf, str.GetString()); #endif项目里如果是Unicode用WideCharToMultiByte是通用做法。也可以借助CStringA中转代码更短但本质是一样的。Qt里如果需要在窗体间传递固定长度的double数组经常会看到const double (data)[10]这种参数。它是C的“数组引用”好处是保留长度信息不会退化成指针。接收方要复制到成员数组时不能直接m_data data因为数组不可整体赋值要用std::copyclass Widget { public: void setData(const double (data)[10]) { std::copy(data, data 10, m_data); } private: double m_data[10]; };如果你用的是std::arraydouble, 10那直接赋值反而可行这也是我更推荐现代C的原因。5. 数组转字符串、去重与办公脚本里的数组操作5.1 数组转字符串的多种姿势数组转字符串在不同语言里思路不一样。JS里最常用的是arr.join(,)它可以自由控制分隔符arr.toString()虽然也行但分隔符固定是逗号。要注意嵌套数组被toString()后会把内层数组一并展开造成数据丢失。Java里Arrays.toString(arr)输出[1, 2, 3]带方括号如果要去掉括号通常先转成Stream再Collectors.joining。Python里必须先把数组元素转成字符串arr [1, 2, 3] s ,.join(map(str, arr))直接,.join(arr)会因为元素是int而报错这是新手常见问题。5.2 数组去重不同数据类型的处理思路JS数组去重最容易new Set(arr)就能解决。但对象数组去重不能直接靠Set因为对象引用不同。按字段去重可以用Map做中间记录const arr [{id: 1, name: a}, {id: 2, name: b}, {id: 1, name: c}]; const dedupe [...new Map(arr.map(item [item.id, item])).values()];上面代码按id去重重复的保留最后一个。如果想保留第一个可以调整Map初始化逻辑。Python里保持顺序去重常用dict.fromkeys(arr)它把数组元素作为字典的key从而天然去重arr [3, 1, 2, 1, 3] result list(dict.fromkeys(arr)) # [3, 1, 2]如果元素是列表这种不可哈希类型要先转元组再去重。5.3 Excel/VBA里数组操作的优化思路Excel场景下的“提取前两列匹配的数据成一个数组”大多数人的本能是循环单元格但Excel VBA访问单元格是出了名的慢。我的建议是先把整个区域一次性读进数组Dim arr As Variant arr Range(A1:B10000).Value这样后续所有匹配都在内存里完成。需要对比两个数组找出匹配数据时嵌套循环虽然简单但数据量一大就会卡死。更快的做法是用字典建立哈希索引Dim dict As Object Set dict CreateObject(Scripting.Dictionary) Dim i As Long For i 1 To UBound(arr) dict(arr(i, 2)) arr(i, 1) Next i然后遍历另一个数组时直接dict.Exists(值)判断复杂度从O(n^2)变成O(n)。这个思路不仅适用于VBA任何需要“按某一列去匹配另一列”的场景都通用。VBA从Range读入的数组默认下标从1开始这一点和普通数组从0开始不一样写循环时要注意。5.4 接口返回的数组对象与JSON处理PHP接口返回数据时常见做法是输出JSON。json_decode($json, true)把JSON转成关联数组适合用下标访问json_decode($json)转成对象适合用-访问属性。输出中文时记得加JSON_UNESCAPED_UNICODE否则中文会变成\u转义串给前端带来不必要的麻烦。JS里数组对象和JSON互转是JSON.stringify和JSON.parse。如果是后端接口返回的对象数组前端常常需要先过滤再提取字段用filter加map两步走基本能覆盖大部分需求。Python里则是json.loads和json.dumps逻辑完全一致。数组的操作说到底就是数据在内存里的排布和变换不同语言只是换了层语法外衣。我个人这些年最大的体会是遇到数组问题先问三个问题——这个数组是固定长度还是动态长度数据是值还是引用遍历边界会不会变把这三个问题想清楚大部分坑都能绕开。真正调试的时候该打印地址就打印地址该看内存就看内存不要只盯着控制台输出猜。