认识数据结构之——排序

一、 插入排序:

直接插入排序(以排升序为例):

排序思想:

  1. 单趟:记录某个位置的值,一个一个和前面的值比较,碰到更大的就往后覆盖,碰到更小的或者相等的就结束,最后将记录的值插入到正确的位置。
  2. 多趟:让索引从第二个元素的位置开始往后迭代(因为一个元素本身不用讨论有序性),记录当前索引的值,一个一个和前面的值比较,碰到更大的就让他往后覆盖,碰到相等的或者更小的就结束循环并将之前记录的值覆盖当前位置。也就是说:每趟排序最后都能确保当前位置以及之前的序列是有序的,而当完成了最后一趟排序之后,最后一个位置以及之前的序列是有序的,这样也就完成的排序。这是一个始终把数组划分为两部分-----左边有序,右边无序-----的过程。

代码实现的细节:

  1. 最外层的for循环从1开始,因为下标为0的第一个元素不用单独关注有序性。
  2. 在每一趟里,和之前的数比较的过程中要使用最开始记录的值而不是下标访问数组,因为起始下标end会改变,而起始下标所指的值val不会变。

代码实现:

cpp 复制代码
//直接插入排序
void InsertSort(int* arr, int size)
{
	for (int i = 1; i < size; i++) //单个元素不讨论有序性,所以从第二个数开始
	{
		int end = i; //起始索引
		int val = arr[end]; //记录起始索引指向的值
		while (end > 0)
		{
			if (val < arr[end - 1])  //用val而不是arr[end]来比较,因为arr[end]
                                     //在后续会被覆盖
			{
				arr[end] = arr[end - 1];
				end--;
			}
			else
			{
				break;
			}
		}
		arr[end] = val;  //此时end来到了正确的位置,直接覆盖
	}
}

希尔排序(以排升序为例):

希尔排序产生的必要性:

思考这样一种情况:我们使用插入排序,期望把一组数排为升序,可这组数本身是降序或者接近逆序的,就会导致其效率直勾勾的达到O(N^2),如下图的极端情况:由于每次end都要遍历到第一个数据才能停下,所执行的次数是1+2+3+......+n,用等差数列的求和公式计算出执行次数的量级是N^2,这时的效率就比较底下了。

排序思想:

尽管直接插入排序存在上述的不足,也并不妨碍它的排序思想是优秀的,因此在它的增强版---希尔排序诞生了,从名字不难看出,这是一个名叫希尔的人发明的。希尔排序的核心思想就是在进行直接插入排序之前先进行预排序,使序列接近有序,这样就可以保证直接插入排序的效率稳定在O(N)~O(N^2)之间。

详解预排序:

预排序就是把待排序列分为几组,分别进行直接插入排序。这里所说的分组并不是真的把数给拆开了,而是利用增量来决定直接插入排序逻辑里数组中两个元素比较和交换的间隔。

因此插入可以看作增量为1的希尔排序,而在这之前的增量不为1的排序就都是预排序,经过特定的逻辑,使得增量最后总能回到1。简单粗暴一点理解:希尔排序 = 一趟或多趟预排序 + 一趟直接插入排序。

代码实现(四层循环):

//希尔排序(四层循环的)
void ShellSort(int* arr, int size)
{
	int gap = size;
	while (gap > 1)
	{
		gap = gap / 3 + 1; //这样可以保证增量gap最后一定是1

		for (int j = gap; j < size; j++) //确立每一组的起始排序元素
		{
			for (int i = j; i <= size-gap; i += gap) //通过增量gap来达到对以j为起始的一组数据的直接插入排序地排序
			{
				int end = i;
				int val = arr[end];
				while (end >= gap)
				{
					if (val < arr[end - gap])
					{
						arr[end] = arr[end - gap];
						end -= gap;
					}
					else
					{
						break;
					}
				}
				arr[end] = val;
			}
		}
	}
}

代码实现(三层循环)

//希尔排序(三层循环的)
void ShellSort(int* arr, int size)
{
	int gap = size;
	while (gap>1)
	{
		gap = gap / 3 + 1; //这样可以保证增量gap最后一定是1
		
		for (int i = gap; i < size; i++)
		{
			int end = i;
			int val = arr[end];
			while (end >= gap)
			{
				if (val < arr[end - gap])
				{
					arr[end] = arr[end - gap];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end] = val;
		}
		
	}
}

二、交换排序:

冒泡排序(以排升序为例):

排序思想:

  1. 比较和交换:依次比较一组序列中的一对数,若不符合预期的大小次序**(这取决于是期望升序还是降序)**,则交换他们的值,否则正常的依次往后比较。
  2. 缩小范围:经过第一轮比较后,所期望的最值已经冒到了末尾,下一趟就不用再比较这个位置的值,也就是缩小的比较的范围。
  3. 多次重复第一和第二步,剩下来的第一个数就已经和之后的序列共同构成有序序列了。

代码实现的细节:

冒泡排序的代码实现实现比较简单,需要注意的只是一点小优化:在序列已经有序的情况下要让结束循环,减少不必要的性能开销。具体实现我放在下面的代码实现里。

代码实现:

//冒泡排序
void BubbleSort(int* arr, int size)
{
	for (int i = 0; i < size; i++)
	{
		int flag = 0;
		for (int j = 0; j < size - i-1; j++)  //j<size-i i每一趟都会加一,
                                              //因此j遍历的范围也会缩小
		{
			if (arr[j] > arr[j + 1]) //升序逻辑
			{
				int mid = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = mid;
				flag = 1;  //如果发生元素交换,记录下来
			}
		}
		if (flag == 0) //优化:如果某一趟排序没有元素发生交换,说明序列有序,不用在继续比较了
		{
			break;
		}
	}
}

快速排序(以排降序为例):

排序思想(递归):

  1. 选key:选一个关键值key,记录它的下标为keyi(一定得是下标,方便后续递归)。通常选择最左边的值为key。
  2. 左右双指针:定义一个左指针在最左端和一个右指针在最右端。
  3. 双指针的遍历顺序:如果第一步的key选的是最左边得的值,那右指针先动,反之左指针先动。
  4. 双指针的遍历逻辑(以排排降序序列为例):由于最终目的是大的值在左边,小的在右边,因此:右指针先往左遍历,在值大于key处的才停下;左指针随后往右遍历,在值小于key处才停下。此时交换左右指针所指向的值,这样较大的数就到了左边,较小得数就到了右边。一直循环这样的过程直到左右指针相遇才停下。
  5. **和key值交换:**第四步结束后,左右指针相遇,在将这个位置的值和keyi处的值交换。
  6. 分割区间来递归:以左右指针相遇的位置为分隔,将待排序序列分为两部分,对这两部分分别重复1---5步,也就递归调用自己这个快速排序的函数,只是每次都会传递更小的两个区间,递归的结束条件是当左指针大于等于右指针。

代码实现的细节(递归):

代码实现(递归):

//快速排序
void _QuickSort(int* arr, int begin, int end)
{
	//递归的终止条件
	if (begin >= end)
	{
		return;
	}

	int keyi = begin;  //记录关键值的下标,方便之后的递归传参
	int left = begin,right = end;
	while (left < right)
	{
		while (left < right && arr[right] <= arr[keyi]) //选取左边的值为key,则右指针right先移动
		{
			right--;
		}
		while (left < right && arr[left] >= arr[keyi])
		{
			left++;
		}
		//此时right所指的值大于keyi所指的,left所指的小于keyi所指的
		if (left < right)
		{
			int mid = arr[right];
			arr[right] = arr[left];
			arr[left] = mid;
		}
	}
	//此时right和left相遇,和keyi位置的值交换
	int mid = arr[left];
	arr[left] = arr[keyi];
	arr[keyi] = mid;
	//此时left和right处的值已经确定,以此将数据划分为两个部分,进行递归
	_QuickSort(arr, begin, left - 1);
	_QuickSort(arr, left + 1, end);

}

排序思想(非递归):

**1,递归的劣势:**快速排序的常规写法是用递归,涉及到函数不断地调用自己,而每次调用函数都要建立函数栈帧,会占用栈区的空间,如果要排序的数据量太大,递归调用的深度太深,会有栈溢出的风险。

2,使用数据结构的栈: 操作系统分配给栈区的空间很小**(比如在32位的Linux环境下,栈只有8MB大小)** ,因此不妨使用咱自己数据结构的栈**(我们的栈从堆区申请空间,堆区上G的空间老大了哈哈哈哈哈,使劲儿霍霍)**,模拟排序递归的过程。

**3,快速排序的递归就是区间的划分:**有了突破口后再来分析快速排序递归的本质,其实就是在每一趟确定一个数的最终位置后以它为基准再对同样逻辑的函数传递划分后的两个区间的区间值------也就是说,递归调用快速排序函数的过程中栈帧里存的就是一个个区间索引。

代码实现的细节(非递归):

1,栈的特性是先进先出,后进后出,且只能从顶端取元素。所以左右区间的出栈和入栈时机时相反的----想要先拿到左区间,就得先入右区间,否则先入左区间。

代码实现(非递归):

//快速排序
void QuickSort(int* arr, int begin, int end)
{
	//普通的递归版本
	//_QuickSort(arr, begin, end - 1);
	//非递归
	_QuickSortUR(arr, begin, end - 1);
}

//快速排序(非递归)
void _QuickSortUR(int* arr, int begin, int end)
{
	Stack st;
	StackInit(&st);
	StackPush(&st, end);  //栈先进先出,后进后出,
                          //如果后续想先取到左区间的值,就要先入右区间的值
	StackPush(&st, begin);
	while (!StackEmpty(&st))
	{
		int left = StackTop(&st);
		StackPop(&st);
		int right = StackTop(&st);
		StackPop(&st);
		int keyi = left;
		int lflag = left, rflag = right;

		while (left < right)
		{
			while (left < right && arr[right] >= arr[keyi])
			{
				right--;
			}
			while (left < right && arr[left] <= arr[keyi])
			{
				left++;
			}
			if (left != right)
			{
				int tmp = arr[left];
				arr[left] = arr[right];
				arr[right] = tmp;
			}
		}
		if (keyi != left)
		{
			int tmp = arr[keyi];
			arr[keyi] = arr[left];
			arr[left] = tmp;
		}
		// [lflag,left-1][left+1,rflag]
		if (left + 1 <= rflag)
		{
			StackPush(&st, rflag);
			StackPush(&st, left+1);
		}
		if (lflag <= left - 1)
		{
			StackPush(&st, left - 1);
			StackPush(&st, lflag);
		}
		
	}
	StackDestory(&st);
}

//栈的相关实现
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>

typedef struct Stack
{
	int* arr;
	int top;

	int capacity;
}Stack,*pst;
//初始化
void StackInit(pst p)
{
	assert(p);
	p->arr = NULL;
	p->top = -1;
	p->capacity = 0;
}
//尾插
void StackPush(pst p,int val)
{
	assert(p);
	if (p->top + 1 == p->capacity)
	{
		int newCapacity = (p->capacity == 0) ? 4 : 2*p->capacity ;
		int* newArr = realloc(p->arr, newCapacity * sizeof(int));
		if (newArr == NULL)
		{
			perror("realloc fail");
			exit(1);
		}
		p->arr = newArr;
		p->capacity = newCapacity;
	}
	p->arr[++(p->top)] = val;
}
//尾删
void StackPop(pst p)
{
	assert(p->arr);
	if (p->top + 1 > 0)
	{
		p->top--;
	}
	
}
//获取栈顶元素
int StackTop(pst p)
{
	assert(p->arr);
	return p->arr[p->top];
}
//判空
bool StackEmpty(pst p)
{
	assert(p->arr);
	return p->top == -1;
}
//销毁
void StackDestory(pst p)
{
	free(p->arr);
	p->arr = NULL;
}

三、选择排序:

选择排序:

排序思想(以排升序为例):

  1. 假设第一个元素最小,遍历整个序列,找出未排序序列里最小的数,将其和未排序的首元素交换。
  2. 此时确立了一个数的最终位置,缩小未排序的序列范围,在到这个范围里遍历找出次小的数放到序列的首位置。
  3. 就这样不断地找出最值并放到正确的位置,不断地缩小范围,直到只剩下一个数,则序列变为有序了。

不难看出,之所以叫选择排序,就是因为每一趟遍历都可以选出当前遍历范围内的最值。

代码实现:

//选择排序
void SelectSort(int* arr, int size)
{
	for (int i = 0; i < size - 1; i++) //最后一个数不用排,自然地有序
	{
		int small_index = i;  //假设遍历范围内的第一个数是最小的
		for (int j = i+1; j < size; j++)
		{
			if (arr[small_index] > arr[j])
			{
				small_index = j;
			}
		}
		int tmp = arr[small_index];
		arr[small_index] = arr[i];
		arr[i] = tmp;
	}
}

堆排序(以排升序为例):

排序思想:

  1. 排序前先通过至下而上的向下调整算法让数组具有大堆的性质。
  2. 交换堆顶元素到当前排序范围的尾部。
  3. 缩小排序范围。
  4. 通过向下调整保持大堆的属性,使得次大的数来到堆顶。
  5. 重复2~4步,直到排序范围缩小到一个元素,则排序完成。
  • 第一步的至下而上 指的是从最后一个元素的父节点开始(如果数组大小为size,那最后一个元素的父节点的下标就是(size-1-1)/2)进行向下调整算法,就这样以此再对倒数第2个、3个...直到倒数第一个父节点(也就是堆顶元素)进行同样的向下调整。
  • 之所以需要至下往上的调整,是因为向下调整算法每次都只能确定相邻两层的元素的大小,倘若在某一次的调整过程中父节点的值大于左右孩子,那循环会提前结束(如果是要建立大堆的话),而这并不能保证这个父节点的值是否大于往下更深层次的节点的值。----所以向下调整建立大堆的关键在于保证父节点的左右节点都是大堆的根节点。
  • 每次都能将当前排序范围内的最值交换到末尾,也就是每次都能选出一个最大的数,因此堆排序属于选择排序是有道理的。

代码实现的细节:

  • 父节点有两个子节点,而想要有条不紊的找出较大的节点来和父节点比较。可以使用假设法:先假设左孩子节点的值更大,如果右孩子节点的值大于左孩子的,那更新最大的需要比较的子节点。(当然了,如果真的要更新为右孩子,也得先加上前置的判断条件来防止越界访问)
  • 在向下调整的代码逻辑里,在确立了较大的子节点后,不能直接无脑和父节点的值交换,还得大于父节点的值才可以交换,如果小于或等于父节点的值,则直接break退出循环不在进行调整。

代码实现(排升序):

//向下调整(大堆逻辑)
void AdjustDown(int* arr,int parent, int size)
{
	assert(arr);
	if (size <= 1) return;

	while (parent*2+1 < size)
	{
		int child = parent * 2 + 1; //假设左孩子更大
		if (child+1<size && arr[child] < arr[parent * 2 + 2])
		{
			child++;
		}
		//交换父子节点的值 (只有当父节点的值大于子节点时才交换,否则结束循环)
		if (arr[parent] < arr[child])
		{
			int tmp = arr[parent];
			arr[parent] = arr[child];
			arr[child] = tmp;
		}
		else
		{
			break;
		}
		//继续往下调整
		parent = child;
	}
}

//堆排序(升序)
void HeapSort(int* arr, int size)
{
	assert(arr);
	if (size <= 1) return;

	//建大堆
	for (int parent = (size - 2) / 2; parent >= 0; parent--)
	{
		AdjustDown(arr, parent, size);
	}
	//排序,每次取出堆顶元素,放到末尾,缩小排序范围,如此往复。
	while (size>1)
	{
		//首尾交换
		int tmp = arr[0];
		arr[0] = arr[size - 1];
		arr[size - 1] = tmp;
		//缩小排序范围
		size--;
		//用向下调整保持大堆特性
		AdjustDown(arr, 0, size);
	}
}

四、归并排序(以排升序为例):

排序思想(递归):

  1. 将一组序列划分为两组有序序列,利用双指针算法和一个额外的数组空间使得这组序列变得有序。
  2. 大多数情况下一组序列分为两组后不会直接就是两组有序序列,除非这两组序列分别都只有一个元素甚至是其中一组序列没有元素,所以通过递归不断地划分区间直到出现这种天然有序的小区间序列。
  3. 体现在代码上就是先递:不停的让一个区间划分为两个区间;再归:当两个区间都只剩下一个元素时,开始排序,然后不停的回到上一层函数接着排序。

代码实现的细节(递归):

  1. MergeSort对外提供尽量简单的函数接口,只需要接受待排序的数组和数组元素个数两个参数即可;里面套一层_MergeSort函数,传递我们自己申请的中间数组和区间,在这个函数内部实现递归的逻辑。
  2. 分隔区间时,mid应该成为左区间的右端点,右区间的右断点应该是mid+1,不这样分割的话会出现意想不到的错误。
  3. 分隔区间并不总是能两等分,也要考虑左右区间不同大小所能导致的特殊情况,分为三种情况。

4.需要注意的是:每次对划分出来的两组区间排序的过程中,由于要确保每个数最后都被插入到中间额外的数组中,在写循环的终止条件时记得加上等号,否则每次都会漏掉两个元素。

代码实现(递归):

//归并排序(升序)
void MergeSort(int* arr, int size)
{
	assert(arr);
	int* tmp = (int*)malloc(sizeof(int) * size);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(1);
	}

	_MergeSort(arr,tmp,0, size - 1);

	free(tmp);
	tmp = NULL;
}

void _MergeSort(int* arr,int* tmp, int begin, int end)
{
	//递归的终止条件
	if (begin >= end) 
		return;
	//确定中间值和分好左右区间
	int mid = begin + (end - begin) / 2;
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;
	//处理两组区间数据个数不同的情况
	if (end1 > end) 
		return;
	if (end2 > end) 
		end2 = end;
	//递归划分区间
	_MergeSort(arr, tmp, begin1, end1);
	_MergeSort(arr, tmp, begin2, end2);
	//对有序的两组元素排序
	int i = begin1;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
			tmp[i++] = arr[begin1++];
		else
			tmp[i++] = arr[begin2++];
	}
	//begin1提前结束
	while (begin2 <= end2)
		tmp[i++] = arr[begin2++];
	//begin2提前结束
	while (begin1 <= end1)
		tmp[i++] = arr[begin1++];
	//将tmp里的值拷贝回原数组
	memcpy(arr + begin, tmp + begin, sizeof(int)*(end - begin + 1));
}

排序思想(非递归):

1.开始排序的时机: 要想实现非递归的归并排序,首先要弄清楚它在递归时是如何进行排序的,下面用一个规律一些的序列(每次都可以分成相同个元素的两组)来展示大致的递归逻辑:通过下图不难看出,在递的过程 中并没有涉及到排序,而是在数组一分为二到每组只有一个元素的时候,才开始归,归的过程才是真正排序的过程。

2.排序的规律: 仔细观察下图中红色分割线及以下的部分,先是每组一个数,相邻两组排序,接着是每组两个数排,再然后是四个数排......在排序过程中每组的元素的个数以2的次方递增(2^0、 2^1、 2^2 ...)。所以只要使用循环控制好每次每组的元素个数,就可以很好的模拟出归并排序递归中的过程。

代码实现的细节(非递归):

  1. 外层for循环决定的是一组的元素个数,从1开始依次按照2的次方增加。
  2. 内层for循环是在每组元素个数一定的情况下,将数组中的元素全部两组两组的排序。其中begin代表了每次排序的起始位置。
  3. 在分割区间时要注意:gap是元素个数,因此区间的左边界下标加上gap后要减一才能得到右边界下标。(就和在size个元素的数组中,最后一个元素下标为size-1一样)

代码实现(非递归):

//归并排序(非递归)
void MergeSortUR(int* arr, int size)
{
	int* tmp = (int*)malloc(sizeof(int) * size);
	if (tmp == NULL)
	{
		perror("malloc faild");
		exit(1);
	}

	for (int gap = 1; gap < size; gap *= 2) //每组元素的个数
	{
		for (int begin = 0;begin<size-gap;begin+=2*gap) //对每两组元素都执行排序逻辑
		{
			int begin1 = begin, end1 = begin1 + gap - 1;
			int begin2 = end1+1, end2 = begin2 + gap - 1;
				//处理两组区间数据个数不同的情况
				if (end1 > size-1)
					break;
				if (end2 > size - 1)
					end2 = size - 1;
				//对有序的两组元素排序
				int i = begin1;
				while (begin1 <= end1 && begin2 <= end2)
				{
					if (arr[begin1] < arr[begin2])
						tmp[i++] = arr[begin1++];
					else
						tmp[i++] = arr[begin2++];
				}
				//begin1提前结束
				while (begin2 <= end2)
					tmp[i++] = arr[begin2++];
				//begin2提前结束
				while (begin1 <= end1)
					tmp[i++] = arr[begin1++];
				//将tmp里的值拷贝回原数组
				memcpy(arr + begin, tmp + begin, sizeof(int) * (end2-begin+1));
		}
	}
	free(tmp);
	tmp = NULL;
}

其他排序:

计数排序(以排升序为例):

排序思想:

  1. 遍历一遍原数组,找出最大和最小的值,由(最大值-最小值+1)算出在数字连续的情况下的数据个数
  2. 定义一个数组,数组的大小取决于第一步里算的数据个数,通过遍历一遍原数组将每个数出现的次数记录在这个数组里。
  3. 此时额外定义的这个数组的下标就代表数据,这个数组每个位置的值就是对应下标在原数组中出现的次数。
  4. 只用遍历这个额外的数组,依次将数据插入到原数组中,根数据出现的次数就决定了一个数要插入几次,次数为0的数直接跳过即可。

文字的描述干巴巴的,下面话图来解释:

理想很丰满,现实很骨感,实际情况里不见得要排序的数都这么小、这么的接近数组的0下标,如果那时还是让待排序的数和数组下标来对应的话,额外开辟的数组可就真的要浪费大把的空间。

因此在遍历时让原数组中的值都减去待排序序列里最小值,从而就能正常的和数组从0开始的下标建立对应关系,之后在按照记录的每个元素出现的次数插入回原数组时再加上这个最小值,得到原来的数,就可以了。

代码实现:

//计数排序
void CountSort(int* arr, int size)
{
	
	//找出最大和最小值
	int small = arr[0], big = arr[1];
	if (arr[1] < arr[0]) small = arr[1], big = arr[0];
	for (int i = 2; i < size; i++)
	{
		if (arr[i] > big) big = arr[i];
		if (arr[i] < small) small = arr[i];
	}
	//开辟额外的数组,并将所有元素初始化为0(calloc函数正好可以做到这一点)
	int* tmp = (int*)calloc(big-small+1, sizeof(int)); //假设待排序的数据包括了small到big,
	if (tmp == NULL)
	{
		perror("calloc fail");
		exit(1);
	}
	//遍历数组,记录每个数据出现的次数
	for (int i = 0; i < size; i++)
	{
		tmp[arr[i]-small]++;  //待排序数组里不见得最小的数是0,所以遍历时减去small得到每个数相对于0的偏移量
	}
	//根据tmp数组里保存的每个数的出现次数,覆盖到原数组
	int j = 0;
	//for (int i = 0; i < size; i++) //错
	for (int i = 0; i < big-small+1; i++)
	{
		while (tmp[i]--) 
		{
			arr[j++] = i+small;  //由于tmp里存的是每个数相对于0的偏移量,所以加上small才能得到原来的数字
		}
	}
	free(tmp);
	tmp = NULL;
}

未完待续......

相关推荐
AC使者2 小时前
5820 丰富的周日生活
数据结构·算法
cwj&xyp2 小时前
Python(二)str、list、tuple、dict、set
前端·python·算法
无 证明2 小时前
new 分配空间;引用
数据结构·c++
xiaoshiguang36 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡6 小时前
【C语言】判断回文
c语言·学习·算法
别NULL6 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇6 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
ZSYP-S8 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring
yuanbenshidiaos8 小时前
C++----------函数的调用机制
java·c++·算法
唐叔在学习8 小时前
【唐叔学算法】第21天:超越比较-计数排序、桶排序与基数排序的Java实践及性能剖析
数据结构·算法·排序算法