高階數據結構——哈希表的實現

目錄

1.概念引入

2.哈希的概念:

2.1 什么叫映射?

2.2 直接定址法

2.3 哈希沖突(哈希碰撞)

2.4 負載因子

2.5?哈希函數

2.5.1?除法散列法(除留余數法)

2.5.2?乘法散列法(了解)

2.5.3 全域散列法(了解)

2.5.4 處理哈希沖突

2.5.4.1 開放地址法:

2.5.4.1.1 線性探測

2.5.4.1.1.1 基本框架

?2.5.4.1.1.2?插入+線性探測

?2.5.4.1.1.3?查找+線性探測

?2.5.4.1.1.4?刪除+線性探測

?2.5.4.1.1.5?總結:

2.5.4.1.1 二次探測

2.5.4.2 鏈地址法:

?2.5.4.2.1 基本框架:

?2.5.4.2.2?插入+擴容:

??2.5.4.2.3?查找:

???2.5.4.2.4?刪除:

????2.5.4.2.4 析構:

?2.5.4.2.5? 代碼總結:


1.概念引入

咱們一開始看見哈希,之前從來沒見過這個東西,是不是感到很厲害的樣子?相信大家都去過超市吧,當你買了一個商品,你拿到柜臺,人家一輸入這個商品的名稱,機器上面就會顯示這個物品的價格,是不是查找很快速。這其實就是一種哈希表的實現。

2.哈希的概念:

哈希(hash)又稱散列,是?種組織數據的方式。從譯名來看,有散亂排列的意思。本質就是通過哈希 函數把關鍵字Key跟存儲位置建立?個映射關系,查找時通過這個哈希函數計算出Key存儲的位置,進 行快速查找。

2.1 什么叫映射?

咱們打個比方,比如你上高中的時候,你班里有個人叫做張三,但是他有個外號叫做“雞哥”,那么雞哥就是他,當別人喊雞哥的時候,張三知道喊的就是他,而不是李四,這就是映射。即可以快速的找到那個人。

一般哈希的實現方法有很多種,下面咱們來講一種關鍵字的范圍比較集中的。

2.2 直接定址法

當關鍵字的范圍比較集中時,直接定址法就是非常簡單高效的方法,比如?組關鍵字都在[0,99]之間, 那么我們開?個100個數的數組,每個關鍵字的值直接就是存儲位置的下標。再比如?組關鍵字值都在 [a,z]的小寫字母,那么我們開?個26個數的數組,每個關鍵字acsii碼-aascii碼就是存儲位置的下標。 也就是說直接定址法本質就是用關鍵字計算出一個絕對位置或者相對位置。

2.3 哈希沖突(哈希碰撞)

已知咱們使用的直接定址法是每個元素有每個元素的空,互相不占用彼此的空間。但是事實上真是如此嘛?并不是,理想狀態上是哈希沖突為0,但也只是理想狀態,咱們只有不斷地減少哈希沖突,不可能消滅哈希沖突。那么哈希沖突(哈希碰撞)就是,我有兩個不同的值,但是占用了同一個位置。那么此時就需要方法去解決這個哈希沖突。

當關鍵字的范圍比較分散時,直接定址法就很浪費內存甚至內存不夠用。

2.4 負載因子

再講能夠實現的方法之前,還得講一個東西,那就是負載因子,這個是用來判斷擴容的時機的。即負載因子到達一定得值就擴容。那么這個負載因子是如何計算的呢?假設哈希表中已經映射存儲了N個值,哈希表的大小為M,那么 N 負載因子?=N/M ,負載因子有些地方?也翻譯為載荷因子/裝載因子等,他的英文為loadfactor。負載因子越大,哈希沖突的概率越高,空間 利用率越高;負載因子越小,哈希沖突的概率越低,空間利用率越低;(這個M是size(),不是capacity())

你想一想,這個負載因子可以想像為就是空間占有率(空間利用率),這個空間利用率高了,那么這個插入的元素就多了,那么相應的哈希沖突的概率是不是就提高了,因為沒有剩余的空間了,所有的空間都有數據,你碰到一個跟你值相同的概率就會增大。

2.5?哈希函數

咱們之前說了,要設計一個方法去解決哈希沖突,那么設計的方法就是哈希函數。?個好的哈希函數應該讓N個關鍵字被等概率的均勻的散列分布到哈希表的M個空間中,但是實際中卻 很難做到,但是我們要盡量往這個方向去考量設計。

2.5.1?除法散列法(除留余數法)

1.除法散列法也叫做除留余數法,這個方法用來找到映射的位置。假設哈希表的大小為M,那么通過key除以M的余數作為 映射位置的下標,也就是哈希函數為:h(key)=key%M。

插話:咱們知道取模,只能取整數的模,如果key是整數還好,直接模就可以了,那如果說key不是整數呢?比如浮點數,字符串,結構體,那么該怎么辦呢?咱們在這只講字符串的處理方法,叫做BKDR 哈希算法:

由Brian Kernighan 和 Dennis Ritchie 提出

用于將字符串轉換為哈希值。它的核心思想是通過一個質數種子(如31、131等)不斷乘以當前哈希值并加上字符的ASCII碼值,從而生成一個分布均勻的哈希值。

BKDR 算法的核心公式為:

????????hash=(hash×seed)+current_char
  • seed:預定義的質數(如 31、131、1313 等),作為乘法基數。

  • current\_char:字符串當前字符的 ASCII 值。

  • hash:初始值為 0,逐字符迭代計算最終哈希值。

為什么要引入一個種子呢?

假如,有一個字符串是"abc",另外一個字符串是"bca",這倆字符串肯定是不一樣的對嗎,那么如果說咱們只加它們對應的ascii值的話,就會導致它們最后的哈希結果是一樣的,但這不是咱們想要的結果,所以為了避免這種情況的出現,就引入了一個種子,每次加之后,都乘以這個種子,那么就可以避免這種情況的出現了。

計算過程示例(以?seed = 131,字符串?"abc"?為例):

  1. 初始值:hash = 0

  2. 處理字符?'a'(ASCII 97):

    hash=0×131+97=97hash=0×131+97=97
  3. 處理字符?'b'(ASCII 98):

    hash=97×131+98=12,785hash=97×131+98=12,785
  4. 處理字符?'c'(ASCII 99):

    hash=12,785×131+99=1,675,854hash=12,785×131+99=1,675,854

最終哈希值為?1,675,854

那么這個種子為什么一定要選擇131呢?哈哈,這是兩位大佬進行很多次試驗后得出的,要是真想知道,可以去問兩位大佬。

代碼實現:

//對于可以直接轉換為整形的,直接轉換即可
template<class K>
struct HashFunc
{size_t operator()(const K& key) const{return (size_t)key;}
};// 對于string類型(不可以直接轉換為整形的)需要進行特化
template<>
struct HashFunc<string>
{size_t operator()(const string& key) const{size_t hash = 0;for (auto ch : key){hash += ch;hash *= 131;}return hash;}
};

2.那么這個M的選擇,也是有講究的。要盡量避免M為某些值,如2的冪,10的冪等。如果是2^x ,那么key%2^x 本質相當于保留key的后X位(這個是保留的二進制的key的后x位),那么后x位相同的值,計算出的哈希值都是?樣的,就沖突了。如: {63 , 31}看起來沒有關聯的值,如果M是16,也就是 2^4,那么計算出的哈希值都是15,因為63的二進制后8位是00111111,31的?進制后8位是00011111。(這里就只比較了二進制的后8位,但咱們知道二進制有32位,這里有個知識點,就是比較的位數越多,哈西沖突的概率就越小。所以這里只比較了8位,肯定不好)如果是 10^x(10進制)?,就更明顯了,保留的都是 10進值的后x位,如:{112,12312},如果M是100,也就是10^2 ,那么計算出的哈希值都是12。(這里也不是比較的全部的位數)

3.當使用除法散列法時,建議M取不太接近2的整數次冪的?個質數(素數)。(這個其實不用太擔心,因為咱們庫里面會給的,就是素數集合,咱們只要用就可以了)。

2.5.2?乘法散列法(了解)

上面講的是觸發散列法,這個講乘法散列法,下面還會講一個全域散列法,但這倆只是了解,你真正的使用的時候,還是除法散列法使用的最多。可以理解為,除法散列法是講一個大數字,化為需要的哈希值。而乘法散列法是將一個小數字,化為需要的哈希值。?

1.乘法散列法對哈希表大小M沒有要求,他的大思路第?步:用關鍵字K乘上常數A(0<A<1),并抽取出k*A的小數部分。第二步:后再用M乘以k*A的小數部分,再向下取整。

2.h(key) = floor(M ×((A ×key)%1.0)) ,其中floor表示對表達式進行下取整,A∈(0,1),這里?最重要的是A的值應該如何設定,Knuth認為 A =( ? 5 1)/2 = 0.6180339887.... (黃金分割點]) 比較好。

3.乘法散列法對哈希表大小M是沒有要求的,假設M為1024,key為1234,A=0.6180339887,A*key = 762.6539420558,取小數部分為0.6539420558, M×((A×key)%1.0)=0.6539420558*1024= 669.6366651392,那么h(1234)=669。

4.適用于常規數據場景,無需對抗攻擊,且需快速哈希計算的場景(如內部數據結構)。

2.5.3 全域散列法(了解)

1.如果存在?個惡意的對手,他針對我們提供的散列函數,特意構造出?個發?嚴重沖突的數據集, 比如,讓所有關鍵字全部落入同?個位置中。這種情況是可以存在的,只要散列函數是公開且確定的,就可以實現此攻擊。解決方法自然是見招拆招,給散列函數增加隨機性,攻擊者就方法找出確定可以導致最壞情況的數據。這種方法叫做全域散列。

2. h ab (key) = ((a ×key +b)%P)%M ,P需要選?個足夠大的質數,a可以隨機選[1,P-1]之間的 h (8) = 任意整數,b可以隨機選[0,P-1]之間的任意整數,這些函數構成了?個P*(P-1)組全域散列函數組。假設P=17,M=6,a=3,b=4,則 h 34 (8)= ((3 ×8+4)%17)%6 = 5 。

3.需要注意的是每次初始化哈希表時,隨機選取全域散列函數組中的?個散列函數使用,后續增刪查改都固定使用這個散列函數,否則每次哈希都是隨機選?個散列函數,那么插入是?個散列函數,查找又是另?個散列函數,就會導致找不到插入的key了。

4.適用于安全敏感場景(如網絡服務處理用戶輸入),防止惡意攻擊導致哈希性能退化。

2.5.4 處理哈希沖突

咱們一般還是選擇除法散列法來處理哈希沖突。當然哈希表無論選擇什么哈希函數也避免不了 沖突,那么插?數據時,如何解決沖突呢?主要有兩種兩種方法,開放定址法和鏈地址法。

2.5.4.1 開放地址法:

在開放定址法中所有的元素都放到哈希表里,當?個關鍵字key用哈希函數計算出的位置沖突了,則按 照某種規則找到?個沒有存儲數據的位置進行存儲,開放定址法中負載因子一定是小于的。這?的規 則有三種:線性探測、二次探測、雙重探測。

2.5.4.1.1 線性探測

1.從發生沖突的位置開始,依次線性向后探測,直到尋找到下一個沒有存儲數據的位置為止,如果走?到哈希表尾,則回繞到哈希表頭的位置

2.h(key) = hash0 = key % M ?i = {1,2,3,...,M ?1} , hash0位置沖突了,則線性探測公式為:hc(key,i) = hashi = (hash0+i)?%?M , ?i?=?{1,2,3,...,M ?1},(就是從當前位置開始往后找,遇到表尾,直接%M,就可以回到表頭了,所以%M的作用是為了回到表頭。直到找到空位置去存放這個key)因為負載因子小于1,則最多探測M-1次,?定能找到?個存儲key的位置。(由于負載因子是小于1的,所以整個空間總有空位置,所以總有位置給我存儲key的值)。

3.下?演示{19,30,5,36,13,20,21,12}等這一組值映射到M=11的表中。

h(19) = 8 , h(30) = 8 , h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 , h(21) = 10 , h(12) = 1

?在看代碼之前,我還是想說一下,就是對于開放地址法,咱們要創建一整個表,那么表中的元素,需要存儲鍵值對,以及元素此時的狀態(這里不光存儲鍵值對,存儲key也可以,只不過這里咱們用鍵值對來實現)。

2.5.4.1.1.1 基本框架
//根據當前容量 n,返回一個預定義的素數表中大于等于 n 的最小素數,用于哈希表擴容。
inline unsigned long __stl_next_prime(unsigned long n)
{// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;// >=const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}
enum State
{EXIST,EMPTY,DELETE
};
//開放尋址法需要一個連續存儲的數組,每個位置不僅要存儲數據,
//還需要記錄該位置的狀態(是否被占用、已刪除等),因此每個元素是HashData結構體,所以是vector<HashData<K, V>> _tables;
//直接存儲鍵值對和狀態
//那么既然每個元素是HashData結構體,那么自然_table[i],訪問的是元素
//自然這個也是可以調用HashData這個結構體里的成員變量的
//例如_table[i]._kv,_table[i].__state
//那么由于_table是vector類型的,只不過_table中的元素是HashData結構體
//所以說_table是可以調用vector中的任意成員函數的。//每個元素的類型
template<class K, class V>
struct HashData
{pair<K, V> _kv;State _state = EMPTY;
};//在C++中,模板類或函數可以指定默認模板參數。這意味著當用戶不顯式提供某個模板參數時,
//編譯器會使用默認值。在這里,`class Hash = HashFunc<K>` 表示如果用戶沒有為第三個模板參數
// (即 `Hash`)提供具體類型,編譯器將默認使用 `HashFunc<K > ` 作為該參數的類型
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:HashTable(size_t n = __stl_next_prime(0)):_tables(n), _n(0){}
private:vector<HashData<K, V>> _tables;size_t _n;   // 實際存儲的數據個數
};
?2.5.4.1.1.2?插入+線性探測
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first))return false;//找不到才插入,找到了插入干啥// 擴容,負載因子==0.7就擴容if ((double)_n / (double)_tables.size() >= 0.7){HashTable<K, V, Hash> newht(__stl_next_prime(_tables.size() + 1));// 遍歷舊表,將舊表的數據全部重新映射到新表for (size_t i = 0; i < _tables.size(); i++){if (_tables[i]._state == EXIST){newht.Insert(_tables[i]._kv);}}_tables.swap(newht._tables);}Hash hs;size_t hash0 = hs(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;// 線性探測, (hash0+i) % Mwhile (_tables[hashi]._state == EXIST){hashi = hash0 + i;i++;hashi %= _tables.size();}//因為你是插入元素,所以說那個結構體里的成員變量都需要修改_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;
}

大家看插入代碼,邏輯其實很簡單,主要就是求出哈希值,求出哈希值之后,就去找為空的位置進行插入。插入完了之后別忘了更新元素中的成員變量的狀態。(這里的插入,是以hash0作為一開始的值,i是不斷增加的,從而實現了不斷向后尋找空狀態的代碼)并且更新實際存儲的元素數量_n。?

再看擴容代碼,當負載因子為0.7的時候進行擴容,別忘了強轉,因為兩個整數相除得不到浮點數。之后創建一個哈希表,表中的元素位置的數量,看上面的素數表。注意,這里的表的類型是HashTable<K, V, Hash>,那是因為這里咱們只創建了一個表,但是等到了下面(待會講的哈希桶),表的類型是vector<Node*>,里面的每個元素是 Node* 類型(指向哈希節點的指針)。之后判斷原表中的元素存不存在,若存在,就使用遞歸插入到新表中(復用插入那部分的代碼)。插入完了之后,別忘了交換兩個表的指針。因為你定義的新表是臨時變量(在函數中定義的),出了作用域就銷毀了,而咱們不要舊表,要新表,所以將他倆交換一下(交換指針即可)。從而,舊表出了作用域就銷毀了。非常nice。

這里還需要強調的一點是,代碼中的Hash hs,其實是實例化了一個仿函數。目的就是將key轉換為可以模的整形數。?

?2.5.4.1.1.3?查找+線性探測
//這個僅僅只是為了尋找元素,所以返回類型是HashData<K,V>(元素類型),而沒有第三個模板參數
HashData<K, V>* Find(const K& key)
{Hash hs;size_t hash0 = hs(key) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY){if (_tables[hashi]._state == EXIST&& _tables[hashi]._kv.first == key){return &_tables[hashi];}// 線性探測,(hash0+i) % Mhashi = hash0 + i;i++;hashi %= _tables.size();}return nullptr;
}

這里的查找你只能到元素狀態不為空的地方去查找吧。并且你如果查找到了那個值,那個值的狀態還得是存在(這個后面刪除的部分會講為什么),這樣才可以返回這個元素的地址。否則就是往后線性探測,直到找到位置。如果最后還沒有找到,那么就返回nullptr即可。

?2.5.4.1.1.4?刪除+線性探測
bool Erase(const K& key)
{HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}
}

這個刪除沒什么好說的,只是用到了一個偽刪除。將要被刪除的元素的狀態置為刪除狀態即可。那么這樣的話,如果查找元素的時候,如果不加個元素的狀態判斷語句,很可能就將已經刪除的元素也給查找上去,所以這就是為什么查找要加一個判斷狀態必須存在,接下來才是查找的元素與表中的元素相不相等。?

?2.5.4.1.1.5?總結:

完整代碼:

//根據當前容量 n,返回一個預定義的素數表中大于等于 n 的最小素數,用于哈希表擴容。
inline unsigned long __stl_next_prime(unsigned long n)
{// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;// >=const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}//對于可以直接轉換為整形的,直接轉換即可
template<class K>
struct HashFunc
{size_t operator()(const K& key) const{return (size_t)key;}
};// 對于string類型(不可以直接轉換為整形的)需要進行特化
template<>
struct HashFunc<string>
{size_t operator()(const string& key) const{size_t hash = 0;for (auto ch : key){hash += ch;hash *= 131;}return hash;}
};namespace open_adrress
{enum State{EXIST,EMPTY,DELETE};//開放尋址法需要一個連續存儲的數組,每個位置不僅要存儲數據,//還需要記錄該位置的狀態(是否被占用、已刪除等),因此每個元素是HashData結構體,所以是vector<HashData<K, V>> _tables;//直接存儲鍵值對和狀態//那么既然每個元素是HashData結構體,那么自然_table[i],訪問的是元素//自然這個也是可以調用HashData這個結構體里的成員變量的//例如_table[i]._kv,_table[i].__state//那么由于_table是vector類型的,只不過_table中的元素是HashData結構體//所以說_table是可以調用vector中的任意成員函數的。//每個元素的類型template<class K, class V>struct HashData{pair<K, V> _kv;State _state = EMPTY;};//在C++中,模板類或函數可以指定默認模板參數。這意味著當用戶不顯式提供某個模板參數時,//編譯器會使用默認值。在這里,`class Hash = HashFunc<K>` 表示如果用戶沒有為第三個模板參數// (即 `Hash`)提供具體類型,編譯器將默認使用 `HashFunc<K > ` 作為該參數的類型template<class K, class V, class Hash = HashFunc<K>>class HashTable{public:HashTable(size_t n = __stl_next_prime(0)):_tables(n), _n(0){}bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;//找不到才插入,找到了插入干啥// 擴容,負載因子==0.7就擴容if ((double)_n / (double)_tables.size() >= 0.7){HashTable<K, V, Hash> newht(__stl_next_prime(_tables.size() + 1));// 遍歷舊表,將舊表的數據全部重新映射到新表for (size_t i = 0; i < _tables.size(); i++){if (_tables[i]._state == EXIST){newht.Insert(_tables[i]._kv);}}_tables.swap(newht._tables);}Hash hs;size_t hash0 = hs(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;// 線性探測, (hash0+i) % Mwhile (_tables[hashi]._state == EXIST){hashi = hash0 + i;i++;hashi %= _tables.size();}//因為你是插入元素,所以說那個結構體里的成員變量都需要修改_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;}//這個僅僅只是為了尋找元素,所以返回類型是HashData<K,V>(元素類型),而沒有第三個模板參數HashData<K, V>* Find(const K& key){Hash hs;size_t hash0 = hs(key) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY){if (_tables[hashi]._state == EXIST&& _tables[hashi]._kv.first == key){return &_tables[hashi];}// 線性探測,(hash0+i) % Mhashi = hash0 + i;i++;hashi %= _tables.size();}return nullptr;}bool Erase(const K& key){HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}private:vector<HashData<K, V>> _tables;size_t _n;   // 實際存儲的數據個數};

線性探測的比較簡單且容易實現,線性探測的問題假設,hash0位置連續沖突,hash0,hash1, hash2位置已經存儲數據了,后續映射到hash0,hash1,hash2,hash3的值都會爭奪hash3位 置,這種現象叫做群集/堆積。下面的二次探測可以?定程度改善這個問題。

2.5.4.1.1 二次探測

1.從發生沖突的位置開始,依次左右按二次方跳躍式探測,直到尋找到下?個沒有存儲數據的位置為 止,如果往右走到哈希表尾,則回繞到哈希表頭的位置;如果往左走到哈希表頭,則回繞到哈希表尾的位置;

2.h(key) = hash0 = key % M , hash0位置沖突了,則二次探測公式為: ? hc(key,i) = hashi = (hash0±i ) % M , i = {1,2,3,..., M/2}

3.?次探測當hashi = (hash0?i )%M時,當hashi<0時,需要hashi+=M.

4.下面演示{19,30,52,63,11,22}等這?組值映射到M=11的表中

h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0?

其實就是講填的每個元素的間隔增大了,從而減少沖突,但是這個本質上解決不了問題,所以?引入了另外一種方法:

2.5.4.1.1 雙重散列(了解)

1.第?個哈希函數計算出的值發生沖突,使用第?個哈希函數計算出?個跟key相關的偏移量值,不斷往后探測,直到尋找到下一個沒有存儲數據的位置為止。

?2.?h 1 (key) = hash0 = key % M , hash0位置沖突了,則雙重探測公式為: hc(key,i) = hashi = (hash0+ i?h(key)) % M,?i?={1,2,3,..., M}。

3.要求h2(key)<M且h2(key)和M互為質數,有兩種簡單的取值方法:1、當M為2整數冪時,
h2(key)從[0,M-1]任選一個奇數;2、當M為質數時,h2(key)=key%(M-1)+ 1。

4.保證h2(key)與M互質是因為根據固定的偏移量所尋址的所有位置將形成一個群,若最大公約數
p=gcd(M,hi(key))>1,那么所能尋址的位置的個數為M/P<M,使得對于一個關鍵字來
說無法充分利用整個散列表。舉例來說,若初始探查位置為1,偏移量為3,整個散列表大小為12,那么所能尋址的位置為[1,4,7,10},尋址個數為12/gcd(12,3)=4。

5.下面演示{19,30,52,74},等這?組值映射到M=11的表中,設 h(key)?= 2? key%10+1。

2.5.4.2 鏈地址法:

解決沖突的思路:

開放定址法中所有的元素都放到哈希表里,鏈地址法中所有的數據不再直接存儲在哈希表中,哈希表 中存儲?個指針,沒有數據映射這個位置時,這個指針為空,有多個數據映射到這個位置時,我們把 這些沖突的數據鏈接成?個鏈表,掛在哈希表這個位置下面,鏈地址法也叫做拉鏈法或者哈希桶。

下面演示{19,30,5,36,13,20,21,12,24,96}等這?組值映射到M=11的表中。

h(19) = 8 , h(30) = 8 , h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 , h(21) = 10 , h(12) = 1,h(24) = 2,h(96) = 88?

需要注意的是上面的0,1,2,3,4.........這些才是同,而桶下面掛的不是桶,而是鏈表,鏈表存儲元素。

?2.5.4.2.1 基本框架:
//而鏈地址法的每個桶是一個鏈表,因此_tables的每個元素是一個指向鏈表頭節點的指針,所以是vector<Node*> _tables;
// 即vector里存的是每個元素,又因為每個元素是指針,所以存的是Node*
// 在下一個類中,進行了typedef HashNode<K, V> Node;
// 所以說,表中的每個元素也是可以使用HashNode的成員變量的。
//鏈表節點(Node)包含實際的鍵值對和指向下一個節點的指針。
template<class K, class V>
struct HashNode
{pair<K, V> _kv;HashNode<K, V>* _next;HashNode(const pair<K, V>& kv):_kv(kv), _next(nullptr){}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{typedef HashNode<K, V> Node;
public:HashTable(size_t n = __stl_next_prime(0)):_tables(n, nullptr)//創建一個大小為n的數組,每個桶初始化為空指針//這個是整個大的外殼是_table,是要初始化為n個元素,每個桶初始化為空指針//而上面寫的HashNode的初始化是針對于每個元素的初始化,可以看成是外殼里面的小部分, _n(0){}
private:vector<Node*> _tables;//每個元素是一個鏈表的頭指針,表示哈希桶中的一個桶size_t _n;				// 實際存儲有效數據個數
};

這里的表中的元素需要存儲的不同于開放地址法,這個表中元素需要存儲的是鍵值對以及指向下一個元素的指針!?

?2.5.4.2.2?插入+擴容:

咱們先來看插入:

插入一般選擇頭插,而頭插入的話,分為桶中有元素以及桶中沒有元素。這兩種方法都是同一種代碼:

// 頭插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點
_tables[hashi] = newnode;// 更新新表的當前桶頭節點為當前節點
//這種插入方法既適用于桶中有節點的,也適用于桶中沒有節點的,即當前節點是最新節點
//如果新表的桶 `hashi` 已經有節點A,那么插入節點B后,B的 `_next` 指向A,
// 新表的頭節點變為B,形成 `B->A` 的結構。
++_n;

那么擴容,有兩種方法。

方法一:

HashTable<K, V> newht(__stl_next_prime(_tables.size() + 1));// 遍歷舊表,將舊表的數據全部重新映射到新表
// 為每個節點創建新的`Node`對象(因為`newht.Insert`會創建新節點)。
for (size_t i = 0; i < _tables.size(); i++)
{Node* cur = _tables[i];while (cur){newht.Insert(cur->_kv);cur = cur->_next;}
}
_tables.swap(newht._tables);
/*特點遞歸插入:遍歷舊表的每個節點,調用 newht.Insert 將數據插入新表。這會觸發完整的插入邏輯(計算哈希、處理沖突)。內存開銷:每個節點會重新創建新的 Node 對象,舊節點隨后被釋放(舊表析構時)。潛在問題:重復計算哈希:每次插入都要重新計算哈希值。內存分配頻繁:為每個節點分配新內存,效率較低。*/

這種寫法得遞歸復用插入的那部分代碼,并且還得重新創建節點,還得釋放舊表中的節點,很浪費空間,效率還不高。

方法二:

	// 擴容vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);//表示創建一個大小為 __stl_next_prime(_tables.size() + 1) 的數組,// 每個桶初始化為空指針。// 遍歷舊表,將舊表的數據全部重新映射到新表//復用舊節點,僅調整指針,不創建新節點。for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;// cur頭插到新表size_t hashi = hs(cur->_kv.first) % newtables.size();cur->_next = newtables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點newtables[hashi] = cur;// 更新新表的當前桶頭節點為當前節點cur = next;}_tables[i] = nullptr;//遍歷舊表的每個桶后,將舊表的桶指針設為`nullptr`。//這是因為節點已經被轉移到新表中,舊表不再擁有這些節點的所有權,// 避免重復釋放或訪問已轉移的節點}_tables.swap(newtables);
}
/*特點節點復用:直接復用舊表的 Node 節點,僅調整指針指向,不創建新對象。高效內存管理:無需分配新內存,避免頻繁的 new / delete 操作。哈希計算優化:每個節點只需計算一次哈希值(舊表節點直接插入新表對應桶)*/。

第二種方法就很好了,直接拿著節點過去,也不用擔心創建,銷毀節點的問題了。效率自然也是高了不少,那么還有一些其他的注意事項,我都寫在了代碼中,大家自行閱讀。所以更推薦大家使用第二種擴容方式。

總代碼:

bool Insert(const pair<K, V>& kv)
{if (Find(kv.first))return false;Hash hs;// 負載因子到1擴容,if (_n == _tables.size()){//HashTable<K, V> newht(__stl_next_prime(_tables.size() + 1));遍歷舊表,將舊表的數據全部重新映射到新表// 為每個節點創建新的`Node`對象(因為`newht.Insert`會創建新節點)。//for (size_t i = 0; i < _tables.size(); i++)//{//	Node* cur = _tables[i];//	while (cur)//	{//		newht.Insert(cur->_kv);//		cur = cur->_next;//	}//}//_tables.swap(newht._tables);/*特點遞歸插入:遍歷舊表的每個節點,調用 newht.Insert 將數據插入新表。這會觸發完整的插入邏輯(計算哈希、處理沖突)。內存開銷:每個節點會重新創建新的 Node 對象,舊節點隨后被釋放(舊表析構時)。潛在問題:重復計算哈希:每次插入都要重新計算哈希值。內存分配頻繁:為每個節點分配新內存,效率較低。*/// 擴容vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);//表示創建一個大小為 __stl_next_prime(_tables.size() + 1) 的數組,// 每個桶初始化為空指針。// 遍歷舊表,將舊表的數據全部重新映射到新表//復用舊節點,僅調整指針,不創建新節點。for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;// cur頭插到新表size_t hashi = hs(cur->_kv.first) % newtables.size();cur->_next = newtables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點newtables[hashi] = cur;// 更新新表的當前桶頭節點為當前節點cur = next;}_tables[i] = nullptr;//遍歷舊表的每個桶后,將舊表的桶指針設為`nullptr`。//這是因為節點已經被轉移到新表中,舊表不再擁有這些節點的所有權,// 避免重復釋放或訪問已轉移的節點}_tables.swap(newtables);}/*特點節點復用:直接復用舊表的 Node 節點,僅調整指針指向,不創建新對象。高效內存管理:無需分配新內存,避免頻繁的 new / delete 操作。哈希計算優化:每個節點只需計算一次哈希值(舊表節點直接插入新表對應桶)*/。//方法二直接復用舊節點,通過指針調整高效轉移,避免了重復內存分配,//因此效率顯著優于方法一。//方法一由于創建新節點和額外的插入操作,效率較低,尤其在數據量大時更為明顯。//其實二者顯著的差別就是,第一種是得創建新節點,因為它只傳遞了桶節點中的值//第二種是可以直接將那個桶節點直接搬運到新的哈希表中的,這個是不需要創建新節點的size_t hashi = hs(kv.first) % _tables.size();// 頭插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點_tables[hashi] = newnode;// 更新新表的當前桶頭節點為當前節點//這種插入方法既適用于桶中有節點的,也適用于桶中沒有節點的,即當前節點是最新節點//如果新表的桶 `hashi` 已經有節點A,那么插入節點B后,B的 `_next` 指向A,// 新表的頭節點變為B,形成 `B->A` 的結構。++_n;return true;
}
??2.5.4.2.3?查找:

Node* Find(const K& key)
{Hash hs;size_t hashi = hs(key) % _tables.size();Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;
}

查找沒什么好說的,就是找到了就返回,沒找到就繼續尋找。

???2.5.4.2.4?刪除:

bool Erase(const K& key)
{Hash hs;size_t hashi = hs(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key){if (prev == nullptr){_tables[hashi] = cur->_next;}else{prev->_next = cur->_next;}--_n;delete cur;return true;}prev = cur;cur = cur->_next;}return false;
}

刪除分為刪除桶中的第一個元素以及刪除中間的元素。中間的if(prev==nullptr),就是為了刪除桶中的第一個元素而準備的。其實跟鏈表的刪除差不了多少。

????2.5.4.2.4 析構:
~HashTable()
{for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_tables[i] = nullptr;}
}

析構函數的話,類似于鏈表的析構,遍歷析構,最后別忘了將桶置為空。因為桶中的元素都沒有了,那么這個桶就沒有存在的必要了。?

?2.5.4.2.5? 代碼總結:
namespace hash_bucket
{//而鏈地址法的每個桶是一個鏈表,因此_tables的每個元素是一個指向鏈表頭節點的指針,所以是vector<Node*> _tables;// 即vector里存的是每個元素,又因為每個元素是指針,所以存的是Node*// 在下一個類中,進行了typedef HashNode<K, V> Node;// 所以說,表中的每個元素也是可以使用HashNode的成員變量的。//鏈表節點(Node)包含實際的鍵值對和指向下一個節點的指針。template<class K, class V>struct HashNode{pair<K, V> _kv;HashNode<K, V>* _next;HashNode(const pair<K, V>& kv):_kv(kv), _next(nullptr){}};template<class K, class V, class Hash = HashFunc<K>>class HashTable{typedef HashNode<K, V> Node;public:HashTable(size_t n = __stl_next_prime(0)):_tables(n, nullptr)//創建一個大小為n的數組,每個桶初始化為空指針//這個是整個大的外殼是_table,是要初始化為n個元素,每個桶初始化為空指針//而上面寫的HashNode的初始化是針對于每個元素的初始化,可以看成是外殼里面的小部分, _n(0){}~HashTable(){for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_tables[i] = nullptr;}}bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;Hash hs;// 負載因子到1擴容,if (_n == _tables.size()){//HashTable<K, V> newht(__stl_next_prime(_tables.size() + 1));遍歷舊表,將舊表的數據全部重新映射到新表// 為每個節點創建新的`Node`對象(因為`newht.Insert`會創建新節點)。//for (size_t i = 0; i < _tables.size(); i++)//{//	Node* cur = _tables[i];//	while (cur)//	{//		newht.Insert(cur->_kv);//		cur = cur->_next;//	}//}//_tables.swap(newht._tables);/*特點遞歸插入:遍歷舊表的每個節點,調用 newht.Insert 將數據插入新表。這會觸發完整的插入邏輯(計算哈希、處理沖突)。內存開銷:每個節點會重新創建新的 Node 對象,舊節點隨后被釋放(舊表析構時)。潛在問題:重復計算哈希:每次插入都要重新計算哈希值。內存分配頻繁:為每個節點分配新內存,效率較低。*/// 擴容vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);//表示創建一個大小為 __stl_next_prime(_tables.size() + 1) 的數組,// 每個桶初始化為空指針。// 遍歷舊表,將舊表的數據全部重新映射到新表//復用舊節點,僅調整指針,不創建新節點。for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;// cur頭插到新表size_t hashi = hs(cur->_kv.first) % newtables.size();cur->_next = newtables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點newtables[hashi] = cur;// 更新新表的當前桶頭節點為當前節點cur = next;}_tables[i] = nullptr;//遍歷舊表的每個桶后,將舊表的桶指針設為`nullptr`。//這是因為節點已經被轉移到新表中,舊表不再擁有這些節點的所有權,// 避免重復釋放或訪問已轉移的節點}_tables.swap(newtables);}/*特點節點復用:直接復用舊表的 Node 節點,僅調整指針指向,不創建新對象。高效內存管理:無需分配新內存,避免頻繁的 new / delete 操作。哈希計算優化:每個節點只需計算一次哈希值(舊表節點直接插入新表對應桶)*/。//方法二直接復用舊節點,通過指針調整高效轉移,避免了重復內存分配,//因此效率顯著優于方法一。//方法一由于創建新節點和額外的插入操作,效率較低,尤其在數據量大時更為明顯。//其實二者顯著的差別就是,第一種是得創建新節點,因為它只傳遞了桶節點中的值//第二種是可以直接將那個桶節點直接搬運到新的哈希表中的,這個是不需要創建新節點的size_t hashi = hs(kv.first) % _tables.size();// 頭插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];// 將當前節點的 next 指向新表的當前桶頭節點_tables[hashi] = newnode;// 更新新表的當前桶頭節點為當前節點//這種插入方法既適用于桶中有節點的,也適用于桶中沒有節點的,即當前節點是最新節點//如果新表的桶 `hashi` 已經有節點A,那么插入節點B后,B的 `_next` 指向A,// 新表的頭節點變為B,形成 `B->A` 的結構。++_n;return true;}Node* Find(const K& key){Hash hs;size_t hashi = hs(key) % _tables.size();Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;}bool Erase(const K& key){Hash hs;size_t hashi = hs(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key){if (prev == nullptr){_tables[hashi] = cur->_next;}else{prev->_next = cur->_next;}--_n;delete cur;return true;}prev = cur;cur = cur->_next;}return false;}private:vector<Node*> _tables;//每個元素是一個鏈表的頭指針,表示哈希桶中的一個桶size_t _n;				// 實際存儲有效數據個數};

?OK,今天的文章到底結束。

...............

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/bicheng/82493.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/82493.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/82493.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

7.安卓逆向2-frida hook技術-介紹

免責聲明&#xff1a;內容僅供學習參考&#xff0c;請合法利用知識&#xff0c;禁止進行違法犯罪活動&#xff01; 內容參考于&#xff1a;圖靈Python學院 工具下載&#xff1a; 鏈接&#xff1a;https://pan.baidu.com/s/1bb8NhJc9eTuLzQr39lF55Q?pwdzy89 提取碼&#xff1…

DB-GPT擴展自定義Agent配置說明

簡介 文章主要介紹了如何擴展一個自定義Agent&#xff0c;這里是用官方提供的總結摘要的Agent做了個示例&#xff0c;先給大家看下顯示效果 代碼目錄 博主將代碼放在core目錄了&#xff0c;后續經過對源碼的解讀感覺放在dbgpt_serve.agent.agents.expand目錄下可能更合適&…

Android 架構演進之路:從 MVC 到 MVI,擁抱單向數據流的革命

在移動應用開發的世界里&#xff0c;架構模式的演進從未停歇。從早期的 MVC 到后來的 MVP、MVVM&#xff0c;每一次變革都在嘗試解決前一代架構的痛點。而今天&#xff0c;我們將探討一種全新的架構模式 ——MVI&#xff08;Model-View-Intent&#xff09;&#xff0c;它借鑒了…

【YOLOv8-pose部署至RK3588】模型訓練→轉換RKNN→開發板部署

已在GitHub開源與本博客同步的YOLOv8_RK3588_object_pose 項目&#xff0c;地址&#xff1a;https://github.com/A7bert777/YOLOv8_RK3588_object_pose 詳細使用教程&#xff0c;可參考README.md或參考本博客第六章 模型部署 文章目錄 一、項目回顧二、文件梳理三、YOLOv8-pose…

集成30+辦公功能的實用工具

軟件介紹 本文介紹的軟件是千峰辦公助手。 軟件功能概述與開發目的 千峰辦公助手集成了自動任務、系統工具、文件工具、PDF工具、OCR圖文識別、文字處理、電子表格七個模塊&#xff0c;擁有30余項實用功能。作者開發該軟件的目的是解決常見辦公痛點&#xff0c;把機械操作交…

IDEA啟動報錯:Cannot invoke “org.flowable.common.engine.impl.persistence.ent

1.問題 項目啟動報錯信息 java.lang.NullPointerException: Cannot invoke "org.flowable.common.engine.impl.persistence.ent 2.問題解析 出現這個問題是在項目中集成了Flowable或Activiti工作流&#xff0c;開啟自動創建工作流創建的表&#xff0c;因為不同環境的數據…

網絡安全--PHP第三天

今天學習文件上傳的相關知識 上傳的前端頁面如下 upload.html <!DOCTYPE html> <html lang"zh-CN"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"&g…

【愚公系列】《生產線數字化設計與仿真》004-顏色分類站仿真(基礎概念)

??【技術大咖愚公搬代碼:全棧專家的成長之路,你關注的寶藏博主在這里!】?? ??開發者圈持續輸出高質量干貨的"愚公精神"踐行者——全網百萬開發者都在追更的頂級技術博主! ?? 江湖人稱"愚公搬代碼",用七年如一日的精神深耕技術領域,以"…

基于 uni-app + <movable-view>拖拽實現的標簽排序-適用于微信小程序、H5等多端

在實際業務中&#xff0c;我們經常遇到「標簽排序」或「菜單調整」的場景。微信小程序原生的 movable-view 為我們提供了一個簡單、高效的拖拽能力&#xff0c;結合 Vue3 uni-app 的組合&#xff0c;我們可以實現一個體驗良好的標簽管理界面。 核心組件&#xff1a;<movab…

一些較好的學習方法

1、網上有一些非常經典的電路&#xff0c;而且有很多視頻博主做了詳細的講解。 2、有一部分拆解的UP主&#xff0c;拆解后會還原該器件的原理圖&#xff0c;并一步步做講解。 3、有兩本書&#xff0c;數電、模電&#xff0c;這兩本書中的內容很多都值得學習。 5、某寶上賣的…

《1.1_4計算機網絡的分類|精講篇|附X-mind思維導圖》

網絡相關知識 按使用范圍分類 公用網 由電信部門或其他提供通信服務的經營部門組建、管理和控制&#xff0c;向全社會提供服務的網絡。 專用網 由某個單位或部門組建、僅供本單位或部門內部使用的網絡。 按傳輸介質分類 有線網絡 如&#xff1a;雙絞線、同軸電纜、光纖…

Git 和 GitHub 學習指南本地 Git 配置、基礎命令、GitHub 上傳流程、企業開發中 Git 的使用流程、以及如何將代碼部署到生產服務器

Windows 上 Git 安裝與配置 下載安裝&#xff1a;訪問 Git 官方網站下載適用于 Windows 的安裝程序。運行安裝包時會出現許可協議、安裝目錄、組件選擇等界面&#xff08;如下圖&#xff09;。在“Select Components”頁面建議勾選 Git Bash Here 等選項&#xff0c;以便在資源…

航空航天領域對滾珠絲桿的精度要求有多高?

航空航天領域對滾珠絲桿的精度要求非常高&#xff0c;尤其是飛行器、火箭和衛星等載具的導航和定位系統都需要高精度的滾珠絲桿&#xff0c;以確保高精度的位置控制和穩定的導航性能。那么&#xff0c;航空航天領域對滾珠絲桿的精度要求有多高&#xff1f; 1、定位精度&#xf…

技術篇-2.5.Matlab應用場景及開發工具安裝

Matlab 在數學建模和數值分析等領域具有無可替代的地位。它幾乎涵蓋所有常見數學算法的內置函數庫&#xff0c;使得從數據預處理、方程求解到優化算法的實現&#xff0c;無需編寫大量底層代碼即可快速完成&#xff1b;同時&#xff0c;Matlab 強大的可視化能力&#xff0c;可以…

Vtk概覽1

vtk環境搭建 見&#xff08;VTK開發環境配置(Visual Studio C)-詳細圖文教程-CSDN博客&#xff09; 在學習vtk圖形圖像進階的第二章時&#xff0c;通過vs2022建的控制臺程序&#xff0c;編寫運行示例2.1 發現 不顯示圖像。 #include <iostream> #include<vtkRenderW…

【數據集】基于ubESTARFM法的100m 地溫LST數據集(澳大利亞)

目錄 數據概述一、輸入數據與處理二、融合算法1. ESTARFM(Enhanced STARFM)2. ubESTARFM(Unbiased ESTARFM)代碼實現數據下載參考根據論文《Generating daily 100 m resolution land surface temperature estimates continentally using an unbiased spatiotemporal fusion…

Lucide:一款精美的開源矢量圖標庫,前端圖標新選擇

名人說:博觀而約取,厚積而薄發。——蘇軾《稼說送張琥》 創作者:Code_流蘇(CSDN)(一個喜歡古詩詞和編程的Coder??) 目錄 一、前言:為何選擇 Lucide?二、Lucide 是什么?1. 基本介紹2. Lucide vs Feather三、如何在項目中使用 Lucide?1. 安裝圖標包(以 React 為例)2…

BeanUtil和BeanUtils有什么區別

BeanUtil 和 BeanUtils 是兩個常見的工具類&#xff0c;通常用于 Java 開發中處理對象之間的屬性復制或轉換。它們的功能可能看起來相似&#xff0c;但實際上它們來自不同的庫&#xff0c;并且在實現細節和使用方式上存在一些差異。 以下是它們的主要區別&#xff1a; 1. 來源…

【CF】Day66——Edu 168.D + CF 853 (Div. 2).C (樹 + 二分 + 貪心 | 組合數學)

D. Maximize the Root 題目&#xff1a; 思路&#xff1a; 樹上二分&#xff0c;中下題 我們可以發現如果 x 可以&#xff0c;那么 x - 1 肯定也可以&#xff0c;所以可以直接二分答案 具體的&#xff0c;我們每次二分能增加的值 mid &#xff0c;如果 a[i] < mid&#xf…

生成對抗網絡(GANs)中的損失函數公式 判別器最優解D^*(x)的推導

https://www.bilibili.com/video/BV1YyHSekEE2 這張圖片展示的是生成對抗網絡&#xff08;GANs&#xff09;中的損失函數公式&#xff0c;特別是針對判別器&#xff08;Discriminator&#xff09;和生成器&#xff08;Generator&#xff09;的優化目標。讓我們用Markdown格式逐…