Bootstrap

数据结构笔记——排序

好好学习,天天向上!

本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/DayDayUP,欢迎Star

⭐⭐⭐⭐⭐转载请注明出处:https://blog.csdn.net/weixin_43461520/article/details/124233235

8.1 排序的基本概念

排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

算法的稳定性。若待排序表中有两个元素 Ri 和 Rj,其对应的关键字相同即 keyi = keyj,且在排序前 Ri 在 Rj 的前面,若使用某一排序算法排序后,Ri 仍然在 Rj 的前⾯,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

8.2 插入排序

8.2.1 直接插入排序

每次将⼀个待排序的记录按其关键字大小插⼊到前面已排好序的子序列中,直到全部记录插入完成。

👆上面这张图中,对 {49, 38, 65, 97, 76, 13, 27, 49} 这个序列进行排序。首先下标0的元素肯定是有序的,所以从下标1的元素开始,38前面只有49,而49>38,所以49后移,将38插入到元素0的位置;65和97本来就是有序的,不用移动;指针来到下标4的76,和下标4前面的元素对比,由于97>76,所以97后移,76插入到下标3的位置;指针来到13的位置,挨个与前面的元素对比,都比13大,所以13前面的元素依次后移一位,元素13插入到下标0的位置;后面两个元素同理。

//直接插入排序
void InsertSort(int A[], int n) {
    //将各元素插入已排好序的序列中,位置0的是有序的,所以从1开始
    for (int i = 1, j; i < n; ++i) {    
        if (A[i] < A[i - 1]) {          //若A[i]小于前驱元素
            int temp = A[i];
            for (j = i - 1; j >= 0 && A[j] > temp; --j) {   //检查所有前面已排好序的元素
                A[j + 1] = A[j];    //所有大于A[i]的元素都向后挪位
            }
            A[j + 1] = temp;        //复制到插入位置
        }
    }
}

空间复杂度为O(1),平均时间复杂度为O(n²)。算法稳定性:稳定

8.2.2 折半插入排序

直接插入排序时,需要挨个与前面的元素进行对比,一边对比一边移动,直到找到插入的位置。由于待排序的关键字前面已经是有序的序列了,所以可以先通过折半查找的方式找到插入位置,再将插入位置后面的元素依次后移一位。

//折半插入排序
void InsertSort2(int A[], int n) {
    //依次将A[1]~A[i-1]插入到前面已排序序列
    for (int i = 1; i < n; ++i) {
        int temp = A[i];
        int low = 0, high = i - 1;
        while (low <= high) {   //折半查找
            int mid = (low + high) / 2;
            if (A[mid] > temp) {
                high = mid - 1;
            } else {
                low = mid + 1;
            }
        }
        //将[low, i-1]内的元素全部右移,空出插入位置
        for (int j = i - 1; j >= low; --j) {
            A[j + 1] = A[j];
        }
        A[low] = temp;  //插入操作
    }
}

当 low>high 时折半查找停止,应将 [low, i-1] 内的元素全部右移,并将temp复制到 low 所指位置,当 A[mid]==A[0] 时,为了保证算法的“稳定性”,应继续在 mid 所指位置右边寻找插入位置。

比起“直接插入排序”,比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是O(n²)

8.2.3 希尔排序

先将待排序表分割成若干形如 L[i, i + d, i + 2d,…, i + kd] 的“特殊”子表,对各个子表分别进行直接插入排序缩小增量d,重复上述过程,直到d=1为⽌。希尔本⼈建议:每次将增量d缩小一半。

希尔排序是追求表中元素部分有序逐渐逼近全局有序

//希尔排序
void ShellSort(int A[], int n) {
    for (int d = n / 2; d >= 1; d /= 2) {   //步长变化
        for (int i = d, j; i < n; i += d) {
            if (A[i] < A[i - d]) {  //需将A[i]插入有序增量子表
                int temp = A[i];    //暂存元素
                for (j = i - d; j >= 0 && A[j] > temp; j -= d) {
                    A[j + d] = A[j];    //元素后移,查找插入位置
                }
                A[j + d] = temp;        //插入
            }
        }
    }
}

希尔排序的空间复杂度为O(1)。时间复杂度与增量序列的选择有关,最坏时间复杂度为O(n²),当n在某个范围内时,可达O(n^1.3)。相同元素在排序过程中可能交换次序👇,不具有稳定性仅适用于顺序表,不适用于链表

8.3 交换排序

基于“交换”的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

8.3.1 冒泡排序

从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。冒泡排序具有稳定性,且适用于链表

第一趟:从后往前两两比较,较小的元素放入前面位置,最终最小的元素会被放入到下标0的位置。

第二趟:从后往前两两比较,较小的元素放入前面位置,最终最小的元素会被放入到下标1的位置。

以此类推,最终会得到一个递增的序列。如果一趟中两两比较的过程中没有发生元素的交换,说明已经整体有序,便不用再进行后序的比较。

void BubbleSort(int A[], int n) {
    for (int i = 0; i < n - 1; ++i) {
        bool flag = false;  //本趟冒泡是否发生交换
        for (int j = n - 1; j > i; --j) {   //一趟冒泡
            if (A[j - 1] > A[j]) {  //若为逆序,交换
                flag = true;        //本趟发生交换
                int temp = A[j - 1];
                A[j - 1] = A[j];
                A[j] = temp;
            }
        }
        if (!flag) {    //若一趟冒泡未发生交换,说明已经有序,不用再进行后续冒泡
            break;
        }
    }
}

8.3.2 快速排序

在待排序表L[1…n]中任取⼀个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivotL[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为⼀次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有⼀个元素或空为止,即所有元素放在了其最终位置上。

👆第一趟:选取第一个元素49作为基准,然后将小于49的元素放在low的左边,大于等于49的元素放在high的右边,最后low==high时将49放入该位置。

👆通过第一趟的排序,整个序列被49分为两部分,49左边的都比其小,49右边的都大于等于49。再对两部分分别选取一个基准元素进行同样的排序。上面是对49左半部分排序。

👆对49右半部分进行排序。

👆递归地对剩余部分进行同样方式的排序。最终可以得到一个递增的序列。

//用第一个元素将待排序序列划分成左右两个部分
int partition(int A[], int low, int high) {
    int pivot = A[low];     //第一个元素作为基准
    while (low < high) {    //搜索基准最终的位置
        while (low < high && A[high] >= pivot) {    //找到比基准元素小的
            high--;
        }
        A[low] = A[high];       //将比基准元素小的放到low左边
        while (low < high && A[low] <= pivot) {     //找到比基准元素大的
            low++;
        }
        A[high] = A[low];       //将比基准元素大的放到high右边
    }
    A[low] = pivot;             //放入基准元素,此时low==high
    return low;
}

//快速排序
void QuickSort(int A[11], int low, int high) {
    if (low < high) {
        int pivotPos = partition(A, low, high); //划分
        QuickSort(A, low, pivotPos - 1);    //划分左子表
        QuickSort(A, pivotPos + 1, high);   //划分右子表
    }
}

  • 快排的优化思路:尽量选择可以把数据中分的基准元素。例如选头、中、尾三个位置的元素,取中间值作为枢轴元素或者随机选⼀个元素作为枢轴元素

  • 快速排序算法不具有稳定性

8.4 选择排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列。

8.4.1 简单选择排序

//简单选择排序
void SelectSort(int A[11], int n) {
    for (int i = 0; i < n - 1; ++i) {   //一共进行n-1趟
        int min = i;                    //记录最小元素的位置
        for (int j = i + 1; j < n; ++j) {   //在A[i...n-1]中选择最小的元素
            if (A[min] > A[j]) {            //更新最小元素的位置
                min = j;
            }
        }
        if (min != i) {     //交换
            int temp = A[i];
            A[i] = A[min];
            A[min] = temp;
        }
    }
    for (int k = 0; k < n; ++k) {
        cout << A[k] << "-";
    }
    cout << endl;
}
  • 空间复杂度:O(1)。

  • 时间复杂度:O(n²)

  • 稳定性:排序后,不带下划线的2与带下划线的2交换了次序,所以不稳定。

  • 适⽤性:既可以用于顺序表,也可用于链表。

8.4.2 堆排序

堆的基本概念

若n个关键字序列L[1…n] 满足下面某一条性质,则称为堆(Heap)。

  1. 若满足:L(i) ≥ L(2i)且L(i) ≥ L(2i+1)(1 ≤ i ≤n/2 )——大根堆(大顶堆)。在一棵顺序存储的完全二叉树中,若其共用n个结点。编号为i的结点,其左孩子编号为2i,右孩子为2i+1。编号大于n/2的的结点为叶子结点。也就是说,大根堆就是根结点≥左右孩子的完全二叉树(对子树同理)。

  2. 若满足:L(i) ≤ L(2i)且L(i) ≤ L(2i+1) (1 ≤ i ≤n/2 )——小根堆(小顶堆)。根结点≤左右孩子

建立大根堆

把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。

👆从下向上检查,检查以9为根结点的子树,由于其左孩子比它大,所以与左孩子交换,使该子树符合大根堆的定义。

👆继续调整以78为根结点的子树,其右孩子比它大,与右孩子进行交换。

👆调整以17为根结点的子树。

👆继续调整,由于53的右孩子比它大,所以与87进行交换,但是交换后以53为根结点的子树不满足大根堆的要求,继续下坠,与53的右子树78交换。最终整棵树都满足大根堆的要求。

//将以k为根的子树调整为大根堆
void HeapAdjust(int A[], int k, int len) {
    A[0] = A[k];    //暂存根结点的值
    for (int i = 2 * k; i <= len; i = 2 * k) {      //i初始指向k的左孩子
        if (i < len && A[i] < A[i + 1]) {
            i++;    //i<len说明有右孩子,对比左右孩子的大小,i指向较大的孩子结点
        }
        if (A[k] >= A[i]) {
            break;  //如果孩子结点不比根结点大,符合大根堆,跳出循环
        } else {
            A[k] = A[i];    //较大孩子结点值赋给父结点
            A[i] = A[0];    //根结点的值赋给孩子结点
            k = i;          //修改k值,方便继续向下筛选
        }
    }
}

//建立大根堆
void BuildMaxHeap(int A[], int len) {
    for (int i = len / 2; i > 0; --i) {
        HeapAdjust(A, i, len);  //从后往前调整所有非终端结点
    }
}
基于大根堆进行排序

将序列调整为大根堆后,堆顶元素就成了序列中的最大值了。每一趟将堆顶元素加入有序子序列(与待排序序列中的最后⼀个元素交换),然后将剩余待排序元素序列再次调整为大根堆(小元素不断下坠)。多次重复该过程,即可得到一个有序序列。

  • 第1趟:将堆顶的87堆底的9互换,再将剩余序列继续调整为大根堆

  • 第2趟:将堆顶的78堆底的53互换,再将剩余元素继续调整为大根堆

  • 第3趟:将堆顶的65与堆底的9互换,再将剩余元素继续调整为大根堆

  • 第4趟:将堆顶的53堆底的17互换,再将剩余元素继续调整为大根堆

  • 第5趟:将堆顶的45堆底的17互换,再将剩余元素继续调整为大根堆。

  • 第6趟:将堆顶的32堆底的09互换,再将剩余元素继续调整为大根堆。

  • 第7趟:将堆顶的17堆底的09互换,只剩下最后一个待排序元素,不用再调整。

//堆排序
void HeapSort(int A[], int len) {
    BuildMaxHeap(A, len);       //初始建堆
    for (int i = len; i > 1; --i) { //n-1趟的交换和建堆过程
        //堆顶元素和堆底元素交换
        int temp = A[1];
        A[1] = A[i];
        A[i] = temp;

        HeapAdjust(A, 1, i - 1);    //把剩余的待排序元素整理成堆
    }
}
算法效率

  • 堆排序的时间复杂度 = O(n) + O(nlog₂n) = O(nlog₂n)

  • 堆排序的空间复杂度 = O(1)

  • 稳定性:从下图中可以看出,两个2在排序后调换了次序,所以堆排序是不稳定的。

基于小根堆建堆排序

基于小根堆建堆排序方式与大根堆类似,便不再赘述。通过小根堆,可以得到一个递减的序列。

//将以k为根的子树调整为小根堆
void HeapAdjust(int A[], int k, int len) {
    A[0] = A[k];    //暂存根结点的值
    for (int i = 2 * k; i <= len; i = 2 * k) {  //i初始指向k的左孩子
        if (i < len && A[i] > A[i + 1]) {
            i++;    //i<len说明有右孩子,对比左右孩子的大小,i指向较小的孩子结点
        }
        if (A[0] <= A[i]) {
            break;  //如果孩子结点不比根结点小,符合小根堆,跳出循环
        } else {
            A[k] = A[i];    //较小孩子结点调整到父结点
            k = i;          //修改k值,方便继续向下筛选
        }
    }
    A[k] = A[0];    //被筛选结点的值放入最终位置
}

//建立小根堆
void BuildMinHeap(int A[], int len) {
    for (int i = len / 2; i > 0; --i) {
        HeapAdjust(A, i, len);
    }
}

//堆排序
void HeapSort(int A[], int len) {
    BuildMinHeap(A, len);       //初始建堆
    for (int i = len; i > 1; --i) { //n-1趟的交换和建堆过程
        //堆顶元素和堆底元素交换
        int temp = A[1];
        A[1] = A[i];
        A[i] = temp;
        HeapAdjust(A, 1, i - 1);    //把剩余的待排序元素整理成堆
    }
}

8.4.3 堆的插入删除

插入

对于小根堆新元素放到表尾,与父节点对比,若新元素比父结点更小,则将二者互换。新元素就这样一路“上升”,直到无法继续上升为止。👇下图中,插入元素13,13与其父结点32对比并交换,13再与其父结点17对比并交换,13再与其父结点9对比但不交换,一共对比3次。

//在小根堆中插入新元素
int InsertInMinHeap(int A[], int len, int target) {
    len = len + 1;
    A[len] = target;    //新元素放入表尾
    //新元素上升,A[i]是A[j]的父结点
    for (int i = len / 2, j = len; i > 0; j = i, i /= 2) {
        if (target < A[i]) {    //对比新元素与其父结点,若比父结点小,则二者互换
            A[j] = A[i];
            A[i] = target;
        } else {
            break;
        }
    }
    return len; //返回新的数组长度
}
删除

被删除的元素用堆底元素替代,然后让该元素不断“下坠”,直到无法下坠为止。👇下图中,删除元素13,用堆底元素46替代。46与其左右孩子对比并与较小的17交换,46再与其左右孩子对比并与较小的32交换,一共对比4次。

//在小根堆中删除元素
int DeleteInMinHeap(int A[], int len, int k) {
    A[k] = A[len];    //用表尾元素替代被删除元素
    len = len - 1;
    HeapAdjust(A, k, len);  //替代后的元素下坠,就是将以k为根结点的子树调整为小根堆
    return len; //返回新的数组长度
}

8.5 归并排序和基数排序

8.5.1 归并排序

基本概念

归并就是把两个或多个已经有序的序列合并成一个

👆上图中,两个指针ij分别指向两个有序序列的第一个元素,然后通过k指向新的序列。不断的比较i,j指向的值,将较小的值放入到新的序列中,直到将两个序列中的所有元素都放入到新序列中为止。

这个是两个序列合并成一个,称为2路归并。多个序列合并也是同样的道理,通过多个指针分别指向各自的序列,不断地挑选出最小的元素放入新序列,直到将所有序列的元素都放入到新序列中为止。

在2路归并中,每次选择较小元素需对比关键字1次,4路归并每次选择较小元素则需对比关键字3次。m路归并,每选出一个元素需要对比关键字 m-1 次

归并排序就是不断地把数组内的两个有序序列归并成一个。首先将每个元素都看成是一个只含有一个元素的有序序列,然后对相邻的两个序列进行归并,多次归并后,便可以得到一个有序序列。

归并排序的实现

归并排序借助一个辅助数组B实现,下图中数组A中的 [low, mid] 和 [mid+1, high] 已经有序了。归并前先把 [low, high] 内的元素复制到辅助数组B,然后通过i,j两个指针不断的选取较小元素赋值到A中。

int *B = new int[100];  //辅助数组

//A[low...mid]和A[mid+1...high]各自有序,将两个部分归并
void Merge(int A[], int low, int mid, int high) {
    int i, j, k;
    for (k = low; k <= high; ++k) { //将数组A中的元素复制到辅助数组B中
        B[k] = A[k];
    }
    for (i = low, j = mid + 1, k = low; i <= mid && j <= high; k++) {
        //较小值复制到数组A中
        if (B[i] <= B[j]) {
            A[k] = B[i++];
        } else {
            A[k] = B[j++];
        }
    }
    //将剩余元素复制到A中
    while (i <= mid) {
        A[k++] = B[i++];
    }
    while (j <= high) {
        A[k++] = B[j++];
    }
}

//归并排序
void MergeSort(int 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, mid, high);           //归并
    }
}

前面提到的2路归并,从形态上可以看作是一棵倒立的二叉树。根据二叉树的一些性质,可以推出归并排序的时间复杂度空间复杂度

8.5.2 基数排序

基数排序得到递增序列的过程:

  1. 设置 r 个空队列,Q0, Q1,…, Q(r-1)。

  2. 按照各个关键字位权重递增的次序(个、十、百),对 d 个关键字位分别做“分配”和“收集”。

  3. 分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插⼊ Qx 队尾。

  4. 收集:把 Q0, Q1,…, Qr−1 各个队列中的结点依次出队并链接。

👇第一趟分配:顺序扫描一遍各个元素,根据个位数,放入对应的队列中。

]

👇第一趟收集:从前往后,将每个队列的元素依次取出 ,组成一个新的序列,新得到的序列按照个位递减

👇第二趟分配与收集:将新序列再扫描一边,按照十位数分别放入对应的序列中,然后再收集,就可以得到一个即按十位递减,也按个位递减的新序列。

👇第三趟分配与收集:最后按照百位的大小进分配与收集后,最终就可以一个按照百位递减的序列,由于十位与个位已经是递减的了,所以整个序列就是单调递减的。

/**
 * 基数排序
 * @param list  排序前的序列
 * @return      排序后的递减序列
 */
private LinkedList<Integer> radixSort(LinkedList<Integer> list) {
    List<Deque<Integer>> queues = new ArrayList<>();    //队列集合
    for (int i = 0; i < 10; i++) {
        queues.add(new ArrayDeque<>());
    }

    //第一趟分配
    while (!list.isEmpty()) {
        int ele = list.pollFirst();
        int index = ele % 10;   //个位数
        queues.get(index).addLast(ele);
    }

    //第一趟收集
    for (int i = 9; i >= 0; i--) {
        Deque<Integer> queue = queues.get(i);
        while (!queue.isEmpty()) {
            list.add(queue.pollFirst());
        }
    }

    //第二趟分配
    while (!list.isEmpty()) {
        int ele = list.pollFirst();
        int index = (ele / 10) % 10;    //十位数
        queues.get(index).addLast(ele);
    }

    //第二趟收集
    for (int i = 9; i >= 0; i--) {
        Deque<Integer> queue = queues.get(i);
        while (!queue.isEmpty()) {
            list.add(queue.pollFirst());
        }
    }

    //第三趟分配
    while (!list.isEmpty()) {
        int ele = list.pollFirst();
        int index = (ele / 100) % 10;   //百位数
        queues.get(index).addLast(ele);
    }

    //第三趟收集
    for (int i = 9; i >= 0; i--) {
        Deque<Integer> queue = queues.get(i);
        while (!queue.isEmpty()) {
            list.add(queue.pollFirst());
        }
    }
    return list;
}
  • 需要r个辅助队列,空间复杂度

  • ⼀趟分配O(n),⼀趟收集O®,总共 d 趟分配、收集,总的时间复杂度=O(d(n+r))

  • 由于值相同的元素,先入队的肯定先出队,所以基数排序是稳定的。

  • 基数排序擅长解决的问题:

    ①数据元素的关键字可以⽅便地拆分为 d 组,且 d 较小

    ②每组关键字的取值范围不大,即 r 较小

    ③数据元素个数 n 较大

    例如给一亿人的身份证号排序。

8.6 外部排序

8.6.1 基本概念

外存与内存之间的数据交换

操作系统以“块”为单位对磁盘存储空间进行管理,磁盘的读/写以“块”为单位数据读入内存后才能被修改修改完了还要写回磁盘。

外部排序的原理

使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序。每次读入两个块的内容,进⾏内部排序后写回磁盘。

  • 构造初始归并段

    首先将前两个磁盘块中的数据读到内存中的两个缓冲区中,进行内部排序,就可以得到一个有序的归并段。采用同样的方式,就可以得到八个初始的归并段。由于每个磁盘块都读入到内存一次和写回内存一次,所以需要16次读和16次写

在这里插入图片描述

  • 第一趟归并

    在磁盘中申请4个磁盘块。将归并段1和归并段2中分别读取一个磁盘块到内存的缓冲区中,进行归并排序排序时缓冲区1空了就要立即用归并段1的下一块补上缓冲区2空了就要立即用归并段2的下一块补上。这样就可以将两个归并段归并为一个更长的有序序列原先两个归并段中共4个磁盘块要归还给系统。采用同样的方式,将剩下的3对归并段都分别合并为一个归并段。这样最终就只剩下4个归并段了。

在这里插入图片描述

  • 第二趟归并

    采用与第一趟归并同样的方式,两两归并。可以将四个归并段归并为两个归并段,每个归并段含有8个磁盘块。

在这里插入图片描述

  • 第三趟归并

    将最后的两个磁盘块进行归并,可以使得整个磁盘中的数据变得有序。

时间开销及优化

从上面的例子中可以看出,生成初始归并段和三趟归并时都要将16个磁盘块读入内存并写回磁盘。

外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间

  • 优化方案1:多路归并

    由于磁盘是慢速设备,外部排序的时间开销主要和读写外存的时间有关。读写次数太多导致时间开销⼤幅增加。通过多路归并可以减少归并的趟数,从而减少读写磁盘块的次数。

在这里插入图片描述
如果采用4路归并,再一次归并后,就只剩下两个归并段了,那么排序只要进行2次归并。相较于2路归并少了一趟归并,就可以减少32次磁盘的读写。

多路归并的弊端:

①k路归并时,需要开辟k个输⼊缓冲区,内存开销增加。

②每挑选⼀个关键字需要对⽐关键字(k-1)次,内部归并所需时间增加。

  • 优化方案2:减少初始归并段数量

    上面的例子中,当缓冲区的数量为2的时候,生成的每个初始归并段只含有2个磁盘块,一共有8个初始归并段。将缓冲区的数量增加后,生成的归并段数量也相应减少,这样也同样可以减少归并的趟数,从而减少磁盘的读写次数。生成初始归并段的“内存工作区”越大,初始归并段越长

8.6.2 败者树

使用k路平衡归并策略,选出⼀个最小元素需要对比关键字 (k-1)次,导致内部归并所需时间增加。为了减少关键字的对比次数,可以使用败者树进行优化。

👆上图就是一棵败者树,可视为⼀棵多了一个头头的完全二叉树,k个绿色的叶结点分别是当前参加比较的元素,灰色的非叶子结点用来记忆左右子树中的失败者,而让胜者往上继续进行比较,一直到根结点。最后的蓝色结点表示优胜者,由于是挑选最小的元素,所以上图表示最小元素来自于归并段3。这是构建败者树的过程,需要进行k-1次比较。

👆从图中可以看出,接下来再次挑选最小元素,只需进行3次比较,也就是灰色分支结点的层数。根据完全二叉树的性质,第h层最多有2^(h-1)=k个结点,那么h-1≤⌈log₂k⌉。所以接下来每次挑选最小元素最多对比⌈log₂k⌉次

k路归并的败者树只需要定义一个长度为 k 的数组即可

8.6.3 置换-选择排序

采用原来的方法构造初始归并段时,用于内部排序的的内存工作区多大,构造的初始归并段就多大。想要得到更大的初始归并段就需要更大的内存工作区。

而使用置换选择排序就可以以较小的工作区去构造更大的归并段。假设现在用于内部排序的内存工作区只能容纳三个元素。MINIMAX相当于当前构造的归并段中的最大值。

  • 归并段1

    按顺序依次从FI中将数据读取到WA中,然后选取WA大于等于MINIMAX的最小值放入FO中,并更新MINIMAX的值。直到WA中已经没有大于等于的值为止,那么归并段1就构建好了。

在这里插入图片描述

  • 归并段2和3

    采用同样的方式可以构造接下来的归并段,直到FI中没有元素为止。

这里需要注意的是:FI是按照磁盘块一块一块地读入内存的,WA的元素也是先放入输出缓冲区的,一块输出缓冲区满再写入到磁盘中

8.6.4 最佳归并树

前面一节的置换选择排序,从图中可以看出,得到的初始归并段长度并不等。对于这些长度不等的归并段,采用不同的归并顺序,I/O次数是不一样的。如果把各个初始归并段都看做叶子结点结点的权值就是磁盘块的数量。那么归并的过程就可以看作是一棵二叉树。树的WPL就是读磁盘、写磁盘数,通过前面的树与二叉树的知识,可以构建一棵哈夫曼树,使得WPL最小,这就是最佳归并树。

以上是一棵2路归并的最佳归并树,它的WPL=(1+2)*4 + 2*3 + 5*2 + 6*1=34。所以这5个归并段进行归并时读写磁盘数都是34次,总的磁盘I/O次数=68。

构建最佳归并树的规则与哈夫曼树相同,都是每次挑选权值最小的两个结点加入树中。那么多路归并的最佳归并树也是同理,比如三路归并,就是每次挑选3个权值最小的结点加入归并树。若初始归并段的数量无法构成严格的 k 叉归并树,则需要补充几个长度为 0 的“虚段”,再进行 k 叉哈夫曼树的构造

上图👆是3路归并的最佳归并树,它的WPL = (2+3+0)*3 + (6+9+12+17+18)*2+241 = 163。磁盘I/O次数=163*2=326次。

看完记得点赞哦~~~

⭐⭐⭐⭐⭐转载请注明出处:https://blog.csdn.net/weixin_43461520/article/details/124233235

本文已收录至我的Github仓库DayDayUPgithub.com/RobodLee/DayDayUP,欢迎Star

如果您觉得文章还不错,请给我来个点赞收藏关注

学习更多编程知识,点击下方链接扫描二维码关注公众号『 R o b o d 』:

;