問題背景
假設我們有以下的測試程序:
1 using System; 2 using System.IO; 3 using System.Text; 4 using System.Linq; 5 using System.Collections.Generic; 6 7 static class Tester 8 { 9 static string RemoveCharOf(this string value, IEnumerable<char> chars) 10 { 11 // TODO: 要求返回的字符串中包含 value 的一個副本,該副本移除了所有在 chars 出現的字符。 12 } 13 14 static void Main() 15 { 16 var value = "http://www.cnblogs.com/skyivben/archive/2012/05/05/2484960.html"; 17 Console.WriteLine(" value: [{0}]", value); 18 Console.WriteLine("result: [{0}]", value.RemoveCharOf(Path.GetInvalidFileNameChars())); 19 } 20 }
這就是說,要我們實現一個給 string 類擴展一個 RemoveCharOf 方法,用以移除指定字符串所有出現在 chars 參數中的字符。這個擴展方法除了上例中用於獲得一個合法的文件名外,還可以有其他的用途。比如我最近一個項目中要把給定了的數據字典的固定寬度的文本文件的內容寫入到 SQLite 的內存數據庫(Data Source=:memory:)中,以供查詢。而這個數據字典中給出的字段名稱可能包含有不能作為數據庫的表的字段的字符,這就用得上 textFieldName.RemoveCharOf("[(/ -)]") 這種方法了。
算法1
首先是最容易想到的、相當直接了當的算法:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 foreach (var c in chars) value = value.Replace(c.ToString(), null); 4 return value; 5 }
這個算法循環調用 string 類的 Replace 方法來移除所有在 chars 中出現的字符。可以預料的是,這個算法是低效和浪費內存的。
算法2
第二個算法對第一個算法稍做改進:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 foreach (var c in chars.Distinct()) value = value.Replace(c.ToString(), null); 4 return value; 5 }
這個改進就是先使用 IEnumerable<T> 的擴展方法 Distinct 移除 chars 中所有重復出現的字符,以免循環體中的 Replace 方法作無用功。不過這個改進也難說得很,因為調用者給出的 chars 中一般是不會出現重復的字符的。這樣一來,反而是在大多數情況下 Distinct 方法作了無用功。
算法3(錯誤)
第三個算法應該是真正有所改進:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var array = chars.ToArray(); 4 for (int k, i = 0; i < value.Length; i++) 5 if ((k = value.IndexOfAny(array)) >= 0) 6 value = value.Remove(k, 1); 7 return value; 8 }
該算法首先使用 IEnumerable<T> 的擴展方法 ToArray 得到一個字符數組(char[])。然后循環是圍繞 value 進行,而不是像前面的算法那樣圍繞 chars 進行。在循環中使用 string 類的 IndexOfAny 方法找出在 chars 中出現的字符在 value 中的位置,再使用 string 類的 Remove 方法從該位置移除這個字符。前兩個算法的正確性是顯而易見的,這個算法的正確性還是要稍微仔細想一下的。但是這個算法真正比前兩個算法快嗎?雖然 string 類的 Remove 方法肯定比 Replace 方法快,但是由於 value.Length 一般來說比 chars.Length 大,所以也有點難說。不同的算法對不同的輸入來說效率也是不同的。上述 C# 源程序的第三行也可以考慮改為:
var array = chars.Distinct().ToArray();
這和第二個算法的情形是一樣的。
算法3(正確)
原來的算法3有錯誤,現改正如下:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var array = chars.Distinct().ToArray(); 4 for (int k; (k = value.IndexOfAny(array)) >= 0; ) value = value.Remove(k, 1); 5 return value; 6 }
原來的算法3對於 "aa".RemoveCharOf("a") 調用會錯誤地返回 "a",而正確的算法應該返回 string.Empty。算法3的循環條件應該以是否在 value 中查找到 chars 中出現的字符為依據,而不能是圍繞 value 進行。看來算法3還是有點復雜,上一小節說其正確性還是要稍微仔細想一下的,還是沒想對。:(
算法4
第四個算法換個角度考慮問題:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var sb = new StringBuilder(); 4 foreach (var c in value) 5 if (!chars.Contains(c)) 6 sb.Append(c); 7 return sb.ToString(); 8 }
這次不是從字符串中移除字符了,而是新建一個空的 StringBuilder,然后逐步把不在 chars 中出現在字符從 value 中添加到這個 StringBuilder 中。想來這個算法應該比前面的都快。同樣,可以考慮在算法的一開始就加上一句:
chars = chars.Distinct();
算法5
第五個算法是第四個算法的改進版:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var sb = new StringBuilder(); 4 var array = chars.Distinct().ToArray(); 5 Array.Sort(array); 6 foreach (var c in value) 7 if (Array.BinarySearch(array, c) < 0) 8 sb.Append(c); 9 return sb.ToString(); 10 }
在第四個算法中使用 IEnumerable<T> 的 Contains 擴展方法來判斷指定的字符是否出現在 chars 中,這個 Contains 擴展方法的時間復雜度肯定是 O(N) 的。而第五個算法先將 chars 使用 IEnumerable<T> 的 ToArray 擴展方法拷貝到字符數組(char[]) array 中(拷貝之前還調用 IEnumerable<T> 的 Distinct 擴展方法消除重復元素,這步也可以省略),再對 array 排序一次,然后使用 Array 類的 BinarySearch 方法來判斷指定的字符是否出現在 array 中,時間復雜度減少到 O(logN) 了。
算法6
第六個算法是對第五個算法的改進:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var sb = new StringBuilder(); 4 var set = new HashSet<char>(chars); 5 foreach (var c in value) 6 if (!set.Contains(c)) 7 sb.Append(c); 8 return sb.ToString(); 9 }
這次將 chars 裝入到一個 HashSet 中,然后使用 HashSet 類的 Contains 方法來判斷指定的字符是否出現在 chars 中。而 HashSet 類的 Contains 方法的時間復雜度是 O(1)。所以應該是很大改進。而且 HashSet 自動消除了 chars 中可能出現的重復元素,也不用考慮什么 chars.Distinct() 了。
還要注意的是,這個 RemoveCharOf 擴展方法的 chars 參數的類型是 IEnumerable<char>,而 HashSet<char> 也是實現了 IEnumerabler<char> 接口的。也就是說,chars 本身就可能是一個 HashSet<char>,那么上述程序第 4 行就將 chars 這個 HashSet<char> 再裝入到另外一個 HashSet<char> 中了。不過這也是沒有辦法的事,因為只有這樣才能在第 6 行調用 HashSet 的 Contains 方法,而不是調用 IEnumerable<T> 的 Contains 擴展方法。
在 MSDN 文檔中對 IEnumerable<T> 的 Contains 擴展方法的描述中有以下這么一句話:
如果 source 的類型實現 ICollection<T>,則將調用該實現中的 Contains 方法以獲取結果。 否則,此方法將確定 source 是否包含指定的元素。
可是沒有說:“如果 source 是 HashSet<T> 類型的話,則將調用 HashSet<T> 的 Contains 方法以獲取結果”。
算法7
第七個算法非常簡單,只有一句話:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 return string.Concat(value.Where(x => !chars.Contains(x))); 4 }
這個算法使用了 IEnumerable<T> 的 Where 擴展方法來篩選字符,謂詞就是要通過篩選的字符不能出現在 chars 中。這個算法的效率完全取決於 .NET Framework Base Class Library 中 Where 擴展方法是如何實現的,我想應該是很好的吧。同樣,也可以考慮在算法的一開始加上一句:
chars = chars.Distinct();
算法8
第八個算法是第七個算法的改進版:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 var set = new HashSet<char>(chars); 4 return string.Concat(value.Where(x => !set.Contains(x))); 5 }
這個改進類似於第六個算法的改進,也是先將 chars 裝入到 HashSet 中,然后使用 HashSet 的 Contains 方法來進行篩選。
此外,這個算法的第3行還可以改為:
var set = (chars as HashSet<char>) ?? new HashSet<char>(chars);
這樣,如果 RemoveCharOf 擴展方法的輸入參數 chars 的類型已經是 HashSet<char>,就不用再裝入到另一個 HashSet<char> 中去了。
算法9(錯誤)
第九個算法如下所示:
1 static string RemoveCharOf(this string value, IEnumerable<char> chars) 2 { 3 return string.Concat(value.Except(chars)); 4 }
這個算法直接使用 IEnumerable<T> 的 Except 擴展方法來移除 chars 中的所有字符。MSDN 中對這個 Except 擴展方法這么描述的:
通過使用默認的相等比較器對值進行比較生成兩個序列的差集。
可惜的是,這個算法是錯誤的。對於本文一開始“問題背景”小節中的測試程序,應用這個算法,將得到如下結果:
D:\work> Tester value: [http://www.cnblogs.com/skyivben/archive/2012/05/05/2484960.html] result: [htpw.cnblogsmkyivear20154896]
而應用其他八個算法,都將得到如下結果:
D:\work> Tester value: [http://www.cnblogs.com/skyivben/archive/2012/05/05/2484960.html] result: [httpwww.cnblogs.comskyivbenarchive201205052484960.html]
看出來了吧,第九個算法將不但移除了 chars 中出現的所有字符,也移除了 value 中重復出現的字符。而 MSDN 中對 IEnumerable<T> 的 Except 擴展方法的描述並沒有明確指出這一點。我們只能從該描述中的“差集”這兩個字去理解。“差集”顯然是一個“集合”,而“集合”是無序的,並且其中的元素不能重復出現(這里不考慮“多重集”)。這樣看來,雖然目前的實現中,Except 還保持了 value 中字符出現的順序,但這也是沒有保證的,以后的 .NET Framework 版本中就有可能返回亂序的結果了。
總結
其實,對於這個問題來說,輸入的規模應該都不會很大,前八個算法中的任何一個應該都能夠很好地工作,也足夠使用了。而且不同的算法對不同的輸入情況來說可能會各有優劣。如果誰實在閑得慌的話,倒是可以編寫一些典型的輸入案例對這八個算法進行一些測試,以決定各個算法的優劣。
此外,還要注意到 HashSet 和 Linq (IEnumerable<T> 的各個擴展方法,如 Distinct 等就是由 System.Linq.Enumerable 類提供的)是 .NET Framework 3.5 以上版本才有的。如果使用 .NET Framework 2.0 的話,上述八個算法中就有一些不能使用了。
參考資料
- MSDN: Path.GetInvalidFileNameChars 方法 (System.IO)
- MSDN: String.Replace 方法 (String, String) (System)
- MSDN: String.IndexOfAny 方法 (Char[]) (System)
- MSDN: String.Concat 方法 (IEnumerable(String)) (System)
- MSDN: Array.Sort(T) 方法 (T[]) (System)
- MSDN: Array.BinarySearch(T) 方法 (T[], T) (System)
- MSDN: HashSet(T).Contains 方法 (System.Collections.Generic)
- MSDN: Enumerable.Contains(TSource) 方法 (IEnumerable(TSource), TSource) (System.Linq)
- MSDN: Enumerable.Distinct(TSource) 方法 (IEnumerable(TSource)) (System.Linq)
- MSDN: Enumerable.Where(TSource) 方法 (IEnumerable(TSource), Func(TSource, Boolean)) (System.Linq)
- MSDN: Enumerable.Except(TSource) 方法 (IEnumerable(TSource), IEnumerable(TSource)) (System.Linq)
- MSDN: Enumerable.ToArray(TSource) 方法 (System.Linq)