第12章 算法
最后更新于
最后更新于
若无必要,勿增实体。
—— 威廉·奥卡姆
如果单打独斗,vector
和list
这些数据结构的用途颇为有限。
使用时,我们需要添加和删除元素这些基本操作(就像list
和vector
实现的那样)。
不过,使用容器仅仅存储对象的情况寥寥无几。
而是还要排序、打印、提取子集、移除元素、查找对象等等。
相应地,标准库里除了最常见的容器类型之外,还为这些容器提供了最常见的算法。
例如,我们可以简单高效地把持有Entry
的vector
进行排序,
并为vector
中每个不重复的元素在list
中创建副本:
要让这段代码运行,必须为Entry
定义小于(<
)和等于(==
)操作。例如:
标准算法以元素的(半开)序列的方式表示。 一个 序列(sequence) 以指向首元素和尾元素后位置的一对迭代器表示:
在本例中,sort()
为vec.begin()
和vec.end()
定义的 迭代器对 定义的序列排序,该 迭代器对 恰好包括了vector
的全部元素。
对于写(输出)操作,仅需指定待写入的首元素位置。
如果有不止一个元素输出,起始元素后的元素会被覆盖。
就是说,要避免出错,lst
的元素数量至少跟vec
中不重复值的数量一样多。
如果要把不重复元素放进一个新(空的)容器里,应该这么写:
back_inserter(res)
这个调用为res
在容器末尾构造一个迭代器,
并且用它添加这些元素,为它们扩展容器以便提供存储空间。
这样我们就盛事了,不必去先分配一块固定容量的空间,而后再填充它。
于是,标准容器加上back_inserter()
消灭了对realloc()
——这个易出错的显式C-风格内存管理——的需求。
标准库容器list
有个转移构造函数(§5.2.2),
它可以令res
的传值返回变得高效(哪怕是装载着成千上万元素的list
)。
如果你觉得这个 迭代器对 风格的代码
——例如sort(vec.begin(),vec.end())
——繁琐,
还可以定义容器版本的算法,然后这么写sort(vec)
(§12.8)。
对于某个容器,有几个指向特定元素的迭代器可以获取;begin()
和end()
就是这种例子。
另外,很多算法也会返回迭代器。
例如,标准算法find()
在某个序列中查找一个值并返回指向该元素的迭代器:
与很多标准库查找算法相似,find()
返回end()
以表示“未找到(not found)”。
一个等价且更简短的has_c()
定义是:
一个更有意思的练习是找到一个字符在某字符串中出现的所有位置。
我们可以返回以承载 string
迭代器 的vector
,以表示的出现位置集合。
返回vector
是高效的,因为它提供了转移语意(§5.2.1)。
如果我们想对找到的位置进行修改,就需要传入 非-const
字符串:
我们用一个常规的循环在这个字符串中进行遍历,
借助++
每次把迭代器p
向容器尾部移动一个元素,
并借助解引用操作符*
查看这些元素。可以这样测试find_all()
:
上面的find_all()
调用可图示如下:
对于每个合乎情理的使用情形而言,迭代器和标准算法在所有标准容器上的应用都是等效的。
因此,可以这样泛化find_all()
:
为了让编译器知道C
的iterator
应该被推断为类型而非一个值,比方说整数7
,
那个typename
是必须的。
可以为iterator
引入一个类型别名(§6.4.2)以隐藏这个实现细节:
现在可以这样写:
迭代器的作用是分离算法和容器。
算法通过迭代器操作数据,并对元素所在的容器一无所知。
反之,容器对操作元素的算法也是不知所以;
它所做的不过是按需提供迭代器(即begin()
和end()
)而已。
这种把数据存储和算法分离的模型催生出了泛化且灵活的软件。
到底什么是迭代器?任何迭代器都是某种类型的一个对象。
只不过,有着很多种不同的迭代器类型,
因为一个迭代器需要为特定的容器类型保存作业所需的信息。
这些迭代器类型可以像容器那般多种多样,还可以按实际情况进行特化。
例如,vector
的迭代器可以是普通的指针,
因为需要指向vector
元素的时候,指针是个相当合理的的方式:
或者,vector
迭代器也可以实现为指向vector
的指针外加一个索引:
使用这种迭代器可以进行越界检查。
list
迭代器不得不比指向元素的指针更复杂一些,
因为一般来说list
的元素并不知道该list
中下一个元素在哪儿。
因此,list
的迭代器有可能是个指向节点的指针:
所有迭代器共通的部分是它们的语意和操作的命名。
例如,对任何迭代器应用++
都会得到指向指向下一个元素的迭代器。
类似地,用*
可以得到该迭代器指向的元素。
实际上,任何对象,只要符合几个诸如此类的简单规则就是一个迭代器
—— 迭代器(Iterator) 是个概束(§7.2,§12.7)。
另外,用户极少需要知道具体迭代器的类型;每个容器都“知道”它自己迭代器类型,
并按惯例以iterator
和const_iterator
为名称提供它们。
例如,list<Entry>::iterator
就是list<Entry>
的通用迭代器类型。
我们几乎没必要操心该类型定义的细节。
在处理容器中的元素序列时,迭代器是个通用且便利的概念。 但是,容器并非元素序列栖身的唯一所在。 例如,一个输入流产生一个值序列,另外,我们会向输出流写入值序列。 所以,迭代器的概念在输入和输出方面的应用也颇为有益。
要得到一个ostream_iterator
,需要指定使用的流以及待写入对象的类型。
例如:
给*oo
赋值待结果就是把该值写入到cout
。例如:
这是另一种将规范化消息写向标准输出的方式。++oo
模拟了利用指针向数组写入的行为。
类似地,istream_iterator
允许我们把一个输入流作为只读容器使用。
同样,还是要指定使用的流和待读取对象的类型:
输入迭代器通常成对出现来表示一个序列,
因此还需要提供一个istream_iterator
以表示输入的末尾。
这就是默认的istream_iterator
:
一般来说,istream_iterator
和ostream_iterator
不会直接拿来就用。
而是会作为参数传递给算法去使用。
例如,可以写个简单的程序读取文件,把读到的单词排序、去重,
然后把结果写入到另一个文件:
ifstream
是个可附着到文件上的istream
,
而一个ofstream
是个可以附着到文件的ostream
(§10.7)。ostream_iterator
的第二个参数用于分隔输出值。
实际上,此程序没必要写这么长。
我们将字符串读取到vector
,然后给它们sort()
,继而去重再输出。
更优雅的方案是根本不存储重复值。
要做到这一点,可以把string
存储在set
中,set
不会保存重复的值,并且会维持元素的顺序(§11.4)。
这样,可以把使用vector
的两行代码以使用set
的一行取代,
并使用更简单的copy()
取代unique_copy()
:
ii
、eos
和oo
都只用了一次,因此可以进一步缩减程序的代码量:
至于最后一步简化是否提高可读性,取决于个人偏好和经验。
到目前为止,例子中的算法对序列中的元素执行简单的“内建(built in)”操作。
但是,我们经常需要把这个操作作为参数传给算法。
比方说,算法find
(§12.2,§12.6)提供了便捷的方式查找特定的值。
有个更通用的变体可以查找一个符合特定条件—— 谓词(predicate) ——的元素。
例如,我们可能需要在一个map
里查找第一个大于42
的值。map
对其元素以 (key,value) 对的序列的方式提供访问,
因此,可以在map<string,int>
查找一个其int
大于42
的pair<const string,int>
:
此处,Greater_than
是个函数对象(§6.3.2)持有42
以便用于比对操作:
此外,还可以使用lambda表达式(§6.3.2):
谓词不能对其访问的元素进行修改。
算法的通用定义是 “由规则组成的有限集合,这些规则为解决一组特定问题规定一系列操作,(并)具有五个重要特性: 有限性……确定性……输入……输出……高效性” [Knuth,1968,§1.1]。 在C++标准库的语境里,算法是一个对元素序列执行操作的函数模板。
标准库提供了数十种算法。这些算法定义在命名空间std
中,
呈现在<algorithm>
头文件里。
这些标准库算法全都以序列作为输入。
一个从b
到e
的半开区间序列表示为[b
:e
)。
以下是几个范例:
部分标准库算法 <algorithm>
f=for_each(b,e,f)
为[b
:e
)中的每个元素执行f(x)
p=find(b,e,x)
p
是[b
:e
)中第一个满足*p==x
的p
p=find_if(b,e,f)
p
是[b
:e
)中第一个满足f(*p)
的p
n=count(b,e,x)
n
是[b
:e
)中满足 *q==x
的元素* q
的数量
n=count_if(b,e,f)
n
是[b
:e
)中满足f(*q)
的元素* q
的数量
replace(b,e,v,v2)
在[b
:e
)中用v2
替换满足 *q==v
的元素* q
replace_if(b,e,f,v2)
在[b
:e
)中用v2
替换满足f(*q)
的元素* q
p=copy(b,e,out)
从[b
:e
)复制到[out
:p
)
p=copy_if(b,e,out,f)
从[b
:e
)复制满足f(*q)
的元素* q
到[out
:p
)
p=move(b,e,out)
从[b
:e
)移动到[out
:p
)
p=unique_copy(b,e,out)
从[b
:e
)复制到[out
:p
);相邻的重复元素不复制
sort(b,e)
以<
作为排序依据,对[b
:e
)中的元素进行排序
sort(b,e,f)
以f
作为排序依据,对[b
:e
)中的元素进行排序
(p1,p2)=equal_range(b,e,v)
[p1
:p2
)是有序序列[b
:e
)中值为v
的子序列;大体上就是针对v
的二分查找
部分标准库算法 <algorithm>(续表)
p=merge(b,e,b2,e2,out)
把[b
:e
)和[b2
:e2
)两个有序序列和并进[out
:p
)
p=merge(b,e,b2,e2,out,f)
把[b
:e
)和[b2
:e2
)两个有序序列和并进[out
:p
),以f
作为比对依据
此处和许多其它的算法(见 §14.3),可应用于容器、string
以及内建数组的元素。
有些算法,例如replace()
和sort()
,修改元素值,
但不存在将容器元素增加或减少的算法。
原因是,一个序列并不知道持有此元素序列的是什么容器。
要增加或删除元素,你需要某个了解该容器的事物(比方 back_inserter
;§12.1)
或者直接在容器上进行操作(即 push_back()
或erase()
;§11.2)。
Lambda表达式经常作为操作以参数的形式传递,例如:
相较于手工书写的循环,标准库算法通常更谨慎、更有针对性地进行设计和实现, 因此,请了解并使用它们,以避免重复造轮子。
在C++20中,标准库算法会被指定概束(第7章)。
相关的初期准备工作请参考 Ranges Technical Specification[RangesTS]。
其具体实现可以在互联网上找到。
对于C++20,区间这个概束定义在<ranges>
中。
Range
是针对 通过begin()
/end()
定义的C++98序列 的一个泛化。Range
是个指定元素序列概念的概束。它的定义包括:
一个迭代器的{begin,end}
对
一个{begin,n}
对,其中begin
是个迭代器,n
是元素数量
一个begin,pred
对,其中begin
是个迭代器,pred
是个谓词;
如果对于迭代器p
来说,pred(p)
为true
,
我们就到达了序列的末尾。
这给了我们数不清的序列,并且序列可以“随时按需(on the fly)”生成。
Range
这个概束让我们可以用sort(v)
取代sort(v.begin(),v.end())
,
后者是STL自1994年开始的使用方式。例如:
Sortable
的关系默认是less
。
一般来说,在标准库算法要求用一对迭代器表示某个序列的地方,
C++20就允许使用一个Range
作为简化的替代写法。
除Range
之外,C++20还提供许多便利的概束。
这些概束定义在头文件<ranges>
、<iterator>
和concepts
中。
核心语言概束<concepts>
Same<T,U>
T
和U
是相同的类型
DerivedFrom<T,U>
T
继承自U
ConvertibleTo<T,U>
一个T
可以转化成一个U
CommonReference<T,U>
T
和U
的共通引用类型相同
Common<T,U>
T
和U
的共通类型相同
Integral<T>
T
是个整数类型
SignedIntegral<T>
T
是个有符号整数类型
UnsignedIntegral<T>
T
是个无符号整数类型
Assignable<T,U>
U
可以赋值给T
SwappableWith<T,U>
T
和U
可以被std:swap()
Swappable<T>
SwappableWith<T,T>
对于某些算法,需要在应用于多个相关类型的时候具备数学上的合理性,Common
在定义这些算法的时候就很重要。Common<T,U>
是指某个类型C
,可以把T
和U
都先转换成C
进行比对。
例如,当我们可能想要把std::string
跟C-风格字符串(char*
),
或者把int
跟double
进行比对,但不会把std::string
和int
进行比对。
在用于定义Common
时,为确定common_type_t
的特化,适宜的方式为:
Common
的定义略有点棘手,但解决了一个很难的基本问题。
幸运的是,除非需要进行操作的混合类型在库中(尚)无适当的定义,
我们无需为common_type_t
定义一个特化。
在定义那些需要对不同的类型做比对的概束和算法时,
多数都用到了Common
或CommonReference
。
与比对相关的概束受到了来自 [Stepanov,2009] 的重要影响:
比对相关的概束<concepts>
Boolean<T>
T
可用作布尔类型(Boolean)
WeaklyEqualityComparableWith<T,U>
T
与U
可使用==
和!=
进行相等性比对
WeaklyEqualityComparable<T>
WeaklyEqualityComparableWith<T,T>
EqualityComparableWith<T,U>
T
和U
可使用==
做等价性比对
EqualityComparable<T>
EqualityComparableWith<T,T>
StrictTotallyOrderedWith<T,U>
T
和U
可使用<
、<=
、>
和>=
进行比对,得出全序关系
StrictTotallyOrdered<T>
StrictTotallyOrderedWith<T,T>
WeaklyEqualityComparableWith
和WeaklyEqualityComparable
二者的使用,
揭示了(到目前为止一直都)被忽视的重载机会。
对象概束<concepts>
Destructible<T>
T
可被销毁且可用一元的&
获取其地址
Constructible<T,Args>
T
可通过一个Args
类型的参数列表构造
DefaultConstructible<T>
T
有默认构造函数
MoveConstructible<T>
T
有转移构造函数
CopyConstructible<T>
T
有拷贝构造函数和转移构造函数
Movable<T>
MoveConstructible<T>
、Assignable<T&,T>
和Swapable<T>
Copyable<T>
CopyConstructable<T>
、Movable<T>
和Assignable<T,const T&>
Semiregular<T>
Copyable<T>
和DefaultConstructable<T>
Regular<T>
SemiRegular<T>
和EqualityComparable<T>
Regular
是类型的理想状态。Regular
类型用起来大体和int
差不多,
并且在某个类型的具体应用(§7.2)方面省却了许多操心。
类中默认==
的缺失,意味着多数类只能以SemiRegular
的形式面世,
尽管它们中的多数都本可以并应该成为Regular
。
可调用概束<concepts>
Invocable<F,Args>
F
可通过一个Args
类型的参数列表调用
InvocableRegular<F,Args>
Predicate<F,Args>
F
可通过一个Args
类型的参数列表调用,返回bool值
Relation<F,T,U>
Predicate<F,T,U>
StrictWeakOrder<F,T,U>
对于某个函数f()
,如果x==y
可导致f(x)==f(y)
,
则该函数是 维持等同性(equality preserving) 的。
严格弱序(strict weak ordering)是标准库针对顺序比对通常的假设,比如<
;
如果你觉得有必要了解就查一下(或者点击表格中该名称的链接——译者)。
Relation
和StrictWeakOrder
仅在语意上有所差别。
我们(目前)还无法在代码层面表示这一差异,因此这两个命名仅体现了我们的意图。
迭代器概束<iterators>
Iterator<I>
I
可被++
自增或*
解引用
Sentinel<S,I>
SizedSentinel<S,I>
S
是个哨兵,且可以用-
运算符和I
运算(即减法运算s-i
——译者)
InputIterator<I>
I
是个输入迭代器,*
可用于只读操作
OutputIterator<I>
I
是个输出迭代器,*
可用于只写操作
ForwardIterator<I>
BidirectionalIterator<I>
I
是个ForwardIterator
,支持--
RandomAccessIterator<I>
I
是个BidirectionalIterator
,
支持+
、-
、+=
、-=
和[]
Permutable<I>
I
是个ForwardIterator
,
并且I
支持移动和交换元素
Mergeable<I1,I2,R,O>
可以按Relation<R>
把有序序列I2
和I2
合并入O
Sortable<I>
可以按less
把承载I
的序列进行排序
Sortable<I,R>
可以按Relation<R>
把承载I
的序列进行排序
对于给定的算法,迭代器的不同类型(分类)可用于选择最优的方式;
见 §7.2.2 和 §13.9.1。对于InputIterator
的范例,请参见 §12.4。
哨兵的基本思路是这样的:我们针对某个区间进行迭代,
该区间始自一个迭代器,直到谓词对于某个元素为 true 终止。
这样,一个迭代器p
和一个哨兵s
就定义了一个区间[p:s(*p)]
。
例如,为了遍历以指针作为迭代器的C-风格字符串,
可以为其哨兵定义一个谓词:
与C++20中的定义相比,此处Mergeable
和Sortable
的介绍进行了简化。
区间概束<ranges>
Range<R>
R
是个区间,由一个起始迭代器和一个哨兵界定
SizedRange<R>
R
是个区间,其size可在常数时间内获知
View<R>
R
是个区间,其复制、移动和赋值的执行是常数时间
BoundedRange<R>
R
是个区间,其迭代器和哨兵的类型一致
InputRange<R>
R
是个区间,其迭代器类型符合 InputIterator 的要求
OutputRange<R>
R
是个区间,其迭代器类型符合 OutputIterator 的要求
ForwardRange<R>
R
是个区间,其迭代器类型符合 ForwardIterator 的要求
BidirectionalRange<R>
R
是个区间,其迭代器类型符合 BidirectionalIterator 的要求
RandomAccessRange<R>
R
是个区间,其迭代器类型符合 RandomAccessIterator 的要求
<ranges>
里还有几个其它的概束,但此表也够入门的了。
在等不及 Range 概束的情况下,可以定义我们自己的简化版区间算法。
例如,提供sort(v)
这种简短写法取代sort(v.begin(),v.end())
可谓易如反掌:
我把容器版本的sort()
(和其它算法)置于独有的命名空间Estd
(“extended std
”)中,以免跟其他程序员对命名空间std
的使用产生冲突,
同时还便于将来用Range
版本取代这个权宜之计。
当对大量数据项执行同样的操作时,只要针对不同数据项的运算相互独立, 就能够以并行的方式去执行:
并行执行(parallel execution) : 任务在多个线程上执行(通常运行在多个处理器核心上)
向量化执行(vectorized execution) : 任务在单一线程上以向量化(vectorization)方式执行, 也称作 SIMD(“Single Instruction, Multiple Data”) 。
标准库对这两种都提供支持,还可以指定顺序执行,在<execution>
中有:
seq
:顺序执行
par
:(在可行的情况下)并行执行
par_unseq
:(在可行的情况下)并行执行 和/或 非顺序(向量化)执行。
以std::sort()
为例:
至于并行执行 和/或 向量化执行是否划算,要取决于算法、序列中元素的数量、硬件, 以及运行该程序的机器的利用率等等。 因此 执行策略标志(execution policy indicators) 仅仅是个示意(hint)。 编译器 和/或 运行时调度器将决定在多大程度上采用并发。 这并非无关紧要的小事,此外,“切勿未经测试就对性能下断言”的规则在此处尤为重要。
绝大多数标准库算法,包括 §12.6 表中除equal_range
外的全部,
都能像sort()
使用par
和par_unseq
那样被并行化和向量化。
为什么equal_range()
不行呢?因为到目前为止,它尚无有益的并行算法。
很多并行算法主要用于数值数据;参见 §14.3.1。
采用并行执行时,请确保避免数据竞争(§15.2)和死锁(§15.5)。
[1] STL 算法可操作一个或多个序列;§12.1。
[2] 输入序列是由一对迭代器定义的半开区间;§12.1。
[3] 在进行查找时,算法通常返回输入序列的结尾以表示“未找到”;§12.2。
[4] 算法不会直接在其参数序列中增添或删减元素;§12.2,§12.6。
[5] 写循环时,考虑一下能否以一个通用算法实现;§12.2。
[6] 请使用谓词和其它函数对象为标准算法赋予更广泛的意义;§12.5,§12.6。
[7] 谓词绝不可修改其参数;§12.5。
[8] 请通晓标准库算法,并用其代替手写的循环;§12.6。
[9] 在 迭代器对 的风格显得冗长时,可以引入一个 容器/区间 版本的算法;§12.8。
F
可通过一个Args
类型的参数列表调用,并
可确保
的Relation<F,T,U>
S
是某个Iterator
类型的哨兵,
就是说,
I
是个前向迭代器,支持