算法(一)简单数据结构
算法(二)简单排序
学习了前面的简单排序方法,当然意犹未尽,因为这些简单方法在时间复杂度上似乎还是不尽人意,并且对于面试考察来说显然面试官不会问这些简单的排序算法,而是喜欢考察你的学习能力和理解能力。本次介绍的排序算法包括:归并排序,堆排序,基数排序,桶排序,前两者属于比较排序,后两者属于非比较排序。比较排序的应用场景较为广泛,而非比较排序的应用场景有着一定的限制,但是非比较排序却有着接近**O(n)**的时间复杂度,因此这些排序算法都值得我们去深究,因为其蕴含的是不同的思想。
算法 | 时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|
归并排序 | O(NlogN) | O(N) | 是 |
堆排序 | O(NlogN) | O(1) | 否 |
桶排序 | O(N+K)~O(N2) | O(K) | 是 |
基数排序 | O(N*K) | O(N+K) | 是 |
其中K代表的是划分数组的数量,如桶排序就是划分的桶的数量,基数排序即数字的最大位数。
归并排序出现在快速排序之前,因为这种方式是分治法的最好体现,将数组划分为两个形状几乎相同的子数组(奇数个数的数组划分数组有微小差别),以此递归进行不断划分,最终划分为大小为1的子数组后,递归返回,最终不断归并为一个有序数组。
显然归并排序合并两个数组的过程中,只要在两侧元素相等时,优先选择左侧的数组,那么相等元素的顺序是不会改变的,这很容易说明归并排序是稳定的。
当然,归并排序有两种实现思想:(1)自顶向下归并排序(2)自底向上归并排序
这两种算法在2的幂次时比较次数完全一致,但是对于不是2的幂次的算法自底向上的归并排序显然对于最后一个子数组的划分总是不均衡的,而第一种算法则每次划分都是大小基本相同的,当然这也并不好判别两种方式谁会更加高效。
这两种算法对于合并两个子数组merge
的操作是一致的,因此先进行实现:(其中aux数组作为辅助数组,不需要进行重复创建,只需要作为对象的一个属性或者每次排序创建一次即可)
private static void merge(Comparable[] a, int lo, int mid, int hi){
if(lo>=hi)return ;
//将所规定的范围内的a数组复制到aux数组中,aux在这里不进行创建
for(int i=lo;i<=hi;i++){
aux[i] = a[i];
}
int m = lo, n=mid+1;
//进行归并
for(int j=lo;j<=hi;j++){
//首先判断左右子数组是否还用剩余元素
if(n>hi)a[j] = aux[m++];
else if(m>mid)a[j]=aux[n++];
//如果右数组当前元素小,则取右侧元素,左侧元素小,则取左侧元素,相等取左侧
else if(isLess(a, n, m))a[j] = aux[n++];
else a[j] = aux[m++];
}
}
自顶向下的实现方式使用的是递归的实现方式,先从上到下对子数组进行划分,然后再递归合并。
public static void mergeSort(Comparable[] a){
aux = new Comparable[a.length];
mergeSort(a, 0, a.length-1);
}
private static void mergeSort(Comparable[] a, int lo, int hi){
if(lo<=hi)return;
int mid = (lo+hi)/2;
mergeSort(a, lo, mid);
mergeSort(a, mid+1, hi);
merge(a, lo, mid, hi);
}
自底向上的算法则主要是通过不断增加步长,从长度为1的子数组开始进行归并,最终增大到两个长度为len/2子数组进行归并。但是这种方式一定要注意边界值,最后一个数组的边界一定要与数组长度进行比较,以防数组越界。
public static void mergeSort(Comparable[] a){
aux = new Comparable[a.length];
int N = a.length;
//不断递增step
for(int step=1;step<N;step*=2){
for(int i=0;i<N-step;i+=2*step){
merge(i, i+step-1, Math.min(i+2*step-1, N-1));
}
}
}
两种方式的编程复杂度基本相同,而自底向上的方式更加适合链表排序,只需要更改相应的链表连接即可实现原地排序,链表的相关排序因为相对复杂,我将会单独写一篇博客来进行论述,包括链表翻转、排序等。
堆排序可以说是所有排序中最为奇特的排序了,通过构造一个二叉堆的数据结构,这种结构类似于完全二叉树的构造,如果存储在数组中,其第k
个节点(从1开始)的子节点一定为2k
与2k+1
;而其数值大小的定义使其存在排序的特性,如最小堆的定义是根节点的数值一定要比两个子节点要大,但是两个节点之间的大小可以是任意的,即两个子节点并不知道谁更大。
如果堆是有序的,那么显然,我们一次得不到正确的排序序列,但是我们能够知道,堆的最顶端节点其一定是最小|最大的。
那怎样才能实现堆有序呢? 通常存在两种方式:元素上浮swim
和下沉sink
。
显然我们并不需要同时使用两种方法,只需要选择其中一种方法即可,因为每个节点只需要调用一次swim
或者sink
方法就可以让整个堆变得有序。
对于上述两种操作,在优先队列中却有所使用,这里简单提及:
元素上浮操作是因为有了新的节点被插入到了堆,新节点一般被插入到最尾部,通过swin
操作到达自己的合适位置。
元素下沉操作则是为了删除准备的,在堆排序的过程中,我们需要不断删除顶部元素来实现类似出队列的操作,如进程通过比较优先级选择优先级最大的进程进行执行的过程。
(相关优先队列的知识点我会单独写一篇博客,敬请期待!)
堆排序的关键就是我们如何让整个数组变得堆有序,我们依然可是使用分治法的思想,当一个堆节点的两个子节点下的堆有序时,我们只需要将当前根节点进行下沉,就可以构造出一个有序的二叉堆,因此我们从最小的堆开始实现有序,我们忽略只有一个节点的堆,即从第N/2
个元素,逐渐向上构造堆,这样我们就能得到一个有序的堆了。
而如何保存堆顶元素呢?我们将堆顶元素放置在数组最后的位置,并不断减少堆排序的范围,以此来实现原地排序的效果。
因此我们可以知道,构造从小到大的序列我们需要使用最大堆,而构造从大到小的序列则需要使用最小堆。
注:如果数组是从零放入元素的,则第k
个元素的子节点显然就是2k+1
与2k+2
的位置。
//下沉方法sink
//k表示要进行下沉的数组编号,N则表示数组的堆排序限定大小
private static void sink(Comparable[] a, int k, int N){
if(k>=N)return;
while((2*k+1)<N){
int j = 2*k+1;
if(j<N-1&&isLess(a, j, j+1))j++;
if(!isLess(a, k, j))break;
exchange(a, k, j);
k = j;
}
}
//上浮方法swim
private static void swim(Comparable[] a, int k, int N){
if(k>=N)return;
while(k>0&&isLess(a, (k-1)/2, k)){
exchange(a, (k-1)/2, k);
}
}
public static void heapSort(Comparable[] a){
int N = a.length;
//对一半的堆节点进行下沉
for(int i=N/2-1;i>=0;i--){
sink(a, i, N);
}
//将根节点与堆最后位置进行互换,并将N-1循环直到堆只剩一个节点
while(N>1){
exchange(a, 0, N-1);
N--;
sink(a, 0, N);
}
}
桶排序依然采用分治法的思想,当数组基本为均匀分布时,其时间复杂度能够达到O(N+K)的级别;并且其属于非比较排序,非比较排序相对比较排序的最大区别是其不需要在元素之间进行比较,而是通过如同散列表一样的映射方式,将元素尽量均匀的放到各个桶内,桶与桶之间有着大小关系,只需要将桶内元素进行排序,然后根据桶的大小顺序将元素重新放回数组中,就可以得到一个有序数组。
桶排序对桶内元素的排序可以通过不断减小桶大小,最终减小到桶大小为1时,再递归返回,从而完成排序,也可以使用别的排序来完成桶内排序(如插入排序等)。
在构造桶时,显然我们需要如同线性探测散列表一样的数据结构,其第一维表示桶的编号,第二维则为桶内元素。这里我们简单使用java.util
中的ArrayList或者LinkedList来实现(当然也可以使用算法(一)简单数据结构中的Bag以及LinkedList进行构造)
注意:这里只是对int类型进行排序,如果对浮点型排序的话务必注意bucketSize
的选择。当然,浮点数的排序不太适合使用桶,因为会让整个算法变得非常棘手,并且效率可能会很低。
参考:排序十大算法
public static ArrayList<Integer> BucketSort(ArrayList<Integer> array, int bucketSize) {
if (array == null || array.size() < 2)
return array;
int max = array.get(0), min = array.get(0);
// 找到最大值最小值
for (int i = 0; i < array.size(); i++) {
if (array.get(i) > max)
max = array.get(i);
if (array.get(i) < min)
min = array.get(i);
}
int bucketCount = (max - min) / bucketSize + 1;
ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketCount);
ArrayList<Integer> resultArr = new ArrayList<>();
for (int i = 0; i < bucketCount; i++) {
bucketArr.add(new ArrayList<Integer>());
}
for (int i = 0; i < array.size(); i++) {
bucketArr.get((array.get(i) - min) / bucketSize).add(array.get(i));
}
for (int i = 0; i < bucketCount; i++) {
if (bucketSize == 1) {
// 如果带排序数组中有重复数字时
for (int j = 0; j < bucketArr.get(i).size(); j++)
resultArr.add(bucketArr.get(i).get(j));
} else {
if (bucketCount == 1)
bucketSize--;
ArrayList<Integer> temp = BucketSort(bucketArr.get(i), bucketSize);
for (int j = 0; j < temp.size(); j++)
resultArr.add(temp.get(j));
}
}
return resultArr;
}
基数排序是另一种常见的非比较排序,这种排序方式是分别对数字从低优先级位到高优先级位进行循环排序,如**{10,21,34,52,66}这样的整数集就是先对个位数排序,再对十位数排序,而其排序的方式是通过一个包含所有基数的二维数组**,如数字的基数就是0-9,可以直接作为数组下标,然后将其放入对应的基数数组中,然后根据每个基数数组的长度来确定各个基数数组放回源数组的起始位置。如此重复各个位的排序,最终得到的就是一个有序的数组。
并且可以发现,在将各个数加入基数数组过程中,由于遍历是从左向右的,即添加过程也是从左向右的,因此不会改变两个相同数字的相对位置,因此基数排序是稳定的。
显然这样的排序方式也同样适合字符串的排序,这种方式称之为低位优先的字符串排序(LSD),但是有着很大的限制:各个字符串长度必须一致。对于多数场景,高位优先的字符串排序显然更加通用。
同时需要注意,对于字符串的排序千万不要是unicode等通用编码,因为其基数数组将会异常庞大,效率也会非常低(unicode需要65535个基数数组)。
这里主要实现一下数字的基数排序,字符串的排序后续文章将会详细介绍。
public static void radixSort(int[] a){
//首先找到最大位数
int N =a.length;
int max = a[0];
for(int i=1;i<N;i++){
max = Math.max(max, a[i]);
}
int maxDigit = 1;
while(max/10!=0){maxDigit++;max/=10;}
//构造基数二维数组,这个过程可以不必每次都重新创建
Bag<LinkedList<Integer>> radixBag = new Bag<>();
for(int m=0;m<10;m++){
radixBag.add(new LinkedList<Integer>());
}
//对每一位进行排序
int mod=10,div=1;
for(int i=0;i<maxDigit;i++,mod*=10,div*=10){
for(int k=0;k<N;k++){
radixBag.get((a[i]%mod)/div).add(a[i]);
}
//将元素重新填回a数组中
int index = 0;
for(int m=0;m<10;m++){
for(int z=0;z<radixBag.get(m).size();z++){
a[index++] = radixBag.get(m).get(z);
}
//顺便将数组清空
radixBag.get(m).clear();
}
}
}