1.O(n^2)排序算法之 选择、插入、冒泡、希尔排序 及 优化
选择排序
思想
首先在数组中找出第一名的位置(即最小的数字 1),将它与目前数组中第一名(即数字8)进行交换。
此时数组中第一个位置已是最小数字,接着在其余位置中找寻最小数字,与其数组中目前的第二个位置进行交换。
后面过程依次类推,直到剩下最后一个位置,已无需排序,已为最大值。
代码
#include <iostream>
#include <algorithm>
using namespace std;
void selectionSort(int arr[], int n){
for(int i = 0 ; i < n ; i ++){
// 寻找[i, n)区间里的最小值
int minIndex = i;
for( int j = i + 1 ; j < n ; j ++ )
if( arr[j] < arr[minIndex] )
minIndex = j; //记录数组中最小值下标
//这里使用的swap交换函数是C++标准库中内置函数,对于C++11而言此函数在命名空间std中(即 using namespace std;),而老的标准需要导入#include <algorithm>
swap( arr[i] , arr[minIndex] );
}
}
int main() {
int a[10] = {10,9,8,7,6,5,4,3,2,1};
selectionSort(a,10);
for( int i = 0 ; i < 10 ; i ++ )
cout<<a[i]<<" ";
cout<<endl;
return 0;
}
插入排序
思想
首先第一个元素8,由于它的位置是第一个,所以保持不动。
继续看第二个位置的元素是6,比前面的元素8小,两者交换位置。
继续看第三个元素2,比第二个元素8小,交换位置,此时元素2是第二个位置,再同第一个元素6进行比较,比它小继而交换位置。
后面过程依次类推。
代码
注意:
插入排序中的外层循环下标不再是从0开始,而是从1开始(从第二个元素开始对前面的元素进行比较)。
在每次循环里需要做的是寻找元素arr[i]前面合适的插入位置。
template<typename T>
void insertionSort(T arr[], int n){
for( int i = 1 ; i < n ; i ++ ) {
// 寻找元素arr[i]合适的插入位置
for( int j = i ; j > 0 ; j-- )
if( arr[j] < arr[j-1] )
swap( arr[j] , arr[j-1] );
else
break;
// 写法2
/*
for( int j = i ; j > 0 && arr[j] < arr[j-1] ; j -- )
swap( arr[j] , arr[j-1] );
*/
}
return;
}
冒泡排序
思想
从数组的第一个位置开始两两比较array[index]和array[index+1],如果array[index]大于array[index+1]则交换array[index]和array[index+1]的位置,止到数组最后一个元素比较完。
代码
for (i = 0; i < n; i++)
{
for (j = i + 1; j < n; j++)
{
if (str[i] > str[j])
{
swap(&str[i], &str[j]);
}
}
}
希尔排序O(n^(3/2))
思想
整体思路就是插入排序衍生,插入排序中是每个元素和之前1个元素进行比较,而希尔排序是每个元素和之前的t个元素进行比较,t从一个大值慢慢缩小成1,无序数组渐变为有序数组,时间复杂度发送质变!时间复杂度为O(n^(3/2))
代码
template<typename T>
void shellSort(T arr[], int n){
// 计算 increment sequence: 1, 4, 13, 40, 121, 364, 1093...
int h = 1;
while( h < n/3 )
h = 3 * h + 1;
while( h >= 1 ){
// h-sort the array
for( int i = h ; i < n ; i ++ ){
// 对 arr[i], arr[i-h], arr[i-2*h], arr[i-3*h]... 使用插入排序
T e = arr[i];
int j;
for( j = i ; j >= h && e < arr[j-h] ; j -= h )
arr[j] = arr[j-h];
arr[j] = e;
}
h /= 3;
}
}
比较
选择排序和插入排序的根本区别
插入排序的内循环在满足条件的情况下是可以提前结束的!而选择排序必须遍历每一次循环。所以插入排序理论上比选择排序更快一些。
选择排序实现简单,但是弊端明显,两重循环中的每次循环都要完成,效率较慢。
虽然插入排序的时间复杂度也是O(n^2),但是在数组近乎有序情况下,效率甚至比 O(n* logn)的还要高,有着重要的实际意义。
而冒泡排序中不可避免的会有许多交换操作,整体性能没有插入排序好,后续不会经常使用。
2.O(n*logn)排序算法之 归并排序(自顶向下、自底向上) 及 算法优化
自顶向下
思想
首先将数组对半划分,分别对左、右边的数组进行排序。
还要分别继续划分左、右边的数组,将左边的数组继续对半划分…
一直这样划分直到划分的“左边”或“右边”只剩下两个元素,对每一个小部分进行排序。
小部分排序完后,进行向上归并,即与旁边的“小组”进行归并(注意:此时各小组的排序已经完成,需要做的步骤是将其归并!),层层往上,直到由多个小组归并成一个大组,归并完成,排序也完成。
算法复杂度
此数组一层层划分下来,总共分成了3级,到第3级时,每个“小组”只剩下1个元素了。8个元素,以2划分,3次下来只剩下1个元素,这个“3”层来源于:log以2为底8 = 3。
所以如果有n个元素,便有 log以2为底n 个层级,虽然分成了不同的部分,但是每一层处理的个数是一样的,如果整个归并过程可以O(n)的时间复杂度来解决的话,那么最后整个过程的时间复杂度就是O(n*logn)。
以上就是O(n*logn)时间复杂度的主要来源:通过二分法达到 logn的层级,每一个层级使用 O(n)的时间来处理排序,最后总时间复杂度就是O(n*logn),而这整个过程可以通过递归来完成。
两个已经各自排序好的小组是如何归并到一个大组的?
主要思路通过以下动画来了解(现在已经有两组搁在排序好的数组,需要将其归并到一个数组 {2,3,6,8},{1,4,5,7}):
首先需要两个临时数组空间来辅助完成归并过程,这也是归并排序的缺点,虽然减少算法复杂度到O(n*logn),但是使用了除O(n)之外的额外空间。(不过在目前计算机中时间效率比空间效率更为重要,可存储的数据规模越来越大,因此设计算法通常优先考虑时间复杂度)
紧接着还需要使用3个索引在数组内进行追踪:
k蓝色箭头:归并过程中最终需要跟踪的位置。
i、j红色箭头:代表两个排序好的数组当前需要考虑的元素。
首先两个红色箭头分别指向数组中的第一个元素,即待比较元素,而蓝色箭头则是指向最终待归并数组的第一个位置,等待合适元素赋值。比较开始,2比1小,将1赋值到待归并数组的第一个位置
蓝色箭头所指的第一个位置的合适元素已找到,箭头向后移,等待第二个合适元素。
而原本指向1的红色箭头向后移,因为1已经找到合适位置了
而指向2的红色箭头不动,等待下一次比较。
后面依次类推。
总之,这个归并排序的过程就是两个已排序好的数组来比较头部的元素,取最小值放入最终数组中。此过程也依赖于3个索引值,i、j指向两个有序数组中正在比较大小的元素,k代表比较后的元素应当归并的位置 ,即下一个元素应该放入的位置。
代码实现
(1)mergeSort函数
目的:主函数中调用此方法即可(暴露给上层调用)
在函数mergeSort递归中还有一个排序的过程,所以再定义一个函数__mergeSort,取名代表它其实是一个私有的函数,被mergeSort所调用,对于用户而言只需调用mergeSort即可。
(2)__mergeSort函数
目的:递归使用归并排序,对arr[l…r]的范围进行排序
首先进行边界判断,若 l 大于或等于 r ,即可停止递归。
计算中间值,对左右分开的两个部分进行归并排序,即分别递归。
左、右两部分分别排序好,就要进行归并操作,将两个部分归并到一个数组中。这里需要调用一个新的函数__merge
(3)__merge函数
目的:将arr[l…mid]和arr[mid+1…r]两部分进行归并
此函数需要进行的逻辑操作在上一点归并过程思想中已详细讲解,来查看具体实现:
创建临时空间,大小为两个带归并数组的总长度,将数组中所有元素赋值到临时空间中。
通过循环,循环次数为待归并数组长度次数,即r-l,在循环中可按照之前分析的逻辑进行代码实现,获取两有序数组头部值中较小值赋值到待归并数组,此处判断有4中情况(根据不同情况进行赋值、移动下标):
如果左半部分元素已经全部处理完毕
如果右半部分元素已经全部处理完毕
左半部分所指元素 < 右半部分所指元素
左半部分所指元素 >= 右半部分所指元素
查看以下代码:
// 将arr[l...mid]和arr[mid+1...r]两部分进行归并
template<typename T>
void __merge(T arr[], int l, int mid, int r){
/* VS不支持动态长度数组, 即不能使用 T aux[r-l+1]的方式申请aux的空间
* 使用VS的同学, 请使用new的方式申请aux空间
* 使用new申请空间, 不要忘了在__merge函数的最后, delete掉申请的空间:)
*/
T aux[r-l+1];
//T *aux = new T[r-l+1];
for( int i = l ; i <= r; i ++ )
aux[i-l] = arr[i];
// 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
int i = l, j = mid+1;
for( int k = l ; k <= r; k ++ ){
if( i > mid ){ // 如果左半部分元素已经全部处理完毕
arr[k] = aux[j-l]; j ++;
}
else if( j > r ){ // 如果右半部分元素已经全部处理完毕
arr[k] = aux[i-l]; i ++;
}
else if( aux[i-l] < aux[j-l] ) { // 左半部分所指元素 < 右半部分所指元素
arr[k] = aux[i-l]; i ++;
}
else{ // 左半部分所指元素 >= 右半部分所指元素
arr[k] = aux[j-l]; j ++;
}
}
//delete[] aux;
}
// 递归使用归并排序,对arr[l...r]的范围进行排序
template<typename T>
void __mergeSort(T arr[], int l, int r){
if( l >= r )
return;
int mid = (l+r)/2;
__mergeSort(arr, l, mid);
__mergeSort(arr, mid+1, r);
__merge(arr, l, mid, r);
}
//主函数中调用此方法即可(暴露给上层调用)
template<typename T>
void mergeSort(T arr[], int n){
__mergeSort( arr , 0 , n-1 );
}
自底向上
思想
将此数组按照从坐到右的顺序两两划分成多个小组来进行归并排序的过程(一个组有2个元素)。
在两个元素归并排序完成后,再按照从坐到右的顺序将两个组进行归并到一个组(即1个组有4个元素)。
依次类推。
代码实现
在此过程中,并不需要递归,而是采用迭代来实现归并排序,代码如下:
首先最外层循环需要对归并的个数进行遍历,size从1开始遍历到n,每次循环增加自身值,即(1->2->4->8)
内存循环就是每一轮在归并过程起始的元素位置,位置从0开始到n - sz,每次循环增加2个size,即第一轮从0~size-1、从size~2size-1这两部分进行归并,第二轮从2size~3size-1、从3size~4size-1这两部分进行归并。注意:这里代码编写需要严谨考虑越界问题。
以下代码中__merge归并过程函数相同,在此不重复粘贴
// 使用自底向上的归并排序算法
template <typename T>
void mergeSortBU(T arr[], int n){
for( int sz = 1; sz < n ; sz += sz )
for( int i = 0 ; i < n - sz ; i += sz+sz )
// 对 arr[i...i+sz-1] 和 arr[i+sz...i+2*sz-1] 进行归并
__merge(arr, i, i+sz-1, min(i+sz+sz-1,n-1) );
}
3.O(n*logn)排序算法之 快速排序(随机化、二路、三路排序) 及衍生算法
思想
首先来回顾一下上篇博文讲解的归并排序重点思想:不论数组排序内容,直接一分为二再逐渐归并排序。而快速排序:
则每次从当前考虑的数组中选择一个元素,以这个元素为基点,进行处理将此基点放到数组中的合适位置,使得左边的其它元素比此元素小,右边的其它元素比此元素大。
之后对左、右边这2个子数组分别使用快速排序的思路进行排序,逐渐递归下去完成整个排序过程。
partion过程
Partition过程
对于快速排序过程而言,最重要的是将数组分成两个部分,使得基点元素在分界点。此过程为快速排序的核心,通常称为 Partition,以下动画演示了此过程:
通常使用数组的第一个元素来作为分界的标志点(基点),记为l(left)
之后逐渐遍历右边所有未被访问元素
在遍历的过程中逐渐整理让整个数组左部分小于 v 这个元素值,右部分大于 v。
在此过程中,用j 来记录左右部分的分界点,当前访问的元素记为 i 。这样整个数组中 arr[l+1……j ] < v,arr[j+1……i-1] >v
接下来讨论 i 这个元素(即当前访问的元素 e)如何变化才能使整个数组保证 v 的左右两部分刚好代表小于、大于v的位于两侧:
当 e > v时:直接将 e 放到大于v右部分的后面,下标i ++,继续判断下一个元素。
当 e < v时:需要将e放到橘黄色部分(也就是v的左部分),这时只需要将j所指的最后一个元素与 e进行交换,也就是一个大于v的元素与e进行交换下标j++,代表 橘黄色部分元素新增了一个,再进行i ++,继续判断下一个元素
代码实现
以上就是整个Partition的过程,理解透彻后可以轻松实现快速排序的逻辑代码。
(1)quickSort函数
目的:主函数中调用此方法即可(暴露给上层调用)
在函数quickSort中定义另一个函数__quickSort,取名代表它其实是一个私有的函数,被quickSort所调用,对于用户而言只需调用quickSort即可。
(2)__quickSort函数
目的:使用递归来进行快速排序,对arr[l…r]的范围进行快速排序
首先进行边界判断(即递归到底的情况),若 l 大于或等于 r ,即可停止递归。
下面开始快速排序核心算法,首先需要调用一个新函数__partition对arr数组从l 到r 进行partition操作,此函数会返回一个索引值,该值就是arr数组被partition后分成左右两部分的中间分界点下标。
获取到索引值后,则相当于将此数组分成左右两个部分(即左部分的所有元素值都小于索引值的元素值,右部分的所有元素则大于…),接下来使用递归分别对这两个子数组进行快速排序
(3)__partition函数
目的:对arr[l…r]部分进行partition操作,返回p, 使得arr[l…p-1] < arr[p] ; arr[p+1…r] > arr[p]
此函数需要进行的逻辑操作在上一点partition过程思想中已详细讲解,来查看具体实现:
在快速排序中需要一个标准值来作判断,这里默认为第一个值l ,用临时变量v 记录其元素值。
通过循环,从l + 1开始遍历整个数组,让整个数组在此循环之后分成两个部分,即arr[l+1…j] < v ; arr[j+1…i) > v。判断当前元素是否大于v
当前元素大于v:无需处理,判断下一个元素即可。
当前元素小于v:需要进行交换操作,交换arr[j+1]和当前元素的值。在循环开始下标j被赋值为l下标,所以j-l代表小于v的元素总数,j+1相当于小于v的元素总数新增一个。
循环结束后,除了第一个元素外,整个数组已经按照第一个元素值为标准,分成了左右两个部分, 即arr[l+1…j] < v ; arr[j+1…i) > v。最后将l 与 j下标的元素交换,因为下一次快速排序还是会以函数中的参数 l 值(即第一个元素)为标准,所以此时应该交换:j下标位置元素值为v,而l 下标的值为小于 v 的一个函数。
最后返回j 下标,这个下标使得整个数组情况(j相当于p):arr[l…p-1] < arr[p] ; arr[p+1…r] > arr[p]
// 对arr[l...r]部分进行partition操作
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
template <typename T>
int __partition(T arr[], int l, int r){
T v = arr[l];
int j = l; // arr[l+1...j] < v ; arr[j+1...i) > v
for( int i = l + 1 ; i <= r ; i ++ )
if( arr[i] < v ){
j ++;
swap( arr[j] , arr[i] );
}
swap( arr[l] , arr[j]);
return j;
}
// 对arr[l...r]部分进行快速排序
template <typename T>
void __quickSort(T arr[], int l, int r){
if( l >= r )
return;
int p = __partition(arr, l, r);
__quickSort(arr, l, p-1 );
__quickSort(arr, p+1, r);
}
template <typename T>
void quickSort(T arr[], int n){
__quickSort(arr, 0, n-1);
}
4.堆排序之 二叉堆(Heapify、原地堆排序优化)
优先队列
例如在1,000,000个元素中选出前100名,也就是“在N个元素中选出前M个元素”。
在前三章中学习了排序算法后,很快得到将所有元素排序,选出前M个元素即可,时间复杂度为O(n*logn)。但是使用了优先队列,可将时间复杂度降低为O(n *logM)!
二叉堆(Binary Heap)的基本存储
特点
1.在二叉树上任何一个子节点都不大于其父节点。
2.必须是一棵完全的二叉树,(若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。)
注意:第一个特征中说明在二叉树上任何一个子节点都不大于其父节点,并不意味着层数越高节点数越大,这都是相对父节点而言的。例如第三层的19比第二层的16大。
这样的二叉堆又被称为“最大堆”
结构实现
对于其具体实现,熟悉树形结构的同学可能认为需要两个指针来实现左、右节点,当然可以这样实现,但是还有一个经典实现方式——通过数组实现,正是因为堆是一棵完全的二叉树。
将这棵二叉树自上到下、自左到右地给每一个节点标上一个序列号,如下图所示。对于每一个父节点i而言:
它的左孩子序列号都是本身序列号的 2倍 2i
它的右孩子序列号都是本身序列号的 2倍+1 2i+1
(这里的根节点下标是由1开始而得出以上规则,但其实由0开始也可得出相应的规则,此部分重点还是放在下标1开始)
代码实现
template<typename Item>
class MaxHeap{
private:
Item *data;
int count;
public:
// 构造函数, 构造一个空堆, 可容纳capacity个元素
MaxHeap(int capacity){
data = new Item[capacity+1];
count = 0;
}
~MaxHeap(){
delete[] data;
}
// 返回堆中的元素个数
int size(){
return count;
}
// 返回一个布尔值, 表示堆中是否为空
bool isEmpty(){
return count == 0;
}
};
// 测试 MaxHeap
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
cout<<maxheap.size()<<endl;
return 0;
}
以上C++代码并不复杂,只是简单实现了最大堆(MaxHeap)的基本结构,定义了data值,因为不知道值的具体类型,通过模板(泛型)结合指针来定义,提供简单的构造、析构、简单函数方法。
二叉堆中的 Shift Up 和 Shift Down
Shift Up插入元素入队操作
算法思想
由于二叉堆是用数组表示,所以相当于在数组末尾添加一个元素,相当于52是索引值11的元素。
算法思想
注意!其实整个逻辑思想完全依赖于二叉树的特征,因为在二叉堆上任何一个子节点都不大于其父节点,所以需要将新插入的元素挪到合适位置来维护此特征:
首先判断新加入的元素(先归到二叉堆中)和其父节点的大小,52比16小,所以交换位置。
52被换到一个新位置,再继续查看52是否大于其父节点,发现52比41大,继续交换。
再继续判断,52比62小,无须挪动位置,插入完成。
代码实现
//将下标k的新增元素放入到二叉堆中合适位置
void shiftUp(int k){
while( k > 1 && data[k/2] < data[k] ){//边界&&循环与父节点比较
swap( data[k/2], data[k] );
k /= 2;
}
}
// 像最大堆中插入一个新的元素 item
void insert(Item item){
assert( count + 1 <= capacity );
data[count+1] = item;//注意下标是从1开始,所以新增元素插入位置为count+1,并非count
count ++;//数量增加1
shiftUp(count);
}
Shift Down删除元素出队操作
算法思想
根据二叉堆的特征,其根节点值最大,所以直接获取下标1的元素,但是根节点值空缺处理,需要重新整理整个二叉树。
将数组中最后一个值替补到根节点,count数组总数量减1。因为在二叉堆上任何一个子节点都不大于其父节点。所以需要调节根节点元素,相应的向下移,不同于Shift Up,它可以向左下移或右下移,这里采用的标准是跟元素值较大的孩子进行交换:
根节点与16与52、30比较,将16和52进行交换。
将交换后的16与两个孩子28、41比较,与41交换。
交换后的16此时只有一个孩子15,比其大,无需交换。Shift Down过程完成。
代码实现
void shiftDown(int k){
while( 2*k <= count ){
int j = 2*k; // 在此轮循环中,data[k]和data[j]交换位置
if( j+1 <= count && data[j+1] > data[j] )
j ++;
// data[j] 是 data[2*k]和data[2*k+1]中的最大值
if( data[k] >= data[j] ) break;
swap( data[k] , data[j] );
k = j;
}
}
// 从最大堆中取出堆顶元素, 即堆中所存储的最大数据
Item extractMax(){
assert( count > 0 );
Item ret = data[1];
swap( data[1] , data[count] );
count --;
shiftDown(1);
return ret;
}
其他
5.排序算法总结 和 索引堆及优化
均时间复杂度
快速排序,待排序数组已经是近乎有序,那么其时间复杂度会退化到O(n^2),所以使用了随机算法优化使其概率降低到0。总体而言,快速排序的性能较优,也就是说在O(n*logn)这3种算法而言有常数性的差异,但快速排序较优,所以一般系统级别的排序采用快速排序,而对于含有大量重复元素的数组可采用优化的三路快速排序。
原地排序
插入排序、快速排序和堆排序可以直接在待排序数组上交换元素完成排序过程,而归并排序无法完成,它必须开辟额外的空间来辅助完成。正因如此,若一个系统对空间使用比较敏感,并不会采用归并排序。
额外空间
对于插入排序和堆排序而言,使用的额外空间就是数组上交换元素,所以所耗空间为O(1)级别,即常数级别。
而归并排序需要O(n)级别空间,即数组同等长度空间来辅助完成归并过程。
快速排序所需O(logn)额外空间,因为它采用递归方式来进行排序,递归有logn层,所以需要O(logn)空间来保证每一层的临时变量以供递归返回时继续使用。
稳定排序
稳定排序
插入排序:算法中有后面元素与前面元素相比较,若小于则前移,否则不动。所以相同元素之间位置不会发生改变。
归并排序:在归并过程中,左右子数组已经有序,需要归并到一起,其核心也是判断当后面元素小于前面元素才前移,否则不动。所以相同元素之间位置不会发生改变。
不稳定排序
快速排序:算法核心中会随机选择一个标志点来进行大于、小于判断排序,所以很有可能使得后面相等元素到前面来。所以相同元素之间位置会发生改变。
堆排序:将整个数组整理成堆的过程中会破坏掉稳定性。所以相同元素之间位置会发生改变。
6.二分搜索树(Binary Search Tree)
特征
二分搜索树本质上是一棵二叉树。
每个节点的键值大于左孩子
每个节点的键值小于右孩子
以左右孩子为根的子树仍为二分搜索树
代码实现
在代码实现堆时,正是因为它是一棵完全的二叉树此特点,所以可使用数组进行实现,但是二分搜索树并无此特性,所以在实现上是设立key、value这种Node节点,节点之间的连续使用指针。
Node节点结构体包含:
Key key;
Value value;
Node *left; //左孩子节点指针
Node *right; //右孩子节点指针
私有成员变量:
Node *root; // 根节点
int count; // 节点个数
公有基本方法:
BST() // 构造函数, 默认构造一棵空二分搜索树
int size() // 返回二分搜索树的节点个数
bool isEmpty() // 返回二分搜索树是否为空
以下就是二分搜索树的基本结构,实现并不复杂,代码如下:
// 二分搜索树
template <typename Key, typename Value>
class BST{
private:
// 二分搜索树中的节点为私有的结构体, 外界不需要了解二分搜索树节点的具体实现
struct Node{
Key key;
Value value;
Node *left;
Node *right;
Node(Key key, Value value){
this->key = key;
this->value = value;
this->left = this->right = NULL;
}
};
Node *root; // 根节点
int count; // 节点个数
public:
// 构造函数, 默认构造一棵空二分搜索树
BST(){
root = NULL;
count = 0;
}
~BST(){
// TODO: ~BST()
}
// 返回二分搜索树的节点个数
int size(){
return count;
}
// 返回二分搜索树是否为空
bool isEmpty(){
return count == 0;
}
};
插入新节点
例如待插入数据60,首先与根元素41比较,大于根元素,则与其右孩子再进行比较,大于58由于58无右孩子,则60为58的右孩子,过程结束。(注意其递归过程)
代码实现:insert函数
判断node节点是否为空,为空则创建节点并将其返回( 判断递归到底的情况)。
若不为空,则继续判断根元素的key值是否等于根元素的key值:
若相等则直接更新value值即可。
若不相等,则根据其大小比较在左孩子或右孩子部分继续递归直至找到合适位置为止。
public:
// 向二分搜索树中插入一个新的(key, value)数据对
void insert(Key key, Value value){
root = insert(root, key, value);
}
private:
// 向以node为根的二分搜索树中, 插入节点(key, value), 使用递归算法
// 返回插入新节点后的二分搜索树的根
Node* insert(Node *node, Key key, Value value){
if( node == NULL ){
count ++;
return new Node(key, value);
}
if( key == node->key )
node->value = value;
else if( key < node->key )
node->left = insert( node->left , key, value);
else // key > node->key
node->right = insert( node->right, key, value);
return node;
}
};
二分搜索树的查找
其实在理解二分搜索树的插入过程后,其查找过程本质上是相同的,这里提供两个搭配使用的查找函数:
bool contain(Key key):查看二分搜索树中是否存在键key
Value* search(Key key):在二分搜索树中搜索键key所对应的值。如果这个值不存在, 则返回NULL。(注意:这里返回值使用Value* ,就是为了避免用户查找的值并不存在而出现异常)
public:
// 查看二分搜索树中是否存在键key
bool contain(Key key){
return contain(root, key);
}
// 在二分搜索树中搜索键key所对应的值。如果这个值不存在, 则返回NULL
Value* search(Key key){
return search( root , key );
}
private:
// 查看以node为根的二分搜索树中是否包含键值为key的节点, 使用递归算法
bool contain(Node* node, Key key){
if( node == NULL )
return false;
if( key == node->key )
return true;
else if( key < node->key )
return contain( node->left , key );
else // key > node->key
return contain( node->right , key );
}
// 在以node为根的二分搜索树中查找key所对应的value, 递归算法
// 若value不存在, 则返回NULL
Value* search(Node* node, Key key){
if( node == NULL )
return NULL;
if( key == node->key )
return &(node->value);
else if( key < node->key )
return search( node->left , key );
else // key > node->key
return search( node->right, key );
}
};
二分搜索树的遍历(深度优先遍历)
前序遍历
思想
前序遍历:先访问当前节点,再依次递归访问左右子树
代码实现
public:
// 二分搜索树的前序遍历
void preOrder(){
preOrder(root);
}
private:
// 对以node为根的二叉搜索树进行前序遍历, 递归算法
void preOrder(Node* node){
if( node != NULL ){
cout<<node->key<<endl;
preOrder(node->left);
preOrder(node->right);
}
}
中序遍历
思想
中序遍历:先递归访问左子树,再访问自身,再递归访问右子树。
查看其打印结果,是按照从小到大的顺序进行打印的,所以在进行实际应用时,可使用二分搜索输的中序遍历将元素按照从小到大顺序输出。其原因与二分搜索树定义相关的!
代码实现
public:
// 二分搜索树的中序遍历
void inOrder(){
inOrder(root);
}
private:
// 对以node为根的二叉搜索树进行中序遍历, 递归算法
void inOrder(Node* node){
if( node != NULL ){
inOrder(node->left);
cout<<node->key<<endl;
inOrder(node->right);
}
}
后序遍历
思想
后续遍历:先递归访问左右子树,再访问自身节点。
代码实现
public:
// 二分搜索树的后序遍历
void postOrder(){
postOrder(root);
}
private:
// 对以node为根的二叉搜索树进行后序遍历, 递归算法
void postOrder(Node* node){
if( node != NULL ){
postOrder(node->left);
postOrder(node->right);
cout<<node->key<<endl;
}
}
注:深度优先遍历,遍历一开始首先会走到最深,再回溯到开始遍历整棵树。
空间释放
析构函数思想
构造二分搜索树的基本结构时,并未具体实现析构函数~BST(),而在理解以上深度优先遍历思想后,可以由此实现:通过后序遍历来删除节点。先判断节点是否为空,若不为空,则先删除掉其左孩子,再删除掉右孩子,最后毫无顾虑了,删除掉自身。
代码实现
public:
// 析构函数, 释放二分搜索树的所有空间
~BST(){
destroy( root );
}
private:
// 释放以node为根的二分搜索树的所有节点
// 采用后续遍历的递归算法
void destroy(Node* node){
if( node != NULL ){
destroy( node->left );
destroy( node->right );
delete node;
count --;
}
}
层序遍历(广度优先遍历)
广度优先遍历则是层序遍历,一层一层地向下遍历
代码实现
public:
// 二分搜索树的层序遍历
void levelOrder(){
queue<Node*> q;
q.push(root);//入队根节点
while( !q.empty() ){//队列为空时结束循环
Node *node = q.front();//获取队首元素
q.pop();
cout<<node->key<<endl;
if( node->left )
q.push( node->left );
if( node->right )
q.push( node->right );
}
}
删除
删除二分搜索树的最小值和最大值
思想
查找过程
首先来了解最简单的情况—–删除二分搜索树的最小值和最大值,其实此过程根据搜索树的特征很容易解决,从根节点开始遍历其左孩子,直至最后节点无左孩子,那么此节点就是最小值;最大值同理,遍历其右孩子即可。
删除过程
注意,这里二分搜索数的最小、大值并非一定完全二叉树下的情况,例如下图,所以在删除节点时,需要将其左孩子或右孩子代替其删除节点,来保持二分搜索树的特征。
代码实现
公有函数,供外层调用:
minimum():寻找二分搜索树的最小的键值
maximum():寻找二分搜索树的最大的键值
removeMin():从二分搜索树中删除最小值所在节点
removeMax():从二分搜索树中删除最大值所在节点
私有函数,内部实际操作:
Node* minimum(Node* node):返回以node为根的二分搜索树的最小键值所在的节点
Node* maximum(Node* node):返回以node为根的二分搜索树的最大键值所在的节点
Node* removeMin(Node* node):删除掉以node为根的二分搜索树中的最小节点,返回删除节点后新的二分搜索树的根
Node* removeMax(Node* node):删除掉以node为根的二分搜索树中的最大节点,返回删除节点后新的二分搜索树的根
删除节点
思想
若待删除节点58同时拥有左、右孩子,该如何操作?
Hubbard Deletion
以下介绍的算法被称为Hubbard Deletion,在之前的讨论中,若待删除节点只有一个孩子,则用此孩子替代待删除节点;若有两个孩子,其思想也是类似,找到一个合适的节点来替代,而Hubbard Deletion算法则认为此替代节点是右子树的最小节点!
因此,需要代替58的节点是59,注意二分搜索树的特征,59的所有右孩子都比58要大,所以右孩子子树中的最小值59代替其58后,此二分搜索树的特征仍然成立!
因此,整个过程可以总结为首先寻找待删除节点的后继节点(右子树中的最小值),由后继节点代替待删除节点即可。
代码实现
若要删除左右都有孩子的节点 d
找到 s = min(d->right),s 是 d 右子树中的最小值,需要代替d的后继节点
s->right = delMin(d->right)
s->left = d->left
删除d,s是新的子树的根
public:
// 从二分搜索树中删除键值为key的节点
void remove(Key key){
root = remove(root, key);
}
private:
// 删除掉以node为根的二分搜索树中键值为key的节点, 递归算法
// 返回删除节点后新的二分搜索树的根
Node* remove(Node* node, Key key){
if( node == NULL )
return NULL;//未找到对应key的节点
if( key < node->key ){//在node的左子树中寻找
node->left = remove( node->left , key );
return node;
}
else if( key > node->key ){//在node的右子树中寻找
node->right = remove( node->right, key );
return node;
}
else{ // key == node->key
// 待删除节点左子树为空的情况
if( node->left == NULL ){
Node *rightNode = node->right;
delete node;
count --;
return rightNode;
}
// 待删除节点右子树为空的情况
if( node->right == NULL ){
Node *leftNode = node->left;
delete node;
count--;
return leftNode;
}
// 待删除节点左右子树均不为空的情况
// 找到比待删除节点大的最小节点, 即待删除节点右子树的最小节点
// 用这个节点顶替待删除节点的位置
Node *successor = new Node(minimum(node->right));
count ++;
successor->right = removeMin(node->right);
successor->left = node->left;
delete node;
count --;
return successor;
}
}
注意:以上过程可完成二分搜索树的节点删除过程,其重点就是当待删除节点同时拥有左、右子树时,寻找右子树中的最小值进行代替。其实同理而言,另外一个思路也可实现:寻找左子树的最大值进行代替,如下图所示,这种特性来源于二分搜索树的特征,可自行实现。
总结
二分搜索树的删除操作时间复杂度为O(logn),主要消耗于查找待删除节点,一旦找到了删除节点的过程只是指针间的交换,是常数级别的,是非常高效的。
二分搜索树的顺序性
前驱节点和后继节点(successor , predecessor)
思想
首先需要理解清楚前驱节点和后继节点的定义,几个例子,下图中 41的前驱节点是 37,后继节点是42。
因此,规律也自然而然得出:
一个节点的前驱节点是其左子树中的最大值,若无左子树,其前驱节点在从根节点到key的路径上,比key小的最大值。
一个节点的后继节点是右子树的最小值,若无右子树,其后继节点在从根节点到key的路径上,比key大的最小值。
代码实现
这里寻找前驱节点或后继节点的逻辑主要分为3个步骤(这里只列出寻找前驱节点的步骤,后继节点同理,在此不赘述):
如果key所在的节点不存在,则key没有前驱, 返回NULL
如果key所在的节点左子树不为空,则其左子树的最大值为key的前驱
否则,key的前驱在从根节点到key的路径上,在这个路径上寻找到比key小的最大值, 即为key的前驱
public:
// 查找key的前驱
// 如果不存在key的前驱(key不存在, 或者key是整棵二叉树中的最小值), 则返回NULL
Key* predecessor(Key key){
Node *node = search(root, key);
// 如果key所在的节点不存在, 则key没有前驱, 返回NULL
if(node == NULL)
return NULL;
// 如果key所在的节点左子树不为空,则其左子树的最大值为key的前驱
if(node->left != NULL)
return &(maximum(node->left)->key);
// 否则, key的前驱在从根节点到key的路径上, 在这个路径上寻找到比key小的最大值, 即为key的前驱
Node* preNode = predecessorFromAncestor(root, key);
return preNode == NULL ? NULL : &(preNode->key);
}
// 查找key的后继, 递归算法
// 如果不存在key的后继(key不存在, 或者key是整棵二叉树中的最大值), 则返回NULL
Key* successor(Key key){
Node *node = search(root, key);
// 如果key所在的节点不存在, 则key没有前驱, 返回NULL
if(node == NULL)
return NULL;
// 如果key所在的节点右子树不为空,则其右子树的最小值为key的后继
if(node->right != NULL)
return &(minimum(node->right)->key);
// 否则, key的后继在从根节点到key的路径上, 在这个路径上寻找到比key大的最小值, 即为key的后继
Node* sucNode = successorFromAncestor(root, key);
return sucNode == NULL ? NULL : &(sucNode->key);
}
private:
// 在以node为根的二叉搜索树中, 寻找key的祖先中,比key小的最大值所在节点, 递归算法
// 算法调用前已保证key存在在以node为根的二叉树中
Node* predecessorFromAncestor(Node* node, Key key){
if(node->key == key)
return NULL;
Node* maxNode;
if(key < node->key)
// 如果当前节点大于key, 则当前节点不可能是比key小的最大值
// 向下搜索到的结果直接返回
return predecessorFromAncestor(node->left, key);
else{
assert(key > node->key);
// 如果当前节点小于key, 则当前节点有可能是比key小的最大值
// 向下搜索结果存储到maxNode中
maxNode = predecessorFromAncestor(node->right, key);
if(maxNode)
// maxNode和当前节点node取最大值返回
return maxNode->key > node->key ? maxNode : node;
else
// 如果maxNode为空, 则当前节点即为结果
return node;
}
}
// 在以node为根的二叉搜索树中, 寻找key的祖先中,比key大的最小值所在节点, 递归算法
// 算法调用前已保证key存在在以node为根的二叉树中
Node* successorFromAncestor(Node* node, Key key){
if(node->key == key)
return NULL;
Node* minNode;
if(key > node->key)
// 如果当前节点小于key, 则当前节点不可能是比key大的最小值
// 向下搜索到的结果直接返回
return successorFromAncestor(node->right, key);
else{
assert(key < node->key);
// 如果当前节点大于key, 则当前节点有可能是比key大的最小值
// 向下搜索结果存储到minNode中
minNode = predecessorFromAncestor(node->left, key);
if(minNode)
// minNode和当前节点node取最小值返回
return minNode->key < node->key ? minNode : node;
else
// 如果minNode为空, 则当前节点即为结果
return node;
}
}
子主题 3
局限性
同样的数据,可以对应不同的二分搜索树。第一种创建情况可能是大部分人心中设想,但是第二种情况也是符合二分搜索树的特征,如此一来,二分搜索树可能退化成链表。二分搜索树的查找过程是与其高度相关,此时高度为n,时间复杂度为O(n^2)。
总结
为了解决此问题,可以改造二叉树的实现,使得其无法退化成链表—–平衡二叉树,它有左右两棵子树,并且其高度差不会超过1,因此可以保证其高度一定是 logn 级别的,此概念的经典实现就是红黑树。
7.平衡二叉树
定义
它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。(它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。)
8.红黑树
特性
R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
红黑树的特性:
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,则它的子节点必须是黑色的。
(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
注意:
(01) 特性(3)中的叶子节点,是只为空(NIL或null)的节点。
(02) 特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。
参考https://www.cnblogs.com/skywang12345/p/3245399.html