下面是徽杭古道安徽绩溪入口处,大约12点左右就到这儿了。注意要在这个地方买票哦(全价¥68),检票口在后面。。。
下图是上图入口处的古道全程景点示意图
从入口的售票处往上走两三百米才到检票口,检票口有历史上走过古道的大人物们
刚进古道风景比较险峻,右边还有栈道,左边则是石阶。下面是一大波漂亮奇特的风景(大多是我自己用mi4拍的,手机渣拍照技术也渣)
经过大约一两小时的艰难跋涉,就到比较平缓的地方了,中间很长一段(大概也要一两小时吧)没有啥独特的风景,不过空气还不错,就当普通的徒步了。
然后,就又要开始爬山了,从逍遥村到下雪堂、上雪堂,最后到蓝天凹,从上雪堂开始就有前几天的积雪了,到蓝天凹积雪更多。
到蓝天凹大约四点半,测了一下海拔,大约一千米。由于计划是两天,于是在徽杭人家落脚,时间还早就去爬了屋后的一坐小山,爬到后面还是挺艰难险阻的,但蓝天凹的天空的确是蓝啊,而山顶的景色也是美不胜收啊有木有!
晚上在屋内用自带的设备煎鸡蛋火腿、煮火锅面条,中间出去看了一下星空,真的是星光灿烂啊,可以渣手机拍不出来。
第二天早上起来煮了个面条,然后就往清凉峰方向出发去野猪堂,一路上很多地方积雪也没融化,比较难走,中间几次都差点摔倒,最后还是没有到野猪堂,在一个有溪水和两间小屋、积雪比较厚的地方(测了下海拔,是1500米左右,感觉没那么高)玩了会儿就折返了,往返大概三个小时。
中午12点左右返回徽杭人家,取行李准备下山了。刚出发遇到两位苏州过来的驴友,最后一路一起回到杭州。
回程过程中也要转两次车,最后到杭州汽车西站大概四点半了,回学校刚好五点左右。
]]>匆匆又一年。
这段时间一直都在忙着写毕业论文,快一个月没有更新博客了,昨天是2014最后一天,也是学校举行的第一个学生节,在去紫荆港吃免费晚餐前将论文的模板提交到了github,这也是2014年最后一次commit了。在校园的最后一个跨年和元旦,就这样一个人在风雨操场看现场晚会度过了。今天是新年的第一天,又是一个人宅在实验室边听着二次元plus萌系的SNH48的『化作樱花树』边写着这篇本该昨天写的年终总结。
作为一个仍在校园里生活的学生,学业还是最重要的,年终总结也必然要提到。作为一个研究僧硕士狗+码农程序猿,这一年在学业上是平平淡淡、颗粒无收,没有什么值得一提的,没有发表什么论文,也没有学到什么新的知识和技术。如果真要扯一点的话,那就是3月份参加了阿里的大数据竞赛,把推荐系统及相关算法学习和捣鼓了一番,虽然最后连初赛都没过,但还是收获不少吧。在推荐算法方面,主要看了libMF的代码和论文,然后把比较好的一些开源推荐工具进行了非常简单的也并非太原创的总结——TOP 10开源的推荐系统简介,没想到居然被人推荐到Fenng的类似于Haker news的dbanotes startup news上了,那几天博客的访问量一下子飙上去了不少,想想还是挺鸡冻的有木有^-^
在参加这个比赛的过程中还对并行计算和并发编程进行了一些浅显的研究,因为发现简单的算法运行时间太长,而多核CPU根本没法好好利用,于是捣鼓了一下Hadoop和Mahout,学了一下一门比较新的传说对并发支持比较nice的由高大上的Google开发的Go语言,并总结在 Go并发编程初探 一文中,但之后就没有怎么用Go做什么东西了,新的一年希望能捡起来再捣鼓出一点东西来。
另一个值得一提的就是,非常非常认真的读了SGI STL的源代码并用一些列博文进行了总结——深入理解STL源码系列文章归档,搞明白了STL中简单的内存池实现,也才知道原来STL双端队列底层是由一块块内存通过链表连接起来的,而set和map的底层是插入操作很复杂的红黑树,还有其中的排序算法并不是数据结构和算法中学的那样的快速排序,还还有其中一些关于变量类型萃取、偏特化等等独具匠心的设计和技巧。
3月就要毕业了,所以2014年找工作必然是一个重头戏。非常庆幸的是,先人一步,在9月中旬就拿到了腾讯的正式Offer。由于研究生期间一直保持着写博客的习惯,虽然自认为码字和表达能力真的很烂很烂,但还是逼迫自己写写,即便是东拼西凑,也算总结了一些语音处理和机器学习方面基本知识和方法,因此被qq音乐音频基础开发组的leader内推到腾讯实习,并通过实习拿到了正式的Offer,这里也要非常感谢他。能够做自己感兴趣的事,而且是将Coding与Music这两个兴趣合二为一,并以此为自己的工作和职业,真的很幸运很幸运,非常非常感激!拿到这个Offer后基本没怎么找工作了,真要说找的话,主要是关注了一些海外的以及国内这方面更好的一些工作机会,但都没能如愿。
当然,在拿到鹅厂实习offer之前,还是好好做了一些准备的,看了一些求职面试的书籍。没有随大流看程序猿面试宝典之类的,而是选择看『C++应用程序性能优化』、『谁是谷歌想要的人才』、『STL源码剖析』这类的书。另外,前后花了差不多三个月时间(主要是每天晚上刷题),将leetcode上的150多道非常经典的面试题目刷完了,期间N多个夜晚一个人在实验室调bug到凌晨一两点,感觉那段时间的自己特别的帅有木有~。
在实习期间,遇到了很多老朋友,还有一个多年未见的小学同学兼同桌;也认识了不少新的朋友,他们都很优秀,从他们身上也学到了很多东西。深圳除了房价之外其他什么的都真的很好,相信毕业正式入职之后,工作上一切都会顺顺利利。
这一年,生活中更加注重健身,也读了不少经典的书籍。其中很长一段时间,平均每周至少都要健身或运动一次,无论是踢球还是跑步、游泳、爬山还是腹肌撕裂者等,虽然没有比较擅长的体育项目,但坚持锻炼感觉还是挺好的。另外还读了很多书,主要包括『裂变』、『雷军传』、『活着』、『王雪红: HTC女掌门的商战与人生』、『创客』、『思考的技术』、『我看电商』、『参与感』、『重来』、『国学精粹:道德经三解』、『你不可不知道的音乐大师及其名作』等等,既有文艺作品,也有关于IT互联网思维的,扩展了眼界。
来杭州上学之后就保持了每周给家里打电话的习惯,过去的这一年也不例外,虽然每次都是差不多的话题和内容,时间也不超过10分钟,但双方都能了解对方近况、平平安安,就很满足了。我们是在一年年长大,而父母却一年年老去,我们成年了就应该多关心关心父母,多陪他们聊聊天说说话。关于爱情,我等呆呆的屌丝程序猿就注孤,掩面哭晕在厕所也不会有人管,顶多能收到一些好人卡。。。
这一年中,虽然实习时挣了一些money,但已经挥霍干净了,买了beats耳机、ipad mini2、米4,让在日本工作的同学带了两张AKB48的原版CD,实习期间去香港high了两天,还有实习结束后第一次去广州的不寻常经历,另外还报了驾校等等,其中的经历确实有些坎坎坷坷。相比而言,在学校有很多活动,周围也发生了很多大大小小的事,满满的正能量。Facebook早期员工王淮和个推创始人方毅回学校分享了那些年的创业经历;王利芬、俞敏洪、徐小平也来紫荆港分享了他们坎坷而成功的人生经历;阿里巴巴在美国纽交所挂牌上市了,杭州一夜之间出现了上千位亿万富翁,而马云一跃成为中国首富,还抛出了那句耐人寻味的话“梦想还是要有的,万一实现了呢”。
但是,梦想都是别人的,我什么也没有。。。
时间仍在不断飞逝,美好的时光总是那么短暂,毕业离校匆匆逼近,在校园的时光马上就要告一段落了,在这最后仅剩的两三个月的时光里,且行且珍惜吧!
]]>豆瓣链接:你不可不知道的音乐大师及其名作
花了接近两个月的时间,终于把这本厚达623页的书看完了,这本书主要讲述了31位在音乐史上赫赫有名、贡献卓著的音乐大师的生平及其著名代表作,主要是西方古典音乐大师,包括我们耳熟能详的巴赫、海顿、莫扎特、贝多芬、舒伯特、肖邦等等,本文整理一下书中这些音乐大师及其代表作。
1、巴赫(Bach,1685-1750,音乐之父):1685年3月21日,出生于今日德国埃森纳赫的一个音乐世家,10岁是父母双亡,也是在这个年龄就开始担任教堂的“女高音”。巴赫的职业生涯可以分为三个阶段——魏玛时代、科腾时代和莱比锡时代。魏玛时代的创作风格洋溢着青春的热情,科腾时代主要是管弦乐与室内乐的创作,而莱比锡时代主要是为宗教奉献的。代表作有《D大调前凑曲与赋格曲》、《A大调前凑曲与赋格曲》、《G小调赋格曲》、《C大调托卡塔、慢板与赋格曲》、《勃兰登堡协凑曲》、《管弦乐组曲》、《无伴凑大提琴组曲》、《无伴凑小提琴凑名曲和组曲》、《平均律钢琴曲集》、《约翰受难曲》、《马太受难曲》等。我个人比较喜欢的还是他的《E调前奏曲》、《B小调帕蒂塔 - Ⅵ 布列舞曲》、《G大调小步舞曲》和《布兰登堡舞曲》等。
2、亨德尔(Handel,1685-1759,清唱剧大师):1685年2月23日出生于德国萨克森州哈雷,朱明的《哈利路亚大合唱》就是他所谱写的。此外他的代表作还有清唱剧《赛尔斯》,歌剧《阿尔辛娜》,民族史诗剧《尤里乌斯·凯撒》,《乞丐歌剧》,《快乐的铁匠》,《水上组曲》和《皇家烟火》等。
3、海顿(Haydn,1732-1809,古典乐派的奠基人):1732年3月31日诞生于奥、匈边境的罗劳,8岁即获选前往维也纳称为圣斯蒂芬大教堂儿童唱诗班成员。一生写了120多首交响曲,确立了交响乐的“4乐章制”——第1乐章是凑鸣曲式,第2乐章是抒情的慢板,第3乐章是带有民俗色彩的小步舞曲,第4乐章是以回旋曲作结,被誉为“交响乐之父”。代表作有《萨洛蒙交响曲》、《告别交响曲》、《太阳四重凑》、《俄罗斯四重凑》、《皇帝四重凑》、清唱剧《创世纪》等。
4、莫扎特(Mozart,1756-1794,音乐天才):1756年1月27日诞生于奥地利的萨尔茨堡,音乐神童,“一闪一闪亮晶晶,满天都是小星星...”这首我们耳熟能详的童谣就是他所作。4岁开始学习钢琴,6岁便举行了第一次独凑音乐会。在短短36年的生命中,留下了近千首创作,除了灿烂的歌剧作品外,还有交响曲、协凑曲、室内乐、钢琴曲、各类声乐曲。代表作有歌剧《费加罗的婚姻》、《唐璜》、《女人皆如此》、《后宫诱逃》与《魔笛》等,交响曲《降E大调交响曲》、《G小调交响曲》、《C大调交响曲》(又名《朱皮特交响曲》),协凑曲《第21钢琴协凑曲》、《G大调第1长笛协凑曲》、《D大调第2长笛协凑曲》,其他管弦乐《嬉游曲》、《小夜曲》、《G大调弦乐夜曲》等,最后一首作品《安魂曲》。
5、贝多芬(Beethoven,1770-1827,乐圣):1770年12月16日,出生于德国波恩的一个乐师家庭,在父亲“望子成莫扎特”的期望下4岁开始一连串的魔鬼训练,学习钢琴和小提琴,8岁即在科隆举办一场成功的音乐会。1787年在维也纳拜见了莫扎特,1792年师从海顿,1819年耳疾恶化。代表作有《月光凑鸣曲》、《第2钢琴协凑曲》、《悲怆凑鸣曲》、《第1~9交响曲》(包括英雄(3)、命运(5)、田园(6)等)、《合唱幻想曲》、歌剧《费德里奥》等。
6、韦伯(Weber,1786-1826,浪漫派的先锋):1786年11月18日出生于德北奥伊廷,韦伯的堂姐康丝丹彩(Constanze Weber)是莫扎特的妻子。韦伯曾跟随交响曲之父的海顿的弟弟学习钢琴和作曲。韦伯的代表作有歌剧《西尔瓦娜》、《魔弹射手》,《单簧管五重凑》、《第1单簧管协凑曲》,钢琴曲《邀舞》等。
7、罗西尼(Rossini,1792-1868,喜歌剧大师):1792年2月29日出生于佩萨罗,父亲是临时小号手,母亲是“行李箱歌手”,一生可谓一帆风顺,作品大多欢快而充满乐趣。代表作有《德米特里奥与波利比奥》、《结婚证书》、《离奇的误会》、《快乐的骗局》、《巴比伦的奇洛》、《试金石》、《坦克雷迪》、《意大利女郎在阿尔及尔》、《奥勒利安在帕尔米拉》、《土耳其人在意大利》、《西吉斯蒙多》、《英格兰女王伊丽莎白》、《塞维利亚理发师》等。
8、舒伯特(Schubert,1797-1828,歌曲之王):1797年1月31日出生于维也纳,在短短31年的生命中,写了许多脍炙人口的歌曲。舒伯特身材矮小、个性温和、嗓音优美,害羞而不善交际但人缘很好,人们昵称他为“小香菇”,19岁时就已写下了《美丽的磨坊少女》、《野玫瑰》、《魔王》、《流浪者》等高雅而灿烂的作品。代表作有《C大调凑鸣曲》、《8首变凑曲》、《4首农村圆舞曲》、《匈牙利诙谐曲》,钢琴五重凑《鳟鱼》,《第8交响曲(未完成)》、《第9交响曲(伟大)》,《摇篮曲》、《音乐颂》、《天鹅之歌》等。
9、柏辽兹(Berlioz,1803-1869,永不休止的音乐实验家):1803年12月11日出生于法国南部格勒诺勒,父亲是喜爱音乐的医生。代表作有四部伟大的交响曲《幻想交响曲》、《哈罗德在意大利》、《罗密欧与朱丽叶》、《葬礼与凯旋交响曲》等。
10、门德尔松(Mendelssonhn,1809-1847,梦幻音乐的翘楚):1809年2月3日出生于德国汉堡,犹太人后裔,著名的《婚礼进行曲》作者。10岁开始学习作曲,12岁时域72岁的歌德开始了一段忘年之交,15岁创作《第1交响曲》,16岁完成了降E大调《弦乐八重凑》,17岁谱出了《仲夏夜之梦》序曲,20岁开始在欧洲各地旅行并创作了《芬加尔洞窟》序曲、《苏格兰交响曲》、《意大利交响曲》以及钢琴曲《无言歌》等。此外代表作还有《纺纱歌》、《甜蜜的回忆》、《春之歌》等。
11、肖邦(Chopin,1810-1849,钢琴诗人):1810年出生于华沙附近热拉佐瓦沃拉镇,父亲是法文教师。7岁,创作第一手乐曲G小调《波兰舞曲》。代表作有《马祖卡舞曲》、C小调《第12练习曲》(又称为《华沙陷落》),一系列的《夜曲》,《E小调协凑曲》、《F小调协凑曲》、《降A大调圆舞曲》、《小狗圆舞曲》、《雨滴前凑曲》、《送葬进行曲》等。
12、舒曼(Schumann,1810-1856,最优浪漫气质的作曲家):1810年6月8日出生于德国萨克森地区的茨维考,7岁向管风琴师学习音乐及钢琴。1831年发表钢琴名曲《蝴蝶》,1834年发表钢琴独凑曲《交响练习曲》,同年与数位朋友一起创办了《新音乐杂志》,次年创作了钢琴曲杰作《狂欢节》。其他代表作还有《第2凑鸣曲》、《幻想曲集》、《儿时情景》、《花之歌》、《幽默曲》,《声乐套曲》、《妇女的爱情与生活》、《诗人之恋》,《降B大调第1交响曲(春天)》,《第3交响曲(莱茵)》、《第4交响曲》,《钢琴五重凑》、《钢琴四重凑》等等。
13、李斯特(Liszt,1811-1886,钢琴之王):1811年10月12日生于匈牙利雷定城,9岁时就举行了第一次钢琴独凑会并技惊四座,10岁举家迁往维也纳并是从贝多芬学生车尔尼,13岁完成第一部歌剧《桑丘先生》。代表作有《革命交响曲》、《巡礼之年》、13大交响诗、《超技练习曲》、19首《匈牙利狂想曲》等。
14、瓦格纳(Wagner,1813-1883,引发热潮的歌剧院狂人):1813年5月22日生于莱比锡,刚满6个月父亲就去世,少年瓦格纳喜爱戏剧胜过音乐,后受贝多芬第7交响曲影响转攻音乐,19岁完成第一步交响曲。代表作有《特里斯坦和伊索尔德》、《尼伯龙根的指环》、《帕西法尔》、《仙女》、《漂泊的荷兰人》、《黎恩济》、《罗恩格林》、《纽伦堡的名歌手》、《汤豪舍》等。
15、威尔第(Verdi,1813-1901,复兴意大利音乐的歌剧之神):1813年10月10日出生于意大利帕尔马省,少年时期在教堂演奏管风琴,26岁时举家迁往米兰后不久两个幼子和妻子相继去世。代表作有《一日国王》、《尼布甲尼撒》、《伦巴底人》、《麦克白》、《莱尼亚诺之战》、《弄臣》、《游吟诗人》、《茶花女》、《阿伊达》、《奥赛罗》、《安魂弥撒曲》等。
16、小约翰·施特劳斯(Johann Strauss,1825-1899,圆舞曲之王):1825年10月25日出生于维也纳,6岁就完成了第一步圆舞曲,19岁新作圆舞曲《寓意短诗》的出道演奏公然挑战了父亲在维也纳乐坛的崇高地位,也轰动了全维也纳。代表作有《蓝色多瑙河》、《晨报》、《艺术家生涯》、《维也纳森林的故事》、《醇酒、美人与情歌》、《春之声》、《皇帝》、《喋喋不休波尔卡》、《电闪雷鸣波尔卡》、《拨弦波尔卡》、《蝙蝠》、《吉普赛男爵》等。
17、勃拉姆斯(Brahms,1833-1897,新古典主义的捍卫者):1833年5月7日生于德国汉堡,父亲是汉堡军乐队中的法国号手,7岁是跟随柯西尔学习钢琴,14岁开始登台演奏自己创作的变奏曲,17岁发表了第一手作品《华尔兹幻想曲》。代表作有《C大调奏鸣曲》、《第1钢琴协奏曲》、《赠叶欧丽斯的竖琴》、《永恒的爱》、《五月的夜》、《降A大调华尔兹舞曲》、《德意志安魂曲》、《E小调大提琴奏鸣曲》、《爱之歌》、《凯旋之歌》、《C小调第1交响曲》、《大学庆典》、《第2、3、4交响曲》、《7首幻想曲》、《3首间奏曲》、《6首钢琴小品》、《D大调小提琴协奏曲》等。
18、柴可夫斯基(Tchaikovsky,1840-1893,俄罗斯音乐的集成大师):1840年5月7日出生于俄国沃金斯克,16岁才开始跟随昆丁格学习钢琴,22岁以前被迫与法律为伴,但音乐仍是业余爱好,1861年才正式进入音乐学院学习作曲,大器晚成。代表作有《第1交响曲(冬之梦)》、《罗密欧与朱丽叶管弦乐幻想曲》、《第1钢琴协奏曲》、《第2、3、4交响曲》、芭蕾音乐《天鹅湖》、钢琴曲集《四季》、《斯拉夫进行曲》、《意大利随想曲》、歌剧《叶普盖尼·奥涅金》、芭蕾舞剧《睡美人》、《D大调小提琴协奏曲》、幻想曲《暴风雨》、《第6交响曲(悲怆)》等。
19、德沃夏克(Dvorak,1844-1904,国民乐派的巨人):1844年9月8日出生于布拉格附近的内拉霍齐夫斯,父亲是小旅店老板兼屠夫,10岁小学毕业被送去当屠夫学徒,14岁进入布拉格管风琴学校学习,17岁以第二名的优异成绩从学校毕业。代表作有清唱剧《赞歌》、《B小调大提琴协奏曲》、《降E大调交响曲》、《摩拉维亚二重唱》、《斯拉夫舞曲》、《圣母悼歌》、《迪米特里》、《鬼的新娘》、《第8交响曲》、《新世界交响曲》、《G小调奏鸣曲》、《诙谐的快板》、《寂静森林》、钢琴小品《8首幽默曲》等。
20、里姆斯基-科萨科夫(Rimsky-Korsakov,1844-1908,俄罗斯音乐革命家):1844年3月18日出生于诺格拉德的季赫文,父亲是福尼亚省长,6岁开始学钢琴,12岁进入圣彼得堡海军学校就读了6年后以海军少尉官阶毕业,闲暇是练琴与听歌剧是他的最爱。代表作有《降E小调第1交响曲》、《萨特阔》、《普斯科夫少女》、《五月之夜》、《雪姑娘》、《西班牙随想曲》、《天方夜谭》、芭蕾舞剧《青春》、《大黄蜂进行曲》、《沙皇的新娘》、《金鸡》等。
21、普契尼(Puccini,1858-1924,跨世纪的剧场魔术师):1858年12月22日诞生于卢卡,6岁父亲就去世,14岁在教堂但仍风琴师,16岁进入卢卡的帕奇尼音乐学校,18岁作交响曲《美丽的意大利之子》。代表作有《阿伊达》、《群妖围舞》、《曼侬·莱斯科》、《艺术家的生涯》、《蝴蝶夫人》、《西部女郎》、《燕子》、《外套》、《安洁丽卡修女》、《图兰朵》等。
22、马勒(Mahler,1860-1944,交响曲巨人):1860年生于波西米亚的喀里希特,16岁进入维也纳音乐学院就读,19岁即将毕业是创作了《悲哀之歌》。代表作有《第1交响曲(巨人)》、《第2交响曲(复活)》、《第5交响曲》、《第8交响曲(千人交响曲)》、《儿童的魔法号角》、《悼亡儿之歌》、《大地之歌》等。
23、德彪西(Debussy,1862-1948,印象乐派的翘楚):1862年8月22日生于巴黎市郊圣热曼昂莱,11岁以优异成绩进入巴黎音乐学院钢琴班,14根据漫维由的诗写作《星夜》。代表作有《月光》、《星光满天之夜》、《美丽的黄昏》、《浪子》、《牧神的午后前奏曲》、《海》、《儿童世界》、《玩具箱》、《意象》、《棕发少女》、《12首练习曲》等。
24、理查德·施特劳斯(Richard Strauss,1864-1949,浪漫派后期的前卫作曲家):1864年6月11日生于慕尼黑,4岁开始学钢琴,6岁开始学小提琴、音乐理论、作曲及器乐法,13岁完成第一件座屏管弦乐曲《节日进行曲》。代表作有《家庭交响曲》、《贡特拉姆》、《玫瑰骑士》、《纳克索斯岛的阿里阿德涅》、《没有影子的女人》、《阿拉贝拉》、《月夜情歌》、《塞西莉亚》、《秘密的邀请》、《清晨》、《美丽的境界》、《向晚之梦》、《唐璜》、《扎拉图斯特拉如是说》、《唐吉可德》、《莎乐美》等。
25、西贝柳斯(Sibelius,1865-1957,芬兰爱国音乐斗士):12月8日诞生于芬兰首都赫尔辛基北方的海门琳纳,3岁父亲死于伤寒,9岁开始学习钢琴,10岁创作了小提琴小品《水滴》。代表作有《A大调弦乐组曲》、《A小调弦乐四重奏》、《芬兰颂》、《传奇(又名冰洲古史)》、《卡莱瓦拉》、管弦乐组曲《春之歌》、《图内拉的天鹅》、《悲伤圆舞曲》、《D大调第2交响曲》、《D小调小提琴协奏曲》、《北国女儿》、《海洋女神》、《塔皮奥拉》、《我的祖国》、《大地之歌》。
26、拉威尔(Ravel,1875-1937,管弦乐法的魔术师):3月7日出生于西班牙巴斯克地区,7岁同亨利·盖斯学音乐,15岁进入巴黎音乐学院先后修业16年才毕业。代表作有《而妈妈组曲》、《波莱罗》、《十全十美》、《战火浮生录》、《哈巴涅拉》、《古风小步舞曲》、《悼念公主帕凡舞》、《西班牙时刻》、《西班牙民谣》、舞蹈诗《维也纳》、《左手钢琴协奏曲》、《G大调钢琴协奏曲》、《唐吉可德致杜西娜》。
27、巴托克(Bartok,1884-1945,匈牙利现代音乐大师):3月25日诞生于拿吉森米克洛斯,5岁岁母亲学钢琴,9岁进入布达佩斯音乐学院。代表作有《多瑙河的水流》、《小提琴与钢琴奏鸣曲》、《蓝胡子城堡》、《木刻王子》、《奇异的中国官吏》、《14首短曲》、《匈牙利民歌》、《五处乡村景色》、《小宇宙》、《9个着迷的单身汉》、《小提琴独奏奏鸣曲》、《第3钢琴协奏曲》等。
28、斯特拉文斯基(Stravinsky,1882-1974,前卫音乐开启者):6月17日生于俄国,20岁进入圣彼得堡大学攻读法律,22岁成为里姆斯基-科萨科夫的学生,24岁开始创作《降E大调第1交响曲》。代表作有《火鸟》、《春之祭》、《烟火》、《彼得鲁什卡》、《士兵的故事》、《俄狄浦斯王》、《诗篇交响曲》、《马戏波尔卡》、《黑檀木协奏曲》、《浪子的结局》、《阿贡》、《歌颂圣马可之名的颂歌》、《挽歌:先知耶利米的哀悼》等。
29、普罗科菲耶夫(Prokofiev,1891-1953,讽刺音乐的能手):4月23日生于松佐夫卡,5岁即创作了第1首乐曲《印度加洛舞曲》,9岁完成第1部歌剧《巨人》,13岁进入圣彼得堡音乐学院。代表作有《古典交响曲》、《瞬息的幻影》、《三橘爱》、《钢铁步伐》、《回头浪子》、《愤怒天使》、《石花》、《第3钢琴协奏曲》、《彼得与狼》、《罗密欧与朱丽叶》、《灰姑娘》、《第5交响曲》、《第3钢琴协奏曲》。
30、格什温(Gershwin,1898-1937,美国音乐的先锋):9月26日生于纽约布鲁克林区,父母是来自圣彼得堡的俄籍犹太人。代表作有《蓝色狂想曲》、《F大调钢琴协奏曲》、《美国人在巴黎》、《蓝色星期一》、《夫人,请好自为之》、《女孩表演》、《疯狂女孩》、《我为你唱歌》、《我所爱的人》、《波基与贝丝》等。
31、萧斯塔科维奇(Shostakovich,1906-1975,用音乐编址历史的苏联作曲家):9月25日出生于圣彼得堡,13岁进入背的包音乐学院,18岁完成《第1交响曲》。代表作有《革命牺牲者的送葬进行曲》、《你如受害者倒下》、《15首交响曲》、《卡特琳娜·伊兹麦洛娃》、《鼻子》、《姆钦斯克县的麦克白夫人》等。
曲目歌单:
这里再把之前写的深入理解 STL 源码的系列文章进行一个归档:
接下来,还需要花大量时间来重温一遍,对之前文章中的一些遗留问题进行梳理和解答。
]]>适配器(adaptor/adapter)在STL组件的灵活运用功能上,扮演着轴承、转换器的角色,将一种容器或迭代器装换或封装成另一种容器或迭代器,例如基于deque容器的stack和queue。Adaptor这个概念,实际上是一种设计模式(design pattern),是《Design Pattern》一书中提及到的23个设计模式之一,其中对adaptor的定义如下:
将一个class的接口转换为另一个class的接口,使原本因接口不兼容而不能合作的classes,可以一起运作。
在STL中,除了上面提到的容器或迭代器的适配器之外,还可以对函数或更广义的仿函数使用适配器,改变其接口,这种称为function adaptor,相应的针对容器或迭代器的适配器则分别称为container adaptor,iterator adaptor,下面将分别介绍这三种适配器。
容器适配器相对而言比较简单,比较典型的就是上面提到的低层由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。
STL提供了许多作用于迭代器之上的适配器,如 insert iterator,reverse iterator,iostream iterator等,相关源代码主要在 stl_iterator.h
文件中。
其中 insert iterator 是将一般的迭代器的赋值(assign)操作变为插入(insert)操作,而其他的自增和自减操作则不做任何处理的返回当前迭代器本身,包括从尾部插入的 back_insert_iterator
和从头部插入的 front_insert_iterator
,尾部插入的 insert iterator 的定义主要内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
reverse iterator 则将一般的迭代器的行进方向逆转,是原本应该前进的 operator++
变为后退操作,而 operator--
变为前进操作,这样做对于需要从尾部开始遍历的算法非常有用。该迭代器的主要定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这种逆向的迭代器只用于那些具有双向迭代器的容器(如vector,list,deque等,而slist,stack,queue,priority queue等则不行)或需要逆向遍历的算法(如copy backward等)。
iostream iterator 则将迭代器绑定到某个 iostream 对象上,有 istream_iterator
和 ostream_iterator
,分别拥有输入和输出功能。
以 istream iterator 为例,它将迭代器绑定到一个输入数据流对象(istream object)上,其实就是在 istream iterator 内部维护一个 istream member,用户对这个 istream iterator 所做的 operator++
操作会被该迭代器变为这个 istream member 的输入操作 operator>>
,这个迭代器是一个 input iterator,没有 operator--
操作,核心实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
可以看到以上的迭代器均非一般意义上的迭代器了,而是一个经过适配了的特殊的迭代器。
从上文中我们看到,container adaptor 内含一个container 的成员,iterator 内含一个 iterator 或 iostream 成员,然后对这些成员的标准接口进行了一定的改造,从而使之变成一个新的 container 或 iterator,满足新的应用环境的要求。而仿函数的适配器也是类似的,其实就是在 function adaptor 内部定义了一个成员变量,它是原始 functor 的一个对象,相关源代码主要在 stl_function.h
文件中。
STL中标准的 functor adaptor 包括对返回值进行逻辑否定的 not1
,not2
;对参数进行绑定的 bind1st
,bind2nd
;用于函数合成的 compose1
,compose2
(非STL标准,SGI私有);用于函数指针的 ptr_fun
;用于成员函数指针的 mem_fun
,mem_fun_ref
等。其中逻辑否定、参数绑定、函数合成的比较简单,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
用于函数指针的 ptr_fun
适配器使得我们可以将一般函数当做仿函数使用,就像原生指针可以当做迭代器传给STL算法一样,它的实际效果相当如 fp(param)
或 fp(param1,param2)
,前者定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
用于成员函数指针的 mem_fun
适配器使得我们可以将成员函数当做仿函数使用,于是成员函数可以搭配各种泛型算法,而当使用父类的虚拟成员函数作为仿函数时,还可以使用泛型算法完成所谓的多态调用(polymorphic function call),这是泛型(genericity)与多态(polymorphism)之间的结合。另外需要注意的是,虽然多态可以对指针或引用起作用,但STL容器只支持“实值语意”,不支持“引用语意”,及容器的内容应该为实值而非引用(类似于vecotr<X&> vc
这种)。一下是 mem_fun
的具体定义(还有很多个版本,这里只是最简单的一个):
1 2 3 4 5 6 7 8 9 10 11 12 |
|
在STL的六大组件中,仿函数可说是体积最小、观念最简单、实现最容易的一个,但小兵也能立大功——他扮演一种“策略”角色,可以让STL算法具有更加灵活的“演出”。
在STL的历史上,仿函数(functor)是早期的命名,C++标准规格定下来后采用了新的名称——函数对象(function object)。就实际意义而言,函数对象的称谓更加贴切:一种具有函数特质的对象。函数对象对调用者而言可以向函数调用一样地被调用,而对被调用者而言则是以对象所定义的函数调用操作符(function call operator)。
在C++中,函数调用操作符是指左右小括弧 ()
,该操作符是可以重载的。许多 STL 算法都提供了两个版本,一个用于一般情况(例如排序时使用 operator<
以递增方式排列),一个用于特殊情况(例如排序时按照使用者自定义的大小关系进行排序)。这有点类似于C语言中的函数指针,但函数指针无法满足STL对抽象性的要求,也不能和STL其他组件(如配接器adaptor)搭配,产生更灵活的变化,关于这一点下一节将详细介绍。
STL算法非常灵活的一个关键因素之一在于STL仿函数的可配接性(adaptability),即函数可以被配接器修饰,彼此相积木一样地串接。为了拥有配接能力,每一个仿函数必须定义自己的相应型别(associate types),就像迭代器如果要融入整个STL大家庭,也必须依照规定定义自己的5个相应型别一样。这样做是为了让配接器能够获得函数的一些特性。相应型别都只是一些 typedef,所有必要操作在编译期就就全部完成了,对程序的执行效率没有任何影响,不带来任何额外负担。
仿函数相应型别主要用来表示函数的参数型别和返回值型别,为了方便,stl_function.h
中定义了两个基类,分别是 unary_function
和 binary_function
,分别表示一元函数和二元函数,其中都是一些型别的定义,仿函数只需要继承其中一个类,就可以拥有配接能力了。
该类用来封装一元函数的参数型别和返回值型别,其定义非常简单:
1 2 3 4 5 |
|
仿函数可以继承该类,这样用户就可以取得该仿函数的参数型别,并以相同方法获得其返回值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
该类用来封装二元函数的参数一、参数二型别和返回值类型,仅比一元函数多了一个输入参数型别的定义而已,其定义如下:
1 2 3 4 5 6 7 8 9 10 |
|
STL 仿函数的分类,若以操作数的个数划分,可以分为一元和二元仿函数,若以功能划分,可以分为算术运算、关系运算、逻辑运算三大类,任何应用程序欲使用STL内建的仿函数,需要包含 <functional>
头文件,而这些仿函数的实际实现都在 stl_function.h
中。以下按功能分别介绍。
主要包括加法(plus)、减法(minus)、乘法(multiplies)、除法(divides)、取模(modulus)、否定(negation)等运算,除了否定以一元运算其他均为二元运算,如下:
1 2 3 4 5 6 7 8 |
|
仿函数搭配STL算法可以很灵活,例如对vector的每个元素求连乘如下:
1
|
|
主要有等于(equal_to)、不等于(not_equal_to)、大于(greater)、大于等于(greater_equal)、小于(less)、小于等于(less_equal)等六种运算,每一个都是二元运算,如下:
1 2 3 4 |
|
例如,对vector进行递减顺序排序:
1
|
|
主要是与(logical_and)、或(logical_or)、非(logical_not)三种逻辑运算,前两者为二元运算,后者为一元运算,如下:
1 2 3 4 5 6 7 8 |
|
这类仿函数都只是将参数原封不动的返回,其中某些仿函数对传回的参数有刻意的选择,或是刻意的忽略。之所以不在STL或其他泛型程序设计中直接使用原本及其简单的identity,project,select等操作,而要再划分一层出来,完全是为了间接性——间接性是抽象化的重要方法。另外,需要说明的是,这些仿函数并非C++标准,只是在SGI STL的实现中作为内部使用,一下是相关部分代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
除此之外,SGI STL实现中还有 constant_void_fun
,constant_unary_fun
, constant_binary_fun
, subtractive_rng
, mem_fun_t
等等,想深入详细了解的可以去看看源代码,还是很好理解的。
相关文章:
C语言函数指针与C++函数调用操作符
stl_algo.h
等。
在文件 stl_algo.h
中有很多常用的算法,包括查找、计数、旋转、删除、排序、合并、集合的交并等运算、求极值、排列组合等等,本文将按源码中各算法的实现顺序来介绍其具体实现细节。由于本文涉及到的算法和相关代码太多,在文中就尽量不贴出代码了,详细的代码及相关注释请参见 stl_algo.h。
1. 求三个数的中值 median
该算法比较简单,几个if-else语句就解决了。该函数只提供内部算法使用,并不对外提供接口,也不是STL标准中的算法,限于篇幅这里就不贴代码了。另外,该算法有两个版本,一个是使用默认的大小比较,另一个是可以指定比较函数。
2. for_each
也很简单,就是对区间 [first, last) 中的每一个元素执行一个给定函数的运算,就一行语句:
1
|
|
其中 __f
为用户传入的一个指定的仿函数。该算法的返回值仍为传入的仿函数 __f
。
3. 查找 find
函数 find
查找特定值的元素,函数 find_if
查找经过用户的指定函数 func(STL中的pred函数) 运算后结果为 true 的元素。主要代码也只有一行:
1
|
|
另外,关于find,考虑偏特化特性,还有在迭代器为随机存取迭代器时,每次循环进行4次判断和自增,这是所谓的 loop unrolling,在StackOverflow 上也有相关解释 questions-24295972。如果学过体系结构,应该也会提及循环展开的加速方法。
还有一个称为 adjacent_find
的查找算法,它查找序列区间中连续相等的两个元素的位置,返回其中第一个元素的迭代器。这个算法就没有做过多的优化和加速考虑了。
初次之外,在algo文件的最后部分,还有 find_first_of
、find_end
、`` 的算法,后面会按顺序介绍到。
4. 计数 count
该算法查找序列中值与给定值相等的元素的个数,即进行计数,返回为void,计数结果通过传入的引用参数 _Size& __n
来返回给用户,主要代码如下:
1 2 |
|
以上这个是非STL标准的,另外还有一个版本返回值为迭代器的 difference_type
的偏特化版本,这个才是STL标准。
5. 搜索search
该算法实现的功能是在区间 [first1, last1) 中搜索是否存在与区间 [first2, last2) 中元素都对应相等的子序列,存在则返回区间1中与区间2匹配的起始位置,否则返回last1。基本思路也很简单,详见源码中我的注释。还有一个版本,可以指定判断条件,而不一定是对应相等这个条件。
另外,还有一个 search_n
的算法与之相似,只是这个算法搜索区间中是否存在长度为count且值均为val的子序列,存在则返回该子序列的起始位置,否则返回last。同样,它也有一个可以指定判断条件的重载版本。
6. 区间置换 swap_ranges
交换两个长度相等的区间:
1 2 |
|
7. 区间变换运算 transform
对区间的每个元素进行opr运算,结果放在result中,仅这一点与 for_each
不同:
1 2 |
|
还有一个版本是两个等长序列的运算,结果放在result中:
1 2 |
|
注意该算法不需要传入第二个区间的last迭代器。
8. 替换 replace
将序列中所有值为oldval的元素值都改为newval:
1 2 |
|
另外还有三个版本的替换: replace_if
,判断条件可以自己指定,而不一定是相等;replace_copy
,将修改后的结果存到一个新的序列中;replace_copy_if
是前两者的合体。
9.生成 generate
将序列中的元素的值按给定函数赋值:
1
|
|
还有一个 generate_n
将序列中的前n个元素的值按给定函数赋值。
10.移除 remove
移除序列中值为val的元素,与 replace 算法类似,有4个版本,其中 remove
和 remove_if
分别通过 remove_copy
、remove_copy_if
实现,只需将后者中的result参数设为该序列的起点first。
1 2 3 4 |
|
11.unique和unique_copy
将区间的元素的值唯一化,即去掉相邻的重复的项。由于判断时是针对相邻的元素,所以一般需要结合sort使用,如果序列无序需要先对序列排序再进行唯一化。unique
的实现是调用 unique_copy
来实现的,只是将参数中result仍设为输入序列的first。
这个算法实现的过程中,有很多函数的调用,其中还有个问题没有解决(见代码中注释关于func4什么时候调用func3,func8什么时候调用func7的问题)。
12.反转 reverse
将区间中元素进行反转,一下是迭代器为随机存取迭代器时的实现:
1
|
|
还有迭代器为双向迭代器的版本和非质变算法版本 reverse_copy
。
13.旋转 rotate
该算法将区间 [first, last) 内的数据以 middle 为分界前后对调,即将[first,middle)+[middle,last) 变为 [middle,last)+[first,middle)。具体实施过程分为两步:首先将middle之后的元素全部调到middle之前,然后对middle之后的元素进行调整,使之按在middle之前时的顺序排列。具体步骤见源码注释,可以结合实例进行理解。该算法的时间复杂度为 $O(n)$,总体上只对序列进行了一次遍历。
另外,除了迭代器为前向迭代器的版本之外,还有迭代器为双向迭代器、随机访问迭代器的版本,分别对算法进行了特化和优化,详见源码注释。其中迭代器为随机访问迭代器时,算法稍微复杂些,但可以通过实例来简化理解。关于旋转算法的几种实现及其效率,可以参见这个 【Vector Rotation】,其中三种算法分别对应于STL中的随机迭代器版、前向迭代器版、双向迭代器版。虽然三种算法的复杂度均为线性的,但对于大量数据的旋转,还是会存在一些明显的效率区别的。
14.随机相关算法 random
random_shuffle
算法将序列随机重排,具体实现是对序列中每个位置的元素与序列中一个随机的元素进行对调:
1 2 |
|
除了这个版本采用STL的random函数生成随机数的版本外,还有一个版本可以自己指定随机数生成函数。
random_sample_n
和 random_sample
都是从序列中随机选取n个样本,不同的是输入参数的形式、返回序列的有序性等,均非STL标准。
15.分割 partition
该算法的功能是将序列按条件分割成两个子序列(实际还是一个序列,只是按分割点分成了满足条件的部分和不满足条件的部分),返回分割点的位置。有迭代器为前向迭代器、双向迭代器的版本,保证稳定性的版本 stable_partition
。
16. 排序 sort
排序算法是STL中最重要也最复杂的算法,总代码量大概是600行(实际上还不止,因为还有调用其他函数,如partition、merge等),占整个文件的1/5。该算法接受两个随机存取迭代器参数,将区间内的元素以渐增的顺序排列,重载版本则允许用户指定一个仿函数作为排序标准。STL的所有关系型容器都拥有自动排序功能(因为底层是RB-tree,属于有序搜索树),不需要用到这个sort算法,而序列式容器中的stack、queue和priority-queue都有特定的出入限制,不允许排序,剩下vector、deque和list、slist,前两者的迭代器都是随机存取迭代器,可以使用sort算法,而list是双向迭代器,slist是前向迭代器,都不适合使用sort算法,如果要对list或slist排序,需要使用list或slist自己实现的sort函数。
insert_sort
插入排序:在序列长度较小时(STL中设置的是长度小于16时),使用线性(而不是二分)插入排序。
sort
排序:在序列较长时,将序列分割为一个个小的区间,使得区间与区间之间整体上有序,然后使用线性插入排序对整体进行排序。(这与我们通常所理解的快速排序还是有很大区别的,最后整体上进行直接插入排序,实际效果与对每个子区间分别进行插入排序的效果是一样的,效率依然是非常高的)
stable_sort
稳定排序:实际上为归并排序,或称为merge sort,时间复杂度仍为 $O(nlogn)$。当子区间长度小于15时,让然是直接用插入排序;当子能够申请到O(n)的buffer时,借助buffer进行merge sort,否则使用inplace merge进行stable sort。而关于两种(with buffer和inplace的)merge的算法的内容,在后文中介绍。
partial_sort
部分排序:使用堆进行排序,功能是将序列 [first, last)
中的较小的 middle-first 个元素排序并放在区间 [first, middle)
中,而其余的 last-middle 个元素仍然是无序的。整个算法分为两个大的步骤,首先是将middle前的元素构建一个max-heap,将middle及之后的元素中比max-heap堆顶小的元素与堆顶对调并调整堆,从而得到middle前的元素都比middle后的元素小;然后使用heap sort对middle之前的元素进行排序。
17. 第n大的数 nth_element
该算法的功能是求一个序列中排行第n大的元素,具体实现时是使用 partition 将搜索范围逐步缩小,直到不足3个元素的区间后,进行insert-sort,最后第n大的元素就位于序列的第n个位置(该算法的迭代器也要求是随机存取的迭代器)。
18. 二分查找 binary_search
该系列算法的前提条件是序列已经有序,迭代器至少是ForwardIterators。
lower_bound
:二分查找 val,存在则返回指向该元素的迭代器,否则返回最小的不小于 val 的元素的迭代器,即在不破坏次序的情况下val可插入的第一个位置。
upper_bound
: 二分查找 val,存在则返回该元素的下一个元素的迭代器,否则返回最小的不小于 val的元素的迭代器,及在不破坏次序的前提下,val可插入的最后一个位置。
equal_range
:二分查找 val,返回值为 val 的区间 [i,j)
,其中 i 是 lower_bound
,而 j 是 upper_bound
。
binary_search
:二分查找,找到返回true,否则返回false。实际上使用的是 lower_bound
来实现的。
19. 合并 merge
merge
:两个有序序列合并为一个有序序列,输入为5个参数,分别为两个序列的首尾迭代器、结果的首迭代器,算法返回结果序列的尾迭代器。基本思路是同时访问两个序列,取较小者放入结果序列并后移,最后必然是一个序列结束而另一个序列还有剩余元素,只需要将剩余部分copy的结果序列的尾部即可。
inplace_merge
:原地将一个序列的两个有序子序列合并,实际上并不一定是原地进行,当可以申请到 O(n)的内存时借助buffer来进行merge,否则进行原地合并。原地合并的基本思路如下:先比较两个有序子序列的长度,将其中较长的序列分成两等分,取该序列的中间元素 first_cut
作为基准,然后得到第二个子序列以该基准分割的位置 second_cut
,再然后进行原地旋转,将两个cut之间大于基准的数据旋转到两个cut之间小于基准的数据的后面,这样两个序列就被分成了两对有序子序列,最后分别将小于和大于基准的每对有序子序列进行merge。
20. 集合算法 set
由于集合的低层容器是红黑树,因此集合中的元素是有序的,这样在遍历两个集合时,复杂度不是O(mn),而是O(m+n)。
includes
:判断集合1是否包含集合2. 基本思想是,遍历两个集合,依次判断集合2中的元素是否均在集合1中出现了。
set_union
:求两个集合的并集,如果两个集合中出现了相同的元素,则只算一次。
set_intersection
:求两个集合的交集,即只保留两个集合中都存在的元素。
set_difference
:两个集合的差集,即集合1中存在而集合2中不存在的元素。
set_symmetric_difference
:两个集合的对称差,即集合1中存在而2中不存在的元素或集合2中存在而集合1中不存在的元素。
21. 求极值 max/min element
遍历整个区间,找到其中最大/小的元素的值,返回的是指向最大/小值的迭代器。
22. 排列的后继和前驱 next/pre permutation
关于该算法在之前的一篇文章中有详细介绍,请参见 全排列及某排列的后继的求解及其STL实现的分析 .
23. 找第一次出现的位置 find first of
在第一个序列中依次查找第二个序列中某个元素第一次出现的位置,使用一个双重循环,外循环遍历第一个序列,内循环遍历第二个序列,只要找到一个就立即返回在序列1中的位置,没有找到则返回序列1的尾迭代器。
24. 查找序列中的子序列 find end
在序列1中查找是否存在序列2这样的子序列,返回最后一次查找结果。还有一个版本是针对双向迭代器的类偏特化版本。
25. 判断序列是否为堆 is heap
判断一个序列是否为堆,即不断地判断父节点是否大于其孩子节点,如果不大于则返回false,否则返回true。
26. 判断序列是否有序 is sorted
判断一个序列是否有序,只需要遍历序列并判断相邻的两个元素的大小关系是否一致即可。
Well Done!终于看完了这些算法了!其中旋转、排序、查找、合并算法是稍微复杂的,且做了一些优化,是需要仔细阅读和体会的。 2014.11.09 更新。
]]>stl_algobase.h
等。
在 stl_algobase.h
中定义的算法都比较简单基础,主要涉及区间相等判断、区间填充、求极值、交换、拷贝、字典序比较等算法,而其他诸如查找、计数、排序、旋转等算法则在文件 stl_algo.h
中实现。在algobase基本算法中,除了字典序比较、复制/拷贝算法外,其他都比较简单,这里先依次介绍这些简单的算法,然后再介绍字典序比较和拷贝算法。
由于这里很多算法比较简单(基本都在10行以内,甚至很多就一行代码),就不一一粘贴代码了。
iter_swap :将两个 ForwardIterators 所指的对象对调,通过申请一个临时变量、三次赋值,就完成了。
min/max :求两个数中的小、大者,还有一个版本可以指定的比较方法(仿函数)。
fill :将 [first, last)
内的所有元素改填为新值 value。
fill_n :将 [first, last)
内的前n个元素改填为新值 value,返回迭代器指向被填入的最后一个元素的下一位置。
mismatch :用来平行比较两个序列,指出两者之间的第一个不匹配点,返回一对迭代器(Iterators Pair),分别指向两序列中的不匹配点。
equal :判断两个序列在 [first, last)
区间内相等,如果第二个序列元素较多,将不予考虑,只有两个序列在各自区间内对应相等才返回true,否则返回false。
lexicographical_compare
以“字典序排列方式”对两个序列 [first, last)
和 [first2, last2)
进行比较。比较操作针对两个序列中的对应位置上的元素进行,直到某一对不相等或同时到达尾部或仁义序列到达尾部。该算法其实并不复杂,但有一点值得注意,那就当且仅当第一个序列字典序小于第二个序列时才返回true,以下是各种情况下的返回值:
源码如下:
1 2 3 4 5 6 7 8 9 10 |
|
除了这个默认的版本外,还有一个版本提供比较方法(仿函数)的参数。另外,对于纯字符串的比较,SGI STL还做了进一步优化,使用原生指针和C标准函数 memcmp()
进行比较,如下:
1 2 3 4 5 6 7 8 |
|
在很多应用程序中,复制copy是一个很常见的操作,特别是在赋值的时候。对于稍微复杂的对象,在不同的语言中赋值时会有一些差别,有的编程语言赋值仅仅是对等号右边的对象的一个引用,而并没有正真的产生一个新的对象,更不用说对象中可能包含的对象成员,例如Python当中的赋值、浅拷贝copy和深拷贝deepcopy等。
而STL 中的copy,除了简单的单一对象的拷贝之外,还有序列区间的拷贝等,这里就涉及到空间分配和时间效率问题。在C++中,复制操作主要是运用assignment operator(复制运算符) 或 copy constructor(拷贝构造函数),在STL的copy算法中使用的是前者,而对于某些具有trivial assignment operator的数据,则可以使用内存直接复制行为(例如C标准库函数memmove、memcpy等),就能极大的节省时间。SGI STL用尽各种办法,包括函数重载、型别特性、偏特化(partial specialization)等技巧(关于偏特化请参见 C++模板特化与偏特化),无所不用其极地加强效率。
除了上面提到的元素型别、偏特化等问题,还有元素复制顺序的问题。copy 算法是将原始区间 [first, last)
内的元素复制到目标区间 [result, result+last-first)
区间内,复制时既可以从 first 开始往 last 复制,但也可以从 last-1开始向 first 复制,后者在 STL 另取名为 copy_backward_。从后往前复制的好处在于,不用担心目标区间与原始区间有重叠,因为如果有重叠区域,那么简单的 copy 时,对于原始数据而言 [result, last)
区间的数据在被复制前被修改了,从而得不到预期的结果。当然,有一种情况使用 copy 不用担心这个问题,那就是对于迭代器为原生指针,使用 memmove (而不是 memcpy,关于二者的区别参见 memcpy() vs memmove())进行复制,此时 memmove 会先将整个区间复制下来,没有被覆盖的危险。
在介绍 copy 算法的源码具体实现前,根据源码及其注释再做一个简单的小结:copy 算法中的一些辅助函数有两个目的,其一是对于简单的数据类型尽量使用 memmove,其二是对于具有 RandomAccessIterators 的对象使用一个计数器来进行循环;除此之外,SGI STL针对编译器是否具有函数模板偏特化、类模板偏特化等进行了适配。下面是 copy 的源码,其中添加了比较详细具体的注释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
|
以上是 copy 的完整代码,关于复制还有两个接口,一个是 copy_n
,另一个是 copy_backward
,前者复制区间 [first, last)
中前 n 个元素,后者从last-1 往 first 复制,这里就不详细展开了。
stl_numberic.h
、numeric
、stl_relops.h
等。
STL 数值算法主要包含以下几个算法(来自C++文档):
下面一一介绍每个算法的实现。
该算法计算 init 和区间 [first, last) 内所有元素的总和。注意,必须提供 init 的初始值,这样即使 first=last 区间为空,仍能得到一个明确定义的值。当 init=0 时,即为计算 [first, last) 区间内所有元素的总和。具体实现有两个版本,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
第二个版本通过仿函数参数 binary_op 指定操作类型,可以实现其他方式的累计,例如累乘等(令init=1,binary_op=multiply)。
该算法用来计算区间 [first, last) 中相邻元素的差(或其他指定运算,结果[i]=当前元素[i]的值-前驱元素[i-1]的值),该算法也有两个版本,一个是指定运算为差,另一个传入仿函数(参数 _binary_op)指定具体运算,这里贴出第二个版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
该算法实现区间 [first1, last1) 和区间 [first2, first2+(last1-first1) ) 的一般内积(generalized inner product),公式为$init = init+(i) * ((first2+(i-first1)))$同样需要提供 init 的值(理由同accumulate)。另外还有一个版本,提供两个仿函数,分别指定上面公式中的加法和乘法。第一个版本的代码如下:
1 2 3 4 5 6 7 |
|
可以看到,这里其实没有判断第二个区间是否越界,所以在调用时需要我们自己注意,但一般来说计算内积的两个区间都是相同长度的。
该算法用来计算局部总和,将 *first
赋值给 *result
,将 *frist+*(first+1)
赋值给 *(result+1)
,依次类推,即有 result[i]=sum(*first..*(first+i))
,这是默认的操作为加法的版本,还有一个版本可以通过仿函数指定操作,以下是默认版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
该算法不是C++/STL标准,主要作用是将区间 [first, last) 的值赋值为 value,value+1,value+2,... 如下:
1 2 3 4 5 |
|
该算法也不是C++/STL标准,作用在于实现 x 的 n 次方的计算,通过将n分解为2的幂来计算。还有一个版本是用户可以指定运算,而不一定是乘法。默认版本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
饶了几道弯,主要看 func1实现即可。
]]>算法(Algorithm)是一个计算的具体步骤,常用于计算、数据处理和自动推理。Donald Knuth 在他的著作 The Art of Computer Programming 里对算法的特征归纳(来自wiki):
算法的核心是创建问题抽象的模型和明确求解目标,常见的算法有分治法、贪婪算法、动态规划、平摊分析等。再好的编程技巧,也无法让一个笨拙的算法起死回生,选择了错误的算法,便注定了失败的命运。
算法的时间复杂度是指算法需要消耗的时间资源。一般来说,计算机算法是问题规模 $n$ 的函数 $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$ 的不断增大,上述时间复杂度不断增大,算法的执行效率越低。
算法的空间复杂度是指算法需要消耗的空间资源。其计算和表示方法与时间复杂度类似,一般都用复杂度的渐近性来表示。同时间复杂度相比,空间复杂度的分析要简单得多。
很多算法能用来解决特定问题(如排序、查找、复制、比较、组合等),并获得数学上的性能分析与证明,这样的算法非常具有复用性,STL 的算法组件就总结了70+ 个极具复用价值的算法,包括排序(sorting)、查找(searching)、排列组合(permutation)等,以及用于数据移动、复制、删除、比较、组合、运算等算法。
某些特定的算法与特定的数据结构相关,例如二叉查找树和红黑树便是为了解决查找问题而发展出来的特殊数据结构,hashtable 拥有快速查找能力,又例如 max-heap 可以协助完成 heap sort,几乎可以说,特定的数据结构是为了实现某种特定的算法。这类与特定数据结构相关的算法,在前几篇介绍容器的文章中都有提到,而接下来几篇文章所要介绍的算法则是无特殊条件限制的空间中的某一段元素区间的算法,即泛型算法。
所有泛型算法的前两个参数都是一对迭代器(iterators),通常称为 first 和 last,用以标识算法的操作区间,STL 习惯采用前闭后开区间表示法,写成 [first, last)
,当 frist==last
时,表示的是空区间。这个 [first, last)
的必要条件是,必须能够进过 increment (累加)操作的反复运用,从 first 到 last,编译器本身无法强求这一点,如果这个条件不成立,会导致无法预料的结果。
前面讲迭代器时我们知道,STL有5类迭代器,他们是input、output、forward、bidirectional、random_access。_每个 STL 算法的声明,都表现出它所需要的最低程度的迭代器类型,例如 find()
需要一个 inputIterators 是最低要求,但也可以接受更高类型的,如 forwardIterators、bidirectionalIterators、randomAccessIterators,但如果传给它一个outputIterators,则会导致错误。将无效的迭代器传给某个算法,虽然是一种错误,却不能保证在编译时期就被捕捉出来,因为所谓的迭代器型别并不是真实的型别,他们只是 function template 的一种型别参数(type parameters)。
许多 STL 算法都有很多个版本,除了默认的只包含迭代器参数的实现之外,还有一个可以传入仿函数(functor)参数的版本,例如 unique()
缺省情况下使用 equality
操作符来比较两个相邻的元素,但如果这些元素的型别并未提供 equality
操作符,或如果用户希望定义自己的 equality
操作符,便可以传一个仿函数给另一个版本的 unique()
,有些算法干脆将这样的两个版本分为两个不同名的实体,如 find_if()
、replace_if()
等。
所谓质变算法(mutating algorithms),是指算法运算过程中,会更改区间[first, last)
内(迭代器所指)的元素内容,诸如复制(copy)、互换(swap)、替换(replace)、填充(fill)、删除(remove)、排列组合(permutation)、分割(partition)、随机重排(random shuffling)等,都属于此类。通常质变算法提供两个版本,一个是就地(in-place)进行,另一个是copy(另地进行)版本,将操作对象复制一份副本,然后在副本上进行修改并返回该副本。copy版一般以 _copy
作为函数名后缀,例如 replace_copy()
等。但并不是所有的质变算法都提供copy版,例如 sort 就没有。如果我们一定要使用 copy 版,需要我们自己先 copy 一份副本,然后再将副本传给相应的算法。
所谓非质变算法(nonmutating algorithms),是指算法运算过程中不会更改区间[first, last)
内的元素内容,诸如查找(find)、匹配(search)、计数(count)、巡访(for_each)_、比较(equal,mismatch)、寻找极值(max、min)等。
STL 算法的实现主要在 stl_algobase.h
、stl_algo.h
、stl_numeric.h
这3个文件中,其中 stl_numeric.h
主要是数值(numeric)算法,包括 adjecent_difference()
、accumulate()
、inner_product()
、partial_sum()
等,相关接口封装在 <numeric>
中。而其他算法如复制、填充、交换、求极值、排列、排序、分割等等算法则在剩下的那两个文件中,相关接口则封装在 <algorithm>
中。C++ 的 官方文档 将 STL 算法分为以下几类:
后续文章将分别介绍这些算法的具体实现。
上文提到过,很多算法是与底层的数据结构相关的,如何将算法独立于其所处理的数据结构之外,使它能够处理任何数据结构,或者在未知的数据结构(也许是 array,也许是vector,也许是list,也许是deque)上正确地实现操作,并不那么简单。其关键在于,需要把操作对象的型别加以抽象化,把操作对象的标示法和区间目标的移动行为抽象化。如此,整个算法也就在一个抽象层面了,这个过程称为算法的泛型化(generalized),简称泛化。
下面以查找算法的泛化过程为例详细介绍算法泛化的奇妙。对于查找算法,我们首先想到的是在一个整型数组中查找指定元素,一个基本的实现如下:
1 2 3 4 5 6 |
|
该函数在数组中查找指定值的元素,返回找到的第一个符合条件的元素的地址,如果没有找到就返回最后一个元素的下一个位置(称为end)。当没有找到时,这里为什么要返回地址值(end)而不返回null呢?这是为了方便调用后续的泛型算法,但实际上该算法本身还是与容器相关的,而且暴露了很多容器的实现细节(如arraySize等)。为了让该算法适用于所有类型的容器,其操作应该更抽象化,可以让 find 接受两个指针作为参数,标识出一个操作区间,如下:
1 2 3 4 |
|
该函数在区间 [begin, end)
内查找 value,并返回一个指针。这样做之后,已经隐藏了容器内部特性了,但不足的是,要求元素的数据类型为整型,我们可以通过模板参数来解决这个问题:
1 2 3 4 5 6 |
|
除了参数模板化之外,值得注意的是其中待查找的对象是以常引用的方式传递,这样对于大对象非常有利。于是,现在的find函数几乎适用于任何容器——只要该容器允许指针,而指针又都支持inequality(判断不相等)操作符、dereference(取值)操作符、(prefix)increment(前置式递增)操作符、copy(复制)行为这四种操作。
但这个版本还不够泛化,因为参数被限制为指针,而那些支持以上四种操作、行为很像指针的某些对象就无法使用 find 了。在STL中有迭代器,它是一种行为类似指针的对象,是一种smart pointers,使用迭代器实现 find 如下:
1 2 3 4 5 |
|
这便是一个完全泛化的find 函数,它与STL中的find函数几乎一模一样(不同之处可自行查看STL源码)。了解和理解了STL算法的泛化过程,就很容易看懂STL中很多其他的算法了。
]]>stl_hash_set.h
、stl_hash_map.h
等文件。
需要说明的是,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 的定义的主要代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
hashmap 是以 hashtable 为底层容器的 map,而 map 是同时拥有实值(value)和键值(key),且不允许键值重复。
而 hash_multi_map 是以 hashtable 为底层容器的 map,且允许键值重复。
stl_hashtable.h
、stl_hash_fun.h
等文件。
在数据结构中我们知道,有种数据结构的插入、删除、查找等操作的性能是常数时间,但需要比元素个数更多的空间,这种数据结构就是哈希表。哈希表的基本思想是,将数据存储在与其数值大小相关的地方,比如对该数取模,然后存储在以余数为下表的数组中。但这样会出现一个问题,就是可能会有多个数据被映射到同一个存储位置,即出现了所谓的“碰撞”。哈希表的主要内容就是解决“碰撞”问题,一般而言有以下几种方法:线性探测、二次探测、开链等。
简单而言,就是在出现“碰撞”后,寻找当前位置以后的空档,然后存入。如果找到尾部都没有空档,则从头部重新开始找。只要空间大小比元素个数大,总能找到的。相应的,元素的查找和删除也与普通的数组不同,查找如果直接定位到相应位置并找到或是空档,就可以确定存在或不存在,而如果定位到当前位置非空且与待查找的元素不同,则要依序寻找后续位置的元素,直到找到或移到了空档。删除则是采用懒惰删除策略,即只标记删除记号,实际删除操作则待表格重新整理时再进行。
与线性探测类似,但向后寻找的策略是探测距当前位置为平方数的位置,即 $index = H+i^{2}$ 。但这样会有一个问题,那就是能否保证每次探测的是不同的位置,即是否存在某次插入时,探测完一圈后回到自己而出现死循环。
这种方法是将出现冲突的元素放在一个链表中,而哈希表中只存储这些链表的首地址。SGI STL中就是使用这种方法来解决“碰撞”的。
由于使用开链的方法解决冲突,所以要维护两种数据结构,一个是 hash table,在 STL 中称为 buckets,用 vector 作为容器;另一个是链表,这里没有使用 list 或 slist 这些现成的数据结构,而是使用自定义 __hashtable_node
,相关定义具体如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
这里 hashtable 的模板参数很多,其含义如下:
Val: 节点的实值类型 Key: 节点的键值类型 HashFcn: 哈希函数的类型 ExtractKey: 从节点中取出键值的方法(函数或仿函数) EqualKey: 判断键值相同与否的方法(函数或仿函数) Alloc: 空间配置器,默认使用 std::alloc
虽然开链法并不要求哈希表的大小为质数,但 SGI STL 仍然以质数来设计表的大小,并将28个质数(大约2倍依次递增)计算好,并提供函数来查询其中最接近某数并大于某数的质数,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
首先只考虑比较简单的情况,即哈希表的大小不需要调整,此时空间配置主要是链表节点的配置,而 hashtable 使用 vector 作为容器,链表节点的空间配置(分配和释放)如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
哈希表的插入操作有两个问题要考虑,一个是 是否允许插入相同键值的元素,另一个是 是否需要扩充表的大小。在 STL 中,首先是判断新插入一个元素后是否需要扩充,判断的条件是插入后元素的个数大于当前哈希表的大小;而是否允许元素重复则通过提供 insert_unique
和 insert_equal
来解决。相关代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|
允许键值重复的插入操作类似的,只是为了确保相同键值的挨在一起,先要找到相同键值的位置,然后插入。
复制和清空时分别涉及空间的分配和释放,所以在这里也介绍一下。首先是复制操作,需要先将目标 hashtable 清空,然后将源 hashtable 的 buckets 中的每个链表一一复制,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
hashtable 的迭代器是前向的单向迭代器,遍历的方式是先遍历完一个 list 然后切换到下一个 bucket 指向的 list 进行遍历。以下是 hashtable 的迭代器的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
在第三节中介绍 hashtable 的数据结构时,提到了一个哈希函数类型的模板参数,从键值到索引位置的映射由这个哈希函数来完成,实际中是通过函数 _M_bkt_num_key
来完成这个映射的,如下:
1 2 3 4 5 6 |
|
这里的 _M_hash
是一个哈希函数类型的成员,可以看做是一个函数指针,真正的函数的定义在 <stl_hash_fun.h>
中,针对 char,int,long 等整数型别,这里大部分的 hash function 什么也没做,只是重视返回原始值,但对字符串(const char* )设计了一个转换函数,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
|
关于函数调用操作符的更多介绍,可以参见我的另一篇文章 【C语言函数指针与C++函数调用操作符】。
]]>stl_map.h
、stl_multimap.h
、stl_pair.h
、map.h
、 multimap.h
、 map
等文件。
map 的特性是,所有元素都是键值对,用一个 pair 表示,pair 的第一个元素是键值(key),第二个元素是实值(value),map 不允许两个元素的键值相同。
与 set 类似的,map 也不允许修改 key 的值,但不同的是可以修改 value 的值,因此 map 的迭代器既不是一种 constant iterators,也不是一种 mutable iterators。同样的,map的插入和删除操作不影响操作之前定义的迭代器的使用(被删除的那个元素除外)。
与 set 不同的是,map 没有交、并、差等运算,只有插入、删除、查找、比较等基本操作。
由于 map 的元素是键值对,用 pair 表示,下面是它的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
然后是 map 的定义,大体上和 set 差不多,只是在使用 RB-tree 作为容器时,传入的模板参数是一个 pair,主要代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
可以看到,基本也是对底层容器 RB-tree 的一个简单的封装。
multimap 与 map 的关系和 multiset 与 set 的关系一样,即 multimap 允许键值(key)重复,插入操作使用 RB-tree 的 insert_equal
,其他都和 map 一样,这里就不贴源代码了。
stl_set.h
、 stl_multiset.h
、 set.h
、 multiset.h
、 set
等文件。
set 即集合,相比于其他容器有些特别。首先是它的每个元素是唯一的,即不允许有相同的值出现。其次,作为一种关联容器,set 的元素不像 map 那样可以同时拥有实值(value)和键值(key),set 元素的键值就是实值,实值就是键值。
由于 set 的实质和键值相同,共用同一个内存空间,而 set 的底层容器为红黑树(中序遍历有序),因此不能对其键值进行修改,否则会破坏其有序特性。为避免非法修改操作,在SGI STL的实现中,set<T>::iterator
被定义为 RB-tree 底层的 const_iterator,_杜绝写入操作。set 与 list 有一个相似的地方是,元素插入、删除后,之前的迭代器依然有效(被删除的那个元素的迭代器除外)。
我们知道集合有一些特殊的操作,诸如并、交、差等,在STL的 set 中,默认也提供了这些操作,如交集 set_intersection
、联集 set_union
、差集 set_difference
和对称差集 set_symmetric_difference
等。与之前那些线性容器不同的是,这些 set 的操作并不是在 set 内部实现的,而是放在了算法模块(algorithm)中,其具体实现在后面的算法章节中会具体介绍。
前面多次提到 set 的底层采用 RB-tree 容器,这是因为 RB-tree 是一种比较高效的平衡二叉搜索树,能够很好的满足元素值唯一的条件,而且查找效率高。由于 RB-tree 已实现了很多操作,因此 set 基本上只是对 RB-tree 进行了一层简单的封装。下面是其实现的主要代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
可以看到基本都是调用 _M_t
的方法来实现的,而这里的 _M_t
是一个红黑树对象。
multiset 的特性和用法与 set 基本相同,唯一差别在于它允许有重复的键值,因此它的插入操作使用的底层机制是 RB-tree 的 insert_equal()
而不是 insert_unique()
,下面是 multiset 的主要代码,主要列出了与 set 不同的部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
其他部分基本与 set 一样。
]]>stl_tree.h
这个文件。
之前几篇文章详细介绍了SGI STL中序列式容器的实现,并提到过STL中还有一类关联式的容器。标准的STL管理师容器分为 set(集合)和map(映射表)两大类,以及这两大类的衍生体multiset(多键集合)和multimap(多键映射表),这些容器的底层机制均以RB-Tree(红黑树)完成。RB-Tree是一种非常高效的数据结构,它本质上是一种平衡的二叉搜索树,因而其查找的平均时间复杂度为元素总个数的对数(即logN)。在STL中RB-Tree是一个独立的容器,但并没有对用户的公开接口,仅提供给STL的set和map使用。
SGI STL在标准STL之外,还提供了一类关联式容器——hash table(哈希表),以及以此为低层机制的hash set(散列集合)、hash map(散列映射表)、hash multiset(散列多键集合)和hash multimap(散列多键映射表)。相比于RB-Tree,hash table的时间效率更高,插入、删除、查找的时间复杂度均为常数时间,但需要比元素总个数多得多的空间。
本文接下来主要介绍树及RB-Tree相关的内容,后续文章将具体介绍SGI STL中set、map、hash table的实现。
树是一种非常常见而且实用的数据结构,几乎所有的操作系统都将文件存放在树状结构里,几乎所有编译器需要实现一个表达式树(expression tree),文件压缩所用的哈夫曼算法也需要用到树状结构,数据库所使用的B-tree则是一种相当复杂的树状结构。
关于树的一些基本概念相信大家都比较熟悉,这里就不赘述了,如果需要可以google或看wikipedia,这里重点重温一下数据结构里的二叉搜索树、平衡二叉搜索树、AVL树。
二叉搜索树:任何节点的键值大于其左子树中每一个节点的键值,并小于其右子树中的每一个节点的键值。根据二叉搜索树的定义可知,按照中序遍历该树可以得到一个有序的序列。平均情况下,二叉搜索树可以提供对数时间的插入和访问。其插入和查找的算法也很简单,每次与根节点的键值进行比较,小于根节点的键值则往根节点的左子树插入或查找,大于则往右子树插入或查找,无论是递归实现还是非递归实现都很简单。
平衡二叉搜索树:上面提到二叉搜索数的平均性能为对数时间,这是因为二叉搜索树的深度与数据插入的顺序有关,如果插入的数据本身就比较有序,那么就会产生一个深度过大的树,甚至会退化为一个链表的结构,这中情况下,其查找的效率就是线性时间了。平衡二叉搜索树就是为了解决这个问题而产生的,“平衡”的意义是,没有任何一个节点过深。不同的平衡条件造就出不同的效率表现,以及不同的实现复杂度,如 AVL-Tree、RB-Tree、AA-Tree _等。他们都比简单的二叉搜索树要复杂,特别是插入和删除操作,但他们可以避免高度不平衡的情况,因而查找时间较快。
AVL树:AVL-tree(Adelson-Velskii-Landis tree)是一个加上了“额外平衡条件”的二叉搜索树,是一种高度平衡的二叉搜索树,它的这个额外的条件为:任何节点的左右子树高度相差最多1。该条件能够保证整棵树的高度为logN,但其插入和删除的操作也相对比较复杂,因为这些操作可能导致树的失衡,需要调整(或旋转)树的结构,使其保持平衡。插入时出现失衡的情况有如下四种(其中X为最小失衡子树的根节点):
- 插入点位于X的左子节点的左子树——左左;
- 插入点位于X的左子节点的右子树——左右;
- 插入点位于X的右子节点的左子树——右左;
- 插入点位于X的右子节点的右子树——右右。
情况1和4对称,称为外侧插入,可以采用单旋转操作调整恢复平衡;2和3对称,称为内侧插入,可以采用双旋转操作调整恢复平衡:先经过一次旋转变成左左或右右,然后再经过一次旋转恢复平衡。1和2的实例如下图:
图中从中间到最右情况1的恢复平衡的旋转方法,只是其中节点3为新插入的元素;而最左到最右是情况2的恢复平衡的旋转方法,其中节点4为新插入的元素。情况3和4分别与2和1对称,其调整方法也很类似,就不赘述了。
RB-tree是另一种被广泛使用的平衡二叉搜索树,也是SGI STL唯一实现的一种搜索树,作为关联式容器的底层容器。RB-tree的平衡条件不同于AVL-tree,但同样运用了单旋转和双旋转的恢复平衡的机制,下面我们详细介绍RB-tree的实现。
所谓RB-tree,不仅仅是一个二叉搜索树,而且必须满足以下规则:
- 每个节点不是红色就是黑色;
- 根节点为黑色;
- 每个叶子节点(NIL)为黑色;
- 如果节点为红,其左右子节点必为黑;
- 对每个节点,从该节点到其子孙中的叶子节点的所有路径上所包含的黑节点数目相同。
上面的这些约束保证了这个树大致上是平衡的,这也决定了红黑树的插入、删除、查询等操作是比较快速的。 根据规则5,新增节点必须为红色;根据规则4,新增节点之父节点必须为黑色。当新增节点根据二叉搜索树的规则到达其插入点时,却未能符合上述条件时,就必须调整颜色并旋转树形。下图是一个典型的RB-tree(来自wiki):
SGI STL中RB-tree的数据结构比较简单,其中每个节点的数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
其中每个节点主要包含一个标志颜色的bool变量 _M_color
,3个节点指针 _M_parent
, _M_left
, _M_right
,2个成员函数 _S_minimum
和 _S_maximum
(分别求取最小(最左)、最大(最右)节点)。
而RB-tree的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
可以看到RB-tree的空间配置器是 simple_alloc
配置器,按 _Rb_tree_node
节点大小分配空间,每次分配或释放一个节点的空间。
要将RB-tree实现为一个泛型容器并用作set、map的低层容器,迭代器的设计是一个关键。RB-tree的迭代器是一个双向迭代器,但不具备随机访问能力,其引用(dereference)和访问(access)操作与list十分类似,较为特殊的是自增(operator++)和自减(operator--)操作,这里的自增/自减操作是指将迭代器移动到RB-tree按键值大小排序后当前节点的下一个/上一个节点,也即按中序遍历RB-tree时当前节点的下一个/上一个节点。RB-tree的迭代器的定义如下:
1 2 3 4 5 6 7 8 9 10 11 |
|
可以看到RB-tree的自增和自减操作是使用基迭代器的increment和decrement来实现的,这里仅分析自增操作的实现(自减操作类似的)。RB-tree的自增操作实际上是寻找中序遍历下当前节点的后一个节点,其代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
下面几节主要介绍一下RB-tree的基本操作。
RB-tree提供两种插入操作,insert_unique()
和 insert_equal()
,顾名思义,前者表示被插入的节点的键值在树中是唯一的(如果已经存在,就不需要插入了),后者表示可以存在键值相同的节点。这两个函数都有多个版本,下面以后者的最简单版本(单一参数:被插入的节点的键值)为实例进行介绍。下面是 insert_equal
的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
至此新节点插入完成。然而,由于新节点的插入,可能会引起RB-tree的性质4,5的破坏,需要对RB-tree进行旋转并对相关节点重新着色,这都是在 _Rb_tree_rebalance
这个函数中实现的,下面就主要介绍RB-tree是如何恢复平衡。
RB-tree的调整与AVL-tree类似但更复杂,因为不仅仅需要旋转,还需要考虑节点的颜色是否符合要求。破坏RB-tree性质4的可能起因是插入了一个红色节点、将一个黑色节点变为红色或者是旋转,而破坏性质5的可能原因是插入一个黑色的节点、节点颜色的改变(红变黑或黑变红)或者是旋转。
在讨论 RB-tree 插入操作之前必须明白一点,那就是新插入的节点的颜色必为红色(调整前),因为插入黑点会增加某条路径上黑结点的数目,从而导致整棵树黑高度的不平衡。但如果新结点的父结点为红色时(如下图所示),将会违反红黑树的性质:一条路径上不能出现父子同为红色结点。这时就需要通过一系列操作来使红黑树保持平衡。为了清楚地表示插入操作以下在结点中使用“N”字表示一个新插入的结点,使用“P”字表示新插入点的父结点,使用“U”字表示“P”结点的兄弟结点,使用“G”字表示“P”结点的父结点。插入操作分为以下几种情况:
1)、树为空
此时,新插入节点为根节点,上面说过新插入节点均为红色,这不符合RB-tree的性质2,只需要将新节点重新改为黑色即可。
2)、黑父
如果新节点的父结点为黑色结点,那么插入一个红点将不会影响红黑树的平衡,此时插入操作完成。红黑树比AVL树优秀的地方之一在于黑父的情况比较常见,从而使红黑树需要旋转的几率相对AVL树来说会少一些。
3)、红父
这种情况就比较复杂。由于父节点为红,所以祖父节点必为黑色。由于新节点和父节点均为红,所以需要重新着色或进行旋转,此时就需要考虑叔父节点的颜色,进而可能需要考虑祖父、祖先节点的颜色。
3.1)、叔父为红
只要将父和叔结点变为黑色,将祖父结点变为红色即可,如下图所示:
但由于祖父结点的父结点有可能为红色,从而违反红黑树性质。此时必须将祖父结点作为新的判定点继续向上(迭代)进行平衡操作。
3.2)、叔父为黑
当叔父结点为黑色时,需要进行旋转,有4中情况(类似AVL),以下图示了所有的旋转可能:
可以观察到,当旋转完成后,新的旋转根全部为黑色,此时不需要再向上回溯进行平衡操作,插入操作完成。篇幅原因,相关代码这里就不粘贴出来了,要注意的一点就是case1和case2的变色方案是一样的,虽然从上图中看一个是P由红变黑,一个是N由红变黑,但实际上在case2中,经过一次旋转后,迭代器所指向的节点已经发生改变,这样刚好使得这两个case的变色方案相同,均为P由红变黑而G由黑变红。case3与case4的变色方案也是类似的。
相比于插入操作,RB-tree的删除操作更加复杂。在侯捷的书上并没有讲删除操作,而在算法导论上是有专门的一节内容的,wiki上也有详细的讲述。限于篇幅,这里指讲解一个大概的思路,更详细的介绍请参见wiki或算法导论。RB-tree删除操作的基本思路是这样的,首先按照一般的二叉搜索树进行节点的删除,然后对RB-tree相关节点进行变色或旋转。
一般的二叉搜索树删除节点的基本思路是:首先找到待删除节点位置,设为D。如果D同时有左右子树,那么用D的后继(右孩子的最左子节点,该后继最多有一个子节点——右孩子)替代D(注意:这里的替代是只key的替代,color不变,仍为D的color),从而将删除位置转移到该后继节点(成为新的D,为叶子节点或只有右孩子)。于是,我们只需要讨论删除只有一个儿子的节点的情况(如果它两个儿子都为空,即均为叶子,我们任意将其中一个看作它的儿子),设这个儿子节点为N,这仍然需要分三种情况:
1)D为红
这种情况比较简单。由于D为红色,所以它的父亲和儿子一定是黑色的,我们可以简单的用它的黑色儿子替换它,并不会破坏性质3和性质4。通过被删除节点的所有路径只是少了一个红色节点,这样可以继续保证性质5。
2)D为黑且N为红
如果只是去除这个黑色节点,用它的红色儿子顶替上来的话,会破坏性质5,可能会破坏性质4,但是如果我们重绘它的儿子为黑色,则曾经通过它的所有路径将通过它的黑色儿子,这样可以继续保持性质5,同时也满足性质4。
3)D为黑且N为黑
这是一种复杂的情况。我们首先把要删除的节点D替换为它的(右)儿子N,在新树中(D被N覆盖),设N的父节点为P,兄弟为S,SL为S的左儿子,SR为S的右儿子。此时,以N为根节点的子树的黑高度减少了一,与S为根节点的子树的黑高度不一致,破坏了性质5。为了恢复,可以分为如下情形:
3.1)N为根节点
已经满足所有性质,不需要调整。
3.2) N是它父亲P的左儿子
case1、S为红色:将P改为红色,S改为黑色,以P为中心左旋,旋转后SL为新的S,SL和SR是新的S的左右孩子,此时case1就转化为了case2或case3或case4;
注:case2~4中S均为黑色(否则是case1)。
case2、SL、SR同为黑色:将S改为红色,这样黑高度失衡的节点变为P,转到3.1)重新开始判断和调整;
case3、SR为黑:此时SL为红(否则是case2)。将S改为红色,SL改为黑色,然后以S为中心右旋,旋转后SL为新的S,而原S成为SR且为红色,这就将case3变成了case4;
case4、SR为红:以P为中心左旋,然后交换P和S的颜色,最后将SR改为黑色,即可完成调整。可以看到调整过程与SL的颜色无关。
3.3)N是它父亲P的右儿子
与3.2)类似,这里就不详细展开了。
RB-tree是一个二叉搜索树,元素的查询是其拿手项目,非常简单,以下是RB-tree提供的查询操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
关于RB-tree基本就介绍到这里了,主要是RB-tree的定义、数据结构、插入删除和查找等基本操作,其中最主要也最困难的就是插入和删除操作中恢复平衡的方法。另外,还介绍了二叉搜索树的基本概念和高度平衡的AVL树,可以看到,AVL树保持平衡的方法非常简单易懂,而RB-tree由于引入了节点的颜色属性,使得理解起来相对比较困难,那么问题就来了,为什么不用AVL-tree而用RB-tree作为set和map的低层容器呢?
这个问题要问STL的实现者了,其实AVL-tree和RB-tree的平均性能在 AVL-tree的wiki _上是有严格的数学公式的,AVL的平均高度为 $1.44logN$ ,而RB-tree的平均高度为 $2logN$ ,这些数据的来历也有相关的论文,感兴趣的可以更深入的看看。
heap
、stl_heap.h
、heap.h
、stl_queue.h
、queue
等几个文件。
前面分别介绍了三种各具特色的序列式容器 —— vector、list和deque,他们几乎可以涵盖所有类型的序列式容器了,但本文要介绍的heap则是一种比较特殊的容器。其实,在STL中heap并没有被定义为一个容器,而只是一组算法,提供给priority queue(优先队列)。故名思议,priority queue 允许用户以任何次序将元素放入容器内,但取出时一定是从优先权最高的元素开始取,binary max heap(二元大根堆)即具有这样的特性,因此如果学过max-heap再看STL中heap的算法和priority queue 的实现就会比较简单。
要实现priority queue的功能,binary search tree(BST)也可以作为其底层机制,但这样的话元素的插入就需要O(logN)的平均复杂度,而且要求元素的大小比较随机,才能使树比较平衡。而binary heap是一种完全二叉树的结构,而且可以使用vector来存储:
1 2 3 4 5 6 7 |
|
另外只需要提供一组heap算法,即元素插入和删除、获取堆顶元素等操作即可。
为了满足完全二叉树的特性,新加入的元素一定要放在vector的最后面;又为了满足max-heap的条件(每个节点的键值不小于其叶子节点的键值),还需要执行上溯过程,将新插入的元素与其父节点进行比较,直到不大于父节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
对heap进行pop操作就是取顶部的元素,取走后要对heap进行调整,是之满足max-heap的特性。调整的策略是,首先将最末尾的元素放到堆顶,然后进行下溯操作,将对顶元素下移到适当的位置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
最后,我们来看看如何从一个初始序列来创建一个heap,有了前面的 adjust_heap
,创建heap也就很简单了,只需要从最后一个非叶子节点开始,不断调用堆调整函数,即可使得整个序列称为一个heap:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
上一篇文章中讲到stack和queue都是基于deque实现的,这里的priority queue是基于vector和heap来实现的,默认使用vector作为容器,而使用heap的算法来维持其priority的特性,因此priority queue也被归类为container adapter。其具体实现的主要代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
值得一提的是,priority queue也没有迭代器,不能对其进行遍历等操作,因为它只能在顶部取和删除元素,而插入元素的位置也是确定的,而不能有用户指定。
关于heap和priority queue的内容就介绍到这里了,而序列式容器的介绍也到此结束了。
deque
、stl_deque.h
、deque.h
、stack
、stl_stack.h
、queue
、stl_queue.h
等几个文件。
前面分别介绍了连续式存储的序列容器vector和以节点为单位链接起来的非连续存储的序列容器list,这两者各有优缺点,而且刚好是优缺互补的,那么何不将二者结合利用对方的优点来弥补己方的不足呢,于是这就有了强大的deque。
没错,与我们在数据结构中学到的固定连续空间的双端队列不同,STL中的deque是分段连续的空间通过list链接而成的序列容器,它结合了vector与list的存储特性,但与vector和list都不同的是deque只能在首部或尾部进行插入和删除操作,这个限制在一定程度上简化了deque实现的难度。由于使用分段连续空间链接的方式,所以deque不存在vector那样“因旧空间不足而重新配置新的更大的空间,然后复制元素,再释放原空间”的情形,也不会有list那样每次都只配置一个元素的空间而导致时间性能和空间的利用率低下。
deque由一段一段连续空间串接而成,一旦有必要在deque的头部或尾端增加新的空间,便配置一段定量连续的空间,串接在deque的头部或尾端。deque的最大任务,就是在这些分段连续的空间上维护其整体连续的假象,并提供随机存取的接口。deque采用一块所谓的map(注意:不是STL中map容器,而是类似于vector)作为主控(为什么不使用list呢?),这块map是一个连续空间,其中每个元素都是一个指针,指向一段连续的空间,称为缓冲区,它才是deque的真正存储空间。SGI中允许指定缓冲区的大小,默认是512字节。除此之外,还有start和finish两个指针,分别指向第一个缓冲区的第一个元素和最后一个缓冲区的最后一个元素。其数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
由于deque涉及到两种类型(map和buffer)数据的空间配置,因此deque定义了两个专属的配置器 _Map_alloc_type
和 _Node_alloc_type
:
1 2 3 4 5 6 7 |
|
而这里的 _Alloc
使用的都是STL默认的 alloc
这个配置器,因此这两个配置器实际上都是 alloc
类型的配置器,即SGI的第二级配置器。
在定义一个deque时,默认调用基类的构造函数,产生一个map大小为0的空的deque,随着第一次插入元素,由于map大小不够,需要调用_M_push_back_aux
进而调用 _M_reallocate_map
进行map的空间配置,如果初始的map不为空,还需要对map进行“分配新空间,复制,释放元空间”的操作,如果从头部插入同样的道理,这是就是map的配置逻辑(实际中,还有一种情况,就是map的前后剩余的node数不同,例如前部分都空着,而后面插入后溢出了,这时可以考虑在map内部移动,即将后半部分整体往前移动一定距离)。其中_M_reallocate_map
的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
那么每个连续的缓冲区buffer(或node)是在什么时候配置呢?它是在map中实际使用到的最后一个node不够用时但map还可以继续在这个node后面加入node时(即map非满而node满时),在 _M_push_back_aux
中调用 _M_allocate_node
来分配,相关函数都比较简单,这里就不贴了。
以上主要是空间分配相关的,那么在 pop
的时候,空间的释放又是怎样的呢?这里也需要判断是否当前node全部被 pop
了,如果是的则需要释放这个node所占用的空间。如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
deque是分段连续空间,前面也提到了deque使用的是Bidirectional Iterators,因此deque的迭代器主要需要实现operator++
和operator--
。要实现这两个操作,需要考虑当前指针是否处于buffer的头/尾,如果在buffer的头部而需要前移(或尾部需要后移),就需要将buffer往前/后移一个,在SGI中是通过调用 _M_set_node
来实现的。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
使用 --
操作符向前移动的同理,这里就不赘述了。
deque中最常用的莫过于 push
和 pop
操作了,这些操作在前面的空间配置中基本已经介绍了,这里就主要介绍一下 clear
、 erase
和 insert
操作吧。
(1)clear
该函数的作用是清除整个deque,释放所有空间而只保留一个缓冲区:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
(2)erase
该函数的作用是清除 [first,last) 间的所有元素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
(3)insert
该函数的作用是在某个位置插入一个元素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
deque原本只能在头部或尾部插入元素的,提供了insert之后,就可以任何位置插入元素了。
由于deque可以从首位两端插入或剔除元素,所以只需要对其进行简单的封装就可以分别实现先进先出(FIFO)的stack和先进后出(FILO)的queue了。stack和queue中都有一个deque类型的成员,用做数据存储的容器,然后对deque的部分接口进行简单的封装,例如stack只提供从末端插入和删除的接口以及获取末端元素的接口,而queue则只提供从尾部插入而从头部删除的接口以及获取首位元素的接口。像这样具有“修改某物接口,形成另一种风貌”的性质的,称为配接器(adapter),因此STL中stack和queue往往不被归类为容器(container),而被归类为容器配接器(container adapter)。(关于配接器后面文章还会具体介绍)
下面只给出stack的基本实现,并加以注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
值得一提的是,stack和queue都没有迭代器,因此不能对stack或queue进行遍历。但他们提供了 operator ==
和 operator<
这两个比较大小的操作符:
1 2 3 4 5 6 7 8 |
|
另外,除了使用默认的deque作为stack和queue的容器之外,我们还可以使用list或其他自定义的容器,只需要实现了stack或queue需要的接口,使用方法很简单:
1 2 |
|
即只需要指定模板中第二个参数即可。
关于deque的内容就介绍到这里了。
list
、stl_list.h
、list.h
等几个文件。
STL中也实现了链表这种数据结构,list是STL标准的双向链表,而slit是SGI的单链表。相比于vector的连续线性空间而言,list即有有点也有缺点:优点是空间分配更灵活,对任何位置的插入删除操作都是常数时间;缺点是排序不方便。list和vector是比较常用的线性容器,那么什么时候用哪一种容器呢,需要视元素的多少、元素构造的复杂度(是否为POD数据)以及元素存取行为的特性而定。限于篇幅,本文主要介绍list的内容,关于单链表slist可以参见源码和侯捷的书。
在数据结构中,我们知道链表的节点node和链表list本身是不同的数据结构,以下分别是node和list的数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
在list中的 _M_node
其实指向一个空白节点,该空白节点的 _M_data
成员是没有被初始化的,实际上该节点是链表的尾部,后面将list的迭代器还会提到这样做的好处。
list缺省使用 alloc (即 __STL_DEFAULT_ALLOCATOR
) 作为空间配置器,并据此定义了另外一个 list_node_allocator
,并定义了_M_get_node
和_M_put_node
两个函数,分别用于分配和释放空间,为的是更方便的以节点大小为配置单位。除此之外,还定义了两个_M_create_node
函数,在分配空间的同时调用元素的构建函数对其进行初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
在list的构造和析构函数、插入、删除等操作中设计到空间的配置。由于list不涉及同时分配多个连续元素的空间,因此用不到SGI的第二层配置器。
由于list的节点在内存中不一定连续存储,其迭代器不能像vector那样使用普通指针了,由于list是双向的链表,迭代器必须具备前移、后移的能力,所以它的迭代器是BidirectionalIterators,即双向的可增可减的,以下是list的迭代器的设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
list有一个重要性质,插入操作(insert)和接合操作(splice)都不会造成原有list迭代器失效,而list的删除操作(erase)也只对“指向被删除元素”的那个迭代器失效,其他迭代器不受任何影响。
list的常用操作有很多,例如最基本的push_front
、push_back
、pop_front
、pop_back
等,这里主要介绍一下clear
、remove
、unique
、transfer
这几个。
(1)clear
clear 函数的作用是清楚整个list的所有节点。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
(2)remove
remove 函数的作用是将数值为value的所有元素移除。
1 2 3 4 5 6 7 8 9 10 |
|
(3)unique
unique函数的作用是移除相同的连续元素,只有“连续而且相同”的元素,才回被移除到只剩一个。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
(4)transfer
transfer的作用是将 [first, last) 内的所有元素移动到 position 之前。它是一个私有函数,它为其他常用操作如 splice、sort、merge 等的实现提供了便利。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
关于list的内容就介绍到这里了。
]]>vector
、stl_vector.h
、vector.h
等几个文件。
在数据结构的课程中,我们主要研究数据的特定排列方式,以利于搜索、排序等算法,几乎可以说,任何特定的数据结构都是为了实现某种特定的算法。STL 容器即由一个个特定的数据结构组成,例如向量(vector),链表(list),堆栈(stack),队列(queue),树(tree),哈希表(hash table),集合(set),映射(map)等,根据数据在容器中的排列特性,这些数据接口分为序列式容器(sequence container)和关联式容器(association container)两种,本文主要解读SGI STL中的序列式容器。
所谓序列式容器,其中的元素可序(ordered),但未必有序(sorted)。C++ 本身提供了一个序列式容器——数组(array),STL中还提供了向量(vector),链表(list),堆栈(stack),队列(queue),优先队列(priority queue)等,其中stack和queue只是将deque(双端队列)设限而得到的,技术上可以被归为一种配接器(adaptor)。本系列文章将依次解读SGI STL各容器的关键实现细节。
在STL中,vector的空间在物理上就是连续的,而且是可以动态扩展的,这里的动态扩展,不需要用户去处理溢出的问题,而只需要关心上层逻辑。vector连续物理空间的动态扩展技术是该容器的关键,它主要分为三个步骤:配置新空间,数据移动,释放旧空间。这三个步骤执行的次数以及每次执行时的效率是影响最终 vector 效率的关键因素。为了减少执行的次数,就需要未雨绸缪,每次扩充空间时,成倍增长。而每次执行的效率,就主要是数据移动的效率了。下面,我们依次介绍vector的数据结构,使用的空间配置器和迭代器,以及常用操作。
vector 的数据结构
vector的数据结构很简单,就是一段连续的物理空间,包含起止地址以及已用到的空间的末尾地址这三个成员:
1 2 3 4 5 6 7 8 9 |
|
其中 _M_finish
是当前使用到的空间的结束地址,而 _M_end_of_storage
是可用空间的结束地址,前者小于等于后者,当新加入元素使得前者大于后者之后,就需要进行空间扩充了。
vector的空间配置器 STL 默认的 alloc
即 __default_alloc_template
配置器,即第二级配置器,它对于 POD(plain old data) 类型数据使用内建内存池来应对内存碎片问题,关于该默认配置器的更多介绍请参见本系列第2篇文章 深入理解STL源码(1) 空间配置器 . 除此之外,SGI vector 还定义了一个 data_allocator
,为的是更方便的以元素大小为配置单位:
1 2 3 4 5 |
|
关于 simple_alloc
的内容见前面的文章,它其实就是简单的对 malloc
等的加一层封装。
vector的内存是在vector的构造或析构、插入元素而容量不够等情况下,需要进行配置。vector 提供了很多的构造函数,具体可见源代码,而更详细的列表并涉及各个版本的说明的列表可以参见C++的文档:cpp references.
由于vector使用的物理连续的空间,需要支持随机访问,所以它使用的随机访问迭代器(Random Access Iterators)。也正由于vector使用连续物理空间,所以不论其元素类型为何,使用普通指针就可以作为它的迭代器:
1 2 3 4 |
|
注意:vector中所谓的动态增加大小,并不是在原空间之后接连续新空间(因为无法保证原空间之后尚有可供配置的空间),而是以原大小的两倍另外配置一块较大空间,然后将原内容拷贝过来,然后再在其后构造新元素,最后释放原空间。因此,对于vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就失效了,这是vector使用中的一个大坑,务必小心。
vector所提供的元素操作很多,这里选取几个常用操作介绍一下。
(1)push_back
1 2 3 4 5 6 7 8 |
|
其中辅助的insert函数的基本逻辑为:按原空间大小的两倍申请新空间,复制原数据到新空间,释放原空间,更新新vector的数据结构的成员变量。
(2)insert
1 2 3 4 5 6 7 8 9 10 |
|
与push_back
类似,只是push_back
在最后插入,更为简单。insert 首先判断是否为在最后插入且容量足够,如果是最后插入且容量足够就就直接内部实现了。否则还是调用上面的辅助插入函数,该函数中首先判断容量是否足够,容量足的话,先构造一个新元素并以当前vector的最后一个元素的值作为其初始值,然后从倒数第二个元素开始从后往前拷贝,将前一元素的值赋给后一元素,知道当前插入位置。
(3)erase
1 2 3 4 5 6 |
|
与insert相反,该函数将某些连续的元素从vector中删除,所以不存在容量不足等问题,但也不会将没有使用的空间归还给操作系统。这里只有简单的元素拷贝(copy)和元素的析构(destroy)。另外,需要说明的是,对于vector而言clear函数和erase函数是等同的,都只清空对应内存块的值,而不将空间归还给操作系统,所以vector的容量是只增不减的。而对于其他一些容器就有所不同了,比如list之类以node为单位的数据结构。
关于vector的内容就介绍到这里了。
iterator.h
, stl_iterator_base.h
, concept_checks.h
, stl_iterator.h
, type_traits.h
, stl_construct.h
, stl_raw_storage_iter.h
等7个文件。
迭代器(iterators)是一种抽象的设计概念,显示程序中并没有直接对应于这个概念的实体。在 Design Patterns 一书中,对 iterators 模式的定义如下:提供一种方法,使之能够依序遍历某个聚合物(容器)所包含的各个元素,而又无需暴露该聚合物内部的表述方式。
在STL中迭代器扮演着重要的角色。STL的中心思想在于:将数据容器(container)和算法(algorithm)分开,彼此独立设计,最后再通过某种方式将他们衔接在一起。容器和算法的泛型化,从技术的角度来看并不困难,C++ 的 class template 和 function template 可以分别达到目标,难点在于如何设计二者之间的衔接器。
在STL中,起者这种衔接作用的是迭代器,它是一种行为类似指针的对象。指针的各种行为中最常见也最重要的便是内容获取(dereference)和成员访问(member access),因此迭代器最重要的工作就是对 operator*
和 operator->
进行重载。然而要对这两个操作符进行重载,就需要对容器内部的对象的数据类型和存储结构有所了解,于是在 STL 中迭代器的最终实现都是由容器本身来实现的,每种容器都有自己的迭代器实现,例如我们使用vector容器的迭代器的时候是这样用的 vector<int>::iterator it;
。而本文所讨论的迭代器是不依存于特定容器的迭代器,它在STL中主要有以下两个方面的作用(我自己的理解和总结):
- 规定容器中需要实现的迭代器的类型及每种迭代器的标准接口
- 通过Traits编程技巧实现迭代器相应型别的获取,弥补 C++ 模板参数推导的不足,为配置器提供可以获取容器中对象型别的接口
其中前一个没啥好解释的。关于第二个,后面第3节会详细介绍,那就是Traits编程技巧。
在SGI STL中迭代器按照移动特性与读写方式分为 input_iterator
, output_iterator
, forward_iterator
, bidirectional_iterator
, random_access_iterator
这5种,他们的定义都在 stl_iterators_base.h
文件中。这5种迭代器的特性如下:
replace()
等算法。operator++
, 而第4种还支持 operator--
, 但这种随机访问迭代器还支持 p+n
, p-n
, p[n]
, p1-p2
, p1+p2
等。从以上的特性可以看出,input_iterator
和 output_iterator
都是特殊的 forward_iterator
, 而 forward_iterator
是特殊的 bidirectional_iterator
, bidirectional_iterator
是特殊的 random_access_iterator
。在 stl_iterator_base.h
文件中,他们的定义中我们并不能看到这种特性的表达,而只是规定了这几种迭代器类型及应该包含的成员属性,真正表达这些迭代器不同特性的代码在 stl_iterator.h
文件中。在 stl_iterator_base.h
文件中,除了对这几种迭代器类型进行规定之外,还提供了获取迭代器类型的接口、获取迭代器中的 value_type
类型、获取迭代器中的 distance_type
、获取两个迭代器的距离(distance
函数)、将迭代器向前推进距离 n (advance
函数)等标准接口。
在 stl_iterator.h
文件中,设计了 back_insert_iterator
, front_insert_iterator
, insert_iterator
, reverse_bidirectional_iterator
, reverse_iterator
, istream_iterator
, ostream_iterator
, 等标准的迭代器,其中前3中都使用 output_iterator
的只写特性(只进行插入操作,只是插入的位置不同而已),而第4种使用的是 bidirectional_iterator
的双向访问特性,第5种使用的是 random_access_iterator
的随机访问特性。而最后两种标准迭代器分别是使用 input_iterator
和 output_iterator
特性的迭代器。从这几个标准的迭代器的定义中可以看出,主要是实现了 operator=
, operator*
, operator->
, operator==
, operator++
, operator--
, operator+
, operator-
, operator+=
, operator-=
等指针操作的标准接口。根据定义的操作符的不同,就是不同类型的迭代器了。
例如,下面是 back_insert_iterator
的标准定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
在算法中运用迭代器是,很可能需要获取器相应型别,即迭代器所指对象的类型。此时需要使用到 function template 的参数推导(argument deducation)机制,在传入迭代器模板类型的同时,传入迭代器所指对象的模板类型,例如:
1 2 3 4 |
|
这里不仅要传入类型 class I
, 还要传入类型 class T
。然而,迭代器的相应型别并不仅仅只有 “迭代器所指对象的类型” 这一种,例如在STL中就有如下5种:
count()
,其返回值的类型就是迭代器的 difference_type
.而且实际当中,并不是所有情况都可以通过以上的 template 的参数推导机制来实现(例如算法返回值的类型是迭代器所指对象的类型,template参数推导机制无法推导返回值类型),因此需要更一般化的解决方案,在STL中,这就是Traits编程技巧。
在STL的每个标准迭代器中,都定义了5个迭代器相应型别的成员变量,在STL定义了一个统一的接口:
1 2 3 4 5 6 7 8 9 10 |
|
其他的迭代器都可以继承这个标注类,由于后面3个模板参数都有默认值,因此新的迭代器只需提供前两个参数即可(但在SGI STL中并没有使用继承机制)。这样在使用该迭代器的泛型算法中,可以返回这5种类型中的任意一种,而不需要依赖于 template 参数推导的机制。
在SGI STL中,如果启用 __STL_CLASS_PARTIAL_SPECIALIZATION
这个宏定义,还有这样一个标准的 iterator_traits
:
1 2 3 4 5 6 7 8 9 |
|
值得一提的是,这些类型不仅可以是泛型算法的返回值类型,还可以是传入参数的类型。例如 iterator_category
可以作为迭代器的接口 advance()
和 distance()
的传入参数之一。 不同类型的迭代器实现同一算法的方式可能不同,可以通过这个参数类型来区分不同的重载函数。
traits 编程技巧非常赞,适度弥补了 C++ template 本身的不足。 STL 只对迭代器加以规范,设计了 iterator_traits
这样的东西,SGI进一步将这种技法扩展到了迭代器之外,于是有了所谓的 __type_traits
。
在SGI中, __type_traits
可以获取一些类型的特殊属性,如该类型是否具备 trivial default ctor?是否具备 trivial copy ctor?是否具备 trivial assignment operator?是否具备 tivial dtor?是否是 plain old data(POD)? 如果答案是肯定的,那么我们对这些类型进行构造、析构、拷贝、赋值等操作时,就可以采用比较有效的方法,如不调用该类型的默认构造、析构函数,而是直接调用 malloc()
, free()
, memcpy()
等等,这对于大量而频繁的操作容器,效率有显著的提升。
SGI中 __type_traits
的特性的实现都在 type_traits.h
文件中。其中将 bool
, char
, short
, int
, long
, float
, double
等基本的数据类型及其相应的指针类型的这些特性都定义为 __true_type
,这以为着,这些对基本类型进行构造、析构、拷贝、赋值等操作时,都是使用系统函数进行的。而除了这些类型之外的其他类型,除非用户指定了它的这些特性为 __true_type
,默认都是 __false_type
的,不能直接调用系统函数来进行内存配置或赋值等,而需要调用该类型的构造函数、拷贝构造函数等。
另外,用户在自定义类型时,究竟一个 class 什么时候应该是 __false_type
的呢?一个简单的判断标准是:如果 class 内部有指针成员并需要对其进行动态配置内存是,这个 class 就需要定义为 __false_type
的,需要给该类型定义构造函数、拷贝构造函数、析构函数等等。