递归
递归的重点是识别出来,哪些问题可以通过递归来解决。
看以下的场景:
- 查找最终推荐用户。A -> B -> C -> D -> E ,除了A以外 其他所有的最终推荐用户均为A。如果要查找一个用户的最终推荐用户,我们保留有推荐关系,此时可以很容易想到使用递归方式解决。
- 查找根节点。与最终推荐用户一样,可以直接考虑用递归实现。
- 假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?这种问题一开始很难想到用递归去解决。
为什么问题3很难想到用递归呢?
探究其本质,问题1,2 都较为简单,只能拆分为一个子问题。而问题3则可以拆分为多个子问题。通常我们在拆分为多个问题时,就很难去理解递归了。
递归的特点
- 拆分。一个问题可以被拆分为多个问题(不局限与一个)。
- 一致。问题拆分后的求解思路与拆分前思路一致。
- 终止。有终止条件。
如何写好递归
如何写出来
回想一下每次写递归代码前,总是会下意识的在脑子中执行一遍递归操作。
对于问题1.2来说,思路简单,大脑很容易就能执行完成,写好递归代码并不难。
但是对于问题3来说,期望大脑一层一层往下执行,做完所有的递、归操作,一开始便陷入了误区。由于问题3较为复杂,所以这个过程往往会很痛苦。
如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,你只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
避免堆栈溢出
递归最大的问题就是可能会堆栈溢出。
试想,每次递归都在执行入栈操作,JVM内存栈一般不会设置很大,如果调用层次很深的话,就会有溢出风险。
如何避免呢?可以考虑限制深度,不过一般不太适用,因为栈溢出跟当前线程栈剩余空间有关,有的线程多有的少,无法实时评估,风险仍然存在。
避免重复计算
如问题3,该问题在计算时便存在重复计算场景。比如 5 可以拆分为 4 ,3 。4 继续拆分为 3 ,2 此时 3的计算便已经重复了。
改为非递归代码
既然递归存在各种风险,那么为什么不直接考虑不用递归呢?
public class StepTest {
/**
* 假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
* 如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2
* 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?
*/
public static void main(String[] args) {
System.out.println(calculate(7));
System.out.println(calculate2(7));
}
private static int calculate2(int i) {
if (i == 2) {
return 2;
}
if (i == 1) {
return 1;
}
int result = 0;
int pre1 = 2;
int pre2 = 1;
for (int j = 3; j <= i; j++) {
result = pre1 + pre2;
pre2 = pre1;
pre1 = result;
}
return result;
}
private static int calculate(int i) {
if (i == 2) {
return 2;
}
if (i == 1) {
return 1;
}
return calculate(i - 1) + calculate(i - 2);
}
}
如上代码,改为非递归后,重复计算逻辑不存在了。
是不是所有递归都适合改为非递归呢?
笼统的说确实是可以的。不过改为递归后并没有办法避免堆栈溢出,本质还是深度遍历。而且发现,写起来也复杂的不少。如果单纯只是少一些重复计算,那么在递归中也是可以避免的。没有必要非得采用循环的方式。
排序
插入排序(On²)
从首位开始,后续数据使用有序新增方式,挨个加入数组。
重点(从小到大排序)
-
如果新增节点 > 当前最后一个节点,直接新增至末尾。结束
-
如果新增节点 < 当前最后一个节点,表示需要往前插入。
- 此时需要移动当前最后一个节点至末尾,然后继续往前查找。
- 直到找到新增节点 > 前一个节点,此时可以直接赋值。结束
-
极端情况,新增数据比数组最小的数据都要小。没有办法满足 新增节点 > 前一个节点条件。此时,需要判断如果已经到了起始节点,直接赋值即可。
/**
* 插入排序
*
* 从下标为1处开始,每次往数组 按顺序 新增一个数据
*/
private static void insert(int[] arr) {
for (int i = 1; i < arr.length; i++) {
insertEle(arr, i, arr[i]);
}
print(arr);
}
/**
* 采用尾插法 判断前一个数据 如果比当前数据小 就直接赋值
* 如果比当前数据大 移动数据 继续对比, 如果直到最后一位 还是比新增数据小 那就直接新增到 第0位
*
* @param count 当前有序数据数量
* @param item 新增数据
*/
private static void insertEle(int[] arr, int count, int item) {
for (int i = count; i > 0; i--) {
// 这里一定要对比 item
if (arr[i - 1] < item) {
// 这里必须要赋值
arr[i] = item;
// 新增完就没有必要 继续循环了 需要跳出
break;
}
// 交换位置
arr[i] = arr[i - 1];
// 最后判断是否 要赋值0位
if (i == 1) {
arr[0] = item;
}
}
}
上方的新增方式略微繁琐。看下方代码:
private static void insertEle(int[] arr, int count, int item) {
int i = count;
for (; i > 0; i--) {
// 判断是否需要交换
if (arr[i - 1] > item) {
arr[i] = arr[i - 1];
} else {
break;
}
}
// 赋值
arr[i] = item;
}
思考
为什么一个写的比较复杂?
没有看透问题本质,对于该问题来说,只有两种情况,交换/赋值。
冒泡排序(On²)
两两交换,将最大值 / 最小值 移动至末尾。
外层循环控制有序数量。
内层循环进行比对,交换,将最大/最小移动到最后。
private static int[] maopao(int[] arr) {
// 可以通过变量 判断是否有变化, 没有变化可以提前结束
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
print(arr);
return arr;
}
为什么冒泡排序没有插入排序更受欢迎?
冒泡排序与插入排序都是源地排序、稳定排序,时间复杂度也都是O(n²)。
插入排序的主要优点就是只有一次赋值操作,节省空间。
选择排序(On²)
选择排序属于源地排序,不需要额外的数组空间。
但是选择排序并非是稳定排序。主要靠数据两两交换来排序。比如数据:3,6,3,1 排序后 第一个3 会被换到最后一个3后边。
挨个遍历选择 最大/最小 值
private static int[] maopao(int[] arr) {
// 可以通过变量 判断是否有变化, 没有变化可以提前结束
for (int i = 0; i < arr.length; i++) {
for (int j = i; j < arr.length; j++) {
if (arr[i] < arr[j]) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
print(arr);
return arr;
}
归并排序(Onlogn)
归并排序 本质属于递归。
思想主要是分制,将数组拆分,然后两两合并。
/**
* 1. 根据递归的思想,确认问题拆分步骤。要对数组排队,先拆分数组 —》 排序合并,
* 2. 终止条件为: 数组元素为 < 2 没有办法继续拆分,
* 3. 无法拆分时即可执行 合并。
*
* @param arr 数组
* @param start 起始位置(方便拆分)
* @param end 终止位置(方便拆分)
*/
private static void guibing(int[] arr, int start, int end) {
if (start >= end) {
return;
}
// 该方式避免 数据过大时 超限
int mid = (end - start) / 2 + start;
guibing(arr, start, mid);
// 这里记得 + 1
guibing(arr, mid + 1, end);
mergeArray(arr, start, mid, end);
}
/**
* 排序 合并
* 1. 需要考虑 拆分为两个时 只需要比对两个即可
*
* @param mid 用于区分两个数组
*/
private static void mergeArray(int[] arr, int start, int mid, int end) {
// 这里记得 + 1 表示 数组长度
int[] temp = new int[end - start + 1];
// 这里 需要使用临时变量, start -> mid, mid + 1 -> end ,
int mid2 = mid + 1;
// 这里 需要使用临时变量。 start 变量后续要用于 copy数组
int startTemp = start;
int i = 0;
while (startTemp <= mid && mid2 <= end) {
if (arr[startTemp] < arr[mid2]) {
temp[i++] = arr[startTemp++];
} else {
temp[i++] = arr[mid2++];
}
}
while (startTemp <= mid) {
temp[i++] = arr[startTemp++];
}
while (mid2 <= end) {
temp[i++] = arr[mid2++];
}
i = 0;
for (int j = start; j <= end; j++) {
arr[j] = temp[i++];
}
}
快速排序(Onlogn)
快速排序本质也是利用分制思想,将排序任务拆分。
思路:
- 采用递归方式,思考拆分以及终止条件。
- 先找任一一个数据作为中间位。(通常可以用最后一位,遍历以及最后一步赋值比较方便)
- 然后遍历数组,将小于中间位的移动到左边,大于中间位的移动到右边。
- 然后将左、右两侧递归,继续拆分、继续排序。
步骤:
- 找到终止递归条件,也就是终止拆分条件。start >= end 只剩下一个时就没有必要拆分了。
- 用最后一位作为中间位。
- 遍历判断,简单写法可以定义临时空间存储排序结果,最后覆盖原有数组。
- 拆分前半部分,调用递归
- 拆分后半部分,调用递归
快排不占用额外的空间,也不属于稳定排序。在极端情况下也是O(n²)的复杂度。那么如何优化快排?
三位取中/随机
private static void quick(int[] arr, int start, int end) {
// 终止条件
if (start >= end) {
return;
}
// 找到对比的值
int mid = arr[end];
// 启动临时缓存(启用了额外的空间,此时便不是源地排序)
int[] temp = new int[end - start + 1];
// 记录 头尾新增情况
int i = 0;
int j = temp.length - 1;
// 挨个遍历数组,小于比对值 放左边, 大于比对值方右边
for (int k = start; k < end; k++) {
if (arr[k] <= mid) {
temp[i++] = arr[k];
} else {
temp[j--] = arr[k];
}
}
// 比对值 放中间
temp[i] = mid;
// 将排序好的 数组赋值给 原数组
i = 0;
for (int k = start; k < end; k++) {
arr[k] = temp[i++];
}
// 拆分继续排序
quick(arr, start, i - 1);
// 拆分继续排序
quick(arr, i+1, end);
}
以上情况并不属于源地排序,对于空间复杂度来说相对较高,但是写起来很方便。那么如果要求源地排序应该如何做呢?
思路:
使用快慢指针。
开始遍历后:判断快指针位置值如果当前值大于比对值,慢指针不动(表示这个位置数据不符合要求,需要移动),快指针继续走,结束本次循环。下次循环,继续判断快指针位置值是否大于比对值,如果不大于,并且此时快慢指针不相等,表示需要交换位置。
最终,由于我们取最后一位作为比对值,而比对值是需要放在中间位置的,也就是遍历完以后慢指针的位置。因此,最后需要交换慢指针位置与结束位置数据。
private static void quick2(int[] arr, int start, int end) {
if (start >= end) {
return;
}
int mid = arr[end];
int slow = start;
int fast = start;
for (; fast < end; fast++) {
// 只有发现比 中间数小 才需要交换
if (arr[fast] < mid) {
if (fast != slow) {
int temp = arr[fast];
arr[fast] = arr[slow];
arr[slow] = temp;
}
slow++;
}
}
arr[end] = arr[slow];
arr[slow] = mid;
quick2(arr, start, slow - 1);
quick2(arr, slow + 1, end);
}
备注:
不要陷入误区,对于快速排序而言,我要保证的是相对有序,开始排序范围较大时,左边小不意味着左边都要有序,快排的有序是不断的缩小范围,保证有序,进而达到最终的有序。
因此为了保证源地排序,可以使用快慢指针(双指针)方式来实现。
最后,快慢指针的思想主要可用于数据交换,在不要求顺序的场景里可以使用。比如过滤掉重复数据(1112333)-》(1,2,3)。
与归并排序相比,不同点有:
-
归并是先拆分,后排序合并。快排是边拆分边排序。
- 归并先将任务拆分到最小,然后挨个合并,直到最终。
- 快排虽然也是先拆分,但是拆分后就立刻排序,然后再拆分,再排序。
案例:
快速求无需数组第K大的值。如何做?
桶排序(On)
思路:
将数据根据范围进行快速的拆分,完成后再拆分的结果中使用快速排序。
为什么不直接用快排呢?
这个思路的场景适合于大量数据的排序,并且排序数据有一定的规则。
比如:
- 快速对100W人基于年龄进行排序。我们可以直接分为10个桶(0~10),不同年龄放在不同的桶里,分完后,对桶里数据进行快排,再合并。甚至可以直接分100个桶
- 如果在内容有限(百兆)情况下,要求你对10G订单数按照价格据进行排序,此时也可以考虑使用桶排序。先查询数据范围,发现金额是在 1
10W,然后,按照1000为单位拆分,如果某个区间数据过多,继续按照0100拆分,保证内存用量最小。
计数排序(On)
思路:
计数排序其实就是桶排序的极端情况。数据范围可控,且较小。
比如,人的年龄,0120。高考分数:0900。
如何查看自己高考分在省内、全国的排名呢?
此时就是直接分为901个桶,每个桶统计数量,比如考0分有0人,考1分的有10人,那么考1分的肯定全国倒数第1,考两分的就是全国倒数第11。
查找
二分查找(Ologn)
二分查找对数据要求较高。主要有三点
- 要求数据是数组结构。(随机访问效率高)
- 要求数据有序。
- 对数据量有要求。太小、太大都不合适。太小直接遍历就OK。太大,数组要求占用连续内存空间,消耗太大。
二分的思想就不多介绍,直接上代码即可。
private static int binarySearch(int[] arr, int num) {
int left = 0;
int right = arr.length;
int mid = 0;
while (left <= right) {
mid = (right - left) / 2 + left;
if (arr[mid] == num) {
return mid;
}
if (arr[mid] > num) {
right = mid - 1;
} else {
left = mid + 1;
}
}
System.out.println("mid = " + mid);
System.out.println("left = " + left);
System.out.println("right = " + right);
return -1;
}
备注:
此处需要注意的点有两个。
- mid的取值。如果要使用位运算,切记要写为 ((right-left) >> 1)) + left。
包含重复数据的二分查找
查询最后一个数据。
/**
* 查询首个 或者最后一个 有几点需要注意
* 1. 在匹配到数据后,不能直接结束,需要继续查找。
* 2. 如果查询的是首个。
* 1. 由于循环结束条件是 left > right, 所以此时 需要 操作 right - 1 达成此条件,并返回 left
* 原因解析:由于 left 表示左侧值, 首个值肯定是最左侧的值,
* 因此,一定是左侧值先到位(考虑极端情况 下标 0 即为目标数据时)
* 因此,我们需要在等于 目标值时 扔去操作 right 值,直到 right 小于 left, 最终返回 left
* 3. 如果查询的是末尾
* 1. 同理可得。要找末尾时, 必须要在等于目标值时,扔操作 left, 最终 返回 right
*
*/
private static int searchReapetLast(int[] arr, int value) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (arr[mid] > value) {
right = mid - 1;
} else {
left = mid + 1;
}
if (left == right) {
System.out.println("true = " + mid + " left = " + left);
}
}
if (arr[right] == value) {
return right;
}
return -1;
}
以上实现可能不太好理解。如果直接看下边这种就容易许多。
private static int searchReapetLast(int[] arr, int value) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (arr[mid] > value) {
right = mid - 1;
} else if (arr[mid] < value) {
left = mid + 1;
} else {
if ((right == arr.length - 1) || arr[right] + 1 != value) {
return mid
} else {
left = mid + 1;
}
}
}
return -1;
}
跳表
二分查找是适用与数组的快速查询。跳表是适用于链表的快速查询。
跳表简单来说就是通过建立上级索引的方式来达到快速查询的目的。
1 5 9
1 3 5 7 9
1 2 3 4 5 6 7 8 9