6.7雜湊表

七龙猪發表於2024-06-07

雜湊表

雜湊表(英文名字為Hash table,國內也有一些演算法書籍翻譯為雜湊表,大家看到這兩個名稱知道都是指hash table就可以了)。

雜湊表是根據關鍵碼的值而直接進行訪問的資料結構。

陣列就是一張雜湊表。雜湊表中關鍵碼就是陣列的索引下標,然後透過下標直接訪問陣列中的元素。

那麼雜湊表能解決什麼問題呢,一般雜湊表都是用來快速判斷一個元素是否出現集合裡。

例如要查詢一個名字是否在這所學校裡。

要列舉的話時間複雜度是O(n),但如果使用雜湊表的話, 只需要O(1)就可以做到。

我們只需要初始化把這所學校裡學生的名字都存在雜湊表裡,在查詢的時候透過索引直接就可以知道這位同學在不在這所學校裡了。

將學生姓名對映到雜湊表上就涉及到了hash function ,也就是雜湊函式

雜湊函式

雜湊函式,把學生的姓名直接對映為雜湊表上的索引,然後就可以透過查詢索引下標快速知道這位同學是否在這所學校裡了。

雜湊函式如下圖所示,透過hashCode把名字轉化為數值,一般hashcode是透過特定編碼方式,可以將其他資料格式轉化為不同的數值,這樣就把學生名字對映為雜湊表上的索引數字了。

雜湊表2

如果hashCode得到的數值大於 雜湊表的大小了,也就是大於tableSize了,怎麼辦呢?

此時為了保證對映出來的索引數值都落在雜湊表上,我們會在再次對數值做一個取模的操作,這樣我們就保證了學生姓名一定可以對映到雜湊表上了。

此時問題又來了,雜湊表我們剛剛說過,就是一個陣列。

如果學生的數量大於雜湊表的大小怎麼辦,此時就算雜湊函式計算的再均勻,也避免不了會有幾位學生的名字同時對映到雜湊表 同一個索引下標的位置。

雜湊碰撞

一般雜湊碰撞有兩種解決方法,拉鍊法和線性探測法

拉鍊法:

剛剛小李和小王在索引1的位置發生了衝突,發生衝突的元素都被儲存在連結串列中。 這樣我們就可以透過索引找到小李和小王了。

其實拉鍊法就是要選擇適當的雜湊表的大小,這樣既不會因為陣列空值而浪費大量記憶體,也不會因為連結串列太長而在查詢上浪費太多時間。

圖中資料規模是dataSize, 雜湊表的大小為tableSize

雜湊表4

線性探測法

使用線性探測法,一定要保證tableSize大於dataSize。 我們需要依靠雜湊表中的空位來解決碰撞問題。

例如衝突的位置,放了小李,那麼就向下找一個空位放置小王的資訊。所以要求tableSize一定要大於dataSize ,要不然雜湊表上就沒有空置的位置來存放 衝突的資料了。如圖所示:

雜湊表5

常見的三種雜湊結構

當我們想使用雜湊法來解決問題的時候,我們一般會選擇如下三種資料結構。

  • 陣列
  • set (集合)
  • map(對映)

這裡陣列就沒啥可說的了,我們來看一下set。

在C++中,set 和 map 分別提供以下三種資料結構,其底層實現以及優劣如下表所示:

集合 底層實現 是否有序 數值是否可以重複 能否更改數值 查詢效率 增刪效率
std::set 紅黑樹 有序 O(log n) O(log n)
std::multiset 紅黑樹 有序 O(logn) O(logn)
std::unordered_set 雜湊表 無序 O(1) O(1)

std::unordered_set底層實現為雜湊表,std::setstd::multiset 的底層實現是紅黑樹,紅黑樹是一種平衡二叉搜尋樹,所以key值是有序的,但key不可以修改,改動key值會導致整棵樹的錯亂,所以只能刪除和增加

對映 底層實現 是否有序 數值是否可以重複 能否更改數值 查詢效率 增刪效率
std::map 紅黑樹 key有序 key不可重複 key不可修改 O(logn) O(logn)
std::multimap 紅黑樹 key有序 key可重複 key不可修改 O(log n) O(log n)
std::unordered_map 雜湊表 key無序 key不可重複 key不可修改 O(1) O(1)

std::unordered_map 底層實現為雜湊表,std::mapstd::multimap 的底層實現是紅黑樹。同理,std::mapstd::multimap 的key也是有序的(這個問題也經常作為面試題,考察對語言容器底層的理解)。

當我們要使用集合來解決雜湊問題的時候,優先使用unordered_set,因為它的查詢和增刪效率是最優的,如果需要集合是有序的,那麼就用set,如果要求不僅有序還要有重複資料的話,那麼就用multiset。

那麼再來看一下mapmap 是一個key value 的資料結構,map中,對key是有限制,對value沒有限制的,因為key的儲存方式使用紅黑樹實現的。

其他語言例如:java裡的HashMapTreeMap 都是一樣的原理。可以靈活貫通。

雖然std::set、std::multiset 的底層實現是紅黑樹,不是雜湊表,std::set、std::multiset 使用紅黑樹來索引和儲存,不過給我們的使用方式,還是雜湊法的使用方式,即keyvalue。所以使用這些資料結構來解決對映問題的方法,我們依然稱之為雜湊法。 map也是一樣的道理。

這裡在說一下,一些C++的經典書籍上 例如STL原始碼剖析,說到了hash_set、 hash_map,這個與unordered_set,unordered_map又有什麼關係呢?

實際上功能都是一樣一樣的, 但是unordered_set在C++11的時候被引入標準庫了,而hash_set並沒有,所以建議還是使用unordered_set比較好,這就好比一個是官方認證的,hash_set,hash_map 是C++11標準之前民間高手自發造的輪子。

雜湊表6

總結

總結一下,當我們遇到了要快速判斷一個元素是否出現集合裡的時候,就要考慮雜湊法

但是雜湊法也是犧牲了空間換取了時間,因為我們要使用額外的陣列,set或者是map來存放資料,才能實現快速的查詢。

如果在做面試題目的時候遇到需要判斷一個元素是否出現過的場景也應該第一時間想到雜湊法!


242. 有效的字母異位詞

題意描述:

給定兩個字串 st ,編寫一個函式來判斷 t 是否是 s 的字母異位詞。

注意:st 中每個字元出現的次數都相同,則稱 st 互為字母異位詞。

示例 1:

輸入: s = "anagram", t = "nagaram"
輸出: true

示例 2:

輸入: s = "rat", t = "car"
輸出: false

提示:

  • 1 <= s.length, t.length <= 5 * 104
  • st 僅包含小寫字母

進階: 如果輸入字串包含 unicode 字元怎麼辦?你能否調整你的解法來應對這種情況?

思路:

陣列其實就是一個簡單雜湊表,而且這道題目中字串只有小寫字元(這點很重要),那麼就可以定義一個陣列,來記錄字串s裡字元出現的次數。

需要定義一個多大的陣列呢,定一個陣列叫做record,大小為26 就可以了,初始化為0,因為字元a到字元z的ASCII也是26個連續的數值。

為了方便舉例,判斷一下字串s= "aee", t = "eae"。

操作動畫如下:

242.有效的字母異位詞

定義一個陣列叫做record用來上記錄字串s裡字元出現的次數。

需要把字元對映到陣列也就是雜湊表的索引下標上,因為字元a到字元z的ASCII是26個連續的數值,所以字元a對映為下標0,相應的字元z對映為下標25。

在遍歷字串s的時候,只需要將s[i] - ‘a’所在的元素做+1 操作即可,並不需要記住字元a的ASCII,只要求出一個相對數值就可以了。 這樣就將字串s中字元出現的次數,統計出來了。

那看一下如何檢查字串t中是否出現了這些字元,同樣在遍歷字串t的時候,對t中出現的字元對映雜湊表索引上的數值再做-1的操作。

那麼最後檢查一下,record陣列如果有的元素不為零0,說明字串s和t一定是誰多了字元或者誰少了字元,return false。

最後如果record陣列所有元素都為零0,說明字串s和t是字母異位詞,return true。

時間複雜度為O(n),空間上因為定義是的一個常量大小的輔助陣列,所以空間複雜度為O(1)。

AC程式碼:

class Solution {
public:
    bool isAnagram(string s, string t) {
        int record[26] = {0};
        for (int i = 0; i < s.size(); i++) {
            // 並不需要記住字元a的ASCII,只要求出一個相對數值就可以了
            record[s[i] - 'a']++;
        }
        for (int i = 0; i < t.size(); i++) {
            record[t[i] - 'a']--;
        }
        for (int i = 0; i < 26; i++) {
            if (record[i] != 0) {
                // record陣列如果有的元素不為零0,說明字串s和t 一定是誰多了字元或者誰少了字元。
                return false;
            }
        }
        // record陣列所有元素都為零0,說明字串s和t是字母異位詞
        return true;
    }
};

時間複雜度: O(n)
空間複雜度: O(1)


383. 贖金信

題意描述:

給你兩個字串:ransomNotemagazine ,判斷 ransomNote 能不能由 magazine 裡面的字元構成。

如果可以,返回 true ;否則返回 false

magazine 中的每個字元只能在 ransomNote 中使用一次。

示例 1:

輸入:ransomNote = "a", magazine = "b"
輸出:false

示例 2:

輸入:ransomNote = "aa", magazine = "ab"
輸出:false

示例 3:

輸入:ransomNote = "aa", magazine = "aab"
輸出:true

提示:

  • 1 <= ransomNote.length, magazine.length <= 105
  • ransomNotemagazine 由小寫英文字母組成

思路:

同上一道,改成有元素大於0則return false(表示magazine覆蓋不了ransomNote)即可.

AC程式碼:

class Solution {
public:
    bool canConstruct(string ransomNote, string magazine) {
        int record[26] = {0};
        for(int i = 0 ; i < ransomNote.size() ; i++)  record[ransomNote[i] - 'a']++;

        for(int i = 0 ; i < magazine.size() ; i++)  record[magazine[i] - 'a']--;

        for(int i = 0 ; i < 26 ; i++){
          if(record[i] > 0)  return false;
        }

        return true;
    }
};

M:49. 字母異位詞分組

題意描述:

給你一個字串陣列,請你將 字母異位詞 組合在一起。可以按任意順序返回結果列表。

字母異位詞 是由重新排列源單詞的所有字母得到的一個新單詞。

示例 1:

輸入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
輸出: [["bat"],["nat","tan"],["ate","eat","tea"]]

示例 2:

輸入: strs = [""]
輸出: [[""]]

示例 3:

輸入: strs = ["a"]
輸出: [["a"]]

提示:

  • 1 <= strs.length <= 104
  • 0 <= strs[i].length <= 100
  • strs[i] 僅包含小寫字母

思路:

兩個字串互為字母異位詞,當且僅當兩個字串包含的字母相同。同一組字母異位詞中的字串具備相同點,可以使用相同點作為一組字母異位詞的標誌,使用雜湊表儲存每一組字母異位詞,雜湊表的鍵為一組字母異位詞的標誌,雜湊表的值為一組字母異位詞列表

遍歷每個字串,對於每個字串,得到該字串所在的一組字母異位詞的標誌,將當前字串加入該組字母異位詞的列表中。遍歷全部字串之後,雜湊表中的每個鍵值對即為一組字母異位詞。

以下的兩種方法分別使用排序和計數作為雜湊表的鍵。

方法一:排序(本題優選)

由於互為字母異位詞的兩個字串包含的字母相同,因此對兩個字串分別進行排序之後得到的字串一定是相同的,故可以將排序之後的字串作為雜湊表的鍵。

emplace 關鍵字是 C++11 的一個新特性。emplace_back()push_abck() 的區別是:push_back() 在向 vector 尾部新增一個元素時,首先會建立一個臨時物件,然後再將這個臨時物件移動或複製到 vector 中(如果是複製的話,事後會自動銷燬先前建立的這個臨時元素);而 emplace_back() 在實現時,則是直接在 vector 尾部建立這個元素,省去了移動或者複製元素的過程。

AC程式碼:

class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string, vector<string>> mp;
        for (string& str: strs) {
            string key = str;
            sort(key.begin(), key.end());
            mp[key].emplace_back(str);
        }
        vector<vector<string>> ans;
        for (auto it = mp.begin(); it != mp.end(); ++it) {
            ans.emplace_back(it->second);
        }
        return ans;
    }
};

時間複雜度:O(nklogk),其中 n 是strs中的字串的數量,k 是strs 中的字串的的最大長度。需要遍歷 n 個字串,對於每個字串,需要O(klogk) 的時間進行排序以及O(1) 的時間更新雜湊表,因此總時間複雜度是O(nklogk)。

空間複雜度:O(nk),其中 n 是strs 中的字串的數量,k 是 strs 中的字串的的最大長度。需要用雜湊表儲存全部字串。

方法二:計數

由於互為字母異位詞的兩個字串包含的字母相同,因此兩個字串中的相同字母出現的次數一定是相同的,故可以將每個字母出現的次數使用字串表示,作為雜湊表的鍵。

由於字串只包含小寫字母,因此對於每個字串,可以使用長度為 26 的陣列記錄每個字母出現的次數。需要注意的是,在使用陣列作為雜湊表的鍵時,不同語言的支援程度不同,因此不同語言的實現方式也不同。

AC程式碼:

class Solution{
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string,vector<string>> map;
        for(string str:strs) {
            int counts[26] = {0};
            for(char c:str) {
                counts[c-'a']++;
            }
            string key = "";
            for(int i = 0;i<26;++i) {
                if(counts[i]!=0) {
                    key.push_back(i+'a');
                    key.push_back(counts[i]);
                }
            }
            map[key].push_back(str);
        }
        vector<vector<string>> res;
        for(auto& p:map) {
            res.push_back(p.second);
        }
        return res;
    }
};

M:438. 找到字串中所有字母異位詞

題意描述:

給定兩個字串 sp,找到 s 中所有 p異位詞 的子串,返回這些子串的起始索引。不考慮答案輸出的順序。

異位詞 指由相同字母重排列形成的字串(包括相同的字串)。

示例 1:

輸入: s = "cbaebabacd", p = "abc"
輸出: [0,6]
解釋:
起始索引等於 0 的子串是 "cba", 它是 "abc" 的異位詞。
起始索引等於 6 的子串是 "bac", 它是 "abc" 的異位詞。

示例 2:

輸入: s = "abab", p = "ab"
輸出: [0,1,2]
解釋:
起始索引等於 0 的子串是 "ab", 它是 "ab" 的異位詞。
起始索引等於 1 的子串是 "ba", 它是 "ab" 的異位詞。
起始索引等於 2 的子串是 "ab", 它是 "ab" 的異位詞。

提示:

  • 1 <= s.length, p.length <= 3 * 104
  • sp 僅包含小寫字母

思路:

根據題目要求,我們需要在字串 s 尋找字串 p 的異位詞。因為字串 p 的異位詞的長度一定與字串 p 的長度相同,所以我們可以在字串 s 中構造一個長度為與字串 p 的長度相同的滑動視窗,並在滑動中維護視窗中每種字母的數量;當視窗中每種字母的數量與字串p中每種字母的數量相同時,則說明當前視窗為字串 p 的異位詞。

在演算法的實現中,我們可以使用陣列來儲存字串 p 和滑動視窗中每種字母的數量。

AC程式碼:

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size(), pLen = p.size();

        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;
        vector<int> sCount(26);
        vector<int> pCount(26);
        for (int i = 0; i < pLen; ++i) {
            ++sCount[s[i] - 'a'];
            ++pCount[p[i] - 'a'];
        }
				//S與p初始視窗count數完全一樣,則返回0起始索引
        if (sCount == pCount) {
            ans.emplace_back(0);
        }
			//共有sLen - pLen + 1 個滑動視窗
        for (int i = 0; i < sLen - pLen; ++i) {
          //每次滑動減去最前一個,加上最後一個
            --sCount[s[i] - 'a'];
            ++sCount[s[i + pLen] - 'a'];
				//由於已經滑動了一次,因此起始索引為i + 1
            if (sCount == pCount) {
                ans.emplace_back(i + 1);
            }
        }

        return ans;
    }
};

時間複雜度:O(m+(n−m)×Σ),其中 n 為字串 s 的長度,m 為字串 p 的長度,Σ為所有可能的字元數。我們需要 O(m) 來統計字串p中每種字母的數量;需要O(m) 來初始化滑動視窗;需要判斷n−m+1 個滑動視窗中每種字母的數量是否與字串 p 中每種字母的數量相同,每次判斷需要O(Σ) 。因為 s p 僅包含小寫字母,所以 Σ=26

空間複雜度:O(Σ)。用於儲存字串 p 和滑動視窗中每種字母的數量。

最佳化:

在方法一的基礎上,我們不再分別統計滑動視窗和字串 p 中每種字母的數量,而是統計滑動視窗和字串 p 中每種字母數量的差;並引入變數 differ 來記錄當前視窗與字串 p 中數量不同的字母的個數,並在滑動視窗的過程中維護它。

在判斷滑動視窗中每種字母的數量與字串p中每種字母的數量是否相同時,只需要判斷 differ是否為零即可。

程式碼:

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size(), pLen = p.size();

        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;
        vector<int> count(26);
        for (int i = 0; i < pLen; ++i) {
            ++count[s[i] - 'a'];
            --count[p[i] - 'a'];
        }

        int differ = 0;
        for (int j = 0; j < 26; ++j) {
            if (count[j] != 0) {
                ++differ;
            }
        }

        if (differ == 0) {
            ans.emplace_back(0);
        }

        for (int i = 0; i < sLen - pLen; ++i) {
          //滑動中判斷最前一格
            if (count[s[i] - 'a'] == 1) {  // 視窗中字母 s[i] 的數量與字串 p 中的數量從不同變得相同
                --differ;
            } else if (count[s[i] - 'a'] == 0) {  // 視窗中字母 s[i] 的數量與字串 p 中的數量從相同變得不同
                ++differ;
            }
          //最前一格的count減一
            --count[s[i] - 'a'];
					//判斷最後新加的一格
            if (count[s[i + pLen] - 'a'] == -1) {  // 視窗中字母 s[i+pLen] 的數量與字串 p 中的數量從不同變得相同
                --differ;
            } else if (count[s[i + pLen] - 'a'] == 0) {  // 視窗中字母 s[i+pLen] 的數量與字串 p 中的數量從相同變得不同
                ++differ;
            }
          //最後一格count++
            ++count[s[i + pLen] - 'a'];
            
            if (differ == 0) {
                ans.emplace_back(i + 1);
            }
        }

        return ans;
    }
};

349. 兩個陣列的交集

題意描述:

給定兩個陣列 nums1nums2 ,返回它們的交集。輸出結果中的每個元素一定是 唯一 的。我們可以 不考慮輸出結果的順序

示例 1:

輸入:nums1 = [1,2,2,1], nums2 = [2,2]
輸出:[2]

示例 2:

輸入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
輸出:[9,4]
解釋:[4,9] 也是可透過的

提示:

  • 1 <= nums1.length, nums2.length <= 1000
  • 0 <= nums1[i], nums2[i] <= 1000

思路:

注意題目特意說明:輸出結果中的每個元素一定是唯一的,也就是說輸出的結果去重, 同時可以不考慮輸出結果的順序

這道題用暴力的解法時間複雜度是O(n^2),那來看看使用雜湊法進一步最佳化。

那麼用陣列來做雜湊表也是不錯的選擇,但是要注意,使用陣列來做雜湊的題目,是因為題目都限制了數值的大小。

而這道題目沒有限制數值的大小,就無法使用陣列來做雜湊表了。

而且如果雜湊值比較少、特別分散、跨度非常大,使用陣列就造成空間的極大浪費。

此時就要使用另一種結構體了set ,關於set,C++ 給提供瞭如下三種可用的資料結構:

  • std::set
  • std::multiset
  • std::unordered_set

std::setstd::multiset底層實現都是紅黑樹,std::unordered_set的底層實現是雜湊表, 使用unordered_set 讀寫效率是最高的,並不需要對資料進行排序,而且還不要讓資料重複,所以選擇unordered_set

set雜湊法

AC程式碼:

class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        unordered_set<int> result_set; // 存放結果,之所以用set是為了給結果集去重
        unordered_set<int> nums_set(nums1.begin(), nums1.end());
        for (int num : nums2) {
            // 發現nums2的元素 在nums_set裡又出現過
            if (nums_set.find(num) != nums_set.end()) {
                result_set.insert(num);
            }
        }
        return vector<int>(result_set.begin(), result_set.end());
    }
};
  • 時間複雜度: O(n + m) ,m 是最後要把 set轉成vector
  • 空間複雜度: O(n)

那有同學可能問了,遇到雜湊問題我直接都用set不就得了,用什麼陣列啊。

直接使用set 不僅佔用空間比陣列大,而且速度要比陣列慢,set把數值對映到key上都要做hash計算的。

不要小瞧 這個耗時,在資料量大的情況,差距是很明顯的。

本題後面力扣改了題目描述 和 後臺測試資料,增添了數值範圍:

  • 1 <= nums1.length, nums2.length <= 1000
  • 0 <= nums1[i], nums2[i] <= 1000

所以就可以使用陣列來做雜湊表, 因為陣列都是 1000以內的。

對應C++程式碼如下:

class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        unordered_set<int> result_set; // 存放結果,之所以用set是為了給結果集去重
        int hash[1005] = {0}; // 預設數值為0
        for (int num : nums1) { // nums1中出現的字母在hash陣列中做記錄
            hash[num] = 1;
        }
        for (int num : nums2) { // nums2中出現話,result記錄
            if (hash[num] == 1) {
                result_set.insert(num);
            }
        }
        return vector<int>(result_set.begin(), result_set.end());
    }
};
  • 時間複雜度: O(m + n)
  • 空間複雜度: O(n)

202題. 快樂數

題意描述:

編寫一個演算法來判斷一個數 n 是不是快樂數。

「快樂數」 定義為:

  • 對於一個正整數,每一次將該數替換為它每個位置上的數字的平方和。
  • 然後重複這個過程直到這個數變為 1,也可能是 無限迴圈 但始終變不到 1。
  • 如果這個過程 結果為 1,那麼這個數就是快樂數。

如果 n快樂數 就返回 true ;不是,則返回 false

示例 1:

輸入:n = 19
輸出:true
解釋:
1^2 + 9^2 = 82
8^2 + 2^2 = 68
6^2 + 8^2 = 100
1^2 + 0^2 + 0^2 = 1

示例 2:

輸入:n = 2
輸出:false

提示:

  • 1 <= n <= 231 - 1

思路:

這道題目看上去貌似一道數學問題,其實並不是!

題目中說了會 無限迴圈,那麼也就是說求和的過程中,sum會重複出現,這對解題很重要!

當我們遇到了要快速判斷一個元素是否出現集合裡的時候,就要考慮雜湊法了。

所以這道題目使用雜湊法,來判斷這個sum是否重複出現,如果重複了就是return false, 否則一直找到sum為1為止。

判斷sum是否重複出現就可以使用unordered_set

還有一個難點就是求和的過程,如果對取數值各個位上的單數操作不熟悉的話,做這道題也會比較艱難。

AC程式碼:

class Solution {
public:
    // 取數值各個位上的單數之和
    int getSum(int n) {
        int sum = 0;
        while (n) {
            sum += (n % 10) * (n % 10);
            n /= 10;
        }
        return sum;
    }
    bool isHappy(int n) {
        unordered_set<int> set;
        while(1) {
            int sum = getSum(n);
            if (sum == 1) {
                return true;
            }
            // 如果這個sum曾經出現過,說明已經陷入了無限迴圈了,立刻return false
            if (set.find(sum) != set.end()) {
                return false;
            } else {
                set.insert(sum);
            }
            n = sum;
        }
    }
};
  • 時間複雜度: O(logn)
  • 空間複雜度: O(logn)

相關文章