PHP7內核學習筆記 - 數組

  • 理想狀況下,無需任何比較就能找到待查關鍵字,查找的期望時間複雜度爲O(1)

PHP7散列表基本結構:
// zend_array和HashTable的含義是相同的,沒有任何區別
typedef struct _zend_array zend_array;
typedef struct _zend_array HashTable;

struct _zend_array {
    zend_refcounted_h gc;
    // 此處的union可以先忽略
    union {
        ...
    } u;
    // 用於散列函數映射存儲在arData數組中的下標
    uint32_int    nTableMask;
    // 存儲元素數組,每個元素的結構統一爲Bucket,arData指向第一個Bucket
    Bucket        *arData;
    // 已用Bucket數
    uint32_t      nNumUsed;
    // 數組實際存儲的元素數
    uint32_t      nNumOfElements;
    // 數組的總容量
    uint32_t      nTableSize;
    
    uint32_t      nInternalPointer;
    
    // 下一個可用的數值索引,如arr[] = 1;arr["a"] = 2;arr[] = 3;則nNextFreeElement = 2;
    zend_long     nNextFreeElement;
    dtor_func_t   pDestructor;
}

散列表的結構中有很多成員,以下是比較重要的幾個成員:

  • arData:散列表中保存 存儲元素(即Bucket)的數組,其內存是連續的,arData指向數組的起始位置
  • nTableSize:數組的總容量,即可以容納的元素數量。arData的內存大小是根據這個值確定的,他的大小是2的冪次方最小爲8。也就是說散列表的大小依次按照8163264…進行遞增。
  • nTableMask:這個值在散列函數根據keyhash code映射元素的位置時用到。他的值實際就是nTableSize負數,即nTableMask = -nTableSize,用位運算來表示的話則爲:nTableMask = ~nTableSize + 1此處需要撰寫一篇筆記專門說明,參考本文件夾內《4.2、原碼、反碼和補碼》)。
  • nNumUsednNumOfElementsnNumUsed是指當前使用掉Bucket數量,而nNumOfElements則是數組中的有效元素的數量,注意兩者是有區別的。在數組中,使用掉的Bucket並非一定是有效元素,當我們從數組中刪除元素的時候並不會把元素馬上從數組中移除,而是將元素的類型標記IS_UNDEF。只有在數組的容量超限,需要進行擴容時纔會刪除。這一機制使得nNumUsed >= nNumOfElements。如果數組沒有擴容,那麼nNumUsed將一直是遞增的,無論是否刪除元素。
  • nNextFreeElement:這個值是給自動確認數值索引使用的,從0開始。比如$a[] = 1,這個時候就將這值增加爲1,下次再有$a[]的操作時就使用剛剛得到的1作爲新元素的索引值。
  • pDestructor:當刪除或覆蓋數組中的某個元素時,如果提供了這個函數句柄,則在刪除或覆蓋後調用此函數,對舊元素進行清理。
  • u:這個結構主要是一些輔助作用,比如flags用來設置散列表的一些屬性:是否持久化、是否已經初始化等。

Bucket

Bucket的結構較爲簡單,此結構主要用於保存元素的keyvalue。除了這兩個還有一個整型的h,它的含義是hash code:如果元素是數值索引,那麼它的元素就是數值索引的值;如果是字符串索引,那麼就根據字符串key通過Time33算法計算得到散列值h的值用來映射元素的存儲位置。另外,存儲的value直接嵌入Bucket結構中:

typedef struct _Bucket {
    zval         val;// 存儲的具體value,這裏嵌入一個zval,而不是一個指針
    zend_ulong   h;// key根據Time33計算得到的哈希值,或者是數值索引
    zend_string  *key;// 存儲元素的key
} Bucket;

arData

arData在數組初始化的時候並不分配內存,而是在首次插入數據的時候觸發內存分配。由zend_hash_real_init_ex()進行內存的分配,分配的大小包括中間表元素數組,共計:nTableSize * (sizeof(Bucket) + sizeof(uint32_t))。分配完成後會把HashTable->u.flags打上HASH_FLAG_INITIALIZED掩碼,這樣下次再插入的時候發現已經分配了內存就不會再觸發內存分配了。分配完成後,HashTable->arData會指向第一個Bucket的位置。

完成內存的分配之後就可以進行插入操作了。插入時首先將元素按照順序插入arData,然後將其在arData中的位置存儲到中間表,它在中間表中的位置計算方式是:根據keyhash code(即key->h)與nTableMask計算後得到的中間表的位置(nIndex = h | ht->nTableMask)。


哈希衝突

散列表中不同元素的key有時候經過hash計算會得到相同的結果(即想存入相同的映射表的位置),而映射表中的一個位置只能存儲一個元素,這時候就發生了哈希衝突

常用的解決方法是把衝突的Bucket串成鏈表,這樣一來映射表映射的就不是一個Bucket元素,而是一個Bucket鏈表。在查找的時候需要根據key遍歷鏈表來查找相應的元素。PHP就是採用的這種方式解決哈希衝突。

HashTable中的Bucket會記錄與他衝突的元素的位置(當前是隻能標記相鄰的)。在設置映射時,如果發現對應的位置已經有元素了,就會把已經存在的值(後加:的位置)放到新申請的Bucket中再把映射表指向新申請Bucket。《PHP7內核剖析》書中有句話叫做“即每次都會把衝突的元素插到開頭”,這裏有兩點不明是衝突的元素是指新元素還是舊元素(後續查看,是新元素),是鏈表的開頭是指新申請的Bucket還是之前的舊Bucket(後續查看,是新申請的Bucket)。這裏的描述與例子是衝突的,例子中並沒有舊的值放入新申請Bucket,例子截圖如下:
http://read.likefirework.com/public/youdao/php/kernal/hashcollision.png

關於上述衝突的說明:“就會把已經存在的值(後加:的位置)放到新申請的Bucket中再把映射表指向新申請Bucket。”這句話坑死爹爹了。。之前一直感覺很奇怪,纔有了下面的疑惑。這邊解讀一下:這裏是指把已經存在的值的位置放到新申請的Bucket中的next中,並非是把整個值拷過去。。

個人覺得明明只要把新Bucket連接到舊的Bucket後面就可以了,爲何需要修改映射表的指向呢?很繁瑣啊,把值拷來拷去的。。

寫在最後:將衝突流程走了一遍之後發現,現在的做法確實是最優的:衝突時只需要修改映射表的值,再將新元素的next指向之前的元素。如果按照我的想法,保持映射表位置不變,那麼就需要寫入新元素,再修改末端元素的指向。假如鏈表中衝突元素已經較多,定位到末端元素也是個費勁的事,還不如現在這種方案,確保每次隻影響兩個地方即可。


查找

查找過程比較簡單,流程如下:首先根據key計算hash code,即zend_string->hnTableMask計算得到的nIndex。然後根據nIndex從映射表查找到鏈表頭元素位置idx。從idx取出第一個Bucket,開始與key進行對比,判斷是否是需要查找的元素,如果是就終止遍歷,否就繼續根據zval.u2.next往下查找。。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章