面试常见的四种算法思想,全在这里了

简介: 面试常见的四种算法思想,全在这里了,今天带你一文了解。

面试常见的四种算法思想,全在这里了,今天带你一文了解。

微信图片_20220608101432.jpg

1、贪心


贪心算法有很多经典的应用,比如霍夫曼编码(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 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付 K 元,最少要用多少张纸币呢?在生活中,我们肯定是先用面值最大的来支付,如果不够,就继续用更小一点面值的,以此类推,最后剩下的用 1 元来补齐。


在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少,这就是一种贪心算法的解决思路。直觉告诉我们,这种处理方法就是最好的。


例子3


假设我们有 n 个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我们从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?


微信图片_20220608101435.jpg


比如任务调度、教师排课等等问题。


这个问题的解决思路是这样的:我们假设这 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 就可以了。


微信图片_20220608101438.jpg


我们把每个字符看作一个节点,并且附带着把频率放到优先级队列中。我们从队列中取出频率最小的两个节点 A、B,然后新建一个节点 C,把频率设置为两个节点的频率之和,并把这个新节点 C 作为节点 A、B 的父节点。最后再把 C 节点放入到优先级队列中。重复这个过程,直到队列中没有数据。


2、分治


分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。


分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:


  • 分解:将原问题分解成一系列子问题;


  • 解决:递归地求解各个子问题,若子问题足够小,则直接求解;


  • 合并:将子问题的结果合并成原问题。


分治算法能解决的问题,一般需要满足下面这几个条件:


  • 原问题与分解成的小问题具有相同的模式;


  • 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别,等我们讲到动态规划的时候,会详细对比这两种算法;


  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;


  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。


分治算法应用举例分析


  1. 假设有n个数据,期望数据从小到大排序,那完全有序的数据的有序度就是n(n-1)/2。逆序度等于0;相反,倒序排序的数据的有序度就是0,逆序度是n(n-1)/2。除了这两种极端情况外,我们通过计算有序对或逆序对的个数,来表示数据的有序度或逆序度。


  1. 现在问:如何编程求出数组中的数据有序对个数或逆序对个数?


  1. 最简单的办法:拿每个数字和他后面的数字比较,看有几个比它小。将比它小的数字个数记作k,通过这样的方式,把每个数字都考察一遍后,对每个数字对应的k值求和,最后得到的总和就是逆序对个数。但时间复杂度是O(n^2)。


  1. 用分治算法,套用分治的思想,将书中分成前后两半A1和A2,分别两者中的逆序对数,然后在计算A1和A2之间的逆序对个数k3。那整个数组的逆序对个数就是k1+k2+k3。


  1. 要快速计算出两个子问题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));


分治思想在海量数据处理中的应用


  1. 假设,给10GB的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,但是因为数据量大,有10GB,而我们的机器的内存可能只有2,3GB这样子,无法一次性加载到内存,也就无法通过单纯地使用快排,归并等基础算法来解决。


  1. 要解决这种数据量大到内装不下的问题,我们就可以利用分治的思想,将海量的数据集合根据某种方法,划分为几个小的数据集合,每个小的数据集合单独加载到内存来解决,然后在将小数据集合合并成大数据集合,实际上利用这种分治的处理思路,不仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。


举例分析


采用分治思想的算法包括:


  1. 快速排序算法


  1. 合并排序算法


  1. 桶排序算法


  1. 基数排序算法


  1. 二分查找算法


  1. 利用递归树求解算法复杂度的思想


  1. 分布式数据库利用分片技术做数据处理


  1. MapReduce模型处理思想

3、回溯


深度优先搜索算法利用的是回溯算法思想。这个算法思想非常简单,但是应用却非常广泛。它除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,比如正则表达式匹配、编译原理中的语法分析等。很多经典的数学问题都可以用回溯算法解决,比如数独、八皇后、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 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。


<?php
class 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);
        }
      }
    }

    回溯算法的思想简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。


    4、动态规划


    一个模型三个特征


    “一个模型” 指的是动态规划适合解决的问题的模型。把这个模型定义为“多阶段决策最优解模型”。


    “三个特征”分别是最优子结构、无后效性和重复子问题。


    最优子结构


    最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来


    无后效性


    无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。


    重复子问题


    如果用一句话概括一下,那就是,不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。


    解题思路


    状态转移表法


    回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码


    先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了


    状态转移方程法


    找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码。


    状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。有两种代码实现方法,一种是递归加“备忘录”,另一种是迭代递推。


    min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))


    0-1背包问题


    我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。


    四种算法思想比较分析


    那贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类,因为它跟其他三个都不大一样。为什么这么说呢?


    前三个算法解决问题的模型,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型


    回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。


    尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。


    贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。


    其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。


    微信图片_20220608101441.jpg

    相关文章
    |
    5天前
    |
    负载均衡 NoSQL 算法
    一天五道Java面试题----第十天(简述Redis事务实现--------->负载均衡算法、类型)
    这篇文章是关于Java面试中Redis相关问题的笔记,包括Redis事务实现、集群方案、主从复制原理、CAP和BASE理论以及负载均衡算法和类型。
    一天五道Java面试题----第十天(简述Redis事务实现--------->负载均衡算法、类型)
    |
    3月前
    |
    负载均衡 算法 应用服务中间件
    面试题:Nginx有哪些负载均衡算法?Nginx位于七层网络结构中的哪一层?
    字节跳动面试题:Nginx有哪些负载均衡算法?Nginx位于七层网络结构中的哪一层?
    93 0
    |
    11天前
    |
    算法 Go
    [go 面试] 雪花算法与分布式ID生成
    [go 面试] 雪花算法与分布式ID生成
    |
    4天前
    |
    算法
    聊聊一个面试中经常出现的算法题:组合运算及其实际应用例子
    聊聊一个面试中经常出现的算法题:组合运算及其实际应用例子
    |
    13天前
    |
    机器学习/深度学习 算法 数据中心
    【机器学习】面试问答:PCA算法介绍?PCA算法过程?PCA为什么要中心化处理?PCA为什么要做正交变化?PCA与线性判别分析LDA降维的区别?
    本文介绍了主成分分析(PCA)算法,包括PCA的基本概念、算法过程、中心化处理的必要性、正交变换的目的,以及PCA与线性判别分析(LDA)在降维上的区别。
    28 4
    |
    11天前
    |
    算法
    突击面试:解密面试官的算法题集合
    突击面试:解密面试官的算法题集合
    |
    15天前
    |
    算法
    分享几道大厂面试算法题
    分享几道大厂面试算法题
    |
    1月前
    |
    算法 Java
    Java面试题:解释垃圾回收中的标记-清除、复制、标记-压缩算法的工作原理
    Java面试题:解释垃圾回收中的标记-清除、复制、标记-压缩算法的工作原理
    33 1
    |
    2月前
    |
    消息中间件 算法 Java
    抖音面试:说说延迟任务的调度算法?
    Netty 框架是以性能著称的框架,因此在它的框架中使用了大量提升性能的机制,例如 Netty 用于实现延迟队列的时间轮调度算法就是一个典型的例子。使用时间轮调度算法可以实现海量任务新增和取消任务的时间度为 O(1),那么什么是时间轮调度算法呢?接下来我们一起来看。 ## 1.延迟任务实现 在 Netty 中,我们需要使用 HashedWheelTimer 类来实现延迟任务,例如以下代码: ```java public class DelayTaskExample { public static void main(String[] args) { System.ou
    34 5
    抖音面试:说说延迟任务的调度算法?
    |
    1月前
    |
    算法 Java 程序员
    Java面试题:解释Java的垃圾回收机制,包括常见的垃圾回收算法。介绍一下Java的垃圾回收算法中的标记-压缩算法。
    Java面试题:解释Java的垃圾回收机制,包括常见的垃圾回收算法。介绍一下Java的垃圾回收算法中的标记-压缩算法。
    26 0