热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

C++不知算法系列之聊聊希尔、归并排序算法中的分治哲学

1.前言排序算法中,冒泡、插入、选择属于相类似的排序算法,这类算法的共同点:通过不停地比较,再使用交换逻辑重新确定数据的位




1. 前言

排序算法中,冒泡插入选择属于相类似的排序算法,这类算法的共同点:通过不停地比较,再使用交换逻辑重新确定数据的位置。

希尔归并快速排序算法也可归为同一类,它们的共同点都是建立在分治思想之上。把大问题分拆成小问题,解决所有小问题后,再合并每一个小问题的结果,最终得到对原始问题的解答。



Tips: 通俗而言:化整为零,各个击破。


分治算法很有哲学蕴味:老祖宗所言 合久必分,分久必合,分开地目的是为了更好的合并。

分治算法的求解流程:


  • 分解问题:将一个需要解决的、看起很复杂 原始问题 分拆成很多独立的**子问题**,子问题原始问题有相似性。

  • 求解子问题:子问题除了与原始问题具有相似性,也具有独立性,即所有子问题都可以独立求解。

  • 合并子问题: 合并每一个子问题的求解结果最终可以得到原始问题的解。

下面通过深入了解希尔排序算法,看看分治算法是如何以哲学之美的方式工作的。


2. 希尔排序

讲解希尔之前,先要回顾一下插入排序。插入排序的平均时间复杂度,理论上而言和冒泡排序是一样的 O(n2),但如果数列是前部分有序,则每一轮只需比较一次,对于 n 个数字的原始数列而言,时间复杂度可以达到 O(n)

插入排序的时间复杂度为什么会出现如此有意思的变化?


  • 插入排序算法的排序思想是尽可能减少数字之间的交换次数
  • 通常情形下,交换处理的时间大约是移动的 3 倍。这便是插入排序的性能有可能要优于冒泡排序的原因。

希尔排序算法本质就是插入排序,或说是对插入排序的改良。

希尔算法的理念:让原始数列不断趋近于排序,从而降低插入排序的时间复杂度。当数列局部有序时,全局必然是趋向于有序。

希尔排序的实现流程:


  • 把原始数列从逻辑上切割成诸多个子数列。
  • 对每一个子数列使用插入排序算法排序。
  • 当所有子数列完成后,再对原数列进行最后一次插入算法排序。

希尔排序的关键在于如何切分子数列,切分方式可以有 2 种:


2.1 前后切分

如有原始数列=[3,9,8,1,6,5,7] ,采用前后分可分成如下图所示的 2 个子数列。

s01.png

然后对前、后部分的数列使用插入算法排序。

s02.png

如上图所示,子数列排序后,要实现原始数列的最终有序,则后部分的数字差不多全部要以超车的方式,插入到前部分数字的中间,交换量较大。

理想的状态是数字整体有序,需要交换的次数不多。所以前后分这种一根筋的切分方式,对于原始问题的最终性能优化起不了太多影响。


2.2 增量切分

增量切分采用间隔切分方案,可能让数字局部有序以正态分布。

增量切分,需要先设定一个增量值。如对原始数列=[3,9,8,1,6,5,7] 设置切分增量为 3 时,整个数列会被切分成 3 个逻辑子数列。增量数也决定最后能切分多少个子数列。

s03.png

对切分后的 3 个子数列排序后可得到下图。

s04.png

下面两张图是增量切分前后数字位置的变化图,可以看出来,几乎所有的数字都产生了位置变化 ,且位置变化的跨度较大。如数字 9 原始位置是 1,经过增量切分再排序后位置可以到 4,已经很接近 9 的最终位置 6 了。有整体趋于有序的势头,在此基础之上,再进行插入排序的的次数要少很多。

s16.png

s18.png

实现希尔排序算法时,最佳的方案是先初始化一个增量值,切分排序后再减少增量值,如此反复直到增量值等于 1 (也就是对原数列整体做插入排序)。



Tips: 增量值大,数字位置变化的跨度就大,增量值小,数字位置的变化会收紧。


编码实现希尔排序:

#include
using namespace std;
// 插入排序
void insertSort(int nums[],int size, int start,int increment) {
//后指针指向原数列的第 2 个数字,所以索引号从 1 开始
for(int backIdx=start + increment; backIdx // 初始,前指针和后指针的关系,
int frontIdx = backIdx;
while(frontIdx>=0 && nums[frontIdx] //交换
int tmp=nums[frontIdx];
nums[frontIdx]=nums[frontIdx-increment];
nums[frontIdx-increment]=tmp;
}
}
}
// 希尔排序
void shellSort(int nums[],int size) {
// 增量
int increment=size/2;
// 新数列
while (increment > 0) {
// 增量值是多少,则切分的子数列就有多少
for(int start=0; start insertSort(nums,size, start, increment);
}
// 修改增量值,直到增量值为 1
increment = increment / 2;
}
}
int main(int argc, char** argv) {
int nums[] = {3, 9, 8, 1, 6, 5, 7};
int size=sizeof(nums)/4;
shellSort(nums,size);
for(int i&#61;0; i cout< }
return 0;
}

这里会有一个让人疑惑的观点&#xff1a;难道一次插入排序的时间复杂度会高于多次插入排序时间复杂度&#xff1f;

通过切分方案&#xff0c;经过子数列的微排序&#xff08;因子数列数字不多&#xff0c;其移动交换量也不会很大&#xff09;&#xff0c;最后一次插入排序只需要在几个数字之间微调&#xff0c;甚至不需要。只要增量选择合适&#xff0c;时间复杂度可以控制 在 O(n) 到 O&#xff08;n2&#xff09;之间。完全是有可能优于单纯的使用一次插入排序。


3. 归并排序

归并排序算法也是基于分治思想。和希尔排序一样&#xff0c;需要对原始数列进行切分&#xff0c;但是切分的方案不一样。

相比较希尔排序&#xff0c;归并排序的分解子问题&#xff0c;求解子问题&#xff0c;合并子问题的过程分界线非常清晰。可以说&#xff0c;归并排序更能完美诠释什么是分治思想。


3.1 分解子问题

归并排序算法的分解过程采用二分方案。


  • 把原始数列一分为二。

  • 然后在已经切分后的子数列上又进行二分。

  • 如此反复&#xff0c;直到子数列不能再分为止。

    如下图所示&#xff1a;

s05.png

如下代码&#xff0c;使用递归算法对原数列进行切分&#xff0c;通过输出结果观察切分过程&#xff1a;

#include
using namespace std;
// 切分原数列
void splitNums(int nums[],int start,int end ) {
int size&#61;end-start;
for(int i&#61;start; i cout< cout< if (size>1) {
// 切分线&#xff0c;中间位置
int spLine &#61; size / 2;
splitNums(nums,start,spLine&#43;start);
splitNums(nums,spLine&#43;start,end );
}
}
int main(int argc, char** argv) {
int nums[] &#61; {3, 9, 8, 1, 6, 5, 7};
int size&#61;sizeof(nums)/4;
splitNums(nums,0,size);
return 0;
}

输出结果&#xff1a; 和上面演示图的结论一样。

s19.png


3.2 求解子问题

因为已经切分到了原子性&#xff0c;可认为子数列是有序的。然后对相邻2 个子数列进行合并&#xff0c;合并后要保证数字依然有序。

如何实现 2 个有序子数列合并后依然有序&#xff1f;

使用首数字比较算法进行合并排序。如下图演示了如何合并 nums01&#61;[1,3,8,9]、nums02&#61;[5,6,7] 2 个子数列。

s07.png


  • 数字 1 和 数字 5 比较&#xff0c;5 大于 1 &#xff0c;数字 1 先位于合并数列中。

s08.png


  • 数字 3 与数字 5 比较&#xff0c;数字 3 先进入合并数列中。

s09.png


  • 数字 8 和数字 5 比较&#xff0c;数字 5 进入合并数列中。

s10.png


  • 重复上述过程&#xff0c;比较首数字的大小。最后&#xff0c;可以保证合并后的数列是有序的。

s11.png


3.3 归并子问题

前面是分步讲解切分和合并逻辑&#xff0c;现在把切分和合并逻辑合二为一&#xff0c;完成归并算法的实现。

#include
using namespace std;
int nums_[] &#61; {3, 9, 8, 1, 6, 5, 7};
// 切分原数列
void splitNums(int nums[],int start,int end ) {
int size&#61;end-start;
if (size>1) {
// 切分线&#xff0c;中间位置
int spLine &#61; size / 2;
//前子数组的绝对大小
int s1&#61;spLine;
//后子数组的绝对大小
int s2&#61;end-spLine-start;
//前面的子数组
int nums01[s1];
int idx&#61;0;
//切分原数组,注意相对位置
for(int i&#61;start; i nums01[idx]&#61;nums_[i];
idx&#43;&#43;;
}
int nums02[s2];
idx&#61;0;
for(int i&#61;spLine&#43;start; i nums02[idx]&#61;nums_[i];
idx&#43;&#43;;
}
splitNums(nums01,start,spLine&#43;start);
splitNums(nums02,spLine&#43;start,end );
// 为 2 个数列创建 2 个指针
int idx_01 &#61; 0;
int idx_02 &#61; 0;
int k &#61; 0;
while (idx_01 if (nums01[idx_01] > nums02[idx_02]) {
// 合并后的数字要保存到原数列中
nums[k] &#61; nums02[idx_02];
idx_02 &#43;&#61; 1;
} else {
nums[k] &#61; nums01[idx_01];
idx_01 &#43;&#61; 1;
}
k &#43;&#61; 1;
}
// 检查是否全部合并
while (idx_02 nums[k] &#61; nums02[idx_02];
idx_02 &#43;&#61; 1;
k &#43;&#61; 1 ;
}
while (idx_01 nums[k] &#61; nums01[idx_01];
idx_01 &#43;&#61; 1;
k &#43;&#61; 1;
}
}
}
int main(int argc, char** argv) {
int size&#61;sizeof(nums_)/4;
splitNums(nums_,0,size);
cout<<"归交排序&#xff1a;"< for(int i&#61;0;i cout< }
return 0;
}

输出结果&#xff1a;

s20.png

从归并算法上可以完整的看到分治理念的哲学之美。


4. 基数排序

基数排序&#xff08;radix sort&#xff09;属于“分配式排序”&#xff08;distribution sort&#xff09;&#xff0c;又称“桶子法”&#xff08;bucket sort&#xff09;bin sort



Tips&#xff1a; 基数排序没有使用分治理念&#xff0c;放在本文一起讲解&#xff0c;是因为基数排序有一个对数字自身切分逻辑。


基数排序的最基本思想&#xff1a;

如对原始数列 nums &#61; [3, 9, 8, 1, 6, 5, 7] 中的数字使用基数排序。


  • 先提供一个长度为 10 的新空数列&#xff08;本文也称为排序数列&#xff09;。



    Tips&#xff1a; 为什么新空数列的长度要设置为 10&#xff1f;等排序完毕&#xff0c;相信大家就能找到答案。


s12.png

。把原数列中的数字转存到新空数列中&#xff0c;转存方案&#xff1a;

nums 中的数字 3 存储在新数列索引号为 3 的位置。

nums 中的数字 9 存储在新数列索引号为 9 的位置。

nums 中的数字 8 存储在新数列索引号为 8 的位置。

​ ……

s13.png

从上图可知&#xff0c;原数列中的数字所转存到排序数列中的位置&#xff0c;是数字所代表的索引号所指的位置。显然&#xff0c;经过转存后&#xff0c;新数列就是一个排好序的数列。

编码实现&#xff1a;

#include
using namespace std;
int main(int argc, char** argv) {
// 原数列
int nums[] &#61; {3, 9, 8, 1, 6, 5, 7};
int size&#61;sizeof(nums)/4;
// 找到数列中的最大值
int maxVal&#61;nums[0];
for(int i&#61;1; i if( nums[i]>maxVal )
maxVal&#61;nums[i];
}
int sortNums[maxVal&#43;1]&#61; {0};
for (int i : nums) {
sortNums[i]&#61;i;
}
for (int i : sortNums) {
if(i!&#61;0)
cout< }
return 0;
}

上述排序的缺点&#xff1a;


  • 新空数列的长度定义为多大由原始数列中数字的最大值来决定。如果数字之间的间隔较大时&#xff0c;新数列的空间浪费就非常大。

如对 nums&#61;[1,98,51,2,32,4,99,13,45] 使用上述方案排序&#xff0c;新空数列的长度要达到 99 &#xff0c;真正需要保存的数字只有 7 个&#xff0c;如此空间浪费几乎是令人恐怖的。

所以&#xff0c;有必要使用改良方案。如果在需要排序的数字中出现了 2 位以上的数字&#xff0c;则使用如下法则&#xff1a;


  • 先根据每一个数字个位上的数字进行存储。个位数是 1 存储在位置为 1 的位置&#xff0c;是 9 就存储在位置是 9 的位置。如下图&#xff1a;

s14.png
可看到有可能在同一个位置保存多个数字。这也是基数排序也称为桶子法的原因。



Tips&#xff1a; 一个位置就是一个桶&#xff0c;可以存放多个具有相同性质的数字。如上图&#xff1a;个位上数字相同的数字就在一个桶中。



  • 把存放在排序数列中的数字按顺序重新拿出来&#xff0c;这时的数列顺序变成 nums&#61;[1&#xff0c;51&#xff0c;2&#xff0c;32&#xff0c;13&#xff0c;4&#xff0c;45&#xff0c;8&#xff0c;99]
  • 把重组后数列中的数字按十位上的数字重新存入排序数列。

s15.png

可以看到&#xff0c;经过 2 轮转存后&#xff0c;原数列就已经排好序。



Tips&#xff1a; 这个道理是很好理解的&#xff1a;现实生活中&#xff0c;我们在比较 2 个数字 大小时&#xff0c;可以先从个位上的数字相比较&#xff0c;然后再对十位上的数字比较。如此&#xff0c;无论是多少位的数字&#xff0c;都可以运用基数排序算法。


基数排序&#xff0c;很有生活的味道&#xff01;&#xff01;


编码实现基数排序&#xff1a; 下面代码使用递归实现。

#include
#include
using namespace std;
//排序用数组
int sortNums[10][10]&#61; {0};
void baseSort(int nums[],int size,int start,int depth) {
if(start&#61;&#61;depth) {
return;
}
//取位
for(int i&#61;0; i int wei&#61;pow(10,start);
int temp&#61;nums[i] / wei % 10;
for(int j&#61;0; j<10; j&#43;&#43;) {
if( sortNums[temp][j]&#61;&#61;0 ) {
sortNums[temp][j]&#61;nums[i] ;
break;
}
}
}
//取出排序的数据
int idx&#61;0;
for(int row&#61;0; row<10; row&#43;&#43;) {
for(int col&#61;0; col<10; col&#43;&#43;) {
if( sortNums[row][col]!&#61;0 ) {
nums[idx]&#61;sortNums[row][col];
sortNums[row][col]&#61;0;
idx&#43;&#43;;
}
}
}
//递归
baseSort(nums,size,start&#43;1,depth);
}
int main(int argc, char** argv) {
// 原数列
int nums[] &#61; {1, 98, 51, 2, 32, 114, 99, 13, 45};
int size&#61;sizeof(nums)/4;
int bakNums[size];
for(int i&#61;0; i bakNums[i]&#61;nums[i];
}
//找到最大值
int maxVal&#61;nums[0];
for(int i&#61;1; i if(nums[i]>maxVal) {
maxVal&#61;nums[i];
}
}
//计算最大值的位数
string str&#61; to_string(maxVal);
int depth&#61;str.size();
//基数排序
baseSort(nums,size,0,depth);
for(int i&#61;0; i cout< }
return 0;
}

输出结果&#xff1a;

s21.png

上述转存过程是由低位到高位&#xff0c;也称为 LSD &#xff0c;也可以先高位后低位方案转存MSD


5. 总结

分治很有哲学味道&#xff0c;当你遇到困难&#xff0c;应该试着找到问题的薄弱点&#xff0c;然后一点点地突破。

当遇到困难时&#xff0c;老师们总会这么劝解我们。分治其实和项目开发中的组件设计思想也具有同工异曲之处。







推荐阅读
  • HDU 2372 El Dorado(DP)的最长上升子序列长度求解方法
    本文介绍了解决HDU 2372 El Dorado问题的一种动态规划方法,通过循环k的方式求解最长上升子序列的长度。具体实现过程包括初始化dp数组、读取数列、计算最长上升子序列长度等步骤。 ... [详细]
  • 本文介绍了九度OnlineJudge中的1002题目“Grading”的解决方法。该题目要求设计一个公平的评分过程,将每个考题分配给3个独立的专家,如果他们的评分不一致,则需要请一位裁判做出最终决定。文章详细描述了评分规则,并给出了解决该问题的程序。 ... [详细]
  • 本文主要解析了Open judge C16H问题中涉及到的Magical Balls的快速幂和逆元算法,并给出了问题的解析和解决方法。详细介绍了问题的背景和规则,并给出了相应的算法解析和实现步骤。通过本文的解析,读者可以更好地理解和解决Open judge C16H问题中的Magical Balls部分。 ... [详细]
  • 本文讨论了使用差分约束系统求解House Man跳跃问题的思路与方法。给定一组不同高度,要求从最低点跳跃到最高点,每次跳跃的距离不超过D,并且不能改变给定的顺序。通过建立差分约束系统,将问题转化为图的建立和查询距离的问题。文章详细介绍了建立约束条件的方法,并使用SPFA算法判环并输出结果。同时还讨论了建边方向和跳跃顺序的关系。 ... [详细]
  • 本文介绍了一种划分和计数油田地块的方法。根据给定的条件,通过遍历和DFS算法,将符合条件的地块标记为不符合条件的地块,并进行计数。同时,还介绍了如何判断点是否在给定范围内的方法。 ... [详细]
  • 在Android开发中,使用Picasso库可以实现对网络图片的等比例缩放。本文介绍了使用Picasso库进行图片缩放的方法,并提供了具体的代码实现。通过获取图片的宽高,计算目标宽度和高度,并创建新图实现等比例缩放。 ... [详细]
  • 开发笔记:加密&json&StringIO模块&BytesIO模块
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了加密&json&StringIO模块&BytesIO模块相关的知识,希望对你有一定的参考价值。一、加密加密 ... [详细]
  • 本文介绍了C++中省略号类型和参数个数不确定函数参数的使用方法,并提供了一个范例。通过宏定义的方式,可以方便地处理不定参数的情况。文章中给出了具体的代码实现,并对代码进行了解释和说明。这对于需要处理不定参数的情况的程序员来说,是一个很有用的参考资料。 ... [详细]
  • 本文介绍了P1651题目的描述和要求,以及计算能搭建的塔的最大高度的方法。通过动态规划和状压技术,将问题转化为求解差值的问题,并定义了相应的状态。最终得出了计算最大高度的解法。 ... [详细]
  • 本文介绍了解决二叉树层序创建问题的方法。通过使用队列结构体和二叉树结构体,实现了入队和出队操作,并提供了判断队列是否为空的函数。详细介绍了解决该问题的步骤和流程。 ... [详细]
  • 《数据结构》学习笔记3——串匹配算法性能评估
    本文主要讨论串匹配算法的性能评估,包括模式匹配、字符种类数量、算法复杂度等内容。通过借助C++中的头文件和库,可以实现对串的匹配操作。其中蛮力算法的复杂度为O(m*n),通过随机取出长度为m的子串作为模式P,在文本T中进行匹配,统计平均复杂度。对于成功和失败的匹配分别进行测试,分析其平均复杂度。详情请参考相关学习资源。 ... [详细]
  • c语言\n不换行,c语言printf不换行
    本文目录一览:1、C语言不换行输入2、c语言的 ... [详细]
  • 本文介绍了为什么要使用多进程处理TCP服务端,多进程的好处包括可靠性高和处理大量数据时速度快。然而,多进程不能共享进程空间,因此有一些变量不能共享。文章还提供了使用多进程实现TCP服务端的代码,并对代码进行了详细注释。 ... [详细]
  • 本文介绍了C函数ispunct()的用法及示例代码。ispunct()函数用于检查传递的字符是否是标点符号,如果是标点符号则返回非零值,否则返回零。示例代码演示了如何使用ispunct()函数来判断字符是否为标点符号。 ... [详细]
  • 本文介绍了一个程序,可以输出1000内能被3整除且个位数为6的所有整数。程序使用了循环和条件判断语句来筛选符合条件的整数,并将其输出。 ... [详细]
author-avatar
昆哥2502852107
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有