冒泡、插入、选择排序
冒泡排序:空间复杂度O(1),稳定的排序算法,最好时间复杂度O(n),平均时间复杂度O(n^2)
插入排序:空间复杂度O(1),稳定的排序算法,最好时间复杂度O(n),平均时间复杂度O(n^2)
选择排序:空间复杂度O(1),不稳定的排序算法,最好时间复杂度O(n^2),平均时间复杂度O(n^2)
思考:为什么插入排序要比冒泡排序更受欢迎?
- 冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
- 但是从代码实现上看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个。
冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
- 我们把执行一个复制语句的时间粗略地计为单位时间,然后分别用冒泡排序和插入排序对同一个逆序度是K的数组进行排序。用冒泡排序,需要K次交换,每次需要3个赋值语句,所以交换操作总耗时就是3*K单位时间。而插入排序中数据移动只需要K个单位时间。
快排、归并排序
两个排序利用的都是分治思想。
分治算法一般都是用递归来实现的,分治是一种解决问题的处理思想,递归是一种编程技巧。
归并排序的核心思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
快速排序的核心思想:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点),遍历p到r的数据,将小于pivot的放到左边,大于的放到右边,将pivot放到中间,之后数据就被分成了三部分,再依次递归处理直至区间缩小为1。
归并排序的性能分析:
归并排序:稳定的排序算法,最好时间复杂度O(nlogn),平均时间复杂度O(nlogn),最坏时间复杂度O(nlogn)
- 归并排序的时间复杂度如何计算?
递归的使用场景是,一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决之后,我们再把b、c的结果合并成a的结果。
如果我们定义求解问题a的时间是T(a),求解问题b、c的时间分别是T(b)和T(c),那我们就可以得到这样的递推关系式:
其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。
从刚刚的分析可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。我们知道,merge()函数合并两个有序子数组的时间复杂度是O(n),所以套用前面的公式就是
T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1
通过这个公式,如何求解T(n)呢?
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
通过一步步求导,可以得到T(n) = 2^kT(n/2^k)+kn。
当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,也就是 n/2^k=1,我们得到 k=log2n 。我们将k 值代入上面的公式,得到 T(n)=Cn+nlog2n。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。
- 归并排序的空间复杂度如何计算?
归并排序尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度是O(n)。
快速排序的一个分区过程:
快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?
可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
快速排序的性能分析:
快速排序:不稳定的排序算法,最好时间复杂度O(nlogn),平均时间复杂度O(nlogn),最坏时间复杂度O(n^2)(分区极其不均衡的情况下)
思考:如何在O(n)的时间复杂度内查找一个无序数组中的第K大元素?
- 比如4,2,5,12,3这样一组数据,第3大元素是4。我们选择数组区间A[0...n-1]的最后一个元素作为pivot,对数组A[0...n-1]进行原地分区,这样数组就分成了3部分,A[0...p-1]、A[p]、A[p+1...n-1]。
- 如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1...n-1]区间,我们按照上面的思路递归地在A[p+1...n-1]这个区间查找。同理,如果K<p+1,那我们就在A[0...p-1]区间查找。
- 时间复杂度分析?第一次分区查找,我们需要对大小为n的数组进行分区操作,需要遍历n个元素。第二次分区查找,我们需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。依次类推,分区遍历元素的个数分别为n、n/2、n/4、n/8、n/16......直到区间缩小为1。如果把每次分区遍历的元素个数累加起来,就是等比数列求和,结果为2n-1。所以,上述解决问题的思路为O(n)。
内容小结
归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是O(n)。也正因此,它也没有快排应用广泛。
快速排序算法虽然最坏情况下的时间复杂度是O(n^2),但是平均情况下时间复杂度都是O(nlogn),不仅如此,快速排序算法时间复杂度退化到O(n^2)的概率非常小,我们可以通过合理地选择pivot来避免这种情况。
思考
- 问题:
有10个访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。现在需要将这10个较小的日志文件合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述任务的机器内存只有1GB,你有什么好的解决思路能快速地将这10个日志文件合并?
- 回答:
- 开10个文件通道,初始每个通道加载100M的日志到内存,充分利用内存,实现高速读写
- 创建一个数组,容量为10,读取内存中每个文件的首位记录,放入数组并升序排序
- 取数组首位记录,写入新文件
- 从数组首位记录所属文件读取首位记录,使用二分查找插入有序数组
- 重复步骤三
- 如果内存中的某个文件内存记录读取完毕,则遍历所有文件内存,从磁盘中加载日志记录到内存,保持每个文件100M内存记录,直至整个文件读取完毕