首先交代下通用的交换数组中元素的函数:
/**
* 交换数组中两个元素
* @param nums
* @param i1 索引1
* @param i2 索引2
*/
public void swap(int[] nums, int i1, int i2) {
int temp = nums[i1];
nums[i1] = nums[i2];
nums[i2] = temp;
}
上面的swap
函数用于将数组中索引为i1
和i2
的两个元素进行交换。
外层循环每一次经过两两比较,把每一轮未排定部分最大的元素放到了数组的末尾。代码实现如下:
public int[] bubbleSort(int[] nums) {
for (int i = nums.length - 1; i > -1; i--) {
//若内层循环中一次交换都没有进行,证明数组已经是升序数组
boolean isSorted = true;
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
isSorted = false;
}
}
if (isSorted) break;
}
return nums;
}
冒泡排序在遍历的过程中,提前检测到数组是有序的,就会结束排序,而不是像下面的选择排序那样,即使输入数据是有序的,选择排序依然需要傻乎乎地走完所有的流程。
对于冒泡排序,其时间复杂度为O(N^2), N为数组的长度;空间复杂度为O(1),使用到常数个临时变量用于交换数组中元素。
每一轮选取未排定的部分中最小的部分交换到未排定部分的最开头,经过若干个步骤,就能排定整个数组。即:
该算法将第i
小的元素放到nums[i]
中,数组的第i
个位置的左边是i
个最小的元素且它们不会再被访问,下图中,算法在黑色元素中查找最小值,红色元素为为排序元素中的最小值,灰色元素已经排序好且不会再移动。
public int[] selectionSort(int[] nums) {
for (int i = 0; i < nums.length - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < nums.length; j++) {
if (nums[j] < nums[minIdx]) {
minIdx = j;
}
}
swap(nums, i, minIdx);
}
return nums;
}
对于长度为N的数组,选择排序需要大约N^2 / 2
次比较和N
次交换。选择排序的运行时间和输入无关,即使输入数组是有序的,选择排序扔需要和随机顺序数组同样的比较和交换次数。同时选择排序具有数据移动最少的特点,也就是说该算法对数据的交换次数最少,每次交换都会改变两个数组元素的值,选择排序的交换次数和数组的长度是成线性关系的,而其他任何基于比较的排序算法都不具备这个性质,也就是说,虽然选择排序貌似没什么用,但如果在交换成本较高的排序任务中,就可以使用选择排序。
对于选择排序,其时间复杂度为O(N^2), N为数组的长度;空间复杂度为O(1),使用到常数个临时变量用于交换数组中元素。
选择排序不会访问索引左侧的元素,因为左侧的元素都已经有序并且在自己该在的最终位置,而下面要说的插入排序则不会访问索引右侧的元素,。
每次将一个数字插入一个有序的数组里,成为一个长度更长的有序数组,而为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位,有限次操作以后,数组整体有序。
与选择排序一样,当前索引左边的元素都是有序的,但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。下图中的灰色元素不会移动,红色的元素就是要插入的元素,为了插入该元素,黑色的元素都向右移动了一格。
public int[] insertionSort(int[] nums) {
for (int i = 1; i < nums.length; i++) {
int temp = nums[i];
int j = i;
while (j > 0 && nums[j - 1] > temp) {
nums[j] = nums[j - 1];
j--;
}
nums[j] = temp;
}
return nums;
}
插入排序在几乎有序的数组上表现良好,特别地,在短数组上的表现也很好。因为短数组的特点是:每个元素离它最终排定的位置都不会太远。为此,在小区间内执行排序任务的时候,可以转向使用插入排序。
对于插入排序,其时间复杂度为O(N^2), N为数组的长度;空间复杂度为O(1),使用到常数个临时变量用于交换数组中元素。
下面的函数实现了对于数组内的部分区间进行插入排序,用于在数组的子部分为小数组时进行插入排序:
/**
* 对数组子区间 [left, right] 使用插入排序
* @param nums
* @param left
* @param right
*/
public void insertionSort(int[] nums, int left, int right) {
for (int i = left + 1; i <= right; i++) {
int tmp = nums[i];
int j = i;
while (j > left && nums[j - 1] > tmp) {
nums[j] = nums[j - 1];
j--;
}
nums[j] = tmp;
}
}
快速排序是一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立地排序,也就是说快速排序每一次都排定一个元素(这个元素呆在了它最终应该呆的位置),然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序。
快速排序的核心关键点就是切分函数partiton
的实现,即如何得到将数组分为两个子数组的切分点,一般方法如下:
pivot
,如果不随机选取的话,在输入数组是有序数组或者是逆序数组的时候,快速排序会变得非常慢,然后将切分元素放到最左边,即切分元素和最左边元素交换;nums[i]
,从数组的右端开始向左扫描直到找到第一个小于等于切分元素的元素nums[j]
。这两个元素显然是未排定的,因此交换它们两个的位置,如此继续这样扫描,就可以保证左指针i
左侧元素都不大于pivot
,右指针j
右侧元素都不小于切分元素;i
、j
两个指针相遇时,只需将切分元素(此时在数组的最左侧)和左子数组最右侧的元素(nums[j]
)交换,然后返回j
即为切分元素的索引。class Solution {
// 列表长度 ≤ 该长度 时,不用快排而是用插入排序
private static final int INSERTION_SORT_THRESHOLD = 7;
public int[] sortArray(int[] nums) {
int len = nums.length;
quickSort(nums, 0, len - 1);
return nums;
}
/**
* 快速排序,对数组区间 [left, right] 快排
* @param nums
* @param left
* @param right
*/
public void quickSort(int[] nums, int left, int right) {
if (right <= left) return; // 分片只有一个元素时无需排序
// 分片元素少时用插入排序
if (right - left <= INSERTION_SORT_THRESHOLD) {
insertionSort(nums, left, right);
return;
}
// 切分元素索引
int pivotIdx = partition(nums, left, right);
quickSort(nums, left, pivotIdx - 1);
quickSort(nums, pivotIdx + 1, right);
}
/**
* 返回切分元素的索引,数组中切分元素左侧均小于等于切分元素,右侧均大于等于切分元素
* @param nums
* @param left inclusive
* @param right inclusive
* @return 切分元素索引
*/
public int partition(int[] nums, int left, int right) {
int randomIdx = new Random().nextInt(right - left + 1) + left;
swap(nums, left, randomIdx);
int i = left + 1, j = right;
int pivot = nums[left];
while (true) {
while (i <= right && nums[i] < pivot) {
i++;
}
while (j > left && nums[j] > pivot) {
j--;
}
if (i >= j) break;
swap(nums, i, j);
i++;
j--;
}
swap(nums, left, j); // 这里必须是j, j才是左子数组最右侧的元素
return j;
}
}
对于小数组,快速排序比插入排序慢,所以上面的算法对快速排序进行了优化,在排序小数组时切换到插入排序。
对于快速排序,其时间复杂度为O(N·logN), N为数组的长度;空间复杂度为O(logN),其占用的空间主要来自递归过程中的栈空间。
至于快速排序的其他优化,如三向切分的快速排序可以在数组中具有大量重复元素的时候取得更好的速度,这里不再赘述。
归并排序的基本思想就是借助额外空间,合并两个有序数组,得到更长的有序数组。其最吸引人的性质就是能够保证将任意长度为N的数组排序所需时间和N·logN
成正比,它的主要缺点就是需要的额外空间和N
成正比。
class Solution {
// 列表长度 ≤ 该长度 时,不用归并排序而是用插入排序
private static final int INSERTION_SORT_THRESHOLD = 7;
public int[] sortArray(int[] nums) {
int len = nums.length;
int[] tmp = new int[len];
mergeSort(nums, 0, len - 1, tmp);
return nums;
}
/**
* 归并排序,对数组区间 [left, right] 归并排序
* @param nums
* @param left
* @param right
* @param tmp
*/
public void mergeSort(int[] nums, int left, int right, int[] tmp) {
if (right <= left) return; // 分片只有一个元素时无需排序
// 分片元素少时用插入排序
if (right - left <= INSERTION_SORT_THRESHOLD) {
insertionSort(nums, left, right);
return;
}
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid, tmp);
mergeSort(nums, mid + 1, right, tmp);
// 数组的两个子数组本身有序,即左半子数组元素全部小于右半子数组元素,无需归并
if (nums[mid] <= nums[mid +1]) return;
mergeTowSortedArray(nums, left, mid, right, tmp);
}
/**
* 合并数组的两个有序子数组
* 左半数组为[left, mid], 右半数组为[mid + 1, right]
* @param nums
* @param left
* @param mid [left, mid]、[mid + 1, right]分别有序
* @param right
* @param tmp 全局使用的临时数组
*/
public void mergeTowSortedArray(int[] nums, int left, int mid, int right, int[] tmp) {
System.arraycopy(nums, left, tmp, left, right - left + 1);
int i = left, j = mid + 1;
for (int k = left; k <= right; k++) {
// 左数组已经归并完毕,只将右数组剩余元素填入即可
if (i == mid + 1) {
nums[k] = tmp[j];
j++;
}
// 右数组已经归并完毕,只将左数组剩余元素填入即可
else if (j == right + 1) {
nums[k] = tmp[i];
i++;
}
// 这里用 <= 为了维持排序的稳定性
else if (tmp[i] <= tmp[j]) {
nums[k] = tmp[i];
i++;
} else {
nums[k] = tmp[j];
j++;
}
}
}
}
上面全程使用了一份和nums
数组相同长度的临时数组tmp
进行合并有序子数组的操作,这样的话可以避免创建临时数组和销毁临时数组的消耗。
对于归并排序,其时间复杂度为O(N·logN), N为数组的长度;空间复杂度为O(N),开辟的临时数组和输入数组规模成正比。
为了说明堆排序,就要先说明什么是堆,在《算法4》中二叉堆的定义如下:
二叉堆是一个能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储。
而什么又是堆有序呢?
当一棵二叉树的每个结点都大于等于它的两个子结点时,该二叉树被称为堆有序。
而我们把一棵完全二叉树存储到数组nums[]
中时,根节点对应nums[0]
,我们不难得出:
nums[i]
,其左孩子结点为nums[2 * i + 1]
,其右孩子节点为nums[2 * i + 2]
;nums[i]
,其父节点为nums[(i - 1) / 2]
。当然,若是为了计算简便,也可以用nums[1]
存储根节点,此时数组长度应为二叉堆节点数 + 1
,这种情况下:
nums[i]
,其左孩子结点为nums[2 * i]
,其右孩子节点为nums[2 * i + 1]
;nums[i]
,其父节点为nums[(i / 2]
。这里我采用将根节点存储在nums[0]
中,不想再开辟多余的空间了。
在堆的有序化过程中有两种情况:
某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序,这就对应着上浮swim
操作:如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,就需要交换它和它的父节点来使堆重新有序化,再交换后,这个节点一定比它的两个孩子都大(一个是曾经的父节点 < 该节点,一个是曾经的父节点的孩子节点 <= 曾经的父节点 < 该节点),但是该节点仍然可能比当前它的父节点大,所以要将其一直上浮直到它小于当前的父节点。
// 将nums[i]作上浮操作
public void swim(int i) {
// 只要其父节点比自己小,就上浮
while (i > 0 && nums[(i - 1) / 2] < nums[i]) {
swap(nums, (i - 1) / 2, i);
i = (i - 1) / 2;
}
}
某个结点的优先级下降,我们需要由上至下恢复堆的顺序,这就对应着下沉sink
操作:如果堆的有序状态因为某个结点变得比它的两个子节点其中或其中一个子节点更小而打破了,则需要通过它与它的孩子中的较大者来使堆重新有序化,这样将该节点如此下沉直到它的子结点都比它更小或者该结点已经达到堆底而没有子结点。
// 将nums[i]作下沉操作
public void sink(int i) {
int len = nums.length;
while(2 * i + 1 < len) {
int j = 2 * i + 1;
// j为两个孩子中较大的节点
if (j + 1 < len && nums[j] < nums[j + 1]) j++;
// 结点比孩子都小时,停止下沉
if (!(nums[j] > nums[i])) break;
i = j;
}
}
堆最主要的操作就是向堆中插入元素和删除最大元素(即堆顶元素):
插入元素:将新元素加入到数组末尾,增加堆的大小并让这个新元素上浮到合适位置;
public void insert(int val) {
nums[++N] = val; // 新元素加入到数组末尾,同时增加堆的大小
swim(N); // 新元素上浮到合适位置
}
删除最大元素:从数组顶端删去最大元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适位置。
public int delMax() {
int max = nums[0]; // 从根节点得到最大元素
swap(nums, 0, N--); // 将其和最后一个结点交换并减小堆的大小
nums[N + 1] = null; // 防止对象游离,将无用空间置为空
sink(0); // 将该元素下沉恢复堆的有序性
return max;
}
堆排序可以分为两个阶段:
对于初始建堆,我们可以从左到右一个节点一个节点的利用上浮swim
加入堆中,但是这种方法无疑需要对N个节点进行上浮操作,更好的办法是从右到左利用下沉sink
构造子堆,因为这样的话不需要对N个节点进行下沉,只需要从数组的中间元素开始从右向左即可,因为其与元素是大小为1的子堆,这些打下为1的子堆不需要进行下沉操作,如图,所有蓝色的节点无需下沉操作,而需要下沉操作的第一个节点为红色节点,这样就省去了一半的节点:
对于下沉排序,我们将堆中的最大元素删除,然后放入堆缩小后数组中后面空出的位置即可。
也就是说,**无论是初始建堆,还是进行下沉排序,它们都基于sink()
方法!**堆排序的代码实现如下:
class Solution {
/**
* 堆排序
* @param nums
* @return
*/
public int[] heapSort(int[] nums) {
int len = nums.length;
heapInitial(nums); // 初始建堆
for (int i = len - 1; i >= 1;) {
// 堆顶元素(最大元素)交换到数组末尾
swap(nums, 0, i);
i--; // 堆有效部分减少
// 新堆顶元素下沉,使得区间[0, i]堆有序
sink(nums, 0, i);
}
return nums;
}
/**
* 初始建堆
* @param nums
*/
public void heapInitial(int[] nums) {
int len = nums.length;
// 大小为1的堆直接被跳过
for (int i = (len - 1) / 2; i >= 0; i--) {
sink(nums, i, len - 1);
}
}
/**
* 由上至下的堆有序化(下沉)
* @param nums
* @param k 当前要下沉元素索引
* @param end 最坏情况下下沉到的索引,即[0, end]为nums的有效部分
*/
public void sink(int[] nums, int k, int end) {
while (2 * k + 1 <= end) {
int j = 2 * k + 1;
// j为两个子节点中较大者的索引
if (j + 1 <= end && nums[j + 1] > nums[j]) j++;
if (!(nums[j] > nums[k])) break;
swap(nums, j, k);
k = j;
}
}
}
对于堆排序,其时间复杂度为O(N·logN),N为数组的长度;空间复杂度为O(1),只开辟了临时变量用于交换元素。
算法 | 是否稳定 | 时间复杂度 | 空间复杂度 |
---|---|---|---|
冒泡排序 | 是 | O(N^2) | O(1) |
选择排序 | 否 | O(N) ~ O(N^2) | O(1) |
插入排序 | 是 | O(N^2) | O(1) |
快速排序 | 否 | O(N·logN) | O(logN) |
归并排序 | 是 | O(N·logN) | O(N) |
堆排序 | 否 | O(N·logN) | O(1) |