算法与数据结构从入门到精通
一、课程概述
1.1 课程介绍
- 数据结构和算法这门课程,无论在哪个学校的计算机专业,都是一门必修课。
- 这门课程非常重要,是编程的基础
- 通往高级开发人员的必经之路,因为程序设计= 数据结构+算法
- 学好数据结构与算法,能够更深层次的理解课程,提升编写代码的能力,让程序的代码更加优雅,性能更高;
1.2 数据结构与算法概述-数据结构
- 什么是数据结构?
- 数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及他们之间的关系和操作等相关问题的学科;
数据结构就是把数据元素按照一定的关系组织起来的集合,用来组织和存储数据;
- 数据结构分类
- 传统上,我们可以把数据结构分为逻辑结构和物理结构两大类;
- 逻辑结构分类:
- 逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,按照对象中数据元素之间的相互关系分类,也是我们后面课题中需要关注和讨论的问题;
- 集合结构:集合结构中数据元素除了属于同一个集合外,他们没有其他任何关系,如下图所示:
- 线性结构:线性结构中的数据元素之间存在一对一的关系,如下图所示:
- 树形结构:树形结构中的数据元素之间存在一对多的层次关系,如下图所示:
- 图形结构:图形结构的数据元素是多对多的关系:
它的分类实际上是按照数据与数据之间的关系来进行分类的;
- 集合结构:集合结构中数据元素除了属于同一个集合外,他们没有其他任何关系,如下图所示:
- 逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,按照对象中数据元素之间的相互关系分类,也是我们后面课题中需要关注和讨论的问题;
- 物理结构分类:
- 逻辑结构在计算机中真正的表达方式(又称为映像)称为物理结构,也可以叫做存储结构。常见的物理结构有顺序存储结构、链式存储结构。
- 顺序存储结构:
- 把数据元素放到地址连续的存储单元里面,其数据间逻辑的逻辑关系和物理关系是一致的,比如我们常用的数组就是顺序存储结构。
顺序存储结构存在一定的弊端,就像生活中排队时也会有人插队也可能有人有特殊情况突然离开,这时候整个结构都处于变化中,此时就需要链式存储结构。
- 链式存储结构:
- 是把数据元素放在任意的存储单元里面,这组存储单元可以是连续的也可以是不连续的。此时,数据元素之间并不能反映元素间的逻辑关系,因此在链式存储结构中引进了一个指针存放数据元素的地址,这样通过地址就可以找到相关联数据元素的位置;
1.2 数据结构与算法概述-算法
- 什么是算法?
- 算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法解决问题的策略机制。也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出;
简单来说,就是根据一定的条件,对一些数据进行计算,得到需要的结果;
- 一个优秀的算法应该追求哪些目标?
- 花最少的时间完成需求:
- 需求如下:计算1到100的和
- 第一种解法:
- 第二种解法:
我们可以看到第二种解法明显更优,第一种解法需要执行100次加法运算,而第二种解法只需要完成三个运算逻辑即可完成需求;所以第二种它能够花费最少时间完成需求;
- 占用最少的内存空间完成需求
- 需求如下:计算10 的阶乘
- 第一种解法:
- 第二种解法:
第一种解法,使用递归完成需求,fun1 方法被调用十次,并且在第一个方法未执行完毕时就会调用第二次方法,第二次执行未完毕时又会调用第三次执行,最多的时候需要在栈内存中同时开辟10块内存分别执行10个fun1方法。
第二种解法,使用for循环完成需求,fun2 方法只会执行一次,最终,只需要在栈内存开辟一块内存执行fun2 方法即可,很明显,第二种算法完成需求,同时占用的内存空间更小;
- 花最少的时间完成需求:
二、算法分析
2.1 算法分析_时间复杂度分析
-
概述:
- 研究算法的最终目的就是如何花更少的时间,如何占用更少的内存去完成相同的需求,并且也通过案例演示了不同算法之间的时间耗费和空间耗费上的差异,但我们并不能将时间占用和空间占用量化;
- 接下来我们将学习对算法时间耗费和算法空间耗费的描述分析,有关算法时间耗费分析,我们称之为算法的时间复杂度分析,有关算法的空间耗费分析,我们称之为算法的控件复杂度分析;
-
分析方法:
- 事后分析估算方法:
- 代码示例:
- 描述:利用计算机计时器对不同的算法编制的程序运行时间进行比较,从而确定算法效率的高低。
- 缺点:
- 必须依靠实现编制好的测试程序
- 通常要花费大量的时间和精力。
- 如果测试完了发现测试的是非常糟糕的算法,那么就白费力气了。
- 不同的测试环境(硬件环境)的差别导致测试的结果差异也很大;
- 代码示例:
- 事前分析估算方法(在计算机程序编写前,依据统计算法对算法进行估算)
- 高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素:
- 算法采用的策略和方案;
- 编译产生的代码质量(由语言、版本决定,我们无法改变)
- 问题的输入规模(所谓的问题输入规模就是输入量的多少,比如一个排序算法,对0~100 进行排序和0~10000000之间进行排序,他们的输入规模不同)
- 机器执行指令的速度;(一般情况下,我们无法改变,比如说运行在公司服务器上,换更好的服务器不是咱们能决定的 =.=)
- 由此可见,抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模。如果算法固定,那么该算法的执行时间就只和问题的输入规模有关系了;
- 我们继续来看之前的求和案例,进行分析:
- 当输入规模为n时,第一种算法执行了 1+1+(n+1)+n= 2n+3次;第二种算法执行了1+1+1= 3次。如果我们把第一种算法的循环体看作是一个整体,忽略结束条件的判断,那么其实这两个算法运行时间的差距就是n和1 的差距。说明第二种算法优于第一种算法,它执行耗时更少;(在第一种算法和第二种算法中,如果只关注 sum+=i 和 sum = (n+1)*n/2 这一行代码时,可以看出来他们的差距就是n和1 的差距)
- 高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素:
- 事后分析估算方法:
-
问题:
- 为什么分析算法的耗时时,对待如2n+1 则直接视为时间复杂度为n,为什么可以忽略掉其中的常数呢?
- 如果我们要精确的研究循环的条件执行了多少次,是一件很麻烦的事情。并且由于真正计算的代码是内循环的循环体,所以,在研究算法的效率时,我们只考虑核心代码的执行次数,这样可以简化分析;
- 我们研究算法复杂度,侧重的是当输入规模不断增大时,算法的增长量的一个抽象(规律),而不是精确地定位需要执行多少次,否则容易主次颠倒;
- 总之,我们不关心编写程序所用的语言是什么,也不关心这些程序跑在什么机器上,我们只关心它所实现的算法。不去计算循环索引的递增和循环终止的条件、变量声明、打印结果等操作,最终在分析程序的运行时间时,最重要的是把程序看作是独立于程序设计语言的算法或一系列步骤。
- 我们分析一个算法的运行时间,最重要的就是把核心操作的次数和输入规模关联起来;
- 时间复杂度1、n、n^2 的耗时趋势是怎样的呢?
- 随着时间的增长,n^2的耗时会急剧增长,n的耗时是平稳增长,而复杂度为1 的一直处于稳定;也可以发现时间复杂度为1 的程序优于复杂度为n的;而n的优于n^2的。
- 为什么分析算法的耗时时,对待如2n+1 则直接视为时间复杂度为n,为什么可以忽略掉其中的常数呢?
-
函数渐进增长:
- 概述: 给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n> N ,f(n)总是比g(n)大,那么我们说f(n)的增长渐进快于g(n),概念似乎有点艰涩难懂,我们看如下问题来辅助分析:
- 测试问题1:如下哪个算法更快一点呢?
- 通过数据表格,比较算法A1和算法B1:
- 当输入规模n = 1 时,A1需要执行5次,B1需要执行4次,所以A1效率低于B1;
- 当输入规模n = 2 时,A1需要执行7次,B1需要执行7次,所以A1和B1的效率一样;
- 当输入规模n > 2 时,A1需要的执行次数一直比B1需要执行的次数少,所以A1 的效率比B1 的效率高;
- 由此我们可以得出结论:
- 当输入规模n>2时,算法A1 的渐进增长小于算法B1的渐进增长
- 同时,通过观察折线图,我们发现,随着输入规模的增大,算法A1和算法A2 逐渐重叠到一块,算法B1和算法B2逐渐重叠到一块,所以我们得出结论:
- 随着输入规模的增大,算法的常数操作可以忽略不计
- 测试问题2:
- 可以得出如下结论:随着输入规模的增大,与最高次项相乘的常数可以忽略
- 测试问题3:
- 通过数据表格,对比算法E1 和算法F1:
- 当n= 1 时,算法E1 和算法F1 的执行次数一样;
- 当n> 1 时,算法E1 的执行次数远远小于算法f1 的执行次数;
所以说算法E1 总体上是优于算法F1的;
- 通过折线图我们可以看到,算法F系列随着n的增长会变得特快,算法E系列随着n的增长相比较算法F来说,变得比较慢,所以可以得出结论:
- 最高次项的指数大的,随着n的增长,结果也会变得增长特别快;
- 测试问题4:
- 通过观察数据表格和折线图,很容易得出结论:
- 算法函数中n最高次幂越小,算法效率越高
- 综上所述,在我们比较算法随着输入规模的增长时,可以有如下规则:
- 算法函数中的常数可以忽略
- 算法函数中最高次幂的常数因子可以忽略
- 算法函数中最高次幂越小,算法效率越高
这三个结论对于我们今后的时间复杂度分析至关重要;
2.2 算法分析_时间复杂度分析_大O记法
- 定义:
- 在进行算法分析时,语句总的执行次数T(n)是关于问题规律n的函数,进而分析T(n)随着n的变化情况并确定T(n)的量级。
- 算法的时间复杂度,就是算法的时间量度,记作:T(n)= O(f(n)).
- 它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度,其中f(n)是问题规模n的某个函数;
- 大O记法:用大写O()来体现算法时间复杂度的记法,在一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法;
首先我们要明确一个事情: 执行次数= 执行时间
- 下面我们使用大O表示法来表示一些求和算法的时间复杂度:
-
算法1:
public static void main(String[] args){ int sum = 0; // 执行1 次 int n = 100; // 执行1 次 sum = (n+1)*n/2; // 执行1 次 System.out.println("sum="+sum); }
-
算法2:
public static void main(String[] args){ int sum = 0; // 执行1次 int n = 100; // 执行1次 for(int i = 1 ; i <= n; i++){ sum += i; // 执行了n次 } System.out.println("sum=" + sum); }
-
算法3:
public static void mian(String[] args){ int sum = 0; //执行1次 int n = 100; // 执行1次 for(int i = 1 ; i <= n; i++){ for(int j = 1; j <=n ; j++){ sum+=i; // 执行n^2次 } } System.out.println("sum="+sum); }
-
分析:
- 如果忽略判断条件的执行次数和输出语句的执行次数,那么当输入规模为n时,以上算法执行的次数分别为:
- 算法1: 3次
- 算法2: n+3次
- 算法3: n^2+2次
- 如果用大O记法表示上述每个算法的时间复杂度,应该如何表示呢?基于我们对函数渐进增长的分析,推导大O阶的表示法有以下几个规则可以使用:
- 用常数1取代运行时间中的所有加法常数;
- 在修改后的运行次数中,只保留高阶项;
- 如果最高阶项存在,且常数影子不为1,则去除与这个项相乘的常数;
- 所以,上述算法的大O记法分别为:
- 算法1:O(1)
- 算法2:O(n)
- 算法3: O(n^2)
- 如果忽略判断条件的执行次数和输出语句的执行次数,那么当输入规模为n时,以上算法执行的次数分别为:
-
2.3 常见的大O阶
- 线性阶:
- 一般含有非嵌套循环涉及线性阶,线性阶就是随着输入规模的扩大,对应计算次数呈直线增长,例如:
public static void main(String[] args){ int sum = 0; int n = 100; for (int i = 1; i <= n; i++){ sum += i; } System.out.println("sum="+sum); }
上面这段代码,它的循环的时间复杂度为O(n),因为循环体中的代码需要执行n次;
- 一般含有非嵌套循环涉及线性阶,线性阶就是随着输入规模的扩大,对应计算次数呈直线增长,例如:
-
平方阶
- 一般嵌套循环属于这种时间复杂度
public static void main(String[] args){ int sum =0;n=100; for(int i = 1; i< n; i++){ for(int j = 1; j<= n ; j++){ sum+= i; } } System.out.println(sum); }
上面这段代码,n=100,也就是说,外层每执行一次,内存循坏就执行100次,那总共程序想要从这两个循环中出来,就需要执行100*100次,也就是n的平方次,所以这段代码的时间复杂度是O(n^2);
- 一般嵌套循环属于这种时间复杂度
-
立方阶
- 一般三层嵌套循环属于这种时间复杂度
public static void main(String[] args){ int x =0;n=100; for(int i = 1; i <=n; i++){ for(int j =i; i<=n; j++){ for(int j = i; j<=n ; j++){ x++; } } } }
上面这段代码,n = 100,也就是说,外层每执行一次,中间循环就执行100次,中间循环每执行一次,最内层循环就要执行100次,那总共程序想要从这三个循环中出来,就需要执行100100100次,就是n的立方,所以这段代码的时间复杂度是O(n^3);
- 一般三层嵌套循环属于这种时间复杂度
-
对数阶
- 对数,属于高中数学的内容,我们分析程序以程序为主,数学为辅,所以不用过分担心。
public static void main(String[] args){ int i =1,n=100; while(i<n){ i = i*2; } }
由于每次i2 之后,就距离n更近一步。当(i2)执行x次之后大于指定的n(这里n是100),则会退出循环;所以可以得到 x^2=n 的公式,即 x=log(2)n,所以这个循环的时间复杂度为O(logn); (这里忽略了底数2)
- 对于对数阶,由于输入规模n的增大,不管底数是多少,他们的增长趋势是一样的,所以我们会忽略底数;
从图里可以看出来增长趋势是一样的,虽然结果有一定差异,但这不是我们所关注的重点;
- 对数,属于高中数学的内容,我们分析程序以程序为主,数学为辅,所以不用过分担心。
-
常数阶
- 一般不涉及循环操作的都是常数阶,因为它不会随着n的增长而增加操作次数。例如:
public static void mian(String[] args){ int n = 100; int i = n + 2; System.out.println(i); }
上述代码,不管输入规模n是多少,都执行2次,根据大O推导法则,常数用1来替换,所以上述代码的时间复杂度为O(1).
- 下面是对常见时间复杂度的一个总结:
描述 增长的数量级 说明 举例 常数级别 1 普通语句 将两个数相加 对数级别 logN 二分策略 二分查找 线性级别 N 循环 找出最大元素 线性对数级别 NlogN 分治思想 归并排序 平方级别 N^2 双层循环 检查所有元素对 立方级别 N^3 三层循环 检查所有三元组 指数级别 2^N 穷举查找 检查所有子集合 - 他们的复杂程序从低到高依次为:O(1)<O(logn) <O(n)<O(nlongn)<O(n2)<O(n3)*
- 根据前面的折线图分析,我们会发现,从平方阶开始,随着输入规模的增大,时间成本会急剧增大,所以我们的算法尽可能的追求的是O(1),O(logn),O(n),O(nlogn)这几种时间复杂度,而
如果发现算法的时间复杂度为平方阶、立方阶或者更复杂的,那我们可以认为这种算法是不可取的,需要优化
;
- 一般不涉及循环操作的都是常数阶,因为它不会随着n的增长而增加操作次数。例如:
2.4 函数调用的时间复杂度分析
- 之前,我们分析的都是单个函数内,算法代码的时间复杂度,接下来我们分析函数调用过程中时间复杂度;
- 案例一:
public static void main(String[] args){ int n = 100; for(int i = 0; i< n; i++){ show(i); } } private static void show(int i){ System.out.println(i); }
在main方法中,有一个for循环,循环体调用了show 方法,由于show 方法内部只执行了一行代码,所以show 方法的时间复杂度为O(1),那main方法的时间复杂度就是O(n);
- 案例二:
public static void main(String[] args){ int n = 100; for(int i = 0; i< n; i++){ show(i); } } private static void show(int i){ for(int j = 0; j< i; i++){ System.out.println(i); } }
在main方法中,有一个for循环,循环体调用了show 方法,由于show 方法内部也有一个for循环,所以show方法的时间复杂度为O(n),那main方法的时间复杂度为O(n^2);
- 案例三:
public static void main(String[] args){ int n = 100; show(n); for(int i = 0; i< n; i++){ show(i); } for(int i = 0; i< n; i++){ System.out.println(j); } } private static void show(int i){ for(int j = 0; j< i; i++){ System.out.println(i); } }
在show方法中,有一个for循环,所以show方法的时间复杂度为O(n),在main方法中,show(n)这行代码内部执行的次数为n,第一个for循环内调用了show方法,所以其执行次数为n2,第二个嵌套for循环内只执行了一行代码,所以其执行次数为n2,那么main方法总执行次数为: n+n2+n2 = 2n2+n,根据大O推导规则,去掉n保留最高阶项,并去掉最高阶项的常数影子2,所以最终main方法的时间复杂度为O(n2);
- 案例一:
2.5 最坏情况、最好情况、平均情况
- 概述:
- 从心理学角度讲,每个人对发生的事情都会有一个预期,比如看到半杯水,有人会说:哇哦,还有半杯水哦!(高于预期,这是他认为的最好情况),但也有人会说:天呐,只有半杯水了(比预期差,可能是他认为的最坏情况)。
- 在算法中也有最好情况、最坏情况、平均情况的分析;
- 举一个例子,请看这个需求:有一个存储了n个随机数字的数组,请从中查找出指定的数字:
-
public int search(int num){ int[] arr = { 11,10,8,9,7,22,23,0}; for(int i = 0; i< arr.length; i++){ if(num == arr[i]){ return i; } } return -1; }
- 最好情况:查找的第一个数字就是期望的数字,那么算法的时间复杂度为O(1)
- 最坏情况:查找的最后一个数字,才是期望的数字,那么算法的时间复杂度为O(n)
- 平均情况:任何数字查找的平均成本是O(n/2)
最坏情况是一种保证,在应用中,这是一种最基本的报账,即使在最坏的情况下,也能够正常提供服务,所以除非特别指定,我们提到的运行时间都指的是最坏情况下的运行时间;
-
三、 算法的空间复杂度分析
3.1 java中常见的内存占用
- 概述:
- 计算机的软硬件都经历了一个比较漫长的演变史,作为为运算提供环境的内存,更是如此,从早些时候的512k,经历了1M、2M、4M…等,发展到现在的8G、16G、32G;
- 所以早期,算法在运行过程中对内存的占用情况也是一个经常需要考虑的问题。
- 我们可以用算法的控件复杂度来描述算法对内存的占用;
- java中常见的内存占用
- 基本数据类型内存占用情况:
数据类型 内存占用字节数 byte 1 short 2 int 4 long 8 float 4 double 8 boolean 1 char 2 - 计算机访问内存的方式都是一次一个字节
- 一个引用(机器地址)需要8个字节表示:
- 例如:Date date = new Date(); 则date这个变量需要占用8个字节来表示;
- 创建一个对象,比如new Date(),除了Date对象内部存储的数据(例如年月日等信息)占用的内存,该对象本身也有内存开销,每个对象的自身开销是16个字节,用来保存对象的头信息;
- 一般内存的使用,如果不够8个字节,都会被自动填充为8字节:
- 代码如下:
public class A { public int a = 1; }
- 通过 new A() 创建一个对象的内存占用如下:
- 整型成员变量a占用4个字节;
- 对象本身占用16个字节;
那么创建该对象总共需要20个字节,但由于不是以8为单位,会自动填充为24个字节;
- 代码如下:
- java中数组被限定为对象,他们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组一般需要24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4个填充字节)再加上额外保存值所需的内存;
- 基本数据类型内存占用情况:
3.2 算法的空间复杂度
- 概述:
- 了解了java的内存最基本的机制,就能够有效帮助我们估计大量程序的内存使用情况;
- 算法的空间复杂度计算公式记作:S(n) = O(f(n)),其中n为输入规模,f(n)为语句关于n所占用存储空间的函数;
- 案例:对指定的数组元素进行翻转,并返回反转的内容:
- 解法一:
public static int[] reverse1(int[] arr){ int n = arr.length; //申请4个字节 int temp; // 申请4个字节 for(int start = 0; end = n-1; start <=end; start ++,end--){ temp = arr[start]; arr[start] = arr[end]; arr[end] = temp; } return arr; }
- 解法二:
public static int[] reverse2(int[] arr){ int n = arr.length; // 申请4个字节 int[] temp = new int[n]; // 申请n*4个字节+数组自身头信息开销24个字节 for(int i = n -1; i >=0; i--){ temp[n-1-i] = arr[i]; } return temp; }
- 忽略判断条件占用的内存,我们得出的内存占用情况如下:
- 算法1: 不管传入数组大小为多少,始终额外申请4+4=9字节的内存;
- 算法2: 4+4n+24 = 4n + 28;
- 根据大O推导法则,算法一的控件复杂度为O(1),算法二的控件复杂度为O(n),所以单从空间占用的角度讲,算法一要优于算法二;
- 解法一:
- 为什么要了解java的内存占用呢?
- 优于java中有内存垃圾回收机制,并且jvm对程序的内存占用也有优化(比如即时编译),我们无法精确的评估一个java程序的内存占用情况,但是了解java的基本内存占用,使我们可以对java程序的内存占用情况进行估算;
四、 排序算法
4.1 排序_Comparable 接口
-
排序概述:
- 排序是一种非常常见的需求,提供一些数据元素,把这些元素按照一定的规则进行排序。
- 比如查询一些订单,按照订单的日期进行排序;再比如查询一些商品,按照商品的价格进行排序等等。
在java 的开发工具包jdk中,已经给我们提供了很多数据结构与算法的实现,比如List,Set,Map,Math等等,都是以API的方式提供,这种方式的好处在于一次编写,多处使用;
-
Comparable接口介绍:
- 由于我们这里要讲排序,所以肯定会在元素之间进行比较,而Java提供了一个接口Comparable就是用来定义排序规则的;
- 我们以一个案例对Comparable接口做一个简单的回顾:
- 定义一个学生类Student,具有年龄age和姓名username两个属性,并通过Comparable接口提供比较规则;
// 学生类 public class Student implements Comparable<Student>{ private String username; private int age; // 省略getter、setter、toString()方法 @Override public int compareTo(Student o){ return this.getAge()-o.getAge(); } }
- 定义测试类Test,在测试类Test中定义测试方法Comparable getMax(Comparable c1, Comparable c2)完成测试:
public class TestComparable{ public static void main(String[] args){ // 创建两个student对象,并调用getMax方法,完成测试 Student s1 = new Student(); s1.setUsername("张三"); s1.setAge(18); Student s2 = new Student(); s2.setUsername("李四"); s2.setAge(20); Comparable max = getMax(s1,s2); System.out.println(max); } public static Comparable getMax(Comparable c1, Comparable c2){ int result = c1.compareTo(c2); // 如果result < 0,则c1 比c2 小; // 如果result > 0,则c1 比c2 大; // 如果result == 0, 则c1 和 c2一样大; if(result >= 0){ return c1; }else{ return c2; } } }
- 定义一个学生类Student,具有年龄age和姓名username两个属性,并通过Comparable接口提供比较规则;
4.2 冒泡排序
- 概述:
- 冒泡排序(Bubble Sort),是一种计算机科学领域的较简单的排序算法;
- 需求:
- 排序前:{4,5,6,3,2,1}
- 排序后:{1,2,3,4,5,6}
- 排序原理:
- 比较相邻的元素。如果前一个元素比后一个元素大,就交换着两个元素的位置;
- 对每一对相邻元素做同样的工作,从开始第一对元素到结尾的最后一对元素。最终最后位置的元素就是最大值;
- 代码实现:
public class Bubble{ /* * 对数组a中的元素进行排序 */ public static void sort(Comparable[] a){ for(int i = a.length-1;i>0; i--){ // 比较索引j 和索引j+1处的值 if(greater(a[j],a[j+1])){ exch(a,j,j+1); } } } /* * 比较v元素是否大于w元素 */ public static boolean greater(Comparable v, Comparable w){ return v.compareTo(w) > 0; } /* * 数组元素i和j交换位置 */ public static void exch(Comparable[] a,int i , int j){ Comparable temp; temp = a[i]; a[i]=a[j]; a[j]=temp; } }
- 测试类:
public class BubbleTest{ Integer[] arr ={ 4,5,6,3,2,1}; Bubble.sort(arr); System.out.println(Arrays.toString(arr)); }
- 冒泡排序时间复杂度分析
- 冒泡排序使用了双层for循环,其中内层循环的循环体是真正完成排序的代码,所以我们分析冒牌排序的时间复杂度,主要分析一下内存循环体的执行次数即可;
4.3 选择排序
-
概述:
- 选择排序是一种更加简单直观的排序算法;
- 排序原理:
- 每一次遍历的过程中,都假定第一个索引处的元素是最小值,和其他索引处的值依次进行比较,如果当前索引处的值大于其他某个索引处的值,则假定其他某个索引处的值为最小值,最后可以找到最小值所在的索引。
- 交换第一个索引处和最小值所在的索引处的值;
- 图示:
-
代码实现:
public class Selection { /* * 对数组a中的元素进行排序 */ public static void sort(Comparablep[] a){ for(int i = 0; i<= a.length-2; i++){ // 定义一个变量,记录最小元素所在索引,默认为参与选择排序的第一个元素所在的位置 for(int j = i+1; j<a.length; j++){ // 需要比较最小索引minIndex处的值和j索引处的值 if(greater(a[minIndex],a[j])){ minIndex = j; } } // 交换最小元素所在索引minIndex 处的值和索引i处的值; exch(a,i,minIndex); } /* * 比较v元素是否大于w元素 */ private static boolean greater(Comparable v,Comparable w){ return v.compareTo(w)>0; } /* * 数组元素i和j交换位置 */ public static void exch(Comparable[] a,int i,int j){ Comparable temp; temp = a[i]; a[i] = a[j]; a[j] = temp; } } }
-
测试类
public class SelectionTest { // 原始数据 Integer[] a = { 4,6,8,7,9,2,10,1}; Selection.sort(a); System.out.println(Arrays.toString(a)); }
-
选择排序的时间复杂度分析:
- 选择排序使用了双层for循环,其中外层循环完成了数据交换,内层循环完成了数据比较,所以我们分别统计数据交换次数和数据比较次数:
4.4 插入排序
-
概述:
- 插入排序(Insertion sort)是一种简单直观且稳定的排序算法。
- 插入排序的工作方式非常像人们排序一手扑克牌一样。
- 开始时,我们的左手为空并且桌子上的牌面朝下。
- 然后,我们每次从桌子上拿走一张牌并将它插入左手中正确的位置。
- 为了找到一张牌的正确位置,我们从右到左将它与已在手中的每张牌进行比较,如下图所示:
-
排序原理:
- 把所有的元素分为两组,已经排序的和未排序的;
- 找到未排序的组中的第一个元素,向已经排序的组中进行插入;
- 倒序遍历已经排序的元素,依次和待插入的元素进行比较,直到找到一个元素小于等于待插入元素,那么就把待插入元素放到这个位置,其他元素向后移动一位;
-
代码实现
public class Insertion { /* * 对数组a中的元素进行排序 */ public static void sort(Comparable[] a){ for(int i = 1; i < a.length; i++){ for(int j = i; j >= 0; j--){ // 比较索引j处的值和索引j-1处的值,如果索引j-1 处的值比索引j处的值大,则交换数据。如果不大,那么就找到合适的位置了,退出循环即可; if(greater(a[j-1]),a[j]){ exch(a,j-1,j); }else{ break; } } } } /* * 比较v元素是否大于w元素 */ private static boolean greater(Comparable v, COmparable w){ return v.compareTo(w)>0; } /* * 数组元素i和j交换位置 */ private static void exch(Comparable[] a,int i, int j){ Comparable temp; temp = a[i]; a[i] = a[j]; a[j] = temp; } }
-
测试
public calss IntertionTest { Integer[] a={ 4,3,2,10,12,1,5,6}; Intertion.sort(a); System.out.println(Arrays.toString(a)); }
-
插入排序的时间复杂度分析
- 插入排序使用了双层for循环,其中内层循环的循环体是真正完成排序的代码,所以,我们分析插入排序的时间复杂度,主要分析一下内存循环体的执行次数即可;
五. 高级排序
5.1 希尔排序
-
概述:
- 之前我们学过基础排序如冒泡排序、选择排序还有插入排序,并且对他们在最坏的情况下的时间复杂度做了分析,发现都是O(n^2),而平方阶通过我们之前学习算法分析我们知道,随着输入规模的增大,时间成本将急剧上升,所以这些基本排序方法不能处理更大规模的问题,接下来我们学习一些高级的排序算法,争取降低算法的时间复杂度最高阶次幂。
- 希尔排序是插入排序的一种,又称**“缩小增量排序”**,是插入排序算法的一种更高效的改进版本;
- 前面学习插入排序的时候,我们会发现一个很不友好的事儿,如果已排序的分组元素为(2,5,7,9,10),未排序的分组元素为{1,8},那么下一个待插入元素为1,我们需要拿着1从后往前,依次和10,9,7,5,2进行交换位置,才能完成真正的插入,每次交换只能和相邻的元素交换位置。那如果我们要提高效率,直观的想法就是依次交换,能把1放到更前面的位置,比如依次交换就能把1插到2和5之间,这样依次交换1就走向前了5个位置。
- 可以减少交换的次数,可这样的需求如何实现呢?接下来我们来看希尔排序的原理;
-
排序原理:
- 选定一个增长量h,按照增长量h作为数据分组的依据,对数据进行分组;
- 对分好组的每一组数据完成插入排序
- 减小增长量,最小减为1,重复第二步操作;
- 增长量h的确定:增长量h的值没有固定的规则,我们这里采用以下规则:
int h = 1; while(h < 数组的长度/2){ h = 2h +1; } // 循环结束后我们就可以确定h的最大值; h 的减小规则为: h = h/2;
它最大的优势在于不是挨着进行交换,而是可以在间隔很大的两个元素也可以完成交换,减少了交换的成本,从而提高了效率;
-
抛出问题:
- 排序前:{9,5,1,2,5,7,4,8,6,3,5},使用希尔排序后:{1,2,3,4,5,6,7,8,9}
-
代码实现:
public class Shell { /** * 对数组a中的元素进行排序 */ public static void sort(Comparable[] a){ // 1. 根据数组a的长度,确定增长量h的初始值 int h = 1; while(h < a.length/2){ h = 2*h+1; } // 2. 希尔排序 while(h >= 1){ // 排序 // 2.1 找到待插入的元素 for(int i = h; i < a.length;i++){ for(int j = i; j >= h; j-= h){ // 待插入的元素是a[j],比较a[j] 和 a[j-h] if(greater(a[j-h],a[j])){ exch(a,j-h,j); }else{ // 待插入元素已经找到了合适的位置,结束循环; break; } } } // 减少h的值 h = h/2; } } /* * 比较v元素是否大于w元素 */ private static boolean greater(Comparable v, COmparable w){ return v.compareTo(w)>0; } /* * 数组元素i和j交换位置 */ private static void exch(Comparable[] a,int i, int j){ Comparable temp; temp = a[i]; a[i] = a[j]; a[j] = temp; } }
-
测试类:
public class ShellTest{ public static void main(String[] args){ Integer[] a = { 9,1,2,5,7,4,8,6,3,5}; Shell.sort(a); System.out.println(Arrays.otString(a)); // {1,2,3,4,5,6,7,8,9} } }
-
希尔排序的时间复杂度分析
- 在希尔排序中,增长量h并没有固定的规则,有很多论文研究了各种不同的递增序列,但都无法证明某个序列是最好的,对于希尔排序的时间复杂度分析,已经超出了咱们博客的设计范畴,所以此处就不再继续分析了;
- 我们可以使用事后分析法对希尔排序和插入排序做比较;比如说找一个指定数据规模的数据,通过之前前后记录时间来测试出它的执行耗时,同时还可以同时用其他算法来进行排序,在同等数据规模下,测试它与其他算法之间的性能差距;
- 定义测试方法:
- 对比测试各自的性能:
- 经过实测,一百万的逆序度最高的数据,使用插入排序,需要37499毫秒,而使用希尔排序则只需要30毫秒!
同学们可以自己本地实测一下;具体代码就不贴了,前面文章认真看了相信可以自己写出来;
- 我们不能事前分析出它的时间复杂度,但是经过我们的实际测试(事后分析),发现它在大数据量的情况下执行耗时非常短,也就是说,在数据达到一定规模它的性能会比插入排序等平方阶复杂度更优秀;
5.2 递归
- 正式学习归并排序前,我们先来学习一下递归;
- 定义:
- 定义方法时,在方法内部调用方法本身,称之为递归:
public void show() { System.out.println("aaa"); show(); }
- 定义方法时,在方法内部调用方法本身,称之为递归:
- 作用:
- 它通常把一个大型复杂问题,层层转换为一个与原问题相似的,规模较小的问题来求解。递归策略只需要少量的程序就可以扫描出解题过程中所需要的的多次重复计算,大大的减少了程序的代码量;
- 举一个例子:我们在扫描文件的时候,程序会先找到第一个文件夹,然后陆续遍历每个文件夹;在遍历第一个文件夹的时候,会先进去看里面是否还有文件夹,这就是递归的遍历;当达到递归到最大深度了就会从第二个文件夹继续遍历搜寻,这就是递归的当前逻辑的结束;当找到目标文件时或者递归次数到达一定程度,就会结束递归,这是递归的终止条件;
- 注意事项:
- 在递归中,不能无限制的调用自己,必须要有边界条件,能够让递归结束,因为每一次递归调用都会在栈内存开辟新的空间,重新执行方法,如果递归的层级太深,很容易造成栈内存溢出;
- 需求问题:
- 请定义一个方法,使用递归完成N的阶乘:
- 代码实现:
public class Test{ public static void main(String[] args) throws Exception{ int result = factorial(5); System.out.println(result); } public static int factorial(int n){ if(n==1){ return 1; } } }
由于没有递归最大次数上限,所以这段代码执行时一定会栈内存溢出;平时使用递归时,一定要注意;
5.3 归并排序
-
概述:
- 递归排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。
- 将已有序的子序列合并,得到完整有序的序列;即先使每个子序列有序,再使子序列段间有序。
- 若将两个有序表合并成一个有序表,称为二路归并;
-
需求:
- 排序前:{8,4,5,7,3,6,2}
- 排序后:{1,2,3,4,5,6,7,8}
-
归并排序原理:
- 尽可能的一组数据拆分成两个元素相等的子组,并对每一个子组继续拆分,直到拆分后的每个子组的元素个数是1为止;
- 将相邻的两个子组进行合并成一个有序的大组;
- 不断的重复步骤2,直到最终只有一个组为止;
-
归并代码:
public class Merge{ // 归并所需要的辅助数组 private static Comparable[] assist; /* * 比较v元素是否小于w元素 */ private static boolean less(Comparable v, Comparable w){ return v.compareTo(w)<0; } /* * 数组元素i和j交换位置 */ private static void exch(Comparable[] a,int i, int j){ Comparable t = a[i]; a[i] = a[j]; a[j] = t; } /* * 对数组a中的元素进行排序 */ public static void sort(Comparable[] a){ // 1. 初始化辅助数组 assist; assist = new Comparable[a.length]; // 2. 定义一个lo变量,和hi变量,分别记录数组中最小的索引和最大的索引; int lo = 0; int hi = a.length-1; // 3. 调用sort 重载方法完成数组a中,从索引lo到索引hi的元素的排序; sort(a,lo,hi); } /* * 对数组a中从lo到hi的元素进行排序 */ private static void sort(Comparable[] a,int lo, int hi){ // 做安全性校验 if(hi <= lo){ return; } // 对lo到hi之间的数据进行分为两个组 int mid = lo + (hi-lo)/2; // 分别对每一组数据进行排序 sort(a,lo,mid); sort(a,mid+1,hi); // 再把两个组中的数据进行归并 merge(a,lo,mid,hi); } /* * 对数组中,从lo到mid为一组,从mid+1到hi为一组,对这两组数据进行归并; */ private static void merge(Comparable[] a, int lo, int mid, int hi){ // 定义三个指针 int i = lo; int p1 = lo; int p2 = mid +1; // 遍历,移动p1指针和p2指针,比较对应索引处的值,找出小的那个,放到辅助数据的对应索引处; while(p1 < mid && p2 <= hi){ // 比较对应索引处的值 if(less(a[p1],a[p2])){ assist[i++] = a[p1++]; }else{ assist[i++] = a[p2++]; } } // 遍历,如果p1 的指针没有走完,那么顺序移动p1指针,把对应的元素放到辅助数组的对应索引处; while(p1 <= mid){ assist[i++] = a[p1++]; } // 遍历,如果p2的指针没有走完,那么顺序移动p2指针,把对应的元素放到辅助数组的对应索引处; while(p2<=hi){ assist[i++] = a[p2++]; } // 把辅助数组中的元素拷贝到原数组中 for(int index = lo; index <= hi; index++){ a[index] = assist[index]; } } }
-
代码逻辑图示:
-
测试类:
public static MergeTest{ public static void main(String[] args){ Integer[] a = { 8,4,5,7,1,3,6,2}; Merge.sort(a); System.out.println(Arrays.toString(a)); // {1,2,3,4,5,6,7,8} } }
-
归并排序时间复杂度分析:
-
归并排序的缺点:
- 需要申请额外的数组空间,导致空间复杂度提升,是典型的以空间换时间的操作;
-
归并排序与希尔排序性能测试:
- 通过实际测试一百万条数据的排序,归并排序耗时为70毫秒左右,说明他们之间在一百万的逆序度最高的情况下,算法性能接近;
5.4 快速排序
-
概述:
- 快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将要排序的数据分隔成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序;
- 整个排序过程可以递归进行,以此达到整个数据变成有序序列;
-
需求:
- 排序前:{6,1,2,7,9,3,4,5,8}
- 排序后:{1,2,3,4,5,6,7,8,8}
-
排序原理:
- 先设定一个分界值,通过该分界值将数组分成左右两部分;
- 将大于或等于分界值的数据放到数组右边,小于分界值的数据放到数组的左边。此时左边部分中各个元素都小于或等于分界值,而右边部分中各个元素都大于或等于分界值;
- 然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组也可以做类似处理;
- 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左侧和右侧两个部分的数据排完序后,整个数组的排序也就完成了;
- 切分原理:把数组切分成两个子数组的基本思想:
- 找一个基准值,用两个指针分别指向数组的头部和尾部;
- 先从尾部向头部开始搜索一个比基准值小的元素,搜索到即停止,并记录指针的位置;
- 再从头部向尾部开始搜索一个比基准值大的元素,搜索到即停止,并记录指针的位置;
- 交换当前左边指针位置和右边指针位置的元素;
- 重复2,3,4步骤,直到左边指针的值大于右边指针的值停止;
- 切分过程:
-
代码演示:
public class Quick { /* * 比较v元素是否小于w元素 */ private static boolean less(Comparable v, Comparable w){ return v.compareTo(w) <0; } /* * 数组元素i和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 sort(Comparable[] a){ int lo = 0; int hi = a.length -1; sort(a,lo,hi); } /* *对数组a中从索引lo到索引hi之间的元素进行排序 */ private static void sort(Comparable[] a,int lo, int hi){ // 安全性校验 if(hi<= lo){ return; } // 需要对数组中lo索引到hi索引处的元素进行分组(左子组合右子组); int partition = partition(a,lo,hi); // 返回的是分组的分界值所在的索引。 // 让左子组有序 sort(a,lo,partition-1); // 让右子组有序 sort(a,partition+1,hi); } /* * 对数组a中,从索引lo到索引hi之间的元素进行分组,并返回分组界限对应的索引; */ public static int partition(Comparable[] a, int lo, int hi){ // 确定分界值 Comparable key = a[lo]; // 定义两个指针,分别指向待切分元素的最小索引处和最大索引处的下一个位置 int left = lo; int right = hi +1; // 切分 while(true){ // 先从右往左扫描,移动right指针,找到一个比分界值小的元素,停止; while(less(key,a[--right])){ if(right == lo){ break; } } // 再从左往右扫描,移动left指针,找到一个比分界值大的元素,停止; while(left(a[++left],key)){ if(left == hi){ break; } } // 判断left >= right ,如果是,则证明元素扫描完毕,结束循环,如果不是,则交换元素即可; if(left >= right){ break; }else{ exch(a,left,right); } } // 交换分界值 exch(a,lo,right); return right; } }
-
测试类
public class QuickTest { public static void main(String[] args){ Integer[] a= { 6,1,2,7,9,3,4,5,8}; Quick.sort(a); System.out.println(Arrays.toString(a)); // {1,2,3,4,5,6,7,8,9} } }
-
快速排序和归并排序的区别?
- 快速排序是另外一种分治的排序算法;
- 它将一个数组分成两个子数组,将两部分独立的排序。
- 快速排序和归并排序是互补的:归并排序将两个数组分成两个子数组分别排序,并将有序的子数组归并从而将整个数组排序,而快速排序的方式则是当两个数组都有序时,整个数组自然就有序了。
- 在归并排序中,一个数组被等分为两半,归并调用发生在处理整个数组之前,在快速排序中,切分数组的位置取决于数组的内容,递归调用发生在处理整个数组之后;
快速排序没有归并操作,快速排序按照分界值切分,并不一定是等分;
-
快速排序时间复杂度分析:
- 快速排序的一次切分从两头开始交替搜索,直到left和right重合,因此,一次切分算法的时间复杂度为O(n),但整个快速排序的时间复杂度和切分的次数相关;
- 最优情况:每一次切分选择的基准数组刚好将当前序列等分;
- 如果我们把数组的切分看做是一个树,那么上图就是它的最优情况的图示,共切分了logn次,所以,最优情况下快速排序的时间复杂度为O(nlogn);
- 最坏情况:每一次切分选择的基准数字是当前序列中最大数或者最小数,这使得每次切分都会有一个子组,那么总共就得切分n次,所以,最坏情况下,快速排序的时间复杂度为O(n^2);
5.5 排序的稳定性
-
稳定性的定义:
- 数组arr中有若干个元素,其中A元素和B元素相等,并且A元素在B元素前面,如果使用某种排序算法排序后,能够保证A元素依然在B元素的前面,可以说这个算法是稳定的。
- 数组arr中有若干个元素,其中A元素和B元素相等,并且A元素在B元素前面,如果使用某种排序算法排序后,能够保证A元素依然在B元素的前面,可以说这个算法是稳定的。
-
稳定性的意义:
- 如果一组数据只需要一次排序,则稳定性一般是没有意义的,如果一组数据需要多次排序,稳定性是有意义的。
- 例如要排序的内容是一组商品对象,第一次排序按照价格由低到高的顺序展现,只有销量不同的对象才需要重新排序。这样既可以保持第一次排序的原有意义,而且可以减少系统系统开销;
- 再比如说,我们按照时间先后顺序查询了一些订单,我们再将它按照订单金额的大小排序。如果它还能够保持时间的先后顺序(稳定性),在用户体验上也会更好,它能够保证排序前后均保持了原有条件的顺序性;
图中所示,就是在当前条件相等的数据,保持了原有的顺序;
-
常见算法的稳定性:
- 冒泡排序:
- 只有当arr[i] > arr[i+1]的时候,才会交换元素的位置,而相等的时候并不交换位置,所以冒泡排序是一种排序稳定算法;
- 选择排序:
- 选择排序是给每个位置选择当前元素最小的。
- 例如有数据{5(1),8,5(2),2,9},第一遍选择到的最小元素为2,所以5(1)会和2进行交换位置,此时5(1)到了5(2)后面,破坏了稳定性。、
- 所以选择排序是一种不稳定的排序算法。
- 插入排序:
- 比较是从有序序列的末尾开始,也就是想要加入的元素和已经有序的最大者比起,如果比它大则直接加入在其后面,否则一直往前找,直到找到它该加入的位置。
- 如果碰见一个和加入元素相等的数据,那么把要加入的元素放在相等元素的后面。(先进的在前面,后进的在后面,跟没有排序前的前后位置一样;)
- 所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的;
- 希尔排序:
- 希尔排序是按照不同步长对元素进行插入排序,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。
- 归并排序:
- 归并排序在归并的过程中,只有arr[i] < arr[i+1]的时候才会交换位置,如果两个元素相等则不会交换位置,所以它并不会破坏稳定性,归并排序是稳定的;
- 快速排序:
- 快速排序需要一个基准值,在基准值的右侧找一个比基准值小的元素,在基准值的左侧找一个比基准值大的元素,然后交换着两个元素,此时会破坏稳定性,所以快速排序是一种不稳定的算法;
- 冒泡排序:
六、线性表
6.1 线性表概述
- 描述:
- 线性表是最基本、最简单、也是最常用的一种数据结构;
- 一个线性是n个具有相同特性的数据元素的有限序列;
- 前驱、后驱元素:
- 前驱元素:若A元素在B元素的前面,则称A为B的前驱元素;
- 后继元素:若B元素在A元素的后面,则称B为A的后继元素;
- 线性表的特征:数据元素之间具有一种“一对一”的逻辑关系。
- 第一个数据元素没有前驱,这个数据元素被称为头结点;
- 最后一个数据元素没有后继,这个数据元素被称为尾结点;
- 除了第一个和最后一个数据元素外,其他数据元素有且仅有一个前驱和一个后继;
如果把线性表用数学语言来定义,则可以表示为(a1,…ai-1,ai,ai+1,…an),ai-1领先于ai,ai领先于ai+1,称ai-1是ai的前驱元素,ai+1是ai的后继元素;
- 线性表的分类:
- 线性表中数据存储的方式可以是顺序存储,也可以是链式存储,按照数据的存储方式不同,可以把线性表分为顺序表和链表;
由此我们可以知道,数组是顺序表的实现,链表链式存储的实现,它们都属于线性表,符合线性表的规范;
6.2 顺序表
-
概述:
- 顺序表示在计算机内存中以数组的形式保存的线性表。
- 线性表的顺序存储是指用一组地址连续的存储单元,依次存储线性表中的各个元素,使得线性表中在逻辑结果上响应的数据元素存储在相邻的物理存储单元中;
- 所以它们是通过数据元素物理存储的相邻关系来反映数据元素之间逻辑上的相邻关系。
-
代码演示:
public class SequenceList<T>{ // 存储元素的数组 private T[] eles; // 记录当前顺序表中的元素个数 private int N; // 构造方法 public SequenceList(int capacity){ // 初始化数组 this.elses = (T[])new Object[capacity]; this.N =0; } // 将一个线性表置为空表 public void clear(){ this.N = 0; } // 判断当前线性表是否为空表 public boolean isEmpty(){ return N == 0; } // 获取线性表的长度 public int length(){ return N; } // 获取指定位置的元素 public T get(int i){ return eles[i]; } // 向线性表中添加元素t public void insert(T t){ eles[N++]=t; } // 在i元素处插入元素t public void insert(int i, T t){ // 先把i索引处的元素及其后面的元素依次向后移动一位 for(int index = N-1; index > i; index --){ eles[index] = eles[index -1]; } // 再把t元素放到i索引处即可 eles[i] = t; // 元素个数+1 N++; } // 删除指定位置i处的元素,并返回该元素 public T remove(int i){ // 记录索引i处的值 T current = eles[i]; // 索引i后面元素依次向前移动一位即可 for(int index = i; index < N-1; index ++){ eles[index] = eles[index + 1]; } // 元素个数 -1 N --; return current; } // 查找t元素第一次出现的位置 public int indexOf(T t){ for(int i = 0; i < N; i++){ if(eles[i].equals(t)){ return i; } } return -1; } }
-
测试代码:
6.3 顺序表的遍历
-
一般作为容器存储数据,都需要向外部提供遍历的方式,因此我们需要给顺序表提供遍历方式。
-
在java中,遍历集合的方式一般都是用的是foreach循环,如果想让我们的SequenceList也能支持foreach循环,则需要做如下操作:
- 让SequenceList实现Iterable接口,重写Iterator方法;
- 在SequenceList内部提供一个内部类Siterator,实现Iterator接口,重写hastNext方法和next方法;
-
代码演示:
public class SequenceList<T> implements Iterable<T>{ // 存储元素的数组 private T[] eles; // 记录当前顺序表中的元素个数 private int N; // 构造方法 public SequenceList(int capacity){ // 初始化数组 this.elses = (T[])new Object[capacity]; this.N =0; } // 将一个线性表置为空表 public void clear(){ this.N = 0; } // 判断当前线性表是否为空表 public boolean isEmpty(){ return N == 0; } // 获取线性表的长度 public int length(){ return N; } // 获取指定位置的元素 public T get(int i){ return eles[i]; } // 向线性表中添加元素t public void insert(T t){ eles[N++]=t; } // 在i元素处插入元素t public void insert(int i, T t){ // 先把i索引处的元素及其后面的元素依次向后移动一位 for(int index = N-1; index > i; index --){ eles[index] = eles[index -1]; } // 再把t元素放到i索引处即可 eles[i] = t; N++; } // 删除指定位置i处的元素,并返回该元素 public T remove(int i){ // 记录索引i处的值 T current = eles[i]; // 索引i后面元素依次向前移动一位即可 for(int index = i; index < N-1; index ++){ eles[index] = eles[index + 1]; } // 元素个数 -1 N --; return current; } // 查找t元素第一次出现的位置 public int indexOf(T t){ for(int i = 0; i < N; i++){ if(eles[i].equals(t)){ return i; } } return -1; } @Override public Iterator<T> iterator(){ return new SIterator(); } private class SIterator implements Iterator{ private int cusor; public SIterator(){ this.cusor = 0; } @Override public boolean hasNext(){ return cusor < N; } @Override public Object next(){ return eles[cusor++]; } } }
6.4 顺序表的容量可变
-
引导语:
- 在之前的实现中,当我们使用SequeenceList时,先 new SequenceList(3)创建一个对象,创建对象时就需要制定容器的大小,初始化指定大小的数组来存储元素。
- 当我们插入元素时,如果已经插入了3个元素,还要继续插入数据,则会报错,就不能插入了。
- 这种设计不符合容器的设计理念,因此我们在设计顺序表时,应该考虑它的容量的伸缩性;
考虑容器的容量伸缩性,其实就是改变存储数据元素的数组的大小,那我们需要考虑什么时候需要改变数组的大小?
-
什么时候考虑改变数组的大小?
- 添加元素时:
- 添加元素时,应该检查当前数组的大小是否能容纳新的元素,如果不能容纳,则需要创建新的容量更大的数组,我们这里创建一个是原数组两倍容量的新数组存储元素;
- 移除元素时:
- 移除元素时,应该检查当前数组的大小是否太大,比如正在用100个容量的数组存储10个元素,这样就会造成内存空间的浪费,应该创建一个容量更小的存储元素。
- 如果我们发现数据元素的数量不足数据容量的1/4,则创建一个是原数组容量的1/2的新数组存储元素;
- 添加元素时:
-
代码演示:
- 由于整个集合方法太多不能凸显重点,博主只列关键方法:
- 根据参数newSize,重置eles的大小:
public void resize(int newSize){ // 定义一个临时数组,指向原数组 T[] temp = eles; // 创建新数组 eles = (T[]) new Object[newSize]; for(int i = 0; i < N; i++){ eles[i] = temp[i]; } }
- 对应的新增方法内增加扩容判断:
6.5 顺序表的时间复杂度
- get(i):
- 从前面手写List的代码中不难看出,不论数据元素量N有多大,只需要一次eles[i]就可以获取到对应的元素,所以时间复杂度为O(1);
- insert(int i, T t):
- 每一次插入,都需要把i位置后面的元素移动一次,随着元素数量N的增大,移动的元素也越多,时间复杂度为O(n);
- remove(int i):
- 每一次删除,都需要把i位置后面的元素移动一次,随着数据量N的增大,移动的元素也越多,时间复杂度为O(n);
由于顺序表的低层由数组实现,数组的长度是固定的,所以在操作的过程中涉及到了容器扩容操作。这样会导致顺序表在使用过程中的时间复杂度不是线性的,在某些需要扩容的节点处,耗时会突增,尤其是元素越多,这个问题越明显;
- ArrayList: 我们java程序员最常用的集合就是这个,同学们可以看看ArrayList的源码,它的底层也是一种顺序表,使用数组实现,同样提供了增删改查以及扩容等功能;我们可以观察它:
- 是否用数组实现;
- 有没有扩容操作;
- 有没有提供遍历方式;
七、链表
7.1 链表概述
-
为什么要使用链表?
- 之前我们已经使用顺序存储结构实现了线性表,我们会发现虽然顺序表的查询很快,时间复杂度为O(1),但是增删的效率是比较低的,因为每一次增删操作都伴随着大量的数据元素移动。
- 这个问题有没有解决方案呢?有,我们可以使用另外一种存储结构实现线性表,链式存储结构。
-
链表概述:
- 链表是一种物理存储单元上非连续、非顺序的存储结构,其物理结构不能直观的表示数据元素的逻辑顺序,数据元素的逻辑顺序是通过链表中的指针连接次序实现的。
- 链表由一系列的结点(链表中的每一个元素称为结点)组成,结点可以在运行时动态生成。
-
代码演示:
- 结点类实现:
public class Node<T>{ // 存储元素 public T item; // 指向下一个结点 public Node next; public Node(T item,Node next){ this.item = item; this.next = next; } }
- 生成链表:
public static void main(String[] args) throws Exception{ // 构建结点 Node<Integer> first = new Node<Integer>(11,null); Node<Integer> second = new Node<Integer>(13,null); Node<Integer> third = new Node<Integer>(12,null); Node<Integer> fourth = new Node<Integer>(8,null); Node<Integer> fifth = new Node<Integer>(9,null); // 生成链表 first.next = second; second.next = third; third.next = fourth; fourth.next = fifth; }
- 结点类实现:
7.2 单向链表
-
概述:
- 单向链表是链表的一种,它由多个结点组成;
- 每个结点都由一个数据域和一个指针域组成
- 数据域用来存储数据,指针域用来指向其后继节点。
- 链表的头结点的数据域不存储数据,指针域指向第一个真正存储数据的节点。
-
单向链表API设计
-
单向链表代码实现
public class LinkList<T> implements Iterable{ // 记录头结点 private Node head; // 记录链表的长度 private int N; // 结点类 private class Node{ // 存储数据 T item; // 下一个结点 Node next; public Node(T item, Node next){ this.item = item; this.next = next; } } public LinkList(){ // 初始化头结点 this.head = new Node(null,null); // 初始化元素个数 this.N = 0; } // 清空链表 public void clear(){ head.next = null; this.N =0; } // 获取链表的长度 public int length(){ return N; } // 判断链表是否为空 public boolean isEmpty(){ return N==0; } // 获取指定位置i处的元素 public T get(int i){ // 通过循环,从头结点开始往后找,依次找i次,就可以找到对应元素 Node n = head.next; for(int index =0; index < i; index ++){ n = n.next; } return n.item; } // 向链表中添加元素 public void insert(T t){ // 找到当前最后一个结点 Node n = head; while(n.next != null){ n = n.next; } // 创建新结点,保存元素t Node newNode = new Node(t,null); // 让当前最后一个结点指向新结点; n.next = newNode; // 元素的个数+1 N++; } // 向指定位置i处添加元素t public void insert(int i, T t){ // 找到i位置前一个结点 Node pre = head; for(int index = 0; index <= i -1; i++){ pre = pre.next; } // 找到i位置的结点 Node curr = pre.next; // 创建新结点,并且新结点需要指向原来i位置的结点 Node newNode = new Node(t,curr); // 原来i位置的前一个结点指向新结点即可 pre.next = newNode; // 元素的个数+1 N++; } // 删除指定位置i处的元素,并返回被删除的元素 public T remove(int i){ // 找到i位置的前一个结点 Node pre = head; for(int index =0; index <=i-1; i++){ pre = pre.next; } // 要找到i位置的节点 Node curr = pre.next; // 找到i位置的下一个节点 Node nextNode = curr.next; // 前一个结点指向下一个结点 pre.next = nextNode; // 元素个数 -1 N--; return curr.item; } // 查找元素i在链表中第一次出现的位置 public int indexOf(T t){ // 从头结点开始,依次找到每一个结点,取出item和t比较,如果相同就找到了; Node n = head; for(int i = 0; n.next != null; i++){ n = n.next; if(n.item.equals(t)){ return i; } } return -1; } @Override public Iterator<T> iterator(){ return new LIterator(); } private class LIterator implements Iterator{ private Node n; public LIterator(){ this.n = head; } @Override public boolean hasNext(){ return n.next != null; } @Override public Object next(){ n = n.next; return n.item; } } }
-
测试类:
7.3 双向链表
-
概述:
- 双向链表也叫双向表,是链表的一种,它由多个结点组成,每个结点都由一个数据域和两个指针域组成,数据域用来存储数据,其中一个指针域用来指向其后继结点,另一个指针域用来指向前驱结点。
- 链表的头结点不存储数据,指向前驱结点的指针域值为null,指向后继结点的指针域指向第一个真正存储数据的结点;
-
API的设计:
- 结点API设计:
- 双向链表API设计:
按照面向对象的思想,我们需要设计一个类,来描述结点这个事物,由于结点是属于链表的,所以我们把结点类作为链表类的一个内部类来实现;
- 结点API设计:
-
代码演示:
public class TowWayLinkList<T> implements Iterable<T>{ // 首结点 private Node head; // 最后一个结点 private Node last; // 链表的长度 private int N; // 结点类 private class Node{ public Node(T item, Node pre, Node next){ this.item = item; this.pre = pre; this.next = next; } // 存储数据 public T item; // 指向上一个结点 public Node pre; // 指向下一个结点 public Node next; } public TowWayLinkList(){ // 初始化头结点和尾结点 this.head = new Node(null,null,null); this.last = null; // 初始化元素个数 this.N = 0; } // 清空链表 public void clear(){ this.head.next = null; this.head.pre = null; this.head.item = null; this.last = null; this.N = 0; } // 获取链表长度 public int length(){ return N; } // 判断链表是否为空 public boolean isEmpty(){ return N ==0; } // 获取第一个元素 public T getFirst(){ if(isEmpty()){ return null; } return head.next.item; } // 获取最后一个元素 public T getList(){ if(isEmpty()){ return null; } return last.item; } // 插入元素i public void insert(T t){ if(isEmpty()){ // 如果链表为空 // 创建新的结点 Node newNode = new Node(t,head,null); // 让新结点成为尾结点 last = newNode; // 让头结点指向尾结点 head.next = last; }else{ // 如果链表不为空 Node oldLast = last; // 创建新的结点 Node newNode = new Node(t,oldLast,null); // 让当前的尾结点指向新结点 oldLast.next = newNode; // 让新结点成为尾结点 last = newNode; } // 元素个数+1 N++; } // 向指定位置i处插入元素t public void isnert(int i , T t){ // 找到i位置的前一个结点 Node pre = head; for(int index =0; index < i; index ++){ pre = pre.next; } // 找到i位置的结点 Node curr = pre.next; // 创建新结点 Node newNode = new Node(t,pre,curr); // 让i位置的前一个结点的下一个结点变为新结点 pre.next = newNode; // 让i位置的前一个结点变为新结点 curr.pre = newNode; // 元素个数+1 N++; } // 获取指定位置i处的元素 public T get(int i){ Node n = head.next; for(int i = 0; n.next != null; i++){ n = n.next; if(n.next.equals(t)){ return i; } } return -1; } // 找到元素t 在链表中第一次出现的位置 public int indexOf(T t){ Node n = head; for(int i =0; n.next != null; i++){ n = n.next; if(n.next.equals(t)){ return i; } } return -1; } // 删除位置i处的元素并返回该元素 public T remove(int i){ // 找到i位置的前一个结点 Node pre = head; for(int index = 0; index <i; index++){ pre = pre.next; } // 找到i位置的结点 Node curr = pre.next; // 找到i位置的下一个结点 Node nextNode = curr.next; // 让i位置的前一个结点的下一个结点变为i位置的下一个结点 pre.next = nextNode; // 让i位置的下一个结点的上一个节点变为i位置的前一个结点 nextNode.pre = pre; // 元素的个数 -1 N--; return curr.item; } @Override public Iterator<T> iterator(){ return new TIterator(); } private class TIterator implements Iterator{ private Node n; public TIterator(){ this.n = head; } @Override public boolean hasNext(){ return n.next != null; } @Override public Object next(){ n = next; return n.item; } } }
-
测试类:
7.4 链表的复杂度分析
- get(int i):
- 每一次查询都是从链表的头部开始,依次向后查找,随着数据元素的增多,比较的元素越多,时间复杂度为O(n);
- insert(int i, T t):
- 每一次插入,都需要先找到i位置的前一个元素,然后完成插入操作,随着数据元素N的增多,查找的元素越多,时间复杂度为O(n);
- remove(int i):
- 每一次移除,需要先找到i位置的前一个元素,然后完成插入操作,随着数据元素N的增多,查找的元素越多,时间复杂度为O(n)
总结来说:
- 相比较顺序表,链表插入和删除的时间复杂度虽然一样,但仍然有很大的优势,因为链表的物理地址是不连续的,它不需要预先指定存储空间大小,或者在存储过程中涉及到扩容等操作,同时它并没有涉及元素的交换;
- 相比较顺序表,链表的查询操作性能会比较低。因此,如果我们的程序中查询操作比较多,建议使用顺序表,增删操作比较多,建议使用链表;
关于链表和数组的对比,我们不能够道听途说,真正地理解了它底层实现,相信每个同学都会有自己的判断;
- 每一次移除,需要先找到i位置的前一个元素,然后完成插入操作,随着数据元素N的增多,查找的元素越多,时间复杂度为O(n)
7.5 链表反转
-
单链表的反转,是面试中的一个高频题目。
-
需求:
- 原链表中数据为:1->2->3->4
- 反转后的链表为:4->3->2->1
-
反转api设计:
- public void reverse(): 对整个链表反转
- public Node reverse(Node curr): 反转链表中的某个结点curr,并把反转后的curr结点返回;
-
原理分析:
- 使用递归可以完成反转;递归反转其实就是从原链表的第一个存数据的结点开始,依次递归调用反转每一个结点,直到把最后一个结点反转完毕,整个链表就反转完毕;
- 图示:
-
代码演示:【为方便展示关键点,此处只写反转部分代码,其他部分请找上一章节的代码】
// 用来反转整个链表 public void reverse(){ // 判断当前链表是否为空链表,如果是空链表则结束运行 if(isEmpty()){ return; } reverse(head.next); } // 反转指定的结点curr,并把反转后的结点返回 public Node reverse(Node curr){ if(curr.next == null){ head.next = curr; return curr; } // 递归的反转当前结点curr的下一个结点;返回值就是链表翻转后,当前结点的上一个结点; Node pre = reverse(curr.next); // 让返回的结点的下一个结点变成当前结点curr; pre.next = curr; // 把当前结点的下一个结点变为null curr.next = null; return curr; }
-
测试类:
本篇博客还没有结束,更多精华后续陆续更新哦!欢迎收藏点赞评论!