[Leetcode] 常见排序算法的标准模板

前言

排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。

分类

假设在待排序的文件中,存在两个或两个以上的记录具有相同的关键字,在用某种排序法排序后,若这些相同关键字的元素的相对次序仍然不变,则这种排序方法是稳定的,反之则称不稳定的

冒泡排序
基本思想

两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

void bubbleSort(vector<int> &a) {
	int n = a.size();
	bool flag = true; // 标记是否发生了交换
	for (int i = 1; i < n && flag; ++i) {
		flag = false;
		for (int j = n - 1; j >= i; --j) {
			if (a[j] > a[j + 1]) {
				swap(a[j], a[j + 1]);
				flag = true;
			}
		}
	}
}
复杂度分析

最好的情况,也就是要排序的表本身就是有序的,那么需要 n − 1 n-1 n1 次比较,没有数据交换,时间复杂度为 O ( n ) O(n) O(n)
最差的情况,即待排序表是逆序的情况,此时需要比较 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 \sum_{i=2}^n(i-1)=\frac{n(n-1)}{2} i=2n(i1)=2n(n1) 次,并作等数量级的记录移动。因此,总时间复杂度为 O ( n 2 ) O(n^2) O(n2)

简单选择排序
基本思想

通过 n − i n-i ni 次关键字间的比较,从 n − i + 1 n-i+1 ni+1 个记录中选出关键字最小(或最大)的记录,并和第 i ( 1 ≤ i ≤ n ) i(1 \leq i \leq n) i(1in) 个记录交换。

void selectionSort(vector<int> &a) {
	int n = a.size();
	for (int i = 0; i < n; ++i) {
		int min = i; // 无序区中最小元素位置
		for (int j = i + 1; j < n; ++j) {
			if (a[j] < a[min]) {
				min = j;
			}
			if (i != min) {
				swap(a[i], a[min]);
			}
		}
	}
}
复杂度分析

无论最好最差情况,其比较次数都是一样多,第 i i i 躺排序需要进行 n − i n-i ni 次关键字的比较,需要 ∑ i = 1 n ( n − i ) = n ( n − 1 ) 2 \sum_{i=1}^n (n-i)=\frac{n(n-1)}{2} i=1n(ni)=2n(n1) 次。因此,总的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。尽管与冒泡排序同为 O ( n 2 ) O(n^2) O(n2),但性能上要略优于冒泡排序。

直接插入排序
基本思想

将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增 1 的有序表。

void insertionSort(vector<int> &a) {
	int n = a.size();
	for (int i = 1; i < n; ++i) {
		for (int j = i; j > 0 && a[j] < a[j - 1]; --j) {
			swap(a[j], a[j - 1]);
		}
	}
}
复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,那么总共比较了 ( n − 1 ) ( ∑ i = 2 n 1 ) (n-1)(\sum_{i=2}^n1) (n1)(i=2n1) 次,没有移动的记录,时间复杂度为 O ( n ) O(n) O(n)
当最坏的情况,也就是要排序的表是逆序的,需要比较 ∑ i = 2 n i = ( n + 2 ) ( n − 2 ) 2 \sum_{i=2}^ni=\frac{(n+2)(n-2)}{2} i=2ni=2(n+2)(n2) 次,而记录移动次数也达到最大值 ∑ i = 2 n ( i + 1 ) = ( n + 4 ) ( n − 1 ) 2 \sum_{i=2}^n(i+1)=\frac{(n+4)(n-1)}{2} i=2n(i+1)=2(n+4)(n1) 次。
如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为 n 2 4 \frac{n^2}{4} 4n2 次。因此,得出时间复杂度为 O ( n 2 ) O(n^2) O(n2)。同样的时间复杂度,它的性能要比冒泡和简单选择更好一些。

希尔排序
基本思想

希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。

void shellSort(vector<int> &a) {
	int n = a.size(), gap = n / 3;
	while (gap > 0) {
		for (int i = gap; i < n; ++i) {
			int tmp = a[i];
			int j;
			for (j = i; j >= gap && tmp < a[j - gap]; j -= gap) {
				a[j] = a[j - gap];
			}
			a[j] = tmp;
		}
		gap /= 3;
	}
}
复杂度分析

大量研究表明,当增量序列为 Δ k = 2 t − k + 1 − 1 ( 0 ≤ k ≤ t ≤ ⌊ l o g 2 ( n + 1 ) ⌋ ) \Delta k=2^{t-k+1}-1(0 \leq k \leq t \leq \lfloor log_2(n+1) \rfloor) Δk=2tk+11(0ktlog2(n+1)) 时,可以获得不错的效率,其时间复杂度为 O ( n 3 2 ) O(n^{\frac{3}{2}}) O(n23)

归并排序
基本思想

假设初始序列含有 n n n 个记录,则可以看成是 n n n 个有序的子序列,每个子序列长度为 1,然后两两归并,得到 ⌈ n / 2 ⌉ \lceil n/2 \rceil n/2 个长度为 2 或 1 的有序子序列。如此重复,直到得到一个长度为 n n n 的有序序列为止。

void merge(vector<int>& a, int low, int mid, int high) {
	vector<int> tmpArr(high - low + 1);
	int k = 0, i = low, j = mid + 1;
	while (i <= mid && j <= high) {
		tmpArr[k++] = a[i] < a[j] ? a[i++] : a[j++];
	}
	while (i <= mid)  tmpArr[k++] = a[i++];
	while (j <= high) tmpArr[k++] = a[j++];
	for (int m = 0; m < tmpArr.size(); ++m) {
		a[low + m] = tmpArr[m];
	}
}

void mergeSort(vector<int>& a, int low, int high) {
	if (low == high) return;
	int mid = low + (high - low) / 2;
	mergeSort(a, low, mid);
	mergeSort(a, mid + 1, high);
	merge(a, low, mid, high);
}

void mergeSort(vector<int>& a) {
	mergeSort(a, 0, a.size() - 1);
}
复杂度分析

一趟归并需要将待排序序列中所有记录扫面一遍,因此耗费 O ( n ) O(n) O(n) 时间,而由完全二叉树的深度可知,整个归并排序需要进行 ⌈ l o g 2 n ⌉ \lceil log_2n \rceil log2n 次,因此总时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。而且这也是归并排序最好、最坏和平均的性能。
由于归并排序在归并中需要与原始记录序列同样的存储空间存放归并结果,递归时深度为 l o g 2 n log_2n log2n 的栈空间,因此空间复杂度为 O ( n + l o g 2 n ) O(n+log_2n) O(n+log2n)

快速排序
基本思想

通过一趟排序将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

/** 填坑法 */
void quickSort(vector<int> &a, int low, int high) {	
	int i = low, j = high;
	int pivot = a[low];  // 以第一个元素为比较基准
	while (i < j) {
		while (i < j && a[j] > pivot) {
			j--;  // 从最右找第一个比pivot小的元素
		}
		if (i < j) {
			a[i++] = a[j];  // 因为第一个元素已经被pivot记录,相当于空出了一个“坑”,把上一步找到的元素填进来,然后最左端的指针指向其右侧的下一个位置
		}
		while (i < j && a[i] < pivot) {
			i++;  // 从最左找第一个比pivot大的元素
		}
		if (i < j) {
			a[j--] = a[i];  // 填坑
		}
	}
	a[i] = pivot;  // 此时i==j
	if (low < i) quickSort(a, low, i - 1);
	if (high > i) quickSort(a, i + 1, high);
}

/** 交换法 */
void quickSort(vector<int> &a, int low, int high) {
	if (low > high) return;
	int i = low, j = high;
	int pivot = a[(i + j) / 2];  // 以中间元素为比较基准
	while (i <= j) {
		while (a[i] < pivot) i++; // 从最左找第一个比pivot大的元素
		while (a[j] > pivot) j--; // 从最右找第一个比pivot小的元素
		if (i <= j) {
			swap(a[i], a[j]);
			++i; --j;
		}
	}
	quickSort(a, low, j);
	quickSort(a, i, high);		
}

/* 使用上面任意一种递归即可 */
void quickSort(vector<int> &a) {
	quickSort(a, 0, a.size() - 1);
}
复杂度分析

在最好的情况下,每次划分都很均匀,如果排序 n n n 个关键字,其递归树的深度就为 ⌊ l o g 2 n ⌋ + 1 \lfloor log_2n \rfloor + 1 log2n+1,仅需递归 l o g 2 n log_2n log2n 次,时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,另一个为空。递归树就是一棵斜树,此时需要执行 n − 1 n-1 n1 次递归,且第 i i i 次划分需要经过 n − i n-i ni 次关键字比较才能找到第 i i i 个记录,因此比较次数为 ∑ i = 1 n ( n − i ) = n ( n − 1 ) 2 \sum_{i=1}^n(n-i)=\frac{n(n-1)}{2} i=1n(ni)=2n(n1) 次,最终时间复杂度为 O ( n 2 ) O(n^2) O(n2)
平均的情况,设枢轴的关键字应该在第 k ( 1 ≤ k ≤ n ) k(1 \leq k \leq n) k(1kn) 的位置,那么 T ( n ) = 1 n ∑ k = 1 n ( T ( k − 1 ) + T ( n − k ) ) + n = 2 n ∑ k = 1 n T ( k ) + n T(n)=\frac{1}{n}\sum_{k=1}^n(T(k-1)+T(n-k))+n=\frac{2}{n}\sum_{k=1}^nT(k)+n T(n)=n1k=1n(T(k1)+T(nk))+n=n2k=1nT(k)+n,可得其数量级为 O ( n l o g n ) O(nlogn) O(nlogn)
空间复杂度,主要是递归造成的栈空间的使用,最好情况递归树深度为 l o g 2 n log_2n log2n,复杂度为 O ( l o g n ) O(logn) O(logn)。最坏情况,需要进行 n − 1 n-1 n1 递归调用,其空间复杂度为 O ( n ) O(n) O(n)。因此平均情况为 O ( l o g n ) O(logn) O(logn)

堆排序
基本思想

将待排序的序列构造成一个大顶堆。此时整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的 n − 1 n-1 n1 个序列重新构造成一个堆,这样就会得到 n n n 个元素中的次小值。如此反复执行,最终得到一个有序序列。

void sink(vector<int> &a, int i, int n) {
	int child, tmp;
	for (tmp = a[i]; 2 * i + 1 < n; i = child) {
		child = 2 * i + 1;
		if (child != n - 1 && a[child] < a[child + 1]) {
			++child;
		}
		if (tmp < a[child]) {
			a[i] = a[child];
		} else {
			break;
		}
	}
	a[i] = tmp;
}

void heapSort(vector<int>& a) {
	int n = a.size();
	for (int i = n / 2 - 1; i >= 0; --i) {
		sink(a, i, n);
	}	
	for (int j = n - 1; j > 0; --j) {
		swap(a[0], a[j]);
		sink(a, 0, j);
	}
}
复杂度分析

它的运行时间主要消耗在初始构建堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和必要的互换。对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为 O ( n ) O(n) O(n)
在正式排序时,第 i i i 次取堆顶记录重建堆需要用 O ( l o g 2 i ) O(log_2i) O(log2i) 的时间(完全二叉树的某个结点到根结点的距离为 ⌊ l o g 2 i + 1 ⌋ \lfloor log_2i + 1 \rfloor log2i+1),并且需要取 n − 1 n-1 n1 次堆顶记录,因此重建堆的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
由于堆排序对原始记录的状态不敏感,因此无论最好、最坏和平均时间复杂度均为 O ( n l o g n ) O(nlogn) O(nlogn)。但由于初始构建堆所需的比较次数较多,因此不适合待排序序列个数较少的情况。

计数排序
基本思想

计数排序的基本思想是对于给定的输入序列中的每一个元素 x x x,确定该序列中值小于 x x x 的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。一旦有了这个信息,就可以将 x x x 直接存放到最终的输出序列的正确位置上。

vector<int> countingSort(vector<int>& a) {
	int n = a.size();
	int maxVal = INT_MIN, minVal = INT_MAX;
	for (int i = 0; i < n; ++i) {
		maxVal = max(maxVal, a[i]);
		minVal = min(minVal, a[i]);
	}
	int diff = maxVal - minVal;
	// 遍历数组,统计元素个数
	vector<int> counts(diff + 1);
	for (int i = 0; i < n; ++i) {
		++counts[a[i] - minVal];
	}
	// 统计数组做变形,后面的元素等于前面的元素之和
	for (int i = 1; i < counts.size(); ++i) {
		counts[i] += counts[i - 1];
	}
	// 倒序遍历原始数列,从统计数组找到正确位置,输出到结果数组
	vector<int> res(n);
	for (int i = n - 1; i >= 0; --i) {
		int index = a[i] - minVal;
		res[counts[index] - 1] = a[i];
		--counts[index];
	}
	return res;
}
复杂度分析

计数排序是复杂度为 O ( n + k ) O(n+k) O(n+k) 的稳定的排序算法, k k k 是待排序列最大值,适用在对最大值不是很大的整型元素序列进行排序的情况下(整型元素可以有负数,我们可以把待排序列整体加上一个整数,使得待排序列的最小元素为 0,然后执行计数排序,完成之后再变回来。这个操作是线性的,所以计数这样做计数排序的复杂度仍然是 O ( n + k ) O(n+k) O(n+k)。本质上是一种空间换时间的算法,如果 k k k 比较小,计数排序的效率优势是很明显的,当k变得很大的时候,这个算法可能就不如其他优秀的排序算法。

桶排序
基本思想

用桶的思想来将数据放到相应的桶内,再将每一个桶内的数据进行排序,最后把所有桶内数据按照顺序取出来,得到的就是我们需要的有序数据。

vector<int> bucketSort(vector<int>& a) {
	int n = a.size();
	int maxVal = INT_MIN, minVal = INT_MAX;
	for (int i = 0; i < n; ++i) {
		maxVal = max(maxVal, a[i]);
		minVal = min(minVal, a[i]);
	}
	int diff = maxVal - minVal;
	// 初始化桶,将每个元素放入桶中
	int bucketSize = n;
	vector<vector<int>> buckets(bucketSize);
	for (int i = 0; i < n; ++i) {
		int bucketIndex = (a[i] - minVal) * (bucketSize - 1) / diff;
		buckets[bucketIndex].push_back(a[i]);
	}
	// 对每个桶进行排序
	for (int i = 0; i < buckets.size(); ++i) {
		sort(buckets[i].begin(), buckets[i].end());
	}
	// 合并数据
	vector<int> res;
	for (auto bucket : buckets) {
		res.insert(res.end(), bucket.begin(), bucket.end());
	}
	return res;
}
复杂度分析

桶排序的平均时间复杂度为线性的 O ( n + k ) O(n+k) O(n+k),其中 k = n ∗ ( l o g n − l o g m ) k=n*(logn-logm) k=n(lognlogm)。如果相对于同样的 n n n,桶数量 m m m 越大,其效率越高,最好的时间复杂度达到 O ( n ) O(n) O(n)。当然桶排序的空间复杂度为 O ( n + m ) O(n+m) O(n+m),如果输入数据非常庞大,而桶的数量也非常多,则空间代价无疑是昂贵的。

基数排序
基本思想

我们考虑对整数进行基数排序的过程,对于整数,我们可以将其分成个位、十位、百位……基数排序就是先对元素的个位进行排序,然后再对十位进行排序。以此类推,最终按最高位进行排序完成整个排序。逻辑可以理解为,比如正常情况下我们排序两个整数,我们首先对比的是十位然后是个位,现在我们颠倒过来先排序个位,再排序十位,如果十位相同上一步我们已经按个位排好序,所以还是有序的。

vector<int> radixSort(vector<int> &a, int exp) {
	int n = a.size(), counts[10] = {0};
	// 遍历数组,统计元素个数
	for (int i = 0; i < n; ++i) {
		++counts[(a[i] / exp) % 10];
	}
	// 统计数组做变形,后面的元素等于前面的元素之和
	for (int i = 1; i < 10; ++i) {
		counts[i] += counts[i - 1];
	}
	// 倒序遍历原始数列,从统计数组找到正确位置,输出到结果数组
	vector<int> res(n);
	for (int i = n - 1; i >= 0; --i) {
		int index = (a[i] / exp) % 10;
		res[counts[index] - 1] = a[i];
		--counts[index];
	}
	return res;
}

vector<int> radixSort(vector<int>& a) {
	int n = a.size();
	int mx = *max_element(a.begin(), a.end());
	for (int exp = 1; mx / exp > 0; exp *= 10) {
		a = radixSort(a, exp);
	}
	return a;
}
复杂度分析

基数排序的时间复杂度是 O ( n k ) O(nk) O(nk),其中 n n n 是排序元素个数, k k k 是数字位数。注意这不是说这个时间复杂度一定优于 O ( n l o g n ) O(nlogn) O(nlogn),因为 k k k 的大小一般会受到 n n n 的影响。 以排序 n n n 个不同整数来举例,假定这些整数以 B 为底,这样每位数都有 B 个不同的数字, k k k 就一定不小于 l o g B ( n ) logB(n) logB(n)。由于有 B 个不同的数字,所以就需要 B 个不同的桶,在每一轮比较的时候都需要平均 n l o g B nlogB nlogB 次比较来把整数放到合适的桶中去,所以就有, k ≥ l o g B ( n ) k \geq logB(n) klogB(n),每一轮(平均)需要 n l o g B nlogB nlogB 次比较。

总结

[Leetcode] 常见排序算法的标准模板_第1张图片

你可能感兴趣的:(Leetcode)