目录
数据结构
数据结构一般是指数据在内存的存储方式,数据结构总共可看成三种:线性结构。树形结构、图。
线性结构
简而言之就是将数据排成一条线的结构,数组、链表,栈。队列都是线性结构,最多只有前后两个方向
数组
数组是是一种线性结构,内存连续的,且具有相同的数据类型的一种数据结构。它被植入到大部分编程语言中。由于数组十分易懂,所以它被用来介绍数据结构的起点。 数组分为2种:无序数组与有序数组。有序数组就是无序数组经过排序后结果。
关于数组的下标为啥从0开始的解释:下标的概念严格来说是偏移(offset),如果用array表示数组的首地址,每个元素占据t个字节,那array[0]就是偏移0的位置,也就是首地址,array[k] 就是偏移k个t的存储位置,所以array[k]的地址=base(首地址)+k*t
数组的特点(数组的连续特性)
- 高效的随机访问(连续的内存下标)
- 低效的插入和删除(由于连续大量移动元素)
数组的效率
在数据结构与算法中,衡量算法的效率是通过时间复杂度和空间复杂度来进行的
操作 | 时间复杂度 |
插入 | O(1) |
删除 | O(N) |
查找 | O(N) |
其中:
O(1)表示,此操作不受数组元素个数的影响,不论数组中现有多少元素,插入操作总是1步就可以完成
O(N)表示此操作受到数据元素个数的影响,最直观的感受是,我们可以看到删除和查找操作,里面都有一个for循环来迭代数组中的所有元素,假设数组中有N个元素,我们随机查找或者删除一个数字,运气好的情况下,可能1次就查找了,运气不好,可能所有的N个元素迭代完了,还是没有找到,根据概率,平均可能需要进行N/2次循环,由于时间复杂度是忽略常数的,因此删除和查找操作的平均时间复杂度是O(N)
基于数组的ArrayList
ArrayList是Java中我们最常使用的List接口的实现类,其是内部就是通过维护一个无序数组来实现的。因此ArrayList具备无序数组拥有的所有优点和缺点:
操作 | 时间复杂度 |
插入 | O(1) |
删除 | O(N) |
查找 | O(N) |
需要注意的是:
1、ArrayList总是将元素加入数组中的第一个非空位置: 当我们往ArrayList中添加一个元素时,为了可以保证以O(1)时间插入元素,ArrayList总是将元素加入数组中的第一个非空位置,这是通过维护size变量实现的,size表示的是数组中已经添加的元素的数量,当我们插入一个数据时,直接在数组size+1的位置上加入这个元素即可。
2、ArrayList中维护的数组中是没有空元素的。这意味着 当删除数组中一个元素时,这个数组中之后所有的元素位置都会前移一个位置。当我们删除一个元素,size变为size-1 ,而如果这个元素不是数组中最后一个元素,意味着虽然只有size-1个元素,但是在0到size的中间有一个位置元素是空的,而size位置上是有元素的。当下一次插入元素时,又在size-1基础上+1,也就是在size位置上插入元素,就会将原来的size位置上元素覆盖掉。
3、ArrayList中维护的数组需要动态扩容。由于数组一旦创建,大小就是固定的。因此当ArrayList中维护的数组容量大小达到限度时,就要将数组拷贝到一个更大的数组中。扩容规则:初始大小10,每次扩容成数组大小的1.5倍
链表(LinkedList)
用一组任意类型的存储单元存储线性表,在逻辑上面相邻的结点在物理位置上面不一定相邻。采用链式存储方法的线性表叫做链表(非连续,非顺序),链表可能是继数组之后第二种使用最广泛的通用存储结构。
链表中的每个元素称之为一个节点(Node
),由数据域和指针域构成。对比数组, 单链表的数据结构可以用下图表示
这张图显示了一个单链表的数据结构,链表中的每个Node都维护2个信息:一个是这个Node自身存储的数据Data
,另一个是下一个Node的引用,图中用Next
表示。对于最后一个Node,因为没有下一个元素了,查找时就是null,所以其并没有引用其他元素,在图中用紫色框来表示。这张图主要显示的是单链表中Node的内部结构和Node之间的关系。一般情况下,我们在链表中还要维护第一个Node的引用,原因是在链表中访问数据必须通过前一个元素才能访问下一个元素,如果不知道第一个Node的话,后面的Node都不可以访问。事实上,对链表中元素的访问,都是从第一个Node中开始的,第一个Node是整个链表的入口;而在数组中,我们可以通过下标进行访问元素。
单链表的插入和删除快,查找时由于内存不连续,无法通过下标计算存储地址,只能通过链表入口(头结点)依次遍历查找,随机访问性差
双向列表示意图:
单链表只有一个后继指针,而双向列表有前驱和后继结点,每个结点(除头结点)都支持双向查找
LinkedList底层就是采用双向列表
面试题:ArrayList和LinkedList的比较
- ArrayList底层是数组,LinkedList底层是双向链表
- 由存储结构的不同特性:ArrayList地址连续,随机访问快,插入和删除则需要移动元素,比较慢;LinkedList离散存储,每次都需要从头开始读取,随机访问差,插入和删除不需要移动元素,比较快
- LinkedList比ArrayList更耗内存,因为链表多存储了指针节点信息
栈(Stack)
栈(Stack)是一种操作受限的线性表---后进先出的数据结构(LIFO:last in first out),只允许在一端进行操作,只允许访问栈中的第一个数据项:即最后插入的数据项。移除这个数据项之后,才能看到第二个数据项,以此类推。
往栈中存入数据称之为压栈(push
),移除数据称之为弹栈(pop
),此外通常还提供查看栈顶元素的peek
方法,此方法可以得到栈顶元素的值,但是并不会将其移除
java.util.Stack
就是JDK提供的一种对栈的实现,这个实现是基于数组的,看源码
这里也可以基于链表实现栈,思路是就是addFirst数据,removeFirst数据,获取栈顶数据getFirst
队列(Queue)
队列的概念现实生活中很多:比如去买火车票,排队,不允许插队,前面的人先买,后面的人后买,先来的排在队头,后来的排在队尾,满足先来的先买,后来的后买(先进先出原则)。所以,队列也是操作受限的线性表,一般情况下,对于Queue而言,最核心的操作是:插入队列(enqueue
)、移出队列(dequeue
)。因为在队列中,插入操作是插入到队列的最后,而移出操作是移出队列的头部元素。因此我们通常会使用两个变量front
(队头指针)和rear
(队尾指针)记录当前元素的位置。
假设我们有一个容量有限队列,用于存放字母,如下图所示:
当我们要插入一个元素时,因为总是插入到队列的最尾部,所以插入的位置是rear+1的位置。
当我们要移出一个元素时,是从队头指针front的位置开始移除(因为Queue头部的元素是最先加入进来的,根据FIFO原则,应该最先移除)。当移除一个元素之后,front应该加1,因为移出一个元素之后,下一个元素就变成了第一个元素。例如现在我们移出了5个元素:
当移出5个元素之后,队头指针就移动到了字母F的位置,前五个位置空下来了。队尾指针rear不变。
现在问题来了:队列头部移出的几个元素,位置空下来了。当我们添加元素的时候,从队尾开始加,当添加到最后一个位置时,怎么办?不让添加时不合理的,毕竟前几个位置已经空下来了。我们的期望是,当一个位置上的元素被移出之后,这个位置是可以被重复使用的。而我们这里讨论的是有限容量的数据,如果我们还继续添加元素,那么就要往队列头部来添加。
这实际上就涉及到了循环队列的概念。也就是当队尾指针到了数组的最后一个下标时,下一个位置应该就是数组的首部。
因此,当队尾指针指向数组顶端的时候,我们要将队尾指针(rear)重置为-1,此时再加1,就是0,也就是数组顶端例。如我们又添加了5个字母,那么结果应该如下图:
java中也提供了queue接口,提供了如下方法:
public interface Queue<E> extends Collection<E> {
//增加一个元索到队尾,如果队列已满,则抛出一个IIIegaISlabEepeplian异常
boolean add(E e);
//移除并返回队列头部的元素,如果队列为空,则抛出一个NoSuchElementException异常
E remove();
//添加一个元素到队尾,如果队列已满,则返回false
boolean offer(E e);
//移除并返问队列头部的元素,如果队列为空,则返回null
E poll();
//返回队列头部的元素,如果队列为空,则返回null
E peek();
//返回队列头部的元素,如果队列为空,则抛出一个NoSuchElementException异常
E element();
}
Java中还提供了一个java.util.Deque双端队列(deque,全名double-ended queue),就是队列中的元素可以从两端弹出,其限定插入和删除操作在表的两端进行。事实上,java中LinkedList就实现了Deque接口。因此LinkedList具有Deque和Queue的所有功能。
使用场景:
树形结构
树是我们计算机中非常重要的一种数据结构,同时使用树这种数据结构,可以描述现实生活中的很多事物,例如家 谱、单位的组织架构、等等。 树是由n(n>=1)个有限结点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就 是说它是根朝上,而叶朝下的。
示例:
树具有以下特点:
- 每个结点有零个或多个子结点;
- 没有父结点的结点为根结点;
- 每一个非根结点只有一个父结点;
- 每个结点及其后代结点整体上可以看做是一棵树,称为当前结点的父结点的一个子树;
树的相关术语:
- 结点的度: 一个结点含有的子树的个数称为该结点的度;
- 叶结点: 度为0的结点称为叶结点,也可以叫做终端结点
- 分支结点: 度不为0的结点称为分支结点,也可以叫做非终端结点
- 结点的层次: 从根结点开始,根结点的层次为1,根的直接后继层次为2,以此类推
- 结点的层序编号: 将树中的结点,按照从上层到下层,同层从左到右的次序排成一个线性序列,把他们编成连续的自然数。
- 树的度: 树中所有结点的度的最大值
- 树的高度(深度): 树中结点的最大层次
- 森林: m(m>=0)个互不相交的树的集合,将一颗非空树的根结点删去,树就变成一个森林;给森林增加一个统一的根 结点,森林就变成一棵树
- 孩子结点: 一个结点的直接后继结点称为该结点的孩子结点
- 双亲结点(父结点): 一个结点的直接前驱称为该结点的双亲结点
- 兄弟结点: 同一双亲结点的孩子结点间互称兄弟结点
二叉树
二叉树的遍历:
示例题:
树的最大深度问题:
算法
操作数据的方法:即如何操作数据使效率更高,更节省空间。
有关算法时间耗费分析,我们称之为算法的时 间复杂度分析,有关算法的空间耗费分析,我们称之为算法的空间复杂度分析。
时间复杂度
- 算法函数中的常数可以忽略;
- 算法函数中最高次幂的常数因子可以忽略;
- 算法函数中最高次幂越小,算法效率越高。
空间复杂度
1.基本数据类型内存占用情况:
2.计算机访问内存的方式都是一次一个字节
3.一个引用(机器地址)需要8个字节表示:
例如: Date date = new Date(),则date这个变量需要占用8个字节来表示
4.创建一个对象,比如new Date(),除了Date对象内部存储的数据(例如年月日等信息)占用的内存,该对象本身也 有内存开销,每个对象的自身开销是16个字节,用来保存对象的头信息。
5.一般内存的使用,如果不够8个字节,都会被自动填充为8字节:
6.java中数组被被限定为对象,他们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组一般需要 24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4个填充字节)再加上保存值所需的内存。
冒泡排序(稳定的排序)
算法时间复杂度分析
对于排序算法的时间复杂度分析,要从2个角度考虑,一个是比较的次数,另一个交换的次数。 对于n个元素的数组,需要进行 n-1趟排序。每趟排序要进行n-i次关键字的比较(1≤i≤n-1)。
比较次数的时间复杂度
第1趟:比较n-1次
第2趟:比较 n-2次
...
第n-1趟:比较1次
根据等差数列的求和公式,可以算出
交换次数的时间复杂度
在比较过程中,交换不是必须的,只有在顺序不对的情况下,才会交换。如果数组是升序的,但是我们希望降序排列,那么每一次比较都需要进行交换,这个时候达到交换的最大次数,且每次比较都必须移动记录三次来达到交换记录位置。
综上,因此冒泡排序总的平均时间复杂度为O(N2)。
算法稳定性
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元 素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。
示例
/**
* 冒泡排序
* @param arr 数组
* @param asc 是否升序
*/
private static int[] sort(int[] arr,boolean asc){
if(asc){
//升序
for(int i=0;i<arr.length;i++){
for(int j=i+1;j<arr.length;j++){
if (arr[i] > arr[j]) {
Utils.swap(arr,i,j);
}
}
}
}else{
//降序
for(int i=0;i<arr.length;i++){
for(int j=i+1;j<arr.length;j++){
if (arr[i] < arr[j]) {
Utils.swap(arr,i,j);
}
}
}
}
return arr;
}
public static void main(String[] args) {
int[] test={1,5,10,8,7,15,2,9,3};
System.out.println("排序前的数组:"+ Arrays.toString(test));
System.out.println("升序数组:"+ Arrays.toString(sort(test,true)));
System.out.println("降序数组:"+ Arrays.toString(sort(test,false)));
}
public class Utils {
/**
* 交换元素
* @param arr 目标数组
* @param i 交换前一个元素下标
* @param j 交换后一个元素下标
*/
public static void swap(int[] arr ,int i,int j){
int temp;
temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
}
运行结果:
选择排序(不稳定的排序)
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理是每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。 选择排序是不稳定的排序方法(比如序列[5, 5, 3]第一次就将第一个[5]与[3]交换,导致第一个5挪动到第二个5后面)。
选择排序只是比冒泡排序优化了一点点,比较的次数没有变,但是减少了交换的次数
回顾冒泡排序中,我们每次遇到两个数字的顺序不对时,立马交换其位置(体现在swap方法写在内层的for循环中),而选择排序中,其最大的优化方面体现在(以降序为例) ,通过一次循环选择出无序区中最大的数字的下标,然后再与无序区第一个位置进行交换。体现在swap方法在外层for循环中调用。
/**
* 选择排序
* @param arr 数组
* @param asc 是否升序
*/
private static int[] sort(int[] arr,boolean asc){
for (int i = 0; i < arr.length; i++) {
//假设第一元素是最大或者最小,记录下标
int index=i;
for (int j = i+1; j < arr.length; j++) {
if(asc){
//升序,选择无序区最小的元素
if(arr[index]>arr[j]){
index=j;
}
}else{
//降序,选择无序区最大的元素
if(arr[index]<arr[j]){
index=j;
}
}
}
if(index!=i){
Utils.swap(arr,i,index);
}
}
return arr;
}
public static void main(String[] args) {
int[] test={1,5,5,10,8,7,15,2,9,3,100};
System.out.println("排序前的数组:"+ Arrays.toString(test));
System.out.println("升序数组:"+ Arrays.toString(sort(test,true)));
System.out.println("降序数组:"+ Arrays.toString(sort(test,false)));
}
运行结果:
插入排序(稳定的排序)
插入排序(Insert Sort)将待排序的数组分为2部分:有序区,无序区。其核心思想是每次取出无序区中的第一个元素,插入到有序区中。 有序与无序区划分,就是通过一个变量标记当前数组中,前多少个元素已经是局部有序了。
在排序开始的时候,把数组的第1个元素当成有序区(即有序区只有一个元素),其余的所有元素当做无序区。
之后在往有序区插入无序区第一个元素值时,有序区中比这个值小(或者大)的元素都要右移一个位置。右移并不会覆盖的数组中已有的数据项值,因为我们总是取无序区中的第一个元素插入,右移也只是覆盖了我们取出的这个元素的位置而已。当无序区为空时,排序完成。
插入排序根据具体实现方式又分为:直接插入排序,二分插入排序(又称折半插入排序),链表插入排序,属于稳定排序的一种(通俗地讲,就是两个相等的数不会交换位置) 。
/**
* 直接插入排序:数组看成有序部分和无序部分,取无序中的一个跟有序比较看插入的位置
* @param arr 数组
* @param asc 是否升序
*/
private static int[] sort(int[] arr,boolean asc){
//有序部分的最后一个下标,首先默认有序部分只有一个元素,下标0
//开始排序时,将有序区结束位置设为0 (开始位置总是0),对应的无序区范围就是 1~arr.length
int orderedLastIndex=0;
//迭代无序区中的每一个元素,依次插入有序区中
for (int i = orderedLastIndex+1; i < arr.length; i++) {
//记录无序区中的第一个元素值,也就是需要和有序区比较是否交换的值
int temp=arr[i];
//在有序区中插入的索引的位置,刚开始就设置为自己的位置
int insertIndex=i;
//从有序区从后往前开始比较
for (int j = orderedLastIndex; j >= 0; j--) {
//升序,有序区中比当前无序区中元素大的都右移一个位置
if(asc){
if(arr[j]>temp){
arr[j+1]=arr[j];
//有序区每移动一次,将插入位置-1
insertIndex--;
}else{
//有序区当前位置元素<=无序区第一个元素,那么之前的元素都会<=,不需要继续比较
break;
}
}
//升序,有序区中比当前无序区中元素小的都右移一个位置
else{
if(arr[j]<temp){
arr[j+1]=arr[j];
insertIndex--;
}else{
break;
}
}
}
arr[insertIndex]=temp;
orderedLastIndex++;
}
return arr;
}
public static void main(String[] args) {
int[] test={1,5,5,10,8,7,15,2,9,3,100};
System.out.println("排序前的数组:"+ Arrays.toString(test));
System.out.println("升序数组:"+ Arrays.toString(sort(test,true)));
System.out.println("降序数组:"+ Arrays.toString(sort(test,false)));
}
运行结果:
直接插入排序的效率:
-
在第1趟排序中,最多需要比较1次
-
在第2趟排序中,最多需要比较2次
-
.....
-
在第n-1趟排序中,最多需要比较n-1次
-
因此最多需要比较n*(n-1)/2次
冒泡排序也是需要比较n*(n-1)/2次,但是二者不同之处在于,冒泡排序肯定是需要比较n*(n-1)/2次,插入排序只有在最坏的情况下才会需要n*(n-1)/2次,回顾上例中的出现的break,当我们发现某个元素不符合时,就直接跳出,有序区之前的元素都不会再比较了,从概率的角度来说,实际上只需要和有序区中一半的元素进行比较,因此需要除以2,即插入排序比较的平均时间复杂度是n*(n-1)/4,所以有的时候我们会看到 插入排序比冒泡排序效率高一倍 的说法
二分插入排序(稳定的排序)
二分插入排序与直接插入排序的区别是,直接插入排序是迭代有序区中的每一个数据项与无序区中的第一个元素进行比较,二分插入排序实际上是充分利用了有序区的特定,我们知道,对于一个有序的数组,我们可以利用二分查找快速定位某个数字应该插入的位置,在定位了这个位置之后,只需要将这个位置以及之后的元素右移一位,将腾出来的位置直接插入无序区中的第一个元素即可,减少了比较次数。
/**
* 二分插入排序:数组看成有序部分和无序部分,每次插入将有序折半取中间那个元素和无序比较是否交换
* @param arr 数组
*/
private static int[] sort(int[] arr){
int low,hight,mid;
//迭代无序区中的每一个元素,依次插入有序区中
for (int i = 1; i < arr.length; i++) {
//从有序区起始位置开始
low=0;
//有序区结束位置
hight=i-1;
//记录无序区中的第一个元素值,也就是需要和有序区比较是否交换的值
int temp=arr[i];
//单独循环确定插入的位置
while (low <= hight) {
// 找出中间值
mid = (low + hight) >> 1;
//如果待插入记录比中间记录小
if (temp<arr[mid] ) {
// 插入点在低半区
hight = mid - 1;
} else {
// 插入点在高半区
low = mid + 1;
}
}
//将前面所有大于当前待插入记录的记录后移
for (int j = i - 1; j >=low; j--) {
arr[j + 1] = arr[j];
}
//将待插入记录回填到正确位置.
arr[low] = temp;
}
return arr;
}
public static void main(String[] args) {
int[] test={1,5,10,8,7,15,2,9,5,3,100};
System.out.println("排序前的数组:"+ Arrays.toString(test));
System.out.println("升序数组:"+ Arrays.toString(sort(test)));
}
运行结果:
希尔排序(不稳定的排序)
希尔排序是插入排序的一种,又称“缩小增量排序”,是插入排序算法的一种更高效的改进版本。
前面学习插入排序的时候,我们会发现一个很不友好的事儿,如果已排序的分组元素为{2,5,7,9,10},未排序的分组 元素为{1,8},那么下一个待插入元素为1,我们需要拿着1从后往前,依次和10,9,7,5,2进行交换位置,才能完成真 正的插入,每次交换只能和相邻的元素交换位置。那如果我们要提高效率,直观的想法就是一次交换,能把1放到 更前面的位置,比如一次交换就能把1插到2和5之前,这样一次交换1就向前走了5个位置,可以减少交换的次数, 这样的需求如何实现呢?接下来我们来看看希尔排序的原理。
需求: 排序前:{9,1,2,5,7,4,8,6,3,5}
排序后:{1,2,3,4,5,5,6,7,8,9}
排序原理:
- 选定一个增长量h,按照增长量h作为数据分组的依据,对数据进行分组;
- 对分好组的每一组数据完成插入排序;
- 减小增长量,最小减为1,重复第二步操作。
public class 希尔排序 {
/**
* 对数组a中的元素进行排序
* @param a
*/
public static void sort(Comparable[] a){
int N = a.length;
//确定增长量h的最大值
int h=1;
while(h<N/2){
h=h*2+1;
}
//当增长量h小于1,排序结束
while(h>=1){
//找到待插入的元素
for (int i=h;i<N;i++){
//a[i]就是待插入的元素
//把a[i]插入到a[i-h],a[i-2h],a[i-3h]...序列中
for (int j=i;j>=h;j-=h){
//a[j]就是待插入元素,依次和a[j-h],a[j-2h],a[j-3h]进行比较,如果a[j]小,
// 那么交换位置,如果不小于,a[j]大,则插入完成。
if (greater(a[j-h],a[j])){
exch(a,j,j-h);
}else{
break;
}
}
}
h/=2;
}
}
/**
* 比较v元素是否大于w元素
* @param v
* @param w
* @return
*/
private static boolean greater(Comparable v,Comparable w){
return v.compareTo(w)>0;
}
/**
* 数组元素i和j交换位置
* @param a
* @param i
* @param j
*/
private static void exch(Comparable[] a,int i,int j){
Comparable t = a[i];
a[i]=a[j];
a[j]=t;
}
public static void main(String[] args) {
Integer[] a = {9,1,2,5,7,4,8,6,3,5} ;
希尔排序.sort(a);
System.out.println(Arrays.toString(a));
}
}
测试:
归并排序(稳定的排序)
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子 序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序 表,称为二路归并。
需求:
排序前:{8,4,5,7,1,3,6,2}
排序后:{1,2,3,4,5,6,7,8}
排序原理:
- 尽可能的一组数据拆分成两个元素相等的子组,并对每一个子组继续拆分,直到拆分后的每个子组的元素个数是 1为止。
- 将相邻的两个子组进行合并成一个有序的大组;
- 不断的重复步骤2,直到最终只有一个组为止。
public class 归并排序 {
private static Comparable[] assist;//归并所需要的辅助数组
/**
* 对数组a中的元素进行排序
* @param a
*/
public static void sort(Comparable[] a) {
assist = new Comparable[a.length];
int lo = 0;
int hi = a.length-1;
sort(a, lo, hi);
}
/**
* 对数组a中从lo到hi的元素进行排序
* @param a
* @param lo
* @param hi
*/
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) {
return;
}
int mid = lo + (hi - lo) / 2;
//对lo到mid之间的元素进行排序;
sort(a, lo, mid);
//对mid+1到hi之间的元素进行排序;
sort(a, mid+1, hi);
//对lo到mid这组数据和mid到hi这组数据进行归并
merge(a, lo, mid, hi);
}
/**
* 对数组中,从lo到mid为一组,从mid+1到hi为一组,对这两组数据进行归并
* @param a
* @param lo
* @param mid
* @param hi
*/
private static void merge(Comparable[] a, int lo, int mid, int hi) {
//lo到mid这组数据和mid+1到hi这组数据归并到辅助数组assist对应的索引处
int i = lo;//定义一个指针,指向assist数组中开始填充数据的索引
int p1 = lo;//定义一个指针,指向第一组数据的第一个元素
int p2 = mid + 1;//定义一个指针,指向第二组数据的第一个元素
//比较左边小组和右边小组中的元素大小,哪个小,就把哪个数据填充到assist数组中
while (p1 <= mid && p2 <= hi) {
if (less(a[p1], a[p2])) {
assist[i++] = a[p1++];
} else {
assist[i++] = a[p2++];
}
}
/**
* 上面的循环结束后,如果退出循环的条件是p1<=mid,则证明左边小组中的数据已经归并完毕,如果退
* 出循环的条件是p2<=hi,则证明右边小组的数据已经填充完毕;
* 所以需要把未填充完毕的数据继续填充到assist中,//下面两个循环,只会执行其中的一个
*/
while(p1<=mid){
assist[i++]=a[p1++];
}
while(p2<=hi){
assist[i++]=a[p2++];
}
//到现在为止,assist数组中,从lo到hi的元素是有序的,再把数据拷贝到a数组中对应的索引处
for (int index=lo;index<=hi;index++){
a[index]=assist[index];
}
}
/**
* 比较v元素是否小于w元素
* @param v
* @param w
* @return
*/
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
public static void main(String[] args) throws Exception {
Integer[] arr = {8, 4, 5, 7, 1, 3, 6, 2};
归并排序.sort(arr);
System.out.println(Arrays.toString(arr));
}
}
假设元素的个数为n,那么使用归并排序拆分的次数为log2(n),所以共log2(n)层,那么使用log2(n)替换上面3*2^3中 的3这个层数,最终得出的归并排序的时间复杂度为:log2(n)* 2^(log2(n))=log2(n)*n,根据大O推导法则,忽略底 数,最终归并排序的时间复杂度为O(nlogn);
归并排序的缺点:
需要申请额外的数组空间,导致空间复杂度提升,是典型的以空间换时间的操作。
注:希尔排序和归并排序在大批量数据下效率差距不大
快速排序(不稳定的排序)
快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一 部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序 过程可以递归进行,以此达到整个数据变成有序序列。
需求:
排序前:{6, 1, 2, 7, 9, 3, 4, 5, 8}
排序后:{1, 2, 3, 4, 5, 6, 7, 8, 9}
排序原理:
- 首先设定一个分界值,通过该分界值将数组分成左右两部分;
- 将大于或等于分界值的数据放到到数组右边,小于分界值的数据放到数组的左边。此时左边部分中各元素都小于 或等于分界值,而右边部分中各元素都大于或等于分界值;
- 然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两 部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
- 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当 左侧和右侧两个部分的数据排完序后,整个数组的排序也就完成了。
切分原理: 把一个数组切分成两个子数组的基本思想:
- 找一个基准值,用两个指针分别指向数组的头部和尾部;
- 先从尾部向头部开始搜索一个比基准值小的元素,搜索到即停止,并记录指针的位置;
- 再从头部向尾部开始搜索一个比基准值大的元素,搜索到即停止,并记录指针的位置;
- 交换当前左边指针位置和右边指针位置的元素;
- 重复2,3,4步骤,直到左边指针的值大于右边指针的值停止
public class 快速排序 {
public static void sort(Comparable[] a) {
int lo = 0;
int hi = a.length - 1;
sort(a, lo,hi);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi<=lo){
return;
}
//对a数组中,从lo到hi的元素进行切分
int partition = partition(a, lo, hi);
//对左边分组中的元素进行排序
//对右边分组中的元素进行排序
sort(a,lo,partition-1);
sort(a,partition+1,hi);
}
public static int partition(Comparable[] a, int lo, int hi) {
Comparable key=a[lo];//把最左边的元素当做基准值
int left=lo;//定义一个左侧指针,初始指向最左边的元素
int right=hi+1;//定义一个右侧指针,初始指向左右侧的元素下一个位置进行切分
while(true){
//先从右往左扫描,找到一个比基准值小的元素
while(less(key,a[--right])){//循环停止,证明找到了一个比基准值小的元素
if (right==lo){
break;//已经扫描到最左边了,无需继续扫描
}
}
//再从左往右扫描,找一个比基准值大的元素
while(less(a[++left],key)){//循环停止,证明找到了一个比基准值大的元素
if (left==hi){
break;//已经扫描到了最右边了,无需继续扫描
}
}
if (left>=right){
//扫描完了所有元素,结束循环
break;
}else{
//交换left和right索引处的元素
exch(a,left,right);
}
}
//交换最后rigth索引处和基准值所在的索引处的值
exch(a,lo,right);
return right;//right就是切分的界限
}
/**
* 数组元素i和j交换位置
* @param a
* @param i
* @param j
*/
private static void exch(Comparable[] a, int i, int j) {
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 比较v元素是否小于w元素
* @param v
* @param w
* @return
*/
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
public static void main(String[] args) throws Exception {
Integer[] arr = {6, 1, 2, 7, 9, 3, 4, 5, 8};
快速排序.sort(arr);
System.out.println(Arrays.toString(arr));
}
}
快速排序和归并排序的区别:
快速排序是另外一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立的排序。快速排序和归并排序 是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并从而将整个数组排序,而快速排序的 方式则是当两个数组都有序时,整个数组自然就有序了。在归并排序中,一个数组被等分为两半,归并调用发生在 处理整个数组之前,在快速排序中,切分数组的位置取决于数组的内容,递归调用发生在处理整个数组之后。
没有深入,后续补充·····