算法
- 暴力法
- 双指针法(快慢指针法、滑动窗口:队列)
- 迭代法
- 递归法:栈(括号匹配、字符串去重)
- 哈希表:一般来说哈希表都是用来快速判断一个元素是否出现集合里
- 排序
- 分组
二叉树
- 递归
- 迭代
- DFS
- BFS
很多算法都可以用递归和循环两种不同的方式实现。通常基于递归的实现方法代码会比较简洁,但性能不如基于循环的实现方法。在面试的时候,我们可以根据题目的特点,甚至可以和面试官讨论选择合适的方法编程。
通常基于排序和查找是面试时考查算法的重点。在准备面试的时候,我们应该重点掌握二分查找、归并排序和快速排序,做到能随时正确、完整地写出它们的代码。
如果面试题要求在二维数组(可能具体表现为迷宫或棋盘等)上搜索路径,那么我们可以尝试用回溯法。通常回溯法很适合用递归的代码实现。只有当面试官限定不可以用递归实现的时候,我们再考虑用栈来模拟递归的过程。
如果面试题是求某个问题的最优解,并且该问题可以分为多个子问题,那么我们可以尝试用动态规划。在用自上而下的递归思路去分析动态规划问题的时候,我们会发现子问题之间存在重叠的更小的子问题。为了避免不必要的重复计算,我们用自下而上的循环代码来实现,也就是把子问题的最优解先算出来并用数组(一般是一维或者二维数组)保存下来,接下来基于子问题的解计算大问题的解。
动态规划特点
- 求一个问题的最优解,这是可以应用
动态规划求解问题的第一个特点。 - 整体问题的最优解是依赖各个子问题的最优解,这是可以应用
动态规划求解问题的第二个特点。 - 我们把大问题分解成若干个小问题,这些小问题之间还有相互重叠的更小的子问题,这是可以应用
动态规划求解问题的第三个特点。 - 从上往下分析问题,从下往上求解问题,这是可以应用
动态规划求解问题的第四个特点。
如果我们告诉面试官动态规划的思路之后,面试官还在提醒说在分解子问题的时候是不是存在某个特殊的选择,如果采用这个特殊的选择将一定能得到最优解,那么,通常面试官这样的提示意味着该面试题可能适用于贪婪算法。当然面试官也会要求应聘者证明贪婪选择的确最终能够得到最优解。
四种算法总结(极客时间 - 41 | 动态规划理论)
贪心、分治、回溯 和 动态规划 这四种算法,将这四种算法思想分一下类,那 贪心、回溯、动态规划 可以归为一类,而 分治 单独可以作为一类,因为它跟其他三个都不大一样。为什么这么说呢?前三个算法解决问题的模型,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而 分治算法 解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。
回溯算法 是个“万金油”。基本上能用的 动态规划、贪心 解决的问题,我们都可以用 回溯算法 解决。 回溯算法 相当于 穷举搜索 。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用 回溯算法 解决的执行 效率就很低 了。
尽管 动态规划 比 回溯算法 高效,但是,并不是所有问题,都可以用 动态规划 来解决。能用 动态规划 解决的问题,需要满足三个特征,最优子结构、无后效性 和 重复子问题。在 重复子问题 这一点上,动态规划 和 分治算法 的区分非常明显。分治算法 要求分割成的子问题,不能有重复子问题,而 动态规划 正好相反,动态规划 之所以高效,就是因为回溯算法实现中存在大量的重复子问题。贪心算法 实际上是 动态规划算法 的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。
其中,最优子结构、无后效性 跟 动态规划 中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
根据 回溯算法 的代码实现,我们可以画出递归树,看是否存在 重复子问题。如果存在重复子问题,那我们就可以考虑能否用 动态规划 来解决;如果 不存在 重复子问题,那 回溯 就是最好的解决方法。
递归需要满足的三个条件
1. 一个问题的解可以分解为几个子问题的解
何为子问题?子问题就是数据规模更小的问题。比如,前面讲的电影院的例子,你要知道,“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题。
2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
比如电影院那个例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的。
3. 存在递归终止条件
把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。
还是电影院的例子,第一排的人不需要再继续询问任何人,就知道自己在哪一排,也就是 f(1)=1,这就是递归的终止条件。
相应的,处理递归问题也可以归纳为递归三部曲:
1. 递归的参数、返回值是什么? 2. 终止条件是什么? 3. 单层逻辑是什么?
位运算
位运算可以看成一类特殊的算法,它是把数字表示成二进制之后对 0 和 1 的操作。由于位运算的对象为二进制数字,所以不是很直观,但掌握它也不难,因为总共只有与、或、异或、左移 和 右移 5 种位运算。
异或
1、一个数和自己做异或的结果是0
2、从异或的真值表可以看出,不管是0还是1,和0做异或值不变,和1做异或得到原值的相反值。可以利用这个特性配合掩码实现某些位的翻转。例如:
unsigned int a, b, mask = 1 << 6;
a = 0x12345678;
b = a ^ mask; /* flip the 6th bit */
3、如果a1 ^ a2 ^ a3 ^ ... ^ an的结果是1,则表示a1、a2、a3...an之中1的个数为奇数个,否则为偶数个。 这条性质可用于奇偶校验(Parity Check),比如在串口通信过程中,每个字节的数据都计算一个校验位,数据和校验位一起发送出去,这样接收方可以根据校验位粗略地判断接收到的数据是否有误。
4、x ^ x ^ y == y,因为x ^ x == 0,0 ^ y == y。 这个性质有什么用呢?我们来看这样一个问题:交换两个变量的值,不得借助于额外的存储空间,所以就不能采用temp = a; a = b; b = temp;的办法了。利用位运算可以这样做交换:
a = a ^ b;
b = b ^ a;
a = a ^ b;
分析一下这个过程。为了避免混淆,把a和b的初值分别记为a0和b0。第一行,a = a0 ^ b0;第二行,把a的新值代入,得到b = b0 ^ a0 ^ b0,等号右边的b0相当于上面公式中的x,a0相当于y,所以结果为a0;第三行,把a和b的新值代入,得到a = a0 ^ b0 ^ a0,结果为b0。
线性表和非线性表
线性表
线性表:顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
线性表存储结构可细分为顺序存储结构和链式存储结构。
- 顺序存储结构:将数据依次存储在连续的整块物理空间中,这种存储结构称为顺序存储结构
- 链式存储结构: 数据分散的存储在物理空间中,通过一根线保存着它们之间的逻辑关系,这种存储结构称为链式存储结构
非线性表
非线性表:比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
数组和链表
- 数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。
- 链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。 | |插入/删除(时间复杂度) | 查询(时间复杂度) | 适应场景 | | - | - | - | - | | 数组 | O(n) | O(1) | 数据量固定、频繁查询,较少增删 | | 链表 | O(1) | O(n) | 数据量不固定,频繁增删,较少查询 |
排序
| 排序算法 | 平均复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn) | 不稳定 |
| 桶排序 | O(n) | O(n) | O(n) | O(n + m) | 稳定 |
| 计数排序 | O(n) | O(n) | O(n) | O(n + m) | 稳定 |
| 基数排序 | O(n) | O(n) | O(n) | O(n + m) | 稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
冒泡排序 - O(n²) 11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
冒泡排序算法的原理比较容易理解,具体的代码我贴到下面,你可以结合着代码来看我前面讲的原理。
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
boolean flag = false;
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
一、冒泡排序是原地排序算法吗?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。
二、冒泡排序是稳定的排序算法吗?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
三、冒泡排序的时间复杂度是多少?
有序度
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n²)。
对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。我们把这种完全有序的数组的有序度叫作满有序度。
- 最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。
- 最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。
- 我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n²),所以平均情况下的时间复杂度就是 O(n²)。
插入排序 - O(n²) 11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?
那插入排序具体是如何借助上面的思想来实现排序的呢
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
插入排序的原理也很简单吧?我也将代码实现贴在这里,你可以结合着代码再看下。
// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; ++i) {
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
}
a[j+1] = value; // 插入数据
}
}
一、插入排序是原地排序算法吗?
从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。
二、插入排序是稳定的排序算法吗?
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
三、插入排序的时间复杂度是多少?
如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n²)。
还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n2)。
选择排序 - O(n²) 11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?\
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
首先,选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n²)。
那选择排序是稳定的排序算法吗?
这个问题我着重来说一下。答案是否定的,选择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。
比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
归并排序 - 最好、平均、最坏都是O(nlogn) 空间复杂度:O(n) 12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?
- 原理:归并排序(由下而上)先处理子问题,再合并分区、
- 空间复杂度:在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
- 稳定性:那我们可以像伪代码中那样,先把 A[p...q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
快速排序 - 平均O(nlogn)、最坏:O(n²) 空间复杂度:O(1) 12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?
- 快速排序(由上而下)先找个点分区,再处理子问题
- 空间复杂度:使用了交换法,不需要开辟额外的空间。
桶排序 - O(n) 13 | 线性排序:如何根据年龄给100万用户数据排序?
计数排序 - O(n) [13 | 线性排序:如何根据年龄给100万用户数据排序?]
(time.geekbang.org/column/arti…)
考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
基数排序 - O(n) 13 | 线性排序:如何根据年龄给100万用户数据排序?
手机号码比较大小
基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
排序算法应用 - 14 | 排序优化:如何实现一个通用的、高性能的排序函数?
qsort()源码分析,运用归并排序、快速排序、和插入排序,有的时候需要空间换时间
查找
二分查找 - 15 | 二分查找(上):如何用最省内存的方式实现快速查找功能?
可以看出来,这是一个等比数列。其中 n/2k=1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)。
O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?
因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。
// 二分查找的递归实现
public int bsearch(int[] a, int n, int val) {
return bsearchInternally(a, 0, n - 1, val);
}
private int bsearchInternally(int[] a, int low, int high, int value) {
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
变体一:查找第一个值等于给定值的元素 - 16 | 二分查找(下):如何快速定位IP对应的省份地址?
变体二:查找最后一个值等于给定值的元素 - 16 | 二分查找(下):如何快速定位IP对应的省份地址?
变体三:查找第一个大于等于给定值的元素 - 16 | 二分查找(下):如何快速定位IP对应的省份地址?
变体四:查找最后一个小于等于给定值的元素 - 16 | 二分查找(下):如何快速定位IP对应的省份地址?
跳表 -17 | 跳表:为什么Redis一定要用跳表来实现有序集合?
从图中我们可以看出,原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点,速度是不是提高了很多?所以,当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。
前面讲的这种链表加多级索引的结构,就是跳表。
跳表查询时间复杂度
第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)
假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。
所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找
跳表的空间复杂度
这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)
散列表 - 18 | 散列表(上):Word文档中的单词拼写检查功能是如何实现的?
应用:word错误单词提示。哈希冲突解决方案:1. 开放寻址法(出现冲突重新找一个) 2. 链表法(出现冲突,留在链表里面)
参赛选手的编号我们叫做键(key)或者关键字。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作散列函数(或“Hash 函数”“哈希函数”),而散列函数计算得到的值就叫作散列值(或“Hash 值”“哈希值”)。
该如何构造散列函数呢?
我总结了三点散列函数设计的基本要求:
- 散列函数计算得到的散列值是一个非负整数;
- 如果 key1 = key2,那 hash(key1) == hash(key2);
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
第三点理解起来可能会有问题,我着重说一下。这个要求看起来合情合理,但是在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。
所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决。
散列冲突
再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢?我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
1. 开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。
在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?我们可以将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。 装载因子的计算公式是:
散列表的装载因子=填入表中的元素个数/散列表的长度 装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
2. 链表法
链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
散列冲突解决防范:- 19 | 散列表(中):如何打造一个工业级水平的散列表?
散列冲突解决防范:开放寻址和链表法(装载因子不高的散列表,比较适合用开放寻址法),大部分情况下,链表法更加普适。还可以把链表改成更加其他动态查找的数据结构(红黑树->避免散列表时间复杂度退化成 O(n))
散列表+链表:- 20 | 散列表(下):为什么散列表和链表经常会一起使用?
- 数组占据随机访问的优势,却有需要连续内存的缺点。
- 链表具有可不连续存储的优势,但访问查找是线性的。
- 散列表和链表、跳表的混合使用,是为了结合数组和链表的优势,规避它们的不足。
在删除一个元素时,虽然能 O(1) 的找到目标结点,但是要删除该结点需要拿到前一个结点的指针,遍历到前一个结点复杂度会变为 O(N),所以用双链表实现比较合适。
iOS 的同学可能知道,YYMemoryCache 就是结合散列表和双向链表来实现的。
哈希算法 -21 | 哈希算法(上):如何防止数据库中的用户信息被脱库?
应用一:安全加密
说到哈希算法的应用,最先想到的应该就是安全加密。最常用于加密的哈希算法是 MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 SHA(Secure Hash Algorithm,安全散列算法)。
除了这两个之外,当然还有很多其他加密算法,比如 DES(Data Encryption Standard,数据加密标准)、AES(Advanced Encryption Standard,高级加密标准)。
应用二:唯一标识
我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。
应用三:数据校验(区块链 存放区块体和上一个区块头的哈希值,改一个,后面所有区块保存的哈希值就不对了)
哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。
应用四:散列函数(均匀分布)
散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。
应用五:负载均衡 - 22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?
我们知道,负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。
如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。 这样,我们就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。
应用六:数据分片 - 22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?
1. 如何统计“搜索关键词”出现的次数?
这个问题有两个难点,
- 第一个是搜索日志很大,没办法放到一台机器的内存中。
- 第二个难点是,如果只用一台机器来处理这么巨大的数据,处理时间会很长。
针对这两个难点,我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度。具体的思路是这样的:为了提高处理的速度,我们用 n 台机器并行处理。我们从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号。
这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。
2. 如何快速判断图片是否在图库中?
假设现在我们的图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而 1 亿张图片构建散列表显然远远超过了单台机器的内存上限。
我们同样可以对数据进行分片,然后采用多机处理。我们准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。
当我们要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。
应用七:分布式存储
我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。
该如何决定将哪个数据放到哪个机器上呢?我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。
树
二叉树 - 23 | 二叉树基础(上):什么样的二叉树适合用数组来存储
其中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树。 编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树。
二叉树的遍历
实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。
前序遍历
前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
中序遍历
中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
后序遍历
后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
// 前序遍历
void preOrder(Node* root) {
if (root == null) return;
print root // 此处为伪代码,表示打印root节点
preOrder(root->left);
preOrder(root->right);
}
// 中序遍历
void inOrder(Node* root) {
if (root == null) return;
inOrder(root->left);
print root // 此处为伪代码,表示打印root节点
inOrder(root->right);
}
// 后序遍历
void postOrder(Node* root) {
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root // 此处为伪代码,表示打印root节点
}
二叉树和散列表的对比 - 24 | 二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?
二叉查找树
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
1. 二叉查找树的查找操作
我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
这里我把查找的代码实现了一下,贴在下面了,结合代码,理解起来会更加容易。
// 二叉查找树 查找工作
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data) p = p.left;
else if (data > p.data) p = p.right;
else return p;
}
return null;
}
public static class Node {
private int data;
private Node left;
private Node right;
public Node(int data) {
this.data = data;
}
}
}
2. 二叉查找树的插入操作
如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
同样,插入的代码我也实现了一下,贴在下面,你可以看看。
// 二叉查找树 插入操作
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
3. 二叉查找树的删除操作
针对要删除节点的子节点个数的不同,我们需要分三种情况来处理:
第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55。
第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。
第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。
老规矩,我还是把删除的代码贴在这里。
// 二叉查找树 删除操作
public void delete(int data) {
Node p = tree; // p指向要删除的节点,初始化指向根节点
Node pp = null; // pp记录的是p的父节点
while (p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 没有找到
// 要删除的节点有两个子节点
if (p.left != null && p.right != null) { // 查找右子树中最小节点
Node minP = p.right;
Node minPP = p; // minPP表示minP的父节点
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 将minP的数据替换到p中
p = minP; // 下面就变成了删除minP了
pp = minPP;
}
// 删除节点是叶子节点或者仅有一个子节点
Node child; // p的子节点
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 删除的是根节点
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
二叉树和散列表的对比
我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?
- 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
- 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
- 第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
- 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
红黑树 - 25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?
红黑树是一种自平衡的二叉查找树,除了二叉查找树的特性,还具有以下特性:
- 每个结点不是红色就是黑色
- 不可能有连在一起的红色结点
- 根节点都是黑色 root
- 每个红色结点的两个子节点都是黑色。叶子结点都是黑色:出度为0。满足了性质就可以近似的平衡了,不一定要红黑,可以为其他的。
递归树 - 27 | 递归树:如何借助树来求解递归算法的时间复杂度?
快速排序结束的条件就是待排序的小区间,大小为 1,也就是说叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10,最长的一个路径每次都乘以 9/10。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 log10n,最长的路径是 log10/9n。
所以,遍历数据的个数总和就介于 nlog10n 和 nlog10/9n 之间。根据复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)。
堆 - 28 | 堆和堆排序:为什么说堆排序没有快速排序快?
堆的定义
- 堆必须是一个完全二叉树
- 堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。(对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。)
往堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的两个特性。
如果我们把新插入的元素放到堆的最后,你可以看我画的这个图,是不是不符合堆的特性了?于是,我们就需要进行调整,让其重新满足堆的特性,这个过程我们起了一个名字,就叫做堆化(heapify)。
我这里画了一张堆化的过程分解图。我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。
我将上面讲的往堆中插入数据的过程,翻译成了代码,你可以结合着一块看。
// 堆化
public class Heap {
private int[] a; // 数组,从下标1开始存储数据
private int n; // 堆可以存储的最大数据个数
private int count; // 堆中已经存储的数据个数
public Heap(int capacity) {
a = new int[capacity + 1];
n = capacity;
count = 0;
}
public void insert(int data) {
if (count >= n) return; // 堆满了
++count;
a[count] = data;
int i = count;
while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
swap(a, i, i/2); // swap()函数作用:交换下标为i和i/2的两个元素
i = i/2;
}
}
}
删除堆顶元素
从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,我们可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。
假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。
我们知道,一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。
堆排序
1. 建堆
建堆的时间复杂度就是 O(n)
2. 排序
排序过程的时间复杂度是 O(nlogn)
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
堆的应用 - 29 | 堆的应用:如何快速获取到Top 10最热门的搜索关键词?
堆这种数据结构几个非常重要的应用:优先级队列、求 Top K 和求中位数。
堆的应用一:优先级队列
合并有序小文件
假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。
这里就可以用到优先级队列,也可以说是堆。我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。
我们知道,删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。是不是比原来数组存储的方式高效了很多呢?
堆的应用二:利用堆求 Top K
遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。
如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻返回给他。
堆的应用三:利用堆求中位数
借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。我们来看看,它是如何做到的?
我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
于是,我们就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。
实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。
图 - 30 | 图的表示:如何存储微博、微信等社交网络中的好友关系?
树中的元素我们称为节点,图中的元素我们就叫做顶点(vertex)。从我画的图中可以看出来,图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做边(edge)。
度(degree),就是跟顶点相连接的边的条数。我们刚刚讲过,无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)。
在带权图中,每条边都有一个权重(weight),我们可以通过这个权重来表示 QQ 好友间的亲密度。
邻接矩阵存储方法
邻接表存储方法
还记得我们之前讲过的时间、空间复杂度互换的设计思想吗?邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时间。
邻接矩阵存储方法的缺点是比较浪费空间,但是优点是查询效率高,而且方便矩阵运算。邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。
基础的数据结构就是数组和链表, 而后面更加复杂的 树 队列 图 等等 都可以通过数组和链表等方式存储, 出现树 队列 图 等数据结构的原因 就是为了解决 部分问题处理过程中时间复杂度过高的问题, 所以数据结构就是为了算法而生的
广度优先搜索(BFS)- 31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?
广度优先搜索(Breadth-First-Search),我们平常都简称 BFS。直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。理解起来并不难,所以我画了一张示意图,你可以看下。(一层一层的搜索)
BFS 遍历使用队列数据结构:
void bfs(TreeNode root) {
Queue<TreeNode> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll(); // Java 的 pop 写作 poll()
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
}
深度优先搜索(DFS)- [31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?]
深度优先搜索(Depth-First-Search),简称 DFS。最直观的例子就是“走迷宫”。
假设你站在迷宫的某个岔路口,然后想找到出口。你随意选择一个岔路口来走,走着走着发现走不通的时候,你就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这种走法就是一种深度优先搜索策略。(先搜索到底,没了再回上一级接着搜索)
DFS 遍历使用递归:
void dfs(TreeNode root) {
if (root == null) {
return;
}
dfs(root.left);
dfs(root.right);
}
字符串匹配
-
- 字符串匹配:BF算法:暴力算法 时间复杂度O(n*m) RK算法:匹配子串的hash值 时间复杂度 O(n)
-
- 字符串匹配:BM算法:坏字符和好后缀原则 时间复杂度O(n)
-
- 字符串匹配:KMP算法 时间复杂度O(n+m),仅需一个next数组的O(n)额外空间
-
- Trie树:查找前缀匹配字符串 -> 自动补全功能,如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等
- 36. AC 自动机算法,
算法
递归 - 10 | 递归:如何用三行代码找到“最终推荐人”?
一、什么是递归?
- 递归是一种非常高效、简洁的编码技巧,一种应用非常广泛的算法,比如DFS深度优先搜索、前中后序二叉树遍历等都是使用递归。
- 方法或函数调用自身的方式称为递归调用,调用称为递,返回称为归。
- 基本上,所有的递归问题都可以用递推公式来表示,比如
- f(n) = f(n-1) + 1;
- f(n) = f(n-1) + f(n-2);
- f(n)=n*f(n-1);
二、为什么使用递归?递归的优缺点?
- 优点:代码的表达力很强,写起来简洁。
- 缺点:空间复杂度高、有堆栈溢出风险、存在重复计算、过多的函数调用会耗时较多等问题。
三、什么样的问题可以用递归解决呢?
一个问题只要同时满足以下3个条件,就可以用递归来解决:
- 问题的解可以分解为几个子问题的解。何为子问题?就是数据规模更小的问题。
- 问题与子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
四、如何实现递归?
1. 递归代码编写
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
2. 递归代码理解
对于递归代码,若试图想清楚整个递和归的过程,实际上是进入了一个思维误区。
那该如何理解递归代码呢?如果一个问题A可以分解为若干个子问题B、C、D,你可以假设子问题B、C、D已经解决。而且,你只需要思考问题A与子问题B、C、D两层之间的关系即可,不需要一层层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
因此,理解递归代码,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
五、递归常见问题及解决方案
- 警惕堆栈溢出:可以声明一个全局变量来控制递归的深度,从而避免堆栈溢出。
- 警惕重复计算:通过某种数据结构来保存已经求解过的值,从而避免重复计算。
六、如何将递归改写为非递归代码?
笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。
贪心算法 - 37 | 贪心算法:如何用贪心算法实现Huffman压缩编码?
如果找出局部最优并可以推出全局最优,就是贪心,如果局部最优都没找出来,就不是贪心,可能是单纯的模拟。
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
分治算法 - 38 | 分治算法:谈一谈大规模计算框架MapReduce中的分治思想
分治算法:分而治之,将原问题划分成 n 个规模较小而结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果
回溯算法 - 39 | 回溯算法:从电影《蝴蝶效应》中学习回溯算法的核心思想
回溯算法:回溯算法本质上就是枚举,优点在于其类似于摸着石头过河的查找策略,且可以通过剪枝少走冤枉路。它可能适合应用于缺乏规律,或我们还不了解其规律的搜索场景中。
回溯是递归的副产品,只要有递归就会有回溯。
回溯法就是暴力搜索,并不是什么高效的算法,最多在剪枝一下。
回溯算法能解决如下问题:
-
组合问题:N个数里面按一定规则找出k个数的集合
-
排列问题:N个数按一定规则全排列,有几种排列方式
-
切割问题:一个字符串按一定规则有几种切割方式
-
子集问题:一个N个数的集合里有多少符合条件的子集
-
棋盘问题:N皇后,解数独等等
动态规划 - 40 | 初识动态规划:如何巧妙解决“双十一”购物时的凑单问题?
一般的动态规划题目思路三步走:
- 定义状态转移方程
- 给定转移方程初始值
- 写代码递推实现转移方程
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
背包问题
- 贪心:一条路走到黑,就一次机会,只能哪边看着顺眼走哪边
- 回溯:一条路走到黑,无数次重来的机会,还怕我走不出来 (Snapshot View)
- 动态规划:拥有上帝视角,手握无数平行宇宙的历史存档, 同时发展出无数个未来
涉及递推送关系的算法问题,可以用动态规划思维解决的,用递归一样可以解决,关键在于要注意到算法性能,通过矩阵数组保存中间过程运算结果,从而避免不必要的重复计算。一句话,去除了重复计算的递归就是动态规划