排序
文章目录
- 稳定性
- 内部排序:排序期间元素全部存放在内存中
- 外部排序:排序期间元素无法同时存在内存中,必须在排序的过程中根据要求不断地在内、外之间移动的排序。
插入排序(Insert Sort)
直接插入排序
思想是,从第二个元素开始,第一个视作已经排序好的序列,不断将元素 在之前排序好的序列中 插入到合适位置,找到合适位置后,这后面的元素均后移。
void InsertSort(int A[], int len) {
int temp;
for (int i = 1; i < len; len ++) { // 从第二个开始,第一个可视作排序完毕
if (A[i] < A[i - 1]) { // A[i] 小于前驱,则需要将 A[i] 插入到前面
temp = A[i]; // 用 temp 暂存 A[i]
for (int j = i - 1; j >= 0 && A[j] > temp; j--) {
/* 从 i 的前一个开始,直至第一个不满足顺序的元素,将它们全部后移一位,
注意用 temp 和 A[j] 比较,因为第一次移位后,A[i]便被覆盖了。*/
A[j + 1] = A[j];
}
/* 此时 j 指向的是不满足顺序排序的元素的前一个元素。*/
A[j + 1] = temp;
}
}
}
-
从第二个元素开始循环,第一个可视作排序完成。
-
用
if (A[i] < A[i - 1])
判断这一趟有无必要移动,若有必要,则令temp = A[i]
,防止移动时 A[i] 被覆盖后无法获取正确内容。 -
移动元素时,从当前元素的前驱开始,注意终止条件:j 需合法,并且 要找到不满足顺序的元素。
结束循环后,最终 j 的位置是不满足顺序的元素的 前驱。
-
总共有 n - 1 趟,最好时间复杂度为 O(n),最坏时间复杂度为 O(n^2),平均复杂度为 O(n^2)
-
该算法 稳定
折半插入排序
直接插入的优化
思想是,从第二个元素开始,第一个视作已经排序好的序列,不断将元素 在之前排序好的序列中 插入到合适位置,找合适位置时,使用折半查找,找到合适位置后,这后面的元素均后移。。
void BinaryInsertSort(A[], int len) {
int temp;
int low, high, mid;
for (int i = 1; i < len; i ++ ) { // 从第二个开始,第一个可视作排序完毕
if (A[i] < A[i - 1]) { // A[i] 小于前驱,则需要将 A[i] 插入到前面
temp = A[i]; // temp 记录 A[i]
low = 2, high = i - 1, mid = (low + high) / 2; // 折半初始化
while (low <= high) { // 折半查找失败信号 low > high
mid = (low + high) / 2;
if (A[mid] > temp) // 在左查找
high = mid - 1;
if (A[mid] < temp) // 在右查找
low = mid + 1;
if (A[mid] == temp) // 特殊之处,相等时,在右查找保证 稳定性
low = mid + 1;
}
/** 折半结束后,此时 low 指向的下标恰好是需要被插入的位置,所以开始移位
移位的起始仍然是 i - 1,终点则确定下来了,一定是 low,
此时 for 中不必要写 A[j] > temp了 */
for (int j = i - 1; j > low; j --) {
A[j + 1] = A[j];
}
A[low] = temp;
}
}
}
- 同样的,
if (A[i] < A[i - 1])
是开始排序的“信号”,随后仍要添加temp = A[i];
,防止 A[i] 在元素移动后无从找起 - 为保证稳定性,当
A[mid] = temp;
时,意味着前面的序列中出现了与 当前元素相同的 key,此时应该在右边继续查找,即令low = mid + 1
,防止两个元素交换位置。 - 移动的次数没有变,只是比较关键字次数减少,所以时间复杂度仍然为 O(n^2)
- 对链表无法实现折半查找,虽然“移动”时间很低,但是由于无法随机存取,时间复杂度仍然是 O(n^2)
希尔排序(Shell Sort)——缩小增量排序
- 注意 d 是增量,而非“间隔”
void ShellSort(A[], int len) {
// A[0] 只是暂存单元,不是哨兵,当 j <= 0 时,插入位置已到
int i, j, dk;
for (dk = len / 2; dk >= 1; dk = dk / 2) { // 每次步长变化
/** 序列元素从 1 开始,第一个元素可视为排序好的,dk + 1 则刚好是子表中第二个元素
每次 i++,切换到“不同的子表”进行 直接插入排序,而非一次性完成一个子表的排序*/
for (i = dk + 1; i < len; i = i ++) {
if (A[i] < A[i - dk]) { // 子表中该元素比前驱小,则进行 “移位”
A[0] = A[i]; // 此时的 A[0] 相当于 temp
for (j = i - 1; j >= 1; j = j - dk) {
A[j + dk] = A[j]; // 注意移动时,向后移动 dk
}
A[j + dk] = A[0]; // 移动结束后, + dk 才是对应的位置
}// if // 该子表该位置前 排序完成
}
}
}
/** version 2 更符合算法思想~ */
void ShellSort(A[], int len) {
int i, j, dk, temp;
for (dk = len / 2; dk >= 1; dk = dk / 2) { // 每次步长变化
/** 这次用 TableStart 来记录子表的起始位置,每次针对一个子表操作 */
for (int TableStart = 0; TableStart < dk; TableStart ++) {
/** 同样的从子表第二个元素开始排序 */
for (i = TableStart + dk; i < len; i = i + dk) {
if (A[i] < A[i - dk]) { // 老规矩,if + temp
temp = A[i];
/** 注意终止条件是找到 合适的 A[j] */
for (j = i - 1; j >= 0 && A[j] > A[0]; j = j - dk) {
A[j + dk] = A[j];
}
A[j + dk] = temp;
}
}// 一个子表完成
}// dk 个子表完成
}// 大 for
}
- 设定好 d 后,一次完整的 Shell 排序有 d 次直接插入排序。
交换排序
冒泡排序(BubbleSort)
老朋友
- 外部循环控制趟数,每走一趟,末尾就排序好一个元素,于是少比较一次。
- 内部循环是每一趟做的事,从第一个开始,每次与后面(j < len -1 防止越界)作比较,符合条件就交换。
// v1 从前往后冒泡~
void BubbleSort(A[], int len) {
bool flag;
/* 做 len - 1次循环即可,因为每次将 最值 放在最后,放 n - 1 个后,即完成了排序*/
for (int i = 0; i < len - 1; i ++) {
/** 可以这么理解,从头到尾,两两比较,“交换”即“淘汰”,最终能走向终点的就是胜者——“最值”
j 和 后面一个比较,最多只用比较 len - 1 次,否则越界;
而每一趟都会在末尾固定好一个“局部最值”,所以下一趟比较次数 - 1
则每次只用比较 len - 1 - i次了 */
for (int j = 0; j < len - i - 1; j ++ ) {
if (A[j] < A[j + 1]) {
swap(A[j], A[j+1]);
flag = true; // 有交换发生
}
}// 一趟结束
if (flag == flase) // 一趟无交换,则已经有序
break;
}
}
// v2 从后往前冒泡~邪门儿的很,用 v 1 吧
void BubbleSort (A[], int len) {
bool flag;
for (int i = 0; i < len - 1; i ++) {
for (int j = len - 1; j > i; j --) {
if (A[j - 1] < A[j]) {
swap(A[j], A[j+1]);
flag = true;
}
}
if (flag == flase)
break;
}
}
🔸快速排序——务必实践
快速排序每次划分的结果是确定了“基准”元素在序列中的位置,并使得此时序列中基准元素左边元素均小于基准元素,右边均大于基准元素,此时再分别基准左右子序列进行递归,不断确定一个个基准元素,即可完成排序。
/** 快速排序很像对树进行遍历的思想~ 只可意会不可言传~ */
void QuickSort(*A, int low, int high) {
if (low < high) { // 递归终止条件 low 和 high相等时,仅有一个元素,无需排序了
/** 对当前序列进行划分——确定 pivot 的位置,然后以此递归 */
int pivotpos = Partition(A, low, high);
QuickSort(A, low, pivotpos - 1); // 依次对子表递归排序
QuickSort(A, pivotpos + 1, high);
}
}
/** 核心所在便是 “划分”,通过 low high 不断比较、交换、移动,使得基准元素移动到一个确定位置
从而以基准元素为界,划分左右子序列*/
int Partition(*A, low, high) {
/** 注意此步,是将初始的 low 位置的元素作为基准,所以 pivot 也记录了此时 low 位置的元素
在后续该位置便可以放一个 小于 pivot 的元素,被覆盖掉 */
ElemType pivot = A[low];
while (low < high) { // 循环跳出条件,low = high 即意味着找到了基准元素的位置
/** 移动 high,直至找到一个小于 pivot 的元素
一定是先移动 high 指针,出现小于 pivot 的元素,去覆盖“被 pivot 记录过的 A[low]” */
while (low < high && A[high] >= pivot)
high--; // high 前移,当 high = low 时,就确定了 pivot 的位置!
/** 循环结束有两种情况:
1.找到了小于 pivot 的元素,此时固定 high,令该元素前移到 low 的位置
2.high = low,此时已经找到了 pivot 的位置了,所以 A[low] = A[high] 是自己=自己*/
/** 第一次的 A[low] 被 pivot 记录过,相当于此位置“空缺”,可以让小于 pivot 的 A[high]
移动到这个位置,之后 A[low] 移动给 A[high],也相当于此处“空缺”,
可以让循环结束符合条件的 A[high] 移动至此*/
A[low] = A[high];
/** 移动 low,直至找到一个大于 pivot 的元素 */
while (low < high && A[low] <= pivot)
low ++;
/** 循环结束有两种情况:
1.找到了大于 pivot 的元素,将其赋值给先前的 high
2.high = low,此时已经找到了 pivot 的位置了,所以 A[low] = A[high] 是自己=自己*/
/** 前面的 A[high] 移动到了 low 位置上,此时 high 位置“空缺”,将 A[low] 移动到 high
位置,此时 low 位置便“空缺”了,为下一次大 while 的 A[high] 移动做准备*/
A[high] = A[low];
}
A[low] = pivot; // 最终 low = high,这就是 pivot 的位置
return low; // 返回 pivot 的位置
}
-
递归的终止条件是,子序列中仅有一个元素,即此时low = high
-
划分的终止条件是,low = high 时,一定找到了基准元素的位置
-
基准元素(枢轴)取表中第一个元素,即表初始时的 A[low]
-
划分时,high 指针用来找小于 pivot 的元素,找到后,将它移动到 low 的位置上,此时 high 位置“空缺”;从 low 开始找大于 pivot 的元素,找到后,便可将其移动到“空缺”的 high 位置,而后 low 位置也“空缺”,重复上述过程,直至 low = high 时,便确定了 pivot 的位置。
-
提升算法效率:
尽量选取一个可以将数据中分的枢轴元素,如,从序列头尾及中间选取一个“中间值”作为枢轴元素,中间值和 A[low] 交换位置,再令 pivot = A[low],即可转换成上述的程序划分;
再如,随机选取一个元素作为枢轴元素,处理方法类似。
-
算法并不稳定。
-
快排的递归并非像归并、块排序那样,递归到最深处,然后层层返回
快排在第一趟结束后,即确定了一个基准元素的位置
第二趟时,会确定该基准左右两个子表各自基准元素的位置,此时可把序列分为 4 部分(3 个基准),满足如下关系:
num < [pivot2_1] < num < [pivot1] < num < [pivot2_2] < num 其中 num 可以是 0 到 多个数字
验证一个序列是否是第二趟的结果,只需验证上述关系(从左向右找,“左边都小,右边都大”,则该元素可以是基准,再从该元素右边起,重复找基准,直到扫描到序列末尾,特别的,当基准为末尾元素时,一定满足【因为右边的子序列递归已经结束了】,便不再需要找基准了)即可。
快速排序递归二叉树
- 把 n 个元素组织成二叉树,二叉树的层数就是递归调用的层数,计算其最小高度和最大高度,即可得知最优和最差空间复杂度。
- 在上图的二叉树中,一次划分 是在一个子表中确定 “基准” 元素的位置,而 一趟排序 指的是在一整层的子表中确定“基准”元素的位置,所以有 一次划分只确定一个元素位置,一趟排序确定多个元素位置。(从程序看来,这也不难理解!)
时间复杂度和空间复杂度
选择排序
简单选择排序(SelectSort)
第 i 趟排序:找从下标为 i 到 len - 1 中的最值,将其和下标为 i 的元素交换,重复 n - 1趟即可完成。
/** 王道书,记录下标即可 */
void SelectSort(ElemType A[], int len) {
for (int i = 0; i < len - 1; i ++){ // n - 1 趟
int min = i; // 记录最小元素位置
for (int j = i + 1; j < len; j ++) { // 从 i 的后一个开始找 最小元素
if (A[j] < A[min]) // 找到一个比当前记录的最小值小
min = j; // 更新最小元素位置
}
if (min != i) // 一趟结束,如果最初 i 的位置不是最小值
swap(A[i], A[min]); // 交换 min 到 i 位置上。
}
}
/** MHH 版本,记录最小 Value*/
void SelectSort(ElemType A[], int len) {
int minValue;
for (int i = 0; i < len - 1; i ++) { // 进行 n - 1 次即可排序完成
for (int j = i; j < len; j++) {
minValue = A[j];
if (A[j] < minValue){
minValue = A[j];
swap(A[j], A[i]);
}
}
}
}
🔸堆排序(HeapSort)
堆排序
- 大根堆:转化成二叉树,根>左右
- 小根堆:转化成二叉树,根<左右
堆的性质和堆排序算法思想
预 备 知 识 : 堆 是 一 种 特 殊 的 二 叉 树 , 而 使 用 顺 序 存 储 的 二 叉 树 ( 从 下 标 1 开 始 ) , 有 以 下 性 质 : 结 点 i 的 左 孩 子 — — 2 i 结 点 i 的 右 孩 子 — — 2 i + 1 结 点 i 的 父 结 点 — — ⌊ i / 2 ⌋ 结 点 i 所 在 层 — — ⌈ log 2 ( i + 1 ) ⌉ = ⌊ log 2 i ⌋ + 1 判 断 结 点 i 是 否 有 左 孩 子 — — 2 i < = n ? 判 断 结 点 i 是 否 有 右 孩 子 — — 2 i + 1 < = n ? 判 断 结 点 i 是 否 是 叶 子 / 分 支 结 点 — — i > ⌊ n / 2 ⌋ ? — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — \begin{array}{l} 预备知识:\\ 堆是一种特殊的二叉树,而使用顺序存储的二叉树(从下标\ 1\ 开始),有以下性质:\\ 结点\ i\ 的左孩子——2i\\ 结点\ i\ 的右孩子——2i+1\\ 结点\ i\ 的父结点——\left\lfloor {i/2} \right\rfloor\\ 结点\ i\ 所在层——\left\lceil {{{\log }_2}(i + 1)} \right\rceil = \left\lfloor {{{\log }_2}i} \right\rfloor + 1\\ 判断结点\ i\ 是否有左孩子——2i<=n?\\ 判断结点\ i\ 是否有右孩子——2i+1<=n?\\判断结点\ i\ 是否是叶子/分支结点——i>\left\lfloor {n/2} \right\rfloor?\\————————————————————————————————————————— \end{array} 预备知识:堆是一种特殊的二叉树,而使用顺序存储的二叉树(从下标 1 开始),有以下性质:结点 i 的左孩子——2i结点 i 的右孩子——2i+1结点 i 的父结点——⌊i/2⌋结点 i 所在层——⌈log2(i+1)⌉=⌊log2i⌋+1判断结点 i 是否有左孩子——2i<=n?判断结点 i 是否有右孩子——2i+1<=n?判断结点 i 是否是叶子/分支结点——i>⌊n/2⌋?—————————————————————————————————————————
【 算 法 思 路 】 : ① 将 序 列 初 始 化 成 堆 ( 大 顶 堆 或 小 顶 堆 ) , 以 大 顶 堆 为 例 : ② 此 时 堆 顶 元 素 为 序 列 首 部 元 素 , 堆 中 最 后 一 个 元 素 为 序 列 尾 部 元 素 , 将 这 两 个 元 素 交 换 : 此 时 尾 部 元 素 为 先 前 大 顶 堆 中 最 大 元 素 , 前 l e n − 1 个 元 素 由 于 序 列 首 部 元 素 变 成 了 最 小 值 , 不 再 是 堆 ; ③ 将 上 述 不 成 堆 的 序 列 ( 不 包 括 尾 部 已 经 交 换 — — 取 出 的 元 素 ) 调 整 成 堆 , 在 这 个 新 的 堆 中 , 交 换 序 列 首 位 ; 此 时 该 序 列 也 不 成 堆 了 , 重 复 ③ 直 至 新 堆 中 仅 剩 一 个 元 素 。 — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — 【 注 】 每 次 得 到 一 个 新 堆 时 , 交 换 序 列 首 和 尾 元 素 , 使 得 当 前 堆 最 值 移 动 到 序 列 尾 部 , 由 整 个 序 列 → 子 序 列 的 调 整 堆 的 过 程 , 从 尾 到 首 , 便 得 到 了 依 次 递 减 的 序 列 。 【 注 】 算 法 的 难 点 在 于 如 何 调 整 堆 , 子 堆 调 整 后 , 其 子 堆 便 可 能 不 满 足 堆 的 定 义 , 因 此 需 要 不 断 “ 下 坠 ” 。 \begin{array}{l} 【算法思路】:\\ ①将序列初始化成堆(大顶堆或小顶堆),以大顶堆为例:\\ ②此时堆顶元素为序列首部元素,堆中最后一个元素为序列尾部元素,将这两个元素交换:\\ \ \ \ 此时尾部元素为先前大顶堆中最大元素,前\ len-1\ 个元素由于序列首部元素变成了最小值,不再是堆;\\ ③将上述不成堆的序列(不包括尾部已经交换——取出的元素)调整成堆,在这个新的堆中,交换序列首位;\\ \ \ \ 此时该序列也不成堆了,重复③直至新堆中仅剩一个元素。\\————————————————————————————————————————\\ 【注】每次得到一个新堆时,交换序列首和尾元素,使得当前堆最值移动到序列尾部,由整个序列→子序列\\ 的调整堆的过程,从尾到首,便得到了依次递减的序列。\\ 【注】算法的难点在于如何调整堆,子堆调整后,其子堆便可能不满足堆的定义,因此需要不断“下坠”。 \end{array} 【算法思路】:①将序列初始化成堆(大顶堆或小顶堆),以大顶堆为例:②此时堆顶元素为序列首部元素,堆中最后一个元素为序列尾部元素,将这两个元素交换: 此时尾部元素为先前大顶堆中最大元素,前 len−1 个元素由于序列首部元素变成了最小值,不再是堆;③将上述不成堆的序列(不包括尾部已经交换——取出的元素)调整成堆,在这个新的堆中,交换序列首位; 此时该序列也不成堆了,重复③直至新堆中仅剩一个元素。————————————————————————————————————————【注】每次得到一个新堆时,交换序列首和尾元素,使得当前堆最值移动到序列尾部,由整个序列→子序列的调整堆的过程,从尾到首,便得到了依次递减的序列。【注】算法的难点在于如何调整堆,子堆调整后,其子堆便可能不满足堆的定义,因此需要不断“下坠”。
-
调整堆需要从最后一个 非叶 / 非分支结点 开始(它是底层第一个有 2 层的二叉树),逐个向前调整子堆:
<调整方法>:
检查每一棵 2 层高的二叉树,将 3 个结点中 最大值 提到 根部。(比较时,先比较左右孩子,再比较跟结点和较大的孩子【程序中得以体现】)
一个子堆调整后,需要检查 以被交换的孩子结点为根的子堆 是否满足 堆 的定义,直至检查完成,继续向前调整子堆,直至全部调整完成。
堆排序程序
/** 建立一个堆,需要从最后一个 非叶 / 非分支结点 开始调整 */
void BuildMaxHeap(ElemType &A[], int len) {
/** int 型的 len/2 是向下取整,即第一个非叶结点
循环条件 i > 0 是因为 A[0] 不存数数据,而用来暂存根结点的值 A[K] ,起到 temp 作用*/
for (int i = len/2; i > 0; i--){
HeadAdjust(A, i, len);
}
}
/** 对以 k 为根的子树进行调整,整个子堆序列长度为 len */
void HeadAdjust(ElemType &A[], int k, int len) {
A[0] = A[k]; // A[0] 不存数数据,而用来暂存根结点的值 A[K] ,起到 temp 作用
// A[0] 向下“下坠”时一直记录原先 k 的值,因此无需交换。
/* 初始时,i 为 k 左孩子下标,i + 1 为 k 右孩子下标 */
for (int i = 2 * k; i <= len; i = i * 2) {
if (i < len && A[i] < A[i + 1]) // i 是左孩子,必须小于 len,否则越界
i++; // 取左右孩子中更大的一个,此时 A[i] 即孩子中最大值
if (A[i] < A[0])
break; // 如果根结点最大,无需调整
else {
A[k] = A[i]; // 将最大值提到根部
k = i; // 修改 k 值为 更大孩子的下标,向下找 k 结点的归属。
}
}
A[k] = A[0]; // k 结点最终的位置。
}
/** 堆排序 */
void HeapSort(ElemType &A[], int len) {
BuildMaxHeap(A, len); // 初始建堆
/** 交换堆顶元素和堆尾元素,去掉序列尾部后,重新调整成堆,进行 n - 1 次 */
for (int i = len; i > 1; i--) {
swap(A[1], A[i]); // 输出堆顶元素,和堆底元素互换
HeadAdjust(A, 1, i - 1);// 调整,每次调整的序列长度 - 1,将剩余 i - 1 个成堆
}
}
/******************* MHH HeadAdjust *****************************************/
/* 王道书上 HeadAdjust 不太人性化,自己写一版*/
void HeadAdjust(ElemType &A[], int k, int len) {
int ChildMax; // 记录孩子中较大的一个
/** 此时 ki 是各个子树的根结点,最大为 len/2 向下取整 */
for (int ki = k; ki <= len/2; ki = ki * 2) {
childMax = ki * 2; // 初始化,先让其暂时指向 左孩子
if (ki * 2 < len && A[ki * 2] < A[ki * 2 + 1]) // 右孩子大就指向右孩子
childMax = ki * 2 +1;
if (A[ki] > A[childMax])
break; // 根结点大,无需调整
else {
swap (A[ki], A[childMax]); // 孩子大,则交换
ki = childMax; // 从新的子树开始,检查是否满足堆的定义
}
}
}
堆的插入和删除
堆的插入
以下程序是我自己写的,不是真的插入写法
- 在考研中,插入后,需要从下到上调整,每次只需要比较,改动的结点和其根结点,而不是像上面进行堆排序时先要比较左右孩子。
bool HeapInsert(ElemType &A[], int key, int len) {
if(len + 1 > MaxSize)
return false;
A[len + 1] = key; // A[0] 不记录任何数据,插入到堆底
for (int i = (len+1)/2; i > 0; i --)
HeadAdjust(A, i, len + 1);
}
堆的删除
- 堆的删除,需要将最后一个元素移动到删除位置来代替它,最后一个元素一定是小于当前被删除元素(大顶堆),因此只需要向下调整即可,而向下调整,就需要比较左右孩子,决出较大一个,再与根结点比较,然后交换。
bool HeapDelete(ElemType &A[], int DelIndex, int len) {
if(DelIndex > len || DelIndex < 1)
return false;
A[DelIndex] = A[len]; // A[0] 不记录任何数据,堆底元素代替
HeadAdjust(A, DelIndex, len - 1); // 从 DelIndex 开始调整
}
归并排序和基数排序
🔸归并排序
ElemType *B = (ElemType *)malloc((n + 1) * sizeof(ElemType)); // 辅助数组 B
void Merge(ElemType A[], int low, int mid, int high) {
/** 表 A 中两段 A[low, mid] 和 A[mid + 1, high] 各自有序,将它们合并成一个有序表 */
for (int k = low; k <= high; k++) {
B[k] = A[k]; // 将 A 中所有元素复制到 B 中
}
/** k 在 A 中移动, i、j 在 B 中移动,比较,然后复制给 A */
for (int i = low; j = mid + 1; i <= mid && j <= high; k++) {
if (B[i] <= B[j]) // 比较 B 中左右两个子表中的元素
A[k] = B[i++]; // 较小值复制给 A[k]
else
A[k] = B[j++];
}
while(i <= mid)
A[k++] = B[i++]; // 若 B 中右表长度更短,则 左有余,继续复制进去
while(j <= high)
A[k++] = B[j++]; // 若左表更短,同理
}
void MergeSort(ElemType A[], int low, int high) {
/** 递归终止条件,表中仅有一个元素*/
if (low < high) { // 归并排序分解成,每两个先成有序,随后不断合并成更大的有序
int mid = (low + high) / 2; // 从中间划分两个子序列
MergeSort(A, low, mid); // 对左侧子序列进行递归排序
MergeSort(A, mid + 1, high); // 对右侧子序列进行递归排序
Merge(A, low, high); // 归并
}
}
- 最深深度是每个子序列中仅有 “两个元素” 时的深度。
- 为什么要递归?递归目的是层层划分,直至找到 两两一组 的序列,由小到大归并,得到越来越长的有序序列。而直接归并并无法直接得到两个长的有序序列。
基数排序
应用举例~
各种内部排序算法的比较及应用
内部排序算法的比较
见 22 P329
内部排序算法的应用
见 22 P329
外部排序
不会在算法设计上考察
外部排序的基本概念和方法
-
操作系统以**“块”为单**位对磁盘存储空间进行管理
-
使用“归并排序”,最少在内存中分配 3 块大小的缓冲区(一个输出缓冲区,两个输入缓冲区),即可对任意一个大文件进行排序。
-
根据内存缓冲区大小,将外存上的文件分成若干长度相等的子文件,依次读入内存并利用 内部排序 方法对它们进行排序,并将排序后得到的有序子文件重新写回外存,称这些有序子文件为 归并段 或 顺串。
-
“归并排序”要求各个子序列有序,因此每次读入至少两个块的内容,进行内部排序后写回磁盘。
-
当多个归并段在内存中进行归并排序时,当输入缓冲区内空时,必须将归并段的下一个块放入输入缓冲区;输出缓冲区满,输出到外存中。
-
若共 N 个记录,内存工作区可以容纳 L 个记录,则初始归并段数量 r = N/L。(内存中 L 个记录内部排序)
-
外部排序时间开销 = 读写外存的时间 + 内部排序所需时间 + 内部归并所需时间
-
使用多路归并,可以减少归并的趟数,从而减少 读写外存的时间。
-
-
对 r 个 初 始 归 并 段 , 做 k 路 平 衡 归 并 则 r 一 定 < = k 叉 树 的 叶 子 结 点 个 数 , 即 r < = k h − 1 得 h − 1 > = log k r 第 一 趟 可 以 将 r 个 初 始 归 并 段 归 并 为 ⌈ r / k ⌉ 个 归 并 段 , 以 后 每 趟 归 并 将 m 个 归 并 段 归 并 成 ⌈ m / k ⌉ 个 归 并 段 , 直 至 最 后 形 成 一 个 大 的 归 并 段 为 止 。 🔸 树 的 高 度 − 1 = 归 并 趟 数 S = log k r 由 此 可 知 , k 增 大 、 r 减 少 能 减 少 归 并 趟 数 S k 通 过 多 路 归 并 增 加 , r 可 以 通 过 增 加 初 始 归 并 段 多 路 归 并 的 负 面 影 响 : ① k 路 归 并 时 , 需 要 开 辟 k 个 输 入 缓 冲 区 , 内 存 开 销 增 加 。 ② 每 挑 选 一 个 关 键 字 需 要 对 比 关 键 字 ( k − 1 ) 次 , 内 部 归 并 所 需 时 间 增 加 。 ( 败 者 树 可 解 决 ) \begin{array}{l} 对\ r\ 个初始归并段,做\ k\ 路平衡归并\\则\ r\ 一定 <=\ k\ 叉树的叶子结点个数,即\ r<={k^{h - 1}}\ 得\ h-1>={\log _k}r\\ 第一趟可以将\ r\ 个初始归并段归并为 \left\lceil {r/k} \right\rceil 个归并段,以后每趟归并将\ m\ 个归并段归并成\left\lceil {m/k} \right\rceil 个归并段,直至最后\\形成一个大的归并段为止。\\\\ 🔸树的高度-1=\ 归并趟数\ S\ ={\log _k}r\ \ \ \ 由此可知,k\ 增大、r\ 减少能减少归并趟数\ S\ \\ \ \ \ \ k\ 通过多路归并增加,r\ 可以通过增加初始归并段 \\\\ 多路归并的负面影响:\\①k\ 路归并时,需要开辟\ k\ 个输入缓冲区,内存开销增加。\\ ②每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加。(败者树可解决) \end{array} 对 r 个初始归并段,做 k 路平衡归并则 r 一定<= k 叉树的叶子结点个数,即 r<=kh−1 得 h−1>=logkr第一趟可以将 r 个初始归并段归并为⌈r/k⌉个归并段,以后每趟归并将 m 个归并段归并成⌈m/k⌉个归并段,直至最后形成一个大的归并段为止。🔸树的高度−1= 归并趟数 S =logkr 由此可知,k 增大、r 减少能减少归并趟数 S k 通过多路归并增加,r 可以通过增加初始归并段多路归并的负面影响:①k 路归并时,需要开辟 k 个输入缓冲区,内存开销增加。②每挑选一个关键字需要对比关键字(k−1)次,内部归并所需时间增加。(败者树可解决)
多路平衡归并与败者树
先前提到过:
多路归并的负面影响:
①k 路归并时,需要开辟 k 个输入缓冲区,内存开销增加。
②每挑选一个关键字需要对比关键字 (k-1) 次,内部归并所需时间增加。(败者树可解决)
败者树
一棵多了一个头头的完全二叉树,k 个叶结点分别是当前参加比较的元素,非叶结点用来记忆 左右子树中的“失败者”,而让胜者往上继续进行比较,一直到头头(根结点)。
- 适用于不断从序列 中选出最值,然后增补序列的情形,能够有效减少关键字的比较次数。
败者树的数据结构
败者树除了“头头”以外,剩余结点可视作是一棵 完全二叉树,因此可以用顺序存储来表示 败者树。
ls[k]; // 按照二叉树的顺序存储结构
// 仅存储非叶结点,非叶结点记忆了上次比较的“失败者”的 缓冲区的标号
- 每个叶子结点都有其对应的一个输入缓冲区,每次胜者决出后,从这个胜者来源的缓冲区,向其对应的叶子结点增补参赛者。
- 多个缓冲区每次只归并出 1 个元素,是为“胜者”,其余“败者”在不断pk时,构建起了一棵“二叉排序树”,从而败者树使用“树形”来记录了多个缓冲区上次归并时“败者”的大小关系,在比较时,由于“败者树”记录个多个数之间的大小关系,因此不需要全部比较,而是比较部分即可。
k 路 归 并 , 每 次 归 并 1 个 元 素 , 有 k 个 元 素 参 与 比 较 , 若 使 用 传 统 比 较 方 法 , 需 k − 1 次 , 若 构 建 好 了 败 者 树 , 由 于 是 和 非 叶 结 点 的 “ 失 败 者 ” 比 较 , 因 此 需 要 比 较 h − 1 次 , 即 ⌈ log 2 k ⌉ 上 述 是 如 何 推 导 的 呢 ? 败 者 树 除 去 叶 结 点 有 k 个 结 点 , 除 去 头 头 有 k − 1 个 结 点 , 我 们 仅 关 注 比 较 次 数 即 除 去 头 头 的 二 叉 树 的 高 度 − 1 , 前 h − 1 层 有 2 h − 1 − 1 个 结 点 , 所 以 除 去 头 头 二 叉 树 结 点 数 满 足 k − 1 < = 2 h − 1 − 1 可 得 到 h − 1 > = ⌈ log 2 k ⌉ \begin{array}{l} k\ 路归并,每次归并\ 1 个元素,有\ k\ 个元素参与比较,若使用传统比较方法,需\ k - 1\ 次,\\若构建好了败者树,由于是和非叶结点的“失败者”比较,因此需要比较\ h-1\ 次,即\ \left\lceil {{{\log }_2}k} \right\rceil\\ \\上述是如何推导的呢?\\败者树除去叶结点有\ k\ 个结点,除去头头有\ k-1\ 个结点,我们仅关注比较次数即除去头头的二叉树的高度-1,\\前\ h-1\ 层有\ {2^{h - 1}} - 1\ 个结点,所以除去头头二叉树结点数满足\ k-1<={2^{h - 1}} - 1\\ 可得到\ h-1 >= \left\lceil {{{\log }_2}k} \right\rceil \end{array} k 路归并,每次归并 1个元素,有 k 个元素参与比较,若使用传统比较方法,需 k−1 次,若构建好了败者树,由于是和非叶结点的“失败者”比较,因此需要比较 h−1 次,即 ⌈log2k⌉上述是如何推导的呢?败者树除去叶结点有 k 个结点,除去头头有 k−1 个结点,我们仅关注比较次数即除去头头的二叉树的高度−1,前 h−1 层有 2h−1−1 个结点,所以除去头头二叉树结点数满足 k−1<=2h−1−1可得到 h−1>=⌈log2k⌉
置换-选择排序(生成初始归并段)
大致思想:
待排序文件输入记录到内存输入缓冲区,输出最小记录到输出缓冲区,并标记该值为 MINIMAX(意味着是归并段中的 当前 最大值),随后从待排序文件中输入一个记录,继续比较,输出一个 大于 MINIMAX 但同时也最小的记录 到输出缓冲区,同时将内存输入缓冲区中 小于 MINIMAX 的记录做标记,输出缓冲区满,则输出到归并段中,重复,直至内存缓冲区中记录均小于 MINIMAX,将输出缓冲区的内容全部输入到归并段中,并且开始一个新的归并段输出。
结合王道书P337 图理解。
- 从WA 中 选择 MINIMAX 记录的过程需利用 败者树 来实现。
最佳归并树
-
使得 IO 次数最小。
-
🔸 首 先 要 记 住 : 归 并 过 程 中 的 I / O 次 数 = 归 并 树 的 W P L ∗ 2 🔸 推 导 虚 段 : 目 的 是 构 造 k 叉 哈 夫 曼 树 , 使 得 W P L 最 小 , 初 始 的 归 并 段 一 定 是 叶 子 结 点 , 度 为 0 。 结 点 数 n = n k + n 0 = k ∗ n k + 0 ∗ n 0 + 1 【 这 个 叫 总 度 数 + 1 = 结 点 数 】 从 而 有 n k = n 0 − 1 k − 1 , 而 n k 一 定 是 一 个 正 整 数 , 因 此 ( n 0 − 1 ) % ( k − 1 ) 需 能 整 除 : 若 ( n 0 − 1 ) % ( k − 1 ) = u , u = 0 , 则 被 整 除 , 不 需 要 添 加 虚 段 ; 若 ( n 0 − 1 ) % ( k − 1 ) = u , 0 < u < k − 1 , 就 需 要 给 添 加 “ n 0 【 n 0 是 叶 子 结 点 , 虚 段 也 是 叶 子 结 点 】 ” , 添 加 k − 1 − u 个 虚 段 , 使 得 刚 好 被 整 除 。 \begin{array}{l} 🔸首先要记住:归并过程中的I/O次数=归并树的WPL*2\\\\ 🔸推导虚段:目的是构造\ k\ 叉哈夫曼树,使得WPL最小,初始的归并段一定是叶子结点,度为\ 0。\\ 结点数\ n={n_k} + {n_0}=k*{n_k}+0*{n_0}+1\ 【这个叫总度数+1=结点数】\\ 从而有\ {n_k}=\frac{{{n_0} - 1}}{{k - 1}},而{n_k}一定是一个正整数,因此\ ({n_0} - 1)\% (k - 1)\ 需能整除:\\ 若\ ({n_0} - 1)\% (k - 1)=u,u=0,则被整除,不需要添加虚段;\\ 若\ ({n_0} - 1)\% (k - 1)=u,0<u<k-1,就需要给添加“\ {n_0}\ 【{n_0}是叶子结点,虚段也是叶子结点】”,\\添加k-1-u\ 个虚段,使得刚好被整除。 \end{array} 🔸首先要记住:归并过程中的I/O次数=归并树的WPL∗2🔸推导虚段:目的是构造 k 叉哈夫曼树,使得WPL最小,初始的归并段一定是叶子结点,度为 0。结点数 n=nk+n0=k∗nk+0∗n0+1 【这个叫总度数+1=结点数】从而有 nk=k−1n0−1,而nk一定是一个正整数,因此 (n0−1)%(k−1) 需能整除:若 (n0−1)%(k−1)=u,u=0,则被整除,不需要添加虚段;若 (n0−1)%(k−1)=u,0<u<k−1,就需要给添加“ n0 【n0是叶子结点,虚段也是叶子结点】”,添加k−1−u 个虚段,使得刚好被整除。
来一波总结
折半写法:
- 拥有“随机存取”特性的几乎最快查找方法!(除了散列查找)
void BinaryFun(A[], int len) {
...其他逻辑
{
...其他逻辑
int low, high, mid;
low = 查找起始位置, high = 查找终点位置, mid = (low + high) / 2; // 折半初始化
while (low <= high) { // 折半查找失败信号 low > high
mid = (low + high) / 2;
if (A[mid] > temp) // 在左查找
high = mid - 1;
if (A[mid] < temp) // 在右查找
low = mid + 1;
if (A[mid] == temp) // 相等时视具体情况分析,是找左还是右
low = mid + 1;
}
...其他逻辑
}
}
移位写法:
/** 将从 xxx 到 i 以前的全部后移 1 位 */
temp = A[i]; // 把当前 A[i] 暂存,防止移位后无从找起
// 在 xxxxx 处写逻辑,完成从 xxx 到 i 的移位
for (j = i - 1; j >= 0 && XXXXXX; j --) {
A[j + 1] = A[j];
}