数据结构——排序

本文详细介绍了多种排序算法,包括插入排序、折半插入排序、希尔排序、冒泡排序、快速排序、选择排序(简单选择排序与堆排序)以及归并排序。重点讨论了快速排序的原理和优化,强调了其在内部排序算法中的高效性。此外,还提及了外部排序的基本概念和多路平衡归并与败者树的概念。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

排序

  • 稳定性
  • 内部排序:排序期间元素全部存放在内存中
  • 外部排序:排序期间元素无法同时存在内存中,必须在排序的过程中根据要求不断地在内、外之间移动的排序。

插入排序(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}     len1    使便便

  • 调整堆需要从最后一个 非叶 / 非分支结点 开始(它是底层第一个有 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<=kh1  h1>=logkr r r/k m m/k🔸1=  S =logkr    k r  S     k r k  k (k1)

    在这里插入图片描述


多路平衡归并与败者树

先前提到过:

多路归并的负面影响:

①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 使 k1  h1  log2k k  k1 1 h1  2h11  k1<=2h11 h1>=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=WPL2🔸 k 使WPL 0 n=nk+n0=knk+0n0+1 +1= nk=k1n01nk (n01)%(k1)  (n01)%(k1)=uu=0 (n01)%(k1)=u0<u<k1 n0 n0k1u 使



来一波总结

折半写法:

  • 拥有“随机存取”特性的几乎最快查找方法!(除了散列查找)
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];
}



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值