算法导论之七(中位数和顺序统计量之选择算法)

实际生活中,我们经常会遇到这类问题:在一个集合,谁是最大的元素?谁是最小的元素?或者谁是第二小的元素?。。。。等等。那么如何在较短的时间内解决这类问题,就是本文要阐述的。

先来熟悉几个概念:

1、顺序统计量:

在一个由n个元素组成的集合中,第i个顺序统计量(order statistic)是该集合中第i小的元素。最小值是第1个顺序统计量(i=1),最大值是第n个顺序统计量(i=n)。

  2、中位数:

一个中位数是它所属集合的“中点元素”,当n为奇数时,中位数是唯一的,位于i=(n+1)/2处;当n为偶数时,存在两个中位数,分别位于i=n/2和i=n/2+1处。

一、最大值最小值问题

直观的看,对于一个包含n个元素的集合中,要求其中最小的元素,需要做多少次比较呢?很容易想到,至少要做n-1次比较;我们只需要遍历集合来进行比较,每次记录较小的元素,遍历结束的时候即可得到最小的元素。对于最大值也可以这样求。

如果问题是需要同时找到最大值和最小值呢,当然可以进行两次遍历,做2*(n-1)次比较,就可以得到最大值和最小值。但这并不是最优的,因为我们并不需要每个数既与最大值进行比较又与最小值进行比较。

以偶数个元素的集合为例,我们可以先比较一下前两个元素,大的那一个先设为max,而小的那一个先设为min,而之后的元素由于是偶数个,所以拆成两个一组,先在组内进行一次比较,然后再将组内大的去与max比较,如果比max大,则替换max的值,否则不变;组内小的去与min比较,类似的操作直到遍历完整个元集合。那么一共进行的比较次数为:1+(n/2-1)*3=3n/2-2次。如果是奇数个元素的集合的话,省略第一次的比较操作,然后直接将max和min都设为第一个元素即可,后面的操作与偶数的情况相同。那么对于奇数个元素的集合而言,一共要进行的比较次数为:((n-1)/2)*3次。如果不考虑奇数还是偶数,我们至多需要进行3[n/2](不大于n/2的最大整数)次比较即可得到最大值和最小值,也即算法的时间复杂度为O(n)。

实现的代码也比较简单:

#include <iostream>

typedef int T;

using namespace std;

/*
 * 包含结果的结构体,里面含有最大值和最小值
 */
struct result {
public:
	T max;
	T min;
	result() :
			max(0), min(0) {
	}
};

result* getMinMax(int a[], int len);

int main() {

	T a[9] = { 5, 8, 0, -89, 9, 22, -1, -31, 98 };
	result* r1 = getMinMax(a, 9);
	cout << "最大值为:" << r1->max << ",最小值为:" << r1->min << endl;

	T b[10] = { 5, 8, 0, -89, 9, 22, -1, -31, 98, 2222 };
	result* r2 = getMinMax(b, 10);
	cout << "最大值为:" << r2->max << ",最小值为:" << r2->min << endl;

	delete r1;
	delete r1;

	return 0;
}

result* getMinMax(T a[], int len) {
	result* re = new result();
	if (len == 0) {
		return 0;
	}
	if (len == 1) {
		re->max = a[0];
		re->min = a[0];
		return re;
	}
	if (len == 2) {
		re->max = a[0] > a[1] ? a[0] : a[1];
		re->min = a[0] < a[1] ? a[0] : a[1];
		return re;
	}

	int max, min;
	int i = 0;
	if (len % 2 == 0) {
		//元素个数为偶数的情况
		re->max = a[i] > a[i + 1] ? a[i] : a[i + 1];
		re->min = a[i] < a[i + 1] ? a[i] : a[i + 1];
		i += 2;
	} else {
		//元素个数为奇数的情况
		re->max = a[i];
		re->min = a[i];
		i++;
	}

	while (i < len) {
		//在成对的数中比较取值,然后再分别与max和min进行比较
		max = a[i] > a[i + 1] ? a[i] : a[i + 1];
		min = a[i] < a[i + 1] ? a[i] : a[i + 1];
		i += 2;
		re->max = re->max > max ? re->max : max;
		re->min = re->min < min ? re->min : min;
	}
	return re;
}

二、求一个集合中第i小的元素,也即第i个顺位统计量

看起来感觉好像要比求最大值和最小值要复杂许多,而实际上,求一个互异元素的集合中的第i小的元素,时间复杂度与上面的一样,也是O(n)。下面关于快速排序的知识,可以参看:算法导论之一(快速排序)

这里要采用的思路与快速排序有些类似,快速排序找找到一个mid位置之后,要继续对mid两边的数组进行快速递归排序。而这里由于只需要找到第i个顺位统计量的位置,所以这个元素要么位于mid位置,要么位于mid左边的数组,要么位于mid右边的数组,所以只需要考虑一边即可。而对应到算法复杂度上,快速排序的期望时间复杂度为O(nlgn),而这里为O(n)。

具体思路:

1、采用快速排序的分组方式,但是稍加改进,每次分组的时候的key值不再是一个确定的位置的值,而是先从所有元素中随机挑选一个作为key值,然后再将其与末尾的元素交换。这样的好处在于能够有效避免每次分组都是极度不平衡的状态:0:n-1。(从概率上看,随机选择key值则使得不存在一种固定的情况让最坏的情况每次都发生)。

2、在得到随机分组结果之后,我们先看看得到的那个mid位置的元素与我们要找的第i顺位统计量是不是一个位置,如果是,则直接返回mid位置的元素。如果发现mid位置位于第i顺位统计量之前,则我们只需要递归的对分组的后面的那部分集合进行上面的操作即可,反之,如果发现mid位于第i顺位统计量之后,则我们只需要递归的堆分组的前半部分集合进行上面的操作即可。

代码如下:

/**
 * 意在解决:线性时间内O(n)内完成查找数组中第i小的元素
 */
#include <iostream>

typedef int T;

using namespace std;

T randomizedSelect(T a[], int start, int end, int i);
int randomizedPartition(T a[], int start, int end);
int partitionArray(T a[], int start, int end);
void swap(T* a, T* b);
void printArray(T* a, int len);
void randomizedQuickSort(T a[], int start, int end);

int main() {
	T a[10] = { 1, 999, -1025, 654, 185, 5, -9, 21, 8, 11 };

	cout << "排序之前的数组:"; // << endl;
	printArray(a, 10);

	int pos = 3;

	cout << "第" << pos << "小的元素为:" << randomizedSelect(a, 0, 9, pos) << endl;

	randomizedQuickSort(a, 0, 9);

	cout << "排序之后的数组:"; // << endl;

	printArray(a, 10);

	return 0;
}

/*
 * 查找第i顺位统计量 即第i小的元素
 */
T randomizedSelect(T a[], int start, int end, int i) {
	if (start == end) {
		return a[start];
	}
	int q = randomizedPartition(a, start, end);
	int keyPos = q - start + 1; //求出这个元素是第几小的元素,方便与第i小元素比对
	//与i相比较
	if (keyPos == i) {
		return a[q];
	} else if (keyPos > i) {
		return randomizedSelect(a, start, q - 1, i);
	} else {
		//keyPos<i时
		return randomizedSelect(a, q + 1, end, i - keyPos);
	}

}

/*
 * 随机分组
 */
int randomizedPartition(T a[], int start, int end) {
	int mid = rand() % (end - start + 1) + start;
	swap(a + end, a + mid);
	return partitionArray(a, start, end);
}

/*
 * 随机快速排序
 */
void randomizedQuickSort(T a[], int start, int end) {
	if (start < end) {
		int k = randomizedPartition(a, start, end);
		randomizedQuickSort(a, start, k - 1);
		randomizedQuickSort(a, k + 1, end);
	}
}

/*
 * 正常分组
 */
int partitionArray(T a[], int start, int end) {
	int i = start - 1;
	int j = start;
//	int p = start;
	int q = end;
	T key = a[end];
	while (j < q) {
		if (a[j] >= key) {
			j++;
			continue;
		} else {
			i++;
			swap(a + i, a + j);
			j++;
		}
	}
	i++;
	swap(a + i, a + j);
	return i;
}

/*
 * 交换两个元素
 */
void swap(T* a, T* b) {
	T tmp = *a;
	*a = *b;
	*b = tmp;
}

/*
 * 打印数组
 */
void printArray(T* a, int len) {
	for (int i = 0; i < len; i++) {
		cout << a[i] << ' ';
	}
	cout << endl;
}

关于上面算法为什么在元素互异的时候时间复杂度为O(n),在《算法导论-第三版》p121~p122页有详细的数学证明,这里就不再赘述。我们可以简单的理解,对于快速排序,期望的时间复杂度为O(nlgn),其递归在分组之后要对两边的两个集合都进行递归;而对于选择算法,只需要对其一边的集合进行递归即可,速度上要优于快排,在元素互异的情况下,时间复杂度为O(n)。

时间: 2024-11-06 06:53:33

算法导论之七(中位数和顺序统计量之选择算法)的相关文章

[算法导论 Ch9 中位数和顺序统计量] Selection in O(n)

1. 寻找第k大(小)的数 假设数据存储在数组a[1..n]中 首先,寻找一个数组中最大或者最小的数,因为最大(小)的数一定要比其他所有的数大(小),因此至少要比较完所有的pair才能确定,所以时间复杂度在O(n).那么寻找第k大(小)呢? 比较直观的,就是对数组中国所有的数据先进行排序,在我们这种渣渣的计算机入门选手而言,可选的有QuickSort,MergeSort和HeapSort,甚至是ShellSort等一些比较高级的方法啊...一般的代价都在O(n*logn)上,然后直接取出即可.

【算法导论学习-015】数组中选择第i小元素(Selection in expected linear time)

1.算法思想 问题描述:从数组array中找出第i小的元素(要求array中没有重复元素的情况),这是个经典的"线性时间选择(Selection in expected linear time)"问题. 思路:算法导论215页9.2 Selection in expect linear time 2.java实现 思路:算法导论216页伪代码 /*期望为线性时间的选择算法,输入要求,array中没有重复的元素*/ public static int randomizedSelect(i

算法导论第九章中位数和顺序统计量(选择问题)

本章如果要归结成一个问题的话,可以归结为选择问题,比如要从一堆数中选择最大的数,或最小的数,或第几小/大的数等, 这样的问题看似很简单,似乎没有什么可研究的必要,因为我们已经知道了排序算法,运用排序+索引的方式不就轻松搞定了?但细想,排序所带来的时间复杂度是不是让这个问题无形之中变得糟糕.那算法研究不就是要尽可能避免一个问题高复杂度地解决,让那些不敢肯定有无最优解的问题变得不再怀疑,这也是算法研究者所追求的一种极致哲学.既然排序让这个问题解决的性能无法确定,那我们就抛开排序,独立研究问题本身,看

【算法导论】用动态规划解活动选择问题

上一篇讲了贪心算法来解活动选择问题([算法导论]贪心算法之活动选择问题),发现后面有一道练习16.1-1是要用动态规划来解活动选择问题.其实跟之前的矩阵链乘法有些相似,也是考虑分割的活动是哪一个,并用二维数据来记录Sij---最大兼容集合个数,和用另一个二维数据来记录Sij取得最大时的活动分割点k.然后就是考虑边界问题,和使用递归来求动态规划的最优解. 代码注解比较详尽: #include <iostream> #include <algorithm> using namespac

算法导论--单源最短路径问题(Dijkstra算法)

转载请注明出处:勿在浮沙筑高台http://blog.csdn.net/luoshixian099/article/details/51918844 单源最短路径是指:给定源顶点s∈V到分别到其他顶点v∈V?{s}的最短路径的问题. Dijkstra算法采用贪心策略:按路径长度递增的顺序,逐个产生各顶点的最短路径.算法过程中需要维护一个顶点集S,此顶点集保存已经找到最短路径的顶点.还需要维护一个距离数组dist, dist[i]表示第i个顶点与源结点s的距离长度. Dijkstra算法思路: S

算法导论学习之快排+各种排序算法时间复杂度总结

快排是一种最常用的排序算法,因为其平均的时间复杂度是nlgn,并且其中的常数因子比较小. 一.快速排序 快排和合并排序一样都是基于分治的排序算法;快排的分治如下: 分解:对区间A[p,r]进行分解,返回q,使得A[p–q-1]都不大于A[q] A[q+1,r]都大于A[q]; 求解:对上面得到的区间继续递归进行快排 合并:因为快排是原地排序,所以不需要特别的合并 从上可以看出最重要的就是分解函数,其按关键值将数组划分成3部分,其具体实现的过程见代码注释. 我们一般取数组的最后一个元素作为划分比较

算法导论 第十六章:贪心算法之单任务调度问题

贪心算法是使所做的选择看起来都是当前最优的,通过所做的局部最优选择来产生一个全局最优解. 其具有的性质如下: 1)贪心选择性质:一个全局最优解可以通过局部最优(贪心)选择来达到.即,在考虑如何做选择时,我们只考虑对当前问题最佳的选择而不考虑子问题的结果. 这一点是贪心算法不同于动态规划之处:在动态规划中,每一步都要做出选择,但是这些选择依赖于子问题的解.因此,解动态规划问题一般是自底向上,从小问题处理至大问题.在贪心算法中,我们所做的总是当前看似最优的选择,然后再解决选择之后所出现的子问题.贪心

【算法导论之七】动态规划求解最长公共子序列

一.动态规划的概念 动态规划(Dynamic Programming)是通过组合子问题的解而解决整个问题的.分治算法是指将问题划分成一些独立的子问题,递归地求解各子问题,然后合并子问题的解而得到原始问题的解,与此不同,动态规划适用于子问题不是独立的情况,也就是各个子问题包含公共的子问题.在这种情况下,采用分治法会做许多不必要的工作,即重复地求解公共地子问题.动态规划算法对每个子问题只求解一次,将其结果保存在一张表中,从而避免每次遇到各个子问题时重新计算答案. 动态规划通常应用于最优化问题.此类问

算法导论笔记——第十六章 贪心算法

通常用于最优化问题,我们做出一组选择来达到最优解.每步都追求局部最优.对很多问题都能求得最优解,而且速度比动态规划方法快得多. 16.1 活动选择问题 按结束时间排序,然后选择兼容活动. 定理16.1 考虑任意非空子问题Sk,令am是Sk中结束时间最早的活动,则am在Sk的某个最大兼容活动子集中. 16.2 贪心算法原理 设计贪心算法步骤: 1>将最优化问题转化为这样的形式:对其做出一次选择后,只剩下一个子问题需要求解. 2>证明作出贪心选择后,原问题总是存在最优解,即贪心选择总是安全的. 3