递归&排序&二分查找

598 阅读7分钟

递归

概念&特性

  1. 去问问,回来告诉我称之为递归。去的过程为 “递”,回来的过程为 "归"

  2. 三个条件

    • 待解决问题的解,可以分为几个子问题的解
    • 待求解问题与分解之后的子问题,只有数据规模不同,求解思路完全相同
    • 存在递归终止条件

怎么写递归代码

主要是写递推公式,再推敲终止条件,多练吧年轻人!

写递归的注意事项

千万别试图弄清整个递和归的过程,你的小脑袋可没有那么深的栈。

  1. 警惕递归代码出现堆栈溢出

    限制递归调用的最大深度

  2. 警惕递归代码重复计算

    有求解好的子问题答案,咱就别再算一遍了,存起来。做一个缓存

递归代码写成非递归代码

递归就是栈,手动模拟入栈,出栈过程,那么任何递归代码都可以改成看上去不是递归代码的样子。🦆 不必!

尾递归了解

用它来干什么呢?避免堆栈溢出?excuse me? 了解了解就行了,没啥卵用。

  1. 明确俩个问题

    • 什么样的代码才能改写成尾递归
    • 尾递归真的能避免堆栈溢出吗
  2. 为啥会堆栈溢出?

    函数调用采用函数调用栈来保存现场(局部变量,返回地址等)。函数调用栈是在内存中开辟的一块存储空间。它被组织成“栈”这种数据结构,数据先进后出。
    递归的过程包含大量的函数调用。如果递归求解的数据规模很大,函数调用层次很深,那么函数调用栈中的数据(栈帧)会越来越多,而函数调用栈空间一般很小。这个时候就会存在堆栈溢出的风险。

  3. 能写成尾递归代码的特点

    • 递归调用出现在函数的最后一行
    • 没有任何局部变量参与最后一行的代码
  4. 举个例子呗

// 正常递归
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)
}
  1. 尾递归真能避免堆栈溢出吗?

    • 并不是所用的编程语言都支持尾递归优化
    • 并不是所有的递归都可以改成尾递归
    • 能改成尾递归的代码都可以改成迭代
    • 尾递归的代码可读性差

排序

我们怎么分析一个排序算法呢

  1. 最好时间复杂度,最坏时间复杂度,平均时间复杂度

  2. 时间复杂度的系数,常数,低阶

  3. 比较次数,和交换(移动次数)

  4. 内存消耗,是原地排序还是非原地排序

  5. 排序算法是否稳定

冒泡排序

  1. 思想

    和相邻元素进行比较,如果不满足大小关系,就交换。每次交换可以归位一个元素,n次交换后达到完全排序。

  2. 改进

    如果一次扫描过程中,没有发生交换。则数据已完全排序。根据此可以设置 flag

  3. 实现

插入排序

  1. 思想

    将数组分为两个区间,已排序区间和未排序区间。取未排序区间的元素,在已排序区间中找到合适的位置插入。

选择排序

  1. 思想

    将数组分为两个区间,已排序区间和未排序区间。取未排序区间内最小的元素,将其放到已排序元素的末尾。

插入排序优于冒泡排序

插入排序的交换操作只需要一个赋值语句,而冒泡排序需要三个。

归并排序

  1. 思想

    使用分治思想。将长排序划分为一个个短的排序, 使短的排序内部完整排序, 然后再将短排序进行合并排序。可使用递归实现。

    流程: 取中间位 --> 分治递归 --> 合并

    合并的思想:使用临时数组存比较数组中的大小关系

  2. 难点:递推公式,分析时间复杂度

    合并的时间复杂度是 O(n)

  3. 归并排序在任何情况下时间复杂度能达到 O(nlogn)O(nlogn)

  4. 致命弱点:非原地排序。空间复杂度是 O(n)

快速排序

  1. 思想

    使用分治思想。先进行分区,区与区之间保证有序。区内再分区,依旧保证再分的区之间有序。一直分下去,直到只有一个元素时,则完成数组完整排序。

  2. 难点:递推公式,分区函数

  3. 巧点

    • 实现原地分区
    • 三数去中法,随机法
  4. 性能分析

    快速排序的时间复杂度可能退化为 O(n2)O(n^2)

    空间复杂度为 O(logn), 可能退化为 O(n)

归并排序和快速排序

归并排序和快速排序都是稍微复杂的排序算法,他们都利用了分治算法的思想,代码通过递归实现。理解归并排序的关键是理解递推公式和合并函数。理解快速排序的关键是理解递推公式和分区函数。

归并排序是一种在任何时候时间复杂度都比较稳定的排序算法,这也使他存在致命的缺点,即归并排序不是原地排序,空间复杂度较高,是O(n)。正因如此,它没有快速排序应用广泛。

虽然快速排序在最坏情况下的时间复杂度是 O(n2)O(n^2), 但平均时间复杂度是O(nlogn)。不仅如此,快速排序的时间复杂度退化到 O(n2)O(n^2) 的概率非常小,我们可以通过有选择的 pivot 来避免极端情况的发生。

桶排序

  1. 思想

    先定义几个有序的桶, 每个桶有数据范围这个一个属性, 将要排序的数据, 对应桶的数据范围入桶。桶内数据再使用快速排序进行排序。然后再将桶内数据按照顺序依次取出。

  2. 适用场景

    桶排序适合用在外部排序。所谓外部排序就是数据存储在外部磁盘中,数据量比较大,而内存有限,无法将数据完全加载到内存中处理。

  3. 极端情况下会退化为 O(nlogn)

计数排序

  1. 思想

    桶排序的一种特殊情况。一个值一个桶, 桶有 value 属性,要排序的数组,对应桶的value入桶(每个桶内数据值相等)。再取出来,就是已经完整排序的数据了。

  2. 理解计数排序中计数的含义

  3. 难点

  4. 适用场景

    数据范围小,计数排序只能给非负整数排序。

基数排序

  1. 思想

    利用数据的位数, 如果数据A靠前的位数已经比数据B靠前的位数大了,后面的位数就灭必要比较了。每一位借助桶排序或计数排序完成每一位的排序工作。

  2. 适用场景

    数据可以划分为高低位,位之间有递进关系。每一位的数据范围不能太大

  3. 特殊的转化

    位数不够补 0

几种排序的整体分析

排序算法是否原地排序是否稳定排序平均时间复杂度空间复杂度
冒泡排序O(n2)O(n^2)O(1)O(1)
插入排序O(n2)O(n^2)O(1)O(1)
选择排序O(n2)O(n^2)O(1)O(1)
归并排序O(nlogn)O(nlogn)O(n)O(n)
快速排序O(nlogn)O(nlogn)O(logn)O(logn)
桶排序O(n+k)O(n + k)(k为数据范围)--
计数排序O(n)O(n)--
基数排序O(dn)O(dn)(d为数据维度)--

排序的优化

  1. 利用上述排序的特点有取舍的选择排序算法。必要时可以同时使用。

  2. 实际的软件开发中,排序算法是原地排序算法是一个非常重要的选择标准。尤其是处理大数据量的排序。

  3. 对于使用递归的排序算法,要注意函数调用深度。

查找 --> 二分查找

思想

利用已排序的数组特点,进行折半查找

速度惊人

O(logn)的惊人查找速度

  1. 对数运算是指数运算的逆运算,指数曲线多恐怖,反过来对数曲线就多柔和

实现

  1. 递归实现
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)
  }
} 
  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) / 2
    if(a[mid] === value) {
      return mid
    } else if(a[mid] < value) {
      low = mid + 1
    } else {
      high = mid - 1
    }
  }
  return -1
}
  1. 三个容易出错的地方
  • 循环退出条件是 low<=highlow <= high , 而不是 low<highlow < high

  • mid 计算公式

    实际上, mid=(low+high)/2mid = (low + high) / 2 这种写法是有问题的。如果 low 和 high 值比较大,两个数之和就可能超过整数数据类型表示的最大范围。为了避免这个问题,我们可以将 mid 的计算公式改为 mid=low+(highlow)/2mid = low + (high - low) / 2 。除此之外,如果想要将性能优化到极致,那么我们可以将公式里的除法运算改为位运算: mid=low+((highlow)>>1)mid = low + ((high - low) >> 1)

  • low 和 high 的更新

    low = mid + 1, high = mid - 1。注意这里的 “+1” 和 “-1”,如果直接写成 low = mid,或者 high = mid,就有可能出现死循环。

二分查找的局限性

  1. 二分查找依赖数组这种结构的下标操作,链表的化时间复杂度就变高了。

  2. 二分查找针对静态有序数据。二分查找只能用在插入、删除操作不频繁,一次排序,多次查找的场景中。

  3. 数据量适中的查找中。太小了顺序遍历就够了。太大了就不适合用数组这种需要连续内存的数据结构了。

🔥难点:二分查找的变体

10个二分9个错。尽管第一个二分查找算法再1946年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现。

  1. 变体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
}
  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
}

  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
}
  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 种二分查找变体问题,二分查找更加有优势,使用哈希表或二叉查找树难以解决