顺序表,链表总结
自学过数据结构,现在老师又讲了一遍,稍微总结一下。
静态存储的顺序表:
1. 初始化
#include <iostream> #include <algorithm> using std::cout; using std::sort; template <class T> class List { private: int max_size;//数组的最大空间; int len; //当前有效元素的个数,从1开始计数; T * lis; public: List(int m_Max = 10); int get_max(){return max_size;};//获取最大空间 int get_len(){return len;};//获取元素长度 bool append(T m);//在末尾插入元素; int find(const T& n);//遍历查找,返回元素n的下标,若不在元素中,则返回-1; int binary_find(const T&);//二分查找 bool remove (const T& n);//删除元素n; bool exchange_del(const T& n);//交换删除,删除后元素顺序改变,时间复杂度O(1) bool insert(const int & loc, const T & n);//在i位置插入n; void show();//打印数组元素 ~List(); };
2.搜索
2.1顺序搜索
template <class T> int List<T>::find(const T& n)//必须是const,否则find(1)报错:“非常量的引用必须是左值。” //隐式转化引起的,T& n = T(5),对常量修改了,禁止! { int i = 0; while(i < len && lis[i] != n )i++; return i < len ? i : -1 ;//i < len说明说明lis[i]==n }
2.2二分搜索
template <class T> int List<T>::binary_find(const T& n){ sort(lis, lis + len);//排序 int min = 0, max = len - 1, mid = (min + max) / 2; while(min <= max){ if(lis[mid] == n)return mid; else if(lis[mid] < n)min = mid + 1;//在mid + 1,max之间查找 else max = mid -1;//在min, mid -1之间查找 mid = (min + max)/2; } return -1; }
3.插入
插入分为在尾部插入和中间某个位置插入。
在尾部插入:不需要移动元素,时间复杂度O(1);
template <class T> bool List<T>::append(T n) { if(len == max_size)//存储空间已满 return false; lis[len++] = n;//插入,有效元素个数加1 return true; }
如果在中间某个位置插入,则需要把当前元素和后面的元素整体向后移动。(注意,当前元素也要向后移动,要不然就是插入后,当前元素就被删除了)i >=1 且 i <= len,当然了,如果把上面的情况包含进去,1<= i <= len + 1;
时间复杂度分析:第一个位置插入,则需要移动移动len个元素,第二个位置插入,移动len - 1个元素。由于插入每个位置的概率是相等的,
时间复杂度:len + len -1 + len - 2 + len -3 + …… + len -len = (0 + 1 + 2 +……+ len) / len = (len + 1) / 2;
template <class T> bool List<T>::insert(const int & loc , const T & n) { /* loc:插入的位置,从1开始; n:插入的数; */ if(len == max_size || loc < 1 || loc > len + 1)return false; for(int j = len -1; j >= loc-1; j--)lis[j+1] = lis[j];//loc = len + 1 时,循环不执行,因此不需要分开讨论 lis[loc-1]=n; len ++;//这个常常忘记 return true; }
4.删除算法
4.1移动删除
删除的话,其实基本的思想就是覆盖,当删除某个元素时,只需要把后面的元素向前移动,覆盖这个元素。
时间复杂度:删除第一个,后面len -1个元素前移,删除第二个,后面len -2个元素前移,……
len - 1 + len -2 + ……+ 0 = (len -1)/2
template <class T> bool List<T>::remove(const T& n) { int index = binary_find(n);//查找n是否在数组中 if(index == -1)return false; for(int i = index + 1; i < len; i++)lis[i-1]=lis[i]; len--; return true; }
4.2交换删除
我们知道,删除最后一个元素的时间复杂度是O(1),如果我们把要删除的元素与最后一个元素交换,接着删除最后一个元素。当然,这样元素的顺序要改变。
template <class T> bool List<T>::exchange_del(const T& n){ int index = binary_find(n); if(index == -1)return false; //n与最后一个元素交换 T temp = lis[index]; lis[index] = lis[len-1]; lis[len-1] = temp; //删除最后一个元素 len--; }
5.扩容:可以采用new一个新对象,把原对象的内容复制过去,这个对象的存储空间是原来空间的n倍,vector中这个n是2。
6.应用
实现集合类的并,交运算
动态存储的顺序表:
vector源码:https://www.kancloud.cn/digest/mystl/192550
上次在群里看到有个人写了if(vector().size()-1 == -1),结果出了bug,半天找不出来,结果呢,看了源码才知道vector.size()的类型是size_t,
她的定义是typedef unsigned int size_t.初始化的vector.size()为0,0-1结果溢出。
(有点没看懂,以后再来瞅瞅)
单链表:针对节点与链表的关系,大致有一下实现方式。
Node:节点类
LinkList:单链表类 1.嵌套类
LinkList{
Node
} 2.复合类 LinkList{ friend Node; }
LinkList可以访问Node内的数据成员,但是Node不一定可以访问LinkList 3.公有继承方式,
LinkList:public Node{
}
但是这样继承好像在逻辑上有点说不过去。
继承是 is a 关系,也就是说强调子类是一种父类,但是子类又有自己的特点。
has a关系 我们常常采用的是把一个类作为另一个个类的数据成员
is like a : like,像而不是is,这是父类常常是包含公有属性或方法的类。
use a : 我们采用友元函数来实现类与类之间的通信问题。 4.结构体+类采用组合的方式
我们可以在LinkList中声明头节点,初始化节点后,节点把头结点指向首节点。
//定义节点类 template <class T> class Node { public: T data;//数据域 Node<T>* next;//指向下一个节点的指针 //此处即使不初始化,next = NULL;NULL即是0,不可访问的内存 public: Node() { next = nullptr; } };
#include "Node.h" #include <cstdlib> #include <ctime> #include <iostream> using std::cout; using std::endl; //定义一个链表类LinkList //规定:第0个节点表示头结点 template <class T> class LinkList: public Node<T> { private: Node<T>* head;//指向头结点的指针 int length; public: LinkList();//生成头结点 bool get(int i, T& data);//获取第i个节点的数据域的值 bool insert(int i, Node<T>* node);//在第i个位置后面插入节点 bool del(int i);//删除第i个节点 void head_create();//整表创建,头插法 void tail_creat();//尾插法 void show();//遍历链表,并打印所有元素 void reverse();//链表反转 void rev();//构造法实现链表反转 int len();//获取链表长度,便于之后遍历; ~LinkList();//释放内存 };
插入:
1.头插法:就是在每一次插入都把节点放在头结点的后面,这个就像是银行排队的时候,新来的人总是要挤到第一个银行窗口去办理业务。好像可以用来实现栈。时间复杂度O(1)
template <class T> void LinkList<T>::head_create(T val) { Node<T>* node = new Node<T>(val); node -> next = head -> next; head -> next = node; length++; //思考:我们在书写循环语句时,不要总是拿新创建的第一个节点去思考怎样操作 //如果思考如何操作第一个节点,只需要把节点地址赋值给头结点的指针域即可 //然而这样思考,生成第二个节点怎么办,第三个,第四个呢? //你应该想在含有多个节点的链表中在头部插入数据怎么办。 }
2.尾插法:当然就是乖乖排到后面去啦,这才像话。时间复杂度O(n)
template <class T> void LinkList<T>::tail_creat(T val) { Node<T>* node = new Node<T>(val); Node<T>* tail = head; for(int i = 0; i < length; i++){ tail = tail -> next; } tail -> next = node; length++; }
3.中间插入
template <class T> bool LinkList<T>::insert(int i, T val) { Node<T> * ran = head; Node<T>* node = new Node<T>(val); //下标不合法 if(i < 0 || i > length)return false; //遍历到n个节点 for(int i = 0; i < length; i++)ran = ran -> next; //插入 node -> next = ran -> next; ran -> next = node; length++; return true; }
首先定位到这个要插入位置的前一个位置x,让要插入的节点的下一个节点是x的下一个节点,再把x的下一个节点改为要插入的节点。关于这个时间复杂度,网上众说纷纭。
有人认为计算时间复杂度时,我们只考虑原子操作,即不能再分割的关键操作,就是插入。时间复杂度为O(1),另一种看法是你要先定位到这个节点,然后插入时间复杂度是
O(n)。我还是同意后者。那么又有个问题,顺序表插入也是O(n),那为啥说链表比你顺序表插入快。其实,仔细分析就可以知道,顺序表的插入是先定位,再移动。数组这个东西,
存取快,时间复杂度是O(1),移动,实际上是寄存器大量的写操作。而单链表呢,首先是时间复杂度为O(n)的定位操作,这个是读操作,再进行O(1)的写,我们知道读操作比写操作快,
所以我们抓主要矛盾,就写操作而言,链表自然插入快得多。
下次回答链表时间复杂度这个问题,就要分情况说。
删除
1.删除中间节点
这个简单,遍历到要删除的节点的前一个节点x,改变x指针的指向即可,就是把x的next指针指向下一个节点的下一个节点。
template <class T> bool LinkList<T>::del(int i) { //遍历到i-1个节点 int j = 1; Node<T>* p = head; while(j < i && p != nullptr){ j++; p = p -> next; } //p == nullptr说明i大了 //当遍历到最后一个节点的时候,这时候也是无法删除的,也就是说,p只能在第一个节点和倒数 //第二个节点之间。这一点和插入不一样,你插入的话,可以遍历到最后一个就节点。可以把最后一个的节点的 //下一个NULL节点也视为一个节点。而且先判断p是否为null,在判断p->next是否是null //如果先判断p -> next是否为null的话,p为null时出现段错误。也就是说,p不为nullptr时,才能使用p -> next if( p == nullptr || p -> next == nullptr || i <= 0 || j > i)return false;//j > i使得程序更健壮 //删除节点 Node<T>* des = p -> next; p -> next = des -> next; delete des; length--; return true; }
2.删除第一个节点,引入头结点
说实话,如果没有头结点的话,删除第一个节点和删除中间节点很难统一起来,因为你要遍历到要删除节点的前一个节点,第一个节点的前一个节点是啥,喔喔,是头结点。
所以,头结点用处大大的,这种思想叫哨兵思想。代码和上面差不多。
3.覆盖删除
上次刷leetcode时,遇到这么一道题。
//删除一个链表中的节点(非尾节点),但是只给你要删除的节点指针。
//一开始,我还很纳闷儿,链表头结点都不告诉
//后面看了题解,这真是鬼才,他用后一个节点直接覆盖这个要删除的节点。这里说的覆盖是内存中的内容,和深拷贝有点像。
//不过要删除节点的后面那个节点内存可能不被释放,但我也不清楚链表内存分配是否是用new来动态分配,还是不要delete的好。
/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode(int x) : val(x), next(NULL) {} * }; */ class Solution { public: void deleteNode(ListNode* node) { * node = *(node -> next); } };
反转
1.双指针法
class Solution { public: ListNode* reverseList(ListNode* head) { ListNode* pre = nullptr;//第一个节点没有前一个节点,初始化为null ListNode* cur = head;//当前节点 ListNode* temp;//后一个节点 while(cur != nullptr){ temp = cur -> next;//因为后面要修改cur -> next,提前保存下来 cur -> next = pre; //下面两条语句顺序不能颠倒 pre = cur; cur = temp; } return pre;//退出循环后,cur为null,pre为新链表的第一个节点 } };
3.头插法。头插法每次提交内存占用最低,然而最慢。谁能告诉我为啥?
template <class T> void LinkList<T>::head_reverse(){ //1.链表为空,不用反转 if(head -> next == nullptr)return; //2.头插法反转 Node<T>* vir_head = new Node<T>; Node<T>* ran = head -> next; Node<T>* p; while(ran != nullptr){ p = ran -> next; ran -> next = vir_head -> next; vir_head -> next = ran; ran = p; } delete head; head = vir_head; }