数据结构与算法学习cpp(三)
1、概述
对于数据量较小的排序,使用冒泡、插入、选择即可,时间复杂度为 O ( n 2 ) O(n^2) O(n2),数据量较大时会很耗时。本文介绍时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的三种排序:希尔排序、归并排序、快速排序。
其中,希尔排序属于插入排序,在普通插入排序上作了策略性的改进;而归并、快速排序则使用了分治的思想。
2、希尔排序
2.1 思路
希尔排序是将待排序的数组元素 按下标的一定增量分组 ,分成多个子序列,然后对各个子序列进行直接插入排序算法排序;然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。
最后一次排序,增量必为1。
示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}
进行希尔排序。
说明:本文只为了解希尔排序本身机制,故此处只选用希尔增量,希尔增量为{ N 2 , N 4 , . . . N 2 k , 1 \frac{N}{2},\frac{N}{4},...\frac{N}{2^k},1 2N,4N,...2kN,1}可根据实际需要改变增量。
初始步长为5,分出5个子数组(已用不同颜色区分),对每个子数组插入排序;
步长逐步变化,重复上述步骤。
为何能降低时间复杂度
比如例子中的初始第9位元素:5,如果执行普通插入排序,要插到第一个需要挨个比较,移动,但是使用图示的方法,第一次排序就直接排到了第4位,相比而言,省去了很多操作步骤。
整体策略相当于,先粗略的把大小相近的数放在一起,然后逐步细化排序,而简单排序则是不管数据如何分布,都是从头排序,相比较而言,希尔排序更优秀。
由于数学水平有限,只能作粗略的定性分析,如果要深入理解,需要查阅相关文献。
2.2 cpp代码(希尔增量 )
新建类:
class Complexsort
{
public:
void PrintResult(int* Arr, int n);
void GetShellGap(vector<int> &Gap, int n);
void ShellSort(const vector<int>& Gap,int* Arr, int n);
};
**获取增量:**可根据实际情况更改此函数来使用不同增量策略;
void Complexsort::GetShellGap(vector<int> &Gap, int n)
{
if (n <= 1)
{
return;
}
Gap.clear();
for (int i = n >> 1; i >= 1; i = i >> 1)//右移1即除以2
{
Gap.push_back(i);
}
cout << "shell gap :" << endl;
for (vector<int>::iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)
{
cout << setw(4) << *itergap;
}
cout << endl;
}
排序实现:
void Complexsort::ShellSort(const vector<int>& Gap, int* Arr, int n)
{
if (n <= 1|| Arr==nullptr|| Gap.empty())
{
return;
}
for (vector<int>::const_iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)//每次执行完更改步长
{
for (int i = *itergap; i < n; i++)//分组,共*itergap组,每组插入排序从第二个元素开始,所以初始i = *itergap;
{
//每一组内部执行插入排序
int InsertVal = Arr[i];
int j = i - *itergap;
for (; j >= 0; j -= *itergap)
{
if (Arr[j] > InsertVal)
{
Arr[j+*itergap] = Arr[j];
}
else
{
break;//如果在已排序区间内找到<=插入值的数,则退出
}
}
Arr[j+*itergap] = InsertVal;
}
}
}
测试:
int main()
{
int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
int len = 11;
Complexsort Comsort;
cout << "testA :" << endl;
Comsort.PrintResult(TestA, len);
vector<int> Gap;
Comsort.GetShellGap(Gap, len);
Comsort.ShellSort(Gap,TestA, len);
cout << "sort result :" << endl;
Comsort.PrintResult(TestA, len);
}
结果:
说明:
如果将这里的步长设为1,则这里的子数组排序算法与数据结构与算法学习cpp(二):冒泡排序、插入排序、选择排序中的插入排序代码相同。
如果不习惯j出现负值,可将代码修改如下
void Complexsort::ShellSort(const vector<int>& Gap, int* Arr, int n)
{
if (n <= 1 || Arr == nullptr || Gap.empty())
{
return;
}
for (vector<int>::const_iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)//每次执行完更改步长
{
for (int i = *itergap; i < n; i++)//分组,共*itergap组,每组插入排序从第二个元素开始,所以初始i = *itergap;
{
//每一组内部执行插入排序
int InsertVal = Arr[i];
unsigned int j = i ; //j不会为负
for (; j >= *itergap; j -= *itergap)
{
if (Arr[j - *itergap] > InsertVal)
{
Arr[j ] = Arr[j- *itergap];
}
else
{
break;//如果在已排序区间内找到<=插入值的数,说明已找到位置,退出
}
}
Arr[j] = InsertVal;
}
}
}
意思相同。
2.3 性能
稳定性: 由于每次步长不同,很容易交换相同大小的元素位置,所以为不稳定;
空间复杂度: 未申请新空间,为 O ( 1 ) O(1) O(1);
时间复杂度: 希尔排序的子数组都是用插入排序,若使用希尔增量且为最坏情况,则时间复杂度也为 O ( n 2 ) O(n^2) O(n2),平均复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),不知如何证明平均复杂度,故只做了解。不同增量的时间复杂度也不同,如何选取增量是一个数学难题。
3、归并排序
3.1 思路
分治:
先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起。
递归思维:
将大问题分解成小问题,小问题继续分解成更小的问题,只要一层的问题模型是一样的,就可以一直进行下去,直到满足终止条件。
所以只需要关注:1、递推公式;2、终止条件;
思维误区:
递归是一种从顶向下的思维,而我们分析问题时,习惯性的先把当前问题解决,然后将返回值代入到下一层问题去解,这样在跟逻辑的时候,很容易被一层层的问题绕进去。
我们正常思维更像是是一种从底向上的方式,所以我们更容易理解迭代的逻辑,只是要实现迭代的逻辑需要把握整体规律,而递归只需要处理单个问题模型即可。
所以递归代码实现简单,但是想跟每一步的处理逻辑很难;迭代的代码实现起来困难,但是逻辑符合我们的思维方式,理解起来容易。
示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}
进行归并排序。
图示:
用偶数个数的排序,作图分析更方便,不过为了说明情况,在此用奇数举例。图中可以看出,奇数个数排序的情况下,有些数连续两步都没有变化,如初始第2、5、8、10个元素。看起来比较特殊,但是实际上这跟偶数个数排序是一样的,因为分割到最后都是单个元素,然后再逐步合并。
归并:
归并的主要思想是申请一个临时数组,定义两个指针分别操作两个待合并的数组,依次比较两个指针指向元素大小,选出小的放进临时数组,然后将当前指针移动到下一位继续比较,直到其中一个数组的指针到底部,此时如果另外一个数组还有元素未处理完,则直接复制到临时数组。最后将临时数组覆盖原数组即可。
由于是递归,所以待合并的两个数组,默认其内部已经完成从小到大的排序。
3.2 cpp代码
3.2.1递归方式
为了清楚地显示排序过程,每一次归并开始都打印了数组起始情况及归并结果。
归并:
//合并并排序数组
void Complexsort::Merge(int* Arr, int Lstart, int Lend, int Rstart, int Rend)
{
static int step = 1;
cout << "step"<< step++ <<":" << endl;
cout << " Lstart = " << Lstart << " Lend = " << Lend <<" **** Rstart = " << Rstart << " Rend = " << Rend << endl;
int i = Lstart;
int j = Rstart;
int n = Rend - Lstart + 1;
int* temp = new int[n];
int k = 0;
while (i <= Lend && j <= Rend)
{
if (Arr[i] < Arr[j])//把小的放进temp
{
temp[k++] = Arr[i++];
}
else
{
temp[k++] = Arr[j++];
}
}
/*如果两个数组中有一个指针没有遍历完,则直接复制该数组剩余元素(该数组在上一步已经排序完成)*/
while (i <= Lend)
{
temp[k++] = Arr[i++];
}
while (j <= Rend)
{
temp[k++] = Arr[j++];
}
cout << "cur Arr = ";
for (k = 0; k < n; k++)
{
Arr[Lstart + k] = temp[k];
cout << setw(4) << Arr[Lstart + k];
}
delete [] temp;
cout << endl<<endl;
}
递归排序:
//由于递归操作,每次数组的下标不确定,所以需要传入当前数组的下标范围
void Complexsort::RecurseMergeSort(int* Arr, int start, int end)
{
if (start>= end ||Arr == nullptr)//只有一个元素无需排序
{
return;
}
//将传入的数组对半分
int LeftStart = start;
int LeftEnd = (start+end)/2;
int RightStart = LeftEnd + 1;
int RightEnd = end;
//对数组左右两部分排序后合并
RecurseMergeSort(Arr, LeftStart, LeftEnd);
RecurseMergeSort(Arr, RightStart, RightEnd);
Merge(Arr, LeftStart, LeftEnd, RightStart, RightEnd);
}
测试:
int main()
{
int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
int len = 11;
Complexsort Comsort;
cout << "testA :" << endl;
Comsort.PrintResult(TestA, len);
cout << endl;
Comsort.RecurseMergeSort(TestA,0,len-1);
cout << "sort result :" << endl;
Comsort.PrintResult(TestA, len);
}
结果:
从结果可以看出,归并时候先归并0 ~ 5,再归并6~10。
3.2.2迭代方式
递归调用次数多,会调用大量的栈空间,且容易出现重复计算的情况,影响性能,可将排序用迭代方式来实现。
迭代归并排序:
void Complexsort::IterMergeSort(int* Arr, int n )
{
if (n <= 1 || Arr == nullptr)//只有一个元素无需排序
{
return;
}
for (int step = 1; step < n; step <<=1)
{
//将数组分成2*step大小的小块
for (int index = 0; index < n; index += step * 2 )
{
Merge(Arr, index, min(index + step - 1, n - 1), min(index + step , n - 1), min(index + step + step - 1, n - 1));
}
}
}
测试:
int main()
{
int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
int len = 11;
Complexsort Comsort;
cout << "testA :" << endl;
Comsort.PrintResult(TestA, len);
cout << endl;
Comsort.IterMergeSort(TestA, len);
cout << "sort result :" << endl;
Comsort.PrintResult(TestA, len);
}
结果:
迭代和递归的方式在分组上有所不同。递归是从顶向下,逐步分割,最后分割成单个元素,再两两重组;而迭代是自下而上,直接从单个元素开始,两两组合。
3.3 性能
仅分析递归方式。
稳定性:
归并时,如果前后位置已拍好,不会再作改变,稳定。
时间复杂度:
根据递推公式得:
T
(
n
)
=
2
∗
T
(
n
/
2
)
+
n
T(n) = 2*T(n/2) + n
T(n)=2∗T(n/2)+n
=
2
∗
(
2
∗
T
(
n
/
4
)
+
n
/
2
)
+
n
= 2*(2*T(n/4) + n/2) + n
=2∗(2∗T(n/4)+n/2)+n
=
4
∗
T
(
n
/
4
)
+
2
∗
n
= 4*T(n/4) + 2*n
=4∗T(n/4)+2∗n
=
4
∗
(
2
∗
T
(
n
/
8
)
+
n
/
4
)
+
2
∗
n
=
8
∗
T
(
n
/
8
)
+
3
∗
n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
=4∗(2∗T(n/8)+n/4)+2∗n=8∗T(n/8)+3∗n
=
8
∗
(
2
∗
T
(
n
/
16
)
+
n
/
8
)
+
3
∗
n
=
16
∗
T
(
n
/
16
)
+
4
∗
n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
=8∗(2∗T(n/16)+n/8)+3∗n=16∗T(n/16)+4∗n
.
.
.
.
.
.
......
......
=
2
k
∗
T
(
n
/
2
k
)
+
k
∗
n
= 2^k * T(n/2^k) + k * n
=2k∗T(n/2k)+k∗n;
令
n
=
2
k
n= 2^k
n=2k,则
k
=
l
o
g
2
n
k=log_2n
k=log2n;
T
(
2
k
)
=
2
k
∗
T
(
1
)
+
k
∗
2
k
T(2^k) =2^k * T(1)+k*2^k
T(2k)=2k∗T(1)+k∗2k;
T
(
n
)
=
n
∗
T
(
1
)
+
n
l
o
g
2
n
=
o
(
n
l
o
g
n
)
T(n) =n* T(1)+nlog_2n=o(nlogn)
T(n)=n∗T(1)+nlog2n=o(nlogn);
空间复杂度:
每次归并申请临时空间后删除,为
o
(
n
)
o(n)
o(n);
4、快速排序
4.1 思路
如果要排序数组中下标从 start 到 end之间的一组数据,我们选择 start 到 end之间的任意一个数据作为 pivot(枢轴)。
我们遍历 start 到 end 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 start 到 end之间的数据就被分成了三个部分,前面 start 到 pivot-1之间都是小于 pivot 的,中间是 pivot,后面的 pivot+1 到 end 之间是大于 pivot 的。
关键是找到枢轴pivot ,把它放在正确位置。
示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}
进行归并排序。
单次操作图示:
如何将多次操作组合起来完成整个数组排序,常见有以下两种方式:
4.1.1 递归方式
根据分治、递归的处理思想,我们可以用递归排序下标从 start 到 pivot-1之间的数据和下标从 pivot+1 到 end 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
4.1.2 栈方式
归并迭代与递归的思考方式相反,因为我们能判断出,归并排序中分解的最终状态一定是单个元素,所以我们从底向上思考,让他们按步长两两排序即可。但是对于快速排序,用这种方式思考比较困难,因此还是按从上向下的方式思考问题,用栈来代替递归。
用栈保存每一个待排序子串的首尾元素下标,下一次while循环时取出这个范围,对这段子序列进行partition操作。
当然,也有大神实现了这个算法,本文暂不研究,留坑以后研究,链接供参考:
知乎:如何用非递归、不用栈的方法,实现原位(in-place)的快速排序?
4.2 cpp代码
找枢轴:
int Complexsort::Partion(int* Arr, int start, int end)
{
int i = start, j = end;//两个指针分别从头尾向相向出发
int pivot = start;//选取基准点,如果选择左边,则要右边指针先动
//打印信息
cout <<"start = "<<start<<", end = "<<end<< ", Arr[start] = Arr["<<start<<"] = "<< Arr[start] << ":" << endl << "before partion, Arr = ";
PrintResult(Arr, 11);
while (i < j)
{
while (i < j && Arr[j] >= Arr[pivot]) //从后向前一直找到比基准点小的值
{
j--;
}
while (i < j && Arr[i] <= Arr[pivot])//从前向后一直找比基准点大的值
{
i++;
}
swap(Arr[i], Arr[j]);
}
pivot = i;
swap(Arr[i], Arr[start]);//将pivot放入正确位置
//打印信息
cout << "pivot = " << i << endl;
cout << "after partion, Arr = ";
PrintResult(Arr, 11);
cout << endl;
return pivot;
}
4.2.1递归方式
递归排序:
void Complexsort::RecurseQuickSort(int* Arr, int start, int end)
{
int pivot;
if (start < end)
{
pivot = Partion(Arr, start, end);
RecurseQuickSort(Arr,start, pivot-1);
RecurseQuickSort(Arr, pivot + 1, end);
}
}
测试:
int main()
{
int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
int len = 11;
Complexsort Comsort;
cout << "testA :" << endl;
Comsort.PrintResult(TestA, len);
cout << endl;
Comsort.RecurseQuickSort(TestA,0,10);
cout << "sort result :" << endl;
Comsort.PrintResult(TestA, len);
}
结果:
从打印结果可看出整体步骤:
1、先确定元素158的位置为9,然后对第0-8和第10个位置操作,由于右侧只有一个元素,所以无需排序;
2、确定元素23的位置为3,然后对第0-2和第4-8个位置元素继续操作
…以此类推,直到start == end,即只有一个元素时,操作停止。
4.2.2 栈方式
据说stl里的栈在这里用性能不佳(未测试),因此自己实现,正好加深一下入栈出栈的理解。
栈方式排序:
void Complexsort::IterQuickSort(int* Arr, int start, int end)
{
int mystack[100] = {0};
int top = -1;
mystack[++top] = start;
mystack[++top] = end;
while (top >= 0)
{
end = mystack[top--];
start = mystack[top--];
int pivot = Partion(Arr, start, end);//找枢轴
//枢轴左侧大于1个元素则对左侧进行操作,仅剩一个元素或没有则无需操作
if (pivot - 1 > start)
{
mystack[++top] = start;
mystack[++top] = pivot - 1;
}
//右侧同理
if (pivot + 1 < end)
{
mystack[++top] = pivot + 1;
mystack[++top] = end;
}
}
}
测试:
int main()
{
int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
int len = 11;
Complexsort Comsort;
cout << "testA :" << endl;
Comsort.PrintResult(TestA, len);
cout << endl;
Comsort.IterQuickSort(TestA, 0, 10);
cout << "sort result :" << endl;
Comsort.PrintResult(TestA, len);
}
结果:
说明:
1、栈使用,注意入栈先加后赋值,出栈先取值后减;
2、从结果可看出,辅助栈方式与递归方式的实现过程几乎相同,顺序略有不同,不过性能上使用辅助栈的方式应该比递归要好(未实测),因为除了多了下标的存储,并未存储多余的函数栈;
4.3 性能
仅分析递归方式。
稳定性:
已有顺序会改变,不稳定。
时间复杂度:
与归并思路相似,正常情况下时间复杂度均为
o
(
n
l
o
g
n
)
o(nlogn)
o(nlogn),但是假设从小到大排序,初始选择的基准点为最大,则快排就退化成了冒泡排序,为
o
(
n
2
)
o(n^2)
o(n2);
为减少出现最坏情况概率,可以选则基准点时运用一些策略,如取随机数,三点取中等,具体做法是按策略选择数组下标,然后将该元素与最左边元素调换,剩下步骤跟上述处理相同。
空间复杂度:
快排的递归与归并的递归略有不同。
消耗栈空间的都是算法核心部分,归并为Merge
,快排为Partion
,但是位置不同,导致归并中本次递归的栈释放了才执行下一次递归,但是快排没有(这也是为什么快排的迭代不好从底向上思考的原因,只能按照递归的从上向下的思想,用辅助栈去实现),所以栈每递归一次增加一次。(水平有限,不知道如何用准确的数学语言描述)
快速排序为原地排序,单次递归为 O ( 1 ) O(1) O(1),共递归 o ( l o g n ) o(logn) o(logn)次,所以总的空间复杂度为 o ( l o g n ) o(logn) o(logn);最坏情况下递归n次,为 O ( n ) O(n) O(n)。
5、总结
1、实际处理较大数据量的排序时,可以将这些算法相结合,libc库自带qsort用的就是这种策略;我们从希尔排序中也能体会到这一点,按策略划分后对子数组使用插入排序;
2、如果对处理效率很敏感,则应根据实际情况选择适当策略,如希尔排序的增量选择,快速排序的基准数选择等。
gitee代码