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
。当两个下标i
,j
交替时,这便意味着排序结束了。
通过对数组数据结构的解析,我们理解并掌握了一种不错的排序方法。
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);
}
}
}
而这只是两种种功能函数,一次只能排好一个元素。想要对堆整体进行排序,便需要思考调用函数的方式。
我们花一点时间思考一下,如何通过调用函数来写成堆初始化呢?
- 插入函数,达成堆初始化,数组元素从什么位置开始?
- 下沉函数,达成堆初始化,数组元素从什么位置开始?
代码如下(示例):
//最大堆初始化
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种不同的排序方式和思维,我都将其写在了脑壳里。
仔细想想,这些排序还真有些相通之处。将大问题分解成小问题,再依次将小问题解决,分治的方法深入我心。
也许,程序的真谛,叫做信息处理。