算法(一)排序算法总结

排序算法的稳定性

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,ri=rj,且ri在rj之前,而在排序后的序列中,ri仍在rj之前,则称这种排序算法是稳定的;否则称为不稳定的。

堆排序、快速排序、希尔排序、直接选择排序不是稳定的排序算法,而基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序,计数排序是稳定的排序算法。

冒泡排序

核心思想

算法开始时,设置待排序的数组为整个数组,每遍历一遍数组,都从左到右依次查看相邻两个元素,把较大者放在右边,遍历一遍后会把最大的数冒到数组的最右端,待排序的数组长度减1,直到所有的元素都不需要挪动位置时,结束排序过程,值得注意的是,过程中不仅让最大的元素不断向右移,也让小的元素向左移,

//Summary:  冒泡排序
//Parameters:
//       array: 待排序的数组
//Return : null, array已经排好序
void bubble_sort(vector<int>& array) {
	for (int i = 0; i < array.size(); i++) {
		int flag = 0; // 结束遍历的标志
		for (int j = 0; j < array.size()-1-i; j++) {
			if (array[j]>array[j+1]) {
				flag = 1;
				swap(array[j+1], array[j]);
			}
		}
		if (flag == 0) break;
	}
}

算法时间复杂度: O ( n 2 ) O(n^{2}) O(n2)

算法空间复杂度: O ( 1 ) O(1) O(1)

稳定性: 当前后元素相同时,不发生交换,因而算法是稳定的,

选择排序

核心思想

将待排序列分为有序区和无序区,每次排序都在无序区中寻找出最小的关键值放在无序区的最前面和已有的有序区构成新的有序区,然后继续对无序区的元素进行选择直到排序完毕。

//Summary:  选择排序
//Parameters:
//       array: 待排序的数组
//Return : null, array已经排好序
void select_sort(vector<int>& array) {
	for (int i = 0; i < array.size() - 1; i++) {
		int index = i; // 从i+1到array.size()-1最小元素的下标
		for (int j = i + 1; j < array.size(); j++) {
			if (array[j] < array[index]) index = j;
		}
		if (index != i) swap(array[index], array[i]);
	}
}

算法时间复杂度: O ( n 2 ) O(n^2) O(n2)

算法空间复杂度: O ( 1 ) O(1) O(1)

稳定性:不稳定,比如序列【5, 5, 3】第一趟就将第一个[5]与[3]交换,导致第一个5挪动到第二个5后面,要排序 N 个数,选择排序大约需要 N 2 / 2 N^2 / 2 N2/2个比较以及整整 N 次交换。

直接插入排序

核心思想

就像打牌一样,牌堆上放置的是未排序好的牌,手中拿的是已排序好的牌,从牌堆中抽出一张牌card然后把这张牌和手中的牌从后往前比较,如果手中的某一张牌比card大,那么就把这样牌向后挪一挪,如果小于等于card,则把card插入到这个位置的后一个位置上来,因为后一个位置上的牌比card要大,如果手中所有的牌都比card大,那么就直接把card放到手中牌的最前面,这样card就被合理的插入到手中了,而后重复之前的过程,直到把牌堆中所有的牌都拿到手上为止,

具体细节:代码中有两层循环,外层的一个循环结束时的状态如下图所示,

//Summary:  直接插入排序
//Parameters:
//       array: 待排序的数组
//Core:
//       跳出while循环时array[i]<=key, array[i+1]>key
//Return : null, array已经排好序
void insert_sort(vector<int>& array) {
	int key = 0, i = 0;
	for (int j = 1; j < array.size(); j++) {
		key = array[j];
		i = j - 1;
		while (i >= 0 && array[i] > key) {
			array[i + 1] = array[i];
			--i;
		}
		// find the proper location of the key and insert into it. 
		array[i + 1] = key; // 这个时候array[i]<=key, array[i+1]>key
	}
}

算法的时间复杂度: O ( n 2 ) O(n^{2}) O(n2)

算法的空间复杂度: O ( 1 ) O(1) O(1)

稳定性: 前后元素相同时不发生交换,因而算法是稳定的,插入排序平均需要 1 / 4 N 2 1/4 N^2 1/4N2的比较次数和 1 / 4 N 2 1/4 N^2 1/4N2的交换次数

折半插入排序

核心思想

用折半查找元素应该插入位置的方法代替直接插入排序中的暴力遍历寻找插入位置的方法,

//Summary:  折半插入排序
//Parameters:
//       array: 待排序的数组
//Core:
//       跳出while循环时array[low]>key, high = low-1
//Return : null, array已经排好序
void half_insert_sort(vector<int>& array) {
	int key = 0, i = 0, low, high;
	for (int j = 1; j < array.size(); j++) {
		key = array[j];
		low = 0;
		high = j - 1;

		while (low <= high) { // 跳出循环时low = high + 1
			int mid = (low + high) / 2;
			if (array[mid] > key) high = mid - 1;
			else low = mid + 1;
		}
		for (int i = j - 1; i >= low; i--){
			array[i + 1] = array[i];
		}
		array[low] = key;
	}
}

这段代码的核心思想就在于内层while循环结束后如何确定插入位置的那4行代码,下面用图解释具体如何确定,当运行到high和low重合的时候,如果这时指定的元素还要比key大的话,high是要减1的,Low不动,这时low指向的这个位置就是要插入的位置,而如果high和low重合时指定的元素比key小,则low+1, 那么这个时候low这个位置就是要插入的位置,综上所述,low 就是要插入的位置

算法的时间复杂度和空间复杂度以及稳定性和直接插入排序相同,但是时间在一般情况下都要比直接插入排序要快,

其实折半排序并没有减少之前插入排序的移动元素的次数,但是减少的是比较的次数,这样导致折半插入排序比插入排序要快一些,

shell排序

核心思想

插入排序的一种更高效的改进版本。它的作法不是每次一个元素挨一个元素的比较。而是初期选用大跨步(增量较大)间隔比较,使记录跳跃式接近它的排序位置;然后增量缩小;最后增量为 1 ,这样记录移动次数大大减少,提高了排序效率。

希尔排序的思想在于每次我们会将数组项移动若干位置 这种操作方式叫做对数组进行 h-排序。所以 h-有序的数组 包含 h 个不同的交叉的有序子序列。
下面这个数组是 4-排序 后的数组,从前 4 个元素开始,检查每 4 个元素都是有序的

具体思路:

  1. 先取一个正整数 d1(d1 < n),把全部记录分成 d1 个组,所有距离为 d1 的倍数的记录看成一组,然后在各组内进行插入排序
  2. 然后取 d2(d2 < d1)
  3. 重复上述分组和排序操作;直到取 di = 1(i >= 1) 位置,即所有记录成为一个组,最后对这个组进行插入排序。一般选 d1 约为 n/2,d2 为 d1 /2, d3 为 d2/2 ,…, di = 1。
//Summary:  shell排序
//Parameters:
//       array: 待排序的数组
//Core:
//       第一层循环决定分组的步长
//       第二层循环根据分组的步长对数组进行分组
//       第三,四层循环对每个组进行直接插入排序
//Return : null, array已经排好序
void shell_sort(vector<int>& array) {
	int gap = array.size() / 2;
	while (gap > 0) {
		for (int i = 0; i < gap; i++) {
			for (int j = i+ gap; j < array.size(); j += gap) {
				int key = array[j], k = j - gap;
				while (k >= 0 && array[k] > key) {
					array[k + gap] = array[k];
					k -= gap;
				}
				array[k + gap] = key;
			}
		}	
		gap /= 2;
	}
}

算法时间复杂度:希尔排序的时间复杂度与增量序列的选取有关。例如,当增量为1时,希尔排序退化成了直接插入排序,此时的时间复杂度为O(N²),而Hibbard增量的希尔排序的时间复杂度为 O ( N 3 / 2 O(N^{3/2} O(N3/2)。

空间复杂度: O ( 1 ) O(1) O(1)

稳定性: 不稳定,因为有可能相邻且相同的元素被分到了不同的组,这样排序后这两个元素的顺序会改变,因而不稳定,

归并排序

核心思想:把一个数组首先从前到后迅速拆成一对一对的数据,再把这些对排好序后从前到后排序合并,

比较官方的解释如下:

  1. 把无序表中的每一个元素都看作是一个有序表,则有n个有序子表;
  2. 把n个有序子表按相邻位置分成若干对(若n为奇数,则最后一个子表单独作为一组),每对中的两个子表进行归并,归并后子表数减少一半;
  3. 反复进行这一过程,直到归并为一个有序表为止。

具体细节:通过sort函数递归迅速把数组nums分为nums.size()/2个元数组,而后再把相邻两个原数组按照大小排好序合并,而后再让相邻两个排好序的数组排序合并,这样一直合并到最后就是排好序的原始数组,

//Summary:  merge the array whose left and right side are arranged
//Parameters:
//       arr: 待排序的数组
//       low: 待排序数组待合并区域头指针
//       mid: 待排序数组待合并区域分界指针
//       high: 待排序数组待合并区域尾指针
//Core:
//       设立两个临时数组arr_left和arr_right来存放待合并区域的左右两部分数据
//       (这时左右两部分数据已经排好序了),且arr_left和arr_right最后都添加
//       一个inf,这是为了arr_left和arr_right的数据放回到原始待合并区域时方便起见的
//Return : null, arr待合并区域已合并完成
void merge(std::vector<int>& arr, int low, int mid, int high) {
    std::vector<int> arr_left(arr.begin() + low, arr.begin() + mid + 1);
    arr_left.push_back(INT_MAX);
    std::vector<int> arr_right(arr.begin() + mid + 1, arr.begin() + high + 1);
    arr_right.push_back(INT_MAX);

    int left_index = 0;
    int right_index = 0;
    for (int i = low; i <= high; ++i) {
        if (arr_left[left_index] <= arr_right[right_index]) {
            arr[i] = arr_left[left_index++];
        }
        else {
            arr[i] = arr_right[right_index++];
        }
    }
}

// 不使用额外辅助空间,时间复杂度变为O(n)
void merge(vector<int>& arr, int start1, int mid, int end) {
    int start2 = mid + 1;
    if (arr[mid] <= arr[start2]) {
        return;
    }
    while (start1 <= mid && start2 <= end) {
        if (arr[start1] <= arr[start2]) {
            ++start1;
        } else {
            int value = arr[start2];
            int idx = start2;
            while (idx != start1) {
                arr[idx] = arr[idx - 1];
                --idx;
            }
            arr[start1] = value;
            ++start1;
            ++mid;
            ++start2;
        }
    }
}

//Summary:  sort the array iteratively 
//Parameters:
//       ori: 待排序的数组
//       low: 待排序数组待合并区域头指针
//       high: 待排序数组待合并区域尾指针
//Core:
//       先通过sort的递归操作以及merge合并操作把数组的左半边区域排好序,
//       而后把用同样的方法把右半边排好序,而后再通过merge合并,
//Return : null, ori待合并区域已合并完成
void merge_sort(vector<int>& ori, int low, int high) {
	// important: iteration condition	
	if (low < high) {
		int mid = (low + high) / 2;
		// sort the left side
		sort(ori, low, mid);
		// sort the right side
		sort(ori, mid + 1, high);
		// merge the left and right side
		merge(ori, low, mid, high);
	}
}

// 迭代形式,空间复杂度能降至O(1)
void merge_sort(vector<int>& arr) {
    int n = arr.size();
    for (int cur_size = 1; cur_size < n - 1; cur_size = 2 * cur_size) {
        for (int left_start = 0; left_start < n - 1; left_start += 2 * cur_size) {
            int mid = min(left_start + cur_size - 1, n - 1);
            int right_end = min(left_start + 2 * cur_size - 1, n - 1);
            merge(arr, left_start, mid, right_end);
        }
    }
}

时间复杂度:这个过程中每完成一次数组的两两合并都需要cn的时间,而一共要进行lg(n)次合并,那么算法的时间复杂度就是 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n))

空间复杂度:merge操作时array_left和array_right的数组长度的总和为"n",而递归的时候压入栈的数据占用的空间,为log(n), 则归并排序的空间复杂度为 O ( n ) O(n) O(n)

稳定性:由于不可能出现排序后相同元素顺序发生改变的情况,因而是稳定的,

多路归并排序

归并排序的普适情况,因为普通的归并排序相当于2路归并排序,可以一下子将数组分为k个部分,对这k个部分分别进行排序,这k个部分的排序可以采用快排的方法,然后再把这k个部分的最小值作为全局最小值,第二小值作为全局第二小值…, 直到把所有的数据全部都排好为止,

上述方法特别适合处理海量数据,因为内存的原因,不能把数据一下全都导入到内存中个,因而只导入一部分,完成排序,然后把排序好的数据存入磁盘,这样再把新数据到入进来,直到把所有部分都局部排好序,这样再进行上述的合并方式,把全局排序好的数分阶段存入磁盘,算法完成,算法的流程图如下,

内存中的排序:

外部排序,

堆排序

堆的定义:堆是一个完全二叉树,(完全二叉树:叶节点只能出现在最下层和次下层,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树)。

堆有序的定义:当一棵二叉树的每个结点都大于等于它的两个子结点时,称为堆有序。
相应的,在堆有序的二叉树中,每个结点都小于等于它的父节点,从任意节点向下,我们都能得到一列非递增的元素。
所以,根结点是堆有序的二叉树中的最大结点。

二叉堆的表示法:

其中每个结点的关键字都不大于其孩子结点的关键字,这样的堆称为小根堆

其中每个结点的关键字都不小于其孩子结点的关键字,这样的堆称为大根堆

堆排序分为两个阶段

  • 堆的构建阶段:将数组中的元素变为堆有序,构建阶段又是由维护堆的工作完成的,
  • 排序阶段:将根节点和最底端的结点交换位置,然后下沉到合适位置,将 N 减一,那么后几个元素就是已经排好序。

以下代码和思路是以大根堆为例来阐述的

维护堆(堆的构建第一阶段)

核心思想:在检查堆中每一个节点是否满足最大堆的要求,这个时候一个很重要的前提如下,

被检查节点的左右孩子为根节点的堆都满足最大堆的要求

首先比较被检查节点和其左右子节点的大小,如果三者中最大值不等于被检查节点,则让最大值点和被检查节点互换,注意这里的互换包括位置以及值的互换,之后在新位置上递归维护堆,

//Summary:  维护堆 
//Parameters:
//       array: 待排序的数组
//       i: 以array[i]为根节点构最大堆
//       delta: 数组中不需要加维护堆的尾部元素的个数,
//              因为最后delta个元素已经排好序
//Core:
//       先通过sort的递归操作以及merge合并操作把数组的左半边区域排好序,
//       而后把用同样的方法把右半边排好序,而后再通过merge合并,
//Return : null, ori待合并区域已合并完成
void max_heapify(vector<int>& array, int i, int delta=0) {
	int largest = 0;
	// 如下if 判断的第一项是为了判断left是否超出数组范围,对于right(i)的判断同理
	if (left(i) < array.size() - delta && array[i] < array[left(i)]) {
		largest = left(i);
	} 
	else {
		largest = i; // 保证在进入下一个判断前largest有定义
	}
	if (right(i) < array.size()-delta && array[largest] < array[right(i)]) {
		largest = right(i);
	}
	if (largest != i) {
		swap(array[i], array[largest]);
		max_heapify(array, largest, delta);
	}
}

维护堆的时间复杂度为O(log(n)), 证明如下,

维护一棵根节点为i,大小为n的子树,要做两件事情:

  1. 调整heap[i], heap[left[i]], heap[right[i]]之间的关系,代价为 θ ( 1 ) \theta(1) θ(1)
  2. 递归调用的时间,最坏情况下是树的高度log(n),因为会从根节点一直交换到叶子节点,而现在的目标是对于节点数为n的完全二叉树,找到最长的路径当当路径最长时左子树节点个数最大,那么现在的目标就是在寻找什么条件下左子树节点个数最大,因为右子树的节点总是小于等于左子树的,显然当最后一层是半满的时候左子树的节点数最多,这时左子树的节点数为2/3n,右子树的节点数为1/3n,这时候树的最底层刚好半满,证明如下,

算法的时间复杂度为: O ( l o g ( n ) ) O(log(n)) O(log(n))

因而总的时间复杂度为: O ( l o g ( n ) ) + θ ( 1 ) = O ( l o g ( n ) ) O(log(n)) + \theta(1) = O(log(n)) O(log(n))+θ(1)=O(log(n))

建堆(堆的构建第二阶段)

维护堆的准备工作完成之后,开始建堆, 谨记这一点,一个堆的非叶子节点数为 n 2 \frac{n}{2} 2n个, 因为最后一个叶子节点满足如下公式,

l e n g t h − 1 ≤ 2 ∗ i + 2 ≤ l e n g t h length-1\leq 2*i+2\leq length length12i+2length

因而可以得到最后一个非叶子节点的下标为 n 2 \frac{n}{2} 2n,而后从堆的最后一个非叶子节点开始维护堆,这样就能够满足维护堆的先决条件了,这个顺序很重要,

void build_max_heap(vector<int>& array) {
	for (int i = array.size() / 2-1; i >= 0; i--) {
		max_heapify(array, i);
	}
}

对于建堆过程中的算法时间复杂度,首先这个循环是从 i = h e a d s i z e / 2 − > 1 i = headsize/2 -> 1 i=headsize/2>1,也就是说这是一个bottom-up的建堆。于是,有1/2的元素向下比较了1次,1/4的元素向下比较了2次,1/8的元素向下比较了3次,…, 1 / 2 k 1/2^k 1/2k的向下比较了k次,其中 1 / 2 k ≤ 1 1/2^k \leq 1 1/2k1, k 约等于lg(n)。于是就有总的比较量:

到最后,有 T = 2 n − 2 − l o g 2 n ≤ 2 n T=2n-2-log_{2}n \leq 2n T=2n2log2n2n, 因而时间复杂度为 O ( n ) O(n) O(n),

堆排序阶段

建堆的准备工作完成后,正式开始进行堆排序,因为每次建堆完成后根节点都是最大的数,把根节点和堆的最后一个数据互换,而后把堆的长度减1,这样就能够把最大的数排除在建堆的过程之外,之后再重复这个过程,就能够得到排好序的数组了,

//Summary:  堆排序阶段
//Parameters:
//       array: 待排序的数组
//Core:
//       每次把一个元素排好序放到 数组的尾部时delta自增1,
//       通过delta来记录已经排好序的元素个数
//Return : null, array 已经排好序
void heap_sort(vector<int>& array) {
	int delta = 0;
	build_max_heap(array);
	for (int i = array.size() - 1; i >= 1; i--){
		swap(array[0], array[array.size() - 1 - delta]); // 每次都把数组的第一个元素和"未排好序的数"的最后一个数交换
		max_heapify(array, 0, ++delta); // 只去维护未排好序的那些数
	}
}

算法时间复杂度:

堆排序的时间花费分为两部分,

  1. 建堆,时间复杂度为 O ( n ) O(n) O(n),
  2. 循环维护堆,时间复杂度为 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n)),因为每次维护堆都会花费log(n)的时间,一共有n个点,因而需要花费 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n))时间,

因而堆排序总的时间复杂度为 O ( n + n l o g ( n ) ) = O ( n l o g ( n ) ) O(n+nlog(n))=O(nlog(n)) O(n+nlog(n))=O(nlog(n))

算法空间复杂度:堆排序没有申请额外的临时空间,因而空间复杂度为 O ( 1 ) O(1) O(1)

稳定性:不稳定的,原因如下图所示,这样的话,维护完大根堆后接着排序的时候一开始后面的7就会和前面的7位置互换,

优先级队列

核心思想:以堆为基础,建立一个具有优先级的队列,传统的队列都是尾入头出,但是优先级队列能够随时弹出一个优先级最高的队列,且可以随时把插入队尾的元素按照其优先级的大小放入合适的位置,

这里我们以数字的大小表示优先级队列中元素优先级的高低,

弹出优先级最高的元素

弹出元素的前提是队列已经满足最大堆的要求

弹出优先级最高的元素,而后维护堆,使其依然满足最大堆要求,

int extract_max(vector<int>& array) {
	if (array.size() == 0) exit(-1);

	int max = array[0]; // 返回最大元素
	array[0] = array[array.size() - 1];
	max_heapify(array, 0, 1); // 维护最大堆

	return max;
}

算法的时间复杂度为 O ( l o g ( n ) ) O(log(n)) O(log(n)),因为这个算法的时间复杂度就是维护堆的时间复杂度,

提升某个元素的优先级

如果某个元素的优先级需要提升,那么提升之后有可能不满足最大堆的要求,因而需要让提升后的元素和父节点比较大小,如果比父节点大,那么与父节点互相交换,即让这个节点上浮一层,而后重复上述操作,直到满足最大堆的要求,

void increase_key(vector<int>& array, int i, int key) {
	if (array[i] > key) exit(-1);

	array[i] = key;
	while (i > 0 && array[parent(i)] < array[i]) {
		swap(array[parent(i)], array[i]);
		i = parent(i);
	}
}

算法的时间复杂度为 O ( l o g ( n ) ) O(log(n)) O(log(n)),因为最坏的情况就是把最底端叶节点一直上浮到根节点,那么时间就是树的高度 l o g ( n ) log(n) log(n),

插入元素element到队列

首先对队列扩容,而后插入一个INT_MIN,之后再提升队列最后一个元素的优先级,特别注意的是,插入INT_MIN这个环节很重要,因为如果随便插入一个数,之后再去插入指定的元素时,可能指定的元素比随便插入的这个数要小,因而就无法插入成功,

void insert(vector<int>& array, int key) {
	array.push_back(INT_MIN); // 确保key能够插入成功
	increase_key(array, array.size() - 1, key);
}

快速排序

核心思想:找到一个元素,使得这个元素左边的数都比这个元素小,右边的元素比这个元素都大,且在寻找这个元素的过程中把所有小于等于这个元素的数放到数组的左边,所有大于这个元素的数放到右边,而后把这个元素放在上述两堆数据中间,

具体操作见下图,这是快速排序的核心内容:

之后再按照相同的方法对这个元素的左边和右边进行排序,直到不能再分为止,这部分是一个递归的过程,

最坏时间复杂度:如果快速排序出现了最坏的情况,即原始数列是逆序或者正序的话,那么快速排序就变成了反向的冒泡排序,即每次只把最后一个元素置换到最前面,算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),最坏 情况下的解决方法是在算法一开始,随机选择数组中的一个元素和最后一个元素互换,排序过程中不会出现类似冒泡排序的现象的出现,

期望时间复杂度:根据快速排序的递推公式,可以得到

T ( n ) = a T ( n / b ) + f ( n ) T(n) = aT(n/b)+f(n) T(n)=aT(n/b)+f(n)
而此时的f(n)正好是 θ ( n ) \theta(n) θ(n), 上式可理解为先对整个数组进行划分,划分的时间复杂度为f(n),之后把问题划分为两个子问题,根据主定理公式第二条,可得期望的时间复杂度为 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n))

最好时间复杂度: 恰巧每次都在中间划分,因而时间复杂度为 θ ( n l o g ( n ) ) \theta(nlog(n)) θ(nlog(n))

算法空间复杂度:由于是递归调用,空间复杂度为 O ( l o g ( n ) ) O(log(n)) O(log(n)),

稳定性:是不稳定的,目前觉得最好的例子为下图,

//Summary:  找到最后一个元素的安放位置
//Parameters:
//       array: 待排序的数组
//Core:
//       以最后一个元素为标志元素, 大于这个元素的元素放到这个元素的右边,
//       小于等于这个元素的放到这个元素得左边,
//Return : 安放好的标志元素的位置, array已经部分有序
int partion(vector<int>& array, int low, int high) {
	int i = low - 1, x = array[high];
	for (int j = low; j <= high - 1; j++) {
		if (array[j] <= x) { // 这里能够显示出快速排序的不稳定性
			swap(array[j], array[++i]);
		}
	}
	swap(array[i + 1], array[high]);
 
	return i + 1;
}

//Summary:  快速排序
//Parameters:
//       array: 待排序的数组
//Core:
//       找到标志元素的安放位置后,递归的对两边元素进行排序
//Return : null, array 已经排好序
void quick_sort(vector<int>& array, int low, int high) {
	if (low < high) { // 数组越界问题要额外注意
		int q = partion(array, low, high);
		quick_sort(array, low, q - 1);
		quick_sort(array, q + 1, high);
	}
}

快速排序的另一种方法如下所示:

void q_sort(vector<int>& nums, int low, int high) {
    if (low >= high) {
        return;
    }
    int i = low;
    int j = high;
    int target = nums[low];
    while (i < j) {
        while (i < j && nums[j] >= target) {
            --j;
        }
        while (i < j && nums[i] <= target) {
            ++i;
        }
        swap(nums[i], nums[j]);
    }
    swap(nums[i], nums[low]);
    q_sort(nums, low, i - 1);
    q_sort(nums, i + 1, high);
}

如下为快速排序的迭代形式,

//Summary:  快速排序迭代形式
//Parameters:
//       array: 待排序的数组
//Core:
//       找到标志元素的安放位置后,迭代的对两边元素进行排序
//Return : null, array 已经排好序
void quick_sort_iterative(std::vector<int>& arr) {
    if (arr.empty()) {
        return;
    }
    int low = 0;
    int high = arr.size() - 1;
    std::stack<int> stk;
    stk.push(high);
    stk.push(low);
    while (!stk.empty()) {
        int low = stk.top();
        stk.pop();
        int high = stk.top();
        stk.pop();
        if (low > high) {
            continue;
        }
        int q = partion(arr, low, high);
        if (q < high) {
            stk.push(high);
            stk.push(q + 1);
        }
        if (q > low) {
            stk.push(q - 1);
            stk.push(low);
        }
    }
}

计数排序

核心思想

先统计数组A中每个元素出现的次数,把结果保存在C中,而后C[A[j]]中统计的是A 中小于等于A[j]的元素个数, 因而如果A中的元素都不相同,则其实C[A[j]]代表的就是A[j]应该存放的正确位置,即比如说有17个元素比A[j]小,则A[j]应该放在第17个位置,但是如果有相同的元素,则放置完A[j]之后必须把C[A[j]]–,这样才能够保证下一次等于A[j]的元素会直接摆在上一次A[j]的前面,这样就能够保证这样的算法是稳定的,而B中存放的就是排好序的结果,

template <class T>
int find_max(std::vector<T>& arr) {
    if (arr.empty()) {
        return -1;
    }

    T val = arr[0];
    for (int i = 0; i < arr.size(); ++i) {
        if (arr[i] > val) {
            val = arr[i];
        }
    }
    return static_cast<int>(val);
}

//Summary:  计数排序
//Parameters:
//       A: 待排序的数组
//		 B: 排序后的数组
//Return : null

//Details: 
//		C 中一开始存放的是A中每个元素出现的次数
//		而后C[A[j]]中统计的是A 中小于等于A[j]的元素个数
//		B 把C的下标和下标对应的元素互换,之后C[A[j]]--,即可得到对应结果,
void count_sort(std::vector<int>& A, std::vector<int>& B) {
    std::vector<int> C(find_max(A) + 1, 0);
    for (auto& elem: A) {
        ++C[elem];
    }
    for (int i = 1; i < C.size(); ++i) {
        C[i] += C[i - 1];
    }

    // 从后到前遍历是为了保证算法的稳定性
    // 记得遍历的是A,而不是C
    // 而且记得是从后向前遍历,所以要--i,
    for (int i = A.size(); i >= 0; --i) {
        B[C[A[i]] - 1] = A[i];
        --C[A[i]];
    }

}

void test_count_sort() {
    std::vector<int> A = {1,2,3,4,6,5,9,8,7};
    std::vector<int> B(A.size(), 0);
    count_sort(A, B);

    for (auto& elem: B) {
        std::cout << elem  << std::endl;
    }
}

对于算法的时间复杂度,设数组的长度为N, 设数组中最大数为k, 因为统计元素出现次数和最后保存结果所需要的时间为 O ( N ) O(N) O(N),而累加的时间复杂度为 O ( k ) O(k) O(k), 因而总的时间复杂度为 O ( N + k ) O(N+k) O(N+k), 空间复杂度也为 O ( N + k ) O(N+k) O(N+k),

对于算法的稳定性,上述代码最后一行保证了算法是稳定的,因为A[j]前面和A[j]相同的元素在最后的结果中一定会摆在A[j]前面,

基数排序

核心思想

把每个数按照右对齐的方式存放在数组中,先从个位开始计数排序(因为个位上的数字的范围是从0到9),而后根据排序的结果把整个数组中的数按照这一位的排序结果移动,之后再来判定十位,直到把所有的位都判定完成则排序完成,

基数排序能够正确实施的前提就是每一位的计数排序是稳定的,不然有可能在某一高位进行计数排序的时候出现大的数排到小的数的前面,

具体代码如下,

int length(int num) {
	int m = 0;
	int count = 0;
	while (num) {
		++count;
		num /= 10;
	}
	return count;
}

void print_vec(vector<int>& arr) {
	for (auto elem : arr) {
		cout << elem << " ";
	}
	cout << endl;
}

int max_len(vector<int>& arr) {
	if (arr.empty())
		return 0;
	int max_len = length(arr[0]);
	for (auto elem : arr) {
		int len = length(elem);
		if (len > max_len)
			max_len = len;
	}
	return max_len;
}

void get_digit(vector<int>& arr, vector<int>& result, int loc) {
	if (arr.empty() || loc < 0 || loc >= arr.size()) {
		return;
	}
	result.clear();

	int base = pow(10, loc);
	for (auto elem : arr) {
		result.push_back(elem / base % 10);
	}
}

int get_digit(int num, int loc) {
	int base = pow(10, loc);
	return num / base % 10;
}

//Summary:  基数排序
//Parameters:
//       arr: 待排序数组
//		 digit: 待比较的位
//		 max_len: 最大位数
//Return : null, arr已经排好序
void radix_sort_core(vector<int>& arr, int digit, int max_len) {
	if (digit > max_len - 1)
		return;
	vector<int> count(10, 0);
	vector<int> bucket(arr.size(), 0);

	vector<int> single_digits;
	int single_digit = 0;
	for (auto elem : arr) {
		single_digit = get_digit(elem, digit);
		++count[single_digit];
		single_digits.push_back(single_digit);
	}

	for (int i = 1; i < count.size(); ++i) {
		count[i] += count[i - 1];
	}

	// 基数排序核心,整个数随着指定位上的大小关系动
	for (int i = bucket.size() - 1; i >= 0; --i) {
		int single_digit = single_digits[i];
		bucket[count[single_digit] - 1] = arr[i];
		--count[single_digit];
	}
	print_vec(bucket);
	// 把bucket中的值刷新到arr
	arr.assign(bucket.begin(), bucket.end());
	radix_sort_core(arr, digit + 1, max_len);
}

void radix_sort(vector<int>& arr) {
	if (arr.size() <= 1)
		return;
	for (auto elem : arr) {
		if (elem < 0)
			return;
	}

	int Max_len = max_len(arr);
	radix_sort_core(arr, 0, Max_len);
}

void test_radix_sort() {
	int num = 0;
	vector<int> arr;
	while (cin >> num) {
		arr.push_back(num);
	}
	radix_sort(arr);
	print_vec(arr);
}

算法的时间复杂度为 k ∗ O ( N ) k*O(N) kO(N) k k k为最大位数,而 N N N是数组长度,每比较一位的时候都只是简单的遍历一遍数组就能把问题搞定,因而时间复杂度为 O ( N ) O(N) O(N), 而一共要遍历 k k k次,因而复杂度为 k ∗ O ( N ) k*O(N) kO(N)

空间复杂度为 O ( N ) O(N) O(N),进行每一位的比较时只是额外申请一个和原始数组等长的空间,因而空间复杂度为 O ( N ) O(N) O(N),

算法由于是建立在计数排序的基础上,因而是稳定的,

桶排序

核心思想

前提:一个数组中的元素隶属于有限的空间,如范围[0-9],对这样的数组arr进行排序,先把数组的数的取值范围range从大到小划分为等长的 N 份,一份称为一个桶,每个桶有一个编号,每个桶中存放一个小范围内的数,将arr数组中的元素按照范围放在每个桶中,桶内载进行排序,而后把每个桶中的数据从前到后串联在一起就得到了最后的结果,如果出现空桶就直接跳过,

特别注意的是,只有数组是均匀分布的情况下桶排序才会生效,如果数组中最大的元素max要比数组的长度大的话,存放max的时候就会数组越界,

工作原理如下图,

时间复杂度:从上述过程来看,算法的时间复杂度就是`$O(n)$`,从一开始构建桶到最后排序都是只遍历一遍的,而对于数学公式的推导,可以看<算法导论>中关于桶排序的时间代价,其实很容易懂的,

空间复杂度:因为需要开辟一个和数组长度相同或者小于数组长度的空间来放桶,因而空间复杂度为 O ( n ) O(n) O(n),

稳定性:不确定,如果桶内排序是稳定的,那么桶排序算法就是稳定,反之则不稳定,

桶排序的特点就是占用空间来换取时间,其实和hash表的道理差不多,而且待排序的数组需要满足均匀分布这个条件,其实挺苛刻的,

//Summary:  桶排序
//Parameters:
//       array: 待排序的数组
//Core:
//       空间换取时间,有空桶则跳过
//Return : null, array 已经排好序
void bucket_sort(vector<int>& array){
	int length = array.size();
	vector<int> buckets(length, 0); //准备一堆桶,桶的下标即待排序数组的数
	//此时每个桶中里面都是0 

	for (int i = 0; i<length; ++i){
		buckets[array[i]]++; //把每个元素放入到对应的桶中  
	}

	int index = 0;
	for (int i = 0; i<length; ++i){ //把蛋取出,空桶则直接跳过  
		for (int j = 0; j<buckets[i]; j++){
			array[index++] = i;
		}
	}
}

各种排序时间和空间复杂度

排序算法是否为原地排序稳定性平均时间最好时间最坏时间空间复杂度
冒泡排序稳定 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
选择排序不稳定 O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
直接插入排序稳定 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
折半插入排序稳定 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
shell排序不稳定 O ( n 3 / 2 ) O(n^{3/2}) O(n3/2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)
归并排序不是稳定 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( n ) O(n) O(n)
堆排序不稳定 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) O ( l g ( n ) ) O(lg(n)) O(lg(n))
快速排序不稳定 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) θ ( n ∗ l o g ( n ) ) \theta(n*log(n)) θ(nlog(n)) O ( n 2 ) O(n^2) O(n2) O ( l g ( n ) ) O(lg(n)) O(lg(n))
计数排序不是稳定 O ( n ) O(n) O(n) O ( n ) O(n) O(n) O ( n ) O(n) O(n) O ( n ) O(n) O(n)
基数排序不是稳定 O ( k ∗ n ) O(k*n) O(kn) O ( k ∗ n ) O(k*n) O(kn) O ( k ∗ n ) O(k*n) O(kn) O ( n ) O(n) O(n)
桶排序not surenot sure O ( n ) O(n) O(n) O ( n ) O(n) O(n) O ( n ) O(n) O(n) O ( n ) O(n) O(n)
  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值