#哈希,双指针#
1.哈希
1.1 两数之和
题目描述:给定一个整数数组 nums
和一个整数目标值 target
,找出 和为目标值 target
的
两个 整数,并返回它们的数组下标。
部分示例:
输入:nums = [2,7,11,15], target = 9 输出:[0,1] 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
额外要求:
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
进阶要求:你可以想出一个时间复杂度小于 O(n2)
的算法吗?
代码部分:
class Solution {
public int[] twoSum(int[] nums, int target) {
HashMap<Integer,Integer> map = new HashMap<>();
int[] result = new int[2];
for(int i = 0; i < nums.length; i++){
if(map.containsKey(target - nums[i])){
result[0] = i;
result[1] = map.get(target - nums[i]);
break;
}
else{
map.put(nums[i],i);
}
}
return result;
}
}
思路与复杂度:
-
创建一个哈希表
map
,用于存储数组元素和它们的索引。 -
遍历数组
nums
中的每个元素nums[i]
:- 检查当前元素的补数(
target - nums[i]
)是否在哈希表map
中。 - 如果补数存在于哈希表中,说明找到了两个数的和等于目标值,返回它们的索引。
- 如果补数不存在于哈希表中,将当前元素及其索引存储到哈希表中。
- 检查当前元素的补数(
-
如果遍历完整个数组都没有找到满足条件的数对,则返回一个空数组。
代码的时间复杂度为 O(n),其中 n 是数组 nums
的长度。在最坏情况下,需要遍历整个数组一次,并在哈希表中进行常数时间的查找和插入操作。因此,整个算法的时间复杂度为 O(n)。
1.2 字母异位词分组
题目描述:给定一字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
部分示例:
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
代码部分:
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
HashMap<String, List<String>> map = new HashMap<>();
for (int i = 0; i < strs.length; i++) {
String str = strs[i];
char[] chars = str.toCharArray();
Arrays.sort(chars);
String sortedStr = new String(chars);
if (map.containsKey(sortedStr)) {
List<String> group = map.get(sortedStr);
group.add(str);
} else {
List<String> group = new ArrayList<>();
group.add(str);
map.put(sortedStr, group);
}
}
return new ArrayList<>(map.values());
}
}
思路与复杂度:
-
创建一个哈希表
map
,用于存储排序后的字符串作为键,以及具有相同排序字符串的字符串列表作为值。 -
遍历输入的字符串数组
strs
:- 将当前字符串
str
转换为字符数组chars
。 - 对字符数组
chars
进行排序,得到排序后的字符串sortedStr
。 - 检查排序后的字符串
sortedStr
是否已经在哈希表map
中。 - 如果已经存在,将当前字符串
str
加入对应的字符串列表中。 - 如果不存在,创建一个新的字符串列表,将当前字符串
str
添加到列表中,并将排序后的字符串sortedStr
作为键存储到哈希表map
中。
- 将当前字符串
-
返回哈希表
map
中的所有值组成的列表。
代码的时间复杂度分析:
- 遍历字符串数组
strs
的时间复杂度为 O(n),其中 n 是字符串数组的长度。 - 对每个字符串进行排序的时间复杂度为 O(klogk),其中 k 是字符串的平均长度。
- 在哈希表中查找和插入操作的平均时间复杂度为 O(1)。
- 总体时间复杂度为 O(n * klogk)。
1.3 最长连续序列
题目描述:给定一个未排序的整数数组 nums
,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度
额外要求:请你设计并实现时间复杂度为 O(n)
的算法解决此问题。
部分示例:
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
代码部分:
class Solution {
public int longestConsecutive(int[] nums) {
if (nums.length == 0) {
return 0;
}
Set<Integer> numSet = new HashSet<>();
for (int num : nums) {
numSet.add(num);
}
int maxCount = 0;
for (int num : nums) {
if (!numSet.contains(num - 1)) {
int count = 1;
while (numSet.contains(num + 1)) {
num++;
count++;
}
maxCount = Math.max(maxCount, count);
}
}
return maxCount;
}
}
思路与复杂度:
-
首先检查数组
nums
的长度,如果为空数组,则直接返回 0。 -
创建一个哈希集合
numSet
,用于存储数组中的数字。 -
遍历数组
nums
,将数组中的每个数字添加到哈希集合numSet
中。 -
初始化一个变量
maxCount
为 0,用于记录最长连续序列的长度。 -
对于数组
nums
中的每个数字num
,判断num
的前一个数字num-1
是否存在于哈希集合numSet
中:- 如果不存在,说明
num
是一个连续序列的起点。 - 在循环中,通过不断增加
num
直到num+1
不在哈希集合numSet
中,统计连续序列的长度。 - 更新
maxCount
为当前连续序列的长度和maxCount
中的较大值。
- 如果不存在,说明
-
返回
maxCount
,即最长连续序列的长度。
代码的时间复杂度分析:
- 将数组中的数字添加到哈希集合的时间复杂度为 O(n),其中 n 是数组的长度。
- 遍历数组的时间复杂度为 O(n)。
- 在循环中,对于每个起点数字,通过向后递增找到连续序列的长度,时间复杂度为 O(1)。
- 总体时间复杂度为 O(n)。
写在后边:
提交后发现执行时间并不低,而且显著高于使用排序的O(nlogn)
分析原因如下:
-
创建哈希集合
numSet
并将数组中的数字添加到集合中的操作,时间复杂度为 O(n),其中 n 是数组的长度。 -
对数组进行第一次遍历,将所有的数字添加到哈希集合
numSet
中,时间复杂度为 O(n)。 -
对数组进行第二次遍历,对于每个数字
num
,通过判断num-1
是否存在于哈希集合numSet
中来确定是否是连续序列的起点。这个判断操作需要在哈希集合中进行查找,平均时间复杂度为 O(1)。因此,这个循环的时间复杂度为 O(n)。 -
在内层循环中,通过不断递增
num
直到num+1
不在哈希集合numSet
中,统计连续序列的长度。这个递增的过程需要在哈希集合中进行查找,平均时间复杂度为 O(1)。因此,内层循环的时间复杂度为 O(1)。
2. 双指针
2.1 移动零
题目描述:给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
额外要求:必须在不复制数组的情况下原地对数组进行操作。
代码部分:
class Solution {
public void moveZeroes(int[] nums) {
int nonZeroIndex = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] != 0) {
nums[nonZeroIndex] = nums[i];
nonZeroIndex++;
}
}
for (; nonZeroIndex < nums.length; nonZeroIndex++) {
nums[nonZeroIndex] = 0;
}
}
}
思路与复杂度:
-
初始化一个变量
nonZeroIndex
,表示非零元素的索引位置,初始值为 0。 -
遍历数组
nums
:- 如果当前元素
nums[i]
不等于 0,将其赋值给nums[nonZeroIndex]
,然后将nonZeroIndex
增加 1,即将非零元素移动到前面。 - 如果当前元素
nums[i]
等于 0,则不执行任何操作,继续遍历下一个元素。
- 如果当前元素
-
在第一次遍历结束后,
nonZeroIndex
表示了非零元素移动到前面后的索引位置。 -
在剩余的位置上,将数组
nums
中的元素都设置为 0,即将零元素移动到末尾。 -
完成操作后,数组
nums
中的零元素已经被移动到了末尾。
代码的时间复杂度分析:
- 遍历数组
nums
的时间复杂度为 O(n),其中 n 是数组的长度。 - 在遍历过程中,只对非零元素进行操作,不包括零元素。因此,不论数组中有多少个零元素,都只进行了一次遍历操作。
- 因此,该代码的时间复杂度为 O(n)。
2.2 盛最多水的容器
题目描述:给定一个长度为 n
的整数数组 height
。有 n
条垂线,第 i
条线的两个端点是 (i, 0)
和 (i, height[i])
。找出其中的两条线,使得它们与 x
轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
部分示例:
额外要求:你不能倾斜容器。
代码部分:
class Solution {
public int maxArea(int[] height) {
int max = 0;
int left = 0;
int right = height.length - 1;
while (left < right) {
int area = Math.min(height[left], height[right]) * (right - left);
max = Math.max(max, area);
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return max;
}
}
思路与复杂度:
-
初始化变量
max
为 0,用于记录最大的面积。 -
初始化两个指针
left
和right
分别指向数组的首尾两个元素。 -
使用双指针法来计算面积:
- 计算当前指针位置所围成的面积,面积的计算公式为:
Math.min(height[left], height[right]) * (right - left)
,其中Math.min(height[left], height[right])
表示两个指针所指的柱子的较小高度,(right - left)
表示两个指针之间的距离。 - 更新
max
为当前面积和max
中的较大值。
- 计算当前指针位置所围成的面积,面积的计算公式为:
-
根据两个指针所指柱子的高度比较,移动指针:
- 如果
height[left]
小于height[right]
,则将left
指针右移一位。 - 否则,将
right
指针左移一位。
- 如果
-
重复步骤 3 和步骤 4,直到两个指针相遇。
-
返回最大面积
max
。
代码的时间复杂度分析:
- 使用双指针法,每次移动指针都能排除掉一个柱子,因此最多需要遍历一次数组。
- 因此,该代码的时间复杂度为 O(n),其中 n 是数组的长度。
2.3 三数之和
题目描述:给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != k
,同时还满足 nums[i] + nums[j] + nums[k] == 0
。请你返回所有和为 0
且不重复的三元组。
额外要求:答案中不可以包含重复的三元组。
代码部分:
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
map.put(0 - nums[i], nums[i]);
}
int num1;
int num2;
ArrayList<ArrayList<Integer>> numbers = new ArrayList<ArrayList<Integer>>();
Arrays.sort(nums);
for (num1 = 0; num1 < nums.length; num1++) {
for (num2 = num1 + 1; num2 < nums.length; num2++) {
int complement = nums[num2] + nums[num1];
if (map.containsKey(complement)) {
ArrayList<Integer> ns = new ArrayList<Integer>();
ns.add(nums[num1]);
ns.add(nums[num2]);
ns.add(map.get(complement));
numbers.add(ns);
}
}
}
return numbers;
}
}
思路与复杂度:
-
创建一个哈希映射
map
,用于存储每个数的相反数作为键,以及原数作为值。这样可以快速查找是否存在满足条件的组合。 -
使用双重循环遍历数组
nums
中的数:- 第一个循环变量
num1
从数组的开头开始。 - 第二个循环变量
num2
从num1 + 1
的位置开始,遍历数组剩余部分的数。
- 第一个循环变量
-
在内层循环中,计算两个数的和
complement
,其中complement = nums[num2] + nums[num1]
。 -
检查
complement
是否存在于哈希映射map
中:- 如果存在,说明找到了满足条件的组合,将三个数的值添加到结果列表中。
- 注意,由于
map
存储的是每个数的相反数,所以这里找到的是和为零的组合。
-
返回结果列表
numbers
,其中包含了所有满足条件的组合。
代码的时间复杂度分析:
- 创建哈希映射
map
的时间复杂度为 O(n),其中 n 是数组的长度。 - 使用双重循环遍历数组的时间复杂度为 O(n^2)。
- 在内层循环中,通过哈希映射的查找操作的时间复杂度为 O(1)。
- 因此,整个代码的时间复杂度为 O(n^2)。
写在后边:另一种思路
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> numbers = new ArrayList<>();
Arrays.sort(nums);
for (int i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] == nums[i - 1]) {
continue; // 跳过重复的元素
}
int target = -nums[i];
int left = i + 1;
int right = nums.length - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
numbers.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 < target) {
left++;
} else {
right--;
}
}
}
return numbers;
}
}
-
创建一个结果列表
numbers
,用于存储满足条件的组合结果。 -
对数组
nums
进行排序,以便在后续的操作中处理重复元素。 -
使用一个循环遍历数组
nums
,循环变量i
从 0 开始到倒数第三个元素。 -
在循环中,判断当前位置的元素是否与前一个元素相同(去重操作):
- 如果当前元素与前一个元素相同且不是第一个元素(
i > 0 && nums[i] == nums[i - 1]
),则跳过当前元素,继续下一次循环。 - 这样可以避免在结果列表中出现重复的组合。
- 如果当前元素与前一个元素相同且不是第一个元素(
-
计算目标值
target
,即当前元素的相反数(target = -nums[i]
)。 -
使用双指针法来寻找满足条件的组合:
- 初始化左指针
left
为i + 1
,指向当前元素的下一个元素。 - 初始化右指针
right
为数组的最后一个元素。
- 初始化左指针
-
在循环中,判断左指针和右指针之间的元素之和
sum
与目标值target
的关系:- 如果
sum
等于target
,说明找到了满足条件的组合:- 将当前组合
[nums[i], nums[left], nums[right]]
添加到结果列表numbers
中。 - 通过移动指针跳过重复的元素:
- 左指针
left
向右移动,直到找到下一个不重复的元素。 - 右指针
right
向左移动,直到找到下一个不重复的元素。
- 左指针
- 继续寻找下一个组合,将左指针
left
右移一位,右指针right
左移一位。
- 将当前组合
- 如果
sum
小于target
,说明当前和偏小,左指针left
向右移动一位,寻找更大的元素。 - 如果
sum
大于target
,说明当前和偏大,右指针right
向左移动一位,寻找更小的元素。
- 如果
-
返回结果列表
numbers
,其中包含了所有满足条件的组合。
该算法的时间复杂度为 O(n^2),其中 n 是数组的长度。排序数组的时间复杂度为 O(nlogn),外层循环的时间复杂度为 O(n),内层循环的时间复杂度为 O(n)。因此,整体的时间复杂度为 O(n^2)。
两种实现的优劣比较:
- 第一个代码实现使用了哈希映射,可以在 O(1) 的时间内进行查找操作,因此在构建哈希映射时的时间复杂度为 O(n),而两重循环的时间复杂度为 O(n^2)。总体时间复杂度为 O(n^2)。缺点是它并没有处理重复元素的情况,结果中可能包含重复的组合,需要额外的处理。
- 第二个代码实现使用了排序和双指针法,排序的时间复杂度为 O(nlogn),外层循环的时间复杂度为 O(n),内层循环的时间复杂度为 O(n)。总体时间复杂度为 O(n^2)。优点是它通过指针的移动和跳过重复元素的处理,避免了重复的组合,并且能够高效地找到满足条件的组合。
2.4 接雨水
题目描述:
部分示例:给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
部分示例:
代码部分:
class Solution {
public int trap(int[] height) {
int left = 0;
int right = height.length - 1;
int leftMax = 0;
int rightMax = 0;
int rains = 0;
while (left <= right) {
if (height[left] <= height[right]) {
if (height[left] > leftMax) {
leftMax = height[left];
} else {
rains += leftMax - height[left];
}
left++;
} else {
if (height[right] > rightMax) {
rightMax = height[right];
} else {
rains += rightMax - height[right];
}
right--;
}
}
return rains;
}
}
class Solution {
public int trap(int[] height) {
int n = height.length;
if (n == 0) {
return 0;
}
int[] leftMax = new int[n]; // 存储每个位置的左边最大高度
int[] rightMax = new int[n]; // 存储每个位置的右边最大高度
leftMax[0] = height[0];
for (int i = 1; i < n; i++) {
leftMax[i] = Math.max(leftMax[i-1], height[i]);
}
rightMax[n-1] = height[n-1];
for (int i = n-2; i >= 0; i--) {
rightMax[i] = Math.max(rightMax[i+1], height[i]);
}
int rains = 0;
for (int i = 0; i < n; i++) {
int rain = Math.min(leftMax[i], rightMax[i]) - height[i];
if (rain > 0) {
rains += rain;
}
}
return rains;
}
}
思路及复杂度:
第一个代码实现使用了双指针的方式来计算接雨水的量。具体思路是:
- 使用两个指针
left
和right
,分别指向数组的起始和末尾位置。 - 初始化两个变量
leftMax
和rightMax
,分别表示左边和右边的最大高度,初始值为 0。 - 使用一个循环,循环条件是
left <= right
:- 在循环中,判断当前位置的高度:
- 如果
height[left] <= height[right]
,说明左边的高度较小或相等,可以计算左边的接雨水量:- 如果当前高度大于
leftMax
,更新leftMax
的值。 - 否则,计算接雨水量并累加到
rains
中。 - 将左指针
left
右移一位。
- 如果当前高度大于
- 如果
height[left] > height[right]
,说明右边的高度较小,可以计算右边的接雨水量:- 如果当前高度大于
rightMax
,更新rightMax
的值。 - 否则,计算接雨水量并累加到
rains
中。 - 将右指针
right
左移一位。
- 如果当前高度大于
- 如果
- 在循环中,判断当前位置的高度:
第二个代码实现使用了动态规划的方式来计算接雨水的量。具体思路是:
- 首先创建两个数组
leftMax
和rightMax
,分别用于存储每个位置的左边最大高度和右边最大高度。 - 遍历数组
height
,计算每个位置的左边最大高度,并将结果存储在leftMax
数组中。 - 遍历数组
height
,计算每个位置的右边最大高度,并将结果存储在rightMax
数组中。 - 遍历数组
height
,计算每个位置的接雨水量,累加到rains
中。
两种实现的思路略有不同,但都能够正确计算接雨水的量。第一个实现使用了双指针,根据当前位置的左右最大高度来计算接雨水量,而第二个实现使用了动态规划,先计算每个位置的左右最大高度,然后计算接雨水量。两种实现的时间复杂度和空间复杂度分析如下:
-
第一个实现的时间复杂度为 O(n),其中 n 是数组的长度。由于只是对数组进行一次遍历,因此时间复杂度为 O(n)。
-
第一个实现的空间复杂度为 O(1),只使用了常数级别的额外空间。
-
第二个实现的时间复杂度为 O(n),其中 n 是数组的长度。需要对数组进行三次遍历,因此时间复杂度为 O(n)。
-
第二个实现的空间复杂度为 O(n),需要额外使用两个大小为 n 的数组来存储左右最大高度。