我在YouTube上观看了一些采访程序员的视频。问题之一是编写一个返回数组的第n个最小元素的函数。
在视频中,我看到一位女士尝试使用C ++进行一些重复编码,但是我认为很好,在C#中,这只是一个衬里:
var nth = vals.OrderBy(x => x).Take(i).Last();
然后我意识到我不知道实际上这是否是一个好的解决方案,因为下一个问题是时间复杂度。我去看了文档,发现所有由OrderBy
返回的对象具有枚举时执行完全延迟排序所需的所有信息。
所以我决定对其进行测试,并在CompareTo方法中编写了一个具有单个值和静态计数器的类MyComparable : IComparable<MyComparable>
:
class MyComparable : IComparable<MyComparable>
{
public MyComparable(int val)
{
Val = val;
}
public static int CompareCount { get; set; }
public int Val { get; set; }
public int CompareTo(MyComparable other)
{
++CompareCount;
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
return Val.CompareTo(other.Val);
}
}
然后我编写了一个循环,该循环在数组中找到第n个元素:
static void Main(string[] args)
{
var rand = new Random();
var vals = Enumerable.Range(0, 10000)
// .Reverse() // pesimistic scenario
// .OrderBy(x => rand.Next()) // average scenario
.Select(x => new MyComparable(x))
.ToArray();
for (int i = 1; i < 100; i++)
{
var nth = vals.OrderBy(x => x).Take(i).Last();
Console.WriteLine($"i: {i,5}, OrderBy: {MyComparable.CompareCount,10}, value {nth.Val}");
MyComparable.CompareCount = 0;
var my_nth = vals.OrderByInsertion().Take(i).Last();
Console.WriteLine($"i: {i,5}, Insert : {MyComparable.CompareCount,10}, value {my_nth.Val}");
MyComparable.CompareCount = 0;
my_nth = vals.OrderByInsertionWithIndex().Take(i).Last();
Console.WriteLine($"i: {i,5}, Index : {MyComparable.CompareCount,10}, value {my_nth.Val}");
MyComparable.CompareCount = 0;
Console.WriteLine();
Console.WriteLine();
}
}
我还写了2种“不同的”实现,分别是找到min元素,将其返回并将其从列表中删除:
public static IEnumerable<T> OrderByInsertion<T>(this IEnumerable<T> input) where T : IComparable<T>
{
var list = input.ToList();
while (list.Any())
{
var min = list.Min();
yield return min;
list.Remove(min);
}
}
public static IEnumerable<T> OrderByInsertionWithIndex<T>(this IEnumerable<T> input) where T : IComparable<T>
{
var list = input.ToList();
while (list.Any())
{
var minIndex = 0;
for (int i = 1; i < list.Count; i++)
{
if (list[i].CompareTo(list[minIndex]) < 0)
{
minIndex = i;
}
}
yield return list[minIndex];
list.RemoveAt(minIndex);
}
}
结果真的让我感到惊讶:
i: 1, OrderBy: 19969, value 0
i: 1, Insert : 9999, value 0
i: 1, Index : 9999, value 0
i: 2, OrderBy: 19969, value 1
i: 2, Insert : 19997, value 1
i: 2, Index : 19997, value 1
i: 3, OrderBy: 19969, value 2
i: 3, Insert : 29994, value 2
i: 3, Index : 29994, value 2
i: 4, OrderBy: 19969, value 3
i: 4, Insert : 39990, value 3
i: 4, Index : 39990, value 3
i: 5, OrderBy: 19970, value 4
i: 5, Insert : 49985, value 4
i: 5, Index : 49985, value 4
...
i: 71, OrderBy: 19973, value 70
i: 71, Insert : 707444, value 70
i: 71, Index : 707444, value 70
...
i: 99, OrderBy: 19972, value 98
i: 99, Insert : 985050, value 98
i: 99, Index : 985050, value 98
到目前为止,仅使用LINQ OrderBy().Take(n)
就是最高效,最快的,这是我所期望的,但是永远不会猜到差距是几个数量级。
所以,我的问题主要是针对面试官:您如何给这样的答案打分?
代码:
var nth = vals.OrderBy(x => x).Take(i).Last();
时间复杂度:
我不知道细节,但是不管我们想要哪个第n个元素,OrderBy都可能使用某种快速排序,而不是n log(n)
。
您会要求我实现那些我自己的方法还是使用框架就足够了?
编辑:
因此,事实证明,像下面的建议答案一样,OrderedEnumerable
使用QuickSelect的变体仅将元素排序到您要的位置。从好的方面来说,它会缓存订单。
虽然您可以更快地找到第n个元素,但它的分类速度并不快,但速度要快一些。而且,每个C#程序员都会理解您的要求。
我认为在面试中我的答案将最终指向“我将使用OrderBy,因为它足够快,编写过程只需10秒钟。如果事实证明它很慢,我们可以使用QucikSelect获得一些时间,但是很好地实现它需要很多时间”
感谢每个决定参加此活动的人,并对所有浪费时间认为这是另外的事情的人表示歉意:)
答案 0 :(得分:1)
好吧,让我们从低挂的水果开始:
您的实现是错误的。您需要从序列中提取index + 1
个元素。要了解这一点,请考虑index = 0
并重新阅读Take
的文档。
您的“基准比较”仅工作,因为在IEnumerable上调用OrderBy()
不会修改基础集合。对于我们要做的事情,只允许对基础数组进行修改就容易了。因此,我可以自由更改代码,以在每次迭代中从头开始生成值。
另外,Take(i + 1).Last()
等效于ElementAt(i)
。您确实应该使用它。
哦,您的基准测试确实没有用,因为您需要使用Take
消耗范围内的元素越多,这些算法之间应越接近。据我所知,您对O(n log n)的运行时分析是正确的。
有一种解决方案的时间复杂度为O(n)(不是我先前错误地声称的O(log n))。这是面试官期望的解决方案。
无论花多少钱,您编写的代码都无法移至该解决方案,因为您无法控制排序过程。
如果您可以实施快速选择(此处是目标),则会对您在此处提出的LINQ查询进行理论上的改进(尤其是对于高索引) )。以下是根据您的代码来自wikipedia article on quickselect的伪代码的翻译
static T SelectK<T>(T[] values, int left, int right, int index)
where T : IComparable<T>
{
if (left == right) { return values[left]; }
// could select pivot deterministically through median of 3 or something
var pivotIndex = rand.Next(left, right + 1);
pivotIndex = Partition(values, left, right, pivotIndex);
if (index == pivotIndex) {
return values[index];
} else if (index < pivotIndex) {
return SelectK(values, left, pivotIndex - 1, index);
} else {
return SelectK(values, pivotIndex + 1, right, index);
}
}
static int Partition<T>(T[] values, int left, int right, int pivot)
where T : IComparable<T>
{
var pivotValue = values[pivot];
Swap(values, pivot, right);
var storeIndex = left;
for (var i = left; i < right; i++) {
if (values[i].CompareTo(pivotValue) < 0)
{
Swap(values, storeIndex, i);
storeIndex++;
}
}
Swap(values, right, storeIndex);
return storeIndex;
}
我运行的测试的非代表性子样本给出了输出:
i: 6724, OrderBy: 52365, value 6723
i: 6724, SelectK: 40014, value 6724
i: 395, OrderBy: 14436, value 394
i: 395, SelectK: 26106, value 395
i: 7933, OrderBy: 32523, value 7932
i: 7933, SelectK: 17712, value 7933
i: 6730, OrderBy: 46076, value 6729
i: 6730, SelectK: 34367, value 6730
i: 6536, OrderBy: 53458, value 6535
i: 6536, SelectK: 18341, value 6536
由于我的SelectK实现使用随机枢轴元素,因此其输出有相当大的变化(例如参见第二轮)。它也比标准库中实现的高度优化的排序算法差很多。
即使在这种情况下,即使我没有花很多力气,SelectK也会直接胜过标准库。
现在用3 [1] (这是一个非常糟糕的枢轴选择器)的中值替换随机枢轴,我们可以获得一个稍有不同的SelectK并与OrderBy和SelectK竞争。 >
我一直在使用数组中具有1m个元素的这三匹马进行比赛,使用您已经拥有的随机排序,在数组的最后20%中请求一个索引,并得到如下结果:
Winning counts: OrderBy 32, SelectK 32, MedianOf3 35
Winning counts: OrderBy 26, SelectK 35, MedianOf3 38
Winning counts: OrderBy 25, SelectK 41, MedianOf3 33
即使对于10万个元素,并且不将索引限制为数组的末尾,这种模式似乎仍然存在,尽管并不那么明显:
--- 100k elements
Winning counts: OrderBy 24, SelectK 34, MedianOf3 41
Winning counts: OrderBy 33, SelectK 33, MedianOf3 33
Winning counts: OrderBy 32, SelectK 38, MedianOf3 29
--- 1m elements
Winning counts: OrderBy 27, SelectK 32, MedianOf3 40
Winning counts: OrderBy 32, SelectK 38, MedianOf3 29
Winning counts: OrderBy 35, SelectK 31, MedianOf3 33
通常来说,草率地实现的quickselect在平均情况下要比您的建议要好三分之二...我想说的是一个非常有力的指标,如果您想了解更多细节,它是更好的算法详细信息。
当然,您的实现显然更容易理解:)
[1]-取自this SO answer的实现,每个递归深度步骤进行3次比较