很多计算机科学家认为排序是算法研究中最基础的问题,不仅如此,有的学者指出对待解决问题先进行排序,可能有利于的问题的分析以及求解思路的产生。
1. 排序算法
- 排序算法按照是否进行比较分为比较排序算法和非比较排序算法。
- 比较排序算法是通过比较数据中的元素的大小从而确定元素的相对顺序。根据决策树模型,可以证明:任意比较排序算法的排序n个元素的最坏情况运行时间的下界为Ω(nlgn),从而证明堆排序和归并排序是渐进最优的比较排序算法。
- 比较排序算法有插入排序,归并排序,堆排序,快速排序。在这四种排序算法中,插入排序,堆排序,和快速排序是原址排序算法,原址排序算法的好处就是不需要额外增加大量的空间,对于空间要求比较高的机器,原址排序能满足其对空间的限制。
- 堆排序与快速排序。快速排序的最坏情况复杂度是O(n^2),期望的时间复杂度是O(nlgn),在实际应用中通常比堆排序快。与插入排序类似,快速排序的代码也很紧凑,因此运行时间中隐含的常数系数很小。快速排序是排序大数组的最常用算法。
- 非比较排序算法有:计数排序,基数排序,桶排序。计数排序的前提是待排序的数组的元素在0-k之间的整数。基数排序是每个待排序的整数有d位数字,每个数字可能有k个取值。桶排序要求了解输入数组中的概率分布。对于[0, 1)内服从均与分布的n个实数,桶排序的平均运行时间为O(n)。
- 以下是几种排序算法的运行时间表
算法 | 最坏情况运行时间 | 平均时间/期望运行时间 | 是否是原址排序 | 是否稳定 |
---|---|---|---|---|
插入排序 | O(n^2) | O(n^2) | 是 | 是 |
归并排序 | O(nlgn) | O(nlgn) | 否 | 是 |
堆排序 | O(nlgn) | – | 是 | 否 |
快速排序 | O(n^2) | O(nlgn) | 是 | 否 |
计数排序 | O(k+n) | O(k+n) | 否 | 是 |
基数排序 | O(d(n+k)) | O(d(k+n) | 取决于选定的稳定排序算法 | 是 |
桶排序 | O(n^2) | O(n)(平均情况) | 否 | 是 |
1.1 插入排序
插入排序是稳定排序,时间复杂度是O(n^2)。思路是采用从后向前依次挪位置,直到key的位置找到,然后放进去。插入排序是原址排序。
void insert_sort(vector<int>& A){
for (int i = 1; i < A.size(); i++){
int key = A[i];
int j = i - 1;
while (j>=0 && A[j] > key){
A[j + 1] = A[j];
j--;
}
A[j+1] = key;
}
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
insert_sort(nums);
cout << "insert-sort: " << endl;
for (auto val : nums)
cout << val << " ";
cout << endl;
return 0;
}
插入排序运行结果如图
1.2 归并排序
归并排序,复杂度O(nlgn)。
思路是每次将数组分成两部分,然后对各部分进行排序,通过合并(归并)操作将两段已经排序过的数组进行整合,形成新的排序数组的顺序。归并排序是非原址排序。
void merge(vector<int>& nums, int left, int mid, int right){
vector<int> L(nums.begin() + left, nums.begin() + mid+1);
vector<int> R(nums.begin() + mid + 1, nums.begin() + right+1);
L.push_back(INT_MAX);
R.push_back(INT_MAX);
int i = 0;
int j = 0;
for (int k = left; k <= right; k++){
if (L[i] <= R[j])
nums[k] = L[i++];
else
nums[k] = R[j++];
}
}
void merge_sort(vector<int>& nums, int left, int right){
if (left < right){
int mid = (left + right) / 2;
merge_sort(nums, left, mid);
merge_sort(nums, mid + 1, right);
merge(nums, left, mid, right);
}
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
merge_sort(nums, 0, nums.size() - 1);
cout << "merge-sort: " << endl;
for (auto val : nums)
cout << val << " ";
cout << endl;
return 0;
}
运行结果如图:
1.3 堆排序
1.3.1 堆
- (二叉)堆是一个数组,它可以看成是近似的完全二叉树,树上的每一个结点对应数组中的一个元素。
- 结点A[i]的左右孩子结点对应为A[2i] & A[2i+1]。则A[i]的父结点为A[i/2](此处以及后文统一默认向下取整)。乘2与除2做操作在计算机中可以用移位操作实现,因此会比较快。
- 由此可知这棵树除最后一层外,完全充满的,且有左向右填充。
- 要构成堆还需要满足满足以下性质:
构成最大堆:任意父结点的值不小于其左右子结点的值,即,对于任意的i: A[i] >= A[2i] && A[i] >= A[2i+1]。
构成最小堆: 任意父结点的值不大于其左右子结点的值,即,对于任意的i: A[i] <= A[2i] && A[i] >= A[2i+1]。
1.3.1.1 维护堆的性质
堆的性质的维护,以下都以最大堆为例。
堆的维护的主要思想是“逐层下降”。举例:某个结点i, 假设其左右子结点left(i),right(i)都已经是最大堆,那么需要调节(或者说是调换)i, left(i), right(i)的值,并保证调换后的子树继续调换下去直到子树继续满足堆的性质。首先,如果结点i的值大于左右子结点的值(即i的值是三个值中的最大值),则不需要调换,因为左右子结点均满足堆的性质,同时结点i也满足了结点值大于等于子结点的值,所以整棵树就是最大堆(也有叫大顶堆)。其次,如果结点i的值不是三个中的最大值,不妨设左结点的值最大(即左结点的值比右结点以及i的值都大),则把i的值跟左结点的值调换,此时i, left, right满足了父结点值大于等于左右子结点值的情况,那么被调换后的左结点的值(变小了),以该结点为参考对象,它因为变小了,那么它还满足堆的要去吗,所以需要继续进行检测和调换,即逐层的进行下去,直到原来的i值滑落到稳定的位置。时间复杂度是O(lgn)。以下是算法代码的具体验证:
/*
最大堆的维持输入参数是一个数组,和一个小标i,
表示维持i到heap-size使该范围的二叉树为一个最大堆。
注意一个前提:结点i的左右子树都已经是最大堆。
此处假设a的数组长度size与heap-size一致。
*/
void max_heapify(vector<int>& a, int i){
int largest = i;
int left = 2 * i+ 1;//因为数组的下标是从0开始的,与算法表述里的数组下标都是从1开始的,故应该进行相应转化,以使其对应
int right = 2 * i + 2;
if (left < a.size() && a[left] > a[i])
largest = left;
if (right < a.size() && a[right] > a[largest])
largest = right;
if (largest != i){
int tmp = a[i];
a[i] = a[largest];
a[largest] = tmp;
max_heapify(a, largest);
}
}
int main()
{
vector<int> a(10, 0);
for (int i= 0; i < 10; i++)
a[i] = 10-i;
for (auto val : a)
cout << val << " ";
cout << endl;
a[0] = 1;
max_heapify(a, 0);
for (auto val : a)
cout << val << " ";
return 0;
}
1.3.1.2 建堆
知道堆的性质的维护方法以后,建堆的思路也就有了,就是从已经堆化的部分逐步往上调用最大堆化函数,使整个数组逐渐全部满足堆的要求。底层的每一个单独的叶结点是满足堆的要求的,叶结点有A.length - A.length/2 个。所以从叶结点的父结点那一层开始建堆,这些点的下标范围是:1– A.length/2 建堆的过程是从后往前每个点调用最大堆化函数,所以可能会有人认为复杂度是O(nlgn),这个结果虽然正确但不是渐近紧确的,经过证明可以得到建堆过程的一个更紧确的界:O(n)。证明方法请参考算法导论。一下是建堆过程的代码示例:
void build_max_heap(vector<int>& a){
for (int i = a.size() / 2-1; i >= 0; i--)
max_heapify(a, i);
}
int main()
{
srand(time(0));
vector<int> a(10, 0);
for (int i= 0; i < 10; i++)
a[i] = rand()%20;
for (auto val : a)
cout << val << " ";
cout << endl;
cout << "buid max-heap: " << endl;
build_max_heap(a);
for (auto val : a)
cout << val << " ";
cout << endl;
return 0;
}
1.3.2 堆排序算法
给定数组A,如要对数组A进行排序输出,则第一步是根据A数组建堆,此步时间复杂度为O(n), 建好堆的数组A的最大元素一定在A[1](算法描述里数组的首个位置下标为1,代码里数组的首个位置下标为0,此处再做一次区分)。那么我把A[1]与A[n]调换,则此时数组里的最大元素已经跑到数组的最后位置,那么数组的前n-1个元素是什么样的情况,由于A[1]和A[n]互换,所以新的A[1]结点以及其左右子结点组成的triangle不一定满足最大堆的性质了(父结点的值大于等于子结点的值),但是其左右子结点对应的树,依然是满足堆的性质的,所以对于A的前n-1个元素,只需要对A[1]结点调用维持堆化过程,整个n-1个元素就又变成了新的最大堆,其第一个元素A[1]就是n-1个元素中的最大值,也即是数组A中的次大值, 此时把A[1]与A[n-1]交换,则次大值放入了正确的位置,依次类推,直到位置2-n都放入了相应的值,则排序结束,时间复杂度O(nlgn)。以下是代码示例:
void max_heapify(vector<int>& a, int i, int heap_size){
int largest = i;
int left = 2 * i + 1;//因为数组的下标是从0开始的,与算法表述里的下标略微不对应,故应该用此表述
int right = 2 * i + 2;
if (left < heap_size && a[left] > a[i])
largest = left;
if (right < heap_size && a[right] > a[largest])
largest = right;
if (largest != i){
int tmp = a[i];
a[i] = a[largest];
a[largest] = tmp;
max_heapify(a, largest, heap_size);
}
}
void build_max_heap(vector<int>& a, int heap_size){
for (int i = heap_size / 2-1; i >= 0; i--)
max_heapify(a, i, heap_size);
}
void heapsort(vector<int>& a){
int n = a.size();
build_max_heap(a, a.size());
for (int i = n - 1; i >= 1; i--){
int tmp = a[0];
a[0] = a[i];
a[i] = tmp;
max_heapify(a, 0, i);
}
}
int main()
{
srand(time(0));
vector<int> a(10, 0);
for (int i= 0; i < 10; i++)
a[i] = rand()%20;
for (auto val : a)
cout << val << " ";
cout << endl;
cout << "heap sort: " << endl;
heapsort(a);
for (auto val : a)
cout << val << " ";
cout << endl;
return 0;
}
1.3.3 优先队列
略
1.4 快速排序
快排是排序中的经典算法,最坏情况下复杂度是O(n^2),期望的时间复杂度是O(nlgn)。主要思想是:每次给一个元素x=A[r]找到其特定的位置,使左边的元素都小于x,右侧的值都大于x。如何实现就是用i来标记小于x的个数,然后没遇到一个小于x的值,就把i的范围值加1,并把这个较小的值置换进小于x的区域里。最后r前的元素都遍历完了,就把元素x与i+1位置上的元素置换,因为i+1这个位置,对应的就应该是x,也就是说值x就对应了排好序的数组情况下的i+1的位置。剩下的对i+1位置之前之后的两段分别排序就行,递归进行。
int partition(vector<int>& a, int p, int r){
int x = a[r];
int i = p - 1;
for (int j = p; j < r; j++){
if (a[j] < x){
i++;
swap(a[i], a[j]);
}
}
swap(a[i + 1], a[r]);
return i + 1;
}
void quicksort(vector<int>& a, int p, int r){
if (p < r){
int q = partition(a, p, r);
quicksort(a, p, q - 1);
quicksort(a, q + 1, r);
}
}
int main()
{
srand(time(0));
vector<int> a(10);
for (int i = 0; i < 10; i++){
a[i] = rand() % 100;
}
for (auto val : a)
cout << val << " ";
cout << endl;
cout << "quicksort: " << endl;
quicksort(a, 0, a.size() - 1);
for (auto val : a)
cout << val << " ";
cout << endl;
return 0;
}
以下是随机版本的快排代码示例
int partition(vector<int>& a, int p, int r){
int x = a[r];
int i = p - 1;
for (int j = p; j < r; j++){
if (a[j] < x){
i++;
swap(a[i], a[j]);
}
}
swap(a[i + 1], a[r]);
return i + 1;
}
int randomized_partition(vector<int>& a, int p, int r){
int i = rand() % (r-p+1)+p;//i是从[p, r]之间的一个随机数
swap(a[i], a[r]);
return partition(a, p, r);
}
void quicksort(vector<int>& a, int p, int r){
if (p < r){
int q = randomized_partition(a, p, r);
quicksort(a, p, q - 1);
quicksort(a, q + 1, r);
}
}
int main()
{
srand(time(0));
vector<int> a(10);
for (int i = 0; i < 10; i++){
a[i] = rand() % 100;
}
for (auto val : a)
cout << val << " ";
cout << endl;
cout << "quicksort: " << endl;
quicksort(a, 0, a.size() - 1);
for (auto val : a)
cout << val << " ";
cout << endl;
return 0;
}
1.5 计数排序
在已知待排序的元素范围为0-k之间时,可使用计数排序。当k=O(n)时,此时运行时间为Θ(n)。思路是:记录对应元素出现的次数,则可以推算出改元素在排序后中的数组的下标。
void counting_sort(vector<int>& A, vector<int>& B, int k){
vector<int> C(k + 1, 0);
for (int i = 0; i < A.size(); i++)
C[A[i]]++;
for (int i = 1; i < C.size(); i++)
C[i] += C[i - 1];
for (int i = A.size() - 1; i >= 0; i--){
B[C[A[i]]-1] = A[i];
C[A[i]]--;
}
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
vector<int> B(10);
counting_sort(nums, B, 20);
cout << "counting-sort: " << endl;
for (auto val : B)
cout << val << " ";
cout << endl;
return 0;
}
1.6 基数排序和桶排序
基数排序,要求n个数,每个数d位,每位有k个可能,则复杂度为O(d(n+k))。基数排序与快排的比较:哪一个排序算法更适合取决于具体实现和底层硬件的特性(例如,快速排序通常比基数排序能更有效的使用硬件的缓存),以及输入数据的特性。另外,当主存容量比较宝贵时,我们可能更会倾向于像快速排序这样的原址排序算法。
桶排序假设输入数据服从均匀分布,并位于[0, 1)之间。最坏时间复杂度O(n^2),平均时间复杂度O(n)。只要输入数据满足下列性质:所有桶的大小的平方和与总元素成线性关系,那么桶排序就能在线性时间完成。桶排序思路是建立若干桶,然后把数据分入桶中,然后各个桶内进行插入排序,最后总的输出。
1.7 总结和要求
今天和伙伴进行了讨论,发现算法的实现要求熟练,即动手就能把代码实现,这算是基本的要求。然后是对时间复杂度的证明。
2. 顺序统计量
顺序统计量就是按顺序来的第k小的量。问题的输入是一个数组A(未排序),和一个整数i,输出是数组A中第i小的量。
2.1 最小值和最大值
寻找最小值或者最大值时,需要遍历吧比较进行n-1次比较,如下:
int minimum(vector<int>& A){
int min = A[0];
for (int i = 1; i < A.size(); i++){
if (min > A[i])
min = A[i];
}
return min;
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
cout << "minimum: " << minimum(nums) << endl;
return 0;
}
如果既要求最小值有要求最大值,则进行几次比较。通常的话2*(n-1)比较,这样是拿一个值既和最小值比较,也和最大值比较。其实可以进行3/2 *n次比较。不妨假设n是偶数,则先用数组A的前两个值对min和max进行赋值,然后每次取出两个值a和b,先a,b进行比较,再把大的跟max比,小的跟min比。这样每拿出两个数就进行3次比较,所以总共3*floor(n/2)次比较。代码示例如下:
//find minimum & maximum
int min_max(vector<int>& A, int& minimum, int& maximum){
const int n = A.size();
int begin=1;
minimum = maximum = A[0];
//对n是偶数的情况下进行处理
if (n % 2 == 0){
maximum = max(A[1], A[0]);
minimum = min(A[1], A[0]);
begin = 2;
}
//每次拿出来两个数做比较
for (int i = begin; i < A.size(); i+=2){
int a = A[i];
int b = A[i + 1];
if (a >= b){
if (maximum < a)
maximum = a;
if (minimum > b)
minimum = b;
}
else{
if (maximum < b)
maximum = b;
if (minimum > a)
minimum = a;
}
}
return 0;
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
int minimum, maximum;
min_max(nums, minimum, maximum);
cout << "minimum: " << minimum << endl;
cout << "maximum: " << maximum << endl;
return 0;
}
2.2 期望为线性时间的选择算法-查找数组中第k小的数
该方法类似于快排。一次选定一个partition q,如果q在A(p, r)中是第k个位置(即是第k小的),则返回A[q],如果(q-p+1=i) < k,则在比A[q]大的那些值里找地k-i小的值,如果i>k,则在比A[q]小的数组里找第k小的值。
int partition(vector<int>& A, int p, int r){
int x = A[r];
int i = p - 1;
for (int j = p; j < r; j++){
if (A[j] < x){
i++;
swap(A[i], A[j]);
}
}
swap(A[i + 1], A[r]);
return i + 1;
}
int randomized_partition(vector<int>& A, int p, int r){
int i = rand() % (r - p + 1) + p;//i是从[p, r]之间的一个随机数
swap(A[i], A[r]);
return partition(A, p, r);
}
int randomized_select(vector<int>& A, int p, int r, int i){
if (p == r)
return A[p];
int q = randomized_partition(A, p, r);
int k = q - p + 1;
if (k == i)
return A[q];
else if (k < i)
return randomized_select(A, q + 1, r, i - k);
else
return randomized_select(A, p, q - 1, i);
}
int main()
{
srand(time(0));
vector<int> nums(10);
for (int i = 0; i < nums.size(); i++){
nums[i] = rand() % 20;
}
for (auto val : nums)
cout << val << " ";
cout << endl;
cout << "the 3st minimum number: "
<< randomized_select(nums, 0, nums.size()-1, 3) << endl;
return 0;
}
2.3 最坏情况为线性时间的选择算法
略。
参考 算法导论