2月19日学习(数组)
今天整理了一下数组相关的知识,发现这玩意儿真的是编程里最基础又最能藏坑的东西。你说它简单?声明、赋值、遍历,三件套下来小学生都会。但真要往深了抠,内存布局、类型推导、边界处理、跨语言差异,每一层都能翻出不少东西。
这两天搜资料的时候看到一堆关于数组的提问,从“C语言二维数组怎么初始化”到“西门子1500PLC怎么获取数组索引”,从“TypeScript数组有哪些方法”到“连续子数组乘积最大值怎么算”,跨度大得离谱。但这也说明一个问题——数组这个东西,不管你是搞嵌入式、写Web、做算法还是玩工业自动化,都绕不开。
这篇算是自己这段时间学习数组的一个系统性整理,把常见操作、底层原理、不同语言的差异、还有实际开发中容易踩的坑都过一遍。新手可以当一份入门手册,老手也能当个查漏补缺的清单。
1. 数组的第一性原理:内存布局与类型系统
1.1 数组到底是什么
数组本质上就是一段连续的内存空间,里面按顺序存放着相同类型的元素。这句话看着简单,但它是理解后面所有问题的基石。
连续内存意味着什么?意味着你可以通过“起始地址 + 索引 × 元素大小”直接算出任意元素的地址,这就是O(1)随机访问的理论基础。相同类型意味着什么?意味着每个元素占用的字节数一样,编译器才能准确计算偏移量。
举个例子,一个int arr[5],在32位系统上,每个int占4字节,整个数组占用20字节,内存布局长这样:
// 假设数组起始地址是 0x1000 // 0x1000 - 0x1003 arr[0] // 0x1004 - 0x1007 arr[1] // 0x1008 - 0x100B arr[2] // 0x100C - 0x100F arr[3] // 0x1010 - 0x1013 arr[4]网上很多人问“C语言数组变量的类型转换”,其实就是没想明白这一点。数组名在这个语境下会退化为指向首元素的指针,arr和&arr[0]在数值上是一样的,但类型不同——前者是int*,后者是指向整个数组的指针int(*)[5]。这两个搞混了,后面做指针运算的时候就会算出莫名其妙的结果。
注意:数组名不是指针!数组名是数组名,它是一个不可修改的左值,只是在表达式中会隐式转换为指向首元素的指针。这两者的区别在
sizeof运算中体现得最明显——sizeof(arr)返回的是整个数组占用的字节数,而sizeof(ptr)返回的是指针本身的大小。
1.2 各语言初始化方式的差异
数组初始化是每个语言入门第一课,但不同语言的初始化语法差异非常大,背后的设计理念也完全不同。
C语言传统数组:
// 完全初始化 int a[5] = {1, 2, 3, 4, 5}; // 部分初始化,剩余补0 int b[5] = {1, 2, 3}; // b[3] = 0, b[4] = 0 // 省略长度,由编译器推导 int c[] = {1, 2, 3, 4, 5}; // 长度为5 // 指定初始化器(C99) int d[10] = {[0] = 1, [5] = 2, [9] = 3};这里有个很多C语言新手容易忽略的点:int b[5] = {1, 2, 3};这种局部初始化,剩余元素会被自动补零,这其实是C标准规定的静态初始化语义。但如果你写的是int b[5];然后不初始化,局部变量就成了未定义行为,里面可能是任何垃圾值。我见过太多人在这个上面翻车,声明了一个数组不初始化就直接拿来用,结果跑出来一个随机数还以为是自己逻辑写错了。
C++的std::array和std::vector:
#include <array> #include <vector> // 定长数组,替代C风格数组 std::array<int, 5> arr = {1, 2, 3, 4, 5}; // 动态数组,长度可以运行时决定 std::vector<int> vec = {1, 2, 3}; vec.push_back(4);C++里我基本不推荐原生数组,除非是写底层或者有明确的性能要求。std::array在栈上分配,性能和原生数组完全一致,但自带边界检查(at()方法)和迭代器支持。std::vector是动态扩容的,存堆上,用起来最省心。
Java数组:
// 声明并初始化 int[] arr = new int[]{1, 2, 3, 4, 5}; // 动态创建,默认值 int[] arr2 = new int[5]; // 元素默认是0 // 数组赋值给另一个数组 int[] arr3 = arr2.clone(); // 深拷贝,新数组 int[] arr4 = Arrays.copyOf(arr2, arr2.length); // 也是深拷贝看到那个热搜词“java数组赋值给另一个数组的方法”没,这里必须说清楚:直接int[] arr3 = arr2;这不是赋值数组,这是让arr3和arr2指向同一个数组对象。你改arr3[0],arr2[0]也跟着变。真要想复制内容,要用clone()、Arrays.copyOf()或者System.arraycopy()。这个坑在Java面试里出现的频率极高。
JavaScript的数组是动态的:
// 直接声明 let arr = [1, 2, 3, 4, 5]; // 创建指定长度的空数组 let arr2 = new Array(5); // 注意:这里是长度5,不是[5] // 添加元素 arr.push(6); // 末尾添加 arr.unshift(0); // 开头添加 arr.splice(2, 0, 99); // 在索引2处插入99JS数组本质上不是传统意义上的数组,它是对象,键是字符串形式的索引,所以它可以不连续、可以混合类型、可以动态扩缩。这也解释了为什么JS数组的“性能”比不上真正的数组——因为它压根不是数组。
这里顺带提一下,热搜里有个“C++ 用unique_ptr智能指针生成动态char数组能用char*类型吗”,答案是可以的,但需要注意几点。std::unique_ptr<char[]>的get()方法返回的是char*,所以可以直接赋值给char*指针。但如果你对这个指针做了delete操作,那就违背了智能指针的管理原则,会导致双重释放或未定义行为。更合理的做法是:
std::unique_ptr<char[]> buffer(new char[1024]); char* raw_ptr = buffer.get(); // 没问题,可以这样用 strcpy(raw_ptr, "hello"); // 不要 delete raw_ptr; // 这块内存归unique_ptr管1.3 指针数组、字符数组和二维数组
热搜词里出现了很多“指针数组”“字符数组”“二维数组”,这几个概念初学者最容易搞混,但其实它们的本质区别非常清晰。
指针数组:是数组,每个元素是一个指针。
const char* fruits[] = {"apple", "banana", "cherry"}; // 这是一个长度为3的数组,每个元素类型是 const char*数组指针:是指针,指向整个数组。
int arr[3][4]; // 二维数组 int (*p)[4] = arr; // p是指向“长度为4的int数组”的指针二维字符数组:本质是“字符串数组”。
char names[3][20] = { "Alice", "Bob", "Charlie" }; // 3行20列,可以存3个最长19个字符的字符串热搜里还有个“C语言二维数组”,以及“c语言 二维数组 指针”,其实二维数组在内存里也是连续的,是“数组的数组”。arr[2][3]这个二维数组,在内存里就是连续的6个int元素。arr[i]的类型是int[3],arr的类型是int[2][3]。所以在函数传参的时候,void func(int arr[][3])这样写是对的,而void func(int arr[][])是错的,因为编译器无法计算第二维的偏移量。
2. 数组常用操作:跨语言对比与底层逻辑
2.1 获取数组索引
热搜词里有一条“西门子1500tplc,怎么获取数组的索引”,这个放在工业自动化场景下其实很典型。PLC里数组用的也不少,比如模拟量采集的缓存区、配方数据等。西门子1500系列支持SCL(结构化控制语言),获取数组索引的方式其实和通用编程差不多,就是遍历查找:
// SCL示例:在数组中找到值=50的索引 FOR i := 0 TO 99 DO IF data[i] = 50 THEN index := i; EXIT; // 找到就退出循环 END_IF; END_FOR;这里比较关键的一点是,PLC里数组的下标默认是从0开始,但有些老设备或者特定HMI组态软件里可能会从1开始,这个一定要看清楚手册。还有就是PLC里数组越界访问会导致CPU进入STOP状态(部分型号),所以做边界检查比在普通PC上更重要。
放在通用编程里,不同语言的索引获取方式差异很大。Python有list.index(),JavaScript有indexOf(),Java有Arrays.binarySearch()(要求有序),C/C++就得自己写循环。算法复杂度也要注意:无序数组查找是O(n),有序数组用二分查找是O(log n),哈希表是O(1)平均——用哪种取决于你的场景,不要无脑循环。
2.2 数组转字符串
“数组转字符串”在每种语言里都有对应的原生方法,但细节差异不少。
JavaScript:
let arr = [1, 2, 3, 4, 5]; let str1 = arr.toString(); // "1,2,3,4,5" let str2 = arr.join('-'); // "1-2-3-4-5" let str3 = JSON.stringify(arr); // "[1,2,3,4,5]"join()是最灵活的,可以指定分隔符。但要注意,如果数组元素是对象或者嵌套数组,toString()和join()都会调用元素的toString()方法,结果可能和你预期的不一样。
Java:
int[] arr = {1, 2, 3}; String str = Arrays.toString(arr); // "[1, 2, 3]"用Arrays.toString(),而不是直接arr.toString()——后者返回的是“类名@哈希码”,很多新手在这里懵了半天。
C++:
std::vector<int> vec = {1, 2, 3}; std::ostringstream oss; for (size_t i = 0; i < vec.size(); ++i) { if (i > 0) oss << ","; oss << vec[i]; } std::string str = oss.str(); // "1,2,3"C++标准库没有直接提供数组转字符串的接口,要么自己写循环,要么借助第三方库(如fmtlib)。不过C++20以后有了std::format,写起来会好一些。
2.3 二维数组的各种操作
二维数组本质上就是“数组的数组”,它在实际开发中常用于矩阵运算、图像处理、表格数据存储等场景。我整理一下几个常见操作:
C语言:遍历二维数组
int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; // 行优先遍历 for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%d ", matrix[i][j]); } }C语言二维数组在内存中是“行优先存储”的,也就是说第0行的4个元素连续存放,接下来是第1行的4个元素。所以在遍历时,把最内层循环放在最后一维上(即连续访问),CPU缓存命中率会高很多,性能差距在数据量大时非常明显。
MATLAB:取出多列
% 取出矩阵的第2列到第3列 sub_matrix = matrix(:, 2:3);MATLAB是列优先存储,而且索引从1开始,很多从C系语言转过来的人经常在这个地方出问题。matrix(:, 2:3)的含义是“所有行、第2到第3列”,出来的结果是一个N×2的矩阵。
Qt:调试时查看整个二维数组
热搜里有“qt5 debug 怎样设置可查看整个2维数组”,这个我也用Qt踩过。在Qt Creator调试时,直接点开一个int[3][4]类型的变量,默认只显示第一层的指针信息,看不到完整矩阵。解决办法是右键变量,选择“Change Value Display Format”,然后自定义格式为:
(*(int(*)[4])variable_name)这样就能以二维数组的形式展开查看。或者更简单的方法:在“Locals and Expressions”窗口里,手动添加表达式,写成(int(*)[4])variable_name,然后展开箭头,也能看到完整数据。实测下来这个方法最省事。
LabVIEW:字节数组转二进制数组
LabVIEW里有一个专门的转换方式,字节数组(U8数组)转成布尔数组或数值数组,用的是“Boolean Array To Number”或者通过“Type Cast”实现。这个场景通常出现在串口通信解析上,收到的原始字节要按位拆开处理。
2.4 动态数组与大小容量
动态数组(Dynamic Array)是个比较宽泛的概念。在C++里是std::vector,在Java里是ArrayList,在JavaScript和Python里,数组本来就是动态的。不同语言处理“扩容”的策略不一样,理解这个对写出高性能代码很有帮助。
std::vector的扩容策略一般是“倍增”,也就是当容量不够时,分配一块新内存(大小是原来的2倍),然后把旧数据搬过去,再释放旧内存。这个“搬迁”是O(n)的开销,所以频繁push_back会导致多次扩容拷贝,影响性能。解决方法是提前reserve():
std::vector<int> vec; vec.reserve(10000); // 提前预留容量 for (int i = 0; i < 10000; ++i) { vec.push_back(i); // 不会触发多次扩容 }热搜词里有个“C#中如何判断一维数组是否为空”,这个也值得提一下,因为“空数组”和“null数组”是两个完全不同的概念:
int[] arr1 = new int[0]; // 空数组,长度为0,不是null int[] arr2 = null; // null引用 // 判断是否为空 if (arr1 == null || arr1.Length == 0) { // 这样才是“空”或“null”的统一判断 }C#里new int[0]会返回一个长度为0的非null数组,直接用arr.Length == 0判断没问题,但如果变量可能是null,就得先判null再判Length,否则会抛NullReferenceException。
3. 数组算法与经典场景实战
3.1 数组去重:基础版到进阶版
数组去重是面试中出现频率最高的数组算法题之一。它的解法非常多,从最简单的双层循环到用哈希表,再到有序数组的双指针,复杂度从O(n²)一路降到O(n)。
JavaScript一行流:
const arr = [1, 2, 2, 3, 3, 4, 5, 5]; const unique = [...new Set(arr)]; // [1, 2, 3, 4, 5]这个利用了Set的特性,实现最简单。但要注意,Set去重是严格相等(SameValueZero),所以1和"1"不会被合并,{}和{}是两个不同的引用,对象数组去重不能这么搞。
对象数组去重:
热搜里有“对象数组去重”,这个稍微麻烦一点。Set对引用类型默认按引用比较,所以用Set去重对象数组是无效的(除非你保证引用相同)。通常的做法是:
const arr = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 1, name: 'Alice' } ]; // 按id去重 const unique = Array.from( new Map(arr.map(item => [item.id, item])).values() ); // 结果: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]这里用Map以id作为键,后面的覆盖前面的,从而实现去重。原理就是哈希表去重,时间复杂度O(n)。
有序数组去重(双指针):
// C++ 版本,原地去重 int removeDuplicates(std::vector<int>& nums) { if (nums.empty()) return 0; int slow = 0; for (int fast = 1; fast < nums.size(); fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }双指针在有序数组去重中非常优雅:fast负责探路,slow负责记录“最后一个不重复元素”的位置,一趟遍历O(n)搞定,且不需要额外空间。如果不是有序数组,就得先排序再双指针,整体O(n log n),或者直接用哈希表O(n)空间换时间。
3.2 树状数组(Fenwick Tree)与二分查找的结合
树状数组是一个比较进阶的数据结构,主要用来解决“单点修改 + 前缀和查询”的问题,两个操作都是O(log n)。热搜里有“树状数组上二分”,这个是在树状数组上做二分查找的技巧。
树状数组本身支持“前缀和”查询,但不支持“第k小”这种顺序统计量查询。不过由于树状数组每个节点的值表示的是一个区间的和,所以可以通过“二进制拆位+累加”的方式实现O(log n)的“求第k小”操作,原理类似二分,但利用了树状数组内部节点的层级关系。
// 树状数组经典结构 int tree[MAXN]; // 单点修改 void update(int i, int delta) { while (i <= n) { tree[i] += delta; i += i & (-i); } } // 前缀和 int query(int i) { int sum = 0; while (i > 0) { sum += tree[i]; i -= i & (-i); } return sum; } // 在树状数组上找“前缀和 >= target”的第一个位置 int find_kth(int k) { int pos = 0; for (int i = LOG_N; i >= 0; i--) { int next_pos = pos + (1 << i); if (next_pos <= n && tree[next_pos] < k) { pos = next_pos; k -= tree[next_pos]; } } return pos + 1; }这个find_kth本质上就是“倍增法”,从最高位开始尝试跳跃,如果跳过去之后前缀和仍小于k,就跳过去并减去这段区间的和。跑出来的效果就是O(log n)找到第k小的元素。
3.3 连续子数组乘积最大值
这个题目是“连续子数组乘积最大值”,和常见的“最大子数组和”有异曲同工之处,但难点在于乘法有个“负负得正”的问题。维护一个最大值是不够的,因为你不知道下一个负数是会让“最小乘积”变成“最大乘积”,还是让“最大乘积”变成负数。
正确的做法是同时维护两个变量:max_prod和min_prod,分别表示以当前位置结尾的子数组乘积的最大值和最小值。
int maxProduct(vector<int>& nums) { if (nums.empty()) return 0; int max_prod = nums[0], min_prod = nums[0], result = nums[0]; for (int i = 1; i < nums.size(); i++) { if (nums[i] < 0) { swap(max_prod, min_prod); } max_prod = max(nums[i], max_prod * nums[i]); min_prod = min(nums[i], min_prod * nums[i]); result = max(result, max_prod); } return result; }核心思想就是:遇到负数时,把当前最大和最小互换,因为“最大的乘积 × 负数”会变成最小的,而“最小的乘积 × 负数”反而可能变成最大的。这题在很多大厂面试中出现过,属于经典的“多状态动态规划”问题。
3.4 一个简单的成绩统计案例
热搜里有个“输入10个学生的成绩,求出平均分,用数组”,这个例子特别适合展示数组的基础应用。我用C语言写一个:
#include <stdio.h> int main() { float scores[10]; float sum = 0, average; printf("请输入10个学生的成绩:\n"); for (int i = 0; i < 10; i++) { printf("第%d个学生:", i + 1); scanf("%f", &scores[i]); sum += scores[i]; // 边输入边累加,省一趟循环 } average = sum / 10; printf("平均分:%.2f\n", average); // 低于平均分的学生人数 int below = 0; for (int i = 0; i < 10; i++) { if (scores[i] < average) below++; } printf("低于平均分的人数:%d\n", below); return 0; }这里有个小技巧:输入的时候就顺便累加,就可以少遍历一次数组。数组的意义在于,如果你只需求平均分,不存数组直接累加变量就行;但如果后面还要统计“多少人低于平均分”,那就必须把成绩存起来了。到底要不要用数组、用多大的数组,取决于你是否需要二次访问这批数据。
4. 多语言与多平台实战问题整理
4.1 TypeScript与JavaScript的数组方法
热搜里有一堆“typescript数组的方法”“ts 数组添加数据”“es6+提取数组对象一部分”,这里一起梳理掉。TypeScript是JavaScript的超集,数组方法沿用JS的,但类型系统让数组操作更安全。
let arr: number[] = [1, 2, 3, 4, 5]; let names: Array<string> = ['Alice', 'Bob']; // 添加数据 arr.push(6); // [1,2,3,4,5,6] arr = [...arr, 7]; // 不可变更新,返回新数组 arr = [0, ...arr]; // 头部插入 // ES6+ 提取数组对象的一部分 interface Person { id: number; name: string; age: number; } const people: Person[] = [ { id: 1, name: 'Alice', age: 20 }, { id: 2, name: 'Bob', age: 25 }, { id: 3, name: 'Charlie', age: 30 } ]; // 只要id和name字段 const result = people.map(({ id, name }) => ({ id, name })); // 或者排除某个字段 const withoutAge = people.map(({ age, ...rest }) => rest);// 坐标表达数组 const points = [[1, 2], [3, 4], [5, 6]]; // 解构 for (const [x, y] of points) { console.log(x, y); }热搜里“javascript 坐标表达数组”其实就是用二维数组表示坐标集合,这在Canvas绘图、游戏开发、地图处理里很常见。
4.2 C++的数组边界与多线程读写
热搜有“c++两个线程分别读写一个大数组”,这个场景多线程编程中很经典。两个线程一个读一个写同一个大数组,需要区分情况:
- 一个线程读、一个线程写不同位置:不用加锁,但前提是内存不冲突,且写入的数据是完整可见的(需要考虑内存序)。
- 一个线程读、一个线程写同一位置:必须同步,否则就是数据竞争(data race),是未定义行为。
- 多个线程读:没问题,可以并发读。
#include <thread> #include <vector> #include <atomic> // 例一:读写不同位置,安全 std::vector<int> arr(1000000); std::thread writer([&]() { for (int i = 0; i < 500000; i++) { arr[i] = i; } }); std::thread reader([&]() { for (int i = 500000; i < 1000000; i++) { std::cout << arr[i] << " "; } }); // 例二:读写同一位置,有数据竞争,需要用锁或原子变量 std::atomic<int> counter{0}; std::thread t1([&]() { for (int i = 0; i < 100000; i++) counter++; }); std::thread t2([&]() { for (int i = 0; i < 100000; i++) counter++; });C++的坑在于,如果你用原生数组而不是std::vector,传递长度信息很容易出错。常见做法是传指针+长度,或者用std::span(C++20引入)来封装数组视图,安全性和表达力都更好:
#include <span> void process(std::span<int> arr) { for (int& x : arr) { x *= 2; } } int data[100]; process(data); // 自动推导长度4.3 JSON数组与YAML数组的解析
热搜词里出现了“json数组”和“yamlcpp读取数组”,这俩都在配置解析场景中高频出现。很多程序员把时间花在“到底怎么把配置文件里的数组读出来”这个问题上。我这里简单给个小例子:
JSON数组:
// JSON.parse 直接得到数组 const jsonStr = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]'; const arr = JSON.parse(jsonStr); console.log(arr[0].name); // AliceC++ yaml-cpp 读取数组:
#include <yaml-cpp/yaml.h> #include <vector> #include <string> // YAML 内容: // fruits: // - apple // - banana // - cherry YAML::Node config = YAML::LoadFile("config.yaml"); std::vector<std::string> fruits; for (const auto& node : config["fruits"]) { fruits.push_back(node.as<std::string>()); }这里要注意,yaml-cpp遍历节点时,如果节点是序列(数组),迭代器按顺序遍历。但如果节点是映射(对象),迭代器顺序是不确定的,所以在读取数组时最好用明确的索引方式:config["fruits"][0].as<std::string>(),而不是依赖遍历顺序。
4.4 工业场景:PLC、LabVIEW与数组
整个热搜词里最有意思的一类,是工业现场的数组问题。我搜集到西门子PLC、LabVIEW和Qt调试的相关提问,这些场景说明一个事实:数组不只是互联网后端或算法面试的东西,嵌入式开发、工控组态、测试测量里数组一样是核心数据结构。
西门子1500 PLC获取数组索引
SCL是1500系列的主要编程语言之一,查找数组索引的通用写法前面已经提到了。这里补充一个经验:在TIA Portal里,数组越界会导致CPU进入STOP模式(默认设置下)。所以在PLC里写数组遍历时,最好用LOWER_BOUND和UPPER_BOUND指令获取数组边界,这样可以写出对变量长度自适应的代码,避免写死上下标:
// SCL 动态边界遍历 FOR i := LOWER_BOUND(data) TO UPPER_BOUND(data) BY 1 DO // 处理data[i] END_FOR;LabVIEW 字节数组转二进制数组
LabVIEW中处理串口数据时经常要用到这个。收到的是一个U8字节数组,但业务逻辑需要按位读取。可以用“Boolean Array To Number”函数,或者在“Numeric Conversion”里转换数据类型。如果要把字节数组按位拆成布尔数组,可以先做位掩码运算:
// 伪代码:把字节数组的第n位取出来 bit_value := (byte_array[i] >> n) & 1LabVIEW的数组操作和文本语言差异很大,它是基于“数据流”的图形化编程,数组的索引、切片、替换都是在“程序框图”上用节点完成的。新手很容易找不到“取出某一行/某一列”的节点,实际上在“数组”函数选板里有“Index Array”和“Array Subset”两个节点能解决绝大部分需求。
Qt 调试查看二维数组
这个前面章节已经提到过,关键就是用类型转换表达式(int(*)[4])variable_name来强制让调试器按二维数组格式展开。还有一个经验是,在Qt Creator里打断点后,可以把鼠标悬停在变量名上,等弹出提示框后右键选择“Preferences”,把“Display array elements”的阈值调大,这样大数组也能完整显示。
5. 现实中我会怎么用:一份数组避坑清单
5.1 边界与初始化:80%的数组Bug都出在这里
我把自己踩过、同事踩过、看过别人踩过的坑做个清单整理,按出现频率排个序,基本就是这份避坑表:
| 问题类型 | 典型错误 | 正确做法 | 涉及语言 |
|---|---|---|---|
| 索引越界 | for (i=0; i<=n; i++) arr[i] | i < length | C/C++/Java/C# |
| 未初始化 | int arr[5];直接用 | 初始化或先填默认值 | C/C++ |
| 浅拷贝 | arr2 = arr1后修改arr2 | clone()/copyOf()/Arrays.copy | Java |
| 索引从0还是1 | MATLAB里写成arr(0) | 确认语言约定 | MATLAB |
| 数组变指针 | sizeof(arr)在函数内求数组大小 | 用std::size或传长度参数 | C/C++ |
| 空数组vs null | 直接arr.Length没判null | 判null再判Length | C#/Java |
| 引用相等 | 用==比较两个数组内容 | Arrays.equals()/std::equal() | Java/C++ |
最后一项值得多说两句。Java里==比较数组比较的是引用是否相同,不是内容是否相等。两个数组内容完全一样,==返回false。要比较内容,Java用Arrays.equals(arr1, arr2),C++用std::equal(std::begin(a), std::end(a), std::begin(b)),JavaScript用every或者先把数组转字符串。
5.2 数组处理小技巧
最后分享几个自己平时攒的小技巧,比较杂,但实用性都不错:
交换数组元素——不引入临时变量时,可以用异或操作:
arr[i] ^= arr[j]; arr[j] ^= arr[i]; arr[i] ^= arr[j];。注意在语言层面这是安全的,但在生产代码里我还是推荐老老实实写临时变量,可读性比那几微秒重要得多。动态扩容的代价——普通数组插入头部元素是O(n)操作,因为后面所有元素都要往后挪。如果频繁在头部插入,用
std::deque(C++)、ArrayDeque(Java)、linked list(C)都比数组合适。一句话总结就是:看你的访问模式。二维数组与缓存——C/C++里按行遍历比按列遍历快,因为内存是行优先的,按行访问时CPU缓存命中率高。数据量大的时候这个差异能拉到10倍以上。这个在“c语言二维数组”相关代码优化中特别重要。
判断数组相等或包含——不需要自己写双层循环。C++用
std::equal/std::find,Java用Arrays.equals/List.contains,JS用includes/every,这些现成的方法比自己手写的循环少90%的Bug。把数组传参时的长度一起带上——不要指望在被调函数里用
sizeof(arr)/sizeof(arr[0])来算数组长度,数组在传参时已经退化成指针了。C++里用std::vector或std::array,或者传size参数,或者用std::span,都比裸指针+隐含长度设计要好。
5.3 后续学习方向
说句实在话,数组本身不是难点,难的是和数组配套的那些东西。我今天花了大半天把这些知识串了一遍,接下来准备往这几个方向深入:
一是树状数组和线段树的延伸,这俩在区间查询类题目里是王牌结构,我整理完树状数组上二分之后发现线段树的可持久化版本也值得啃一遍。
二是多维数组在图像处理中的应用,其实就是二维数组的实操版本,卷积、滤波这些操作本质都是数组遍历和邻域运算,等下次有空我把OpenCV里对Mat对象的遍历方式也整理一篇。
三是C++中数组和STL容器的性能对比。我目前的理解是std::vector比原生数组更安全,但底层实现到底差在哪、什么时候该用std::array、什么时候该用vector、什么时候该客串一下裸数组,这中间的取舍值得再做一组benchmark实测。
数组这个东西,就是这样——看起来谁都会,但每个细节抠出来都有一篇千字文章能写。今天先记到这里,下次继续。