堆
堆结构
(1) 堆结构就是用数组实现的完全二叉树结构
(2) 完全二叉树中如果每棵子树的最大值都在顶部就是大根堆
(3) 完全二叉树中如果每棵子树的最小值都在顶部就是小根堆
(5) PriorityQueue底层就是堆结构
堆结构中i位置的左孩子是2i+1,右孩子为2i+2,父节点为Math.floor(
i
−
1
2
\frac{i-1}{2}
2i−1):
完全二叉树中前n层的节点数为
1
+
2
+
4
+
.
.
.
+
2
n
−
1
=
2
n
−
1
1+2+4+...+2^{n-1} = 2^n-1
1+2+4+...+2n−1=2n−1 (等比数列求和),设i位置当前第n行前面有x个节点,则
i
=
2
n
−
1
−
1
+
x
i=2^{n-1}-1+x
i=2n−1−1+x
左孩子位于n+1行,前面还有2x个节点(因为i在第n层前面有x个,每一个节点在下一层都会有2个节点),所以左孩子的位置为
2
n
−
1
+
2
x
=
2
(
2
n
−
1
+
x
)
−
1
=
2
(
2
n
−
1
−
1
+
x
)
+
1
=
2
i
+
1
2^n-1+2x=2(2^{n-1}+x)-1=2(2^{n-1}-1+x)+1=2i+1
2n−1+2x=2(2n−1+x)−1=2(2n−1−1+x)+1=2i+1
右孩子位置为左孩子位置+1,故为2i+2
设父节点位置为k,其左孩子或右孩子位置为i,则有2k+1=i,k=(i-1)/2;或2k+2=i, k=(i-2)/2。由于java中自动采用向下取整,对于右孩子来说
i
−
2
2
=
i
2
−
1
\frac{i-2}{2}=\frac{i}{2}-1
2i−2=2i−1的结果和
i
2
−
1
2
\frac{i}{2}-\frac{1}{2}
2i−21向下取整是一样的,所以父节点的位置都用
i
−
1
2
\frac{i-1}{2}
2i−1记录即可。
堆排序
(1)将数组变为大根堆(heapinsert);
(2)让最后一位和堆顶交换,heapsize-1(即将最大值保存至数组最后的位置,继续对前面的部分进行堆操作)
(3)再重新变为大根堆,相当于把堆顶元素变小再重排(heapify)
(4)直到堆大小为1停止。
heapInsert:
在大根堆中(末尾)新加入一个节点,不断向上与父节点比较及swap,直至父节点>=它时停止,形成一个新的大根堆。复杂度为O(logN),因为需要swap的次数至多为堆的高度
heapify:
假设数组中一个值变小了,重新调整为大根堆的过程。
这里步骤(2)后从根节点开始向下与子节点比较及与子节点中更大的数swap,直至子节点都比当前数小或没有子节点,使整个数组恢复堆结构
上述步骤(1)(从上往下添数)的时间复杂度为O(NlogN),使用floyd建堆法(自下而上,即数组从右往左heapify将子树恢复成堆)可以下降至O(N)级别。
分析:
假设目标堆是一个满堆,即第 k 层节点数为 2ᵏ。输入数组规模为 n, 堆的高度为 h, 那么 n 与 h 之间满足 n=2ʰ⁺¹ - 1,可化为 h=log₂(n+1) - 1。 (层数 k 和高度 h 均从 0 开始,即只有根节点的堆高度为0,空堆高度为 -1)。
建堆过程中每个节点需要一次下滤操作,交换的次数等于该节点到叶节点的深度。那么每一层中所有节点的交换次数为节点个数乘以叶节点到该节点的深度(如第一层的交换次数为 2⁰ · h,第二层的交换次数为 2¹ · (h-1),如此类推)。从堆顶到最后一层的交换次数 Sn 进行求和:
Sn = 2⁰ · h + 2¹ · (h - 1) + 2² · (h - 2) + … + 2ʰ⁻² · 2 + 2ʰ⁻¹ · 1 + 2ʰ · 0
对①等于号左右两边乘以2,记为②式:
②: 2Sn = 2¹ · h + 2² · (h - 1) + 2³ · (h - 2) + … + 2ʰ⁻¹ · 2 + 2ʰ
②-①错位相减得到③式:
③ = Sn =-h + 2¹ + 2² + 2³ + … + 2ʰ⁻¹ + 2ʰ
等比数列求和,a=2, q=2,n=h,则
S
n
=
−
h
+
a
1
∗
(
1
−
q
n
)
1
−
q
=
−
h
+
2
∗
(
1
−
2
h
)
1
−
2
=
2
h
+
1
−
(
h
+
2
)
=
2
log
2
(
n
+
1
)
−
(
log
2
(
n
+
1
)
+
1
)
=
n
−
log
2
(
n
+
1
)
S_n=-h+\frac{a_1*(1-q^n)}{1-q} =-h+ \frac{2*(1-2^h)}{1-2}=2^{h+1}-(h+2)=2^{\log_2(n+1)}-(\log_2(n+1)+1)=n-\log_2(n+1)
Sn=−h+1−qa1∗(1−qn)=−h+1−22∗(1−2h)=2h+1−(h+2)=2log2(n+1)−(log2(n+1)+1)=n−log2(n+1), 渐进为O(N)。
// 堆排序
public int[] heapSort(int[] nums){
if (nums == null || nums.length<=1) {
return nums;
}
// O(NlogN)
// for (int i=0; i<nums.length;i++) { //O(N)
// heapInsert(nums, i); //O(logN)
// }
//优化:Floyd建堆法 O(N)
for (int i=nums.length-1; i>=0; i--) {
heapify(nums, i, nums.length);
}
int heapsize = nums.length;
swap1(nums, 0, --heapsize); //将第一位和最后一位进行交换,heapsize-1
while (heapsize >0) { // O(N)
heapify(nums, 0, heapsize); // O(logN)
swap1(nums, 0, --heapsize); // O(1)
}
return nums;
}
public void heapInsert(int[] nums, int index) { //在index位置插入新数
while (nums[index] > nums[(index-1)/2]) {
swap1(nums, index, (index-1)/2);
index = (index-1)/2;
}
}
public void heapify(int[] nums, int index, int heapsize) { //从index位置开始heapify,即index处的数变小后重新整理数组恢复为大根堆结构的过程
int left = 2*index+1;
while (left< heapsize) { //下方还有孩子的时候
//储存子结点中更大的数的下标 (有右比大,没右左大)
int larger = ((left+1<heapsize) && (nums[left+1]>nums[left])) ? (left+1) : left;
//父节点和较大孩子之间进行比较
int largest = nums[index]>nums[larger] ? index : larger;
if (largest == index) { //若父节点已经比子节点都大,则大根堆结构已完成
break;
}
swap1(nums, index, largest);
index = largest;
left = 2*index+1;
}
}
heapSort的时间复杂度为O(NlogN),额外空间复杂度为O(1)
堆排序扩展题
已知一个几乎有序的数组(几乎有序是指,如果把数组顺序排号的话,每个元素移动的距离不超过k,并且k相对于数组来说比较小)。选择合适的算法对数组排序。
思路:
建立一个heapSize为k+1的小根堆,数组中最小的数一定在这个小根堆中;重复操作弹出最小值、小根堆范围在数组中往后挪1位并heapify(滑动窗口),直至heapSize==1;
该方法复杂度为O(Nlog(k+1));考虑k时相对数组来说很小的数,可以渐进为O(N)
此题可以用java中内置的PriorityQueue作小根堆,因为只需要弹出和添数的操作。
注1:系统内置的堆结构,改中间的值不支持自动调整;只支持弹出和添数。
注2:由数组维持的堆结构,添数时考虑扩容情况(满时新开辟一个length*2的空间,并把原来的全部复制一遍,1->2->4->8…)的时间复杂度仍是O(logN)级别。分析:扩容的次数为logN,乘上N个数复制的总操作是NlogN,但平均到添加每个数每个数就是O(logN)。
public void sortedArrDistanceLessK(int[] arr, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue(); //系统默认是小根堆
int index=0;
for (; index<=Math.min(arr.length-1, k); index++) {
minHeap.add(arr[index]);
}
int i=0;
for (; index<arr.length; i++, index++) {
arr[i]=minHeap.poll();
minHeap.add(arr[index]);
}
while (!minHeap.isEmpty()) {
arr[i]=minHeap.poll();
}
}
桶排序
计数排序
- 确定数组的范围,并创建一个length为该范围的数组,如一组数据最小值为0,最大值为100,则创建一个length为100的数组
- 遍历数组,用创建的数组记录原数组中每个数出现的次数
e.g.
[0, 5, 3, 3, -2, -3, -5, 0, -5, 2] 最大值5,最小值-5,共11 个元素。
得到计数数组:[2, 0, 1, 1, 0, 2, 0, 1, 2, 0, 1]
计数排序的时间复杂度为O(N+k),k为数组元素的范围;空间复杂度为O(k)。
如果要求直接修改原数组,则有序数组的空间复杂度不可省略,空间复杂度是 O(n + k)。
但这种方法的使用取决于数据状况,只适用于小范围的数字;对于非整数的比较、大范围的比较都不适用,所以该方法并不常用
如何将频次数组转化为顺序数组:
- 将频次数组转化为前缀和数组:频次数组中的数表示原数组中有多少个值为该元素的数,前缀和数组中的数为频次数组中每个数与它左边所有数求和,表示原数组中有多少个值为<=该元素的数
- 创建与原数组长度相同的有序数组用于存储排序后的元素。反向遍历原数组,将原数组中的每个元素填到有序数组中的正确下标处:
- 当一个元素在前缀和数组中对应的计数为 x 时,表示不超过该元素的值有 x 个,因此该元素在有序数组中应该存放的下标位置是 x−1
- 将该元素填到有序数组中之后,将前缀和数组中该元素对应的计数-1
e.g.
将计数数组转换成前缀和数组:[2, 2, 3, 4, 4, 6, 6, 7, 9, 9, 10]
反向遍历填充:
[_, _, _, _, _, _,2, _, _, _ ],[2,2,3,4,4,6,6,6,9,9,10]
[_,−5, _, _, _, _,2, _, _, _],[1,2,3,4,4,6,6,6,9,9,10]
public int[] countingSort(int[] nums) {
int min = nums[0], max = nums[0];
for (int i=1; i<nums.length; i++) {
min = nums[i]<min ? nums[i] : min;
max = nums[i]>max ? nums[i] : max;
}
int[] counts = new int[max-min+1];
//统计频次
for (int i=0; i<nums.length; i++) {
counts[nums[i]-min]++;
}
//频次数组转化为前缀和数组
for (int i=1; i<counts.length; i++) {
counts[i] += counts[i-1];
}
//反向遍历填充
int[] sorted = new int[nums.length];
for (int i=nums.length-1; i>=0;i--) {
int index = --counts[nums[i]-min];
sorted[index] = nums[i];
}
return sorted;
}
基数排序
- 创建10个桶(0-9),先根据个位的键值依次将数组中的数字放入对应的桶中
- 依次将每个桶中的数根据先进先出的原则弹出放入原数组
- 根据十位的数字依次放入对应的桶,重复以上操作(从最低有效位到最高有效位)
出桶操作采用和计数排序一样的反向遍历填充法
基数排序相对于计数排序,是几进制的数字就只需要准备几个桶。但这些非基于比较的排序都取决于数据状况,基数排序也不能用于非数字的比较。
public int[] radixSort(int[] nums, int L, int R) {
final int radix = 10;
//有多少个数就准备多大的辅助空间
int[] help = new int[R-L+1];
//考虑有负数的情况,将数组中每个数减去min,使得最小值为0
int min = nums[0];
int max = nums[0];
for (int i=1; i<nums.length; i++) {
min = nums[i]<min ? nums[i] : min;
max = nums[i]>max ? nums[i] : max;
}
for (int i=0; i<nums.length; i++) {
nums[i] -= min;
}
max -= min;
int digit = maxDigit(max);
//主逻辑
for (int d=1; d<=digit; d++) { //有多少位就要有多少次入桶出桶的操作
//10个空间
//count[0]表示当前位(d位)是0的数字有多少个
//count[1]表示当前位(d位)是1的数字有多少个
int[] count = new int[radix];
for (int i=L; i<=R; i++) {
int unit = getDigit(nums[i], d);
count[unit]++;
}
//频次数字转化为前缀和数组
for (int i=1; i<radix; i++) {
count[i] += count[i-1];
}
//反向遍历填充
for (int i=R; i>=L; i--) {
int unit = getDigit(nums[i], d);
help[--count[unit]] = nums[i];
}
//将排好序的数组倒回原数组
for (int i=L,j=0; i<=R; i++,j++) {
nums[i] = help[j];
}
}
//反向平移
for (int i=0; i<nums.length; i++) {
nums[i] += min;
}
return nums;
}
public int maxDigit(int num) {
int res = 0;
while (num !=0) {
res++;
num /= 10;
}
return res;
}
public int getDigit(int num, int d) {
return ((num / ((int) Math.pow(10, d-1))) % 10);
}
时间复杂度:O(d(n + k))O(d(n+k)),其中 n 是数组的长度,k 是基数排序使用的进制数,d 是每个元素最多的有效位数。
空间复杂度:O(n + k)O(n+k),其中 nn 是数组的长度,kk 是基数排序使用的进制数。
(补) 桶排序
桶排序的原理是将数组中的元素分到多个桶内,对每个桶内的元素分别排序,最后将每个桶内的有序元素合并,即可得到排序后的数组。
(补) 希尔排序
排序总结
稳定性
稳定性:在排序后,若相等的数值的相对次序不改变,则该算法视为稳定的。
不具有稳定性的排序:选择,快速,堆
具有稳定性的排序:冒泡,插入,归并,一切桶排序思想下的排序
选择排序:无法做到稳定(最小值和前面的值交换的时候会把原先排在前面的挪到后面);
冒泡排序:可做到稳定的,当遇到相等的数值时不交换;
插入排序:可做到稳定的,当遇到相等的数值时不交换;
归并排序:可做到稳定的,当遇到相等的数值先拷贝左边数组中的值(小和问题中遇到相等值先拷贝右边的,丧失了稳定性);
快速排序:无法做到稳定(partition时<=pivot的数会和<=区的下一个数交换,会将原先排在前面的挪到后面);
堆排序:无法做到稳定(堆结构无法保持稳定。如[5,4,4,6])在heapInsert [6]时,6就会和第一个4交换);
桶排序思想(不基于比较):都是稳定的,均保持先入桶先出桶
综合比较
时间复杂度 | 空间复杂度 | 稳定性 | |
---|---|---|---|
选择排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 不稳定 |
冒泡排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 稳定 |
插入排序 | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 稳定 |
归并排序 | O ( N log N ) O(N\log N) O(NlogN) | O ( N ) O(N) O(N) | 稳定 |
快速排序(3.0) | O ( N log N ) O(N\log N) O(NlogN) | O ( log N ) O(\log N) O(logN) | 不稳定 |
堆排序 | O ( N log N ) O(N\log N) O(NlogN) | O ( 1 ) O(1) O(1) | 不稳定 |
- 一般会使用快速排序,因为与归并和堆经过实验比较,快排的常数项更低,速度最快。
- 空间受限时使用堆排序;需要稳定性时使用归并排序。
- 基于比较的排序无法做到时间复杂度O(NlogN) 以下;目前没有找到时间复杂度O(NlogN),空间复杂度O(1),又稳定的排序。
常见的坑
- 归并排序的额外空间复杂度可以变为O(1),但是非常难,不需要掌握。见“归并排序 内部缓存法”。但使用该方法后该算法不再稳定。
- “原地归并排序”的帖子都是垃圾,会让时间复杂度变为O(n^2)
- 快排可以做到稳定,但是非常难,不需要掌握。见论文 “01 stable sort”。但同时会让空间复杂度变为O(N)水平
- 所有“改进”都不重要,都会破坏其他性质
- 面试坑题:奇数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变。要求时间复杂度O(N),空间复杂度O(1)。
解:快排partition(荷兰国旗1.0)时将数组分为<=区和>区,这是一种0/1标准,等效于把奇数放在左边,偶数放右边;经典快排无稳定性,所以无法做到,需使用01 stable sort。
工程上对排序的改进
- 充分利用O(NlogN)和O(N^2)排序各自的优势:
在大样本量下,总体使用快排调度;但当具体处理其中的小样本时(<=60的情况下),使用插入排序。这是因为在长度N为60以内,插入排序的时间复杂度为O(N²)的劣势体现不出来,反而插入排序常数项很低 --> 综合排序
public void partition(int[] nums, int L, int R) {
if (L >= R) {
return;
}
//nums[L,R] 小样本
if (R - L < 60) {
// 在nums[L,R] 插入排序
return;
}
int pivot = (int) (Math.random() * (R-L+1) + L);
int[] equalArea = sortColors(nums, nums[pivot], L, R);
partition(nums, L, equalArea[0]-1);
partition(nums, equalArea[1]+1, R);
}
- 为什么系统实现的Array.sort排序方法内部对于基本数据类型会使用快排,对于自定义的数据类型会使用归并?
出于稳定性考虑。
reference:
堆排序中 i 位置的节点的子节点位置为 2i+1, 2i+2, 父节点为 (i-1) / 2
第二课:荷兰国旗问题,快速排序,堆排序,排序算法的稳定性,桶排序
堆排序中建堆过程时间复杂度O(n)怎么来的? - SCVTheDefect的回答 - 知乎
leetcode912-解题-stormsunshine