算法基础:排序算法和二分法

165 阅读9分钟

详解各类排序方式

排序算法

排序算法是算法中最基础,最实用的算法之一。简单来说,就是将一组无序数列按照设定的排序方式进行排列

练习程序

冒泡排序

冒泡排序是最简单的一种排序方式之一,重复循环该数列,然后一次比较两个元素,如果排序错误就将该两个元素进行位置交换,依次重复当前操作直到不可操作

依稀记得在刚学习Java的时候,大冬天在家里练习冒泡手冷的场景

我们先来看看动图效果:

依照上图的展示形式,其实我们得到如下思路

  • 第一次循环的时候,元素比较进行交换,那么最终会将该组数列中最大的元素放到最后一位,此后数列循环范围是【0~N-2】
  • 第二次循环的时候,元素比较进行交换,在最后将此时数列中最大的元素放到【N-1】的位置,此后数列循环范围是【0~N-3】
  • ...
  • 每次循环范围递减,直到最终的排序完成

【元素下标具体表示形式】

0和1比较 1和2比较 2和3比较 。。。end-1和end比较

那么明白了其中的思路之后,下面我们开始正式编码

// 元素交换, 在后面也用这个
public static void swap(int[] arr, int i, int j) {
    int tep = arr[i];
    arr[i] = arr[j];
    arr[j] = tep;
}
​
public static void bubbetSort(int[] arr) {
    // 如果是null或者元素只有一个,根本就没必要排序
    if (null == arr || arr.length == 1) {
        return;
    }
​
    // 第一层循环遍历
    for (int i = 0, size = arr.length; i < size; i++) {
        // 第二层循环遍历,用来两两元素进行比较
        for (int j = 1; j < size; j++) {
            if (arr[j] < arr[j - 1]) {
                // 交换位置
                swap(arr, j - 1, j);
            }
        }
    }
}

到这里就完成了冒泡,大家可以自己测试

选择排序

选择排序在每次循环数列的时候会标记除当前数列中最小的元素,然后和第【0 + 1】的位置进行元素交换,这种情况下保证了最起始位置上一定是最小的元素

和冒泡排序唯一的区别就是:

  • 冒泡固定最大值,数列的最后位置
  • 选择固定最小值,数列的起始位置

还是先来看看动图效果

思路很重要:

  • 第一次循环整个数列,找到该数列中最小的元素,然后将它和第一位上的元素进行交换,这样就固定了索引0上的元素,此后循环范围为【1~N-1】
  • 第二次循环数列,再次找到数列中最小元素,然后和索引1上的元素进行交换。此后循环范围【2~N-1】
  • 。。。
  • 最后一次循环数列范围为【N-2~N-1】,比较然后进行交换

好,按照这样的逻辑,我们来看看代码实现

public static void choiceSort(int[] arr) {
    // 如果数据为null或者元素只有1个,不处理
    if (null == arr || arr.length == 1) {
        return;
    }
​
    // 循环遍历,直到最后一个元素
    for (int i = 0, size = arr.length; i < size; i++) {
        // 定义最小值的数组下标
        int minValueIndex = i;
        // 依次循环数列,找到当前数列中最小元素的下标
        for (int j = i+1; j < size; j++) {
            minValueIndex =
                arr[minValueIndex] > arr[j] ? j : minValueIndex;
        }
        // 将最小元素交换到指定位置
        swap(arr, i, minValueIndex);
    }
}

插入排序

插入排序也是一种简单直观的排序方式,在循环的时候会将元素和之前构建的有序数列依次进行比较,在哪里小就插入到对应的位置上

同理还是先来看看动图效果

如果用流程来表示的话就是这样的:

  • 【0~0】范围内排序,保证了有序
  • 【01】范围内排序,在第一步已经保证了【00】上有序,所以需要将索引1上的元素和之前的数列进行比较
  • 【02】范围内排序,在上一步已经保证了【01】上有序,所以需要将索引2上的元素和之前的数列进行比较
  • 。。。
  • 【0N-1】范围内排序,就需要将索引N-1上的元素和【0N-2】范围内的有序数列进行比较

思路已经有了,那么我们就开始Coding吧

public static void insertSort(int[] arr) {
    // 如果数据为null或者元素只有1个,不处理
    if (null == arr || arr.length == 1) {
        return;
    }
    
    // 循环数列,索引0上只有1位元素,这个元素就不需要比较,所以循环的起始位置从1开始
    for (int i = 1, size = arr.length; i < size; i++) {
        int newValueIndex = i;
        // 保证newValueIndex--的过程中不会为负数 && 当前元素小于前一个元素
        while (newValueIndex > 0 && arr[newValueIndex] < arr[newValueIndex - 1]) {
            // 元素交换
            swap(arr, newValueIndex, newValueIndex - 1);
            newValueIndex--;
        }
    }
}
​
// 这是另一种写法,和上面是一样的
public static void insert2Sort(int[] arr) {
    if (null == arr || arr.length == 1) {
        return;
    }
​
    for (int i = 1, size = arr.length; i < size; i++) {
        for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
            swap(arr, j, j - 1);
        }
    }
}

归并排序

这个排序是一个非常有意思的排序方式,在大数据层面上运用非常广泛,采用分治思想

原理就是将大的数组拆分成多个小数组,对小数组内进行排序,保证有序之后,进行合并操作。最终完成对大数组的排序过程。

示例动图如下

在图中可以很明显的看到某些节点上颜色变淡

这里对应的是每个小数组中的指针位置,当两个小数组【PL, PR】需要进行数据合并的时候,会发生如下情况

  • PL上的P1位置 < PR 上的 P2位置,那么将P1位置元素提取出来,P1++,否则提取P2位置元素,P2++
  • P1指针的位置指向了PL的最后一个元素,此时PL数组全部提取完成,那么如果PR还存在元素的情况下,那么就将PR剩下的元素原封不动提取出来,反之亦然
  • 最后将合并之后的数组还原到原先的数组上去

原理将清楚之后,那么接下来就看看代码实现:

public static void process(int[] arr, int L, int R) {
    // 如果只有一个元素, 就不排序
    if (L == R) {
        return;
    }
​
    // 分组 找到中间位置,将数组对半劈
    int mid = L + ((R - L) >> 1);
    // 递归左
    process(arr, L, mid);
    // 递归右
    process(arr, mid + 1, R);
    // 合并左右数组
    merge(arr, L, mid, R);
}
​
public static void merge(int[] arr, int L, int mid, int R) {
    int[] helper = new int[R - L + 1];
    int i = 0;
    int P1 = L;
    int P2 = mid + 1;
​
    // 如果两者都没有超过边界
    while (P1 <= mid && P2 <= R) {
        helper[i++] = arr[P1] <= arr[P2] ? arr[P1++] : arr[P2++];
    }
​
    // 如果P1没有超过边界,那么拷贝P1剩下的元素
    while (P1 <= mid) {
        helper[i++] = arr[P1++];
    }
    // 虽然这里写了两个循环,但是只会有一个地方执行
    while (P2 <= R) {
        helper[i++] = arr[P2++];
    }
    // copy到原始数组中
    System.arraycopy(helper, 0, arr, L, helper.length);
}
​
// process(arr, 0, arr.length-1);

这是递归写法

非递归的写法就不展示了,主要就是变化在什么地方拆分数组,也就是步长,然后限制边界条件

快速排序

快速排序也是属于分治思想的产物,原理也非常简单:

  • 取数组中的某一个数作为排序的基准【P】,然后将待排序的数据中的元素和【P】进行比对,小的就放到左边,大的放到最右边,等于的放中间。
  • 按照这种方式排序,中间位置一定是有序的,那么我们就只要处理左边和右边的即可
  • 递归第一步,最终排序完成

我们来手动画一张图来看看这个思想

首先,我们先来看看分为两个区的过程

那么,如果采用代码的话就是如下程序

public static void splitNum(int[] arrs) {
    int N = arrs.length;
    int index = 0;
    int lessM = -1;
​
    while (index < N) {
        if (arrs[index] <= arrs[N-1]) {
            swap(arrs, ++lessM, index++);
        } else {
            index++;
        }
    }
}
​
public static void swap(int[] arrs, int i, int j) {
    int tmp = arrs[i];
    arrs[i] = arrs[j];
    arrs[j] = tmp;
}

两个区的分完了,下面升级难度,看看如何将数组分为 <区=区>区

原理都是一样的,每个元素和基准值进行比对

  • 如果小于基准值:将index上的元素和++less的位置进行交换,然后index++
  • 如果等于基准值:不操作,直接index++
  • 如果大于基准值,将--moreR的值和index的值进行交换
  • 最后一步将基准值和moreR的位置进行交换

这种情况相当于一点点的将中间=区向中间挤,如图过程

代码也非常简单:

public static void splitNum2(int[] arrs) {
    int N = arrs.length;
    int index = 0;
    int lessM = -1;
    int moreR = N - 1;
​
    while (index < moreR) {
        if (arrs[index] < arrs[N - 1]) {
            swap(arrs, ++lessM, index++);
        } else if (arrs[index] > arrs[N - 1]) {
            swap(arrs, --moreR, index);
        } else {
            index++;
        }
    }
    swap(arrs, moreR, N-1);
}

为什么要++less, 因为less是从-1开始的

为什么要--moreR,因为moreR的起始位置是N-1的位置

至于为什么要做上面的操作,快速排序本身基于分三区的过程来操作,专业一点可以讲上面的过程成为分区,那么接下来才是快速排序的正式操作

首先需要改变一下splitNum2()使它返回=区的开始和结束位置,这样我们才能做后续操作

// L = 0, R = arrs.length-1 来考虑
public static int[] partition(int[] arrs, int L, int R) {
    int lessM = L - 1;
    int moreR = R;
    int index = L;
​
    while (index < moreR) {
        if (arrs[index] < arrs[R]) {
            swap(arrs, ++lessM, index++);
        } else if (arrs[index] > arrs[R]) {
            swap(arrs, --moreR, index);
        } else {
            index++;
        }
    }
    swap(arrs, index, R);
    return new int[]{lessM, moreR};
}
​
// 主方法
public static void quickSort(int[] arrs) {
    if (arrs == null || arrs.length < 2) {
        return;
    }
    // 递归方法
    process(arrs, 0, arrs.length - 1);
}
​
public static void process(int[] arrs, int L, int R) {
    // 如果L > R,不符合条件
    if (L >= R) {
        return;
    }
​
    // 分区交换
    int[] partition = partition(arrs, L, R);
    // 递归左部分
    process(arrs, L, partition[0] - 1);
    // 递归右部分
    process(arrs, partition[1] + 1, R);
}

总的来说,快速排序的代码量相对比上面4个排序多了不少,还是按照流程来看一看,练一练

二分法

对半劈:小了找左边,大了找右边

一般情况下使用二分法的数组都是基于有序状态的,这样才能通过对半劈的方式来找到我们想要的东西

在LinkedList中的get(int index)方法就是通过二分法来优化了检索的过程

练习程序

概念上的东西实在不知道该说什么,我们还是来做题吧

找到目标所在的索引

给定一组有序的数组和一个目标值,返回其所在的索引位置,如果在当前数组中不存在,那么就返回-1

比如:

参数:arr = [1,2,3,6,7,8,9]    target = 2
返回:index=1
    
参数:arr = [1,2,3,6,7,8,9]    target = 22
返回:index=-1

好,我们明白了题目之后,就来看看如何对半:

  • 既然需要对半,那么我们就将数组长度 / 2, 得到一个位置,然后该中间位置的值和目标值进行比对:

    • 如果中间值 < 目标值,那么目标值就可能在右半边
    • 如果中间值 > 目标值,那么目标值就可能在左半边
  • 重复上面的过程,直到找到目标值的位置

public static int findByTarget(int[] arrs, int target) {
    // 为空不分
    if (null == arrs || arrs.length == 0) {
        return -1;
    }
​
    // 只有一个元素, 就单独判断
    if (arrs.length == 1) {
        return arrs[0] == target ? 0 : -1;
    }
​
    // 左右边界,将目标锁定在某一段范围内
    int left = 0;
    int right = arrs.length;
​
    while (left <= right) {
        int middle = (left + right) >> 1;
        if (arrs[middle] == target) {
            return middle;
        }
        // 如果中间值大于目标值,说明在左边,右边界减1
        if (arrs[middle] > target) {
            right = middle -1;
        } else {
        // 如果中间值小于目标值,说明在右边,左边界加1
            left = middle + 1;
        }
    }
​
    return -1;
}

找满足>=value的最左位置

一组数组和一个给定值,找到在数组中>=目标值的最左的位置

参数:arr = [1,2,2,3,3,5,6,6,7,8,9]    target = 3
返回:index=3

原则上思路和上面那道题是一样的,还是直接来看代码吧

public static int lessLeft(int[] arrs, int target) {
    // 为空不分
    if (null == arrs || arrs.length == 0) {
        return -1;
    }
    // 只有一个元素, 就单独判断
    if (arrs.length == 1) {
        return arrs[0] >= target ? 0 : -1;
    }
​
    int L = 0;
    int R = arrs.length;
    int index = -1;
​
    while (L <= R) {
        int middle = (L + R) >> 1;
        if (arrs[middle] >= target) {
            // 这里不直接返回:虽然找到了一个符合条件的,但是不能确定这是唯一一个符合条件的,所以需要继续执行
            index = middle;
            R = middle - 1;
        } else {
            L = middle + 1;
        }
    }
​
    return index;
}

找满足<=value的最右位置

public static int lessTarget(int[] arrs, int target) {
    // 为空不分
    if (null == arrs || arrs.length == 0) {
        return -1;
    }
    // 只有一个元素, 就单独判断
    if (arrs.length == 1) {
        return arrs[0] <= target ? 0 : -1;
    }
​
    int L = 0;
    int R = arrs.length;
    int index = -1;
​
    while (L <= R) {
        int middle = (L + R) >> 1;
        if (arrs[middle] <= target) {
            index = middle;
            L = middle + 1;
        } else {
            R = middle - 1;
        }
    }
​
    return index;
}

这里和上一题就是一个模子里出来的,就不多说什么了

这里强调一点:在做二分法题目的时候只需要记住这一点就好

  • 当中间值 > 目标值的时候,调整右边界
  • 当中间值 < 目标值的时候,调整左边界

只要按照对半分的思想,这样在做的时候就非常简单了

找到数组中局部最小的位置

上面我们练习的数组都是有序数组,那么还有一种情况下是可以不要求数组有序的,如题:

在一个数组中,相邻的两个数不一样,要求要找出整个数组中局部最小的位置

先思考一下

根据这个题意我们可以提炼出以下的思路:

  • 还是取中间位置,由于相邻的两个数不一样,那么只要能够保证middlemiddle - 1middle + 1位置上的数都小,那么middle上的就是局部最小的
  • 否则的话就对半劈

那么就按照这个思路,我们来看看代码如何实现:

public static int findLocalMinimum(int[] arrs) {
    if (null == arrs || arrs.length == 0) {
        return -1;
    }
​
    int N = arrs.length;
    // 这里判断了只有一位的时候
    if (N == 1) {
        return 0;
    }
​
    // 这里判断只有两位的时候
    // 0和1比较
    if (arrs[0] < arrs[1]) {
        return 0;
    }
​
    // 1和0比较
    if (arrs[N - 1] < arrs[N - 2]) {
        return N - 1;
    }
​
    // 大于2位的情况
    
    int L = 0;
    int R = N - 1;
​
    while (L < R - 1) {
        int middle = (L + R) >> 1;
        // 我们要找的不是数组中最小的位置,而是有局部最小的位置
        if (arrs[middle] < arrs[middle -1] && arrs[middle] < arrs[middle + 1]) {
            // 局部最小
            return middle;
        } else {
            // 如果是中间位置大,左边位置小,说明整体线是向下延伸的,那么就将右边抛弃,只留左边
            if (arrs[middle] > arrs[middle - 1]) {
                R = middle - 1;
            } else {
                // 将左边抛弃,只留右边
                L = middle + 1;
            }
        }
    }
​
    // 从(L < R - 1)出来,说明当前L和R的区间内只存在两个元素,那么只需要单独对这两个元素进行判断就行
    return arrs[L] < arrs[R] ? L : R;
}
难点:while条件是L < R - 1而不是L <= R

如果while条件是L <= R的话,因为在内部需要判断middle+1middle-1的位置,如果L和R正好相等,那么就会触发边界问题,有可能抛出异常

L < R - 1这样就能避免L和R正好相等的情况

最后

为大家推荐一个学习算法的网站,该文中的动图都是从该网站录制出来的

VisuAlgo