跳到主要内容Java 字符串算法核心攻略 | 极客日志Javajava算法
Java 字符串算法核心攻略
系统讲解 Java 字符串处理的核心方法与算法技巧。涵盖基础 API 操作、字符统计、双指针、滑动窗口、动态规划及栈的应用。通过 LeetCode 经典例题(如异位词、回文串、最长子串等)展示解题思路与代码实现。同时提供常见陷阱分析与刷题路线图,帮助开发者掌握字符串相关算法优化方案。
栈溢出9.2K 浏览 Java 字符串算法核心攻略
提示:在 ASCII 码(美国信息交换标准代码)中,大小写英文字母和数字的十进制数值范围如下:
- 大写字母 (A - Z):65 到 90
- 小写字母 (a - z):97 到 122
- 数字是 (0-9):48 到 57
一、必会的字符串方法
刷题前先把这些方法练熟,后面会反复用到。
1. 基础操作
String s = "hello";
s.length();
s.charAt(0);
s.substring(1, 4);
s.indexOf('l');
s.contains("ell");
s.equals("hello");
2. 字符串与数组互转
char[] chars = s.toCharArray();
String s2 = new String(chars);
String[] arr = "a,b,c".split(",");
String s3 = String.join(",", arr);
3. StringBuilder(循环拼接必用)
String s = "";
for (int i = 0; i < 10000; i++) {
s += "a";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append("a");
}
String result = sb.toString();
4. 字符判断
char c = 'a';
Character.isDigit(c);
Character.isLetter(c);
Character.isLetterOrDigit(c);
Character.isLowerCase(c);
Character.toLowerCase(c);
'5' - '0';
5. 大小写转换与清理
String s = " Hello World ";
s.toLowerCase();
s.toUpperCase();
s.trim();
s.replaceAll("\\s+", "");
6. 高级查找与匹配
String s = "hello world";
s.indexOf("world", 6);
s.lastIndexOf('l');
s.startsWith("he");
s.endsWith("ld");
s.matches("[a-z]+");
7. 字符数组的高级操作
char[] chars = {'h', 'e', 'l', 'l', 'o'};
java.util.Arrays.sort(chars);
String sorted = new String(chars);
java.util.Arrays.equals(chars1, chars2);
8. 频率统计神器(HashMap/数组)
int[] count = new int[128];
count['a']++;
count[c]--;
java.util.Map<Character, Integer> map = new java.util.HashMap<>();
map.put(c, map.getOrDefault(c, 0) + 1);
map.get(c);
map.containsKey(c);
9. 格式化输出(调试与构造必用)
String name = "Alice";
int age = 25;
String info = String.format("Name: %s, Age: %d", name, age);
二、字符串题型与解题思路
类型 1:哈希表类(字符统计)
什么时候用哈希表?
- 字符频次、出现次数
- 异位词、字母重排
- 第一个唯一字符
- 两个字符串的映射关系
模板:字符频次统计
public Map<Character, Integer> countChars(String s) {
Map<Character, Integer> map = new HashMap<>();
for (char c : s.toCharArray()) {
map.put(c, map.getOrDefault(c, 0) + 1);
}
return map;
}
public int[] countCharsArray(String s) {
int[] count = new int[26];
for (char c : s.toCharArray()) {
count[c - 'a']++;
}
return count;
}
为什么数组比 HashMap 快?数组直接通过索引访问,HashMap 需要计算哈希值。但数组只适用于字符集有限的情况(如 26 个字母)。
题目 1:有效的字母异位词(LeetCode 242)
题目:判断两个字符串是否是异位词(字母相同但顺序不同)
输入:s = "anagram", t = "nagaram" 输出:true
输入:s = "rat", t = "car" 输出:false
思路:异位词的特点是每个字符出现的次数相同,统计频次后比较即可。
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) return false;
int[] count = new int[26];
for (char c : s.toCharArray()) {
count[c - 'a']++;
}
for (char c : t.toCharArray()) {
count[c - 'a']--;
if (count[c - 'a'] < 0) {
return false;
}
}
return true;
}
为什么这样思考?如果两个字符串是异位词,那么统计完 s 的频次后,遍历 t 应该刚好把所有计数清零。如果某个字符变负数,说明 t 中这个字符比 s 多,不是异位词。
题目 2:字符串中的第一个唯一字符(LeetCode 387)
输入:s = "leetcode" 输出:0('l'只出现一次)
输入:s = "loveleetcode" 输出:2('v'是第一个只出现一次的)
思路:先统计每个字符的频次,再遍历一遍找第一个频次为 1 的字符。
public int firstUniqChar(String s) {
int[] count = new int[26];
for (char c : s.toCharArray()) {
count[c - 'a']++;
}
for (int i = 0; i < s.length(); i++) {
if (count[s.charAt(i) - 'a'] == 1) {
return i;
}
}
return -1;
}
为什么要遍历两遍?第一遍统计频次,第二遍按原字符串顺序查找,这样能保证找到的是"第一个"唯一字符。
题目 3:赎金信(LeetCode 383)
题目:判断字符串 ransomNote 能否由字符串 magazine 中的字符构成
输入:ransomNote = "a", magazine = "b" 输出:false
输入:ransomNote = "aa", magazine = "aab" 输出:true
思路:统计 magazine 中每个字符的频次,然后遍历 ransomNote,每用一个字符就减 1,如果不够用就返回 false。
public boolean canConstruct(String ransomNote, String magazine) {
int[] count = new int[26];
for (char c : magazine.toCharArray()) {
count[c - 'a']++;
}
for (char c : ransomNote.toCharArray()) {
count[c - 'a']--;
if (count[c - 'a'] < 0) {
return false;
}
}
return true;
}
为什么这样思考?这题本质上是检查 magazine 是否包含 ransomNote 所需的所有字符(包括数量),用数组统计频次最直接。
类型 2:双指针类
核心思想: 用两个指针从不同位置遍历,避免嵌套循环
什么时候用双指针?
- 回文问题(从两端向中间)
- 反转字符串(交换两端字符)
- 原地修改数组(快慢指针)
模板:对撞指针(判断回文)
public boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
为什么从两端向中间?回文的特点就是对称,从两端比较最直接。时间复杂度 O(n),空间复杂度 O(1)。
题目 1:验证回文串(LeetCode 125)
题目:判断字符串是否是回文(只考虑字母和数字,忽略大小写)
输入:s = "A man, a plan, a canal: Panama" 输出:true(去掉非字母数字后是"amanaplanacanalpanama")
思路:用双指针,遇到非字母数字就跳过,比较时忽略大小写。
public boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
left++;
}
while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
right--;
}
if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
return false;
}
left++;
right--;
}
return true;
}
为什么要跳过非字母数字?题目要求只考虑字母和数字,其他字符(空格、标点)都忽略。
题目 2:反转字符串(LeetCode 344)
输入:s = ['h','e','l','l','o'] 输出:['o','l','l','e','h']
public void reverseString(char[] s) {
int left = 0, right = s.length - 1;
while (left < right) {
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
为什么这样思考?反转就是把第一个和最后一个交换,第二个和倒数第二个交换…用双指针最直接。
类型 3:滑动窗口类(最重要)
核心思想: 维护一个动态窗口,右指针扩大窗口,左指针收缩窗口
什么时候用滑动窗口?
滑动窗口的两种类型
1. 固定窗口: 窗口大小固定,只需要滑动
2. 可变窗口: 窗口大小动态变化,需要收缩
模板:可变窗口(最常用)
public int slidingWindow(String s) {
int left = 0, maxLen = 0;
Set<Character> window = new HashSet<>();
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
while (window.contains(c)) {
window.remove(s.charAt(left));
left++;
}
window.add(c);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
为什么叫滑动窗口?想象一个窗户在字符串上滑动,窗户里的内容就是当前考虑的子串。右边界不断扩大,左边界根据条件收缩。
题目 1:无重复字符的最长子串(LeetCode 3)
输入:s = "abcabcbb" 输出:3("abc")
输入:s = "pwwkew" 输出:3("wke")
思路:用 HashSet 维护窗口内的字符,遇到重复字符就收缩窗口。
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) return 0;
int left = 0;
int maxLen = 0;
Set<Character> window = new HashSet<>();
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
while (window.contains(c)) {
window.remove(s.charAt(left));
left++;
}
window.add(c);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
为什么要用 HashSet?HashSet 可以 O(1) 时间判断字符是否存在,比遍历数组快得多。
优化版本:用 HashMap 记录字符的索引,遇到重复字符可以直接跳到重复位置的下一个。
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) return 0;
int left = 0, maxLen = 0;
Map<Character, Integer> map = new HashMap<>();
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (map.containsKey(c) && map.get(c) >= left) {
left = map.get(c) + 1;
}
map.put(c, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
为什么更快?不需要一个一个移动左指针,直接跳到重复字符的下一个位置。
题目 2:找到字符串中所有字母异位词(LeetCode 438)
题目:找出字符串 s 中所有 p 的异位词的起始索引
输入:s = "cbaebabacd", p = "abc" 输出:[0, 6]
解释:索引 0 的子串"cba"是"abc"的异位词
索引 6 的子串"bac"是"abc"的异位词
思路:固定窗口大小为 p 的长度,滑动窗口,每次检查窗口内的字符频次是否和 p 相同。
public List<Integer> findAnagrams(String s, String p) {
List<Integer> result = new ArrayList<>();
if (s.length() < p.length()) return result;
int[] pCount = new int[26];
int[] sCount = new int[26];
for (char c : p.toCharArray()) {
pCount[c - 'a']++;
}
for (int i = 0; i < p.length(); i++) {
sCount[s.charAt(i) - 'a']++;
}
if (Arrays.equals(pCount, sCount)) {
result.add(0);
}
for (int i = p.length(); i < s.length(); i++) {
sCount[s.charAt(i) - 'a']++;
sCount[s.charAt(i - p.length()) - 'a']--;
if (Arrays.equals(pCount, sCount)) {
result.add(i - p.length() + 1);
}
}
return result;
}
为什么是固定窗口?异位词长度必须和 p 相同,所以窗口大小固定。每次滑动只需要更新边界字符的频次,不需要重新统计整个窗口。
题目 3:字符串的排列(LeetCode 567)
题目:判断 s2 是否包含 s1 的排列(即 s1 的异位词)
输入:s1 = "ab", s2 = "eidbaooo" 输出:true
解释:s2 包含 s1 的排列之一("ba")
输入:s1 = "ab", s2 = "eidboaoo" 输出:false
思路:和上一题类似,固定窗口大小为 s1 的长度,检查窗口内字符频次是否和 s1 相同。
public boolean checkInclusion(String s1, String s2) {
if (s1.length() > s2.length()) return false;
int[] s1Count = new int[26];
int[] s2Count = new int[26];
for (char c : s1.toCharArray()) {
s1Count[c - 'a']++;
}
for (int i = 0; i < s1.length(); i++) {
s2Count[s2.charAt(i) - 'a']++;
}
if (Arrays.equals(s1Count, s2Count)) return true;
for (int i = s1.length(); i < s2.length(); i++) {
s2Count[s2.charAt(i) - 'a']++;
s2Count[s2.charAt(i - s1.length()) - 'a']--;
if (Arrays.equals(s1Count, s2Count)) {
return true;
}
}
return false;
}
为什么这样思考?s1 的排列就是 s1 的异位词,所以问题转化为:s2 中是否存在长度为 s1.length() 的子串,其字符频次和 s1 相同。
类型 4:动态规划类
什么时候用动态规划?
题目 1:最长回文子串(LeetCode 5)
输入:s = "babad" 输出:"bab" 或 "aba"
输入:s = "cbbd" 输出:"bb"
思路:回文串的特点是中心对称,从每个可能的中心向两边扩展。
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) return s;
int start = 0;
int maxLen = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i);
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
if (len > maxLen) {
maxLen = len;
start = i - (len - 1) / 2;
}
}
return s.substring(start, start + maxLen);
}
private int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left - 1;
}
为什么要考虑奇数和偶数?回文串可能是奇数长度(如"aba")或偶数长度(如"abba"),中心不同。
题目 2:回文子串(LeetCode 647)
输入:s = "abc" 输出:3
解释:"a", "b", "c"
输入:s = "aaa" 输出:6
解释:"a", "a", "a", "aa", "aa", "aaa"
思路:和上一题类似,从每个中心向两边扩展,统计回文子串的数量。
public int countSubstrings(String s) {
int count = 0;
for (int i = 0; i < s.length(); i++) {
count += expandAroundCenter(s, i, i);
count += expandAroundCenter(s, i, i + 1);
}
return count;
}
private int expandAroundCenter(String s, int left, int right) {
int count = 0;
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
count++;
left--;
right++;
}
return count;
}
为什么这样思考?每个回文子串都有一个中心,从中心向两边扩展,每扩展一次就找到一个回文子串。
类型 5:栈类
什么时候用栈?
题目 1:有效的括号(LeetCode 20)
题目:判断括号字符串是否有效(每个左括号都有对应的右括号)
输入:s = "()" 输出:true
输入:s = "()[]{}" 输出:true
输入:s = "(]" 输出:false
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
}
else {
if (stack.isEmpty()) return false;
char top = stack.pop();
if (c == ')' && top != '(') return false;
if (c == ']' && top != '[') return false;
if (c == '}' && top != '{') return false;
}
}
return stack.isEmpty();
}
为什么用栈?括号匹配是典型的"最近配对"问题,最近的左括号应该匹配最近的右括号,这正是栈的后进先出特性。
题目 2:简化路径(LeetCode 71)
题目:给定 Unix 风格的绝对路径,简化为规范路径
输入:path = "/home/" 输出:"/home"
输入:path = "/../" 输出:"/"
输入:path = "/home//foo/" 输出:"/home/foo"
输入:path = "/a/./b/../../c/" 输出:"/c"
思路:用栈处理路径,遇到".."就弹出栈顶(返回上级目录),遇到"."或空字符串就跳过,其他情况入栈。
public String simplifyPath(String path) {
Stack<String> stack = new Stack<>();
String[] parts = path.split("/");
for (String part : parts) {
if (part.equals("..")) {
if (!stack.isEmpty()) {
stack.pop();
}
} else if (!part.equals(".") && !part.isEmpty()) {
stack.push(part);
}
}
if (stack.isEmpty()) return "/";
StringBuilder sb = new StringBuilder();
for (String dir : stack) {
sb.append("/").append(dir);
}
return sb.toString();
}
为什么用栈?路径处理中,".."表示返回上级目录,这是典型的"撤销"操作,用栈的后进先出特性最合适。
三、解题思维框架
看到题目先问自己 3 个问题
-
这是什么类型的问题?
- 字符统计、异位词 → 哈希表
- 子串、子数组 → 滑动窗口
- 回文、反转 → 双指针
- 括号匹配 → 栈
- 最优解 → 动态规划
-
暴力解法是什么?能优化吗?
- 先想出暴力解法(通常是嵌套循环)
- 再想能否用双指针、滑动窗口、哈希表优化
-
需要什么数据结构?
- 查找/统计 → HashMap 或数组
- 动态窗口 → Set 或 Map
- 配对问题 → Stack
优化路径
暴力 O(n²/n³) ↓ 双指针/滑动窗口 O(n) ↓ 哈希表优化(用空间换时间) ↓ 数组优化(字符集有限时)
四、常见陷阱
1. 字符串不可变
String s = "";
for (int i = 0; i < n; i++) {
s += "a";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("a");
}
2. 字符串比较
if (s1 == s2) {}
if (s1.equals(s2)) {}
3. 数组越界
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
}
s.substring(0, 3);
4. 边界条件
if (s == null || s.length() == 0) return 默认值;
if (s.length() == 1) return 特殊处理;
五、刷题路线
入门(10 题)- 先把基础打牢
- 344 反转字符串 - 双指针入门
- 242 有效的字母异位词 - 哈希表入门
- 387 字符串中的第一个唯一字符 - 哈希表统计
- 125 验证回文串 - 双指针 + 字符判断
- 28 找出字符串中第一个匹配项的下标 - 字符串匹配
- 14 最长公共前缀 - 字符串比较
- 58 最后一个单词的长度 - 字符串遍历
- 20 有效的括号 - 栈入门
- 392 判断子序列 - 双指针
- 383 赎金信 - 哈希表
进阶(15 题)- 掌握核心技巧
- 3 无重复字符的最长子串 - 滑动窗口经典题
- 5 最长回文子串 - 中心扩展
- 49 字母异位词分组 - 哈希表分组
- 151 反转字符串中的单词 - 字符串处理
- 438 找到字符串中所有字母异位词 - 固定滑动窗口
- 567 字符串的排列 - 滑动窗口 + 哈希表
- 647 回文子串 - 中心扩展变形
- 205 同构字符串 - 双向映射
- 290 单词规律 - 哈希表映射
- 459 重复的子字符串 - KMP 或技巧
- 680 验证回文字符串 II - 双指针 + 贪心
- 696 计数二进制子串 - 分组统计
- 709 转换成小写字母 - 字符处理
- 771 宝石与石头 - 哈希表查找
- 819 最常见的单词 - 哈希表统计
六、总结
核心方法速查
charAt(i), toCharArray(), substring(start, end)
indexOf(), contains()
isEmpty(), equals(), startsWith(), endsWith()
toLowerCase(), toUpperCase(), trim(), replace()
split(), String.join(), StringBuilder.append()
解题三步法
- 识别题型 - 看关键词选方法
- 选数据结构 - HashMap/Set/Stack/数组
- 套模板 - 双指针/滑动窗口/哈希表/栈
记忆口诀
字符统计用哈希 子串问题滑动窗 回文对称双指针 括号配对用栈解
最重要的:先理解思想,再记模板。每道题都问自己"为什么这样做",而不是死记硬背。
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online