本课程为王晓华老师达人课课程,需要购买训练营课程的同学请点击下方链接:
28 天玩转算法-第02期
市面上关于算法的书可谓琳琅满目,有经典但难啃的、也有简单入门的、更有独辟蹊径的,不过这些大多数都是偏理论的多、偏应用的少,很多读者啃完后,对各种排序、搜索、遍历等常用算法了如指掌,但是遇到实际问题时还是束手无策,这其实就是经验和方法集的问题了。本课程将带着大家玩算法,其实就是希望大家能做到以下三点:
若要做到这些,除了熟练掌握各种常用的基础算法外,还需要了解算法设计的常用思想和模式,同时要掌握将题目转换成数据模型并进一步用数据结构来实现数据模型的一般方法,也就是我们常说的建模。本课程挑选了 35 个算法,涵盖以上三点,其重点是如何设计算法实现以及如何将理论上的算法应用到实际工作中并解决具体的问题。
授人以鱼不如授人以渔,作者在对每个算法的分析、分解和实现的过程中,同时也会分享设计算法的方法和一些常用的技巧。
《算法应该怎么玩》展示有趣的问题、启发有趣的思路、归纳有趣的解法,是一门有趣的课程。
——王益,百度美研 T10 架构师,百度深度学习系统 PaddlePaddle 的技术负责人
《算法应该怎么玩》是真正在训练读者解决问题的能力,而解决问题的能力,正是任何一家公司所需人才的最核心的技能。
——黄鑫(飞林沙),极光推送首席科学家
王晓华,毕业于华中科技大学,目前就职于中兴通讯,任职开发经理和资深软件工程师,主要工作是嵌入式通讯软件开发。精通 C 和 C++ 开发语言,熟悉的领域和技术还包括:算法设计、面向对象的软件设计和重构、测试驱动开发、敏捷与过程改进、Windows 内核文件系统、汇编语言、软件破解与保护、网络安全。
主要的作品有《算法的乐趣》和译作《雷神的微软平台安全宝典》,个人公众号在紧张筹备中。
本课程大纲分为七大部分,共计 50 篇:
大家好,我是王晓华,网名 orbit。2015 年出版了一本书,名为《算法的乐趣》,以“趣味性”为着手点,介绍了二十多个趣味算法的原理和实现,主要目的是希望读者了解到算法并非是枯燥、抽象的代码,算法的设计和应用是一件十分有趣的事情。作为一本非典型的算法书,许多读者学习后觉得意犹未尽,希望能以更系统的方式来介绍各类算法的设计和实现,同时介绍更多分析问题的方法和抽象问题数据模型的技巧,而这正是本课程的目标。
算法在程序中扮演着非常重要的角色,有人将数据结构比喻为程序的骨架,将算法比喻为程序的灵魂,这一点也不为过。正是因为这一点,很多朋友都立志要学好算法,但是我常常看到各种抱怨,比如“看了半年《算法》这本书,才看了几十页”,再比如“四年了,还是没有啃完《算法导论》”。出现这种情况的主要原因有两个,其一是算法纷繁复杂、知识点多,没有一种放之四海而皆准的通用规则,很难一下子从总体上掌握全貌;其二是一些算法虽然有常用的设计模式,但是不同的问题有不同的数学模型,需要设计好数学模型才能带入算法模式进行求解,然而设计数学模型对新手来说通常是个高高的门槛。
人们设计各种算法的目的是解决现实中的问题,虽然各种算法的实现五花八门,但是设计算法却有一些通用的方法或思想(也有的资料将其称为算法设计模式)。归纳起来,这些常见的算法设计方法有迭代法、穷举搜索法、分支界限法(剪枝法)、递推法、递归法、回溯法、分治法、贪婪法和动态规划法等。
本课程选择了三十多个简单且实用的算法实例,这些算法实例基本覆盖了各种算法比赛中经常出现的题目以及生活中常见的一些有趣的算法实现。每个算法实现都将讲解的侧重点放在各种算法的设计方法和思想在算法中的体现,通过一个个算法例子,来引导大家掌握常见的算法设计思想。除此之外,在算法实现的过程中,还会详细介绍针对各个问题的建模过程,使读者能够举一反三,学会如何将文字描述的问题抽象为算法能够使用的数据模型。总之,本课程的目的不是学会这些算法,而是通过学习这些算法的实现,掌握算法设计的方法,以后遇到类似的问题,可以自己设计并实现解决问题的算法。
前面介绍的各种算法设计方法并不是孤立存在的,很多算法最终的实现都是几种算法思想在一起融合后的产物。例如,穷举搜索法常常要结合递归和回溯操作实现穷举遍历,有时候还需要借助分支界限法“剪掉”一些重复的分支或明显不可能存在解的分支,目的是提高穷举的效率;再比如很多读者望而生畏的动态规划法,其递推关系的确定体现的就是递推的思想;再再比如,迭代法通常结合分治的思想,使得每次迭代都能把大问题分解为一系列容易求解的小问题。有一些设计方法可以单独成为一个主题,有些设计方法无法单独列为一个主题进行讲解,本课程前半部分根据常见的算法题目,重点介绍迭代、穷举和动态规划三个主题,三个主题共计 18 个算法实例,基本上涵盖了上述所有算法设计方法和思想的应用。本课程后半部分则通过 24 个有趣的算法实现,让大家理解算法的重要性,但重点仍然是各种算法设计思想的应用和如何设计适用于算法的数据模型。
本课程的目的是培养大家解决实际问题的能力,每篇介绍一个算法问题,通过对这个问题的分析和解决,锻炼大家针对问题设计数据模型,并应用合适的算法思想设计出解决问题的算法实现的能力。
《算法应该怎么玩?》
本课程分为七大部分,共计 49 篇。
这部分内容包含 7 课,第 1 课为算法基础内容,介绍了将问题抽象成数据建模的常用方法;第 2 ~ 第 6 课介绍了 5 种常用的算法设计思想(模式),分析了各种算法模式的特点和使用时需要注意的要点,每种模式都用一到两个具体的算法做示例;第 7 课介绍了一些基础算法和技巧,比如排序、单链表逆序和用数组存储树等小算法,作为本课程开始之前的热身或开胃菜。
这部分内容通过 3 个在数学领域常用的数值分析方法,介绍了迭代思想在算法设计中的应用,还包括如何将数学公式或文字描述的递推关系转化为数据模型的方法。
这部分内容包含 9 课,前 7 课通过 7 个不同的算法实例,介绍了几种常见的穷举方法,比如线性数字类问题的穷举方法、树形空间的穷举方法、棋盘类游戏的常用穷举方法以及复杂问题的多重穷举和组合穷举方法,重点介绍了如何设计与穷举算法相适应的数据模型,以及用合理的数据模型简化算法实现的技巧。后两课分别介绍了如何理解和设计递归函数,以及算法中浮点数处理的一些经验。
这部分内容包含 8 课,第 1 课介绍了动态规划算法的常用设计思想和方法,后面 7 课分别介绍了 7 个有趣的算法,其中有一些是算法比赛中出现过的题目,还有一些是动态规划的典型问题,既有简单的线性(一维)动态规划和二维动态规划问题,也有凸多边形的三角剖分、矩阵链乘这样的热点问题。
动态规划最重要的是划分阶段和确定状态(确定递推关系式),通过这些例子,可以了解如何建立问题对应的数据模型以及建立在数据模型上的递推关系式。掌握这些方法,再遇到动态规划类的问题时,就不会束手无策了。
图论既是各种算法比赛中出题的“重灾区”,也是现实生活中很多有趣算法的理论基础,这部分内容选了 6 个有趣的算法,包括二分图的最大匹配、图的排序和关键路径、欧拉图、最大流等经典问题,讲解的重点仍然是各种算法设计的思想和建立数据模型。
这部分内容包含 8 课,介绍了一些游戏中常用的有趣味算法,包括决策树、博弈树和行为树的搜索算法,涉及的内容包括穷举、递归和分支界限法等算法思想的应用。这些算法都是非常经典的,掌握这些算法思想,将有助于读者开阔思维、提升代码能力。
这部分内容介绍了 6 个算法,都是一些名声远扬的公开算法,不需要重新设计,学习这些算法的关键是如何根据实际的问题建立数据模型,然后套用算法解决问题。每种算法都有具体的实例,通过这些例子与算法原理相结合,不仅有助于理解算法,还可以了解到将这些算法应用到实践中常用的建模方法。
尽管算法设计的常用方法有很多,但是这些方法之间并不是互相孤立的。比如,有些递推关系可以通过迭代法实现,如牛顿迭代法,还有些递推关系需要通过广域搜索来实现,比如常见的动态规划算法。贪婪法很少单独用于解决最优解问题,但是贪婪法的思想体现在很多算法中,比如著名的“Dijkstra 算法”,在确定某个顶点的下一个最短路径点时,就使用了贪婪法的思想,每次选择距离最近的那个点作为下一个顶点。本课程将会在剖析这些算法原理的过程中,为大家指出各种算法设计思想的体现,以加深对算法设计常用方法的认识。
本课程的目的不是告诉读者这些问题的解决方案,因为很多问题的算法都是已经实现的公开算法(各种顶着光环的“XXX”算法),重复这些内容毫无意义。“授人以鱼不如授人以渔”,我们希望在对每个算法进行分析、分解和实现的过程中,让读者掌握设计算法的方法和一些常用技巧。通过学习本课程的内容,读者在面对各种算法问题时,可以摆脱之前束手无策的状态,能够自己设计算法解决问题。
希望大家通过本课程的学习,将掌握算法设计常用的思想和技巧,提升将具体问题抽象(转化)为数据模型的能力,了解各种设计算法常用的代码技巧,提高动手能力,今后遇到复杂算法问题或再啃各种大部头算法书籍的时候,能够轻松应对。最后,预祝大家学习愉快!
点击了解《算法应该怎么玩?》
既然是“玩”算法,首先要会玩,否则只会被算法“玩死”。很多朋友啃完了《算法》《算法导论》或其他算法书籍,对各种排序、搜索、遍历等常用算法了如指掌,但是遇到实际的问题时还是束手无策,这与智力无关,这其实就是经验和方法集的问题。很多啃过算法书的朋友都知道堆排序和最大最小堆,但是却不能有效地应用到实际问题中。例如,某算法书介绍 Dijkstra 算法时,提到当问题规模比较大时,每次查找 dist 数组中的最小值可能成为效率的瓶颈,可以用一个最小堆来维护 dist 结果,使得每次取最小值的操作变成 O(1) 时间复杂度。看到这,许多读者不知所措,不知道如何将自己掌握的最小堆算法与 Dijkstra 算法结合在一起改进算法的效率。尽管部分人看不起穷举法,但是不可否认,有些人却连基本的穷举算法都设计不出来。
“玩”算法就是要能够做到以下三点:对遇到的特殊问题要能够自己设计出算法实现(可能是一个智力游戏题目,也可能是工作中遇到的实际问题);对于原理公开的知名算法,要能将算法原理翻译成具体的算法代码(如二部图匹配的匈牙利算法、大整数乘法的 Karatsuba 算法);对已有具体实现的算法,要能够设计出合适的数学模型,将算法应用到实际问题中(如遗传算法、SIFT 图像识别算法)。想要做到这些,除了熟练掌握各种常用的基础算法外,还需要了解算法设计的常用思想和模式,并且要掌握将题目转换成数据模型,并进一步用数据结构实现数据模型的一般方法,这一节课我们就来讲讲数据模型和建模。
《算法应该怎么玩?》
如果想要计算机来解决问题,就必须用计算机能理解的方式描述问题。计算机只能用数据描述问题,这就需要一个合理的数据模型用来存储这些数据,这里提到的数据模型不同于大家普遍理解的数学模型,因为数学模型的意义更宽泛,也更抽象,语言、图表和公式都可以用来描述数学模型。数据模型的定义更具体一点,就是用在计算机程序中可以直接使用的,用编程语言直接描述的数学模型,可以将数据模型简单理解为与数学模型相一致的数据结构定义,是数学模型的一种表达形式。
建立问题的数据模型实际上是对问题的一种抽象表达,通常也需要伴随着一些合理的假设,其目的就是对问题进行简化,抓住主要因素,舍弃次要因素,逐步用更精确的语言描述问题,最终过渡到用计算机语言的数据结构能够描述问题为止。一个完整的算法实现应该包含三个重要的组成部分,即数据模型、算法逻辑主体和输入输出。输入就是把自然语言描述的问题转化成计算机能存储或处理的数据,并存入数据模型中;输出就是将计算机处理后的结果(也在数据模型中定义)转化成人类能理解的方式输出。算法的逻辑主体就是具体承载数据处理的代码流程,负责对数据模型中的输入数据进行处理、转换,并得到结果。这三个组成的核心是数据模型,好的数据模型不仅能准确地描述问题,还能简化算法实现或提高算法的效率,不好的数据模型可能会导致算法的实现困难、效率低下,甚至无法实现算法。
根据问题的描述建立数据模型的能力是“玩”算法的关键。不能对问题进行归纳并抽象出数据模型的,就不能设计出解决问题的算法实现,换句话说,就是缺乏解决实际问题的能力。这种能力的缺乏体现在两个方面,一方面是不能针对特有的问题设计出解决问题的算法实现,而这种特有的问题有可能是其他人没有遇到过的,没有现成的方法可用;另一方面是不能用已有的通用算法解决具体的问题,像遗传算法这样的通用算法,通常需要结合实际问题的数据模型才能真正解决问题。如果不能解决工作和生活中实际面临的问题,学再多的算法又有何用?不过是把别人做过的事情再做一遍而已。
建模是个很抽象的话题,这世界上的问题纷繁复杂,不存在能解决一切问题的通用建模方法,一个人也不可能看了几篇文章就能全面掌握各种问题的建模方法。前面提到过,这种能力其实就是经验和方法集的问题,多练习、多思考,学会总结和归纳,是提高建模能力的关键。话题抽象并不表示这个问题是毫无章法可言的,实际上,在某些方面还是有一些规律可循。接下来的内容是我总结出来的一些惯用方法,给大家提供一个建模时的思考方向。
信息数字化就是把自然语言描述的信息,转化成方便代码数据模型表达的数字化信息,这是各种问题建模的一个通用思考方向,比如当问题中出现用“甲、乙、丙、丁”或“A、B、C、D”来标识物品或人物的序列时,就可以考虑用数字 1、2、3、4 来表达它们;还有很多其他的非量化属性,也可以转化成数字信息,比如判断结果“大于、等于和小于”时,可以用正数、0 和负数来表示;布尔值的真和假,可以用 1 和 0 表示,一些表示“有”和“无”的状态,也可以用 1 和 0 来表示。
假设有四个人,这四个人用编号 1~4 来代表,其中编号为 2 的人有喝啤酒的习惯,我们就可以用数据模型这样来描述:
people[2].drink = 1;
再来看一个完整的例子:警察抓了 A、B、C、D 四名罪犯,其中一名是小偷,审讯的时候:
A说:“我不是小偷。” x !=0B说:“C 是小偷。” x = 2C说:“小偷肯定是 D。” x = 3 D说:“C 是在冤枉人。” x != 3
现在已经知道四个人中三个人说的是真话,一个人说了假话,请判断一下到底谁是小偷?
对这个问题分析,首先对 A、B、C、D 四个人分别用 0~3 四个数字进行编号,接着将四个人的描述结果用数字量化,如果描述是真,则结果是 1,如果是假,则结果是 0。我们假设小偷的编号是 x,对于四个人的描述,数字化的结果是:
int dis_a = (x != 0) ? 1 : 0; int dis_b = (x == 2) ? 1 : 0; int dis_c = (x == 3) ? 1 : 0; int dis_d = 1 - dis_c;
依次假设 x 是 A、B、C、D(0~3 的编号数值),对每次假设对应的 dis_a、dis_b、dis_c 和 dis_d 的值求和,若结果是 3,则表示假设是对的,x 对应的值就是小偷的编号。如此将自然语言的信息数字化后,算法就可以非常简单地实现了:
void who_is_thief(){ for (int x = 0; x < 4; x++) { int dis_a = (x != 0) ? 1 : 0; int dis_b = (x == 2) ? 1 : 0; int dis_c = (x == 3) ? 1 : 0; int dis_d = 1 - dis_c; if ((dis_a + dis_b + dis_c + dis_d) == 3) { char thief = 'A' + x; std::cout << "The thief is " << thief << std::endl; break; } }}
很多情况下,信息数字化是建立数据模型的基础。数字化后的数据和数据模型是相辅相成的两个东西,先要知道有什么数据,才能设计相应的数据模型存储和表达这些数据,而好的数据模型不仅有利于数据的存储和访问,也有利于算法的高效实现。
你可以设计新的模型,但是有时候也可以像使用模式一样使用那些经典的或常用的模型,或者根据不同对象的某些相似性,借用已知领域的模型。当我们解决未知的问题时,常常把已知的旧问题当作基础或经验来源。正如艾萨克·牛顿说的那样:“如果我看得比别人远,那是因为我站在巨人的肩膀上。”从根本上讲,把未知的问题转化成已知问题,然后再用已知的方法解决已知问题,是解决未知问题的基础手段。但是,如何将一个未知的问题转化为我们熟知的模型是一个复杂而艰难的过程,完成这个过程需要相当多的经验积累,同时也是算法设计中最有趣味的部分。
下面来看一个算法几何的例子。
判断 n 个矩形之间是否存在包含关系是经典的算法几何问题。按照一般的思路,应该是 n 个矩形两两进行包含判断,但是很显然,这个简单的方法需要 n(n−1) 次矩形包含判断,时间复杂度是 O(n2)。如果知道区间树的概念,就可以将这个问题转化为区间树的查询问题。首先根据矩形的几何位置,利用水平边和垂直边分别构造两棵区间树(根据矩形的几何特征,只需要处理一条水平边和一条垂直边即可),然后将 n 个矩形的水平边作为被查找元素,依次在水平边区间树中查找,如果找到其他矩形的水平边完整覆盖被查找矩形的水平边,则在垂直边区间树上进一步判断该矩形的垂直边被覆盖的情况;如果存在被查找矩形的水平边和垂直边都被同一个矩形的水平边和垂直边覆盖,则说明这两个矩形存在包含关系。采用区间树的算法复杂度是 O(nlg(n)),额外的开销是建立区间树的开销,但是只要 n 足够大,这个算法仍然比简单的比较法高效。
再来看一个项目管理问题的例子。
一个工程项目经过层层结构分解最终得到一系列具体的活动,这些活动之间往往存在复杂的依赖关系,如何安排这些活动的开始顺序,使得项目能够顺利完成是个艰巨的任务。但是如果能把这个问题转化成有向图,图的顶点就是活动,顶点之间的有向边代表活动之间的前后关系,则只需要使用简单的有向图拓扑排序算法就可以解决这个问题。一个工程分解出的这么多活动,每个活动的时间都不一样,如何确定工程的最短完工时间?工程的最短完工时间取决于这些活动中时间最长的那条关键活动路径,从成百上千个活动中找出关键路径看似是个无法入手的问题,但是如果将问题转化为有向图,顶点代表事件,边代表活动,边的权代表活动时间,则可以利用有向图的关键路径算法来解决问题。
大部分数学问题的建模,相对比较简单一些,因为大部分的信息其实都已经是数字化或量化的描述,并且很多问题都可以归纳为一组不等式作为约束条件,或者几个函数表达式作为目标函数。数学中的很多数据类型,比如列表、树和图等问题,都可以用与之对应的数据结构描述,大大降低了设计数据模型的难度。当然,数学问题也有数学问题的特点,比如无穷大和无穷小是无法用计算机表达的,极限和无穷数列也是无法用计算机存储和描述的,对此类问题,就需要对模型进行特殊处理,比如裁剪范围,或者是在不影响问题解决的前提下增加约束条件。
计算几何的问题范围都是整个坐标系,比如直线是向两端无限延伸的,但是对于计算机来说,即便有大数计算库的支持,它能表达的最大范围也是有限的。通常会根据实际应用场景裁剪规模,以便于计算机算法的建模和处理。比如某绘图仪的最大坐标范围是 [−32768,32768],那就可以定义一个比 −32768 还小的数作为负无穷大,定义一个比 32768 还大的数作为正无穷大,这样直线就可以作为一个两端超过区间 [−32768,32768] 的大线段来建模,对于坐标范围是 [−32768,32768] 来说,模型符合直线的特征,对于计算机来说,这是一条数据模型能表达和存储的线段。
对于涉及数学公式的建模,相对比较简单,只要定义的数据结构能表达公式描述的各项属性即可。需要注意的是,很多公式是隐含着无穷数列的特征的,在建模时需要增加约束条件,使得问题能在某个范围内用算法解决。
下面以求 n 次二项式的展开式系数问题为例来讲解一下对这个问题建模时需要的考量。
n 次二项式的展开公式如下所示:
从这个展开式可以看出展开后的多项式项数与 n 相关(n+1 项),受制于存储空间的限制,在考虑数据模型的时候需要限制 n 的最大值。再观察每个展开项可知,需要存储的数据有多项式系数、a 的幂和 b 的幂三个属性,因此,定义的数据结构要有相对应的条目这些属性,可以这么定义每一项的数据结构:
typedef struct{ int c; int am; int bm;}ITEM;
根据展开式的特点,需要一个列表存储各项的数据,显然这个列表不存在频繁删除和插入操作,可以选择用数组作为数据模型。这个例子模型限制 n 的最大值是 32,当然,这个值可以根据问题域和存储空间的限制来综合考虑,最终定义的数据模型就是:
ITEM items[N];
图1 杨辉三角递推计算示意图
item 中系数 c 的计算采用杨辉三角的递推公式计算,避免使用 $C_{n}^{k}$ 公式计算,这样做的话计算量太大了。杨辉三角的递推关系如图1所示,第 n 阶系数的首项和末项都是 1,其他 n-2 项系数可以从第 n-1 阶的系数递推 i 计算出来,其递推计算关系是:
$$C{n}=C{}'{n}+C{}'_{n-1},n=2,3,...,n-1$$
am 和 bm 则比较简单,一个是从 n 到 0 递减,一个是从 0 到 n 递增。
根据我们定义的数据模型 items,求二项式展开式各项系数和幂的算法实现也就水到渠成了:
if (n == 0) { items[0] = {1, 0, 0}; return; } for (unsigned int i = 1; i <= n; i++) //从第1阶开始递推到第n阶 { unsigned int nc = i + 1; //每一阶的项数 items[nc - 1] = { 1, 0, i }; //末项 //倒着递推第2项到第n-1项的值,实际下标范围是[1, nc-2],不需要额外的存储空间转存items数组 for (unsigned int j = nc - 2; j > 0; j--) { unsigned int c = items[j].c + items[j - 1].c; items[j] = {c, i - j, j}; } items[0] = { 1, i, 0 }; //首项 }
计算机也无法直接表示大小和不等于这样的关系,对于不等式的建模,通常是转换成减法,然后对结果进行正、负的判断。对于方程也是一样的,通常将方程转换成 f(x)=0 的形式建模,模型会比较简单。
图论相关的算法也是非常典型的一类问题。描述图的数据结构最常用的是邻接矩阵和邻接链表两种形式,这两种数据结构的介绍资料有很多,这里只是讲一下在实际使用它们设计数据模型时需要考虑的其他方面的内容。先来说说邻接矩阵,邻接矩阵一般由一个一维的顶点信息表和一个二维的邻接关系表组成,根据实际问题的情况,还可以增加其他属性,如顶点个数和边的个数等。
请看一个典型的邻接矩阵数据模型定义:
typedef struct{ int vertex[MAX_VERTEX]; //顶点信息表 int edge[MAX_VERTEX][MAX_VERTEX]; //边信息表 int numV; //顶点数 int numE; //边数}GRAPH;
如果你使用的编程语言中数组的属性中包含元素个数,那么表示顶点数的 numV 属性就没有必要,同样,表示边数的 numE 属性也不是必需的。如果问题中关于顶点信息除了编号,还有其他信息,那么顶点信息表的元素类型就不能简单用 int 类型了,而是要根据题目给出的信息做相适应的修改。比如与地图有关的问题,通常作为顶点的每个城市有很多属性,如城市名称、公路出口个数和入口个数等,就需要定义相关的顶点数据结构,比如包含了城市名称的顶点信息:
typedef struct{ std::string name; int node;}VERTEXVERTEX vertex[MAX_VERTEX]; //顶点信息表
表示边信息的矩阵,每个元素是边的权重,对于不相邻的顶点,权重一般是一个特殊值。如果边的信息除了权重,若还有其他信息,则需要定义与之相适应的数据结构来描述边的信息。比如有个求最优解的规划类题目,城市之间除了距离,还有交通困难指数,比如是水路、山路还是平地等信息,此时边的定义就可以改成如下代码:
typedef struct{ int weight; int traffic_type;}EDGEEDGE edge[MAX_VERTEX][MAX_VERTEX]; //边信息表
使用邻接矩阵定义图,优点是顶点之间的边的信息很容易获取,如果你要处理的问题需要频繁地确定顶点之间的连接信息,那么使用邻接矩阵是一个比较好的选择。邻接矩阵的缺点是它是一个稀疏矩阵,当顶点比较多的情况下,对存储空间的浪费比较严重。邻接表是一种顺序分配和链式分配相结合的数据结构,顶点信息顺序存放,每个顶点相邻的顶点信息,则通过一个链表链接到该顶点的邻接点域。一个典型的邻接表数据模型如下:
typedef struct EDGE{ int node; //边的对应顶点 int weight; EDGE *nextEdge; //下一条边的信息}EDGE;typedef struct{ int node; EDGE *firstEdge; //第一个边的信息}VERTEX;typedef struct { VERTEX vertex[MAX_VERTEX]; //顶点列表 int numV; //顶点数 int numE; //边数}GRAPH;
如果题目需要顶点和边来描述更多的信息的话,则在此基础上扩展 EDGE 和 VERTEX 的定义,增加相应的属性即可。
“玩”算法的目的不是学会一种算法或很多种算法,而是学会用算法来解决问题,掌握解决问题的能力是关键。这一课,我们介绍了这种能力的核心内容——如何建立与算法相适应的数据模型。建模能力的提高是一个长期的积累过程,这里提到的只是最常见的思路和方法。除此之外,提高建模能力还需要熟悉各种常见的数据结构的特点和使用方法,需要多做、多练、多思考,善于把别人的经验变成自己的经验。
[help me with Html]
点击了解《算法应该怎么玩?》
算法作为智力活动的结果,并不是随机头脑风暴活动的产物,虽然因人而异,会有不同的结果,但是基本上它应该是遵循一定规律的活动结果。首先,它需要一些基础性的知识作为这种智力活动的着力点,比如相关领域的数学知识、各种数据结构的掌握等;其次,它需要对问题域做充分的分析和研究,高度概括并抽象出问题的精确描述,也就是各种建立数学模型的方法;最后,有一些常用的模式和原则,可以作为构造算法的选择项,有人将其称为算法设计方法,我建议将它称为算法设计模式或算法设计思想,以便于将其与一些具体的算法名称区分开。
模式作为算法演进的一些固定的思路,它提供了一些构造算法的常用思想。常用的算法设计思想有迭代法、贪婪法、穷举搜索法、递推法、递归法、回溯法、分治法、动态规划法等,这一课将介绍贪婪法。
贪婪法(Greedy Algorithm),又称贪心算法,是寻找最优解问题的常用方法,这种方法模式一般将求解过程分成若干个步骤,但每个步骤都应用贪心原则,选取当前状态下最好的或最优的选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最好或最优的解。贪婪法的每次决策都以当前情况为基础并根据某个最优原则进行选择,不从整体上考虑其他各种可能的情况。一般来说,这种贪心原则在各种算法模式中都会体现,这里单独作为一种方法来说明,是因为贪婪法对于特定的问题是非常有效的方法。
贪婪法和动态规划法以及分治法一样,都需要对问题进行分解,定义最优解的子结构,但是与其他方法最大的不同在于,贪婪法每一步选择完局部最优解之后就确定了,不再进行回溯处理,也就是说,每一个步骤的局部最优解确定以后,就不再修改,直到算法结束。因为不进行回溯处理,贪婪法只在很少的情况下可以得到真正的最优解,比如最短路径问题、图的最小生成树问题。在大多数情况下,由于选择策略的“短视”,贪婪法会错过真正的最优解,而得不到问题的真正答案。但是贪婪法简单、高效,省去了为找最优解可能需要的穷举操作,可以得到与最优解比较接近的近似最优解,通常作为其他算法的辅助算法来使用。
贪婪法的基本设计思想有以下三个步骤:
定义最优解的模型通常和定义子问题的最优结构是同时进行的,最优解的模型一般都体现了最优子问题的分解结构和堆叠方式。对于子问题的分解有多种方式,有的问题可以按照问题的求解过程一步一步进行分解,每一步都在前一步的基础上选择当前最好的解,每做一次选择就将问题简化为一个规模更小的子问题,当最后一步的求解完成后就得到了全局最优解。还有的问题可以将问题分解成相对独立的几个子问题,对每个子问题求解完成后再按照一定的规则(比如某种公式或计算法则)将其组合起来得到全局最优解。
这里说的定义子问题分解和子问题的最优解结构可能有点抽象,我们来看一个具体的经典的例子——找零钱。假如,某国发行的货币有 25 分、10 分、5 分和 1 分四种硬币,如果你是售货员且要找给客户 41 分钱的硬币,如何安排才能找给客人的钱既正确且硬币的个数又最少?这个问题的子问题定义就是从四种币值的硬币中选择一枚,使这个硬币的币值和其他已经选择的硬币的币值总和不超过 41 分钱。子问题的最优解结构就是在之前的步骤已经选择的硬币再加上当前选择的一枚硬币,当然,选择的策略是贪婪策略,即在币值总和不超过 41 的前提下选择币值最大的那种硬币。按照这个策略,第一步会选择 25 分的硬币一枚,第二步会选择 10 分的硬币一枚,第三步会选择 5 分的硬币一枚,第四步会选择 1 分的硬币一枚,总共需要 4 枚硬币。
上面的例子得到的确实是一个最优解,但是很多情况下贪婪法都不能得到最优解。同样以找零钱为例,假如,某国货币发行为 25 分、20 分、5 分和 1 分四种硬币,这时候找 41 分钱的最优策略是 2 枚 20 分的硬币加上 1 枚 1 分硬币,一共 3 枚硬币,但是用贪婪法得到的结果却是 1 枚 25 分硬币、3 枚 5 分硬币和 1 枚 1 分硬币,一共 5 枚硬币。
《算法应该怎么玩?》
本节课将介绍一个贪婪法的经典例子——0-1 背包问题:有 N 件物品和一个承重为 C 的背包(也可定义为体积),每件物品的重量是 wi,价值是 pi,求解将哪几件物品装入背包可使这些物品在重量总和不超过 C 的情况下价值总和最大。背包问题(Knapsack Problem)是此类组合优化的 NP 完全问题的统称,如货箱装载问题、货船载物问题等,因问题最初来源于如何选择最合适的物品装在背包中而得名,这个问题隐含了一个条件,每个物品只有一件,也就是限定每件物品只能选择 0 个或 1 个,因此又被称为 0-1 背包问题。
来看一个具体的例子,有一个背包,最多能承载重量为 C=150 的物品,现在有 7 个物品(物品不能分割成任意大小),编号为 1~7,重量分别是 wi=[35,30,60,50,40,10,25],价值分别是 pi=[10,40,30,50,35,40,30],现在从这 7 个物品中选择一个或多个装入背包,要求在物品总重量不超过 C 的前提下,所装入的物品总价值最高。这个问题的数学模型非常简单,就是一个承重是 C 的背包和 n 个物品,每个物品都有重量和价值两个属性。但是在对问题分析的过程中,我们发现,每个物品还需要一个状态用于标记该物品的选择状态,以确定该物品是否已经被选进背包了,状态是 1 表示物品已经被装到包里了,后续的选择不要再考虑这个物品了。需要特别说明的是状态值为 2 的情况,这种情况表示用当前策略选择的物品导致总重量超过了背包承重量,在这种情况下,如果放弃这个物品,按照策略从剩下的物品中再选一个,有可能就能满足背包承重的要求。因此,设置了一个状态 2,表示当前选择物品不合适,下次选择也不要再选这个物品了。描述每个物品的数据结构 OBJECT 定义为:
typedef struct{ int weight; int price; int status; //0:未选中;1:已选中;2:已经不可选}OBJECT;
接下来是背包问题的定义,背包问题包括两个属性,一个是可选物品列表,另一个是背包总的承重量,简单定义背包问题数据结构如下:
typedef struct{ std::vector
确定数学模型之后,接下来就要确定子问题了。根据题意,本题的子问题可以描述为:在背包承重还有 C’ 的情况下,选择一个还没有被选择过,且符合贪婪策略的物品装入背包。每选择一个物品 p[i],都要调整背包的承重量 C’=C’-p[i].weight,问题的初始状态是 C’=150,且所有物品都可以选择。假如选择了一个重为 35 的物品后,子问题就变成在背包容量 C’ 是 115 的情况下,从剩下 6 件物品中选择一个物品。确定了子问题的描述,算法的整体实现过程就是按照选择物品装入背包的过程,按部就班地一步一步解决子问题,直到背包不能再装入物品或所有物品都已经装入背包时,结束算法。
那么如何选择物品呢?这就是贪婪策略的选择问题。对于本题,常见的贪婪策略有三种:第一种策略是根据物品价值选择,每次都选价值最高的物品,根据这个策略最终选择装入背包的物品编号依次是 4、2、6、5,此时包中物品总重量是 130,总价值是 165。第二种策略是根据物品重量选择,每次都选择重量最轻的物品,根据这个策略最终选择装入背包的物品编号依次是 6、7、2、1、5,此时包中物品总重量是 140,总价值是 155。第三种策略是定义一个价值密度的概念,每次选择都选价值密度最高的物品,物品的价值密度 si 定义为 pi/wi,这 7 件物品的价值密度分别为 si=[0.286,1.333,0.5,1.0,0.875,4.0,1.2]。根据这个策略最终选择装入背包的物品编号依次是 6、2、7、4、1,此时包中物品的总重量是 150,总价值是 170。
GreedyAlgo() 函数是贪婪算法的主体结构,包括子问题的分解和选择策略的选择都在这个函数中。能够明显看出来这个算法使用了迭代法的算法模式,当然,这个算法主体的实现还可以使用递归法,正如函数所展示的那样,它可以作为此类问题的一个通用解决思路:
void GreedyAlgo(KNAPSACK_PROBLEM *problem, SELECT_POLICY spFunc){ int idx; int ntc = 0; //spFunc 每次选最符合策略的那个物品,选后再检查 while((idx = spFunc(problem->objs, problem->totalC - ntc)) != -1) { //所选物品是否满足背包承重要求? if((ntc + problem->objs[idx].weight) <= problem->totalC) { problem->objs[idx].status = 1; ntc += problem->objs[idx].weight; } else { //不能选这个物品了,做个标记后重新选 problem->objs[idx].status = 2; } } PrintResult(problem->objs);}
spFunc 参数是选择策略函数的接口,通过替换这个参数,可以实现上文提到的三种贪婪策略,分别得到各种贪婪策略下得到的解。以第一种策略为例,每次总是选择 price 最大的物品,可以这样实现:
int Choosefunc1(std::vector
看起来第三种策略取得了最好的结果,和动态规划方法得到的最优结果是一致的,但是实际上,这只是对这组数据的验证结果而已,如果换一组数据,结果可能完全相反。当然,对于一些能够证明贪婪策略得到的就是最优解的问题,应用贪婪法可以高效地求得结果,比如求最小生成树的 Prim 算法和 Kruskal 算法。
在大多数情况下,贪婪法受自身策略模式的限制,通常很难直接求解全局最优解问题,也很难用于多阶段决策问题。贪婪法只能得到比较接近最优解的近似最优解,但是作为一种启发式辅助方法在很多算法中都得到了广泛的应用,很多常用的算法在解决局部最优决策时,都会应用到贪婪法。比如 Dijkstra 的单源最短路径算法在从 dist 中选择当前最短距离的节点时,就是采用的贪婪法策略。事实上,在任何算法中,只要在某个阶段使用了只考虑局部最优情况的选择策略,都可以理解为使用了贪婪算法。
点击了解《算法应该怎么玩?》
在第 1-2 课中介绍了算法模式中的贪婪法,这一课我们继续介绍分治法。分治,顾名思义,分而治之。分治法(Divide and Conquer)也是一种解决问题的常用模式,分治法的设计思想是将无法着手解决的大问题分解成一系列规模较小的相同问题,然后逐个解决小问题,即所谓分而治之。分治法产生的子问题与原始问题相同,只是规模减小,反复使用分治方法,可以使得子问题的规模不断减小,直到能够被直接求解为止。
分治法作为算法设计中一个古老的策略,在很多问题中得到了广泛的应用,比如最轻、最重问题(在一堆形状相同的物品中找出最重或最轻的那一个),矩阵乘法、大整数乘法以及排序(例如,快速排序和归并排序)。除此之外,这个技巧也是许多高效算法的基础,比如快速傅立叶变换算法和 Karatsuba 乘法算法。
应用分治法,一般出于两个目的,其一是通过分解问题,使得无法着手解决的大问题变成容易解决的小问题,其二是通过减小问题的规模,降低解决问题的复杂度(或计算量)。给 1000 个数排序,可能会因为问题的规模太大而无从下手,但是如果减小这个问题的规模,将问题一分为二,变成分别对两个拥有 500 个数的序列排序,然后再将两个排序后的序列合并成一个就得到了 1000 个数的排序结果。对 500 个数排序仍然无法下手,需要继续分解,直到最后问题的规模变成 2 个数排序的时候,只需要一次比较就可以确定顺序。这正是快速排序的实现思想,通过减小问题的规模使问题由难以解决变得容易解决。计算 N 个采样点的离散傅立叶变换,需要做 N2 次复数乘法,但是将其分解成两个 N/2 个采样点的离散傅立叶变换,则只需要做 (N/2)2 +(N/2)2 = N2/2 次复数乘法,做一次分解就使得计算量减少了一半,这正是快速傅立叶变换(FFT)的实现思想,通过减小问题的规模来减少计算量,以降低问题的复杂度。
《算法应该怎么玩?》
在很多情况下,分治法都会使用递归的方式对问题逐级分解,但是在每个子问题的层面上,分治法基本上可以归纳为三个步骤。
分治法的实现模式可以是递归方式,也可以是非递归方式,一般采用递归方式的算法模式可以用伪代码描述为:
T DivideAndConquer(P){ if(P 可以直接解决) { T <- P 的结果; return T; } 将 P 分解为子问题{P1, P2,..., Pn}; for_each(Pi : {P1, P2,..., Pn}) { ti <- DivideAndConquer(Pi); //递归解决子问题 Pi } T <- Merge(t1, t2,...,tn); //合并子问题的解 return T;}
能使用分治法解决的问题一般都具有两个显著的特点,第一个特点是问题可以分解为若干个规模较小的相同问题,并且这个分解关系可以用递归或递推的方式逐级分解,直到问题的规模小到可以直接求解的程度。这里说的相同问题,并不是说分解后的子问题与原问题完全一样,这里说的相同只是问题的结构相同,比如原问题有四个属性,分解后规模较小的子问题也应该具有四个相同的属性,不同的只是各个属性的范围和规模。第二个特点是子问题的解可以用某种方式合并出原始问题的解。这很容易理解,如果不能合并出原始问题的解,那么子问题的划分和求解就没有意义了。
分治法的难点是如何将子问题分解,并且将子问题的解合并出原始问题的解,针对不同的问题,通常有不同的分解与合并方式。先来看看快速排序算法,快速排序算法的分解思想是选择一个标兵数,将待排序的序列分成两个子序列,其中一个子序列中的数都小于标兵数,另一个子序列中的数都大于标兵数,然后分别对这两个子序列排序,其合并思想就是将两个已经排序的子序列一前一后拼接在标兵数前后,组成一个完整的有序序列。再来看看快速傅立叶变换,快速傅立叶变换的分解思想是将一个 N 点离散傅立叶变换,按照奇偶关系分成两个 N/2 点离散傅立叶变换,其合并思想就是将两个 N/2 点离散傅立叶变换结果按照蝶形运算的位置关系重新排列成一个 N 点序列。
最后再介绍一下 Karatsuba 大整数乘法算法,其分解思想是将两个参与计算的 n 位大数各自分成两部分:a + b 和 c + d,其中,a 和 c 分别是这两个大整数的整数幂部分,b 和 d 分别是它们的剩余部分,然后利用乘法的分解公式:(a + b)(c + d) = ac + ad + bc + bd,将其分解为四次小规模大数的乘法计算,并且利用一个小技巧将其化解成三次乘法和少量移位操作。最终结果的合并思想就是用几次加法对小规模乘法的结果进行求和,得到原始问题的解。
以上两个例子的具体原理和实现在《算法的乐趣》一书中都有详细的介绍,有兴趣的读者可以了解一下。
由以上的例子可知,分治法最难也最灵活的部分就是对问题的分解和结果的合并,对于一个未知的问题,只要能找到对子问题的分解方式和结果的合并方式,应用分治法就可以迎刃而解。而在数学上,只要能用数学归纳法证明的问题,一般也都可以应用分治法解决,这也是一个应用分治法的强烈信号。
递归作为一种算法的实现方式,与分治法是一对儿天然的好朋友。为什么这么说呢?因为问题的分解肯定不是一步到位,往往需要反复使用分治手段,在多个层次上层层分解,这种分解的方法很自然地导致了递归方式的使用。从算法实现的角度看,分治法得到的子问题和原问题是相同的,当然可以用相同的函数来解决,区别只在于问题的规模和范围不同。通过特定的函数参数安排,使得同一个函数可以解决不同规模的相同问题,这就是递归方法的基础。
以快速排序为例,如果把待排序的序列作为问题的话,那么子问题的规模就可以定义为子序列在原始序列中的起始位置。对此一般化之后,原始问题和子问题的描述就统一了,都是原始序列 + 起始位置,原始问题的起始位置就是 [1,n],子问题的起始位置就是 [1,n] 中的某一个子区间,由此一来,递归的接口就明确了:
void quick_sort(int *arElem, int p, int r)
其中,p 和 r 就分别是子序列在 arElem 中的起始位置,有了子问题的递归定义接口,快速排序的算法实现也就水到渠成了:
void quick_sort(int *arElem, int p, int r){ if(p < r) { int mid = partion(arElem, p, r); quick_sort(arElem, p, mid - 1); quick_sort(arElem, mid + 1, r); }}int intArray[] = {12, 56, 22, 78, 102, 6, 90, 57, 29};quick_sort(0, 8); //原始问题:对数组中的1-9号元素排序
不用递归是不是就不能用分治法了?当然不是,快速傅立叶变换算法就没有用递归。很多算法都有自己的非递归实现方式,是否用了递归方法不是判断是不是分治法的必要条件。即便是一些使用了递归方法的算法,也都可以用一个自己构造的栈将其改编为非递归方法,比如快速排序就有很多用栈实现的非递归方法。Robert Sedgewick 在其著作《算法:C语言实现》一书中就给出了一种快速排序的非递归高效算法,有兴趣的读者可阅读此书,了解一下算法实现。
我们的问题是:给定一个没有重复字母的字符串,输出该字符串中字符的所有排列。假如给定的字符串是“abc”,则应该输出“abc”、“acb”、“bac”、“bca”、“cab”和“cba”六种结果。首先分析这是一个全排列问题,解决这个问题我们的常用策略是每次选择固定一个字符,然后对剩下的两个字符进行排列。比如这个三个字母的字符串,我们首先选择固定 a,然后对 bc 进行排列,可以得到“abc”和“acb”两个结果;然后选择固定 b,对 ac 进行排列,可以得到“bac”和“bca”两个结果;最后选择固定 c,对 ab 排列,可以得到“cab”和“cba”两个结果。
不知道大家有没有意识到,这其实就是使用了分治法的思想在解决问题。三个字符排列,我们人脑可能处理不过来,但是我们固定一个字母后,把问题的规模减小为两个字符的排列,两个字符的排列只有两种结果,是可以解决的问题;然后我们将小问题的结果与固定的字母组合在一起,就可以得到原始问题,即三个字符的排列结果。分治法分解子问题,并不是一定要用某种方式均匀分解原始问题,哪怕是每次只能将原始问题的规模变小一点,也是一种分解子问题的方法。
回到我们这个问题上,对字符串类问题分解子问题,通常考虑的方法有两个。
对于这个问题,我们选择用区间的方法定义子问题,即用字符位置索引区间 [begin, end] 表示子问题,选好子问题的表达方式,接下来就要考虑如何分解子问题。根据之前的分析,我们采用每次固定一个字符,然后将剩下的字符串作为一个子问题进行全排列的方式分解子问题。因为每个字符都要被“固定”一次,所以算法实现的方法是用一个循环对子问题 [begin, end] 区间上的每个字符都选择一次。因为大多数编程语言都没有提供直接的方法能够将一个字符固定,同时将剩下的内容重组为一个连续的字符串,所以很显然,这里面就会有一个实现上的困难需要克服,即如何选中一个字符固定,还要让剩下的字符保持连续,成为子问题所描述的字符串。我们采用的方法是将问题区间 [begin, end] 中的 begin 位置作为选中的固定字符位置,将除了这个位置之外的问题区间 [begin+1, end] 作为子问题进一步处理。如果被选中的固定字符不在 begin 位置,则交换两个字符的位置,使得被选中的固定字符位于 begin 位置。
解决了子问题的分解,接下来要考虑子问题的求解。分解的目的是为了减小问题的规模,直到问题能够求解,对于这个字符串排列问题,当子问题的规模减小到只有一个字符的时候,子问题就可以求解了。因为我们处理方式是从前向后,每次固定 begin 位置的字符,然后将区间 [begin+1, end] 作为子问题进一步处理,所以当 begin 位置和 end 位置相同的时候,就说明字符串只有一个字符了,这时就不需要再分解子问题了。因为这个问题的特点,它不需要显式求解子问题,只需在子问题变成只有一个字符的字符串时输出这个字符串即可,并且因为之前分解子问题的时候,每个位置都已经固定好字符,所以当 begin 位置和 end 位置相同的时候,就实际得到了一个全排列结果。
算法实现的主体就是一个可递归调用的 Permutation() 函数,Permutation() 函数解决字符串 chList 中从 begin 位置开始到 end 位置结束的字符串的全排列问题,要求解原始问题,只需将 begin 设置成 0,将 end 设置成字符串长度 -1 即可(字符串长度 -1 就是字符串最后一个字符的索引位置)。递归展现出了无与伦比的优雅,最后的算法实现只要十几行代码就搞定了。
void Swap(std::string& chList, int pos1, int pos2){ if (pos1 != pos2) { auto tmp = chList[pos1]; chList[pos1] = chList[pos2]; chList[pos2] = tmp; }}//将字符串[begin, end]区间的子串全排列void Permutation(std::string& chList, int begin, int end){ if (begin == end)//就剩一个字符了,不需要排列了,直接输出当前的结果 { std::cout << chList << std::endl; } for (int i = begin; i <= end; i++) { Swap(chList, begin, i); //把第 i 个字符换到 begin 位置,将 begin+1 位置看作新的子串开始 Permutation(chList, begin + 1, end); //求解子问题 Swap(chList, begin, i); //在挑选下一个固定字符之前,需要换回来 }}//求解问题字符串:abcd std::string cl = "abcd"; Permutation(cl, 0, cl.length()); //原始问题的规模是从 0 位置开始的整个字符串
分治法有很多典型的应用,比如二分查找、Karatsuba 大整数乘法、棋盘覆盖问题、快速排序、合并排序,等等,大家可以找来相关的算法实现研究一下,看看各种情况下分解子问题和合并子问题的解的方法。我记得前几年有个很火的网文,说是 90% 的程序员写不出完全正确的二分查找算法,那么本节课的问题就是,用你熟悉的编程语言实现一个二分查找算法,完成这个作业,你就是那 10% 了。
点击了解《算法应该怎么玩?》
终于,在 2019 年的第一天下午,《算法应该怎么玩》的最后一篇完成了,时间比我预想的要长(很多)。因为写一个精品课和写书是两种完全不同的体验,写精品课可以根据读者的反馈随时调整、完善内容,能和读者有一个直接的接触,了解他们的想法或遇到的问题;而写书就不同了,必须全部完稿后,经过三审三校才能出版,而这期间,万一遇到技术升级等,那么图书出版后还要考虑后面的修订版……
当大家看到这篇结语时,相信已经看完了前面的内容了,希望再次回顾一下这门课所要传达的理念,那就是学习算法的目的(或意义):
其实这里面隐含着第三个目的,就是开阔思路,在跟着我的思路解决各种类型的问题过程中,了解到设计数据模型的各种典型手法(或者说就是我本人惯用的方法和经验),以形成大家自己的方法集。之前和读者的交流中也提到过我的观点,包括我对团队内的实习生和新入职员工的观察,发现那些让人觉得“聪明伶俐”的人,都有一个共同点,那就是解决问题的方法多。
方法多的人若遇到一个问题,会用自己惯用的各种方法去尝试解决问题,一种不行就换另一种,在不断尝试的过程中加深对问题的认识,最终找到适合这个问题的解决方法,甚至创造出新的方法;而那些让人感觉有点“笨”的人,往往是方法不多,几种方法试过不行之后就手足无措了。其实这和智商没有太大关系,方法集的形成主要是经验积累,自己多学、多做、多思考,举一反三,或者是从其他人那里学到经验,加入到自己的方法集中。
再来解释一下为什么没有讲基本的数据结构,尽管有很多读者抱怨:算法的课程居然不讲数据结构,不太像话,但是那些内容真不是这门课所关心的。讲讲数组、讲讲链表、讲讲各种排序算法总是轻松的,很容易学会,让人很有掌握这些方法后的成就感,但是,除了面试的时候满足一下懒惰的面试官,对于之后的工作没啥用。
甚至包括我在内的很多人在面试别人的时候,都不会问这种学院派问题。我们通常会找一个工作中遇到的问题问面试者,并不期望他解决,只是观察面试者在分析问题的过程中,对问题建立了什么样的数据模型,从侧面了解他们对问题的抽象思维能力和各种数据结构掌握的程度。我并不是说排序这类基本的算法不重要,相反,这些基础很重要,但不是我的课程关注的内容。
因此,这个课程对读者是有要求的,那就是要了解这些基本的数据结构的特点和使用原则,当然,还要能熟练地使用一种编程语言。
这个课程在介绍算法的时候,都会结合具体的例子来分析。比如“Dijkstra 算法”,大多数数据结构的书或课程都会讲,但基本上都是用几个数字表示的节点图,讲讲算法原理,很容易让读者产生学会了这种算法的成就感。本课程在介绍这个算法的时候,结合了两个实际的比赛题目,重点讲的是如何对问题建模,将问题转化成可以用“Dijkstra 算法”解决的图论模型,最后的算法实现是用 C++ 语言还是用 Java 语言已经不重要了。
讲解“A * 算法”的时候也是一样的,我们用了一个带障碍物的 16 × 16 地图来介绍这个算法,这也是一些老的 RPG 游戏惯用的组织地图的方法,通过这个算法实例,大家可以直观地知道这些著名的算法是怎样与应用相结合的。
为准备这个课程的内容,我找了很多经典的(或著名的)算法。我记得关于一些看起来像是二维矩阵问题建模的时候,大家在群里对一维模型还是二维模型有过讨论,相信看过第 6 部分介绍的 Warren Smith 棋盘模型之后,孰优孰劣大家心里应该都有数了,思路开阔了,遇到问题的时候就多了一种应对的方法。
“Zobrist 哈希算法”是如此的简单,即便无法直接使用这个算法的场合,这种在随机数的基础上异或再异或的方法,也可以用在其他需要哈希计算的场合。
介绍“RLE 压缩算法”的时候,介绍了 PCX 文件的格式以及对这种格式化文件的处理方法。对有格式文件的处理,大家工作中都经常用到,介绍这些惯用思想,反而让“RLE 压缩算法”成了配角。
讲“余弦算法”的时候,介绍了文字处理常用的向量化思想,在介绍“贝叶斯分类算法”识别垃圾邮件的时候,用的还是这种将文本分词,然后再向量化的处理思想。这种思想也是信息数字化的一种思路,掌握这种处理思想,对今后解决文字处理问题,会有很大的帮助。
另外,在课程内容准备的过程中,根据读者的反馈,还补充了“如何理解动态规划法”、“如何设计递归函数”、“状态压缩与动态规划”等相关知识,同时讲解了解决某种类型问题的惯用方法。在介绍中文分词算法的时候,我还补充了汉字编码的一些知识,这些都是我之前在做文字处理相关软件的时候解决过的问题,相信大家今后也会遇到此类问题。
最后,希望每个读者都能看到这里,希望这个课程真的对你有用。
阅读全文: http://gitbook.cn/gitchat/column/5b6d05446b66e3442a2bfa7b