哈希表
1、定义
哈希表(Hash table),也称散列表,是根据关键码的值而直接进行访问的数据结构。
一般哈希表都是用来快速判断一个元素是否出现集合里,只需要在初始化时用哈希函数(hash function)将这些元素映射在哈希表的索引上。
即利用散列技术,在记录的存储位置和它的关键字之间建立一个确定的关系f,使每一个关键字key对应一个存储位置 f(key)。散列技术既是一种存储方法,也是一种查找方法。
散列表无法解决同样的关键字对应很多记录的情况,比如用学号找学生而不是用性别来找学生;
散列表不适合范围查找,无法获得表中记录的排序或最大、最小值。
直白来讲其实数组就是一张哈希表。哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
2、哈希函数(hash function)
2.1 哈希函数
通过 hashCode把元素转化为数值,一般 hashcode 是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把元素映射为哈希表上的索引数字了。
构造散列函数的考虑因素:
- 计算散列地址所需的时间;
- 关键字的长度;
- 散列表的大小;
- 关键字的分布情况;
- 记录查找的频率。
● 如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
- 此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
● 此时问题又来了,哈希表就是一个数组,如果学生的数量大于哈希表的大小怎么办?
- 此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。接下来将用到哈希碰撞(散列冲突)。
2.2 哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
哈希碰撞的解决方法:
(C++ 实现1、C++ 实现2)
2.2.1 开放定址法
一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大(tableSize一定要大于dataSize),空散列地址总能找到。
f i ( k e y ) = ( f ( k e y ) + d i ) M O D m f_i(key) = (f(key) + d_i) MOD m fi(key)=(f(key)+di)MODm
m 为哈希表表长,d 为某一形式的增量,i 为探测次数。
- 线性探测法:
d
i
=
1
,
2
,
3
,
.
.
.
,
m
−
1
d_i = 1, 2, 3, ..., m-1
di=1,2,3,...,m−1
(容易产生堆积,即本不会冲突的key在线性探测之后产生冲突) - 二次探测法:
d
i
=
1
2
,
−
1
2
,
2
2
,
−
2
2
,
.
.
.
,
q
2
,
−
q
2
,
q
<
=
m
/
2
d_i = 1^2, -1^2, 2^2, -2^2,...,q^2, -q^2, q<= m/2
di=12,−12,22,−22,...,q2,−q2,q<=m/2
(左右探测,避免聚集堆积) - 随机探测法:
d
i
d_i
di为伪随机数。
(同一个key用相同的随机种子)
2.2.2 再散列函数法
事先准备多个散列函数,该方法能够使关键字不产生聚集,但相应增加了计算时间。
2.2.3 公共溢出区法
建立两张表,一张为基本表,另一张为溢出表。基本表存储没有发生冲突的数据,当关键字由哈希函数生成的哈希地址产生冲突时,就将数据填入溢出表(在基本表用哈写函数查不到时,再在溢出表顺序查找)。
2.2.4 链地址法(拉链法)
将所有产生冲突的关键字所对应的数据全部存储在同一个线性链表中。例如有一组关键字为{19,14,23,01,68,20,84,27,55,11,10,79},其哈希函数为:H(key)=key MOD 13,使用链地址法所构建的哈希表如图所示:
散列表中只存储所有同义词子表的头指针,查找时增加了遍历单链表的性能消耗,但不会产生堆积。
3、常见的三种哈希结构
- 数组
- set (集合)
集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::set | 红黑树 | 有序 | 否 | 否 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
std::multiset | 红黑树 | 有序 | 是 | 否 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
std::unordered_set | 哈希表 | 无序 | 否 | 否 | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) |
- map(映射)
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::map | 红黑树 | key有序 | key不可重复 | key不可修改 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
std::multimap | 红黑树 | key有序 | key可重复 | key不可修改 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
std::unordered_map | 哈希表 | key无序 | key不可重复 | key不可修改 | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) |
242. 有效的字母异位词 ●
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。
示例 1: 输入: s = “anagram”, t = “nagaram” 输出: true
1、排序后对比
t 是 s 的异位词等价于「两个字符串排序后相等」。因此我们可以对字符串 s 和 t 分别排序,看排序后的字符串是否相等即可判断。
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.length() != t.length()) {
return false;
}
sort(s.begin(), s.end());
sort(t.begin(), t.end());
return s == t;
}
};
-
时间复杂度: O ( n l o g n ) O(nlogn) O(nlogn),其中 n 为 s 的长度。排序的时间复杂度为 O(nlogn),比较两个字符串是否相等时间复杂度为 O(n),因此总体时间复杂度为 O(nlogn+n)=O(nlogn)。
-
空间复杂度: O ( l o g n ) O(logn) O(logn)。排序需要 O(logn) 的空间复杂度。
2、哈希表
t 是 s 的异位词等价于「两个字符串中字符出现的种类和次数均相等」。由于字符串只包含 26 个小写字母,因此我们可以维护一个长度为 26 的频次数组 table,先遍历记录字符串 s 中字符出现的频次,然后遍历字符串 t,减去 table 中对应的频次,如果出现 table[i]<0,则说明 t 包含一个不在 s 中的额外字符,返回 false 即可。
class Solution {
public:
bool isAnagram(string s, string t) {
if(s.length() != t.length()){
return false;
}
vector<int> hashtable(26,0); // 创建哈希表
for(auto &ch : s){
hashtable[ch - 'a']++; // 索引编码方式 ch - 'a' 哈希映射 [ a - z ] -> [ 0 - 25 ]
}
for(auto &ch : t){
hashtable[ch - 'a']--;
if(hashtable[ch - 'a'] < 0) return false;
}
return true;
}
};
- 时间复杂度: O ( n ) O(n) O(n),其中 n 为 s 的长度。
- 空间复杂度: O ( S ) O(S) O(S),其中 S 为字符集大小,此处 S=26。
383.赎金信 ●
给你两个字符串:ransomNote 和 magazine ,判断 ransomNote 能不能由 magazine 里面的字符构成。
如果可以,返回 true ;否则返回 false 。
magazine 中的每个字符只能在 ransomNote 中使用一次。
- 数组哈希表
- 时间复杂度: O ( m + n ) O(m+n) O(m+n),其中 m 是字符串 ransomNote 的长度,n 是字符串 magazine 的长度。
- 空间复杂度: O ( ∣ S ∣ ) O(∣S∣) O(∣S∣),S 是字符集,这道题中 S 为全部小写英语字母,因此 |S| = 26。
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
vector<int> HashTable(26,0); // 字母哈希表
for(char ch : magazine){
HashTable[ch-'a']++;
}
for(char ch : ransomNote){
HashTable[ch-'a']--;
if(HashTable[ch-'a'] < 0) return false;
}
return true;
}
};
49.字母异位词分组 ●●
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。(只有小写字母)
输入: strs = [“eat”, “tea”, “tan”, “ate”, “nat”, “bat”]
输出: [[“bat”],[“nat”,“tan”],[“ate”,“eat”,“tea”]]
同一组字母异位词中的字符串具备相同点,可以使用相同点作为一组字母异位词的标志,使用哈希表存储每一组字母异位词,哈希表的键为一组字母异位词的标志,哈希表的值为该组在答案数组中的索引值。
1. 排序 + 哈希表<string, int>
键–相同点–字母异位词标志:排序后字符串相同
- 时间复杂度: O ( n k log k ) O(nk \log k) O(nklogk),其中 n 是 s t r s strs strs 中的字符串的数量,k 是 s t r s strs strs 中的字符串的的最大长度。需要遍历 n 个字符串,对于每个字符串,需要 O ( k log k ) O(k \log k) O(klogk) 的时间进行排序以及 O(1) 的时间更新哈希表,因此总时间复杂度是 O ( n k log k ) O(nk \log k) O(nklogk)。
- 空间复杂度:O(n),其中 n 是 s t r s strs strs 中的字符串的数量,需要用哈希表存储异位词组的索引值。
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
int index = 0; // 记录当前的不同字母组索引
vector<vector<string>> ans;
unordered_map<string, int> hash; // 哈希表,查找字母组索引
for(int i = 0; i < strs.size(); ++i){ // 遍历字符串数组
string str = strs[i]; // 创建字符串缓存当前字符串,否则源字符串会被排序
sort(str.begin(), str.end()); // 字符串排序
if(hash.find(str) == hash.end()){ // 当前字符串未建立新组
hash[str] = index; // 创建哈希元素,即字母组索引值
ans.emplace_back();
ans[index].emplace_back(strs[i]);
++index;
}else{
ans[hash[str]].emplace_back(strs[i]); // 已存在的字母组加入新的字符串
}
}
return ans;
}
};
2. 字母计数 + 哈希
由于互为字母异位词的两个字符串包含的字母相同,因此两个字符串中的相同字母出现的次数一定是相同的,故可以将每个字母出现的次数使用字符串或数组(长度为26)表示,作为哈希表的键。
- 时间复杂度: O ( n ( k + ∣ Σ ∣ ) ) O(n(k+|\Sigma|)) O(n(k+∣Σ∣)),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的的最大长度, Σ Σ \SigmaΣ ΣΣ 是字符集,在本题中字符集为所有小写字母, ∣ Σ ∣ = 26 |\Sigma|=26 ∣Σ∣=26。需要遍历 n 个字符串,对于每个字符串,需要 O(k) 的时间计算每个字母出现的次数, O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣) 的时间生成哈希表的键,以及 O(1) 的时间更新哈希表。
- 空间复杂度: O ( n ( 1 + ∣ Σ ∣ ) ) O(n(1+|\Sigma|)) O(n(1+∣Σ∣))。需要用哈希表存储位词组的索引值,而记录每个字符串中每个字母出现次数的字符串需要的空间为 O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣)。
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
int index = 0; // 记录当前的不同字母组索引
vector<vector<string>> ans;
unordered_map<string, int> hash; // 哈希表,查找字母组索引
for(int i = 0; i < strs.size(); ++i){ // 遍历字符串数组
string str(26, '0'); // 创建字母计数字符串
for(auto ch : strs[i]){
++str[ch-'a']; // 当前字符出现次数+1
}
if(hash.find(str) == hash.end()){ // 当前字符串未建立新组
hash[str] = index; // 创建哈希元素,即字母组索引值
ans.emplace_back();
ans[index].emplace_back(strs[i]);
++index;
}else{
ans[hash[str]].emplace_back(strs[i]); // 已存在的字母组加入新的字符串
}
}
return ans;
}
};
438. 找到字符串中所有字母异位词 ●●
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
输入: s = “cbaebabacd”, p = “abc”
输出: [0,6]
解释:
起始索引等于 0 的子串是 “cba”, 它是 “abc” 的异位词。
起始索引等于 6 的子串是 “bac”, 它是 “abc” 的异位词。
1. 滑动串口 + 数组哈希
时间复杂度: O ( m + ( n − m ) × Σ ) O(m + (n-m) \times \Sigma) O(m+(n−m)×Σ),其中 n 为字符串 s 的长度,m 为字符串 p 的长度, Σ \Sigma Σ 为所有可能的字符数。我们需要 O(m) 来统计字符串 p 中每种字母的数量;需要 O(m) 来初始化滑动窗口;需要判断 n−m+1 个滑动窗口中每种字母的数量是否与字符串 p 中每种字母的数量相同,每次判断需要 O ( Σ ) O(\Sigma) O(Σ) 。因为 s 和 p 仅包含小写字母,所以 Σ \Sigma Σ= 26。
空间复杂度: O ( Σ ) O(\Sigma) O(Σ)。
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ans;
vector<int> pcount(26, 0);
vector<int> scount(26, 0);
int plen = p.length();
int slen = s.length();
for(char ch : p){
++pcount[ch-'a']; // 字母计数
}
int count = 0; // count为已记录字符个数
for(int i = 0; i < slen; ++i){
if(slen - i + count < plen){ // s剩下的字符+已记录的字符 < p的长度
break;
}
++scount[s[i]-'a']; // 字母计数
++count;
if(count >= plen){
if(pcount == scount){ // 判断相等
ans.emplace_back(i-plen+1);
}
--scount[s[i-plen+1]-'a']; // 滑动窗口
--count;
}
}
return ans;
}
};
2. 判断优化
在方法一的基础上,我们不再分别统计滑动窗口和字符串 p 中每种字母的数量,而是统计滑动窗口和字符串 p 中每种字母数量的差;并引入变量 d i f f e r differ differ 来记录当前窗口与字符串 p 中数量不同的字母的个数,并在滑动窗口的过程中维护它。
在判断滑动窗口中每种字母的数量与字符串 p 中每种字母的数量是否相同时,只需要判断 d i f f e r differ differ 是否为零即可,此时复杂度为O(1)。
- 时间复杂度: O ( n + m + Σ ) O(n+m+\Sigma) O(n+m+Σ),需要 O(m) 来统计字符串 p 中每种字母的数量;需要 O(m) 来初始化滑动窗口;需要 O ( Σ ) O(\Sigma) O(Σ) 来初始化 differ;需要 O(n−m) 来滑动窗口并判断窗口内每种字母的数量是否与字符串 p 中每种字母的数量相同,每次判断需要 O(1) 。
- 空间复杂度: O ( Σ ) O(\Sigma) O(Σ)。用于存储滑动窗口和字符串 p 中每种字母数量的差。
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ans;
vector<int> dcount(26, 0);
int plen = p.length();
int slen = s.length();
int count = 0; // count为已记录字符个数
int differ = 0; // 不同数量的字母个数
for(char ch : p){
++dcount[ch-'a']; // 统计p中的字母个数
}
for(int i : dcount){
if(i>0) ++differ; // 统计p中的不同字母个数
}
for(int i = 0; i < slen; ++i){
if(slen - i + count < plen){ // s剩下的字符+已记录的字符 < p的长度
break; // 剪枝
}
++count; // 窗口元素计数
--dcount[s[i]-'a']; // 窗口扩大一位,dcount对应减小
if(dcount[s[i]-'a'] == 0){ // 0 表示个数相同
--differ;
}else if(dcount[s[i]-'a'] == -1){ // -1 表示由于减小之后,由相同变为不同
++differ;
}
if(count >= plen){ // 窗口大小等于p长度
if(differ == 0){ // 判断相等
ans.emplace_back(i-plen+1);
}
--count; // 窗口左边界右移
++dcount[s[i-plen+1]-'a']; // dcount对应增加
if(dcount[s[i-plen+1]-'a'] == 0){ // 0 表示个数相同
--differ;
}else if(dcount[s[i-plen+1]-'a'] == 1){ // 1 表示由于增加,由相同变为不同
++differ;
}
}
}
return ans;
}
};
349. 两个数组的交集 ●
给定两个数组 nums1 和 nums2 ,返回它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
1、哈希表<unordered_set>
- 使用数组来做哈希的题目,是因为题目都限制了数值的大小。
- 而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
- 而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
- 使用 <unordered_set> 读写效率是最高的,并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。
- 时间复杂度: O ( m + n ) O(m+n) O(m+n)
- 空间复杂度:
O
(
m
+
n
)
O(m+n)
O(m+n)
迭代器处理:
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> ansSet; // 存放结果的集合
unordered_set<int> numsSet(nums1.begin(),nums1.end()); // 存放nums1出现过的数字集合
for(int i : nums2){ // 遍历nums2
if(numsSet.find(i) != numsSet.end()){ // unordered_set 的 find() 会返回一个迭代器。
ansSet.insert(i); // 这个迭代器指向和参数哈希值匹配的元素,如果没有匹配的元素,会返回这个容器的结束迭代器(set.end())。
}
}
return vector<int> (ansSet.begin(),ansSet.end()); // 返回结果
}
};
unordered_set<int> HashTable;
HashTable.insert(i);
HashTable.count(i)
ans.resize(num);
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> HashTable;
vector<int> ans(max(nums1.size(), nums2.size()),0);
int num = 0;
for(int i : nums1){
HashTable.insert(i);
}
for(int i : nums2){
if(HashTable.count(i)){
ans[num] = i;
num++;
HashTable.count(i);
}
}
ans.resize(num);
return ans;
}
};
2、排序+双指针
-
时间复杂度: O ( m l o g m + n l o g n ) O(mlogm+nlogn) O(mlogm+nlogn),其中 m 和 n 分别是两个数组的长度。对两个数组排序的时间复杂度分别是 O(mlogm) 和 O(nlogn),双指针寻找交集元素的时间复杂度是 O(m+n),因此总时间复杂度是 O(mlogm+nlogn)。
-
空间复杂度: O ( l o g m + l o g n ) O(logm+logn) O(logm+logn),其中 m 和 n 分别是两个数组的长度。空间复杂度主要取决于排序使用的额外空间。
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
sort(nums1.begin(), nums1.end());
sort(nums2.begin(), nums2.end());
int length1 = nums1.size(), length2 = nums2.size();
int index1 = 0, index2 = 0;
vector<int> intersection;
while (index1 < length1 && index2 < length2) {
int num1 = nums1[index1], num2 = nums2[index2];
if (num1 == num2) {
// 保证加入元素的唯一性
if (!intersection.size() || num1 != intersection.back()) {
intersection.push_back(num1);
}
index1++;
index2++;
} else if (num1 < num2) {
index1++;
} else {
index2++;
}
}
return intersection;
}
};
350. 两个数组的交集 II ●
给你两个整数数组 nums1 和 nums2 ,以数组形式返回两数组的交集。返回结果中每个元素出现的次数,应与元素在两个数组中都出现的次数一致(如果出现次数不一致,则考虑取较小值)。可以不考虑输出结果的顺序。
1、排序 + 双指针
-
时间复杂度: O ( m l o g m + n l o g n ) O(mlogm+nlogn) O(mlogm+nlogn),其中 m 和 n 分别是两个数组的长度。对两个数组排序的时间复杂度分别是 O(mlogm) 和 O(nlogn),双指针寻找交集元素的时间复杂度是 O(m+n),因此总时间复杂度是 O(mlogm+nlogn)。
-
空间复杂度: O ( 1 ) O(1) O(1)
class Solution {
public:
vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
sort(nums1.begin(), nums1.end());
sort(nums2.begin(), nums2.end());
vector<int> ans;
int index1 = 0, index2 = 0;
while(index1 < nums1.size() && index2 < nums2.size()){
if(nums1[index1] == nums2[index2]){
ans.push_back(nums1[index1]);
++index1;
++index2;
}
else if(nums1[index1] < nums2[index2]){
++index1;
}
else{
++index2;
}
}
return ans;
}
};
2、哈希表 <unordered_map>
为了降低空间复杂度,首先遍历较短的数组并在哈希表中记录每个数字以及对应出现的次数,然后遍历较长的数组得到交集。
- 时间复杂度: O ( m + n ) O(m+n) O(m+n),其中 m 和 n 分别是两个数组的长度。需要遍历两个数组并对哈希表进行操作,哈希表操作的时间复杂度是 O(1),因此总时间复杂度与两个数组的长度和呈线性关系。
- 空间复杂度: O ( m i n ( m , n ) ) O(min(m,n)) O(min(m,n)),其中 m 和 n 分别是两个数组的长度。对较短的数组进行哈希表的操作,哈希表的大小不会超过较短的数组的长度。
class Solution {
public:
vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
if (nums1.size() > nums2.size()) { // 减小空间内存消耗,对短数组进行遍历生成哈希表
return intersect(nums2, nums1);
}
vector<int> ans;
unordered_map <int, int> hashMap;
for(int i : nums1){
++hashMap[i];
}
for(int i : nums2){
if(hashMap[i] > 0){
ans.push_back(i); // 新增重复元素
--hashMap[i];
if (hashMap[i] == 0) {
hashMap.erase(i); // 删除掉迭代器指示的结点
}
}
}
return ans;
}
};
如果内存十分小,不足以将数组全部载入内存,那么必然也不能使用哈希这类费内存空间的算法,只能选用空间复杂度最小的算法,即解法一。
但是解法一中需要改造,一般说排序算法都是针对于内部排序,一旦涉及到跟磁盘打交道(外部排序),则需要特殊的考虑。归并排序是天然适合外部排序的算法,可以将分割后的子数组写到单个文件中,归并时将小文件合并为更大的文件。当两个数组均排序完成生成两个大文件后,即可使用双指针遍历两个文件,如此可以使空间复杂度最低。
外部排序:
-
假设文件需要分成 k 块读入,需要从小到大进行排序。
-
依次读入每个文件块,在内存中对当前文件块进行排序(应用恰当的内排序算法),此时,每块文件相当于一个由小到大排列的有序队列;
-
在内存中建立一个最小堆,读入每块文件的队列头;
-
弹出堆顶元素,如果元素来自第i块,则从第i块文件中补充一个元素到最小值堆。弹出的元素暂存至临时数组;
-
当临时数组存满时,将数组写至磁盘,并清空数组内容;
-
重复过程3、4,直至所有文件块读取完毕。
202. 快乐数 ●
输入:n = 19
输出:true
解释:
12+ 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1
三种情况:
- 最终会得到 1。
- 最终会进入循环。
- 值会越来越大,最后接近无穷大。(永远不会发生,因为处理后数值会变小)
1、迭代处理 + 哈希表判断是否循环
- 时间复杂度: O ( 243 ⋅ 3 + l o g n + l o g l o g n + l o g l o g l o g n ) . . . = O ( l o g n ) O(243⋅3+logn+loglogn+logloglogn)... = O(logn) O(243⋅3+logn+loglogn+logloglogn)...=O(logn)。
- 空间复杂度: O ( l o g n ) O(logn) O(logn)
class Solution {
public:
bool isHappy(int n) {
unordered_set<int> hash; // 哈希集合,用于判断当前数字是否存在,是否进入循环
while(n != 1 && !hash.count(n)){ // 判断是否为1,是否进入循环
hash.insert(n); // 插入哈希表
int sum = 0;
while(n>0){
sum += (n%10)*(n%10); // 对数字n取余,得到个位数
n = n/10; // 对数字n取模,得到下一个计算的数字
}
n = sum; // 得到对数字处理后的数值
}
return n == 1;
}
};
2、双指针法判断循环
通过反复调用 getNext(n)
得到的链是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。next 指针是通过调用 getNext(n)
函数获得。
在算法的每一步中,慢指针在链表中前进 1 个节点,快指针前进 2 个节点(对 getNext(n) 函数的嵌套调用)。不管快慢指针在循环中从哪里开始,它们最终都会相遇。这是因为快指针每走一步就向慢指针靠近一个节点(在它们的移动方向上)。
- 时间复杂度: O ( l o g n ) O(logn) O(logn)。该分析建立在对前一种方法的分析的基础上,但是这次我们需要跟踪两个指针而不是一个指针来分析,以及在它们相遇前需要绕着这个循环走多少次。
- 如果没有循环,那么快跑者将先到达 1,慢跑者将到达链表中的一半。我们知道最坏的情况下,成本是 O(2⋅logn)=O(logn)。
- 一旦两个指针都在循环中,在每个循环中,快跑者将离慢跑者更近一步。一旦快跑者落后慢跑者一步,他们就会在下一步相遇。假设循环中有 k 个数字。如果他们的起点是相隔 k−1 的位置(这是他们可以开始的最远的距离),那么快跑者需要 k−1 步才能到达慢跑者,这对于我们的目的来说也是不变的。因此,主操作仍然在计算起始 n 的下一个值,即 O(logn)。
- 空间复杂度: O ( 1 ) O(1) O(1),对于这种方法,我们不需要哈希集来检测循环。指针需要常数的额外空间。
class Solution {
public:
int getNext(int n){ // 处理得到下一个数字
int sum = 0;
while(n > 0){
sum += (n%10)*(n%10);
n = n / 10;
}
return sum;
}
bool isHappy(int n) {
int slow = n;
int fast = getNext(n);
while(fast != 1 && slow != fast){
slow = getNext(slow); // 慢指针移动一次
fast = getNext(fast);
fast = getNext(fast); // 快指针移动两次
}
return fast == 1;
}
};
41. 缺失的第一个正数 ●●●
给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。
请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。
1、负号占位(原地哈希数组)
对于一个长度为 N 的数组,其中没有出现的最小正整数只能在 [1,N+1] 中。这是因为如果 [1,N] 都出现了,那么答案是 N+1,否则答案是 [1,N] 中没有出现的最小正整数;
将给定的数组设计成哈希表的「替代产品」,对数组进行遍历,对于遍历到的数 x,如果它在 [1,N] 的范围内,那么就将数组中的第 x−1 个位置(注意:数组下标从 0 开始)变成负数进行占位标记。
- 将非正数赋值为n+1,此时所有数值都大于0;
- 对数组遍历,并进行负号标记
nums[i]-1
索引上的数,表示nums[i]
存在;(对于遍历数组中的每一个数 nums[i],它可能已经被打了标记,因此需要绝对值处理) - 对数组遍历检查。
- 时间复杂度: O ( N ) O(N) O(N),其中 N 是数组的长度。
- 空间复杂度: O ( 1 ) O(1) O(1)。
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for(int i = 0; i < n; ++i){
if(nums[i] <= 0){ // 小于等于0的数置n+1
nums[i] = n + 1; // 此时所有数值都大于0
}
}
for(int i = 0; i < n; ++i){
int num = abs(nums[i]); // 绝对值处理
if(num <= n){ // 符合要求的数为1~n,把num-1的数值变为负数,进行标记,表示num存在
nums[num - 1] = -abs(nums[num - 1]);
}
}
for(int i = 0; i < n; ++i){
if(nums[i] > 0){
return i+1; // 检查到第一个正数,答案返回i+1
}
}
return n+1; // 1~n都在数组内,答案返回n+1
}
};
2、置换(原地哈希数组)
除了打标记以外,我们还可以使用置换的方法,将给定的数组「恢复」成下面的形式:
如果数组中包含 x∈[1,N],那么恢复后,数组的第 x−1 个元素为 x。
以题目中的示例二 [3, 4, -1, 1] 为例,恢复后的数组应当为 [1, -1, 3, 4],我们就可以知道缺失的数为 2。
对数组进行一次遍历,对于遍历到的数 x=nums[i]
,如果 x∈[1,N],我们就知道 x 应当出现在数组中的 x−1 的位置,因此交换 nums[i]
和 nums[x−1]
,这样 x 就出现在了正确的位置。在完成交换后,新的 nums[i] 可能还在 [1,N] 的范围内,我们需要继续进行交换操作,直到 nums[i] 不属于 [1,N]。
如果nums[i]=x=nums[x−1]
,说明 x 已经出现在了正确的位置。因此我们可以跳出循环,开始遍历下一个数,避免死循环。
- 时间复杂度: O ( N ) O(N) O(N),其中 N 是数组的长度。由于每次的交换操作都会使得某一个数交换到正确的位置,因此交换的次数最多为 N。
- 空间复杂度: O ( 1 ) O(1) O(1)。
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for(int i = 0; i < n; ++i){ // 将当前索引下的数组置换到设定好规则的位置,即在nums[i]-1索引下
while(nums[i] > 0 && nums[i] <= n && nums[i] != nums[nums[i]-1]){
swap(nums[i], nums[nums[i]-1]);
}
}
for(int i = 0; i < n; ++i){
if(nums[i] != i + 1){ // 遍历检查不符合规则的索引
return i+1;
}
}
return n+1;
}
};
1. 两数之和 ●
1、嵌套for循环
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 空间复杂度: O ( 1 ) O(1) O(1)。
2、哈希表 <unordered_map>
1)两次遍历(创建哈希表再查找)
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
vector<int> ans;
unordered_map<int, int> hash;
int n = nums.size();
for(int i = 0; i < n; ++i){
hash.emplace(nums[i], i); // 添加键值对
}
for(int i = 0; i < nums.size(); ++i){ // 判断是够存在另一个加数
if(hash.count(target - nums[i]) && hash[target - nums[i]] != i){
ans.push_back(i);
ans.push_back(hash[target - nums[i]]);
break;
}
}
return ans;
}
};
2)一次遍历优化(边查找哈希表,边添加键值对)
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
std::unordered_map <int,int> map;
for(int i = 0; i < nums.size(); i++) {
auto iter = map.find(target - nums[i]); // 迭代器
if(iter != map.end()) { // 存在时不等于 end
return {iter->second, i}; // iter->second 指向 值
}
map.insert(pair<int, int>(nums[i], i)); // 添加键值对
}
return {};
}
};
454. 四数相加 II ●●
分组+哈希表< unordered_map >
我们可以将四个数组分成两部分,A 和 B 为一组,C 和 D 为另外一组。
对于 A 和 B,我们使用二重循环对它们进行遍历,建立哈希映射,每个键表示一种 A[i]+B[j],对应的值为 A[i]+B[j] 出现的次数。
对于 C 和 D,我们同样使用二重循环对它们进行遍历。当遍历到 C[k]+D[l] 时,如果 −(C[k]+D[l]) 出现在哈希映射中,那么将 -(C[k]+D[l]) 对应的值累加进答案中。
最终即可得到满足 A[i]+B[j]+C[k]+D[l]=0 的四元组数目。
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)。我们使用了两次二重循环,时间复杂度均为 O ( n 2 ) O(n^2) O(n2)。在循环中对哈希映射进行的修改以及查询操作的期望时间复杂度均为 O(1),因此总时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
- 空间复杂度: O ( n 2 ) O(n^2) O(n2),即为哈希映射需要使用的空间。在最坏的情况下,A[i]+B[j] 的值均不相同,因此值的个数为 n 2 n^2 n2。
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> hash;
for(int a : nums1){
for(int b : nums2){
++hash[a+b]; // 嵌套遍历,建立哈希映射
}
}
int ans = 0;
for(int c : nums3){
for(int d : nums4){
ans += hash[-c-d]; // 取出哈希映射
}
}
return ans;
}
};