数据结构与算法 - 数组

一、数组

1. 概述

定义:在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识。

因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:

int[] array = {1,2,3,4,5}

知道了数组的数据起始地址BaseAddress,就可以由公式BaseAddress + i * size来计算索引i元素的地址

  • i即索引,在Java、C等语言都是从0开始
  • size是每个元素占用字节,了例如int占4,double占8

空间占用

Java中数组结构为

  • 8字节markword,记录对象的锁状态、对象实例的哈希值、年龄信息、GC状态
  • 4字节class指针(压缩class指针的情况)
  • 4字节数组大小(决定了数组最大容量是2^32
  • 数组元素 + 对齐字节(Java中所有对象大小都是8字节的整数倍,不足的要用对齐字节补足)

例如

int[] array = {1, 2, 3, 4, 5};

的大小为40字节,组成如下

8 + 4 + 4 + 5*4 + 4(alignment)

随机访问性能

即根据索引查找元素,时间复杂度是O(1)。

2. 动态数组

Java示例

import java.util.Arrays;
import java.util.Iterator;
import java.util.function.Consumer;
import java.util.stream.IntStream;

public class DynamicArray implements Iterable<Integer> {
    private int size = 0;  // 逻辑大小
    private int capacity = 8;  // 容量
    private int[] array = {};

    /**
     * 向最后位置[size]添加元素
     *
     * @param element 待添加元素
     */
    public void addLast(int element) {
        add(size, element);
    }

    /**
     * 向[0 ... size]位置添加元素
     *
     * @param index   索引位置
     * @param element 待添加元素
     */
    private void add(int index, int element) {
        if (index < 0 || index > size) {
            throw new RuntimeException("索引越界");
        }
        // 扩容
        checkAndGrow();

        // 添加逻辑
        if (index < size) {
            // 向后挪动,空出待插入位置
            System.arraycopy(array, index, array, index + 1, size - index);
        }
        array[index] = element;
        size++;
    }

    /**
     * 数组容量扩容
     */
    private void checkAndGrow() {
        // 容量检查
        if (size == 0) {
            array = new int[capacity];
        } else if (size == capacity) {
            // 进行扩容 1.5 1.618 2
            capacity += capacity >> 1;
            int[] newArray = new int[capacity];
            System.arraycopy(array, 0, newArray, 0, size);
            array = newArray;
        }
    }

    /***
     * 从[0 ... size)范围删除元素
     * @param index 索引位置
     * @return 被删除元素
     */
    public int remove(int index) {
        int removed = array[index];
        if (index < size - 1) {
            System.arraycopy(array, index + 1, array, index, size - index - 1);
        }
        size--;
        return removed;
    }

    /**
     * 查询元素
     *
     * @param index
     * @return
     */
    public int get(int index) {
        if (index < 0 || index >= size) {
            throw new RuntimeException("索引越界");
        }
        return array[index];
    }

    /**
     * 遍历方法1
     *
     * @param consumer 遍历要执行的操作,入参:每个元素
     */
    public void foreach(Consumer<Integer> consumer) {
        for (int i = 0; i < size; i++) {
            // 提供array[i]
            // 返回void
            consumer.accept(array[i]);
        }
    }

    /**
     * 遍历方法2, 迭代器遍历
     *
     * @return
     */
    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<Integer>() {
            int i = 0;

            @Override
            public boolean hasNext() {  // 有没有下一个元素
                return i < size;
            }

            @Override
            public Integer next() {  // 返回当前元素并移动到下一个元素
                return array[i++];
            }
        };
    }

    /**
     * 遍历方法3 - stream遍历
     *
     * @return stream流
     */
    public IntStream stream() {
        return IntStream.of(Arrays.copyOfRange(array, 0, size));
    }

    public static void main(String[] args) {
        DynamicArray dynamicArray = new DynamicArray();
        dynamicArray.addLast(1);
        dynamicArray.addLast(2);
        dynamicArray.addLast(3);
        dynamicArray.addLast(4);
        dynamicArray.addLast(5);

        dynamicArray.add(5, 6);

        // System.out.println(dynamicArray.get(4));
        /*dynamicArray.foreach((element) -> {
            System.out.println(element);
        });*/

        /*for (Integer element : dynamicArray) {
            // hasNext() next()
            System.out.println(element);
        }*/

         // dynamicArray.stream().forEach(System.out::println);

         // System.out.println(dynamicArray.remove(2));
         // System.out.println(dynamicArray.size);  

    }
}

这些方法实现,都简化了index的有效性判断,假设输入的index都是合法的。

插入或删除性能

  • 头部位置,时间复杂度是O(n)
  • 中间位置,时间复杂度是O(n)
  • 尾部位置,时间复杂度是O(1)

注:函数式接口

函数式接口(Functional Interface)是Java 8中引入的一个新概念。它是一个只有一个抽象方法的接口。这意味着,这个接口中只能有一个需要实现的方法。

在Java 8之前,我们通常会使用匿名内部类来实现接口。但是,这种方式比较繁琐,代码也比较冗长。而使用函数式接口,我们可以使用Lambda表达式或方法引用来简化代码。

例如,我们定义一个函数式接口:

@FunctionalInterface
public interface MyFunctionalInterface {
    int doSomething(int x, int y);
}

在这个接口中,只有一个抽象方法`doSomething(int x, int y)`。我们可以使用Lambda表达式来实现这个接口:

MyFunctionalInterface myInterface = (x, y) -> x + y;
int result = myInterface.doSomething(2, 3); // result = 5

或者使用方法引用:

MyFunctionalInterface myInterface = Integer::sum;
int result = myInterface.doSomething(2, 3); // result = 5

这样,我们就可以更简洁地实现接口,而无需创建匿名内部类。

函数式接口有以下特点:

1. 只有一个抽象方法。
2. 可以有默认方法和静态方法。
3. 可以使用`@FunctionalInterface`注解来标注。

使用函数式接口可以让我们的代码更加简洁和易读,同时也支持了Java 8中的新特性,如Lambda表达式和方法引用。
 

3. 二维数组

int[][] array = {
    {11, 12, 13, 14, 15},
    {21, 22, 23, 24, 25},
    {31, 32, 33, 34, 35},
};

内存图如下:

二维数组占32个字节,其中array[0],array[1],array[2]三个元素分别保存了指向三个一维数组的引用

  • 三个一维数组各占40个字节
  • 它们在内层布局上是连续

更一般的,对一个二维数组Array[m][n]

  • m是外层数组的长度,可以看作row行
  • n是内层数组的长度,可以看作column列

当访问Array[i][j]时,就相当于

  • 先找到第i个内层数组(行)
  • 再找到此内存数组中第j个元素(列)

小测试

Java环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组

byte[][] array = {
    {11, 12, 13, 14, 15},
    {21, 22, 23, 24, 25},
    {31, 32, 33, 34, 35},
};

已知array对象起始地址是0x1000,那么23这个元素的地址是什么?

答:

  • 起始地址 0x1000

  • 外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20

  • 第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18

  • 第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2

  • 最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a

4. 局部性原理

这里只讨论空间局部性

  • cpu读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,存缓存中能读到的话,就不必读内存了
  • 缓存的最小存储单位是缓存行(cache line),一般是64bytes,一次读的数据少了不划算,因此最少读64bytes填满一个缓存行,因此读入某个数据时,也会读取其临近的数据,这就是空间局部性。

对效率的影响

比较下面 ij 和 ji两个方法的执行效率

int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];

StopWatch sw = new StopWatch();
sw.start("ij");
ij(a, rows, columns);
sw.stop();
sw.start("ji");
ji(a, rows, columns);
sw.stop();
System.out.println(sw.prettyPrint());

ij方法

public static void ij(int[][] a, int rows, int columns) {
    long sum = 0L;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < columns; j++) {
            sum += a[i][j];
        }
    }
    System.out.println(sum);
}

ji方法

public static void ji(int[][] a, int rows, int columns) {
    long sum = 0L;
    for (int j = 0; j < columns; j++) {
        for (int i = 0; i < rows; i++) {
            sum += a[i][j];
        }
    }
    System.out.println(sum);
}

执行结果

0
0
StopWatch '': running time = 96283300 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
016196200  017%  ij
080087100  083%  ji

可以看到 ij 的效率比 ji快很多,为什么呢?

  • 缓存是有限的,当新数据来了之后,一些旧的缓存行数据就会被覆盖
  • 如果不能充分利用缓存的数据,就会造成效率低下

以 ji 执行为例,第一次内循环要读入[0, 0]这条数据,由于局部性原理,读入[0, 0]的同时也读入了[0, 0] ... [0, 13],如图所示

但很遗憾,第二次内循环要的是[1, 0]这条数据,缓存中没有,于是再读入了下图的数据

这显然是一种浪费,因为[0, 1] ... [0, 13],包括[1, 1] ... [1, 13]这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时

缓存的第一行数据已经被新的数据[8, 0] ... [8, 13]覆盖掉了,以后如果想再读,比如[0, 1],又得到内存去读了。同理可以分析ij函数则能充分利用局部性原理加载到的缓存数据。

举一反三

1. I/O读写时同样可以体现局部性原理

2. 数组可以充分利用局部性原理,那么链表呢?

答:链表不行,因为链表的元素并非相邻存储

5. 越界检查

java中对数组元素的读写都有越界检查,类似于下面的代码

bool is_within_bounds(int index) const        
{ 
    return 0 <= index && index < length(); 
}

此检查代码不需要由程序员来调用,JVM会帮我们调用

6. 习题

6.1 合并两个有序数组

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。

请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。

注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。

示例 1:

输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。

示例 2:

输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]
解释:需要合并 [1] 和 [] 。
合并结果是 [1] 。

示例 3:

输入:nums1 = [0], m = 0, nums2 = [1], n = 1
输出:[1]
解释:需要合并的数组是 [] 和 [1] 。
合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。

提示:

  • nums1.length == m + n
  • nums2.length == n
  • 0 <= m, n <= 200
  • 1 <= m + n <= 200
  • -10^9 <= nums1[i], nums2[j] <= 10^9

进阶:你可以设计实现一个时间复杂度为 O(m + n) 的算法解决此问题吗?

解法一:使用双指针法从后向前填充 nums1,避免了覆盖尚未比较的元素

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        int i = m + n - 1;
        m--;
        n--;

        while(n >= 0) {
            if(m >= 0 && nums1[m] > nums2[n]) {
               nums1[i--] = nums1[m--];
            } else {
                nums1[i--] = nums2[n--];
            }
        }
    }
}

6.2 合并数组的两个有序区间

原题修改

解法一:迭代

package com.itheima.algorithms.datastructure;

public class MergeArray {
    public static void main(String[] args) {
        int[] a1 = {1, 5, 6, 2, 4, 6, 10, 11};
        int[] a2 = new int[a1.length];
        merge(a1, 0, 2, 3, a1.length - 1, a2);
        for (int i : a2) {
            System.out.print(i + " ");
        }
    }

    /**
     * 合并两个有序区间
     * @param a 原始数组
     * @param i 第一个有序区间的起点
     * @param iEnd 第一个有序区间的终点
     * @param j 第二个有序区间的起点
     * @param jEnd 第二个有序区间的终点
     * @param a2 结果数组
     */
    public static void merge(int[] a, int i, int iEnd, int j, int jEnd, int[] a2) {
        int k = i;
        while(i <= iEnd && j < jEnd) {
            if(a[i] < a[j]) {
                a2[k] = a[i];
                i++;
            } else {
                a2[k] = a[j];
                j++;
            }
            k++;
        }
        if(i > iEnd) {  // 第一个区间已经没有元素
            System.arraycopy(a, j, a2, k, jEnd - j + 1);
        }

        if(j > jEnd) {  // 第二个区间已经没有元素
            // src srcPos dest destPos length
            System.arraycopy(a, i, a2, k, iEnd - i + 1);
        }
    }
}

解法二:递归

package com.itheima.algorithms.datastructure;

public class MergeArray {
    public static void main(String[] args) {
        int[] a1 = {1, 5, 6, 2, 4, 10, 11};
        int[] a2 = new int[a1.length];
        merge(a1, 0, 2, 3, a1.length - 1, a2, 0);
        for (int i : a2) {
            System.out.print(i + " ");
        }
    }

    /**
     * 合并两个有序区间
     * @param a 原始数组
     * @param i 第一个有序区间的起点
     * @param iEnd 第一个有序区间的终点
     * @param j 第二个有序区间的起点
     * @param jEnd 第二个有序区间的终点
     * @param a2 结果数组
     */
    public static void merge(int[] a, int i, int iEnd, int j, int jEnd, int[] a2) {
        int k = i;
        while(i <= iEnd && j < jEnd) {
            if(a[i] < a[j]) {
                a2[k] = a[i];
                i++;
            } else {
                a2[k] = a[j];
                j++;
            }
            k++;
        }
        if(i > iEnd) {  // 第一个区间已经没有元素
            System.arraycopy(a, j, a2, k, jEnd - j + 1);
        }

        if(j > jEnd) {  // 第二个区间已经没有元素
            // src srcPos dest destPos length
            System.arraycopy(a, i, a2, k, iEnd - i + 1);
        }
    }

    public static void merge(int[] a, int i, int iEnd, int j, int jEnd, int[] a2, int k) {
        if(i > iEnd) {  // 有序区1已空
            System.arraycopy(a, j, a2, k, jEnd -j + 1);
            return;
        }

        if(j > jEnd) { // 有序区2已空
            System.arraycopy(a, i, a2, k, iEnd - i + 1);
            return;
        }

        if(a[i] < a[j]) {
            a2[k] = a[i];
            merge(a, i + 1, iEnd, j, jEnd, a2, k + 1);
        } else {
            a2[k] = a[j];
            merge(a, i, iEnd, j + 1, jEnd, a2, k + 1);
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值