递归
概念&特性
-
去问问,回来告诉我称之为递归。去的过程为 “递”,回来的过程为 "归"
-
三个条件
- 待解决问题的解,可以分为几个子问题的解
- 待求解问题与分解之后的子问题,只有数据规模不同,求解思路完全相同
- 存在递归终止条件
怎么写递归代码
主要是写递推公式,再推敲终止条件,多练吧年轻人!
写递归的注意事项
千万别试图弄清整个递和归的过程,你的小脑袋可没有那么深的栈。
-
警惕递归代码出现堆栈溢出
限制递归调用的最大深度
-
警惕递归代码重复计算
有求解好的子问题答案,咱就别再算一遍了,存起来。做一个缓存
递归代码写成非递归代码
递归就是栈,手动模拟入栈,出栈过程,那么任何递归代码都可以改成看上去不是递归代码的样子。🦆 不必!
尾递归了解
用它来干什么呢?避免堆栈溢出?excuse me? 了解了解就行了,没啥卵用。
-
明确俩个问题
- 什么样的代码才能改写成尾递归
- 尾递归真的能避免堆栈溢出吗
-
为啥会堆栈溢出?
函数调用采用函数调用栈来保存现场(局部变量,返回地址等)。函数调用栈是在内存中开辟的一块存储空间。它被组织成“栈”这种数据结构,数据先进后出。
递归的过程包含大量的函数调用。如果递归求解的数据规模很大,函数调用层次很深,那么函数调用栈中的数据(栈帧)会越来越多,而函数调用栈空间一般很小。这个时候就会存在堆栈溢出的风险。 -
能写成尾递归代码的特点
- 递归调用出现在函数的最后一行
- 没有任何局部变量参与最后一行的代码
-
举个例子呗
// 正常递归
function fn(n: number) {
if(n <= 1>) return 1;
return n * f(n - 1)
}
// 尾递归
function fn(n: number, res: number) {
if(n <= 1) return res;
return f(n -1, n * res)
}
-
尾递归真能避免堆栈溢出吗?
- 并不是所用的编程语言都支持尾递归优化
- 并不是所有的递归都可以改成尾递归
- 能改成尾递归的代码都可以改成迭代
- 尾递归的代码可读性差
排序
我们怎么分析一个排序算法呢
-
最好时间复杂度,最坏时间复杂度,平均时间复杂度
-
时间复杂度的系数,常数,低阶
-
比较次数,和交换(移动次数)
-
内存消耗,是原地排序还是非原地排序
-
排序算法是否稳定
冒泡排序
-
思想
和相邻元素进行比较,如果不满足大小关系,就交换。每次交换可以归位一个元素,n次交换后达到完全排序。
-
改进
如果一次扫描过程中,没有发生交换。则数据已完全排序。根据此可以设置 flag
-
实现
插入排序
-
思想
将数组分为两个区间,已排序区间和未排序区间。取未排序区间的元素,在已排序区间中找到合适的位置插入。
选择排序
-
思想
将数组分为两个区间,已排序区间和未排序区间。取未排序区间内最小的元素,将其放到已排序元素的末尾。
插入排序优于冒泡排序
插入排序的交换操作只需要一个赋值语句,而冒泡排序需要三个。
归并排序
-
思想
使用分治思想。将长排序划分为一个个短的排序, 使短的排序内部完整排序, 然后再将短排序进行合并排序。可使用递归实现。
流程: 取中间位 --> 分治递归 --> 合并
合并的思想:使用临时数组存比较数组中的大小关系
-
难点:递推公式,分析时间复杂度
合并的时间复杂度是 O(n)
-
归并排序在任何情况下时间复杂度能达到
-
致命弱点:非原地排序。空间复杂度是 O(n)
快速排序
-
思想
使用分治思想。先进行分区,区与区之间保证有序。区内再分区,依旧保证再分的区之间有序。一直分下去,直到只有一个元素时,则完成数组完整排序。
-
难点:递推公式,分区函数
-
巧点
- 实现原地分区
- 三数去中法,随机法
-
性能分析
快速排序的时间复杂度可能退化为
空间复杂度为 O(logn), 可能退化为 O(n)
归并排序和快速排序
归并排序和快速排序都是稍微复杂的排序算法,他们都利用了分治算法的思想,代码通过递归实现。理解归并排序的关键是理解递推公式和合并函数。理解快速排序的关键是理解递推公式和分区函数。
归并排序是一种在任何时候时间复杂度都比较稳定的排序算法,这也使他存在致命的缺点,即归并排序不是原地排序,空间复杂度较高,是O(n)。正因如此,它没有快速排序应用广泛。
虽然快速排序在最坏情况下的时间复杂度是 , 但平均时间复杂度是O(nlogn)。不仅如此,快速排序的时间复杂度退化到 的概率非常小,我们可以通过有选择的 pivot 来避免极端情况的发生。
桶排序
-
思想
先定义几个有序的桶, 每个桶有数据范围这个一个属性, 将要排序的数据, 对应桶的数据范围入桶。桶内数据再使用快速排序进行排序。然后再将桶内数据按照顺序依次取出。
-
适用场景
桶排序适合用在外部排序。所谓外部排序就是数据存储在外部磁盘中,数据量比较大,而内存有限,无法将数据完全加载到内存中处理。
-
极端情况下会退化为 O(nlogn)
计数排序
-
思想
桶排序的一种特殊情况。一个值一个桶, 桶有 value 属性,要排序的数组,对应桶的value入桶(每个桶内数据值相等)。再取出来,就是已经完整排序的数据了。
-
理解计数排序中计数的含义
-
难点
-
适用场景
数据范围小,计数排序只能给非负整数排序。
基数排序
-
思想
利用数据的位数, 如果数据A靠前的位数已经比数据B靠前的位数大了,后面的位数就灭必要比较了。每一位借助桶排序或计数排序完成每一位的排序工作。
-
适用场景
数据可以划分为高低位,位之间有递进关系。每一位的数据范围不能太大
-
特殊的转化
位数不够补 0
几种排序的整体分析
| 排序算法 | 是否原地排序 | 是否稳定排序 | 平均时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 冒泡排序 | 是 | 是 | ||
| 插入排序 | 是 | 是 | ||
| 选择排序 | 是 | 否 | ||
| 归并排序 | 否 | 是 | ||
| 快速排序 | 是 | 否 | ||
| 桶排序 | 否 | 是 | (k为数据范围) | -- |
| 计数排序 | 否 | 是 | -- | |
| 基数排序 | 否 | 是 | (d为数据维度) | -- |
排序的优化
-
利用上述排序的特点有取舍的选择排序算法。必要时可以同时使用。
-
实际的软件开发中,排序算法是原地排序算法是一个非常重要的选择标准。尤其是处理大数据量的排序。
-
对于使用递归的排序算法,要注意函数调用深度。
查找 --> 二分查找
思想
利用已排序的数组特点,进行折半查找
速度惊人
O(logn)的惊人查找速度
- 对数运算是指数运算的逆运算,指数曲线多恐怖,反过来对数曲线就多柔和
实现
- 递归实现
function bsearch(a: Array<number>, n: number, val: number): number {
return bsearchInternally(a, 0, n-1, val)
}
function bsearchInternally(a: Array<number>, low: number, high: number, value: number) {
if(low > high) return -1
const mid = low + ((high - low) >> 1)
if(a[mid] === value) {
return a[mid]
} else if(a[mid] < value) {
return bsearchInternally(a, mid + 1, high, value)
} else {
return bsearchInternally(a, low, mid - 1, value)
}
}
- 非递归实现
function bsearch(a: Array<number>, n: number, value: number): number {
let low = 0
let high = n - 1
while(low < high>) {
let mid = (low + high) / 2
if(a[mid] === value) {
return mid
} else if(a[mid] < value) {
low = mid + 1
} else {
high = mid - 1
}
}
return -1
}
- 三个容易出错的地方
-
循环退出条件是 , 而不是
-
mid 计算公式
实际上, 这种写法是有问题的。如果 low 和 high 值比较大,两个数之和就可能超过整数数据类型表示的最大范围。为了避免这个问题,我们可以将 mid 的计算公式改为 。除此之外,如果想要将性能优化到极致,那么我们可以将公式里的除法运算改为位运算: 。
-
low 和 high 的更新
low = mid + 1, high = mid - 1。注意这里的 “+1” 和 “-1”,如果直接写成 low = mid,或者 high = mid,就有可能出现死循环。
二分查找的局限性
-
二分查找依赖数组这种结构的下标操作,链表的化时间复杂度就变高了。
-
二分查找针对静态有序数据。二分查找只能用在插入、删除操作不频繁,一次排序,多次查找的场景中。
-
数据量适中的查找中。太小了顺序遍历就够了。太大了就不适合用数组这种需要连续内存的数据结构了。
🔥难点:二分查找的变体
10个二分9个错。尽管第一个二分查找算法再1946年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现。
- 变体1:查找第一个值等于给定值的元素
function bsearch (a: Array<number>, n: number, value: number): number{
let low = 0
let high = n - 1
while(low <= high) {
let mid = low + ((high - low) >> 1)
if(a[mid] > value) {
high = mid - 1
} else if(a[mid] < value) {
low = mid + 1
} else {
if((mid === 0) || (a[mid - 1] !== value)){
return mid
} else {
high = mid - 1
}
}
}
return - 1
}
- 变体2:查找最后一个值等于给定值的元素
function bsearch (a: Array<number>, n: number, value: number): number{
let low = 0
let high = n - 1
while(low <= high) {
let mid = low + ((high - low) >> 1)
if(a[mid] > value) {
high = mid - 1
} else if(a[mid] < value) {
low = mid + 1
} else {
if((mid !== n - 1) || (a[mid + 1] !== value)){
return mid
} else {
low = mid + 1
}
}
}
return - 1
}
- 变体3:查找第一个值大于或等于给定值的元素
function bsearch (a: Array<number>, n: number, value: number): number{
let low = 0
let high = n - 1
while(low <= high) {
let mid = low + ((high - low) >> 1)
if(a[mid] >= value) {
if((mid === 0) || (a[mid - 1] < value)) {
return mid
} else {
high = mid - 1
}
} else {
low = mid + 1
}
}
return -1
}
- 变体4: 查找最后一个值小于等于给定值的元素
function bsearch (a: Array<number>, n: number, value: number): number{
let low = 0
let high = n - 1
while(low <= high) {
let mid = low + ((high - low) >> 1)
if(a[mid] <= value) {
if((mid === n - 1) || (a[mid - 1] > value)) {
return mid
} else {
low = mid + 1
}
} else {
high = mid - 1
}
}
return -1
}
凡是用二分查找能解决的问题,对于其中的绝大部分,我们倾向于用哈希表或者二叉查找树来解决。对于查找值等于给定值的问题,我们可以使用二分查找解决,也可以使用哈希表和二叉查找树来解决。对于上述提到的 4 种二分查找变体问题,二分查找更加有优势,使用哈希表或二叉查找树难以解决