HashMap, HashTable,HashSet,TreeMap 的時間複雜度 注意陣列連結串列 增刪改查的時間複雜度都不相同
hashmap的擴容因子是0.75 原因 參考:HashMap預設載入因子為什麼選擇0.75?(阿里)
阿里的人問 陣列的時間複雜度是多少,連結串列的是多少,hashmap的時間複雜度是多少。。。。。
後來才知道,時間複雜度是要區分 增刪改查的。。。。主要看查詢的時間複雜度;
1、陣列 查詢的時間複雜度 O(n)
2、連結串列 查詢的時間複雜度 O(n)
3、hashmap查詢的時間複雜度 O(1)
陣列 查詢的時間複雜度 O(n)
建議看一下下面的部落格:
hashSet,hashtable,hashMap 都是基於雜湊函式, 時間複雜度 O(1) 但是如果太差的話是O(n)
TreeSet==>O(log(n))==> 基於樹的搜尋,只需要搜尋一半即可
O⑴的原因是離散後,下標對應關鍵字
hash就是雜湊,甚至再雜湊。但是我一直對hash表的時間複雜度有個疑問。一個需要儲存的字串,通過hash函式雜湊到一個相對較短的索引,使得存取速度加快。但為什麼存取的時間複雜度能達到常量級O(1)呢?? 查詢時搜尋索引不需要費時間嗎?為什麼不是O(n)呢? n是hash表的長度,
如果對Hashtable的構造有很深的理解的話,就知道了,Hashtable 其實是綜合了陣列和連結串列的優點,當Hashtable對數值進行搜尋的時候,首先用該數值與Hashtable的長度做了取模的操作,得到的數字直接作為hashtable中entry陣列的index,因為hashtable是由entry陣列組成的,因此,可以直接定位到指定的位置,不需要搜尋,當然,這裡還有個問題,每個entry其實是連結串列,如果entry有很多值的話,還是需要挨個遍歷的,因此可以這樣講Hashtable的時間複雜度最好是O(1)但是最差是 O(n) 最差的時候也就是hashtable中所有的值的hash值都一樣,都分配在一個entry裡面,當然這個概率跟中1億彩票的概率相差不大。
如果還不理解可以參考我寫的專門的部落格:
關於HashMap的:HashMap的實現原理--連結串列雜湊
關於Hashtable的:Hashtable資料儲存結構-遍歷規則,Hash型別的複雜度為啥都是O(1)-原始碼分析
在看起來就是對Entry連結串列的迴圈的時間複雜度影響最大,連結串列查詢的時間複雜度為O(n),與連結串列長度有關。我們要保證那個連結串列長度為1,才可以說時間複雜度能滿足O(1)。但這麼說來只有那個hash演算法儘量減少衝突,才能使連結串列長度儘可能短,理想狀態為1。因此可以得出結論:HashMap的查詢時間複雜度只有在最理想的情況下才會為O(1),最差是O(n),而要保證這個理想狀態不是我們開發者控制的。
======================================================================開始=======================================================================================
常用資料結構的時間複雜度
常用資料結構的時間複雜度Data Structure | 新增 | 查詢/Find | 刪除/Delete | GetByIndex |
陣列 Array (T[]) | O(n) | O(n) | O(n) | O(1) |
連結串列 Linked list (LinkedList<T>) | O(1) | O(n) | O(n) | O(n) |
Resizable array list (List<T>) | O(1) | O(n) | O(n) | O(1) |
Stack (Stack<T>) | O(1) | - | O(1) | - |
Queue (Queue<T>) | O(1) | - | O(1) | - |
Hash table (Dictionary<K,T>) | O(1) | O(1) | O(1) | - |
Tree-based dictionary(SortedDictionary<K,T>) | O(log n) | O(log n) | O(log n) | - |
Hash table based set (HashSet<T>) | O(1) | O(1) | O(1) | - |
Tree based set (SortedSet<T>) | O(log n) | O(log n) | O(log n) | - |
如何選擇資料結構
Array (T[])
- 當元素的數量是固定的,並且需要使用下標時。
Linked list (LinkedList<T>)
- 當元素需要能夠在列表的兩端新增時。否則使用 List<T>。
Resizable array list (List<T>)
- 當元素的數量不是固定的,並且需要使用下標時。
Stack (Stack<T>)
- 當需要實現 LIFO(Last In First Out)時。
Queue (Queue<T>)
- 當需要實現 FIFO(First In First Out)時。
Hash table (Dictionary<K,T>)
- 當需要使用鍵值對(Key-Value)來快速新增和查詢,並且元素沒有特定的順序時。
Tree-based dictionary (SortedDictionary<K,T>)
- 當需要使用價值對(Key-Value)來快速新增和查詢,並且元素根據 Key 來排序時。
Hash table based set (HashSet<T>)
- 當需要儲存一組唯一的值,並且元素沒有特定順序時。
Tree based set (SortedSet<T>)
- 當需要儲存一組唯一的值,並且元素需要排序時。
Array
在計算機程式設計中,陣列(Array)是最簡單的而且應用最廣泛的資料結構之一。在任何程式語言中,陣列都有一些共性:
- 陣列中的內容是使用連續的記憶體(Contiguous Memory)來儲存的。
- 陣列中的所有元素必須是相同的型別,或者型別的衍生型別。因此陣列又被認為是同質資料結構(Homegeneous Data Structures)。
- 陣列的元素可以直接被訪問。比如你需要訪問陣列的第 i 個元素,則可以直接使用 arrayName[i] 來訪問。
對於陣列的常規操作包括:
- 分配空間(Allocation)
- 資料訪問(Accessing)
在 C# 中,可以通過如下的方式宣告陣列變數。
int allocationSize = 10; bool[] booleanArray = new bool[allocationSize]; FileInfo[] fileInfoArray = new FileInfo[allocationSize];
上面的程式碼將在 CLR 託管堆中分配一塊連續的記憶體空間,用以容納數量為 allocationSize ,型別為 arrayType 的陣列元素。如果 arrayType 為值型別,則將會有 allocationSize 個未封箱(unboxed)的 arrayType 值被建立。如果 arrayType 為引用型別,則將會有 allocationSize 個 arrayType 型別的引用被建立。
如果我們為 FileInfo[] 陣列中的一些位置賦上值,則引用關係為下圖所示。
.NET 中的陣列都支援對元素的直接讀寫操作。語法如下:
// 讀陣列元素 bool b = booleanArray[7]; // 寫陣列元素 booleanArray[0] = false;
訪問一個數組元素的時間複雜度為 O(1),因此對陣列的訪問時間是恆定的。也就是說,與陣列中包含的元素數量沒有直接關係,訪問一個元素的時間是相同的。
ArrayList
由於陣列是固定長度的,並且陣列中只能儲存同一種類型或型別的衍生型別。這在使用中會受到一些限制。.NET 提供了一種資料結構 ArrayList 來解決這些問題。
ArrayList countDown = new ArrayList(); countDown.Add(3); countDown.Add(2); countDown.Add(1); countDown.Add("blast off!"); countDown.Add(new ArrayList());
ArrayList 是長度可變的陣列,並且它可以儲存不同型別的元素。
但這些靈活性是以犧牲效能為代價的。在上面 Array 的描述中,我們知道 Array 在儲存值型別時是採用未裝箱(unboxed)的方式。由於 ArrayList 的 Add 方法接受 object 型別的引數,導致如果新增值型別的值會發生裝箱(boxing)操作。這在頻繁讀寫 ArrayList 時會產生額外的開銷,導致效能下降。
List<T>
當 .NET 中引入泛型功能後,上面 ArrayList 所帶來的效能代價可以使用泛型來消除。.NET 提供了新的陣列型別 List<T>。
泛型允許開發人員在建立資料結構時推遲資料型別的選擇,直到使用時才確定選擇哪種型別。泛型(Generics)的主要優點包括:
- 型別安全(Type Safety):使用泛型定義的型別,在使用時僅能使用指定的型別或型別的衍生型別。
- 效能(Performance):泛型移除了執行時型別檢測,消除了裝箱和拆箱的開銷。
- 可重用(Reusability):泛型打破了資料結構與儲存資料型別之間的緊耦合。這提高了資料結構的可重用性。
List<T> 等同於同質的一維陣列(Homogeneous self-redimensioning array)。它像 Array 一樣可以快速的讀取元素,還可以保持長度可變的靈活性。
// 建立 int 型別列表 List<int> myFavoriteIntegers = new List<int>(); // 建立 string 型別列表 List<string> friendsNames = new List<string>();
List<T> 內部同樣使用 Array 來實現,但它隱藏了這些實現的複雜性。當建立 List<T> 時無需指定初始長度,當新增元素到 List<T> 中時,也無需關心陣列大小的調整(resize)問題。
List<int> powersOf2 = new List<int>(); powersOf2.Add(1); powersOf2.Add(2); powersOf2[1] = 10; int sum = powersOf2[1] + powersOf2[2];
List<T> 的漸進執行時(Asymptotic Running Time)複雜度與 Array 是相同的。
LinkedList<T>
在連結串列(Linked List)中,每一個元素都指向下一個元素,以此來形成了一個鏈(chain)。
向連結串列中插入一個新的節點的漸進時間取決於連結串列是否是有序的。如果連結串列不需要保持順序,則插入操作就是常量時間O(1),可以在連結串列的頭部或尾部新增新的節點。而如果需要保持連結串列的順序結構,則需要查詢到新節點被插入的位置,這使得需要從連結串列的頭部 head 開始逐個遍歷,結果就是操作變成了O(n)。下圖展示了插入節點的示例。
連結串列與陣列的不同之處在於,陣列的中的內容在記憶體中時連續排列的,可以通過下標來訪問,而連結串列中內容的順序則是由各物件的指標所決定,這就決定了其內容的排列不一定是連續的,所以不能通過下標來訪問。如果需要更快速的查詢操作,使用陣列可能是更好的選擇。
使用連結串列的最主要的優勢就是,向連結串列中插入或刪除節點無需調整結構的容量。而相反,對於陣列來說容量始終是固定的,如果需要存放更多的資料,則需要調整陣列的容量,這就會發生新建陣列、資料拷貝等一系列複雜且影響效率的操作。即使是 List<T> 類,雖然其隱藏了容量調整的複雜性,但仍然難逃效能損耗的懲罰。
連結串列的另一個優點就是特別適合以排序的順序動態的新增新元素。如果要在陣列的中間的某個位置新增新元素,不僅要移動所有其餘的元素,甚至還有可能需要重新調整容量。
所以總結來說,陣列適合資料的數量是有上限的情況,而連結串列適合元素數量不固定的情況。
在 .NET 中已經內建了 LinkedList<T> 類,該類實現了雙向連結串列(doubly-linked list)功能,也就是節點同時持有其左右節點的引用。而對於刪除操作,如果使用 Remove(T),則運算複雜度為 O(n),其中 n 為連結串列的長度。而如果使用 Remove(LinkedListNode<T>), 則運算複雜度為 O(1)。
Queue<T>
當我們需要使用先進先出順序(FIFO)的資料結構時,.NET 為我們提供了 Queue<T>。Queue<T> 類提供了 Enqueue 和 Dequeue 方法來實現對 Queue<T> 的存取。
Queue<T> 內部建立了一個存放 T 物件的環形陣列,並通過 head 和 tail 變數來指向該陣列的頭和尾。
預設情況下,Queue<T> 的初始化容量是 32,也可以通過建構函式指定容量。
Enqueue 方法會判斷 Queue<T> 中是否有足夠容量存放新元素。如果有,則直接新增元素,並使索引 tail 遞增。在這裡的 tail 使用求模操作以保證 tail 不會超過陣列長度。如果容量不夠,則 Queue<T> 根據特定的增長因子擴充陣列容量。
預設情況下,增長因子(growth factor)的值為 2.0,所以內部陣列的長度會增加一倍。也可以通過建構函式中指定增長因子。Queue<T> 的容量也可以通過 TrimExcess 方法來減少。
Dequeue 方法根據 head 索引返回當前元素,之後將 head 索引指向 null,再遞增 head 的值。
Stack<T>
當需要使用後進先出順序(LIFO)的資料結構時,.NET 為我們提供了 Stack<T>。Stack<T> 類提供了 Push 和 Pop 方法來實現對 Stack<T> 的存取。
Stack<T> 中儲存的元素可以通過一個垂直的集合來形象的表示。當新的元素壓入棧中(Push)時,新元素被放到所有其他元素的頂端。當需要彈出棧(Pop)時,元素則被從頂端移除。
Stack<T> 的預設容量是 10。和 Queue<T> 類似,Stack<T> 的初始容量也可以在建構函式中指定。Stack<T> 的容量可以根據實際的使用自動的擴充套件,並且可以通過 TrimExcess 方法來減少容量。
如果 Stack<T> 中元素的數量 Count 小於其容量,則 Push 操作的複雜度為 O(1)。如果容量需要被擴充套件,則 Push 操作的複雜度變為 O(n)。Pop 操作的複雜度始終為 O(1)。
Hashtable
現在我們要使用員工的社保號作為唯一標識進行儲存。社保號的格式為 DDD-DD-DDDD(D 的範圍為數字 0-9)。
如果使用 Array 儲存員工資訊,要查詢社保號為 111-22-3333 的員工,則將會嘗試遍歷陣列的所有選擇,即執行復雜度為 O(n) 的查詢操作。好一些的辦法是將社保號排序,以使查詢複雜度降低到 O(log(n))。但理想情況下,我們更希望查詢複雜度為 O(1)。
一種方案是建立一個大陣列,範圍從 000-00-0000 到 999-99-9999 。
這種方案的缺點是浪費空間。如果我們僅需要儲存 1000 個員工的資訊,那麼僅利用了 0.0001% 的空間。
第二種方案就是用雜湊函式(Hash Function)壓縮序列。
我們選擇使用社保號的後四位作為索引,以減少區間的跨度。這樣範圍將從 0000 到 9999。
在數學上,將這種從 9 位數轉換為 4 位數的方式稱為雜湊轉換(Hashing)。可以將一個數組的索引空間(indexers space)壓縮至相應的雜湊表(Hash Table)。
在上面的例子中,雜湊函式的輸入為 9 位數的社保號,輸出結果為後 4 位。
H(x) = last four digits of x
上圖中也說明在雜湊函式計算中常見的一種行為:雜湊衝突(Hash Collisions)。即有可能兩個社保號的後 4 位均為 0000。
當要新增新元素到 Hashtable 中時,雜湊衝突是導致操作被破壞的一個因素。如果沒有衝突發生,則元素被成功插入。如果發生了衝突,則需要判斷衝突的原因。因此,雜湊衝突提高了操作的代價,Hashtable 的設計目標就是要儘可能減低衝突的發生。
避免雜湊衝突的一個方法就是選擇合適的雜湊函式。雜湊函式中的衝突發生的機率與資料的分佈有關。例如,如果社保號的後 4 位是隨即分佈的,則使用後 4 位數字比較合適。但如果後 4 位是以員工的出生年份來分配的,則顯然出生年份不是均勻分佈的,則選擇後 4 位會造成大量的衝突。
我們將選擇合適的雜湊函式的方法稱為衝突避免機制(Collision Avoidance)。
在處理衝突時,有很多策略可以實施,這些策略稱為衝突解決機制(Collision Resolution)。其中一種方法就是將要插入的元素放到另外一個塊空間中,因為相同的雜湊位置已經被佔用。
例如,最簡單的一種實現就是線性挖掘(Linear Probing),步驟如下:
- 當插入新的元素時,使用雜湊函式在雜湊表中定位元素位置;
- 檢查雜湊表中該位置是否已經存在元素。如果該位置內容為空,則插入並返回,否則轉向步驟 3。
- 如果該位置為 i,則檢查 i+1 是否為空,如果已被佔用,則檢查 i+2,依此類推,直到找到一個內容為空的位置。
現在如果我們要將五個員工的資訊插入到雜湊表中:
Alice (333-33-1234) Bob (444-44-1234) Cal (555-55-1237) Danny (000-00-1235) Edward (111-00-1235)
則插入後的雜湊表可能如下:
元素的插入過程:
Alice 的社保號被雜湊為 1234,因此存放在位置 1234。 Bob 的社保號被雜湊為 1234,但由於位置 1234 處已經存放 Alice 的資訊,則檢查下一個位置 1235,1235 為空,則 Bob 的資訊就被放到 1235。 Cal 的社保號被雜湊為 1237,1237 位置為空,所以 Cal 就放到 1237 處。 Danny 的社保號被雜湊為 1235,1235 已被佔用,則檢查 1236 位置是否為空,1236 為空,所以 Danny 就被放到 1236。 Edward 的社保號被雜湊為 1235,1235 已被佔用,檢查1236,也被佔用,再檢查1237,直到檢查到 1238時,該位置為空,於是 Edward 被放到了1238 位置。
線性挖掘(Linear Probing)方式雖然簡單,但並不是解決衝突的最好的策略,因為它會導致同類雜湊的聚集。這導致搜尋雜湊表時,衝突依然存在。例如上面例子中的雜湊表,如果我們要訪問 Edward 的資訊,因為 Edward 的社保號 111-00-1235 雜湊為 1235,然而我們在 1235 位置找到的是 Bob,所以再搜尋 1236,找到的卻是 Danny,以此類推直到找到 Edward。
一種改進的方式為二次挖掘(Quadratic Probing),即每次檢查位置空間的步長為平方倍數。也就是說,如果位置 s 被佔用,則首先檢查 s + 12處,然後檢查s – 12,s + 22,s – 22,s + 32依此類推,而不是象線性挖掘那樣以 s + 1,s + 2 … 方式增長。儘管如此,二次挖掘同樣也會導致同類雜湊聚集問題。
.NET 中的 Hashtable 的實現,要求新增元素時不僅要提供元素(Item),還要為該元素提供一個鍵(Key)。例如,Key 為員工社保號,Item 為員工資訊物件。可以通過 Key 作為索引來查詢 Item。
Hashtable employees = new Hashtable(); // Add some values to the Hashtable, indexed by a string key employees.Add("111-22-3333", "Scott"); employees.Add("222-33-4444", "Sam"); employees.Add("333-44-55555", "Jisun"); // Access a particular key if (employees.ContainsKey("111-22-3333")) { string empName = (string)employees["111-22-3333"]; Console.WriteLine("Employee 111-22-3333's name is: " + empName); } else Console.WriteLine("Employee 111-22-3333 is not in the hash table...");
Hashtable 類中的雜湊函式比前面介紹的社保號的實現要更為複雜。雜湊函式必須返回一個序數(Ordinal Value)。對於社保號的例子,通過擷取後四位就可以實現。但實際上 Hashtable 類可以接受任意型別的值作為 Key,這都要歸功於 GetHashCode 方法,一個定義在 System.Object 中的方法。GetHashCode 的預設實現將返回一個唯一的整數,並且保證在物件的生命週期內保持不變。
Hashtable 類中的雜湊函式定義如下:
H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))] % hashsize
這裡的 GetHash(key) 預設是呼叫 key 的 GetHashCode 方法以獲取返回的雜湊值。hashsize 指的是雜湊表的長度。因為要進行求模,所以最後的結果 H(key) 的範圍在 0 至 hashsize – 1 之間。
當在雜湊表中新增或獲取一個元素時,會發生雜湊衝突。前面我們簡單地介紹了兩種衝突解決策略:
線性挖掘(Linear Probing) 二次挖掘(Quadratic Probing)
在 Hashtable 類中則使用的是一種完全不同的技術,稱為二度雜湊(rehashing)(有些資料中也將其稱為雙精度雜湊(double hashing))。
二度雜湊的工作原理如下:
有一個包含一組雜湊函式 H1…Hn 的集合。當需要從雜湊表中新增或獲取元素時,首先使用雜湊函式 H1。如果導致衝突,則嘗試使用 H2,以此類推,直到 Hn。所有的雜湊函式都與 H1 十分相似,不同的是它們選用的乘法因子(multiplicative factor)。
通常,雜湊函式 Hk 的定義如下:
Hk(key) = [GetHash(key) + k * (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)))] % hashsize
當使用二度雜湊時,重要的是在執行了 hashsize 次挖掘後,雜湊表中的每一個位置都有且只有一次被訪問到。也就是說,對於給定的 key,對雜湊表中的同一位置不會同時使用 Hi 和 Hj。在 Hashtable 類中使用二度雜湊公式,其始終保持 (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)) 與 hashsize 互為素數(兩數互為素數表示兩者沒有共同的質因子)。
二度雜湊較前面介紹的線性挖掘(Linear Probing)和二次挖掘(Quadratic Probing)提供了更好的避免衝突的策略。
Hashtable 類中包含一個私有成員變數 loadFactor,loadFactor 指定了雜湊表中元素數量與位置(slot)數量之間的最大比例。例如:如果 loadFactor 等於 0.5,則說明雜湊表中只有一半的空間存放了元素值,其餘一半都為空。
雜湊表的建構函式允許使用者指定 loadFactor 值,定義範圍為 0.1 到 1.0。然而,不管你提供的值是多少,範圍都不會超過 72%。即使你傳遞的值為 1.0,Hashtable 類的 loadFactor 值還是 0.72。微軟認為loadFactor 的最佳值為 0.72,這平衡了速度與空間。因此雖然預設的 loadFactor 為 1.0,但系統內部卻自動地將其改變為 0.72。所以,建議你使用預設值1.0(但實際
// Add some employees employeeData.Add(455110189) = new Employee("Scott Mitchell"); employeeData.Add(455110191) = new Employee("Jisun Lee"); // See if employee with SSN 123-45-6789 works here if (employeeData.ContainsKey(123456789))
上是 0.72)。
向 Hashtable 中新增新元素時,需要檢查以保證元素與空間大小的比例不會超過最大比例。如果超過了,雜湊表空間將被擴充。步驟如下:
- 雜湊表的位置空間幾乎被翻倍。準確地說,位置空間值從當前的素數值增加到下一個最大的素數值。
- 因為二度雜湊時,雜湊表中的所有元素值將依賴於雜湊表的位置空間值,所以表中所有值也需要重新二度雜湊。
由此看出,對雜湊表的擴充將是以效能損耗為代價。因此,我們應該預先估計雜湊表中最有可能容納的元素數量,在初始化雜湊表時給予合適的值進行構造,以避免不必要的擴充。
Dictionary<K,T>
Hashtable 類是一個型別鬆耦合的資料結構,開發人員可以指定任意的型別作為 Key 或 Item。當 .NET 引入泛型支援後,型別安全的 Dictionary<K,T> 類出現。Dictionary<K,T> 使用強型別來限制 Key 和 Item,當建立 Dictionary<K,T> 例項時,必須指定 Key 和 Item 的型別。
Dictionary<keyType, valueType> variableName = new Dictionary<keyType, valueType>();
如果繼續使用上面描述的社保號和員工的示例,我們可以建立一個 Dictionary<K,T> 的例項:
Dictionary<int, Employee> employeeData = new Dictionary<int, Employee>();
這樣我們就可以新增和刪除員工資訊了。
Dictionary<K,T> 與 Hashtable 的不同之處還不止一處。除了支援強型別外,Dictionary<K,T> 還採用了不同的衝突解決策略(Collision Resolution Strategy),這種新的技術稱為鏈技術(chaining)。
前面使用的挖掘技術(probing),如果發生衝突,則將嘗試列表中的下一個位置。如果使用二度雜湊(rehashing),則將導致所有的雜湊被重新計算。而新的鏈技術(chaining)將採用額外的資料結構來處理衝突。Dictionary<K,T> 中的每個位置(slot)都對映到了一個數組。當衝突發生時,衝突的元素將被新增到桶(bucket)列表中。
下面的示意圖中描述了 Dictionary<K,T> 中的每個桶(bucket)都包含了一個連結串列以儲存相同雜湊的元素。
上圖中,該 Dictionary 包含了 8 個桶,也就是自頂向下的黃色背景的位置。一定數量的 Employee 物件已經被新增至 Dictionary 中。如果一個新的 Employee 要被新增至 Dictionary 中,將會被新增至其 Key 的雜湊所對應的桶中。如果在相同位置已經有一個 Employee 存在了,則將會將新元素新增到列表的前面。
向 Dictionary 中新增元素的操作涉及到雜湊計算和連結串列操作,但其仍為常量,複雜度為 O(1)。
對 Dictionary 進行查詢和刪除操作時,其平均時間取決於 Dictionary 中元素的數量和桶(bucket)的數量。具體的說就是執行時間為 O(n/m),這裡 n 為元素的總數量,m 是桶的數量。但 Dictionary 幾乎總是被實現為 n = m,也就是說,元素的總數絕不會超過桶的總數。所以 O(n/m) 也變成了常量 O(1)。
參考:常用資料結構的時間複雜度