渣渣
首发于渣渣

算法思维

启发式思维:

  1. 历史启发。历史计算过的值对计算当前值有启发作用。比如常见递推式、动态规划算法。

a_n = f(a_{n-1},...,a_1)

这也是常见的递归思维。

2. 前缀、中缀、后缀启发,固定的前中后缀会对计算有启发。固定的前中后缀是一个集合,相当于对问题进行了划分(分割思维),固定住了某个维度,问题变得简单清晰。如LIS中,前缀分析中,固定a[i],所有以a[i]结束的最长递增序列。

3. 规则启发。客观规则对计算当前值有启发作用,比如数学规则(数学公理定义定理)、对称性限制等等(manacher算法)。强规则限制,人无法改变,比如逻辑、数学等等。

4. 背景启发。在特定领域内的规则对计算有启发作用。比如物理规则、人为制定的规则等等。典型算法:A*启发搜索。弱规则限制,人可以改变。

5. 相似性启发。主要靠联想和抽象。表象上完全不同的东西,抽象之后可能会有相似的结构。 比较强的相似性就是1-1映射转换空间。

6. 跨领域启发。比如生物启发计算机。

递归思维:

当前的项是由前面几项通过一定规则构造出来的。问题向前/向后走一步问题和上一步是相识的,只是规模有变化,没有超出我们对问题的定义。

结构递归:比如二叉树就是递归定义的,每一个单元有相似的结构。整体是由这个基本结构组成的。

过程递归:整个序列是由固定的规则生成的。比如汉诺塔。

分割思维:

一次分割(划分)相当于创造了新的秩序,使原先混沌的东西逐渐秩序化。通过应用规则,来简化问题。划分之后的结构可以给我一些启发(比如这个区域的东西如何改变才能去另一个区域)或者可以各个击破。比如dijkstra算法。分割要要注意分割的完备性(不要有漏掉的东西)。

不断对划分后的集合进行划分(过程递归),最后的结果会形成一棵树。

转换思维:

把一个问题1-1映射到另一领域,使得问题变得容易解决(转换之后的领域武器(定理)多)。最典型的就是积分变换(傅立叶变换、拉普拉斯变换等等)、隐式图算法。

简化思维:

想办法简化目标问题。强化或者弱化目标条件、分步骤向目标进行,比如求最大平均值,我们可以先求均值,在求最大。简化依赖项(比如依赖前n项简化为依赖前1项)。

打破思维:

发现核心的模式,即发现一类算法中共同的部分,要突破某个界限(如时间复杂度),必须修改核心模式。例如一般排序都需要交换位置(核心是交换),只要使用交换元素的方法,就无法打破O(nlogn)的下界,计数排序则不使用交换,这样就突破了普通排序时间复杂度O(nlogn)的下界。要突破某个界限,需要打破原有的思路,需求新方法。

直线思维:

最直接最简单最暴力的方式求解。直线算法不能满足需要的时候,我们要考虑其它路径。


算法中的常用模式:

一般次序:分治,贪心,递归,回溯,动态规划,隐式树,隐式图。

马尔可夫模型: A_{n-1}\rightarrow A_n ,一个序列中,当前项仅依赖后面一项。即 A_{n-1}A_n 来说是充分的。

广义马尔可夫模型: [A_{i},...,A_{n-1}] \rightarrow A_n ,即当前项依赖前面的多项。如果把多项看做1项就是马尔可夫模型, [A_{i},...,A_{n-1}] \rightarrow [A_{i+1},...,A_n]

动态规划:即广义马尔可夫模型。我们要找到问题相关的马尔可夫状态转移方程(或一段程序)。一般步骤是:

  1. 抽象出问题相关的变量。大胆假设,小心求证。
  2. 确定核心状态(可能是多维的),状态需要根据情况自定义。比如固定了哪些维度,问题就会简单很多(看得更清楚)。
  3. 确定转移方程。
  4. 使用缓存,降低时空复杂度。

动态规划的核心是:从整体考虑,发型最优子结构,减小问题规模。即当前整体分解为子整体的构造过程,并且这个过程能证明最后的结果(充分性)。对于较复杂的问题,往往要找到好的状态、维度,分割定义域,用高维数组关联多个特征(维度)等等思路。

动态规划的维度和状态:a[i][j][k]= x ;左边是固定的自由度,右边是可变化的自由度。 i,j,k是我们抽取的维度,i,j,k表示一个固定的子结构,x是一个状态,x是从自由变化的状态X中选择出来的。x把i,j,k这几个维度关联起来,i,j,k的固定(取某一值)限制了x的取值集合X。x如何选择(从更小的子结构中构造)就是转移方程。

贪心:即马尔可夫模型。仅考虑上一步或下一步的变化就能解决问题。贪心策略要证明策略对问题的充分性。

分治:划分定义域,分别处理。分情况讨论。

递归:往往是结构递归。发现结构相似性。

回溯: 往往是DFS。

算法思考的一般步骤:

典型问题,已有方法 -> 转换简化成已有方法 -> 分治 -> 贪心 -> 动规 -> 隐式图(树)->

思路要清晰,论证要充分

图的特点:

图和线性结构(链表)的最大区别是:图在在当前时刻只能看到局部(相邻的部分),线性结构随时可以看到整体。如果某一情况下,我们只能到达(看到)某个局部(邻接点)那就可以作为图来处理(隐式图或树(只有一个爸爸))。

在图中我们想要看到整体,就必须通过各种形式的遍历方法来实现。如dfs/bfs,其它算法可以看做是dfs/bfs的变形和深化。其实思路都差不多,就是挨个挨个访问,不断做划分。

dfs在深度维度上的划分,就是按照深度的一直遍历完,图可以划分为:完全访问过的分支,部分访问的分支,没有访问的分支。

bfs在广度维度上的划分,变种有dijkstra,bellmanford,A*。优先访问最临近的一层。访问的信息是按层给定的。纯bfs,图可划分为:完全访问过的层,部分访问的层,没有访问的层。

  • Dijkstra的层可以看着由距离定义的。不同的距离不同在不同的层,差不多一个节点一层。
  • Bellman-Ford不断更新起点到不同层的距离,最多能缩短n次(到头了,都访问过来,没最短的了)。
  • A*和Dijkstra类似,只不过最近候选的点不是决定的(通过启发估计得到的),计算完成的点可能不是确定的,需要在后续迭代中不断更新。


例子:

LCS(最长公共子序列):

# eg. s1= "hellzo",s2= "ahebllco" -> 5
# 变量:i:s1的第i个字符, j:s2的第j个字符
# 状态:c[i][j] , 遍历到s1的第i个字符,s2的第j个字符时最长的公共子序列长度。关联i,j两个维度。
# 转移方程: c[i][j]的构造过程,
# c[i][j] = {
# c[i-1][j-1]+1			if x[i]=y[j] 0<=i<n1,0<=j<n2
# max(c[i-1][j],c[i][j-1]) 	if x[i]!=y[j] 0<=i<n1,0<=j<n2
# }
# c[i][j]包含的子整体:c[i-1][j-1],c[i-1][j],c[i][j-1]; 1
# 构造过程主要是子整体的if、加法和max运算。
def LCSLen(s1,s2):
	n1,n2 = len(s1),len(s2)
	c = [[0 for _ in range(n2)] for _ in range(n1)]
	for i in range(n1):
		for j in range(n2):
			if s1[i]==s2[j]:
				c[i][j] = c[i-1][j-1]+1
			else:
				c[i][j] = max(c[i-1][j],c[i][j-1])
	return c[n1-1][n2-1]

LIS(最长递增子序列):

# eg. [5,1,3,2,4,5,1] -> 4
# 维度: b[i]是以a[i]结尾的最长递增子序列的长度。
# 转移方程:b[i] = b[j]+1 ; j = argmax{b[j] | a[i]>=a[j] and j<i}
# 固定一个a[i],以a[i]结尾的所有子串中,最长的可行的(a[i]>=a[j])子串。
def LIS(arr):
	n = len(arr)
	b = [1 for _ in range(n)]
	mb, mi = 0,0
	for i in range(n):
		for j in range(i): # 最长可行子串
			if arr[i]>=arr[j]:
				if b[j]+1>b[i]:
					b[i] = b[j]+1
		if b[i]> mb:
			mb,mi = b[i],i
	return mb

编辑于 2019-08-01

文章被以下专栏收录