排序(下)

排序(下): 如何用快排思想在O(n)内查找第K大元素?

冒泡排序,插入排序选择排序的时间复杂度都是O(n2 ),比较高,适合小规模数据排序,而归并排序和快速排序,时间复杂度都是O(nlogn)的排序,适合大规模的数据排序

归并排序

如果要排序一个数组,先把数组从 中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就是有序的

 

归并排序使用的 是分治思想,将一个大问题分解成小的子问题来解决.可以使用递归的的编程技巧来实现

递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
​
终止条件:
p >= r 不用再继续分解
​

merge_sort(p…r),给下标从p到r之间的数组排序,将这个排序的问题转化为两个子问题,merge_sort(p…q)和merge_sort(q+1…r),其中下标q等于p和r的中间位置,也就是(p+r)/2,当从下标p到q和q+1到r这两个子数组都排好序之后,再将两个有序的子数组合并到一起,这样从下标p到r之间的数据就是有序的

伪代码实现:

// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}
​
// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return
​
  // 取 p 到 r 之间的中间位置 q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}
​

合并的过程是如何实现的?

申请一个临时数组tmp,大小与A[p…r]相同,用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素,比较这两个元素A[i]和A[j],就把A[i]放入到临时数组tmp,并且i后移一位,否则将A[j]放入到数组tmp,j后移一位;继续上述比较过程,直到其中一个子数组中所有的数据都放入临时数组中,再把另一个数组中的数据加入到临时数组的末尾,这时临时数组中存储的就是这两个子数组合并之后的结果,最后把临时数组tmp中的数据拷贝到原数组A[p…r]中

 

代码实现;

package com.zach.algorithm;
​
import java.util.Arrays;
​
public class MergeSort {
    public static void main(String[] args) {
        int[] arr = {1, 3, 8, 10, 20, 0, 9};
        mergeSort(arr, 7);
        System.out.println(Arrays.toString(arr));
    }
​
    public static void mergeSort(int[] a, int n) {
        mergeSortInternally(a, 0, n - 1);
    }
​
    private static void mergeSortInternally(int[] a, int p, int r) {
        if (p >= r)
            return;
        //取p到r之间的位置q,防止(p+r)的和超过int类型最大值
        int q = p + (r - p) / 2;
        //分治递归
        mergeSortInternally(a, p, q);
        mergeSortInternally(a, q + 1, r);
​
        //将A[p...q]和A[q+1...r]合并为A[p...r]
        merge(a, p, q, r);
    }
​
    private static void merge(int[] a, int p, int q, int r) {
        int i = p;
        int j = q + 1;
        int k = 0;//初始化变量i,j,k
        int[] tmp = new int[r - p + 1];//申请一个临时数组
        while (i <= q && j <= r) {
            if (a[i] <= a[j]) {
                tmp[k++] = a[i++];
            } else {
                tmp[k++] = a[j++];
            }
        }
​
        //判断哪个子数组中有剩余的数据
        int start = i;
        int end = q;
        if (j <= r) {
            start = j;
            end = r;
        }
        //将剩余的数据拷贝到临时数组tmp中
        while (start <= end) {
            tmp[k++] = a[start++];
        }
        //将tmp中的数组拷贝回a[p...r]
        for (i = 0; i <= r - p; ++i) {
            a[p + i] = tmp[i];
        }
    }
​
}
​

归并排序在合并的过程中,如果A[p…q]和A[q+1…r]之间有值相同的元素,可先把A[p..q]中元素放入tmp数组,这样就保证了值相同的元素,在合并前后的 先后顺序不变,故是一个稳定的排序算法

递归代码的时间复杂度分析:

递归的适用场景是,一个问题a可以分解为多个子问题b,c,那求解问题a就可以分解为求解问题b,c,然后将b,c的求解结果合并为a的结果;定义求问题a 的时间是T(a),求解问题b,c的时间分别T(b)和T(c),则得到公式:

T(a) = T(b) + T(c) + K
//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) = 2k T(n/2k )+Kn,当T(n/2k )=T(1)时,即n/2k =1. k = log2 n,将k值代入公式既得T(n) = Cn+nlog2 n,如果用大O标记法来表示: T(n)=O(nlogn) 所以归并排序时间复杂度就是O(nlogn),归并排序的执行效率与排序的原始数组的有序程度无关,时间复杂度非常稳定,不管最好情况,最坏情况,还是平均情况,时间复杂度都是O(nlogn)

递归代码的空间复杂度不能像时间复杂度那样累加,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了,在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的 内存空爱你在使用,临时内存空间最大也不会超过n个数据的大小,故空间复杂度是O(n)

快速排序的原理

如果排序的数组中下标从p到r之间的 一组数据,选择p到r之间的 任意一个数据作为pivot(分区点)

遍历p到r之间的 数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面是大于pivot

 

根据分治递归的处理思想,递归排序下标p到q–1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了

partition()分区函数,就是随机选择一个元素pivot(一般情况下,选择p到r区间的最后一个元素),然后对A[p…r]分区,函数返回pivot的下标,简单实现分区函数,可以申请两个临时数组X和Y,遍历A[p…r],将小于pivot的元素都拷贝到临时数组X,将大于pivot的 元素都拷贝到临时数组Y,最后将X和Y中数据顺序拷贝到A[p…r],这种实现方式需要很多额外的内存空间,就不是原地排序算法了;

原地分区函数的实现思路: 通过游标把A[p…r-1]分成两部分,A[p..i-1]的元素都是小于pivot的,暂时成为已处理区间,A[i…r-1]是未处理区间,每次都从未处理区间中取一个 元素A[j],与pivot对比,如果小于pivot,则将其加入到已处理区间的尾部,即A[i]的位置

代码实现:

//实现方案一
//对顺序表elem进行快速排序
    public void quickSort(int[] elem) {
        QSort_2(elem, 1, elem.length - 1);
    }
    
    //对顺序表elem中的子序列elem[start...end]做快速排序
    public void QSort(int[] elem, int start, int end) {
        
        int pivot;
        if(start < end) {
            pivot = Partition(elem ,start, end);
            
            QSort(elem, start, pivot - 1);
            QSort(elem, pivot + 1, end);
        }
    } 
    
    /**
     * 交换顺序表elem中字表记录,使基数记录到位,并返回其所在位置
     * 此时在它之前(后)的记录均不大(小)于它
     * @param elem
     * @param low
     * @param high
     * @return
     */
    public int Partition(int[] elem, int low, int high) {
        
        int pivotkey = elem[low];
​
        while(low < high) {
            
            while((low < high) && (elem[high] >= pivotkey)) {
                high--;             
            }
            swap(elem, low, high);
            
            while((low < high) && (elem[low] <= pivotkey)) {
                low++;
            }
            swap(elem, low, high);
        }
        return low;
    }
​
//实现方案二
public class QuickSort {
​
    public static void main(String[] args) {
        int[] a = {2,1,5,4,7,6,3};
        quickSort(a,7);
        System.out.println(Arrays.toString(a));
    }
​
    public static void quickSort(int[] a, int n) {
        quickSortInternally(a, 0, n - 1);
    }
​
    private static void quickSortInternally(int[] a, int p, int r) {
        if (p >= r)
            return;
        //获取分区点
        int q = partition(a, p, r);
        quickSortInternally(a, p, q - 1);
        quickSortInternally(a, q + 1, r);
    }
​
    private static int partition(int[] a, int p, int r) {
        int pivot = a[r];
        int i = p;
        for (int j = p; j < r; ++j) {
            if (a[j] < pivot) {
                if (i == j) {
                    ++i;
                } else {
                    int tmp = a[i];
                    a[i++] = a[j];
                    a[j] = tmp;
                }
            }
        }
        int tmp = a[i];
        a[i] = a[r];
        a[r] = tmp;
        System.out.println("i=" + i);
        return i;
    }
}

快排和归并用的都是分治思想,递归的实现方式,区别在哪里

归并排序的处理过程是由下到上的 ,先处理子问题,然后再合并,而快排正好相反,它的处理过程是由上到下,先分区,然后再处理子问题,归并排序虽然是稳定的,时间复杂度为O(nlongn)的排序算法,但是它是非原地排序算法,主要原因是合并函数无法在 原地执行,快速排序通过巧妙设计原地分区函数,可实现原地排序,解决了归并排序占用太多内存的问题;

 

快速排序的性能分析:

如果每次分区操作,正好把数组分成大小接近相等的两个小区间,极端情况下快排的时间复杂度就退化成O(n2 )

如何在O(n)的 时间复杂度内求无序数组中的第K大元素?

如[4,2,5,12,3]这样一组数据,第3大元素就是4,选择数组区间A[0...n-1]的最后一个元素A[n-1]作为pivot,对数组A[0...n-1]原地分区,数组分成了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]区间,同理,K<p+1,那么就在A[0...p-1]区间查找

第一次分区查找,需要对n执行分区操作,遍历n个元素,第二次则遍历n/2个元素,以此类推,分区遍历元素的个数分别为n/4,n/8,n/16...直到区间缩小为1,然后累加求和: n+n/2+n/4+n/8+n/16+...+1,和等于2n-1故时间复杂度为O(n)

思考题:

现在有10个接口访问日志文件,每个日志文件大约300MB,每个文件里的日志都是按照时间戳从小到大排序的,希望将这10个较小的日志文件,合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列,希望将这10个较小的日志文件,合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列,如果处理上述排序任务的 机器内存只有1GB,用一种方法快速的将10个日志文件合并;

方案一:

先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存

方案二:

先取得十个文件时间戳的最小值数组的最小值a,和最大值数组的最大值b。然后取mid=(a+b)/2,然后把每个文件按照mid分割,取所有前面部分之和,如果小于1g就可以读入内存快排生成中间文件,否则继续取时间戳的中间值分割文件,直到区间内文件之和小于1g。同理对所有区间都做同样处理。最终把生成的中间文件按照分割的时间区间的次序直接连起来即可。

方案三:

1.申请10个40M的数组和一个400M的数组。 2.每个文件都读40M,取各数组中最大时间戳中的最小值。 3.然后利用二分查找,在其他数组中快速定位到小于/等于该时间戳的位置,并做标记。 4.再把各数组中标记位置之前的数据全部放在申请的400M内存中, 5.在原来的40M数组中清除已参加排序的数据。[可优化成不挪动数据,只是用两个索引标记有效数据的起始和截止位置] 6.对400M内存中的有效数据[没装满]做快排。 将排好序的直接写文件。 7.再把每个数组尽量填充满。从第2步开始继续,知道各个文件都读区完毕。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章