哈希表
散列表(Hash table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構。也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散列表。
給定表M,存在函數f(key),對任意給定的關鍵字值key,代入函數后若能得到包含該關鍵字的記錄在表中的地址,則稱表M為哈希(Hash)表,函數f(key)為哈希(Hash) 函數。
關鍵碼值與地址一一映射
適用場景
適用於關鍵字與某一值一一對應,即 可使用鍵值對map,而hashmap是鍵值對中較好的實現類
關鍵詞:一一對應
遍歷哈希表的四種方式
public static void main(String[] args) {
Map<String,String> map=new HashMap<String,String>();
map.put("1", "value1");
map.put("2", "value2");
map.put("3", "value3");
map.put("4", "value4");
//第一種:普通使用,二次取值
// 遍歷鍵,取出值
System.out.println("\n通過Map.keySet遍歷key和value:");
for(String key:map.keySet()) {
System.out.println("Key: "+key+" Value: "+map.get(key));
}
//第二種
// 使用Map.entrySet()的迭代器
System.out.println("\n通過Map.entrySet使用iterator遍歷key和value: ");
Iterator map1it=map.entrySet().iterator();
while(map1it.hasNext()) {
Map.Entry<String, String> entry=(Entry<String, String>) map1it.next();
System.out.println("Key: "+entry.getKey()+" Value: "+entry.getValue());
}
//第三種:推薦,尤其是容量大時
// foreach
System.out.println("\n通過Map.entrySet遍歷key和value");
for(Map.Entry<String, String> entry: map.entrySet()) {
System.out.println("Key: "+ entry.getKey()+ " Value: "+entry.getValue());
}
//第四種
// 遍歷value
System.out.println("\n通過Map.values()遍歷所有的value,但不能遍歷key");
for(String v:map.values()) {
System.out.println("The value is "+v);
}
}
輸出結果:
通過Map.keySet遍歷key和value:
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4
通過Map.entrySet使用iterator遍歷key和value:
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4
通過Map.entrySet遍歷key和value
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4
通過Map.values()遍歷所有的value,但不能遍歷key
The value is value1
The value is value2
The value is value3
The value is value4
【推薦】使用entrySet遍歷Map類集合KV,而不是keySet方式進行遍歷。
說明:keySet 其實是遍歷了2次,一次是轉為Iterator對象,另一次是從hashMap中取出key所對應的value。而entrySet只是遍歷了一次就把key和value都放到了entry中,效
率更高。如果是JDK8,使用Map.foreach方法。
正例:values()返回的是V值集合,是一個list集合對象; keySet()返回的是K值集合,是一個Set集合對象; entrySet()返回的是K-V值組合集合。
記錄數組中元素出現頻數
遍歷nums1,使用哈希表存儲關鍵字,以及他們出現的次數
方法一:遇到空的就賦初值,非空就+1
// 1. 遍歷nums1,使用哈希表存儲關鍵字,以及他們出現的次數
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums1.length; i++) {
if (map.get(nums1[i]) != null) {
map.put(nums1[i], map.get(nums1[i])+1);
} else {
map.put(nums1[i], 1);
}
}
方法二:使用getOrDefault()
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for (int num : nums1) {
int count = map.getOrDefault(num, 0) + 1;
map.put(num, count);
}
方法三:如果元素固定,那么我們可以就使用一個一維數組new int[128]
來存儲他們出現的頻數。這也是哈希表法。
遍歷字符串 p,記錄字符頻數
// int[] sArr = new int[26]; // 26個字母時
int[] sArr = new int[128]; // 所有ASCII碼字符128個
for (int i = 0; i < p.length(); i++) {
sArr[s.charAt(i) - 'a']++;
}
方法四:如果要求元素只出現一次 或者判斷是否有重復元素,那就可以用哈希集合
Set<Integer, Integer> set = new HashSet<Integer>();
for (int num : nums1) {
// 添加此元素至 Set,加入失敗那就代表有重復
if(!set.add(num)) {
return false;
}
}
哈希表 + 列表
哈希表 = <String, ArrayList>
,鍵值對為<字符串, 列表>
for (int i = 0; i < p.length(); i++) {
if (!map.containsKey(keyStr)) { // 如果不存在此鍵
map.put(keyStr, new ArrayList<>()); // 創建一個列表
}
map.get(keyStr).add(s); // 獲取此鍵對應的列表,添加列表元素
}
面試題 10.02. 變位詞組
編寫一種方法,對字符串數組進行排序,將所有變位詞組合在一起。變位詞是指字母相同,但排列不同的字符串。
注意:本題相對原題稍作修改
示例:
輸入: ["eat", "tea", "tan", "ate", "nat", "bat"],
輸出:
[
["ate","eat","tea"],
["nat","tan"],
["bat"]
]
答案
用哈希表來存儲列表,使字符串與列表一一對應。
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
//邊界條件判斷
if (strs == null || strs.length == 0)
return new ArrayList<>();
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] ca = new char[26];
// 統計字符串中每個字符串出現的次數
for (char c : s.toCharArray())
ca[c - 'a']++;
// 統計每個字符出現次數的數組轉化為字符串
String keyStr = String.valueOf(ca);
// map.getOrDefault(keyStr, new ArrayList<>()).add(s);
// 不能用上面那個,因為沒有put,我們只是獲取到列表然后在列表后面添加而已,沒有修改哈希表的值
if (!map.containsKey(keyStr))
map.put(keyStr, new ArrayList<>());
map.get(keyStr).add(s);
}
return new ArrayList<>(map.values());
}
}
實例
1. 兩數之和
給定一個整數數組 nums 和一個整數目標值 target,請你在該數組中找出 和為目標值 的那 兩個 整數,並返回它們的數組下標。
你可以假設每種輸入只會對應一個答案。但是,數組中同一個元素不能使用兩遍。
你可以按任意順序返回答案。
示例 1:
輸入:nums = [2,7,11,15], target = 9
輸出:[0,1]
解釋:因為 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
輸入:nums = [3,2,4], target = 6
輸出:[1,2]
示例 3:
輸入:nums = [3,3], target = 6
輸出:[0,1]
答案
class Solution {
public int[] twoSum(int[] nums, int target) {
// 哈希表用來存放(關鍵字,下標)
Map<Integer, Integer> map = new HashMap<>();
// 遍歷數組,每次遇到一個元素判斷哈希表里面有沒有與其對應的target - nums[i]元素
// 如果有就返回下標,如果沒有就把它的關鍵字和下標放進去,
for (int i = 0; i < nums.length; i++) {
Integer index = map.get(target - nums[i]);
if (index != null) {
return new int[]{index, i};
} else {
map.put(nums[i], i);
}
}
return null;
}
}
面試題 16.24. 數對和
設計一個算法,找出數組中兩數之和為指定值的所有整數對。一個數只能屬於一個數對。
示例 1:
輸入: nums = [5,6,5], target = 11
輸出: [[5,6]]
示例 2:
輸入: nums = [5,6,5,6], target = 11
輸出: [[5,6],[5,6]]
答案一:兩次遍歷
遇到這題的第一反應就是,先哈希表確定每個元素的出現次數,再來找匹配的元素。
class Solution {
public List<List<Integer>> pairSums(int[] nums, int target) {
//key:數組的元素;value:該元素出現的次數
Map<Integer, Integer> map = new HashMap<>();
List<List<Integer>> res = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
int count = map.getOrDefault(nums[i], 0) + 1;
map.put(nums[i], count);
}
for (int i = 0; i < nums.length; i++) {
int count1 = map.getOrDefault(nums[i], 0);
map.put(nums[i], count1 - 1);
int count2 = map.getOrDefault(target - nums[i], 0);
map.put(target - nums[i], count2 - 1);
if (count1 > 0 && count2 > 0) {
List<Integer> temp = new ArrayList<>();
temp.add(nums[i]);
temp.add(target - nums[i]);
res.add(temp);
}
}
return res;
}
}
答案二:一次遍歷
后來我們開始思考,能不能邊遍歷邊尋找匹配的元素呢?
其實我們可以在遍歷的時候,直接看有沒有匹配的
- 如果有,那就匹配的那個元素-1,當前元素就不+1了,+1-1抵消了
- 如果沒有,那就當前元素+1
class Solution {
public List<List<Integer>> pairSums(int[] nums, int target) {
//key:數組的元素;value:該元素出現的次數
Map<Integer, Integer> map = new HashMap<>();
List<List<Integer>> res = new ArrayList<>();
for (int num : nums) {
// 在遍歷的時候,直接看有沒有匹配的
// 如果有,那就匹配的那個元素-1
// 如果沒有,那就當前元素+1
int count = map.getOrDefault(target - num, 0);
if (count > 0) {
res.add(Arrays.asList(num, target - num));
map.put(target - num, --count);
} else {
map.put(num, map.getOrDefault(num, 0) + 1);
}
}
return res;
}
}
有效的字母異位詞
給定兩個字符串 s 和 t ,編寫一個函數來判斷 t 是否是 s 的字母異位詞。
示例 1:
輸入: s = "anagram", t = "nagaram"
輸出: true
示例 2:
輸入: s = "rat", t = "car"
輸出: false
說明:
你可以假設字符串只包含小寫字母。
進階:
如果輸入字符串包含 unicode 字符怎么辦?你能否調整你的解法來應對這種情況?
答案
方法一:排序
t 是 s 的異位詞等價於「兩個字符串排序后相等」。因此我們可以對字符串 s 和 t 分別排序,看排序后的字符串是否相等即可判斷。此外,如果 s 和 t 的長度不同,t 必然不是 s 的異位詞。
Java
class Solution {
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
char[] str1 = s.toCharArray();
char[] str2 = t.toCharArray();
Arrays.sort(str1);
Arrays.sort(str2);
return Arrays.equals(str1, str2);
}
}
復雜度分析
時間復雜度:\(O(n \log n)\),其中 n 為 s 的長度。排序的時間復雜度為 \(O(n\log n)\),比較兩個字符串是否相等時間復雜度為 \(O(n)\),因此總體時間復雜度為 \(O(n \log n+n)=O(n\log n)\)。
空間復雜度:\(O(\log n)\)。排序需要 \(O(\log n)\) 的空間復雜度。注意,在某些語言(比如 Java & JavaScript)中字符串是不可變的,因此我們需要額外的 \(O(n)\) 的空間來拷貝字符串。但是我們忽略這一復雜度分析,因為:
這依賴於語言的細節;
這取決於函數的設計方式,例如,可以將函數參數類型更改為 char[]。
方法二:哈希表
前面我們說過了關鍵碼值與地址一一映射,就可以稱為哈希表(即 散列表),所以此處的方法也可以稱為哈希表法。
從另一個角度考慮,t 是 s 的異位詞等價於「兩個字符串中字符出現的種類和次數均相等」。由於字符串只包含 26 個小寫字母,因此我們可以維護一個長度為 26 的頻次數組 \(\textit{table}\),先遍歷記錄字符串 s 中字符出現的頻次,然后遍歷字符串 t,減去 \(\textit{table}\) 中對應的頻次,如果出現 \(\textit{table}[i]<0\),則說明 t 包含一個不在 s 中的額外字符,返回 \(\text{false}\) 即可。
Java
class Solution {
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
int[] table = new int[26];
for (int i = 0; i < s.length(); i++) {
table[s.charAt(i) - 'a']++;
}
for (int i = 0; i < t.length(); i++) {
table[t.charAt(i) - 'a']--;
if (table[t.charAt(i) - 'a'] < 0) {
return false;
}
}
return true;
}
}
對於進階問題,\(\text{Unicode}\) 是為了解決傳統字符編碼的局限性而產生的方案,它為每個語言中的字符規定了一個唯一的二進制編碼。而 \(\text{Unicode}\) 中可能存在一個字符對應多個字節的問題,為了讓計算機知道多少字節表示一個字符,面向傳輸的編碼方式的 \(\text{UTF}-8\) 和 \(\text{UTF}-16\) 也隨之誕生逐漸廣泛使用,具體相關的知識讀者可以繼續查閱相關資料拓展視野,這里不再展開。
回到本題,進階問題的核心點在於「字符是離散未知的」,因此我們用哈希表維護對應字符的頻次即可。同時讀者需要注意 \(\text{Unicode}\) 一個字符可能對應多個字節的問題,不同語言對於字符串讀取處理的方式是不同的。
Java
class Solution {
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
Map<Character, Integer> table = new HashMap<Character, Integer>();
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
table.put(ch, table.getOrDefault(ch, 0) + 1);
}
for (int i = 0; i < t.length(); i++) {
char ch = t.charAt(i);
table.put(ch, table.getOrDefault(ch, 0) - 1);
if (table.get(ch) < 0) {
return false;
}
}
return true;
}
}
復雜度分析
時間復雜度:\(O(n)\),其中 n 為 s 的長度。
空間復雜度:\(O(S)\),其中 S 為字符集大小,此處 \(S=26\)。
劍指 Offer 61. 撲克牌中的順子
從撲克牌中隨機抽5張牌,判斷是不是一個順子,即這5張牌是不是連續的。2~10為數字本身,A為1,J為11,Q為12,K為13,而大、小王為 0 ,可以看成任意數字。A 不能視為 14。
示例 1:
輸入: [1,2,3,4,5]
輸出: True
示例 2:
輸入: [0,0,1,2,5]
輸出: True
答案
class Solution {
public boolean isStraight(int[] nums) {
Set<Integer> repeat = new HashSet<>();
int max = 0, min = 14;
for(int num : nums) {
if(num == 0) continue; // 跳過大小王
max = Math.max(max, num); // 最大牌
min = Math.min(min, num); // 最小牌
// 添加此牌至 Set,加入失敗那就代表有重復
if(!repeat.add(num)) return false; // 若有重復,提前返回 false
}
return max - min + 1 <= 5; // 最大牌 - 最小牌 + 1 <= 5 則可構成順子,因為包含有0、0、0、9、11的情況
}
}
面試題 01.04. 回文排列
給定一個字符串,編寫一個函數判定其是否為某個回文串的排列之一。
回文串是指正反兩個方向都一樣的單詞或短語。排列是指字母的重新排列。
回文串不一定是字典當中的單詞。
示例1:
輸入:"tactcoa"
輸出:true(排列有"tacocat"、"atcocta",等等)
答案
主要利用函數統計出s中所有重復次數為奇數次元素的個數,如果個數為1或0,則該字符串是一個回文串,否則就不是
class Solution {
public boolean canPermutePalindrome(String s) {
char[] map = new char[128]; // 這里需要存儲整個ASCII字符,所以128
int ans = 0;
for (int i = 0; i < s.length(); i++) {
map[s.charAt(i)]++;
}
for (int i = 0; i < map.length; i++) {
if (map[i] % 2 == 1) {
ans++;
}
}
return ans <= 1;
}
}