在数组中,待排序的项称为 记录(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、快速排序的三个步骤:
- 选择基准:在待排序列中,按照某种方式挑出一个元素,作为 "基准"(pivot)
- 分割操作:以该基准在序列中的实际位置,把序列分成两个子序列。此时,在基准左边的元素都比该基准小,在基准右边的元素都比基准大
- 递归地对两个序列进行快速排序,直到序列为空或者只有一个元素。
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相等元素分割,具体过程:在处理过程中,会有两个步骤:
- 在划分过程中,把与key相等元素放入数组的两端
- 划分结束后,把与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);
}