目录
目录结构不太合理,基本按照教学顺序记的,暂时不纠结这个,等全部学完会重新梳理一遍
以下内容如有错误欢迎指出
一、基本概念
1.堆结构
1.1 定义:
用数组实现的完全二叉树的结构,优先级队列结构就是堆结构
完全二叉树:满二叉树或者是从左往右依次遍满的状态
1.2 实现:
数组从0出发的连续一段对应成完全二叉树
heapSize=7,与数组长度无关,决定了堆的内容有哪些(即0~6)
下标对应关系:
- 左孩子=2*i+1
- 右孩子=2*i+2
- 父=(i-1)/2
2.大根堆和小根堆
大根堆:
完全二叉树中如果每棵子树的最大值都在顶部就是大根堆
小根堆:
完全二叉树中如果每棵子树的最小值都在顶部就是小根堆
二、排序算法
1.堆排序
1.1 heapInsert过程
保证每进来一个数,都保持是大根堆的结构
新进来的数和父比较,比父大则交换,直到PK不过父了或是到头了为止,这里就是新进来的6要与5交换
代码:
public static void heapInsert(int[]arr,int index)
{
while (arr[index] > arr[(index - 1) / 2])
{
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
1.2 heapify堆化过程
去掉堆中最大值,仍然保持大根堆的结构
将堆中最后一个数字先放到0位置上,从头节点开始再依次向下比较,先在左孩子和右孩子中选一个最大值和父位置比较,比父大则交换,直到没有孩子比自己大或者没孩子为止
这里是将6去掉,将末尾的4放到0位置上,heapSize-1(即5位置上的数与堆断开,是无效区域),3和5中选最大的5和4比较,4和5交换,4到尾了停止
代码:
public static void heapify(int[] arr, int index,int heapsize)
{
int left = 2 * index + 1;
while (left< heapsize)
{
int largest = (left + 1) < heapsize && arr[left] < arr[left + 1] ? left + 1 : left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index)
{
break;
}
swap(arr, index, largest);
index = largest;
left = 2 * index + 1;
}
}
如果将大根堆中任意一个数做了调整,只要做一遍heapify+heapInsert就仍然能保持堆结构(不是上去就是下去)
调整一个数的时间复杂度是O(logN) -> 只关注父或子一条路径上的高度是logN级别的
1.3 堆排序详解
1.3.1 实现:
①等同于一个个加数字,每加一个数字都保持大根堆的结构(做heapInsert操作),直到heapSize为数组长度
②将最大值与最后一个数交换,heapSize-1,即最大值与堆断开联系,最大值排好,换到最前面的数做heapfiy操作保持大根堆结构
③重复②操作直到heapSize减到0,全部排好
时间复杂度O(NlogN),空间复杂度O(1)
代码:
public static void heapSort(int[] arr)
{
if (arr == null || arr.Length < 2)
{
return ;
}
//for (int i = 0; i < arr.Length; i++) //O(N)
//{
// heapInsert(arr, i); //O(logN)
//}
for (int i = arr.Length-1; i >=0; i--)
{
heapify(arr, i, arr.Length);
}
int heapsize = arr.Length;
while (heapsize>0) //O(N)
{
swap(arr, 0, --heapsize);//O(1)
heapify(arr, 0, heapsize); //O(logN)
}
}
1.3.2 优化:
第一步可以做优化,假设所有数字都有,从倒数第二层节点开始做heapify,逐渐往上检查调整成大根堆,最底层子节点数量是N/2级别的,可以不用做heapify
时间复杂度的估计:
最底层子节点看一眼 N/2 -> 1
倒数第二层节点看一眼+1次heapify N/4 -> 2
倒数第三层节点看一眼+2次heapify N/4 -> 3
:
T(N)=N/2*1+N/4*2+N/8*3+...
2T(N)=N/2*2+N/2*2+N/4*3+...
2T(N)-T(N)=N+N/2+N/4+...
T(N)=O(N)
1.3.3 补充说明:
①扩容问题
底层是用数组形式存的堆结构,但是数组长度有限,数组长度不够用的时候需要扩容,每次成倍扩容,扩容一次成本是O(N),添加N个数扩容次数是logN次,那么添加一个数的成本就是N*logN/N即O(logN)
②用系统自带堆结构还是手写堆结构
系统自带的堆结构相当于一个黑盒,支持add或者poll的基础操作,不支持以很小的代价调整其中一个数还维持堆结构,手写的可以,所以某些情况要手写堆结构来提高效率
1.4 堆排序拓展
已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离不超过k,且k相对于数组来说比较小,请选择一个合适的排序算法针对这个数据进行排序
假设k=6,那么0位置上的数不可能是7位置之后的,所以只要将0-6位置上的7个数扔到小根堆里,小根堆的最小值即为0位置上的数,依次往后7个数重新放到小根堆,再选出最小值放到1位置,周而复始,到最后不满7个数的时候,依次弹出继续保持小根堆结构即可。
时间复杂度为O(Nlogk)
每个语言都有现成的小根堆算法,Java->PriorityQueue,C#还真没找到
代码:
2. 比较器的使用
比较器的实质就是重载运算符,可以很好的应用再特殊标准的排序上,可以很好的应用在根据特殊标准排序的结构上
2.1 规定:
返回负数,第一个参数在前面
返回正数,第二个参数在前面
返回0,谁在前面无所谓
2.2 案例:
①自定义一个student结构,包含姓名,id,年龄三个参数,要求按照id升序排序
代码:
②将默认的小根堆改成大根堆
代码:
3. 桶排序
桶排序思想下的排序都是不基于比较的排序,时间复杂度为O(N),额外空间负载度O(M),应用范围有限,需要样本的数据状况满足桶的划分
3.1 计数排序
对于特殊数据可以使用计数排序,例如针对员工年龄进行排序,假设员工年龄范围为0-100岁,那么准备一个0-100的数组,遍历一遍原数组,在计数数组中按顺序记录年龄出现的频次,再将词频还原成有序的数组,本质上是一种特殊的桶排序
时间复杂度为O(N)
3.2 基数排序
10进制的数,准备10个桶,将数据从左往右依次按照个位数大小放桶
再按顺序倒出来,先进先出
再按照十位数依次进桶,按顺序倒出来,最后按照百位数进桶 ,倒出来排好序
代码:
public static void radixSort(int[] arr)
{
if (arr == null || arr.Length < 2)
{
return;
}
radixSort(arr, 0, arr.Length - 1, maxbits(arr));
}
public static void radixSort(int[] arr,int L,int R ,int digit)
{
int radix = 10;
int i, j = 0;
int[] bucket = new int[R - L + 1];
for (int d = 1; d <= digit; d++)
{
int[] count = new int[radix];
for (i = L; i<=R;i++)
{
j = getDigit(arr[i], d);
count[j]++;
}
for (i = 1; i < radix; i++)
{
count[i] = count[i] + count[i - 1];
}
for (i = R; i >= L; i--)
{
j = getDigit(arr[i], d);
bucket[count[j] - 1] = arr[i];
count[j]--;
}
for (i = L, j = 0; i <= R; i++, j++)
{
arr[i] = bucket[j];
}
}
}
public static int maxbits(int[] arr)
{
int max = int.MinValue;
for (int i = 0; i < arr.Length-1; i++)
{
max = Mathf.Max(max,arr[i]);
}
int res = 0;
while (max != 0)
{
res++;
max /= 10;
}
return res;
}
public static int getDigit(int x ,int d)
{
return ((x / ((int)Mathf.Pow(10, d - 1))) % 10);
}
代码层面做了很大程度的优化
count数组记录词频
将count数组变成前缀和数组(和前一个数做累加),含义从原来个位数=2的数有2个变成个位数小于等于2的有4个
再从右往左,根据词频表,将数据放到对应的Help数组(与原数组等大),这里062,个位数是2,根据词频表知道个位数小于等于2的有4个,所以这个数只能放到第0,1,2,3上,因为是最右侧的数是最后进桶的,要最后出桶,所以放到3位置
这里利用count数组实现分片等同于完成一次出桶入桶的操作
从右往左,保证了先进先出?
三、排序内容大总结
1. 排序算法的稳定性
同样值得个体之间,如果不因排序而改变相对次序,就是这个排序是有稳定性的,否则就没有。
对于基础类型的数组稳定性并没有什么用,因为都是等效的,但是对于特殊结构的数据,稳定性是有必要的。
例如定义一个学生结构,它有班级和年龄两个属性,先按照年龄从小到大排序,再按照班级排序,如果是具有稳定性的排序,那么在第二次排班级的时候,每一个班级里面的数据也会保持第一次排序的结果是从小到大的,否则就又乱掉了。
再比如将商品的价格从低到高排一下,再按照好评率从高到低排一下,如果是具有稳定性的排序,就能得到物美价廉的产品,是有这种实际需求的。
不具备稳定性的排序:
选择排序、快速排序、堆排序
- 选择排序:
- 快速排序:
- 堆排序:
具备稳定的排序:
冒泡排序,插入排序、归并排序、一切桶排序思想下的排序
- 冒泡排序和插入排序在遇到数值相等的时候不换就可以保持稳定性
- 归并排序在遇到数值相等的时候先拷贝左边的就可以保持稳定性(小和问题里改写的归并排序需要先拷贝右边的就丧失了稳定性)
- 桶本身具有先进先出的性质,入桶和出桶的顺序是可以维持的,具有稳定性的
2. 总结
排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 |
选择排序 | O(N²) | O(1) | × |
冒泡排序 | O(N²) | O(1) | √ |
插入排序 | O(N²) | O(1) | √ |
归并排序 | O(NlogN) | O(N) | √ |
快速排序 | O(NlogN) | O(logN) | × |
堆排序 | O(NlogN) | O(1) | × |
一般会选择使用快速排序,因为经过实验的结果,快排的常数项最低,实在是有空间的限制可以用堆排,或者对稳定性有要求的用归并排序
3. 常见的坑
第5条这里可以类比快排,放在左边和右边都是非0即1的选择,是等效的
4. 工程上堆排序的改进
①充分利用O(NlogN)和O(N²)排序各自优势
针对大样本量使用快速排序O(NlogN)的调度方式,对于拆分好的小样本样本量使用插入排序O(N²),因为插入排序的常数项极低,样本量小的时候N²的瓶颈没有那么明显,反而常数项变得重要,这是一种综合排序
②稳定性的考虑
系统的排序方法,针对基础类型数据使用快速排序,非基础类型排序使用归并排序