算法导论(二)排序和排序统计量

         在数组中,待排序的项称为 记录(record),每个记录包含一个 关键字(key),即排序问题中要重排的值,记录的剩余部分由 卫星数据(statellite data)组成。

         如果输入数组中仅有常数个元素需要在排序过程中存储在数组之外,则称排序算法是 原址的(in place)。插入排序可以在O(n^2)时间内将n个数排好序,是一种非常快的原址排序算法;归并排序有更好的渐近运行时间O(nlgn),但它使用的MERGE过程并不是原址的。

堆排序

1.计算父节点、左右孩子节点下标(从0开始)

PARENT(i)
    return i/2

LEFT(i)
    return 2i+1

RIGHT(i)
    return 2i+2
  • 最大堆 除了根以外的所有节点 i 满足:A[PARENT(i)]≥A[i]A[PARENT(i)]≥A[i]
  • 最小堆 除了根以外的所有节点 i 满足:A[PARENT(i)]≤A[i]

2.维护堆的性质

void maxHeap(int[] a,int i) {//a数组从0开始
        int l = 2*i + 1;
        int r = 2*i + 2;
        int largest = i;      
        if (l <= heapSize && a[l] > a[i]) largest = l;
        if (r <= heapSize  && a[r] > a[largest]) largest = r;
        if (largest != i) {
            int temp = a[largest];
            a[largest] = a[i];
            a[i] = temp;
            maxHeap(a,largest);
        }       
}

每个孩子的子树的大小最多为 2n/3(最坏情况发生在树的最底层半满的时候),故maxHeap运行时间 T(n)≤T(2n/3)+Θ(1),时间复杂度为T(n)=O(lgn)

3.建堆

void buildHeap(int[] a) {     
        for (int i = heapSize;i >= 0;i--) {
            maxHeap(a,i);
        }
}

把大小为 n = a.length 的数组 a[1..n] 转换为最大堆。渐近上界 buildHeap需要调用maxHeap O(n) 次,故总的时间复杂度为 O(nlgn)

4.堆排序

    void heap(int[] a) {
        int heapSize = a.length-1;
        for (int i = a.length-1;i >= 0;i--) {
            buildHeap(a,i);
            int temp = a[i];
            a[i] = a[0];
            a[0] = temp;
            heapSize--;
        }
    }

5.典型例题

例1:在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。且你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。例如:

输入: [3,2,1,5,6,4] 和 k = 2
输出: 5

输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4

求第k个最大的元素,利用最小堆解决问题,保持堆的大小为k,根节点是堆中最小的值,每有一个比根节点大的值则更换根节点,重新建堆,最终根节点就是第k个最大的元素。

    public int findKthLargest(int[] nums, int k) {
        int n = nums.length;
        if (n == 1) return nums[0];
        for (int i = k-1;i >= 0;i--) {
            minHeap(nums,i,k);
        }
        for (int i = k;i < n;i++) {
            if (nums[i] > nums[0]) {
                swap(nums,0,i);
                minHeap(nums,0,k);
            }
        }
        return nums[0];
    }
    void minHeap(int[] a,int i,int k) {//a数组从0开始
        int l = 2*i + 1;
        int r = 2*i + 2;
        int min = i;
        if (l < k && a[l] < a[i]) min = l;
        if (r < k  && a[r] < a[min]) min = r;
        if (min != i) {
            swap(a,min,i);
            minHeap(a,min,k);
        }
    }
    void swap(int[] a,int i,int j) {
        int t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

6.优先队列

java.util.PriorityQueue<E>是一个基于优先级堆的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的 Comparator 进行排序,具体取决于所使用的构造方法。优先级队列不允许使用 null 元素。依靠自然顺序的优先级队列还不允许插入不可比较的对象(这样做可能导致 ClassCastException)。

默认构造的是最小堆,此队列的 是按指定排序方式确定的最小元素。如果多个元素都是最小值,则头是其中一个元素——选择方法是任意的,可能是任何一个。注意,此优先队列不是同步的。

构造方法默认为最小堆,可以自定义比较器Comparator进行排序,比如自定义一个最大堆优先队列:

PriorityQueue<Integer> queue = new PriorityQueue<Integer>(
                //初始容量为20
                20,new Comparator<Integer>() {
            @Override//自定义构造器为最大堆
            public int compare(Integer o1, Integer o2) {
                return o2 - o1;
            }
        });

/*--------------还可以用更简洁的Lambda表达式替代--------------*/

PriorityQueue<Integer> heap = new PriorityQueue<Integer>(20,
               (n1,n2) -> Integer.compare(n2,n1));

/*--------------还可以用更更简洁的方法引用替代--------------*/

PriorityQueue<Integer> heap = new PriorityQueue<Integer>(20, 
                (n1,n2) -> n2-n1);

使用优先队列解决问题就会很简单,以上述例题1为例:


  public int findKthLargest(int[] nums, int k) {
        //建立最小堆的优先队列,默认初始容量为11
        PriorityQueue<Integer> heap =
            new PriorityQueue<Integer>((n1, n2) -> n1 - n2);
        //保持k个数量的最小堆
        for (int n: nums) {
          heap.add(n);
          if (heap.size() > k)
            heap.poll();
        }
        return heap.poll();        
  }

快速排序

快速排序是目前几大排序算法中平均性能最快的排序方式,是一种原址排序,时间复杂度为O(nlgn)。

先看个示例,依然以上述题为例,除了最小堆,还可用快速排序的思想来实现,主要思想为先取k-1位置的值为中枢轴,从大到小的快速排序排序一次,若左边的数的个数为k-1则当前数就是第k个最大的数,否则继续从大于k-1个数的那边重复上诉步骤,代码如下:

class Solution {
    public int findKthLargest(int[] nums, int k) {
        int n = nums.length;
        if (n == 1) return nums[0];
        quick(nums,0,n-1,k-1);
        return nums[k-1];
    }
    void quick(int[] a,int start,int end,int k) {
        if (start >= end) return;
        int i = start;
        int j = end;
        int t = a[k];
        while (i < j) {
            while (i < j && a[i] > t) i++;
            while (i < j && a[j] <= t) j--;
            if (i < j) swap(a,i,j);
        }
        if(i > k)  quick(a,start,i-1,k);
        if(i < k) {
            swap(a,i,k);
            quick(a,i+1,end,k);
        }
    }
    void swap(int[] a,int i,int j) {
        int t = a[i];
        a[i] = a[j];
        a[j] = t;
    }
}

1、快速排序的基本思想:

   快速排序使用分治的思想,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字均比另一部分记录的关键字小。之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

2、快速排序的三个步骤:

  1. 选择基准:在待排序列中,按照某种方式挑出一个元素,作为 "基准"(pivot)
  2. 分割操作:以该基准在序列中的实际位置,把序列分成两个子序列。此时,在基准左边的元素都比该基准小,在基准右边的元素都比基准大
  3. 递归地对两个序列进行快速排序,直到序列为空或者只有一个元素。

3、选择基准的方式

对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。也就是说,基准的选择是很重要的。选择基准的方式决定了两个分割后两个子序列的长度,进而对整个算法的效率产生决定性影响。

最理想的方法是,选择的基准恰好能把待排序序列分成两个等长的子序列。下面介绍三种选择基准的方法:

1.固定位置

取序列的第一个、最后一个或某一个确定位置的元素作为基准。例中取第一个值为基准:

public int partition(int[] arr,int left,int right) {
	int temp = arr[left];
	while(left < right) {//直达left和right重合的时候,才找到合适的位置
		//先从后往前找比基准小的,当right的值大于temp的值的时候才执行
		while(left < right  &&  arr[right] >= temp) {
//等号一定得写,因为可能会出现,保存的temp元素和数据中的元素一样的,不写会出现死循环的现象		
			right--;
		}
		arr[left] = arr[right];
		//从前往后找,找比基准大的,当left的值小于temp的值的时候执行
		while(left < right  && arr[left] <= temp) {
			left++;
		}
		arr[right] = arr[left];
	}
	arr[left] = temp;//此时的left和right在同一个位置
	return left;
}

void quick(int[] arr,int left,int right) {
	if(left < right) {
		int pivot = partition(arr,left,right);
		quick(arr,left,pivot-1);
		quick(arr,pivot+1,right);
	}
}

如果输入序列是随机的,处理时间可以接受的。如果数组已经有序时,此时的分割就是一个非常不好的分割。因为每次划分只能使待排序序列减一,此时为最坏情况,快速排序沦为冒泡排序,时间复杂度为O(n^2)。而且,输入的数据是有序或部分有序的情况是相当常见的。因此,使用第一个元素作为枢纽元是非常糟糕的,为了避免这个情况,就引入了下面两个获取基准的方法。

2.随机基准

取待排序列中任意一个元素作为基准。

/*随机选择枢轴的位置,区间在low和high之间*/
int selectPivotRandom(int[] a,int low,int high) {
     Random random = new Random();
     //产生枢轴的位置,0 ≤ random < MAX_VALUE之间的任意数
     int pivotPos = random.nextInt(Integer.MAX_VALUE)%(high-low+1) + low;
     //把枢轴位置的元素和low位置元素互换
     swap(a,pivotPos,low);
     return a[low];
}
//最后在partition函数中令temp = 随机数即可
public int partition(int[] arr,int left,int right) {
	int temp = selectPivotRandom(a,low,high);
    ... ...
}

这是一种相对安全的策略。由于枢轴的位置是随机的,那么产生的分割也不会总是会出现劣质的分割。在整个数组数字全相等时,仍然是最坏情况,时间复杂度是O(n^2)。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。

3.三数取中

顾名思义,就是取找到数组中最小下标,最大下标,中间下标的数字,进行比较,把中间大的数放在数组最左边,即low、high和 mid = low + ((high - low) >> 1) 中处于中间大小的值放入第一位low,此方法特别适合于解决已经基本有序的数组排序

/*取待排序序列中low、mid、high三个位置上数据,选取他们中间的那个数据作为枢轴*/
void SelectPivotMedianOfThree(int[] a,int low,int high){
        int mid = low + ((high - low) >> 1);//计算数组中间的元素的下标
        //使用三数取中法选择枢轴
        if (a[mid] > a[high]) swap(a,mid,high); 
        if (a[low] > a[high]) swap(a,low,high);
        if (a[mid] > a[low])  swap(a,mid,low);
}

int partition(int[] a,int low,int high) {
        selectPivotMedianOfThree(a,low,high);
        int t = a[low];
        ... ...
}

使用三数取中选择枢轴优势还是很明显的,但是还是处理不了重复元素多的数组。

4、优化算法

优化方法1:当待排序序列的长度分割到一定大小后,使用插入排序

对于数组长度很小或已经部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。

//子数组元素小于10时使用插入排序
if (high - low + 1 < 10) { 
	InsertSort(arr,low,high);
	return;
}//else时,正常执行快排

针对随机数组,使用三数取中选择枢轴+插入排序效率还是可以提高一点,真是针对已排序的数组,是没有任何用处的。因为待排序序列是已经有序的,那么每次划分只能使待排序序列减一。此时,插排是发挥不了作用的。所以这里看不到时间的减少。另外,三数取中选择枢轴+插排还是不能处理重复数组。

优化方法2:聚集相等的元素,不再分割

在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割,具体过程:在处理过程中,会有两个步骤:

  1. 在划分过程中,把与key相等元素放入数组的两端
  2. 划分结束后,把与key相等的元素移到枢轴周围

下面采用三数取中+插排+聚集相等元素,是目前最快的快排组合,和库函数的sort效率差不多。

void QSort(int arr[],int low,int high)  {  
   int first = low;  
   int last = high;  
  
   int left = low;  
   int right = high;  
 
   int leftLen = 0;  //用来统计左边与key相等的元素的个数
   int rightLen = 0;  //统计右边与key相等的元素的个数
  
    if (high - low + 1 < 10)  {  
        InsertSort(arr,low,high);  //数据量少,就用插入排序
        return;  
    }  
      
    //一次分割  
   int key = SelectPivotMedianOfThree(arr,low,high);//使用三数取中法选择枢轴  
         
    while(low < high) {  
       while(high > low && arr[high] >= key) {  
         if (arr[high] == key) { //处理相等元素  
               swap(arr[right],arr[high]); //把右边与key元素相等的聚集的右端
                right--;  
                rightLen++;  
            }  
            high--;  
        }  
        arr[low] = arr[high];  
        while(high > low && arr[low] <= key)  {  
            if (arr[low] == key) { //把左边与key元素相等的聚集数组的左端      
                swap(arr[left],arr[low]);  
                left++;  
                leftLen++;  
            }  
            low++;  
        }  
       arr[high] = arr[low];  
    }  
    arr[low] = key;  
 
    //一次快排结束  
    //把与枢轴key相同的元素移到枢轴最终位置周围  
    int i = low - 1;  //轴的左边
    int j = first;  
    while(j < left && arr[i] != key) {  
        swap(arr[i],arr[j]);  //此时,把数组左端与key相等的数据换到key的左边
        i--;  
        j++;  
    }  
    i = low + 1;  //轴的右边
    j = last;  
    while(j > right && arr[i] != key) {  
       swap(arr[i],arr[j]);  //此时,把数组右端与key相等的数据换到,key右边
       i++;  
       j--;  
    }  
    QSort(arr,first,low - 1 - leftLen);  
    QSort(arr,low + 1 + rightLen,last);  
}  

 

 

 

 

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值