优化查找和排序
优化查找和排序
C++程序会进行许多查找操作。从编程语言的编译器到浏览器,从控制链表到数据库,许多反复进行的程序活动都会在某个内部的循环底层进行查找操作。就经验而言,查找操作通常会出现在热点函数的列表中。因此我们需要特别注意查找操作的效率。
使用std::map和std::string的键值对表
使用std::map创建表是一个例子,它向我们展示了C++标准库提供了多么强大的抽象能力,让我们无需太多思考和编码即可实现不错的大O性能。
改善查找性能的工具箱
假如分析器指出一个包含查找表操作的函数是程序中最热点的函数之一,应该怎么办呢?
经验丰富的开发 人员会立刻注意到那些低效的地方。我有时就会这么做,尽管这条解决之道充满了风险。另外一个较好的做法是,开发人员有条理地推进性能优化工作:
- 测量当前的实现方式的性能来得到比较基准
- 识别出待优化的抽象活动(向后思考的技巧是问为什么)
- 将待优化的活动分解为组件算法和数据结构
- 修改或是替换那些可能并非最优的算法和数据结构(向前思考则是问怎么做)
- 进行性能测试以确定修改是否有效果
std::map的实现方式是一棵平衡二叉树,因此它是一种链式数据结构,必须被逐节点构造,所以一定存在一种插入算法。构建和销毁表的算法以及它的时间开销常常被忽视,这种开销可能会非常大。即使与查找表操作的时间相比,这种开销很小,但如果表具有静态存储期,那么初始化表的开销可能会被加到所有在程序启动时发生的其他初始化操作上。如果程序需要进行太多的初始化操作,那么它会失去响应。而如果表具有自动存储期,那么它可能会在程序运行期间中多次被初始化,使程序的启动开销变得更大。
优化std::map的查找
以固定长度的字符数组作为std::map的键
开发人员可能希望避免在热点代码的键值对表中使用std::string作为键带来的开销,因为内存分配占据了绝大部分创建表的开销。如果开发人员可以使用一种不会动态分配存储空间的数据结构作为键类型,就能够将这个开销减半。而且,如果表使用std::string作为键,而开发人员希望用C风格的字符串字面常量来查找元素,那么每次查找都会将char*的字符串字面常量转换为std::string,其代价是分配更多的内存,而且这些内存紧接着会立即被销毁掉。
如果键的最大长度不是特别大,那么一种解决方法是使用足以包含最长键的字符数组作为键的类型。不过无法直接使用数组,因为C++数组没有内置的比较运算符。我们可以定义一个名为charbuf的简单的固定长度字符数组模板类:
template <unsigned N=10, typename T=char> struct charbuf {
charbuf();
charbuf(charbuf const& cb);
charbuf(T const* p);
charbuf& operator=(charbuf const& rhs);
charbuf& operator=(T const* rhs);
bool operator==(charbuf const& that) const;
bool operator<(charbuf const& that) const;
private:
T data_[N];
};
charbuf的长度是在编译时就确定了的,它不会动态分配内存。
离开了运算符的定义,C++是不知道如何比较两个类的实例或是如何对它们进行排序的。开发人员必须定义他需要使用的所有相关运算符。C++标准库通常只会使用==运算符和<运算符。其他四种运算符可以从这两种中合成出来。运算符的定义可以是非成员函数,但一种更简单和更好的办法是,在charbuf中定义会访问charbuf的实现的C++风格的<运算符。
程序员在使用charbuf时需仔细思考。只能在其中保存长度小于它内部存储空间大小的字符串,这里还要将最后一个字符串结束符算进去。因此与std::string相比,无法确保它的安全性。验证所有哦可能的键都可以被存储在charbuf中,是将计算从运行时移动到设计时的一个例子。同时,这也是为了改善型男而在安全性上作出妥协的一个例子。只有那些独立的设计团队才能洞察出这种改动的收益是否大于风险,权威专家的凭空判断不可信。
以C风格的字符串组作为键使用std::map
有时,程序会访问那些存储期很长的、C风格的、以空字符结尾的字符串,那么我们就可以用这些字符串的char* 指针作为std::map的键。例如,当程序使用C++字符串字面常量来构造表时,我们可以直接使用char* 来避免构造和销毁std::string的实例的开销。
不过,以char* 作为键类型也有一个问题。std::map会在它的内部数据结构中,依据键类型的排序规则对键值对进行排序。虽然在char* 中也定义了<运算符,但它比较的却是指针,而不是指针所指向的字符串。
std::map让开发人员能够通过提供一个非默认的比较算法来解决这个问题。这也是C++允许开发人员对它的标准容器进行精准控制的一个例子。比较算法是通过std::map的第三个模板参数提供的。比较算法的默认值是函数对象std::less
原则上,程序能够对char* 特化std::less。不过这种特化必须至少对整个文件都是可见的,这可能会导致程序中其他部分出现意外行为。
如代码清单9-1所示,我们可以不使用函数对象,而是使用C风格的非成员函数来执行比较运算。这时,该函数的签名变为了map声明中的第三个参数,我们我可以用一个指向该函数的指针来初始化map。
代码清单9-1 以C风格的char*作为键、非成员函数作为比较函数的map
bool less_free(char const* p1, char const* p2) {
return strcmp(p1,p2)<0;
}
std::map<char const*,
unsigned,
bool(*)(char const*, char const*)> table(less_free);
程序还可以创建一个函数对象来封装比较操作。
代码清单9-2 以C风格的char*作为键、函数对象作为比较函数的map
struct less_for_c_strings(char const* p1, char const* p2) {
return strcmp(p1,p2)<0;
}
std::map<char const*,
unsigned,
less_for_c_strings> table;
在C++11中,另外一种为std::map提供char*比较函数的方法是,定义一个lambda表达式并将它传递给map的构造函数。使用lambda表达式非常便利,因为我们可以在局部定义它们,而且它们的声明语法也非常简洁。
代码清单9-3 以C风格的char*作为键、lambda表达式作为比较函数的map
auto comp = [](char const* p1, char const* p2) {
return strcmp(p1,p2)<0;
};
std::map<char const*,
unsigned,
decltype(comp)> table(comp);
这段示例代码中使用了C++11中的decltype关键字。map的第三个参数是一个类型。名字comp是一个变量,而decltype(comp)则是变量的类型。lambda表达式的类型没有名字,每个lambda表达式的类型都是唯一的,因此decltype是获得lambda表达式的类型的唯一方法。
在这段示例代码中,lambda表达式的行为类似于一个带有()运算符的函数对象,因此尽管lambda 表达式必须作为参数传递给构造函数,但这种机制的性能测量结果与之前的版本相同。
当键就是值的时候,使用map的表亲std::set
定义一种数据结构,其中包含一个键以及其他数据作为键所对应的值,有些程序员可能会觉得这是一件再自然不过的事了。事实上,std::map在内部声明了一种像下面这样的可以结合键与值的结构体:
template <typename KeyType, typename ValueType> struct value_type {
KeyType const first;
ValueType second;
};
如果程序定义了这样一种数据结构,那么无法将它直接用于std::map中。出于一些实际的原因,std::map要求键和值必须分开定义。键必须是常量,因为修改键会导致整个map数据结构无效。同样,指定键可以让map知道如何访问它。
std::map有一个表亲——std::set。它是一种可以保存它们自己的键的数据结构。这种类型会使用一个比较函数,该比较函数默认使用std::less来比较两个完整元素。因此,要想使用std:set和一种包含自身的键的用户自定义的结构体,开发人员必须为哪个用户自定义的结构体实现std::less、指定<运算符或是提供一个非默认的比较对象。这其中没有哪一种方法更好,只是编程风格问题。
使用<algorithm>头文件优化算法
C++标准库还提供了一组算法,其中就包括查找和排序算法。标准库算法接收迭代器作为参数。迭代器抽象了指针的行为,从包含这些值的数据结构中分离出值的遍历。标准库算法的行为是通过它们的迭代器参数的抽象行为,而不是由某些具体的数据结构指定的。基于迭代器的算法能够适用于许多种数据结构,只要这些数据结构上的迭代器具有所需的特性即可。
标准库查找算法接收两个迭代器参数:一个指向待查找序列的开始位置,另一个则指向待查找序列的末尾位置。所有的算法还都接收一个要查找的键作为参数以及一个可选的比较函数参数。这些算法的区别在于它们的返回值,以及比较函数必须定义键的排序关系还是只是比较是否相等。
有些基于迭代器的查找方法实现了分而治之的算法。这些算法依赖于某些迭代器的一种特性——计算两个迭代器之间的距离或是元素数量的能力——以这种方法实现比线性大O性能更高的性能。通过逐渐增大迭代器知道与另一个迭代器相等,总是能够计算出两个迭代器之间的距离,但是这会导致计算距离的时间开销变为O(n)。随机访问迭代器具有一种特殊的特性,即它能够以常量时间计算出这个距离。
提供了随机访问迭代器的序列容器有C风格的数组、std::string、std::vector和std::deque。分而治之算法也能够适用于std::list,但是它们的时间开销是O(n),而不是O(logn),因为计算双向迭代器之间的距离的开销更大。
以序列容器作为被查找的键值对表
相比于std::map或std::set,有几个理由使得选择序列容器实现键值对表更好:序列容器消耗的内存比map少,它们的启动开销也更小。标准库算法的一个非常有用的特性是它们能够遍历任意类型的普通数组,因此,它们能够高效地查找静态初始化的结构体的数组。这样可以移除所有启动表的开下和销毁表的开销。而且,诸如MISRA C++等编码标准都禁止或是限制了动态分配内存的数据结构的使用。因此,使用序列容器是一种能够高效地在这些环境中进行查找的解决方案。
struct kv { // (key,value) pairs
char const* key;
unsigned value;
};
static kv names[] = {
{ "alpha", 1 }, { "bravo", 2 },
{ "charlie", 3 }, { "delta", 4 },
{ "echo", 5 }, { "foxtrot", 6 },
{ "golf", 7 }, { "hotel", 8 },
{ "india", 9 }, { "juliet", 10 },
{ "kilo", 11 }, { "lima", 12 },
{ "mike", 13 }, { "november",14 },
{ "oscar", 15 }, { "papa", 16 },
{ "quebec", 17 }, { "romeo", 18 },
{ "sierra", 19 }, { "tango", 20 },
{ "uniform",21 }, { "victor", 22 },
{ "whiskey",23 }, { "xray", 24 },
{ "yankee", 25 }, { "zulu", 26 }
};
names数组的初始化是静态集合初始化。C++编译器会在编译时为C风格的结构体创建初始化数据。创建这样的数组不会有任何运行时开销。
标准库容器类提供了begin()和end()成员函数,这样程序就能够得到一个指向待查找范围的迭代器。C风格的数组更加简单,通常没有提供这些函数。不过,我们可以通过提供类型安全的模板函数来实现这个需求。由于它们接收一个数组类型作为参数,数组并不会像通常那样退化为一个指针:
template <typename T, int N> size_t size(T (&a)[N]) { return N; }
template <typename T, int N> T* begin(T (&a)[N]) { return &a[0]; }
template <typename T, int N> T* end(T (&a)[N]) { return &a[N]; }
在C++11中,我们可以在头文件中的命名空间std中,找到更复杂的begin()和end()的定义。包含任何一个标准库容器类文件时,都会包含这个头文件。Visual Studio 2010预见到了这个标准,提前提供了这些定义。不幸的是,size()直到C++14才被纳入标准,因此该方法并没有出现在Visual Studio 2010中,但我们很容易提供一个简单的等效函数。
std::find():功能如其名,O(n)时间开销
在标准库
template <class It, class T> It find(It first, It last, const T& key);
find()是一个简单的线性查找算法,最通用的查找方式。它不需要待查找的数据已经排序完成,值需要能够比较两个键是否相等即可。要想进行比较操作,必须在find()被实例化的作用域内定义用于比较关键字的函数。该函数会 告诉std::find()在进行比较时所需知道的一切信息。
find()函数的一种变化形式是find_if(),它接收比较函数作为第四个参数。这里开发人员可以编写一个lambda表达式作为比较函数。lambda表达式只接收一个参数——要进行比较的表元素,因此lambda表达式必须从环境中捕获键值。
std::binary_search():不返回值
二分查找一种常用的分而治之的策略,在C++标准库中,有几种不同的算法都使用了它。但是出于某些原因,binary_search这个名字却被用于另外一种不常用的查找算法。
标准库算法binary_search()返回一个bool值,表示键是否存在于有序表中。非常奇怪的是,标准库却没有提供配套的返回匹配的表元素的函数。因此,find()和binary_search()虽然从名字上看都像是我们要找的解决方法,但其实不然。
如果程序只是想知道一个元素是否存在于表中,而不是找到它的值,那么可以使用binary_search()。
使用std::equal_range()的二分查找
如果序列容器是有序的,那么开发人员能够从C++标准库提供的零零散散的函数中组合出一个高效的查找函数。不幸的是,这些零零散散的函数的名字都难以使人联想起二分查找。
在C++标准库的
template <class ForwardIt, class T>
std::pair<ForwardIt, ForwardIt>
equal_range(ForwardIt first, ForwardIt last, const T& value);
equal_range()会返回一对迭代器,它们确定的范围是有序序列中包含要查找的元素的子序列[first, last)。如果没有找到元素,equal_range()会返回一对指向相等值的迭代器,这表示这个范围是空的。如果返回的两个迭代器不等,表示至少找到了一条元素。
经过测试发现,equal_range()比在相同大小的表中进行线性查找更慢。equal_range()并非是二分查找函数的最佳选择。
使用std::lower_bound()的二分查找
尽管equal_range()所承诺的时间开销是O(logn),但除了表查找以外,它还有其他不必要的功能。它的一种可能实现方式看起来像下面这样:
template <class ForwardIt, class T>
std::pair<ForwardIt, ForwardIt>
equal_range(ForwardIt first, ForwardIt last, const T& value) {
return std::make_pair(std::lower_bound(first, last, value),
std::upper_bound(first, last, value));
};
upper_bound()会在表中第二次分而治之来查找要返回的范围的末尾,这是因为equal_range()需要足够通用,能够适用于任何存在一个键对应多个值的有序序列。
kv* result = std::lower_bound(std::begin(names),
std::end(names),
key);
if (result != std::end(names) && key < *result.key)
result = std::end(names);
使用std::lower_bound()进行查找的性能与使用std::map的最佳实现方式的性能旗鼓相当,而且它还有一个额外的优势,那就是构造或是销毁静态表是没有任何开销的。std::binary_search()函数版的测试结果与std::lower_bound()相同,不过它只返回bool值。看起来这是我们使用C++标准库算法所能达到的性能极限了。
自己编写二分查找法
我们可以自己编写二分查找法,使其所接收的参数与标准库函数相同。标准库算法都使用一个单独的排序函数——<运算符,这样就可以对外只提供最小的接口。由于这些函数最终都需要确定是否存在与某个键向匹配的一条元素,因此最后它们都会进行一次比较,而我们可以将a==b定义为! (a < b) && !(b < a)。
我们将初始表的取值的连续范围值定义为[start, end)。在每一步查找中,函数都会计算取值范围的中间位置,并将键与中间位置的元素进行比较。这种方法可以高效地将表的取值范围分成两部分——[start, mid+1)和[mid+1, stop)。性能与std::lower_bound()几乎相同。
使用strcmp()自己编写二分查找法
如果注意到<运算符可以用strcmp()替换,那么还可以进一步提高性能。与<运算符一样,strcmp()也会对两个键进行比较,但是strcmp()的输出结果包含的信息更多。strcmp()的返回值不是将序列分为两部分,而是三部分:[start, mid)、[mid, mid+1)和[mid+1, stop)。
// binary search using strcmp() to divide range into 3 parts, same API as find()
kv* find_binary_3way(kv* start, kv* end, char const* key) {
auto stop = end;
while (start < stop) {
auto mid = start + (stop-start)/2;
auto rc = strcmp(mid->key, key);
if (rc > 0) {// mid->key > key, search left half
stop = mid;
}
else if (rc < 0) {// mid->key < key, search right half
start = mid + 1;
}
else {
return mid;
}
}
return end;
}
if-else逻辑会先进行可能性更大的比较操作来改善性能,甚至可以用switch来改善性能。这个版本的性能比基于标准库的最佳版本的二分查找要略快。
优化键值对散列表中的查找
散列表:无论键是什么类型,它都可以被一个散列函数归约为一个整数散列值。接着我们使用这个散列值作为数组索引,让它直接指向表中的元素。如果总是可以通过散列值直接找到表元素,那么访问散列表的时间是常量时间。唯一的开销是产生这个散列值的开销。与线性查找一样,散列斌不需要键之间具有排序关系,而只需要一种方法来比较键的相等性。
寻找高效的散列函数是实现散列表时的一个复杂环节。一个含有10个字符的字符串所包含的位数可能会比一个32位整数所包含的位数多。因此,可能存在多个字符串具有相同索引值的情况。我们必须提供一种机制来应对这种冲突。散列表中的每条元素都可能是散列到某个索引值的元素列表中的第一个元素。或者,可以寻找相邻索引值来查找匹配的元素,直到遇到一个空索引为止。
另外一个问题是,对于表中的所有有效键,散列函数可能都不会产生某个索引值,导致在散列表中会存在未使用的空间。这使得散列表可能会比保存相同元素的有序数组大。
一个糟糕的散列函数或是一组不太走运的键可能会导致许多键散列到相同的索引值上。这样,散列表的性能会降到O(n),使得相比于线性查找它没有任何优势。
一个优秀的散列函数所计算出的数组索引不会与键的各个位的值紧密相关。随机数生成器和密码编码器非常适合实现这个目标。但是如果散列函数的计算开销非常大,那么除非表非常大,否则相比于二分查找它没有任何优势。
多年来,找到更好的散列函数已经成为了计算机科学家们的消遣。在Stack Exchange上的Q&A中,提供了几种流行的散列函数的性能数据和参考链接。试图优化散列表代码的开发人员应当知道相关研究已经非常透彻了,从这里得不到太大的性能提升。
C++定义了一个称为std::hash的标准散列函数对象。std::hash是一个模板,为整数、浮点数据、指针和std::string都提供了特化实现。同样适用于指针的未特化的std::hash的定义会将散列类型转换为size_t,然后随机设置它的各个位的值。
使用std::unordered_map进行散列
在C++11中,标准头文件<unordered_map>提供了一个散列表。Visual Studio 2010预料到了这个标准并提供了该头文件。不过,std::unordered_map无法与静态表一起使用。我们必须将元素插入到散列表中,这会增加构建散列表的性能开销。
代码清单9-9 初始化散列表
std::unordered_map<std::string, unsigned> table;
for (auto it = names; it != names+namesize; ++it)
table[it->key] = it->value;
std::unordered_map使用的默认散列函数是模板函数对象std::hash。由于该模板为std::string提供了特化实现,因此我们无需显式地提供散列函数。当所有元素都被插入到表中后,就可以如下这样进行查找了:auto it = table.find(key);
it是一个迭代器,它要么指向一条匹配元素,要么指向table.end()。
虽然其性能测试结果(不包括构造表的时间)比以string为键的std::map快一些,但是并非一个非常理想的结果。
对固定长度字符数组的键进行散列
charbuf也可以与散列表一起使用。
template <unsigned N=10, typename T=char> struct charbuf {
charbuf();
charbuf(charbuf const& cb);
charbuf(T const* p);
charbuf& operator=(charbuf const& rhs);
charbuf& operator=(T const* rhs);
operator size_t() const;
bool operator==(charbuf const& that) const;
bool operator<(charbuf const& that) const;
private:
T data_[N];
};
散列函数是运算符size_t()。这有一点不直观,还有一点不纯净。std::hash()的默认特化实现会将参数转换为size_t。对于指针,通过情况下这只会转换指针的各个位,但是如果是charbuf&,那么charbuf的size_t()运算符会被调用,它会返回散列值作为size_t。当然,由于size_t()运算符被劫持了,它无法再返回charbuf长度。现在,表达式sizeof(charbuf)返回的是一个容易让人误解的值。使用charbuf的散列表的声明语句如下:std::unirdered_map<charbuf<>, unsigned> table;
这个散列表的性能令人失望,甚至比以std::string为键的散列表或map更差。
以空字符结尾的字符串为键进行散列
如果能够用C++字符串字面常量这样的存储期很长的以空字符结尾的字符串来初始化散列表,那么就可以用指向这些字符串的指针来构造基于散列值的键值对表。以char*为键配合std::unordered_map一起使用是一座值得挖掘的性能金矿。
std::unordered_map的完整定义是:
template<
typename Key,
typename Value,
typename Hash = std::hash<Key>,
typename KeyEqual = std::equal_to<Key>,
typename Allocator = std::allocator<std::pair<const Key, Value>>
> class unordered_map;
Hash是用于计算Key的散列值的函数的函数对象或是函数指针的类型声明。KeyEqual是通过比较两个键的实例是否相等来解决散列冲突的函数的函数对象或是函数指针的类型声明。
如果Key是一个指针,那么Hash具有良好的定义。程序编译不会出错,而且看起来也能运行。但程序其实是错误的。std::hash会生成指针的值的散列值,而不是指针所指向的字符串的散列值。如果测试程序是从字符串数组初始化表,然后测试每个字符串是否能被找到,那么指向测试键的指针与指向初始化表的键的指针是同一个指针,因此程序看起来似乎可以正常工作。不过,如果在测试时使用用户另外输入的相同的字符串作为测试键,那么测试结果回收字符串并不在表中,因为指向测试字符串的指针与指向初始化表的键的指针不同。
我们可以通过提供一个非默认的散列函数替代模板的第三个参数的默认值来解决这个问题。就像对于map,这个参数可以是一个函数对象、lambda表达式声明或是非成员函数指针:
// function object for hashing a char* string
struct hash_c_string {
void hash_combine(size_t& seed, T const& v) {
seed ^= v + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
std::size_t operator() (char const* p) const {
size_t hash = 0;
for (; *p; ++p)
hash_combine(hash, *p);
return hash;
}
};
// 这种解决方法是不完整的,理由请往下看
std::unordered_map<char const*, unsigned, hash_c_string> table;
这里用到了Boost中的散列函数。如果标准库的实现符合C++14或是以后的标准,那么你也可以在标准库实现中找到这个函数。可惜的是Visual Studio 2010没有提供这个函数。
尽管这段代码没有编译错误,而且编译后的程序在小型表上也可以正常工作,但通过仔细测试发现这段代码仍然是错误的。问题出在std::unordered_map模板的 第四个参数KeyEqual上。这个参数的默认值是std::equal_to,一个使用==比较两个运算对象的函数对象。虽然指针定义了==运算符,但它比较的是指针在计算机内存空间中的顺序,而不是指针所指向的字符串。解决方式是提供另一个非默认的函数对象替代KeyEqual模板参数:
// function object for hashing a char* string
struct hash_c_string {
void hash_combine(size_t& seed, T const& v) {
seed ^= v + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
std::size_t operator() (char const* p) const {
size_t hash = 0;
for (; *p; ++p)
hash_combine(hash, *p);
return hash;
}
};
// function object compares two char* strings for equality
struct comp_c_string {
bool operator()(char const* p1, char const* p2) const {
return strcmp(p1,p2) == 0;
}
};
std::unordered_map<
char const*,
unsigned,
hash_c_string,
comp_c_string
> table;
这比基于std::string的散列表快了73%,但比基于char*和std::map的最佳实现值快了9%。而且它比使用std::lower_bound在存储键值对元素的简单静态数组上进行二分查找的算法要慢。
用自定义的散列表进行散列
要想适用于所有键,那么散列函数就必须足够通用。如果能够像示例程序中那样提前知道表中的键值,那么一个非常简单的散列函数可能就足够了。
在创建表时,对于给定的一组键不会产生冲突的散列称为完美散列。能够创建出无多余空间的表的散列称为最小散列。散列函数的圣杯是能够创建出无冲突、无多余空间的表的最小完美散列。当键的数量相当有限时,容易创建完美散列,甚至是完美最小散列。这时,散列函数可以尝试通过首字母、字母和以及键长来计算散列值。
unsigned hash(char const* key) {
if (key[0] < 'a' || key[0] > 'z')
return 0;
return (key[0]-'a');
}
kv* find_hash(kv* first, kv* last, char const* key) {
unsigned i = hash(key);
return strcmp(first[i].key, key) ? last : first + i;
}
最小完美散列函数通常都是很简单的函数。在互联网上有些论文讨论了在小型关键字集合上自动生成完美最小散列函数的各种方法。GNU计划(还有其他项目)构建了一个称为gperf(http://www.gnu.org/software/gperf/)的命令行工具 ,它所生成的完美散列函数通常也是最小散列函数。
斯特潘诺夫的抽象惩罚
线性查找在查找存在于表中的键时性能相对来说更好,因为线性查找一旦匹配到待查找的元素后会立即结束。二分查找则无论待查找的键是否在表中,进行比较的次数几乎都是相同的。
VS2010正式版,i7,1ms迭代 | 较上一个版本提高的百分比 | 较上一个种类提高的百分比 | |
---|---|---|---|
map<string> | 2307 | ||
map<char*>非成员函数 | 1453 | 59% | 59% |
map<char*>函数对象 | 820 | 77% | 181% |
map<char*> lambda | 820 | 0% | 181% |
std::find() | 1425 | ||
std::equal_range() | 1806 | ||
std::lower_bound | 973 | 53% | 86% |
fing_binary_3way() | 771 | 26% | 134% |
std::unordered_map() | 509 | ||
find_hash() | 195 | 161% | 161% |
二分查找比线性查找更快,散列查找比二分查找更快。
C++标准库提供了一组直接可用且经过调试的算法和数据结构,它们能够适用于许多情况。C++标准定义了最差情况下的大O时间开销,来证明这些算法和数据结构是能够被广泛使用的。
但是使用标准库的这种及其强大和通用的机制是有开销的。即使标准库算法具有优秀的性能,它也往往无法与最佳手工编码的算法匹敌。这可能是因为模板代码中的缺点或是编译器设计中的缺点,抑或是因为标准库代码需要能够工作于通用情况下。这种开销可能会导致开发人员不得不自己去编写那些确实非常重要的查找算法。
这个存在于标准算法和手工编写的优秀算法之间的鸿沟被称为“斯特潘诺夫的抽象惩罚”,在亚历山大·斯特潘诺夫设计出了初始版本的标准库算法和容器类后,一度没有编译器能够编译它们。相对于手动编码的解决方案,斯特潘诺夫的抽象惩罚是通用解决方案无法避免的开销,它也是使用C++标准库算法这样的能够提高生产力的工具的代价。这并非一件坏事,但却是当开发人员需要提高程序性能时必须注意的事情。
使用C++标准库优化排序
在能够使用分而治之算法高效地进行查找之前,我们必须先对序列容器排序。C++标准库提供了另种能够高效地对序列容器进行排序的标准算法——std::sort()和std::stable_sort()。
尽管C++标准并没有明确指定使用了哪种排序算法,但它的定义允许使用快速排序的某个变种实现std::sort以及可以使用归并排序实现std::stable_sort()。C++03要求std::sort的平均性能达到O(nlogn)。符合C++03标准的实现方式通常都会用快速排序实现std::sort,而且通常都会使用一些折中技巧来降低快速排序发生最差情况的O(n2)时间开销的几率。C++11要求最差情况性能为O(nlogn)。符合C++11标准的实现方式通常都是Timsort或内省排序等混合排序。
std::stable_sort()通常都是归并排序的变种。C++标准中的措辞比较奇怪,它指出如果能够分配足够的额外内存,那么std::stable_sort()的时间开销是O(nlogn),否则它的时间开销是O(n(logn)2)。如果递归深度不是太深,典型的实现方式是使用归并排序;而如果递归深度太深,那么典型的实现方式是堆排序。
稳定排序的价值是程序能够按照若干个条件对某个范围内的记录进行排序,并先将记录按照第二个条件进行排序,然后在这个基础上按照第一个条件排序。只有稳定排序具有这个特性。这个额外的特性证明有两种排序是合理的。
std::vector,100000个元素,VS2010正式版,i7 | 时间(ms) |
---|---|
std::sort() vector | 18.61 |
std::sort() 已排序的vector | 3.77 |
std::stable_sort() vector | 16.08 |
std::stable_sort() 已排序的vector | 5.01 |
序列容器std::list只提供双向迭代器。因此,在一个list上,std::sort()的时间开销是O(n2)。std::list提供了一个具有O(nlogn)时间开销的成员函数sort()。
C++标准库<algorithm>头文件包含各种排序算法,我们可以使用这些算法为那些具有额外特殊属性的输入数据定制更加复杂的排序。
- std::heap_sort将一个具有堆属性的范围转换为一个有序范围。不是稳定排序。
- std::partition会执行快速排序的基本操作。
- std::merge会执行归并排序的基本操作。
- 各种序列容器的insert成员函数会执行插入排序的基本操作。
小结
- C++的混合特性为我们提供了多种实现方式,一方面我们可以实现性能管理的全自动化,另一方面也可以对性能逐渐地进行精准控制。正是这些选择方式使得我们可以优化C++程序以满足性能需求。
- 在大多数活动中都会有足够多的组件值得优化,而试图在脑海中记住它们是不可靠的。好记性不如烂笔头,将它们记录在纸上更好。
- 在一项查找一个由26个键的表的性能测试中,以字符串为键的std::unordered_map只比以字符串为键的std::map快了52%。大家都在炒作std::unordered_map在散列性能上战胜了std::map,但实际的测试结果却令人吃惊。
- 斯特潘诺夫的抽象惩罚是使用C++标准库算法这样的能够提高生产力的工具的代价。