数组和字符串是每个写程序的人绕不过去的基础。我在带新人或者做技术分享时经常发现一个有意思的现象很多人能写出“能用”的代码但对数组和字符串的底层逻辑、边界条件、跨语言差异其实是一笔糊涂账。比如C里std::string和C风格字符数组到底什么关系Java的String为什么不可变Python的列表和数组又有什么本质区别这些问题一旦较真马上就能筛掉一大半人。这篇文章我想以“第六章数组与字符串”为主线把我这些年踩过的坑、验证过的结论、以及不同语言之间的对照系统性梳理一遍。不管你是刚学编程的初学者还是写了几年业务代码想补一补内功的开发者这篇内容应该都能给你一些参考。1. 先把底层的存储逻辑搞明白1.1 数组不是“一组数”而是一段连续内存“数组”这个中文译名其实有点误导性它容易让人以为数组只是“一组数字”。实际上数组是一段连续的内存空间里面可以放数字、放字符、放结构体、放对象引用。关键在于“连续”这两个字。因为连续所以访问下标为i的元素时CPU可以直接通过“首地址 i × 每个元素大小”算出地址这就是为什么数组随机访问的时间复杂度是O(1)也是为什么数组在大多数场景下比链表更适合高频随机访问。链表虽然插入删除灵活但它每个节点散落在内存各处访问第i个节点必须从头开始遍历时间复杂度是O(n)。理解了连续存储很多看似玄乎的问题就都能推导出来。比如C语言里int a[10]如果你访问a[10]编译器不一定报错但你已经越界了因为你访问的是数组结束之后的那块内存。再比如二维数组int a[3][4]它在内存里仍然是一段连续空间总共12个int按行优先排列。所以a[1][2]本质上等价于*(a 1 * 4 2)这也是为什么二维数组传到函数里时第二维必须指定因为编译器要算出正确的偏移量。在更底层的环境中比如MIPS汇编里操作数组甚至还要关心内存对齐。因为MIPS要求访问word时地址要4字节对齐否则会触发异常。很多学过汇编的人第一次写数组遍历时直接用lw读取一个char类型数据结果程序直接崩掉就是因为没搞懂“数组连续”以外的另一个要点不同数据类型有不同的大小和对齐规则。1.2 字符串的本质是字符数组但不是所有语言都这么简单C语言没有原生字符串类型char数组就是字符串字符串结尾用\0标识。这是最底层、也最容易被忽视的事实。char str[] hello这个数组的长度不是5而是6因为末尾会多一个\0。所以你用sizeof(str)得到6用strlen(str)得到5这两个结果经常让人懵。但C、Java、Python、C#这些语言里的字符串本身是类对象底层普遍也是字符数组或字节数组只是外面包了层更友好的接口。这种封装带来了便利也带来了一系列新的约束。一个非常关键的约束是在很多语言里字符串是不可变的。Java的String、C#的string、Python的str都不可变。也就是说任何replace、substring、toUpperCase操作返回的都是一个新对象底层数组并不会被原地修改。这个特性会带来一个非常现实的性能问题如果你在循环里做大量字符串拼接就会不断创建新对象产生大量中间垃圾。正确做法是使用StringBuilder或StringBuffer。这一点很多写了几年业务代码的人都会犯我后面第3.4节还会专门展开。2. 数组操作从初始化到边界全是细节2.1 初始化方式对比以及C数组初始化的坑“C字符串数组初始化”是很多人搜索过的话题这个看着简单坑是真不少。先看C里最常见的几种写法int arr[5]; // 未初始化里面是随机值 int arr[5] {}; // 全部初始化为0 int arr[5] {1, 2, 3}; // 前三个指定后面自动补0 int arr[] {1, 2, 3}; // 自动推断长度为3 char str[] hello; // 长度是6别忘了一个隐藏的\0 const char* str hello; // 指向字符串字面量严格来说不能修改这里最容易出问题的就是char数组和const char*的区分。char str[]是一个可修改的数组副本你可以改str[0]而const char* str指向的是一块只读的字符串字面量如果你尝试修改它在有些平台上会直接段错误有些平台则是未定义行为。网上还有人问unique_ptr生成动态char数组能不能用char*类型来接收。这背后的本质问题其实是“所有权”和“类型”的混淆。std::unique_ptrchar[]可以生成动态字符数组调用get()方法确实能拿到char*但如果你把这个裸指针再赋值给另一个char*变量并且最后手动delete了它那就会和unique_ptr析构时的自动释放冲突造成双重释放。正确做法是让unique_ptr始终持有这个动态数组只有在需要传参或调底层接口时才临时用get()取出裸指针但不要让它脱离unique_ptr的生命周期。举个例子std::unique_ptrchar[] buffer(new char[1024]); snprintf(buffer.get(), 1024, hello %d, 123); char* raw buffer.get(); // 只借不还不能自己去delete这个习惯能帮你避免一大批内存问题。2.2 索引、指针数组、二维数组与内存布局“如何获取数组的索引”这个问题在普通编程里似乎不值一提但在工控场景比如西门子S7-1500 PLC里却是一个真实存在的需求。PLC里数组结构用于批量管理模拟量、工艺参数想要定位某个变量的索引本质上和计算机数组一致要么用循环遍历比较要么维护一张索引映射表。但在PLC里循环要讲究扫描周期如果你为了找一个索引把整个数组从头到尾扫一遍可能在实时性要求高的场景里出问题。更合理的做法是建立哈希表或按关键字段排序后用二分定位这也是工业软件和普通应用的一个显著区别。再看指针数组和二维数组。很多人分不清这两个概念其实它们内存布局完全不同。int a[3][4]; // 一段连续内存12个int排成一排 int* b[3]; // 一个数组里面存了3个int*指针a是真正的二维数组所有数据连续存放b只是存放了指针每个指针可能指向不同的内存区域。在C/C里char *argv[]就是典型的指针数组每个元素指向一个字符串这些字符串的长度可以完全不同但指针数组本身大小固定。如果你要传参int a[][3]和int (*a)[3]其实等价因为数组在函数参数传递时会退化成指针。难点在于一个二维数组传入函数后编译器需要知道第二维大小才能计算a[i][j]的地址。这也是为什么C语言规定二维数组传参时第二维必须显式声明否则编译器就懵了。至于“二维字符数组”本质上就是字符串数组。比如char names[3][20] {Alice, Bob, Charlie};这表示三行每行最多存20个字符。因为每行长度固定即使“Bob”只有3个字符也要占20字节内存浪费在所难免。如果你的字符串长度差异很大更合适的是指针数组或者std::vectorstd::string。空间换时间还是时间换空间看业务场景决定。2.3 动态数组、去重与二分查找的边界数组去重是一个高频题目从最简单的“双重循环”到“哈希Set”再到“排序后相邻去重”方案很多。但热词里有个“对象数组去重”特别值得单独拿出来说。如果是JavaScript中的对象数组直接用Set去重是无效的因为每个对象都是独立引用Set比较的是引用地址而不是对象内容。哪怕两个对象长得一模一样只要不是同一个引用Set也会认为它们不同。正确做法是使用Map按唯一字段去重const arr [ { id: 1, name: a }, { id: 1, name: b }, { id: 2, name: c } ]; const map new Map(arr.map(item [item.id, item])); const result [...map.values()];如果去重要求保留第一个还是最后一个只要调整生成Map时的覆盖顺序即可。这个技巧在很多前端面试和实际数据清洗中经常用到。另一个底层一点的进阶话题是“树状数组上二分”。普通的树状数组擅长求前缀和但如果要查找“前缀和大于等于某个值的最小位置”最直接的做法是二分一下位置再查前缀和复杂度是O(log^2 n)。树状数组上二分则利用了下标二进制中“1”的分布从高位往低位尝试累加可以在O(log n)内找到答案。写起来并不复杂int find_kth(int k) { int pos 0; for (int i LOG; i 0; --i) { int next pos (1 i); if (next n bit[next] k) { pos next; k - bit[next]; } } return pos 1; }这个算法的核心思路是从大到小尝试填充二进制位如果当前累计值还不足以满足第k个前缀和就把这个位置先占住继续往下找。它比二分套树状数组更适合处理动态变化的数据比如带查询和修改的排行榜、逆序数计数等等。虽然普通业务开发用不上但算法面试和竞赛里很香。3. 字符串处理逆序、替换、分割、转换的通用套路3.1 字符串逆序的三种写法字符串逆序是入门题但不同写法能看出一个人对底层是否理解。这里我对比三种常见写法。第一种是双指针原地交换适合C/C这类能直接访问字符的语言void reverse_str(char* s) { int left 0, right strlen(s) - 1; while (left right) { swap(s[left], s[right]); left; --right; } }第二种是递归写法。递归思路是把第一个字符移到末尾剩下的子串继续逆序。这个思路其实和“递归法将一个整数n转换成字符串”很像核心都是“缩小规模 处理边界”。用C语言写整数转字符串的递归版本void to_string_recursive(int n, char* buf, int* pos) { if (n 0) { buf[(*pos)] -; n -n; } if (n / 10 ! 0) { to_string_recursive(n / 10, buf, pos); } buf[(*pos)] 0 n % 10; buf[*pos] \0; }第三种是借助语言自带能力比如C的std::reversePython的s[::-1]Java的StringBuilder.reverse。自己手写并不复杂但当然考算法时还是别偷懒。3.2 字符串替换与分割的注意点字符串替换最常见的坑是“连续替换”和“替换后位置移动”。比如要把a-b--c里的--替换成-如果用String.replace按顺序扫描很容易把新生成的内容再次纳入替换范围产生非预期的结果。比如Java里str.replace(--, -)不会循环替换所以结果正确但如果自己手写循环用indexOf加substring拼接就要额外注意searchFrom的推进位置。字符串分割同样有陷阱。刚接触的朋友通常会用split(-)去切一个a-b--c结果发现两个连续分隔符之间会产生一个空字符串。这在很多语言里是默认行为但不同语言处理方式不一样。Javaa-b--c.split(-)结果是[a, b, , c]默认丢弃末尾空串。Pythona-b--c.split(-)结果是[a, b, , c]保留所有空串。C#Split(-)默认不保留空条目可以通过参数调整。SQL ServerSTRING_SPLIT只支持单字符分隔符且不返回顺序号这是很多人第一次用的时候会踩的坑。如果业务上需要把字符串按分隔符拆成多行在SQL Server 2016及以上版本可以用STRING_SPLIT但它不保证输出顺序而且不能处理多个连续分隔符。想保留顺序要么改用OPENJSON技巧要么在应用层先把字符串处理成带序号的结果集。3.3 字符串与数字互转跨语言踩坑记录字符串转数字是个看似简单、实际很容易翻车的话题。热词里出现了“sqlserver 字符串转数字”“python十六进制和字符串互转”“java字符串转json jackson”这些问题本质上都属于“类型解析”。先看不同语言的转换接口语言字符串转数字数字转字符串注意事项C / Catoi/strtolsprintf/snprintf/to_stringatoi无法区分错误和合法0JavaInteger.parseInt/Long.parseLongString.valueOf非法输入抛NumberFormatExceptionPythonint(str, base)str(num)支持任意进制C#int.Parse/TryParseToString建议优先用TryParseSQL ServerCAST/CONVERT/TRY_CONVERTSTR/CASTCONVERT失败直接报错以atoi为例很多老C代码喜欢用它但它遇到12abc时不会报错而是返回12只解析前缀数字遇到abc时返回0但这不是合法的“0”可函数无法告诉你它失败了。更可靠的是strtol它能通过endptr告诉你解析停在了哪个位置。十六进制互转也是高频需求。Python里# 字符串转整数告诉int是16进制 value int(1a, 16) # 26 # 整数转16进制字符串 hex_str hex(26) # 0x1a # 去掉前缀 hex_str_no_prefix format(26, x) # 1a # 字节串和十六进制字符串互转 bytes.fromhex(1a2b) # b\x1a b\x1a\x2b.hex() # 1a2bJava中如果要用Jackson把字符串转成JSON对象常见的正确写法是ObjectMapper mapper new ObjectMapper(); MapString, Object map mapper.readValue(str, new TypeReferenceMapString, Object() {});这里要注意的是readValue需要捕获JsonProcessingException而且更推荐用TypeReference来保留泛型信息否则结果是LinkedHashMap类型转换时容易出意外。3.4 编码、大小写、不可变性与拼接性能字符串处理到了生产环境除了“能不能实现功能”更要关心“会不会隐藏性能问题”。最大的一块坑集中在“字符长度”与“字节长度”的混淆。Java里String.length()返回的是UTF-16编码下的char数组长度。一个中文占一个char但如果字符串里包含表情符号emoji这个字符可能占两个char导致length()返回2而屏幕上用户只看到1个字符。Python 3的len()直接统计Unicode码点数量一个emoji通常算1。C#里string.Length则是char数量。所以在不同语言里同样一个你好长度函数返回的结果可能不一样。如果你做短信计费、数据库字段长度校验必须搞清楚系统用的是字符数还是字节数。大小写转换同样有语言差异。土耳其语环境下的I.toLowerCase()可能变成ı不带点的小写i这在跨语言国际化项目里非常容易翻车。稳妥做法是指定Locale.ROOT或Locale.ENGLISH不要直接依赖系统默认区域。再讲不可变性和拼接性能。Java里这样写String s ; for (int i 0; i 10000; i) { s i; }每次都会创建一个新字符串把旧内容复制一遍再追加时间复杂度是O(n^2)。数据量小看不出来但一旦循环次数到十万、百万级别程序会明显卡顿。正确做法是用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String s sb.toString();同理C#用StringBuilder或string.ConcatPython则推荐用joinparts [] for i in range(10000): parts.append(str(i)) text ,.join(parts)这里的原则是一致的尽量一次性分配充足空间减少中间对象的创建。4. 常见问题与排查技巧实录4.1 数组越界为什么难查数组越界是C/C开发者的老朋友。最气人的是它不一定立刻崩溃很多时候只是悄悄改坏了旁边某个变量导致程序在一个完全不相关的地方出现诡异现象。比如你声明了int a[10]; int b 100; // 某处错误地执行了 a[10] 5;因为a[10]的内存可能恰好紧挨着b你写入5之后程序后续逻辑以为b还是100实际却变成了5排查起来非常困难。定位手段一般来说三种使用AddressSanitizer编译选项GCC/Clang加-fsanitizeaddress能精确报告越界地址和调用栈。Linux下用Valgrind跑一遍内存非法访问会直接报错。在上述手段都不可用的情况下可以在关键区域增加边界变量守护区在数组前、后各放一个固定值每次检查它们有没有被改写。Java、C#这类带运行时检查的语言数组越界会直接抛ArrayIndexOutOfBoundsException或IndexOutOfRangeException好排查得多但对性能有一定影响。某些非常追求性能的框架会用Unsafe或Span绕开检查那就是另一个领域的话题了。4.2 字符串比较到底用还是equals字符串比较是跨语言最容易踩的坑之一。不同语言对的定义完全不同。C语言比较的是指针地址所以两个内容相同的字符串用比较结果几乎总是false。必须用strcmp。Cstd::string重载了可以直接比较内容。Java比较引用地址equals比较内容。从常量池来的字面量用可能相等但new String出来的对象用必然不等。C#string的被重载为值比较所以用没问题。但如果在某些泛型或动态场景里用了ReferenceEquals结果可能又不一样。Python比较内容is比较引用。最典型的问题出现在Java字符串常量池。以下代码String a hello; String b hello; String c new String(hello); System.out.println(a b); // true因为常量池复用 System.out.println(a c); // false因为c是新对象 System.out.println(a.equals(c)); // true这段代码运行后很多人会对a b产生误区以为Java的可以比较字符串内容。其实a b返回true只是因为编译器和JVM把它们指向了同一个常量池对象。一旦字符串来自运行时输入或拼接的结果就不可依赖了。稳妥规则只有一个Java里比较字符串内容一律用equals。4.3 大数据量数组操作多线程读写和去重的正确姿势热词里有个典型的C问题“两个线程分别读写一个大数组”。这个问题要分情况讨论。如果是两个线程分别读数组的不同区域或者一个线程读、一个线程写但写的区域和读的区域完全不重叠那不需要加锁因为不存在数据竞争。比如std::vectorint data(1000000); std::thread t1([]() { for (int i 0; i 500000; i) data[i] i; }); std::thread t2([]() { for (int i 500000; i 1000000; i) data[i] i * 2; });这两个线程各管一半互不干扰可以安全并发。但如果是两个线程同时读写同一片区域就必须加锁或使用原子变量。C的std::atomic只适合简单类型复杂结构、容器或数组的某个区段最好用std::mutex做临界区保护。还有一种做法是“读改写分离”也就是双缓冲。后台线程往一个独立缓冲里写完整副本写完后原子切换指针前台线程只读当前快照。这样既避免了锁竞争又能保证数据一致性非常适合游戏或UI渲染场景里的大数组更新。至于大数据量的字符串列表去重仍然遵循“先哈希再批量”的思路。在C里可以用std::unordered_set在Java里用HashSet在SQL Server里可以先DISTINCT再插入临时表避免程序内存吃爆。4.4 其他高频疑问速查最后把一些搜索频率很高的“小问题”集中整理成表格方便你直接查。问题结论TypeScript数组添加数据推荐用push需要新数组时用展开运算符[...arr, item]TypeScript数组常用方法map、filter、reduce、some、every、sort注意sort默认按字典序JavaScript模板字符串里加变量用${变量}比如${name}JavaScript对象数组去重用Map按唯一字段去重不能直接用SetJava字符串转JSON用Jackson的readValue泛型用TypeReferenceDelphi字符串作字典Key可以但注意大小写敏感用TDictionarystring, Integer前搞清楚比较模式Qt Debug查看整个二维数组在调试器变量窗口右键数组名选择“按数组方式展开”或添加调试器格式化表达式C两个线程操作同一数组分区域读写可不加锁同区域必须加锁或用原子变量递归将整数n转成字符串先处理负数再递归n/10最后设置结尾\0SQL Server字符串包含判断用CHARINDEX(子串, 字段) 0或LIKE %子串%SQL Server字符串转数字CAST(字段 AS INT)失败会报错容错用TRY_CONVERT(INT, 字段)C#判断一维数组是否为空array nullPython十六进制和字符串互转int(x, 16)和bytes.fromhex/.hex()还有一类很有趣的问题比如字符串谜题“我们得到了一串神秘字符串tasc?o3rjmv?wdjkx?zm问号部分是未知大写字母为了确认信息……”这类题目本质上是字符串遍历、ASCII码转换、大小写判断的综合练习。解法很简单遍历每个字符遇到问号则按条件尝试补上大写字母然后统一转成可用字符串继续处理。它考察的知识点并没有超纲只是把数组索引、字符判断、字符串拼接放在一个场景里。5. 从基础语法到工程习惯几点个人心得数组和字符串的内容很多教程会在一章里快速带过但实际工程里它俩的坑远不止语法层面。第一个体会是写代码前先想清楚“这段数据放在内存里是什么样子”。这在C/C里尤为重要。数组越界、指针悬空、unique_ptr滥用根源都是对底层布局理解不够。哪怕你用Java、Python这种带自动内存管理的语言理解字符串拼接会创建新对象、理解数组扩容是会复制元素的也能解释很多性能问题。第二个体会是不同语言的“字符串”不是一个东西踩坑前最好先确认当前语言的语义。Java里和equals不一样C#里被重载成值比较C里必须靠函数。SQL Server里给字符串转数字要思考失败会不会导致整个查询报错。这些差异不是编程语言在故意刁难人而是因为设计方向不同。Java选择不可变字符串是为了安全C#重载是为了让代码更自然C选择不检查边界是为了性能。理解设计意图比你死记硬背规则更能应对陌生语言。第三个体会是工具链和调试手段同样重要。遇到数组越界C可以用AddressSanitizer遇到字符串格式异常Java可以打印堆栈遇到SQL Server转换报错先拆SQL逐步验证。有时候排查方向对了5分钟就找到问题方向错了可能折腾一个晚上。最后再分享一个小技巧写数组和字符串相关代码时第一件事不是直接写实现而是先想清楚边界条件。字符串为空、数组长度为0、字符串只包含一个字符、数组包含负数、输入带空格……把这些边界情况先用注释列出来再写代码会省掉非常多调试时间。数组和字符串是基础但基础不等于简单。愿意多花一点时间把这些细节吃透后面学任何框架、做任何业务都会顺很多。