简介
数据结构的本质,只有两种结构,数组与链表。其它的都是它的衍生与组合
算法的本质就是穷举。
数组
数组可以分为两大类,静态数组与动态数组。
静态数组的本质是一段连续的内存,因为是连续的,所以我们可以采用偏移量的方式来对元素实现快速访问。
而动态数组则是对静态数组的封装,使得更加方便操作元素。有了动态数组,后续的栈,哈希,队列都能更加优雅的实现。
静态数组
- 数组的超能力
随机访问。只要任意一个索引,都能推测出元素的内存地址,而计算机的内存寻址能力为Log(1),所以数组的随机访问时间复杂度也同样为Log(1)
- 数组的局限性
由于数组的大小是固定的,所以当数组满了,或者需要在中间插入/删除时。都需要移动元素,这时候时间复杂度就上升为Log(N)
动态数组
动态数组无法解决静态数组Log(N)的问题,它只是帮你隐藏了动态扩容与元素搬移的过程,以及更加易用的API。
数组随机访问的超能力源于数组连续的内存空间,而连续的内存空间就不可避免地面对元素搬移和扩缩容的问题
一个简单的动态数组
- public class MyList<T>()
-
- {
- //真正存储数据的底层
- private T[] arr = new T[5];
- //记录元素的数量
- public int Count { get; private set; }
- /// <summary>
- /// 增
- /// </summary>
- /// <param name="item"></param>
- public void Add(T item)
- {
- if (Count == arr.Length)
- {
- //扩容
- Resize(Count * 2);
- }
- arr[Count] = item;
- Count++;
- }
- /// <summary>
- /// 删
- /// </summary>
- /// <param name="idx"></param>
- public void RemoveAt(int idx)
- {
- if (Count == arr.Length / 4)
- {
- //缩容
- Resize(arr.Length / 2);
- }
- Count--;
- for (int i = idx; i < Count; i++)
- {
- arr[i] = arr[i + 1];
- }
-
- arr[Count] = default(T);
-
- }
- public void Remove(T item)
- {
- var idx = FindIndex(item);
- RemoveAt(idx);
- }
- /// <summary>
- /// 改
- /// </summary>
- /// <param name="idx"></param>
- /// <param name="newItem"></param>
- public void Put(int idx,T newItem)
- {
- arr[idx] = newItem;
- }
- /// <summary>
- /// 查
- /// </summary>
- /// <param name="item"></param>
- /// <returns></returns>
- public int FindIndex(T item)
- {
- for(int i = 0; i < arr.Length; i++)
- {
- if (item.Equals(arr[i]))
- return i;
- }
- return -1;
- }
-
- /// <summary>
- /// 扩容/缩容操作
- /// </summary>
- /// <param name="initCapacity"></param>
- private void Resize(int initCapacity)
- {
- var newArray=new T[initCapacity];
- for(var i = 0; i < Count; i++)
- {
- newArray[i] = arr[i];
- }
- arr = newArray;
- }
-
- }
复制代码 数组的变种:环形数组
有人可能会问了?数组不是一段连续的内存吗?怎么可能是环形的?
从物理角度出发,这确实不可能。但从逻辑角度出发,这是有可能的。
其核心内容就是利用求模运算- public static void Run()
- {
- var arr = new int[] { 1, 2, 3, 4, 5, 6 };
- var i = 0;
- while (arr.Length > 0)
- {
- Console.WriteLine(arr[i]);
- //关键代码在此,当i遍历到末尾时,i+1与arr.Length去余数变成0
- //从逻辑上完成了闭环
- i = (i + 1) % arr.Length;
- if ((i % arr.Length) == 0)
- {
- Console.WriteLine("完成了一次循环,i归零");
- Thread.Sleep(1000);
- }
- }
- }
复制代码环形数组的关键在于,它维护了两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引
环形数组解决了什么问题?数组在头部增删从O(N),优化为O(1)
一个简单的环形数组
点击查看代码链表
链表分为单链表与双链表,单链表只有一个指针,指向next元素。双链表有两个指针,分别指向previous与next。
除此之外并无其它区别。主要功能区别在于能否向前遍历。
为什么需要链表
前面说到,数组的本质是一段连续的内存,当元素移动/扩容时,需要one by one 移动,花销很大。
那有没有一种能突破内存限制的数据结构呢?链表就应运而生。链表不需要连续内存,它们可以分配在天南海北,它们之间的联系靠next/prev链接,将零散的元素串成一个链式结构。
这么做有两个好处
- 提高内存利用率,分配在哪都可以。所以可以降低内存碎片
- 方便扩容与移动,只需要重新指向next/previous 即可实现增,删,改等操作,无需移动元素与扩容。
但万物皆有代价,因为链表的不连续性,所以无法利用快速随机访问来定位元素,只能一个一个的遍历来确定元素。因此链表的查询复杂度为Log(N)
一个简单的链表
链表的变种:跳表
在上面简单的例子中,查询的复杂度为O(N),插入的复杂度为O(1).
主要消耗在查询操作,只能从头结点开始,逐个遍历到目标节点。
所以我们优化的重点就在于优化查询。
上面的例子中,我们使用了虚拟头尾节点来空间换时间,提高插入效率。同样的,我们也可以采用这个思路来提高查询效率
跳表核心原理
- index 0 1 2 3 4 5 6 7 8 9
- node a->b->c->d->e->f->g->h->i->j
复制代码 此时此刻,你想拿到h的节点,你需要从0开始遍历直到7。
这时候你就想,如果我能提前知道6的位置就好了,这样我就只需要Next就能快速得到h
调表就是如此- indexLevel 0-----------------------8-----10
- indexLevel 0-----------4-----------8-----10
- indexLevel 0-----2-----4-----6-----8-----10
- indexLevel 0--1--2--3--4--5--6--7--8--9--10
- nodeLevel a->b->c->d->e->f->g->h->i->j->k
复制代码 调表在原链表的基础上,增加了多层索引,每向上一层,索引减少一半,所以索引的高度是O(log N)
- 首先从最高层索引开始往下搜,索引7在[0,8]区间
- 从节点0开始,发现7在【4,8】,拿到节点4的地址
- 从节点4开始,发现7在【6,8】,拿到节点6的地址
- 从节点6开始,发现7在【6,7】,最终找到节点7
在搜索的过程中,会经过O(log N)层索引,所以时间复杂度为O(log N)
调表实现比较复杂,当新增与删除时,还需考虑索引的动态调整,需要保证尽可能的二分,否则时间复杂度又会退化为O(N)
有点类似自平衡的二叉搜索数,不过相对来说比较简单。
一个简单的跳表
挖坑待埋
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |