Re0:Java编程系列-3 进阶排序思维分析与对比

Re0:Java编程系列

作者参加校招,在复习Java的同时,决定开一打系列博客。复习的同时,作者希望能留下材料,方便也服务一些新入门的小伙伴。

本系列文章从基础入手,由简单的功能函数开始,再扩展为项目结构等等。进一步延伸至Java编程的核心思想,封装继承多态

文章从面向功能编程方法开始过渡,手写代码的同时,逐渐向小伙伴揭示Java的编程思想和面向对象编程方法


提示:Java写代码前,需要Java环境和IDE集成环境,作者不赘述,希望小伙伴们提前准备好。


前言

前些天我们简单地书写并管理了,排序算法的项目结构。
而现在,我们对数组的数据结构已经比较熟悉了。对于排序来说,自然是越快越好。排序思维的提升,与新的数据组织形式,对数据来进行更高效的管理,已经对我们更加重要。
想到这里,我合上漫画的书壳,没有沉浸在二次元的温柔乡,开始摸上自己的键盘。

温馨提示:本篇文章内容篇幅较长,可以找个安静的环境细细思考,可以提高食用质量。


一、数据结构?

引导:数据结构,数据的组织形式。

数据结构是一门相当重要的学科,它能涉及的内容很多,对许多领域都有很深远的影响。对个人来说,对数据结构的理解,能直接作用于逻辑思维与抽象分析的强化。例如,人际关系的分析。

现在,数据结构已经影响到编程的方方面面。好比,我们写的项目,包和类,从本质来讲,也是数据结构的一种。

我们将食物抽象化,举例面包和牛奶。面包,这个泛化概念,包括了许多许多种类的面包。分出菠萝包,法棍等等更具体的面包种类。

  • 菠萝包:味道甜,颜色橙黄,面包软硬适中。

它,菠萝包,具有一些属性。而我们将这些属性组织起来,建立一个类。类名就是菠萝包,而它的一些特点便是其中的成员变量。而这样的类,我们同样可以写出很多。

  • 法棍面包:味道难吃,颜色贼黄,面包巨硬。

简单地将两个类组织到同一个包下,将包名命名为面包,将许多和这个面包一样的包组织到一起,例如,米饭面包。这样,我们可以得到一个项目,它的名字可以叫食物

仔细想想,我们所接触到的绝大多数开源项目,都以这样的方式组织着。而数据结构,将这种,由一个点分支出多个点的结构,称为树状结构

数据结构逻辑上大体分为四种,集合结构线性结构树状结构图形结构

而且,经过前两次的Re0:Java编程系列,相信小伙伴们,已经对数组这种数据形式比较熟悉了。

接下来,我们将从实践中逐渐熟悉线性结构,对数组进行实际编程分析。

本次我们需要学习三个算法,快速排序,希尔排序和堆排序。通过三个算法的学习,我们开始对数据组织形式开始探索!

二、开始敲码!

1.线性结构-数组

线性结构中,最为典型的代表便是数组。

数组中,我们依靠下标对数组数据进行位置的判定,依靠数值对数组数据大小进行判定。
数组
从简单上理解理解,下标对应着一个存储位置,而每一个存储位置中包含一个值。

不过,数组的下标与其所存储的值没有直接联系。因此,存储数据没有直接的逻辑联系,使得存储数据之间相互独立。

对结点的插入,删除运算时,需要遍历数组并且寻找数据,同时移动一系列的结点。

大概理解数组的结构后,接下来我们试着写一个难一点的排序。

快速排序:每一轮开始,选择一个中枢,并将其移动到合适的位置。将小于中枢的数值放在其左边位置,继续快排,将大于中枢的数值放在其右边位置,继续快排。直至,所有元素排序完成。

这是快排的一种经典写法,容易理解,也十分常见。现在,我们来分析,这种快速排序方法,需要哪些元素。

  • 存储一个中枢,记录下中枢的数值。
  • 排序过程时,我们需要左右的执行位置。
  • 注意函数的结构,一个获取中枢位置的函数,方便下一次快排。

仔细思考以上的问题,我们花上两三分钟,再开始编码。

代码如下(示例):

	package swap;
	
	//获取中枢函数
	public static int getqs1Mid(int[] array,int left,int right) {
		//初始记录中枢为数组第一个元素值
		int index = array[left];
		//判断下标,初始轮,右边寻找
		while(left < right) {
			//从右开始,第一个比中枢小的元素,若不存在,数组有序退出条件为left==right
			while(array[right] >= index && left < right) {
				right--;
			}
			//交换,左边寻找开始
			array[left] = array[right];
			//从左开始,第一个比中枢大的元素,若不存在,数组有序退出条件为left==right
			while(array[left] <= index && left < right) {
				left++;
			}
			//交换,次轮开始
			array[right] = array[left];	
		}
		//赋予中枢值到合适位置
		array[left] = index;
		//返回排好的中枢下标
		index = left;
		return index;
	}
	//快排,静态函数,递归调用
	public static int[] quickSort1(int[] array,int left,int right) {
		int index = 0;
		//判断左右下标
		if(left < right) {
			//获取中枢下标
			index = getqs1Mid(array, left, right);
			//中枢左边递归,直至left==right
			quickSort1(array, left, index-1);
			//中枢右边递归,直至left==right
			quickSort1(array, index+1, right);
		}
		return array;
	}

这种快速排序中,我们使用的存储方式为数组,从逻辑上来考虑,也是通过数组的下标进行排序。

记录数组首位中枢值,循环,从右边开始,找到小于中枢的第一个数,交换到左边位置,从左边开始,找到大于中枢的第一个数,交换到右边,一直持续到左右位置交替。

下标位置移动到合适位置时,index-1左边递归快排,index+1右边递归快排。

仔细想想,中枢位置移动式的快速排序,如何优化呢?

换一种方式,我们通过对左右两边元素进行交换。

代码如下(示例):

	package swap;
	
	//快排,静态递归,左右交换方式
	public static int[] quickSort2(int[] array,int left,int right) {
		//记录左移动下标
		int i = left;
		//记录右移动下标
		int j = right;
		int temp = 0;
		int pivot;
		//记录数组中间元素的值
		if((array[(left+right)/2]-array[left])*(array[left]-array[right])>=0) {
			pivot = array[left];
		} else if((array[left]-array[right])*(array[right]-array[(left+right)/2])>=0){
			pivot = array[right];
		} else {
			pivot = array[(left+right)/2];
		}
		
		//下标交替时,循环结束
		while(i<=j) {
			//寻找左下标,左边第一个大于pivot的元素,等于时保底,元素位置不会越过中值位置
			while(pivot > array[i]) {
				i++;
			}
			//寻找右下标,右边第一个小于pivot的元素,等于时保底,元素位置不会越过中值位置
			while(pivot < array[j]) {
				j--;
			}
			//交换一次,并使i,j记录到新的位置
			if(i<=j) {
				temp = array[i];
				array[i] = array[j];
				array[j] = temp;
				i++;
				j--;
			}
			//左快排
			if (right > i) {
				quickSort2(array, i, right);
			}
			//右快排
			if (left < j) {
				quickSort2(array, left, j);
			}
		}	
		return array;
	}

在这种快排模式中,我们不需要将中枢移动到合适位置。我们需要的是一个中间值,类似于中位数,通过中位数对左右两边进行判断排序。

记录移动左右两边的位置,我们发现,每一次排序完成后,i下标位置前的数必定大于中值,j下标位置后的书必定小于中值。

由此,我们多次递归,左快排下标由i->right,右快排下标由left->j。当两个下标ij交替时,这便意味着排序结束了。

通过对数组数据结构的解析,我们理解并掌握了一种不错的排序方法。

2.强化训练-多数组

快速排序函数编程完毕,我们发现,每一轮排序中,我们都是在一个数组结构中操作。

快排中,我们确认了函数的首下标与尾下标。在逻辑中,我们将一个数组一分为二,然后在分好的小数组中继续分,一直到排序完成。

这种思维模式,称为分治,是常用算法思维的一种。简单来说,将一个大问题,分解为许多独立平级的小问题,再将小问题解决,大问题便迎刃而解。

再次加深对数据结构的印象,我们来写个希尔排序。

希尔排序,通过将一个数组划分为多个小数组,对每个小组进行插入排序。多轮分组后,最后一次相当于对整个数组进行排序。不过,因为前面的多次分组排序,数组已经相当有序了,最后一次排序会非常迅速。

代码如下(示例):

	package insert;

	public int total;
	//小组的间隔量
	public int gap;

	public int[] shellSort1(int[] array) {
		total = 0;
		int temp = 0;
		//距离量控制
		for (gap = array.length/2; gap > 0; gap = gap/2) {
			//排序从分好的第一组开始,第一组下标0,gap
			for (int i = gap; i < array.length; i++) {
				//一次插入排序
				int j = i;
				temp = array[j];
				//判断小数组是否处于数组首
				while(j-gap > 0) {
					//同一轮中,小数组下标间隔为gap
					if(array[j-gap] > temp) {
						array[j] = array[j-gap];
						j = j - gap;
					} else {
						break;
					}
				}
				total++;
				array[j] = temp;
			}
		}
		return array;
	}

对于希尔排序来说,我们将一个整体数组,分出许多小数组,再依次对小数组进行插入排序。

3.树状结构-堆

经过对数组的详细分析与强化训练,相信对于数组这个数据结构,我们已经比较清楚了。

而现在,我们新增一种树状结构,堆。

堆是一种特殊的树状结构,满足两点。

  • 堆是一个完全二叉树。
  • 堆中结点的值总是不大于或不小于其父结点的值。

简单来讲,常用堆有两种,最大堆最小堆,当然其它的叫法也存在。

最大堆:根结点为整个堆中的最大元素。

最小堆:根结点为整个堆中的最小元素。

不过,说到堆,我们需要了解一下完全二叉树。这里,稍稍提一下。

二叉树:每个结点至多拥有两棵子树,并且,二叉树的子树有左右之分,其次序不能任意颠倒。

完全二叉树:二叉树的一种,最后一层可以不完全填充,其叶子结点都靠左对齐。

  • 任务:查寻以下内容,并弄清楚概念。
  • Complete Binary Tree-完全二叉数
  • Perfect Binary Tree-完美二叉树
  • Full Binary Tree-完满二叉树

接下来,将以最大堆为例,对数据结构堆进行详细分析。

首先,进行堆排序,我们需要思考一个内容,我们需要什么样的存储结构?

之前的算法分析,相信大家多少有一点注意到了。

在程序设计中,数据逻辑结构和数据存储结构是可以不一致的。

堆排序中,我们存储数据的思想是,将数据按照最大堆的方式进行存储。叶子结点的值,将不大于根结点。

最大堆

但实际上,我们实现它,依旧可以使用数组结构。

数组下标从0开始,那么根结点为0下标时,它的左右孩子结点分别为1下标2下标

如此推导,设一个结点下标为index,它的左孩子为index*2+1,它的右孩子为index*2+2。而无论它是父结点的左孩子还是右孩子,父结点始终为(index-1)/2

这取决于,完全二叉树的性质。

4.堆排序

之前,我们已经分析好,如何在数组中存储,堆结构。接下来,我们要通过最大堆这一数据结构,进行排序。

最大堆中,我们能知道,根结点始终是堆结构中的最大元素。那么,当数组已经排好最大堆结构时,我们将根结点与最后一个叶子结点置换,再将数组的长度置为length-1,再次将新的数组置为最大堆。

如此,我们便可以重复操作,一直到数组有序。

而堆排序中,我们首要目的,便是将一群数组中的无序元素,组织成最大堆的结构。

堆中,有两种常用的操作方法。

  • 插入函数,将一个元素插入到堆中合适位置。
  • 下沉函数,将一个元素下沉到堆中合适位置。

在堆排序中,我们可以使用这两种函数,对堆初始化,依次排序,通过下标在结构中新增元素和减少元素。

代码如下(示例):

package select;

	//最大堆,将index位置的数值向上上浮数组合适位置
	public static void insertHeap(int[] array,int index) {
		//记录index下标的数值,子结点的值
		int temp = array[index];
		//引索父结点位置
		int i = (index-1)/2;
		//循环条件,判断父子结点到达最大堆顶端后
		while(i >= 0 && index > 0) {
			//判断,父结点大于子结点
			if(array[i] >= temp)
			{
				break;
			} else {
				array[index] = array[i];
				//更新父子结点
				index = i;
				i = (index-1)/2;
			}
			//子结点值移动位置
			array[index] = temp; 
		}
	}
	
	//最大堆,将index位置的数值向下下沉到合适位置,循环写法
	public static void sinkHeap(int[] array,int index,int length) {
		//引索左叶子结点位置
		int i = index*2+1;
		//记录index下标的数值,父结点的值
		int temp = array[index];
		//判断,存在左孩子
		while(i < length) {
			//判断右孩子,选择左右孩子中最大的
			if(i+1 < length && array[i+1] > array[i]) {
				i++;
			}
			//判断,孩子结点值小于父结点,退出
			if(array[i] <= temp) {
				break;
			}
			//父结点的值更新
			array[index] = array[i];
			//父结点位置下移
			index = i;
			//新增左孩子结点位置
			i = index*2+1;
		}
		//赋值初始父结点值
		array[index] = temp;
	}
	
	//最大堆,将index位置的数值向下下沉到合适位置,递归写法
	public static void submergeHeap(int[] array,int index,int length) {
		if(index < length) {
			//设最大值下标
			int max = index;
			//左右孩子下标位置
			int left = index*2+1;
			int right = index*2+2;
			
			//三结点,索引最大值下标
			if(left < length) {
				if(array[max] < array[left]) {
					max = left;
				}
			}
			if(right < length) {
				if(array[max] < array[right]) {
					max = right;
				}
			}
			
			//判断,交换父子结点值,父结点下标下移
			if(max != index) {
				int temp = array[index];
				array[index] = array[max];
				array[max] = temp;
				
				//下沉至合适位置
				submergeHeap(array, max, length);
			}
		}
	}

而这只是两种种功能函数,一次只能排好一个元素。想要对堆整体进行排序,便需要思考调用函数的方式。

我们花一点时间思考一下,如何通过调用函数来写成堆初始化呢?

  1. 插入函数,达成堆初始化,数组元素从什么位置开始?
  2. 下沉函数,达成堆初始化,数组元素从什么位置开始?

代码如下(示例):

	//最大堆初始化
	public void setHeap(int[] array) {
		//从后往前,第一个父结点开始
		for (int i = (array.length-1)/2; i >= 0; i--) {
			sinkHeap(array, i, array.length);
		}
	}

	//堆排1,插入函数控制
	public int[] heapSort1(int[] array) {
		int temp = 0;
		//堆排序,从堆的大小判定
		for (int i = array.length-1; i > 0; i--) {
			//堆初始化
			for (int j = 0;j <= i;j++) {
				insertHeap(array, j);
			}
			//置换元素值
			temp = array[0];
			array[0] = array[i];
			array[i] = temp;
		}
		return array;
	}

	//堆排2,下沉函数控制
	public int[] heapSort2(int[] array) {
		int temp = 0;
		//堆初始化
		setHeap(array);
		//堆排序,判断堆的大小
		for (int i = array.length-1; i >= 0; i--) {
			temp = array[0];
			array[0] = array[i];
			array[i] = temp;
			//每轮首元素下沉合适位置,重新置为最大堆
			sinkHeap(array, 0, i);
		}
		return array;
	}

细细思考一下,这三个函数都有一些十分细节的内容。这便当做,本次博客的小作业了。

  • 编程细节
  • 函数中第一次循环的位置,思考为何?
  • heapSort1函数中使用插入方式进行初始化,和heapSort2函数使用下沉方式的区别在哪里?

我们在test包中,写好的sortTest类中,将新增的排序类导入。写好测试用例,主函数新增调用。

鼠标右键,敲击Run,我们来跑程序。


总结

到现在,6种不同的排序方式和思维,我都将其写在了脑壳里。
仔细想想,这些排序还真有些相通之处。将大问题分解成小问题,再依次将小问题解决,分治的方法深入我心。
也许,程序的真谛,叫做信息处理。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值