1.排序算法
1.1 评价维度
# 输入数据是按照姓名排序好的# (name, age)('A', 19)('B', 18)('C', 21)('D', 19)('E', 23)# 假设使用非稳定排序算法按年龄排序列表,# 结果中 ('D', 19) 和 ('A', 19) 的相对位置改变,(原本d在a的下面,此时,d变到了a的前面)# 输入数据按姓名排序的性质丢失('B', 18)('D', 19)('A', 19)('C', 21)('E', 23)
1.2 理想排序算法
2.选择排序
5.仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
/* 选择排序 */
void selectionSort(int[] nums) {
int n = nums.length;
// 外循环:未排序区间为 [i, n-1]
for (int i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素
int k = i;
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[k])
k = j; // 记录最小元素的索引
}
// 将该最小元素与未排序区间的首个元素交换
int temp = nums[i];
nums[i] = nums[k];
nums[k] = temp;
}
}
2.1算法特性
·时间复杂度为O(n的2次方)、非自适应排序:外循环共n一1轮,第一轮的未排序区间长度为n,最后一轮的未排序区间长度为2,即各轮外循环分别包含n、n-1、...3、2轮内循环.求和为(n—1)(n+2)﹒空间复杂度O(1)、原地排序:指针i和j使用常数大小的额外空间。
·非稳定排序:如下图所示,元素nuns[i]有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
3.冒泡排序
3.1算法流程
设数组的长度为n,冒泡排序的步骤如下图所示。
1.首先,对n个元素执行“冒泡”,将数组的最大元素交换至正确位置,
2.接下来,对剩余n―1个元素执行“冒泡”,将第二大元素交换至正确位置。3.以此类推,经过n―1轮“冒泡”后,前n―1大的元素都被交换至正确位置。4.仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
/* 冒泡排序 */
void bubbleSort(int[] nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.length - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
}
3.2效率优化
/* 冒泡排序(标志优化) */
void bubbleSortWithFlag(int[] nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.length - 1; i > 0; i--) {
boolean flag = false; // 初始化标志位
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
flag = true; // 记录交换元素
}
}
if (!flag)
break; // 此轮冒泡未交换任何元素,直接跳出
}
}
3.3算法特性
·时间复杂度为O(n的2次方)、自适应排序:各轮“冒泡”遍历的数组长度依次为n —1、n一2、...、2、1,总和为(n — 1)n/2。在引入flag 优化后,最佳时间复杂度可达到O(n)。
·空间复杂度为O(1)、原地排序︰指针i和j使用常数大小的额外空间。
﹒稳定排序:由于在“冒泡”中遇到相等元素不交换。
4. 插入排序
4.1算法流程
/* 插入排序 */
void insertionSort(int[] nums) {
// 外循环:已排序元素数量为 1, 2, ..., n
for (int i = 1; i < nums.length; i++) {//将第一个元素视为已排序的部分,从下标为1的开始循环
int base = nums[i], j = i - 1;//找到未排序部分的第一个元素,j用于在已排序部分找到 base 的正确位置。
// 内循环:将 base 插入到已排序部分的正确位置
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位。将 nums[j] 向右移动一位,为插入 base 腾出位置。
j--;
}
nums[j + 1] = base; // 将 base 赋值到正确位置
}
}
4.2算法特性
·时间复杂度O(n的2次方)、自适应排序∶最差情况下,每次插入操作分别需要循环n 一1、n一2、...、2、1次,求和得到(n 一 1)n/2,因此时间复杂度为O(n2)。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度O(n)。
·空间复杂度O(1)、原地排序:指针i和j使用常数大小的额外空间。
·稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
4.3 插入排序优势
插入排序的时间复杂度为O(n2),而我们即将学习的快速排序的时间复杂度为O(n log n)。尽管插入排序的时间复杂度相比快速排序更高,但在数据量较小的情况下,插入排序通常更快。
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类O(n logn)的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时,n2和 n log n的数值比较接近,复杂度不占主导作用;每轮中的单元操作数量起到决定性因素。
实际上,许多编程语言(例如Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为O(n2),但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。
·冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及3个单元操作;插入排序基于元素赋值实现,仅需 1 个单元操作。因此,冒泡排序的计算开销通常比插入排序更高。
5. 快速排序
「快速排序quick sort」是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如下图所示。
1.选取数组最左端元素作为基准数,初始化两个指针i和j分别指向数组的两端。
2.设置一个循环,在每轮中使用i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
3.循环执行步骤2.,直到i和j相遇时停止,最后将基准数交换至两个子数组的分界线。
/* 元素交换 */
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/* 哨兵划分 */
int partition(int[] nums, int left, int right) {
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
5.1算法流程
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right)
return;
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
5.2算法特性
·时间复杂度O(n log n)、自适应排序︰在平均情况下,哨兵划分的递归层数为log n ,每层中的总循环数为n,总体使用O(n logn)时间。在最差情况下,每轮哨兵划分操作都将长度为n的数组划分为长度为0和n―1的两个子数组,此时递归层数达到n层,每层中的循环数为n,总体使用O(n2)时间。·空间复杂度O(n)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度n,使用O(n)栈帧空间。排序操作是在原数组上进行的,未借助额外数组
·非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
5.3快排为什么快?
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
·出现最差情况的概率很低:虽然快速排序的最差时间复杂度为O(n2),没有归并排序稳定,但在绝大多数情况下,快速排序能在O(n log n)的时间复杂度下运行。
·缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
·复杂度的常数系数低:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
5.4基准数优化
/* 选取三个元素的中位数 */
int medianThree(int[] nums, int left, int mid, int right) {
// 此处使用异或运算来简化代码
// 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
if ((nums[left] < nums[mid]) ^ (nums[left] < nums[right]))
return left;
else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right]))
return mid;
else
return right;
}
/* 哨兵划分(三数取中值) */
int partition(int[] nums, int left, int right) {
// 选取三个候选元素的中位数
int med = medianThree(nums, left, (left + right) / 2, right);
// 将中位数交换至数组最左端
swap(nums, left, med);
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
5.5尾递归优化
在某些输入下,快速排序可能占用空间较多。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为0,递归树的高度会达到n―1,此时需要占用O(n)大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过n/2,因此这种方法能确保递归深度不超过log n,从而将最差空间复杂度优化至O(log n)。
/* 快速排序(尾递归优化) */
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = partition(nums, left, right);
// 对两个子数组中较短的那个执行快排
if (pivot - left < right - pivot) {
quickSort(nums, left, pivot - 1); // 递归排序左子数组
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
} else {
quickSort(nums, pivot + 1, right); // 递归排序右子数组
right = pivot - 1; // 剩余未排序区间为 [left, pivot - 1]
}
}
}
int partition(int[] nums, int left, int right) {
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
6.归并排序
6.1 算法流程
/* 合并左子数组和右子数组 */
static void merge(int[] nums, int left, int mid, int right) {
// 左子数组区间 [left, mid], 右子数组区间 [mid+1, right]
// 创建一个临时数组 tmp ,用于存放合并后的结果
int[] tmp = new int[right - left + 1];
// 初始化左子数组和右子数组的起始索引
//i 是左子数组的起始索引,而 j 是右子数组的起始索引。在 while 循环中,我们比较左右子数组的元素,
// 并将较小的元素放入临时数组。通过 j = mid + 1,我们从右子数组的第一个元素开始比较。
int i = left, j = mid + 1, k = 0;
// 当左右子数组都还有元素时,比较并将较小的元素复制到临时数组中
while (i <= mid && j <= right) {
if (nums[i] <= nums[j])
tmp[k++] = nums[i++];//先赋值再++
else
tmp[k++] = nums[j++];
}
// 将左子数组和右子数组的剩余元素复制到临时数组中
while (i <= mid) {
tmp[k++] = nums[i++];
}
while (j <= right) {
tmp[k++] = nums[j++];
}
// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
for (k = 0; k < tmp.length; k++) {
nums[left + k] = tmp[k];
}
}
/* 元素交换 */
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
6.2算法特性
·时间复杂度O(n logn)、非自适应排序:划分产生高度为log n 的递归树,每层合并的总操作数量为n,因此总体时间复杂度为O(n log n)。
·空间复杂度O(n)、非原地排序:递归深度为log n,使用O(log n)大小的栈帧空间。合并操作需要借助辅助数组实现,使用O(n)大小的额外空间。
﹒稳定排序:在合并过程中,相等元素的次序保持不变。
6.3链表排序
7堆排序
7.1算法流程
/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(int[] nums, int n, int i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
int l = 2 * i + 1;
int r = 2 * i + 2;
int ma = i;
if (l < n && nums[l] > nums[ma])
ma = l;
if (r < n && nums[r] > nums[ma])
ma = r;
// 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if (ma == i)
break;
// 交换两节点
int temp = nums[i];
nums[i] = nums[ma];
nums[ma] = temp;
// 循环向下堆化
i = ma;
}
}
/* 堆排序 */
void heapSort(int[] nums) {
// 建堆操作:堆化除叶节点以外的其他所有节点
for (int i = nums.length / 2 - 1; i >= 0; i--) {
siftDown(nums, nums.length, i);
}
// 从堆中提取最大元素,循环 n-1 轮
for (int i = nums.length - 1; i > 0; i--) {
// 交换根节点与最右叶节点(即交换首元素与尾元素)
int tmp = nums[0];
nums[0] = nums[i];
nums[i] = tmp;
// 以根节点为起点,从顶至底进行堆化
siftDown(nums, i, 0);
}
}
7.2 算法特性
8.桶排序
8.1算法流程
/* 桶排序 */
void bucketSort(float[] nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.length / 2;
List<List<Float>> buckets = new ArrayList<>();
for (int i = 0; i < k; i++) {
buckets.add(new ArrayList<>());
}
// 1. 将数组元素分配到各个桶中
for (float num : nums) {
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = (int) (num * k);
// 将 num 添加进桶 i
buckets.get(i).add(num);
}
// 2. 对各个桶执行排序
for (List<Float> bucket : buckets) {
// 使用内置排序函数,也可以替换成其他排序算法
Collections.sort(bucket);
}
// 3. 遍历桶合并结果
int i = 0;
for (List<Float> bucket : buckets) {
for (float num : bucket) {
nums[i++] = num;
}
}
}
8.2 算法特性
桶排序适用于处理体量很大的数据。例如,输入数据包含100万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成1000个桶,然后分别对每个桶进行排序,最后将结果合并。
·时间复杂度O(n+k)︰假设元素在各个桶内平均分布,那么每个桶内的元素数量为k分之n。假设排序单个桶使用O(k分之n log k分之n)时间,则排序所有桶使用O(n log k分之n)时间。当桶数量k比较大时,时间复杂度则趋向于O(n)。合并结果时需要遍历所有桶和元素,花费O(n+k)时间。
·自适应排序:在最坏情况下,所有数据被分配到一个桶中,且排序该桶使用O(n的2次方)时间。·空间复杂度O(n+k)、非原地排序:需要借助k个桶和总共n个元素的额外空间。·桶排序是否稳定取决于排序桶内元素的算法是否稳定。
8.3 如何实现平均分配
9.计数排序
9.1 简单实现
先来看一个简单的例子。给定一个长度为n的数组nuns,其中的元素都是“非负整数”,计数排序的整体流程如下图所示。
1.遍历数组,找出数组中的最大数字,记为m,然后创建一个长度为m +1的辅助数组counter。
2.借助counter统计nuns 中各数字的出现次数,其中 counter[num]对应数字num的出现次数。统计方法很简单,只需遍历nuns(设当前数字为 num),每轮将counter[nun]增加1即可。
3.由于counter的各个索引天然有序,因此相当于所有数字已经被排序好了。接下来,我们遍历c
ounter,根据各数字的出现次数,将它们按从小到大的顺序填入nums即可。
/* 计数排序 */
// 简单实现,无法用于排序对象
void countingSortNaive(int[] nums) {
// 1. 统计数组最大元素 m
int m = 0;
for (int num : nums) {
m = Math.max(m, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
int[] counter = new int[m + 1];
for (int num : nums) {
counter[num]++;
}
// 3. 遍历 counter ,将各元素填入原数组 nums
int i = 0;
for (int num = 0; num < m + 1; num++) {
for (int j = 0; j < counter[num]; j++, i++) {
nums[i] = num;
}
}
}
9.2 完整实现
void countingSort(int[] nums) {
// 1. 统计数组最大元素 m
int m = 0;
for (int num : nums) {
m = Math.max(m, num);//最大4
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
int[] counter = new int[m + 1];
for (int num : nums) {
counter[num]++;//counter[]:[3,2,3,0,2],0出现三次,1出现两次...
}
// 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
// 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
for (int i = 0; i < m; i++) {
//[3,5,8,8,10],遍历结束后数组变成了这个样子,
// 现在counter[num]-1就是代表数字num在排序后数组中最后一次出现的索引
counter[i + 1] += counter[i];
}
// 4. 倒序遍历 nums ,将各元素填入结果数组 res
// 初始化数组 res 用于记录结果
int n = nums.length;//10
int[] res = new int[n];
//counter[]:[3,5,8,8,10]
for (int i = n - 1; i >= 0; i--) {
int num = nums[i];//拿出这个数字,4
//counter[num] - 1,拿到数字num在排序后数组中最后一次出现的索引,
res[counter[num] - 1] = num; // 将 num 放置到对应索引处,res[9]=4
//这是为了确保下一个相同的数字(如果有的话)能够被正确放置到排序后数组中的前一个位置,这个过程对于保持排序的稳定性非常重要。
counter[num]--; // 令前缀和自减 1 ,得到下次放置 num 的索引,也就是[3,5,8,8,9],下次再取到相同值的num如4,就会取到counter[num] - 1=8,在8位置上在放4
}
// 使用结果数组 res 覆盖原数组 nums
for (int i = 0; i < n; i++) {
nums[i] = res[i];
}
}
}
9.3 算法特性
·时间复杂度O(n +m)︰涉及遍历nuns 和遍历counter ,都使用线性时间。一般情况下n> >m,时间复杂度趋于O(n)。
·空间复杂度O(n + m)、非原地排序:借助了长度分别为n和m的数组res和counter。
·稳定排序:由于向res中填充元素的顺序是“从右向左”的,因此倒序遍历nuns可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历nuns也可以得到正确的排序结果,但结果是非稳定的。
9.4 局限性
10 基数排序
10.1 算法流程
/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
// 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
return (num / exp) % 10;
}
/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(int[] nums, int exp) {
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
int[] counter = new int[10];
int n = nums.length;
// 统计 0~9 各数字的出现次数
for (int i = 0; i < n; i++) {
int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
counter[d]++; // 统计数字 d 的出现次数
}
// 求前缀和,将“出现个数”转换为“数组索引”
for (int i = 1; i < 10; i++) {
counter[i] += counter[i - 1];
}
// 倒序遍历,根据桶内统计结果,将各元素填入 res
int[] res = new int[n];
for (int i = n - 1; i >= 0; i--) {
int d = digit(nums[i], exp);
int j = counter[d] - 1; // 获取 d 在数组中的索引 j
res[j] = nums[i]; // 将当前元素填入索引 j
counter[d]--; // 将 d 的数量减 1
}
// 使用结果覆盖原数组 nums
for (int i = 0; i < n; i++)
nums[i] = res[i];
}
/* 基数排序 */
void radixSort(int[] nums) {
// 获取数组的最大元素,用于判断最大位数
int m = Integer.MIN_VALUE;
for (int num : nums)
if (num > m)
m = num;
// 按照从低位到高位的顺序遍历
for (int exp = 1; exp <= m; exp *= 10)
// 对数组元素的第 k 位执行计数排序
// k = 1 -> exp = 1
// k = 2 -> exp = 10
// 即 exp = 10^(k-1)
countingSortDigit(nums, exp);
}
为什么从最低位开始排序?在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 𝑎 < 𝑏 ,而第二轮排序结果 𝑎 > 𝑏 ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
10.2 算法特性
11.总结
‧ 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 𝑂(𝑛2 ) 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到𝑂(log 𝑛) 。