几个数据结构的设计实现原理

顺序表

数组是具体的数据类型,顺序表只是个抽象的概念,那我们就用数组这个具体的类型来实现顺序表这个抽象概念。

我们知道,对于数组来说,访问、修改元素,都是通过下标实现的,比如:

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,就单纯从它们的特点来设计这些数据结构,我们不必拘泥于它们的源码是怎么写的,只要明白其中的道理,写出工程性源码不过是时间问题。

  • 对于顺序表,因为是顺序的,我们就可以用数组来模拟实现,这样就可以通过下标快速定位元素。

  • 对于链表,因为是分散的,我们就可以用一个一个的节点来实现,然后通过持有下一个节点的引用把它串联起来。

  • 对于栈和队列,实现方案有多种,我们只要理解挪移的方向即可。单端存外删内,双端都向存。

  • 28
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值