第2章:数据结构之千万不要小瞧数组(2)

第二章 不要小瞧数组

2-6 使用泛型
2-7 动态数组
2-8 简单的复杂度分析
2-9 均摊复杂度和防止复杂度的震荡


2-6 使用泛型
目前为止,我们所做的数组类最大的问题是只能承载Int型这样的变量。而实际上数组类作为一个容器,它应当能存放任意类型的数据(不仅仅包括Java内置的类型,还包括用户自定义的其他类型),这样的技术称为泛型

在Java语言中使用泛型技术,可以让我们的数据结构可以放置“任何”数据类型(这里需要注意的是,泛型类不可以放置基本数据类型,只能是类的对象)

  • 基本数据类型有:boolean, byte, char, short, int, long, float, double
  • 每个基本数据类型有对应的包装类:Boolean, Byte, Char, Short, Int, Long, Float, Double
    在操作基本数据类型时,其可以自动转换成包装类,反之亦可。

支持泛型的数组:
Array,此处的E(Element)代表数据类型,换句话说,Array这个数组存放的数据类型是E,具体这个数据类型是什么在具体使用时可以再进行声明(ex. String,Integer,或者自己自定义的类型etc.)
代码为支持泛型的数组表达(细节内容的讲解在代码下方):

public class Array <E> {
    // 类的成员变量都是私有的(private)
    private E[] data;
    private int size;
    // 构造函数,传入数组的容量capacity构造Array
    public Array(int capacity){
        data = (E[]) new Object[capacity];
        size = 0;
    }
    // 无参数的构造函数,默认数组的容量capacity=10
    public Array(){
        this(10);
    }
    // 获取数组中的元素个数
    public int getSize(){
        return size;
    }
    // 获取数组的容量
    public int getCapacity(){
        return data.length;
    }
    // 返回数组是否为空
    public boolean isEmpty(){
        return size == 0;
    }
    // 向所有元素后添加一个新元素e
    public void addLast(E e){
        add(size, e);
    }
    // 在所有元素前添加一个新元素e
    public void addFirst(E e){
        add(0,e);
    }
    // 在第index个位置插入一个新元素e
    public void add(int index, E e){
        if (size == data.length)
            throw new IllegalArgumentException("Add failed. Array is full.");
        if (index < 0 || index > size)
            throw new IllegalArgumentException("Add failed. Required index >= 0 and index <= size ");
        for (int i = size - 1; i >= index; i --)
            data[i+1] = data[i];
        data[index] = e;
        size ++;
    }
    // 获取index索引位置的元素
    public E get(int index){
        if (index < 0 || index >= size)
            throw new IllegalArgumentException("Get failed. Index is illegal. Required index >= 0 and index < size ");
        return data[index];
    }
    // 修改index索引位置的元素为e
    public void set(int index, E e){
        if (index < 0 || index >= size)
            throw new IllegalArgumentException("Get failed. Index is illegal. Required index >= 0 and index < size ");
        data[index] = e;
    }
    // 查找数组中是否有元素e
    public boolean contains(E e){
        for (int i = 0; i < size; i ++){
            if (data[i].equals(e))
                return true;
        }
        return false;
    }
    // 查找数组中元素e所在的索引,如果不存在元素e,则返回-1
    public int find(E e){
        for (int i = 0; i < size; i ++){
            if (data[i].equals(e))
                return i;
        }
        return -1;
    }
    // 从数组中删除index位置的元素,返回删除的元素
    public E remove(int index){
        if (index < 0 || index >= size)
            throw new IllegalArgumentException("Get failed. Index is illegal.");
        E ret = data[index];
        for (int i = index + 1; i < size; i ++)
            data[i-1] = data[i];
        data[size] = null; //loitering objects != memory leak
        size --;
        return ret;
    }
    // 从数组中删除第一个元素,返回删除的元素
    public E removeFirst(){
        return remove(0);
    }
    // 从数组中删除最后一个元素,返回删除的元素
    public E removeLast(){
        return remove(size-1);
    }
    // 从数组中删除元素e
    public void removeElement(E e){
        int index = find(e);
        if (index != -1)
            remove(index);
    }
}

(1)new一个泛型类型的数组
在修改为泛型数组的时候需要对静态数组data的类型进行更改,但由于Java的历史原因,不支持"data = new E[capacity]",即不可以直接实例化泛型数组。因此需要使用父类object来进行转换,之后使用强制类型转换即可:data = (E[ ]) new Object[capacity].

// 错误用法
data = new E[capacity];
// 正确用法
data = (E[]) new Object[capacity];
    }

(2)两个类对象之间进行值的比较使用equals.
equals和 ‘==’ 的区别在于 equals是值比较(比较内容), 后者是引用比较(比较地址)

// 错误用法
if (data[i] == e)
// 正确用法
if (data[i].equals(e))

(3)闲散的无用对象如何去除
在删除index位置的元素后,data[size] 其实还指着一个引用,Java中的垃圾回收机制可以自动实现引用的释放。但是如果data[size] 依然存放一个对象的引用的话,便不会被Java自动回收。为了处理这个问题,可以添加下列代码:

//添加的代码在这里:
data[size] = null; //loitering objects != memory leak
  • 添加 “data[size] = null”后,此时原本指向的对象便不会与程序中的其他对象相关联,Java的垃圾回收机制就可以自动快速处理该对象。实际上,即使没有添加这段代码,在之后的add操作中也会自动覆盖,逻辑上没有问题
  • //loitering objects != memory leak 这句注释的意思是:闲散的无用对象并不等于内存泄露,手动添加“data[size] = null”有助于程序优化

(4)Main函数中数组的声明
由于泛型不支持基本数据类型,因此在Main函数中声明数组时不能写成int,而应该写为Integer.

// 错误用法
Array<int> arr = new Array<>(20);
// 正确用法
Array<Integer> arr = new Array<>(20);
// 正确用法
Array<Integer> arr = new Array<Integer>(20);

2-7 动态数组
数组另外一个局限性是:静态数组的容量是有限的。使用这种数组类无法实现预估我们将要在这个数组中放入多少元素,如果容量开得太大,浪费空间;如果容量开得太小,空间不够用。动态数组是一个很好的解决方案,可以使得数组的容量变得可伸缩。

数组空间的扩容/缩容:
当数组data容量充满时,开辟一个新的数组newData,之后对data 内的数据元素进行循环遍历,复制进newData中,最后将成员变量data的引用指向newData即可(扩容一般设置为已有数组空间的1.5 / 2倍),而缩容是指数组元素删除到某程度时,将其数组空间缩小(缩容一般设置为已有数组空间的0.5倍)。
(1)resize函数的编写:

    private void resize(int newCapacity){
        E[] newData = (E[]) new Object[newCapacity];
        for (int i = 0; i < size; i ++)
            newData[i] = data[i];
        data = newData;
    }

(2)add方法中,扩大容器的capacity,此处为2倍:

      // 扩大容器capacity
        if (size == data.length)
            resize(2 * data.length);

结果如下:注意当数据开始超过原来的capacity时,新的capacity由原来的10变为20

(3)remove方法中,缩小容器的capacity,此处为0.5倍:

     // 缩小容器capacity
        if (size == data.length / 2)
            resize(data.length / 2);

结果如下:注意当数据减少到原来的capacity的一半时,新的capacity由原来的20变为10

2-8 简单的复杂度分析(重点)
常见的复杂度:O(1), O(n), O(lgn), O(nlogn), O(n^2)
简单的来说,大O描述的是算法的运行时间和输入数据之间的关系。

举个栗子:
对一个数组中的数字求和。初始化sum等于0,for循环来遍历nums里的每一个元素,每遍历到一个num,sum += num,最后return sum.

public static int sum(int[] nums){
    int sum = 0;
    for (int num: nums) sum += num;
    return sum;
}

通常来说,上边的代码它的复杂度是O(n)
n是nums中的元素个数,O(n)中算法和n呈线性关系,实际上O(n) = c1 x n (运算耗时) + c2 (开辟空间等耗时),但我们通常会省略低阶项和常数项,只说是O(n)级别。

  • 如果我们把c1, c2具体是多少分析出来,一方面必要性不大,另一方面有些时候是不可能的(不同的语言、不同的实现、不同的指令数,很难准确判断这些常数)

通常O(n)指渐进时间复杂度描述当n趋近与无穷时不同算法的性能(忽略低阶项和常数)

T = 2 x n + 2 ----> O(n)
T = 2000 x n +10000 ----> O(n)
T = 1 x n x n + 0 ----> O(n^2)
T = 2 x n x n + 300 x n + 10 ----> O(n^2)

  • n趋向于无穷时,低阶项系数比较大的O(n)的运算速度高于低阶项系数较小的O(n^2)
  • 在实际应用中,当数据规模较小时,我们通常会选择低阶项系数较小的O(n^2)来代替低阶项系数较大的O(n)。比如在较小的数组中,我们会选择使用插入排序来代替快速排序/归并排序,使程序得到1-%~15%的优化
  • 分析时间复杂度时,通常考虑最坏的情况

分析动态数组的时间复杂度
(1)添加操作:O(n)

addLast(e) -->O(1) // 意味着这个操作消耗的时间和数据的规模没有关系,addLast在常数时间里完成
addFirst(e) -->O(n) // addFirst将数组中的每一个元素向后移动一位
add(index, e) --> O(n/2) = O(n) // 具体的时间与index:(0, size)的值相关。严格计算需要一些概率论知识
以上三个实际上时间复杂度都是:O(n),因为通常我们考虑最坏情况
resize: O(n)

(2)删除操作: O(n)

removeLast(e) -->O(1)
removeFirst(e) -->O(n)
remove(index, e) --> O(n/2) = O(n)
以上三个实际情况中的时间复杂度都是:O(n)
resize: O(n)

(3)修改操作: 已知索引O(1),未知索引O(n)

set(index, e) --> O(1)

(4)查找操作:已知索引O(1),未知索引O(n)

get(index) --> O(1)
contains(e) --> O(n)
find(e) --> O(n)

2-9 均摊复杂度和防止复杂度的震荡
(1)均摊复杂度
在上一章节中,添加操作中:

动态数组中,由于resize的存在我们将数组的添加操作复杂度视为O(n),但由于addLast的复杂度为O(1),而且并不是每次addLast操作都会出发resize,因此需要对复杂度进行均摊。

举个栗子
假设当前capacity = 8,并且每一次添加操作都使用addLast,需要进行8次元素的添加,每一次复杂度为O(1)

  1. 再添加第9个元素时,发现数组满了,要进行resize,在resize操作中要把之前添加的8个元素全部复制到新的静态数组中,所以消耗8*O(1)的时间,再加上第9个新的元素O(1),这样resize操作使用了9个O(1)
  2. 因此,9次addLast操作,触发resize,总共进行了17次(9次元素的添加+8个元素的转移操作)基本操作

假设capacity = n, n+1次addLast,触发resize, 总共进行2n+1次基本操作。平均下来,每次addLast操作,进行两次基本操作(2n+1)/ n 约等于2,这样均摊计算,addLast的均摊复杂度为O(1)。同理,removeLast操作,均摊复杂度也为O(1)

(2)复杂度震荡
在数组容量充满时,且删除与添加的标准一致时,连续的addLast + removeLast操作会出现O(n)的复杂度震荡。

此时发现当出现“扩容 -> 缩容 -> 扩容 -> 缩容”的情况时,每一次都会耗费O(n)的复杂度。
出现问题的原因:removeLast时resize过于着急(Eager)

解决方案:Lasy
对扩容和缩容设置不同的边界值,比如如果添加元素个数size超过原来的capacity = n,就扩容成2倍的capacity = 2n,当进行多次remove操作后元素的size少于capacity = 1/4 * (2n)时再缩容。

 // Eager: size == capacity/2 时capacity减半
        if (size == data.length / 2)
            resize(data.length / 2);
        // Lazy: 当size == capacity/4 时, 才将capacity减半
        if (size == data.length / 4 && data.length / 2 != 0)
            resize(data.length / 2);

这里有一个小细节需要注意:在缩容的过程中,随着data.length越来越小,它可能等于1,此时data.length / 2有可能等于0。因此需要加上 “data.length / 2 != 0” 这样的条件。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值