📉
leetcode-题解
  • leetcode-notes
  • linked-list
    • 2-Add-Two-Numbers
    • 109-Convert-Sorted-List-to-Binary-Search-Tree
    • 19-Remove-Nth-Node-From-End-of-List
    • 92-Reverse-Linked-List-II
    • 142-Linked-List-Cycle-II
    • 83-Remove-Duplicates-from-Sorted-List
    • 61-Rotate-List
    • 148-Sort-List
    • 86-Partition-List
    • 82-Remove-Duplicates-from-Sorted-List-II
    • 138-Copy-List-with-Random-Pointer
    • 328-Odd-Even-Linked-List
    • 23- Merge-k-Sorted-Lists
    • 25-Reverse-Nodes-in-k-Group
  • templates
    • bitmap
    • ologn
    • Morris
    • dp
    • binary-search
    • Maxwindow
    • 递归
    • union
    • graph
    • greedy-algorithm
    • kmp
    • list
    • ordered-list
    • tree
    • Manacher
    • Monotonic-stack
    • big-data
    • sort-Summary
    • Bucket-sort
    • bit-opreation
    • heap-sort
  • arrays
    • others
      • 31-Next-Permutation
      • 66-Plus- One
      • 229-Majority-Element-II
      • 414-Third-Maximum-Number
    • matrix
      • 74-Search-a-2D-Matrix
      • 289-Game-of-Life
    • PrefixOrSuffix
      • 560-Subarray-Sum-Equals-K
      • 238-Product-of-Array-Except-Self
    • 二分法
      • rotated-array-problem
      • D天内送达包裹的能力
      • 162-Find-Peak-Element
      • Minimize-maximum-and-maximize-minimum
    • 多指针
      • 611-Valid-Triangle-Number
      • 228-Summary-Ranges
      • 75-Sort-Colors
      • 18-4Sum
      • 27-Remove-Element
      • 三数之和
      • 26-Remove-Duplicates-from-Sorted-Array
      • 盛最多水的容器
      • 80-Remove-Duplicates-from-Sorted-Array-II
      • 最接近的三数之和
    • array-circle
      • 457-Circular-Array-Loop
      • 287-Find-the-Duplicate-Number
      • 565-Array-Nesting
    • 智力题
      • 73-Set-Matrix-Zeroes
      • 最佳观光组合
    • 几何问题
      • 统计全为1的正方形子矩阵
      • 495-Teemo-Attacking
    • sort
      • 88-Merge-Sorted-Array
      • 57-Insert-Interval
  • tree
    • 105-Construct-Binary-Tree-from-Preorder-and-Inorder-Traversal
    • 230-Kth-Smallest-Element in-a-BST
    • 106-Construct-Binary-Tree-from-Inorder-and-Postorder-Traversal
    • 257-Binary-Tree-Paths
    • 113-Path-Sum-II
    • 96-Unique-Binary-Search-Trees
    • 124-Binary-Tree-Maximum-Path-Sum
    • 103-Binary-Tree-Zigzag-Level-Order-Traversal
    • 426-Convert-Binary-Search-Tree-to-Sorted-Doubly-Linked-List
    • 117-Populating-Next-Right-Pointers-in-Each-Node-II
    • 99-Recover-Binary-Search-Tree
    • 366-Find-Leaves-of-Binary-Tree
    • 337-House-Robber-III
    • 333-Largest-BST-Subtree
    • 298-Binary-Tree-Longest-Consecutive-Sequence
    • 428-Serialize-and-Deserialize-N-ary-Tree
    • 1367-Linked-List-in-Binary-Tree
    • 173-Binary-Search-Tree-Iterator
    • 98-Validate-Binary-Search-Tree
    • 156-Binary-Tree-Upside-Down
    • 404-Sum-of-Lef- Leaves
    • 255-Verify-Preorder-Sequence-in-Binary-Search-Tree
    • 272-Closest-Binary-Search-Tree-Value-II
    • 95-Unique-Binary-Search-Trees-II
    • 222-Count-Complete-Tree-Nodes
    • 431-Encode-N-ary-Tree to-Binary-Tree
    • Lowest-Common-Ancestor-of-a-Binary-Tree
    • 129-Sum-Root-to-Leaf-Numbers
  • recursive
    • 前言
    • 39-Combination-Sum
    • 79-Word-Search
    • 04-Power-Set-LCCI
    • 前言
    • 90-Subsets-II
    • 40-Combination-Sum-II
    • 351-Android-Unlock-Patterns
  • dynamic-programming
    • 276-Paint-Fence
    • 132-Palindrome-Partitioning-II
    • 361-Bomb-Enemy
    • 62-Unique-Paths
    • 376-Wiggle-Subsequence
    • 403-Frog-Jump
    • 32-Longest-Valid-Parentheses
    • 97-Interleaving-String
    • 354-Russian-Doll-Envelopes
    • 279-Perfect-Squares
    • 304-Range-Sum-Query-2D-Immutable
    • 10-Regular-Expression-Matching
    • Paint-House-series
    • 139-Word-Break
    • Best-Time-to-Buy-and-Sell-Stock-series
    • 416-Partition-Equal-Subset-Sum
    • 300-Longest-Increasing-Subsequence
    • 91-Decode-Ways
    • Ugly-Number-series
    • 363-Max-Sum-of-Rectangle-No-Larger-Than-K
    • 368-Largest-Divisible-Subset
    • 63-Unique-Paths-II
    • 312-Burst-Balloons
    • 322-Coin-Change
    • 64-Minimum-Path-Sum
    • 140-Word-Break-II
    • 120-Triangle
    • 72-Edit-Distance
    • House-Robber-series
    • 413-Arithmetic-Slices
    • 174-Dungeon-Game
    • 87-Scramble-String
    • 44-Wildcard-Matching
    • 338-Counting-Bits
    • 152-Maximum-Product-Subarray
    • 375-Guess-Number-Higher-or-Lower-II
  • hash-table
    • 381-Insert-Delete-GetRandom-O(1) - Duplicates-allowed
    • 442-Find-All-Duplicates-in-an-Array
    • 380-Insert-Delete-GetRandom-O(1)
    • 1-Two-Sum
    • 3-Longest-Substring-Without-Repeating-Characters
    • 41-First-Missing-Positive
  • stack
    • Monotonic stack
      • 84-Larges-Rectangle-in-Histogram
      • 42-Trapping-Rain-Water
  • bit-manipulation
    • 08-Draw-Line-LCCI
  • Mysql
    • 185-Department-Top-Three-Salaries
    • 177-N-Highest-Salary
    • 178-Rank-Scores
    • 180-Consecutive-Numbers
  • greedy
    • 56-Merge-Intervals
    • 55-Jump-Game
    • 53-Maximum-Subarray
  • math
    • 357-Count-Numbers-with-Unique-Digits
    • 343-Integer-Break
    • 119-Pascal's-Triangle-II
  • string
    • Palindrome
      • 5-Longest-Palindromic-Substring
      • Manacher
  • sliding-window
    • 209-Minimum-Size-Subarray-Sum
Powered by GitBook
On this page
  • 图结构模板
  • 图结构java模板
  • 图遍历
  • BFS
  • 拓扑排序
  • p算法
  • 最短路径

Was this helpful?

  1. templates

graph

图的基本算法主要有四个:BFS、DFS、最短路径、最小生成树算法、拓扑排序算法。算法比较多,并且图的表达形式也非常多,所以我们可以维护一种常用的图结构来作为构成图的模板,将不熟悉的图结构转化为我们常用的图结构。

图结构模板

``` c++ "图的总体结构" struct Graph{ unordered_map nodes; unordered_set edges; };

图主要有点集`nodes`和边集`edges`构成,这里选用unordered结构的原因是因为hashmap比普通的map查询更快。`Node`是基本点的wrapper,这么做是为了方便算法编写。

``` c++ "node的基本结构"
struct Node{
    int value;//基本点的序号,即点1、点2...
    int in;//该点的入度
    int out;//该店的出度
    unordered_set<Node*,HashNodeptr,EqualNodeptr> nexts;//该set表示由该点出发可以到达的第一个邻居节点
    unordered_set<Edge*,HashEdgePtr,EqualEdgePtr> edges;//该set表示属于该点的边,即从该点出发的边
    Node (int v):value(v){
        in=0;
        out=0;
    }
}

``` c++ "Edge的基本结构" struct Edge{ int weight;//该边的权重 Node from;//该边的出发节点 Node to;//改边的目的节点 Edge(int w,Node f,Node t):weight(w),from(f),to(t){} }

因为结构体相互嵌套的原因,所以必须使用指针。

使用上面的图结构,只需要写一个转换接口就可以使用。假设采用这样一种图接口:一个二维数组:每一行表示一个节点:`[Node from,Node to,int weight]`,下面是一个栗子。

``` c++ "转化函数"
Graph* Transform(vector<vecotr<int>>& nums){
    Graph* graph=new Graph;
    for(auto elem:nums){
        int from=elem[0];
        int to=elem[1];
        int weight=elem[2];
        //没有构造过该节点,将该节点加入nodes
        //比如[1,2,0]和[2,3,0],在处理第一个边时节点2已经构造好了,只需要更新节点相关信息即可
        if(graph->nodes.find(from)==graph->nodes.end()){
            graph->nodes.insert(pair<int,Node>(from,*new Node(from)));
        }
        if(graph->nodes.find(to)==graph->nodes.end()){
            graph->nodes.insert(pair<int,Node>(to,*new Node(to)));
        }
        //这里不能使用at,因为返回值copy,除非对应的value是指针,无所谓copy与否,使用find返回迭代器直接修改
        auto from_iter=graph->nodes.find(from);
        auto to_iter=graph->nodes.find(to);
        //更新入度出度
        from_iter->second.out++;
        to_iter->second.in++;
        //更新邻居节点
        from_iter->second.nexts.insert(&to_iter.second);
        Edge* edge=new Edge(weight,&from_iter.second,&to_iter.second);
        from_iter->second.edges.insert(edge);
        graph->edges.insert(*edge);
        //一个节点处理完毕
    }
    return graph;
}

使用时只需要稍微修改上述模板即可使用该结构。

图结构java模板

``` java "图结构" public class Graph { HashMap nodes; HashSet edges; Graph(){ this.nodes=new HashMap<>(); this.edges=new HashSet<>(); } }

class Node{
public int value;
public int in;
public int out;
public ArrayList<Node> nexts;
public ArrayList<Edge> edges;

public Node(int value) {
    // TODO Auto-generated constructor stub
    this.value=value;
    this.in=0;
    this.out=0;
    this.nexts=new ArrayList<>();
    this.edges=new ArrayList<>();
}

}

class Edge{
int weight;
Node from;
Node to;
public Edge(int weight, Node from, Node to) {
    super();
    this.weight = weight;
    this.from = from;
    this.to = to;
}

}

``` java "转换函数"
Graph transfoGraphTemp(int[][] data) {
    Graph graph=new Graph();
    for(int i=0;i<data.length;++i) {
        Integer from=data[i][1];
        Integer to=data[i][0];
        //查询是否加入过这两个点

        if(!graph.nodes.containsKey(from)) {
            graph.nodes.put(from, new Node(from));
        }
        if(!graph.nodes.containsKey(to)) {
            graph.nodes.put(to, new Node(to));
        }
        //更新点的入度和出度
        Node fromNode=graph.nodes.get(from);
        Node toNode=graph.nodes.get(to);
        Edge newEdge=new Edge(0, fromNode, toNode);

        //更新所属边和邻居节点

        fromNode.nexts.add(toNode);
        fromNode.out++;
        toNode.in++;

        fromNode.edges.add(newEdge);
        graph.edges.add(newEdge);
    }
    return graph;
}

图遍历

图遍历两种常用的方法是DFS和BFS,含义就不细说了,主要讲讲实现代码。

BFS

顾名思义,按照广度遍历,这里需要使用一个队列结构,以及一个hashset结构(防止重复将邻居节点放入队列中)

void BFS(Node* head){
    if(head =nullptr){
        return;
    }
    queue<Node*> que;
    unordered_set<Node*,HashNodePtr,EqualNodePtr> sets;
    //入队列和入set操作是配套的
    que.push(head);
    sets.insert(head);
    while(!que.empty()){
        Node* current=que.front();
        que.pop();
        //do operation
        cout<<"value: "<<current->value<<endl;
        //处理该节点的所有邻居节点
        for(auto elem:current->nexts){
            //如果该邻居节点没有注册过,那么进行注册并放入队列
            if(sets.find(elem)==sets.end()){
                que.push(elem);
                sets.insert(elem);
            }
        }
    }
    return;
}
void DFS(Node* head){
    if(head==nullptr){
        return;
    }
    stack<Node*> stacks;
    unordered_set<Node*,HashNodePtr,EqualNodeptr> sets;
    stacks.push(head);
    sets.insert(head);
    cout<<"value: "<<head->value<<endl;
    while(!stacks.empty()){
        Node* current=stacks.top();
        stacks.pop();
        //处理current的邻居节点
        for(auto elem:current->nexts){
            //如果next中的一个节点没有注册过,那么就注册,并入栈
            //在入栈前必须先将current先入栈,原因如下:
            //如果current的next节点elem没能遍历完所有节点,
            //那么退栈时退到current会发现其的next节点没有全部注册
            //说明还没遍历完,需要使用下一个next继续遍历;
            //那么如果next全部注册,就直接退栈
            //所以需要在第一次入栈的时候打印;如果不是第一次,一个节点可能入栈出栈多次
            //比如current节点,需要亲自处理所有next节点,即current入栈,next1入栈,next1出栈,current出栈,
            //然后current的next2入栈,current入栈,到底哪次打印?
            if(sets.find(elem)==sets.end()){
                stacks.push(current);
                stacks.push(elem);
                sets.insert(elem);
                cout<<"value: "<<elem->value<<endl;
                break;
            }
        }
    }
    return;
}

拓扑排序

这个有啥用?有一个典型应用场景:编译项目时,子项目的依赖问题,需要决定先后顺序,这时候就能用到拓扑排序了。解决该问题时需要用到节点的入度与出度。

list<Node*> SortTopo(cosnt Graph* graph){
    list<Node*> results;
    queue<Node*> zero_in_nodes;
    unordered_map<Node*,int HashNodePtr,EqualNodeptr> in_maps;
    //先找到所有入度为0的节点
    for(auto elem:graph->nodes){
        if(elem.second.in == 0){
            zero_in_nodes.push(&elem.second);
        }
    }
    while(!zero_in_nodes.empty()){
        Node* current=zero_in_nodes.front();
        zero_in_nodes.pop();
        results.emplace_back(current);
        //消除该节点对所有邻居节点的影响
        for(auto every_node:current->nexts){
            //这样写就破坏图结构了,不好,应使用hashmap维护节点的入读
            /*
            every_nodes->in--;
            if(every_nodes->in == 0){
                zero_in_nodes.push(every_nodes);
            }
            */
            in_map[every_node]=--every_nodes->in;
            if(in_map[every_node]== 0){
                zero_in_nodes.push(every_node);
            }

        }
    }
}

写代码时应考虑是否应该更改参数的相关数据,所以不希望更改时应加const,那么如何表示常量指针呢? 答案:const写在*前面就是常量指针,写在后面就是指针常量,比如const Graph* graph,graph是一个常量指针。

java版本:

``` java "拓扑排序java版" static List TopoSort(GraphTemp graph,int numCourse) { HashMap inMap=new HashMap<>(); Queue zeroInQueue=new LinkedList<>();

for(Node elem:graph.nodes.values()) {
    inMap.put(elem, elem.in);
    if(elem.in==0) {
        zeroInQueue.add(elem);
    }
}
if(zeroInQueue.isEmpty()) {
    return false;
}
List<Node> results=new ArrayList<>();

while(!zeroInQueue.isEmpty()) {
    Node cur=zeroInQueue.poll();
    results.add(cur);

    for(Node elem:cur.nexts) {
        inMap.put(elem, inMap.get(elem)-1);
        if(inMap.get(elem)==0) {
            zeroInQueue.add(elem);
        }
    }
}
return resullts;

}

## 最小生成树(无向图算法)

最小生成树有两种算法:

- p算法:在不构造环的前提下,依次选取权重最小的边,当所有边都选完了,最小生成树也构造好了。
- k算法

### p算法

就一个重点:**不构造环的前提下**,那么如何判断会不会构造环?这里提供一种判断方法:如果两个点在一个集合中时,就会构成环。所以问题转化成了如何判断两个点是否属于同一个集合。一般采用**并查集**的方法快速判断,这里先实现一种简单的结构。

``` c++
struct Mysets{
    //需要维护一个hashmap,key为node,value为对应的集合
    //这里如果直接存放set,不好判断两个点是否在同一个集合啊
    unordered_map<Node*,set<Node*,HashNodePtr,EqualNodeptr>*,HashNodePtr,EqualNodePtr> sets_map;
    //进行初始化
    Mysets(const unordered_map<int,Node,HashNode,EqualNode>& nodes){
        for(auto elem:nodes){
            sets_map.insert(pair<Node*,set<Node*,HashNodePtr,EqualNodePtr>>(&elem.second,new set{&elem}));
        }
    }
    //需要为用户提供两个方法
    //判断两个点是否属于同一个集合
    bool isSameSet(const Node* lhs,const Node* rhs){
        //判断两个node对应的set内存地址是否相同
        return sets_map[lhs]==sets_map[rhs];
    }
    //合并两个点至同一集合
    void Union(Node* lhs,Node* rhs){
        auto left_iter=sets_map.find(lhs);
        auto right_iter=sets_map.find(rhs);
        auto address=right_iter->second;
        for(auto elem:*right_iter->second){
            left_iter->second->insert(elem);
            //需要更新每个节点对应的集合
            Mysets[elem]=right_iter->second;
        }
        //而不是在最后只更新rhs对应的集合
        //sets_map[right_iter.first]=left_iter->second;
        delete address;
    }

};

list<Edge*> Kruskal(Graph* graph){
    list<Edge*> results;
    //首先需要对所有边构造一个小根堆
    auto cmp=[](const Node* lhs,const Node* rhs)->bool{return lhs->weight>rhs->weight};
    priority_queue<Node*,vector<Node*>,decltype(cmp)> pri_queue(cmp);
    //初始化节点与集合
    Mysets myset(graph->nodes);
    for(auto elem:graph->edges){
        pri_queue.push(&elem);
    }
    //由小到大处理每条边
    while(!pri_queue.empty()){
        Edge* current_edge=pri_queue.top();
        pri_queue.pop();
        Node* from=current_edge->from;
        Node* to=current_edge->to;
        //如果新选取的边首尾两点不在同一集合,那么就选它
        if(myset.isSameSet(from,to)){
            results.emplace_back(current_edge);
            myset.Union(from,to);
        }
    }
    return std::move(results);
}

p算法

p算法就是首先随便选取一个点,然后选取该点权值最小的边,查看该边的to节点是否已经处理过,如果没有处理,将该点的所有边加入小根堆,继续选取最小的边

注意:map中的每个元素是一个pair,而不是一个pair迭代器,这一点不要弄混了

list<Edge*> Prim(const Graph* graph){
    list<Edge*> results;
    unordered_set<Node*,HashNodePtr,EqualNodePtr> selected_nodes;
    auto cmp=[](const Edge* lhs,const Edge* rhs)->bool{return lhs->weight>rhs->weight;};
    priority_queue<Edge*,vector<Edge*>,decltype(cmp)> pri_queue(cmp);
    //for循环是为了处理森林问题,有可能图是不连通的,但是这又是按点选取,不像k算法按照边构造最小生成树
    for(auto every_node_pair:graph->nodes){
        //随便选一个点
        if(selected_nodes.find(&every_node_iter.second)==selected_nodes.end()){
            selected_nodes.(&every_node_iter.second);
        }
        for(auto elem:every_node_pair.second.edges){
            pri_queue.push(elem);
        }
        while(!pri_queue.empty()){
            Edge* current_edge=pri_queue.top();
            pri_queue.pop();
            Node* current_to=current_edge->to;
            //如果没有处理to节点
            //每次选取的最小边就是最小生成树的一部分,当然只有边的to节点未处理才行
            results.emplace_back(current_edge);
            if(selected_nodes.find(current_to)==selected_nodes.end()){
                selected_nodes.insert(current_to);
                for(auto elem:current_to->edges){
                    pri_queue.push(elem);
                }
            }
        }
    }
    return std::move(results);
}
  • 简单来说p算法就是按点选,先随便选一个点,选取属于该点并且权值最小的边

  • 然后再看该边的to节点是否已经处理过,如果没有处理过,就将属于to节点的边加入候选边组,并且将该变加入结果集,不是是个最小边就要加入哦(只有边的to节点没处理才会加入,这是为了防止形成环,)

  • 然后再从候选边组选最小的边

最短路径

这里使用迪杰斯特拉算法,求一个节点到所有节点的路径

unordered_map<Node*,int,HashNodePtr,EqualNodePtr> djstla(Node* head){
    if(head ==nullptr){
        return unordered_map<Node*,int,HashNodePtr,EqualNodePtr>{};
    }
    unordered_map<Node*,int,HashNodePtr,EqualNodePr> distance_map;
    //同样需要对点实行一种注册机制
    unordered_set<Node*,HashNodePtr,EqualNodeptr> selected_nodes;
    //选取距离源节点距离最短并且没有注册过的节点
    distance_map[head]=0;
    Node* current=GetMinDistanceAndUnused(distance_map,selected_nodes);
    //当所有节点都注册后,表示源点对所有节点的最短距离已经求出
    //current距离源点的最短距离已经求出,下面操作为了求出下一层的邻居节点的最短距离
    while(current != nullptr){
        //取得当前距离源点的最短距离
        int current_dis=distance_map.at(current);
        //更新源点经过current节点到下一邻居节点的距离
        for(auto edge_to:current->edges){
            //如果to节点没有加入distance_map,直接加入即可,因为一定可以到达,因为是不断通过next到达node_to的
            Node* node_to=edge_to->to;
            if(distance_map.find(node_to)==distance_map.end()){
                distance_map[node_to]=current_dis+edge_to->weight;
            }
            //加入过node_to节点,需要比较源点到node_to的距离和经过current后距离源点的距离
            distance_map[node_to]=min(current_dis+edge_to->weight,distance_map[node_to]);
        }
        //所有current节点的边都已处理完成
        selected_nodes.inert(current);
        current=GetMinDistanceAndUnused(distance_map,selected_nodes);
    }
    return std::move(distance_map);
}

Node* GetMinDistanceAndUnused(const unordered_map<Node*,int,HashNodePtr,EqualNodePtr>& distance_map,const unordered_set<Node*,HashNodePtr,EqualNodePtr>& selected_nodes){
    //选取距离源点最小并且没有注册过的点
    Node* result{nullptr};
    int distance=INT_MAX;
    //遍历map,通过set过滤
    for(auto elem:distance_map){
        if(selected_nodes.find(elem.first)==selected_nodes.end()
        && elem,second<distance){
            result=elem.first;
            distance=elem.second;
        }
    }
    return result;
}

这个注册过的点表示源点至该点的最小距离已经确定,无需更改。

hash table相关的自定义hash function

struct HashNode {
    std::size_t operator()(const Node& nodes) const
    {
        using std::size_t;
        using std::hash;

        return ((hash<int>()(nodes.value)
            ^ (hash<int>()(nodes.in) << 1)) >> 1)
            ^ (hash<int>()(nodes.out) << 1);
    }
};
struct EqualNode
{
    bool operator () (const Node& lhs, const Node& rhs) const
    {
        return lhs.value == rhs.value
            && lhs.in == rhs.in
            && lhs.out == rhs.out
            &&lhs.nexts==rhs.nexts
            &&lhs.edges==rhs.edges;
    }
};
struct HashNodePtr {
    std::size_t operator()(const Node* nodes) const
    {
        using std::size_t;
        using std::hash;

        return ((hash<int>()(nodes->value)
            ^ (hash<int>()(nodes->in) << 1)) >> 1)
            ^ (hash<int>()(nodes->out) << 1);
    }
};
struct EqualNodePtr
{
    bool operator () (const Node* lhs, const Node* rhs) const
    {
        return lhs->value == rhs->value
            && lhs->in == rhs->in
            && lhs->out == rhs->out
            && lhs->nexts == rhs->nexts
            && lhs->edges == rhs->edges;
    }
};
struct HashEdge {
    std::size_t operator()(const Edge& edges) const
    {
        using std::size_t;
        using std::hash;

        return ((hash<int>()(edges.weight)
            ^ (hash<int>()(edges.from->value) << 1)) >> 1)
            ^ (hash<int>()(edges.to->value) << 1);
    }
};
struct EqualEdge
{
    bool operator () (const Edge& lhs, const Edge& rhs) const
    {
        //权重、首尾节点相同,边就相同
        return lhs.weight == rhs.weight
            && lhs.from->value == rhs.from->value
            && lhs.to->value == rhs.to->value;
    }
};
PreviousunionNextgreedy-algorithm

Last updated 4 years ago

Was this helpful?