顺序表
1.1定义
顺序表是用一段物理地址(内存)连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数 据的增删查改。
1.2分类
顺序表一般可以分为:
静态顺序表:使用定长数组存储。
动态顺序表:使用动态开辟的数组存储。
静态顺序表适用于确定知道需要存多少数据的场景.
静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用.
相比之下动态顺序表更灵活, 根据需要动态的分配空间大小.
1.3自己动手实现一个顺序表
我依稀还记得自己动手实现一个顺序表的代码出现在了刘欣老师的《码农翻身》一书当中,当时留心老师的书中就要求自己去动手实现一个顺序表出来
下来我先将要自己实现的接口(方法)列举出来:
// 打印顺序表 public void display() { } // 在 pos 位置新增元素 public void add(int pos, int data) { } // 判定是否包含某个元素 public boolean contains(int toFind) { return true; } // 查找某个元素对应的位置 public int search(int toFind) { return -1; } // 获取 pos 位置的元素 public int getPos(int pos) { return -1; } // 给 pos 位置的元素设为 value public void setPos(int pos, int value) { } //删除第一次出现的关键字key public void remove(int toRemove) { } // 获取顺序表长度 public int size() { return 0; } // 清空顺序表 public void clear() { }
代码示例:
顺序表实现核心代码:
import java.util.Arrays; public class MyArrayList { //定义一个数组 private int[] elem; //usedSize数据用于计算数组中有效数据的个数 private int usedSize; //利用构造方法来进行elem这个实例成员变量的初始化 public MyArrayList() { this.elem = new int[10]; } public MyArrayList(int capacity) { this.elem = new int[capacity]; } //在 pos 位置新增元素 public boolean isFull() { if (this.usedSize == this.elem.length) { //代表顺序表已满 return true; } return false; } //定义一个数组扩容方法resize public void resize() { System.out.println("满了就进行扩容,扩大两倍"); //调用copyOf方法进行扩容 this.elem = Arrays.copyOf(this.elem, this.elem.length * 2); } public void add(int pos, int data) { //如果顺序表满,则不能插入 if (isFull()) { System.out.println("顺序表已满"); //如果此时顺序表满了,调用resize方法来进行扩容 resize(); } if (pos < 0 || pos > this.usedSize) { System.out.println("pos位置不合法"); return; } //插入后,其他元素后移 for (int i = this.usedSize - 1; i >= pos; i--) { this.elem[i + 1] = this.elem[i]; } //将data数据插入到pos处 this.elem[pos] = data; //插入成功后有效数据+1 this.usedSize++; } //假设此时我们默认插入的数据都在数组最后面,方法如add2所示 public void add2(int data) { if (isFull()) { System.out.println("数组已满,禁止插入"); //调用resize方法进行扩容 resize(); } this.elem[this.usedSize] = data; this.usedSize++; } // 打印顺序表 public void display() { for (int i = 0; i < this.usedSize; i++) { System.out.print(this.elem[i] + " "); } System.out.println(); } // 判定是否包含某个元素 public boolean contains(int toFind) { for (int i = 0; i < this.usedSize; i++) { if (this.elem[i] == toFind) { return true; } } //找不到返回false return false; } // 查找某个元素对应的位置 public int search(int toFind) { for (int i = 0; i < this.usedSize; i++) { if (this.elem[i] == toFind) { return i; } } //找不到返回-1 return -1; } // 获取 pos 位置的元素 public int getPos(int pos) { if (pos < 0 || pos >= this.usedSize) { return -1; } return this.elem[pos]; } // 给 pos 位置的元素设为 value public void setPos(int pos, int value) { if (pos < 0 || pos >= this.usedSize) { return; } this.elem[pos] = value; } //删除第一次出现的关键字key public void remove(int key) { int j = search(key); if (j == -1) { System.out.println("没有这个元素呀"); return; } for (int i = j; i < this.usedSize - 1; i++) { this.elem[i] = this.elem[i + 1]; } this.usedSize--; } // 获取顺序表长度 public int size() { return this.usedSize; } // 清空顺序表 public void clear() { this.usedSize = 0; } }
下面我们来挨个分析这段代码:
//定义一个数组 private int[] elem; //usedSize数据用于计算数组中有效数据的个数 private int usedSize; //利用构造方法来进行elem这个实例成员变量的初始化 public MyArrayList() { this.elem = new int[10]; } public MyArrayList(int capacity) { this.elem = new int[capacity]; }
首先一开始我们定义一个数组, 因为顺序表底层就是一个数组,用于存储数据用的,usedSize代表我们这个数组中有效的数据的个数 ,例如数组长度为10,元素个数为5,那么usedSize=5.当然我们给这两个实例成员的权限为private,当然可以使用setter和getter方法进行初始化,为了方便起见,我们就使用构造函数来进行初始化了。
//在 pos 位置新增元素 public boolean isFull() { if (this.usedSize == this.elem.length) { //代表顺序表已满 return true; } return false; } //定义一个数组扩容方法resize public void resize() { System.out.println("满了就进行扩容,扩大两倍"); //调用copyOf方法进行扩容 this.elem = Arrays.copyOf(this.elem, this.elem.length * 2); } public void add(int pos, int data) { //如果顺序表满,则不能插入 if (isFull()) { System.out.println("顺序表已满"); //如果此时顺序表满了,调用resize方法来进行扩容 resize(); } if (pos < 0 || pos > this.usedSize) { System.out.println("pos位置不合法"); return; } //插入后,其他元素后移 for (int i = this.usedSize - 1; i >= pos; i--) { this.elem[i + 1] = this.elem[i]; } //将data数据插入到pos处 this.elem[pos] = data; //插入成功后有效数据+1 this.usedSize++; } //假设此时我们默认插入的数据都在数组最后面,方法如add2所示 public void add2(int data) { if (isFull()) { System.out.println("数组已满,禁止插入"); //调用resize方法进行扩容 resize(); } this.elem[this.usedSize] = data; this.usedSize++; }
在向一个顺序表中插入数据的时候,一般有两种方法,第一种是向顺序表中插入数据,第二种方法是在顺序表的尾部插入数据
先来看第一种add方法,当我们向顺序表中插入数据的时候,需要注意两点:
1.判断此时数组是否满,满了的话就不能再插入了
所以我们定义了一个isFull函数来判断此时数组是否已经满了
2.判断插入的位置是否合法,插入的数组下标位置应既不能小于0,也不能大于usedSize
原因是如果插入的数组下标位置小于0,说明插在了这个数组最前面,显然不符合当前情况,如果大于了usedSize,打个比方,假如usedSize的值为3, 说明此时顺序表中有三个有效数据,那么在顺序表中插入的下标位置的取值范围一定是在【0,3】这样的一个闭区间内, 取0-2说明在数组内部某个下标处插入,那么这个下标后面的原有元素都要后移 假如在下标为3的位置插入,相当于紧挨着顺序表插入,若大于3,是不可行的,相当于跟当前数组中最后一个有效元素隔了一个格子进行插入。
进行完上述判断后,此时我们来进行元素在数组间的插入操作,当一个元素插入一个位置后,原来插入的位置上的元素及其之后的元素都要相应的后移,例如现有一个数组存储了1,2,4,5这四个数字,现在要往下标为2的地方存入3这个数字,那么就需要将原来下标为2处所对应的4这个数字和5这个数字依次向后移动,也就有了this.elem[i + 1] = this.elem[i]这条语句,并且注意我们这个循环中i是从数组最后一个元素的下标开始的,数组中的有效数字个数为usedSize,那么最后一个数字的下标为usedSize-1,然后i的边界为>=pos,i可以等于pos,原因是我们原本pos对应的数字也是要往后移动的,所以需要移动完4这个数字后再将3这个数字插入2这个下标处,对应this.elem[pos] = data这条语句,最终数组中的有效数据计量单位usedSize加1,完成了数据插入数组间的操作.
此外,我们还可以看到此处定义了一个扩容函数resize,为什么要用到扩容函数呢?
原因是假如我们的数组已经满了,即无法再插入进去了,那么就需要对数组进行扩容,否则我们插入的时候会报数组越界异常错误。我们自行实现扩容的方法是运行Arrays工具类中的copyOf方法来进行扩容,因为copyOf方法中的数组长度是可以自定义的,具体可以看我之前数组的博客。https://blog.csdn.net/qq_41972686/article/details/111411606
当然顺序表(ArrayList)是有其自己的扩容方式的,是1.5倍扩容
最后我们就还定义了一个add2方法,这个方法是为了解决在顺序表尾部插入数据的问题,可以看到只需一次便可插入成功,故时间复杂度为O(1)
第一种方法的时间复杂度为O(N),第二种方法的时间复杂度为O(1).
所以根据时间复杂度是按照最坏情况来界定的话,顺序表的插入删除操作的时间复杂度为O(N)
同时还有一个写法大家需要注意:如果在一个返回值为void的方法中遇到了return;这样的语句,代表直接退出本函数。
return;这个语句一般出现在一个无返回值的函数里面, 函数里面可以有很多行,有的时候需要程序运行到某一行就要停止退出本函数,就需要用到return;语句
记住在方法中如果遇到return语句的时候就代表结束了,不管这个return后面有多少语句.
//打印顺序表 public void display() { for (int i = 0; i < this.usedSize; i++) { System.out.print(this.elem[i] + " "); } System.out.println(); }
打印顺序表的方法跟遍历数组的方法相同,这块没有什么好说的,注意i的边界是小于usedSize即可.因为usedSize-1就是数组中最后一个元素的下标.
// 判定是否包含某个元素 public boolean contains(int toFind) { for (int i = 0; i < this.usedSize; i++) { if (this.elem[i] == toFind) { return true; } } //找不到返回false return false; }
判断顺序表中是否包含某个元素,其核心思想还是遍历顺序表底层的数组,根据遍历判断每个下标对应的数字是否是我们所想要的数字,如果是,返回true,否则返回false.
// 查找某个元素对应的位置 public int search(int toFind) { for (int i = 0; i < this.usedSize; i++) { if (this.elem[i] == toFind) { return i; } } //找不到返回-1 return -1; }
查找顺序表中某个元素的位置,其实就是返回这个元素在数组中所对应的下标的位置,同样的方式去遍历顺序表的底层数组,通过判断某个下标所对应的数值跟我们所要找的元素是否相等,来返回对应的下标值,如果找不到就返回-1.
// 获取 pos 位置的元素 public int getPos(int pos) { if (pos < 0 || pos >= this.usedSize) { return -1; } return this.elem[pos]; }
获取pos位置的元素的时候需要注意一个问题:pos值的取值问题.
因为pos值就相当于数组的下标,所以当我们去获取pos位置的元素的时候,需要考虑到pos的取值问题,如果小于0或者大于了usedSize,相当于取到了数组下标取值范围以外的数字,那么如果我们不处理这种情况,就会发生数组下标越界异常,所以当出现这种情况的时候,我们返回-1即可,否则就返回pos位置的元素.
// 给 pos 位置的元素设为 value public void setPos(int pos, int value) { if (pos < 0 || pos >= this.usedSize) { return; } this.elem[pos] = value; }
将pos位置的元素设置为value,其实就是直接将pos位置所对应的值改成value值即可,并不需要遍历循环去找到pos位置的元素再修改,这样无非增加了时间复杂度。
当然在进行修改前要先判断所修改的数值对应的下标pos是否符合要求,如代码所示,如果不符合要求直接return结束.
//删除第一次出现的关键字key public void remove(int key) { int index = search(key); if (index == -1) { System.out.println("没有这个元素呀"); return; } for (int i = index; i < this.usedSize - 1; i++) { this.elem[i] = this.elem[i + 1]; } this.usedSize--; }
// 查找某个元素对应的位置 public int search(int toFind) { for (int i = 0; i < this.usedSize; i++) { if (this.elem[i] == toFind) { return i; } } //找不到返回-1 return -1; }
删除第一次出现的关键字key,也就是找到我们顺序表底层数组中是否有跟这个key值相同的元素,如果有,那么就删掉这个元素,如果这个元素在这个数组中出现超过一次,那么就按照下标顺序删除第一次出现的这个元素.
当然在进行删除之前还要判断下当前数组中是否包含我们所要删除的元素,如果没有的话,就直接return结束,那么怎么确定当前数组中是否有这个要删除的元素呢?
此时就需要用到search方法了,这个方法是用来查找数组中元素的位置的,假如数组中有这个元素,就返回这个元素的下标值,否则就返回-1.
那么这个search方法刚好可以用到我们的删除方法remove中,首先调用search方法来判断数组中是否有我们要删除的元素,如果没有,search方法便会返回-1,那么当其返回-1时,我们的remove方法就直接return结束,如果返回值不是-1,就开始执行我们的for循环来进行删除工作。
这里大家可能会疑惑为什么删除插入操作都要用到for循环呢?因为当我们执行删除或者插入操作的时候是牵扯到元素的前移和后移的,所以用循环可以更好的表示所涉及到的需要移动的元素。
在删除操纵中有两点需要注意:
1:第一点for循环中i的初始值为index,index就是我们所要删除的第一次出现的key值的下标,因为元素的删除和移动都是从当前这个下标开始的,所以i的初始值为index.
当前i的边界也就是终止条件为i小于this.usedSize-1,这块的小于是需要额外注意下的,原因是我们后期元素移动的语句为 this.elem[i] = this.elem[i + 1],我们可以看到假如此时i<=this.usedSize-1,相当于i是可以取到最后一个元素的下标值的,那么this.elem[i] = this.elem[i + 1]这条语句会报错,原因是i+1此时数组下标越界,最终会显示数组下标越界异常.
2:最后要记得每当我们删除一个元素的时候,我们的数组中有效数据的计量单位usedSize要对应减掉一个,别忘了this.usedSize--这条语句.
// 获取顺序表长度 public int size() { return this.usedSize; }
获取顺序表的长度就非常简单了,有usedSize这个负责记录数组中有效数据的计量单位,我们可直接返回usedSize即可,usedSize的值就代表了当前数组中元素的个数,也就是我们顺序表的长度.
// 清空顺序表 public void clear() { this.usedSize = 0; }
清空顺序表的话就直接将usedSize置为0就好,代表我们的顺序表中此时不再有有效数据了,说明元素全部清空.
好了到这里我们自主实现的顺序表代码和解析就结束啦,写到这里大家可能也会有了一些感悟和思考,下面我们来进行下总结。
1.4顺序表的总结和思考
顺序表缺点
(1)顺序表中间或头部插入,时间复杂度为O(N),原因是当我们使用中间插入时,循环要执行的次数为N-1(N为当前线性表中有效数据的个数)
那么经过大O渐进法三部曲后,最终时间复杂度为O(N)
尾部直接插入,时间复杂度为O(1)
所以就插入删除元素来说,在最坏情况下,顺序表的时间复杂度可以达到O(N).所以在插入删除的时候一般使用链表.
(2)假设此时顺序表的大小为10,当顺序表存满后我们再向内部进行插入数据的时候,就需要对其进行扩容,java中顺序表的扩容是按照原来数组长度的1.5倍进行扩容的,那么扩容后的数组长度为15,但最后我只插入了一个数据,说明有4个空位此时没有用,浪费掉了。说明原数组长度越大,浪费的空间越大。说明顺序表增容的代价很大.
顺序表优点
如果是通过下标直接取数据,那么时间复杂度是可以达到O(1).
由此,为了改变顺序表的缺点问题,我们引入了链表.