《啊哈!算法》学习心得

文章目录

    • 内容概述
    • 第一章 一大波数正在靠近——排序
    • 第二章 栈、队列、链表
    • 第三章 枚举!很暴力
    • 第四章 万能的搜索
    • 第五章 图的遍历
    • 第六章 最短路径
      • Floyd-Warshall算法
      • Dijkstra算法
      • Bellman Ford算法
    • 第七章 神奇的树
    • 第八章 更精彩的算法
      • 图的最小生成树
      • 图的割点
      • 图的割边
      • 二分图的最大匹配

一本通俗易懂、生动形象的算法书~作为算法入门指南是最合适不过的了

内容概述

书中涉及到的数据结构有:栈、队列、链表、树(二叉树、堆、并查集)、图等;

涉及到的算法思路有:枚举、搜索、贪心、动态规划等;

具体的算法内容有:排序算法、深度优先搜索、宽度优先搜索、最短路径算法(Floyd-Warshall算法、Dijkstra算法、Bellman-Ford算法)、最小生成树、图的割点、割边和二分图最大匹配算法;

第一章 一大波数正在靠近——排序

具体的算法有:桶排序、冒泡排序、快速排序;

  1. 桶排序的核心是:统计每一个数字出现的次数,然后实现排序目的;
  2. 冒泡排序的核心是:每一次遍历中,通过相邻两数间的比较,找出待排序数中最小(或者最大)的数,最后没有数可找的时候,数据就有序了。
  3. 快速排序的核心是:二分的思想+双指针遍历数组;所谓二分是指将数据分为两个部分:比某数A大的数和不大于A的数。这样,我们遍历完一遍数据后,A就到了其该出现的位置,同时数据也分为两部分,而这两部分可以继续使用这种方法处理,知道所处理的数据大小变为1;双指针遍历数组是一个非常常用的技巧;

我们知道,排序算法的主要目的就是排序啦,可是如何形成一个“序列”呢?我们要操作的对象是一行数(通常,都用数组来存储代排序数组,所以“行”字比较形象);冒泡排序和快速排序以及其他一些排序算法(如,归并排序、插入排序、希尔排序、堆排序等)都是在“横”向操作数据,也就是在“行”的方向上操作以让数据有序化,但是,桶排序确是在“列”的方向上操作数据以实现排序目的,虽然桶排序有自己的不足,但是在一些特定问题中,桶排序的思想还是很好用的;

第二章 栈、队列、链表

这一章主要介绍了上述三种数据结构,并且提供了对应的应用实例;这些数据结构比较基础,其详细内容可以参见数据结构之链表;

至于如何选择合适的数据结构,个人的心得是看如何操作数据。使用栈和队列,说明对数据的操作不是一次性的,如果是一次性的,那么直接遍历一遍就ok了。我们假设对某列数据X有A和B两种操作,对X中的数据x来说执行完A后,才能执行B。现在有x和y两个数据个体,如果在执行A的时候,顺序是x,y,并且执行B的时候,其顺序还是x,y;那么就应该使用队列啦;如果执行B的时候,顺序为y,x,那么就要选用栈啦;比如A=排队,B=拿到早餐;则排队顺序就是拿到早餐的顺序,所以需要使用队列;A=填充子弹,B=发射子弹,那么就应该使用栈了;

第三章 枚举!很暴力

枚举是一种常见的算法思想:将所有可能的结果遍历一遍,以获得某种知识;当然,这里要求面对的解空间是有限的并且是可枚举的;有时候使用枚举,将面临非常大的时空开销,这是因为解空间太大,这时候我们往往需要缩小解空间,以便使枚举的时空开销可控;关于枚举,最近接触过的实际应用便是一道求解数独的算法题,在leetcode上属于困难类别,但是也有网友说是最简单的困难题(大佬眼里,都是简单题啊,就像学霸眼里的送分题一样),就是使用枚举思想,不过并不是每一个小方格都枚举1-9,而是通过数独的要求,并且维护一个记录已出现数字的数组,以降低解空间的大小;

第四章 万能的搜索

这里,就涉及到对图的遍历了。图的遍历方式总体来说有两种:深度优先和广度优先;

所谓深度优先,就是指一路向下,无路可走的时候就往回走,然后继续一路向下;深度优先常常使用递归的方法实现,当然也可以使用栈+循环来实现——本质上都是相同的;

所谓广度优先,也叫宽度优先是一种横向的遍历方式,如果把深度优先称为纵向的遍历方式;广度优先遍历图的时候使用队列组织待遍历点;

这里,搜索的一个实例就是全排列,给定n个互不相同的数,求出这组数的所有全排列;

其实,搜索也是一种遍历方法;全排列问题其实就是遍历n个空格,遍历空格的时候需要作出选择:为其赋值,一旦赋值,就继续访问下一个空格;没有空格的时候就回头;当然,随着访问深度的增加,其可选的数字也越来越少,也就越容易回头;

第五章 图的遍历

第四章算是遍历的实际应用,而第五章则正式提出概念;

第六章 最短路径

在图上求两点之间的最短路径,算是一种对图的常见操作,这一张一共介绍了三种最短路径的求法和一种优化算法;

Floyd-Warshall算法

Floyd-Warshall算法:该算法简洁明了,核心代码一共五行(显然,没有算上大括号),使用了“动态规划”的策略。

for(int k=1;k<=n;k++)
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			if(e[i][j]>e[i][k]+e[k][j])
				e[i][j]=e[i][k]+e[k][j];

其思想是对于点i和点j之间的距离,是否因为点k的中转而变小,如果变小,则修改之;有可能有小伙伴会问,那么如何处理因为经s,m,n等多点中转而导致距离变小的问题呢?我们先看看这三层for循环都干了些什么~

当然是从最里面开始看了~很明显,for循环的最里面执行了一件事:满足一定条件则更新i和j之间的边值;所以其中第三层和第二层循环其实是在遍历图中的边,而且第一层循环是在遍历中间点;也就是说,对于每一个中间点,我们都遍历了一遍图中的边;

接下来,我们考虑经多点中转的情况,我们假定i和j之间依次通过s,m,n之后得到的路径最短,且s

基于上面的分析,我们知道当k=s的时候,i与m之间形成最短距离(经s中转);当k=m的时候,i与n形成最短距离(经m中转,因为i到m经s中转,所以此时路径上有s,m);自然,当k=n的时候,i与j形成最短路径,自然也就是经过s,m,n中转的了。
到这里,Floyd-Warshall算法的合理性就解释清楚啦;另外,我们如何记录从i到j的最短路径上的点呢?其实,这里我们可以参考并查集的处理方法,使用数组来记录最短路径的情况;关于并查集,在第七章里还会谈到的;

Dijkstra算法

Dijkstra算法使用的是“贪心”策略。从起点出发,每次都选择距离起点最短的一个点A加入“最短路径”,这时因为A点的加入,会使起点到其他点的距离发生变化——通过A的中转,有可能改变起点到其他点之间的距离;当然,这里的其他点是指和A有边关系的那些点;更新完这些距离后就继续在所有尚未加入“最短路径”的点中寻找下一个最近的点,直到到达终点;

每次都选择局部最优解,最后获得全局最优解的策略就是贪心策略;当然,有时候局部最优解的叠加不一定带来全局最优解,但是在这里,恰好可以证明最后一定会得到最优解;可以继续使用反证法哦;这里就不做证明啦;

从关注点的角度来看,Dijkstra算法是一个关注“点”的算法,因为每一次对“最短路径”的扩充,都是以点为踏板,寻找一个与其相关的最短的边;

值得注意的是,Dijkstra算法无法处理边的长度为负值的情况;因为如果存在一条边为负值,那么引入这条边的时候,有可能影响起点到负边另一端点的最小距离,从而导致贪心出现失误,无法得到正确的解:比如三边为AB=10,AC=8,BC-3。起点选择A,那么根据Dijkstra算法,首先加入最短路径的是C,因为AC的距离为8;然后更新AB的距离为5;因为B点的引入,使得继续更新A到C的距离,结果AC就变成了2,然后就循环下去了。。。那么如果BC=1的话,AB的距离为AC+BC=9,所以引入B之后,会更新A到C的距离,此时新AC=AB+BC=旧AC+BC+BC,而BC大于0,所以新AC一定大于旧AC,所以说一旦某个点X被引入“最短路径”,起点到X之间就已经形成最短路径,接下来的点不能改变起点到X的距离,而负边恰恰改变了,所以Dijkstra算法中图里不能出现负边;当然,这和实际的情况是相吻合的;

另外,这里还需要考虑的问题就是图的存储方式了,这和理解Dijkstra算法的核心思想无关,只是可以在特定问题下对算法进行优化而已,这个问题先抛出来,然后我们会在数据结构之图中详细探讨~

Bellman Ford算法

这是“一个无论是思想上还是代码实现上都堪称完美的最短路径算法”(原文)其核心代码只有四行:

for(k=1;kdis[u[i]]+w[i])
    		dis[v[i]]=dis[u[i]]+w[i];

其中,n为顶点个数,m为边的个数;u[k]表示第k条边的起点,v[k]表示第k条边的终点,w[k]表示第k条边的权重;dis[k]表示源点(也就是起点啦)到顶点k的最短距离;

我们先看看两重循环都做了什么。首先最内层表示:如果源点到v[m]所表示的点的距离因为编号为m的边的存在而变小,那么就更新相应的距离;第二层循环是遍历了所有的边;那么遍历完所有的边意味着什么呢?最多就是修改了dis数组,为什么会修改数组呢?这是因为某一条边的引入改变了相应的距离;是的,当k=1的时候,就表示引入一条边的时候,会改变那些点对应的dis数组值;那么一个有n个顶点的图中,两点之间的最短路径最多只有n-1条边;难道就没有回路?

是的,最短路径肯定是一个不包含回路的简单路径;如果最短路径中包含正权回路,那么去掉这个回路可以得到更短的路径;如果最短路径里包含负权路径,那么就没有最短路径可言了,所以最短路径(如果可求的话),只会包含n-1条边,所以我们最多只需引入n-1条边。那么问题来了,如果图中的边数m

实际上,上面的代码还可以优化;这是因为,在一轮边遍历结束后,如果dis[v[m]]发生变化,那么它将会影响到从起点到那些点的距离呢?自然是以v[m]为顶点的出边所对应的端点;这就是可优化的地方:我们可以使用队列来维护这些点,然后当队列为空的时候,就停止边遍历~当然,这里还涉及对于如何寻找以A点为顶点的出边等图的存储问题;所以同上,抛出这个问题,以后做详细总结;

第七章 神奇的树

树,算是一种特殊的图。特殊性在于每个结点只有一个父结点(根结点除外);二叉树有很多好的性质;我们在数据结构之树中已经提到,这里不做过多讨论;

至于,堆,可以用于构造优先权队列,也可以用于堆排序中,查找最值的话很方便,但是需要维护堆这一结构,而堆的原型还是二叉树;所以这里也不做深究;我想谈的是关于并查集的一些想法,因为以前没有在意过,也没思考过,这次算是还以前的债吧~

数据结构是使用计算机来组织数据的方式,而组织数据的时候,我们同样也维护了数据之间的关系;因此,不同的数据结构,其所直观体现的数据间关系也不同;

我们知道,最常见的二叉树实现方式是使用链表,此时,我们可以通过父节点快速得到其子节点,但是通过子节点却不太容易找到父节点,除非维护一个指向父节点的指针;因为二叉树的特点,所以有时也使用数组来构造二叉树,这就使得我们可以通过下标运算快速得到父子关系;但是使用数组却无法很方便地存储“传统”的树。之所以说是传统的树,是因为,我们(或者说,是我吧)常常希望通过父节点找到子节点;而父-子关系是一个一对多的关系,但是反过来看,子-父关系确实一个一对一的关系:使用数组来存储一对一关系是很方便的呀!!!并查集就是通过维护子-父关系来刻画一棵树的;这算是我关于并查集的第一点想法;

其实,我们曾经使用数据来存储路径,这么做的原因也是因为路径上前后两个端点之间也是一对一的关系啊(如果,指定方向的话~);

并查集,顾名思义:并+查;所以使用并查集应该很容易实现“并”操作和“查”操作吧没错,这里的“并”就是指树的合并,而“查”则是指对公共祖先节点的查询(额,自己的理解了如和官方说法有矛盾,请以你自己的理解为准)。所以,并查集是对森林进行管理的一种好方式;只是,我们需要“从下往上”看这棵树(即通过子节点寻找父节点);这不能算是缺点,应该算是特点吧所以,利用好并查集的特点才是我们该做的事~

接下来,我们看看,怎么合并,怎么查吧~两棵树的合并是指这两棵树将拥有同一个父节点,那么面临的一个问题就是父节点从何处来呢?这里其实随意即可,因为不论怎样,它们合并就可(它们拥有同一个父节点即可);比如,两棵树K和M合并,因为K和M作为根节点,UF[K]=K;UF[M]=M;这里我们把UF[M]修改为K,表示M节点的父节点为K节点;于是,所有以M为最原始祖先的节点,它们的根节点都变成了K;也就是完成了合并,是不是很简单呢?

通过并查集,我们可以很容易判断两个节点是否有同一个根节点;这便是“查”啦;

另外,树作为一种组织数据的数据格式,它通过特殊的结构表示特殊的关系,这里的关系,是一种属于关系:节点属于树,从而可以将同一棵树上的节点视为同一类节点;于是,并查集就可以用来分类啦而这将在第八章中关于图的算法(最小生成树、二分图的最大匹配算法等)中得到体现

总体来说,并查集是一种组织森林的方式,而什么是森林?这就需要我们给出自己的答案啦~

第八章 更精彩的算法

图的最小生成树

把一个有n个点的图变为连通图(连通图这一概念将在专门的博文里系统总结),至少需要n-1条边;如果一个连通无向图不包含回路,那么其实就是一棵树了我们把这棵树叫做图的生成树,树的特点之一是任意两个节点之间存在通路~而其中所有边的权值最小的一棵(不唯一)被称为最小生成树;

所以,求一个图的最小生成树,我们只需要选择n-1条权值最小的边,并且在添加一条边的时候,我们不能形成回路(因为树里没有回路嘛);接下来,我们的问题就是如何判断添加一条边会不会造成回路?如果两个节点(边所对应的两个点)已经在生成树里了,那么再添加一条这两个节点之间的边,就会形成回路;相反,如果两个节点中,一个在生成树中,一个不在,那么就可以放心添加啦,注意,这里出现了分类哦于是,我们的并查集登场啦

这个算法的名字叫Kruskal算法,其核心思想是:将边按权值排序,每次选择最小的边,且该边所关联的两个点不在同一个集合里,将这样的边加入生成树中,直到加入n-1条边为止;

书中提到的另一个关于最小生成树的著名就是Prim算法:该算法以点为踏板,不断寻找距离生成树最近的点,将其加入生成树中;直到树中有n-1个点;值得注意的是,Kruskal算法以边为中心,而Prim算法则以点为中心;就像DIjKstra算法以点为主,而Bellman Ford算法则以边为主一样,很好玩,也很有意思;

图的割点

关于图的割点,是这样的一类点:如果从图上将该点抹去,那么图就会失去连通性;书中提到的算法思路是:使用深度优先遍历方法遍历图,在访问到某个节点时,为其分配一个时间戳标记,标志访问顺序,如果在访问一个节点时,对其所有子节点进行深度优先访问,并记录其子节点可以访问到的最小时间戳,然后更新当前节点可以访问到的最小时间戳,如果某个子节点可以访问到的节点的时间戳大于等于当前节点的时间戳,那么当前节点就是一个割点,因为其子节点不通过其父节点就无法联系到祖宗啦;

这里,连通表现为两两可达,而引入时间戳的作用则是记录访问顺序;

图的割边

图的割边是这样的边:如果从图中抹去这条边,那么图就不再连通;

关于图的割边求法,实际上只需要改变判断条件即可:如果一个节点连父节点都回不去,那么从父节点到该子节点之间的边就是一条割边了;

二分图的最大匹配

二分图是这样的图:图中所有顶点分别属于X和Y两个集合,并且所有边的两个顶点恰好一个属于X,另一个属于Y,那么这样的图就是二分图;

所谓二分图的最大匹配就是在X集合中的点与Y集合中的点之间建立边的联系,其中边数量最多的一种匹配称为最大匹配;没错,这里组织X和Y的方式就是并查集~

算法思路:

  1. 从尚未匹配的点集中选择一个点M,考察与该点有边关系的一条边,如果另一个节点A尚未匹配,那么找到一个匹配,更新相关数据;如果A已经匹配,那么就为与A匹配的那个点,再找一个匹配(递归调用~);如果找到了,那么就可以修改关系;如果没找到就返回;
  2. 如果上面的匹配失败,那么就继续尝试其他边~直到M匹配成功或者,遍历完所有与之相关的边;
  3. 对未匹配的点执行1和2,直到所有的点都尝试完毕;
  4. 输出匹配信息

总体来说,《啊哈!算法》是一本非常棒的算法书,带给我很多启示,特别是书中会留一些问题给读者思考,如果自学的话,很有可能就忽略了问题,从而无法彻底理解,比如,在快速排序中,为什么要尾指针先动?

期待《啊哈!算法2——伟大思维闪耀时》!我想,这一定是一本讲解算法思想的书~

你可能感兴趣的:(算法思悟)