欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

分区和快速排序

最编程 2024-05-21 21:02:03
...

1. 分治法 D&C

1.1 什么是分治法?

分治法(divide and conquer,D&C)是一种算法思想,面对许多有一定规模的问题时,我们很难直接对其进行求解,这时候就需要考虑能否对其分而治之

通过把一个复杂的问题,分解成有限个相同的子问题,再把分解的子问题继续分解,经过不断地缩小问题的规模,子问题的规模已经被缩小到能被我们直接求解,把所有子问题的解合并起来,就是原问题的解。

1.2 分治法如何解决问题?

分治法的基本思想:把一个大规模的问题化成有限个易求解的子问题,再把子问题的解合并,就得到了原问题的解。

这里我们通过一个问题,来更好地理解这段话。这个问题就是如何将一块1680x640长方形,分成若干个正方形,并且这些正方形要足够地大。

image-20220717185719328

如何将一块地均匀地分成正方形,并确保分出的正方形是最大的呢?这里就可以使用分治法来解决。

使用分治法解决问题包括两个步骤:

  1. 找出基线条件。基线条件就意味着问题在这种条件下已经容易求解,可以直接计算出答案。
  2. 不断将问题分解,缩小问题的规模,直到符合基线条件。

下面我们就遵循这两个步骤,使用分治法来对这个问题进行求解:

首先,找出基线条件。

想要找出基线条件,我们需要思考的是,在什么情况下,能够非常容易地求出问题的答案?对于这个问题,很明显,当矩形的一边是另一边的整数倍时,我们能够非常容易地得到解。例如,矩形的一条边长为nx,另一条边为x,显然此时的解就是将这个矩形分成边长为x的正方形。

找出基线条件后,我们需要做的就是不断缩小问题的规模。我们把需要继续分解时的条件称为递归条件。对于这个问题,我们如何缩小问题的规模呢?首先,我们可以找出这个矩形可以容纳的最大正方形。

image-20220717192252834

现在,我们就只需要找出640x400这个矩形可以容纳的最大正方形,就能够得到整个矩形的解,也就是说,我们已经将问题的规模从1680缩小到了640。接下来,我们只需要继续对640x400这个矩形重复这个过程,就可以将规模不断缩小,直到符合基线条件,也就是矩形长是宽的整数倍,最后这个基线条件的解,就是整个矩形的解。

这个问题的代码实现如下:

	package com.frontend.day41;

/**
 * @Classname Demo1
 * @Description 分解矩形
 * @Date 2022/7/17 19:30
 * @Created by Yang Yi-zhou
 */
public class Demo1 {
    public static int findMaxSquare(int l, int aol) {
        int length = Math.max(l, aol);
        int width = Math.min(l, aol);
        //基线条件
        if (length % width == 0) {
            return width;
        }
        //递归条件
        return findMaxSquare(length % width, width);
    }

    public static void main(String[] args) {
        System.out.println(findMaxSquare(1680, 640));
    }
}

可以求出,最后的解为80,也就是这个1680x640的矩形可以均匀分解成若干个正方形的最大的边长的是80。

image-20220717193813849

总之,分治法的解决问题的步骤就是:

  1. 找出简单的基线条件。
  2. 确定如何缩小问题的规模,使其符合基线条件。

1.3 分治法适用于什么问题?

分治法对于解决一些大规模问题十分有效,但他也不是万能的,当我们要使用分治法解决问题时,应该要满足一下几点:

  1. 当问题缩小到一定程度时,就可以很容易的求解。
  2. 该问题可以分解成若干个子问题,并且该问题和子问题应该时相同的问题。
  3. 子问题的解,可以得出原问题的解。
  4. 子问题应该是独立的。

第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加。

第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用。

第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。

第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。

注:常用的算法还有动态规划算法、贪心算法、回溯法、分支限界算法等。

2. 快速排序

2.1 快速排序的思想

有一个整数类型的数组,我们要对它进行排序,该怎么进行呢?

根据分治法的思想,首先,我们先找到对一个数组进行排序的基线条件。很明显,只有一个元素或者没有元素的时候,对一个数组排序时最容易的,因为它根本不需要排序。

  • **基线条件1:**如果数组没有元素,或者只有一个元素,数组排序完成。

我们继续增加一个元素,要对有2个元素的数组进行排序同样很简单,只需要判断哪个元素比较大,大的元素排在小的元素之后,就完成了排序。

  • **基线条件2:**如果数组有2个元素,直接比较排序。

现在继续增加一个元素,对于一个有3个元素的数组该怎么进行排序呢?似乎情况就有点复杂起来了,我们不能通过一次简单的计算就把数组排序好。回想前面分治法的思想,对于3个元素的数组,我们无法简单的就完成对它的排序,所以我们不如思考如何将它的问题规模缩小

只要我们能够缩小问题的规模,直到符合基线条件,我们就可以很容易地对数组进行排序了。现在,对于3个元素的数组,只要我们能够把它转换成排序2个、1个或者没有元素的数组,我们就可以对其完成排序。快速排序正是基于这一理念来实现的。

2.2 快速排序的步骤

快速排序的原理就是,首先,从数组中选取一个元素,这个元素被叫做基准值(pivot)。

然后,找出所有比基准值小以及比基准值大的值,这个步骤叫做分区(partitioning)。

现在,你就有了:

  • 一个所有元素都比基准值小的数组
  • 基准值
  • 一个所有元素都比基准值大的数组

注意这里我们只是进行了分区,两个子数组仍然是无序的,但是我们假设这两个子数组是有序的,那么现在对整个子数组的排序就十分容易了——只需要把合并两个子数组和基准值,我们就完成了对于整个数组的排序。

如何对子数组进行排序呢?

  • 对于元素小于等于2个的数组,也就是符合基线条件的数组,快速排序已经知道了如何对它进行排序
  • 对于元素大于2个的数组,快速排序也知道了如何将它的规模缩小

因此,只要对这两个子数组进行快速排序,再合并结果,我们完成了对于整个数组的排序了。

2.3 快速排序的代码实现

实现步骤

快速排序的实现重点在于partitioning部分,这里我们采用双指针双向遍历的方式来实现,这种方式并不高效,但是易于理解。

我们以**{-1, 0, 4, -7, 14, 9}**这个整型数组为例,来模拟一下partitioning的过程。

这里,我们定义两个指针,一个low指针从左向右遍历,一个high指针从右向左遍历。

  1. 先使用数组的第一个元素作为pivot,pivot=-1,low指针指向数组第一个元素,high指针指向数组的最后一个元素

    image-20220718153449249
  2. low、high指针开始遍历,找到比pivot大、小的值

    image-20220718153546083

  3. 交换low和high的值

    image-20220718153645418
  4. 再将low指针指向的值与pivot交换,就完成了一次分区。两个子数组分别为**{-7, 0}{4, 14, 9}**,只需要再对这两个子数组进行快速排序,整个数组的排序就完成了。

    image-20220718153927903

代码细节

这里需要注意一个细节,low指针和high指针相遇时(没有重叠)遍历实际上就已经结束,但是在代码实现中,我们需要通过判断指针是否重叠来判断遍历是否结束,这里的问题就在于,是让low向右边多走一步与high重叠,还是让high向左多走一步与low重叠呢?(代码层面上,就是low指针和high指针谁先开始遍历)

事实上,low指针和high指针谁先开始遍历,要取决于我们的pivot取值,因为在low与high重叠后,最后一步就是要将low/high指针指向的元素与pivot元素交换,来实现分区。所以可以有如下两个逻辑:

  1. 如果pivot的值取的是数组的第一个元素,而这个位置最后属于偏小子数组,所以low/high指针,最后也应该指向小数组的最右端,也就是应该让high指针先走,才能够保证最后pivot左边的值都比pivot小
  2. 如果pivot的值取得是数组得最后一个元素,同理,这个位置最后属于偏大子数组,所以最后low/high指针应该指向大数组得最左端,也就是应该让low指针先走

总结一下就是:

  1. 左端作pivot,先从右端开始遍历
  2. 右端作pivot,先从左端开始遍历

代码实现

package com.frontend.day41.demo;

import java.util.Arrays;

/**
 * @Classname QuickSort
 * @Description
 * @Date 2022/7/17 21:27
 * @Created by Yang Yi-zhou
 */
public class QuickSort {
    public static void main(String[] args) {
        int[] arr = {-1, 0, 4, -7, 14, 9};
        System.out.println(Arrays.toString(arr));
        sort(arr);
        System.out.println(Arrays.toString(arr));
    }

    public static void sort(int[] arr) {
        partitioning(arr, 0, arr.length - 1);
    }

    public static void partitioning(int[] arr, int low, int high) {
        //基线条件
        if (high - low < 1) {
            return;
        }
        if (high - low == 1) {
            if (arr[low] > arr[high]) {
                swap(arr, low, high);
            }
            return;
        }
        //标记头、尾
        int left = low;
        int right = high;
        int pivot = arr[left];
        //递归条件
        while (low < high) {
            while (low < high && arr[high] >= pivot) {
                high--;
            }
            while (low < high && arr[low] <= pivot) {
                low++;
            }
            swap(arr, low, high);
        }
        swap(arr, left, low);

        partitioning(arr, left, low - 1);
        partitioning(arr, low + 1, right);
    }

    private static void swap(int[] arr, int low, int high) {
        int temp = arr[low];
        arr[low] = arr[high];
        arr[high] = temp;
    }

}

运行结果:

image-20220718155612092

3. 总结

  • 分治算法的步骤:
    • 找出简单的基线条件。
    • 确定如何缩小问题的规模,使其符合基线条件。
  • 分治算法的核心:
    • 如何分,也就是如何缩小问题的规模
  • 分治法的重要应用:
    • 快速排序是如何实现的
  • 双指针双向遍历中,哪个指针先走?
    • 左端作pivot,先从右端开始遍历
    • 右端作pivot,先从左端开始遍历