快排,堆等算法 记录

排序算法名词解释

名词 解释
稳定性 当数组经过算法排序后,相等的数字的前后顺序没有发生变动。
不稳定性 当数组经过算法排序后,相等的数字的前后顺序发生变动。
原地排序算法 指空间复杂度为O(1)。在整段代码中没有额外的申请空间。
时间复杂度 代码执行时间随数据规模增长的变化趋势,也叫作渐进时间复杂度。
空间复杂度 全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。

这里在学习极客时间的数据结构与算法之美的老师,提出了一个关于稳定性有用的例子

我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?

最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。借助稳定排序算法,这个问题可以非常简洁地解决。

解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。

算法的属性总结

排序算法 平均复杂度 最好时间复杂度 最坏情况时间复杂度 空间复杂度(原地) 是否基于比较 稳定性
冒泡排序 O(n2) O(n) O(n2) O(1) 稳定
插入排序 O(n2) O(n) O(n2) O(1) 稳定
选择排序 O(n2) O(n2) O(n2) O(1) 不稳定
快速排序 O(nlogn) O(nlogn) O(n2) O(1) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不稳定
桶排序 O(n) O(n) O(nlogn) O(n + m) 不是 稳定
计数排序 O(n) O(n) O(n) O(m) 不是 稳定
基数排序 O(n) O(n) O(n) O(n + m) 不是 稳定

n: 数据规模
m: 桶的个数

冒泡排序

基本的思想,从数组的头部开始,每两个元素比较大小并进行交换;然后把这一轮当中的最大值或最小值放置在数组的尾部;然后一直持续上述的过程。

示例代码

public  void Bubble(int[] array) {
    
		for (int i = 0; i < array.length; i++) {
			boolean flag = false; //保存一个标记
			for (int j = 0; j < array.length - 1 - i; j++) {
				if (array[j] > array[j + 1]) {
					int temp = array[j];
					array[j] = array[j + 1];
					array[j + 1] = temp;
					flag = true;
				}
			}
			if(!flag) break; // 如果此次未发生交换,则表示已经排序完毕,提前终止
		}
	}

分析

时间复杂度:O(n2)

  • 最好情况下,数据是排序好的,只需要进行一次冒泡操作就结束也就是O(n)。
  • 最坏情况是,要排序的数据刚好是倒序的,每个数据都要冒泡,也就是 n 次,所以最坏是 O(n2)

插入排序

插入排序通过在分成两个区间,一个为已排序区间,而另外一个为未排序区间。通过从未排序区间取出一个值,然后在已排序好的区间中找到位置插入。

示例代码

public void quicklysort(int[] array,int length){
    if(length <= 1) return ;
    for(int i = 1;i < length;i++){
        //先保存待排序的数
        int value = array[i];
        //寻找插入位置
        int j = i - 1;
        for(; j >=0; j--){
            if(value <array[j]){
                array[j+1] = array[j];//数据的向后移动
            }else{
                break;
            }
        }
        array[j] = value;//插入数据
    }
}

分析

时间复杂度 O(n2)

  • 最好的情况是已经排序好的数字,那么只需要每个数比较一次,复杂度为 O(n)
  • 最坏的情况在倒序的时候,每次都要插入在第一位,意味着在往后移动数据的时候要移动的个数越来越多,时间复杂度为 O(n2)

空间复杂度 O(1)
从代码的过程,都不需要额外的存储空间,所以空间复杂度为 O(1)。

选择排序

选择也是通过区分已排序区间和未排序区间,通过在未排序区间找到最小的元素,添加到已排序区间的末尾。

示例代码

public void selectSort(int[] array,int length){
    if(length<=1) return;
    for(int i = 0;i < length; i++){
        int min = i; //假定一个最小值
        for(int j = i+1;j < length;j++){
            if(array[min] > array[j]){
                min = j; // 保存最小值索引下标
            }
        }
        if(min != i){ // 最小值下标发生改变,交换元素
            int temp = array[min];
            array[min] = array[i];
            array[i] = temp;
        }
    }
}

分析

时间复杂度 O(2)
最好和最坏的情况,发现无论是寻找最小值,已经跟最小值的比较(它每个都要比较一次),都跟原本数组的前后顺序无关。

空间复杂度
不需要额外存储空间,所以空间复杂度为 O(1) 。

不是稳定排序算法
因为算法的原理就是,在未排序的区间里面寻找最小值,如果是像相同的值,则靠后的最小值,会被优先插到前面来。交换了位置,破坏了稳定性。

选择 冒泡 插入的比较

冒泡会比选择高效些,因为冒泡可以提前终止循环(在没有发生交换元素的情况下)。但是插入会比冒泡高效些,在交换元素的部分,冒泡所要执行的语句比较多,需要第三个变量来辅助交换,而插入则并不需要。

归并排序

核心思想,就是把要排序的数组,从中间分成前后两部分,然后对前后两个部分分别排序,再将排序好的两部分合并在一起,这样整个数组就有序了。

示例代码

//n 为数组长度
public void marge_sort(int[] array,int n) {
		marge_sort_c(array,0,n-1);
}
//s 数组开始左边,e 数组结束坐标
public void marge_sort_c(int[] array, int s, int e) {
		if (s >= e)
			return;
		int center = (e + s) / 2;
		marge_sort_c(array, s, center);
		marge_sort_c(array, center+1, e);
		marge1(array, s, center, e);
	}
//s 开始坐标,e 结束坐标,c为中间点
public void marge(int[] array,int s,int c,int e){
    int i = s;
    int j = c+1;
    int k = 0;//记录临时数组的当前位置
    int[] temp = new int[e-s+1];//申请一个临时数组
    //将两个区间中,小的值先插入到数组中
    while (i <= c && j <= e) {
		if (array[i] < array[j]) {
			temp[k++] = array[i++];
		}else {
			temp[k++] = array[j++];
		}
	}
    //判断哪个区间的数插入完毕
    int start = j;
	int end = e;
	if(i <= c) {
		start = i;
		end = c;
	}
    //剩余的区间直接插入到临时数组中
    while(start<=end) {
		temp[k++] = array[start++];
	}
    //将临时数组(temp)拷贝到原始数组(array)
    for(i = 0;i <= e-s;i++) {
    	array[s + i] = temp[i];
	}
    
}

另一种方法,设置哨兵,在两个区间的边缘设置一个最大值作为哨兵,当其中一个区间遇到哨兵的时候,另外一个区间的数不会在比这个哨兵大,就会全部放入原数组中。

/* 前面是一样的 */
//s 开始坐标,e 结束坐标,c为中间点
public void marge1(int[] array,int s,int c,int e) {
	int n1 = c - s + 1;
	int n2 = e - c;
	//申请两个临时数组用来保存两个分区,多出一个位置来设置哨兵
	int[] left = new int[n1 + 1];
	int[] right = new int[n2 + 1];
	
	for (int i = 0; i < n1; i++) {
		left[i] = array[s + i];
	}
	//设置哨兵
	left[n1] = Integer.MAX_VALUE;
	for (int i = 0; i < n2; i++) {
		right[i] = array[c + i + 1];
	}
	//设置哨兵
	right[n2] = Integer.MAX_VALUE;
	int j = 0;
	int k = 0;
	for(int i = s; i <= e; i ++) {
	    //当遇到哨兵的时候,就把剩下的直接放到原数组
		if(left[j]<=right[k]) {
			array[i] = left[j++];
		}else {
			array[i] = right[k++];
		}
	}
}

复杂度分析

时间复杂度 O(nlogn)
无论最好情况,最坏情况,平均情况都是 O(nlogn)

空间复杂度 O(n)
主要的操作在 merge() 函数阶段,尽管每次的合并都需要申请额外的存储空间给临时数组,但是这些空间在合并完之后就释放了。我们知道 cpu 只会在一个函数执行,也就是当前就一个空间在被使用,这个内存空间最大不会超过 array 数组大小,所以空间复杂度为 O(n)

稳定排序算法
归并排序的比较发生在合并那个,在 merge() 中,如果两个相同的数,一个数在
s-c 中,另外一个在 (c+1) - e 中,那么前面那个相同的会被先放到 temp 数组中,这也就保证了先后顺序不变,是个稳定排序算法。

快速排序

快排就是,把要排序的数组 p 到 r ,我们选择其中任意一个数据作为 pivot (分区点),把比这个 pivot 小的放在左边,比 pivot 大的放到右边,将 pivot 放中间。这样数组分成了三份。然后就这样分治和递归下去,当区间缩小到为 1 的时候,那么数据就有序了。

示例代码

//n 为数组长度
public void quick_sort(int[] array ,int n) {
	quick_sort_c(array,0,n-1);
}
public void quick_sort_c(int[] array,int p,int r) {
	if(p >= r) return;
	//q 为 pivot 得位置	
	int q = partition(array,p,r);
	quick_sort_c(array,p,q-1);
	quick_sort_c(array,q+1,r);
}
private int partition(int[] array, int p, int r) {
    //默认取数组最后一个为 pivot值
	int pivot = array[r];
	int i = p;//用来记录比 pivot 大的位置
	//这里将比 pivot 小得值放到左侧
	for(int j = p; j < r; j ++) {
	    
		if(array[j]<pivot) {
			int temp = array[j];
			array[j] = array[i];
			array[i] = temp;
				i++;
		}
	}
	//在循环结束后,把最后一个比 pivot 大的值放到右边
	array[r] = array[i];
	array[i] = pivot;
	return i;
}

这里的思想很巧妙,因为最初的想法是借用两个额外的数组来保存分别比 pivot 大和小的值,之后进行合并。但是会消耗更多额外的空间。

这里借用比较,利用 i 将数组分为两部分, p~i-1为小于 pivot 的,i~r-1 为大于 pivot 的,每次我们都从 i~r-1 取一个数,比 pivot 小的就插入到 p~i-1 尾部,也就是和 array[i] 交换,因为 i 对应的值是比 pivot 大的,就把小的值移出了 i ~ r-1的空间。

最后 i 在与 pivot 进行交换。我们知道,交换数组的操作,是可以在 O(1) 的时间复杂度内完成,这样就节省了空间和时间。

分析

时间复杂度 O(nlogn)
在普通情况下复杂度 O(n) ,但是在极端情况下,比如数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。

不稳定排序算法
如果是相同的值,比如数组 4,5,6,5 来说,取最后一位 5 为 pivot ,当对比到数组第二位 5 的时候循环中交换就把会发生,知道最后 pivot 与 i 发生交换,那么前面顺序就变了。

快排 归并 的比较

  • 快排是从上到下依次缩小区间下来的(先分区,在处理子问题),而归并则是从下往上排序区间上来的(先处理子问题,再合并区间)。
  • 快排的了原地排序,解决了归并带来的空间消耗问题

桶排序

和桶类似,就是每个桶对应数据的范围,对应着桶的容量似的,把对应的数字放入到对应的桶中,然后在对桶中的数据在进行排序。

image

桶排序为什么时间复杂度 为O(n) ?

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。

示例代码

     /**
     * 桶排序
     *
     * @param arr 数组
     * @param bucketSize 桶容量
     */
    public static void bucketSort(int[] arr, int bucketSize) {
        if (arr.length < 2) {
            return;
        }

        // 数组最小值
        int minValue = arr[0];
        // 数组最大值
        int maxValue = arr[1];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] < minValue) {
                minValue = arr[i];
            } else if (arr[i] > maxValue) {
                maxValue = arr[i];
            }
        }

        // 桶数量
        int bucketCount = (maxValue - minValue) / bucketSize + 1;
        int[][] buckets = new int[bucketCount][bucketSize]; 
        int[] indexArr = new int[bucketCount]; // 用来记录每个桶已经保存的数量

        // 将数组中值分配到各个桶里
        for (int i = 0; i < arr.length; i++) {
            int bucketIndex = (arr[i] - minValue) / bucketSize;
            if (indexArr[bucketIndex] == buckets[bucketIndex].length) {
                ensureCapacity(buckets, bucketIndex);
            }
            buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
        }

        // 对每个桶进行排序,这里使用了快速排序
        int k = 0;
        for (int i = 0; i < buckets.length; i++) {
            if (indexArr[i] == 0) {
                continue;
            }
            quickSortC(buckets[i], 0, indexArr[i] - 1);
            for (int j = 0; j < indexArr[i]; j++) {
                arr[k++] = buckets[i][j];
            }
        }
    }
    
    /**
     * 数组扩容
     *
     * @param buckets
     * @param bucketIndex
     */
    private static void ensureCapacity(int[][] buckets, int bucketIndex) {
        int[] tempArr = buckets[bucketIndex];
        int[] newArr = new int[tempArr.length * 2];
        for (int j = 0; j < tempArr.length; j++) {
            newArr[j] = tempArr[j];
        }
        buckets[bucketIndex] = newArr;
    }

条件

  1. 数据需要很容易就能划分成 m 个桶
  2. 桶之间有着天然的大小顺序
  3. 桶中的数据是均匀分布的
    1. 如果所有数据都在一个桶中,将会退化成 O(nlogn)

试用的场景

适用于在外部磁盘中的排序。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?

可以先查看桶中的数据范围,如果是 1 到 100 万,那么我们可以分 100 个桶,桶中的范围分别是 1 ~ 1000元范围内,第二个桶 1001~2000范围内… 依此类推。最后对每个桶的对应的文件按照顺序进行编号。

那么就会有 100 个小文件,每次都导入一个文件来进行内部排序即可。

计数排序

和桶排序类似,这次如果排序的 n 个数,最大值为 k ,那么我们就安排 k 个桶,这样就省去了我们要对桶内进行排序的时间,每个桶对应着一个数字,桶中则记录着该数字出现的次数。

排序原理

假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。

考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。像我刚刚举的那个例子,我们只需要遍历一遍考生分数,就可以得到 C[6]的值。

image
从图中可以看出,分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 R[8]中,会保存下标 4,5,6 的位置。
image

那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?
我们先对数组 C 进行顺序求和,C[k] 保存的是小于等于 k 的人数有多少个。如下图
image
如 C[3] 就是小于等于 3 的一共有 7 个。

我们假设一个数组 R 用来保存排序好的数组,然后我们从后往前遍历数组 A,当取出 3 时,从数组 c 中获得 7,也就是小于等于 3 这个分数的人有 7 个,所以这个 3 放在 R[6] 中,之后 C[3] - 1。接下来 取出 0,从数组 c 中获得 2,也就是小于等于 0 有两个人,所以把这个 0 放在 R[1],然后 c[0] - 1,之后依此类推。就是一个排序后的数组了

代码示例


// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申请一个计数数组c,下标大小[0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 计算每个元素的个数,放入c中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 临时数组r,存储排序之后的结果
  int[] r = new int[n];
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 将结果拷贝给a数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

条件

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

基数排序

这里举个例子,比如我们要个十万个手机号进行从小到大排序,们之前讲的快排,时间复杂度可以做到 O(nlogn),还有更高效的排序算法吗?桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢

我们知道对于手机号的前几位,如果前面的几位已经对比出大小了,那么就不用再对比后面的几位了。

这里我们利用稳定性的优势来排序,我们知道稳定排序就是相同的数字,他们在经过排序之后,前后的顺序不会改变。我们从后往前进行排序,先按照最后一位数字进行排序,然后倒数第二位,依此类推。每一位数字在进行桶排序或者计数排序。那么每个数字都是的排序的时间复杂度就是 O(n) ,一共有 K 位,那总的时间复杂度就是 K*O(n) ,如果 k 足够小,比如我们这里是 11 ,那 k 就可以近乎忽略,总的时间复杂度位 O(n)。

这里要注意,因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。

示例代码

这里利用了计数排序

public class RadixSort {

    /**
     * 基数排序
     *
     * @param arr
     */
    public static void radixSort(int[] arr) {
        int max = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }

        // 从个位开始,对数组arr按"指数"进行排序
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSort(arr, exp);
        }
    }

    /**
     * 计数排序-对数组按照"某个位数"进行排序
     *
     * @param arr
     * @param exp 指数
     */
    public static void countingSort(int[] arr, int exp) {
        if (arr.length <= 1) {
            return;
        }

        // 计算每个元素的个数
        int[] c = new int[10];
        for (int i = 0; i < arr.length; i++) {
            c[(arr[i] / exp) % 10]++;
        }

        // 计算排序后的位置
        for (int i = 1; i < c.length; i++) {
            c[i] += c[i - 1];
        }

        // 临时数组r,存储排序之后的结果
        int[] r = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            r[c[(arr[i] / exp) % 10] - 1] = arr[i];
            c[(arr[i] / exp) % 10]--;
        }

        for (int i = 0; i < arr.length; i++) {
            arr[i] = r[i];
        }
    }
}

条件

基数排序的使用也是很严格的,因为基数排序要求,其中的数字要求要能够分割成 ”位“,并且每个”位“之间有着明显的递进关系。像是比如排序手机号码,只要知道前面的几位比后面的几位来得大,就不需要进行比较了。

桶排序 计数排序 计数排序 的总结

三个都用到了桶的概念

  • 桶排序:每个桶存储一定的范围,然后对内部使用其他高效的排序算法
  • 计数排序:是每个桶存储同一个键值,之后取出来合并就行
  • 基数排序:则是根据每个键值的每位数子来划分桶

堆排序

什么是堆

堆是一种二叉树,只是这二叉树对于数字的摆放是有要求的。

  • 首先是一个完全二叉树
  • 每个节点的值必须大于等于(小于等于)它的子树包括子树每个节点的值。

大顶堆就是它的每个节点的值必须是大于等于它的子树的节点的值。小顶堆就是它的每个节点的值必须是小于等于它的子树的节点的值。

如图所示:
image
其中 1 和 2 是大顶堆,3 是小顶堆,4 不是堆。

存储堆的方式

利用数组存储完全二叉树是非常合适的,因为利用数组的特性,我们可以单词的利用数组下标来找到一个节点的左右子节点和父节点。

image

从图中看到,例如下标为 i 的节点的左子节点,就是下标为 i * 2 的节点,右子节点就是下标为 i * 2 + 1 的节点,父节点就是下标为 i/2 的节点。

往堆中插入一个元素

堆化

就是插入一个元素后,重新进行调整,让其重新满足堆的特性,这个过程就是堆化。

堆化的两种形式,一种从下往上和从上往下堆化,以下用大顶堆示例。

从下往上堆化

先将数据插到数组的最后,我们让新插入的节点与父节点对比大小。如果不满足子节点小于父节点的关系,就交换节点,一直重复这个过程,直到父子节点之间都满足关系。
image
代码示例:

public class Heap {
	// 注意我这里的数组第一位不存储数据,只为让后面计算父节点方便
	private int[] heap;//数组,从下标 1 开始存储数据
	private int n; //堆的最大容量
	private int count;// 当前堆中已经存储的堆的数据个数
	
	public Heap(int n) {
		heap = new int[n + 1];//多赋值一个,下标 0 的不需要赋值
		this.n = n;
		count = 0;
	}
	public void insert(int data) {
		if(count >= n) return; // 如果堆中已满则不插入
		heap[++count] = data; // 从 下标 1 开车存储数据
		int i = count;
		// 对于当前插入的元素堆化,从下往上堆化
		while(i / 2 >0 && heap[i/2] <= heap[i]) {
			int temp = heap[i/2];
			heap[i/2] = heap[i];
			heap[i] = temp;
			i = i / 2;
		}
	}
}

删除堆顶元素

如果建造的是大顶堆,那么堆顶元素就是最大值。将堆顶元素删除后,我们把最后一个元素拿到第一位,然后通过对比大小关系,然后互换位置,实现从上而下的堆化。

// 注意我这里的数组第一位不存储数据,只为让后面计算父节点方便
public int removeMax() {
		if(count==0) return -1;
		int data = heap[1];
		heap[1] = heap[count--];
		heapify(heap,count,1);
		return data;
	
}
// n 为需要排序的堆长度 , i 为当前需要堆化的下标,从上往下堆化
private void heapify(int[] heap,int n, int i) {
	while(true) {
		int maxPos = i;
		if(i * 2 <= n && heap[i * 2] > heap[i]) {
			maxPos = i * 2;
		}
		if(i * 2 +1 <= n && heap[ i * 2 + 1] > heap[maxPos]) {
			maxPos = i * 2 + 1;
		}
		if(maxPos == i) break;
		int temp = heap[i];
		heap[i] = heap[maxPos];
		heap[maxPos] = temp;
		i = maxPos;
	}
}
	

怎么实现堆排序

建堆

第一种

利用插入的方式将数据逐个插入到堆中,组成了堆。

第二种

从下往上,我们知道叶子节点往下堆化只会跟自己比较,所以从非叶子节点开始进行依次堆化比较。我这里采用第二种

// 注意我这里的数组第一位不存储数据,只为让后面计算父节点方便
// arr 中的数据需要是在 1~n 位置,n 为数据个数
public static void buildHeap(int[] arr,int n) {
		for(int i = n/2; i >= 1; i --) {
			heapify(arr,n, i);
		}
}
// n 为需要排序的堆长度 , i 为当前需要堆化的下标,从上往下堆化
private static void heapify(int[] heap,int n, int i) {
	while(true) {
		int maxPos = i;
		if(i * 2 <= n && heap[i * 2] > heap[i]) {
			maxPos = i * 2;
		}
		if(i * 2 +1 <= n && heap[ i * 2 + 1] > heap[maxPos]) {
			maxPos = i * 2 + 1;
		}
		if(maxPos == i) break;
		int temp = heap[i];
		heap[i] = heap[maxPos];
		heap[maxPos] = temp;
		i = maxPos;
	}
}

排序

建完堆之后,就是排序问题了,我们知道大顶堆的堆顶就是最大值了,那么我们把它跟数组最后一个元素交换,那么最大值就被放到数组末尾了。

交换之后,排除掉最后一个元素,只剩下 n-1 个元素,此时在对这 n - 1 个元素进行堆化。直到只剩下一个元素,排序就完成了。

// 注意我这里的数组第一位不存储数据,只为让后面计算父节点方便
// 数组中的数据需要是在 1~n 位置, n 为数据个数
public static  void sort(int[] arr,int n) {
		buildHeap(n);
		int k = n;
		while(k > 1) {
			swap(arr,k,1);
			--k;
			heapify(k, 1);
		}
}
public static void swap(int[] arr,int i,int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
}

分析

不是稳定排序算法
排序过程存在堆顶与最后一个交换,顺序就可能发生了改变。

建堆的时间复杂度 O(n)
因为我们的每个节点都要进行依次堆化,但是这是从非叶子节点,所以是 n/2 + 1 个节点,而每个节点的堆化时间为 O(logn),因为每次堆化过程
,顺着节点的路径进行比较,跟树的高度有关系,树的高度又不会超过 log2n,所以对话的时间就是 O(logn)。那么建堆理论总的时间复杂度为 O(nlogn)。

因为需要堆化的节点从倒数第二层开始,每个节点的堆化过程,需要比较和交换节点个数,跟这个节点的高度成正比,我们把每一层对应的节点的高度求和,最后的时间复杂度就为 O(n)。

排序时间复杂度O(nlogn)
因为每个节点的堆化为 O(logn) ,所以 n 个节点就是 O(nlogn)

总的时间复杂度为 O(nlogn)

对比快排

  • 堆排序的数据访问方式没有快排友好
    • 因为堆排序是对数组下标跳着访问,不想快排局部有序的访问
  • 对于同样的数据,排序过程堆排序的交换次数要比快排多
    • 堆排序每次都要建堆,那么会把原来的数组打乱依次,这样可能导致比较次数增多

以上的部分图片来自于极客时间的 “数据结构与算法之美” ,该篇是学习总结。

原创文章 54 获赞 29 访问量 6414

猜你喜欢

转载自blog.csdn.net/qq_37391214/article/details/105178201
今日推荐