开始时间:2025.3.14
字母异位词分组P49
方法:分组字母异位词(Group Anagrams)
该方法使用 哈希表(HashMap) 进行分组,将每个单词按照字母排序后作为键,值是属于该组的单词列表。
代码注释
public static List<List<String>> groupAnagrams(String[] strs) {
// 使用 HashMap 存储字母排序后的字符串作为 key,对应的异位词列表作为 value
Map<String, List<String>> map = new HashMap<>();
// 遍历字符串数组
for (String str : strs) {
// 将字符串转换为字符数组,并进行排序
char[] strArr = str.toCharArray();
Arrays.sort(strArr);
// 排序后的字符串作为 key
String sortedStr = new String(strArr);
// 获取当前 key 对应的异位词列表
List<String> strList = map.get(sortedStr);
if (strList != null) {
// 如果 key 已存在,直接加入对应列表
strList.add(str);
} else {
// 如果 key 不存在,新建一个列表并加入 map
ArrayList<String> newList = new ArrayList<>();
newList.add(str);
map.put(sortedStr, newList);
}
}
// 提取 HashMap 中所有的值(即分组结果)
List<List<String>> res = new ArrayList<>();
for (Map.Entry<String, List<String>> entry : map.entrySet()) {
res.add(entry.getValue());
}
return res;
}
public static void main(String[] args) {
String[] strs = {"eat", "tea", "tan", "ate", "nat", "bat"};
System.out.println(groupAnagrams(strs));
}
}
思路分析
-
使用哈希表(
Map<String, List<String>>
)存储分组key
:将字符串排序后得到的字符串value
:所有属于该 key 的字母异位词列表
-
遍历
strs
数组,对每个字符串进行处理- 将字符串转换为字符数组,并使用
Arrays.sort()
排序 - 排序后的字符串作为
key
- 检查
map
是否已有该key
- 若存在,则直接添加到对应
List<String>
- 若不存在,则新建
ArrayList<String>
并存入map
- 若存在,则直接添加到对应
- 将字符串转换为字符数组,并使用
-
返回结果
- 遍历
map
,提取所有List<String>
作为最终结果
- 遍历
时间复杂度分析
假设输入数组长度为 n
,每个字符串的平均长度为 m
:
- 排序字符串:
Arrays.sort(strArr)
需要O(m log m)
- 遍历字符串数组:遍历
n
个字符串,每个字符串的处理复杂度是O(m log m)
- 哈希表操作:
put()
和get()
的均摊时间复杂度为O(1)
- 存储
n
个字符串,整体O(n)
总的时间复杂度:
O(n * m log m)
空间复杂度分析
- 哈希表存储
n
个字符串:O(n)
- 每个字符串存储
m
个字符,共O(n * m)
- 最终返回的
List<List<String>>
也占用O(n * m)
总的空间复杂度:
O(n * m)
示例运行
输入
String[] strs = {"eat", "tea", "tan", "ate", "nat", "bat"};
输出
[[eat, tea, ate], [tan, nat], [bat]]
总结
✅ 核心思路:利用哈希表存储排序后的字符串来分组
✅ 时间复杂度:O(n * m log m)
(排序占主导)
✅ 空间复杂度:O(n * m)
(哈希表存储所有字符串)
这种方法适用于较短字符串的分组问题,在 m
较大时可以使用计数数组法优化排序部分,降低到 O(n * m)
🚀
方法二
/**
* 分组字母异位词(Anagrams)。
*
* @param strs 输入字符串数组
* @return 按字母异位词分组后的列表
*
* 思路:
* 1. 创建一个 `HashMap<String, List<String>>`,用于存储排序后的字符串作为 key,对应的字母异位词列表作为 value。
* 2. 遍历输入字符串数组:
* - 将字符串转换为字符数组并排序,使得异位词具有相同的 key。
* - 使用 `getOrDefault(key, new ArrayList<>())` 获取当前 key 对应的列表,如果 key 不存在,则返回一个新的 `ArrayList<>`。
* - 将当前字符串添加到该列表中,并更新 `map`。
* 3. 最后,将 `map` 中的所有 `value`(即字母异位词分组)转换为 `ArrayList<List<String>>` 并返回。
*
* 时间复杂度:
* - 对每个字符串排序的时间复杂度为 O(k log k)(其中 k 为字符串长度)。
* - 遍历所有字符串的时间复杂度为 O(n)(其中 n 为字符串个数)。
* - 整体时间复杂度为 O(n * k log k)。
*
* 空间复杂度:
* - 主要取决于 `map` 的存储,最坏情况下 O(n * k)。
*/
public static List<List<String>> groupAnagrams3(String[] strs) {
// 创建一个 HashMap 存储排序后的字符串作为 key,对应的字母异位词列表作为 value
Map<String, List<String>> map = new HashMap<>();
// 遍历所有字符串
for (String str : strs) {
// 将字符串转换为字符数组,并进行排序
char[] charArray = str.toCharArray();
Arrays.sort(charArray);
String key = new String(charArray); // 排序后的字符串作为 key
// 获取或创建存储相同 key 的字符串列表
List<String> list = map.getOrDefault(key, new ArrayList<>());
list.add(str); // 添加当前字符串
map.put(key, list); // 更新 HashMap
}
// 返回所有字母异位词分组
return new ArrayList<>(map.values());
}
最长连续序列P128
解题思路:
在数组中可能存在多个连续的序列,我们需要找到每个序列的起点进行扩展。使用 Set
存储数组元素,以便 O(1) 时间复杂度进行查找,避免重复计算。对于每个数 num
,如果 num - 1
存在,则说明 num
不是某个连续序列的起点,直接跳过。否则,从 num
开始向后查找,计算最长的连续序列长度。此外,我们也可以选择最大的数作为起点,进行递减查找,思路相同。
import java.util.HashSet;
import java.util.Set;
public class Solution {
/**
* 计算数组中最长的连续序列长度
* @param nums 整数数组
* @return 最长连续序列的长度
*/
public static int longestConsecutive(int[] nums) {
int max_len = 0; // 记录最长连续序列的长度
Set<Integer> set = new HashSet<>(); // 使用 HashSet 存储数组中的所有元素,以便快速查找
// 将所有数字存入 HashSet,以便 O(1) 时间复杂度内查找
for (int num : nums) {
set.add(num);
}
// 遍历数组,寻找最长连续序列
for (int num : nums) {
int cur_len = 1; // 记录当前连续序列的长度
// 如果 num-1 存在,说明 num 不是序列的起点,跳过该数
if (set.contains(num - 1)) {
continue;
}
// num 作为连续序列的起点,向后寻找连续的数
while (set.contains(num + 1)) {
num++; // 继续寻找下一个连续的数
cur_len++; // 增加当前连续序列的长度
}
// 更新最长连续序列的长度
max_len = Math.max(max_len, cur_len);
}
return max_len;
}
}
时间复杂度分析
- 构建
Set
:我们首先遍历nums
并将其存入Set
,该操作的时间复杂度为 O(n)。 - 遍历
nums
并查找最长序列:对于每个num
,如果num - 1
存在,则跳过;否则,我们在Set
中查找num + 1, num + 2, ...
直到找不到为止。- 每个数字最多被访问两次(一次插入
Set
,一次作为起点查找),因此整体复杂度仍然是 O(n)。
- 每个数字最多被访问两次(一次插入
最终时间复杂度: O(n)
空间复杂度分析
- 使用
Set
存储所有元素,占用 O(n) 额外空间。 - 仅使用常数级别的额外变量(如
max_len
、cur_len
、num
等),占用 O(1) 额外空间。
最终空间复杂度: O(n)
遇到的问题: 第二次循环使用的是原数组进行遍历,造成重复的元素循环便利,造成代码超时,应该使用set进行遍历
三数之和 P15
方法一双指针
下面是 三数之和 (Three Sum) 的 双指针解法,包括详细注释和时间、空间复杂度分析。
代码(双指针法)
import java.util.*;
public class ThreeSumOptimized {
/**
* 计算数组中所有不重复的三元组,使得它们的和为 0
* @param nums 输入整数数组
* @return 所有满足条件的三元组
*/
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums); // 先排序 O(n log n),便于去重和双指针查找
int n = nums.length;
for (int i = 0; i < n - 2; i++) { // 固定第一个数 O(n)
// **去重**: 如果当前数与前一个数相同,跳过,避免重复答案
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1, right = n - 1; // 双指针初始化
while (left < right) { // O(n)
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
// 找到一个三元组
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
// **去重**:跳过重复的 left 和 right 值
while (left < right && nums[left] == nums[left + 1]) left++;
while (left < right && nums[right] == nums[right - 1]) right--;
// 移动指针继续寻找其他解
left++;
right--;
} else if (sum < 0) {
left++; // 需要更大的值
} else {
right--; // 需要更小的值
}
}
}
return res;
}
public static void main(String[] args) {
ThreeSumOptimized solution = new ThreeSumOptimized();
int[] nums = {-1, 0, 1, 2, -1, -4};
System.out.println(solution.threeSum(nums));
// 输出: [[-1, -1, 2], [-1, 0, 1]]
}
}
时间 & 空间复杂度分析
时间复杂度:O(n²)
- 排序:
Arrays.sort(nums);
需要 O(n log n)。 - 遍历
nums[i]
: 外层for
循环需要 O(n)。 - 双指针查找: 每次
while (left < right)
运行 O(n),整体 O(n²)。 - 总时间复杂度: O(n log n) + O(n²) ≈ O(n²)(排序的时间复杂度相比
O(n²)
可忽略)。
空间复杂度:O(1)
- 排序所需空间: 由于
Arrays.sort(nums)
是 原地排序,不需要额外空间(O(1))。 - 返回结果
res
: 最坏情况下可能存储所有三元组(O(n))。 - 总空间复杂度: 主要由
res
结果集占用,最坏情况下 O(n),但如果只考虑额外空间消耗,则是 O(1)。
2025.3.18
方法二回溯递归(超时)
// 结果集,存储所有满足条件的三元组
private static List<List<Integer>> res;
/**
* 主方法:求解三数之和
* @param nums 输入的整数数组
* @return 结果集,包含所有不重复的三元组
*/
public static List<List<Integer>> threeSum(int[] nums) {
res = new ArrayList<>();
Arrays.sort(nums); // **排序 O(n log n),保证去重有效**
backTracking(nums, new ArrayList<>(), 0, 0);
return res;
}
/**
* 回溯算法:寻找所有满足条件的三元组
* @param nums 排序后的数组
* @param list 当前路径,存储已选择的数
* @param i 当前递归的起始索引
* @param sum 当前路径中元素的和
*/
public static void backTracking(int[] nums, List<Integer> list, int i, int sum) {
// 终止条件:如果已经选了 3 个数
if (list.size() == 3) {
if (sum == 0) { // 只有和为 0 才加入结果集
res.add(new ArrayList<>(list));
}
return; // 终止递归
}
// 遍历数组,尝试加入新的数
for (int j = i; j < nums.length; j++) {
// **去重**: 只在同一层中跳过重复的元素
if (j > i && nums[j] == nums[j - 1]) continue;
// 选择当前元素
list.add(nums[j]);
sum += nums[j];
// 递归探索下一层
backTracking(nums, list, j + 1, sum);
// 回溯(撤销选择)
sum -= nums[j];
list.remove(list.size() - 1);
}
}
时间 & 空间复杂度分析
时间复杂度:O(n³)
- 排序:
Arrays.sort(nums);
需要 O(n log n)。 - 回溯搜索:
- 第一层
for
循环有O(n)
次迭代。 - 第二层递归每次执行
O(n)
次,形成O(n²)
。 - 第三层递归也会执行
O(n)
次,形成O(n³)
复杂度。 - 但是由于
sum > 0 || sum < 0
会剪枝,实际复杂度稍低,但仍接近 O(n³)。
- 第一层
- 总复杂度: O(n log n) + O(n³) ≈ O(n³)(排序的影响可以忽略)。
空间复杂度:O(n)
- 递归调用栈: 在最坏情况下,递归深度为
3
,但在大n
情况下,递归栈最多可能达到O(n)
。 list
临时存储:list
的最大长度为3
,但递归过程中需要额外的存储,最坏情况是O(n)
。res
结果存储: 最坏情况下可能存储O(n²)
个解。- 总复杂度: O(n) + O(n²) = O(n²)。
为什么这个方法会超时?
✅ 回溯 (O(n³)) 太慢了,在大数据量情况下容易超时
✅ 没有剪枝优化,所有情况都暴力枚举
✅ 三数之和问题可以用 双指针优化为 O(n²)
推荐优化:使用双指针
如果你希望提高效率,建议使用 双指针法(时间复杂度 O(n²)),如下所示:
import java.util.*;
public class ThreeSumOptimized {
public static List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums); // O(n log n)
int n = nums.length;
for (int i = 0; i < n - 2; i++) { // O(n)
if (i > 0 && nums[i] == nums[i - 1]) continue; // 去重
int left = i + 1, right = n - 1;
while (left < right) { // O(n)
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
while (left < right && nums[left] == nums[left + 1]) left++; // 去重
while (left < right && nums[right] == nums[right - 1]) right--; // 去重
left++;
right--;
} else if (sum < 0) {
left++;
} else {
right--;
}
}
}
return res;
}
}
总结
方法 | 时间复杂度 | 空间复杂度 | 适用场景 | 备注 |
---|---|---|---|---|
回溯法 | O(n³) | O(n²) | 小规模数据 | 易超时 |
双指针法 | O(n²) | O(1) | 大规模数据 | 推荐! |
接雨水P42
代码1
// 方法1:使用单调栈计算能存储的雨水量
public static int trap(int[] height) {
int sum = 0;
Stack<Integer> stack = new Stack<>(); // 维护一个单调递减栈
for (int i = 0; i < height.length; i++) {
// 当当前高度大于栈顶高度时,说明形成了一个洼地,可以存水
while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
Integer idx = stack.pop(); // 取出洼地的底部索引
if (stack.isEmpty()) { // 如果栈为空,说明没有左边界,无法存水
break;
}
Integer j = stack.peek(); // 取左边界的索引
int h = Math.min(height[i], height[j]) - height[idx]; // 计算洼地的有效高度
sum += h * (i - j - 1); // 计算当前洼地的存水量,并累加
}
stack.push(i); // 将当前索引入栈
}
return sum;
}
代码2
// 方法2:使用动态规划计算能存储的雨水量
public static int trap(int[] height) {
int n = height.length;
if (n == 0) {
return 0;
}
// 预计算左侧最大高度数组
int[] leftMax = new int[n];
leftMax[0] = height[0];
for (int i = 1; i < n; ++i) {
leftMax[i] = Math.max(leftMax[i - 1], height[i]);
}
System.out.println("Left Max: " + Arrays.toString(leftMax));
// 预计算右侧最大高度数组
int[] rightMax = new int[n];
rightMax[n - 1] = height[n - 1];
for (int i = n - 2; i >= 0; --i) {
rightMax[i] = Math.max(rightMax[i + 1], height[i]);
}
System.out.println("Right Max: " + Arrays.toString(rightMax));
// 计算每个柱子上可以存储的雨水量
int ans = 0;
for (int i = 0; i < n; ++i) {
ans += Math.min(leftMax[i], rightMax[i]) - height[i];
}
return ans;
第一个方法 trap
(基于单调栈)
这个方法使用 单调递减栈 来计算能存储的雨水量。
工作原理:
- 遍历数组中的每个元素(即高度
height[i]
),并尝试找到当前元素height[i]
能够“填满”的区域。 - 当
height[i]
大于栈顶元素(意味着有可能形成洼地存水):- 弹出栈顶元素
idx
,它表示低洼区域的底部。 - 如果弹出后栈为空,说明没有左边界,无法形成洼地,继续遍历。
- 取栈顶
j
作为左边界,计算能存的水:高度 = 两边最矮的墙高度 - 洼地的高度,宽度 =i - j - 1
。 - 把计算出的雨水量加到
sum
里。
- 弹出栈顶元素
- 最后返回
sum
,即总存水量。
总结:
它是按列计算雨水,每次计算当前柱子 height[i]
作为右边界时,看看之前的洼地能存多少水,并在过程中一点一点地累加。
第二个方法 trap2
(基于动态规划)
这个方法采用 前缀最大值 + 后缀最大值 来计算能存的雨水量。
工作原理:
- 计算左侧最大值数组
leftMax[]
:leftMax[i]
表示从左到i
位置的最高柱子。
- 计算右侧最大值数组
rightMax[]
:rightMax[i]
表示从右到i
位置的最高柱子。
- 计算
i
位置的存水量:water[i] = min(leftMax[i], rightMax[i]) - height[i]
- 这表示,在
i
位置能存的水取决于左右两侧的最矮墙壁,减去当前高度height[i]
。
- 累加所有位置的水量,返回结果。
总结:
它是按行计算雨水,即每个 i
位置可以存多少水是独立计算的,然后总和得到结果。
对比
方法 | 计算方式 | 额外空间 | 适用场景 |
---|---|---|---|
单调栈 (trap ) | 动态处理每个位置的存水情况 | O(n)(栈的存储) | 适用于实时处理数据,空间稍优 |
动态规划 (trap2 ) | 预计算左最大 & 右最大,然后遍历一次求雨水量 | O(n)(两个辅助数组) | 适用于一次性计算,时间稳定 |
核心区别:
trap
(单调栈)是 动态寻找洼地,边遍历边处理存水问题,每个i
只遍历一次栈结构(更接近实际流体的填充过程)。trap
(动态规划)是 提前计算出左右最高墙,再遍历一次统一计算每个位置的存水量(更直观但需要额外存储空间)。
两者在时间复杂度上都是 O(n),但 trap2
额外用了 O(n) 的空间来存 leftMax[]
和 rightMax[]
,而 trap
只用了一个栈(通常比数组更节省空间)。
你更喜欢哪种方法呢?