我是架构精进之路,点击上方“关注”,坚持天天为你分享技术干货,私信我回复“01”,送你一份程序员成长进阶大礼包。php
面试常见的四种算法思想,全在这里了,今天带你一文了解。程序员
一、贪心
贪心算法有不少经典的应用,好比霍夫曼编码(Huffman Coding)、Prim 和 Kruskal 最小生成树算法、还有 Dijkstra 单源最短路径算法。面试
解决问题步骤
第一步,当咱们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,咱们定义了限制值和指望值,但愿从中选出几个数据,在知足限制值的状况下,指望值最大。
第二步,咱们尝试看下这个问题是否能够用贪心算法解决:每次选择当前状况下,在对限制值同等贡献量的状况下,对指望值贡献最大的数据。
第三步,咱们举几个例子看下贪心算法产生的结果是不是最优的。正则表达式
例子1
咱们有 m 个糖果和 n 个孩子。咱们如今要把糖果分给这些孩子吃,可是糖果少,孩子多(m<n),因此糖果只能分配给一部分孩子。每一个糖果的大小不等,这 m 个糖果的大小分别是 s1,s2,s3,……,sm。除此以外,每一个孩子对糖果大小的需求也是不同的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才获得知足。假设这 n 个孩子对糖果大小的需求分别是 g1,g2,g3,……,gn。问题是,如何分配糖果,能尽量知足最多数量的孩子?算法
咱们能够把这个问题抽象成,从 n 个孩子中,抽取一部分孩子分配糖果,让知足的孩子的个数(指望值)是最大的。这个问题的限制值就是糖果个数 m。咱们如今来看看如何用贪心算法来解决。对于一个孩子来讲,若是小的糖果能够知足,咱们就不必用更大的糖果,这样更大的就能够留给其余对糖果大小需求更大的孩子。另外一方面,对糖果大小需求小的孩子更容易被知足,因此,咱们能够从需求小的孩子开始分配糖果。由于知足一个需求大的孩子跟知足一个需求小的孩子,对咱们指望值的贡献是同样的。数据库
咱们每次从剩下的孩子中,找出对糖果大小需求最小的,而后发给他剩下的糖果中能知足他的最小的糖果,这样获得的分配方案,也就是知足的孩子个数最多的方案编程
例子2
这个问题在咱们的平常生活中更加广泛。假设咱们有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c一、c二、c五、c十、c20、c50、c100。咱们如今要用这些钱来支付 K 元,最少要用多少张纸币呢?数组
在生活中,咱们确定是先用面值最大的来支付,若是不够,就继续用更小一点面值的,以此类推,最后剩下的用 1 元来补齐。
在贡献相同指望值(纸币数目)的状况下,咱们但愿多贡献点金额,这样就可让纸币数更少,这就是一种贪心算法的解决思路。直觉告诉咱们,这种处理方法就是最好的。微信
例子3
假设咱们有 n 个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。咱们从这 n 个区间中选出一部分区间,这部分区间知足两两不相交(端点相交的状况不算相交),最多能选出多少个区间呢?多线程
好比任务调度、教师排课等等问题。
这个问题的解决思路是这样的:咱们假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就至关于,咱们选择几个不相交的区间,从左到右将[lmin, rmax]覆盖上。咱们按照起始端点从小到大的顺序对这 n 个区间排序。
咱们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽可能小的,这样可让剩下的未覆盖区间尽量的大,就能够放置更多的区间。这实际上就是一种贪心的选择方法。
用贪心算法实现霍夫曼编码
假设我有一个包含 1000 个字符的文件,每一个字符占 1 个 byte(1byte=8bits),存储这 1000 个字符就一共须要 8000bits,那有没有更加节省空间的存储方式呢?
假设咱们经过统计分析发现,这 1000 个字符中只包含 6 种不一样字符,假设它们分别是 a、b、c、d、e、f。而 3 个二进制位(bit)就能够表示 8 个不一样的字符,因此,为了尽可能减小存储空间,每一个字符咱们用 3 个二进制位来表示。那存储这 1000 个字符只须要 3000bits 就能够了,比原来的存储方式节省了不少空间。不过,还有没有更加节省空间的存储方式呢?
a(000)、b(001)、c(010)、d(011)、e(100)、f(101)
霍夫曼编码就要登场了。霍夫曼编码是一种十分有效的编码方法,普遍用于数据压缩中,其压缩率一般在 20%~90% 之间。如何给不一样频率的字符选择不一样长度的编码呢?根据贪心的思想,咱们能够把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码。
可是,霍夫曼编码是不等长的,每次应该读取 1 位仍是 2 位、3 位 等等来解压缩呢?这个问题就致使霍夫曼编码解压缩起来比较复杂。为了不解压缩过程当中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另外一个编码前缀的状况
假设这 6 个字符出现的频率从高到低依次是 a、b、c、d、e、f。咱们把它们编码下面这个样子,任何一个字符的编码都不是另外一个的前缀,在解压缩的时候,咱们每次会读取尽量长的可解压的二进制串,因此在解压缩的时候也不会歧义。通过这种编码压缩以后,这 1000 个字符只须要 2100bits 就能够了。
咱们把每一个字符看做一个节点,而且附带着把频率放到优先级队列中。咱们从队列中取出频率最小的两个节点 A、B,而后新建一个节点 C,把频率设置为两个节点的频率之和,并把这个新节点 C 做为节点 A、B 的父节点。最后再把 C 节点放入到优先级队列中。重复这个过程,直到队列中没有数据。
二、分治
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分红 n 个规模较小,而且结构与原问题类似的子问题,递归地解决这些子问题,而后再合并其结果,就获得原问题的解。
分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法通常都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操做:
-
分解:将原问题分解成一系列子问题;
-
解决:递归地求解各个子问题,若子问题足够小,则直接求解;
-
合并:将子问题的结果合并成原问题。
分治算法能解决的问题,通常须要知足下面这几个条件:
-
原问题与分解成的小问题具备相同的模式;
-
原问题分解成的子问题能够独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别,等咱们讲到动态规划的时候,会详细对比这两种算法;
-
具备分解终止条件,也就是说,当问题足够小时,能够直接求解;
-
能够将子问题合并成原问题,而这个合并操做的复杂度不能过高,不然就起不到减少算法整体复杂度的效果了。
分治算法应用举例分析
-
假设有n个数据,指望数据从小到大排序,那彻底有序的数据的有序度就是n(n-1)/2。逆序度等于0;相反,倒序排序的数据的有序度就是0,逆序度是n(n-1)/2。除了这两种极端状况外,咱们经过计算有序对或逆序对的个数,来表示数据的有序度或逆序度。
-
如今问:如何编程求出数组中的数据有序对个数或逆序对个数?
-
最简单的办法:拿每一个数字和他后面的数字比较,看有几个比它小。将比它小的数字个数记做k,经过这样的方式,把每一个数字都考察一遍后,对每一个数字对应的k值求和,最后获得的总和就是逆序对个数。但时间复杂度是O(n^2)。
-
用分治算法,套用分治的思想,将书中分红先后两半A1和A2,分别二者中的逆序对数,而后在计算A1和A2之间的逆序对个数k3。那整个数组的逆序对个数就是k1+k2+k3。
-
要快速计算出两个子问题A1和A2之间的逆序对个数须要借助归并排序算法。
归并排序算法有个很是关键的操做,即将两个有序的小数组,合并成一个有序的数组。实际上,在合并的过程当中,就能够计算这两个小数组的逆序对个数。每次合并操做,都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数。
求两个数的最大共因子(欧几里得算法)
<?php/** * 分治算法 * 逻辑: * (1) 找出基线条件,这种条件必须尽量简单。 * (2) 不断将问题分解(或者说缩小规模),直到符合基线条件 */class dc { /** * 最大公因子(欧几里得算法) * 能够引伸到-客厅长宽固定,问选择多大的正方形地砖,能够正好铺满客厅 * @param $a * @param $b * @return mixed */ function greatestCommonFactor($a, $b) { if ($a < $b) { $c = $a; $a = $b; $b = $c; } $c = $a % $b; if ($c == 0) { return $b; } else { $n = $this->greatestCommonFactor($c, $b); } return $n; }}dd((new dc())->greatestCommonFactor(160, 56));
分治思想在海量数据处理中的应用
-
假设,给10GB的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,可是由于数据量大,有10GB,而咱们的机器的内存可能只有2,3GB这样子,没法一次性加载到内存,也就没法经过单纯地使用快排,归并等基础算法来解决。
-
要解决这种数据量大到内装不下的问题,咱们就能够利用分治的思想,将海量的数据集合根据某种方法,划分为几个小的数据集合,每一个小的数据集合单独加载到内存来解决,而后在将小数据集合合并成大数据集合,实际上利用这种分治的处理思路,不只能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。
举例分析
采用分治思想的算法包括:
-
快速排序算法
-
合并排序算法
-
桶排序算法
-
基数排序算法
-
二分查找算法
-
利用递归树求解算法复杂度的思想
-
分布式数据库利用分片技术作数据处理
-
MapReduce模型处理思想
三、回溯
深度优先搜索算法利用的是回溯算法思想。这个算法思想很是简单,可是应用却很是普遍。它除了用来指导像深度优先搜索这种经典的算法设计以外,还能够用在不少实际的软件开发场景中,好比正则表达式匹配、编译原理中的语法分析等。不少经典的数学问题均可以用回溯算法解决,好比数独、八皇后、0-1 背包、图的着色、旅行商问题、全排列等等。
回溯的处理思想,有点相似枚举搜索。咱们枚举全部的解,找到知足指望的解。为了有规律地枚举全部可能的解,避免遗漏和重复,咱们把问题求解的过程分为多个阶段。每一个阶段,咱们都会面对一个岔路口,咱们先随意选一条路走,当发现这条路走不通的时候(不符合指望的解),就回退到上一个岔路口,另选一种走法继续走。
八皇后问题
<?php/** * 八皇后问题 * 有一个 8x8 的棋盘,但愿往里放 8 个棋子(皇后),每一个棋子所在的行、列、对角线都不能有另外一个棋子 * Class queen */class queen { //全局或成员变量,下标表示行,值表示queen存储在哪一列 private $result = []; public function cal8queens(int $row) { // 8个棋子都放置好了,打印结果 if ($row == 8) { $this->printQueens(); // 8行棋子都放好了,已经无法再往下递归了,因此就return return; } // 每一行都有8中放法 for ($column = 0; $column < 8; ++$column) { // 有些放法不知足要求 if ($this->isOk($row, $column)) { // 第row行的棋子放到了column列 $this->result[$row] = $column; // 考察下一行 $this->cal8queens($row + 1); } } } private function isOk(int $row, int $column) { $leftUp = $column - 1; $rightUp = $column + 1; // 逐行往上考察每一行 for ($i = $row - 1; $i >= 0; --$i) { // 第i行的column列有棋子吗 if ($this->result[$i] == $column) { return false; } // 考察左上对角线:第i行leftUp列有棋子吗 if ($leftUp >= 0 && $this->result[$i] == $leftUp) { return false; } // 考察右上对角线:第i行rightUp列有棋子吗 if ($rightUp < 8 && $this->result[$i] == $rightUp) { return false; } --$leftUp; ++$rightUp; } return true; } // 打印出一个二维矩阵 private function printQueens() { for ($row = 0; $row < 8; ++$row) { for ($column = 0; $column < 8; ++$column) { echo $this->result[$row] == $column ? "Q " : "* "; } echo "\n"; } echo "\n"; }}(new Queen())->cal8queens(0);
0-1 背包问题
这个问题的经典解法是动态规划,可是也可使用回溯算法,实现简单,可是没有那么高效。
0-1 背包问题有不少变体,这里介绍一种比较基础的。有一个背包,背包总的承载重量是 Wkg。如今咱们有 n 个物品,每一个物品的重量不等,而且不可分割。咱们如今指望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
咱们能够把物品依次排列,整个问题就分解为了 n 个阶段,每一个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,而后再递归地处理剩下的物品。
<?phpclass backpack { public $maxW; // cw表示当前已经装进去的物品的重量和;i表示考察到哪一个物品了; // w背包重量;items表示每一个物品的重量;itemCount表示物品个数 // 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那能够这样调用函数: // f(0, 0, a, 10, 100) public function f(int $i, int $cw, array $items, int $itemCount, int $w) { // cw==w表示装满了;i==n表示已经考察完全部的物品 if ($cw == $w || $i == $itemCount) { if ($cw > $this->maxW) { $this->maxW = $cw; } return; } // 递归调用表示不选择当前物品,直接考虑下一个(第 i+1 个),故 cw 不更新 $this->f($i + 1, $cw, $items, $itemCount, $w); if ($cw + $items[$i] <= $w) { // 表示选择了当前物品,故考虑下一个时,cw 经过入参更新为 cw + items[i] $this->f($i + 1, $cw + $items[$i], $items, $itemCount, $w); } }}
正则表达式
正则表达式中,最重要的就是通配符,通配符结合在一块儿,能够表达很是丰富的语义。为了方便讲解,我假设正则表达式中只包含“*”和“?”这两种通配符,而且对这两个通配符的语义稍微作些改变,其中,“*”匹配任意多个(大于等于 0 个)任意字符,“?”匹配零个或者一个任意字符。基于以上背景假设,咱们看下,如何用回溯算法,判断一个给定的文本,可否跟给定的正则表达式匹配?
咱们依次考察正则表达式中的每一个字符,当是非通配符时,咱们就直接跟文本的字符进行匹配,若是相同,则继续往下处理;若是不一样,则回溯。
若是遇到特殊字符的时候,咱们就有多种处理方式了,也就是所谓的岔路口,好比“*”有多种匹配方案,能够匹配任意个文本串中的字符,咱们就先随意的选择一种匹配方案,而后继续考察剩下的字符。若是中途发现没法继续匹配下去了,咱们就回到这个岔路口,从新选择一种匹配方案,而后再继续匹配剩下的字符。
public class Pattern { private boolean matched = false; private char[] pattern; // 正则表达式 private int plen; // 正则表达式长度 public Pattern(char[] pattern, int plen) { this.pattern = pattern; this.plen = plen; } public boolean match(char[] text, int tlen) { // 文本串及长度 matched = false; rmatch(0, 0, text, tlen); return matched; } private void rmatch(int ti, int pj, char[] text, int tlen) { if (matched) return; // 若是已经匹配了,就不要继续递归了 if (pj == plen) { // 正则表达式到结尾了 if (ti == tlen) matched = true; // 文本串也到结尾了 return; } if (pattern[pj] == '*') { // *匹配任意个字符 for (int k = 0; k <= tlen-ti; ++k) { rmatch(ti+k, pj+1, text, tlen); } } else if (pattern[pj] == '?') { // ?匹配0个或者1个字符 rmatch(ti, pj+1, text, tlen); rmatch(ti+1, pj+1, text, tlen); } else if (ti < tlen && pattern[pj] == text[ti]) { // 纯字符匹配才行 rmatch(ti+1, pj+1, text, tlen); } }}
回溯算法的思想简单,大部分状况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个知足要求的解。回溯算法很是适合用递归来实现,在实现的过程当中,剪枝操做是提升回溯效率的一种技巧。利用剪枝,咱们并不须要穷举搜索全部的状况,从而提升搜索效率。
四、动态规划
一个模型三个特征
“一个模型” 指的是动态规划适合解决的问题的模型。把这个模型定义为“多阶段决策最优解模型”。
“三个特征”分别是最优子结构、无后效性和重复子问题。
-
最优子结构
最优子结构指的是,问题的最优解包含子问题的最优解。反过来讲就是,咱们能够经过子问题的最优解,推导出问题的最优解。若是咱们把最优子结构,对应到咱们前面定义的动态规划问题模型上,那咱们也能够理解为,后面阶段的状态能够经过前面阶段的状态推导出来 -
无后效性
无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,咱们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦肯定,就不受以后阶段的决策影响。无后效性是一个很是“宽松”的要求。只要知足前面提到的动态规划问题模型,其实基本上都会知足无后效性。 -
重复子问题
若是用一句话归纳一下,那就是,不一样的决策序列,到达某个相同的阶段时,可能会产生重复的状态。
解题思路
状态转移表法
回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码
先画出一个状态表。状态表通常都是二维的,因此你能够把它想象成二维数组。其中,每一个状态包含三个变量,行、列、数组值。咱们根据决策的前后过程,从前日后,根据递推关系,分阶段填充状态表中的每一个状态。最后,咱们将这个递推填表的过程,翻译成代码,就是动态规划代码了
状态转移方程法
找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码。
状态转移方程法有点相似递归的解题思路。咱们须要分析,某个问题如何经过子问题来递归求解,也就是所谓的最优子结构。有两种代码实现方法,一种是递归加“备忘录”,另外一种是迭代递推。
min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
0-1背包问题
咱们把整个求解过程分为 n 个阶段,每一个阶段会决策一个物品是否放到背包中。每一个物品决策(放入或者不放入背包)完以后,背包中的物品的重量会有多种状况,也就是说,会达到多种不一样的状态,对应到递归树中,就是有不少不一样的节点。
四种算法思想比较分析
那贪心、回溯、动态规划能够归为一类,而分治单独能够做为一类,由于它跟其余三个都不大同样。为何这么说呢?
前三个算法解决问题的模型,均可以抽象成咱们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,可是,大部分都不能抽象成多阶段决策模型
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,咱们均可以用回溯算法解决。回溯算法至关于穷举搜索。穷举全部的状况,而后对比获得最优解。不过,回溯算法的时间复杂度很是高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。
尽管动态规划比回溯算法高效,可是,并非全部问题,均可以用动态规划来解决。能用动态规划解决的问题,须要知足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分很是明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之因此高效,就是由于回溯算法实现中存在大量的重复子问题。
贪心算法其实是动态规划算法的一种特殊状况。它解决问题起来更加高效,代码实现也更加简洁。不过,它能够解决的问题也更加有限。它能解决的问题须要知足三个条件,最优子结构、无后效性和贪心选择性(这里咱们不怎么强调重复子问题)。
其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,经过局部最优的选择,能产生全局的最优选择。每个阶段,咱们都选择当前看起来最优的决策,全部阶段的决策完成以后,最终由这些局部最优解构成全局最优解。
- END -
做者:架构精进之路,专一软件架构研究,技术学习与我的成长,关注并私信我回复“01”,送你一份程序员成长进阶大礼包。
往期热文推荐:
「技术架构精进」专一架构研究,技术分享
Thanks for reading!
本文分享自微信公众号 - 架构精进之路(jiagou_jingjin)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。