寒假讲课Day 12:排序

排序

排序虽然基础,但涉及多种算法思想特别是分治思想、计数思想的综合运用。

基础知识

任何全序集都可以排序,不一定是数,也不一定是升序。例如,可以对结构体排序。

当排序的标准不是被排序对象本身时,可以定义排序的稳定性。如果一个排序算法能够保证键值相等的元素相对顺序不变,则称该排序算法是稳定的(stable),否则称其为不稳定的(unstable)。

\(i<j\land a_i>a_j\),则称\((i,j)\)\(\vec{a}\)的一个逆序对(inverse pair)\(\sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}(a_i>a_j)\)称为\(\vec{a}\)的逆序对数。逆序对数不超过\(\dbinom{n}{2}\)。详见OI Wiki - 置换和排列

一部分排序算法是基于比较的,称为比较排序(comparative sort),另一部分称为非比较排序(non-comparative sort)

几种排序算法

动画

选择排序

我个人认为选择排序是最贴近人类直觉的排序方法。

选择排序(selection sort)是一种简单直观的排序算法。它的工作原理是每次找出第\(i\)小的元素(同时也是\(a[i,n)\)中最小的元素),然后将这个元素与数组第\(i\)个位置上的元素交换。

void selectionSort(std::vector<int> &arr)  {
	for (size_t i = 0; i < arr.size(); ++i) {
		size_t i_min = i;
		for (size_t j = i + 1; j < arr.size(); ++j) {
			if (arr[j] < arr[i_min]) {
				i_min = j;
			}
		}
		std::swap(arr[i], arr[i_min]);
	}
}

时间复杂度:\(\dbinom{n}{2}=\varTheta(n^2)\)
空间复杂度:\(\varTheta(1)\)
稳定性:取决于具体实现,上面这种常见的实现形式是不稳定的。

插入排序

插入排序(insertion sort)是将待排列元素划分为「已排序」和「未排序」两部分,每次从「未排序的」元素中选择一个插入到「已排序的」元素中的正确位置。

设已排序\(i\)个元素\(a[0,i)\)。考虑将\(a_i\)插入到有序部分。可以通过交换来实现插入。

void insertionSort(std::vector<int> &arr) {
	for (size_t i = 1; i < arr.size(); ++i) {
		for (size_t j = i; j && arr[j - 1] > arr[j]; --j) {
			std::swap(arr[j - 1], arr[j]);
		}
	}
}

也可以整体移动元素来插入。

void insertionSort(std::vector<int> &arr) {
	for (size_t i = 1; i < arr.size(); ++i) {
		int key = arr[i];
		size_t j = i;
		while (j > 0 && arr[j - 1] > key) {
			arr[j] = arr[j - 1];
			--j;
		}
		arr[j] = key;
	}
}

时间复杂度:最好\(\varTheta(n)\),最坏\(\varTheta(n^2)\)
空间复杂度:\(\varTheta(1)\)
稳定性:稳定。

插入排序在接近有序规模很小的数据上表现优秀。

冒泡排序

冒泡排序(bubble sort)是一种简单的排序算法,依次比较相邻元素并交换其中不符合顺序的。在算法的执行过程中,较小的元素像是气泡般慢慢「浮」到数列的顶端。

经过\(i\)次扫描后(从\(1\)开始数),数列的末尾\(i\)项必然是最大的\(i\)项,因此冒泡排序最多需要扫描\((n-1)\)遍数组就能完成排序。

void bubbleSort(std::vector<int> &arr) {
	for (size_t i = 0; i < arr.size(); ++i) {
		for (size_t j = 1; j + i < arr.size(); ++j) {
			if (arr[j - 1] > arr[j]) {
				std::swap(arr[j - 1], arr[j]);
			}
		}
	}
}

可以进一步优化:如果一遍下来没有元素发生交换,说明已经有序,可以提前终止,不必跑完全部\((n-1)\)轮。

void bubbleSort(std::vector<int> &arr) {
	for (size_t i = 0; i < arr.size(); ++i) {
		bool swapped = false;
		for (size_t j = 1; j < arr.size() - i; ++j) {
			if (arr[j - 1] > arr[j]) {
				std::swap(arr[j - 1], arr[j]);
				swapped = true;
			}
		}
		if (!swapped) break;
	}
}

时间复杂度:最好\(\varTheta(n)\)(只需遍历一遍确认有序),最坏\(\varTheta(n^2)\)
空间复杂度:\(\varTheta(1)\)
稳定性:稳定。

归并排序

昨天讲过。

void mergeSort(std::vector<int> &arr, size_t beg, size_t end) {
	if (beg + 1 >= end) return;

	// divide and conquer
	size_t mid = beg + (end - beg) / 2;
	mergeSort(arr, beg, mid);
	mergeSort(arr, mid, end);

	// merge
	std::vector<int> aux(end - beg);
	size_t i = beg, j = mid, k = 0;
	while (i < mid && j < end) {
		if (arr[i] <= arr[j]) {
			aux[k++] = arr[i++];
		} else {
			aux[k++] = arr[j++];
		}
	}
	while (i < mid) {
		aux[k++] = arr[i++];
	}
	while (j < end) {
		aux[k++] = arr[j++];
	}

	for (k = 0; k < aux.size(); ++k) {
		arr[beg + k] = aux[k];
	}
}

时间复杂度:\(\varTheta(n\log n)\)(证明需要用主定理(main theorem))。
空间复杂度:\(\varTheta(n)\)。原地归并是可行的,但比较复杂,且会使时间增加到\(\varTheta(n\log^2n)\),一般来说不用。
稳定性:稳定。

快速排序

快速排序(quick sort),又称分区交换排序(partition-exchange sort),简称「快排」,是一种被广泛运用的排序算法(std::sort通常采用快速排序实现),其步骤是:

  1. 将数列划分(partition)为左右两部分,要求保证相对大小关系,即前一部分的所有元素小于后一部分的所有元素。
  2. 递归到两个子序列中分别进行快速排序。
  3. 不用合并,因为此时数列已经完全有序。

实际上,快速排序没有指定应如何具体实现partition,不论是选择划分标准还是划分的过程,都有不止一种实现方法。

最简单的partition方法是直接选取当前子数组的首/尾/中央元素作为主元。

size_t partition(std::vector<int> &arr, size_t beg, size_t end) {
	assert(beg < end);
	if (beg + 1 == end) return beg;

	std::swap(arr[beg], arr[beg + (end - beg) / 2]); // pivot
	size_t i = beg + 1;
	for (size_t j = beg + 1; j < end; ++j) {
		if (arr[j] < arr[beg]) {
			std::swap(arr[i], arr[j]);
			++i;
		}
	}
	std::swap(arr[beg], arr[i - 1]);
	return (i - 1);
}

void quickSort(std::vector<int> &arr, size_t beg, size_t end) {
	if (beg + 1 >= end) return;

	size_t pivot_idx = partition(arr, beg, end);
	quickSort(arr, beg, pivot_idx);
	quickSort(arr, pivot_idx + 1, end);
}

不过,以上实现(称为朴素实现)在特殊构造的数据下可以被卡到\(\varTheta(n^2)\),因为其高度依赖所选主元的性质。如果每次选取的主元总是当前范围内的最小值,则每次只会将区间划分为长\(1\)\((n-1)\)的两部分,时间复杂度退化到\(\varTheta(n^2)\)。常见的优化有:

  • 选取首、中、尾三个元素的中位数作为划分的主元,以避免极端数据带来的退化问题。这种主元选取方式称为三数取中(median of three)。
  • 序列较短(短于设定的阈值)时,使用常数小的排序算法(例如插入排序),避免在短序列上递归。
  • 每趟排序后,将与分界元素相等的元素聚集在分界元素周围,这样可以避免极端数据(如序列中大部分元素都相等)带来的退化。常通过三路划分(小于、等于、大于主元三部分)来实现,采用这种划分方法的快速排序称为三路快速排序(3-way quick sort)。
  • 限制递归深度不超过\(\lfloor\log_2n\rfloor\),对于过深的排序改为堆排序。这种排序方式称为内省排序(intro(spective )sort)

以下是采用三数取中、小区间优化的三路快速排序。

void medianOfThree(std::vector<int> &arr, size_t beg, size_t end) {
	size_t mid = beg + (end - beg) / 2;
	if (arr[beg] > arr[mid]) std::swap(arr[beg], arr[mid]);
	if (arr[beg] > arr[end - 1]) std::swap(arr[beg], arr[end - 1]);
	if (arr[mid] > arr[end - 1]) std::swap(arr[mid], arr[end - 1]);
	std::swap(arr[mid], arr[beg]);
}

std::pair<size_t, size_t> threeWayPartition(std::vector<int> &arr, size_t beg, size_t end) {
	assert(beg < end);

	medianOfThree(arr, beg, end);

	size_t lt = beg + 1; // arr[beg + 1, lt) < pivot
	size_t gt = end;	 // arr[gt, end) > pivot
	size_t i = beg + 1;	 // arr[lt, i) == pivot

	while (i < gt) {
		if (arr[i] < arr[beg]) {
			std::swap(arr[lt], arr[i]);
			++lt;
			++i;
		} else if (arr[i] > arr[beg]) {
			--gt;
			std::swap(arr[i], arr[gt]);
		} else {
			++i;
		}
	}

	std::swap(arr[beg], arr[lt - 1]);
	--lt;

	return {lt, gt};
}

void insertionSort(std::vector<int> &arr, size_t beg, size_t end) {
	for (size_t i = beg + 1; i < end; ++i) {
		for (size_t j = i; j > beg && arr[j - 1] > arr[j]; --j) {
			std::swap(arr[j - 1], arr[j]);
		}
	}
}

void threeWayQuickSort(std::vector<int> &arr, size_t beg, size_t end, size_t threshold = 16) {
	if (beg + threshold >= end) {
		insertionSort(arr, beg, end);
		return;
	}

	auto [lt, gt] = threeWayPartition(arr, beg, end);
	threeWayQuickSort(arr, beg, lt, threshold);
	threeWayQuickSort(arr, gt, end, threshold);
}

时间复杂度:最好\(\varTheta(n)\)(所有元素都相同),最坏仍为\(\varTheta(n^2)\)(通过特殊构造仍可以使每次选的主元必为区间最值),随机序列的平均时间复杂度为\(\varTheta(n\log n)\)。要将最坏情况也限制在\(\varTheta(n\log n)\),必须采用内省排序。
空间复杂度:最好、平均\(\varTheta(\log n)\),最坏\(\varTheta(n)\)
稳定性:不稳定

计数排序

计数思想。仅限于关键字为整数且值域较小的情况。当然,对于非整数但可散列(hash)或值域过大的情况,也可以利用散列函数将其转换为值域较小的整数。

朴素的计数排序如下。

void countingSort(std::vector<int> &arr, int min, int max) {
	size_t range = max - min + 1;
	std::vector<size_t> cnts(range, 0);
	for (const auto &e : arr) {
		++cnts[e - min];
	}
	size_t idx = 0;
	for (int i = 0; i < range; ++i) {
		for (size_t j = 0; j < cnts[i]; ++j) {
			arr[idx] = i + min;
			++idx;
		}
	}
}

不过,这只能用于对整数本身进行排序。如果排序对象是其他的东西(例如结构体),仅以整数为键值,怎么办呢?可以采用以下的方案:

  1. 计算每个数出现了几次。
  2. 求出每个数出现次数的前缀和
  3. 利用出现次数的前缀和,倒序计算每个数的排名。

利用前缀和信息可以知道每个元素在相等键值的元素中如何排布。

void countingSort(std::vector<int> &arr, int min, int max) {
	if (arr.empty()) return;
	assert(min <= max);

	int range = max - min + 1;
	std::vector<size_t> cnts(range, 0);
	for (const auto &e : arr) {
		++cnts[e - min];
	}
	for (int i = 1; i < range; ++i) {
		cnts[i] += cnts[i - 1];
	} // prefix sum
	std::vector<int> aux(arr.size());
	for (size_t i = arr.size(); i--;) {
		aux[cnts[arr[i] - min] - 1] = arr[i];
		--cnts[arr[i] - min];
	}
	arr = std::move(aux);
}

时间复杂度:\(\varTheta(max-min+n)\)。一般来说值域大小是比元素个数大的,所以通常就是\(O(max-min)\)
空间复杂度:\(\varTheta(max-min+n)\)
稳定性:稳定。

桶排序

桶排序(bucket sort)可以认为是将计数排序扩展到更大的值域,其步骤为:

  1. 设置若干个“桶(bucket)”。
  2. 遍历序列,并将元素一个个放到对应的桶中。
  3. 对每个桶进行排序。可以用基于比较的排序,也可以用计数排序。
  4. 从不是空的桶里把元素再放回原来的序列中。

桶排序适用于待排序数据值域较大但分布比较均匀的情况。

void bucketSort(std::vector<int> &arr, int min, int max, int buc_num) {
	if (arr.empty()) return;
	assert(min <= max && buc_num > 0);

	int range = max - min + 1;
	int buc_size = (range + buc_num - 1) / buc_num; // ceil(range / buc_num)
	std::vector<std::vector<int>> buckets(buc_num);
	for (const auto &e : arr) {
		int buc_idx = std::min((e - min) / buc_size, buc_num - 1);
		buckets[buc_idx].push_back(e);
	}
	size_t idx = 0;
	for (auto &buc : buckets) {
		insertionSort(buc);
		for (const auto &e : buc) {
			arr[idx] = e;
			++idx;
		}
	}
}

桶排序的时空复杂度取决于内层排序的具体实现。当能够保证每个桶中的元素数量都较少时,采用插入排序为好,此时时间复杂度为\(\varTheta(n+n^2/buc\_num+buc\_num)\)。当\(buc\_num\approx n\)时,时间复杂度为\(\varTheta(n)\)
空间复杂度为\(\varTheta(buc\_num+n)\)
稳定性取决于内层排序的实现。如果内层排序是稳定的,而且按照元素原始顺序插入桶中,则桶排序仍然稳定。

排序相关STL

  • std::sort:一般采用内省排序。
  • std::stable_sort:一般用自适应归并排序实现。内存充足时会开辟全量或部分缓冲区,否则进行原地归并排序。
  • std::partial_sort:一般用堆排序实现。
  • std::nth_element

排序的应用

排序通常仅作为其他算法的预处理步骤。

逆序对计数

排序等价于消除逆序对。

给定一个数组,求其逆序对数。这个问题通常有两种办法:

  • 归并排序。
  • 维护一个值域上的树状数组(Fenwick/binary indexed tree)线段树(segment tree)。这两个都是比较高级的数据结构,不讲。

归并排序实现:排序后的数组无逆序对。归并排序的合并操作中,每次后段的元素被作为当前最小值取出时,前段剩余元素个数即是合并操作减少的逆序对数量。

uint64_t mergeSort(std::vector<int> &arr, size_t beg, size_t end) {
	if (beg + 1 >= end) return 0;

	// divide and conquer
	size_t mid = beg + (end - beg) / 2;
	uint64_t inv = mergeSort(arr, beg, mid) + mergeSort(arr, mid, end);

	// merge
	std::vector<int> aux(end - beg);
	size_t i = beg, j = mid, k = 0;
	while (i < mid && j < end) {
		if (arr[i] <= arr[j]) {
			aux[k] = arr[i];
			++i;
			++k;
		} else {
			inv += (mid - i); // arr[i, mid) > arr[j]
			aux[k] = arr[j];
			++j;
			++k;
		}
	}
	while (i < mid) {
		aux[k] = arr[i];
		++i;
		++k;
	}
	while (j < end) {
		aux[k] = arr[j];
		++j;
		++k;
	}

	for (k = 0; k < aux.size(); ++k) {
		arr[beg + k] = aux[k];
	}
	return inv;
}

时间复杂度:\(\varTheta(n\log n)\)
空间复杂度:\(\varTheta(n)\)

posted @ 2026-02-14 14:37  我就是蓬蒿人  阅读(14)  评论(0)    收藏  举报