字符串算法入门:从反转字符串到回文判断,面试不再慌
开篇:字符串,程序员最熟悉的"陌生人"
每天写代码,你一定和字符串打过无数次交道------拼接 SQL、解析 JSON、处理用户输入......字符串无处不在。
但你有没有想过:为什么 JavaScript 里数组有 reverse() 方法,而字符串没有? 'abc'.length 明明能取到长度,可 'abc' 不是一个基本数据类型吗,它哪来的 .length?
这些问题看似基础,背后却藏着 JS 语言设计的精妙之处。而围绕字符串的算法题------反转字符串、回文判断------更是面试中的"常客"。
今天,我们就从这些基础问题出发,把字符串算法的核心套路彻底搞懂。
一、先备知识:JS 中的字符串到底是什么?
在进入算法之前,我们先搞清楚一个更根本的问题。
1.1 基本类型,哪来的属性和方法?
javascript
let str = 'abc'; // 简单数据类型
console.log(str.length); // 3 ← 凭什么?
str 明明是一个简单数据类型(原始类型),存放在栈内存 中。按理说它不应该有属性和方法。那 .length 是怎么来的?
答案藏在 JS 的包装类机制里:
javascript
// 当你写 str.length 时,JS 底层偷偷做了这件事:
// 1. 临时将 str 包装成 String 对象:new String('abc')
// 2. 从这个 String 实例上读取 .length 属性
// 3. 用完之后,自动销毁这个临时对象,把 str 恢复为简单数据类型
// 就像灰姑娘的魔法------到时间就自动消失!
📌 知识点 --- 包装类(Wrapper Class) : JS 中有三种基本类型拥有对应的包装类:
String、Number、Boolean。 当你尝试在基本类型上调用属性或方法时,JS 引擎会自动装箱(Autoboxing) ------临时创建一个对应的包装对象,调用完后再拆箱回去。 这样做的好处是:既保留了基本类型的高效(存栈内存、不可变),又让开发者可以用面向对象的方式统一操作它们。
javascript
let str1 = 'abc'; // 简单类型,存栈内存
let str2 = new String('abc'); // 对象实例,存堆内存
// 用 Object.prototype.toString 来区分类型
Object.prototype.toString.call(str1); // "[object String]"
Object.prototype.toString.call(str2); // "[object String]"
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call({}); // "[object Object]"
1.2 Object.prototype.toString --- 类型判断的"终极方案"
你可能会问:typeof 不行吗?
javascript
typeof 'abc'; // "string" ✅ 还可以
typeof [1,2,3]; // "object" ❌ 没法区分数组和普通对象
typeof null; // "object" ❌ 历史遗留 bug
Object.prototype.toString.call() 是 JS 中判断变量具体子类型的最可靠方式。它的原理是:
📌 知识点 --- call 方法 :
call可以让一个函数"借用"给另一个对象执行,并指定函数运行时的this指向。 比如Object.prototype.toString.call([])的意思是:把Object.prototype上的toString方法借给数组用,this指向这个数组,于是返回"[object Array]"。 这种"函数借用"的模式在 JS 底层和高级编程中非常常见。
javascript
// 函数借用示例
let boss = {
name: '邰老板',
say() { console.log(this.name); }
};
let colleague = { name: '甜总' };
boss.say(); // "邰老板"
boss.say.call(colleague); // "甜总" ← this 被 call 改变了指向!
二、反转字符串:看似简单,实则暗藏玄机
2.1 为什么字符串没有 reverse 方法?
这是个经典的面试闲聊题。答案很简单:
字符串在 JS 中是不可变的(immutable) 。你不能原地修改字符串中的某个字符,任何"修改"操作实际上都是创建了一个新字符串。而
reverse是一个原地修改的方法,与字符串的不可变性冲突。
javascript
let s = 'abc';
s[0] = 'x'; // 无效!严格模式下甚至报错
console.log(s); // 还是 "abc"
// 数组则不同------它是可变的
let arr = [1, 2, 3];
arr[0] = 99; // 可以!
console.log(arr); // [99, 2, 3]
2.2 三种语言对比
| 语言 | 字符串 reverse | 说明 |
|---|---|---|
| Python | s[::-1] |
切片语法,一行搞定 |
| JavaScript | str.split('').reverse().join('') |
迂回战术 |
| Java | new StringBuilder(s).reverse().toString() |
借助可变类 |
JS 没有直接的 reverse,但我们可以"借道"数组:
javascript
const str = 'abc';
// 分三步走:拆分 → 反转 → 拼接
const reversed = str.split('').reverse().join('');
console.log(reversed); // "cba"
📌 知识点 --- 三步走拆解:
split(''):将字符串按空字符拆成字符数组 →['a', 'b', 'c']reverse():数组原地反转 →['c', 'b', 'a']join(''):将数组按空字符拼接回字符串 →'cba'💡 延伸思考 :
split()和join()是一对"互逆"操作。split把字符串变数组,join把数组变字符串。这在各种字符串处理场景中都是一对黄金搭档。
三、回文字符串:对称之美的算法表达
3.1 什么是回文字符串?
回文(Palindrome):正着读和反着读一模一样的字符串。
arduino
"yessey" → 正着读:y-e-s-s-e-y,反着读:y-e-s-s-e-y → ✅ 是回文
"hello" → 正着读:h-e-l-l-o,反着读:o-l-l-e-h → ❌ 不是回文
"上海自来水来自海上" → 中文也一样!
3.2 解法一:利用反转(API 流)
最直觉的思路:反转后和原串比较。
javascript
function isPalindrome(str) {
const reversedStr = str.split('').reverse().join('');
return reversedStr === str;
}
一行核心代码,非常简洁。但这种方法遍历了两次字符串 (一次反转,一次比较)且额外创建了数组。
能不能更高效?当然------
3.3 解法二:双指针法(对称性思维)
回文字符串的本质是对称------站在中间往两边看,每一位都应该和"镜子里的自己"一模一样。
ini
y e s s e y
↑ ↑
i=0 j=5 ← 首位对首位,必须相等
y e s s e y
↑ ↑
i=1 j=4 ← 第二位对第二位,必须相等
利用这个对称性,我们只需要一个循环,用两个指针从两端向中间逼近:
javascript
function isPalindrome(str) {
const len = str.length;
// 只需要遍历到中间位置
for (let i = 0; i < len / 2; i++) {
if (str[i] !== str[len - i - 1]) {
return false; // 只要有一对不相等,立刻判定不是回文
}
}
return true;
}
📌 知识点 --- 双指针(Two Pointers) : 双指针是字符串和数组问题中最常用的技巧之一。核心思想是用两个游标(
i和j)同时扫描,而不是嵌套循环。
- 对撞指针 (本文用法):一个从头往后,一个从尾往前,在中间相遇。时间复杂度 O(n) ,空间复杂度 O(1)。
- 快慢指针:两个指针同向移动,一快一慢(常用于链表问题)。
对比反转法:虽然时间复杂度同样是 O(n),但双指针法不需要额外创建数组,且可以在发现不匹配的第一时间提前返回(短路优化),实际效率更高。
| 解法 | 时间复杂度 | 空间复杂度 | 额外特点 |
|---|---|---|---|
| 反转 + 比较 | O(n) | O(n) | 代码最简洁 |
| 双指针 | O(n) | O(1) | 可提前返回,更高效 |
四、进阶:最多删除一个字符的回文判断
4.1 问题描述
这是回文字符串的经典衍生问题,面试中出现频率很高:
给定一个非空字符串
s,最多删除一个字符。判断是否能成为回文字符串。
arduino
"aba" → ✅ 本身就是回文
"abca" → ✅ 删除 'c' 得到 "aba",是回文
"abc" → ❌ 删哪个都不行
4.2 贪心 + 双指针
核心思路:先用双指针从两端向中间走,找到第一对不匹配的字符后,你有两个选择:
- 跳过左边的字符:判断
[i+1, j]范围是否是回文 - 跳过右边的字符:判断
[i, j-1]范围是否是回文
只要其中任意一个成立,就说明可以通过删除一个字符变成回文。
css
"a b c a"
↑ ↑
i j
a == a ✅ → i++, j--
"a b c a"
↑ ↑
i j
b ≠ c ❌ → 第一对不匹配!
尝试 1:跳过左边 b → 判断 [c, a] 是否回文 → ❌
尝试 2:跳过右边 c → 判断 [b, a] 是否回文 → ❌
结论:两个都不行 → ❌ 不是回文
javascript
function validPalindrome(s) {
const len = s.length;
let i = 0, j = len - 1;
// 第一步:双指针找到第一对不匹配的位置
while (i < j && s[i] === s[j]) {
i++;
j--;
}
// 如果 i >= j,说明本身就是回文
if (i >= j) return true;
// 第二步:尝试跳过左边 或 跳过右边
// 只要有一个成立,就返回 true
if (isPalindromeRange(i + 1, j)) return true;
if (isPalindromeRange(i, j - 1)) return true;
return false;
// 辅助函数:判断 [st, ed] 范围内的子串是否是回文
function isPalindromeRange(st, ed) {
while (st < ed) {
if (s[st] !== s[ed]) return false;
st++;
ed--;
}
return true;
}
}
📌 知识点 --- 贪心思想(Greedy) : 这道题体现了贪心算法的核心------在每个决策点,只考虑当前最优选择。 当我们遇到第一对不匹配的字符时,我们不需要回溯到更早的位置,因为前面的字符都已经匹配过了(最优子结构)。我们只需要尝试两种"跳过"方案------这是一个局部决策,但足以决定全局结果。
时间复杂度 :O(n),每个字符最多被访问两次 空间复杂度:O(1),只用了几个指针变量
4.3 执行过程可视化
以 "abca" 为例,追踪整个过程:
css
初始:i=0, j=3
s[0]='a', s[3]='a' → 相等 ✅ → i=1, j=2
第一轮后:i=1, j=2
s[1]='b', s[2]='c' → 不等 ❌
→ 尝试 1:isPalindromeRange(2, 2) 单字符 'c' → true ✅
→ 直接返回 true!
五、延伸:这道题的变体与考察点
面试官往往会在基础回文题上不断加码:
| 变体 | 描述 | 难度 |
|---|---|---|
| 基础回文 | 判断一个字符串是否回文 | ⭐ |
| 最多删一个字符 | 本文讲解的题目 | ⭐⭐ |
| 最长回文子串 | 找出字符串中最长的回文子串 | ⭐⭐⭐ |
| 回文子串个数 | 统计所有回文子串的数量 | ⭐⭐⭐ |
| 分割成回文串 | 将字符串分割,使每段都是回文 | ⭐⭐⭐⭐ |
你会发现,双指针 + 中心扩展是解决回文类问题最核心的套路。掌握这个思路,大部分回文题都能迎刃而解。
六、核心知识点总结
字符串与 JS 语言特性
| 要点 | 说明 |
|---|---|
| 不可变性 | 字符串是不可变的,任何"修改"都创建新串 |
| 包装类 | str.length 本质是 JS 底层自动装箱 new String(str) |
| 栈 vs 堆 | 简单类型存栈内存,对象存堆内存 |
| 类型判断 | Object.prototype.toString.call() 是最可靠的方式 |
| 函数借用 | call 改变函数运行时的 this 指向 |
字符串算法核心套路
| 技巧 | 适用场景 | 复杂度 |
|---|---|---|
| split + reverse + join | 反转字符串 | O(n) 时间 / O(n) 空间 |
| 双指针(对撞) | 回文判断、两数之和、反转数组 | O(n) 时间 / O(1) 空间 |
| 贪心 + 双指针 | 允许删除/修改的变体回文题 | O(n) 时间 / O(1) 空间 |
JavaScript 字符串常用 API 速查
javascript
// 基础操作
str.length // 长度
str[0] // 下标访问(只读!)
str.charAt(0) // 同下标访问
// 查找
str.indexOf('a') // 子串位置,找不到返回 -1
str.includes('a') // 是否包含,返回 boolean
str.startsWith('a') // 是否以某串开头
str.endsWith('a') // 是否以某串结尾
// 截取
str.slice(0, 2) // 子串 [0, 2),支持负数
str.substring(0, 2) // 子串 [0, 2),不支持负数
str.substr(0, 2) // ⚠️ 已废弃,从0开始取2个字符
// 转换
str.split('') // 拆成数组
str.toLowerCase() // 转小写
str.toUpperCase() // 转大写
str.trim() // 去首尾空格
str.replace('a','b')// 替换(只替换第一个匹配)
写在最后
字符串算法看似简单,但细节很多。回看今天的内容,我们实际上覆盖了:
- 语言层面 :JS 的包装类、值类型 vs 引用类型、栈内存 vs 堆内存、
call函数借用 - 算法层面:双指针技巧、贪心思想、回文的对称性本质
- 工程层面:不可变性的设计考量、API 的时间空间取舍
很多开发者觉得"字符串有什么好学的",结果面试碰到回文题就卡壳。其实不在于题有多难,而在于你是否真正理解了字符串的本质特性 (不可变)以及处理字符串的核心技巧(双指针)。
下次当你顺手写下 str.split('').reverse().join('') 时,希望你不再只是"背 api",而是真正理解每一步在做什么、JS 底层又是怎么配合的。
面试不慌,从基础开始 😉
如果这篇文章对你有帮助,欢迎点赞、收藏!有问题也可以在评论区一起讨论~