算法 Basic
算法是一组完成任务的指令。要么速度快,要么解决了有趣的问题,或者兼而有之。
这篇文章主要是整理了一些算法的思路。
NP理论
- 多项式时间:在计算复杂度理论中,指的是一个问题的计算时间是不大于问题大小n的多项式倍数。
- P类问题:所有可以在多项式时间内求解的判定问题构成P类问题。
- 判定问题:判断是否有一种能够解决某一类问题的能行算法的研究课题。
- NP类问题:所有的非确定性多项式时间可解的判定问题构成NP类问题。
- 非确定性算法:非确定性算法将问题分解成猜测和验证两个阶段。算法的猜测阶段是非确定性的,算法的验证阶段是确定性的,它验证猜测阶段给出解的正确性。设算法A是解一个判定问题Q的非确定性算法,如果A的验证阶段能在多项式时间内完成,则称A是一个多项式时间非确定性算法。
- 约化(归约): 如果能找到这样一个变化法则,对任意一个程序A的输入,都能按这个法则变换成程序B的输入,使两程序的输出相同,那么我们说,问题A可约化为问题B。一个问题A可以约化为问题B的含义即是,可以用问题B的解法解决问题A,或者说,问题A可以“变成”问题B。“问题A可约化为问题B”有一个重要的直观意义:B的时间复杂度高于或者等于A的时间复杂度。也就是说,问题A不比问题B难。
- NPC问题:首先,它得是一个NP问题;然后,所有的NP问题都可以约化到它。证明一个问题是 NPC问题也很简单。先证明它至少是一个NP问题,再证明其中一个已知的NPC问题能约化到它。NPC问题目前没有多项式的有效算法,只能用指数级甚至阶乘级复杂度的搜索。
- NP-Hard问题: NP-Hard是这样一种问题,它满足NPC问题定义的第二条但不一定要满足第一条(就是说,NP-Hard问题要比 NPC问题的范围广)。NP-Hard问题同样难以找到多项式的算法,但它不列入我们的研究范围,因为它不一定是NP问题。即使NPC问题发现了多项式级的算法,NP-Hard问题有可能仍然无法得到多项式级的算法。事实上,由于NP-Hard放宽了限定条件,它将有可能比所有的NPC问题的时间复杂度更高从而更难以解决.
数据结构
存储
算法中的存储,实质上是考虑数据结构。对于散列表、栈、队列、堆、树、图等等各种数据结构而言。底层存储无非数组或者链表,二者的优缺点如下:
- 数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
- 链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
数据访问
数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改。
各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。
线性就是 for/while 迭代为代表,非线性就是递归为代表。再具体一步,无非以下几种框架:
- 数组遍历框架,典型的线性迭代结构:
1 | void traverse(int[] arr) { |
- 链表遍历框架,兼具迭代和递归结构:
1 | /* 基本的单链表节点 */ |
- 二叉树遍历框架,典型的非线性递归遍历结构:
1 | /* 基本的二叉树节点 */ |
二叉树框架可以扩展为 N 叉树的遍历框架:
1 | /* 基本的 N 叉树节点 */ |
常用算法思路
变形
即对问题进行变形处理,常见于算法面试题,例如对链表翻转,对原数据排序等。有的问题直接入手比较麻烦,可以先通过转换,来简化问题。值得注意的是,转换操作的时间/空间复杂度不要忘记
穷举
穷举算法(ExhaustiveA ttack method)是最简单的一种算法,其依赖于计算机的强大计算能力来穷尽每一种可能的情况,从而达到求解问题的目的。穷举算法效率一般都较差,适应于一些没有明显规律可循的场合。
穷举算法的基本思想就是从所有可能的情况中搜索正确的答案,其执行步骤如下:
- 对于一种可能的情况,计算其结果。
- 判断结果是否满足要求,如果不满足则执行第1步来搜索下一个可能的情况;如果满足要求,则表示寻找到一个正确的答案。
递归
递归算法是非常常用的算法思想。使用递归算法,往往可以简化代码编写,提高程序的可读性。
递归算法就是在程序中不断反复调用自身来达到求解问题的方法。这里的重点是调用自身,这就要求待求解的问题能够分解为相同问题的一个子问题。这样,通过多次递归调用,便可以完成求解。
函数的递归调用分两种情况:直接递归和间接递归。
- 直接递归:即在函数中调用函数本身。
- 间接递归:即间接地调用一个函数,如出如func_a调用 func_b, func_b 又调用func_a。间接递归用得不多。
编写递归函数的注意点:
- 返回值的处理
- 何时结束递归
- 尾递归写法,避免栈太深。
部分编程语言提供了对尾递归的优化,避免了栈溢出问题。但是,对于问题规模不确定的情况下,最好使用循环,而不是递归。
递归算法的时间复杂度怎么计算?用子问题个数乘以解决一个子问题需要的时间。
分治
分治算法是一种化繁为简的算法思想。分治算法往往应用于计算步骤比较复杂的问题,通过将问题简化而逐步得到结果。
对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
分治算法的执行过程如下:
- 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
- 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
- 合并:将各个子问题的解合并为原问题的解。
特征:
- 该问题的规模缩小到一定的程度就可以容易地解决
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
- 利用该问题分解出的子问题的解可以合并为该问题的解;
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
分析:
- 第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;
- 第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;
- 第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。
- 第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
二分查找法
二分查找法不断将问题规模缩小,直到变成最小问题。
1 | int binarySearch(int[] nums, int target) { |
滑动窗口
滑动窗口的思路比较简单,维护一个窗口,不断滑动,然后更新答案。
1 | int left = 0, right = 0; |
动态规划
每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
基本步骤:
- 分析最优解的性质,并刻画其结构特征。
- 递归的定义最优解。
- 以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值
- 根据计算最优值时得到的信息,构造问题的最优解
能采用动态规划求解的问题的一般要具有3个性质:
- 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
动态规划的解法可以概括为下面几步:
- 明确 base case
- 明确「状态」
- 明确「选择」
- 定义 dp 数组/函数的含义。
- 确定不同选择下的状态迁移方程。
1 | # 初始化 base case |
贪心
在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。
贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关。所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。
基本步骤:
- 建立数学模型来描述问题。
- 把求解的问题分成若干个子问题。
- 对每一子问题求解,得到子问题的局部最优解。
- 把子问题的解局部最优解合成原来解问题的一个解。
回溯(DFS)
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。
若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。
特征:
- 针对所给问题,确定问题的解空间:首先应明确定义问题的解空间,问题的解空间应至少包含问题的一个(最优)解。
- 确定结点的扩展搜索规则
- 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
分支限界法(BFS)
概述:
类似于回溯法,也是一种在问题的解空间树T上搜索问题解的算法。但在一般情况下,分支限界法与回溯法的求解目标不同。回溯法的求解目标是找出T中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解。
策略:
在扩展结点处,先生成其所有的儿子结点(分支),然后再从当前的活结点表中选择下一个扩展对点。为了有效地选择下一扩展结点,以加速搜索的进程,在每一活结点处,计算一个函数值(限界),并根据这些已计算出的函数值,从当前活结点表中选择一个最有利的结点作为扩展结点,使搜索朝着解空间树上有最优解的分支推进,以便尽快地找出一个最优解。
与回溯法的区别:
回溯法:【方式不同】深度优先搜索堆栈活结点的所有可行子结点被遍历后才被从栈中弹出找出满足约束条件的所有解【目标不同】。
分支限界法:【方式不同】广度优先或最小消耗优先搜索队列、优先队列每个结点只有一次成为活结点的机会找出满足约束条件的一个解或特定意义下的最优解【目标不同】。