算法 - 排序

选择排序

算法推演

什么是选择排序?选择排序,就是从一堆数中有选择的挑选出最小的那个数。

算法核心思想:
每一次遍历,找出所有数字中最小的那个,放到最前面去。

假设现有数组:a[] = [6,3,4,2,1,5]
数组总长度 n = 6;
第一次:index=0,从a[]下标为 0 -> (n-1)中选出最小的数字,放到index处,即a[0]和a[4]数值交换
a[]=[1,3,4,2,6,5],此时a[0]为所有数字的最小值
第二次:index=1,从a[]下标为 1 -> (n-1)中选出最小的数字,放到index处,即a[1]和a[3]数值交换
a[]=[1,2,4,3,6,5],此时a[0] ,a[1]为所有数字中的最小值
后面逻辑类似,index=2,从a[]下标为 index ->(n-1)中选出最小的数字,放到index处。直至最后一个数字。

分析下选择排序算法,最坏情况下,每一次循环需要遍历(n-1),(n-2),(n-3)…1 个数据量,其时间复杂度为O(n2)

代码实现

public static void selectSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }

        for (int index = 0; index < arr.length - 1; index++) {
            // 用于记录本轮循环中,最小值的index
            int minDataIndex = index;

            // 从index开始,依次往后遍历,找到剩余数据的最小值的index
            for (int j = index + 1; j < arr.length; j++) {
                minDataIndex = arr[j] < arr[minDataIndex] ? j : minDataIndex;
            }

            // 将index处的值,与剩余数据的最小值进行交换
            swap(arr, index, minDataIndex);

        }
    }

冒泡排序

算法推演

假设现有数组:a[] = [6,3,4,2,1,5]

算法核心思想:
每一次遍历,将最大的数,挪到数组最后面。每一次遍历中,都是前后两个数字比较,较大的数据往后挪。

外层第1次循环
第一次:比较a[0]和a[1],6>3,需要交换: a[] = [3,6,4,2,1,5]
第二次:比较a[1]和a[2],6>4,需要交换: a[] = [3,4,6,2,1,5]
第三次:比较a[2]和a[3],6>2,需要交换: a[] = [3,4,2,6,1,5]
第四次:比较a[3]和a[4],6>1,需要交换: a[] = [3,4,2,1,6,5]
第五次:比较a[4]和a[5],6>5,需要交换: a[] = [3,4,2,1,5,6]
经过第一轮循环,最大值6已经被交换到了最后面。
此时:a[] = [3,4,2,1,5,6]

外层第2次循环
第一次:比较a[0]和a[1],3<4,不需要交换: a[] = [3,4,2,1,5,6]
第二次:比较a[1]和a[2],4>2,需要交换: a[] = [3,2,4,1,5,6]
第三次:比较a[2]和a[3],4>1,需要交换: a[] = [3,2,1,4,5,6]
第四次:比较a[3]和a[4],4<5,不需要交换: a[] = [3,2,1,4,5,6]
第五次:比较a[4]和a[5],5<6,不需要交换: a[] = [3,2,1,4,5,6]
经过第一轮循环,最大值5,6都已经被交换到了最后面。
此时:a[] = [3,2,1,4,5,6]

外层第3次循环:4,5,6放到了数组最右边
外层第四次循环,3,4,5,6放到了数组最右边

最终顺序: [1,2,3,4,5,6]

代码实现

public static void maoPaoSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }

        // 控制外层循环
        for (int index = arr.length - 1; index > 0; index++) {
            // 内层循环,到length-1为止
            for (int j = 0; j < index; j++) {
                // 相邻两个数进行比较,如果后一个数比前一个数小,交换数据
                if (arr[j] > arr[j + 1]) {
                    swap(arr, j, j + 1);
                }
            }
        }
    }

时间复杂度:O(n2)

选择排序 VS 冒泡排序:
两种算法相差不大,选择排序是选出最小的数字,在一次遍历完成之后,再进行数值交换,将最小的放到最左边。
冒泡排序,在一次遍历过程中,不间断的将最大的数字往后挪动,当一次遍历完成之后,最大的数字很自然的已经在最右边,不再需要进行交换。
时间复杂度都是O(n2)

插入排序

算法推演

算法核心思想:
从index开始依次往左检查,遇到小于当前数值的停止检测:
0->0,有序
0->1,有序
0->2,有序

0->n-1,有序

假设现有数组:a[] = [6,3,4,2,1,5]

index=0,0->0,即a[0] -> a[0],只有一个数字,天然有序

index=1:
第一轮:lindex=index-1=0,即检测a[0]与a[1],6>3,需要交换,
此时:a[] = [3,6,4,2,1,5],a[0] -> a[1]之间有序了

index=2:
第一轮:lindex=index-1=2-1=1,即检测a[2]与a[1],6>4,需要交换
此时:a[] = [3,4,6,2,1,5]
第二轮:lindex=index-2=2-2=0,即检测a[1]与a[0],3<4,不需要交换,终止
此时:a[] = [3,4,6,2,1,5],a[0] -> a[2]之间有序了

index=3:
第一轮:lindex=index-1= 3-1,即检测a[3] 与a[2],6>2,需要交换,
此时:a[] = [3,4,2,6,1,5]
第二轮:lindex=lindex-2= 3-2,即检测a[2]与a[1],4>2,需要交换,
此时:a[] = [3,2,4,6,1,5]
第三轮:lindex=lindex-3= 3-3,即检测a[1]与a[0],3>2,需要交换,
此时:a[] = [2,3,4,6,1,5],此时a[0] -> a[3]之间有序了

index=4:
第一轮:lindex=index-1= 4-1,即检测a[4] 与a[3],6>1,需要交换,
此时:a[] = [2,3,4,1,6,5]
第二轮:lindex=lindex-2= 4-2,即检测a[3]与a[2],2>1,需要交换,
此时:a[] = [2,3,1,4,6,5]
第三轮:lindex=lindex-3= 4-3,即检测a[2]与a[1],3>1,需要交换,
此时:a[] = [2,1,3,4,6,5]
第三轮:lindex=lindex-3= 4-4,即检测a[1]与a[0],2>1,需要交换,
此时:a[] = [1,2,3,4,6,5],此时a[0] -> a[4]之间有序了

index=5:
第一轮:lindex=index-1= 5-1,即检测a[5] 与a[4],6>5,需要交换,
此时:a[] = [1,2,3,4,5,6]
第一轮:lindex=index-1= 5-2,即检测a[4] 与a[3],4<5,不需要交换,终止
此时:a[] = [1,2,3,4,5,6],此时a[0] -> a[5]之间有序了

算法最坏情况下,时间复杂度:O(n2)

算法可以这样理解:从index处取出一个数,需要将这个数插入到其左边的某一个地方,由于新插入了一个数,必然会影响其左边原有数据的排序,因其左边在此之前已经是有序状态了,所以只要检测到一个数字比它小即可停止,此时左边依旧是有序的。

代码实现

public static void insertSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }

        // 遍历第index位置的数
        for (int index = 1; index < arr.length; index++) {
            // 将数据取出后,向左进行遍历,看该数应该插入在左侧的哪个位置
            for (int j = index - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
                swap(arr, j, j + 1);
            }
        }
    }

归并排序

算法推演

看到归并这两个字,肯定会想到合并,既然需要合并,那前提肯定是发生了拆分。由此可以看出,归并排序的核心点,就在于拆分和合并。我们先来看下简单的情况:
a[] = [2,1]
现在数组仅有2个元素,2和1,需要先拆分再合并,最终将元素排好序:
在这里插入图片描述
[2,1] 我们可以拆分成两个数组,[2]和[1],下面我们需要将[2] 和[1]内部先排好序,由于分别仅有一个元素,所以已经是排好序的了,然后再将[2],[1]两个数组进行排序,所以结果是[1,2]

下面我们将元素组进行扩展:
a[]=[3,2,1]
在这里插入图片描述
我们按照从中间切分的原则,可以将[3,2,1]切分成两个数组[3]和[2,1],而[2,1]又可以被切分成[2]和[1]。
我们现在已经可以做到[3],[2],[1]各自是有序的,因为他们数组各自只有一个元素。
假如我们可以将[2] 和[1] 变成有序数组[1,2],同时又可以将[1,2]和[3]变成有序数组[1,2,3],这样整个数组就可以做到有序了。
这便就是归并排序。

为了寻找这里面的规律,我们数据再扩充得大一些:
在这里插入图片描述
相信大家一定发现了,这就是一个递归的过程,将一个大数组拆分成两个独立的数组之后,如果两个数组内部有序了,有一套算法可以实现数组合并之后也有序,那整个数组就可以有序了。
如图:
[3],[7]已经各自有序,合并之后实现大数组[3,7]有序
[1],[3,7]已经各自有序,合并之后实现大数组[1,3,7]有序
[2],[1,3,7]已经各自有序了,合并之后实现大数组[1,2,3,7]有序
[1,2,3,7],[4,5,6]已经各自有序了,合并之后,即可实现[1,2,3,4,5,6,7]有序

所以问题变成了,如何将[1,2,3,7],[4,5,6] 两个各自有序的数组,合并成最终的[1,2,3,4,5,6,7],
或者将[3],[7] 合成最终的[3,7]
或者将[1], [3,7]合成最终的[1,3,7]

在这里插入图片描述我们可以将两个数组中的元素分别进行比较,[1,2,3,7]中从1开始,与[4,5,6]中的4开始比较,同时需要定义一个临时数组,用于存储排好序之后的大数组:
step1:[1,2,3,7]中index=0取出1,[4,5,6]中index=0取出4,由于1<4,所以本次比较后,先将1存入temp数组,[1,2,3,7]的index++,指向下一条数据
step2: [1,2,3,7]中index=1取出2,[4,5,6]中index=0取出4,由于2<4,所以本次比较后,先将2存入temp数组,[1,2,3,7]的index++,指向下一条数据
step 3:[1,2,3,7]中index=2取出3,[4,5,6]中index=0取出4,由于3<4,所以本次比较后,先将3存入temp数组,[1,2,3,7]的index++,指向下一条数据
step 4:[1,2,3,7]中index=3取出7,[4,5,6]中index=0取出4,由于7>4,所以本次比较后,先将4存入temp数组,[4,5,6]的index++,指向下一条数据
step 4:[1,2,3,7]中index=3取出7,[4,5,6]中index=1取出5,由于7>5,所以本次比较后,先将5存入temp数组,[4,5,6]的index++,指向下一条数据
step5: 如上循环

综上,归并排序的算法核心,由两块组成:

  1. 递归,将大数组拆分成细粒度的小数组,直至每个数组不可再切割
  2. 将不可再切割的小数组两两整个成有序的大数组,最终合并成最终有序的大数组

代码实现:

/**
     * 递归排序
     *
     * @param arr        待排序数组
     * @param leftIndex  左边界
     * @param rightIndex 右边界
     */
    public static void process(int[] arr, int leftIndex, int rightIndex) {
        if (leftIndex == rightIndex) {
            // 已经切分到唯一的数据了,最小数据粒度,不需要再进行排序
            return;
        }

        //将边界从中间一分为二
        int mid = leftIndex + ((rightIndex - leftIndex) >> 1);

        // 将左半边进行排序
        process(arr, leftIndex, mid);
        // 将右半边进行排序
        process(arr, mid + 1, rightIndex);
        // 将左右数组合并成一个有序的大数组
        merge(arr, leftIndex, mid, rightIndex);
    }

小数组合并算法:

/**
     * 将数组左右两边进行排序
     * [1,2,3,4,5,6,7,8,9]
     * leftindex......midIndex][midIndex+1 .....rightIndex
     * [0.....5] [6.......8]
     *
     * @param arr
     * @param leftIndex  左边界
     * @param midIndex   中间界限
     * @param rightIndex 右边界
     */
    public static void merge(int[] arr, int leftIndex, int midIndex, int rightIndex) {
        // 创建新数组,用来暂存左右两边排好序之后的data
        int[] temp = new int[rightIndex - leftIndex + 1];

        // temp 数组的插入数据的index
        int index = 0;
        // 左半边数组的起始位置
        int lLeft = leftIndex;
        // 右半边数组的起始位置
        int rLeft = midIndex + 1;

        while (lLeft <= midIndex && rLeft <= rightIndex) {
            temp[index++] = arr[leftIndex] <= arr[rLeft] ? arr[lLeft++] : arr[rLeft++];
        }

        while (lLeft <= midIndex) {
            temp[index++] = arr[lLeft++];
        }

        while (rLeft <= rightIndex) {
            temp[index++] = arr[rLeft++];
        }

        // 将已经排好序的temp数据写回arr
        for (int i = 0; i < temp.length; i++) {
            arr[leftIndex + i] = temp[i];
        }
    }

test:

		int[] arr = new int[]{2, 1, 3, 7, 6, 4,5};

        process(arr, 0, arr.length - 1);

由于一切递归算法,都可以转换为对应迭代算法的原则,我们来分析下上面递归的过程。
[2,1,3,7,6,5,4]先是从中间被一分为2,mid=7 / 2 = 3 ,分成[2,1,3,7] 和[6,5,4]两个数组
下面再将[2,1,3,7]从中间一分为2,mid =4 / 2 =2,分成[2,1,3]和 [7]
再将[2,1,3]从中间一分为2,mid=3/2 =1,分成[2,1] 和 [3]
再将[2,1] 从中间一分为2, mid= 2/2 =1 ,分成[2] 和 [1]

按照我们上面的递归算法,先是[2] 自己排序,[1]自己排序,此时排序的size=1
然后后[1] 和[2] 两个数组进行合并,此时排序的size= 2 = 12
然后[2,1]和[3]两个数组进行合并,此时排序的size = 3 =1
2*2 -1,
后面不重复了。

我们可以看到,每次递归,其实就是size*2。

那现在就有了,如果换成非递归算法,那就是按照size变化:
size=1 :[2,1,3,7,6,5,4]先按照下标各自比较,

size=2:下标为0,1的两个数比较,2,1 ----> 1,2
下标为2,3的两个数比较,3,7 -----> 3,7
下标为4,5的两个数比较,6,5 ------> 5,6
下标为6的数比较,4 -----> 4
此轮数组顺序已经调整为 1,2,3,7,5,6,4

size=4: 下标为 0到3 的4个数进行比较,1,2,3,7 —> 1,2,3,7
下标为 4,到7 的3个数进行比较,5,6,4 -----> 4,5,6
此轮数组顺序已经调整为 1,2,3,7,4,5,6

size =8:下标为0到6的7个数字进行比较,1,2,3,7,4,5,6 -----> 1,2,3,4,5,6,7

public static void mergeSort(int[] arr) {
        // 最多只有一条数据,无需排序
        if (arr == null || arr.length == 1) {
            return;
        }

        int mergeSize = 1;

        while (mergeSize < arr.length) {
            // 定义左边界
            int leftIndex = 0;

            while (leftIndex < arr.length) {
                // 定义中间界
                int midIndex = leftIndex + mergeSize - 1;

                if (midIndex >= arr.length) {
                    break;
                }

                // 定义右边界
                int rightIndex = Math.min(midIndex + mergeSize, arr.length - 1);

                // 数据合并
                merge(arr, leftIndex, midIndex, rightIndex);

                // 对数组剩余的数据再次进行切割
                leftIndex = rightIndex + 1;
            }

            if (mergeSize > arr.length / 2) {
                break;
            }

            // mergeSize *2
            mergeSize <<= 1;
        }
    }

我们来看下归并排序的时间复杂度,遍历细粒度的节点,O(n),然后再两两进行合并O(logn),合计时间复杂度为O(n* logn)。

快速排序

Partition

要了解快排,需要先了解Partition。也就是将数组中的元素进行分区。假设指定数组中的某一个元素A,所有比它小的,在它左边,所有比它大的,在它右边。这样将数据进行分区后,不论左边区域内部(或者右边区域内部)数据怎么排列,元素A的排列顺序是固定不变的。
在这里插入图片描述
现有数组,有小于9的30个数字,大于9的30个数字,等于9的3个数字。

那么在我们按照Partition分区之后,[9,9,9]这三个数字,一定位于排好序的最终数组的第31,32,33位。换而言之,当我们挑选出数组中某一个数字进行分区之后,一定可以确认这个数字在最终排好序的数组中所在的位置。

举个例子:
在这里插入图片描述
我们始终以数组的最后一个元素值进行Partition,

  1. 以3进行分区,确定了元素3的最终位置,同时得到左边界数组「1,2,2],和右边界数组[7,5,6,4]
  2. 左边界数组「1,2,2]以2进行分区,得到[1],[2,2]两个数组,左边界完成排序
  3. 右边界数组[7,5,6,4]以4进行分区,确定了元素4的最终位置,同时得到右边界数组[7,5,6]
  4. 右边界数组[7,5,6]以6进行分区,确定元素6的最终位置,同时得到左边界[5]和右边界[7],至此,排序完成。

算法推演

在这里插入图片描述
我们选数组最后一个元素为num =3

  1. index=0时,a[0] =7 ,7>3,归属于“大于num区域”,也就是B区域。我们将B区域前的一个元素与index对于的元素(也就是最末位的3)进行交换,交换之后,区域B左扩1位,index不变:
    在这里插入图片描述

  2. 数据交换完成之后,用交换后的值再次判断。a[index=0] = 3,等于num,位置不变,index++在这里插入图片描述

  3. a[index]=5,由于5>3,归属于区域B,需要将B前面的一个元素,也就是4,与index处的元素进行交换,index不变:在这里插入图片描述

  4. a[index] =4,由于4>3,归属于区域B,再次执行一样的步骤:在这里插入图片描述

  5. a[index] =2,由于2<3,归属于区域A,此时需要将区域A的后一个元素,也就是3,与index处元素进行交换,区域A右扩一位,同时index++:在这里插入图片描述

  6. a[index]=2,2归属于区域A,此时A的后一个元素是3,需要与index处元素交换,A区域右扩,index++在这里插入图片描述

  7. 后面的6,1也是一样的处理,当一轮循环完成之后:
    在这里插入图片描述
    至此我们便可以确认元素3的位置,并且得到了左右AB两个区域,对左右AB两块区域做同样的处理,便可以完成对整个数组的排序。

代码实现

public static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

首先我们定义一个数据交换的方法,用于交换,

/**
     * 分区,返回num所在的最终位置
     * @param arr
     * @param leftIndex
     * @param rightIndex
     * @return
     */
    public static int partition(int[] arr, int leftIndex, int rightIndex) {
        if (leftIndex > rightIndex) {
            return -1;
        }

        if (leftIndex == rightIndex) {
            return leftIndex;
        }

        // [小于区域] [num,num,num] [大于区域]
        // lessEqual : 小于区域的右边界
        int lessEqual = leftIndex - 1;
        // index 循环开始的index
        int index = leftIndex;

        // 恒定以数组最右边元素为本次分区的num
        int num =arr[rightIndex];

        while (index < rightIndex) {
            if (arr[index] < num) {
                // 当前元素小于num,当归属于小于区域,交换顺序,同时小于区域右扩1位,即lessEqual+1
                // 并且index++
                swap(arr, index, ++lessEqual);
            }
            index++;
        }

        swap(arr, ++lessEqual, rightIndex);

        return lessEqual;
    }

再次我们定义个分区方法,以数组最右侧元素为num,将数组划分成区域A,num区,区域B

public static void process1(int[] arr, int leftIndex, int rightIndex) {

        if (leftIndex >= rightIndex) {
            return;
        }

        // 获取到数据最右边元素的最终位置
        int mid = partition(arr, leftIndex, rightIndex);

        // 将左侧小于num的区域进行递归,也即将小于区域中每一个最右端元素确认最终位置
        process1(arr, leftIndex, mid - 1);
        // 同理,一样处理大于num区域的数据
        process1(arr, mid + 1, rightIndex);

    }

在定义一个递归方法,支持以数组中每一个最右侧元素为num进行分区。

public static void quickSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }

        process1(arr, 0, arr.length - 1);
    }

定义入口方法,完成排序。

我们来分析下代码,其实大家一定可以发现,在这段代码中,我们并没有实现右侧“大于区域B”的左扩逻辑,在上述算法中,核心思想是,将小于num的数据放入区域A中,实现了区域A的右扩,最后将num放到了中间区域。
这里面会有一个问题,假设数组中,等于num的不止一条,如:[9, 6, 8, 3, 7,5, 6, 3, 2, 1, 9, 7]
我们来看下当我们第一次以原始数组的最末尾7为num的情况:
在这里插入图片描述
我们将最末尾的7放到mid处之后,并没有将剩余的7也放入mid区域,这是因为partition只是返回了一个mid数据,我们应该是将所有num=7的位置异同返回

所以我们对算法进行改进:

分区方法,支持将所有的num都放入"中间区域C"中去,返回值是 : ["中间区域C"的起始下标,"中间区域C"的结束下标]

public static int[] partition2(int[] arr, int leftIndex, int rightIndex) {
        if (leftIndex > rightIndex) {
            return new int[]{-1, -1};
        }

        if (leftIndex == rightIndex) {
            return new int[]{leftIndex, rightIndex};
        }

        // 区域图:[A]  [leftIndex,rightIndex]   [B]
        //..............index
        // 定义小于区域的右边界
        int lessRightIndex = leftIndex - 1;
        // 定于大于区域的左边界
        int bigLeftIndex = rightIndex;

        // 定义遍历开始的点,index从数组的起始点开始遍历
        int index = leftIndex;

        // 以数组最右侧元素作为num
        int num = arr[rightIndex];

        while (index < bigLeftIndex) {
            // 遍历点还没有达到"大于区域"的左边界,可以继续遍历
            if (arr[index] == num) {
                // 当前元素等于num,什么都不做,遍历下一个元素
                index++;
            } else if (arr[index] < num) {
                // 当前元素应该归属于"小于区域A",交换元素,区域A右扩一位,index++
                swap(arr, index++, ++lessRightIndex);
            } else {
                // 当前元素归属于"大于区域B",交换元素,区域B左扩一位,index不变
                swap(arr, index, --bigLeftIndex);
            }
        }

        swap(arr, bigLeftIndex, rightIndex);

        return new int[]{lessRightIndex + 1, bigLeftIndex};
    }

在递归的过程中,直接跳过 “中间区域C” 这部分,因为这部分已经排好序了,不需要再次进行排序

public static void process2(int[] arr, int leftIndex, int rightIndex) {

        if (leftIndex >= rightIndex) {
            return;
        }

        int[] numArea = partition2(arr, leftIndex, rightIndex);
        process2(arr, leftIndex, numArea[0] - 1);
        process2(arr, numArea[1] + 1, rightIndex);

    }

下面我们再来看下一样的数组,在第一次执行最右侧num分区后的结果:
在这里插入图片描述
分析下这两种快排的时间复杂度,最坏情况下,数组最右侧永远是最大值,即数组本身就是有序的。我们每次都以最大值去进行分区,每次都需要对length-1 条数进行递归,时间复杂度为O(n2)。

主要的问题是,我们是始终以最右侧元素作为num。在快排算法下,最理想的num选取,结果应该是 区域A 与 区域B 的大小差不多大,如果我们不以最右侧元素为num,而是随机选择一个数呢?

 public static void process3(int[] arr, int leftIndex, int rightIndex) {

        if (leftIndex >= rightIndex) {
            return;
        }
        // 随机选择一个数,与数组最右侧元素进行交换
        swap(arr, leftIndex + (int) (Math.random() * (rightIndex - leftIndex + 1)), rightIndex);
        
        int[] numArea = partition2(arr, leftIndex, rightIndex);
        process2(arr, leftIndex, numArea[0] - 1);
        process2(arr, numArea[1] + 1, rightIndex);

    }

因为这次我们是随机选择了一个数作为num,其每次递归的深度变成了概率事件,此时的时间复杂度变成了O(n * logn)。

堆排序

在介绍堆排序之前,我们先来认识下堆这种数据结构。

堆定义

堆(heap)是一颗完全二叉树,即节点的子节点,从左往右,属于有值的过程中,则为完全二叉树:
在这里插入图片描述
根据定义,这几种树,都属于完全二叉树。要么完全没有子节点,要么有左子节点,要么左右子节点都有。
在这里插入图片描述
如果仅有右子树,而没有左子树,则不是完全二叉树。

所以heap,要么都没有子节点,要么一定有左子节点。

堆存储

heap一般使用数组进行存储,以数组的顺序来决定heap的数据
假设我们现在有数组[1,2,3,4,5,6,7,8],数组下标分别为0,1,2,3,4,5,6,7,则映射到heap上结构为:
在这里插入图片描述
每一个节点对应数组中一个元素,排列方式以数组下标做规律性排列

假设当前节点的下标为index,则:
当前节点的左子树下标: 2 * index + 1
当前节点的右子树下标: 2* index +2
当前节点的父节点下标: (index -1 ) /2

demo:
我们以数组中元素2位例:
2在数组中的下标为1,则:
元素2的左子树节点对应的下标为 (21 +1) =3 ,也就是元素4
元素2的右子树节点对应的下标为 (2
1 +2) =4 ,也就是元素5
元素2的父节点对应的下标为 (2-1)/2 =0 ,也就是元素1

堆的分类

  • 大根堆
    所有父亲节点,都比其子节点要大于,或者等于:
    在这里插入图片描述
    如图所示,此时大根堆对应的数组排列顺序为[8,7,6,5,4,3,2,1]

  • 小跟堆
    与大根堆正好相反, 所有父亲节点,都比其子节点要小于,或者等于:
    在这里插入图片描述
    如图所示,此时大根堆对应的数组排列顺序为[1,2,3,4,5,6,7,8]

算法推演

假设我们现在数组已经有元素[2,3,4,7,8],则其对应的heap结构为:
在这里插入图片描述
我们需要将入元素1,需要要添加后,要求符合小跟堆。按照完全二叉树规则,添加后结构如下:
在这里插入图片描述
很明显,节点4的左子树1,这个排列是不满足小跟堆要求的。此时我们看下数组排列:[2,3,4,7,8,1]
为了满足小跟堆要求,我们需要将节点4与节点1进行互换:
在这里插入图片描述
此时我们看下数组排列:[2,3,1,7,8,4]

到这一步,是否已经满足小跟堆要求了呢?跟明显还是没有,节点2根节点1并不满足条件,所以还需要进行交换:
在这里插入图片描述
这是便满足了小跟堆要求,此时数组排列为[1,3,2,7,8,4]。

我们已经成功的加入了一个元素,并且保持住了小跟堆的特性,那么如果我们弹出root节点元素呢?既然root节点要被弹出,那么必须得要有一个元素占据原root节点的位置。我们选取heap的最后一个元素4,弹出后heap结构如图:
在这里插入图片描述
这个时候我们来检查下heap结构,由于4>2,已经不满足小跟堆的要求,故此需要进行数值交换:
在这里插入图片描述
交换完成之后,heap又是符合小跟堆的要求了。

到这边相信大家一定发现了,如果一个heap属于小跟堆,那么它的root节点一定是所有元素中最小的那个。
反之,如果heap属于大根堆,那么它的root节点,一定是所有元素中最大的那个。

以小跟堆为例:
所以,如果我们每次取出root节点,那么第一次pop的结果是最小值,第二次pop结果是次小值,第三次pop结果一定是次次小值。

所以这里,我们可以根据小跟堆(大根堆)的特性,将待排序的数组,构建成小跟堆(大根堆),然后每次取出root节点,便可以完成排序操作。

代码示例

public class MyHeap {
    private int[] heap;

    /**
     * 记录堆中已经存放了多少条数据
     */
    private int heapSize;

    public MyHeap(int limit) {
        this.heap = new int[limit];
        this.heapSize = 0;
    }
  }

我们定义一个自己的heap结构,构造方法中指定heap的大小,同时初始化heapSize=0,代表heap中还没有存入数据

private void insertSwap(int index) {
        while (heap[index] < heap[(index - 1) / 2]) {
            // 如果新插入的数据,比其父节点数据要大,则父子节点进行交换
            swap(index, (index - 1) / 2);
            // 交换后,当前节点已经变成了父节点,肯定比其子节点要小了,如果它还有父节点,需要再次比较它上面的父节点
            index = (index - 1) / 2;
        }
    }

如上面的推导逻辑,当我们向heap中添加一条数据之后,需要调整调整节点的左右叶子节点,使heap依然满足小跟堆的要求

 public void push(int value) {
        if (heapSize == heap.length) {
            throw new RuntimeException("堆已满");
        }
        // 将当前数据插入index位置
        heap[heapSize] = value;
        // 与子节点进行比较,如果不满足条件则进行交换数据
        insertSwap(heapSize);

        heapSize++;
    }

下面我们便可以向heap中添加数据了,构建出小跟堆。

// index : 需要弹出的当前节点下标
private void heapIfy(int index) {
        // 获取左子树下标
        int leftLeafIndex = index * 2 + 1;

        // 左子树下标没有越界
        while (leftLeafIndex < heapSize) {

            // 如果节点同时有左右叶子节点,找到值最大的那个节点,如果只有左节点而没有右节点,则直接选左节点
            // 获取右子树下标
            int rightLeftIndex = index * 2 + 2;
            // 选择左右叶子节点中值最大的index
            int largeLeafIndex = rightLeftIndex < heapSize && heap[rightLeftIndex] < heap[leftLeafIndex] ?
                    rightLeftIndex : leftLeafIndex;

            // 当前节点的值,比其下所有叶子节点的最大值还要小,不需要调整顺序
            if (heap[index] <= heap[largeLeafIndex]) {
                break;
            } else {
                // 父节点与子节点需要交换数据
                swap(index, largeLeafIndex);

                // 交换数据后,当前节点来到了原来子节点的位置
                index = largeLeafIndex;

                // 重新计算出变成子节点后的下一个节点,判断是否仍然需要交换数据
                leftLeafIndex = index * 2 + 1;
            }
        }
    }

还是一样,当我们pop出root节点之后,一样需要调整heap结构,使heap维持小跟堆

  public int pop() {
        if (heapSize <= 0) {
            // 堆里面没有数据可以弹出
            return -1;
        }

        int max = heap[0];

        // 将根节点元素与最后一位元素互换,size--,相当于数组最后一位元素,也就是原来的根节点元素不再可以被访问到
        // 也就相当于数据被弹出
        swap(0, --heapSize);

        // 将数组最后一个元素换到根节点之后,需要调整heap的结构,使其符合小根堆规则
        heapIfy(0);

        // 返回要弹出的元素
        return max;
    }

这边我们便可以将root节点弹出了。

在这里插入图片描述
到这边,我们便完成了利用heap实现数组排序。

heap的JDK实现

在这里插入图片描述
JDK中自带PriorityQueue实现,我们看下部分源码:
在这里插入图片描述
在这里插入图片描述
主题逻辑跟我们自己实现的差不多。

假设我们现在有一个几乎已经排好序的数组,规定数组中每一个元素,最多移动k个位置,即可以到达它最终排好序的位置,问该如何实现排序。

比如有数组[3,2,1,5,4,7,6],其最终排序为[1,2,3,4,5,6,7],则元素3需要挪动2个位置,元素2需要挪动0个位置,元素1需要挪动1个位置,元素5,4,6,7 需要挪动1个位置。在此例中k=2。

假设现在告知k=5,该怎么排序呢?
在这里插入图片描述
根据设定,前5个元素中,也就是index从[0,4],必然有一个数是整个数组中的最小值,index从[1,5]中不然有一个数是整个数组中的次最小值,以此往后类推。

我们可以取出前5个元素,即index 从 0到4 构建出一个小跟堆,然后pop出root节点,此时root节点即为数组最小值
小跟堆中还剩4条数据,将 index =5的数据push进小跟堆,然后再pop出root节点,此时root节点为数组次最小值
下面以此类推。

==============
既然JDK提供了PriorityQueue实现heap排序,为什么还话大力气自己写了一个呢?
JDK提供的heap,一旦heap的结构被确认之后,是不会变化的。
比如现在我们对student对象进行排序,stu1:score=1,stu2:score=2,stu3:score=3,我们按照学生的得分字段构建出heap,满足小跟堆要求:
在这里插入图片描述
但是后续发现,stru1的得分算错了,将stu1的score改成4:
在这里插入图片描述

但是此时我们发现,JDK自带的heap已经不满足小跟堆的要求了,在数据被修改之后,并没有重新根据score将数组进行排序。

所以如果是我们自己定义的heap,我们在push和pop等方法的时候,在执行逻辑之前可以检查一遍当前数组排列是否满足要求,甚至我们可以专门提供一个方法出去,当有数据发生变动后,直接调我们方法,我们在此方法内部实现heap的重新排列。

桶排序

上面介绍的几种排序,都是基于比较值来确定先后顺序的,桶排序是不急于比较的排序。

计数排序

基数排序

桶排序严格要求排序样本的初识状态,使用场景比较窄,不写了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1 目标检测的定义 目标检测(Object Detection)的任务是找出图像中所有感兴趣的目标(物体),确定它们的类别和位置,是计算机视觉领域的核心问题之一。由于各类物体有不同的外观、形状和姿态,加上成像时光照、遮挡等因素的干扰,目标检测一直是计算机视觉领域最具有挑战性的问题。 目标检测任务可分为两个关键的子任务,目标定位和目标分类。首先检测图像中目标的位置(目标定位),然后给出每个目标的具体类别(目标分类)。输出结果是一个边界框(称为Bounding-box,一般形式为(x1,y1,x2,y2),表示框的上角坐标和右下角坐标),一个置信度分数(Confidence Score),表示边界框中是否包含检测对象的概率和各个类别的概率(首先得到类别概率,经过Softmax可得到类别标签)。 1.1 Two stage方法 目前主流的基于深度学习的目标检测算法主要分为两类:Two stage和One stage。Two stage方法将目标检测过程分为两个阶段。第一个阶段是 Region Proposal 生成阶段,主要用于生成潜在的目标候选框(Bounding-box proposals)。这个阶段通常使用卷积神经网络(CNN)从输入图像中提取特征,然后通过一些技巧(如选择性搜索)来生成候选框。第二个阶段是分类和位置精修阶段,将第一个阶段生成的候选框输入到另一个 CNN 中进行分类,并根据分类结果对候选框的位置进行微调。Two stage 方法的优点是准确度较高,缺点是速度相对较慢。 常见Tow stage目标检测算法有:R-CNN系列、SPPNet等。 1.2 One stage方法 One stage方法直接利用模型提取特征值,并利用这些特征值进行目标的分类和定位,不需要生成Region Proposal。这种方法的优点是速度快,因为省略了Region Proposal生成的过程。One stage方法的缺点是准确度相对较低,因为它没有对潜在的目标进行预先筛选。 常见的One stage目标检测算法有:YOLO系列、SSD系列和RetinaNet等。 2 常见名词解释 2.1 NMS(Non-Maximum Suppression) 目标检测模型一般会给出目标的多个预测边界框,对成百上千的预测边界框都进行调整肯定是不可行的,需要对这些结果先进行一个大体的挑选。NMS称为非极大值抑制,作用是从众多预测边界框中挑选出最具代表性的结果,这样可以加快算法效率,其主要流程如下: 设定一个置信度分数阈值,将置信度分数小于阈值的直接过滤掉 将剩下框的置信度分数从大到小排序,选中值最大的框 遍历其余的框,如果和当前框的重叠面积(IOU)大于设定的阈值(一般为0.7),就将框删除(超过设定阈值,认为两个框的里面的物体属于同一个类别) 从未处理的框中继续选一个置信度分数最大的,重复上述过程,直至所有框处理完毕 2.2 IoU(Intersection over Union) 定义了两个边界框的重叠度,当预测边界框和真实边界框差异很小时,或重叠度很大时,表示模型产生的预测边界框很准确。边界框A、B的IOU计算公式为: 2.3 mAP(mean Average Precision) mAP即均值平均精度,是评估目标检测模型效果的最重要指标,这个值介于0到1之间,且越大越好。mAP是AP(Average Precision)的平均值,那么首先需要了解AP的概念。想要了解AP的概念,还要首先了解目标检测中Precision和Recall的概念。 首先我们设置置信度阈值(Confidence Threshold)和IoU阈值(一般设置为0.5,也会衡量0.75以及0.9的mAP值): 当一个预测边界框被认为是True Positive(TP)时,需要同时满足下面三个条件: Confidence Score > Confidence Threshold 预测类别匹配真实值(Ground truth)的类别 预测边界框的IoU大于设定的IoU阈值 不满足条件2或条件3,则认为是False Positive(FP)。当对应同一个真值有多个预测结果时,只有最高置信度分数的预测结果被认为是True Positive,其余被认为是False Positive。 Precision和Recall的概念如下图所示: Precision表示TP与预测边界框数量的比值 Recall表示TP与真实边界框数量的比值 改变不同的置信度阈值,可以获得多组Precision和Recall,Recall放X轴,Precision放Y轴,可以画出一个Precision-Recall曲线,简称P-R
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值