有没有想过,当我们定义一个对象的时候,该对象是定义在堆上还是栈上的呢?

现在,假设你已经清楚什么是堆,什么是栈。

如果需要在堆上创建对象,要么使用C++的new运算符,要么使用C语言的malloc系列函数。这点没有异议。

那么假如有一个类A,语句如下:

A a;

此时,a是在栈上分配的吗?

其实,这行语句的含义是,使对象a具有“自动存储(automatic storage)”的性质。所谓“自动存储”,意思是这个对象的存储位置取决于其声明所在的上下文。

如果这个语句出现在函数内部,那么它就在栈上创建对象。

如果这个语句不是在函数内部,而是作为一个类的成员变量,则取决于这个类的对象是如何分配的。

一般来说,如果你用new来生成的对象都是放在堆中的,而直接定义的局部变量都是放在栈中的,全局和静态的对象是放在数据段的静态存储区中。

那么,该如何定义一个只能在堆/栈上生成对象的类呢?

只能在堆上

方法:将析构函数设置为私有

原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。

如下所示:

#include <iostream>
using namespace std;

class A{
  public:
    A(){};
  private:
    ~A(){};//将析构函数设为私有
};

int main(){
    A a;//无法通过编译,提示A类的析构函数是私有的
    A *aa = new A();//通过编译,在堆上生成对象
}

 

只能在栈上

方法:将 new 和 delete 重载为私有

原因:在堆上生成对象,使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

#include <iostream>
using namespace std;

class A{
  public:
    A(){};
    ~A(){};
   private:
    void * operator  new ( size_t  t){}//重载new运算符为私有类型
    void operator delete ( void * ptr){}//重载delete运算符为私有类型
};

int main(){
    A a;//通过编译,在栈上建立了对象
    A *aa = new A();//无法通过编译,提示new运算符是A的私有成员
}

 

翻阅以前的代码,时常会看到如下代码:

if(str.size() == 0) {}
if(!vec.size())     {}
if(!list.size())    {}
// ......

这些例子中只是把 size() 的返回值简单地进行布尔逻辑比较,意思是判断容器空与非空。

代码没有逻辑问题,只是可能效率不够高。

在C++的标准库容器中都有一个 empty() 方法,返回布尔值,表明容器当前是否为空。

使用 size() 判断容器非空与否的不好之处在于:某些标准库中的实现中对某些容器的 size() 求值操作不是线性的。

比如 std::list<>,在某些实现中,每一次调用 size() 都会遍历整个 list 来求 size,这会带来一定的性能问题。如果是线性容器则不会有此问题。

总之,如果只是想判断空与非空,则应总是使用 empty(),而不是 size()

#include <iostream>
#include<vector>
using namespace std;


void quickSort(vector<int>&a,int first , int last){
    if(first >= last){
        return;
    }
    int i = first,j = last;
    int key = a[first];//选择当前数字中第一个数作为基准key
    while(i < j){
        while(i < j && a[j] > key){
            j--;
        }
        a[i] = a[j];//在数组的后面遇到了比基准key小的数,放到前面去
        while(i < j && a[i] < key){
            i++;
        }
        a[j] = a[i];//在数组的前面遇到了比基准key大的数,放到后面去,刚好覆盖上次那个比基准大的数的位置
    }
    a[j] = key;//此时i==j,这个位置应该就是我们的基准key应该放到的地方
    quickSort(a,first,j-1);//递归对目前的key左边部分使用快速排序
    quickSort(a,i+1,last );//递归对目前的key右边部分使用快速排序
}

int main()
{
    vector<int>a = {22,32,54,79,154,2,10,0,-6,48,99};
    quickSort(a,0,10);
    for(int i = 0;i<11;i++){
        cout<<a[i]<<" ";
    }
}

快速排序是综合性能最好的排序方式,平均时间复杂度为O(nlogn),且不具有稳定性。

当序列已经是递增序列的时候,快速排序此时的时间复杂度最高,为O(n²)。

运算符重载就是用同一个运算符完成不同的运算功能,和函数重载一样,运算符重载是在编译阶段完成的,体现出静态的多态性。

C++运算符重载的规定如下:

  • 不能改变原运算符的优先级
  • 不能改变原运算符的结合性
  • 默认参数不能和运算符重载一起使用
  • 不能改变原运算符的操作数个数
  • 不能创建新的运算符,且部分已有运算符可能因为存在二义性问题所以无法被重载
  • 当运算符作用于C++内部提供的数据类型时,原来的含义保持不变。
  • 运算符可以被重载用于用户定义的类对象或者用户定义的类对象与内置数据类型变量的组合

C++中不能重载的运算符:

  1. .
  2. .*
  3. ::
  4. ?:
  5. sizeof

运算符重载为类成员函数时主要有3种形式:重载为类的成员函数、重载为类的友元函数、重载为普通函数。

 

重载++,–单目运算符

++和–重载运算符有前缀和后缀两种运算符重载形式,这里以++重载运算符为例,采用成员函数重载。

#include<iostream>
using namespace std;

class A
{
    int n;
public:
    A(int i){n = i;}
    operator ++(){//重载前缀运算符
     n++;
    }
    operator ++(int){//重载后缀运算符
     n = n+2;
    }
void show(){
cout<<"n="<<n<<endl;
}

};

int main()
{
    A a(5);
    A b(5);
    ++a;
    b++;
    a.show();
    b.show();
}

 

输出结果为:

n=6
n=7

 

如果改用成员函数的调用方式,重载后缀运算符的调用方式是a.operator++(1),其中1可以改为任意整数,它只是一个占位符。重载前缀运算符的调用方式是b.operator()。

 

重载下标运算符

下标运算符[]通常用于取数组的某个元素,下标运算符重载可以实现数组下标的越界检测等,如下Words类所示:

class Words
{
    int len;
    char *str;
public:
    Words(char *s){
    str = new char[strlen(s)+1];
    strcpy(str,s);
    len = strlen(s);
    }

    char operator[](int n){
    if(n > len-1){
        cout<<"数组下标越界";
        return ' ';//返回一个特殊字符即可
    }
    else return *(str+n);
    }
};

 

重载运算符new/delete

C++提供了new和delete两个运算符用于内存管理,在大多数情况下它们是非常有效的,但在有些情况下需要自己管理内存,以克服原new与delete的不足,这就是重载运算符new和delete。delete和delete只能被重载为类的成员函数或者普通函数,不能被重载为友元函数,而且不论是否使用关键字static进行修饰,重载了的new和delete均为类的静态成员函数。

重载new运算符的一般格式:

void *类名::operator new(size_t size,其他形参){
     //使用malloc完成分配工作
     //完成自己需要的操作
     return void *型指针;
}

上述new重载函数应返回一个无值型的指针,形参可以有多个,但第1个参数一定是size_t类型的参数,它表示要分配空间的大小,再调用时由系统自动获取。

在使用重载new运算符时先调用new的重载成员函数,再调用该类的构造函数。

重载delete运算符的一般格式:

void *类名::operator delete(void *p){
     //使用free完成内存释放工作
     //完成自己需要的操作
}

在使用重载delete运算符时默认先调用类的析构函数,再调用重载的delete成员函数。

重载new[]/delete[]和重载new/delete类似。

 

重载输入/输出运算符

重载插入运算符的一般格式如下:

friend ostream& operator<< (ostream & stream,类名 &类引用名){
  //函数体
   return stream;
}

 

重载输出运算符的一般格式如下:

friend istream& operator>> (ostream & stream,类名 &类引用名){
  //函数体
   return stream;
}

 

设计单例模式的意图是保证一个类仅有一个实例,并提供类的一个全局访问点,该实例被所有程序模块共享。

采用C++实现单例模式有多种方式,这里采用嵌套类实现。

其中类A实现单例模式。

  • 它的构造函数的私有的,这样就不能从别处创建该类的实例。
  • 它有一个唯一实例的静态对象指针pinstance,且是私有的。
  • 它有一个共有函数GetInstance(),可以获取这个唯一的实例,并在需要的时候创建该实例。
  • 设计嵌套类的目的是为了定义它的静态子对象,在程序结束时会调用该子对象的析构函数以释放唯一的实例。
  • 如果采用在类A中设计析构函数来释放实例,则该析构函数必须是公用的,这在单例模式中是不恰当的。

源代码如下:

#include<iostream>
using namespace std;

class A{
   //其他数据成员
public:
    static A *GetInstance(){
    if(pinstance ==NULL){
        pinstance = new A();
    }
    return pinstance;
    }
private:
    static A *pinstance;//返回唯一实例的指针
    class B //嵌套类,它的唯一工作就是在析构函数中释放实例
    {
    public:
        ~B(){
          if(A::pinstance!=NULL){
            delete A::pinstance;
            cout<<"delete";
          }
        }
    };
    static B b;//定义一个子对象,在程序结束时会调用它的析构函数
};

A *A::pinstance=NULL;//静态子对象指针初始化
A::B A::b;//静态子对象初始化

int main(){
    A *p= A::GetInstance();
    A *q = A::GetInstance();
    if(p == q) cout<<"Same"<<endl;
    return 0;
}

 

输出结果为:

Same
delete

 

从结果可以看出类A的两个对象指针指向同一个实例,说明只能创建唯一的实例,并且该唯一实例是自动销毁的。

在C++中,对象复制分为浅复制和深复制。

(1)对象的浅复制

当两个对象之间进行复制时,若复制完成后它们还共享某些内存空间,其中一个对象的销毁会影响到另一个对象,这种对象之间的复制称为浅复制。

(2)对象的深复制

当两个对象之间进行复制时,若复制完成后它们不会共享任何内存空间,其中一个对象的销毁不会影响到另一个对象,这种对象之间的复制称为深复制。

通常情况下拷贝构造函数执行的都是浅复制,当有必要时,我们可以自己写一个拷贝构造函数实现深复制。当一个类中的数据成员带有指针时,此时使用默认的拷贝构造函数,类对象的指针数据成员经过浅复制后会指向同一块内存区域,表面上看上去仿佛没事,但一旦销毁其中任意一个对象时,会调用该类的析构函数,那么指针指向的那块内存区域会被回收,此时再访问另一个对象时,这个对象的指针数据成员所指地址的内存区域已经被回收,会产生未定义的结果,要解决这个问题,我们需要重写拷贝构造函数,实现深复制。实现深复制只需要在该类的拷贝构造函数中,动态分配一块大小和被复制的对象的指针指向的内存区域相同大小的内存区域,将数据复制过去,并将指针指向刚刚分配的内存区域即可。

如下为String 类的基本实现,用到了深复制。

class String{
public:
    String(const char *str = NULL);//构造函数
    String(const String &other);//拷贝构造函数
    ~String(void);//析构函数
    String & operator = (const String &other);//重载赋值运算符
private:
    char *m_data;//用于保存字符串
};

String::String(const char *str )//构造函数
{
    if(str == NULL){
        m_data = new char[1];
        *m_data = '\0';
    }
    else{
        int length = strlen(str);
        m_data = new char[length+1];
        strcpy(m_data,str);
    }
}
String::String(const String &other)//拷贝构造函数
{
    int length = strlen(other.m_data);
    m_data = new char[length+1];
    strcpy(m_data,other.m_data);
}
String::~String(void)//析构函数
{
    delete m_data;
}
String &String :: operator = (const String &other)//重载赋值运算符
{
    if(this==&other){//检查自赋值
        return *this;
    }
    delete m_data;
    int length = strlen(other.m_data);
    m_data = new char[length+1];
    strcpy(m_data,other.m_data);
    return *this;
}

 

C++的空类是指设计时不包含任何数据成员和成员函数的类。

实际上,对于一个空类,系统会自动添加以下默认的成员函数:

1.默认构造函数

2.默认拷贝构造函数

3.默认析构函数

4.赋值“=”运算符

5.取地址运算符

6.取地址运算符const

对于一个空类A,sizeof(A)的值为1,这是因为实例化的原因,空类同样可以被实例化,每个实例在内存中都应该有唯一的地址,为了达到这个目的,编译器会给一个空类插入一个字节,这样空类在实例化后在内存中得到唯一的地址,所以空类所占的内存大小是1个字节。

 

在C++中,类对象的存储空间分配方式如下:

(1)一个类对象的分配空间中仅包含所有非静态数据成员,并按照这些数据成员定义的顺序存放。

(2)一个类对象的大小为其所有非静态数据成员的类型大小之和,普通成员函数与sizeof是没有关系的。当类中有一个或者多个虚函数时,由于要维护虚函数表,所以有一个虚函数表指针,另外当存在虚继承时还需要一个指向虚基类的指针,每个指针占用一个地址大小的内存空间,即4个字节。

(3)类对象中的各个数据成员分配内存时也遵守内存对齐规则。内存对齐规则请看:内存对齐

在了解了对象的存储结构后,可以采用取指定地址中值的方式访问对象的私有数据成员,如下程序所示:

#include<iostream>
using namespace std;

class A{
private:
    int x;
    int y;
public:
    A(int i,int j){
        x = i;
        y = j;
    }
    
};

int main()
{
    A a(1,3);
    cout<<"a.x="<<*((int *)(&a))<<endl;
    cout<<"a.y="<<*((int *)(&a)+1)<<endl;
}

输出为:

a.x=1
a.y=3
Program ended with exit code: 0

 

assert宏(在assert.h头文件中定义)用于测试表达式的值,如果表达式的值为假,则assert输出错误信息,并调用函数abort()以结束程序。这是测试某个变量是否具有正确值的有用的调试工具例如以下语句就是一个断言:

assert(x<10);

当在程序中遇到这个语句时,如果x的值大于或等于10,则将打印包含行号和文件名的错误信息,而且程序终止。然后程序将在这个代码区域内查找错误。

void  *memcpy(void *&pvTo, const void *pvFrom, size_t size) {
       char *pbTo = (char*)pvTo;
       char *pbFrom = (char*)pvFrom;
       assert((pvTo != NULL) && (pvFrom != NULL));      
//使用断言检查输入指针的有效性
       assert(pbTo >= pbFrom + size ||  pbFrom >= pbTo + size);
//使用断言检查两个指针指向的内存区域是否重叠
       while(size -- > 0 )
              *pbTo++ = *pbFrom++ ;
}

可以通过定义 NDEBUG 来关闭 assert,但是需要在源代码的开头,include <assert.h> 之前。

 

要求:

  1. 利用随机函数产生N个随机整数(20000个以上),对这些数进行多种方法排序;
  2. 采用插入排序、希尔排序、冒泡排序、快速排序、选择排序、堆排序、归并排序方法实现上述方法求解;
  3. 把排序后的结果保存在不同的文件中;
  4. 统计每一种排序方法的性能,以上机运行程序所花费的时间为准进行对比;
  5. 找出其中两种较快的方法。

排序算法可以说是一项基本功,解决实际问题中经常遇到,针对实际数据的特点选择合适的排序算法可以使程序获得更高的效率,有时候排序的稳定性还是实际问题中必须考虑的。

需求分析

我们的程序需要实现几个模块,其中包括

  • 产生随机数模块
  • 多种方法排序模块
  • 比较排序性能模块

产生随机数模块可以通过C++的rand()函数随机生成伪随机数,并将这20000个随机数存在一个名为number.txt的文件中。

然后在程序中实现插入排序、希尔排序、冒泡排序、快速排序、选择排序、堆排序、归并排序等排序方法以完成多种方法排序模块。

然后通过执行不同的排序算法所花费的时间进行比较,得出最快的两种方法,并得出以上排序算法的性能排名。

程序流程图:

系统设计

开发环境:

操作系统:Microsoft Windows 10 64位

IDE:CodeBlocks 32位

编译器:GNU GCC Compiler

编程语言:C++

图形库:ACLLib

数据结构:

本程序使用了顺序表来存储所有的数据,并基于顺序表的结构进行排序。

同时,本程序使用了ACLLib图形库画出了简单的图形界面,使我们的性能对比更加直观。

函数调用关系图:

排序算法伪代码:

插入排序:

for i←2 to n

x←A[i]

j←i-1

while(j>0) and (A[j]>x)

A[j+1]←A[j]

j←j-1

end while

A[j+1]←x

end for

 

希尔排序:

input: an array a of length n with array elements numbered 0 to n − 1
inc ← round(n/2)
while inc > 0 do:
for i = inc .. n − 1 do:
temp ← a[i]
j ← i
while j ≥ inc and a[j − inc] > temp do:
a[j] ← a[j − inc]
j ← j − inc
a[j] ← temp
inc ← round(inc / 2.2)

 

冒泡排序:

BubbleSort(input ele[],input length)

for i <- 1 to length step 1

for j <- i+1 to 0 step -1

if ele[j] < ele [j – 1]

swap (ele[j],ele[j – 1])

end if

end

end

 

快速排序:

if low<high    then

SPLIT(A[low…high],w)    //w为A[low]的新位置

quicksort(A,low,w-1)

quicksort(A,w+1,high)

end if

 

选择排序:

for  i←1 to n-1

k←i       //设立标志位

for j←i+1 to n     //查找第i小的元素

if A[j]<A[k]    then k←j

end for

if k≠i    then 交换A[i]与A[k]

end for

 

堆排序:

1.下标计算[为与程序对应,下标从0开始]

Parent(i)://为了伪代码描述方便

returni/2

Left(i):

return2*i+1

Right(i):

return2*i+2

2.使下标i元素为根的的子树成为最大堆

MAX_HEAPIFY(A,i):

l<——Left(i)

r<——Right(i)

ifl<length(A)andA[l]>A[i]

thenlargest<——l

elselargest<——i

ifr<length(A)andA[r]>A[largest]

thenlargest<——r

iflargest!=i

thenexchangeA[i]<——>A[largest]//到这里完成了一层下降

MAX_HEAPIFY(A,largest)//这里是递归的让当前元素下降到最低位置

3.最大堆的建立,将数组A编译成一个最大堆

BUILD_MAX_HEAP(A):

heapsize[A]<——length[A]

fori<——length[A]/2+1  to0

MAX_HEAPIFY(A,i)//堆排序的开始首先要构造大顶堆,这里就是对内层节点进行逐级下沉(小元素)

4.堆排序

HEAP_SORT(A):

BUILD_MAX_HEAP(A)

fori<——length[A]-1to1//这里能够保证堆大小和数组大小的关系,堆在每一次置换后都减一

doexchangeA[1]<——>  A[i]

length[A]<——length[A]-1

MAX_HEAPIFY(A,0)//对交换后的元素下沉

 

归并排序:

mergesort(A,1,n)

if low<high    then

mid←⌊(low+high)/2⌋

mergesort(A,low,mid)

mergesort(A,mid+1,high)

MERGE(A,low,mid,high)

end if

 

MERGE()

//B[p…r]是个辅助数组

s←p;t←q+1;k←p

while s≤q and t≤r

if A[s]≤A[t]    then

B[k]←A[s]

s←s+1

else

B[k]←A[t]

t←t+1

end if

k←k+1

end while

if s=q+1    then B[k…r]←A[t…r]

else B[k…r]←A[s…q]

end if

A[p…r]←B[p…r]

系统实现

函数功能:

(1)void adjust(int arr[], int len, int index) // 递归方式构建大根堆(len是arr的长度,index是第一个非叶子节点的下标)

(2)void bubbleSort(int nums[],int n) //冒泡排序算法

(3)bool comp(const mysort &a,const mysort &b)//结构体的排序函数

(4)string doubleToString(double num)//double类型转string类型函数

(5)void heapSort(int nums[],int n) //堆排序算法

(6)void insertSort(int nums[],int n) //插入排序算法

(7)void makeRandNumber()//生成20000个随机数放在项目目录的number.txt下

(8)void Merge(int A[], int start, int mid, int step, int n)//归并排序算法

(9)void mergeSort(int nums[],int n) //递归进行归并排序

(10)void mouseListener(int x , int y ,int button ,int event){//监听函数,用来判断鼠标点击

(11)void quickSort(int b, int e, int a[])//快速排序算法

(12)void readNums(int nums[]) //读取文件中的数字到数组中

(13)void selectionSort(int nums[],int n) //选择排序算法

(14)int Setup()//ACLLib图形库进入Win32程序的入口函数

(15)void shellSort(int nums[],int n) //希尔排序算法

(16)void showIndex()//画出界面的首页

算法:

  1. 插入排序

思想:

每次选择一个元素K插入到之前已排好序的部分A[1…i]中,插入过程中K依次由后向前与A[1…i]中的元素进行比较。若发现发现A[x]>=K,则将K插入到A[x]的后面,插入前需要移动元素。

时间复杂度

最好的情况下:正序有序(从小到大),这样只需要比较n次,不需要移动。因此时间复杂度为O(n)

最坏的情况下:逆序有序,这样每一个元素就需要比较n次,共有n个元素,因此实际复杂度为O(n2)

平均情况下:O(n2)

稳定性: 

理解性记忆比死记硬背要好。因此,我们来分析下。稳定性,就是有两个相同的元素,排序先后的相对位置是否变化,主要用在排序时有多个排序规则的情况下。在插入排序中,K1是已排序部分中的元素,当K2和K1比较时,直接插到K1的后面,没有必要插到K1的前面,因为这样做还需要移动。因此,插入排序是稳定的。

实现代码:

void insertSort(int nums[],int n) //插入排序

{

//从第二个元素开始,加入第一个元素是已排序数组

for (int i = 1; i < n; i++)

{

//待插入元素 nums[i]

if (nums[i] < nums[i – 1])

{

int wait = nums[i];

int j = i;

while (j > 0 && nums[j – 1] > wait)

{

//从后往前遍历已排序数组,若待插入元素小于遍历的元素,则遍历元素向后挪位置

nums[j] = nums[j – 1];

j–;

}

nums[j] = wait;

}

}

}

 

  1. 希尔排序

思想:

希尔排序也是一种插入排序方法,实际上是一种分组插入方法。先取定一个小于n的整数d1作为第一个增量,把表的全部记录分成d1个组,所有距离为d1的倍数的记录放在同一个组中,在各组内进行直接插入排序;然后,取第二个增量d2(<d1),重复上述的分组和排序,直至所取的增量dt=1(dt<dt-1<…<d2<d1),即所有记录放在同一组中进行直接插入排序为止。

时间复杂度: 

最好情况:由于希尔排序的好坏和步长d的选择有很多关系,因此,目前还没有得出最好的步长如何选择(现在有些比较好的选择了,但不确定是否是最好的)。所以,不知道最好的情况下的算法时间复杂度。

最坏情况下:O(n*logn),最坏的情况下和平均情况下差不多。

平均情况下:O(N*logn),实际约为O(n(1.3—2))。

稳定性:

由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

实现代码:

void shellSort(int nums[],int n) //希尔排序

{

int i, j;

int increment = n;

do

{

increment = increment / 3 +1;

for (i = increment ; i <= n – 1; i++)

{

if (nums[i] < nums[i – increment])

{

int tmp;

tmp = nums[i];

for (j = i – increment; j >= 0 && tmp < nums[j]; j -= increment)

nums[j + increment] = nums[j];

nums[j + increment] = tmp;

}

}

}

while (increment > 1);

}

 

  1. 冒泡排序

思想:

通过无序区中相邻记录关键字间的比较和位置的交换,使关键字最小的记录如气泡一般逐渐往上“漂浮”直至“水面”。

时间复杂度: 

最好情况下:正序有序,则只需要比较n次。故,为O(n)。

最坏情况下:  逆序有序,则需要比较(n-1)+(n-2)+……+1,故,为O(N*N)。

稳定性:

排序过程中只交换相邻两个元素的位置。因此,当两个数相等时,是没必要交换两个数的位置的。所以,它们的相对位置并没有改变,冒泡排序算法是稳定的。

实现代码:

void bubbleSort(int nums[],int n) //冒泡排序

{

for(int i = 0; i<n-1; i++)

{

for(int j = i+1; j<n; j++)

{

if(nums[i]>nums[j])

{

swap(nums[i],nums[j]);

}

}

}

}

 

  1. 快速排序

思想:

它是由冒泡排序改进而来的。在待排序的n个记录中任取一个记录(通常取第一个记录),把该记录放入适当位置后,数据序列被此记录划分成两部分。所有关键字比该记录关键字小的记录放置在前一部分,所有比它大的记录放置在后一部分,并把该记录排在这两部分的中间(称为该记录归位),这个过程称作一趟快速排序。

时间复杂度:

最好的情况下:因为每次都将序列分为两个部分(一般二分都复杂度都和logN相关),故为 O(N*logN)。

最坏的情况下:基本有序时,退化为冒泡排序,几乎要比较N*N次,故为O(N*N)。

稳定性:

由于每次都需要和中轴元素交换,因此原来的顺序就可能被打乱。如序列为 5 3 3 4 3 8 9 10 11会将3的顺序打乱。所以说,快速排序是不稳定的。

实现代码:

void quickSort(int b, int e, int a[])

{

int i = b, j = e;  //i是数组的头,j是数组的尾

int x = a[(b + e) / 2]; //选取数组的中间元素作为划分的基准

while (i<j)

{

while (a[i]<x) i++;

while (a[j]>x) j–;

if (i <= j)

std::swap(a[i++], a[j–]);

}

if (i<e)

quickSort(i, e, a);

if (j>b)

quickSort(b, j, a);

}

 

  1. 选择排序

思想:

首先在未排序序列中找到最小元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小元素,然后放到排序序列末尾。以此类推,直到所有元素均排序完毕。具体做法是:选择最小的元素与未排序部分的首部交换,使得序列的前面为有序。

时间复杂度:

最好情况下:交换0次,但是每次都要找到最小的元素,因此大约必须遍历N*N次,因此为O(N*N),减少了交换次数。

最坏情况下,平均情况下:O(N*N)。

稳定性:

由于每次都是选取未排序序列A中的最小元素x与A中的第一个元素交换,因此跨距离了,很可能破坏了元素间的相对位置,因此选择排序是不稳定的。

实现代码:

void selectionSort(int nums[],int n) //选择排序

{

int i, j, minn;

for (i = 0; i < n – 1; i++)

{

minn = i;                       //记录最小值的下标,初始化为i

for (j=i+1; j<=n-1; j++)

{

if (nums[j] < nums[minn])

minn = j;            //通过不断地比较,得到最小值的下标

}

swap(nums[i], nums[minn]);

}

}

 

  1. 堆排序

思想:

利用完全二叉树中双亲节点和孩子节点之间的内在关系,在当前无序区中选择关键字最大(或者最小)的记录。也就是说,以最小堆为例,根节点为最小元素,较大的节点偏向于分布在堆底附近。

时间复杂度:

最坏情况下,接近于最差情况下:O(N*logN),因此它是一种效果不错的排序算法。

稳定性:

堆排序需要不断地调整堆,因此它是一种不稳定的排序。

实现代码:

// 递归方式构建大根堆(len是arr的长度,index是第一个非叶子节点的下标)

void adjust(int arr[], int len, int index)

{

int left = 2*index + 1; // index的左子节点

int right = 2*index + 2;// index的右子节点

 

int maxIdx = index;

if(left<len && arr[left] > arr[maxIdx])     maxIdx = left;

if(right<len && arr[right] > arr[maxIdx])     maxIdx = right;

 

if(maxIdx != index)

{

swap(arr[maxIdx], arr[index]);

adjust(arr, len, maxIdx);

}

 

}

 

void heapSort(int nums[],int n) //堆排序

{

// 构建大根堆(从最后一个非叶子节点向上)

for(int i=n/2 – 1; i >= 0; i–)

{

adjust(nums, n, i);

}

// 调整大根堆

for(int i = n – 1; i >= 1; i–)

{

swap(nums[0], nums[i]);           // 将当前最大的放置到数组末尾

adjust(nums, i, 0);              // 将未完成排序的部分继续进行堆排序

}

}

 

  1. 归并排序

思想:

多次将两个或两个以上的有序表合并成一个新的有序表。

时间复杂度:

最好的情况下:一趟归并需要n次,总共需要logN次,因此为O(N*logN) 。

最坏的情况下,接近于平均情况下,为O(N*logN) 。

说明:对长度为n的文件,需进行logN 趟二路归并,每趟归并的时间为O(n),故其时间复杂度无论是在最好情况下还是在最坏情况下均是O(nlgn)。

稳定性:

归并排序最大的特色就是它是一种稳定的排序算法。归并过程中是不会改变元素的相对位置的。

实现代码:

void Merge(int A[], int start, int mid, int step, int n)

{

int rightLen = 0;

if (mid + step > n)

{

rightLen = n – mid;

}

else

{

rightLen = step;

}

//申请空间存放临时结果

int *tempArray = new int[step + rightLen]();

int i = 0, j = 0, k = 0;

while (i < step && j < rightLen)

{

if (A[start + i] < A[mid + j])

{

tempArray[k++] = A[start + i];

i++;

}

else

{

tempArray[k++] = A[mid + j];

j++;

}

}

//如果左边没有归并完,那么直接将剩余的元素复制到tempArray的后面

if (j == rightLen)

{

memcpy(tempArray + i + j, A + start + i, (step – i) * sizeof(int));

}

//如果右边没有归并完,那么直接将剩余的元素复制到tempArray的后面

else if (i == step)

{

memcpy(tempArray + i + j, A + mid + j, (rightLen – j) * sizeof(int));

}

//将临时数组中排好序的内容复制回原数组

memcpy(A + start, tempArray, (step + rightLen) * sizeof(int));

delete[] tempArray;

}

 

void mergeSort(int nums[],int n) //归并排序

{

for (int step = 1; step < n; step *= 2)

{

for (int i = 0; i < n – step; i += 2*step)

{

Merge(nums ,i, i+step, step, n);

}

}

}

最后,排序其实是有改进方法的,最近研究了一下。

快速排序主要有以下几种改进方法:

快速排序基本思路:找到一个标定点,左边的元素小于标定点,右边元素大于标定点,然后再对左右区间递归快速排序。

改进1:如果数组近乎有序,标定点的选择会影响快速排序的性能,如果每次都选择最小值作为标定点,快速排序时间复杂度会退化为O(N*N),因此需要随机化标定点元素。

改进2:如果数组有大量相同元素,上面的做法会将相同元素分到大于v的区间,会造成两个子区间元素不平衡,所以需要将相等元素平衡地分入两个区间。

改进3:可以改成三路快速排序算法。

当然,在我们程序中排名最慢的冒泡排序也是可以优化的,我们都知道,当一个序列已经有序的情况下,冒泡排序往往会做很多不必要的重复的比较,在此基础上我们能够优化一下。

优化:设置一个标志符flag,flag初始值是0,如果在一趟遍历中有出现元素交换的情况,那就把flag置为1。一趟冒泡结束后,检查flag的值。如果flag依旧是0,那这一趟就没有元素交换位置,说明数组已经有序,循环结束。

用户手册

首先运行我们的程序,界面如下:

分别用鼠标左键单击插入排序、希尔排序、冒泡排序、快速排序、选择排序、堆排序、归并排序,界面会显示出我们执行相应的算法所花费的时间,如图所示:

与此同时,我们的项目目录下会多出一些文本文件,这些文本文件分别是各种排序算法的结果,我们用文本将结果保存下来,如图所示:

最后,我们点击排名,即可看到各种算法的性能比较排名,如图所示:

排序是软件设计中最常用的运算之一,有多种不同的算法,每种算法各有其特定性和最合适的适用范围。因此,了解这些特性对于实际应用时选择最恰当算法是软件设计中的重要技术。通过本次课程设计,我体会到了各种算法的性能特点,包括时间性能、空间性能以及排序的稳定性。同时,通过实验的方法来分析算法的各种性能是计算机科学与技术领域重要的手段,是研究各类问题求解的新算法所必需的技术,应引起足够的重视,可以看出在数据规模达到一定数目时,我们的冒泡排序算法相对于其他算法所花费的时间越来越多,由此可见算法的性能十分重要。经过测试我们发现在大部分情况下快速排序的效率都是极其高的,但当测试的数据为有序的时候,快排的效率便会降低,并且有可能造成栈溢出等问题。

当然,这里提一下我对排序算法的稳定性的理解,首先,排序算法的稳定性大家应该都知道,通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。在简单形式化一下,如果Ai = Aj, Ai原来在位置前,排序后Ai还是要在Aj位置前。其次,说一下稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。另外,如果排序算法稳定,对基于比较的排序算法而言,元素交换的次数可能会少一些。

最后,在这次课程设计中,我简单的学习了一下图形化编程,受益匪浅,程序不再由黑色的命令行框框组成,而是有趣的图形化界面,增加了我对于编程的兴趣,最后,我们的排序算法还有一些可以优化的地方,我会慢慢学习改进这些排序算法。

在这里我们研究学习的全部都是内部排序,那么外部排序是否也类似呢?在程序的世界里,我们往往在做一些分而治之的事情,通过对内部排序的初步学习和认识,我对外部排序也产生了浓烈的兴趣。

学习和生活中有好多地方都设计排序的问题,学好排序的相关知识很有用。

源代码:maplefan/sortCompare