行行宜行行

热爱风光、热爱新技术、踏出每一步,过好每一天!

排序算法

排序算法是我们会遇到的最基本的算法之一,算法分为了内部排序和外部排序。所谓内部排序就是数据记录在内存并且进行排序,外部排序就是因为排序的数据量太大了,不能全都在内存中完成,需要访问磁盘等工具进行辅助。

内部排序

常见的内部排序算法有:冒泡排序、选择排序、插入排序、快速排序、堆排序、希尔排序

外部排序

外部排序有:归并排序、桶排序、基数排序

冒泡排序

原理

简单来说,冒泡排序就是挨个比较相邻的两个元素,如果前面的元素大于后面的元素则交换他们的位置,每一轮比较都会把当前最大的数移动到末尾,完成一轮之后重复以上步骤,并且每次排除最后一个元素缩小范围。

实现

/**
 * @name  BubbleSort()
 * @param  std::vector<int>& nums
 *      两个数挨个对比,然后将较小的数放到前面,大的放在后面,即交换位置
 *      时间复杂度O(n^2)  空间复杂度O(1)
 * */
void BubbleSort(std::vector<int> nums)
{
    for(int i = 0; i < nums.size() - 1; i++)
    {
        for(int j = 0; j < nums.size() - 1 - i; j++)
        {
            if(nums[j] > nums[j + 1])  // 前一个数比后一个数大
            {
                // 这里也可以直接 swap(),一样的思想
                int temp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = temp;
            }
        }
    }
    std::cout << "after BubbleSort " << std::endl;
    show(nums);
}

其中show()只是辅助函数,打印出数组元素

复杂度分析

每一次比较都会遍历整个数组,并且会比较直到最后一个元素,所以时间复杂度应该是O(n^2),没有使用外部空间,空间复杂度为O(1)

优缺点

优点:实现非常简单,大家都可以很容易就理解了,并且是原地排序,不需要额外开辟空间

缺点:效率不高,并且如果已经接近有序的原始数组,会进行多次的无效比较

当输入数据是从大到小依次排列的时候,效率是最慢的,要把所有的元素位置翻转。

选择排序

原理

选择排序就是每次选择一个数(我们可以默认选择第一个数)作为最大或者最小(实际可以看需要),后面的数依次跟选择的这个数进行比较,满足对应条件也是进行交换操作,循环直到全部排序完成。

实现

/**
 * @name  ChoiceSort()
 * @param  std::vector<int>& nums
 * 选择排序
 *      将第一个当作最大或者最小,以此用后面的元素与第一个进行比较,如果发现更小或者更大的就交换两数
 *      时间复杂度O(n^2)  空间复杂度O(1)
 * */
void ChoiceSort(std::vector<int> nums)
{
    for(int i = 0; i < nums.size(); i++)
    {
        int minVal = nums[i];
        int index = i;		
        for(int j = i + 1; j < nums.size(); j++)
        {
            if(nums[j] < minVal)
            {
                minVal = nums[j];
                index = j;
            }
        }
        // 交换位置
        // swap(nums[index], nums[i]);
        int temp = nums[index];
        nums[index] = nums[i];
        nums[i] = temp;
    }
    std::cout << "after ChoiceSort " << std::endl;
    show(nums);
}

复杂度分析

选择排序其实基本上也会每次遍历整个数组,所以时间复杂度也是O(n^2),空间复杂度是O(1)。

优缺点

选择排序的实现方式比较简单,并且是原地排序,不需要额外空间,但是时间复杂度太高,不适合大规模的数据,同时也是不稳定的排序算法。

适合于数据较少,性能要求也不高的场景。

插入排序

插入排序建立在部分有序的序列基础之上(一个元素也是有序的),然后后面那些未排序的元素,会在已排序的序列上从后往前依次扫描,找到了相对应的位置就插入。

主要步骤

  • 首先将序列划分为已排序和未排序状态,可以取第一个元素为已排序序列,其余的为未排序序列;
  • 从未排序的序列取出第一个元素
  • 在已排序序列上,从后往前依次比较,找到第一个比这个未排序序列中取出的这个数小的元素,然后插入到其后面
  • 重复直到全部完成排序

实现

/**
 * @name  InsertSort()
 * @param  std::vector<int>& nums
 * 插入排序
 *      假定前面有一个有序序列,后面的数依次插入到第一个比该数小的数之后
 *      时间复杂度O(n^2)  空间复杂度O(1)
 * */
void Insert(std::vector<int>& nums) {
    for (int i = 1; i < nums.size(); i++) {
        if (nums[i] < nums[i-1]) {
            int val = nums[i];
            int j = i - 1;
            // 在已经排序的序列中搜索
            for (; j >= 0 && nums[j] >= val; j--) {
                nums[j + 1] = nums[j];	// 将找到正确的插入位置前遇到的已经排序的数据往后挪
            }
            nums[j + 1] = val;
        }
    }
}


复杂度分析

插入排序适合数据已经趋于有序的情况,在这种情况排序效率可以趋于线性排序。时间复杂度是O(n^2),空间复杂度为O(1)

插入排序算法是稳定的不会改变相同数据的相对位置,可以作为一些复杂算法的辅助算法,比如std::sort()底层就使用了插入排序作为辅助排序方式。

希尔排序

原理

如果数据趋于有序,插入排序是所有排序算法中,效率最高的排序算法,但是插入排序每次都是移动一个数据,所以在插入排序的基础上改进得到了希尔排序。

希尔排序将整个序列利用分治的思想,划分为多个子序列,分别进行插入排序,然后对整体的序列进行直接插入排序。

步骤

  1. 选择一个增量将原始列表分为若干个子列表,初始增量一般为(n/2)
  2. 分组进行插入排序
  3. 逐步缩小增量,重复分组和排序过程,直到增量为1
  4. 对整个序列进行插入排序

实现

/**
 * @name  ShellSort()
 * @param  std::vector<int>& nums
 * 希尔排序
 *      如果数据趋于有序,插入排序是所有排序算法中,效率最高的排序算法---> 插入排序的思想
 *      那么希尔排序就是插入排序的基础上做优化:即将数据尽可能在全局趋于有序
 *      方法:  分组进行插入排序
 *              二分缩小范围   初始gap = size / 2, 之后每一次分组 gap /= 2 直到 gap = 0退出
 *      时间复杂度O(n^2)  空间复杂度O(1)
 * */
void ShellSort(std::vector<int>& nums) {
    // 在插入排序基础上划分gap
    foe (int gap = nums.size(); gap > 0; gap / 2) {
        for (int i = gap; i < nums.size(); i += gap) {
            if (nums[i] < nums[i-gap]) {
                int val = nums[i];
                int j = i - gap;
                foe (; j >= 0 && nums[j] >= val; j -= gap) {
                    nums[j + gap] = nums[j];
                }
                nums[j + gap] = val;
            }
        }
    }
}

复杂度分析

希尔排序的时间复杂度取决于选取的增量序列,如果是在最坏的情况下,时间复杂度为O(n^2),最好的情况下是O(nlogn),平均下来就在这两者之间。

希尔排序没有开辟额外的空间,所以空间复杂度是O(1)

优缺点

相对于直接插入排序,效率更高,适用更大规模的数据集,也不需要开辟额外的内存空间,但是依赖于增量序列的选择,这也是一种不稳定的排序算法。

快速排序

原理

快速排序是一种基于分治思想的排序算法,核心原理是通过选择一个基准元素,然后将列表分为大于基准和小于基准的序列,再递归对这两部分排序

步骤

  1. 从列表中选择一个元素作为基准,一般可以选择第一个元素、中间元素、随机元素或者最后一个元素。
  2. 将列表重新排列,使得所有小于基准的元素在基准的左侧,所有大于基准元素的元素都在基准右侧,在每轮排列之后基准元素就是空出来的位置
  3. 对基准元素左侧和右侧的子序列进行递归快速排序
  4. 最后将所有的子序列进行合并

实现

/**
 * @name  FastSort()
 * @param  std::vector<int>& nums
 * 快速排序    二分+递归的思想
 *      1、找到一个基准点L = 0, R = nums.size() - 1  val = nums[L]
 *      2、从R开始向前找到第一个小于val的值,放到L处, L++
 *      3、从L开始向后找到第一个大于val的值,放到R处,R--
 *      4、循环重复 2 、3
 * */
int pos(std::vector<int>& nums, int left, int right) {
    // 选择基准点
    int val = nums[left];
    while(left < right) {
        // 从右往左找到第一比基准数小的,放到left位置
        while (left < right && nums[right] > val) {
            right--;
        }
        // 此时已经找到了第一个比基准数小的元素
        if (left < right) {
            nums[left++] = nums[right];
        }
        
        // 现在开始步骤三
        while (left < right && nums[left] < val) {
            left++;
        }
        if (left < right) {
            nums[right--] = nums[left];
        }
    }
    nums[left] = val;
    return left;
}

// 快速排序辅助函数
void subFastSort(std::vector<int>& nums, int left, int right) {
    if (left >= right) {
        return ;
    }
    
    int postion = pos(nums, left, right);
    subFastSort(nums, left, pos - 1);
    subFastSort(nums, pos + 1, right);
}

void FastSort(std::vector<int>& nums) {
    subFastSort(nums, 0, nums.size() - 1);
}

复杂度分析

其实采用的二分思想,递归的层数就是O(logn),需要O(n)的时间来合并排序后的子列表,所以时间复杂度应该是O(nlogn),但是在最坏的情况下会退化到O(n^2).

以上都是在同一个数组中实现的,所以原地排序,空间复杂度为O(1)

优缺点

快速排序的核心是选择基准元素,将序列划分为 “小于基准”“等于基准”“大于基准” 三部分,但划分过程中可能破坏相等元素的相对顺序,所以是一种不稳定的算法。

优点是很快,适合大规模数据

归并排序

原理

归并排序采用分治思想将一个问题划分为多个问题,然后分别进行解决最后汇总起来。

所以归并排序首先会将序列进行分组,然后对分组之后的子序列进行递归排序,最后再进行合并。

步骤

  1. 首先利用二分法进行分组递归,递归终止条件是遇到叶子节点:即 left >= right
  2. 左右两边的子序列再递归
  3. 在归的过程中进行合并排序,

实现

/**
 * @name  MergeSort(std::vector<int>& nums)                                                     归并排序主接口
 *        SubMergeSort(std::vector<int>& nums, int left, int right, std::vector<int>& p)        递归过程接口
 *        Merge(std::vector<int>& nums, int left,int mid,  int right, std::vector<int>& p)      归排序过程接口
 * @param  std::vector<int>& nums
 * 归并排序
 *      1、在递归的过程中进行数据合并与排序,首先利用二分法进行分组递归,递归终止条件是遇到叶子节点:即  left >= right
 *      2、左右分别进行递归
 *      3、在归的过程中进行合并排序,即开辟一块新内存,对左右子树的数据进行比较,从小到大依次放入新开辟的空间,然后将此空间中排序后的元素拷贝回原始数组中[left, right]区间内
 * */
void  Merge(std::vector<int>& nums, int left,int mid,  int right, std::vector<int>& p) {
    // 此时左右两边子树分别是已经有序了,现在需要进行合并,那么就是比较这俩子树的大小,然后依次添加到p数组中
    int index = 0, i = left, j = mid + 1;
    while (i <= mid && j <= right) {
        // 分别开始遍历添加到p数组中
        if (nums[i] < nums[j]) {
            p[index++] = nums[i];
        } else {
            p[index++] = nums[j];
        }
    }
    
    // 若左边子树还有剩余,则将其直接添加到p数组末尾
    while (i <= mid) {
        p[index++] = nums[i];
    }
    while (j <= right) {
        p[index++] = nums[j];
    }
    
    // 将p数组中的元素拷贝回nums数组
    for (j = 0, i = left; i <= right; i++, j++) {
        nums[i] = p[j];
    }
}


void SubMergeSort(std::vector<int>& nums, int left, int right, std::vector<int>& p) {
    // 递归终止条件
    if (left >= right) {
        return;
    }
    
    int mid = left + (right - left) / 2;
    // 左右子树递归
    SubMergeSort(nums, left, mid, p);
    SubMergeSort(nums, mid + 1, right, p);
    Merge(nums, left, mid, right, p);
}

void MergeSort(std::vector<int>& nums)  {
    std::vector<int>& p(nums.size());
    SubMergeSort(nums, 0, nums.size() - 1, p);
}

复杂度分析

整体的时间复杂度是O(ologn),情况与快排类似。

因为开辟了额外的数组p,所以空间复杂度为O(n)

优缺点

适合大规模排序,同时归并排序也可以适用于外部排序,当海量数据时,利用分治思想,分别处理,然后合并。

稳定的排序算法

堆排序

堆排序是一种基于堆这种数据结构的排序算法,利用了堆的特性实现排序。

这里先介绍一下堆

堆其实是一颗特殊的完全二叉树,它的主要性质是:子树的键值总是小于(大于)父节点的键值,所以就有两种堆,分别是:

  • 大顶堆:每个父节点的值 ≥ 子节点的值(根节点是最大值)
  • 小顶堆:每个父节点的值 ≤ 子节点的值(根节点是最小值)。

堆通常都是使用数组实现的,对于下标为i的节点,它的左右节点以及父节点的坐标分别是:

  • 父节点:(i-1) // 2
  • 左节点:2*i+1
  • 右节点:2*i+2

堆的实现

堆就是一个优先级队列

class PriorityQueue {
public:
    using Comp = std::function<bool(int, int)>;    

    PriorityQueue(int cap = 20,
                  Comp comp = std::greater<int>() /*默认使用从大到小,也可以自己传lambda表达式表示排列顺序*/);

    // 重载,用户只需要传入比较规则
    PriorityQueue(Comp comp);

    // 析构函数
    ~PriorityQueue();

public:
    // 以下是一些公共接口,比如 push pop top empty size等
    void push(int val);

    void pop();

    bool empty() const;

    int size() const;

    int top();

private:
    // 上浮调整
    void ShiftUp(int i, int val);

    // 下沉调整
    void ShiftDown(int i, int val);

private:
    int *que_;                  // 可动态扩容的数组
    int size_;                  // 数组中的实际元素个数
    int cap_;                   // 数组的最大容量
    Comp comp_;                 // 比较器
};

首先是构造与析构,都是开辟基础的空间,然后初始化一些变量

PriorityQueue::PriorityQueue(int cap, Comp comp) : cap_(cap) , size_(0), comp_(comp) {
    que_ = new int[cap_];
}

PriorityQueue::PriorityQueue(Comp comp) : cap_(20) , size_(0), comp_(comp) {
    que_ = new int[cap_];
}

// 释放堆上开辟的数组空间
PriorityQueue::~PriorityQueue() {
    delete[]que_;
    que_ = nullptr;
}

压入队列与弹出

入队前首要判断队列是否已满,需要扩容,出队前判断队列是否为空,避免在空地址上进行操作,造成未定义行为

void PriorityQueue::push(int val) {
    // 判断数组是否已满,是否需要扩容
    if (size_ == cap_) {
        int *p = new int[2 * cap_];
        // void * __cdecl memcpy(void * __restrict__ _Dst,const void * __restrict__ _Src,size_t _Size)
        memcpy(p, que_, cap_ * sizeof(int));	// 拷贝原来的数据
        delete que_;
        que_ = p;
        cap_ = 2 * cap_;
    }

    // 如果只有一个元素,不需要上浮调整
    if (size_ == 0) {
        que_[size_] = val;
    } else {
        // 进行上浮操作
        ShiftUp(size_, val);
    }
    size_++;
}

void PriorityQueue::pop() {
    if (size_ == 0) {
        // 数组为空,弹出元素,抛出异常
        throw "que_ size_ = 0!";
    }

    size_--;
    // 如果还有元素,需要调整
    if (size_ > 0) {
        // 删除堆顶元素,还有剩余的元素,要进行堆的下沉调整
        ShiftDown(0, que_[size_]);
    }
}

上浮:当向堆中插入新元素时,将元素放在堆的末尾,然后通过 “上浮” 调整,使其移动到符合堆特性的位置,保证堆的结构不被破坏。当新元素比父节点大时上浮

// 上浮调整:将下标i处的元素(值为val)向上调整到正确位置
void PriorityQueue::ShiftUp(int i, int val) {
    // 前提是不能超过根节点(i=0时为根,无需上浮)
    while (i > 0) {
        int father = (i - 1) / 2;  // 计算父节点下标(完全二叉树的性质)
        
        // 比较当前元素与父节点的大小(通过比较器comp_)
        if (comp_(val, que_[father])) {
            // 若满足比较条件(例如:val > 父节点值,对于大顶堆)
            // 则父节点下移(腾出位置给当前元素)
            que_[i] = que_[father];
            i = father;  // 更新当前元素位置为父节点位置,继续向上比较
        } else {
            // 若不满足条件,说明当前位置已符合堆特性,停止上浮
            break;
        }
    }
    // 循环结束后,i即为当前元素的正确位置,放入val
    que_[i] = val;
}

下沉调整:

当堆顶元素被移除(或替换)后,将末尾元素移到堆顶,然后通过 “下沉” 调整,使其移动到符合堆特性的位置,保证堆的结构不被破坏。

// 下沉调整:将下标i处的元素(值为val)向下调整到正确位置
void PriorityPriorityQueue::ShiftDown(int i, int val) {
    // 循环条件:i必须是非叶子节点(叶子节点无需下沉)
    // size_/2 是最后一个非叶子节点的下标(完全二叉树的性质)<---> i <= (size_ - 1 - 1)/2
    while (i < size_ / 2) {
        // 左孩子下标(默认与左孩子比较)
        int child = 2 * i + 1;
        
        // 若右孩子存在,且右孩子更符合堆特性(通过comp_判断),则选择右孩子
        if (child + 1 < size_ && comp_(que_[child + 1], que_[child])) {
            child = child + 1;  // 切换到右孩子
        }
        
        // 比较当前元素与选中的孩子节点
        if (comp_(que_[child], val)) {
            // 若孩子节点更符合堆特性(例如:孩子 > 当前值,对于大顶堆)
            // 则孩子节点上移
            que_[i] = que_[child];
            i = child;  // 更新当前元素位置更新为孩子节点位置,继续向下比较
        } else {
            // 若当前位置已符合堆特性,停止下沉
            break;
        }
    }
    // 循环结束后,i即为当前元素的正确位置,放入val
    que_[i] = val;
}

以上两个辅助函数的关键在于:优先选择更符合堆性质的孩子节点,确保堆的性质不被破坏,通过比较器 comp_ 灵活支持大顶堆和小顶堆,通过数组下标计算父子节点位置,维护堆的特性

这两个操作的时间复杂度均为 O(log n)(堆的高度为 log n),因此基于堆的优先队列的插入和删除操作也都是 O (log n) 级别的操作

堆排序

核心思想

  1. 构建堆:将无序数组转换为大顶堆(或小顶堆)。

  2. 交换与调整

    • 将堆顶元素(最大值)与末尾元素交换,此时末尾为有序区。
    • 缩小堆的范围(排除已排序的末尾元素),重新调整剩余元素为大顶堆。
  3. 重复步骤 2:直到整个数组有序。

完整实现

/**
 * @name  HeapSort(std::vector<int>& nums)                                                     堆排序主接口
 *        SiftDown(std::vector<int>& nums, int i, int size_)                                   下沉过程接口
 *
 * @param  std::vector<int>& nums
 * 堆排序
 *     1、从第一个非叶子结点开始,将二叉堆调整为一个最大堆
 *     2、将堆顶元素和最后一个元素交换,依次下沉调整,每次比较的时候除去最后的元素
 *
 *
 * */
// 下沉调整
void SiftDown(std::vector<int>& nums, int i, int size)
{
    int val = nums[i];
    while(i < size / 2 /*i <= (size_ - 1 - 1)/2*/)
    {
        // 找到其左右孩子节点的下标
        int child = 2 * i + 1;   // 默认左孩子,后续比较左右孩子的大小,更新较大的孩子的下标
        if( child + 1 < size && nums[child + 1] > nums[child])
        {
            // 此时说明右边孩子比左边孩子大
            child = child + 1;
        }

        // 接下来比较孩子节点和调整节点的值
        if(nums[child] > val)
        {
            nums[i] = nums[child];
            i = child;
        }else{
            break;
        }
    }
    nums[i] = val;
}

void HeapSort(std::vector<int>& nums)
{
    int n = nums.size() - 1;
    // 对第一个非叶子节点开始调整为最大堆
    for(int i = (n - 1)/2; i >= 0; i--)
    {
        SiftDown(nums, i, nums.size());
    }

    // 将堆顶元素与末尾元素进行交换,从堆顶开始进行下沉操作
    for(int i = n; i > 0; i--)
    {
        swap(nums[0], nums[i]);		
        // 进行下沉操作
        SiftDown(nums, 0, i);
    }
}

时间复杂度分析

构建堆的过程为O(n),但是调整堆的位置的时间复杂度为O(logn),一共需要调整n-1次,所以一共是O(ologn),所以总的时间复杂度为O(nlogn);

堆排序属于原地排序,时间复杂度为O(1);但是不是一个稳定的排序算法。

适用于数据量特别大的场景,外部排序

 

Tags:

发表回复

Your email address will not be published. Required fields are marked *.

*
*

近期评论

您尚未收到任何评论。

conviction

要想看到璀璨的日出时刻,就要有跋山涉水的决心和坚毅