1. 常见算法复杂度

大O加上()的形式,里面其实包裹的是一个函数f(),O(f()),指明某个算法的 “耗时/耗空间”与“数据增长量”之间的关系。其中的 n代表输入数据的量

1.1. O(1)

1. 解释

最低复杂度,常量值。也就是 “耗时/耗空间” 与 “数据增长量” 无关,无论输入数据增大多少倍,“耗时/耗空间” 都不变。

2. 举例

哈希算法就是典型的 O(1)时间复杂度算法,例如 HashMap、布隆过滤器等哈希算法的应用,无论数据规模多大,在计算出 hash key 的值之后,都可以一次性找到目标(不考虑哈希冲突的话)。

1.2. O(n)

1. 解释

数据量增大几倍,耗时也增大几倍。

2. 举例

例如:最常见的遍历算法,遍历一次所有值,找出其中的最大值。

1.3. O(n^2)

1. 解释

对n个数排序,需要扫描 n^2次。

2. 举例

例如:冒泡排序法、选择排序法等。因为该算法都是2层循环,第一层循环遍历 n-1 趟,第二层循环遍历 n-i 趟(i递增)。

1.4. O(logn)

1. 解释

当数据增大n倍时,耗时增大logn倍。

这里 log 是以2为底。比如:log256=8

2. 举例

二分查找法就是 O(logn) 的算法,每找一次就排除一般的可能性,256条数据中只需查找8次就可以。

1.5. O(nlogn)

1. 解释

当数据增大n倍时,耗时增大 n乘以logn 倍。例如当数据增大256倍,耗时增大 256*8 倍。

这个复杂度高于线性O(n),低于平方O(n^2)。

2. 举例

归并排序法、快速排序法的时间复杂度就是 O(nlogn)。

2. 排序算法复杂度

网上看到这张图:

2.1. 冒泡排序为啥 O(n^2)

冒泡排序法遍历的次数:

  • 总层数:n−1
  • 每层遍历次数:n−i(i在 1 ~ n 递增)
  • 可基于 ∑i 求和,计算出总次数:n(n−1)/2=n^2/2 - n/2

既然是 n^2/2 - n/2,为什么说时间复杂度是 O(n^2) 呢?因为我们常说的复杂度不是准确的值,而是当数据量膨胀时,随之时间膨胀的模型。当 n 趋向于无限大时,n^2/2 - n/2 也就趋向于 n^2。

2.2. 快速排序为啥 O(nlogn)

1. 算法原理
  1. 从待排序的n个记录中任意选取一个记录(通常选取第一个记录)为分区标准;
  2. 把所有小于该排序列的记录移动到左边,把所有大于该排序码的记录移动到右边,中间放所选记录,称之为第一趟排序;
  3. 然后对前后两个子序列分别重复上述过程,直到所有记录都排好序。
2. 稳定性

不稳定排序。

3. 时间复杂度

O(nlog2n)至O(n2),平均时间复杂度为O(nlgn)。

4. 最好的情况

是每趟排序结束后,每次划分使两个子文件的长度大致相等,时间复杂度为O(nlog2n)。

  • 总层数:logn,因为假设每次划分都使两个子文件的长度大致相等,那么划分logn 次后即无法继续划分。
  • 每层遍历次数:n-i
  • 总次数:同冒泡排序法,当n趋于无限大,总次数为 nlogn
5. 最坏的情况

是待排序记录已经排好序。

  • 第一趟经过n-1次比较后第一个记录保持位置不变,并得到一个n-1个元素的子记录;
  • 第二趟经过n-2次比较,将第二个记录定位在原来的位置上,并得到一个包括n-2个记录的子文件。

依次类推,这样总的比较次数是:
Cmax=∑i=n−1(n−i)=n(n−1)/2=O(n2)

6. 复杂度总结

通过最好、最坏的情况对比,每层遍历的次数差距不大,差距大在层数。

这里层数的计算,实际等同于二叉树的高度,二叉树查找算法的复杂度就 O(logn),我们就拿它当二叉树来看。

如果二叉排序树的子树间的高度相差太大,就会让二叉排序树操作的时间复杂度升级为O(n),为了避免这一情况,为最坏的情况做准备,就出现了平衡二叉树。

总结:

  • 当二叉树趋于平衡,也就是快排的每次划分比较均等,层数趋于 logn,快排复杂度趋于 O(nlogn)。
  • 当二叉树不够平衡,甚至都成一条直线了,层数趋于 n,快排复杂度趋于 O(n^2)。

3. 数据结构:堆

3.1. 定义

定义

堆是使用数组实现,基于完全二叉树的数据结构。

完全二叉树
  • 二叉树: 每个非叶子节点最多有两个分支节点。
  • 满二叉树: 每个非叶子节点都有两个子节点。
  • 完全二叉树: 最后一层的最后一个节点的父节点不满足满二叉树之外,其它非叶子节点都满足满二叉树。
最小堆与最大堆
  • 最大堆:是一个完全二叉树,所有的父节点都大于或等于它的左右孩子节点。
  • 最小堆:是一个完全二叉树,所有的父节点都小于或等于它的左右孩子节点。
后一半是叶子节点

堆的前一半是非叶子节点,后一半是叶子节点。

因为叶子节点排在数组最后,而假设某叶子节点位置为 k,对应父节点为 k/2

3.2. 堆操作

二叉堆虽然是一个完全二叉树,但是它的存储方式并不是链式存储,而是顺序存储,它所有的节点都存储在数组中。

  1. 它是完全二叉树,除了树的最后一层结点不需要是满的,其它的每一层从左到右都是满的,如果最后一层结点不是满的,那么要求左满右不满。
  2. 它通常用数组来实现。并且从索引 1 开始存储,即索引 0 直接废弃。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置 1,它的子结点在位置 2 和 3,而子结点的子结点则分别在位置 4, 5 , 6 和 7,以此类推。

    如果一个结点的位置为 k,则它的父结点(根节点没有父结点)的位置为[k/2],而它的两个子结点的位置则分别为2k和2k+1。这样,在不使用指针的情况下,我们也可以通过计算数组的索引在树中上下移动。

  3. 每个结点都(大于或小于)等于它的两个子结点。这里要注意堆中仅仅规定了每个结点(大于或小于)等于它的两个子结点,但这两个子结点的顺序并没有做规定,跟二叉查找树是有区别的。
  4. 上述提到的结点需要(大于或小于)等于它的两个子节点,是根据堆的类别来判断的。将根节点最大的堆叫做最大堆或大根堆,结点需要大于等于它的两个子结点;根节点最小的堆叫做最小堆或小根堆,结点需要小于等于它的两个子结点。
1. 堆的插入

堆是用数组完成数据元素的存储的,我们往数组中从索引 1 处开始,依次往后存放数据,但是堆中对元素的顺序是有要求的,每一个结点的数据要(大于或小于)等于它的两个子结点的数据,所以每次插入一个元素,都会使得堆中的数据顺序变乱,这个时候我们就需要通过一些方法让刚才插入的这个数据放入到合适的位置。

如果往堆中新插入元素,我们只需要不断的比较新结点 a[k] 和它的父结点 a[k/2] 的大小,然后根据结果完成数据元素的交换,就可以完成堆的有序调整。这里就设计到堆的上浮操作,等会再细谈。

2. 删除根节点

由堆的特性我们可以知道,索引1处的元素,也就是根结点。当我们把根结点的元素删除后,堆的顺序就乱了,那么我们应该怎么删除呢?

思路:

  • 交换根节点与最后一个元素
  • 把末尾的根节点删除
  • 对新的根节点进行下沉操作,使之处于正确的位置

当需要删除最值时,只需要将最后一个元素放到索引 1 处,并不断的拿着当前结点 a[k] 与它的子结点 a[2k]和 a[2k+1] 中的(较大者或较小者,根据最大堆、最小堆判断)交换位置,即可完成堆的有序调整。

3. 上浮操作

最大堆的上浮思路:

  1. 确定需要上浮元素的下标 k
  2. 当 k > 1时,比较 item[k] 与 item[k / 2] 的大小
    2.1. 若 item[k] > item[k / 2],交换两者位置,k = k / 2
    2.2. 若 item[k] <= item[k / 2],上浮结束

最小堆的上浮思路:

  1. 确定需要上浮元素的下标 k
  2. 当 k > 1时,比较 item[k] 与 item[k / 2] 的大小
    2.1. 若 item[k] < item[k / 2],交换两者位置,k = k / 2
    2.2. 若 item[k] >= item[k / 2],上浮结束
4. 下沉操作

最大堆的下沉思路:

  1. 确定需要下沉元素的下标 k
  2. 当 k 2 <= N (N 为堆中元素个数)时,比较 item[k] 与 max{ item[k 2],item[k 2 + 1]} 的大小,并记录 item[k 2],item[k * 2 + 1] 较大值的下标 maxIndex
    2.1. 若 item[k] < item[maxIndex],交换两者位置,k = maxIndex
    2.2. 若 item[k] >= maxIndex,下沉结束

最小堆的下沉思路:

  1. 确定需要下沉元素的下标 k
  2. 当 k 2 <= N (N 为堆中元素个数)时,比较 item[k] 与 min{ item[k 2],item[k 2 + 1]} 的大小,并记录 item[k 2],item[k * 2 + 1] 较小值的下标 minIndex
    2.1. 若 item[k] > item[maxIndex],交换两者位置,k = minIndex
    2.2. 若 item[k] <= maxIndex,下沉结束
5. 堆的构造

堆的构造,最直观的想法就是另外再创建一个新数组,然后从左往右遍历原数组,每得到一个元素后,添加到新数组中,并通过上浮,对堆进行调整,最后新的数组就是一个堆。
上述的方式虽然很直观,也很简单,但是我们可以用更聪明一点的办法完成它。创建一个新数组,把原数组[0 ~ length -1]的数据拷贝到新数组的 [1 ~ length]处,再从新数组长度的一半处开始往 1 索引处扫描(从右往左),然后对扫描到的每一个元素做下沉调整即可。

6. 堆的排序

实现步骤:

  1. 构造堆
  2. 得到堆顶元素,这个值就是最大值
  3. 交换堆顶元素和数组中的最后一个元素,此时所有元素中的最大元素已经放到合适的位置
  4. 对堆进行调整,重新让除了最后一个元素的剩余元素中的最大值放到堆顶
  5. 重复2~4这个步骤,直到堆中剩一个元素为止

对于堆的构造,上述已经谈到,对构造好的堆,我们只需要做类似于堆的删除操作,就可以完成排序。

  1. 将堆顶元素和堆中最后一个元素交换位置;
  2. 通过对堆顶元素下沉调整堆,把最大的元素放到堆顶(此时最后一个元素不参与堆的调整,因为最大的数据已经到了数组的最右边)
  3. 重复1~2步骤,直到堆中剩最后一个元素。

3.3. 优先队列

队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列。

此时,数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列(Priority Queue)

优先级队列实现了Queue接口。

JDK1.8中的 PriorityQueue底层使用了的数据结构。

优先队列按照其作用不同,可以分为以下两种:

  1. 最大优先队列:可以获取并删除队列中最大的值,基于最大堆实现。
  2. 最小优先队列:可以获取并删除队列中最小的值,基于最小堆实现。

4. 问题考察

这里转述一个问题:

假如有10亿条数据,希望找出最大的10条,最优方案有哪些?具体时间复杂度是多少?

我们可以用 n 代替1亿,k代替10,下列有三种解法作为参考。

1. 解法一:O(nlogn)

看到取最大/小数,估计很多人首先想到的,就是通过xx排序法做个排序,然后再取值。然后快速排序法相对而言复杂度最低,就由此答案。

整个过程的复杂度也就是快速排序法的复杂度:O(nlogn)。

2. 解法二:O(nk)

第一种解法最大的问题在于,虽然快速排序法对整体排序的复杂度最好,但我只需要获取最大的10条数据,却花费性能,对10亿条数据都做了排序。

因此这就诞生了第二种解法,就使用冒泡排序法、选择排序法等,只需要10次遍历,就能选出最大的10条数据。

每次遍历需要 n-i次,因为n有10亿,i最大仅为10,那么整个过程的复杂度为:O(n*10)。

3. 解法三:O(nlogk)

接下来换一种思路:

  1. 我们先取前10条数据维护一个集合,只找出这10条数据中的最小值。
  2. 然后从第11条数据开始依次遍历,如果第i条数据大于集合内的最小值,那么就用这第i条数据替换集合内的最小值,并且对集合内的数据重新排序。
  3. 当遍历到最后一条数据,这集合内的数据就是最大的10条数据了。

接下来算一下复杂度:

  • 遍历次数:n-10,约为n。
  • 找到集合内的最小值:

    • (1)如果是直接遍历,复杂度为 O(10);
    • (2)如果我们维护最小堆,复杂度为 O(log10)。明显最小堆复杂度更低。
  • 总最小时间复杂度:O(nlog10)

KerryWu
641 声望159 粉丝

保持饥饿


引用和评论

0 条评论