(最近刚来到简书平台,以前在CSDN上写的一些东西,也在逐渐的移到这儿来,有些篇幅是很早的时候写下的,因此可能会看到一些内容杂乱的文章,对此深感抱歉,以下为正文)
引子
众所周知,数据结构和算法对于一个开发人员是多么的重要,一个好的数据结构和算法,可以让你在实现同一个功能的时候,提升非常多的效率。笔者作为一个初入IT业的菜鸟,觉得也很有必要在这方面下一番功夫,所以特开此篇作为学习数据结构和算法的开篇,后面会继续记录分享我的学习经历。
因为笔者主要做Java这块儿的工作,所以前期写的东西大多都是以Java作为基础语言来进行描述的,不过数据结构和算法这种东西,其实跟语言的关联性不是特别大。想学习算法,那么必须对数据结构有一定的了解,一些好的算法难免要结合一些数据结构的知识来实现。本篇讲述的是最基础最简单的数据结构(数组,集合和散列表),尽管它们很基础,但我们仍需要好好的去了解它们。
正文
数组
数组是数据结构中最为基础的一个结构,是我们学习数据结构与算法的基石,事实上大部分的数据结构都可以用数组来进行实现。就像在前面Java IO笔记的篇幅中,我们可以经常在源码中看到数组的存在,灵活的运用它们可以为我们解决很多事情。
那么数组是什么呢,在我看来数组就是有限个数的同类型数据,按照顺序排列,组合在一起的一个有序集合。当然在一些弱语言中,数组对内部元素的数据类型没有强制要求,如JavaScript中我们可以使用var来声明变量而不用指定数据类的类型,但在Java中,数组要求数据元素必须是同一种类型。数组可以分为一维数组和多维数组,事实上多维数组就是一维数组的扩展,就是一维数组中的元素还是数组,如是而已。
数组有一个最为明显的特征,那就是它是定长的,在使用前我们需要先行明确它需要在内存中开辟出多大的空间,如果开辟的空间过小,那么我们无法将所需要的数据完整的存储到数组之中,如果我们开辟的过大,那么除了装载我们所需的数据之外,其它的空间将会被浪费掉。所以我们在使用数组的过程中必须要结合使用的场景谨慎使用,合理初始化。下面将结合一些Java的代码以及图示来简单地描述数组的使用。
在Java中,数组(一维)的创建方式如下,笔者为了方便,之后的实例中都将以int类型数组作为描述对象:
public class Test {
@org.junit.Test
public void initArray() {
// 该种方法先声明数组,然后为其赋值
int[] array1 = new int[5];
for (int i = 0; i < array1.length; i++) {
array1[i] = i + 1;
}
System.out.print("先声明后赋值: ");
printArray(array1);
// 该种方法在声明的同时就为其赋值
int[] array2 = new int[] { 1, 2, 3, 4, 5 };
System.out.print("声明同时直接赋值:");
printArray(array2);
}
private void printArray(int[] arrays) {
for (int temp : arrays) {
System.out.print(temp + " ");
}
System.out.println();
}
}
执行上述代码后可以在控制台看到如下打印:
控制台输出
上面展示的示例代码演示了在Java中创建数组的过程。第一种是先声明再赋值,执行时,会先在内存中开辟出指定的空间,此时数组中没有赋值,JVM会自动为其在内存中赋上默认初始值,初始值根据数组类型来分配,在Java中,引用型数据类型默认初始为null,基本数据类型的默认初始值如下图所示:
数值初始化
之后再将需要存储的值放入开辟好的空间之中,下面笔者将通过画图的方式更形象地来描述这个过程。
内存描述
如上图所示,我截取了initArray方法中的一部分代码并画图分析。程序运行,首先JVM会将Test.class文件加载到方法区中,本例中,首先被压入栈内存的是initArray方法,方法中定义了一个局部变量array1,该变量是个int类型数组,初始化时,采用了先开辟空间后赋值的方式,因此当执行完第一条语句后,JVM会在堆空间中开辟出一个可以容纳5个int型数据的空间,此时数组空间中因为没有指定初始化值,JVM会自动向内填充初始值,赋值规则在上面已经提到过,不同类型数据的默认初始值是不同的,本例中,因为使用的是int型数组,所以默认的初始值为0,然后array1变量便指向了这片内存。之后通过一个for循环为数组重新赋值,堆空间中使用新的值替换掉之前的默认值,接着执行printArray方法,该方法进栈,因为我们是将array1变量作为参数传入了该方法,所以该方法中的arrays参数也是指向着array1所在的这片内存,然后方法内通过一个循环遍历了该数组中的元素,并将它们打印了出来。
采用第二种方式其实在本质上跟第一种方式没有区别,都会经历默认初始化值这一步,只是给人的感官上是省略了默认初始化这一步。通过上面的描述,对数组有了初步的认识,我们知道了数组拥有着定长的特性,除了定长的特性,数组还有着顺序访问的特性,尽管在编程中我们可以使用索引来获取数组中指定位置的值,但计算机在执行时,其实还是一个一个的按顺序访问的,直到访问到指定索引的位置。
因为数组的特性,所以数组的适用场景主要是那些业务不会出现变化的场景。笔者曾经写过一个小的象棋游戏,当时的棋盘就是用二维数组来表示的,因为棋盘上可以落子的位置个数是固定的,正好符合数组的特性。尽管数组的作用很强大,但也有这其劣势,因为定长的特性,导致如果业务出现变化时,需要重新改变程序,所以下面就来说说数组的延伸运用。
动态数组
正如前面所说,数组虽然比较高效,但是因为其定长的特性导致了其应用场景的局限性。神说要有光,于是便有了光,因为有着需求,所以有了创新。为了解决数组定长的问题,集合这类数据结构诞生了,因为集合的概念比较宽泛,所以暂且将其理解为一个用于存储元素的容器,并且其是变长的。
集合的出现,解决了数组因为定长特性而不能解决的应用场景。集合跟数组一样也有着其一些属于自己的特性:
- 集合的容量是非固定的。当集合的当前容量不足以装载新的元素时,它会自动扩展容量以装纳新的元素。这点是跟数组最明显的区别。
- 集合中存放的为引用性数据类型,因为自动装箱拆箱的原因,也可以存放基本数据类型,但本质上在存储时基本数据类型已经自动装箱为其对应的引用型数据类型进行存储。
- 集合在不适用泛型约束的时候,一个集合中是可以存放多种类型的数据的。数组只能存放单一类型数据。
集合是一种比较宽泛的定义,为了解决各种不同的需求,它可以细化成很多独有的数据结构:
- 列表:列表是集合的一种,它是一种顺序结构,常见的有链表、队列、栈等。
- 集:集也是集合的一种,它是一种无序的结构,并且其中的存储的元素是不能重复的。
- 多重集:多重集也是集合的一种,一般来说它也是无序的,但相较集而言,它是可以存储重复的值的。
- 关联数组:关联数组同样也是集合的一种,它也是无需的,但它多了一种键-值的概念,通过键可以获得值。
- 树、图等等。
在Java中,其已经为我们内置了很多常用的集合结构,它们都位于java.util包下,在之后的学习中将会对其进行进一步的了解。
本节的标题是动态数组也可以叫其动态列表。其实集合这类结构大多数都可以通过数组来实现的,如果读者有过c语言的基础,对此看法肯定会表示一定的赞同。下面将展示如何基于数组去建立一个动态数组。
在开始动手之前,我们可以进行简单的分析。我们之所以想构建动态数组的原因仅仅只是因为数组定长的特性。那么如何解决这个问题呢,好比正常生活中我们现在使用着一部内存16g的手机,随着我们使用时间的增长,发现手机内存不足以满足我们的需求,这时你会怎么做呢?删掉一些东西继续使用?这个方法只是临时的缓解当前的困境,随着时间的增长,仍然会发生一样的问题。大部分的人应该会直接购买一个内存容量更大的手机,当然你还会把原来手机上重要的数据拷贝到新手机之上。
说到这里,我们是不是也找到了问题的解决办法了呢。因为数组定长的特性,当我们遇到所要装载数据量大于其数组空间的时候,我们完全可以创建一个容量足够大的数组用以装载数据,同时将原数组的数据拷贝过来就行了。动态数组便是基于这个原理实现的。下面可以通过一副简单的流程图来描述动态数组的工作流程。
动态数组工作流程
了解了工作原理之后,接下来就是动手开始撸码了,我们对于该变长数组的实现,可以参考Java中为我们提功的ArrayList类,该类是Java为我们封装好的集合类之一。下面贴上利用数组简单实现动态列表的代码:
package Arraylist;
import java.util.Arrays;
public class MyArrayList{
//定义了一个常量值,表示内置数组的初始长度为10
private static final int INITIAL_SIZE = 10;
//该变量用于表示内置数组中实际使用的数量
private int size = 0;
//定义了一个Object类型数组,用于存储元素
private Object[] data;
/**
* 一个无参构造函数,为内置数组进行初始化,初始化容量为默认的初始值10
*/
public MyArrayList(){
data = new Object[INITIAL_SIZE];
}
/**
* 一个带一个参数的构造函数
* @param initial 默认的列表初始化容量
*/
public MyArrayList(int initial){
if(initial <= 0){
throw new IllegalArgumentException("输入的容量初始值必须大于0");
}
data = new Object[initial];
}
/**
* 该方法用于向动态列表中插入数据
* @param obj 插入的元素
*/
public void add(Object obj){
if(size == data.length){
data = Arrays.copyOf(data, size*2);
System.out.println("内置数组自动扩容1倍");
}
data[size++] = obj;
}
/**
* 该方法用于获取指定索引处的元素值
* @param index 指定的索引值
* @return
*/
public Object get(int index){
if(index < 0){
throw new IllegalArgumentException("传入的索引值必须大于0");
}else if(index >= size){
throw new IndexOutOfBoundsException("传入的参数值大于了内置数组的最大长度");
}
return data[index];
}
/**
* 设置指定位置的元素值
* @param index
* @param obj
*/
public void set(int index,Object obj){
data[index] = obj;
}
/**
* 获得当前动态列表中存入数据的长度
* @return
*/
public int size(){
return size;
}
/**
* 获得当前动态列表中内置数组的容量
* @return
*/
public int length(){
return data.length;
}
public static void main(String[] args) {
MyArrayList list = new MyArrayList();
System.out.println("初始化时动态列表中存储的元素为"+list.size());
System.out.println("初始化时动态列表中的容量大小为"+list.length());
for(int i = 0; i < 11; i++){
list.add(i);
}
System.out.println("赋值后动态列表中存储的元素为"+list.size());
System.out.println("赋值后动态列表中的容量大小为"+list.length());
System.out.print("列表中的元素为:");
for(int i = 0; i < list.size(); i++){
System.out.print(list.get(i)+"\t");
}
}
}
执行上述代码,可以在控制台看到如下打印:
控制台输出
从控制台的打印中可以看出,我们创建的动态列表成功的将数据存储了起来,并且当自身空间不够使用的时候,自动进行了扩容的操作。尽管这个动态列表是基于数组创建的,内置的数组仍然保有定长的特性,但在外部使用时,完全感受不到其定长的特性,扩容的操作已经在内部便消化了。
在上面的代码中,最关键的扩容步骤中,笔者使用了一个工具类方法Arrays.copyOf方法,该方法的内部其实是调用了System.arraycopy方法,该方法是一个native方法,执行效率非常高,远比我们使用循环赋值的方式来拷贝旧数据要效率的多。
因为此动态数组的实现是基于数组的,所以它的执行效率相对数组而言也没有什么提升,只是解决了定长的问题。并且在扩容和拷贝旧数据的时候甚至还会增加系统的开销,所以在使用时也要根据实际场景来判断如何使用,定义一个合理的初始化容量,以及一个合理的扩容倍数都可以提升该结构在实际场景中应用的效率。
散列表
前面提到了集合,并且建立了一个动态数组来摆脱数组定长的约束,但它在使用时,仍然有一些限制。因为动态数组其本身仍然是顺序存储的,所以当我们要查询其中的某个元素时,我们需要顺序地遍历其中的每个元素,直到找到了要指定的元素。当动态数组中元素总量很大,并且我们所要查找的元素在动态数组中的位置相对靠后时,此时的查询效率是比较慢的。所以散列表就这么应运而生了,散列表同样是集合中的一种,它是一种使用空间去置换时间的存储结构。正所谓鱼和熊证二者不可兼得,在我们使用的过程中,我们要根据实际的情况去选用合适的数据结构,如果只是因为看到散列表的效率比较高并且其又能解决数组定长的问题就一昧的去使用它,那么你会发现占用过多的存储空间也是一件令人头疼的事情。
上面描述了这么多,并没能描述出散列表到底是什么,实在是罪过罪过,小生在这儿给各位大侠赔礼了。
举个生活中的小例子来描述一样吧。查字典,这个经典的动作就可以很好地对其进行诠释。当我们查字典时,如果使用动态数组的方式去查询的话,那么意味着我们需要从第一页一页一页的不停往后翻,直到我们查到了想要查找到的字。可以想象一下,如果我们所要查询的字在字典的最后一页上,那意味着我们需要将整本字典都翻上一遍。如果我们使用散列表,那么我们的操作流程如下,我们会先通过字典的索引表上找到我们要查找的字所在的页数,然后通过查询到的页数,便可以直接翻至我们所要查找的字所在的页,事实上,我们在生活中也确实是使用这种方式来查询字典的。
通过上面的描述,此时对散列表也就有了一些初步的了解。散列表也叫做哈希表,它能通过给定的关键字直接访问到具体的值 ,即可以把关键字映射到某一个表中位置上来直接访问记录。这样的操作方式大大加快了访问的速度。
一般上述的关键字我们称其key,将其对应的值称为value。我们可以通过key值通过映射表直接访问到对应的value值。这里的映射表一般称作散列函数或者哈希函数。而存储数据的数组称其散列表。
使用散列表时,我们通过一个key值,必定能访问到一个唯一的value地址,理想状态下,key值和value地址值是一一对应的,但实际情况下,不同的key值经散列函数计算之后可能会指向同一个value地址,这便发生了碰撞,在下面的篇幅中,会讲述一些解决碰撞问题的方法。
散列函数在散列表中起到了至关重要的作用,一个好的散列函数,可以极大程度的提高散列表的效率。下面就来介绍一些简单的散列函数:
- 直接寻址法
直接寻址法是直接取关键字或者关键字的某个线性函数作为散列地址。即address = key 或者 address = a*key+b。 - 数字分析法
数字分析法是指通过对数据进行分析,找出数据中冲突比较少的部分作为散列地址,即取关键字中的部分位作为散列地址,该种方法一般是在散列表中的关键字事先知道的情况下可以选择的一种方式。比如学校中学生的学号,学生的学号一般包含入学年份,所修系的编号,所修专业的编号,班级号,班级中的编号等,我们可以剔除入学年份,系编号这种存在大可能性雷同的数据字段,采用后面剩余的字段来作为散列地址。 - 平方取中法
平方取中法是指当我们无法确定关键字中哪几位的分布相对比较均匀时,可以先求出关键字的平方值,然后取平方值的中间几位作为散列地址。因为计算平方之后的中间几位基本和关键字中的每一位值都息息相关,这样可以使得散列地址的分配是随机的,至于是取中间几位作为散列地址,这个主要看表的长度来决定的。 - 取随机数法
使用随机数算法为关键字分配散列地址。 - 除留取余法
除留取余法是指取关键字被某个不大于散列表表长m的数n除后所得的余数p来作为散列地址。该种方法可以再使用了上述其它方法后再次使用。该种方式对数n的要求比较高,一般取素数或者直接使用表长m,若选择不好,则很容易产生碰撞。
上述的5种散列函数是比较基础易懂的。事实上你完全可以自己设计出属于自己的散列函数,当然你得参考一些关键因素:关键字的长度,哈希表的大小,关键字的分布状况,记录的查找频率等等。
前面有提到过,当我们向散列表中装载数据时,同一个关键字key值经过散列函数的计算过后,可能会分配至同一个地址空间,从而产生了碰撞,在这种情况下,我们就可能会覆盖掉原有的数据,从而造成了数据丢失,这是万万无法接受的。既然发现了问题,那我们自然要去解决问题。下面就简单介绍几种常见的解决冲突的方法:
- 开放寻址法
开放寻址法就是当关键字经过散列函数计算获取到一个地址后,首先会判断当前地址是否已经被使用了,如果当前地址没有被使用,那么便将需要存储的数据放入该地址下,如果发现当前地址已经被使用了,那么需要对该地址进行探索再哈希。比如向后移动一个地址,直到找到未被占用的地址,然后将要存储的数据放入其中。其中探索再哈希这个过程可以通过一些算法来增加我们的效率,该方面有机会的话再其它的篇幅中描述。开放寻址法与后面的链地址法是相对应的,开放寻址法是将所有的元素都存放在散列表中,而不像链地址法通过加入链表结构来解决冲突。 - 链地址法
链地址法就是当产生冲突时,同一个地址放置需要装载多个数据的时候,多个数据采用链表的形式装载进指定的地址之中,该种方法在很多高级语言中均有实现,之后代码演示的自定义的散列表中解决冲突的方式便是采用这种方式的。 - 创建一个公共溢出区
该种方式就是单独创建一个公共溢出区,当地址存在冲突后,将新的地址放到公共溢出区里。
理想状态下,散列表的搜索时间复杂度为O(1),影响该时间复杂度的主要原因就是是否产生冲突。因此影响散列表效率的主要因素就是是否有一个好的散列函数,是否一个好的冲突解决方法,以及是散列表的填装因子(即散列表中元素个数与表长的商),填装因子越小越好,当填装因子过大时发生冲突的可能性就越大,在java中,填装因子为0.75。
散列表一般在平常的使用当中可以用于做消息缓存,比如可以先将数据库或服务器上常用的数据,以散列表的形式缓存到本地,这样便可以提高程序的运行效率,散列表还可以用于一些需要快速查找的场景之中,毕竟散列表这种结构相较其它的数据结构来看,其查找,插入,删除等一般操作的时间复杂度仅为O(1),效率还是要快于其它的。
下面就是使用java代码简单实现的一个自定义的散列表。其散列函数采用了除留取余法,解决冲突的方法采用了链地址法。
package HashTable;
//该类作为链表中的元素类
public class Entry {
int key;
int value;
Entry next;
public Entry(int key, int value, Entry next){
super();
this.key = key;
this.value = value;
this.next = next;
}
@Override
public String toString() {
return "[key="+key+",value="+value+",next="+next+"]";
}
}
package HashTable;
public class HashTable {
public static void main(String[] args) {
HashTable hashtable = new HashTable();
hashtable.put(1, 1);
hashtable.put(5, 2);
hashtable.put(2, 3);
System.out.println(hashtable.hash(1));
System.out.println(table[1]);
System.out.println(hashtable.size());
System.out.println(hashtable.getLength());
System.out.println(hashtable.getUse());
}
// 默认散列表的初始化长度
private static final int DEFAULT_INITIAL_CAPACITY = 4;
// 扩充因子,如果散列表内元素数量占总容量的比例达到这个标准,那么在之后的操作中将会自动扩容
private static final float LOAD_FACTOR = 0.75f;
// 散列表中用于存储元素的数组,使用初始化长度作为数组的初始长度。
private static Entry[] table = new Entry[DEFAULT_INITIAL_CAPACITY];
// size表示散列表中元素的个数,use表示散列表使用地址的个数
private int size = 0;
private int use = 0;
/**
* 向散列表中添加或者修改元素
* @param key 指定的key值
* @param value 指定的value值
*/
public void put(int key, int value) {
// 通过散列函数获得索引
int index = hash(key);
// 如果当前索引下没有任何元素,那么向散列表数组中添加一个空元素
if (table[index] == null) {
table[index] = new Entry(-1, -1, null);
}
// 从散列表数组中获取当前索引的元素
Entry e = table[index];
if (e.next == null) {
// 如果e.next==null,表示当前索引下元素并没有指向其它的元素,那么可以直接向当前元素追加一个新的元素。
table[index].next = new Entry(key, value, null);
// 添加完元素后,对size,use值自增,同时检测是否需要扩容。
size++;
use++;
if (use >= table.length * LOAD_FACTOR) {
resize();
}
} else {
// 如果e.next!=null,表示当前索引下的元素存在指向其它的元素,那么遍历当前索引下的链表,判断是否存在元素的key值等于指定key值的元素,如果存在则修改其value值。
for (e = e.next; e != null; e = e.next) {
int k = e.key;
if (k == key) {
e.value = value;
return;
}
}
// 如果遍历完当前链表中没有发现key值对应的元素,那么新建一个元素插入到链表中,对应size值自增,这里的插入是将新元素插入到当前索引的下一个位置,新元素指向原先元素指向的元素。因为散列表中没有使用新的地址值,所以use值无需自增。
Entry temp = table[index].next;
Entry newEntry = new Entry(key, value, temp);
table[index].next = newEntry;
size++;
}
}
/**
* 删除指定key值的元素
* @param key
*/
public void remove(int key) {
// 通过散列函数获取对应的索引值
int index = hash(key);
// 获取当前索引下的元素
Entry e = table[index];
Entry pre = table[index];
// 判断当前链表中是否有实际元素
if (e != null && e.next != null) {
// 遍历链表中所有的元素,如果存在指定key的元素,那么修改链表中前一个元素的指向,从而起到删除的作用,然后size值对应自减
for (e = e.next; e != null; pre = e, e = e.next) {
int k = e.key;
if (k == key) {
pre.next = e.next;
size--;
return;
}
}
}
}
/**
* 返回指定key值的value值
* @param key 指定的key值
* @return 一个int型值,返回的是指定key值的value值
*/
public int get(int key) {
// 通过散列函数获取对应的索引值
int index = hash(key);
// 通过索引获取散列数组中对应的元素
Entry e = table[index];
// 判断获取的元素是否是有效元素,如果是,则进一步遍历链表,查找是否有对应key值的元素。
if (e != null && e.next != null) {
// 遍历链表,查找是否有对应key值的元素,如果存在就返回其value值。
for (e = e.next; e != null; e = e.next) {
int k = e.key;
if (k == key) {
return e.value;
}
}
}
// 如果没有找到对应key值的元素,则返回-1。
return -1;
}
/**
* @return 返回散列表中元素的数量
*/
public int size() {
return size;
}
/**
* @return 返回散列表中已经使用的地址个数
*/
public int getUse(){
return use;
}
/**
*
* @return 返回散列表数组的长度
*/
public int getLength() {
return table.length;
}
/**
* 散列函数,这里使用了比较简单的除留取余法
* @param key 指定的key值
* @return 返回一个int值,作为hash算法的返回值
*
*/
private int hash(int key) {
return key % table.length;
}
/**
* 该方法用于扩容
*/
private void resize() {
// 定义了一个int型值用于作为新的散列表数组的长度,其为原有长度的两倍
int newLength = table.length * 2;
// 定义了一个Entry类型数组,用于存放原有的散列数组中的元素,用于后面扩容后,将原有数据重新赋值到新的散列数组中。
Entry[] oldTable = table;
// 备份完原有数据后,将table指向新的散列数组对象,容量为原来的两倍,此时散列数组中没有数据,故use值重置为0.
table = new Entry[newLength];
use = 0;
// 通过循环将备份的老散列数组中的数据放入新的散列数组之中。
for (int i = 0; i < oldTable.length; i++) {
// 该判断用于筛选出老的散列数组中存储数据的位置
if (oldTable[i] != null && oldTable[i].next != null) {
// 新建了一个Entry元素,用于接收老的散列数组中对应索引处的元素
Entry e = oldTable[i];
// 遍历老的散列数组中的链表,将其重新通过散列函数计算,重新放置到新的散列数组中。
while (null != e.next) {
// 新建了一个Entry元素,用于存放当前元素指向的下一个元素
Entry next = e.next;
// 通过散列函数,重新计算key值对应的索引值
int index = hash(next.key);
// 获取当前索引下的元素,如果为空向其中添加第一个头元素,同时use值自增
if (table[index] == null) {
use++;
table[index] = new Entry(-1, -1, null);
}
// 如果当前元素不为空,那么将旧列表中的指定元素加入到新列表中指定链表的首部(头元素之后)。
Entry temp = table[index].next;
Entry newEntry = new Entry(next.key, next.value, temp);
table[index].next = newEntry;
// 元素指向下一位
e = next;
}
}
}
}
}
执行上述代码可以在控制台看到如下打印:
控制台输出
可以看到我们存入的元素成功的存储进了我们自定义的散列表之中。
以上为本篇的全部内容,其中有的知识点一概而过了,可能的话会在其它的篇幅中进行补充。
网友评论