算法原理系列:查找

Posted Demon的黑与白

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法原理系列:查找相关的知识,希望对你有一定的参考价值。

查找

该系列我把它们命名为【算法原理】系列,意不在追求【算法细节】,而是从一个宏观的角度来看这些实现,重在数据结构的演变上,及分析它们的算法性能。参考书籍为《算法 第四版》及《算法导论》。

基本概念

字典是算法当中一个基本且常见的概念,类似于那本将单词的释义按照字母顺序排列起来的历史悠久的参考书。在英语字典里,键就是单词,值就是单词对应的定义、发音和词源。字典有时又叫索引,即书本最后将术语按照字母顺序列出以方便查找的那部分。概念很容易理解,但在计算机的世界中该如何实现字典这种数据结构呢?

字典抽象一下可以表示为map<key,value>,在这里需要明确一些基本性质:

  • key 不能重复
  • value 可重复
  • key可以有序,也可以无序(根据实际需求决定)
  • key 不能为空

好了,有了这些性质我们便可以一步步来设计我们的数据结构了。对我这种初学者来说,设计思路总要让我思考很久,或许我是习惯了直接把答案拿过来去研究问题。但我还是要强调,自己构思的思路永远都是你自己的,而拿别人已经研究好的答案去分析时,往往会忽略一些形成细节,这对我们分析问题是不利的,所以请还是尽量找寻它们设计的灵感与出处。

对我来说,字典有两个集合所组成,集合key和集合value,这里其实还需要明确集合的定义,但没必要太较真了,直接把这个概念拿来用吧。所以,字典的含义就是有某种未知的【作用】使得【key】关联到了【value】,而且这里的关联还需要再进一步明确下,对应与每个属于key的元素,只与value中的某个元素一一对应,在集合论中,这种性质叫满射。所以<key,value>只要成对出现就好了。

在我目前学的数据结构中,计算机表达键值对的原始结构有【数组】,它是天然的键值对,如int[] nums = new int[3];在Java程序设计语言中,nums表示一个大小为3的数组,在访问元素时,如nums[0] = 3;那么0就表示一个key,而对应的3则是个value。但这数组能否支持我们现在的key和value呢?至少在Java程序设计语言中,显然有点力不从心,原因很简单,数组访问的key对应的是数字[0,1,2…,n-1],假设数组大小为n。它的状态被阿拉伯数字给限制住了,所以无法简单的用它们来实现。

现在我们的目标是可以让程序以nums[key] = value,且key支持其它状态如字符a,b,c...等,或者更加高级的key,如时间,姓名等。

构建思路

哈哈,在Java设计思路中,它有一种叫面向对象的概念,啥意思咧,除了基本的数据类型,如int,boolean,char等,我们可以自定义类,如People类,定义如下:

public class People{
    private String name; //key
    private int salary; //value
}

而这些自定义的类同样可以以数组的方式创建,如People[] peoples = new People[3],我们叫集合吧,概念更加准确。咦,现在这种定义是否实现了key和value的一一绑定,并很好的符合上述性质呢?

的确,key和value的确放在了一个综合的结构,但是就这些代码还无法达到用key找寻value的目的,不信你试试!我是不信的!所以我们还需要改进整个数据结构,尝试性的,

if I find people.name
return people.salary

所以,我想到了一种办法,用一个类再套一层,有

public class ST{

    People[] peoples = new People[10];

    ST(People[] peoples){
        this.peoples = peoples;
    }

    public int get(String name){
        1. 从peoples数组中查找是否有对应的name
        2. 返回people.salary
    }   

    public void put(String name, int salary){
        1. 查找,如果存在:更新salary;返回
        2. 如果不存在:new People,插入数组,返回
    }
}

在这样一个结构中,就基本完成了key和value的一一对应,并且能够实现字典的两个基本操作,get和put方法。这已经是最简单的字典实现框架了,总的来说,它需要借助Java的高级特性来完成,两个主要手段,类的封装及数组(集合)。

字典进阶一

上述定义的字典还是低级幼稚的,现在我们直接拿出正式的API来分析它们的每个操作。一种简单的泛型符号表API:

public class ST <key,value>
ST()创建一个符号表
void put(Key key,Value val)将键值对存入表中(若值为空则将键key从表中删除)
Value get(Key key)获取key对应的值(若键key不存在则返回null)
void delete(Key key)从表中删去key(及其对应的值)
boolean contains(Key key)键key在表中是否有对应的值
boolean isEmpty()表是否为空
int size()表中的键值对数量
Iterable<Key> keys()表中的所有键的集合

此处用到Java中的一些高级特性,如泛型,迭代等,简单来说,这些特性能够保证一个ST类能够应用与各种自定义的key和value类,有很强的泛化能力。我们重在研究每个接口是如何实现,以及它的性能如何!

在上面简单粗暴的一个ST实现中,我是用数组封装了所有的键值对,然后在定义get和put方法时,内部都需要用到查找,而我们所知道的查找有哪几种?请回想一下,有

  • 无序查找,无任何信息可供我们使用,所以查询效率O(n)
  • 有序查找,利用数据的有序性,最经典的就是二分查找,查询效率为 O(logn)

而在前文说了,字典key的性质可以有序和无序,为了提高查询的效率,在数组的实现版本中我们使用有序key。所以由数组实现的字典它的查询效率最佳也是 O(logn) ,对吧。

我们现在来尝试实现【数组】的版本。

数组实现

在最低级版本中,我们是把key和value绑定在了一个类中,但这里用到了一个平行数组的概念,集合key和集合value是分别存放在两个数组中去的,但为了保证key和value的值一一对应,我在操作key时,同时也需要操作value,如在调用put("demon",3000)是,保证key[1] = "demon"value[1] = 3000,初始化代码有:

1.平行数组二分字典初始化

public class BinarySearchST<Key,Value> {

    private Key[] keys;
    private Value[] values;

    public BinarySearchST(int capacity){
        keys = (Key[]) new Object[capacity];
        values = (Value[]) new Object[capacity];
    }

    //接口暂未实现
    public void put(Key k, Value v){
    }

    //接口暂未实现
    public Value get(Key k){
        return null;
    }

}

现在的问题是如何把对应的key和value放入到正确的位置呢,BinarySearchST类,可以把它当作一个自动维持动态的有序字典类,在插入中,我们可以这样实现,当不存在这样的key时,直接插入到对应位置,否则修改key对应的value值。重点关注【不存在key时,需返回应该插入的位置】,这是实现BinarySearchST的核心思想,也是传统二分查找的一个扩展应用。

所以抽象一个接口出来,我们暂且定义为rank()方法,它的适用场景如下:

输入:private int rank(Key[] keys, key target);
输出:

  • keys中存在target,返回target的位置
  • keys中不存在target,返回位置i,使得 target[i1]<target[i]<target[i+1]

这样,每当用重复的key时,我就修改value的值,而当有新的key插入时,我便插入到指定的位置,使其符合有序。而rank()方法实际就是一个变种二分法,代码如下:

2.二分查找实现put()接口

public class BinarySearchST<Key extends Comparable<Key>, Value> {

    private Key[] keys;
    private Value[] values;

    //注意它的使用
    private int N = 0;

    public BinarySearchST(int capacity) {
        keys = (Key[]) new Comparable[capacity];
        values = (Value[]) new Object[capacity];
    }

    public void put(Key k, Value v) {
        int i = rank(this.keys, k);

        if(i < N && keys[i].compareTo(k) == 0){
            values[i] = v;
            return;
        }

        for (int j = N; j >= i; j--){
            keys[j+1] = keys[j];
            values[j+1] = values[j];
        }

        keys[i] = k;
        values[i] = v;

        N++;

    }

    //暂不实现
    public Value get(Key k) {
        return null;
    }

    private int rank(Key[] keys, Key target) {
        int lf = 0, rt = N - 1;

        while (lf <= rt) {
            int mid = lf + (rt - lf) / 2;
            int cmp = target.compareTo(keys[mid]);
            if (cmp > 0) { // keys[mid] > target
                lf = mid + 1;
            } else if (cmp < 0) {
                rt = mid - 1;
            } else {
                return mid;
            }
        }
        return lf;
    }
}

现在我们可以分析下put()方法的性能了,你会发现虽然使用了二分查找来提高查询效率,但在进行插入时,我们平均还是需要移动N次,而在最坏情况下需要移动2N次。

平均情况计算:
1. 由于输入随机,所以我们采用均摊分析方法,假设总共有N种插入情况,那么它们可能的位置有[0,1,2,3,…,N-1,N],分别需要移动[N,..,2,1,0]次,所以有 2N(N+1)2/N 次移动,其中2表示key和value都需要访问数组,N表示有N中情况。

最坏情况计算:
1. 在最坏情况下,新插入一个元素每个数组就需要移动N次,所以key和value总共需要移动2N次。

总结:
二分查找对put()的性能没有实质的优化。

3.二分查找实现get()接口

public Value get(Key k) {
        int i = rank(keys,k);
        if(i< N && keys[i].compareTo(k) ==0){
            //hit
            return values[i];
        }
        else
            return null;
    }

get()的实现相当简单,需要注意的是,因为i返回的位置,并不一定有k,所以我们需要额外使用compareTo去比较一下,它的查找效率相当高,为 O(logn)

链表实现

刚才我们是用数组实现了有序字典的两个重要接口,我们也分析了put方法和get方法的性能,那还有其他的实现方式么?你心中可能已经有答案了,没错,就是链表,那么它该怎么实现呢?

这就需要从链表和数组的几个基本性质说起了,我们简单做个比较吧,数组是集合,所以自然的想法就是让链表也表示成一个集合,我刚开始说的链表指的是链式结构的定义,如下:

public class Node{
    Node next;
    Value val;

    Node(Value val){
        this.val = val;
    }
}

所以为了让这个链式结构变成一个集合,我们自定了一个List,由链式结构实现,最简单的就是LinkedList类,具体不再实现了,我们主要为了比较【数组】和【链表】的一些基本操作:

1. 指定数据的读写(查找)

数组:

  • 在有序的情况下,可以快速搜索,如二分查找,效率为对数级别
  • 在无序的情况下,需要逐个遍历,效率为线性级别

链表:

  • 不管有序无序,都需要逐个遍历,效率为线性级别

综上,在查找上,数组的总体性能要高于链表。

2.数据的插入与删除

数组:

  • 不管有序无序,插入操作都需要对整个数组进行移位,效率为线性级别。

链表:

  • 可以在头插或尾插的应用场景中,效率为常数级别。
  • 在指定位置插入时,需逐个遍历,效率为线性级别。

综上,在插入元素方面,链表有它独一无二的优势,总体性能高于数组。

在插入上,数组显然表现得有点无力,现在我们的一个设计目标就应该利用链表的优势,来设计咱们的字典,尽量往链表的插入性能上来考虑。先给出它的一个初始结构。

public class SequentialSearchST<Key,Value>{

    private Node first;

    private class Node{

        Key key;
        Value value;
        Node next;

        public Node(Key key, Value value, Node next){
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    //暂未实现
    public void put(Key key, Value val){
    }

    //暂未实现
    public Value get(Key key){
        return null;
    }

}

喔,原来在链表节点上,我们可以封装我们的键值对啊,长知识了。我们直接顺着它的思路来吧,put方法是字典插入的地方,所以每当有新的key和value时,我们直接插入呗。但你会发现按照书中的思路去做,貌似不太行。。。我是没有做出来,所以无奈改了下结构,有了如下代码:

public class SequentialSearchST<Key,Value>{

    private Node head = new Node();

    private class Node{

        Key key;
        Value value;
        Node next;

        public Node(){
        }

        public Node(Key key,Value val){
            this.key = key;
            this.value = val;
        }

    }

    public void put(Key key, Value val){
        Node node = new Node(key,val);
        node.next = head.next;
        head.next = node;
    }

    public Value get(Key key){
        return null;
    }

}

典型的头插法,每当有新的node进来时,断链进行插入,其中用到了辅助的头节点。但这实现对么?字典最基本的性质在于当存在重复的key时,就应该更新对应的value值,而上述实现版本中显然并不符合要求。所以,从这里看,要实现常数级别的put方法已经不可能了,它必须像数组一样,需要遍历整个链表是否有指定元素,如果没有,则进行头插或者尾插,而如果存在重复的key则对应地更新该结点value值,不插。

public class SequentialSearchST<Key,Value>{

    private Node head = new Node();

    private class Node{

        Key key;
        Value value;
        Node next;

        public Node(){
        }

        public Node(Key key,Value value){
            this.key = key;
            this.value = value;
        }

    }

    public void put(Key key, Value val){

        for (Node curr = head.next; curr != null; curr = curr.next){
            if(curr.key.equals(key)){
                curr.value = val;
                return;
            }
        }

        Node node = new Node(key,val);
        node.next = head.next;
        head.next = node;
    }

    public Value get(Key key){
        for (Node curr = head.next; curr != null; curr = curr.next){
            if(curr.key.equals(key)){
                return curr.value;
            }
        }
        return null;
    }
}

这就是基于链表的字典实现代码,它并不需要保证输入key在该结构中保持有序性,接着我们就分析下它的put方法和get方法的性能。

get操作:

怎么说呢,get操作的性能是相当糟糕的,每次查找都需要遍历整个链表,最坏需要N次,而平均情况下的成本为N/2次,平均情况下用了均摊分析,和数组实现插入分析方法相同,不再赘述。

put操作

也就是插入操作,由于在插入操作之前,需要进行一次查找,虽然插入是常数级别的,但查找最坏和平均情况下都属于N次。

综上,我们可以参考书中的表格,如下:
alt text

字典进阶二

那么问题来了,原本想用链表实现插入的高效性能,结果却事与愿违,为了维护key集合的唯一性,在插入时,我们先要扫一遍key集合,保证不存在重复的键。这是查找所带来的性能开销,对于链式结构来说,是无法避免的。如果仔细观察【数组】和【链表】实现,你能发现一个共同点,get方法和put方法都需要做查找,而元素的有序性是提升查找性能的关键,所以总体的优化方案一定是围绕有序来展开的。

首先,我们来明确下,链式结构能否维护有序性,答案是可以的,但就字典问题,链式维护有序性显得没有任何意义。因为【有序】为了提升查找【效率】,提升查找效率的方法目前最优的是二分法,而在链表中,却并【不支持】二分操作,所以链式有序并不能改善查找性能,让它有序无意义。

有没有一种除数组以外的结构能够实现有序的二分查找且能改善插入的成本?是树?为什么是树?解决二分查找时,树结构还没有发明时怎么办?

来看一张图,
alt text

这是计算机看链式和数组的视角,对于链式结构,计算机是无法看到全局信息的,每个元素藏在上一个元素之下,所以为了得到全局信息,必须全部遍历一遍。而数组却能被计算机全局的掌控,它能访问任意位置信息。为何会出现这种情况?因为链式是单方向遍历,而数组的遍历则非常随意,你可以从低到高,可以从高到低,也可以两头同时遍历,向中间靠齐,也可以从中间开始向两头发散,这是我见过遍历最灵活的结构了!!!

灵活的遍历结构,能做的事情就多了,就拿上述视角来说,你可以做任意切分,自然对于有序二分查找,每当你砍掉一半元素时,你就少遍历一半时间。如果说是从遍历方向灵活性来解释树的诞生,还是有点牵强,这里有一种进一步的方案,从视角上来分析,我们可以设计出如下数据结构。
alt text

public class Node{
    private Node left;
    private Node right;
    private Node next;

    private Key key;
}

这在遍历上就有了一个扩展,如果直接能够拿到3的节点,那么我们可以判断目标key是否大于该节点,大则往right走,比3小则往left走,这起码少比较了一半元素吧,链式结构总算可以选择自己的方向了,没错,这种选择性很重要,天然的树结构,吼吼,所以往两个方向遍历的数据结构诞生了,所以直接给出树的定义:

public class TreeNode{
    private TreeNode left;
    private TreeNode right;
}

next的设计是冗余的,因为简单地,没有right的分支的结构即可以等效为next,所以完全可以去掉。

从视角上来分析,链表是垂直结构,而数组是扁平结构,但不管是垂直结构还是扁平结构,它们都有各自的缺点,如因为扁平所以需要全局移动来维护扁平结构所存储的信息,而如垂直,则需要全局遍历来得到全局信息。而树就好像是这两者的折中产物,有深度的同时也有广度,或许这就是为什么说BFS和DFS的原因吧!树能够看到全局信息,同时支持零活的插入操作,独一无二的结构!

所以有了树结构,如果我们能够把有序性,存入树结构中,那么这件事就成功一大半了。在这里可以分析该树需要满足什么样的性质即可,不难定义。

  • 树的结构为【根节点】,【左子树】,【右子树】。
  • 为了进行有序比较操作,左子树的元素都小于根节点,右子树的元素都大于根节点。

上述定义是递归的,左右子树的每个节点也符合上述性质。那么,自然而然地,我们可以从根开始遍历,且每当决策一次时,便抛弃一棵子树,很明显,它的比较次数是大大小于链表的。回过头来再看看书上第3.2节关于二叉查找树的定义,不就是它么。于是有

public class BST<Key extends Comparable<Key>,Value> {

    private Node root;
    private class Node{
        private Key key;
        private Value val;

        private Node left;
        private Node right;
        private int N;

        private Node(Key key, Value val,int N){
            this.key = key;
            this.val = val;
            this.N = N;
        }
    }

    //暂未实现
    public void put(Key key, Value val){

    }

    //暂未实现
    public Value get(Key key){
        return null;
    }

}

接下来我们分别来实现查找和插入方法,插入操作需要递归的使用root,所以我们还需要定义个私有方法让它可以在递归时不断把自己传入,所以有

public void put(Key key, Value val){
        root = put(root,key,val);
    }


    private Node put(Node root, Key key, Value val){

        if(root == null) return new Node(key,val,1);

        int cmp = key.compareTo(root.key);
        if(cmp > 0) root.right = put(root.right,key,val);
        else if(cmp < 0) root.left = put(root.left,key,val);
        else root.val = val;

        root.N = size(root.left) + size(root.right) + 1;
        return root;
    }

递归很简单,当root为null时,则新建结点,并且插入在指定位置。这位置可以是上一层的left或者right,要看具体的比较情况。特殊地,当该字典插入第一个元素时,直接返回一个根节点,而后续再插入元素时,就一步步开始构建左子树或者右子树。

同理我们有,

public Value get(Key key){
        return get(root,key);
    }

    private Value get(Node root, Key key) {
        if (root == null)
            return null;
        int cmp = key.compareTo(root.key);

        if (cmp < 0) {
            return get(root.left, key);
        } else if (cmp > 0) {
            return get(root.right, key);
        } else {
            return root.val;
        }
    }

也是个递归操作,我们从另外一个角度去理解。假设get操作是获取指定key的val,那么作为一棵含有左子树和右子树以及根节点的结构来说,该操作将根据cmp的值,选择根或者左子树或者右子树,而当选择左子树或者右子树时,它从通俗意义上来说,又是一个和先前结构一模一样的树,所以操作完全一样,就直接return get(root.left,key)即可。

好了现在我们来分析这【树结构】插入和查找操作的性能吧,其实我们可以在脑海中类比下数组的查找过程,几乎是一样的。为什么不是完全呢?如在数组中,我们选择了一个lo和hi指针,并由此计算得到mid,刚好指向数组的中间,key与keys[mid]进行比较,当小于中间值时,则丢弃右半边,更新hi指针,当大于中间值时,则丢弃左半边,更新lo指针,这和树的cmp操作是一模一样的。但需要注意的是,树并不一定是从元素的中值比较,它的构建和元素的插入顺序相关,所以并不完全和数组等效。所以进一步地,树的平均插入性能必然也会比数组来得大。

但对我们来说,树改善了链表的插入性能,经过理论计算,二叉查找树的插入查找平均性能均为 1.39lgN ,查找依然是对数级的,但插入从线性级变为对数级!

二叉查找树还有一些基本的删除操作,原理很简单,找到当前要删除的节点,若该节点还有左子树和右子树则要么从左子树中找个最大节点代替当前节点,要么从右子树中找个最小节点代替当前节点,上代码。

public Key min(){
        return min(root).key;
    }

    private Node min(Node root){
        if(root.left == null) return root;
        return min(root.left);
    }

    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;
    }

    public void delete(Key key) {
        root = delete(root, key);
    }

    private Node delete(Node root , Key key){
        //先查找
        if(root == null) return null;

        int cmp = key.compareTo(root.key);
        if(cmp < 0){
            root.left = delete(root.left,key);
        }
        else if (cmp >0){
            root.right = delete(root.right, key);
        }
        else{
            //删除操作
            if(root.left == null) return root.right; 
            if(root.right == null) return root.left; 

            Node curr = root;
            root = min(curr.right); 
            root.left = curr.left;
            root.right = deleteMin(curr.right);

        }
        root.N = size(root.left) + size(root.right) + 1;

        return root;
    }

总的来说,二叉查找树的实现并不困难,且当树的构造和随机模型近似时在各种实际应用场景中它都能进行快速地查找和插入,最后拿张图总结吧。
alt text

参考文献

  1. Robert Sedgewick. 算法 第四版[M]. 北京:人民邮电出版社,2012.10
  2. Cormen. 算法导论[M].北京:机械工业出版社,2013

以上是关于算法原理系列:查找的主要内容,如果未能解决你的问题,请参考以下文章

经典算法系列之:二分查找

经典算法系列之:二分查找

字符串匹配算法系列一:KMP算法原理

从搜索文档中查找最小片段的算法?

leetcode查找算法(顺序查找,二分法,斐波那契查找,插值查找,分块查找)

kmp算法的个人理解