ologn
前言
本篇讲述关于ologn的排序算法,包括归并排序、快速排序、堆排序
基本概念
递归时间复杂度T(N)基本原理:假设规模为n,左右侧递归处理规模为n/2,其余代码的复杂度为O(1),那么复杂度为$T(N)=2T(\frac{N}{2})+O(1)$
base case:
master公式
$T(N)=aT(\frac{N}{b})+O(N^d)$
公式解读 子问题规模都为$\frac{N}{b}$,一共处理子问题$a$次,除去子问题外的时间复杂度为$O(N^d)$, $d$就是复杂度的最高阶,最后复杂度分三种情况:
$\log_ba < d$ , 则$T(N) = O(N^d)$
$\log_ba > d$ , 则$T(N) = O(N^{\log_ba})$
$\log_ba = d$ , 则$T(N) = O(N^d {\times \log N})$
只能用来求子规模相等的递归情况,但是不一定非要把子规模等分,比如从左起规模为2n/3的子过程和从右起规模也为2n/3的子过程,虽然子过程中间有重合的部分,但是仍可以用master公式
归并排序
简而言之,就是将待目标序列分割成两组,先将左组和右组分别排好序,在最后将这两组合并,在合并的过程中实现整个序列的有序。
递归形式
base case 当递归到序列的长度为1时就停止分割序列,开始一次合并序列,这里序列采取左闭右开的形式。
bool MergeSort(vector<int>& target) {
if (!target.size())
return false;
custom_sort(target, target.begin(), target.end());
return true;
}
void custom_sort(vector<int>& target, vector<int>::iterator begin, vector<int>::iterator end) {
//base case
if (begin+1 == end)
return;
auto mid = begin + (end - begin) /2;
custom_sort(target, begin, mid);
custom_sort(target, mid, end);
merge(target, begin, mid, end);
}
void merge(vector<int>& target, vector<int>::iterator begin,
vector<int>::iterator mid, vector<int>::iterator end) {
vector<int>help(distance(begin,end));
auto help_point=help.begin(), left_point = begin, right_point = mid;
while (left_point < mid && right_point < end) {
/*
if (*right_point < *left_point) {
for (auto index = left_point; index != mid; ++index)
cout << "(" << *index << "," << *right_point << ")" << " ";
}
*/
//此句先拷贝左值维持稳定性
*help_point++ = *left_point <= *right_point ? *left_point++ : *right_point++;
}
while (left_point < mid)
* help_point++ = *left_point++;
while (right_point < end)
* help_point++ = *right_point++;
for (auto index : help)
* begin++ = index;
}
在合并数组时,需要注意:
需要开辟长度为左右序列和的辅助空间,因为合并时需要将左序列和右序列的值有序的拷贝到辅助数组,最后再拷贝至原序列
当左值a和右值b相等时,先拷贝左值至辅助数组就会保持原序列的稳定性,因为两个数相等时,a先进辅助数组,当拷贝b时,b仍会在a的后面,保持稳定性
时空复杂度
时间:应用master公式,求得时间复杂度为$O(N*log N)$
空间:因为每次合并,都需要开辟额外空间,最多开辟长度为N的辅助空间,所以空间复杂度为$O(N)$
相关问题
为什么比$O(N^2)$算法快?
因为$O(N^2)$算法在大量的浪费比较行为,前一次的比较跟后一次的比较毫无关系。而归并不同,前一次排序的结果能够为下一次排序使用。
小和问题
什么是小和? 假设有序列{2,3,5,4,1,7}
,值4
产生的小和即为序列中位置在4
左侧且值小于4
的值产生的和,4
产生的小和值为:2+3=5
,以此类推,计算序列中每个值的小和,加起来即为序列的小和。暴力求法很简单在这就不说了,主要讲讲利用归并求小和的方法。
归并求法 仍以上述序列为例。{2,3,5,4,1,7}
,在2
的左侧有3、5、4、7
都大于2
,所以在求3、5、4、7
的小和时,都会将把2
加一遍,即总共会把2
加4遍。那么我们就可以换一种求法。
对于值2
,其右侧大于2
的值有四个,所以小和sum += 2*4
;然后对于值3
,其右侧大于3
的值有俩个,所以小和sum +=3*2
;以此类推,对每个值都如此操作,就会得到序列的小和。
在归并排序的同时,可以顺带解决这个问题。
比如在合并{2,3,5}
{1,4,7}
两个有序子序列时,假设现在左序列指针指向2,右序列指针指向1,2>1,则不会产生新小和,将1拷贝到额外数组中的同时右指针指向4,此时2<4,从4开始距离尾部的长度为2(大于2
的值有两个),所以此时新产生的小和为:sum += 2*2
以此类推,在合并的同时对左侧序列的每个值都是如此操作即可求出小和。
相关问题
为什么只对左侧序列中的值进行上述操作?
因为左侧序列和右侧序列的小和都已确定,只有合并产生的小和待计算。
与归并排序有什么不同?
在合并左右序列时,如果左值与右值相等,必须先拷贝右值到额外数组中,但会失去稳定性。 这与原始归并排序不同。原始归并排序在左右值相等时,会先拷贝左值并保持稳定性。 因为在查看右侧序列中有多少个值大于左值时,依靠的是当前右值距离尾部的长度,如果先拷贝左值,就会失去一部分小和。 比如左右序列
{1,2,5}
、{1,3,6}
,左右值都为1,如果先拷贝左值,那么本身左1
产生的部分小和:1*2=2(右侧序列有两个值大于1)
就会被忽略,但是先拷贝右值就会避免这种情况。为什么要排序?
因为是通过下标计算的,所以序列必须有序,才能够通过下标确定个数
逆序对问题
什么是逆序对
逆序对问题。给一列数$a_1,a_2,…,a_n$,求它的逆序对数,即有多少个有序对(i,j),使得i < j 但 $a_i>a_j$。n可以高达$10^6$。
解决思路
这与小和问题的解决思路很相似,小和问题是求右序列中有多少个值大于左序列中的当前值,这道题是求左序列中有多少个值大于右序列中的当前值,很相似,这里就不赘述了。
快速排序
快速排序可以由荷兰国旗问题改进而成,下面先来看看荷兰国旗。
荷兰国旗问题
现有红白蓝三个不同颜色的小球,乱序排列在一起,请重新排列这些小球,使得红白蓝三色的同颜色的球在一起。这个问题之所以叫荷兰国旗问题,是因为我们可以将红白蓝三色小球想象成条状物,有序排列后正好组成荷兰国旗。
解决思路 可以将排在中间的颜色定位目标num,将序列分割成三个部分:
左侧:小于num的部分
中间:等于num的部分
右侧:大于num的部分
荷兰国旗是对序列中特定数进行分割,如果将分割粒度细化到长度为1的序列,对每个数都进行分割,就会完成序列的升序排序
partition操作
完成一次荷兰国旗问题,就完成了一次partition操作,也就是完成了对特定num的划分。至于特定num选谁?这又是一个问题。
每次都选待分割序列的最后一个数,待分割完成后,将特定num与大于num区域的第一个数交换
随机选择待分割序列的任意一个数,将其与最后一个数进行交换后,进行分割,然后将特定num与大于num区域的第一个数交换
void QSort(vector<int>& target,
int beg, int end) {
//base case
if (beg >= end)
return;
auto index = parition(target, beg, end);
QSort(target, beg, index.first);
QSort(target, index.second, end);
}
//return the low bound of small area and upper bound of large area
pair<int,int> parition(vector<int>& target,
int beg, int end) {
auto small_point = beg, large_point = end - 1;
//version 2.0
//auto num = target[end];
//version 3.0
random_device rd;
//获取[beg,end]范围的随机数
uniform_int_distribution<int> dis(beg, end);
default_random_engine gen{ rd() };
std::swap(target[dis(gen)], target[end]);
while (beg <= large_point) {
if (target[beg] < num)
swap(target, beg++, small_point++);
else if (target[beg] == num)
++beg;
else
swap(target, beg, large_point--);
}
swap(target[end], target[++large_point]);
return make_pair( --small_point,++large_point );
}
时空复杂度
时间:基于特定num的选取,快排的时间复杂度有两种情况
最坏复杂度:如果每次选取的基数num的最终位置都处于序列的两极,比如最左或最右侧,那么每次partition只能分割出两个区域。假设num每次都处于最右侧,那么每次都只能分割出两个区域,每次都将num放置正确位置的比较次数分别为$n-1、n-2、n-3... 1$,那么partition
N
次的代价为$O(N^2)$最好复杂度:如果每次选取的num都能将序列正好划分为两半,应用master公式,那么最好的就是$O(N* log N)$
空间:空间复杂度同样分为最好和最差
最差:最差的情况出现与num处于极端位置,那么递归树的高度为(n-1),即空间复杂度为$O(N)$
最好:num每次都将序列分成两半,递归数高度为$O(log N)$,因为每次二分,二分$log N$次,序列长度变为1
改进:可以改进的地方就是num的选取,每次都随机选取的话,那么平均时空复杂度就为:$O(N*log N)$和$O(log N)$,当然由于概率问题,这是最后的期望值
堆排序
什么是堆我也不说了,大家都懂,这里讲讲大根堆的两个基本操作。以大根堆为例。heapInsert
操作将index处的数重新调整位置,将堆重新调整为大(小)根堆。heapify
操作将index处的数重新调整,同样将堆重新调整为大(小)跟堆。
heapInsert(插入操作)
在index处插入新值,默认在[0...index-1]
已经是一个堆了,插入后需要重新调整在[0...index]
范围的堆
``` c++ "大根堆" void heapInsert(vector& target,int index){ while(target[index] > target[(index-1)/2]){ swap(target[index],target[(index-1)/2]); //交换后需要更新index index = (index-1)/2; } }
当当前节点的值大于其父节点的值时,才会交换二者的值,当index达到堆顶点时,`target[index]=target[(index-1)/2]`,会终止循环
**时间复杂度**
由于堆的高度为$log N$,所以一个数从堆底部到达堆顶只需要交换$log N$次,复杂度即$O(log N)$
> 当数组已经达到最大时,需要将数组扩容才能继续插入数据构建大根堆,假设当前数组长度为1,一次扩大一倍,总共需要扩容$log N$次至N的大小。
当扩容完成后,还需将原数组的数据拷贝至新扩容的数组,是$O(N)$的复杂度,总的扩容复杂度为$O(N*log N)$ ,平均到每个元素,也是$\frac{O(N*log N)}{N}=O(log N)$
### heapify(弹出操作)
{% note info %}
`heapify`仅仅完成index处的调整操作,同样无需关心堆的大小
{% endnote %}
所要做的就是调整index处的值,使堆重新调整大根堆
``` c++
void heapify(vector<int>& target,int index,int heapSize){
auto left = index*2 + 1;
//当左子节点存在时
while(left< heapsize){
//在子节点中挑出大的
auto largest= (left+1)<heapsize && target[left]<target[left+1]? left+1 : left;
//在index和其较大子节点中挑出大的
largest = target[index]< target[largest]? largest:index;
//如果最大的就是index本身,无需继续调整
if(largest == index)
break;
swap(target[targest],target[index]);
//如果最大的值是index子节点,将二者交换,并将index更新为largest
index = largest;
left = index*2 +1;
}
}
时间复杂度 同样的,一个数从堆顶到达堆底,所需的操作次数也为堆的高度:$log N$次,所以弹出操作的时间复杂度同样为$O(log N)$
堆排序的完整过程
大根堆的构建
常规方法$(O(N*logN))$ 有了堆的两个基本操作,堆排序就很简单了。如果给定一个序列,将序列中的值依次插入堆中,就可构建一个大根堆。
for(auto index=0;index<target.size();++index){//O(N)
heapInsert(target,index);//O(log N)
}
时间复杂度为$O(NlogN)$,因为每插入一个节点其最差都可能走到堆顶点,复杂度为$log N$,所以插入N个节点的时间复杂度为$O(Nlog N)$
高效方法$(O(N))$ 上述常规方法是基于一次只给一个值的情况构建一个大根堆,如果一次给了所以所有值,可以假设现在已经有了一个堆,只需要使用heapify
操作快速构建大根堆,值的调整顺序从下往上,从右往左。这样保证了每一颗子树都是大根堆。
for(auto index=target.size()-1;index >= 0;--index){
//假设现在已经有个一个target.size()的堆,只需调整即可
heapify(target,index,target.size());
}
时间复杂度为$O(N)$,计算过程有点复杂,以后有时间再讲。至于为什么不是$O(N*log N)$,因为每个节点不一定都移动$log N$次。比如最下层节点,最坏情况也是移动0次,跟常规方法不同。
大根堆构建后的排序过程
现在已经拿到了一个大根堆,每次将堆顶点依次和堆的最后一个顶点交换结合,即最大值放到序列后侧,当大根堆的大小为0时,就完成了堆排序
while(heapSize){//O(N)
swap(target[0],target[heapSize-1]);
heapify(target,0,--heapSize);//O(log N)
}
时间复杂度为$O(Nlog N)$,因为每个值最差时都需要调整$log N$次,所以总的时间复杂度为$O(N logN)$
堆排序复杂度
时间:$O(N*log N)$
空间:$O(1)$
堆结构
什么时候用系统的,什么时候需要自己写? 除非需要在数据插入到堆后,还要更改这个数据的大小,否则一般情况都可以用系统实现的堆结构
比较器 比较器就是比较规则,一般遵循一下规定:
返回正数,第二个数据排在前面
返回负数,第一个数据排在前面
0, 返回谁无所谓
相关问题
已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序给定一个int数组A,同时给定A的大小n和题意中的k,请返回排序后的数组。
void almost_heapify(vector<int>& target, int index, int heapSize) {
auto left = index * 2 + 1;
while (left < heapSize) {
auto minst = left + 1 < heapSize && target[left + 1] < target[left] ? left + 1 : left;
minst = target[index] < target[minst] ? index : minst;
if (minst == index)
break;
swap(target[index], target[minst]);
index = minst;
left = index * 2 + 1;
}
}
void almost_heapInsert(vector<int>& target, int elem, int index) {
target[index] = elem;
while (target[index] < target[(index - 1) / 2]) {
swap(target[index], target[(index - 1) / 2]);
index = (index - 1) / 2;
}
}
void almost_sort(vector<int>& target, int k) {
//首先建立大小为(k+1)的小根堆
vector<int> temp(k+1);
int heapSize = 0;
for (auto index = 0; index < k+1; ++index) {
almost_heapInsert(temp, target[index], heapSize++);
}
//滑动窗口n次
auto right_point = k+1;
for (auto index = 0; index < (int)target.size(); ++index) {
//heapSize = (index + 3) > (target.size() - 1) ? heapSize - 1 : heapSize;
target[index] = temp[0];
swap(temp[0], temp[heapSize - 1]);
almost_heapify(temp, 0, --heapSize);
//当index+k == target.size()-1时,已经没有数可以入堆了,即最后一个长度为(K+1)的窗口已经处理完了
if((index + k) < (target.size() - 1))
almost_heapInsert(temp, target[right_point++], heapSize++);
}
}
需要注意的地方有俩点:
移动不会超过k个距离,则需要准备大小为(k+1)的小根堆,因为要包括自身,如从0位置移动3位置,即总长度为4
最后一个窗口需要额外处理,当处理晚最后一个长度为(k+1)的窗口时,就没有新数据进行heapInsert了。
Last updated
Was this helpful?