【排序三部曲】1. 简单排序算法

231 阅读7分钟

选择排序

public static void selectionSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return ;
    }
    for(int i = 0; i < arr.length - 1; i ++) {
        int minIndex = i;
        for (int j = i; j < arr.length; j ++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;
            }
        }
        swap(arr, i, minIndex);
    }
}

冒泡排序

public static void bubbleSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return ;
    }
    for (int i = 0; i < arr.length - 1; i ++) {
        for (int j = 0; j < arr.length - 1 - i; j ++) {
            if (arr[j] > arr[j + 1]) {
                swap(arr, j, j + 1);
            }
        }
    }
}

插入排序

插入排序的时间复杂度受到了数据状况的影响。

  • 对[1,2,3,4,5]进行排序,时间复杂度是O(N)。

  • 对[5,4,3,2,1]进行排序,时间复杂度是O(N^2)。

那么插入排序的时间复杂度到底是多少?O(N^2)

一般研究算法的复杂度都是按照最差指标O(...),所以为插入排序的时间复杂度O(N^2)。

public static void insertSort(int[] arr) {
    // arr[0]一个数相当于有序,所以从i = 1开始遍历
    for (int i = 1; i < arr.length; ++ i) {
        // j = 1和arr[j] >= arr[j - 1]是停止交换的条件
        for (int j = i; j > 0 && arr[j] < arr[j - 1]; -- j) {
            swap(arr, j - 1, j);
        }
    }
}

关于swap

1. 普通写法

额外空间复杂度为O(1)。

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

2. 高级写法

额外空间复杂度为O(0)。

通过异或运算的两个基本性质:恒等率a ^ 0 = a,归零率a ^ a = 0可以推导出来。

使用该方法的前提是:交换双方必须是两块独立的内存。所以要想在排序中使用该方法,必须保证 i != j。

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

异或运算(面试题)

  1. 一个整形数组arr,已知该数组中只有一种数字出现了奇数次,其他所有的数都出现了偶数次。

    要求:时间复杂度O(N),额外空间复杂度O(1)

    1. 如何找到出现了奇数次的这个数?

      思路:

      1. 利用异或运算的恒等率a ^ 0 = a,归零率a ^ a = 0,交换律a ^ b = b ^ a,结合律 a ^ b ^ c = (a ^ b) ^ c = a ^ (b ^ c)。
      2. 数组中出现偶数次的数异或到最后都是0,出现奇数次的数异或到最后是x, 0 ^ x = x。所以将数组中每一个数进行异或,得到的结果就是出现了奇数次的那个数。
      public static int findOddNumber(int arr[]) {
          int result = 0;
          for (int i = 0; i < arr.length; i ++) {
              result ^= arr[i];
          }
          return result;
      }
      
    2. 如果已知有两种数字出现了奇数次,其他所有数都出现了偶数次,如何找到出现了奇数次的这两种数?

      思路:

      1. 利用异或运算的恒等率a ^ 0 = a,归零率a ^ a = 0,交换律a ^ b = b ^ a,结合律 a ^ b ^ c = (a ^ b) ^ c = a ^ (b ^ c)。
      2. 利用异或运算的性质:a ^ b = c ----> a = b ^ c
      3. 数组中出现偶数次的数异或到最后都是0,两种出现奇数次的数异或到最后是x和y,x ^ y = z。已知,x不等于y,所以z不等于0。将z的二进制表示就一定会有至少有一位是1,那么z的二进制表示中是1的位对应的x和y一定一个是1,一个是0。所以通过x和y一个二进制位的不同就可以将x和y分成两大类,数组中的所有数也都属于两类中的一类。在每一个类中,除了x或者y的其他种类数一定都是偶数个,所以每一类中所有数进行异或得到的结果一定一个是x,一个是y。如第2点,我们只需要确定一个组最后异或的结果,就可以反推出另一个数。
      public static int[] findOddNumbers(int arr[]) {
          int result = 0;
          // 得到x^y
          for (int cur : arr) {
              result ^= cur;
          }
          // 这里用的是x^y二进制表示中最右边是1的位保留,其余置0的二进制表示所构成的数对x和y进行分类
          int rightmostIndex = result & (~ result + 1);
          int one = 0;
          // 得到x和y其中一个的值
          for (int cur : arr) {
              // rightmostIndex是1还是0进行分组
              if ((cur & rightmostIndex) != 0) {
                  one ^= cur;
              }
          }
          // 由异或的性质得到另外一个数
          return new int[] {one, result ^ one};
      }
      

二分法详解与扩展

1. 在有序数组中查找某个数

时间复杂度:O(log2N)

解释:本案例依照访问数组元素的次数来确定时间复杂度。比方说,有序数组中有8个数,log28 = 3,最坏情况下需要进行3次访问数组的操作。

在算法的复杂度中,O(log2N)一般简写为O(logN)。如果底数不是2,才会加上底数,例如O(log3N)。

 public static int binarySearch(int[] arr, int target) {
     int low = 0;
     int high = arr.length - 1;
     int current = (low + high) / 2;
     while (low <= high) {
         if (arr[current] < target) {
             low = current + 1;
         } else if (arr[current] > target) {
             high = current - 1;
         } else {
             // 查找到target就返回
             return arr[current];
         }
         current = (low + high) / 2;
     }
     // 没查找到返回-1
     return -1;
 }

2. 在有序数组中查找>=某个数最左侧的位置

例如:找[1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5]中>=3的数中,最左边的数的下标是多少。

解释:该案例和在有有序数组中查找某个数的区别在于,查找某个数有可能中途查到就停止了,而该案例必须要查到结束才可以查找到结果。

public static int findGtEqLeftmost(int[] arr, int target) {
    int low = 0;
    int high = arr.length - 1;
    int current = (low + high) / 2;
    // arr中>=target最左边的数的下标
    int left = -1;
    while (low <= high) {
        if (arr[current] < target) {
            low = current + 1;
        } else if (arr[current] >= target) {
            high = current - 1;
            left = current;
        }
        current = (low + high) / 2;
    }
    return left;
}

该案例的原理也可以适用于在有序数组中查找<=某个数最右侧的位置。

3. 局部最小值问题

题目描述:在一个无序数组中,任何两个相邻的元素都不相等。在这个数组中,设定找到任何一个局部最小的位置的算法,该算法的时间复杂度好于O(N)。

局部最小:对于arr[0]来说,如果arr[0] < arr[1]那么,arr[0]为局部最小。对于arr[length - 1]来说,arr[length - 1] < arr[length - 2],arr[length - 1]为局部最小。对于arr[i]来说,arr[i] < arr[i + 1] && arr[i] < arr[i - 1],arr[i]为局部最小。

同样是二分查找,二分查找常用在凸显排他性的一系列问题上。并不局限于有序数组。

递归行为

1. 获取一个数组的最大值

注意:

要想知道一个数组中间的下标是多少,一般情况下会用 (left + right) / 2 的方式得到。但是这种方式不是无懈可击的,如果left和right非常大,那么left + right可能会造成溢出,所以该方法可以改进为 left + ( (right - left) / 2 )。因为已知位运算符>>可以将一个数的二进制表示所有数向右移动,如果右移一位的话,也可以达到该数除以2的效果,并且比除以2效率高。所以 left + ( (right - left) / 2 ) 还可以改进为 left + ( (right - left) >> 1 ) 。

public static int getMax(int[] arr, int left, int right) {
    if (left == right) {
        return arr[left];
    }
    int middle = left + ((right - left) >> 1);
    int leftMax = getMax(arr, left, middle);
    int rightMax = getMax(arr, middle + 1, right);
    return Math.max(leftMax, rightMax);
}

2. 递归算法的时间复杂度

一系列符合子问题规模等规模的这些递归算法的时间复杂度都可以用master公式求解。

master公式:可以将递归行为归纳为 —— T(N) = a * T(N/b) + O(N^d)

  • T(N):母问题的数据规模是N。
  • a:每一次递归子问题被调用的次数。
  • T(N/b) :子问题的数据规模相等,都是N/b。
  • O(N^d):每一次递归除去子问题被调用之外,剩下的过程的时间复杂度。

时间复杂度求法:

logba < d ----> O(N^d)

logba > d ----> O(N^logba)

logba = d ----> O(N^d*logN)

案例:

获取一个数组的最大值的算法中,a = 2,由T(N/2)得到b = 2,又由O(1)得到d = 0。

所以带入master公式:T(N) = 2 * T(N/2) + O(1)

由master公式的时间复杂度求法:log22 = 1 > 0 -----> 时间复杂度 = O(N^logba) = O(N^log22) = O(N)

比较器

1. 适用场景

  • 比较器可以很好的应用在特殊标准的排序上,例如排序Student对象。
  • 比较器可以很好的应用在根据特殊标准排序的结构上,例如构建大小根堆,有序表,红黑树等。

以系统排序接口Arrays.sort为例

如果排序的对象是数字的话,那么会之间按照数字的数值大小进行排序

int[] arr = {3, 5, 1, 2, 9, 8, 7, 6, 4};
Arrays.sort(arr);

如果排序的对象是不能直接做出比较的类型,那么就需要比较器来制定比较规则进行排序

Student[] arr = {new Student(1, "John", 18), new Student(2, "Jack", 20), new Student(3, "George", 36)};
// 在没有比较器的情况下,系统会按照对象的地址进行排序
Arrays.sort(arr);

比较器还可以构建任何复杂的比较规则。例如在比较学生时,规定学生年龄小的排前面,年龄大的排后面。当年龄相等时,id小的排前面,id大的排后面。

2. Java中实现比较器

通过控制compare方法的返回值,就可以实现任何比较策略。

// id升序比较器
public class IdAscendingComparator implements Comparator<Student> {

    /**
     * @param o1 比较的第一个对象
     * @param o2 比较的第二个对象
     * @return 返回值为复数时,第一个参数排前面;返回值为正数时,第二个参数排前面;返回值为0时,谁排前面无所谓
     */
    @Override
    public int compare(Student o1, Student o2) {
        if (o1.id < o2.id) {
            return -1;
        }
        if (o1.id > o2.id) {
            return 1;
        }
        return 0;
    }
}

简化写法为

public class IdAscendingComparator implements Comparator<Student> {

    @Override
    public int compare(Student o1, Student o2) {
        return o1.id - o2.id;
    }
}

使用比较器制定的规则来比较

Student[] arr = {new Student(1, "John", 18), new Student(2, "Jack", 20), new Student(3, "George", 36)};
// 按照id的大小进行排序
Arrays.sort(arr, new IdAscendingComparator());