递归/回溯/深度优先搜索/广度优先搜索 /动态规划/二分搜索/贪婪算法
递归(Recursion)
算法思想
递归算法是一种调用自身函数的算法(二叉树的许多性质在定义上就满足递归)。递归的基本性质就是函数调用,在处理问题的时候,递归往往是把一个大规模的问题不断地变小然后进行推导的过程。
举例:(汉诺塔问题)有三个塔 A、B、C,一开始的时候,在塔 A 上放着 n 个盘子,它们自底向上按照从大到小的顺序叠放。现在要求将塔 A 中所有的盘子搬到塔 C 上,让你打印出搬运的步骤。在搬运的过程中,每次只能搬运一个盘子,另外,任何时候,无论在哪个塔上,大盘子不能放在小盘子的上面。
通俗来说,把要实现的递归函数看成是已经实现好的, 直接利用解决一些子问题,然后需要考虑的就是如何根据子问题的解以及当前面对的情况得出答案。这种算法也被称为自顶向下(Top-Down)的算法。
解题步骤
- 合法性判断;判断当前情况是否非法,如果非法就立即返回,这一步也被称为完整性检查(Sanity Check)。例如,看看当前处理的情况是否越界,是否出现了不满足条件的情况。通常,这一部分代码都是写在最前面的。
- 递归结束条件判断;判断是否满足结束递归的条件。在这一步当中,处理的基本上都是一些推导过程当中所定义的初始情况。
- 递归主程序;将问题的规模缩小,递归调用。在归并排序和快速排序中,我们将问题的规模缩小了一半,而在汉诺塔和解码的例子中,我们将问题的规模缩小了一个。
- 结果整合;利用在小规模问题中的答案,结合当前的数据进行整合,得出最终的答案。
##斐波那契数列 def fibnacci(n): if n == 1 or n == 2: return 1 else: return fibnacci(n-1) + fibnacci(n-2) ##非递归写法 def fibnacci_no_recurision(n): f = [0,1,2] if n > 2: for i in range(n -2): num = f[-1]+f[-2] f.append(num) return f[n] print(fibnacci_no_recurision(100))
回溯(Backtracking)
算法思想
回溯实际上是一种试探算法,这种算法跟暴力搜索最大的不同在于,在回溯算法里,是一步一步地小心翼翼地进行向前试探,会对每一步探测到的情况进行评估,如果当前的情况已经无法满足要求,那么就没有必要继续进行下去,也就是说,它可以帮助我们避免走很多的弯路。
回溯算法的特点在于,当出现非法的情况时,算法可以回退到之前的情景,可以是返回一步,有时候甚至可以返回多步,然后再去尝试别的路径和办法。这也就意味着,想要采用回溯算法,就必须保证,每次都有多种尝试的可能。
解题步骤
- 判断当前情况是否非法,如果非法就立即返回;
- 当前情况是否已经满足递归结束条件,如果是就将当前结果保存起来并返回;
- 当前情况下,遍历所有可能出现的情况并进行下一步的尝试;
- 递归完毕后,立即回溯,回溯的方法就是取消前一步进行的尝试。
递归和回溯可以说是算法面试中最重要的算法考察点之一,很多其他算法都有它们的影子。例如,二叉树的定义和遍历就利用到了递归的性质;归并排序、快速排序的时候也运用了递归;动态规划,它其实是对递归的一种优化;二分搜索,也可以利用递归去实现。
注意:要能熟练掌握好分析递归复杂度的方法,必须得有比较扎实的数学基础,比如对等差数列、等比数列等求和公式要牢记。
深度优先搜索(Depth-First Search / DFS)
深度优先搜索,从起点出发,从规定的方向中选择其中一个不断地向前走,直到无法继续为止,然后尝试另外一种方向,直到最后走到终点。就像走迷宫一样,尽量往深处走。
DFS 解决的是连通性的问题,即,给定两个点,一个是起始点,一个是终点,判断是不是有一条路径能从起点连接到终点。起点和终点,也可以指的是某种起始状态和最终的状态。问题的要求并不在乎路径是长还是短,只在乎有还是没有。有时候题目也会要求把找到的路径完整的打印出来。
DFS 是图论里的算法,分析利用 DFS 解题的复杂度时,应当借用图论的思想。图有两种表示方式:邻接表、邻接矩阵。假设图里有 V 个顶点,E 条边。
时间复杂度:
- 邻接表
访问所有顶点的时间为 O(V),而查找所有顶点的邻居一共需要 O(E) 的时间,所以总的时间复杂度是 O(V + E)。
- 邻接矩阵
查找每个顶点的邻居需要 O(V) 的时间,所以查找整个矩阵的时候需要 O(V2) 的时间。
举例:利用 DFS 在迷宫里找一条路径的复杂度。迷宫是用矩阵表示。
解法:把迷宫看成是邻接矩阵。假设矩阵有 M 行 N 列,那么一共有 M × N 个顶点,因此时间复杂度就是 O(M × N)。
空间复杂度:
DFS 需要堆栈来辅助,在最坏情况下,得把所有顶点都压入堆栈里,所以它的空间复杂度是 O(V),即 O(M × N)。
例题:利用 DFS 去寻找最短的路径。
解题思路
思路 1:暴力法。
寻找出所有的路径,然后比较它们的长短,找出最短的那个。此时必须尝试所有的可能。因为 DFS 解决的只是连通性问题,不是用来求解最短路径问题的。
思路 2:优化法。
一边寻找目的地,一边记录它和起始点的距离(也就是步数)。
从某方向到达该点所需要的步数更少,则更新。
广度优先搜索(Breadth-First Search / BFS)
广度优先搜索,一般用来解决最短路径的问题。和深度优先搜索不同,广度优先的搜索是从起始点出发,一层一层地进行,每层当中的点距离起始点的步数都是相同的,当找到了目的地之后就可以立即结束。
广度优先的搜索可以同时从起始点和终点开始进行,称之为双端 BFS。这种算法往往可以大大地提高搜索的效率。
举例:在社交应用程序中,两个人之间需要经过多少个朋友的介绍才能互相认识对方。
解法:
- 只从一个方向进行 BFS,有时候这个人认识的朋友特别多,那么会导致搜索起来非常慢;
- 如果另外一方认识的人比较少,从这一方进行搜索,就能极大地减少搜索的次数;
- 每次在决定从哪一边进行搜索的时候,要判断一下哪边认识的人比较少,然后从那边进行搜索。
动态规划
Wikipedia 定义:它既是一种数学优化的方法,同时也是编程的方法。
1. 是数学优化的方法——最优子结构
动态规划是数学优化的方法指,动态规划要解决的都是问题的最优解。而一个问题的最优解是由它的各个子问题的最优解决定的。
由此引出动态规划的第一个重要的属性:最优子结构(Optimal Substructure)。
一般由最优子结构,推导出一个状态转移方程 f(n),就能很快写出问题的递归实现方法。
2. 是编程的方法——重叠子问题
#动态规划 p = [0,1,5,8,10,13,17,18,22,25,30] ##含递归,重复计算效率低 def cut_rod_recurision(p,n): if n == 0: return 0 else: res = p[n] for i in range(1,n): res = max(res,cut_rod_recurision(p,i)+cut_rod_recurision(p,n-i)) return res cut_rod_recurision(p,9) #最优子结构、重复子问题 def cut_rod_recurision2(p,n): if n == 0: return 0 else: res = 0 for i in range(1,n+1): res = max(res,p[i]+cut_rod_recurision2(p,n-i)) return res print(cut_rod_recurision2(p,9))
动态规划是编程的方法指,可以借助编程的技巧去保证每个重叠的子问题只会被求解一次。
引出了动态规划的第二个重要的属性:重叠子问题(Overlapping Sub-problems)。
因此,判断一个问题能不能称得上是动态规划的问题,需要看它是否同时满足这两个重要的属性:最优子结构(Optimal Substructure)和重叠子问题(Overlapping Sub-problems)
自底向上(Bottom-Up)
自底向上指,通过状态转移方程,从最小的问题规模入手,不断地增加问题规模,直到所要求的问题规模为止。依然使用记忆化避免重复的计算,不需要递归。
根据动态规划问题的难易程度,把常见的动态规划面试题分成如下三大类。
线性规划
面试题中最常见也是最简单的一种。
线性,就是说各个子问题的规模以线性的方式分布,并且子问题的最佳状态或结果可以存储在一维线性的数据结构里,例如一维数组,哈希表等。
解法中,经常会用 dp[i] 去表示第 i 个位置的结果,或者从 0 开始到第 i 个位置为止的最佳状态或结果。例如,最长上升子序列。dp[i] 表示从数组第 0 个元素开始到第i个元素为止的最长的上升子序列。
区间规划
区间规划,就是说各个子问题的规模由不同的区间来定义,一般子问题的最佳状态或结果存储在二维数组里。一般用 dp[i][j] 代表从第 i 个位置到第 j 个位置之间的最佳状态或结果。
解这类问题的时间复杂度一般为多项式时间,对于一个大小为 n 的问题,时间复杂度不会超过 n 的多项式倍数。例如,O(n)=n^k,k 是一个常数,根据题目的不同而定。
约束规划
在普通的线性规划和区间规划里,一般题目有两种需求:统计和最优解。
这些题目不会对输出结果中的元素有什么限制,只要满足最终的一个条件就好了。但是在很多情况下,题目会对输出结果的元素添加一定的限制或约束条件,增加了解题的难度。
举例:0-1 背包问题。
给定 n 个物品,每个物品都有各自的价值 vi 和重量 wi,现在给你一个背包,背包所能承受的最大重量是 W,那么往这个背包里装物品,问怎么装能使被带走的物品的价值总和最大。
因为很多人都熟悉这道经典题目,因此不去详细讲解,但是建议大家好好去做一下这道题。
NP 完全问题
该例题为 NP 完全问题。NP 是 Non-deterministic Polynomial 的缩写,中文是非決定性多项式。通俗一点来说,对于这类问题,我们无法在多项式时间内解答。这个概念很难,但是理解好它能帮助你很好的分析时间复杂度。
时间复杂度
时间复杂度并不是表示程序解决问题需要花费的具体时间,而是说程序运行的时间随着问题规模扩大增长的有多快。
如果程序具有 O(1) 的时间复杂度,那么,无论问题规模有多大,运行时间都是固定不变的,这个程序就是一个好程序。如果程序运行的时间随着问题规模的扩大线性增长,复杂度是 O(n),也很不错。还有一些平方数 O(n2)、立方数 O(n3) 的复杂度等,比如冒泡排序。另外还有指数级的复杂度,例如 O(2n),O(3n) 等。还有甚至 O(n!) 阶乘级的复杂度,例如全排列算法。分类如下:
- 多项式级别时间复杂度
O(1)、O(n)、O(n×logn)、O(n2)、O(n3) 等,可以表示为 n 的多项式的组合
- 非多项式级别时间复杂度
O(2n),O(3n) 等指数级别和 O(n!) 等阶乘级别 。
二分搜索(Binary Search)
二分搜索(折半搜索)的 Wikipedia 定义:是一种在有序数组中查找某一特定元素的搜索算法。从定义可知,运用二分搜索的前提是数组必须是排好序的。另外,输入并不一定是数组,也有可能是给定一个区间的起始和终止的位置。
优点:时间复杂度是 O(lgn),非常高效。
因此也称为对数搜索。
缺点:要求待查找的数组或者区间是排好序的。
对数组进行动态的删除和插入操作并完成查找,平均复杂度会变为 O(n)。此时应当考虑采取自平衡的二叉查找树:
- 在 O(nlogn) 的时间内用给定的数据构建出一棵二叉查找树;
- 在 O(logn) 的时间里对目标数据进行搜索;
- 在 O(logn) 的时间里完成删除和插入的操作。
因此,当输入的数组或者区间是排好序的,同时又不会经常变动,而要求从里面找出一个满足条件的元素的时候,二分搜索就是最好的选择。
二分搜索一般化的解题思路如下。
- 从已经排好序的数组或区间中取出中间位置的元素,判断该元素是否满足要搜索的条件,如果满足,停止搜索,程序结束。
- 如果正中间的元素不满足条件,则从它两边的区域进行搜索。由于数组是排好序的,可以利用排除法,确定接下来应该从这两个区间中的哪一个去搜索。
- 通过判断,如果发现真正要找的元素在左半区间的话,就继续在左半区间里进行二分搜索。反之,就在右半区间里进行二分搜索。
二分搜索看起来简单,但是 Programming Pearls 这本书的作者 Jon Bentley 提到,只有 10% 的程序员能正确地写出二分搜索的代码。面试题经常是经典二分搜索的变形,但万变不离其中,需要把握好二分搜索的核心。
递归解法
优点:简洁;缺点:执行消耗大
例题分析一:找确定的边界
边界分上边界和下边界,有时候也被成为右边界和左边界。确定的边界指边界的数值等于要找的目标数。
例题:LeetCode 第 34 题,在一个排好序的数组中找出某个数第一次出现和最后一次出现的下标位置。
示例:输入的数组是:{5, 7, 7, 8, 8, 10},目标数是 8,那么返回 {3, 4},其中 3 是 8 第一次出现的下标位置,4 是 8 最后一次出现的下标位置。
解题思路
在二分搜索里,比较难的是判断逻辑,对这道题来说,什么时候知道这个位置是不是 8 第一次以及最后出现的地方呢?
把第一次出现的地方叫下边界(lower bound),把最后一次出现的地方叫上边界(upper bound)。
那么成为 8 的下边界的条件应该有两个。
- 该数必须是 8;
- 该数的左边一个数必须不是 8:
- 8 的左边有数,那么该数必须小于 8;
- 8 的左边没有数,即 8 是数组的第一个数。
而成为 8 的上边界的条件也应该有两个。
- 该数必须是 8;
- 该数的右边一个数必须不是 8:
- 8 的右边有数,那么该数必须大于8;
- 8 的右边没有数,即 8 是数组的最后一个数。
例题分析二:找模糊的边界
二分搜索可以用来查找一些模糊的边界。模糊的边界指,边界的值并不等于目标的值,而是大于或者小于目标的值。
例题:从数组 {-2, 0, 1, 4, 7, 9, 10} 中找到第一个大于 6 的数。
解题思路
在一个排好序的数组里,判断一个数是不是第一个大于 6 的数,只要它满足如下的条件:
- 该数要大于 6;
- 该数有可能是数组里的第一个数,或者它之前的一个数比 6 小。
只要满足了上面的条件就是第一个大于 6 的数。
例题分析三:旋转过的排序数组
二分搜索也能在经过旋转了的排序数组中进行。
例题:LeetCode 第 33 题,给定一个经过旋转了的排序数组,判断一下某个数是否在里面。
示例:给定数组为 {4, 5, 6, 7, 0, 1, 2},target 等于 0,答案是 4,即 0 所在的位置下标是 4。
解题思路
对于这道题,输入数组不是完整排好序,还能运用二分搜索吗?思路如下。
一开始,中位数是 7,并不是我们要找的 0,如何判断往左边还是右边搜索呢?这个数组是经过旋转的,即,从数组中的某个位置开始划分,左边和右边都是排好序的。
如何判断左边是不是排好序的那个部分呢?只要比较 nums[low] 和 nums[middle]。nums[low] <= nums[middle] 时,能判定左边这部分一定是排好序的,否则,右边部分一定是排好序的。
例题分析四:不定长的边界
前面介绍的二分搜索的例题都给定了一个具体范围或者区间,那么对于没有给定明确区间的问题能不能运用二分搜索呢?
例题:有一段不知道具体长度的日志文件,里面记录了每次登录的时间戳,已知日志是按顺序从头到尾记录的,没有记录日志的地方为空,要求当前日志的长度。
解题思路
可以把这个问题看成是不知道长度的数组,数组从头开始记录都是时间戳,到了某个位置就成为了空:{2019-01-14, 2019-01-17, … , 2019-08-04, …. , null, null, null ...}。
思路 1:顺序遍历该数组,一直遍历下去,当发现第一个 null 的时候,就知道了日志的总数量。很显然,这是很低效的办法。
思路 2:借用二分搜索的思想,反着进行搜索。
- 一开始设置 low = 0,high = 1
- 只要 logs[high] 不为 null,high *= 2
- 当 logs[high] 为 null 的时候,可以在区间 [0, high] 进行普通的二分搜索
二分搜索算法是重中之重,因为它看似简单,但要写对却不那么容易。
贪婪(Greedy)
贪婪算法的 Wikipedia 定义:是一种在每一步选中都采取在当前状态下最好或最优的选择,从而希望导致结果是最好或最优的算法。
优点:对于一些问题,非常直观有效。
缺点:
并不是所有问题都能用它去解决;
得到的结果并一定不是正确的,因为这种算法容易过早地做出决定,从而没有办法达到最优解。
下面通过例题来加深对贪婪算法的认识。例题:0-1 背包问题,能不能运用贪婪算法去解决。
有三种策略:
- 选取价值最大的物品
- 选择重量最轻的物品
- 选取价值/重量比最大的物品
由上,贪婪算法总是做出在当前看来是最好的选择。即,它不从整体的角度去考虑,仅仅对局部的最优解感兴趣。因此,只有当那些局部最优策略能产生全局最优策略的时候,才能用贪婪算法。