数据结构与算法之数组

前言

想写想学的东西有很多,数据结构与算法是其中最重要之一,其实数据结构与算法是两门学科,一般而言,数据结构指的是一组数据的存储结构,算法指的是操作数据的一组方法,两者相辅相成,缺一不可。
这篇博客是我数据结构与算法的系列文章的第一篇,从数组开始,严蔚敏,吴伟民老师所著的<<数据结构(C语言版)>>一书中,最先开始讲解的便是线性表中的数组,为什么呢?因为数组是编程语言中最基础的数据结构,同时它也是一种数据类型,它既简单基础又重要,理解数组,有助于我们理解操作系统中的内存这一概念。

概念

什么是数组?
来自维基百科的定义:

在计算机科学中,数组数据结构(array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。

从定义中,可以看到数组最典型的特征:相同类型的元素集合,连续的内存,使用索引寻址。
我们想象一些数组的样子,它应当是这样:
在这里插入图片描述
或者这样(画了半天,还是贼丑):

在这里插入图片描述
在计算机的世界里,是没有上下左右这个概念的,所以,不要以为数组一定是从上至下的顺序。
上文提到过,数组属于线性表,所谓的线性,指的是逻辑上的线性,即数据的存储方式,是首尾相连的,其元素的方向只有两个,所以线性表存在着前驱和后继。
线性表在物理地址上不一定是使用连续的地址存放,也可能是采用分散的地址存放,比如链表。

随机访问

对数据的操作无非是crud(增删改查),数组的优势在于查与改,它支持数据的随机访问。
所谓数据的随机访问,可以这样理解:给定一个随机值,可以直接获取该值对应位置的数据。
与随机访问不同的是顺序访问,即给定一个随机值,必须通过数据的一端来进行遍历等方式寻址最终找到数据。
这个随机值,便是索引,孰优孰劣一看便知!
从上图可以看到,我们只要给定一个随机值,比如2,便马上能找到arr[2],为什么?因为数组是线性且连续的。
这里有一个引申的问题,数组的索引为什么从0开始?
其实,我们不妨做一个思考,那就是数组的随机访问是如何实现的,当我们给定一个索引值为2,计算机马上就能定位到这个2的地址吗,还是计算机底层其实也是通过一个参照物,所谓的索引为2,其实是该索引代表的地址与参照物之间的"距离"呢?
<<算法4>>中提到:

这个习惯来源于机器语言,那时要计算一个数组元素的地址需要将数组的起始地址加上该元素的索引。同时程序开辟内存的时候,位置空间是从0开始计数的。
1、将起始索引设为1会浪费数组的第一个元素的空间。
2、会花费额外的时间来将索引减1.

其次,还有历史原因,c语言中,数组的下标是从0开始,java沿用了该习惯。
编程语言中的数组索引一定从0开始吗?不一定,比如python就不是,它的数组型索引只要是整数就可以。

低效的增删

只要存在索引值,数组这种数据结构在改查有着非常高的效率,但即使知道索引值,增和删的操作也不那么高效,这是因为数组这种数组结构在创建的时候,便被分配好了一段连续的内存,我们对之crud,其实是对这段固定的内存的操作,数组是一个静态的数据结构。

静态数据结构:特点是由系统分配固定大小的存储空间,以后在程序运行的过程中,存储空间的位置和容量都不会再改变。
动态数据结构:不确定总的数据存储量,而是为现有的每一个数据元素定义一个确定的初始大小的空间,若干个数据元素分配若干个同样大小的空间;当问题的数据量发生变化时,数据的存储空间的大小也发生变化。如果数据量增加,就重新向系统申请新的空间;如果数据量减少,就将现有的多余的空间归还给系统;使用时候才能明确所使用的数据有哪些属性字段,这些属性字段是什么类型的,无需提前明确定义每一个属性字段的名字、 类型、所属层级。

如果删除直接将索引为2的值从数组中剔除掉或者直接从数组中间增加一个5,此时内存就不连续了:
删除2
在这里插入图片描述
插入5:
在这里插入图片描述
其实上图的两个操作是链表的操作,图上的两个操作也是无法实现,这是因为数组从一开始边分配了固定的连续内存,你在程序中无法使用这样的操作。事实上为了维护内存的连续性,每当我们增加和删除一个元素的时候,数组必须进行数据的迁移(无论你决定哪种迁移策略):
在这里插入图片描述
数组一旦被初始化,其大小是固定的。
如果该数组声明为基本类型数组,每一个元素拥有一个默认值,以int为例,其默认值为0,如果该数组声明为一个对象数组,其默认值为null。
上图的灰色表示默认值,即无效的值,绿色便是插入的值,红色便是迁移的值。
很容易看到,越靠近数组的首部进行增加和删除元素,数组的迁移次数越多。
通过定义迁移策略,可以优化数组的增删操作,这里暂不作讨论。

时间复杂度

对于数组的改查,数组有随机访问的特性,故而根据索引访问数组的时间复杂度为O(1);
对于数组的增删,越靠近数组首部时间复杂度越高,最坏时间复杂度为O(n),越靠近尾部时间复杂度越低,最好时间复杂度为O(1),平均时间复杂度为O(n)。

平均复杂度计算方法:(1+2+...+(n-1)+n)/n
数组边界

前文提到,数组是一种静态的数据结构,其被创建之始便被分配了固定大小且连续的内存,所以数组是有边界的,通过索引来操纵数组,我们应警惕越界问题!
用java举例,典型的越界使用:

 public static void main(String[] args) {
        int []arr = new int[3];
        arr[4]=5;
    }
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 4
	at com.lucifer.array.OutOfBounds.main(OutOfBounds.java:12)

于java而言,数组的越界是一个运行时异常,数组边界对开发者是透明的,java总会对数组边界进行检查。

数组的使用

接下来,以java为例,举例来说明一下数组的使用:

声明数组

java中声明数组 一般使用 数据类型[],下面以int类型的数组为例:

        //定义数组
        //方式一
        int[] arr1 = {1, 2, 3};
        //方式二
        int[] arr2 = new int[]{1, 2, 3};
        //方式三
        int[] arr3 = new int[3];

这里,提出一个问题:方式一和方式二有区别吗
这里给出几个答案:
1.有区别,方式一不创建新的对象,而方式二会创建新的数组对象;
2.有区别,方式一首先会去常量字符缓冲池去找是否有这么一个数组,如果有,则直接将arr1指向该内存地址,如果没有,则创建新的对象,而方式二直接创建新的对象;
3.没有区别,方式一方式二无论如何都会创建新的对象;
我猜测,很多人会选择第二个答案:)
用代码来证明:

        //方式二
        int[] arr3 = new int[]{1, 2, 3};
        System.out.println("arr3:"+arr3);
        //方式一
        int[] arr1 = {1, 2, 3};
        int[] arr2 = {1, 2, 3};
        System.out.println("arr1:"+arr1);
        System.out.println("arr2:"+arr2);
        System.out.println(arr1==arr2);
        System.out.println(arr1==arr3);
        //方式三
        int[] arr4 = new int[3];

以下是运行结果:

arr3:[I@1b6d3586
arr1:[I@4554617c
arr2:[I@74a14482
false
false

显而易见,三个数组对象的地址皆不相同,所以,正确答案是,方式一方式二无论如何都会创建新的对象。
其次,在java中还有一种数组定义方式:

 int arr[]={1,2,3};

由于这种方式的可读性较差,故而最好不要使用

数组的遍历

遍历是数据结构中特别重要的概念,也是一个极其重要的操作,比如链表,树,图等都存在遍历,包括数组。

所谓遍历(Traversal),是指沿着某条搜索路线,依次对树(或图)中每个节点均做一次访问。访问结点所做的操作依赖于具体的应用问题, 具体的访问操作可能是检查节点的值、更新节点的值等。

对于数组而言,路线只有两条,从首部向尾部或者从尾部向首部,而遍历的方式是多种多样的,可以使用for,while,do…while,foreach,一般而言,我们使用for和foreach进行遍历,对于将数组转换为List再遍历的方式暂不作讨论,:

for遍历:
    public static void main(String[] args) {
        int[]arr = {1,2,3};
        for (int i = 0; i < arr.length; i++) {
            System.out.println(arr[i]);
        }
    }

for,while,do…while都是通过下标来进行遍历。

foreach遍历(也称增强for):
    public static void main(String[] args) {
        int[] arr = {1, 2, 3};
        for (int i : arr) {
            System.out.println(i);
        }
    }

foreach遍历数组的方式对我们而言,使用起来比较简单,那其底层是如何实现的呢,我们可以看看反编译后的代码:

  public ErgodicArray() {
    }
    public static void main(String[] args) {
        int[] arr = new int[]{1, 2, 3};
        int[] var2 = arr;
        int var3 = arr.length;

        for(int var4 = 0; var4 < var3; ++var4) {
            int i = var2[var4];
            System.out.println(i);
        }
    }

其底层同样是通过for循环来实现的!

Arrays

对于数组来说,使用equals与==是一样的效果,因为数组是java中内置的数据类型,并非以一个对象的形式给予我们使用,但是java提供了Arrays类来对数组进行操作,以完成类似于Object所携带的各个方法:

数组的比较:
 	    int[]arr1 = {1,2,3};
        int[]arr2 = {1,2,3};
        System.out.println(arr1==arr2);
        System.out.println(arr1.equals(arr2));
        System.out.println(Arrays.equals(arr1,arr1));

结果:

false
false
true
数组打印:
 		int[]arr1 = {1,2,3};
 		System.out.println(arr1.toString());
        System.out.println(arr1);
        System.out.println(Arrays.toString(arr1));

结果:

[I@1b6d3586
[I@1b6d3586
[1, 2, 3]

可以看到,直接调用数组对象的toString(),输出的是地址,想要获取数组中元素的值,应当使用Arrays中的toString()。

数组hashcode

这里,有一个Demo,不妨来猜测一下,输出的结果:

	    public static void main(String[] args) {
        int[]arr1 = {1,2,3};
        int[]arr2 = {1,2,3};
        System.out.println(arr1==arr2);
        System.out.println(arr1.hashCode()==arr2.hashCode());
        System.out.println(Arrays.hashCode(arr1)==Arrays.hashCode(arr2));
    }

如果是这样呢?

  public static void main(String[] args) {
        int[]arr1 = {1,2,3};
        int[]arr2 = {1,2,4};
        System.out.println(arr1==arr2);
        System.out.println(arr1.hashCode()==arr2.hashCode());
        System.out.println(Arrays.hashCode(arr1)==Arrays.hashCode(arr2));
    }

从前文,我们可以很容易得到arr1==arr2的值始终不同,因为其地址始终不同,我们来看看,直接输出数组对象的hashCode()与Arrays类中的hashCode()有区别吗?

    public static void main(String[] args) {
        int[]arr1 = {1,2,3};
        int[]arr2 = {1,2,3};
        System.out.println("arr1.hashCode():"+arr1.hashCode());
        System.out.println("arr2.hashCode():"+arr2.hashCode());
        System.out.println("Arrays.hashCode(arr1):"+Arrays.hashCode(arr1));
        System.out.println("Arrays.hashCode(arr2):"+Arrays.hashCode(arr2));
    }

结果:

arr1.hashCode():460141958
arr2.hashCode():1163157884
Arrays.hashCode(arr1):30817
Arrays.hashCode(arr2):30817

数组对象的hashCode()得到的值不同,而使用Arrays类得到的hashCode()相同,为什么?
因为数组对象的hashCode()是jvm底层实现,与其地址相关,而Arrays类的hashCode与其值相关。
这是Arrays类中入参为int[]的hashCode():

    public static int hashCode(int a[]) {
        if (a == null)
            return 0;

        int result = 1;
        for (int element : a)
            result = 31 * result + element;

        return result;
    }
优缺点

优点: 1、按照索引查询元素速度快 2、能存储大量数据 3、按照索引遍历数组方便
缺点: 1、根据内容查找元素速度慢 2、数组的大小一经确定不能改变 3、数组只能存储一种类型的数据 4、增加、删除元素效率慢 5、未封装任何方法,所有操作都需要用户自己定义。

总结

数组作为一个基础的数据结构,优势与缺点都很明显,但不论如何,我们都应该了解数组,在日常的开发可能选择集合足够,但一些性能要求较高的场景,集合就不那么适用了,同时,很多数据结构底层都会使用到数组,我们应该去权衡各种数据结构的利弊,在合适的场景选择合适的数据结构才是最重要的。
转载请注明出处,来自路西菲尔的博客https://blog.youkuaiyun.com/csdn_1364491554/article/details/103668302

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值