条款31:了解你的排序选择
How can I sort thee? Let me count the ways.
当很多程序员想到排序对象时,只有一个算法出现在脑海:sort。(有些程序员想到qsort,但一旦他们看了条款46,他们会放弃qsort的想法并用sort的想法取代之。)
现在,sort是一个令人称赞的算法,但如果你不需要你就没有必要浪费表情。有时候你不需要完全排序。比如,如果你有一个Widget的vector,你想选择20个质量最高的Widget发送给你最忠实的客户,你需要做的只是排序以鉴别出20个最好的Widget,剩下的可以保持无序。你需要的是部分排序,有一个算法叫做partial_sort,它能准确地完成它的名字所透露的事情:
bool qualityCompare(const Widget& lhs, const Widget& rhs) { // 返回lhs的质量是不是比rhs的质量好 } ... partial_sort(widgets.begin(), // 把最好的20个元素 widgets.begin() + 20, // (按顺序)放在widgets的前端 widgets.end(), qualityCompare); ... // 使用widgets...
调用完partial_sort后,widgets的前20个元素是容器中最好的而且它们按顺序排列,也就是,质量最高的Widget是widgets[0],第二高的是widgets[1]等。这就是你很容易把最好的Widget发送给你最好的客户,第二好的Widget给你第二好的客户等。
如果你关心的只是能把20个最好的Widget给你的20个最好的客户,但你不关心哪个Widget给哪个客户,partial_sort就给了你多于需要的东西。在那种情况下,你需要的只是任意顺序的20个最好的Widget。STL有一个算法精确的完成了你需要的,虽然名字不大可能从你的脑中迸出。它叫做nth_element。
nth_element排序一个区间,在ri位置(你指定的)的元素是如果区间被完全排序后会出现在那儿的元素。另外,当nth_element返回时,在n以上的元素没有在排序顺序上在位置n的元素之后的,而且在n以下的元素没有在排序顺序上在位置n的元素之前的。如果这听起来很复杂,那只是因为我必须仔细地选择我的语言。一会儿我会解释为什么,但首先让我们看看怎么使用nth_element来保证最好的20个Widget在widgets vector的前端:
nth_element (widgets.begin(), // 把最好的20个元素 widgets.begin() + 20, // 放在widgets前端, widgets.end(), // 但不用担心 qualityCompare); // 它们的顺序
正如你所见,调用nth_element本质上等价于调用partial_sort。它们结果的唯一区别是partial_sort排序了在位置1-20的元素,而nth_element不。但是两个算法都把20个质量最高的Widget移动到vector前端。
那引出一个重要的问题。当有元素有同样质量的时候这些算法怎么办?比如假设有12个元素质量是1级(可能是最好的),15个元素质量是2级(第二好的)。在这种情况下,选择20个最好的Widget就是选择12个1级的和15个中的8个2级的。partial_sort和nth_element怎么判断15个中的哪些要放到最好的20个中?对于这个问题,当多个元素有等价的值时sort怎么判断元素的顺序?
partial_sort和nth_element以任何它们喜欢的方式排序值等价的元素,而且你不能控制它们在这方面行为。(条款19可以告诉你什么是两个值等价。)在我们的例子中,当需要把质量都是2级的Widget选出最后8个放到vector的前20个时,它们可以选择它们想要的任何一个。这不是没有理由的。如果你需要20个最好的Widget和一些也很好的Widget,你不该抱怨你取回的20个至少和你没有取回的一样好。
对于完整的排序,你有稍微多一些的控制权。有些排序算法是稳定的。在稳定排序中,如果一个区间中的两个元素有等价的值,它们的相对位置在排序后不改变。因此,如果在(未排序的)widgets vector中Widget A在Widget B之前,而且两者都有相同的质量等级,那么稳定排序算法会保证在这个vector排序后,Widget A仍然在Widget B之前。不稳定的算法没做这个保证。
partial_sort是不稳定的。nth_element、sort也没有提供稳定性,但是有一个算法——stable_sort——它完成了它的名字所透露的。如果当你排序的时候你需要稳定性,你可能要使用stable_sort。STL并不包含partial_sort和nth_element的稳定版本。
现在谈谈nth_element,这个名字奇怪的算法是个引人注目的多面手。除了能帮你找到区间顶部的n个元素,它也可以用于找到区间的中值或者找到在指定百分点的元素:
vector<Widget>::iterator begin(widgets.begin()); // 方便地表示widgets的 vector<Widget>::iterator end(widgets.end()); // 起点和终点 // 迭代器的变量 vector<Widget>::iterator goalPosition; // 这个迭代器指示了 // 下面代码要找的 // 中等质量等级的Widget // 的位置 goalPosition = begin + widgets.size() / 2; // 兴趣的Widget // 会是有序的vector的中间 nth_element(begin, goalPosition, end, // 找到widgets中中等 qualityCompare); // 质量等级的值 ... // goalPosition现在指向 // 中等质量等级的Widget // 下面的代码能找到 // 质量等级为75%的Widget vector<Widget>::size_type goalOffset = // 指出兴趣的Widget 0.25 * widgets.size(); // 离开始有多远 nth_element(begin, begin + goalOffset, end, // 找到质量值为 qualityCompare); // 75%的Widget ... // goalPosition现在指向 // 质量等级为75%的Widget
如果你真的需要把东西按顺序放置,sort、stable_sort和partial_sort都很优秀,当你需要鉴别出顶部的n个元素或在一个指定位置的元素时nth_element就会买单。但有时候你需要类似nth_element的东西,但不是完全相同。比如假设,你不需要鉴别出20个质量最高的Widget。取而代之的是,你需要鉴别出所有质量等级为1或2的。当然你可以按照质量排序这个vector,然后搜索第一个质量等级比2差的。那就可以鉴别出质量差的Widget的区间起点。
但是完全排序需要很多工作,而且对于这个任务做了很多不必要的工作。一个更好的策略是使用partition算法,它重排区间中的元素以使所有满足某个标准的元素都在区间的开头。
比如,移动所有质量等级为2或更好的Widget到widgets前端,我们定义了一个函数来鉴别哪个Widget是这个级别。
bool hasAcceptableQuality(const Widget& w) { // 返回w质量等级是否是2或更高; }
传这个函数给partition:
vector<Widget>::iterator goodEnd = // 把所有满足hasAcceptableQuality partition(widgets.begin(), // 的widgets移动到widgets前端, widgets.end(), // 并且返回一个指向第一个 hasAcceptableQuality); // 不满足的widget的迭代器
此调用完成后,从widgets.begin()到goodEnd的区间容纳了所有质量是1或2的Widget,从goodEnd到widgets.end()的区间包含了所有质量等级更低的Widget。如果在分割时保持同样质量等级的Widget的相对位置很重要,我们自然会用stable_partition来代替partition。
算法sort、stable_sort、partial_sort和nth_element需要随机访问迭代器,所以它们可能只能用于vector、string、deque和数组。对标准关联容器排序元素没有意义,因为这样的容器使用它们的比较函数来在任何时候保持有序。唯一我们可能会但不能使用sort、stable_sort、partial_sort或nth_element的容器是list,list通过提供sort成员函数做了一些补偿。(有趣的是,list::sort提供了稳定排序。)所以,如果你想要排序一个list,你可以,但如果你想要对list中的对象进行partial_sort或nth_element,你必须间接完成。一个间接的方法是把元素拷贝到一个支持随机访问迭代器的容器中,然后对它应用需要的算法。另一个方法是建立一个list::iterator的容器,对那个容器使用算法,然后通过迭代器访问list元素。第三种方法是使用有序的迭代器容器的信息来迭代地把list的元素接合到你想让它们所处的位置。正如你所见,有很多选择。
partition和stable_partition与sort、stable_sort、partial_sort和nth_element不同,它们只需要双向迭代器。因此你可以在任何标准序列迭代器上使用partition和stable_partition。
我们总结一下你的排序选择:
- 如果你需要在vector、string、deque或数组上进行完全排序,你可以使用sort或stable_sort。
- 如果你有一个vector、string、deque或数组,你只需要排序前n个元素,应该用partial_sort。
- 如果你有一个vector、string、deque或数组,你需要鉴别出第n个元素或你需要鉴别出最前的n个元素,而不用知道它们的顺序,nth_element是你应该注意和调用的。
- 如果你需要把标准序列容器的元素或数组分隔为满足和不满足某个标准,你大概就要找partition或stable_partition。
- 如果你的数据是在list中,你可以直接使用partition和stable_partition,你可以使用list的sort来代替sort和stable_sort。如果你需要partial_sort或nth_element提供的效果,你就必须间接完成这个任务,但正如我在上面勾画的,会有很多选择。
另外,你可以通过把数据放在标准关联容器中的方法以保持在任何时候东西都有序。你也可能会考虑标准非STL容器priority_queue,它也可以总是保持它的元素有序。(priority_queue在传统上被考虑为STL的一部分,但是,正如我在导言中所表明的,我对“STL”的定义是要求STL容器支持迭代器,而priority_queue并没有迭代器。)
“但性能怎么样?”,你想知道。这是极好的问题。一般来说,做更多工作的算法比做得少的要花更长时间,而必须稳定排序的算法比忽略稳定性的算法要花更长时间。我们可以把我们在本条款讨论的算法排序如下,需要更少资源(时间和空间)的算法列在需要更多的前面:
1. partition | 4. partial_sort |
2. stable_partition | 5. sort |
3. nth_element | 6. stable_sort |
我对于在这些排序算法之间作选择的建议是让你的选择基于你需要完成的任务上,而不是考虑性能。如果你选择的算法只完成了你需要的(比如用partition代替完全排序),你能得到的不仅是可以最清楚地表达了你要做的代码,而且是使用STL最高效的方法来完成它。