参考资料:拉勾网课程《重学数据结构与算法》 公瑾
本笔记中代码全部使用C#实现,C#也很易读,不妨碍其他语言的用户参考。但本笔记的参考资料,也就是《重学数据结构与算法》这门课程中的代码是Java实现。
如何灵活使用数据结构?数据处理的基本操作有哪些?
课程中有一个例题非常不错。有一个数组[1,2,3,4,5,5,6],查出其中出现次数最多的元素的值。显然该数组出现次数最多的元素的值为5,它出现了两次。
普通的做法:
static void Main(string[] args)
{
int[] arr = {1,2,3,4,5,5,6};
int val_max = -1; // 出现次数最多的元素的值
int time_max = 0; // 出现次数最多的元素的出现次数
int time_tmp = 0; // 临时储存当前元素的出现次数
for (int i=0; i<arr.Length; i++)
{
time_tmp = 0;
for (int j=0; j<arr.Length; j++)
{
if (arr[i] == arr[j])
{
time_tmp += 1;
}
if (time_tmp > time_max)
{
time_max = time_tmp;
val_max = arr[i];
}
}
}
Console.WriteLine(val_max);
}
输出的结果为5,结果正确。考虑一下如何优化这段代码。上一篇数据结构的文章中提到过利用复杂度来优化程序的三步:
- 暴力解法:在没有限制的情况下完成开发。
- 处理无效操作:删掉代码中的无效存储和无效计算,降低时间和空间复杂度。
- 时空转换:设计合理数据结构,将时间复杂度转移到空间复杂度上。
这段代码相当于已经完成了步骤1。分析一下这段代码,这段代码中没有无效计算,需要考虑数据结构的一些方法来进行优化,把时间复杂度转移到空间复杂度上。代码有两层for循环,时间复杂度为O(n^2),我们考虑用一个for循环来解决问题。这时候往往就能想到大学的数据结构课程中讲过的“字典”这个数据结构。
为何要使用字典?
先分析一下此处代码需要对数据进行哪些操作:
- 根据原始数组计算每个元素出现的次数;
- 根据1.的结果,找到出现次数最多的元素。
步骤1.
如何处理1.统计出现的次数呢?该选择哪种数据结构来存储呢?假设我们此时还没有选择字典作为数据结构,我们选择数据结构A。
对每一次的for循环,都得到数组中的元素arr[i],每次循环得到它之后,就要判断它在数据结构A中是否出现过。
- 如果出现了,就需要对出现的次数加1。
- 如果没有出现过,则把这个元素新增到数据结构A中,并且把次数赋值为1。
一共进行过三种数据操作:
- 查找:看能否在数据结构A中查找到这个元素,即判断元素是否出现过。
- 新增:若没有出现过,新增这个元素。
- 改动:若出现过,对这个元素出现的次数加1。
步骤2.
上面已经分析完步骤1.的操作,再看步骤2.。步骤2.是访问数据结构A中的各个元素,找到出现次数最多的元素。只涉及查找操作。
假设数据结构A为数组,使用两个数组分别按照对应顺序记录arr中的元素和对应元素的出现次数。想查找数组中的元素只能逐一访问,时间复杂度是O(n)。在O(n)复杂度的for循环中,又嵌套了O(n)复杂度的查找动作,所以时间复杂度是 O(n^2),没有变化。
字典类型的数据结构能在O(1)时间复杂度内完成查找操作。字典的查找是通过键值对的匹配完成的。先通过一次for循环,将数组转变为元素(Key)-出现次数(Value)的一个字典。再去遍历一遍这个字典,找到出现次数最多的那个元素,就能找到最后的结果了。
static void Main(string[] args)
{
int[] arr = { 1, 2, 3, 4, 5, 5, 6 };
var dic = new Dictionary<int, int>();
for (int i = 0; i < arr.Length; i++)
{
if (dic.ContainsKey(arr[i]))
{
dic[arr[i]]++;
}
else
{
dic.Add(arr[i], 1);
}
}
int val_max = -1; // 出现次数最多的元素的值
int time_max = 0; // 出现次数最多的元素的出现次数
foreach (var kv in dic) // kv是键值对的意思,也就是字典中的一项
{
if (kv.Value > time_max)
{
time_max = kv.Value;
val_max = kv.Key;
}
}
Console.WriteLine(val_max);
}
课程中的代码是Java实现。我对Java了解不多,只会一些基础语法,不敢多数,只是觉得课程中代码使用字典的时候确实有点憨,甚至引入了一个其他变量才能最终实现这个功能。使用C#的字典时没有那么麻烦。并不是说C#比Java好,Java用户很多,所以这门语言不可能不好,如果不好,哪来的这么多用户,但因为我是一个编程初学者,没有资格评判这些,所以不再多说。
上述代码实现了跟起初的那段代码同样的功能,但这段代码的两个for/foreach不是嵌套关系,也就是说时间复杂度是O(n)+O(n)=O(2n),也就还是O(n)。通过采用更复杂且高效的数据结构,完成了时间复杂度到空间复杂度的转移,提高了空间复杂度,成功把时间复杂度指数级的下降了。
再看空间复杂度。无论使用数组还是字典,都需要额外的存储空间来存储数据,空间复杂度都为O(n)。
如何设计合理的数据结构?
从问题本身出发,我们可以采用这样的思考顺序:
- 分析这段代码到底对数据先后进行了哪些操作。
- 根据分析出来的数据操作,找到合理的数据结构。
理清了数据处理的基本操作,更复杂的问题也是这些基本操作的叠加和组合。只要按这种逻辑进行思考,就可以轻松设计出合理的数据结构。
代码对数据的处理就是代码对输入的数据进行计算,得到结果并输出。数据处理的操作就是找到需要处理的数据,计算结果,保存结果。套路如下:
- 找到待处理的数据。即按照条件进行查找。
- 将结果存到新的内存空间中。即在现有数据上进行新增。
- 将结果存到一个已使用的内存空间中。先删除内存空间中的已有数据,再新增新的数据。
很复杂的代码对数据的处理也只有这3个基本操作,增、删、查。只要围绕这3个操作进行分析,就能得出解决问题的最优方案。分析方法:
- 这段代码对数据进行了哪些操作?
- 这些操作中,哪个操作最影响效率,对时间复杂度的损耗最大?
- 哪种数据结构最能帮助你提高数据操作的使用效率?
查找、新增、删除
查找就是从数据结构中找到满足某个条件的元素的过程。一般有两种:
- 根据元素的位置或索引来查找
- 根据元素的数值特征来查找
数组的查找:
从数组arr中查找数组的第n个元素,也就是n-1号arr[n-1],时间复杂度为O(1),因为数组有索引,有index,可以直接找到。
链表的查找:
链表和数组的空间复杂度都是O(n),但数组有index的索引,链表没有。链表通过指针,按某个顺序连在一起。要查找其第n个元素,就必须要先知道第一个元素在哪里,按照第一个元素的next指针寻找第二个元素,然后继续找下去。只能通过从前往后的顺序一个一个去查找。因为链表因为没有索引。查找的时间复杂度是O(n)。
如果要查找数据结构中数值等于v的元素是否存在。这样,数组和链表就都不行了,需要按顺序一个一个判断当前元素数值是否等于v,时间复杂度为O(n)。但如果借助“字典”这种数据结构,就能在O(1)内完成查找。我个人感觉就像给数据结构加上了索引。这也顺便让我理解了为什么给数据库加索引能提高访问速度,为什么Redis这么快。
除了查找,还有新增和删除。
在复杂数据结构中,在尾端新增一条数据,对原先的数据结构没有影响。首先查找到最后一个数据的位置,然后在这个位置之后,通过新增操作赋值或插入一条新数据。
在原数据结构中间新增一条数据,会对原数据结构产生影响,即插入元素的位置之后的元素数据的位置依次加1。
删除也一样,分为尾端删除和中间删除。
如何分析操作过程?
在某个复杂数据结构中,在第2个元素之后新增一条数据。随后再删除第1个满足数值大于6的元素。分析操作过程,有两个步骤的操作:
- 在第2个元素之后新增一条数据。包含了查找和新增两个操作,查找第二个元素的位置,并在数据结构中间新增一条数据。
- 删除第1个数值大于6的元素。包含查找和删除两个操作,即查找出第二个数值大于6的元素的位置,并删除这个位置的元素。
因此,需要完成的操作包括按照位置的查找、新增和按照数据数值的查找、删除。这就是一个典型的分析操作过程的步骤。
学拉勾网《重学数据结构与算法》这门课程到这里,我学到了如何使用“数据结构”。感觉学校的课程只是教了我们一些数据结构,但我完全不知道怎么用,什么时候用。公瑾老师的这门课算是我学习数据结构与算法的引导课,引领我入门。目前课程就更新到这里,也是希望能尽快更新,我现在是求知若渴,哈哈。公瑾老师不愧是搞机器学习算法的,搞通用算法也是非常厉害。看来用“内功”来形容数据结构与算法,是一点也不虚。搞会了它们,就锻炼了人的一种解决问题的思路,无论是在编程中做什么,都是用得到的。
笔记中或多或少出现了一些课程的付费内容,感觉我的笔记有帮助的朋友,不妨也去购买一波课程,两位数的价格非常便宜。为知识付费,支持更多高质量的内容产出。