豆瓣链接:你不可不知道的音乐大师及其名作

花了接近两个月的时间,终于把这本厚达623页的书看完了,这本书主要讲述了31位在音乐史上赫赫有名、贡献卓著的音乐大师的生平及其著名代表作,主要是西方古典音乐大师,包括我们耳熟能详的巴赫、海顿、莫扎特、贝多芬、舒伯特、肖邦等等,本文整理一下书中这些音乐大师及其代表作。

1、巴赫(Bach,1685-1750,音乐之父):1685年3月21日,出生于今日德国埃森纳赫的一个音乐世家,10岁是父母双亡,也是在这个年龄就开始担任教堂的“女高音”。巴赫的职业生涯可以分为三个阶段——魏玛时代、科腾时代和莱比锡时代。魏玛时代的创作风格洋溢着青春的热情,科腾时代主要是管弦乐与室内乐的创作,而莱比锡时代主要是为宗教奉献的。代表作有《D大调前凑曲与赋格曲》、《A大调前凑曲与赋格曲》、《G小调赋格曲》、《C大调托卡塔、慢板与赋格曲》、《勃兰登堡协凑曲》、《管弦乐组曲》、《无伴凑大提琴组曲》、《无伴凑小提琴凑名曲和组曲》、《平均律钢琴曲集》、《约翰受难曲》、《马太受难曲》等。我个人比较喜欢的还是他的《E调前奏曲》、《B小调帕蒂塔 - Ⅵ 布列舞曲》、《G大调小步舞曲》和《布兰登堡舞曲》等。

前后花了大概快六个月的时间(从6月8日到11月23),终于把侯捷的《STL 源码剖析》看完了,同时也把 SGI 实现 STL 的最新版本看完了。在这段时间里,几乎是每个周末花大约一天的时间来看书和代码,并将相应内容在博客中加以归纳和总结。由于基本上都是在周末抽时间看的,所以周期拉得比较长,但收获还是挺多的,其中印象比较深刻的是 STL 中的迭代器与型别萃取、空间配置与内存池的实现、双端队列 (deque) 的实现、红黑树的实现、排序等算法的实现、类与函数的偏特化、函数对象与适配器等等,对 STL 的整体架构也有了比较深入的认识。这段时间也经历了很多事情,看书和代码的过程中也遇到了很多问题,有些经过反复琢磨自己解决了,还有些仍未完全弄清楚,需要再回过头去看一遍。另外,在看书和代码的过程中,基本没有去写代码、去实践,只停留在理论上,再看一遍的时候可以写些测试的代码或自己实现一个相应的模块或功能。

这里再把之前写的深入理解 STL 源码的系列文章进行一个归档:

STL 简介

  1. 深入理解STL源码(0) STL简介

STL 空间配置器

  1. 深入理解STL源码(1) 空间配置器(allocator)

STL 迭代器

  1. 深入理解STL源码(2) 迭代器(Iterators)和Traits

1. 概述

适配器(adaptor/adapter)在STL组件的灵活运用功能上,扮演着轴承、转换器的角色,将一种容器或迭代器装换或封装成另一种容器或迭代器,例如基于deque容器的stack和queue。Adaptor这个概念,实际上是一种设计模式(design pattern),是《Design Pattern》一书中提及到的23个设计模式之一,其中对adaptor的定义如下:

将一个class的接口转换为另一个class的接口,使原本因接口不兼容而不能合作的classes,可以一起运作。

在STL中,除了上面提到的容器或迭代器的适配器之外,还可以对函数或更广义的仿函数使用适配器,改变其接口,这种称为function adaptor,相应的针对容器或迭代器的适配器则分别称为container adaptor,iterator adaptor,下面将分别介绍这三种适配器。

2. 容器适配器

容器适配器相对而言比较简单,比较典型的就是上面提到的低层由deque构成的stack和queue,其基本实现原理是,在 stack 和 queue 内部定义一个 protected 的 deque 类型的成员变量,然后只对外提供 deque 的部分功能或其异构,如 stack 的 push 和 pop 都是从 deque 的尾部进行插入和删除,而 queue 的 push 和 pop 分别是从尾部和头部进行插入和删除,这样 stack 和 queue 都可以看做是适配器,作用于容器 deque 之上的适配器。关于 stack 和 queue 的具体内容请参见之前将容器的文章 深入理解STL源码(3.3) 序列式容器之deque和stack、queue

1. 仿函数|函数对象概述

在STL的六大组件中,仿函数可说是体积最小、观念最简单、实现最容易的一个,但小兵也能立大功——他扮演一种“策略”角色,可以让STL算法具有更加灵活的“演出”。

在STL的历史上,仿函数(functor)是早期的命名,C++标准规格定下来后采用了新的名称——函数对象(function object)。就实际意义而言,函数对象的称谓更加贴切:一种具有函数特质的对象。函数对象对调用者而言可以向函数调用一样地被调用,而对被调用者而言则是以对象所定义的函数调用操作符(function call operator)。

在C++中,函数调用操作符是指左右小括弧 () ,该操作符是可以重载的。许多 STL 算法都提供了两个版本,一个用于一般情况(例如排序时使用 operator< 以递增方式排列),一个用于特殊情况(例如排序时按照使用者自定义的大小关系进行排序)。这有点类似于C语言中的函数指针,但函数指针无法满足STL对抽象性的要求,也不能和STL其他组件(如配接器adaptor)搭配,产生更灵活的变化,关于这一点下一节将详细介绍。

2. 可适配(Adaptable)的关键

STL算法非常灵活的一个关键因素之一在于STL仿函数的可配接性(adaptability),即函数可以被配接器修饰,彼此相积木一样地串接。为了拥有配接能力,每一个仿函数必须定义自己的相应型别(associate types),就像迭代器如果要融入整个STL大家庭,也必须依照规定定义自己的5个相应型别一样。这样做是为了让配接器能够获得函数的一些特性。相应型别都只是一些 typedef,所有必要操作在编译期就就全部完成了,对程序的执行效率没有任何影响,不带来任何额外负担。

仿函数相应型别主要用来表示函数的参数型别和返回值型别,为了方便,stl_function.h 中定义了两个基类,分别是 unary_functionbinary_function,分别表示一元函数和二元函数,其中都是一些型别的定义,仿函数只需要继承其中一个类,就可以拥有配接能力了。

本文主要介绍STL中的稍微复杂的算法,主要涉及到的源码文件有 stl_algo.h 等。

在文件 stl_algo.h 中有很多常用的算法,包括查找、计数、旋转、删除、排序、合并、集合的交并等运算、求极值、排列组合等等,本文将按源码中各算法的实现顺序来介绍其具体实现细节。由于本文涉及到的算法和相关代码太多,在文中就尽量不贴出代码了,详细的代码及相关注释请参见 stl_algo.h

1. 求三个数的中值 median
该算法比较简单,几个if-else语句就解决了。该函数只提供内部算法使用,并不对外提供接口,也不是STL标准中的算法,限于篇幅这里就不贴代码了。另外,该算法有两个版本,一个是使用默认的大小比较,另一个是可以指定比较函数。

2. for_each
也很简单,就是对区间 [first, last) 中的每一个元素执行一个给定函数的运算,就一行语句:

for ( ; __first != __last; ++__first) __f(*__first);

其中 __f 为用户传入的一个指定的仿函数。该算法的返回值仍为传入的仿函数 __f

本文主要介绍STL中的基本算法,主要涉及到的源码文件有 stl_algobase.h 等。

stl_algobase.h 中定义的算法都比较简单基础,主要涉及区间相等判断、区间填充、求极值、交换、拷贝、字典序比较等算法,而其他诸如查找、计数、排序、旋转等算法则在文件 stl_algo.h 中实现。在algobase基本算法中,除了字典序比较、复制/拷贝算法外,其他都比较简单,这里先依次介绍这些简单的算法,然后再介绍字典序比较和拷贝算法。

1. 交换、填充等简单算法

由于这里很多算法比较简单(基本都在10行以内,甚至很多就一行代码),就不一一粘贴代码了。

iter_swap :将两个 ForwardIterators 所指的对象对调,通过申请一个临时变量、三次赋值,就完成了。

min/max :求两个数中的小、大者,还有一个版本可以指定的比较方法(仿函数)。

fill :将 [first, last) 内的所有元素改填为新值 value。

fill_n :将 [first, last) 内的前n个元素改填为新值 value,返回迭代器指向被填入的最后一个元素的下一位置。

mismatch :用来平行比较两个序列,指出两者之间的第一个不匹配点,返回一对迭代器(Iterators Pair),分别指向两序列中的不匹配点。

equal :判断两个序列在 [first, last) 区间内相等,如果第二个序列元素较多,将不予考虑,只有两个序列在各自区间内对应相等才返回true,否则返回false。

本文主要介绍STL中的数值算法,主要涉及到的源码文件有 stl_numberic.hnumericstl_relops.h 等。

STL 数值算法主要包含以下几个算法(来自C++文档):

  • accumulate: Accumulate values in range
  • adjacent_difference: Compute adjacent difference of range
  • inner_product: Compute cumulative inner product of range
  • partial_sum: Compute partial sums of range
  • iota: Store increasing sequence
  • power: power(x,n) 1 multiply by x n times (not in C++ standard)

下面一一介绍每个算法的实现。

1. accumulate

该算法计算 init 和区间 [first, last) 内所有元素的总和。注意,必须提供 init 的初始值,这样即使 first=last 区间为空,仍能得到一个明确定义的值。当 init=0 时,即为计算 [first, last) 区间内所有元素的总和。具体实现有两个版本,如下:

template <class _InputIterator, class _Tp>
_Tp accumulate(_InputIterator __first, _InputIterator __last, _Tp __init){
  __STL_REQUIRES(_InputIterator, _InputIterator); // concept check
  for ( ; __first != __last; ++__first)
    __init = __init + *__first; // 求和
  return __init;
}
template <class _InputIterator, class _Tp, class _BinaryOperation>
_Tp accumulate(_InputIterator __first, _InputIterator __last, _Tp __init, _BinaryOperation __binary_op){
  __STL_REQUIRES(_InputIterator, _InputIterator); // concept check
  for ( ; __first != __last; ++__first)
    __init = __binary_op(__init, *__first); // 指定二元操作
  return __init;
}

1. 算法概述

算法(Algorithm)是一个计算的具体步骤,常用于计算、数据处理和自动推理。Donald Knuth 在他的著作 The Art of Computer Programming 里对算法的特征归纳(来自wiki):

  • 输入:一个算法必须有零个或以上输入量。
  • 输出:一个算法应有一个或以上输出量,输出量是算法计算的结果。
  • 明确性:算法的描述必须无歧义,以保证算法的实际执行结果是精确地符合要求或期望,通常要求实际运行结果是确定的。
  • 有限性:依据图灵的定义,一个算法是能够被任何图灵完备系统模拟的一串运算,而图灵机只有有限个状态、有限个输入符号和有限个转移函数(指令)。而一些定义更规定算法必须在有限个步骤内完成任务。
  • 有效性:又称可行性。能够实现,算法中描述的操作都是可以通过已经实现的基本运算执行有限次来实现。

算法的核心是创建问题抽象的模型和明确求解目标,常见的算法有分治法、贪婪算法、动态规划、平摊分析等。再好的编程技巧,也无法让一个笨拙的算法起死回生,选择了错误的算法,便注定了失败的命运。

算法的时间复杂度是指算法需要消耗的时间资源。一般来说,计算机算法是问题规模 $n$ 的函数 $f(n)$ ,算法的时间复杂度也因此记做:

$T(n) = O(f(n))$

算法执行时间的增长率与$f(n)$的增长率正相关,称作渐近时间复杂度(Asymptotic Time Complexity),简称时间复杂度。 常见的时间复杂度有:常数阶 $O(1)$ ,对数阶 $O({log}_ {2}n)$ ,线性阶 $O(n)$ , 线性对数阶 $O(n{log}_ {2} n)$ ,平方阶 $O(n2 )$ ,立方阶 $O(n3 )$ ,..., k次方阶 $O( nk )$ ,指数阶 $O( 2n )$ 。随着问题规模 $n$ 的不断增大,上述时间复杂度不断增大,算法的执行效率越低。

算法的空间复杂度是指算法需要消耗的空间资源。其计算和表示方法与时间复杂度类似,一般都用复杂度的渐近性来表示。同时间复杂度相比,空间复杂度的分析要简单得多。

本文涉及到 SGI STL 源码的文件主要是 stl_hash_set.hstl_hash_map.h 等文件。

1. hashset 和 hash_multi_set

需要说明的是,STL 标准只规范了复杂度与接口,并没有规范实现方法,但 STL 实现的版本中 set 大多以 RB-tree 为底层机制,SGI STL 在实现了以 RB-tree 为底层机制的 set 外,还实现了以 hashtable 为底层机制的 hashset。
和 set 一样,hashset 的键值(key)和实值(value)是同一个字段,不同的是 set 默认是自动排序的,而 hashset 则是无序的。除此之外,hashset 与 set 的对外接口完全相同。
这里还有一种称为 hash_multi_set 的集合,它同 multiset 类似,允许键值重复,而上面的 hashset 则不允许。下面是 hashset 的定义的主要代码:

本文涉及到 SGI STL 源码的文件主要是 stl_hashtable.hstl_hash_fun.h 等文件。

1. hashtable 简介

在数据结构中我们知道,有种数据结构的插入、删除、查找等操作的性能是常数时间,但需要比元素个数更多的空间,这种数据结构就是哈希表。哈希表的基本思想是,将数据存储在与其数值大小相关的地方,比如对该数取模,然后存储在以余数为下表的数组中。但这样会出现一个问题,就是可能会有多个数据被映射到同一个存储位置,即出现了所谓的“碰撞”。哈希表的主要内容就是解决“碰撞”问题,一般而言有以下几种方法:线性探测、二次探测、开链等。

线性探测

简单而言,就是在出现“碰撞”后,寻找当前位置以后的空档,然后存入。如果找到尾部都没有空档,则从头部重新开始找。只要空间大小比元素个数大,总能找到的。相应的,元素的查找和删除也与普通的数组不同,查找如果直接定位到相应位置并找到或是空档,就可以确定存在或不存在,而如果定位到当前位置非空且与待查找的元素不同,则要依序寻找后续位置的元素,直到找到或移到了空档。删除则是采用懒惰删除策略,即只标记删除记号,实际删除操作则待表格重新整理时再进行。

二次探测

与线性探测类似,但向后寻找的策略是探测距当前位置为平方数的位置,即 $index = H+i^{2}$ 。但这样会有一个问题,那就是能否保证每次探测的是不同的位置,即是否存在某次插入时,探测完一圈后回到自己而出现死循环。

开链

这种方法是将出现冲突的元素放在一个链表中,而哈希表中只存储这些链表的首地址。SGI STL中就是使用这种方法来解决“碰撞”的。

2. hashtable 的数据结构

由于使用开链的方法解决冲突,所以要维护两种数据结构,一个是 hash table,在 STL 中称为 buckets,用 vector 作为容器;另一个是链表,这里没有使用 list 或 slist 这些现成的数据结构,而是使用自定义 __hashtable_node ,相关定义具体如下: