找回密码
 立即注册
首页 业界区 业界 重生之数据结构与算法----数组&链表

重生之数据结构与算法----数组&链表

昆拗干 2025-6-4 22:22:44
简介

数据结构的本质,只有两种结构,数组与链表。其它的都是它的衍生与组合
算法的本质就是穷举。
数组

数组可以分为两大类,静态数组与动态数组。
静态数组的本质是一段连续的内存,因为是连续的,所以我们可以采用偏移量的方式来对元素实现快速访问。
而动态数组则是对静态数组的封装,使得更加方便操作元素。有了动态数组,后续的栈,哈希,队列都能更加优雅的实现。
静态数组


  • 数组的超能力
    随机访问。只要任意一个索引,都能推测出元素的内存地址,而计算机的内存寻址能力为Log(1),所以数组的随机访问时间复杂度也同样为Log(1)
  • 数组的局限性
    由于数组的大小是固定的,所以当数组满了,或者需要在中间插入/删除时。都需要移动元素,这时候时间复杂度就上升为Log(N)
动态数组

动态数组无法解决静态数组Log(N)的问题,它只是帮你隐藏了动态扩容与元素搬移的过程,以及更加易用的API。
数组随机访问的超能力源于数组连续的内存空间,而连续的内存空间就不可避免地面对元素搬移和扩缩容的问题
一个简单的动态数组
  1. public class MyList<T>()
  2.    
  3. {
  4.     //真正存储数据的底层
  5.     private T[] arr = new T[5];
  6.     //记录元素的数量
  7.     public int Count { get; private set; }
  8.     /// <summary>
  9.     /// 增
  10.     /// </summary>
  11.     /// <param name="item"></param>
  12.     public void Add(T item)
  13.     {
  14.         if (Count == arr.Length)
  15.         {
  16.             //扩容
  17.             Resize(Count * 2);
  18.         }
  19.         arr[Count] = item;
  20.         Count++;
  21.     }
  22.     /// <summary>
  23.     /// 删
  24.     /// </summary>
  25.     /// <param name="idx"></param>
  26.     public void RemoveAt(int idx)
  27.     {
  28.         if (Count == arr.Length / 4)
  29.         {
  30.             //缩容
  31.             Resize(arr.Length / 2);
  32.         }
  33.         Count--;
  34.         for (int i = idx; i < Count; i++)
  35.         {
  36.             arr[i] = arr[i + 1];
  37.         }
  38.         
  39.         arr[Count] = default(T);
  40.         
  41.     }
  42.     public void Remove(T item)
  43.     {
  44.         var idx = FindIndex(item);
  45.         RemoveAt(idx);
  46.     }
  47.     /// <summary>
  48.     /// 改
  49.     /// </summary>
  50.     /// <param name="idx"></param>
  51.     /// <param name="newItem"></param>
  52.     public void Put(int idx,T newItem)
  53.     {
  54.         arr[idx] = newItem;
  55.     }
  56.     /// <summary>
  57.     /// 查
  58.     /// </summary>
  59.     /// <param name="item"></param>
  60.     /// <returns></returns>
  61.     public int FindIndex(T item)
  62.     {
  63.         for(int i = 0; i < arr.Length; i++)
  64.         {
  65.             if (item.Equals(arr[i]))
  66.                 return i;
  67.         }
  68.         return -1;
  69.     }
  70.    
  71.     /// <summary>
  72.     /// 扩容/缩容操作
  73.     /// </summary>
  74.     /// <param name="initCapacity"></param>
  75.     private void Resize(int initCapacity)
  76.     {
  77.         var newArray=new T[initCapacity];
  78.         for(var i = 0; i < Count; i++)
  79.         {
  80.             newArray[i] = arr[i];
  81.         }
  82.         arr = newArray;
  83.     }
  84.    
  85. }
复制代码
数组的变种:环形数组

有人可能会问了?数组不是一段连续的内存吗?怎么可能是环形的?
从物理角度出发,这确实不可能。但从逻辑角度出发,这是有可能的。
其核心内容就是利用求模运算
  1.         public static void Run()
  2.         {
  3.             var arr = new int[] { 1, 2, 3, 4, 5, 6 };
  4.             var i = 0;
  5.             while (arr.Length > 0)
  6.             {
  7.                 Console.WriteLine(arr[i]);
  8.                 //关键代码在此,当i遍历到末尾时,i+1与arr.Length去余数变成0
  9.                 //从逻辑上完成了闭环
  10.                 i = (i + 1) % arr.Length;
  11.                 if ((i % arr.Length) == 0)
  12.                 {
  13.                     Console.WriteLine("完成了一次循环,i归零");
  14.                     Thread.Sleep(1000);
  15.                 }
  16.             }
  17.         }
复制代码
环形数组的关键在于,它维护了两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引
环形数组解决了什么问题?数组在头部增删从O(N),优化为O(1)
一个简单的环形数组

点击查看代码
  1.     public class CircularArray<T>: IEnumerable<T>
  2.     {
  3.         public static void Run()
  4.         {
  5.             var arr = new CircularArray<string>();
  6.             arr.AddLast("4");
  7.             arr.AddLast("5");
  8.             arr.AddFirst("3");
  9.             arr.AddFirst("2");
  10.             arr.AddFirst("1");
  11.             foreach (var item in arr)
  12.             {
  13.                 Console.WriteLine(item);
  14.             }
  15.         }
  16.         private T[] _array;
  17.         private int _head;
  18.         private int _tail;
  19.         public int Count { get; private set; }
  20.         public int Capacity { get; private set; }
  21.         public CircularArray()
  22.         {
  23.             var capacity = 10;
  24.             _array = new T[capacity];
  25.             _head = 0;
  26.             _tail = 0;
  27.             Count = 0;
  28.             Capacity = capacity;
  29.         }
  30.         /// <summary>
  31.         /// 扩容/缩容
  32.         /// </summary>
  33.         /// <param name="capacity"></param>
  34.         private void Resize(int capacity)
  35.         {
  36.             var newArr=new T[capacity];
  37.             for(int i=0; i < Count; i++)
  38.             {
  39.                 newArr[i] = _array[(_head + i) % Capacity];
  40.             }
  41.             _array = newArr;
  42.             //重置指针
  43.             _head = 0;
  44.             _tail = Count;
  45.             Capacity = capacity;
  46.         }
  47.         /// <summary>
  48.         /// 在头部添加元素
  49.         /// O(1)
  50.         /// </summary>
  51.         /// <param name="item"></param>
  52.         public void AddFirst(T item)
  53.         {
  54.             if (Count == Capacity)
  55.             {
  56.                 Resize(Capacity * 2);
  57.             }
  58.             _head = (_head - 1 + Capacity) % Capacity;
  59.             _array[_head] = item;
  60.             Count++;
  61.         }
  62.         /// <summary>
  63.         /// 在尾部添加元素
  64.         /// </summary>
  65.         /// <param name="item"></param>
  66.         public void AddLast(T item)
  67.         {
  68.             if (Count == Capacity)
  69.             {
  70.                 Resize(Capacity * 2);
  71.             }
  72.             _array[_tail] = item;
  73.             _tail = (_tail + 1) % Capacity;
  74.             Count++;
  75.         }
  76.         /// <summary>
  77.         /// 在尾部删除
  78.         /// </summary>
  79.         public void RemoveLast()
  80.         {
  81.             _tail = (_tail - 1 + Capacity) % Capacity;
  82.             _array[_tail] = default;
  83.             Count--;
  84.         }
  85.         /// <summary>
  86.         /// 在头部删除
  87.         /// </summary>
  88.         public void RemoveFirst()
  89.         {
  90.             _array[_head] = default;
  91.             _head = (_head + 1) % Capacity;
  92.             Count--;
  93.         }
  94.         /// <summary>
  95.         /// 获取头部元素
  96.         /// </summary>
  97.         /// <returns></returns>
  98.         public T GetFirst()
  99.         {
  100.             return _array[_head];
  101.         }
  102.         /// <summary>
  103.         /// 获取尾部元素
  104.         /// </summary>
  105.         /// <returns></returns>
  106.         public T GetLast()
  107.         {
  108.             return _array[(_tail - 1 + Capacity) % Capacity];
  109.         }
  110.         public T Get(int idx)
  111.         {
  112.             return _array[idx];
  113.         }
  114.         public IEnumerator<T> GetEnumerator()
  115.         {
  116.             for (int i = 0; i < Count; i++)
  117.             {
  118.                 var index = (_head + i) % Capacity;
  119.                 yield return _array[index];
  120.             }
  121.         }
  122.         IEnumerator IEnumerable.GetEnumerator()
  123.         {
  124.             return GetEnumerator();
  125.         }
  126.     }
复制代码
链表

链表分为单链表与双链表,单链表只有一个指针,指向next元素。双链表有两个指针,分别指向previous与next。
除此之外并无其它区别。主要功能区别在于能否向前遍历。
为什么需要链表

前面说到,数组的本质是一段连续的内存,当元素移动/扩容时,需要one by one 移动,花销很大。
那有没有一种能突破内存限制的数据结构呢?链表就应运而生。链表不需要连续内存,它们可以分配在天南海北,它们之间的联系靠next/prev链接,将零散的元素串成一个链式结构。
这么做有两个好处

  • 提高内存利用率,分配在哪都可以。所以可以降低内存碎片
  • 方便扩容与移动,只需要重新指向next/previous 即可实现增,删,改等操作,无需移动元素与扩容。
但万物皆有代价,因为链表的不连续性,所以无法利用快速随机访问来定位元素,只能一个一个的遍历来确定元素。因此链表的查询复杂度为Log(N)
一个简单的链表
  1. public class MyLinkedList<T>
  2. {
  3.     public static void Run()
  4.     {
  5.         var linked = new MyLinkedList<string>();
  6.         linked.AddLast("a");
  7.         linked.AddLast("b");
  8.         linked.AddLast("c");
  9.         linked.AddLast("d");
  10.         linked.Add(1, "bc");
  11.         linked.Put(1, "aaaa");
  12.         Console.WriteLine(linked.ToString()) ;
  13.     }
  14.     /// <summary>
  15.     /// 虚拟头尾节点,有两个好处
  16.     /// 1.无论链表是否为空, 两个虚拟节点都存在,避免很多边界值处理的情况。
  17.     /// 2.如果要在尾部插入数据,如果不知道尾节点,那么需要复杂度退化成O(N),因为要从头开始遍历到尾部。
  18.     /// </summary>
  19.     private Node _head, _tail;
  20.     public int Count { get; private set; }
  21.     public MyLinkedList()
  22.     {
  23.         _tail = new Node();
  24.         _head = new Node();
  25.         _head.Next = _tail;
  26.         _tail.Prev = _head;
  27.     }
  28.     public void AddLast(T item)
  29.     {
  30.         var prev = _tail.Prev;
  31.         var next = _tail;
  32.         var node = new Node(item);
  33.         node.Next = next;
  34.         node.Prev = prev;
  35.         prev.Next = node;
  36.         next.Prev = node;
  37.         Count++;
  38.     }
  39.     public void AddFirst(T item)
  40.     {
  41.         var prev = _head;
  42.         var next = _head.Next;
  43.         var node=new Node(item);
  44.         node.Prev= prev;
  45.         node.Next= next;
  46.         prev.Next= node;
  47.         next.Prev = node;
  48.         Count++;
  49.     }
  50.     public void Add(int idx,T item)
  51.     {
  52.         var t = Get(idx);
  53.         var next = t.Next;
  54.         var prev = t;
  55.         var node = new Node(item);
  56.         node.Next = next;
  57.         node.Prev = prev;
  58.         prev.Next = node;
  59.         next.Prev = node;
  60.     }
  61.     public void Remove(int idx)
  62.     {
  63.         var t = Get(idx);
  64.         var prev = t.Prev;
  65.         var next = t.Next;
  66.         prev.Next = next;
  67.         next.Prev = next;
  68.         t = null;
  69.         Count--;
  70.     }
  71.     public void Put(int idx,T item)
  72.     {
  73.         var t = Get(idx);
  74.         t.Value= item;
  75.     }
  76.     private Node? Get(int idx)
  77.     {
  78.         var node = _head.Next;
  79.         //这里有个优化空间,可以通过idx在Count的哪个区间。从而决定从head还是从tail开始遍历
  80.         for (int i = 0; i < idx; i++)
  81.         {
  82.             node = node.Next;
  83.         }
  84.         return node;
  85.     }
  86.    
  87.     public override string ToString()
  88.     {
  89.         var sb = new StringBuilder();
  90.         var node = _head.Next;
  91.         while (node != null && node.Value != null)
  92.         {
  93.             sb.Append($"{node.Value}<->");
  94.             node = node.Next;
  95.         }
  96.         sb.Append("null");
  97.         return sb.ToString();
  98.     }
  99.     private class Node
  100.     {
  101.         public T? Value { get; set; }
  102.         public Node Next { get; set; }
  103.         public Node Prev { get; set; }
  104.         public Node()
  105.         {
  106.             Value=default(T);
  107.         }
  108.         public Node(T value)
  109.         {
  110.             Value = value;
  111.         }
  112.     }
  113. }
复制代码
链表的变种:跳表

在上面简单的例子中,查询的复杂度为O(N),插入的复杂度为O(1).
主要消耗在查询操作,只能从头结点开始,逐个遍历到目标节点。
所以我们优化的重点就在于优化查询。
上面的例子中,我们使用了虚拟头尾节点来空间换时间,提高插入效率。同样的,我们也可以采用这个思路来提高查询效率
跳表核心原理
  1. index  0  1  2  3  4  5  6  7  8  9
  2. node   a->b->c->d->e->f->g->h->i->j
复制代码
此时此刻,你想拿到h的节点,你需要从0开始遍历直到7。
这时候你就想,如果我能提前知道6的位置就好了,这样我就只需要Next就能快速得到h
调表就是如此
  1. indexLevel   0-----------------------8-----10
  2. indexLevel   0-----------4-----------8-----10
  3. indexLevel   0-----2-----4-----6-----8-----10
  4. indexLevel   0--1--2--3--4--5--6--7--8--9--10
  5. 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)
有点类似自平衡的二叉搜索数,不过相对来说比较简单。
一个简单的跳表

挖坑待埋

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册