堆排序、桶排序思想以及比较器的运用

  1. 堆排序
  • 堆结构
    说到堆排序,首先得了解堆结构。堆结构实质上是一个完全二叉树的结构,但是可以用数组来模拟,例如一个长度为n的数组(数组下标0~n-1),0位置可以当做二叉树的根,对于下标为i的元素,其父节点下标为(i-1)/2向下取整,左孩子下标为i*2+1,右孩子下标为i*2+2。一般为了方便管理堆的元素,有一个变量为heapSize,表示堆中元素的个数,对应下标就是0~heapSize-1,所有超出这个下标范围的元素就不归堆来管理了。
    • 一个堆可以分为大根堆和小根堆,大根堆顾名思义就是对于一个堆来说,整个树和其子树都满足根结点是最大的结构,小根堆与大根堆相反。
    • 对于一个堆来说(以大根堆为例),想要新插入一个元素,如何插入?插入之后如何保持还是一个大根堆?这都是插入需要注意的问题。首先插入,堆默认是以完全二叉树的方式插入新元素(从上到下,从左到右),即插入到末尾,对应数组就是插入在heapSize的位置上。插入新元素之后,堆有可能就不是大根堆了,这个时候需要进行调整,因为是插入到最后,所以这个元素是没有左右孩的但是可能存在兄弟结点,只需要看这个元素是否比其父结点大(兄弟节点肯定比父结点小),如果是,则与父结点进行交换,同时下标变为父结点的下标,此时再与其新的父结点进行比较,如此往复,直到不再比其父结点值大或者自己已经是堆的根时结束。至此调整完毕,且heapSize++。堆的这个操作被称为heapInsert,通常是从下往上调整的。
    • 如果将一个堆(还是以大根堆为例)的堆顶元素返回并将堆的最后一个元素(下标最大)放在堆顶上面,此时堆将不再是大根堆,需要进行调整,此时需要调整的元素在根上,所以要从上往下进行调整,首先找出其左右孩子中较大的孩子,然后与之相比较,如果小于较大孩子,则与之交换,同时下标变为较大孩子的下标,再与新的左右孩子中较大的孩子进行比较,大的往上换,如此往复,直到不再有孩子比它大或者它已经没有了左右孩子时结束。至此调整完毕。且heapSize- -。堆的这个操作被称为heapify,通常是从上往下调整的。
  • 堆排序基本思想
    有了上面的基础,堆排序就显得简单了。对于一个有n个元素的无序数组,堆排序首先有个建堆的过程,即从零开始建立堆,通常这个操作由heapInsert操作完成,建完堆之后(此时heapSize=n)依次将堆顶元素与堆的最后一个元素交换,且heapSize–,再将这个堆重新调整为一个新的大根堆,通常这个操作由heapify完成。heapSize自减的目的是堆的调整不包括刚刚换出去的堆顶元素,因为这个元素已经在正确位置上了。接下来重复上述操作,直至heapSize减为0,至此整个数组有序。
  • 时间复杂度和空间复杂度
    堆排序时间复杂度主要分为两部分,一部分是初始建堆过程(heapInsert操作),另一部分是调整堆的过程(heapify操作)。对于heapInsert操作,每个元素从下到上调整(最坏情况:二叉树的叶子到根),可知每次调整是log(n)级别的,也即树的高度。由于有n个元素,所以建堆整个过程的时间复杂度是O(nlogn)。对于heapify操作,每个元素从上到下调整(最坏情况:二叉树的根到叶子),可知每次调整也是log(n)级别的(树高),同样heapify操作也有n次操作,所以调整整个过程的时间复杂度也是O(nlogn)。综合以上两者,堆排序总的时间复杂度还是O(nlogn).
    由于堆排序没有用到递归也没有额外开数组,所以空间复杂度是O(1),如果你看了我前面的博文,可知堆排序是唯一一个时间复杂度为O(n
    logn)且空间复杂度为O(1)的排序算法,其他无论是简单、选择、冒泡、归并和快排都没有在时间和空间上完胜堆排序。
  • 代码实现
//堆排序
public static void heapSort(int[] arr){
       if(arr.length < 2)return;
       int heapSize = 0;
       for (int i = 0; i < arr.length; i++) {
           heapInsert(arr,i); //建堆过程 O(logn)
           heapSize++;
       }
       swap(arr,0,heapSize-1); //堆顶元素和最后一个元素交换
       heapSize--;
       while (heapSize > 0){
           heapify(arr,0,heapSize); //调整堆 O(logn)
           swap(arr,0,heapSize-1); //堆顶元素和最后一个元素交换 O(1)
           heapSize--; //已经排好的元素不参与堆调整
       }
   }

	//调整堆
    private static void heapify(int[] arr, int index, int heapSize) {
        int rchild = index*2+1; //左孩子下标
        if(rchild > heapSize-1)return;//若没有左孩子说明不用调整
        int largestChild = (rchild+1)<=heapSize-1 && arr[rchild+1] > arr[rchild] ? rchild+1 : rchild; //寻找左右孩子中较大者,这里注意右孩子可能越界,做一个判断即可,只有当右孩子存在且大于左孩子,较大者才是右孩子,否则是左孩子
        //寻找父亲和孩子中较大者中的较大者
        int largest = arr[index] > arr[largestChild] ? index : largestChild;
        while(index != largest){//不相等说明孩子比父亲大,需要交换
            swap(arr,index,largest);
            index = largest;
            rchild = index*2+1;
            if(rchild > heapSize-1)break;//若没有左孩子说明已经调整完毕
            largestChild = (rchild+1)<=heapSize-1 && arr[rchild+1] > arr[rchild] ? rchild+1 : rchild;
            largest = arr[index] > arr[largestChild] ? index : largestChild;
        }
    }

	//建堆
    private static void heapInsert(int[] arr, int index) {
        int parent = (index-1)/2; //父结点

        int largest = arr[index] > arr[parent] ? index : parent; //与父做比较

        while(index == largest && index>0){//相等说明比父结点大,则与父交换
            swap(arr,index,parent);
            index = parent;  //孩子替代父亲
            parent = (index-1)/2; //寻找新父亲
            largest = arr[index] > arr[parent] ? index : parent;//与新父做比较
        }
    }

上面的代码实际上是可以继续优化的,建堆使用heapInsert过程一个个插入效率略低,可以直接采取heapify来建堆。采用heapify建堆,实际上就不是一个个的调整,而是将整个无序数组进行一步步调整。这样做时间复杂度可以达到O(n)级别。下面来分析并证明:
考虑n个元素的无序数组,想象成一个完全二叉树,最后一层是叶子,共有n/2个,倒数第二层有n/4个,倒数第三层有n/8…依此类推。heapify从右往左从下到上开始调整(默认先从叶子),因为heapify是从上到下的调整(叶子结点已经是最下面的元素),所以叶子只会"扫一眼"并不会经历heapify的过程,不防将代价计作1,那么所有叶子的代价为n/2 * 1,倒数第二层,会扫一眼且每个结点最多经历一次heapify过程(当前子树的高2),所以每个倒数第二层结点的代价为2,总的代价计为n/4 * 2,倒数第三层总代价为n/8 * 3…第四层总代价n/16 * 4依此类推。
可以得出总的heapify的时间复杂度为T(n)=n/2 * 1+n/4 * 2+n/8 * 3+n/16 * 4+…。(1)
那么2T(n)=n+n/2 * 2+n/4 * 3+n/8 * 4+…(2)
令(2)-(1)得到(错位相减):T(n)=n+n/2+n/4+n/8+…。由此可以得出是一个 等比数列,根据等比数列求和公式T(n)=2(n-n/2^n-1),易知时间复杂度为O(n)级别。
但是注意整体堆排序的时间复杂度还是O(n * logn)
代码实现:

  public static void heapSort(int[] arr){
        if(arr.length < 2)return;
        int heapSize = 0;
//       for (int i = 0; i < arr.length; i++) {
//           heapInsert(arr,i);
//           heapSize++;
//       }
	//上述for修改为下面的for
       for (int i = 0; i < arr.length; i++) {
           heapify(arr,arr.length-1-i,arr.length); //整体做调整
           heapSize++;
       }
       swap(arr,0,heapSize-1);
       heapSize--;
       while (heapSize > 0){
           heapify(arr,0,heapSize);
           swap(arr,0,heapSize-1);
           heapSize--;
       }
   }

提一嘴:优先队列底层就是堆结构(默认是小根堆,如果想要大根堆,传一个比较器即可),只不过他只能进行一些简单的操作,比如进栈和出栈,复杂操作对它来说效率很低或者难以满足要求(比如在任意位置插入一个数还满足堆结构),这个时候可以考虑自己实现堆结构。
2. 桶排序

  • 基本思想
    桶排序又叫做基数排序,它的优点是可以不基于比较就能较快的排序完数组,缺点也很明显,因为只能排序数字且是基于进制的(默认是10进制)。主要思想是首先根据每个数字的个位数字,进入到不同的桶中(桶可以看作是一个容器,满足先进先出),再依次从桶中取出这些数字,再根据十位数字进桶,再依次出桶,再根据百位数…如此往复进出n次,整个数组就有序了,这个n等于数组中最大数的位数。
    具体代码实现,为了满足桶的先进先出,采取一个bucket数组+一个计数器count数组,bukcet数组就是“桶”,count数组每次先记录整个数组个位数(0~9)出现的次数(count[bit]++),再依次求前缀和(count[i]+=count[i-1]).求前缀和的目的是为了满足桶的先进先出原则,考虑这样一种情况:如果将数组中的数根据个位数从后往前依次放入桶中,那么每个数放入的位置就是bucket[count[bit]-1] (bit是当前数的个位数),因为正常情况下是从前往后放,但根据计数器知道了个位数小于等于bit的数有多少了,从后往前的个位数等于bit的那个数必定在个位数小于等于bit最后位置。例如count =[1,3,5,7,9] (求了前缀和之后的count),如果从后往前遍历,且当前数等于83,那83应该放在bucket[7-1]位置上,再比如当前数是62,那就放在bucket[5-1]。如此第一趟放完bucket之后(进桶完成),在将bucket从前往后一一复制到原来数组(出桶完成)。重复上述操作digit次,digit=最大数的位数。
    需要注意的是,桶排序一般不能排序负数,不过改进也很简单,一个方法就是所有数都重新分配成正数(根据最小的数,所有数同加一个正数),最后排序完减去这个正数返回即可。
  • 时间复杂度和空间复杂度
    从下述代码实现中可以看出时间复杂度主要是中间嵌套的for循环,外层for循环循环d次,内层有4个for循环,分别循环n次、radix次、n次(findBit几乎可以忽略)、n次。所以整个for的操作次数是d * (3n+radix),那么整个时间复杂度就是O(d * (n+radix))级别。
    空间复杂度主要是一个bucket数组(依赖于数组规模n)和count数组(依赖基数radix且循环了d次),所以整个空间复杂度是O(n+radix * d)。
  • 代码实现
 public static void bucketSort(int[] arr){
        int max = findMax(arr); //找到数组中的最大数
        int digit = maxBit(max);//求最大数的位数
        int radix = 10; //默认基数为10

        int[] bucket = new int[arr.length];


        for (int d = 1; d <= digit; d++) {
            int[] count = new int[radix];
            for (int i = 0; i < arr.length; i++) { //计数
                count[findBit(arr[i],d)]++;
            }
            for (int i = 1; i < radix; i++) {//求前缀和
                count[i] = count[i] + count[i-1];
            }

            for (int j = arr.length - 1; j >= 0; j--) { //放桶操作
                int bit = findBit(arr[j],d);//得到当前数的个位数或十位数。。。
                bucket[count[bit]-1] = arr[j];
                count[bit]--;
            }
            for (int k = 0; k < arr.length; k++) { //出桶操作
                arr[k] = bucket[k];
            }
        }
    }

    private static int findBit(int n, int d) {
        for (int i = 1; i < d; i++) {
            n /=10;
        }
        return n%10;
    }

    private static int maxBit(int max) {
        int cnt = 0;
        while (max > 0){
            cnt++;
            max /= 10;
        }
        return cnt;
    }

    private static int findMax(int[] arr) {
        int max = Integer.MIN_VALUE;
        for (int i = 0; i < arr.length; i++) {
            if(arr[i] > max){
                max = arr[i];
            }
        }
        return max;
    }
  1. 比较器
    比较器说白了就是为复杂排序而准备的,因为由于具体业务需求,排序算法不再只针对单纯的数字进行排序,有时候是需要对复杂对象进行排序,这个时候就要指定特定的比较器,排序算法才能起作用。
    比较器一般的定义:
int cmp(Object o1,Object o2){ //Object可以是任何类型,只要其中能有比较的属性。
   //如果返回正数,则第二个参数排在前面
   //如果返回负数,则第一个参数排在前面
   //如果返回0,则无论先后
   return o1.data-o2.data; //这个data可以是任意属性,只要可以比较
}

当然比较器还有更复杂的定义,比如排序要根据两个属性来限制,如果第一个属性相等再根据第二个属性判断,定义可以是如下:

int cmp(Object o1,Object o2){
   //如果返回正数,则第二个参数排在前面
   //如果返回负数,则第一个参数排在前面
   //如果返回0,则无论先后
   if(o1.data1 != o2.data1)
   return o1.data1-o2.data1; //这个data可以是任意属性,只要可以比较
   else return o1.data2-o2.data2;
}

如此还有根据三个属性,四个属性等等,都可以按这种套路实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值