真是O(1)吗?想清楚了没?
當然標題里這個O(1)可以換成任何復雜度。
話說寫程序的時候我們會用到各種數據結構,但十有八九不會由我們自己從頭寫起,都會直接拿來用。于是很多人就會記住,譬如HashMap或Dictionary的存取是O(1)的操作,二分查找什么的則是O(log(N))。不過,我們在實踐中直接把這些類拿來用的時候,最好也留個心眼,知道這些類內部到底做了些什么,為什么它們能夠達到O(1)之類的時間復雜度。
例如,我們都知道List<T>的Add是O(1)的操作,但之所以它是O(1),是因為它的“擴容”操作被均攤了(amortized),但每次擴容時其實還是需要復制所有元素,次數越少越好,于是實踐中在可行的情況下我們往往應該給它指定一個初始容量——用StringBuilder的時候也是一樣。
我這里還可以再舉一個更為復雜的例子,例如HashSet和SortedSet,我們要向其中添加N個元素(如字符串),哪個會更快一些?從文檔上可以知道,HashSet的Add方法是O(1)的操作,而SortedSet內部是用了紅黑樹,它的Add方法是O(log(N))的操作(但它能順序輸出元素)。顯然,從時間復雜度上來講,SortedSet的性能要落后于HashSet,不過我們能否設計一個用例,讓HashSet慢于SortedSet呢?
當然可以,例如以前那個由哈希碰撞引起的DoS安全漏洞,其實就是設計了一些Hash Code相同,但具體內容不同的字符串,讓Dictionary(原理與HashSet相同)Add/Remove操作的時間復雜度從O(1)退化為O(N),這顯然低于O(log(N))。不過如今的BCL中的實現已經對碰撞次數設置了閾值,超過這個閾值則會對哈希函數進行隨機化,因此這種做法已經很難生效了。所以這里我們可以設計出另一個案例,且看代碼:
static string[] GetRandomStrings(int number, int length) {var random = new Random(DateTime.Now.Millisecond);var array = new string[number];var buffer = new char[length];for (var i = 0; i < array.Length; i++) {for (var j = 0; j < buffer.Length; j++) {buffer[j] = (char)(random.Next(Char.MinValue, Char.MaxValue) + 1);}array[i] = new string(buffer);}Console.WriteLine("Generated");return array; }static void CollectGarbage() {for (var i = 0; i < 10; i++) {GC.Collect();GC.WaitForPendingFinalizers();} }static void AddToSetTime(string name, ISet<string> set, string[] array) {CollectGarbage();var watch = new Stopwatch();watch.Start();foreach (var s in array) {set.Add(s);}Console.WriteLine(name + ": " + watch.ElapsedMilliseconds); }static void Main() {var array = GetRandomStrings(5000, 50000);AddToSetTime("HashSet", new HashSet<string>(), array);AddToSetTime("SortedSet", new SortedSet<string>(), array); }GetRandomStrings方法用于生成一系列的隨機字符串,我們會將這些字符串使用AddToSetTime方法放入一個集合類中,并輸出耗時。這段程序在我的機器上輸出:
Generated HashSet: 165 SortedSet: 17您可以自己嘗試一下,具體數值可能不同,但HashSet顯著慢于SortedSet則基本是確定的。為什么會這樣?O(1)為什么完敗于O(log(N))?難道HashSet的常數就那么大么?其實原因很簡單,我們只需想想HashSet和SortedSet分別是怎么實現的就行。
- HashSet:調用元素的GetHashCode方法獲得Hash Code,算出該元素放在哪個Bucket中,然后順著鏈表使用Equals方法依次比較Hash Code的相同元素。由于Hash Code散列程度較高,相同Bucket中重復元素極少,因此時間復雜度近似為O(1)。
- SortedSet:使用CompareTo方法比較新元素與紅黑樹里的元素,以此決定元素的樹中的“走向”,需要時再進行“平衡”操作。由于一顆平衡二叉樹的高度為log(N),因此添加一個元素要進行大約log(N)次比較(以及最多log(N)次O(1)的平衡操作),其時間復雜度大約為O(log(N))。
看起來很正常嘛,但是其中還隱含一些“假設”,那就是GetHashCode、Equals還有CompareTo方法都是O(1)的操作,但事實真是如此嗎?針對以上代碼生成的隨機字符串來說,Equals和CompareTo方法都可以幾乎瞬間返回(比較第一個字符即可)。不過GetHashCode便麻煩一些了,便順手從BCL的代碼里摘抄出來吧:
namespace System {public sealed class String {public override int GetHashCode() {#if FEATURE_RANDOMIZED_STRING_HASHINGif (HashHelpers.s_UseRandomizedStringHashing) {return InternalMarvin32HashString(this, this.Length, 0);} #endif // FEATURE_RANDOMIZED_STRING_HASHINGunsafe {fixed (char* src = this) {Contract.Assert(src[Length] == '\0', "src[this.Length] == '\\0'");Contract.Assert(((int)src) % 4 == 0, "Managed string should start at 4 bytes boundary");#if WIN32int hash1 = (5381 << 16) + 5381; #elseint hash1 = 5381; #endifint hash2 = hash1;#if WIN32// 32 bit machines. int* pint = (int*)src;int len = Length;while (len > 2) {hash1 = ((hash1 << 5) + hash1 + (hash1 >> 27)) ^ pint[0];hash2 = ((hash2 << 5) + hash2 + (hash2 >> 27)) ^ pint[1];pint += 2;len -= 4;}if (len > 0) {hash1 = ((hash1 << 5) + hash1 + (hash1 >> 27)) ^ pint[0];} #elseint c;char* s = src;while ((c = s[0]) != 0) {hash1 = ((hash1 << 5) + hash1) ^ c;c = s[1];if (c == 0)break;hash2 = ((hash2 << 5) + hash2) ^ c;s += 2;} #endif#if DEBUG// We want to ensure we can change our hash function daily.// This is perfectly fine as long as you don't persist the // value from GetHashCode to disk or count on String A // hashing before string B. Those are bugs in your code.hash1 ^= ThisAssembly.DailyBuildNumber; #endifreturn hash1 + (hash2 * 1566083941);}}}} }從代碼里可以看出,撇開最先的InternalMarvin32HashString這個不談,其他分支下的哈希算法都是與字符串的長度呈線性關系。至于Marvin32這個神秘的哈希算法,我只知道可用于避免哈希碰撞攻擊,但搜索了半天都找不到它的具體信息,只有一個“疑似”的簡化實現。目前,我們還是用簡單的測試來驗證字符串長度與GetHashCode方法耗時的關系:
static void GetHashCodeTime(string name, string str, int iteration) {CollectGarbage();str.GetHashCode(); // warm upvar watch = new Stopwatch();watch.Start();for (var i = 0; i < iteration; i++) {str.GetHashCode();}Console.WriteLine(name + ": " + watch.ElapsedMilliseconds); }static void Main() {var shortStr = new string('a', 100);var longStr = new string('a', 10000);var iteration = 1000000;GetHashCodeTime("Short", shortStr, iteration);GetHashCodeTime("Long", longStr, iteration); }我們創建了長度相差百倍的字符串,并比較其GetHashCode方法的耗時。我們可以開啟隨機化的字符串哈希算法(即使用Marvin32哈希算法),例如打開之后在我的機器上執行結果是:
Short: 114 Long: 9142耗時與長度基本呈線性關系。關閉“隨機化哈希”之后執行速度略有提高,但耗時與長度的關系依然不變,其實從代碼上也已經能夠看出這點。
再回到最早針對HashSet和SortedSet的實驗,由于我故意生成了長度高達5w的字符串,因此HashSet時間復雜度為O(1)又如何?單次GetHashCode方法調用的耗時,就已經遠遠超過許多次CompareTo方法了。從中我們也可以看出,假如我們用字符串作為字典的鍵,其效率會較int或是普通未重載過GetHashCode和Equals方法的類型為低。話說回來,其實用一個最普通的類作為字典的鍵效率很高,因為它的GetHashCode可以直接返回它的初始地址,而Equals方法則直接比較兩個對象的引用。
在實踐中,我遇到各種需要以字符串作為鍵的場景,我都會思考下有沒有簡單的替代方法,例如使用int做鍵,甚至直接使用數組。例如,前段時間@左耳朵耗子提到對一個csv文件里的數據進行排序,我們可以使用一個字典來保存一行數據,其中鍵為字段名:
List<Dictionary<string, string>> data = ...; var ordered = data.OrderBy(row => row["column0"]) // 先以column0排序.ThenBy(row => row["column1"]); // 再以column1排序但更有效率(內存使用也更緊湊)的做法是以一個數組來保存一行數據。我們先找出需要排序的列的下標,然后再從數組中找出排序用的字段值:
string[] columns = ...; var index0 = columns.IndexOf("column0"); var index1 = columns.IndexOf("column1");List<string[]> data = ...; var ordered = data.OrderBy(row => row[index0]).ThenBy(row => row[index1]);從理論上講,兩種做法的時間復雜度一致,但實際上后者比前者會有不少提高。我們在了解“理論”的同時也需要注意實踐上的細節,例如,其實在實踐中O(log(N))其實也是個不比O(1)大多少的時間復雜度,此時可能也需要考慮下“常數”會對性能造成多大影響。
from:?http://blog.zhaojie.me/2013/01/think-in-detail-why-its-o-1.html
總結
以上是生活随笔為你收集整理的真是O(1)吗?想清楚了没?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于浮点数计算时的精度问题
- 下一篇: 跟vczh看实例学编译原理——零:序言