目录
算法概览
算法分析与复杂度标识O()
STL算法总览
质变算法mutating algorithms----会改变操作对象之值
非质变算法nonmutating algorithms----不改变操作对象之值
STL算法的一般形式
算法的泛化过程
算法概览
算法,问题之解法也。
以有限的步骤,解决逻辑或数学上的问题,这一专门科目我们称为算法(Algorithms)。大学信息相关教育里面,与编程最有关直接关系的科目,首推算法与数据结构(Data Structures,亦即STL中的容器)。STL算法即是将最被运用的算法规范出来,其涵盖区间有可能在每五年一次的c++标准委员会中不断增订。
广义而言,我们所写的每个程序都是一个算法,其中的每个函数也都是一个算法,毕竟它们都用来解决或大或小的逻辑问题或数学问题。唯有用来解决特定问题(例如排序,查找,最短路径,三点共线。。。),并且获得数学上的效能分析与证明,这样的算法才具有可复用性。本章讨论的便是被收录于STL之中,极具复用价值的70余个STL算法,包括赫赫有名的排序(sorting),查找(searching),排列组合(permutation)算法,以及用于数据移动,复制,删除,比较,组合,运算等算法。
特定的算法往往搭配特定的数据结构。例如binary search tree(二叉查找树)和RB-tree(红黑树)便是为了解决查找问题而发展出来的特殊数据结构,hashtable拥有快速查找的能力。又例如max-heap(或min-heap)可以协助完成所谓的heap sort(堆排序)。几乎可以说,特定的数据结构是为了实现某种特定的算法。这一类"与特定数据结构相关"的算法,被本书(及STL)归类为“关联式容器”(associated containers)之列。本章所讨论的,是可施行于“无太多特殊条件限制”之空间中的某一段元素区间的算法。
算法分析与复杂度标识O()
当我们发现(发明)一个可以解决问题的算法时,下一个重要步骤就是决定该算法所耗用的资源,包括空间和时间。这个操作称为算法分析(algorithm analysis)。可以这么说,如果一个算法得耗用数GB的内存空间才能获得令人满意的效率,这种算法没有用--至少在目前的计算机架构下没有使用价值。
一般而言,算法的执行时间和其所要处理的数据量有关,两者之间存在某种函数关系,可能是一次(线性linear),二次(quadratic),三次(cubic)或对数(logarithm)关系。当数据量小时,多项式函数中的每一项都可能对结果带来相当程度的影响,但是当数据量够大(这是我们应该关注的情况)时,只有最高次方的项目才具主导地位。
下面是三个复杂度各异的问题
- 1.最小元素问题:求取array中的最小元素
- 2.最短距离问题:求取X-Y平面上N个点中,距离最近的两个点
- 3.三点共线问题:决定X-Y平面上的N个点,是否有任何三点共线。
最小元素问题的解法一定必须两两元素比对,逐一进行。N个元素需要N次比对,所以数据量和执行时间呈线性关系。“最短距离”问题所需计算的元素对(pairs)共有N(N-1)/2!,所以大数量和执行时间呈二次关系。“三点共线”问题要计算的元素对共有N(N-1)(N-2)/3!,所以大数据量和执行时间呈三次关系。
上述三种复杂度,以所谓BIG-Oh标记法表示为.这种标记法的定义如下:
如果有任何正值常数c和,使得时,,那么我们便可将T(N)的复杂度标识为。
以下三个问题出现一种新的复杂度形式:
- 4.需要多少bits才能表现出N个连续整数?
- 5.从X=1开始,每次将X扩充两倍,需要多少次扩充才能使?
- 6.从X=N开始,每次将X缩减一半,需要多少次缩减才能使?
就问题4而言,B个bits可表现出个不同的整数,因此欲表现N个连续整数,需满足方程式,亦即。
问题5称为"持续加倍问题",必须满足方程式,此式同问题4,因此解答相同。问题6称为“程序减半问题”,与问题5意义相同,只不过方向相反,因此解答相同。
如果有一个算法,花费固定时间(常数时间,O(1))将问题的规模降低某个固定比例(通常是1/2),基于上述问题6的解答,我们便说此算法的复杂度是。注意问题规模的降低比例如何,并不会带来影响,因此它会反应在对数的底上,而底对于Big-Oh标记法是没有影响。
算法复杂度,可以作为我们衡量算法效率的标准。
STL算法总览
STL算法,主要包括查找,填充,排序,排序判断;算法名称包括accumulate,binary_search, fill,find_if,max, min, sort, remove,reverse, rotate等算法。
质变算法mutating algorithms----会改变操作对象之值
所有的STL算法都作用在有迭代器[first, last)所标示出来的区间上,所谓"质变算法",是指运算过程中会更改区间内(迭代器所指)的元素内容。诸如拷贝(copy),互换(swap),替换(replace),填写(fill), 删除(remove),排列组合(permutation),分割(partition),随机重排(random shuffling)、排序(sort)等算法,都属此类。如果将此类算法运用于常数区间上,例如:
int ia[] = {22, 30, 30, 17, 33, 40, 17, 23, 22, 12, 20};
vector<int> iv(ia, ia + sizeof(ia) / sizeof(int));vector<int>::const_iterator cite1 = iv.begin();
vector<int>::const_iterator cite2 = iv.end();sort(cit1, cit2);
非质变算法nonmutating algorithms----不改变操作对象之值
所有的STL算法都作用在由迭代器[first, last)所标示出来的区间上。所以非质变算法,是指运算过程中不会更改区间内(迭代器所指)的元素内容。诸如查找(find),匹配(search),计数(count),巡访(for_each),比较(match,mismatch),寻找极值(max,min)等算法,都属此类。但是如果你在for_each(巡访每个元素)算法上应用了一个会改变元素的仿函数(functor),例如
template<class T>
struct plus2 {void operator()(T&x) const {x += 2;}
};int ia[] = {22, 30, 30, 17, 33, 40, 17, 23, 22, 12, 20};
vector<int> iv(ia, ia + sizeof(ia) / sizeof(int));for_each(iv.begin(), iv.end(), plus2<int>());
那么当然元素会被改变。
STL算法的一般形式
所有泛型算法的前两个参数都是一对迭代器(iterators),通常称为first和last,用以标示算法的操作区间。STL习惯采用前闭后开标识法,写成[first,last),表示区间涵盖first至last(不含last)之间的所有元素。当first==last时,上述所表现的便是一个空区间。
这个[first, last)区间的必要条件是,必须能够经由increment(累加)操作符的反复运用,从first到last。编译器本身无法强求这一点。如果这个条件不成立,会导致未可预期的结果。
根据行进特性,迭代器可分为5类。每个STL算法的声明,都表现出它所需要的最低程度的迭代器类型。例如find()需要一个InputIterator,这是它的最低要求,但它也可以接受更高类型的迭代器,如ForwardIterator,BidirectionalIterator或RandomAccessIterator,因为无论是ForwardIterator,BidirectionalIterator或RandomAccessIterator也都是一种InputIterator。但如果你交给find()一个OutputIterator,会导致错误。
将无效的迭代器传给某个算法,虽然是一个错误,却不保证能够在编译器就被捕捉出来,因为所谓迭代器类型并不是真实的型别,它们只是function template的一种型别参数。
许多STL算法不知支持一个版本。这一类算法的某个版本采用缺省运算行为,另一个版本提供额外参数,接受外界传入一个仿函数(functor),以便采用其他策略。例如unique()缺省情况下使用equality操作符来比较两个相邻元素,但如果这些元素的型别并未供应equality操作符,或用户希望定义自己的equality操作符,便可以传一个仿函数给另一个版本的unique()。有些算法干脆将两个版本命名为两个不同名称的实体,附从的那个总是以_if作为尾词,例如find_if(),另一个例子是replace(),使用内建的equality操作符进行比对操作,replace_if则以接收到的仿函数进行比对行为。
质变算法通常提供两个版本:一个是in-place(就地进行)版本,就地改变其操作对象;另一个是copy(另地进行)版,将操作对象的内容复制一份副本,然后再副本上进行修改并返回该副本。copy版本总是以_copy作为函数名称尾词,例如replace和replace_copy()。并不是所有的质变算法都有copy版,例如sort()就没有。如果我们希望以这类"无copy版本"之质变算法施行于某段区间的元素的副本上,我们必须自行制作并传递那一份副本。
所有的数值算法,包括adjacent_difference(), accumulate(), inner_product, partial_sum等等,都实现与SGI<stl_numeric.h>之中,这是个内部文件,STL规定用户必须包含的是上层的<numeric>.其中STL算法都实现于SGI的<stl_algo.h>和<stl_algobase.h>文件中,夜都市内部文件,欲使用这些算法,必须包含上层相关头文件<algorithm>
算法的泛化过程
将一个叙述完整的算法转化为程序代码,是任何训练有素的程序员胜任愉快的工作。这些工作由的极其简单(例如循序查找),有的稍微复杂(例如快速排序法),有的十分繁复 (例如红黑树之建立与元素存取),但基本上都不应该形成任何难以跨越的障碍。
然而,如何将算法独立于其所处理的数据结构之外,不受数据结构的羁绊,思想层面就不是那么简答了。如何设计一个算法,是他适用于任何(或大多数)数据结构呢?换个说法,我们如何在即将处理的未知的数据结构(也许是array, 也许是vector,也许是list,也是是deque...)上,正确实现所有操作呢?
关键在于,只要把操作对象的型别加以抽象化,把操作对象的标示法和区间目标的移动行为抽象化,整个算法也就在一个抽象层面上工作了。整个过程称为算法的泛型化(generalized),简称泛化。
让我们看看算法泛化的一个实例。以简单的循序查找为例,假设我们要写一个find函数,在array中寻找特定值。面对整数array,我们的直觉反应是:
int* find(int *arrayHead, int arraySize, int value) {int I = 0;for (I =0; I<arraySize; ++I) {if (arrayHead[I] == value) break;}return &(arrayHead[I])
}
该函数在某个区间内查找value。返回一个指针,指向它所找到的第一个符合条件的元素;如果没有找到,就返回最后一个元素的下一个位置(地址)。
“最后元素的下一个未知”成为end.返回end以表示"查找无结果"似乎是个可笑的做法,为什么不返回null?因为,一如稍后即将看到的,end指针可以对其他种类的容器带来泛型效果,这是null所无法达到的。是的,从小我们被教导,使用array时千万不要超越其区间,但事实上一个指向array元素的指针,不但可以合法指向array内的任何未知,也可以指向array尾端以外的任何位置。只不过当指针指向array尾端以外的位置时,它只能用来与其他array指针相比较,不能提领(dereference)其值。现在,我们可以这样使用find函数。
const int arraySize = 7;
int ia[arraySize] = {0, 1, 2, 3, 4, 5, 6};
int *end = ia + arraySize;int *ip = find(ia, arraySize, 4);
if (ip == end) {cout << "4 not found" << endl;
} else {cout << "4 found. " << *ip << endl;
}
上述find的做法暴露了容器太多的细节(例如arraySize),也因此太过依附特定容器。为了让find适用于所有类型的容器,其操作应该更抽象化些。让find接受连个指针作为参数,标识出一个操作区间,就是很好的做法:
int* find(int* begin, int* end, int value) {while(begin != end && *begin != value) {++begin;}return begin;
}
这个函数在"前闭后开"区间[begin, end)内查找value,并返回一个指针,指向它所找到的第一个符合条件的元素;如果没找到就返回end。现在,你可以这样使用find函数:
const int arraySize = 7;
int ia[arraySize] = {0, 1, 2, 3, 4, 5, 6};
int *end = ia + arraySize;int *ip = find(ia, end, 4);
if (ip == end) {cout << "4 not found" << endl;
} else {cout << "4 found. " << *ip << endl;
}
find函数也可以很方便地用来查找array的子区间
int*p = find(ia +2, ia+5, 3);
if (ip == end)cout << "3 not found" << endl;
else cout << "3 found " << *p << endl;
由于find()函数之内并无任何操作是针对特定的整数array而发的,所以我们可以将它改为template
template <class T>
T* find(T* begin, T* end, const T& value) {while(begin != end && *begin != value) {++begin;}return begin;
}
请注意数值的传递由pass-by-value改为了pass-by-reference-const,因为如今所传递的value,其型别可为任意;于是对象一大,传递成本便会提升,这是我们不愿见到的。pass-by-reference可完全避免这些成本。
这样find很好,几乎使用与任何容器---只要该容器允许指针指入,而指针们又都支持一下四种find()函数中出现的操作行为:
- inequality 判断不相等操作符
- dereferencelm 提领、取值操作符
- prefix increment 前置式递增操作符
- copy 复制行为
C++有一个极大的优点便是,几乎所有东西都可以改写为程序员自定义的形式或行为。是的,上述这些操作符或操作行为都可以被重载(overloaded),既事如此,何必将find限制为只能使用指针呢?何不让支持以上四种行为的、行为很像指针的某种对象都可以被find()使用呢?如此一来,find()函数便可以从原生(native)指针的思想框框中跳脱出来。如果我们以一个原生指针指向某个list,则该指针进行"++"操作并不能使指针指向下一个串行节点。但如果我们设计一个class,拥有原生指针,并使其"++"操作指向list的下一个节点,那么find就可以施行于list容器身上了。
这便是迭代器(iterator)的观念。迭代器是一种行为类似指针的对象,换句话说,是一种smart pointers.现在我们将find()函数内的指针以迭代器取代,重新写过
template <class Iterator, class T>
Iterator find(Iterator begin, Iterator end, const T& value) {while(begin != end && *begin != value) {++begin;}return begin;
}
这便是一个完全泛化话的find()函数。我们可以再任何c++标准库的头文件里看到它,长相几乎一模一样。SGISTL把它放在<stl_algo.h>之中。
有了这样的观念准备,后续看到的STL的各种泛型算法,将随处可见迭代器及数据作为泛型参数作为算法的输入参数。
参考文档《STL源码剖析》--侯捷