1.我们在实际的数据查询和搜索应用中,当寻找最小数或者最大数,或者中间的第K小的数时,首先的问题是对这个杂乱的数据进行排序,然后就可以快速定位到需要搜寻到的具体的数。那么怎么排序,如何进行快速的排序,是本文的研究和分析重点。其中我们从时间复杂度和空间复杂度进行推理和证明。
2.1首先映入眼帘的是插入排序:其排序的思想和方法实现较之前学习的冒泡和选择排序更加的容易理解和实现,而且排序效率更高。插入排序可以这样去理解:给定一个存储了大量数据的向量vector <int>nums,我们将第一个数nums[0]首先看作是已经排好的序列,然后从第二个数nums[1]开始,将nums[1]与前面的数nums[0]进行比较,若较之前大,则就放在后面,否则,若较之前小,则将两者的值进行交换,依次向前寻找到合适的位置为止。
insertionSort算法实现如下:
vector<T> SortAndFind<T>::insertSort(vector<T>& nums)
{
//插入排序的思想是将后面的元素依次插入到前面数据的合适位置,通过每次与前一个元素作比较,向前插入到最合适的位置,按照非递减排序
T data;
//只有当数据量大于1才具有排序的意义
for (int i = 1; i < nums.size(); i++)
{
data = nums[i];
for (int j = i - 1; j >= 0; j--)
{
if (nums[j]>data)
{
nums[j+1] = nums[j];
nums[j] = data; //通过移动来代替交换
}
else
{
break;
}
}
}
return nums;
}
插入排序的好处是较容易在排序的阶段,返回原始数据中第k大的数,其时间复杂度的分析:插入排序的时间复杂度的平均情形是Θ(),根据N个互异元素的数组的平均逆序数是N(N-1)4定理,insertionSort时间复杂度平均是二次的。基于只交换相邻元素的任何的排序算法存在一个很强的下界
(
)。
2.2 接下来分析希尔排序,为什么我们会一下子跳到希尔排序,其实希尔排序算法是冲破二次(类似前面分析的插入排序等)的时间屏障的第一批算法。上面的基于交换的排序算法在计算机中执行时和进行仅执行一些比较得出最终位置的算法相比较为费时,因此为了使一个排序算法以亚二次的时间运行,我们的研究可以从执行比较算法上进行,特别是要对想距较远的元素进行交换时,首先通过比较定位然后进行交换值,这样大大减少了交换的时间。
希尔排序的原理是选择一个希尔增量序列h1,h2,h3.....ht,但h1必须等于1,因为这样才能保证每一个元素都能进行比较,当h=5时,则仅对下标为i=0, i+5, i+5*2,i+5*3....的数执行插入排序,保证在间隔为5时的所有数是有序的,然后对下标为h=h/2的数依次递归进行。
shellSort算法:
void SortAndFind<T>::shellSort(vector<T>&nums,int h) //h为增量
{
/* T data;
for (h; h >= 1; h /= 2)
{
for (int i = h; i < nums.size(); i += h)
{
data = nums[i];
for (int j = i - h; j >= 0; j -= h)
{
if (nums[j]>data)
{
nums[j + h] = nums[j];
nums[j] = data;
}
else
{
break;
}
}//for(j)
}//for(i)
}//for(h)
*/
if (h <= nums.size() / 2) //若小于一半时,则继续递归下去,然后在执行以下语句
{
shellSort(nums, h * 2);
}
T data;
for (int i = h; i < nums.size(); i+=h)
{
data = nums[i];
for (int j = i - h; j >=0; j-=h)
{
if (nums[j]>data)
{
nums[j+h] = nums[j];
nums[j] = data;
}
else
{
break;
}
}
}
}
这里我采用递归的方式,一直递归到增量序列为数据量大学的一半为止,然后执行插入排序。shellSort的时间复杂度的最坏的情形是N的二次方,但是可以选择更好的增量序列来提高排序的效率,例如Hibbard增量的时间复杂度为O()。
2.3 优先队列(堆)排序
堆的本质来源于优先队列,这个问题产生于现实生活中的排队问题,以及操作系统进程的调度问题,当我们在处理不同的进程时,如何安排不同进程的优先级来确保CPU最大限度的得到利用,又能使不同进程间得到最优的处理,这时,我们就给不同进程安排一个优先级,优先级最高(在队列中我们可以将值最小的节点放在最前面,类似于小堆的完全二叉树结构)我们先出队列,然后处理。存储结构我们采用了vector向量,当插入元素时,则向直接插到向量的最后面,然后进行上移调整(其实是父节点下移)。采用数组下标可以很直接计算得到其父节点的下标值,方便作比较和调整。
这里是核心的插入和上移的算法
template <class T>
void PriorityBinaryHeap<T>::insertHeap(T data)
{
if (binaryHeap.size()>1)
{
binaryHeap.push_back(data); //先插入到尾部,再进行调整
moveUp(data);
}
else
{
binaryHeap.push_back(data);
}
}
template <class T>
void PriorityBinaryHeap<T>::moveUp(T data) //将较小的元素上移,这里将最后的元素上移至合适的位置
{
int n = binaryHeap.size()-1;
int i = n / 2;
while (binaryHeap[i] > data&&i)
{
binaryHeap[n] = binaryHeap[i];
n = i;
i = i / 2;
}
binaryHeap[n] = data;
}
这样就更加数据的大小建立了一个小堆,然后不断的删除最小值,来得到一个递增序列:
删除根结点后时,我们将最后的元素补充到根结点的位置,然后执行下移操作,将补充的元素调整到最合适的位置以保证最小堆的性质(左右连个元素都比父节点小)。
删除和下移算法:主要是比较左右子树节点选择一个比更小的值来与父节点进行替换,依次类推到不能替换为止。
template <class T>
T PriorityBinaryHeap<T>::deleteGetMin()
{
T data = binaryHeap[1];
downMove(1);
return data;
}
template <class T>
void PriorityBinaryHeap<T>::downMove(int i)
{
int n = binaryHeap.size();
T data = binaryHeap[n - 1];
binaryHeap.pop_back();
n--; // ***注意pop_back()后向量的大小减小一个,因为n是在pop_back()之前,所以这里的n需要减去1
while (2*i<n)
{
if (i * 2 + 1 < n)
{
if (binaryHeap[i * 2] < binaryHeap[i * 2 + 1])
{
if (data > binaryHeap[i * 2])
{
binaryHeap[i] = move(binaryHeap[i * 2]);
i = i * 2;
}
else
{
binaryHeap[i] = data;
break;
}
}//if
else
{
if (data > binaryHeap[i * 2+1])
{
binaryHeap[i] = binaryHeap[i * 2 + 1];
i = i * 2+1;
}
else
{
binaryHeap[i] = data;
break;
}
}
}
else //i=2*n; 说明只有一个左子树
{
if (data > binaryHeap[i * 2])
{
binaryHeap[i] = binaryHeap[i * 2];
i = i * 2;
}
else
{
binaryHeap[i] = data;
break;
}
}
}//while
if (binaryHeap.size()>1)
{
binaryHeap[i] = data;
}
}
因为堆的逻辑结构为完全二叉树,所以,在执行插入和删除时,上移和下移的操作最多需要进行log(n)次比较,所以对于具有n个元素的最小值的依次获取需要进行nlog(n)次总的比较,因此堆的时间复杂度为O(nlog(n))。
让我们回顾上面的排序算法,从最开始的插入到堆排序,可以发现,根据现实的需要,算法效率的研究路从n2到nlog(n),这极大的提高了大数据搜索的效率,接下来,我们分析几个更加优化的算法:归并排序和快速排序以及基数排序。
2.4 归并排序
归并排序的思想是将两个已经排好序的数据,从头到尾进行一对一的比较,谁更小,谁就进入第三个向量。然后输出第三个向量的值即为已经排好序。那么问题来了,我们不肯能最开始得到两个已经排好序的数据,而是一组杂乱的数据集,这是我们的做法是不进行排序,而是直接将这个数据集一分为二,然而,两边的数据集也是无序的,这样也不能进行归并,那么怎么办了,让我们将思路更进一步:如果最开始只有两个数据,那么一分为二时,每边都只有一个数据,这样每边自然就有序(一个序),然后我们将思路收回来,既然一组数可以一分为二,那么每边也可以进行分割,最后进过log(n)次分割后,最后的最里层的部分不就是一个数据吗。想到这里,我们可以用递归的方法进行程序的编写:如果两个子部分中各自已经归并排序了,那么在将两个已经排好序的子部分进行归并,继续回溯到最开始的数据集。(我们在后面将要介绍的这种思想四分治法,只不过这里实现时利用了递归结构,其实任何递归都可以用循环实现)
mergeSort算法,主要分为两个部分,递归分,对两部分进行比较合并:
template <class T>
void SortAndFind<T>::mergeSort(vector<T>&nums, int first, int last)
{
/**
采用的思想是分治法,
*/
int mid = (first + last) / 2;
if (first +1< last)
{
mergeSort(nums, first, mid); //左部分
mergeSort(nums, mid + 1, last); //右部分
//两边排完顺序后,在执行合并操作,从first 到last 这段数据进行合并操作
}
//不用递归就合并
mergeAB(nums, first, mid, mid + 1, last);
}
template <class T>
void SortAndFind<T>::mergeAB(vector<T>&nums, int firstA, int lastA, int firstB, int lastB)
{
vector<T>mergeC;
int i = firstA;
int j = lastB;
while (firstA <= lastA&&firstB <= lastB)
{
if (nums[firstA] < nums[firstB])
{
mergeC.push_back(nums[firstA]);
++firstA;
}
else if (nums[firstA] > nums[firstB])
{
mergeC.push_back(nums[firstB]);
++firstB;
}
else
{
mergeC.push_back(nums[firstA]);
mergeC.push_back(nums[firstB]);
++firstA;
++firstB;
}
}//while(all)
if (firstA > lastA&&firstB <= lastB)
{
while (firstB <= lastB)
{
mergeC.push_back(nums[firstB]);
++firstB;
}
}
else if (firstA <= lastA&&firstB > lastB)
{
while (firstA <= lastA)
{
mergeC.push_back(nums[firstA]);
++firstA;
}
}
//
int t = 0;
while (i <= j)
{
nums[i] = mergeC[t];
++i;
++t;
}
}
归并排序中只存在比较,没有交换,所以排序的效率很高。同时,合并的操作为log(n)次,所以而合并n个元素的总的时间为O(nlog(n))。归并排序的优势在于不需要数据移动,仅进行比较,但是需要额外的空间N来存储合并的元素。
2.5 上节我们通过比较来替代交换(交换需要进行数据交换,耗时),节省时间提高效率,从这个研究方向继续考虑,是否还能进一步减少移动次数而使用更多的比较了,我们想到了通过移动下标来进行比较(这里与选择排序相区别:虽然都是比较下标所指示的数据,但是选择排序的每个数都要和剩下的所有数进行比较,所有总的比较次数耗时为二次方,但是快速排序是将所有数据与当前选择的中间数进行比较分成左右两部分,然后在一半中在分成两部分,这样一半中的标杆数与n/2个数进行比较,接下来是n/4,因此总的时间为两部分的递归调用时间+分割的线性时间,O(nlog(n))。
快速排序算法最重要的是中间比较对象的privot的选取,本文算法中我们采用三数中值分割法:选取first,last ,mid=(first+last)/2下标元素中第二大的元素为privot,然后依次类推,递归调用。
quickSort算法:
template <class T>
void SortAndFind<T>::quickSort(vector<T>&nums, int first,int last) //privot为中间的比较值
{
//递归的对两边取中间值进行划分
if (first < last) //一个元素则不用进行交换
{
int mid = (first + last) / 2;
int privot = findPrivotIndex(nums, first, mid, last); //参数一般表示为灰色
int i = findQuickCenterIndex(nums, first, last , privot); //i和j元素以privot为中心执行交换
quickSort(nums, first, i - 1); //左部分递归
quickSort(nums, i + 1, last); //右部分递归
}
}
template <class T>
int SortAndFind<T>::findPrivotIndex(vector<T>&nums, int first, int mid, int last)
{
T max = nums[last];
T min = nums[first];
int maxFlag=last;
int minFlag = first;
if (nums[first] > max)
{
max = nums[first];
maxFlag = first;
}
if (nums[mid] > max)
{
max = nums[mid];
maxFlag = mid;
}
//求最小值
if (nums[last] < min)
{
min = nums[last];
minFlag = last;
}
if (nums[mid] < min)
{
min = nums[mid];
minFlag = mid;
}
//返回值
if (maxFlag == last)
{
if (minFlag == first)
{
return mid;
}
else
{
return first;
}
}
else if (maxFlag==first)
{
if (minFlag == last)
{
return mid;
}
else
{
return last;
}
}
else //maxFlag==mid
{
if (minFlag == first)
{
return last;
}
else
{
return first;
}
}
}
template <class T>
int SortAndFind<T>::findQuickCenterIndex(vector<T>&nums, int first, int last,int privot)
{
int i=first;
int j = last - 1;
T data;
T changeValue;
data = nums[privot];
nums[privot] = nums[last];
nums[last] = data;
while (i <= j)
{
if (nums[i]<data&&nums[j]>data)
{
++i;
--j;
}
else if (nums[i]<data)
{
++i;
}
else if (nums[j]>data)
{
--j;
}
else // when i and j stop
{
changeValue = nums[i];
nums[i] = nums[j];
nums[j] = changeValue;
i++;
j--;
}
}//while
data = nums[i];
nums[i] = nums[last];
nums[last] = data;
return i;
}
到这里,我们已经研究了不同的算法,从N的二次方到Nlog(N),将移动交换逐步向分割比较上靠拢,最终设计出了效率较之前高的堆和快速,归并排序。那么,算法的效率就此为止吗,我们还有没有更加好的算法,将时间复杂度降到最低(O(n)),这是最快的比较了(因为每个数据仅进行一次比较)所以最快。我们继续研究下去,可以发现以线性时间排序的算法是可以做到的。将每个数的每一位进行比较然后排序,这样总的比较次数为cN,c实时为位数,那么时间复杂度为O(n),这就是radixSort。
2.6 基数排序
因为所有数的每一位的大小都是从0到9,所以仅需要一个大小为10的向量vector进行排序即可空间复杂度为O(1),然后依次通过取余数的方法,取出各个位的数,放到相等下标的位置中。在算法的实现过程中,我们按照反序进行桶式排序,首先从最低有效为开始,当然可能某位数上相同的数有多个,因此我们采用一个二维向量来表示。
radixSort算法:
template <class T>
void SortAndFind<T>::radixSort(vector<T>&nums)
{
//若相等,则按照原序排列
vector<vector<int> >radix;
radix.resize(10); //i 0到9
int s = 0;
//二维则使用push_back();
int remainder = 0; //余数
int t=10;
int k = 1;
int flag = 1;
while (flag)
{
flag = 0;
s = 0;
for (int i = 0; i < nums.size(); i++)
{
if (nums[i] / k)
{
flag = 1;
}
remainder = (nums[i] / k) % 10;
radix[remainder].push_back(nums[i]); //从小到大排序
}
k *= 10;
//返回到数组nums中
for (int i = 0; i < radix.size(); i++)
{
for (int j = 0; j < radix[i].size();j++)
{
nums[s++] = radix[i][j]; //永远赋值第一个
cout << radix[i][j] << ", ";
}
radix[i].clear();
}
cout << endl;
}
}
radixSort的一个应用场景是字符串的排序,然后进行搜索,我们一定要认识一个道理就是:排序的目的是为了查找和检索。
扩展排序:以上我们所有的排序都是在内存中完成的,但是当数据量比较大的时候,我们该怎么办,该如果去研究更好的方法进行此类排序,这里大家可以去查找相关的论文进行研究。研究无止境,只有不断的研究和改进才能得到最好的算法。