算法导论真的好(厚)啊,刚看到排序

211 阅读3分钟

排序

插入排序

插入排序适合元素较少的情况。

循环不变式

  • 初始化:循环的第一次迭代之前,它为真。
  • 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
  • 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。

代码实现

  • 升序

    public static void insertionSort(int[] A) {
        for (int i = 1; i < A.length; i++) {
            int key = A[i];
            int j = i - 1;
            while (j >= 0 && A[j] > key) {
                A[j + 1] = A[j];
                j--;
            }
            A[j + 1] = key;
        }
    }
    
  • 降序

    public static void insertionSort(int[] A) {
        for (int i = 1; i < A.length; i++) {
            int key = A[i];
            int j = i - 1;
            while (j >= 0 && A[j] < key) {
                A[j + 1] = A[j];
                j--;
            }
            A[j + 1] = key;
        }
    }
    

最好的情况是数组本来就完全有序或基本有序,时间复杂度可以降到O(n),平均情况下,时间复杂度为O(n^2^),空间复杂度为O(1).

注意:将插入过程中的查找过程使用二分查找可以有效地减少比较次数,但移动元素的次数仍然不变,此时运行总时间依然是O(n^2^),而不会变成O(nlgn)。

算法习题

把两个n位二进制数相加。

public static int[] addBinary(int[] A,int[] B) {
    int[] C = new int[A.length + 1];
    int carry = 0; 
    for (int i = 0; i < A.length; i++) {
        C[i] = (A[i] + B[i] + carry) % 2;
        carry = (A[i] + B[i] + carry) / 2;
    }
    C[i] = carry;
    return C;
}

归并排序

归并排序算法完全遵循分治模式。

  • 分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列

  • 解决:使用归并排序递归地排序两个子序列

  • 合并:合并两个已排序的子序列以产生已排序的答案

代码实现

public static void mergeSort(int[] A,int p,int r) {
    if (p < r) {
        int q = (p + r) / 2;
        mergeSort(A,p,q);
        mergeSort(A,q + 1,r);
        merge(A,p,q,r);
    }
}

public static void merge(int[] A,int p,int q,int r) {
    int n1 = q - p + 1;
    int n2 = r - q;
    int[] L = new int[n1 + 1];
    int[] R = new int[n2 + 1];
    for (int i = 0; i < n1; i++) {
        L[i] = A[p + i];
    }
    for (int j = 0; j < n2; j++) {
        R[j] = A[q + j + 1];
    }
    //设置两个哨兵
    L[n1] = Integer.MAX_VALUE;
    R[n2] = Integer.MAX_VALUE;
    int i = 0,j = 0;
    for (int k = p; k <= r; k++) {
        if (L[i] <= R[j]) {
            A[k] = L[i];
            ++i;
        } else {
            A[k] = R[j];
            ++j;
        }
    }
}

如果merge中不使用哨兵,则代码应修改为:

public static void merge(int[] A,int p,int q,int r) {
    int n1 = q - p + 1;
    int n2 = r - q;
    int[] L = new int[n1];
    int[] R = new int[n2];
    for (int i = 0; i < n1; i++) {
        L[i] = A[p + i];
    }
    for (int j = 0; j < n2; j++) {
        R[j] = A[q + j + 1];
    }

    int i = 0,j = 0;
    int k;
    for (k = p; i < L.length && j < R.length; k++) {
        if (L[i] <= R[j]) {
            A[k] = L[i];
            ++i;
        } else {
            A[k] = R[j];
            ++j;
        }
    }
//下面两个循环只会执行其中一个     
    while (i < L.length) {
        A[k] = L[i];
        ++i;
        ++k;
    }
    while (j < R.length) {
        A[k] = R[j];
        ++j;
        ++k;
    }
}

运行时间分析

分解:计算数组中间位置,需要常量是时间。

解决:递归地求解两个规模均为n/2的子问题,将贡献2T(n/2)的运行时间

合并:merge需要Θ(n)的时间

递归式为:

T(n)=Θ(1),n=1T(n)=2T(n/2)+Θ(n)+Θ(1)=2T(n/2)+Θ(n),n>1.T(n) = Θ(1),若n = 1;T(n)=2T(n/2)+Θ(n)+Θ(1) = 2T(n/2)+Θ(n),若n > 1.

由主定理可得递归式的解为:

T(n)=Θ(nlgn)T(n) = Θ(nlgn)

算法习题

确定n个整数的集合S中是否存在两个数其和刚好为x(S,x为参数)要求时间复杂度为O(nlgn)。

public static boolean practise(int[] A,int x) {
        mergeSort(A,0,A.length - 1);
        for (int i = 0; i < A.length; i++) {
            if (binarySearch(A,0,A.length - 1,i,x - A[i])) {
                return true;
            }
        }
        return false;
    }

	//归并排序,时间复杂度为O(nlgn)
    public static void mergeSort(int[] A,int p,int r) {
        if (p < r) {
            int q = (p + r) / 2;
            mergeSort(A,p,q);
            mergeSort(A,q + 1,r);
            merge(A,p,q,r);
        }
    }

    public static void merge(int[] A,int p,int q,int r) {
        int n1 = q - p + 1;
        int n2 = r - q;
        int[] L = new int[n1 + 1];
        int[] R = new int[n2 + 1];
        for (int i = 0; i < n1; i++) {
            L[i] = A[p + i];
        }
        for (int j = 0; j < n2; j++) {
            R[j] = A[q + j + 1];
        }
        L[n1] = Integer.MAX_VALUE;
        R[n2] = Integer.MAX_VALUE;
        int i = 0,j = 0;
        for (int k = p; k <= r; k++) {
            if (L[i] <= R[j]) {
                A[k] = L[i];
                ++i;
            } else {
                A[k] = R[j];
                ++j;
            }
        }
    }
	//二分查找,时间复杂度为O(lgn)
    public static boolean binarySearch(int[] A,int low,int high,int index,int x) { //传入index是为了保证找到的不是已经减去的值(如8 - 4 = 4)
        while (low <= high) {
            int mid = (low + high) / 2;
            if (x == A[mid] && mid != index) {
                return true;
            } else if (x < A[mid]) {
                high = mid - 1;
            } else {
                low = mid + 1;
            }
        }
        return false;
    }

快速排序

主要思想:与归并排序一样。快速排序也使用了分治的思想。

过程和实现

  • 分解:数组A[p..r]被划分为两个子数组A[p...q - 1]和A[q + 1,..r],其中A[p...q - 1]小于等于A[q],A[q + 1,..r]大于等于A[q].
  • 解决:通过递归调用快速排序,对子数组A[p...q - 1]和A[q + 1,..r]进行排序。
  • 合并:因为子数组都是原址排序的,不需要进行合并操作:数组A[p..r]已经有序。

代码实现(java):

public static void quickSort(int[] A,int p,int r) {
        if (p < r) {
            int q = partition(A,p,r);
            quickSort(A,p,q - 1);
            quickSort(A,q + 1,r);
        }

    }

    public static int partition(int[] A,int p,int r) {
        int x = A[r];
        int i = p - 1;
        for (int j = p; j <= r - 1; j++) {
            if (A[j] <= x) { //降序则只需将此处修改为if(A[j] >= x)
                i = i + 1;
                swap(A,i,j);
            }
        }
        swap(A,i + 1,r);
        return i + 1;
    }
    public static void swap(int[] A,int i,int j) {
        int x = A[i];
        A[i] = A[j];
        A[j] = x;
    }

快速排序的性能

  • 最坏情况划分

    当划分产生的两个子问题分别包含了n - 1个元素和0个元素时,快速排序的最坏情况发生。划分操作的时间复杂度为Θ(n²),算法运行时间的递归式为

    T(n)=T(n1)+T(0)+Θ(n)=T(n1)+Θ(n)T(n) = T(n - 1) + T(0) + Θ(n) = T(n - 1) + Θ(n)

    利用代入法可直接得到其解为

    T(n)=Θ(n2)T(n) = Θ(n²)

所以当输入数组完全有序时,快速排序的时间复杂度是Θ(n²),而在同样情况下插入排序的时间复杂度O(n).。

  • 最好情况划分

    当partition得到的两个子问题的规模都不大于n/2时,快速排序的性能最好。算法运行时间的递归式为:

    T(n)=2T(n/2)+Θ(n)T(n) = 2T(n / 2) + Θ(n)

    利用主方法可得到其解为

    T(n)=Θ(nlgn)T(n) = Θ(nlgn)

快速排序的平均情况更接近于其最好的情况,而非最坏的情况。

特殊情况:

  • 情况一:

    当数组中的元素都具有相同值时,QuickSort的时间复杂度是Θ(n^2^)。因为每次partition都会返回r,将数组划分为n - 1和0的子数组。为了让其此情况下可以返回(p + r) / 2,可作如下修改:

public static int partition(int[] A,int p,int r) {
        int x = A[r];
        int i = p - 1;
        for (int j = p; j <= r - 1; j++) {
            if (A[j] <= x) {
                i = i + 1;
                swap(A,i,j);
            }
        }
        swap(A,i + 1,r);
        if (i + 1 == r) {
            return (p + r) / 2;
        } else {
            return i + 1;
        }
    }
  • 情况二:

    当数组中的元素是降序排列时,QuickSort的时间复杂度为Θ(n^2^)。因为每次partition都会返回p(因为都比枢纽值大),将数组划分为0和n - 1的子数组。

  • 情况三:

    当数组中的元素基本满足升序排列时,QuickSort的时间复杂度为Θ(n^2^)。因为每次partition都会返回r(因为都比枢纽值小),将数组划分为你- 1和n0的子数组。

快速排序的随机化版本

前面都是选择数组的最后一个元素作为主元,现在为了随机化,可以从A[p..r]中随机选择一个元素作为主元,此时主元素是等概率地从数组中选取的。

代码实现:

public static void quickSort(int[] A,int p,int r) {
    if (p < r) {
        int q = randomizedPartition(A,p,r);
        quickSort(A,p,q - 1);
        quickSort(A,q + 1,r);
    }

}

public static int randomizedPartition(int[] A,int p,int r) {
    Random random = new Random();
    int i = random.nextInt(r - p + 1) + p;
    swap(A,i,r);
    return partition(A,p,r);
}
public static int partition(int[] A,int p,int r) {
    int x = A[r];
    int i = p - 1;
    for (int j = p; j <= r - 1; j++) {
        if (A[j] <= x) {
            i = i + 1;
            swap(A,i,j);
        }
    }
    swap(A,i + 1,r);
    return i + 1;
}
public static void swap(int[] A,int i,int j) {
    int x = A[i];
    A[i] = A[j];
    A[j] = x;
}

快速排序的运行时间是由在partitition操作上所花费的时间决定的。每调用一次partition都会选择一个主元素,而且该元素不会被包含在后续的quicksort和partition调用中。因此最多会调用n次partition操作。

整个过程中,数组中的每对元素最多比较一次,最少0次。

比较一次:因为每个元素至于主元素进行比较,在某一次划分操作后,此次调用所用的主元素就再也不会对任何其他元素进行比较了。

比较零次:被划分成两部分的元素永远不会进行比较。

计数排序 基本思想 对每一个输入元素想,确定小于x的元素个数。利用这一信息直接把x放到它在输出数组中的位置上。

代码实现

public static void countingSort(int[] A,int[] B,int k) {
    int[] C = new int[k + 1];  
    for (int i = 0; i < A.length; i++) {
        C[A[i]]++;
    }
    for (int i = 1; i < k + 1; i++) {
        C[i] += C[i - 1]; //每个元素表示的是数组中每个数自己加上小于等于它的数的个数
    }
    for (int i = A.length - 1; i >= 0; i--) {
        B[C[A[i]] - 1] = A[i];
        C[A[i]]--;
    }

}

public static int findMaxValue(int[] A) { //用来产生k
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < A.length; i++) {
        if (A[i] > max) {
            max = A[i];
        }
    }
    return max;
}

时间复杂度为O(n + k),空间复杂度为O(n + k)

计数排序不需要进行比较,而且是稳定的,这种稳定性只有当进行排序的数据还附带卫星数据时才比较重要。计数排序经常会被用作基数排序算法的一个子过程。

稳定的原因

  • 比如两个数值相等元素A[i] = A[j],其中i<j,在计数过程中,位于前面的A[i]总是先被计入,且在后面还原数组时,由于是从后往前遍历放置,原来在前面的元素小于等于它的数要更少,故可以保证其稳定性。

如果最后一个循环不是从后往前,而改为从前往后即i = 0 -->A.length - 1,仍可以完成排序,但无法保证稳定性。

算法拓展

根据该思想我们可以在O(1)时间内求出输入的n个整数落在区间[a,b]的个数。预处理阶段就是计数阶段,其时间复杂度为O(n + k)。

代码实现

public static int[] countNumbers(int[] A,int k) { //预处理
    int[] C = new int[k + 1];
    for (int i = 0; i < A.length; i++) {
        C[A[i]]++;
    }

    for (int i = 1; i < k + 1; i++) {
        C[i] += C[i - 1];
    }
    return C;
}
public static int countNumbersOfa_b(int[] C,int a,int b) {
    return C[b] - C[a - 1]; 
}