数据结构与算法之排序

排序

  • 冒泡排序(Bubble Sort)
  • 插入排序(Insertion Sort)
  • 归并排序(Merge Sort)
  • 快速排序(Quick Sort)
  • 堆排序(Heap Sort)
  • 计数排序(Counting Sort)
  • 桶排序(Bucket Sort)
  • 拓扑排序(Topological Sort)

冒泡排序(Bubble Sort)

基本思想

给定一个数组,我们把数组里的元素通通倒入到水池中,这些元素将通过相互之间的比较,按照大小顺序一个一个地像气泡一样浮出水面。

实现

每一轮,从杂乱无章的数组头部开始,每两个元素比较大小并进行交换,直到这一轮当中最大或最小的元素被放置在数组的尾部,然后不断地重复这个过程,直到所有元素都排好位置。其中,核心操作就是元素相互比较。

例题分析

给定数组 [2, 1, 7, 9, 5, 8],要求按照从左到右、从小到大的顺序进行排序。

解题思路

从左到右依次冒泡,把较大的数往右边挪动即可。

  1. 首先指针指向第一个数,比较第一个数和第二个数的大小,由于 2 比 1 大,所以两两交换,[1, 2, 7, 9, 5, 8]。

  2. 接下来指针往前移动一步,比较 2 和 7,由于 2 比 7 小,两者保持不动,[1, 2, 7, 9, 5, 8]。到目前为止,7 是最大的那个数。

  3. 指针继续往前移动,比较 7 和 9,由于 7 比 9 小,两者保持不动,[1, 2, 7, 9, 5, 8]。现在,9 变成了最大的那个数。

  4. 再往后,比较 9 和 5,很明显,9 比 5 大,交换它们的位置,[1, 2, 7, 5, 9, 8]。

  5. 最后,比较 9 和 8,9 比 8 大,交换它们的位置,[1, 2, 7, 5, 8, 9]。经过第一轮的两两比较,9 这个最大的数就像冒泡一样冒到了数组的最后面。

  6. 接下来进行第二轮的比较,把指针重新指向第一个元素,重复上面的操作,最后,数组变成了:[1, 2, 5, 7, 8, 9]。

  7. 在进行新一轮的比较中,判断一下在上一轮比较的过程中有没有发生两两交换,如果一次交换都没有发生,就证明其实数组已经排好序了。

实现代码

    public static void bubbleSort(int[] nums) {
        // 定义一个布尔变量 hasChange,用来标记每轮遍历中是否发生了交换
        boolean hasChange = true;
        for (int i = 0; i < nums.length - 1 && hasChange; i++) {
            // 每轮遍历开始,将 hasChange 设置为 false
            hasChange = false;
            // 进行两两比较,如果发现当前的数比下一个数还大,那么就交换这两个数,同时记录一下有交换发生
            for (int j = 0; j < nums.length - 1 - i; j++) {
                if (nums[j] > nums[j+1]) {
                    swap(nums, j, j+1);
                    hasChange = true;
                }
            }
        }
    }
	// 交换数组中的两个数
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

算法分析

空间复杂度

假设数组的元素个数是 n,由于在整个排序的过程中,我们是直接在给定的数组里面进行元素的两两交换,所以空间复杂度是 O(1)。

时间复杂度

  1. 给定的数组按照顺序已经排好

    在这种情况下,我们只需要进行 n−1 次的比较,两两交换次数为 0,时间复杂度是 O(n)。这是最好的情况。

  2. 给定的数组按照逆序排列

    在这种情况下,我们需要进行 n(n-1)/2 次比较,时间复杂度是 O(n2)。这是最坏的情况。

  3. 给定的数组杂乱无章

    在这种情况下,平均时间复杂度是 O(n2)。

由此可见,冒泡排序的时间复杂度是 O(n2)。它是一种稳定的排序算法。(稳定是指如果数组里两个相等的数,那么排序前后这两个相等的数的相对位置保持不变。)

插入排序(Insertion Sort)

基本思想

不断地将尚未排好序的数插入到已经排好序的部分。

特点

在冒泡排序中,经过每一轮的排序处理后,数组后端的数是排好序的;而对于插入排序来说,经过每一轮的排序处理后,数组前端的数都是排好序的。

例题分析

对数组 [2, 1, 7, 9, 5, 8] 进行插入排序。

解题思路

首先将数组分成左右两个部分,左边是已经排好序的部分,右边是还没有排好序的部分,刚开始,左边已排好序的部分只有第一个元素 2。接下来,我们对右边的元素一个一个进行处理,将它们放到左边。

  1. 先来看 1,由于 1 比 2 小,需要将 1 插入到 2 的前面,做法很简单,两两交换位置即可,[1, 2, 7, 9, 5, 8]。
  2. 然后,我们要把 7 插入到左边的部分,由于 7 已经比 2 大了,表明它是目前最大的元素,保持位置不变,[1, 2, 7, 9, 5, 8]。
  3. 同理,9 也不需要做位置变动,[1, 2, 7, 9, 5, 8]。
  4. 接下来,如何把 5 插入到合适的位置。首先比较 5 和 9,由于 5 比 9 小,两两交换,[1, 2, 7, 5, 9, 8],继续,由于 5 比 7 小,两两交换,[1, 2, 5, 7, 9, 8],最后,由于 5 比 2 大,此轮结束。
  5. 最后一个数是 8,由于 8 比 9 小,两两交换,[1, 2, 5, 7, 8, 9],再比较 7 和 8,发现 8 比 7 大,此轮结束。到此,插入排序完毕。

实现代码

    public static void insertionSort(int[] nums) {
        // 将数组的第一个元素当作已经排好序的,从第二个元素,即 i 从 1 开始遍历数组
        for (int i = 1, j, current; i < nums.length; i++) {
            // 外围循环开始,把当前 i 指向的值用 current 保存
            current = nums[i];
            // 指针 j 内循环,和 current 值比较,若 j 所指向的值比 current 值大,则该数右移一位
            for (j = i - 1; j >= 0 && nums[j] > current; j--) {
                nums[j + 1] = nums[j];
            }
            // 内循环结束,j+1 所指向的位置就是 current 值插入的位置
            nums[j + 1] = current;
        }
    }

算法分析

空间复杂度

假设数组的元素个数是 n,由于在整个排序的过程中,是直接在给定的数组里面进行元素的两两交换,空间复杂度是 O(1)。

时间复杂度

  1. 给定的数组按照顺序已经排好

    只需要进行 n-1 次的比较,两两交换次数为 0,时间复杂度是 O(n)。这是最好的情况。

  2. 给定的数组按照逆序排列

    在这种情况下,我们需要进行 n(n-1)/2 次比较,时间复杂度是 O(n2)。这是最坏的情况。

  3. 给定的数组杂乱无章

    在这种情况下,平均时间复杂度是 O(n2)。

由此可见,和冒泡排序一样,插入排序的时间复杂度是 O(n2),并且它也是一种稳定的排序算法。

归并排序(Merge Sort)

基本思想

核心是分治,就是把一个复杂的问题分成两个或多个相同或相似的子问题,然后把子问题分成更小的子问题,直到子问题可以简单的直接求解,最原问题的解就是子问题解的合并。归并排序将分治的思想体现得淋漓尽致。

实现

一开始先把数组从中间划分成两个子数组,一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素,才开始排序。

排序的方法就是按照大小顺序合并两个元素,接着依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好。

例题分析

例题:利用归并排序算法对数组 [2, 1, 7, 9, 5, 8] 进行排序。

解题思路

首先不断地对数组进行切分,直到各个子数组里只包含一个元素。

接下来递归地按照大小顺序合并切分开的子数组,递归的顺序和二叉树里的前序遍历类似。

  1. 合并 [2] 和 [1] 为 [1, 2]。
  2. 子数组 [1, 2] 和 [7] 合并。
  3. 右边,合并 [9] 和 [5]。
  4. 然后合并 [5, 9] 和 [8]。
  5. 最后合并 [1, 2, 7] 和 [5, 8, 9] 成 [1, 2, 5, 8, 9],就可以把整个数组排好序了。

合并数组 [1, 2, 7] 和 [5, 8, 9] 的操作步骤如下。

  1. 把数组 [1, 2, 7] 用 L 表示,[5, 8, 9] 用 R 表示。
  2. 合并的时候,开辟分配一个新数组 T 保存结果,数组大小应该是两个子数组长度的总和
  3. 然后下标 i、j、k 分别指向每个数组的起始点。
  4. 接下来,比较下标i和j所指向的元素 L[i] 和 R[j],按照大小顺序放入到下标 k 指向的地方,1 小于 5。
  5. 移动 i 和 k,继续比较 L[i] 和 R[j],2 比 5 小。
  6. i 和 k 继续往前移动,5 比 7 小。
  7. 移动 j 和 k,继续比较 L[i] 和 R[j],7 比 8 小。
  8. 这时候,左边的数组已经处理完毕,直接将右边数组剩余的元素放到结果数组里就好。

合并之所以能成功,先决条件必须是两个子数组都已经分别排好序了。

实现代码

    public static void mergeSort(int[] arr, int lo, int hi) {
        // 判断是否只剩下最后一个元素
        if (lo >= hi) {
            return;
        }
        // 从中间将数组分成两个部分
        int mid = lo + (hi - lo) / 2;
        // 分别递归地将左右两半排好序
        mergeSort(arr, lo, mid);
        mergeSort(arr, mid + 1, hi);
        // 将排好序的左右两半合并
        merge(arr, lo, mid, hi);
    }
    // 归并
    public static void merge(int[] nums, int lo, int mid, int hi) {
        // 复制一份原来的数组
        int[] copy = nums.clone();
        // 定义一个 k 指针表示从什么位置开始修改原来的数组,i 指针表示左半边的起始位置,j 表示右半边的起始位置
        int k = lo, i = lo, j = mid + 1;
        while(k <= hi) {
            if(i > mid) {
                nums[k++] = copy[j++];
            } else if(j > hi) {
                nums[k++] = copy[i++];
            } else if(copy[j] < copy[i]) {
                nums[k++] = copy[j++];
            } else {
                nums[k++] = copy[i++];
            }
        }
    }

其中,While 语句比较,一共可能会出现四种情况。

  • 左半边的数都处理完毕,只剩下右半边的数,只需要将右半边的数逐个拷贝过去。
  • 右半边的数都处理完毕,只剩下左半边的数,只需要将左半边的数逐个拷贝过去就好。
  • 右边的数小于左边的数,将右边的数拷贝到合适的位置,j 指针往前移动一位。
  • 左边的数小于右边的数,将左边的数拷贝到合适的位置,i 指针往前移动一位。

算法分析

空间复杂度

由于合并 n 个元素需要分配一个大小为 n 的额外数组,合并完成之后,这个数组的空间就会被释放,所以算法的空间复杂度就是 O(n)。归并排序也是稳定的排序算法。

时间复杂度

归并算法是一个不断递归的过程。

举例:数组的元素个数是 n,时间复杂度是 T(n) 的函数。

解法:把这个规模为 n 的问题分成两个规模分别为 n/2 的子问题,每个子问题的时间复杂度就是 T(n/2),那么两个子问题的复杂度就是 2×T(n/2)。当两个子问题都得到了解决,即两个子数组都排好了序,需要将它们合并,一共有 n 个元素,每次都要进行最多 n-1 次的比较,所以合并的复杂度是 O(n)。由此我们得到了递归复杂度公式:T(n) = 2×T(n/2) + O(n)。

对于公式求解,不断地把一个规模为 n 的问题分解成规模为 n/2 的问题,一直分解到规模大小为 1。如果 n 等于 2,只需要分一次;如果 n 等于 4,需要分 2 次。这里的次数是按照规模大小的变化分类的。

以此类推,对于规模为 n 的问题,一共要进行 log(n) 层的大小切分。在每一层里,我们都要进行合并,所涉及到的元素其实就是数组里的所有元素,因此,每一层的合并复杂度都是 O(n),所以整体的复杂度就是 O(nlogn)。

快速排序(Quick Sort)

基本思想

快速排序也采用了分治的思想。

实现

把原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组。

举例:把班里的所有同学按照高矮顺序排成一排。

解法:老师先随机地挑选了同学 A,让所有其他同学和 A 比高矮,比 A 矮的都站在 A 的左边,比 A 高的都站在 A 的右边。接下来,老师分别从左边和右边的同学里选择了同学 B 和 C,然后不断地筛选和排列下去。

在分成较小和较大的两个子数组过程中,如何选定一个基准值(也就是同学 A、B、C 等)尤为关键。

例题分析

对数组 [2, 1, 7, 9, 5, 8] 进行排序。

解题思路

  1. 按照快速排序的思想,首先把数组筛选成较小和较大的两个子数组。
  2. 随机从数组里选取一个数作为基准值,比如 7,于是原始的数组就被分成了两个子数组。注意:快速排序是直接在原始数组里进行各种交换操作,所以当子数组被分割出来的时候,原始数组里的排列也被改变了。
  3. 接下来,在较小的子数组里选 2 作为基准值,在较大的子数组里选 8 作为基准值,继续分割子数组。
  4. 继续将元素个数大于 1 的子数组进行划分,当所有子数组里的元素个数都为 1 的时候,原始数组也被排好序了。

实现代码

	public static void quickSort(int[] nums, int lo, int hi) {
        // 判断是否只剩下一个元素,是则直接返回
        if (lo >= hi) {
            return;
        }
        // 利用partition函数找到一个随机基准点
        int p = partition(nums, lo, hi);
        // 递归地对基准点左半边和右半边的数进行排序
        quickSort(nums, lo, p - 1);
        quickSort(nums, p + 1, hi);
    }
    // 获得基准值
    public static int partition(int[] nums, int lo, int hi) {
        // 随机选择一个数作为基准值,nums[hi] 就是基准值
        swap(nums, randRange(lo, hi), hi);
        int i, j;
        // 从左到右用每个数和基准值比较,若比基准值小,则放到指针 i 所指向的位置。循环完毕后,i 指针之前的数都比基准值小
        for (i = lo, j = lo; j < hi; j++) {
            if (nums[j] <= nums[hi]) {
                swap(nums, i++, j);
            }
        }
        // 末尾的基准值放置到指针 i 的位置,i 指针之后的数都比基准值大
        swap(nums, i, j);
        // 返回指针 i,作为基准点的位置
        return i;
    }
    // 获取随机值
    public static int randRange(int lo, int hi) {
        return (int) (lo + Math.random() * (hi - lo + 1));
    }

    // 交换数组中的两个数
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

算法分析

空间复杂度

和归并排序不同,快速排序在每次递归的过程中,只需要开辟 O(1) 的存储空间来完成交换操作实现直接对数组的修改,又因为递归次数为 logn,所以它的整体空间复杂度完全取决于压堆栈的次数,因此它的空间复杂度是 O(logn)。

时间复杂度

  1. 最优情况:被选出来的基准值都是当前子数组的中间数。

    这样的分割,能保证对于一个规模大小为 n 的问题,能被均匀分解成两个规模大小为 n/2 的子问题(归并排序也采用了相同的划分方法),时间复杂度就是:T(n) = 2×T(n/2) + O(n)。

    把规模大小为 n 的问题分解成 n/2 的两个子问题时,和基准值进行了 n-1 次比较,复杂度就是 O(n)。很显然,在最优情况下,快速排序的复杂度也是 O(nlogn)。

  2. 最坏情况:基准值选择了子数组里的最大或者最小值

    每次都把子数组分成了两个更小的子数组,其中一个的长度为 1,另外一个的长度只比原子数组少 1。划分过程和冒泡排序的过程类似,算法复杂度为 O(n2)。

tips:可以通过随机地选取基准值来避免出现最坏的情况。

堆排序(Heap Sort)

基本思想

堆排序是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

实现

  • 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
  • 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
  • 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

实现代码

    public static void heapSort(int[] arr) {
        len = arr.length;
        if (len < 1) {
            return;
        }
        //1.构建一个大根堆
        buildMaxHeap(arr);
        //2.循环将堆首位(最大值)与末位交换,然后再重新调整最大堆
        while(len > 0) {
            swap(arr, 0, len-1);
            len--;
            adjustHeap(arr, 0);
        }
    }
    // 建立大根堆
    public static void buildMaxHeap(int[] arr) {
        // 从最后一个非叶子节点开始向上构造大根堆
        for (int i = (len / 2 -1); i >= 0; i--) {
            adjustHeap(arr, i);
        }
    }
    // 调整使之成为大根堆
    public static void adjustHeap(int[] arr, int i ) {
        int maxIndex = i;
        // 如果有左子树且左子树大于父节点,则将最大指针指向左子树
        if (i * 2 < len && arr[i * 2] > arr[maxIndex]) {
            maxIndex = i * 2;
        }
        // 如果有右子树且右子树大于父节点,则将最大指针指向右子树
        if (i * 2 + 1 < len && arr[i * 2 + 1] > arr[maxIndex]) {
            maxIndex = i * 2 + 1;
        }
        // 如果父节点不是最大值,则将父节点与最大值交换并递归调整与父节点交换的位置
        if (maxIndex != i) {
            swap(arr, maxIndex, i);
            adjustHeap(arr, maxIndex);
        }
    }
    // 交换数组中的两个数
    public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

算法分析

时间复杂度

 堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)...1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。

计数排序(Counting Sort)

基本思想

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序(Counting sort)是一种稳定的排序算法。计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。它只能对整数进行排序。

实现

  • 找出待排序的数组中最大和最小的元素;
  • 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
  • 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
  • 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

动图演示
数据结构与算法之排序_第1张图片

实现代码

    public static void countingSort(int[] arr) {
        if (arr.length == 0) {
            return;
        }
        int bias, min = arr[0], max = arr[0];
        // 1.确认数组中的最大值最小值
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
            if (arr[i] < min) {
                min = arr[i];
            }
        }
        bias = 0 - min; // bias记录新数组的下标偏移量
        int[] bucket = new int[max - min + 1];
        // 2.统计并存入新数组
        for (int i = 0; i < arr.length; i++) {
            bucket[arr[i] + bias]++;
        }
        int index = 0, i = 0;
        // 3.反向填充目标数组
        while(index < arr.length) {
            if (bucket[i] != 0) {
                arr[index] = i - bias;
                bucket[i]--;
                index++;
            } else {
                i++;
            }
        }
    }

算法分析

当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 O(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。

最佳情况:T(n) = O(n+k) 最差情况:T(n) = O(n+k) 平均情况:T(n) = O(n+k)

桶排序(Bucket Sort)

思想

桶排序是计数排序的升级版。当数列取值范围过大,或者不是整数时不能适用计数排序,这时可以使用桶排序来解决问题。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。

桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)

实现

每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。桶排序的第一步,就是创建这些桶,确定每一个桶的区间范围:

数据结构与算法之排序_第2张图片

具体建立多少个桶,如何确定桶的区间范围,有很多不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除了最后一个桶只包含数列最大值,前面各个桶的区间按照比例确定。

区间跨度 = (最大值-最小值)/ (桶的数量 - 1)

第二步,遍历原始数列,把元素对号入座放入各个桶中:

数据结构与算法之排序_第3张图片

第三步,每个桶内部的元素分别排序(显然,只有第一个桶需要排序):

数据结构与算法之排序_第4张图片

第四步,遍历所有的桶,输出所有元素:

0.5,0.84,2.18,3.25,4.5

到此为止,排序结束。

实现代码

    public static void bucketSort(double[] array){
        //1.得到数列的最大值和最小值,并算出差值d
        double max = array[0];
        double min = array[0];
        for(int i=1; i max) {
                max = array[i];
            }
            if(array[i] < min) {
                min = array[i];
            }
        }
        double d = max - min;
        //2.初始化桶
        int bucketNum = array.length;
        ArrayList> bucketList = new ArrayList>(bucketNum);
        for(int i = 0; i < bucketNum; i++){
            bucketList.add(new LinkedList());
        }
        //3.遍历原始数组,将每个元素放入桶中
        for(int i = 0; i < array.length; i++){
            int num = (int)((array[i] - min) * (bucketNum-1) / d);
            bucketList.get(num).add(array[i]);
        }
        //4.对每个通内部进行排序
        for(int i = 0; i < bucketList.size(); i++){
            //JDK底层采用了归并排序或归并的优化版本
            Collections.sort(bucketList.get(i));
        }
        //5.输出全部元素
        int index = 0;
        for(LinkedList list : bucketList){
            for(double element : list){
                array[index] = element;
                index++;
            }
        }
    }

算法分析

时间复杂度:O(N + C)

对于待排序序列大小为 N,共分为 M 个桶,主要步骤有:

  • N 次循环,将每个元素装入对应的桶中
  • M 次循环,对每个桶中的数据进行排序(平均每个桶有 N/M 个元素)

一般使用较为快速的排序算法,时间复杂度为 O ( N l o g N ),实际的桶排序过程是以链表形式插入的。

整个桶排序的时间复杂度为:

O ( N ) + O ( M ∗ ( N / M ∗ l o g ( N / M ) ) ) = O ( N ∗ ( l o g ( N / M ) + 1 ) )

当 N = M 时,复杂度为 O ( N )

空间复杂度:O(N+M)

拓扑排序(Topological Sort)

基本思想

和前面介绍的几种排序不同,拓扑排序应用的场合不再是一个简单的数组,而是研究图论里面顶点和顶点连线之间的性质。拓扑排序就是要将这些顶点按照相连的性质进行排序。

要能实现拓扑排序,得有几个前提:

  1. 图必须是有向图
  2. 图里面没有环

拓扑排序一般用来理清具有依赖关系的任务。

举例:假设有三门课程 A、B、C,如果想要学习课程 C 就必须先把课程 B 学完,要学习课程 B还得先学习课程 A,所以得出课程的学习顺序应该是 A -> B -> C。

实现

  1. 将问题用一个有向无环图(DAG, Directed Acyclic Graph)进行抽象表达,定义出哪些是图的顶点,顶点之间如何互相关联。
  2. 可以利用广度优先搜索或深度优先搜索来进行拓扑排序。

例题分析

有一个学生想要修完 5 门课程的学分,这 5 门课程分别用 1、2、3、4、5 来表示,现在已知学习这些课程有如下的要求:

  • 课程 2 和 4 依赖于课程 1

  • 课程 3 依赖于课程 2 和 4

  • 课程 4 依赖于课程 1 和 2

  • 课程 5 依赖于课程 3 和 4

那么这个学生应该按照怎样的顺序来学习这 5 门课程呢?

解题思路

可以把 5 门课程看成是一个图里的 5 个顶点,用有向线段按照它们的相互关系连起来,于是得出下面的有向图。

首先可以看到,这个有向图里没有环,无论从哪个顶点出发,都不会再回到那个顶点。并且,这个图里并没有孤岛的出现,因此,我们可以对它进行拓扑排序。

方法就是,一开始的时候,对每个顶点统计它们各自的前驱(也就是入度):1(0),2(1),3(2),4(1),5(2)。

  1. 选择其中一个没有前驱(也就是入度为 0)的顶点,在这道题里面,顶点 1 就是我们要找的那个点,将它作为结果输出。同时删除掉该顶点和所有以它作为起始点的有向边,更新顶点的入度表。
  2. 接下来,顶点 2 就是下一个没有前驱的顶点,输出顶点 2,并将以它作为起点的有向边删除,同时更新入度表。
  3. 再来,顶点 4 成为了没有前驱的顶点,输出顶点 4,删除掉它和顶点 3 和 5 的有向边。
  4. 然后,顶点 3 没有了前驱,输出它,并删除它与 5 的有向边。
  5. 最后,顶点 5 没有前驱,输出它,于是得出最后的结果为:1,2,4,3,5。

一般来说,一个有向无环图可以有一个或多个拓扑排序的序列。

实现代码

运用广度优先搜索的方法对这个图的结构进行遍历。在构建这个图的过程中,用一个链接矩阵 adj 来表示这个图的结构,用一个 indegree 的数组统计每个顶点的入度,重点看如何实现拓扑排序。

void topologicalSort() {
    Queue q = new LinkedList(); // 定义一个队列 q

    // 将所有入度为 0 的顶点加入到队列 q
    for (int v = 0; v < V; v++) {
        if (indegree[v] == 0) q.add(v);
    }

    // 循环,直到队列为空
    while (!q.isEmpty()) {
        int v = q.poll();
        // 每次循环中,从队列中取出顶点,即为按照入度数目排序中最小的那个顶点
        print(v);
        
        // 将跟这个顶点相连的其他顶点的入度减 1,如果发现那个顶点的入度变成了 0,将其加入到队列的末尾
        for (int u = 0; u < adj[v].length; u++) {
            if (--indegree[u] == 0) {
                q.add(u);
            }
        }
    }
}

算法分析

时间复杂度

统计顶点的入度需要 O(n) 的时间,接下来每个顶点被遍历一次,同样需要 O(n) 的时间,所以拓扑排序的时间复杂度是 O(n)。

你可能感兴趣的:(数据结构与算法之排序)