学习任何东西,都需要深究其底层,掌握其思想,融会贯通才能应万变。对于算法而言,同样如此,编程的算法题千千万,刷题是刷不完的,还是需要掌握算法的思想才能掌握如何求解算法题。

最经典的算法思想有以下几种:

  • 贪心算法:每一步都采用最优的选择,从而希望结果是最好的
  • 分治算法:将原问题拆分成多个结果类似的子问题,递归解决后再合并其结果
  • 回溯算法:类似于试探性枚举搜索,用于指导深度优先搜索这样的经典算法
  • 动态规划:优化自顶向下的重复子问题,自底向上地推算出问题的最优解

贪心算法

理论

贪心算法是一种在每一步选择当中都采取当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。

贪心算法在有最优子结构的问题中尤为有效,简单地说,就是问题能够分解成子问题来解决,子问题的最优解就能递推到最终问题的最优解。

细节

  1. 创建数学模型来描述问题;
  2. 把问题分成若干个子问题;
  3. 对每一子问题求解,得到子问题的局部最优解;
  4. 把子问题的局部最优解合并成原来问题的一个解。

案例

对于零钱的问题:假设有 25 分、10 分、5 分、1 分这 4 种硬币,现在要从中取出 41 分钱的硬币,并且要求硬币的个数最少。

通过贪心算法,我们每次都拿出最大额度的硬币,直到此额度超过了所需的额度。详细的过程如下:

  1. 对于 41 分钱,拿出 25 分的硬币,此时还差 16 分钱,25 分的硬币超过了所需的额度,自然需要往更优、更小的额度去取;
  2. 对于 16 分钱,拿出 10 分的硬币,此时还差 6 分钱,10 分的硬币超过了所需的额度,自然需要往更优、更小的额度去取;
  3. 对于 6 分钱,拿出 5 分的硬币,此时还差 1 分钱,5 分的硬币超过了所需的额度,自然需要往更优、更小的额度去取;
  4. 对于 1 分钱,拿出 1 分的硬币,此时还差 0 分钱;
  5. 最终,41 分钱拆分成了 1 个 25 分、1 个 10 分、1 个 5 分、1 个 1 分,总共 4 个硬币。

分治算法

理论

分治算法字面上的解释是“分而治之”,就是把一个复杂的问题拆分成两个或多个相同或相似的子问题,再把子问题拆分成更小的子问题……直到最后子问题可以简单地求解,原问题的解即子问题的解的合并。

在广义的定义下,所有递归或循环的算法均被视为“分治算法”,也有只将具有最少两个子问题的算法作为“分治算法”。

细节

在每一层递归上都有三个步骤:

  1. 分解:将原问题分解成若干个规模较小,相对独立,与原问题形式相同的子问题;
  2. 解决:若子问题规模较小且易于解决时,则直接解。否则,递归地解决各子问题;
  3. 合并:将各子问题的解合并为原问题的解。

案例

分治算法最让人熟知的应用案例就是归并排序,以下归并排序的部分代码能一一对应分治算法的细节步骤:

void merge_sort(int array[], unsigned int first, unsigned int last) {
    int mid = 0;
    if (first < last) {
        mid = (first + last) / 2;
        merge_sort(array, first, mid);
        merge_sort(array, mid + 1,last);
        merge(array,first,mid,last);
    }
}

merge_sort() 函数中,将原本一个序列的排序问题从中间位置拆分成了两个子序列的的排序问题:

  1. firstmid 的子序列排序;
  2. 在对 mid+1last 的子序列排序;
  3. 并且是递归调用 merge_sort() 进行排序,最终将两个有序子序列合并。

回溯算法

理论

回溯算法是一种择优选择的算法思想,又称为试探法,按择优条件向前搜索,以达到目标。

将回溯算法映射到一个现实的例子就是:在迷宫当中,通过选择不同的岔路口寻找出口,一个岔路口一个岔路口地去尝试找到出口,如果在中途走错了路,则返回到岔路口的另一条路,直到找到出口。

细节

用回溯算法解决问题的一般步骤:

  1. 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解;
  2. 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间;
  3. 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

案例

在 8 x 8 的棋盘上,每一个空格可以放一个皇后,皇后可以攻击与它同行、同列或同一斜线的其他皇后,在该棋盘上摆放 8 个皇后,使其不能互相攻击。这就是“八皇后”问题,该问题就是典型的回溯算法案例,

对于一个 4 x 4 的棋盘结构,应该叫作“四皇后”问题,下述树形结构是其解题思路:

四皇后问题

上图就是一个回溯算法的思路,即先在第一行放置一个皇后,然后再试探性地再第二行放置一个皇后,以此类推,如果出现不满足要求的情况,则及时止损,返回上一行继续试探性地选择不同的方案,直到所有的方案都被列举出来位置。

这里说的回溯算法有点像是穷举法,其实它们是有点类似的,对于像“八皇后”问题需要所有解答时就可以将回溯算法认为是试探性穷举法。

动态规划

理论

动态规划的思想是,若要解决一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。

在各种场景中,动态规划在对重复子问题求最优解时非常有效。其使用的方法是,在求解的过程中将子问题的结果存储下来重复利用,从简单的问题直到整个问题都被解决。

细节

使用动态规划优化算法,基本上遵循一个固定的流程:

  1. 递归的暴力破解:将一个问题拆解成子问题,使用递归的思路解决问题;
  2. 带备忘录的递归解法:在递归的过程中,将子问题的结果存储下来重复利用,减少时间耗费;
  3. 非递归的动态规划解法:遵循自底向上的推算思路,将递归转换成非递归的循环解法。

案例

如果想要求解斐波那契数列,使用递归的思路非常简单,只要推算出递归的入口即可。

当想要优化的时候,也可以使用备忘录存储中间结果集,当然,对于求解斐波那契数列,此优化思路提升得并不多。

引入动态规划的思路之后,自底向上求解斐波那契数列的思路就会有点与众不同,如下是求解的代码:

int fib(int N) {
    vector<int> dp(N + 1, 0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

其实,拿求解斐波那契数列的例子来说明并不能展现出动态规划的优势,但却能展示出从一个递归算法改成动态规划算法的过程。