书忆江南的IT博客

自己平时工作学习的总结

常用查找算法与数据结构实现、适用场景及优缺点(Java)

1.二分查找算法

import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

import java.util.Arrays;
public class BinarySearch {
    public static int rank(int key,int[] a){
        //数组必须为有序的
        int lo=0;
        int hi=a.length-1;
        while(lo<=hi){
            //被查找的键要么不存在,要么必然存在于a[lo+hi]之中
            int mid=lo+(hi-lo)/2;
            if(key<a[mid]) hi=mid-1;
            else if(key>a[mid]) lo=mid+1;
            else return mid;
        }
        return -1;
    }
    public static void main(String[] args){
        int[] whitelist= In.readInts(args[0]);
        Arrays.sort(whitelist);
        while(!StdIn.isEmpty())
        { //读取键值,如果不存在于白名单中则将其打印
            int key=StdIn.readInt();
            if(rank(key,whitelist)<0)
                StdOut.println(key);
        }
    }
}

2.在符号表中,最好使用不可变的数据类型作为查找键,例如Integer,Double,String,File,URL等,否则表的一致性无法保证,优先队列与符号表的区别在于优先队列中可以存在重复的建但是符号表不行,而且有序符号表支持的操作更多。研究符号表处理大型文本的性能要考虑两个方面的因素:(1)每个单词都会被作为键进行搜索,因此处理性能和输入文本的单词总量相关;(2)输入流中不同的单词的总数也有关。

3.顺序查找(基于无序链表):

import edu.princeton.cs.algs4.Queue;

public class SequentialSearchST<Key,Value> {
    //顺序查找(基于无序链表
    private Node first;  //链表首节点
    private int N=0;
    private class Node{
        //链表节点的定义
        Key key;
        Value val;
        Node next;
        public Node(Key key,Value val,Node next){
            this.key=key;
            this.val=val;
            this.next=next;
        }
    }
    public Value get(Key key){
        //查找给定的键,返回相关联的值
        for(Node x=first;x!=null;x=x.next)
            if(key.equals(x.key))
                return x.val;  //命中
        return null;  //未命中
    }
    public void put(Key key,Value val){
        //查找给定的键,找到则更新其值,否则在表中新建节点
        for(Node x=first;x!=null;x=x.next){
            if(key.equals(x.key)){
                x.val=val;  //命中,更新
                return;
            }
        }
        first=new Node(key,val,first);  //未命中,在链表开头新建节点
        N++;
    }
    //Exercise 3.1.5
    public int size(){
        return N;
    }
    public void delete(Key key){
        first=delete(first,key);
    }
    private Node delete(Node x,Key key){
        if(x==null){
            return null;
        }
        if(x.key.equals(key)){  //如果链表第一项就是要删除的key的项,则删除第一项并将原来的第二个链表项变成现在链表的第一项
            N--;
            return x.next;
        }
        x.next=delete(x.next,key);  //链表第一项不是要删除的项,则从第二项开始递归查找并删除对应的key
        return x;
    }
    public Iterable<Key> keys(){
        Queue<Key> queue=new Queue<>();
        for(Node x=first;x!=null;x=x.next){
            queue.enqueue(x.key);
        }
        return queue;
    }
}

在含有N对键值的基于无序链表的符号表中,未命中的查找和插入操作都需要N次比较。命中的查找在最坏情况下需要N次比较,向一个空表中插入N个不同的键需要约N2/2次比较。但是,基于链表的实现以及顺序查找比较低效,比较的总次数和查找次数与插入次数的乘积成正比无序数组适合没有时间限制但只有极少内存的场合

4.基于有序符号表(有序数组)的二分查找

import edu.princeton.cs.algs4.Queue;

public class BinarySearchST<Key extends Comparable<Key>,Value> {
    private Key[] keys;
    private Value[] vals;
    private int N;
    public BinarySearchST(int capacity){
        keys=(Key[])new Comparable[capacity];
        vals=(Value[])new Object[capacity];
    }
    public int size(){return N;}
    public Value get(Key key){
        if(isEmpty()) return null;
        int i=rank(key);
        if(i<N && keys[i].compareTo(key)==0) return vals[i];
        else return null;
    }
    public boolean isEmpty(){return N==0;}
    public int rank(Key key){
        int lo=0,hi=N-1;
        while(lo<=hi){
            int mid=lo+(hi-lo)/2;
            int cmp=key.compareTo(keys[mid]);
            if(cmp<0) hi=mid-1;
            else if(cmp>0) lo=mid+1;
        }
        return lo;
    }
    public void put(Key key,Value val){
        //查找键,找到则更新值,否则创建新的元素
        int i=rank(key);
        if(i<N && keys[i].compareTo(key)==0){
            vals[i]=val;
            return;
        }
        for(int j=N;j>i;j--){  //将i位置后面的键和值都往后移一位,给i空出位置插入
            keys[j]=keys[j-1];
            vals[j]=vals[j-1];
        }
        keys[i]=key;
        vals[i]=val;
        N++;
    }
    public void delete(Key key){
        //exercise 3.1.16
        if(isEmpty()) return;
        int i=rank(key);
        if(i<N && keys[i].compareTo(key)==0){
            for(int j=i;j<N-1;j++){  //将要删除的位置i处后面的键和值都向前移一位,这样最后一位会被空出来,再通过置空值的方式删除最后一位
                keys[j]=keys[j+1];
                vals[j]=vals[j+1];
            }
            N--;
            keys[N]=null;
            vals[N]=null;
        }
    }
    public Key min(){return keys[0];}
    public Key max(){return keys[N-1];}
    public Key select(int k){return keys[k];}
    public Key ceiling(Key key){ //找出大于等于该键的最小键
        int i=rank(key);
        return keys[i];
    }
    public Key floor(Key key){  //找出小于等于该键的最大值
        //exercise 3.1.17
        int i=rank(key);
        if(i<N){
            if(keys[i].compareTo(key)==0){
                return key;
            }else if(i>0){
                return keys[i-1];
            }
        }
        return null;
    }
    public boolean contains(Key key){
        return get(key)!=null;
    }
    public Iterable<Key> keys(Key lo,Key hi){
        Queue<Key> q=new Queue<Key>();
        for(int i=rank(lo);i<rank(hi);i++)
            q.enqueue(keys[i]);
        if(contains(hi))
            q.enqueue(keys[rank(hi)]);
        return q;
    }
}

在N个键的有序数组中进行二分查找最多需要(lgN+1)次比较(无论是否成功)。二分查找所需时间必然在对数范围之内。但是,二分查找不适用于查找和插入操作混合进行的庞大符号表场合

5.六种符号表实现的优缺点:


6.一棵二叉查找树(BST)的每个节点都含有一个Comparable的键以及相关联的值,且每个节点的键都大于其左子树中的任意节点的键而小于右子树的任意节点的键。左链接指向一棵由小于该节点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键组成的二叉查找树。变量N为以该当前节点为根的子树的节点总数。

基于二叉查找树的符号表实现如下所示:

import edu.princeton.cs.algs4.Queue;

public class BST<Key extends Comparable<Key>,Value> {  //基于二叉查找树的符号表
    private Node root;  //二叉查找树的根节点
    private class Node{
        private Key key;  //键
        private Value val;  //值
        private Node left,right;  //指向左右子树的链接
        private int N;  //以该节点为根的子树中的节点总数
        public Node(Key key,Value val,int N){
            this.key=key;
            this.val=val;
            this.N=N;
        }
    }
    public int size(){return size(root);}
    private int size(Node x){
        if(x==null) return 0;
        else return x.N;
    }
    public Value get(Key key){
        return get(root,key);
    }
    private Value get(Node x,Key key){
        //在以x为根节点的子树中查找并返回key所对应的值,如果找不到则返回null
        if(x==null) return null;
        int cmp=key.compareTo(x.key);
        if(cmp<0) return get(x.left,key);  //如果键比根节点小,则递归查找左子树
        else if(cmp>0) return get(x.right,key);  //如果键比根节点大,则递归查找右子树
        else return x.val;
    }
    public void put(Key key,Value val){
        //查找key,找到则更新它的值,否则为它创建一个新的节点
        root=put(root,key,val);
    }
    private Node put(Node x,Key key,Value val){
        //如果key存在于以x为根节点的子树中则更新它的值,否则将以key和val为键值对的新节点插入到该子树中
        if(x==null) return new Node(key,val,1);
        int cmp=key.compareTo(x.key);
        if(cmp<0) x.left=put(x.left,key,val);  //如果键比根节点小,则递归查找左子树并插入新节点的键值
        else if(cmp>0) x.right=put(x.right,key,val);  //如果键比根节点大,则递归查找右子树并插入新节点的键值
        else x.val=val;  //如果查找插入的键就是根节点,则修改根节点的值
        x.N=size(x.left)+size(x.right)+1;
        return x;  //返回根节点
    }
    public Key min(){return min(root).key;}
    private Node min(Node x){
        if(x.left==null) return x;  //如果根节点左链接为空,则最小键就是根节点
        return min(x.left);  //如果左链接非空,则树中的最小键就是左子树中的最小键,不断递归到左子树的底部
    }
    public Key max(){return max(root).key;}
    private Node max(Node x){
        if(x.right==null) return x;  //如果根节点右链接为空,则最大键就是根节点
        return max(x.right);  //如果右链接非空,则树中的最大键就是右子树中的最大键,不断递归到右子树的底部
    }
    public Key floor(Key key){  //查找小于等于key的最大键,向下取整
        Node x=floor(root,key);
        if(x==null) return null;
        return x.key;
    }
    private Node floor(Node x,Key key){
        if(x==null) return null;
        int cmp=key.compareTo(x.key);
        if(cmp==0) return x;  //如果查找的向下取整键与根的键相同,返回根节点
        if(cmp<0) return floor(x.left,key);  //如果查找的向下取整键比根的键小,则递归查找左子树
        Node t=floor(x.right,key);  //如果查找的向下取整键大于根的键,则查找右子树,再在右子树中递归查找左子树,并找到向下取整键的节点
        if(t!=null) return t;
        else return x;  //如果最终还是没找到向下取整键,则返回开始进入右子树时该右子树的父节点
    }
    public Key ceiling(Key key){  //查找大于等于key的最小键,向上取整
        Node x=ceiling(root,key);
        if(x==null) return null;
        return x.key;
    }
    private Node ceiling(Node x,Key key){
        if(x==null) return null;
        int cmp=key.compareTo(x.key);
        if(cmp==0) return x;  //如果查找的向上取整键与根的键相同,返回根节点
        if(cmp>0) return ceiling(x.right,key);   //如果查找的向上取整键比根的键大,则递归查找右子树
        Node t=ceiling(x.left,key);  //如果查找的向上取整键小于根的键,则查找左子树,再在左子树中递归查找右子树,并找到向上取整键的节点
        if(t!=null) return t;
        else return x;  //如果最终还是没找到向上取整键,则返回最初开始进入左子树时该左子树的父节点
    }
    public Key select(int k){return select(root,k).key;} //找到排名为k的键,即树中正好有k个小于它的键
    private Node select(Node x,int k){
        //返回排名为k的节点
        if(x==null) return null;
        int t=size(x.left);
        if(t>k) return select(x.left,k);  //如果左子树中的节点数t大于k,则左子树节点数量充足可以继续在左子树中查找排名为k的键
        else if(t<k) return select(x.right,k-t-1); //如果左子树中的节点数t小于k,则不需要查找左子树,在右子树中查找排名为k-t-1的键
        else return x;  //如果t等于k,就返回根节点
    }
    public int rank(Key key){return rank(key,root);}  //返回键的排名
    private int rank(Key key,Node x){
        //返回以x为根节点的子树中小于x.key的键的数量
        if(x==null) return 0;
        int cmp=key.compareTo(x.key);
        if(cmp<0) return rank(key,x.left);  //如果给定的键小于根节点,返回该键在左子树中的排名(递归)
        else if(cmp>0) return 1+size(x.left)+rank(key,x.right);  //如果给定的键大于根节点,返回t+1(包括根节点)加上它在右子树的排名(递归)
        else return size(x.left);  //如果给定的键和根节点的键相等,返回左子树中的节点总数t
    }
    public void deleteMin(){root=deleteMin(root);}
    private Node deleteMin(Node x){
        if(x.left==null) return x.right;  //如果当前节点已经没有左子树,说明自己已经是最小节点,则返回右子树
        x.left=deleteMin(x.left);  //如果存在左子树,则一直递归直到找到没有左子树的最小键的节点,这行当前节点的左子树已经指向递归后上一行返回的右链接,也就是说,将最小键节点的父节点的左子树链接指向该节点的右子树,这样父节点不再指向该节点,最小键节点会被垃圾回收
        x.N=size(x.left)+size(x.right)+1;  //更新节点计数器
        return x;  //在递归中不断的return当前递归中的节点,一层层往上最终在递归语句那一行return根节点的下一层左子树根节点,这样递归结束后最后在这一行返回根节点
    }
    public void deleteMax(){root=deleteMax(root);}
    private Node deleteMax(Node x){
        if(x.right==null) return x.left;   //如果当前节点已经没有右子树,说明自己已经是最大节点,则返回左子树
        x.right=deleteMax(x.right);   //如果存在右子树,则一直递归直到找到没有右子树的最大键的节点,这行当前节点的右子树已经指向递归后上一行返回的左链接,也就是说,将最大键节点的父节点的右子树链接指向该节点的左子树,这样父节点不再指向该节点,最大键节点会被垃圾回收
        x.N=size(x.left)+size(x.right)+1;   //更新节点计数器
        return x;   //在递归中不断的return当前递归中的节点,一层层往上最终在递归语句那一行return根节点的下一层右子树根节点,这样递归结束后最后在这一行返回根节点
    }
    public void delete(Key key){root=delete(root,key);}  //删除对应键的节点
    private Node delete(Node x,Key key){
        if(x==null) return null;
        int cmp=key.compareTo(x.key);
        if(cmp<0) x.left=delete(x.left,key);  //如果要删除的键小于当前节点的键,则递归查找左子树并删除节点
        else if(cmp>0) x.right=delete(x.right,key);  //如果要删除的键大于当前节点的键,则递归查找右子树并删除节点
        else{
            //如果当前节点的键等于要删除的键
            if(x.right==null) return x.left;
            if(x.left==null) return x.right;
            Node t=x;  //将指向即将被删除的节点的链接保存为t
            x=min(t.right);  //将x指向将删除节点的后继节点,该节点是将删除节点的右子树中的最小节点,这样后继节点补上删除节点的位置后依然能保持二叉树有序
            x.right=deleteMin(t.right);  //用deleteMin将后继节点的父节点的左链接(原本指向后继节点)链接到后继节点的右链接(也就是说,父节点解除对后继节点的链接),右链接即后继节点的右子树,最后通过这一行的赋值再次将后继节点的右子树链接到原本它的父节点上去
            x.left=t.left;  //将后继节点的左子树链接到原来被删除节点的左子树上去
        }
        x.N=size(x.left)+size(x.right)+1;  //更新节点数
        return x; //返回根节点
    }
    public Iterable<Key> keys(){return keys(min(),max());}  //二叉查找树的范围查找
    public Iterable<Key> keys(Key lo,Key hi){
        Queue<Key> queue=new Queue<Key>();  //将所有在给定范围内的键加入到一个队列中
        keys(root,queue,lo,hi);
        return queue;
    }
    private void keys(Node x,Queue<Key> queue,Key lo,Key hi){
        if(x==null) return;
        int cmplo=lo.compareTo(x.key);
        int cmphi=hi.compareTo(x.key);
        if(cmplo<0) keys(x.left,queue,lo,hi);  //如果范围下限的键小于根节点的键,则递归查找左子树
        if(cmplo<=0 && cmphi>=0) queue.enqueue(x.key);  //如果当前子树根节点的键处在查找范围内,则在队列中加入根节点的键
        if(cmphi>0) keys(x.right,queue,lo,hi);  //如果范围上限的键大于根节点的键,则递归查找右子树
    }
}

查找、插入、向下取整、选择、删除最小键节点与删除特定键节点、范围查找的过程图如下所示:






7.在由N个随机键构造的二叉查找树中,查找命中平均所需的比较次数为约2lnN次(约1.39lgN)。二叉查找树中查找随机键的成本比二分查找高约39%。但是,在由N个随机键构成的二叉查找树中插入操作和查找未命中平均所需比较次数约为2lnN(约1.39lgN),说明这些额外的查找成本是值得的,因为二叉查找树插入一个新键的成本是对数级别的,而基于二分查找的有序数组的插入操作所需访问数组的次数是线性级别的。随机构造的树中所有的路径长度都小于3lgN。二叉查找树的良好性能依赖于其中的键的树状分布足够随机以消除长路径。几种符号表的实现的成本总结如下所示:

8.2-3查找树由以下节点组成:

(1)2-节点,含有一个键(及其值)和两条链接,左链接指向的子树中的键都小于该节点,右链接指向的子树中的键都大于该节点。

(2)3-节点,含有两个键(及其值)和三条链接,左链接指向的子树中的键都小于该节点,右链接指向的子树中的键都大于该节点。

一棵完美平衡的2-3查找树中的所有空链接到根节点的距离都应该是相同的。2-3查找树示意图如下所示:

向完美平衡的2-3树中插入新键的不同情况流程图如下所示:


2-3树插入算法的根本在于这些变换都是局部的,除了相关的节点和链接之外不必修改或者检查树的其他部分。在树的局部变换中,将一个4-节点分解为一棵2-3树可能有6种情况,如下所示:

如果在变换之前根节点到所有空链接的路径长度为h,那么变换之后该长度依然为h,只有当根节点由一个临时的4-节点被分解为3个2-节点时,所有空链接到根节点的路径长度才会加一。理解所有局部变换都不会影响整棵树的有序性和平衡性是这个算法的关键,如下所示:

在符号表的实现中,一般无法控制用例会按照什么顺序向表中插入键,因此对最坏情况的分析是唯一能够提供性能保证的方法。在一棵大小为N的2-3树中,查找和插入操作访问的节点必然不超过lgN个。

9.上述2-3查找树在最坏情况下仍有较好的性能,例如10亿个节点的一棵2-3树的高度仅在19到30之间,最多只需要访问30个节点就能够在10亿个键中进行任意查找和插入操作。但是直接写出2-3树的代码实现比较困难,且直接维护2-3树的节点信息开销较大,可能会比标准的二叉查找树更慢,因此诞生了红黑二叉查找树。红黑二叉查找树背后的基本思想是用标准的二叉查找树(完全由2-节点构成)和一些额外的信息(替换3-节点)来表示2-3树。红黑树将树中的链接分为两种类型:红链接将两个2-节点连接起来构成一个3-节点,黑链接则为2-3树中的普通链接,即将3-节点表示为由一条左斜的加粗红色链接(两个2-节点之一是另一个的左子节点)。这样表示的优点是无需修改就可以直接使用标准二叉查找树的get()方法。红黑树的表示图如下所示:

由红链接相连的节点合并,得到的就是一棵2-3树,而如果将一棵2-3树中的3-节点画为红色左链接相连的两个2-节点,则树是完美黑色平衡的,即任意底部空链接到根节点路径上的黑链接数量相同,因为黑链接即为2-3树中的普通链接。红黑树既是二叉查找树,也是2-3树,这样的关系图如下所示:

10.在红黑树中,每个节点都只会有一条从自己的父节点指向自己的链接,红黑树的具体实现如下所示:

红黑树中,每个节点都只会有一条从自己的父节点指向自己的链接,红黑树的具体实现如下所示:
public class RedBlackBST<Key extends Comparable<Key>,Value> {
    private Node root;
    private static final boolean RED=true;
    private static final boolean BLACK=false;
    private class Node{
        //含有color变量的Node对象
        Key key;  //键
        Value val;  //相关联的值
        Node left,right;  //左右子树
        int N;  //这棵子树中的节点总数
        boolean color;  //由父节点指向它的链接的颜色

        Node(Key key,Value val,int N,boolean color){
            this.key=key;
            this.val=val;
            this.N=N;
            this.color=color;
        }
    }
    private boolean isRed(Node x){  //判断父节点指向自己的链接是否是红链接
        if(x==null) return false;
        return x.color==RED;
    }
    private int size(Node x){
        if(x==null) return 0;
        else return x.N;
    }
    private Node rotateLeft(Node h){  //将指向右链接的红链接旋转为指向左链接
        Node x=h.right;
        h.right=x.left;  //将节点h的右链接指向原来节点x的左子树,即介于h与x的键之间的键子树
        x.left=h;  //开始将节点h变为节点x的左子树
        x.color=h.color;  //指向h的链接的颜色赋予指向x的链接,迁移过去h作为子树根节点时的颜色属性,让节点x接过节点h的属性
        h.color=RED;  //节点h已经是节点x的左子树根节点,指向h的链接颜色变为红色,这样红色左链接完成
        x.N=h.N;  //x成为新的子树根节点,所以子树链接数不变
        h.N=1+size(h.left)+size(h.right);  //节点h已经成为新根节点x的左子树根节点,因此要更新一下链接数
        return x;  //返回新的子树根节点
    }
    private Node rotateRight(Node h){  //将指向左链接的红链接旋转为指向右链接
        Node x=h.left;
        h.left=x.right;  //将节点h的左链接指向原来节点x的右子树,即介于h与x的键之间的键子树
        x.right=h;  //开始将节点h变为节点x的右子树
        x.color=h.color;  //指向h的链接的颜色赋予指向x的链接,迁移过去h作为子树根节点时的颜色属性,让节点x接过节点h的属性
        h.color=RED;   //节点h已经是节点x的右子树根节点,指向h的链接颜色变为红色,这样红色右链接完成
        x.N=h.N;  //x成为新的子树根节点,所以子树链接数不变
        h.N=1+size(h.left)+size(h.right);  //节点h已经成为新根节点x的右子树根节点,因此要更新一下链接数
        return x;  //返回新的子树根节点
    }
    private void flipColors(Node h){  //将左右子节点的链接颜色由红变黑之外,父节点的链接颜色由黑变红
        h.color=RED;
        h.left.color=BLACK;
        h.right.color=BLACK;
    }
    public Value get(Key key){
        return get(root,key);
    }
    private Value get(Node x,Key key){
        if(x==null){
            return null;
        }
        int cmp=key.compareTo(x.key);
        if(cmp<0){
            return get(x.left,key);
        }else if(cmp>0){
            return get(x.right,key);
        }else{
            return x.val;
        }
    }
    public Key min(){
        return min(root).key;
    }
    private Node min(Node x){
        if(x.left==null){
            return x;
        }
        return min(x.left);
    }
    public void put(Key key,Value val){
        //查找key,找到则更新其值,否则为它新建一个节点
        root=put(root,key,val);
        root.color=BLACK;
    }
    private Node put(Node h,Key key,Value val){
        if(h==null)  //标准的插入操作,和父节点用红链接相连,若不存在根节点,则插入根节点
            return new Node(key,val,1,RED);
        int cmp=key.compareTo(h.key);
        if(cmp<0) h.left=put(h.left,key,val);  //如果要插入的键比根节点的键小,则递归查找左子树
        else if(cmp>0) h.right=put(h.right,key,val);  //如果要插入的键比根节点的键大,则递归查找右子树
        else h.val=val;  //如果找到了对应的键,则更新值
        if(isRed(h.right) && !isRed(h.left)) h=rotateLeft(h);  //如果当前节点指向右子树的链接为红色,并且指向左子树的链接为黑色,对自身进行左旋转
        if(isRed(h.left) && isRed(h.left.left)) h=rotateRight(h);  //如果当前节点指向左子树的链接为红色,并且左子树根节点指向其左子节点的链接也为红色,对自身进行右旋转
        if(isRed(h.left) && isRed(h.right)) flipColors(h);  //如果当前节点指向左子树和右子树的链接都是红色,则进行颜色转换
        h.N=size(h.left)+size(h.right)+1;  //从底部向上递归更新黑链接的数目
        return h;  //每次递归从底部一层层向上返回当前递归循环的子树根节点,最终返回根节点
    }
    //Exercise 3.3.39
    private Node moveRedLeft(Node h){  //删除最小键之前的变换
        flipColors(h);
        if(isRed(h.right.left)){
            h.right=rotateRight(h.right);
            h=rotateLeft(h);  //在删除最小节点的情况下,节点h虽然是根节点,但是从右子树分支来看h是最小键节点,所以将原子树根节点h移动到左下角左子树位置,且形成了便于删除节点的3-节点或4-节点
        }
        return h;
    }
    public void deleteMin(){  //删除最小键
        if(!isRed(root.left) && !isRed(root.right)){
            root.color=RED;  //将指向根节点的链接颜色变为红色
        }
        root=deleteMin(root);
        if(root!=null){
            root.color=BLACK;  //删除最小键完成后再把指向根节点的颜色重置为黑色
        }
    }
    private Node deleteMin(Node h){
        if(h.left==null){
            return null;
        }
        if(!isRed(h.left) && !isRed(h.left.left)){  //如果最小键位置连续两个左链接都是黑色,说明只有2-节点不利于删除后树平衡,需要转换出一个左下角的3-节点便于删除
            h=moveRedLeft(h);
        }
        h.left=deleteMin(h.left);  //递归查找左子树直到找到最小键节点然后删除
        return balance(h);  //删除最小键节点后将红黑树进行结构平衡
    }
    private Node balance(Node h){
        if(isRed(h.right)){
            h=rotateLeft(h);  //发现有向右的红链接,则进行左旋转
        }
        if(isRed(h.left) && isRed(h.left.left)){  //如果连续两条指向左子树的链接都是红色,则右旋转当前节点指向左子树的链接
            h=rotateRight(h);
        }
        if(isRed(h.left) && isRed(h.right)){  //如果左右两条左子树的链接为红色,则改变颜色
            flipColors(h);
        }
        h.N=size(h.left)+size(h.right)+1;  //因为有deleteMin的递归,所以从底层一层层向上更新链接数
        return h;
    }
    //Exercise 3.3.40
    private Node moveRedRight(Node h){
        flipColors(h);
        if(isRed(h.left.left)){
            h=rotateRight(h);  //在删除最大节点的情况下,节点h虽然是根节点,但是从左子树分支来看h是最大键节点,所以将原子树根节点h移动到右下角右子树位置,且形成了便于删除节点的3-节点或4-节点
        }
        return h;
    }
    public void deleteMax(){
        if(!isRed(root.left) && !isRed(root.right)){
            root.color=RED;
        }
        root=deleteMax(root);
        if(root!=null){
            root.color=BLACK;
        }
    }
    private Node deleteMax(Node h){
        if(isRed(h.left)){
            h=rotateRight(h);
        }
        if(h.right==null){
            return null;
        }
        if(!isRed(h.right) && !isRed(h.right.left)){  //如果子树根节点的右子树链接和右子树的左子树链接都是黑色,说明右子树分支没有3-节点,需要从左子树兄弟分支借一个节点过来组成3-节点便于删除
            h=moveRedRight(h);
        }
        h.right=deleteMax(h.right);  //从右子树递归查找并删除最大键
        return balance(h);  //删除最大键节点后将红黑树进行结构平衡
    }
    //Exercise 3.3.41
    public void delete(Key key){
        if(!isRed(root.left) && !isRed(root.right)){
            root.color=RED;
        }
        root=delete(root,key);
        if(root!=null){
            root.color=BLACK;
        }
    }
    private Node delete(Node h,Key key){
        if(key.compareTo(h.key)<0){  //当要删除的键小于根节点的键
            if(!isRed(h.left) && !isRed(h.left.left)){  //如果左子树中不存在3-节点,则为了便于删除节点之后的树平衡,制造出一个3-节点
                h=moveRedLeft(h);
            }
            h.left=delete(h.left,key);  //递归想底部查找左子树,直到删除对应的键的节点
        }else{  //要删除的键大于等于根节点的键
            if(isRed(h.left)){
                h=rotateRight(h);
            }
            if(key.compareTo(h.key)==0 && h.right==null){  //如果要删除的是根节点,而根节点没有右子树,,返回空
                return null;
            }
            if(!isRed(h.right) && !isRed(h.right.left)){
                h=moveRedRight(h);  //如果子树根节点的右子树链接和右子树的左子树链接都是黑色,说明右子树分支没有3-节点,需要从左子树兄弟分支借一个节点过来组成3-节点便于删除
            }
            if(key.compareTo(h.key)==0){  //如果要删除的键就是子树根节点,则将后继节点设置为右子树的最小键节点,并解除对该根节点的应用
                h.val=get(h.right,min(h.right).key);
                h.key=min(h.right).key;
                h.right=deleteMin(h.right);
            }else{
                h.right=delete(h.right,key);
            }
        }
        return balance(h);  //删除节点后将红黑树进行结构平衡
    }
}

有关颜色表示、旋转红链接、各种情况下插入新键的示意图如下所示:





由上面几种情况下插入节点的规律可知,只要适当运用左旋转,右旋转以及颜色转换这三种操作,就可以保证插入操作后红黑树与2-3树的一一对应关系。在沿着插入点到根节点的路径向上移动时在经过的每个节点中顺序完成以下操作,就可以完成插入操作:

(1)如果指向右子节点的链接是红色的而指向左子节点的链接是黑色的,进行左旋转;

(2)如果指向左子节点的链接是红色的且它的左子节点上面的链接也是红色的,进行右旋转;

(3)如果指向左右子节点的链接都是红色的,则进行颜色转换。三种操作如下所示:

红黑树插入算法的例子轨迹如下所示:

对于删除红黑树中最小键的节点,主要思想是防止树中最底层的最小键节点是2-节点,否则删除2-节点会破坏树的完美平衡性,可以先将最底部的最小键节点所在位置变成3-节点,比如从右侧同层的兄弟节点“借”一个节点到自己这边,在不同情况下删除最小键操作中的变换图如下所示:

11.所有基于红黑树的符号表实现都能保证操作的运行时间为对数级别(范围查找除外,所需的额外时间和返回的键的数量成正比)。一棵大小为N的红黑树的高度不会超过2lgN。在查找时,根节点到任意节点的平均路径长度约为1.00lgN。因此,红黑树适合极大量数据而且查找频繁的场合。各种符号表实现的性能总结如下所示:

12.散列表(Hash Table)适合内存较小且对查找速度要求较高,而且不在意元素有序性(最大最小排序等)的场合。它的查找算法分为两步:(1)用散列函数将被查找的键转化为数组的一个索引。(2)处理多个键被散列到相同索引值的碰撞冲突情况,如拉链法和线性探测法。散列表平衡了将每个键都作为一个索引会占用内存过大以及无序数组速度较慢的两个缺点。将一些键散列成为一定范围内的哈希值,即第一步的实现,有以下几种情况:

(1)键为正整数,使用除留余数法,即选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数,这样就可以有效地将键散布在0到M-1的范围内,如下所示:

(2)键为浮点数,则将键表示为二进制数然后使用(1)中的除留余数法。

(3)键为字符串,则可使用如下方式计算每个字符的哈希值:

当R取一个较小的素数,例如31时,可以保证字符串中的所有字符都能发挥作用。

(4)组合键,该类型含有多个整型变量,例如键为Date类型,可以如下计算哈希散列值:

int hash=(((day*R+month)%M)*R+year)%M;

13.在形成散列表的第二步碰撞处理,也就是处理两个或多个键的散列值相同的情况中,一种办法是将大小为M的数组(哈希值数组)的每个元素都指向一条链表,该链表中每个节点存储键值对,这些键值对的哈希值全都相同,为对应哈希值数组的当前元素索引值。这样在查找的时候,首先根据哈希值找到对应的链表,然后沿着链表顺序查找相应的键,如下所示:

基于拉链法的散列表实现如下所示:

import edu.princeton.cs.algs4.Queue;
import edu.princeton.cs.algs4.SequentialSearchST;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

public class SeparateChainingHashST<Key,Value> {
    private int N;  //键值对总数
    private int M;  //散列表大小
    private SequentialSearchST<Key,Value>[] st;  //存放链表对象的数组

    public SeparateChainingHashST(){this(997);}  //默认有997条链表
    public SeparateChainingHashST(int M){
        //创造M条链表
        this.M=M;
        st=(SequentialSearchST<Key,Value>[])new SequentialSearchST[M];
        for(int i=0;i<M;i++)
            st[i]=new SequentialSearchST();
    }
    private int hash(Key key){return (key.hashCode() & 0x7fffffff)%M;}
    public Value get(Key key){return (Value)st[hash(key)].get(key);}  //根据对应键的哈希值,在哈希值数组中找到该哈希值指向的链表,然后在所在链表中查找键的值
    public void put(Key key,Value val){  //根据对应键的哈希值,在哈希值数组中找到该哈希值指向的链表,然后在所在链表中插入键值对
        if(val==null){
            delete(key);
            return;
        }
        if(N>=8*M) resize(2*M);  //当每条链表平均长度大于等于8时扩容
        if(!st[hash(key)].contains(key)) N++;
        st[hash(key)].put(key,val);
    }
    //Exercise 3.1.5,删除操作,先用哈希值找到含有该键的SequentialSearchST对象,然后调用该对象的delete()方法
    public void delete(Key key){
        int i=hash(key);
        if(st[i].contains(key)) N--;
        st[i].delete(key);
        if(N>0 && N<=2*M) resize(M/2); //当每条链表平均长度小于等于2时,缩减容量
    }
    //Exercise 3.4.19,在迭代器中将键以符号表的形式遍历
    public Iterable<Key> keys(){
        Queue<Key> queue=new Queue<Key>();
        for(int i=0;i<M;i++){
            for(Key key:st[i].keys())
                queue.enqueue(key);
        }
        return queue;
    }
    private void resize(int cap){  //保持每条键值对链表平均长度在2到8之间,这个范围在put和delete中完成
        SeparateChainingHashST<Key,Value> temp=new SeparateChainingHashST<Key,Value>(cap);  //创建一个新的调整过容量的拉链法散列表
        for(int i=0;i<M;i++){
            for(Key key:st[i].keys()){
                temp.put(key,st[i].get(key));  //将原有键值对放到新的拉链法散列表中
            }
        }
        this.M=temp.M;  //将原有散列表键值对的属性迁移到新散列表中
        this.N=temp.N;
        this.st=temp.st;
    }

    public static void main(String[] args){
        SeparateChainingHashST<String,Integer> st=new SeparateChainingHashST<String,Integer>();
        for(int i=0;!StdIn.isEmpty();i++){
            String key=StdIn.readString();
            st.put(key,i);
        }
        for(String s:st.keys())
            StdOut.println(s+" "+st.get(s));
    }
}

在一张含有M条链表和N个键的散列表中,未命中查找和插入操作所需的比较次数约为N/M。在基于拉链法的散列表中使用大小为M的数组能够将查找和插入操作的效率相比基于无序链表的顺序查找(SequentialSearchST)提高M倍。

14.在实现基于拉链法的散列表时,目标是选择适当的数组大小M,既不会因为空链表而浪费大量内存,也不会因为链表太长而在查找上浪费大量时间。但是M的大小对于拉链法来说并非最重要,如果存入的键多于预期,查找所需的时间只会比选择更大的数组稍长,如果少于预期,虽然有些空间浪费但查找会非常快。但内存不是很紧张时,可以选择一个足够大的M,使得查找需要的时间变为常数;当内存紧张时,选择尽量大的M依然可以将性能提高M倍。

15.散列(哈希)最主要的目的在于均匀地将键散布开来,因此在计算哈希值后键的顺序信息就丢失了,在顺序重要的场合,例如查找最大最小值,查找某个范围内的键等,散列表都不是合适的选择,因为这些操作的运行时间会变成线性的。但是在键的顺序不重要的应用中,基于拉链法的散列表可能是最快的符号表实现

16.实现散列表的另一种方式是用大小为M的数组保存N个键值对,其中M>N。需要依靠数组中的空位来解决碰撞冲突,基于这种策略的所有方法都被称为开放地址散列表。在开放地址散列表中解决碰撞冲突的方法称为线性探测法:当要把某个键值插入到某个哈希值的位置中,但是另一个不同的键已经占据这个哈希值位置,可以直接检查散列表中的下一个位置,将索引值加一。这样检查下一个位置时会产生三种结果:

(1)查找命中,该位置的键与被查找的键相同。

(2)未命中,键为空,即该位置没有键。

(3)继续查找,该位置的键和被查找的键不同。

用散列函数找到键在数组中的哈希索引,检查其中的键和被查找的键是否相同。如果不同则继续查找,将索引增大,到达数组结尾时折回数组的开头,直到找到该键或者遇到一个空元素,如下所示:

开放地址类的散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表中的空元素,这些空元素可以作为查找结束的标志。基于线性探测的符号表实现如下所示:

import edu.princeton.cs.algs4.Queue;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

import javax.sound.sampled.Line;

public class LinearProbingHashST<Key,Value> {  //基于线性探测的符号表
    private int N;  //符号表中键值对的总数
    private int M=16;  //线性探测表的大小
    private Key[] keys;  //键
    private Value[] vals;  //值
    public LinearProbingHashST(int cap){
        M=cap;
        keys=(Key[])new Object[M];
        vals=(Value[])new Object[M];
    }
    private int hash(Key key){return (key.hashCode() & 0x7fffffff)%M;}
    public boolean contains(Key key){
        return get(key)!=null;
    }
    private void resize(int cap){  //始终保持散列表占用率α=N/M不大于1/2,在put和delete中完成
        LinearProbingHashST<Key,Value> t;
        t=new LinearProbingHashST<Key,Value>(cap);  //创造一个新的线性探测符号表来容纳键值对
        for(int i=0;i<M;i++){
            if(keys[i]!=null)
                t.put(keys[i],vals[i]);  //将原有键值对放到新的线性探测散列表中
        }
        keys=t.keys;  //将原有散列表键值对的属性迁移到新散列表中
        vals=t.vals;
        M=t.M;
    }
    public void put(Key key,Value val){
        if(N>=M/2) resize(2*M);  //当占用空间超过一半时,将容量M加倍
        int i;
        for(i=hash(key);keys[i]!=null;i=(i+1)%M){  //在键不为空时,如果一位一位向后查找,找到了对应的键,则修改键的值
            if(keys[i].equals(key)){
                vals[i]=val;
                return;
            }
        }
        keys[i]=key;  //如果没找到对应键就插入新键值对
        vals[i]=val;
        N++;  //插入新键值对后更新数目
    }
    public Value get(Key key){  //查找键的值
        for(int i=hash(key);keys[i]!=null;i=(i+1)%M)
            if(keys[i].equals(key))
                return vals[i];
        return null;
    }
    //Exercise 3.4.19,在迭代器中将键以符号表的形式遍历
    public Iterable<Key> keys(){
        Queue<Key> queue=new Queue<Key>();
        for(int i=0;i<M;i++)
            if(keys[i]!=null) queue.enqueue(keys[i]);
        return queue;
    }

    public void delete(Key key){  //删除对应键的元素
        if(!contains(key)) return;
        int i=hash(key);
        while(!key.equals(keys[i]))  //当未找到对应键的位置时向后继续查找
            i=(i+1)%M;
        keys[i]=null;  //找到后将键值对置为空
        vals[i]=null;
        i=(i+1)%M;  //向后移一位开始处理被删除键值对后面的元素
        while(keys[i]!=null){  //将要删除的键值对删除后,需要把被删除键值对后面的所有键重新插入到散列表以免前面删除的键置为空之后后面的元素都无法访问
            Key keyToRedo=keys[i];
            Value valToRedo=vals[i];
            keys[i]=null;  //将被删除元素后面的所有该键簇的元素都删除,再重新从当初被删除元素的位置开始将后面的元素一一插入
            vals[i]=null;
            N--;
            put(keyToRedo,valToRedo);
            i=(i+1)%M;
        }
        N--;  //删除元素并处理好后续元素后,将总数量减一
        if(N>0 && N==M/8) resize(M/2);  //当占用率α小于1/8时,减半数组容量
    }

    public static void main(String[] args){
        LinearProbingHashST<String,Integer> st=new LinearProbingHashST<String,Integer>(10);
        for(int i=0;!StdIn.isEmpty();i++){
            String key=StdIn.readString();
            st.put(key,i);
        }
        for(String s:st.keys())
            StdOut.println(s+" "+st.get(s));
    }
}

当一张大小为M,并含有N个键的基于线性探测的散列表快满的时候,查找所需的探测次数是巨大的,α=N/M表示的散列表占用率越高越趋近于1,探测的次数也会越来越大,但当占用率α小于1/2时查找探测的预计次数只在1.5到2.5之间。所以动态调整哈希值数组的大小很有必要。

17.除了存储键和值所需的空间之外,实现的拉链法散列表保存了M个SequentialSearchST对象和它们的引用,每个SequentialSearchST对象需要16个字节,它的每个引用需要8字节,临海还有N个Node对象,每个都需要24字节以及3个引用(key,value和next)。在使用动态调整数组大小保证散列表的使用率在1/8到1/2之间的情况下,线性探测散列表使用4N到16N个引用。符号表内存使用如下所示:

18.对于典型的应用场合,应该在散列表和二叉查找树之间进行选择。相对于二叉查找树,散列表的优点在于代码更简单,且查找时间最优(常数级别)。二叉查找树相对于散列表的优点在于抽象结构更简单,不需要设计哈希散列函数。而红黑树可以保证最坏情况下的性能而且它能够支持的操作更多,例如排名、选择、排序和范围查找。根据经验,大多数情况下第一选择都是散列表,在其他因素更重要时才会选择红黑树。各种符号表实现的渐进性能如下所示:

19.如果符号表的键值对不是原始数据类型,而是例如Integer或Double等引用类,会需要额外的两个引用来访问键值对数据。上亿的数据和查找会因这些引用造成巨大开销,使用原始数据类型代替键和值可以各省略一次引用,如下所示:

20.常用的集合类实际应用:

(1)白名单:

import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

import java.util.HashSet;

public class WhiteFilter {  //只将所有在白名单上的键传递到输出并忽略所有不在白名单上的键
    public static void main(String[] args){
        HashSet<String> set;
        set=new HashSet<String>();
        In in=new In(args[0]);
        while(!in.isEmpty())
            set.add(in.readString());
        while(!StdIn.isEmpty()){
            String word=StdIn.readString();
            if(set.contains(word))
                StdOut.print(word+" ");
        }
    }
}

(2)黑名单:

import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.SET;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

public class BlackFilter {
    //黑名单,指定的键都忽略
    public static void main(String[] args){
        SET<String> set=new SET<String>();
        In in=new In(args[0]);
        while(!in.isEmpty()){
            String word=in.readString();
            set.add(word);
        }
        while(!StdIn.isEmpty()){
            String word=StdIn.readString();
            if(!set.contains(word))
                StdOut.println(word);
        }
    }
}

(3)过滤器去掉输入流中的重复项:

import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

import java.util.HashSet;

public class DeDup {  //去掉输入流中的重复项
    public static void main(String[] args){
        HashSet<String> set;
        set=new HashSet<String>();
        while(!StdIn.isEmpty()){
            String key=StdIn.readString();
            if(!set.contains(key)){
                set.add(key);
                StdOut.print(key+" ");
            }
        }
    }
}

过滤器最大的用处在于处理输入数据量未知的情况

21.许多实际应用都将符号表看作一个可以方便查询并更新其中信息的动态字典,例如字典类实际应用中,有从文件或者网页中提取由逗号分隔的.csv文件格式的程序:

import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.ST;
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

public class LookupCSV {
    //字典的查找,读取由逗号分隔的.csv格式文件中的内容
    public static void main(String[] args){
        In in=new In(args[0]);
        int keyField=Integer.parseInt(args[1]);
        int valField=Integer.parseInt(args[2]);
        ST<String,String> st=new ST<String,String>();
        while(in.hasNextLine()){
            String line=in.readLine();
            String[] tokens=line.split(",");
            String key=tokens[keyField];
            String val=tokens[valField];
            st.put(key,val);
        }
        while(!StdIn.isEmpty()){
            String query=StdIn.readString();
            if(st.contains(query))
                StdOut.println(st.get(query));
        }
    }
}

用于测试的.csv文件部分预览如下所示:

字典查找程序的输出结果如下所示:

22.索引表示一个键和多个值相关联的符号表,例如商业交易中客户账户为键,值为该账号有关的所有交易,或者搜索引擎中,键是输入的关键词,值为一组网页等。而反向索引是指用值来查找键的操作,比如有大量的数据并且希望知道某个键都在哪些地方出现过,例如原本电影是键,它的演员是值,现在需要通过该演员找出他出演过的所有电影。索引的示意图如下所示:

索引及反向索引的查找实现如下所示:

import edu.princeton.cs.algs4.*;

public class LookupIndex {
    //索引以及反向索引的查找
    public static void main(String[] args){
        In in=new In(args[0]);  //索引数据库
        String sp=args[1];  //分隔符
        ST<String,Queue<String>> st=new ST<String,Queue<String>>();  //存放键及其对应多个值的符号表
        ST<String,Queue<String>> ts=new ST<String,Queue<String>>();  //存放值及其对应的键出现的多个位置
        while(in.hasNextLine()){
            String[] a=in.readLine().split(sp);
            String key=a[0];
            for(int i=1;i<a.length;i++){
                String val=a[i];  //i从1开始,所以是值,0为键
                if(!st.contains(key)) st.put(key,new Queue<String>());  //在符号表中插入键和对应这个键的多个值的队列容器
                if(!ts.contains(val)) ts.put(val,new Queue<String>());  //在符号表中插入该值和对应键出现的所有位置的队列容器
                st.get(key).enqueue(val);  //找到这个键对应的多个值所在的队列容器,插入对应的多个值
                ts.get(val).enqueue(key);  //找到这个值对应键的所有位置的队列容器,插入对应键的多个位置
            }
        }
        while(!StdIn.isEmpty()){
            String query=StdIn.readLine();
            if(st.contains(query))
                for(String s:st.get(query))
                    StdOut.println(" "+s);  //根据查找的键输出多个值
            if(ts.contains(query))
                for(String s:ts.get(query))
                    StdOut.println(" "+s);  //根据查找的值找到对应键出现的所有位置
        }
    }
}

索引及反向索引查找的输出结果如下所示:

文件索引是将文件中的一个单词和出现过该单词的所有文件构成的SET对象关联起来形成反向索引,如下所示:

import edu.princeton.cs.algs4.*;

import java.io.File;

public class FileIndex {
    public static void main(String[] args){
        ST<String,SET<File>> st=new ST<String,SET<File>>();  //单词及包含该单词的所有文件集合
        for(String filename:args){
            File file=new File(filename);  //根据命令行输入的文件名创建File对象,并作为In的输入
            In in=new In(file);
            while(!in.isEmpty()){
                String word=in.readString();
                if(!st.contains(word)) st.put(word,new SET<File>()); //每个不重复的单词都插入一个该单词及包含它的文件集合的符号表项
                SET<File> set=st.get(word);  //在上一行创立的文件集合容器中,一个个插入包含该单词的文件名
                set.add(file);
            }
        }
        while(!StdIn.isEmpty()){
            String query=StdIn.readString();
            if(st.contains(query))
                for(File file:st.get(query))
                    StdOut.println(" "+file.getName());  //根据单词来查找出现该单词的所有文件名
        }
    }
}

输出结果如下所示:

23.在矩阵和向量点乘操作中,普通的实现所需的时间和N2成正比,计算所需的空间也和N2成正比,在实际应用中N十分巨大,例如网络中网页的总数。但是,实际应用中的矩阵常常是稀疏的,即其中大多数项都是0,而且每行中的非零项也是一个较小的常数:每个网页中指向其他页面的链接相比网页总数来说很少,可以将该矩阵表示为由稀疏向量组成的一个数组,如下所示:

import edu.princeton.cs.algs4.ST;

public class SparseVector {  //完成两条向量点乘的稀疏向量
    private ST<Integer,Double> st;
    public SparseVector(){st=new ST<Integer,Double>();}
    public int size(){return st.size();}
    public void put(int i,double x){st.put(i,x);}
    public double get(int i){
        if(!st.contains(i)) return 0.0;
        else return st.get(i);
    }
    public double dot(double[] that){
        double sum=0.0;
        for(int i:st.keys())
            sum+=that[i]*this.get(i);
        return sum;
    }
}

稀疏矩阵的表示如下所示:

在稀疏矩阵中,不再使用a[i][j]来表示第i行第j列的元素,而是使用a[i].put(j,val)表示在矩阵中插入值并使用a[i].get(j)来获取它。这种方法所需的时间仅和N加上矩阵中的非零元素的数量成正比。

阅读更多
文章标签: Algorithms
个人分类: Java
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

不良信息举报

常用查找算法与数据结构实现、适用场景及优缺点(Java)

最多只允许输入30个字

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭