数组大概是所有编程初学者最早上手的东西,也是最容易“以为会了、一写就错”的东西。有人说数组不就是“同一类型的元素排成一排”,这话对,但不够——你写代码时遇到的内存崩溃、传参报错、赋值失灵、越界警告,底层全是数组的“连续内存”和“类型退化”这两个属性在作怪。我见过太多人在 C 语言里直接 b = a,在 Python 里用 b = a 后改了 b 结果 a 也变了,在 Java 里以为 new 了数组就是一块独立空间结果引用一塌糊涂。这篇文章我打算把数组从底层到实战整个过一遍,包括下标访问的原理、多维数组的本质、各语言初始化和赋值差异、去重和轮转这类高频算法题,以及它在 MATLAB、Simulink、LabVIEW、Verilog 和前端 Excel 解析里的具体用法。不管是刚学语法的新手,还是被数组坑过、想系统理一遍的老手,都适合往下看。
1. 数组的底层逻辑:连续内存、下标访问和数组名的真相
1.1 数组为什么“快”?因为它在内存里是一条连续的走廊
数组本质上不是一个抽象概念,而是一条被系统安排好的连续内存。你可以把这段内存想象成一排等大的房间,每个房间有一个固定门牌号,门牌号从 0 开始,房间里存放的元素大小完全一致。正因为所有房间紧挨在一起,计算机只需要知道“走廊的起点地址”和“每个房间的宽度”,就能够在 O(1) 时间内算出任意门牌号对应的位置。
这个计算在 C 语言里就是公式:a[i]的地址 = 数组首地址 + i × 每个元素占用的字节数。举例来说,int a[5]在 32 位平台上每个元素占 4 字节,a[0] 如果在地址 0x1000,那么 a[2] 的地址就是 0x1000 + 2 × 4 = 0x1008。
这也顺便解释了一个许多人从没细想过的问题:为什么数组下标是从 0 开始而不是从 1 开始?因为从 0 开始,地址计算公式就是“基址 + 偏移”,不需要做i - 1的减法,编译器省一步运算,硬件实现也更直接。很多现代语言如 C、Java、JavaScript、Python 都沿用了这套从 0 开始的约定,而 MATLAB 等数学软件为了贴近使用习惯选择了从 1 开始,这也是 MATLAB 和常规编程语言混用时常让人栽跟头的地方。
1.2 数组名到底是啥?C 里是“退化的指针”,Java 里是“数组对象”
数组名这个概念在不同语言里含义差距极大,最容易引发混乱。
C 语言中,数组名在大多数表达式里会“退化”成指向首元素的指针。所谓退化,是指int a[5]本身有它的类型——它是一个长度为 5 的整型数组,但在赋值、传参、参与运算时,它自动变成int*类型的指针,指向 a[0]。这就是为什么sizeof(a) / sizeof(a[0])能算出数组长度,而一旦把 a 作为参数传进函数,sizeof(a)就变成 8(64 位平台指针大小),再也算不出原来的元素个数了。
Java 里数组是“对象”,它被分配在堆上,通过引用来操作。int[] a = new int[5]实际上是创建了一个数组对象,a 保存的是这个对象在堆上的引用。数组对象内部有一个length字段,所以 Java 能直接拿a.length,不需要额外传一个长度参数。
Python 里的 list 则更“动态”,它内部是一个指针数组,每个元素又指向真正的对象,所以你可以往一个 list 里塞不同类型的数据。这种设计灵活,代价是每个元素多了一层间接引用,内存占用和访问开销天然比 C 的裸数组高。
Java 和 Python 里还要特别警惕“引用赋值”。int[] b = a并不会把 a 的元素复制给 b,而是让 b 和 a 指向同一个数组对象。改动 b[0] 会同步改变 a[0]。这在多语言协作时是经典误区,尤其从 C 语言转过来的人,特别容易默认赋值就是在拷贝。
1.3 各语言数组的本质差异速览
| 语言 | 数组本质 | 长度获取 | 下标起点 | 整体赋值 | 是否支持动态扩容 |
|---|---|---|---|---|---|
| C | 连续内存块 + 首元素地址 | sizeof 计算, 传参后失效 | 0 | 不支持 | 不支持,需手动分配 |
| C++ | 原生数组 / std::array | std::size() | 0 | 原生数组不支持, array 支持 | vector 支持 |
| Java | 堆上的数组对象 | length 属性 | 0 | 复制的是引用 | ArrayList 支持 |
| Python | list 对象(指针数组) | len() | 0 | 复制的是引用 | append 天然扩容 |
| JavaScript | Array 动态对象 | length 属性 | 0 | 复制的是引用 | push/pop 天然扩容 |
| MATLAB | 矩阵/多维数组 | size()/length() | 1 | 支持整体赋值 | 可重新赋值扩容 |
| Verilog | 寄存器数组 | 编译期常数 | 0 | 行为级代码中可以 | 不支持 |
1.4 数组越界:聪明的编译器不救你
C 和 C++ 里,数组越界属于“未定义行为”,编译器不会帮你做边界检查。也就是说,int a[5]; a[100] = 1;可能正常跑,可能在运行时报段错误,也可能悄悄改掉了别的变量的值,导致一个八竿子打不着的 bug 在几分钟后才爆发。
我早年调试过一个问题:程序跑着跑着,一个局部变量的值莫名其妙变成天文数字。查了一天,最终发现是另一个函数里数组越界写入,覆盖了栈上相邻变量的内存。从这以后,我给自己定了个规矩:凡是手写循环访问数组,一律先确认索引范围;凡是涉及外部输入长度的地方,哪怕是个人脚本,也做一次边界检查。现代 C++ 可以借助std::array::at()或std::vector::at()获得越界抛出异常的保护,代价是微小的性能损耗,在非极端性能场景下完全值得。
2. 一维到多维,再绕开指针数组、结构数组这些易混概念
2.1 二维数组的本质:数组套数组,内存还是平的
二维数组的实际表现是“数组的数组”。比如 C 语言里的int a[3][4],a 是一个长度为 3 的数组,每个元素又是一个长度为 4 的整型数组。把它展开成数据结构概念,就是 3 行 4 列,一共 12 个连续 int 元素。
内存里这 12 个元素是怎么排的?按“行优先”排列:先存第 0 行的 4 个元素,再存第 1 行的 4 个元素,最后存第 2 行的 4 个元素。这就是所谓的“行优先存储”。所以a[1][2]实际对应的是首地址偏移(1 * 4 + 2)个元素的位置。
这也解释了为什么 C 语言里函数参数传二维数组时,第二维必须写明。比如void func(int a[][4]),因为编译器计算a[i][j]的地址需要用到“每行有几个元素”这个信息,不知道列数,它就没法算出第 i 行的起始位置。这是新手的常见报错点:只写了void func(int a[][]),编译直接失败。
Java 的二维数组和 C 有个显著区别:Java 里int[][] a = new int[3][4]是“3 个引用的数组,每个引用指向一个长度为 4 的 int 数组”,它在内存里是分散的,不是一个连续的大块。因此 Java 的二维数组允许“不规则的锯齿数组”——每行长度可以不同,只要你先分配外层再逐行分配内层。
Python 里用嵌套列表模拟二维数组时,有个特别容易踩的坑:[[0] * 4] * 3看着像是 3 行 4 列,实际上外层 3 个元素全都指向同一个内部列表。改a[0][1] = 9,三行同时变。正确写法是用列表推导式:[[0] * 4 for _ in range(3)],确保每一行都是独立新建的列表对象。
2.2 字符数组与字符串:C 语言里没有“字符串类型”
C 语言里字符串不是一种内置类型,而是“以'\0'结尾的字符数组”。char s[6] = "hello"实际上分配了 6 个字节:'h'、'e'、'l'、'l'、'o'、'\0'。平时写char *p = "hello",p 指向的是一个只读的字符串字面量,尝试通过 p 修改内容是未定义行为,很多平台会直接崩溃。
这是char s[]和char *p之间最核心的区别:s 是本地数组,可修改;p 是指向只读区的指针,只能读不能写。函数sizeof(s)返回 6,sizeof(p)返回 8(指针大小),凡是在这两者间做判断或转换,都得先明确自己操作的是数组还是指针。
2.3 指针数组和数组指针:从定义就能拆穿的两个概念
“指针数组”和“数组指针”长得像,意思完全相反。一个简单可靠的读法:先找到变量名,再判断它先和谁结合。
int *p[5]:p 先和[5]结合,所以 p 是一个长度为 5 的数组,数组里的每个元素都是int*类型的指针。这叫指针数组。int (*p)[5]:小括号让 p 先和*结合,所以 p 是一个指针,它指向的对象是一个长度为 5 的整型数组。这叫数组指针。
指针数组最常见的用法是保存多个字符串的起始地址。比如const char *strs[3] = {"apple", "banana", "cherry"},这里 strs 是一个包含 3 个const char*的数组,每个元素指向一个字面量。这种结构在命令解析、错误信息表中非常常见。
数组指针则多用于指向二维数组的行。int a[3][4]; int (*p)[4] = a;之后,p 指向第 0 行,p + 1 指向第 1 行,*(p + 1) + 2就等价于&a[1][2]。搞懂这两个,读 C/C++ 代码时遇到复杂声明就不会再头皮发麻。
2.4 结构数组与普通数组,以及共用体套数组的玩法
普通数组要求所有元素类型相同,比如全是 int、全是 double。结构体数组则把“相同类型的结构体”存成数组:struct Student { char name[20]; int age; } students[50];访问某个学生的姓名是students[i].name,年龄是students[i].age,逻辑上很像 Excel 表格的一行行记录。
结构数组与普通数组的核心区别在于:普通数组的每个元素一般只有“值”可以读取或修改,而结构数组的每个元素携带多个字段,一组字段之间天然绑定,移动一条记录时需要整体拷贝或对整体排序。在数据库批量读取、文件解析、图形渲染顶点数据这类“一条条记录”的场景中,结构数组几乎是标配。
共用体(union)里套结构体和数组就更有意思了。union 的特点是所有成员共享同一段内存,大小取最大成员的大小。有人用它做协议解析:
typedef union { uint32_t raw; struct { uint16_t type; uint16_t length; } header; uint8_t bytes[4]; } PacketHeader;读网络包时,可以直接把收到的 4 字节数据塞进bytes,再按业务需要以raw或header的方式解读。这种写法在嵌入式通信协议栈里很常见。需要提醒的是,内存对齐会直接影响 union 的大小和字段偏移,跨平台使用时必须确认结构体是否紧凑排列(比如使用#pragma pack),否则解析出来的字段可能错位。
3. 不同语言里数组的初始化、赋值和传参,藏着这些坑
3.1 初始化的几种姿势,写错就白忙
C 语言里int a[5] = {0};是常见做法,它把所有元素都初始化为 0。如果只给部分值,比如int a[5] = {1, 2, 3};,剩下的第 3、4 个元素会被自动补 0,这是 C 标准规定的行为。利用这个特性,int a[100] = {0};是安全的全部清零方式。但int a[5] = {1};只把第一个元素设成 1,其余补 0,不是把全部元素设成 1,这是新人出手即错的经典场景。
C++11 之后有std::array<int, 5> a = {1, 2, 3, 4, 5};,它在编译期就知道自己的大小,不会像 C 原生数组那样在传参后退化成指针。动态数组用std::vector<int> v = {1, 2, 3};或者std::vector<int> v(10, 0)初始化 10 个 0。
Java 的int[] a = {1, 2, 3};是静态初始化,int[] a = new int[5];是动态初始化,动态初始化后所有元素自动是默认值(int 为 0,boolean 为 false,引用类型为 null)。
Python 里[0] * 10可以生成 10 个 0 的列表,但千万记住前文说的,[[0] * n] * m是在复制引用,不是生成独立二维数组。
JavaScript 里new Array(5).fill(0)可以生成 5 个 0 的数组,但要注意new Array(5)本身只是创建一个长度为 5 的稀疏数组,里面没有任何元素,直接 map 会有坑,建议配合 fill 使用。
3.2 两个等大小的数组可以直接赋值吗?答案因语言而异
这个问题在各语言社区里反复出现。先记结论:
- C 语言:不能。
int b[5] = a;编译报错,因为数组名是地址常量,不允许整体赋值。复制要手动循环或用memcpy(b, a, sizeof(a))。 - C++ 原生数组:不能,理由同上。但
std::array可以,因为它重载了赋值运算符,b = a会逐元素复制。 - Java:
int[] b = a;能编译,但只是让 b 和 a 指向同一个数组,没有复制内容。要复制内容,用System.arraycopy(a, 0, b, 0, a.length)或Arrays.copyOf(a, a.length)。 - Python:
b = a同样只复制引用。复制内容用b = a[:]或b = list(a)。 - JavaScript:
b = a也是引用。复制用b = a.slice()或b = [...a]。
我见过不少转语言的人在这上面翻车。最典型的场景是:Java 里想把一个数组临时保存,回头再恢复,直接copy = original,结果后续改了 original,copy 也跟着变;或者 Python 里把一个列表作为默认参数传给函数,函数内部改了列表,结果下次调用默认值已经被污染了。这类问题一旦触发,debug 起来特别迷惑,因为代码看起来完全没毛病。
3.3 数组名的类型转换与“退化”:C 系面试必考
C 语言中数组名在表达式里会自动转换为指向首元素的指针,这个过程叫“数组退化”。比如int a[5]; int *p = a;是合法的。但这种退化在以下场景会带来陷阱:
sizeof(a)得到整个数组占用的字节数,sizeof(p)得到指针大小。- 将数组作为函数参数传递时,
void f(int a[])和void f(int *a)完全等价,函数内部拿到的都是指针,所以函数里不能通过sizeof(a) / sizeof(a[0])求数组长度。 - 数组名取地址
&a,类型是int (*)[5],指的是整个数组,而不是首元素的地址。虽然数值上它和a一样,但指针加减时步长不同。a + 1跳过 1 个 int,&a + 1跳过整个 5 元素数组。
很多初学者希望给数组做“类型转换”,比如把char array转成int array,但数组本身没有转换这个概念。通常的“转换”实际上是以不同的类型去解释同一段内存,常见的做法是int *p = (int *)byteArray;,这本质上是把内存重新解释,与各字节的排列顺序(大端小端)强相关。涉及跨平台数据交互时要格外小心。
3.4 二维数组传参为什么必须给列数?C 大数组到底放哪?
前文已经提过,void f(int a[][4])第二个维度必须写明。原因是地址计算需要列数。如果非要传一个任意列数的二维数组,可以退化为单层指针,手动用a[i * cols + j]访问,相当于把二维数组压平成一维。
“C++ 大数组怎么开”也是搜索里很高频的问题。答案是:不要直接开在函数栈上。默认栈空间在 Linux 上一般是 8MB,在 Windows 上大约 1MB,开一个int a[10000000](约 40MB)必然直接爆栈。常见替代方案:
// 静态存储区 static int a[10000000]; // 堆上分配 int *a = new int[10000000]; // 使用 STL 动态数组 std::vector<int> a(10000000);用 vector 最省心,它自动管理堆内存,还能动态扩容。用new时必须配套delete[],小心内存泄漏。用static则是在编译期分配,大小必须是编译期常量,不能是运行时算出来的变量。
3.5 动态数组:malloc、ArrayList 与 Python list 的扩容策略
C 里没有内置动态数组,需要手动管理。malloc分配内存,realloc扩容,free释放。因为 realloc 可能把内存搬到新的位置,释放时必须使用最新的指针。
Java 的ArrayList内部是一个用数组存储的容器,当元素个数超过内部数组容量时,它会自动扩容为原来的 1.5 倍左右,然后把旧元素全部复制到新数组。这就是热词里“动态数组”的实际运作方式。如果预先知道大小,尽量在构造时指定初始容量,减少扩容复制的次数。
Python 的 list 扩容策略类似,也会以约 1.125 的倍率增长,平时写代码基本不用关心,但做大规模数值计算时,用array模块或第三方库 NumPy 会更高效,因为 numpy 的数组是真正的连续同类型内存块,而不是一堆 Python 对象指针。
4. 数组算法实战:去重、左移 k 位和最长连续递增子序列
4.1 升序数组原地去重:双指针一次遍历
热词里有“给定一个升序排列的数组,原地删除重复出现的元素,使得每个元素只出现一次”。这是 LeetCode 26 题,解法非常经典:用双指针,一个慢指针指向结果数组末尾,一个快指针遍历原数组。
int removeDuplicates(vector<int>& nums) { if (nums.empty()) return 0; int k = 0; for (int i = 1; i < nums.size(); i++) { if (nums[i] != nums[k]) { nums[++k] = nums[i]; } } return k + 1; }为什么能原地做?因为数组是升序的,相等元素一定相邻。当快指针发现新值与慢指针位置的值不同,说明遇到了新的不重复元素,直接把它搬到慢指针后面。因为快指针永远走在慢指针前面,写入操作不会覆盖还没扫描到的元素。
这个思路也提示了一个通用原则:凡是要求原地操作、空间复杂度 O(1) 的数组题,双指针往往是最先该尝试的方法。如果面试官不给“升序”这个条件,这个写法就不适用,需要先排序或改用哈希表。
4.2 数组整体左移 k 位:暴力循环与三次反转
热词“数组整体左移 k 位 每次移动 k 位”指向的是一个常见的轮转问题。向左轮转 k 位,最直观的想法是每次移动 1 位,循环 k 次,时间复杂度 O(n×k)。k 很大时会很慢。
更优雅的做法是“三次反转法”。以[1, 2, 3, 4, 5, 6, 7]左移 3 位为例,结果是[4, 5, 6, 7, 1, 2, 3]。做法是:
- 反转整个数组:
[7, 6, 5, 4, 3, 2, 1] - 反转前 n-k 个元素:
[4, 5, 6, 7, 3, 2, 1] - 反转最后 k 个元素:
[4, 5, 6, 7, 1, 2, 3]
void reverse(vector<int>& nums, int l, int r) { while (l < r) swap(nums[l++], nums[r--]); } void leftRotate(vector<int>& nums, int k) { int n = nums.size(); if (n == 0) return; k = k % n; if (k == 0) return; reverse(nums, 0, n - 1); reverse(nums, 0, n - k - 1); reverse(nums, n - k, n - 1); }注意 k 一定要先取模 n。因为左移 n 次等于没动,取模能把 k 压缩到 [0, n-1] 的范围内。三次反转法的时间复杂度是 O(n),空间复杂度 O(1),是这类问题的最优解之一。如果不要求原地,也可以直接构造新数组,把第 i 个元素搬到 (i - k + n) % n 的位置。
4.3 无序数组最长连续递增子序列:一次贪心遍历
热词里有“给定一个无序数组,找出最长连续递增子序列的长度”。注意题眼是“连续”,意思是索引也必须连续。比如[1, 3, 5, 4, 7]中,[1, 3, 5]是连续递增子序列,[1, 3, 5, 7]不连续索引上的递增子序列,不算。
因此一次遍历就能解决:用一个计数器 cur 记录当前递增段的长度,遇到不递增的元素就重置。
int findLengthOfLCIS(vector<int>& nums) { if (nums.empty()) return 0; int maxLen = 1, cur = 1; for (int i = 1; i < nums.size(); i++) { if (nums[i] > nums[i - 1]) { cur++; } else { maxLen = max(maxLen, cur); cur = 1; } } return max(maxLen, cur); }如果要求“不连续索引的最长递增子序列”,问题就变成经典的 LIS,需要使用动态规划或贪心加二分,复杂度完全不同。做题前先确认“连续”和“递增”这两个条件,会直接影响算法选型。
4.4 数组去重:一维和对象数组的通用处理
一维数组去重在 JavaScript 里最简单的是[...new Set(arr)]。如果数组中元素是引用类型,比如对象数组,Set 的去重逻辑是按引用比较的,无法直接按对象的某个字段去重。
常用的方案是按 id 之类的唯一字段去重:
const unique = [...new Map(arr.map(item => [item.id, item])).values()];这行代码的思路是:先用arr.map把每个对象转成[id, item]的键值对,再用 Map 自动覆盖同 id 的旧值,最后取 values。Map 保证了键的唯一性,而且时间复杂度是 O(n)。如果要保留第一个出现的元素,顺序不要颠倒,Map 的 set 会覆盖旧值,所以想保留首次出现的对象,要调整成“先判断是否存在再 set”。
C 里没有这种现成工具,通常的做法是先排序再去重,或者用哈希法标记。先排序再去重可以做到 O(n log n) 时间、O(1) 额外空间(如果不允许开数组存标记的话)。
4.5 小练习:生成包含 10 个随机数的数组并统计
热词里“产生一个包含 10 个随机数的一堆数组”其实是一个很适合练手的综合小题。比如生成 0 到 99 之间的随机数,放入长度为 10 的数组,然后输出最大值、最小值、平均值。
import random arr = [random.randint(0, 99) for _ in range(10)] print(arr) print("max:", max(arr), "min:", min(arr), "avg:", sum(arr) / len(arr))这个例子虽然简单,但包含了“初始化数组 → 填充数据 → 遍历统计”的完整链路。很多初学者在写这类程序时会把数组长度硬编码在多个地方,比如for i in range(10)和arr = [0] * 10里各写一个 10。更稳妥的做法是先用常量定义长度,比如N = 10,所有地方引用 N。这样以后想改成长度为 100,只动一行就行。这个习惯看起来不起眼,但代码维护价值很大。
5. 性能探底:从缓存友好到树状数组,数组还能这么用
5.1 数组访问为什么比链表快?局部性原理在起作用
从时间复杂度上看,数组按索引访问是 O(1),链表按索引访问是 O(n),这是理论层面。但即使都做一遍遍历,数组依然常常比链表快,原因是 CPU 缓存。
数组在内存中是连续的,当程序访问 a[0] 时,CPU 会把 a[0] 附近的一整块内存(通常是一次缓存行的大小,现代 CPU 一般是 64 字节)加载进高速缓存。紧接着访问 a[1]、a[2],大概率已经在缓存里,命中了,速度快得飞起。
链表的节点分散在内存各处,每次访问下一个节点,CPU 都可能要重新从主存加载,缓存命中率低。如果链表节点恰好紧挨着还好,但一般情况下并不如此。
这个原理还解释了数组遍历顺序的重要性。在 C 语言中遍历二维数组int a[row][col]:
// 快:按行优先遍历 for (int i = 0; i < row; i++) for (int j = 0; j < col; j++) sum += a[i][j]; // 慢:按列优先遍历 for (int j = 0; j < col; j++) for (int i = 0; i < row; i++) sum += a[i][j];两种遍历的元素访问总次数一样,但第一种访问的内存是邻居地址,缓存命中率高;第二种每次跳跃一个整行,缓存利用率低。数据量大时,两者性能差距可能达到数倍。写矩阵运算、图像处理这类核心循环时,优先保证外层循环按行索引,就是缓存友好型写法。
5.2 vector 扩容的隐藏代价
C++ 的std::vector比较方便,但它扩容时要把旧元素全部拷贝或移动到新内存,这个开销容易被忽略。连续插入大量元素时,vector 可能多次扩容,每次都触发一次全量复制。
解决方法是预估容量后用reserve提前分配空间:
std::vector<int> v; v.reserve(10000); // 预先分配 10000 个元素的空间 for (int i = 0; i < 10000; i++) v.push_back(i);同理,Java 的 ArrayList 构造时也可以指定初始容量:new ArrayList<>(10000)。如果没法精确估计,给一个偏大的初始容量通常也比反复扩容划算。
动态数组扩容还有一个常见的隐藏问题:扩容可能使之前获取的迭代器或指针失效。C++ 中vector扩容后,所有旧迭代器、指针、引用全部失效;Java 的 ArrayList 扩容后,通过索引访问不受影响,但如果有人在扩容期间持有内部数组的引用做反射操作,也会遇到问题。写代码时不要把“内部数组地址”这种假设写死。
5.3 树状数组:用数组高效维护前缀和的经典手段
热词里有“树状数组模板”。树状数组(Binary Indexed Tree)是一种用数组实现的数据结构,支持 O(log n) 的单点修改和前缀和查询,并且实现极短。它的底层逻辑是把数组下标通过 lowbit 运算映射成一段段区间。
int n; vector<int> bit; int lowbit(int x) { return x & (-x); } void add(int i, int delta) { for (; i <= n; i += lowbit(i)) bit[i] += delta; } int sum(int i) { int ans = 0; for (; i > 0; i -= lowbit(i)) ans += bit[i]; return ans; } // 查询区间 [l, r] 的和 int rangeSum(int l, int r) { return sum(r) - sum(l - 1); }lowbit(x)返回 x 二进制表示中最低位的 1 所对应的值,比如 lowbit(6) = lowbit(0b110) = 2。为什么x & (-x)能取到最低位 1?因为在补码表示下,-x 等于 x 取反加一,与 x 做与运算后,只有最低位的 1 会被保留下来。画个区间图就会发现,树状数组里的每个位置负责管理一段前驱区间的和,修改和查询都沿着 i += lowbit(i) 或 i -= lowbit(i) 这条链走。
没接触过树状数组的人第一次看这段代码会觉得很绕。我的建议是:先把模板背下来,再在纸上模拟一个长度为 8 的数组,手动执行 add(1, 1)、add(2, 1)、sum(7) 的每一步,把 bit 数组的变化画出来。这个过程做一次,比看十篇文章都有效。
5.4 大数组优化方向:前缀和与差分
数组的另一个高频优化思路是前缀和。如果要对一个静态数组频繁查询区间和,每次循环累加的时间复杂度是 O(n),而先预处理出前缀和数组,查询区间 [l, r] 的和只需要 O(1):prefix[r] - prefix[l - 1]。
差分区分的思路适合处理“对区间频繁统一加减”的场景。比如给数组 a,执行多次操作“让 [l, r] 内每个元素都加 d”,暴力做是 O(n×操作数),用差分数组 diff 记录区间端点变化,做完所有操作后再前缀和还原,总复杂度降到 O(n + 操作数)。
这些优化都建立在“数组是连续存储、支持随机快速访问”这个基础特性上。理解了这个特性,很多数据结构的选型决策自然就有了依据。
6. 跨工具实战:数组在 MATLAB、Simulink、LabVIEW 和前端里的花式操作
6.1 MATLAB:下标从 1 开始,取多列用冒号
MATLAB 的数组天然是矩阵,下标从 1 开始,这一点和 C/Java/Python 完全不同。很多人把 MATLAB 当 C 写,写for i = 0:4直接越界。
MATLAB 取出数组的多列非常简单:A(:, 2:3)表示取所有行的第 2 列和第 3 列。这是热词“matlab数组+取出多列”的答案。
A = rand(10, 10); % 10x10 随机矩阵 B = A(:, 2:3); % 取出第2、3列,结果大小为 10x2 C = A([1 3 5], :); % 取出第1、3、5行,所有列 D = A(end); % 取出最后一个元素MATLAB 还支持逻辑索引,比如A(A > 0.5)会返回所有大于 0.5 的元素组成的一维数组。这种“先比较生成逻辑数组,再用来取元素”的写法比显式 for 循环高效得多,也更符合 MATLAB 的向量化哲学。
6.2 Simulink:从工作空间读取数组,用 Selector 取元素
Simulink 里读取数组,通常先把数据从 MATLAB 工作空间导入。使用 From Workspace 模块,在参数里填入变量名,比如simin,并保证该变量是带时间戳的结构体或数组。模型运行后,数据会按时间步进入仿真。
要从数组信号中取某个元素,可以用 Selector 模块,设置索引方式和索引值。比如从长度为 100 的一维数组中取第 20 个元素,就在 Selector 里选择“Index vector (port)”或直接输入固定索引 20。在 Simulink 里搞混合数组时,记得先确认信号的宽度和你设置的索引是否一致,否则运行时会出现 dimension mismatch 报错。
6.3 LabVIEW:创建 VI,用索引数组函数读取元素
热词“labview如何创建一个vi”是一个新手高频问题。在 LabVIEW 中,创建一个 VI 的步骤是:打开 LabVIEW → 新建 VI → 得到前面板和程序框图两个窗口。前面板负责界面,程序框图负责逻辑。
要在前面板上创建一个数组控件,从“控件选板 → 数组与矩阵 → 数组”拖一个数组壳到前面板,再把一个数值常量拖进数组壳里,数组控件就创建好了。程序框图中,从“函数选板 → 编程 → 数组”选择“索引数组”函数,连接数组和索引号,即可取出指定位置的元素。索引从 0 开始,这是 LabVIEW 数组容易踩的坑。
如果要在循环里逐个处理数组元素,最简单的方式是利用 For 循环的自动索引功能:把一个数组接到 For 循环的边框隧道上,隧道会自动变成“按元素输出”,即每次循环吐出一个元素。这比手动用索引数组函数加循环计数器方便很多。
6.4 Verilog:parameter 数组描述硬件参数表
Verilog 中可以用数组形式定义 parameter,这在参数化硬件设计中很实用。比如定义一组查找表的系数:
parameter [7:0] coeff [0:3] = '{8'h10, 8'h20, 8'h30, 8'h40};读取某个参数时,coeff[i]可以用于给寄存器赋值或在 generate 循环中例化具体模块。需要注意,Verilog 的 parameter 数组是编译期常量,不能在运行时动态赋值。SystemVerilog 里还有更灵活的localparam int coeff [4] = '{1,2,3,4};,用途类似。
在 RTL 设计中,parameter 数组常用于配置多通道 ADC 的系数、FIR 滤波器抽头系数、归一到不同地址空间的基地址表等。好处是参数集中管理,修改系数不用改逻辑主体,直接改参数定义即可。
6.5 前端读取 Excel 转为数组:XLSX 库配合 FileReader
热词“js读取excel文件内容转换为数组”对应的是浏览器里解析 Excel 文件的常见需求。最常用的库是 SheetJS(xlsx)。用户在页面选择文件后,用FileReader读取文件内容,再交给 xlsx 库转成 JSON 数组。
const input = document.querySelector('input[type=file]'); input.addEventListener('change', async (e) => { const file = e.target.files[0]; const data = await file.arrayBuffer(); const workbook = XLSX.read(data, { type: 'array' }); const sheet = workbook.Sheets[workbook.SheetNames[0]]; const jsonArray = XLSX.utils.sheet_to_json(sheet); console.log(jsonArray); });sheet_to_json默认把 Excel 的每一行转成一个对象,表头作为对象的键。这个 jsonArray 的每一项就是一个“自定义结构体”,对应着“结构数组”那一节的概念。拿到数组后,可以再配合 Map 去做对象数组去重或按列筛选。
需要注意,大 Excel 文件不建议直接在前端一次性读成巨型数组,容易卡住浏览器。此时可以考虑使用 Web Worker 做解析,或限制文件大小,或等用户操作后再按需读取。
6.6 C# 二维像素数组转图片:LockBits 比 SetPixel 快得多
C# 中图像处理常需要把图片读成二维像素数组,再处理完写回 Bitmap。简单做法是双循环调用Bitmap.GetPixel/SetPixel,但这种方式每像素都要经过方法调用和边界检查,处理大图会非常慢。
推荐使用 LockBits + Marshal.Copy,把像素数据整块复制到字节数组:
using (Bitmap src = new Bitmap("input.png")) { Rectangle rect = new Rectangle(0, 0, src.Width, src.Height); BitmapData data = src.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); int bytes = Math.Abs(data.Stride) * src.Height; byte[] raw = new byte[bytes]; System.Runtime.InteropServices.Marshal.Copy(data.Scan0, raw, 0, bytes); src.UnlockBits(data); // 处理 raw 数组,比如灰度化、翻转,把处理结果写回新 Bitmap // 再 LockBits 一次 + Marshal.Copy(raw, 0, target.Scan0, bytes) 写回 }要注意data.Stride是每行字节数,它通常大于width * 4,因为内存对齐会补位到 4 的倍数。遍历像素时千万别直接用width * 4作为步长,否则行末会有几字节错位。实际处理二维像素时,可以把它当一维数组用:int pixelIndex = y * data.Stride + x * 4;,然后分别处理 B、G、R、A 通道。
这个顺手的小技巧处理 4K 图片时,速度能比 SetPixel 方式快几十倍。凡是涉及批量像素操作的 C# 图像程序,都应该优先走 LockBits 这条路。
数组这个东西,说难不难,但想用得稳、用得省、用得对,需要把底层的存储模型和各语言的语义差异真正捋清楚。我个人在经历了无数次越界、浅拷贝、传参退化、缓存不友好这些坑之后,最大的体会是:每换一种语言,第一件事不是抄语法,而是搞清楚这个语言里“数组”到底代表“一块连续内存”还是一个“引用对象”,这决定了后面所有写法的正确性。你写代码时踩过的那些数组相关的 bug,十有八九都能在这条底层差异线上找到答案。