顺序表
数组是具体的数据类型,顺序表只是个抽象的概念,那我们就用数组这个具体的类型来实现顺序表这个抽象概念。
我们知道,对于数组来说,访问、修改元素,都是通过下标实现的,比如:
int[] arr;
// 访问
int a = arr[i];
// 修改
arr[i] = 10;
这极其不方便,我不关心你的下标是多少,我只想往里放,从里取,然后传个下标就给我返回对应的元素,这样行吗?
可以!那么,怎么实现呢?
很简单,我们就用数组,然后根据下标index去操作元素,存放元素的时候就让index+1,取元素的时候就让index-1,访问指定下标元素的时候,直接返回index对应的元素就行。
上代码:
// 名字就先这么委屈地叫着吧
class XianxingBiao {
// 初始化一个大小为10的数组
private int[] arr = new int[10]();
// 当前元素的下标,默认是没有的,所以就是-1
privage int curIndex = -1;
// 存放元素
public void put(int num) {
// 下标+1,然后再存放元素
arr[++curIndex] = a;
}
// 获取元素,可能会下标越界,直接返回,效率好高
public int get(int index) {
return arr[index];
}
// 在指定位置插入元素,效率好低啊
public int add(int num, int index) {
// 我们前面讲过了,需要将index以及后面的元素全部往后挪一步
// 第一步,先把index以及后面的元素挪一步,我们要从后往前开始,因为前面的移动会覆盖后面的
for(int i = arr.length-1; i > index; i--) {
arr[i] = arr[i-1];
}
// 第二步,上面已经把arr[index]空出来了,那就直接放新值就行
arr[index] = num;
}
// 删除指定位置的元素,效率好低啊
public void remove(int index){
// 我们前面讲过了,需要将index后面的元素全部往前挪一步
// 这里的需要从index开始,因为后面的移动会覆盖前面的
for(int i = index; i <= arr.length-2;i++) {
arr[i] = arr[i+1];
}
}
}
有人说,添加元素是两步,删除元素为啥是一步呢?不应该先把那个位置空出来,然后再把后面元素往前挪移吗?
不用!你直接挪移元素,后面的就会把index位置的覆盖了,那不就等于删除了吗?何必多此一举呢。
还有问题,上面的数组,创建的时候大小是 10,添加元素或者插入元素的时候,如果放不下,怎么办呢?
扩容!要尽量选择易变的数据结构,那么我们设计的也肯定要这样。数组的扩容很费劲,究竟有多费劲呢?就是只能新建一个大的,把老的挨个复制进去,这性能确实酸爽。
我们来看扩容的代码:
class XianxingBiao {
// 初始化一个大小为10的数组
private int[] arr = new int[10]();
// 当前元素的下标,默认是没有的,所以就是-1
privage int curIndex = -1;
// 扩容
private void resize() {
// 还记得左移吗,就是乘以2,也就是新的数组大小是老的2倍,就是20
int newSize = arr.length << 1;
int[] newArray = new int[newSize];
// 把老数组元素挨个拷贝到新数组中
for(int i = 0; i < arr.length; i++) {
newArray[i] = arr[i];
}
// 还记得吗?数组是个对象,对象是个引用,改变引用的值就是改变指向的对象
arr = newArray();
}
}
上述我们创建一个新数组,将老数组元素按顺序拷贝过去,然后将新数组赋值给老数组,这样,老数组就完成了扩容,简单粗暴。
那么,我们在 put()和add()之前,就要先检查下是否能存放下,如果存放不下,那么就先扩容。如下:
class XianxingBiao {
// 存放元素
public void put(int num) {
// 如果当前元素已经到头了,就是没位置了,就要扩容
if(curIndex == arr.length-1) {
resize();
}
...其他代码
}
// 在指定位置插入元素
public int add(int num, int index) {
// 如果当前元素已经到头了,就是没位置了,就要扩容
if(curIndex == arr.length-1) {
resize();
}
...其他代码
}
}
好,这样我们就解决了“我们的顺序表不是易变的数据结构”这个问题。
这实现是实现了,不过这拷贝一份儿,这效率也太低了。嗯,你可以将数组的初始容量变大一些,但是这样就可能浪费空间;所以,你要考虑你的需求场景,如果是数据量很大的,就不妨将数组初始容量改大一些,避免频繁扩容带来的开销;如果数据量不大,那么就小一些,达到节省内存的效果。如果你真的是插入/删除得频繁,那就可以考虑下下面的链式表。
不过这名字也太难听了,那就改名字,我们是使用数组实现的顺序表,数组就是 Array,表就是 List,那就叫 ArrayList 吧。这正是 Java 中的顺序表的名字。
好,名字也改了,我们就来看看链式表吧。
链表
我们需要提供跟上述ArrayList一样的 API 来供程序员使用,公平起见,我们就起名为LinkedList。我们直接上代码:
// 定义元素Node
class Node {
// 存放数值
int value;
// 存放下一个元素的值
Node next;
// 构造函数
Node(int value, Node next) {
this.value = value;
this.next = next;
}
Node(int value) {
this.value = value;
}
}
class LinkedList {
// 第一个元素
private Node first;
// 添加元素
public void put(int num) {
// 创建新节点,新节点是在后面的,所以next就是null
Node node = new Node(num, null);
// 如果链表为空,这就是第一个节点,否则就加到链表后边
if(first == null) {
first = node;
} else {
first.next = node;
}
}
// 访问第index个元素,效率好低啊
public int get(int index) {
// 只能硬着头皮从前往后数index个元素
int i = index;
Node curNode = first;
while(i > 0) {
// 这里可能有空指针,我们暂不考虑
curNode = curNode.next;
i--;
}
return curNode.value;
}
// 插入元素,效率好高啊
public void add(int num, int index) {
// 第一步,找到要插入的节点位置
Node node = first;
// 这里要先做减法,比如要插在3号位,就要找到2号位置即可。
while(--index > 0) node = node.next;
// 第二步,插入元素,现在假设我们找到了2号位置
Node newNode = new Node(num);
newNode.next = node.next;
node.next = newNode;
}
// 删除元素,效率好高啊
public void remove(int num, int index) {
// 第一步,找到要删除的点
Node node = first;
// 这里要先做减法,比如要插在3号位,就要找到2号位置即可。
while(--index > 0) node = node.next;
// 第二步,删除元素,直接让前面的next指向要删除的next就行
node.next = node.next.next;
}
}
猛一看可能有点懵,我们来具体分析,假如链表如下(不懵的可以跳过哈):
A -> B -> D -> E
我们要在 D 位置插入 C,也就是要变成:A -> B -> C -> D -> E。
那我们首先就找到 B,然后创建出 C 节点。然后就让: C.next = D;但是我们没法直接拿到 D,我们就通过 B 来拿到 D,也就是:C.next = B.next,此时,C 的 next 就是 D 了。这一步之后,我们的链表就变成了:
没错,那接下来,就需要让 B 指向 C 就行了,也就是: B.next = C。修改过后,我们的链表就变成了:
可以看到,C 已经正确地插入了链表中。
我们的执行步骤是:
C.next = B.next;
B.next = C;
其中,B 就是add()方法中的 node,C 就是add()方法中的 newNode。
有人说,我先让 B 指向 C,再让 C 指向 D 不行吗,也就是:
B.next = C; C.next = B.next;
不行,因为你一旦执行B.next = C;那么B.next就变了,后面的C.next = B.next就等于C.next = C了,明显不对。
我们可以概括一点:你先把执行的步骤写下来,然后看有哪些是最后用到的,然后就将最后用到的放在后面即可。比如上面的B.next是最后用到的,就把用到它的语句放在最后。
至于链表的删除,就简单了,直接让 B 指向 C 即可,因为 B 指向 C 的操作隐含了断开 B 和 D 的连接。
栈和队列
其实栈和队列的设计很相似,如果出入都是同一头,那么就是栈,比如吃了 yue 出来;如果是一头入一头出,那就是队列,比如吃了拉出来。
那这个实现起来就丰富了,我们可以用一个数组,每次添加元素都放在后面,每次删除元素也删除后面,这样就是栈。
我们每次push()(存放元素)的时候,就让curIndex+1,也就是往右移,每次pop()(删除元素)的时候,就让curIndex-1,也就是往左移,这样就等于封死了左边的口,也就达到了先入后出的效果。
int[] arr = new int[10]();
int curIndex = -1;
// 入栈,加在后面
fun push(int num) {
arr[++curIndex] = num;
}
// 出栈,返回后面元素
fun pop() {
return arr[curIndex--];
}
猛一看,没问题,仔细一看,有很多问题。比如:栈空了怎么办?栈满了怎么办?
这个其实很简单,我们使用下标即可判断,如果curIndex == arr.length,那么就表示栈满了;如果curIndex < 0,那就表示栈空了。如果栈满了,我们就需要扩容,扩容的方式跟ArrayList中的方式一模一样;如果栈空了,我们就要抛出异常或者返回空,以此来告诉调用者。
如果我们每次添加元素都放在后面,每次删除元素都删除前面,那么就是队列。
如果,我们每次让元素入队之后,都让pushIndex+1,也就是右移,这样就指向下一个要入队的位置;每次让元素出队之后,都让popIndex+1,这样就指向下一个要出队的元素。
int[] arr;
// 记录入队伍位置
int pushIndex;
// 记录出队位置
int popIndex;
// 入队
fun inQueue(int num) {
arr[pushIndex++] = num;
}
// 出队
fun outQueue(int num) {
return arr[popIndex++];
}
这个代码也有问题:这样出出入入来几次,马上就到头了,这样数组前面的元素空间不就浪费了吗?
环形数组!
假如,我们把数组头尾相连,也就是 0 和 10 连起来,这样形成一个环,是不是永远都不会到头啊。
但是这是不可能的啊,内存条,都说是条了,怎么会有环呢?嗯,我们物理上实现不了,我们就在逻辑上实现。
我们每次计算的时候,不用pushIndex和popIndex了,我们用它们的模,也就是使用pushIndex % arrayLength和popIndex % arrayLength,其中arrayLength是数组的长度。那就变成了:
// 假设数组长度是10
int length = 10;
int[] arr = new int[length]();
// 记录入队伍位置
int pushIndex;
// 记录出队位置
int popIndex;
// 入队
fun inQueue(int num) {
arr[pushIndex%length] = num;
pushIndex++;
}
// 出队
fun outQueue(int num) {
return arr[popIndex%length];
popIndex++;
}
我们假设数组长度为 10,来验证下。如上图,我们存放到下标 9 的时候,pushIndex++就变成了 10,对 10 求余就变成了 0,也就是回到了下标为 0 的地方开始存放数据,这是对的。同样,popIndex也是对的。
那么又有问题了,这样怎么判断队列已满,又怎么判断队列为空呢?
很简单,我们看图,当pushIndex不断地存放数据,直到下标 1,也就是popIndex的前面,那么队列就放满了,此时: pushIndex+1==popIndex。
同理,如果popIndex不断取数据,直到下标为 6,也就是指向pushIndex,那么此时队列就空了,此时:pushIndex==popIndex。
由于我们的数组是环形的,我们的数字需要对长度求余,也就是:
-
队列满:(pushIndex+1)%length == popIndex%10。
-
队列空:pushIndex%length == popIndex%10。
当然,你也可以定义一个 count 值,来记录元素的个数,每次入队就加 1,每次出队就减 1,当count==length就是满了,当count==0就是空了。
我们也可以使用链表来实现栈和队列,这里就不再废话,原理其实就是一个:栈就是对一端进行左右挪移,存放就往外挪,删除就往内挪;队列就是对两头进行挪移,出入都往存放那一头挪。
总结
我们讲了常见数据结构的实现原理,我们没有讲任何 API,就单纯从它们的特点来设计这些数据结构,我们不必拘泥于它们的源码是怎么写的,只要明白其中的道理,写出工程性源码不过是时间问题。
-
对于顺序表,因为是顺序的,我们就可以用数组来模拟实现,这样就可以通过下标快速定位元素。
-
对于链表,因为是分散的,我们就可以用一个一个的节点来实现,然后通过持有下一个节点的引用把它串联起来。
-
对于栈和队列,实现方案有多种,我们只要理解挪移的方向即可。单端存外删内,双端都向存。