万书网 > 文学作品 > 漫画算法:小灰的算法之旅 > 第2章 数据结构基础

第2章 数据结构基础





2.1 什么是数组



2.1.1 初识数组



这些特点是如何体现的呢?

参加过军训的读者,一定都记得这样的场景。

在军队里,每一个士兵都有自己固定的位置、固定的编号。众多士兵紧密团结在一起,高效地执行着一个个命令。

大黄,咱们为什么要说这么多关于军队的事情呢?

因为有一个数据结构就像军队一样整齐、有序,这个数据结构叫作数组  。

什么是数组?

数组对应的英文是array,是有限个相同类型的变量所组成的有序集合,数组中的每一个变量被称为元素。数组是最为简单、最为常用的数据结构。

以整型数组为例,数组的存储形式如下图所示。

正如军队里的士兵存在编号一样,数组中的每一个元素也有着自己的下标,只不过这个下标从0开始,一直到数组长度-1。

数组的另一个特点,是在内存中顺序存储  ,因此可以很好地实现逻辑上的顺序表  。

数组在内存中的顺序存储,具体是什么样子呢?

内存是由一个个连续的内存单元组成的,每一个内存单元都有自己的地址。在这些内存单元中,有些被其他数据占用了,有些是空闲的。

数组中的每一个元素,都存储在小小的内存单元中,并且元素之间紧密排列,既不能打乱元素的存储顺序,也不能跳过某个存储单元进行存储。

在上图中,橙色的格子代表空闲的存储单元,灰色的格子代表已占用的存储单元,而红色的连续格子代表数组在内存中的位置。

不同类型的数组,每个元素所占的字节个数也不同,本图只是一个简单的示意图。

那么,我们怎样来使用一个数组呢?

数据结构的操作无非是增、删、改、查4种情况,下面让我们来看一看数组的基本操作。



2.1.2 数组的基本操作

1.  读取元素

对于数组来说,读取元素是最简单的操作。由于数组在内存中顺序存储,所以只要给出一个数组下标,就可以读取到对应的数组元素。

假设有一个名称为array的数组,我们要读取数组下标为3的元素,就写作array[3];读取数组下标为5的元素,就写作array[5]。需要注意的是,输入的下标必须在数组的长度范围之内,否则会出现数组越界。

像这种根据下标读取元素的方式叫作随机读取  。

简单的代码示例如下:

1.  int[]  array  =  new  int[]{3,1,2,5,4,9,7,2};

2.  //  输出数组中下标为3的元素

3.  System.out.println(array[3]);

2.  更新元素

要把数组中某一个元素的值替换为一个新值,也是非常简单的操作。直接利用数组下标,就可以把新值赋给该元素。

简单的代码示例如下:

1.  int[]  array  =  new  int[]{3,1,2,5,4,9,7,2};

2.  //  给数组下标为5的元素赋值

3.  array[5]  =  10;

4.  //  输出数组中下标为5的元素

5.  System.out.println(array[5]);

小灰,咱们刚刚讲过时间复杂度的概念,你说说数组读取元素和更新元素的时间复杂度分别是多少?

嘿嘿,这难不倒我。数组读取元素和更新元素的时间复杂度都是O(1)  。

3.  插入元素

在介绍插入数组元素的操作之前,我们需要补充一个概念,那就是数组的实际元素数量有可能小于数组的长度,例如下面的情形。

因此,插入数组元素的操作存在3种情况。

尾部插入

中间插入

超范围插入

尾部插入,是最简单的情况,直接把插入的元素放在数组尾部的空闲位置即可,等同于更新元素的操作。

中间插入,稍微复杂一些。由于数组的每一个元素都有其固定下标,所以不得不首先把插入位置及后面的元素向后移动,腾出地方,再把要插入的元素放到对应的数组位置上。



中间插入操作的完整实现代码如下:

1.  private  int[]  array;

2.  private  int  size;

3.

4.  public  MyArray(int  capacity){

5.  this.array  =  new  int[capacity];

6.  size  =  0;

7.  }

8.

9.  /**

10.  *  数组插入元素

11.  *  @param  element  插入的元素

12.  *  @param  index  插入的位置

13.  */

14.  public  void  insert(int  element,  int  index)  throws  Exception  {

15.  //判断访问下标是否超出范围

16.  if(index<0  ||  index>size){


17.  throw  new  IndexOutOfBoundsException("超出数组实际元素范围!");

18.  }

19.  //从右向左循环,将元素逐个向右挪1位

20.  for(int  i=size-1;  i>=index;  i--){

21.  array[i+1]  =  array[i];

22.  }

23.  //腾出的位置放入新元素

24.  array[index]  =  element;

25.  size++;

26.  }

27.

28.  /**

29.  *  输出数组

30.  */

31.  public  void  output(){

32.  for(int  i=0;  i
33.  System.out.println(array[i]);

34.  }

35.  }

36.

37.  public  static  void  main(String[]  args)  throws  Exception  {

38.  MyArray  myArray  =  new  MyArray(10);

39.  myArray.insert(3,0);

40.  myArray.insert(7,1);

41.  myArray.insert(9,2);

42.  myArray.insert(5,3);

43.  myArray.insert(6,1);

44.  myArray.output();

45.  }

代码中的成员变量size是数组实际元素的数量。如果插入元素在数组尾部,传入的下标参数index等于size;如果插入元素在数组中间或头部,则index小于size。

如果传入的下标参数index大于size或小于0,则认为是非法输入,会直接抛出异常。

可是,如果数组不断插入新的元素,元素数量超过了数组的最大长度,数组岂不是要“撑爆”了?

问得很好,这就是接下来要讲的情况——超范围插入。

超范围插入,又是什么意思呢?

假如现在有一个长度为6的数组,已经装满了元素,这时还想插入一个新元素。

这就涉及数组的扩容  了。可是数组的长度在创建时就已经确定了,无法像孙悟空的金箍棒那样随意变长或变短。这该如何是好呢?

此时可以创建一个新数组,长度是旧数组的2倍,再把旧数组中的元素统统复制过去,这样就实现了数组的扩容。

如此一来,我们的插入元素方法也需要改写了,改写后的代码如下:

1.  private  int[]  array;

2.  private  int  size;

3.

4.  public  MyArray(int  capacity){

5.  this.array  =  new  int[capacity];

6.  size  =  0;

7.  }

8.

9.  /**

10.  *  数组插入元素

11.  *  @param  element  插入的元素

12.  *  @param  index  插入的位置

13.  */

14.  public  void  insert(int  element,  int  index)  throws  Exception  {

15.  //判断访问下标是否超出范围

16.  if(index<0  ||  index>size){

17.  throw  new  IndexOutOfBoundsException("超出数组实际元素范围!");

18.  }

19.  //如果实际元素达到数组容量上限,则对数组进行扩容

20.  if(size  >=  array.length){

21.  resize();

22.  }

23.  //从右向左循环,将元素逐个向右挪1位

24.  for(int  i=size-1;  i>=index;  i--){

25.  array[i+1]  =  array[i];

26.  }

27.  //腾出的位置放入新元素


28.  array[index]  =  element;

29.  size++;

30.  }

31.

32.  /**

33.  *  数组扩容

34.  */

35.  public  void  resize(){

36.  int[]  arrayNew  =  new  int[array.length*2];

37.  //从旧数组复制到新数组

38.  System.arraycopy(array,  0,  arrayNew,  0,  array.length);

39.  array  =  arrayNew;

40.  }

41.

42.  /**

43.  *  输出数组

44.  */

45.  public  void  output(){

46.  for(int  i=0;  i
47.  System.out.println(array[i]);

48.  }

49.  }

50.

51.  public  static  void  main(String[]  args)  throws  Exception  {

52.  MyArray  myArray  =  new  MyArray(4);

53.  myArray.insert(3,0);

54.  myArray.insert(7,1);

55.  myArray.insert(9,2);

56.  myArray.insert(5,3);

57.  myArray.insert(6,1);

58.  myArray.output();

59.  }

4.  删除元素

数组的删除操作和插入操作的过程相反,如果删除的元素位于数组中间,其后的元素都需要向前挪动1位。

由于不涉及扩容问题,所以删除操作的代码实现比插入操作要简单:

1.  /**

2.  *  数组删除元素

3.  *  @param  index  删除的位置

4.  */

5.  public  int  delete(int  index)  throws  Exception  {

6.  //判断访问下标是否超出范围

7.  if(index<0  ||  index>=size){

8.  throw  new  IndexOutOfBoundsException("超出数组实际元素范围!");

9.  }

10.  int  deletedElement  =  array[index];

11.  //从左向右循环,将元素逐个向左挪1位

12.  for(int  i=index;  i
13.  array[i]  =  array[i+1];

14.  }

15.  size--;

16.  return  deletedElement;

17.  }

小灰,我再考考你,数组的插入和删除操作,时间复杂度分别是多少?

先说说插入操作,数组扩容的时间复杂度是O(n),插入并移动元素的时间复杂度也是O(n),综合起来插入操作的时间复杂度是O(n)  。至于删除操作,只涉及元素的移动,时间复杂度也是O(n)  。

说得没错。对于删除操作,其实还存在一种取巧的方式,前提是数组元素没有顺序要求。

例如下图所示,需要删除的是数组中的元素2,可以把最后一个元素复制到元素2所在的位置,然后再删除掉最后一个元素。

这样一来,无须进行大量的元素移动,时间复杂度降低为O(1)。当然,这种方式只作参考,并不是删除元素时主流的操作方式。



2.1.3 数组的优势和劣势

数组的基本知识我懂了,那么,使用数组这种数据结构有什么优势和劣势呢?

数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。有一种高效查找元素的算法叫作二分查找,就是利用了数组的这个优势。

至于数组的劣势,体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。

总的来说,数组所适合的是读操作多、写操作少  的场景,下一节我们要讲解的链表则恰恰相反。好了,让我们下一节再会!