问题背景
假设我们有以下的测试程序:
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)