🍕 递归
递归案例 - 求数组最大值
public static int process(int[] arr, int L, int R) {
if (L == R) {
return arr[L];
}
int mid = L + ((R - L) >> 1);
int leftMax = process(arr, L, mid);
int rightMax = process(arr, mid+1, R);
return Math.max(leftMax, rightMax);
}
求中点: mid = (L+R) / 2
其中的 L + R
可能溢出, 故改写成 L + (R-L) / 2
。 除以 2 可以使用位运算 >>1
实现。
举个🌰: 数组长度 5, 使用递归求最大值的过程如下:
[0, 4] 0 + (4-0)>>2 = 2
/ \
[0, 2] [3, 4] 0+(2-0)>>2 = 1 ; 3+(4-3)>>2 = 3
/ \ / \
[0, 1] [2] [3] [4] 0+(1-0)>>2 = 0
/ \
[0] [1]
- push; 计算 [0,4] 的最大值, 二分为 [0,2] 和 [3,4],等待 [0,2] 和 [3,4] 各自的最大值
- push; 计算 [0,2] 的最大值, 二分为 [0,1] 和 [2],等待 [0,1] 和 [2] 各自的最大值
- push; 计算 [0,1] 的最大值, 二分为 [0] 和 [1],等待 [0] 和 [1] 各自的最大值
- push; 计算 [0] 的最大值, 直接返回 [0] 的值
- pop; 得到 [0] 的最最大值
- push; 计算 [1] 的最大值, 直接返回 [1] 的值
- pop; 得到 [1] 的最大值
- pop; 比较 [0] 和 [1] 得出 [0,1] 的最大值
- push; 计算 [2] 的最大值, 直接返回 [2] 的值
- pop; 得到 [2] 的最大值
- pop; 比较 [0,1] 和 [2] 得出 [0,2] 的最大值
- push; 计算 [3,4] 的最大值, 二分为 [3] 和 [4],等待 [3] 和 [4] 的各自最大值
- push; 计算 [3] 的最大值, 直接返回 [3] 的值
- pop; 得到 [3] 的最大值
- push; 计算 [4] 的最大值, 直接返回 [4] 的值
- pop; 得到 [4] 的最大值
- pop; 比较 [3] 和 [4] 得出 [3,4] 的最大值
- pop; 比较 [0,2] 和 [3,4] 得出 [0,4] 的最大值
- 结束
⇓[0] ⇑ ⇓[1] ⇑
⇓[0,1] [0,1] [0,1] [0,1] [0,1] ⇑ ⇓[2] ⇑ ⇓[3] ⇑ ⇓[4] ⇑
⇓[0,2] [0,2] [0,2] [0,2] [0,2] [0,2] [0,2] [0,2] [0,2] ⇑ ⇓[3,4] [3,4] [3,4] [3,4] [3,4] ⇑
⇓[0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] [0,4] ⇑ END
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
求递归时间复杂度 - master 公式
满足以下递归关系的递归, 可以使用 master 公式计算时间复杂度
T(N) = a * T(N/b) + O(N^d)
其中:
T(N)
表示问题规模为N
时的时间复杂度a
表示子问题的数量N/b
表示每个子问题的规模O(N^d)
表示除去子问题外的其他操作所需的复杂度
计算方法如下(证明略):
- log_b(a) 表示以 b 为底 a 的对数
- 如果 log_b(a) > d, 则时间复杂度为
O(N^log_b(a))
- 如果 log_b(a) = d, 则时间复杂度为
O(N^d * logN)
- 如果 log_b(a) < d, 则时间复杂度为
O(N^d)
以前面的递归求数组最大值为例:
- 数组
arr
的长度就是问题规模N
- 子问题就是求解
leftMax
和rightMax
, 共有两个子问题, 所以 a = 2 - 因为子问题的问题规模是原本规模的
1/2
, 所以子问题规模为n/2
, 即 b = 2 - 除去子问题外, 剩下的就是一个
if
判断, 一个mid
值的计算和Math.max()
的计算, 所以O(N^d)
的时间复杂度是O(1)
, 即 d = 0 - 由于 log_b(a) = 1 > 0, 所以时间复杂度为 O(N^log_b(a)) =
O(N)
注意⚠️, 只有需要满足上面给定的递归关系的递归, 才能使用 master 公式求解复杂度。
还是以刚才的代码举例, 假设每次递归时, leftMax
选取的是 左侧 1/3 的内容, rightMax
选取的是右侧 2/3 的内容, 这种情况下, 一个子问题规模是 n/3
另一个子问题规模是 2n/3
这就不满足前面的递归关系, 所以无法使用 master 求解复杂度。
还是以刚才的代码举例, 假设每次递归时, 有三个子问题, 每个子问题规模为 1/3
, 即将数据划分为三份, 分别求取三份各自的最大值, 再比较三个最大值谁大。 这种情况下, 还是满足前面的递归关系的, 因为子问题规模相同。 只不过此时的 a = 3, b = 3。
🍕 归并排序
基本思路: 将数据分为两份, 然后依次对其中两份进行排序, 排序后再将这两份合并起来。 这是一个递归的过程。
老师代码:
public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
process(arr, 0, arr.length - 1);
}
public static void process(int[] arr, int L, int R) {
if (L == R) {
return;
}
int mid = L + ((R - L) >>1);
process(arr, L, mid); // 左侧排好序
process(arr, mid+1, R); // 右侧排好序
merge(arr, L, mid, R); // 将两侧合并
}
public static void merge(int[] arr, int L, int M, int R) {
int[] help = new int[R - L + 1]; // 创建一块备份空间, 用于存放合并后的数组
int i = 0; // 用于 help 的下标
int p1 = L; // 用于左侧的下标
int p2 = M + 1; // 用于右侧的下标
// 将左右两侧的内容存放到 help 数组中
while (p1 <= M && p2 <= R) {
help[i++] = arr[p1] <= arr[p2]? arr[p1++] : arr[p2++];
}
// 前面结束后, 左右两侧一定右一个时间到达边界, 一个未到达边界, 所以下面两个 while 只会执行其中一个
while (p1 <= M) { // 左侧未到达边界, 则直接将左侧剩下的数放到 help 中
help[i++] = arr[p1++];
}
while (p2 <= M) { // 右侧未到达边界, 则直接将右侧剩下的数放到 help 中
help[i++] = arr[p2++];
}
// 将排好序后的 help 数组复制到原数组中
for (i = 0; i < help.length; i++) {
arr[L+i] = help[i];
}
}
利用前面的 master 公式求解时间复杂度。
其中的 process
函数是一个递归函数, 它有两个子问题, 子问题规模是 N/2, 除去子问题外的时间复杂为主要由 merge
函数占用。 merge
函数不是递归函数, 求解时间复杂度比较简单, 时间复杂度很容易得出是 O(N)
。 所以列出递归关系为 2 * log_2(N) + O(N), 其中 a = 2, b = 2, d = 1, 满足 log_b(a) = d, 故时间复杂度为 O(N^d * logN) = O(N*logN)
🍕 归并排序比 选择,冒泡,插入排序 好在哪里
前面的三种排序(选择,冒泡和插入)的时间复杂度都是 O(N^2)
, 而归并排序的复杂度是 O(N*logN)
。 即归并排序将其中一个 N 优化成了 logN。
前面三类排序差就差在浪费了比较的机会, 每次比较的信息都直接被丢弃了, 比如选择排序, 第一次比较了 N-1 次, 结果就只排序好一个数, 然后又比较 N-2 次, 又只排序好一个数。 而归并排序保存了比较的信息, 归并排序在, 只在 merge
合并的过程中进行了比较, 并且这个比较的信息保留下来了, 具体体现在每次比较(merge)后的数组都是有序的。
主要注意的是, 前面给出的归并排序的空间复杂度是 O(N)
, 因为在 merge 的过程中定义了一个 help
临时数组用于存储排序后的内容。
🍕 归并排序变体 - 小和问题
在一个数组中, 将每一个数左边中比当前数小的所有数字累加起来,叫做这个数组的小和。 注意⚠️, 小和是只看数的左边, 而不是两边
举个例子说明: 有这么一个数组 [1,3,4,2,5],
- 1 左边没有比 1 小的数;
- 3 左边比 3 小的数有 1, 此时 小和 += 1;
- 4 左边比 4 小的数有 1,3, 此时 小和 += (1+3)
- 2 左边比 2 小的数有 1, 此时 小和 += 1;
- 5 坐标比 5 小的数有 1,3,4,2, 此时 小和 += (1+3+4+2)
- 故这个数组的小和为 16
暴力解法: 从左到右依次遍历每个数; 对于每个数, 遍历当前数的左侧, 将所有比当前数小的数相加起来求出小和。 这种方式的时间复杂度是 O(N^2)
正常流程是在左侧求比当前数小的数, 然后将他们加起来求小和; 但我们可以反过来: 遍历右侧查看有多少个数比当前数大, 然后将这个数量和当前数相乘得出小和。
这种方式的暴力解法为: 从左到右依次遍历每个数; 对于每个数, 遍历当前数的右侧, 统计有多少个数比当前数大, 然后将这个数量与当前数相乘得到小和。 时间复杂度依然是 O(N^2)
归并排序解决小和问题 - 过程描述
首先, 会先对数组进行递归分组, 如下所示:
[1,3,4,2,5]
/ \
[1,3,4] [2,5]
/ \ / \
[1,3] [4] [2] [5]
/ \
[1] [3]
然后自底向上依次排序合并, 在这个过程中求小和。
先求 小和[1] 和 小和[3], 它们的值都是 0。再求合并后的 小和[1,3] 的值:
定义左右两个指针, 分别指向 左侧[1] 和 右侧[3]。 判断左侧指针所指的数是否小于右侧指针所指的数, 如果左侧小于右侧, 则直接通过右侧指针和右侧数组的长度, 求出右侧右多少个数比左侧大。 (这里就是关键, 因为不需要遍历右侧数组, 而是直接一步到位求出右侧比当前数大的数量)
如果右侧先到达边界, 说明左侧中剩余的数都大于右侧的数, 此时他们的小和都是 0 。 如果左侧先到达边界, 此时已经将左侧中所有数的小和已经求取完毕。
求 小和[1,3] 如下所示:
[1,3] return 0 + 0 + 1*1 = 1
/ \
[1] [3] return 0 ; return 0
求 小和[4], 值为 0。 再求合并后的 小和[1,3,4] 的值, 具体步骤和上面一样, 小和[1,3,4] 的值如下所示:
[1,3,4] return 1 + 0 + (1*1 + 3*1) = 5
/ \
[1,3] [4] return 1 ; return 0
/ \
[1] [3]
接下来 [1,3,4] 需要和右侧 [2,5] 合并, 求 小和[1,3,4,2,5] 的值, 所以需要先求 小和[2,5] 的值
小和[2] 和 小和[5] 的值都是 0。 再求 小和[2,5] 的值, 如下所示:
[2,5] return 0 + 0 + (2*1) = 2
/ \
[2] [5] return 0 ; return 0
现在可以求 [1,3,4] 和 [2,5] 合并后的 小和[1,3,4,2,5] 的值了, 如下所示:
[1,2,3,4,5] return 5 + 2 + (1*2 + 3*1 + 4*1) = 16
/ \
[1,3,4] [2,5] return 5 ; return 2
如何保证小和不重不漏?
归并排序求小和问题能够保证求解出的小和不会重复也不会遗漏, 具体看下面案例说明:
假设数组为 [a,b,c,d,e,f], 它的划分如下:
[a,b,c,d,e,f]
/ \
[a,b,c] [d,e,f]
/ \ / \
[a,b] [c] [d,e] [f]
/ \ / \
[a] [b] [d] [e]
对于数组 [a,b,c,d,e,f], 我们求小和的目的就是找出每一个数字的 右边 拥有的大于该数的数量, 然后将这个数量与该数相乘得到一个结果(result), 然后将所有数的结果相加, 就是这个数组的小和。
我们使用归并排序计算小和的过程中, 每次求取的数的范围是从左到右慢慢变大的, 并且这个范围不会重叠, 也不会漏选。
比如, 对于数字 a, 先求他在右侧范围 [b] 下的小和, 然后再求它在右侧范围 [c] 下的小和, 最后求他在右侧范围 [d,e,f] 下的小和。 可以看出, 它的所有右侧范围不会有重叠的地方, 同时这些范围也包含了所有的右侧数。
小和问题老师代码:
public static void smallSum(int[] arr) {
if (arr == null || arr.length < 2) {
return 0;
}
return process(arr, 0, arr.length - 1);
}
// 处理 [L, ..., R] 过程中, 既要排好序, 也要求小和
public static void process(int[] arr, int L, int R) {
if (L == R) {
return 0;
}
int mid = L + ((R - L) >>1);
// 注意左右是相对而言的, 在左侧里面同样也有左右之分, 在右侧中同样也有左右之分
return process(arr, L, mid) // 左侧排好序, 并且求左侧的小和
+ process(arr, mid+1, R) // 右侧排好序, 并且求右侧的小和
+ merge(arr, L, mid, R); // 两侧合并排好序后, 求小和
}
public static void merge(int[] arr, int L, int M, int R) {
int[] help = new int[R - L + 1];
int i = 0;
int p1 = L;
int p2 = M + 1;
int res = 0
while (p1 <= M && p2 <= R) {
// 排序前, 先求小和
res += arr[p1] < arr[p2]
? (R - p2 + 1) * arr[p1] // 小和 = 左组大于当前数的数量 * 右组当前数; arr[p1] 是右组的当前数
: 0 ;
// 注意这里, 左右两组相等时, 有限拷贝左组, 即先移动左组的指针
help[i++] = arr[p1] < arr[p2]? arr[p1++] : arr[p2++];
}
// 前面 while 已经求出右组中的所有数在当前 arr 下能产生的小和总数了, 后面内容只是用于排序
while (p1 <= M) {
help[i++] = arr[p1++];
}
while (p2 <= M) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[L+i] = help[i];
}
return res
}
注意:
- 排序步骤不能少, 因为排序后, 才能够直接计算出右组中有几个数比左组当前数的大, 没有排序的话, 就需要遍历, 这个时候复杂度就不是 O(1) 了
- 当下标指向的左右两组的数值相等时, 一定是先将右组的内容拷贝到
help
数组中, 即移动右组的下标, 左组下标不动。
🍕 快排前菜 - 荷兰国旗问题
问题 1 - 两项划分(Two-Way Partitioning)
问题一: 给定一个数组 arr, 和一个数 num, 请把小于等于 num 的数放在数组的左边, 大于 num 的数放在数组的右边。 要求额外空间复杂度 O(1), 时间复杂度 O(N)
基本思路:
- 利用一个变量 l 来指定 "≤ num 区域" 的边界, l 初始值为 -1, 代表 "≤ num 区域" 为空。
- 遍历数组, 当数值 ≤ num 时, 将该数和 [l+1] 位置的数交换, 然后 l++, 表示 "≤ num 区域" 变大。
- 当数值 > num 时, 不做处理。 但指针还是会继续指向下一个, 即循环会继续。
原理: 创建一个 "≤ num 区域", 遍历数组时, 确保 ≤ num 的数字能够被划入 "≤ num 区域" 内, 而 > num 的数字能够被排除的 "≤ num 区域" 外面
举个🌰: 数组 [3,5,6,7,4,3,8], num 为 5
初始: l = -1
━━┓
≤ ┃ 3 5 6 7 4 3 8
━━┛ ↑
识别到 3 ≤ 5, 交换当前数, 并且 l++, 指针指向下一个
━━┓
3 ≤ ┃ 5 6 7 4 3 8
━━┛ ↑
识别到 5 ≤ 5, 交换当前数, 并且 l++, 指针指向下一个
━━┓
3 5 ≤ ┃ 6 7 4 3 8
━━┛ ↑
识别到 6 > 5, 指针指向下一个
━━┓
3 5 ≤ ┃ 6 7 4 3 8
━━┛ ↑
识别到 7 > 5, 指针指向下一个
━━┓
3 5 ≤ ┃ 6 7 4 3 8
━━┛ ↑
识别到 4 ≤ 5, 交换当前数, 并且 l++, 指针指向下一个
━━┓
3 5 4 ≤ ┃ 7 6 3 8
━━┛ ↑
识别到 3 ≤ 5, 交换当前数, 并且 l++, 指针指向下一个
━━┓
3 5 4 3 ≤ ┃ 6 7 8
━━┛ ↑
识别到 8 > 5, 指针指向下一个, 发现到达边界, 故退出
问题 2 - 三项划分(Three-Way Partitioning)
问题二: 给定一个数组 arr, 和一个数 num, 请把小于 num 的数放在数组的左边, 等于 num 的数放在数组的中间, 大于 num 的数放在数组的右边。要求额外空间复杂度 O(1), 时间复杂度 O(N)
基本思路:
- 利用一个变量 l 来指定 "< num 区域" 的边界, l 初始值为 -1, 代表 "< num 区域" 为空。
- 利用一个变量 r 来指定 "> num 区域" 的边界, r 初始值为 数组长度, 同样表示 "> num 区域" 为空。
- 遍历数组, 当数值 < num 时, 当前数和 [l+1] 位置的数交换, 表示 "< num 区域" 变大, 同时指针指向下一个数
- 当数值 = num 时, 指针直接指向下一个
- 当数值 > num 时, 当前数和 [r-1] 位置的数交换, 表示 "> num 区域" 变大, 此时指针不动, 因为还没有处理当前指针位置上出现的新的数值
原理: 和问题 1 差不多, 只不过这里定义了两个区域来保存数, 遍历数组, 将符号条件的数划入对应区域即可
举个🌰: 数组 [3,5,6,7,4,3,8], num 为 5
初始: l = -1
━━┓ ┏━━
< ┃ 3 5 6 7 4 3 8 ┃ >
━━┛ ↑ ┗━━
识别到 3 < 5, l++, 然后交换当前数到 [l], 指针指向下一个
━━┓ ┏━━
3 < ┃ 5 6 7 4 3 8 ┃ >
━━┛ ↑ ┗━━
识别到 5 = 5, 指针指向下一个
━━┓ ┏━━
3 < ┃ 5 6 7 4 3 8 ┃ >
━━┛ ↑ ┗━━
识别到 6 > 5, r--, 然后交换当前数到 [r], 指针不动
━━┓ ┏━━
3 < ┃ 5 8 7 4 3 ┃ > 6
━━┛ ↑ ┗━━
识别到 8 > 5, r--, 然后交换当前数到 [r], 指针不动
━━┓ ┏━━
3 < ┃ 5 3 7 4 ┃ > 8 6
━━┛ ↑ ┗━━
识别到 3 < 5, l++, 然后交换当前数到 [l], 指针指向下一个
━━┓ ┏━━
3 3 < ┃ 5 7 4 ┃ > 8 6
━━┛ ↑ ┗━━
识别到 7 > 5, r--, 然后交换当前数到 [r], 指针不动
━━┓ ┏━━
3 3 < ┃ 5 4 ┃ > 7 8 6
━━┛ ↑ ┗━━
识别到 4 < 5, l++, 然后交换当前数到 [l], 指针指向下一个
━━┓ ┏━━
3 3 4 < ┃ 5 ┃ > 7 8 6
━━┛ ┗━━ ↑
指针进入 "> num 区域", 结束
🍕 快速排序 1.0 版本
基本思路:
- 取数组最后一个数作为 num, 将前面的所有元素作为 arr,
- 然后执行前面的荷兰国旗问题一, 将数组划分两部分,
- 将 num 和分界点位置的值交换, 此时能够保证 num 的位置是正确的
- 对划分后的两部分, 对他们继续之前上述步骤, 即递归执行, 直到所有值都排好序
原理: 利用前面的荷兰国旗问题, 不断递归, 每次递归都能保证有一个数的位置是正确的。
🍕 快速排序 2.0 版本
2.0 版本和 1.0 版本类似, 区别只在于 1.0 版本执行的是两项划分, 而 2.0 版本执行的是三项划分。 三项划分情况下, 每次递归可能能够将一批相等数据排好序, 所以顺序稍微快点。
快排中, 每次划分的过程, 叫做 partition。 最坏的情况, 每次 partition, 划分值都在两侧, 时间复杂度是 O(N^2)
。 最好的情况, 每次 partition, 划分值都在中间, 这种情况下满足 master 算法, 递归关系为 T(N)=2*T(N/2)+O(N), 所以复杂度是 O(N*log(N)
🍕 快速排序 3.0 版本
快排 1.0 和 2.0 两个版本, 时间复杂度都是 O(N^2)
, 因为每次都是拿最后一个数进行划分, 所以很容易掉入最坏情况。
而快排 3.0 版本, 和前面的区别只在于随机选取一个划分数, 因为是随机选取, 所以在数学上, 各种情况, 包括最好的情况和最坏的情况出现的概率都是相同的。 比如, 可能选取的划分数是在最边边的, 也可能是在 1/5 的位置, 也可能是在 1/3 的位置, 也可能是在 1/2 的位置。 总之, 划分数在每个位置上都是等概率事件, 对所有的情况的概率和复杂度累加, 求数学期望, 得出的复杂度是 O(N*log(N))
。 (具体证明过程略)
老师代码:
public static void runQuickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int L, int R) {
if (L < R) {
// 等概率随机取一个数, 将它和最后位置进行交换
swap(arr, L + (int)(Math.random() * (R - L + 1)), R);
// 对 L, R 进行划分, 返回的是划分后的边界
int[] p = partition(arr, L, R);
// p[0] 是 "< 区"的右边界, p[1] 是 "> 区"的左边界
quickSort(arr, L, p[0] - 1); // "< 区"
quickSort(arr, p[1] - 1, R); // "> 区"
}
}
// 三项划分
public static int[] partition(int[] arr, int L, int R) {
int less = L - 1; // "< 区" 的右边界; 即 (..., less]
int more = R; // "> 区" 的左边界; 即 [more, ...); 注意, 最后位置上的值 [R] 是划分数, 不被认为是 "< 区" 的内容
while (L < more) {
if (arr[L] < arr[R]) { // 当前值 < 划分值
swap(arr, ++less, L++);
} else if (arr[L] > arr[R]) { // 当前值 > 划分值
swap(arr, --more, L);
} else {
L++;
}
}
// 将划分值放到正确的位置上
swap(arr, more, R);
return new int[] {less+1, more};
}