七大常见的算法思想
0. 算法简介
1. 枚举
2. 迭代
3. 递归
4. 分治
5. 动态规划
6. 贪心
7. 回溯
0. 算法简介
算法的概念
算法,简单来说就是利用计算机解决问题的步骤。狭义的来讲,算法可看作是数据传递和处理的方法,就像是各种排序算法等。算法的应用不单体现在编程中,广义的来讲,算法更像是一种事物运行的逻辑和规则。
算法必须具备 5 个重要条件:
-
输入:0 个或多个输入数据,这些输入必须有清楚的描述或定义。
-
输出:至少会有一个输出结果,不可以没有输出结果。
-
明确性:每一个质龄或步骤必须是简洁明确的。
-
有效性:步骤清楚且可行,能让用户用纸笔计算而求出答案。
-
有限性:在有限步骤后一定会结束,不能无限循环。
数据结构可以看作是算法实现的容器,通过一系列特殊结构的数据集合,能够将算法更为高效而可靠地执行起来。即数据结构是为算法服务的,具体选择什么数据结构,与期望支持的算法(操作)有关。
数据结构和相关的算法就是数据进入计算机进行处理的一套完整逻辑。
算法效率的度量
时间复杂度(Time Complexity)
时间复杂度决定了运行时间的长短。一个算法花费的时间与算法中语句的执行次数成正比。
空间复杂度(Space Complexity)
空间复杂度决定了计算时所需的空间资源多少,是对一个算法在运行过程中临时占用存储空间大小的度量。
一个算法在计算机存储上所占用的存储空间包括 3 个方面:
-
存储算法本身所占用的存储空间(与算法书写的长短成正比)。
-
算法的输入输出数据所占用的存储空间(由要解决的问题决定,是通过函数调用而来的,不随算法不同而改变)。
-
算法在运行过程中临时占用的存储空间(因算法而异,有的算法只需要占用少量的临时工作单元,而且不随问题规模的大小而改变,称这种算法为“就地”(in-place)进行的,是节省存储的算法)。
1. 枚举
首先,最为简单的思想,枚举算法。枚举也叫穷举,顾名思义,就是穷尽列举。枚举思想的应用场景十分广泛,也非常容易理解。简单来说,枚举就是将问题所有可能的解,按照不重复、不遗漏的原则依次列举出来,然后一一带入问题检验,从而从一系列可能解中获得能够解决问题的精确解。
枚举虽然看起来简单,但是其实还是有一些容易被人忽视的考虑点。比方说待解决问题的「可能解/候选解」的筛选条件,「可能解」之间相互的影响,穷举「可能解」的代价,「可能解」的穷举方式等等。
很多时候实际上不必去追求高大上的复杂算法结构,反而大道至简,采用枚举法就能够很好地规避系统复杂性带来的冗余,同时或许在一定程度上还能够对空间进行缩减。
图解
枚举思想的流程可以用下图来表示。通过实现事先确定好「可能解」,然后逐一在系统中进行验证,根据验证结果来对「可能解」进行分析和论证。这是一种很明显的结果导向型的思想,简单粗暴地试图从最终结果反向分析「可能解」的可行性。
不过,枚举思想的劣势当然也很明显。在实际的运行程序中,能够直接通过枚举方法进行求解的问题少之又少。而当「可能解」的筛选条件不清晰,导致「可能解」的数量和范围无法准确判断时,枚举就失去了意义。
然而当「可能解」的规模比较小,同时依次验证的过程容易实施时,枚举思想不失为一种方便快捷的方式。只不过在具体使用时,还可以针对应用场景对「可能解」的验证进行优化。
这种优化可以从两个方向入手:
- 问题的简化,尽可能对需要处理的问题进行模型结构上的精简。这种精简具体可体现在问题中的变量数目,减少变量的数据,从而能够从根本上降低「可能解」的组合。
- 对筛选「可能解」的范围和条件进行严格判断,尽可能的剔除大部分无效的「可能解」。
虽说如此,但是一般而言大部分枚举不可用的场景都是由于「可能解」的数量过多,无法在有限空间或有限时间内完成所有可能性的验证。不过实际上枚举思想是最接近人的思维方式,在更多的时候是用来帮助我们去「理解问题」,而不是「解决问题」。
常见应用
- 选择排序
- 顺序查找法
- ...
2. 迭代
迭代法(iterative method)是指无法使用公式一次求解,而需要使用迭代,例如用循环取重复执行程序代码的某些部分来得到答案,本质思想是递推。
递推思想跟枚举思想一样,都是接近人类思维方式的思想,甚至在实际生活具有比枚举思想更多的应用场景。人脑在遇到未知的问题时,大多数人第一直觉都会从积累的「先验知识」出发,试图从「已知」推导「未知」,从而解决问题,说服自己。
事实上,迭代就是一种递推的算法思想。递推思想的核心就是从已知条件出发,逐步推算出问题的解。实现方式很像是初高中时我们的数学考卷上一连串的「因为」「所以」。那个时候还是用三个点来表示的。
而对于计算机而言,复杂的推导其实很难实现。计算机擅长的是执行高密度重复性高的工作,对于随机性高变化多端的问题反而不好计算。相比之下,人脑在对不同维度的问题进行推导时具有更高的自由度。
比方说,人脑可以很容易的从「太阳从东边升起」推出「太阳从西边落下」,然后大致推出「现在的时间」。但是对于计算机而言并没有那么容易,你可能需要设置一系列的限制条件,才能避免计算机推出「太阳/月亮/星星」从「南/北/东边」「落下/飞走/掉落」的可能性。
这个例子的用意是在说明,计算机在运用递推思想时,大多都是重复性的推理。比方说,从「今天是1号」推出「明天是2号」。这种推理的结构十分类似,往往可以通过继而往复的推理就可以得到最终的解。
图解
常见应用
- 冒泡排序
- 矩阵相加、相乘、转置
- 链表
- ...
3. 递归
「递」的理解可以是逐次、逐步。在递归中可理解为逐次回归迭代,直到跳出回归。
递归算法实际上是把问题转化成规模更小的同类子问题,先解决子问题,再通过相同的求解过程逐步解决更高层次的问题,最后获得问题的最终解。递归算法要求子问题跟父问题的结构相同。
用一句话来形容递归算法的实现,就是在函数内部调用(函数)自身的算法。所以在实现的过程中,最重要的是确定递归过程终止的条件,也就是迭代过程跳出的条件判断。否则,程序会在自我调用中无限循环,最终导致内存溢出而崩溃。
图解
递归思想其实就是一个套娃过程。一般官方都是严禁套娃行为的。所以在使用时一定要明确「套娃」举动的停止条件,及时止损。
常见应用
- 回溯算法
- 阶乘
- 斐波那契数列
- 堆栈
- 汉诺塔
- 二叉树遍历
- 图的深度优先遍历
- 图的广度优先遍历
- ...
4. 分治
分治,分而治之。分治算法的核心步骤就是两步,一是分,二是治。在实际的运用中,分治算法主要包括两个维度的处理,一是自顶向下,将主要问题逐层级划分为子问题;二是自底向上,将子问题的解逐层递增融入主问题的求解中。
分治算法很像是一种向下管理的思想,从最高级层层划分,将子任务划分给不同的子模块,进而可以进行大问题的拆分,对系统问题的粒度进行细化,寻求最底层的最基本的解。这样的思路在很多领域都有运用,比如几何数学中的正交坐标、单位坐标、基的概念等,都是通过将复杂问题依照相同的概念,分割成多个子问题,直到这些子问题足够简单到可以解决,最后再将各个子问题的解合并,从而得到原问题的最终解。
分治算法还引申出了一系列的问题,为什么分,怎么分,怎么治,治后如何。
-
为什么要分?这个很好解释,由于主要问题的规模过大,无法直接求解,所以需要对主要问题进行粒度划分。
-
那怎么分?遵循计算机最擅长的高密度重复性运算,划分出来的子问题需要相互独立并且与原问题结构特征相同,这样能够保证解决子问题后,主问题也就能够顺势而解。
-
怎么治?这就涉及到最基本子问题的求解,我们约定最小的子问题是能够轻易得到解决的,这样的子问题划分才具有意义,所以在治的环节就是需要对最基本子问题的简易求解。
-
治后如何?子问题的求解是为了主问题而服务的。当最基本的子问题得到解后,需要层层向上递增,逐步获得更高层次问题的解,直到获得原问题的最终解。
图解
如下图所示,通过层层粒度上的划分,将原问题划分为最小的子问题,然后再向上依次得到更高粒度的解。从上而下,再从下而上。先分解,再求解,再合并。
常见应用
- 递归算法
- 归并排序
- 快速排序
- 二分查找法
- 插值查找法
- ...
5. 动态规划
我们知道分治思想最重要的一点是分解出的子问题是相互独立且结构特征相同的,这一点并不是所有问题都能满足,许多问题划分的子问题往往都是相互重叠且互相影响的,那么就很难使用分治算法进行有效而又干净的子问题划分。
于是乎,动态规划来了。动态规划同样需要将问题划分为多个子问题,但是子问题之间往往不是互相独立的。
动态规划的主要做法是:如果一个问题答案与子问题相关的话,就能将大问题拆解成各个小问题,其中与分治法最大不同的地方是可以让每一个子问题的答案被存储起来,以供下次求解时直接取用。这样的做法不但能减少再次计算的时间,还可以将这些解组合成大问题的解答,故而使用动态规划可以解决重复计算的问题。
动态规划是在目前看来非常不接近人类思维方式一种算法,主要原因是在于人脑在演算的过程中很难对每一次决策的结果进行记忆。动态规划在实际的操作中,往往需要额外的空间对每个阶段的状态数据进行保存,以便下次决策的使用。
最优化原理
当前子问题的解可看作是前多个阶段问题的完整总结。因此这就需要在子问题求解的过程中进行多阶段的决策,同时当前阶段之前的决策都能够构成一种最优的子结构。这就是所谓的最优化原理。
一个最优化策略具有这样的性质:不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略。同时,这样的最优策略是针对已作出决策的总结,对后来的决策没有直接影响,只能借用目前最优策略的状态数据。这也被称之为无后效性。
图解
动态规划的求解思路如下图解:动态规划的开始需要将问题按照一定顺序划分为各个阶段,然后确定每个阶段的状态,如图中节点的 F0 等。然后重点是根据决策的方法来确定状态转移方程。也就是需要根据当前阶段的状态确定下一阶段的状态。在这个过程中,下一状态的确定往往需要参考之前的状态。因此需要在每一次状态转移的过程中将当前的状态变量进行记录,方便之后的查找。
常见应用
示例:根据动态规划法的思想,修改斐波那契数列的递归算法
1 output = [None] * 1000 # 斐波那契数列的暂存区 2 3 def fibonacci(n): 4 result = output[n] 5 if result is None: 6 if n == 0: 7 result = 0 8 elif n == 1: 9 result = 1 10 else: 11 result = fibonacci(n-1) + fibonacci(n-2) 12 output[n] = result 13 return result 14 15 if __name__ == "__main__": 16 for i in range(10): 17 print(fibonacci(i))
6. 贪心
人活在世上,不可能每一个选择都那么恰到好处,很多问题根本没有准确解,甚至于无解。所以在某些场景下,傻傻地去追求问题的最精确解是没有意义的。
有人说,那还有最优解呢。难道最优解都不要了吗?没错,许多问题虽然找不到最精确的解,但是的确会存在一个或者一些最优解。但是一定要去追求这些最优解吗?我看不一定。
算法的存在不是单纯地为了对问题求解,更多的是提供一种「策略」。何谓「策略」,解决问题的一种方式,一个角度,一条路。所以,贪心思想是有价值的。
从贪心二字可得知,这个算法的目的就是为了「贪图更多」。但是这种贪心是「目光短浅」的,这就导致贪心算法无法从长远出发,只看重眼前的利益。
具体点说,贪心算法在执行的过程中,每一次都会选择当前最大的收益,但是总收益却不一定最大。所以这样傻白甜的思路带来的好处就是选择简单,不需要纠结,不需要考虑未来。
贪心算法的实现过程就是从问题的一个初始解出发,每一次都作出「当前最优」的选择,直至遇到局部极值点。
- 缺点:贪心所带来的局限性很明显,就是无法保证最后的解是最优的,很容易陷入局部最优的情况。
- 优点:但是它每一次做选择的速度很快,同时判断条件简单,能够比较快速地给出一种差不多的解决方案。
图解
下图表示的是对多条直线的交点进行求解。很显然,图中的直线是不存在统一交点的,但是可以通过算法求得统一交点的最优解。若是采用贪心算法,那么在进行迭代时,每一次都会选择离此时位置最近的直线进行更新。这样一来,在经过多次迭代后,交点的位置就会在某一片区域无限轮回跳转。而这片区域也就是能得出的大致的最优解区域。
常见应用
- 最小生成树
- 图的最短路径法
- ...
7. 回溯
回溯算法也可称作试探算法,是不是让你回忆起了在女神面前的小心翼翼。简单来说,回溯的过程就是在搜索过程中寻找问题的解,当发现不满足求解问题时,就回溯(即不再递归到下一层,而是返回到上一层,以节省时间),尝试别的路径,避免无效搜索,是一种走不通就退回再走的方法。
这样看起来,回溯算法很像是一种进行中的枚举算法,在行进的过程中对所有可能性进行枚举并判断。常用的应用场景就在对树结构、图结构以及棋盘落子的遍历上。
回溯思想在许多大规模的问题的求解上都能得到有效的运用。回溯能够将复杂问题进行分步调整,从而在中间的过程中可对所有可能运用枚举思想进行遍历。这样往往能够清晰地看到问题解决的层次,从而可以帮助更好地理解问题的最终解结构。
图解
假设目的是从 O0 到达 O4,需要对所有节点进行回溯遍历路径。那么回溯算法的过程则需要在前进的每一步对所有可能的路径进行试探。
比方说,O0 节点前进的路径有三条,分别是上中下条的 O1。回溯过程的开始,先走上面的 O1,然后能够到达上面 O2,但是这时是一条死路。那么就需要从 O2 退回到 O1,而此时 O1 的唯一选择也走不通,所以还需要从 O1 退回到 O0。然后继续试探中间的 O1。
回溯算法的过程就是不断进行这样的试探、判断、退回并重新试探,直至找到一条完整的前进路径。只不过在这个过程中,可以通过「剪枝」等限制条件降低试探搜索的空间,从而避免重复无效的试探。比方说上下的 O2 节点,在经过 O0-O1-O2 的试探之后,就已经验证了该节点不可行性,下次就无须从 O1 开始重复对 O2 的试探。
常见应用
- 八皇后问题
- ...