Skip to content

字符串:不仅仅是字符数组

字符串是编程中最基础的数据类型,也是 LeetCode 中考察频率极高的主题(约占 20%)。

表面上看,字符串似乎只是"字符的数组",但在底层实现和内存管理上,它比数组要复杂得多。理解这些底层机制,不仅能帮你写出更高效的代码,还能让你在面试中展现出对技术深度的追求。


1. 字符串的本质与存储

1.1 字符的有序序列

在概念上,字符串确实是字符的有序序列。在 JavaScript 中,字符串使用 UTF-16 编码,这意味着:

  • 大多数常用字符(如字母、数字、汉字)占用 2 个字节(1 个码元)。
  • 某些特殊字符(如 Emoji 😀、生僻字)占用 4 个字节(2 个码元,称为"代理对")。

1.2 核心特性:不可变性 (Immutability)

JavaScript 中的字符串是不可变的。一旦创建,其内容就无法被修改。

javascript
let str = "Hello";
str[0] = "h"; // ❌ 无效操作,不会报错,但也不会改变
console.log(str); // "Hello"

深度思考:为什么要设计成不可变?

你可能会觉得不可变性限制了操作,但它带来了巨大的工程价值:

  1. 安全性:字符串常用于传递敏感信息(如密码、Token)或作为对象的 Key。不可变性保证了它们不会被意外(或恶意)修改。
  2. 性能优化(Hash 缓存):因为内容不会变,字符串的 Hash 值可以被缓存。这使得字符串非常适合作为 Map 的键或对象的属性名,查找速度极快。
  3. 内存共享(String Interning):相同的字符串字面量在内存中只需要存储一份。

1.3 V8 引擎下的字符串(进阶)

在 V8 引擎(Chrome/Node.js)内部,字符串的存储方式远比我们想象的智能。它不仅仅是一块连续的内存,而是有多种形态:

  1. OneByteString / TwoByteString:最普通的字符串。如果只包含 ASCII,使用单字节存储(节省内存);如果包含中文,使用双字节。
  2. ConsString (拼接字符串):当你执行 a + b 时,V8 并不一定会立即复制内存生成新字符串,而是创建一个"树节点"(ConsString),指向 ab。只有在真正需要读取内容时,才会进行拼接。这让字符串拼接在某些场景下非常快。
  3. SlicedString (切片字符串):当你执行 str.slice(0, 10) 时,V8 只是创建了一个"视图",指向原字符串的某一部分,而不会复制内容。
    • 警惕内存泄漏:如果你有一个 10MB 的大字符串,只截取了其中 10 个字符并持有引用,由于 SlicedString 引用了原字符串,导致整个 10MB 内存无法被回收。解决方案是显式拷贝:let small = big.slice(0, 10).repeat(1)(强制生成新字符串)。

2. 常用操作与最佳实践

虽然字符串方法很多,但从算法题角度,我们主要关注三类:查询截取转换

2.1 高效查询

javascript
const str = "Hello World";

// 基础查询
str.indexOf("o");       // 4 (O(n))
str.includes("World");  // true (O(n))
str.startsWith("He");   // true (O(k))

// 正则匹配(强大但慢)
str.search(/World/);    // 6

2.2 灵活截取

slicesubstringsubstr 三个方法中,请坚定地使用 slice

javascript
const str = "Hello World";

// slice(start, end) - 包含 start,不包含 end
str.slice(0, 5);   // "Hello"
str.slice(6);      // "World" (省略 end 到末尾)
str.slice(-5);     // "World" (支持负数,倒数第5个开始)

理由:slice 支持负数索引,且与数组的 slice 行为一致,记忆负担最小。

2.3 类型转换与数组互转

由于字符串不可变,修改字符串的通用模式是:转数组 -> 修改数组 -> 转回字符串

javascript
const s = "hello";

// 1. 转数组
const arr = s.split(''); // ['h', 'e', 'l', 'l', 'o']
// 或者使用展开运算符(推荐,能处理 Emoji)
const arr2 = [...s]; 

// 2. 修改
arr[0] = 'H';

// 3. 转回字符串
const newS = arr.join(''); // "Hello"

3. 性能陷阱:拼接操作

在循环中拼接字符串是一个经典的性能考点。

场景:拼接大量字符串

javascript
// 方式 A:直接拼接
let res = "";
for (let i = 0; i < 100000; i++) {
    res += "a";
}

// 方式 B:数组 Join
const arr = [];
for (let i = 0; i < 100000; i++) {
    arr.push("a");
}
const res = arr.join("");

结论

  • 在现代 V8 引擎中,方式 A (+=) 并不慢,甚至在某些场景下比数组更快,因为 V8 使用 ConsString 优化了拼接过程。
  • 但是,方式 B (Array.join) 性能更稳定,且跨浏览器/引擎的表现一致性更好。
  • 建议:在 LeetCode 刷题时,为了代码清晰和稳妥,推荐使用 数组收集 + join 的模式。

4. 常见坑点:Unicode 与 Emoji

当字符串包含 Emoji 时,length 属性会欺骗你。

javascript
const smile = "😀";

console.log(smile.length); // 2 (!! 并不是 1)
console.log(smile[0]);     //  (乱码,代理对的前半部分)

如何正确处理?

使用 ES6 迭代器for...of... 展开),它们能正确识别 Unicode 码点。

javascript
// 正确获取长度
const realLength = [...smile].length; // 1

// 正确遍历
for (const char of smile) {
    console.log(char); // "😀"
}

本章小结

  1. 心智模型:把字符串看作"不可变的字符数组",但要注意它底层的复杂性(UTF-16、ConsString)。
  2. 操作核心:修改字符串 = split + 修改数组 + join
  3. API 选择:截取认准 slice,遍历优先用 for...of(防 Emoji 坑)。
  4. 性能意识:虽然 V8 优化了 +=,但大规模拼接仍推荐用数组。

掌握了这些,你就跳出了"死记 API"的初级阶段,能从底层理解字符串的行为。接下来,我们将进入实战,学习字符串处理中最重要的思维模式。

字符串:不仅仅是字符数组 has loaded