《Data Structures and Algorithm Analysis in Java》Second Edition中文版笔记

本文详细解析了Java中表的ADT,尤其是链表的实现,以及Java Collections API中的表,特别是ArrayList和LinkedList的区别。此外,介绍了二叉树、二叉查找树、AVL树和B树等树形数据结构,以及优先队列(堆)中的二叉堆概念。最后,深入探讨了排序算法,如插入排序、堆排序、归并排序和快速排序,重点讨论了快速排序的选取枢纽元策略、分割方法以及小数组的处理。

第3章 表、栈和队列

3.2 表ADT

3.2.2 简单链表

  1. 在表的前端添加项或删除第一项的特殊情形此时也属于常数时间的操作,当然要假设到链表前端的链是存在的。只要我们拥有到链表最后节点的链,那么在链表末尾进行添加操作的特殊情形(即让新的项成为最后一项)可以花费常数时间。因此,典型的链表拥有到该表两端的链。
  2. 保留指向最后节点的节点的第3个链的想法行不通,因为它在删除操作期间也需要更新。我们的做法是,让每一个节点持有一个指向它在表中的前驱节点的链,我们称之为双链表(doubly linked list)。

3.3 Java Collections API中的表

3.3.2 Iterator接口

  1. 每次对next的调用都给出集合的(尚未见到的)下一项。因此,第1次调用next给出第1项,第2次调用给出第2项,等等。
  2. 当直接使用Iterator(而不是通过一个增强的for循环间接使用)时,重要的是要记住一个基本法则:如果对正在被迭代的集合进行结构上的改变(即对该集合使用add、remove或clear方法),那么迭代器就不再合法(并且在其后使用该迭代器时将会有ConcurrentModificationException异常被抛出)。
  3. 然而,如果迭代器调用了它自己的remove方法,那么这个迭代器就仍然是合法的。这是有时候我们更愿意使用迭代器的remove方法的第二个原因。

3.3.3 List接口、ArrayList类和LinkedList类

  1. LinkedList类则提供了List ADT的双链表实现。
  2. 这里,ArrayList的运行时间是O(N),但对于LinkedList来说,其运行时间则是O(N2),因为在LinkedList中,对get的调用为O(N)操作。可是,要是使用一个增强的for循环,那么它对任意List的运行时间都是O(N),因为迭代器将有效地从一项到下一项推进。

3.3.4 例:remove方法对LinkedList类的使用

删除表中的偶数:由于ConcurrentModificationException异常而无法运行:

public static void removeEvensVer2(List<Integer> lst) {
    for (Integer x : lst)
        if (x % 2 == 0)
            lst.remove(x);
}

该程序产生一个异常,因为当一项被删除时,由增强的for循环所使用的基础迭代器是非法的。
所以,我们不用List的remove方法,而使用Iterator的remove方法。

public static void removeEvensVer3(List<Integer> lst) {
    Iterator<Integer> itr = lst.iterator();
    while(itr.hasNext())
        if(itr.next() % 2 == 0)
            itr.remove();
}

3.3.5 关于ListIterator接口

  1. ListIterator扩展了List的Iterator的功能。方法previous和hasPrevious使得对表从后向前的遍历得以完成。add方法将一个新的项以当前位置放入表中。当前项的概念通过把迭代器看做是在对next的调用所给出的项和对previous的调用所给出的项之间而抽象出来的。
  2. void set(E e)
    Replaces the last element returned by next() or previous() with the specified element (optional operation). This call can be made only if neither remove() nor add(E) have been called after the last call to next or previous.
    修改next()和previous()返回的对象,并且是和remove()以及add(E)互斥的,不能同时使用。

3.4 ArrayList类的实现

public class MyArrayList<AnyType> implements Iterable<AnyType> {
    private static final int DEFAULT_CAPACITY = 10;
    private int theSize;
    private AnyType[] theItems;
    ...
    public java.util.Iterator<AnyType> iterator() {
        return new ArrayListIterator(); 
    }
    private class ArraListIterator implements java.util.Iterator<AnyType> {
        private int current = 0;
        public boolean hasNext() {
            return current<size();
        }
        public AnyType next() {
            if(!hasNext())
                throw new java.util.NoSuchElementException();
            return theItems[current++];
        }
        public void remove() {
            MyArrayList.this.remove(--current);
        }
    }
}

迭代器用一个内部类实现。嵌套类是在内部类前加一个static修饰符。内部类可以看到外部类的所有成员,包括private成员,而嵌套类,因为是static的,所以只能访问外部类的static成员。嵌套类因为是static的,所以即使没有外部类的instance,它也可以被访问。

3.5 LinkedList类的实现

public class MyLinkedList<T> implements Iterable<T> {
    // 节点定义
    private static class Node<T> {
        public Node( T d, Node<T> p, Node<T> n ){
            data = d; prev = p; next = n;
        }
        public T data;
        public Node<T> prev;
        public Node<T> next;
    }
    // LinkedList的成员。使用头节点和尾节点的优点是排除了许多特殊情形。每次对一个迭代器方法的调用都将用到该链表内的当前modCount检测在迭代器内存储的modCount,并且当这两个计数器不匹配时抛出一个ConcurrentModificationException异常。
    private int theSize;
    private int modCount = 0;
    private Node<T> beginMarker;
    private Node<T> endMarker;
    // 初始化
    public MyLinkedList() {
        clear();
    }
    public void clear() {
        beginMarker = new Node<T>(null, null, null);
        endMarker = new Node<T>(null, beginMarker, null);
        beginMarker.next = endMarker;
        theSize = 0;
        modCount++;
    }
    //插入节点
    private void addBefore(Node<T> p, T x) {
        Node<T> newNode = new Node<T>(x, p.prev, p);
        newNode.prev.next = newNode;
        p.prev = newNode;
        theSize++;
        modCount++;
    }
    //删除节点
    private T remove(Node<T> p) {
        p.prev.next = p.next;
        p.next.prev = p.prev;
        theSize--;
        modCount++;
        return p.data;
    }
    //链表的get
    private Node<T> getNode(int idx) {
        Node<T> p;
        if( idx<0 || idx > size())
            throw new IndexOutOfBoundsException();
        if(idx < size()/2) {
            p = beginMarker.next;
            for(int i=0;i<idx;i++)
                p = p.next;
        }
        else {
            p = endMarker;
            for(int i=size();i>idx;i--)
                p = p.prev;
        }
        return p;
    }
    //迭代器
    public java.util.Iterator<T> iterator() {
        return new LinkedListIterator();        }
    private class LinkedListIterator implements java.util.Iterator<T> {
        private Node<T> current = beginMarker.next;
        private int expectedModCount = modCount;
        private boolean okToRemove = false;
        public boolean hasNext() {
            return current != endMarker;
        }
        public Node<T> next() {
            if(modCount!=expectedModCount)
                throw new java.util.ConcurrentModificationException();
            if(!hasNext())
                throw new java.util.NoSuchElementException();
            T nextItem = current.data;
            current = current.next;
            okToRemove = true;
            return nextItem;
        }
        public void remove() {
            if(modCount!=expectedModCount)
                throw new java.util.ConcurrentModificationException();
            if(!okToRemove)
                throw new IllegalStateException();
            MyLinkedList.this.remove(current.prev);
            okToRemove = false;
            expectedModCount++;
        }
    }
}

第4章 树

4.2 二叉树

  1. 我们现在给出一种算法来把后缀表达式转变成表达式树。由于我们已经有了将中缀表达式转变成后缀表达式的算法,因此我们能够从这两种常用类型的输入生成表达式树。我们一次一个符号地读入表达式。如果符号是操作数,那么就建立一个单节点树并将它推入栈中。如果符号是操作符,那么就从栈中弹出两棵树T1和T2(T1先弹出)并形成一棵新的树,该树的根就是操作符,它的左、右儿子分别是T2和T1。然后将这棵新树压入栈中。

4.3 查找树ADT——二叉查找树

public class BinarySearchTree<T extends Comparable<? super T>> {
    private static class BinaryNode<T> {
        BinaryNode(T theElement) {
            this(theElement, null, null);
        }
        BinaryNode(T theElement, BinaryNode<T> lt, BinaryNode<Y> rt) {
            element = theElement;
            left = lt;
            right = rt;
        }
        T element;
        BinaryNode<T> left;
        BinaryNode<T> right;
    }
    private BinaryNode<T> root;
    public BinarySearchTree() {
        root = null;
    }
    // contains方法
    private boolean contains(T x, BinaryNode<T> t) {
        if(t==null) return false;
        int compareResult = x.compareTo(t.element);
        if(compareResult < 0)
            return contains(x, t.left);
        else if(compareResult > 0)
            return contains(x, t.right);
        else 
            return true;
    }
    // findMin和finMax
    private BinaryNode<T> findMin(BinaryNode<T> t) {
        if (t==null) return null;
        else if(t.left == null) return t;
        return findMin(t.left);
    }
    private BinaryNode<T> findMax(BinaryNode<T> t) {
        if(t!=null)
            while(t.right!=null)
                t=t.right;
        return t;
    }
    //insert方法
    private BinaryNode<T> insert(T x, BinaryNode<T> t) {
        if(t==null)
            return new BinaryNode<T>(x, null, null);
        int compareResult = x.compareTo(t.element);
        if(compareResult<0)
            t.left = insert(x, t.left);
        else if(compareResult>0)
            t.right = insert(x, t.right);
        else 
            ; //Duplicate; do nothing
        return t;
    }
    //remove方法。复杂的情况是处理具有两个儿子的节点。一般的删除策略是用其右子树的最小的数据(很容易找到)代替该节点的数据,并递归地删除那个节点(现在它是空的)。因为右子树的最小的节点不可能有左儿子,所以第二次remove要容易。
    //但这个效率并不高,因为它沿该树进行两趟搜索以查找和删除右子树中最小的节点。
    private BinaryNode<T> remove(T x, BinaryNode<T> t) {
        if(t==null) return t; //Item not found
        int compareResult = x.compareTo(t.element);
        if (compareResult<0)
            t.left = remove(x, t.left);
        else if(compareResult>0)
            t.right = remove(x, t.right);
        else if(t.right!=null && t.left!=null) { //Two Children
            t.element = findMin(t.right).element;
            t.right = remove(t.element, t.right);
            // BinaryNode<T> newRef = t.right;
            // t.element = removeMin(newRef);
        }
        else 
            t = (t.left!=null) ? t.left : t.right;
        return t;
    }
    //所以我添加了removeMin方法,就是找到了右子树最小节点就删除,并返回最小节点的element。
    private T removeMin(BinaryNode<T> t) {
        if (t!=null)
            while(t.left!=null)
                t = t.left;
        T x = t.element;
        if (t.right==null) t = null; //它一定没有左孩子
        else t = t.right;
        return x;
    }

4.4 AVL树

  1. 第一种情况是插入发生在“外边”的情况(即左-左的情况),该情况通过对树的一次单旋转(single rotation)而完成调整。第二种情况是插入发生在“内部”的情形(即左-右的情况或右-左的情况),该情况通过稍微复杂些的双旋转(double rotation)来处理。
  2. 除旋转引起的局部变化外,编程人员必须记住:树的其余部分必须被告知该变化。如本例中节点2的右儿子必须重新设置以链接到4来代替3。这一点很容易忘记,从而导致树被破坏(4就会是不可访问的)。
  3. 在图4-34中的子树Y已经有一项插入其中,这个事实保证它是非空的。因此,我们可以假设它有一个根和两棵子树。于是,我们可以把整棵树看成是4棵子树由3个节点连结。恰好树B或树C中有一棵比D深两层(除非它们都是空的),但是我们不能肯定是哪一棵。事实上这并不要紧,在图4-35中B和C都被画成比D低112层。
//AvlNode
private static class AvlNode<T> {
    AvlNode(T theElement) {
        this(theElement, null, null);
    }
    AvlNode(T theElement, AvlNode<T> lt, AvlNode<T> rt) {
        element = theElement;
        left = lt;
        right = rt;
        height = 0;
    }
    T element;
    AvlNode<T> left;
    AvlNode<T> right;
    int height;
}
// Return the height of node t, or -1, if null
private int height(AvlNode<T> t) {
    return t == null ? -1 : t.height;
}
// 插入
private AvlNode<T> insert(T x, AvlNode<T> t) {
    if (t==null) 
        return new AvlNode(x);
    int compareResult = x.compareTo(t.element);
    if (compareResult<0) {
        t.left = insert(x, t.left);
        if (height(t.left) - height(t.right) == 2) 
            if (x.compareTo(t.left.element < 0) // 插入到左子树的左子树
                t = rotateWithLeftChild(t);
            else //插入到左子树的右子树
                t = doubleWithLeftChild(t);
    }
    else if (compareResult>0) {
        t.right = insert(x, t.right);
        if (height(t.right) - height(t.left) == 2) {
            if (x.compareTo(t.right.element) > 0) //插入到右子树的右子树
                t = rotateWithRightChild(t);
            else
                t = doubleWithRightChild(t);
        }
    }
    else
        ;
    t.height = Math.max(height(t.left), height(t.right)) + 1;
    return t;
}
// rotateWithLeftChild的参数是根k2,左子树的根是k1。旋转,k1做根,右子树的根是k2,k1的右子树变成K2的左子树,返回k1。
private AvlNode<T> rotateWithLeftChild(AvlNode<T> k2) {
    AvlNode<T> k1 = k2.left;
    k2.left = k1.right;
    k1.right = k2;
    k2.height = Math.max(height(k2.left), height(k2.right)) + 1;
    k1.height = Math.max(height(k1.left), k2.height) + 1;
    return k1;
}
// rotateWithRightChild和rotateWithLeftChild是对称的。k1,k2,k3的命名是中序遍历的顺序
private AvlNode<T> rotateWithRightChild(AvlNode<T> k1) {
    AvlNode<T> k2 = k1.right;
    k1.right = k2.left;
    k2.left = k1;
    k1.height = Math.max(height(k1.left), height(k1.right))+1;
    k2.height = Math.max(k1.height, height(k2.right))+1;
    return k2;
}
// doubleWithLeftChild是双旋转,先要向左单旋转(根节点是k1,右子树的根是k2,withRightChild),再要向右单旋转(根节点是k3,左子树的根现在是k2,withLeftChild)
private AvlNode<T> doubleWithLeftChild(AvlNode<T> k3) {
    AvlNode<T> k1 = k3.left;
    //AvlNode<T> k2 = rotateWithRightChild(k1);//这句可能是错的,因为k3.left好像已经和k2断开了,应写成
    k3.left = rotateWithRightChild(k1);
    //或者加上一句k3.left = k2;
    return(rotateWithLeftChild(k3));
    //最好的写法是:
    // k3.left = rotateWithRightChild(k3.left);
    // return(rotateWithLeftChild(k3);
}
// doubleWithRightChild也是双旋转,先要向右旋转(根节点是k3,左子树的根是k2,withLeftChild),再向左旋转(根节点是k1,右子树的根节点现在是k2,withRightChild)。
private AvlNode<T> doubleWithRightChild(AvlNode<T> k1) {
    k1.right = rotateWithLeftChild(k1.right);
    return(rotateWithRightChild(k1));
}   

4.7 B树

  1. 阶为M的B树是一棵具有下列特性的树:

    数据项存储在树叶上。
    非叶节点存储直到M1个关键字以指示搜索的方向;关键字i代表子树i+1中的最小的关键字。
    树的根或者是一片树叶,或者其儿子数在2和M之间。
    除根外,所有非树叶节点的儿子数在\ceilM/2M之间。
    所有的树叶都在相同的深度上并有\ceilL/2L之间个数据项。

  2. 每个节点代表一个磁盘区块,于是我们根据所存储的项的大小选择M和L。例如,设一个区块能容纳8192字节。在上面的例子中,每个关键字使用32个字节。在一棵M阶B树中,有M1个关键字,总数为32M32字节,再加上M个分支。由于每个分支基本上都是另外的一些磁盘区块,因此可以假设一个分支是4个字节。4个字节是32位,8192字节是65536位。65536=232。这样,这些分支共用4M个字节。一个非叶节点总的内存需求为36M32个字节。使得不超过8192字节的M的最大值是228。因此我们选择M=228。由于每个数据记录是256字节,因此可以把32个记录装入一个区块中。于是,我们选择L=32。这样就保证每片树叶有16到32个数据记录以及每个内部节点(除根外)至少以114种方式分叉。由于有1千万个记录,因此至多存在625000片树叶。由此得知,在最坏情况下树叶将在第4层上。更具体地说,最坏情形的访问次数近似地由logM/2N给出,这个数可以有1的误差(例如,根和下一层可以存放在主存中,使得经过长时间运行后磁盘访问将只对第3层或更深层是需要的)。

  3. 正如这里的情形所示,当一个非叶节点分裂时,它的父节点得到了一个儿子。如果父节点的儿子个数已经达到规定的限度怎么办?在这种情况下,继续沿树向上分裂节点直到找到一个不需要再分裂的父节点,或者到达树根。如果分裂树根,那么我们就得到两个树根。显然这是不可接受的,但我们可以建立一个新的根,这个根以分裂得到的两个树根作为它的两个儿子。这就是为什么准许树根可以最少有两个儿子的特权的原因。这也是B树增加高度的唯一方式。

第6章 优先队列(堆)

6.3 二叉堆

  1. 一个重要的观察发现,因为完全二叉树这么有规律,所以它可以用一个数组表示而不需要使用链。
  2. 对于数组中任一位置i上的元素,其左儿子在位置2i上,右儿子在左儿子后的单元(2i+1)中,它的父亲则在位置\floori/2上。
  3. 因此,一个堆结构将由一个(Comparable对象的)数组和一个代表当前堆大小的整数组成。该数组有一个位置0,而堆的根是从位置1开始放的。
  4. 在一个堆中,对于每一个节点X,X的父亲中的关键字小于(或等于)X中的关键字,根节点除外(它没有父亲)。
  5. 当删除一个最小元时,要在根节点建立一个空穴。由于现在堆少了一个元素,因此堆中最后一个元素X必须移动到该堆的某个地方。
  6. delete(p)操作删除堆中位置p上的节点。该操作通过首先执行decreaseKey(p, 无穷大)然后再执行deleteMin()来完成。当一个进城被用户中止(而不是正常终止)时,它必须从优先队列中除去。
  7. 构建堆:一般的算法是将N项以顺序放入树中,保持结构特性。此时,如果percolateDown(i)从节点i下滤,那么buildHeap程序则可以由构造方法用于创建一棵堆序的树(heap-ordered tree)。
public class BinaryHeap<T extends Comparable<? super T>> {
    private static final int DEFAULT_CAPACITY = 10;
    private int currentSize; // Number of elements in heap
    private T[] array; // The heap array

    public BinaryHeap() { ... }
    public BinaryHeap(int capacity) { ... }
    public BinaryHeap(T[] items) {
        currentSize = items.length;
        array = (T[]) new Comparable[(currentSize+2)*11/10];
        int i=1;
        for(T item : items)
            array[i++] = item;
        buildHeap();
    }

    private void buildHeap() {
        for (int i=currentSize/2; i>0; i--) // 叶子节点不用下滤
            percolateDown(i);
    }
    private void percolateDown(int hole) {
        int child;
        T tmp = array[hole];
        for (; hole*2 <= currentSize; hold=child) {
            child = hole*2;
            if(child!=currentSize && array[child+1].compareTo(array[child])<0)
                child++; // 挑选出左右孩子中更小的那个
            if (array[child].compareTo(tmp) < 0)
                array[hole] = array[child]; // 把父结点、左孩子、右孩子中值最小的节点挪到父节点上
            else
                break; // 如果一直能下滤,就让hole=child,继续循环。只要一次不用下滤,整个下滤就完成了。
        } 
        array[hole] = tmp;
    }
    private void enlargeArray(int newSize) { ... }

    public void insert(T x) {
        if(currentSize == array.length-1)
            enlargeArray(array.length * 2 + 1);
        // Percolate up
        int hole = ++currentSize; // hole就是在末尾多添加了一个槽位
        for(; hole>1 && x.compareTo(array[hole/2])<0; hole/=2)
            array[hole] = array[hole/2];
        array[hole] = x;
    }
    public T findMin() { ... }
    public T deleteMin() {
        if(isEmpty())
            throw new UnderflowException();
        T minItem = findMin();
        array[1] = array[currentSize--];
        percolateDown(1);
        return minItem;
    }
    public boolean isEmpty() { ... }
    public void makeEmpty() { ... }
}

第7章 排序

7.2 插入排序

  1. 在第p趟,我们将位置p上的元素向左移动,直到它在前p+1个元素中的正确位置被找到的地方。
  2. 另一方面,如果输入数据已预先排序,那么运行时间为O(N),因为内层for循环的检测总是立即判定不成立而终止。事实上,如果输入几乎被排序,那么插入排序将运行得很快。
public static <T extends Comparable<? super T>> void insertionSort(T[] a) {
    int j;
    for(int p=1;p<a.length;p++) {
        T tmp = a[p];
        for(j=p;j>0 && tmp.compareTo(a[j-1])<0;j--)
            a[j] = a[j-1];
        a[j] = tmp;
    }
}

7.3 一些简单排序算法的下界

  1. 成员是数的数组的逆序(inversion)即具有性质i<ja[i]>a[j]的序偶(a[i],a[j])。逆序数正好是需要由插入排序(隐含)执行的交换次数。
  2. 通过交换相邻元素进行排序的任何算法平均都需要Ω(N2)时间。证明:初始的平均逆序数是N(N1)/4=Ω(N2),而每次交换只减少一个逆序,因此需要Ω(N2)次交换。这个下界告诉我们,为了使一个排序算法以亚二次(subquadratic)或O(N2)时间运行,必须执行一些比较,特别是要对相距较远的元素进行交换。一个排序算法通过删除逆序得以向前进行,而为了有效地进行,它必须使每次交换删除不止一个逆序。

7.5 堆排序

  1. 回忆在第6章建立N个元素的二叉堆的基本策略,这个阶段花费O(N)时间(因为包含2h+1个节点、高为h的理想二叉树(perfect binary tree)的节点的高度的和为2h+11(h+1))。然后我们执行N次deleteMin操作。按照顺序,最小的元素先离开堆。通过将这些元素记录到第二个数组然后再将数组拷贝回来,得到N个元素的排序。由于每个deleteMin花费时间O(logN),因此总的运行时间是O(NlogN)
  2. 在我们的实现方法中将使用一个(max)堆,但由于速度的原因避免了实际的ADT。照通常的习惯,每一件事都是在数组中完成的。第一步以线性时间建立一个堆。然后通过每次将堆中的最后元素与第一个元素交换,执行N-1次deleteMax操作,每次将堆的大小缩减1并进行下滤。当算法终止时,数组则以拍好的顺序包含这些元素。
private static int leftChild(int i) {
    return 2*i+1; // 根的index从0开始了
}
// @int n the logical size of the binary heap
private static <T extends Comparable<? super T>> void percDown(T[] a, int i, int n) { 
    int child;
    T tmp;
    for(tmp=a[i]; leftChild(i)<n; i=child) {
        child = leftChild(i);
        if(child!=n-1 && a[child].compareTo(a[child+1])<0) 
            child++;
        if(tmp.compareTo(a[child])<0)
            a[i] = a[child];
        else 
            break;
    }
    a[i] = tmp;
}
public static <T extends Comparable<? super T>> void heapsort(T[] a) {
    for (int i=a.length/2; i>=0; i--)
        percDown(a, i, a.length);
    for (int i=a.length-1; i>0; i--) {
        swapReferences(a, 0, i);
        percDown(a, 0, i);
    }
}

7.6 归并排序

  1. 这个算法中基本的操作是合并两个已排序的表。因为这两个表是已排序的,所以若将输出放到第3个表中,则该算法可以通过对输入数据一趟排序来完成。基本的合并算法是取两个输入数组A和B,一个输出数组C,以及3个计数器Actr、Bctr、Cctr,它们初始置于对应数组的开始端。A[Actr]和B[Bctr]中的较小者被拷贝到C中的下一个位置,相关的计数器向前推进一步。当两个输入表有一个用完的时候,则将另一个表中剩余部分拷贝到C中。
private static <T extends Comparable<? super T>> void mergeSort(T[] a, T[] tmpArray, int left, int right) {
    if (left<right> {
        int center = (left+right)/2;
        mergeSort(a, tmpArray, left, center);
        mergeSort(a, tmpArray, center+1, right);
        merge(a, tmpArray, left, center+1, right);
    }
}
public static <T extends Comparable<? super T>> void mergeSort(T[] a) {
    T[] tmpArray = (T[]) new Comparable[a.length];
    mergeSort(a, tmpArray, 0, a.length-1);
}
// @param leftPos the left-most index of the subarray
// @param rightPos the index of the start of the second half
// @param rightEnd the right-most index of the subarray
private static <T extends Comparable<? super T>> void merge(T[] a, T[] tmpArray, int leftPos, int rightPos, int rightEnd) {
    int leftEnd = rightPos - 1;
    int tmpPos = leftPos;
    int numElements = rightEnd - leftPos + 1;

    while(leftPos<=leftEnd && rightPos<=rightEnd) 
        if(a[leftPos].compareTo(a[rightPos])<=0)
            tmpArray[tmpPos++] = a[leftPos++];
        else
            tmpArray[tmpPos++] = a[rightPos++];

    while(leftPos<=leftEnd)
        tmpArray[tmpPos++] = a[leftPos++];
    while(rightPos<=rightEnd)
        tmpArray[tmpPos++] = a[rightPos++];

    for(int i=0; i<numElements; i++,rightEnd--)
        a[rightEnd] = tmp[rightEnd];
}

与其他的O(NlogN)排序算法比较,归并排序的运行时间严重依赖于比较元素和在数组(以及临时数组)中移动元素的相对开销。这些开销是与语言相关的。

例如,在Java中,当执行一次泛型排序(使用Comparator)时,进行一次元素比较可能是昂贵的(因为比较可能不容易被内嵌,从而动态调度的开销可能会减慢执行的速度),但是移动元素则是省时的(因为它们是引用的赋值,而不是庞大对象的拷贝)。归并排序使用所有流行的排序算法中最少的比较次数,因此是使用Java的通用排序算法中的上好的选择。事实上,它就是标准Java类库中泛型排序所使用的算法。

另一方面,在C++的泛型排序中,如果对象庞大,那么拷贝对象可能需要很大的开销,而由于编译器具有主动执行内嵌优化的能力,因此比较对象常常是相对省时的。在这种情形下,如果我们还能够使用更少的数据移动,那么有理由让一个算法多使用一些比较。下一节将要讨论的Quicksort达到了这种平衡,并且是C++库中通常所使用的排序例程。

在Java中,快速排序也用作基本类型的标准库排序。这里,比较和数据移动的开销是类似的,因此使用少得多的数据移动足以补偿那些附加的比较而且还有盈余。

7.7 快速排序

如果S中元素个数是0或1,则返回。
S中任一元素v,称之为枢纽元(pivot)。
S{v}S中其余元素)划分成两个不相交的集合:S1={xS{v}|xv}S2={xS{v}|xv}
返回{quicksort(S1)后跟v,继而返回quicksort(S2)}。

  1. 由于对那些等于枢纽元的元素的处理上,第3步分割的描述不是唯一的,因此这就成了一种设计决策。
  2. 如同归并排序那样,快速排序递归地解决两个子问题并需要线性的附加工作(第3步),不过,与归并排序不同,这两个子问题并不保证具有相等的大小,这是个潜在的隐患。快速排序更快的原因在于,第3步分割成两组实际上是在适当的位置进行并且非常有效,它的高效不仅可以弥补大小不等的递归调用的不足而且还能有所超出。

7.7.1 选取枢纽元

  1. 通常的、无知的选择是将第一个元素用作枢纽元。
  2. 预排序的输入(或具有一大段预排序数据的输入)是相当常见的,因此,使用第一个元素作为枢纽元是绝对可怕的坏主意,应该立即放弃这种想法。
  3. 一种安全的方针是随机选取枢纽元。一般来说这种策略非常安全,除非随机数发生器有问题(它并不像你可能想象的那么罕见),因为随机的枢纽元不可能总在接连不断地产生劣质的分割。另一方面,随机数的生成一般开销很大,根本减少不了算法其余部分的平均运行时间。
  4. 三数中值分割法(Median-of-Three Partitioning):一般的做法是使用左端、右端和中心位置上的三个元素的中值作为枢纽元。

7.7.2 分割策略

  1. 该法的第一步是通过将枢纽元与最后的元素交换使得枢纽元离开要被分割的数据段。i从第一个元素开始而j从倒数第二个元素开始。
  2. 当i在j的左边时,我们将i右移,移过那些小于枢纽元的元素,并将j左移,移过那些大于枢纽元的元素。当i和j停止时,i指向一个大元素而j指向一个小元素。
  3. 然后我们交换由i和j指向的元素,重复该过程直到i和j彼此交错为止。
  4. 此时,i和j已经交错,故不再交换。分割的最后一步是将枢纽元与i所指向的元素交换。
  5. 我们必须考虑的一个重要的细节是如何处理那些等于枢纽元的元素。问题在于当i遇到一个等于枢纽元的元素时是否应该停止,以及当j遇到一个等于枢纽元的元素时是否应该停止。直观地看,i和j应该做相同的工作,否则分割将出现偏向一方的倾向。例如,如果i停止而j不停,那么所有等于枢纽元的元素都将被分到S2中。
  6. 为了明确一种更好的办法,我们考虑数组中所有的元素都相等的情况。如果i和j都停止,那么在相等的元素间将有很多次交换。虽然这似乎没有什么意义,但是其正面的效果则是i和j将在中间交错,因此当枢纽元被替代时,这种分割建立了两个几乎相等的子数组。归并排序的分析告诉我们,此时总的运行时间为O(NlogN)
  7. 如果i和j都不停止,那么就应该有相应的程序防止i和j越出数组的端点,不进行交换的操作。虽然这样似乎不错,但是正确的实现方法却要把枢纽元交换到i最后到过的位置,这个位置是倒数第二个位置(或最后的位置,这依赖于精确的实现)。这样的做法将会产生两个非常不均衡的子数组。如果所有的关键字都是相同的,那么运行时间是O(N2)。对于预排序的输入而言,其效果与使用第一个元素作为枢纽元相同。它花费的时间是二次的可是却什么事也没干!
  8. 这样我们就发现,进行不必要的交换建立两个均衡的子数组要比蛮干冒险得到两个不均衡的子数组好。因此,如果i和j遇到等于枢纽元的关键字,那么我们就让i和j都停止。对于这种输入,这实际上是四种可能性中唯一的一种不花费二次时间的可能。

7.3.3 小数组

  1. 对于很小的数组(N20),快速排序不如插入排序。不仅如此,因为快速排序是递归的,所以这样的情形经常发生。通常的解决方法是对于小的数组不使用递归的快速排序,而代之以诸如插入排序这样的对小数组有效的排序算法。使用这种策略实际上可以节省大约15%(相对于不用截止的做法而自始至终使用快速排序时)的运行时间。一种好的截至范围(cutoff range)是N=10,虽然在5到20之间任一截止范围都有可能产生类似的结果。这种做法也避免了一些有害的退化情形,如取三个元素的中值而实际上却只有一个或两个元素的情况。

7.3.4 实际的快速排序例程

快速排序的驱动程序:

public static <T extends Comparable<? super T>> void quickSort(T[] a) {
    quicksort(a, 0, a.length-1);
}

选取枢纽元最容易的方法是对a[left]、a[right]、a[center]适当地排序。这种方法还有额外的好处,即该三元素中的最小者被分在a[left],而这正是分割阶段应该将它放到的位置。三元素中的最大者被分在a[right],这也是正确的位置,因为它大于枢纽元。因此,我们可以把枢纽元放到a[right-1]并在分割阶段将i和j初始化为left+1和right-2。因为a[left]比枢纽元小,所以将它用作j的警戒标记,这是另一个好处。因此,我们不必担心j跑过端点。由于i将停在那些等于枢纽元的关键字处,故将枢纽元存储在a[right-1]则提供一个警戒标记。
执行三数中值分割法的程序:

private static <T extends Comparable<? super T>> T median3(T[] a, int left, int right) {
    int center = (left+right)/2;
    if(a[center].compareTo(a[left])<0)
        swapReferences(a, left, center);
    if(a[right].compareTo(a[left])<0)
        swapReferences(a, left, right);
    if(a[right].compareTo(a[center])<0)
        swapReferences(a, center, right);
    // Place pivot at position right-1
    swapReferences(a, center, right-1);
    return a[right-1];

快速排序的主例程:

private static <T extends Comparable<? super T>> void quicksort(T[] a, int left, int right) {
    if(left + CUTOFF <= right) {
        T pivot = median3(a, left, right);
        // Begin partitioning
        int i = left, j = right-1;
        for(;;) {
            while(a[++i].compareTo(pivot)<0) { }
            while(a[--j].compareTo(pivot)>0) { }
            if(i<j)
                swapReferences(a, i, j);
            else
                break;
        }
        swapReferences(a,i,right-1); // Restore pivot

        quicksort(a, left, i-1);
        quicksort(a, i+1, right);
    }
    else // Do an insertion sort on the subarray
        insertionSort(a, left, right);
}

7.9 桶式排序

  1. 输入数据A1,A2,...,AN必须只由小于M的正整数组成(显然还可以对其进行扩充)。如果是这种情况,那么算法很简单:使用一个大小为M的称为count的数组,它被初始化为全0。于是,count有M个单元或称桶,这些桶初始化为空。当读Ai时,count[Ai]增1。在所有的输入数据读入后,扫描数组count,打印出排序后的表。该算法用时O(M+N)。如果M为O(N),那么总量就是O(N)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值