zoukankan      html  css  js  c++  java
  • 数据结构与算法(栈、队列、链表)

    栈是一种特殊的线性表,仅能够在栈顶进行操作,有着先进后出的特性

    我们先定义一个简单的 Stack 类

    function Stack(){
      var items = []; // 使用数组存储数据
    }

    栈有以下几个方法:

    • push:添加一个元素到栈顶
    • pop:弹出栈顶元素
    • top:返回栈顶元素(不是弹出)
    • isEmpty:判断栈是否为空
    • size:返回栈里元素的个数
    • clear:清空栈
    function Stack(){
      var items = []; // 使用数组存储数据
    
      // 从栈顶添加元素,也叫压栈
      this.push = function(item){
        items.push(item)
      }
    
      // 弹出栈顶元素
      this.pop = function(){
        return items.pop()
      }
    
      // 返回栈顶元素
      this.top = function(){
        return items[items.length - 1]
      }
    
      // 判断栈是否为空
      this.isEmpty = function(){
        return items.length === 0
      }
    
      // 返回栈的大小
      this.size = function(){
        return items.length
      }
    
      // 清空栈
      this.clear = function(){
        items = []
      }
    }

    练习1:合法括号

    下面的字符串中包含小括号,请编写一个函数判断字符串中的括号是否合法,所谓合法,就是括号成对出现

    sdf(sd(qwe(qwe)wwe)req)ewq   合法
    (sd(qwewqe)asdw(swe))        合法
    ()()sd()(sd()sw))(           不合法

    思路:

    用for循环遍历字符串中的每一个字符,对每个字符做如下操作:

    • 遇到左括号,就把左括号压入栈中
    • 遇到右括号,判断栈是否为空,为空说明没有左括号与之对应,字符串不合法;如果栈不为空,则把栈顶元素移除

    当for循环结束之后,如果栈是空的,就说明所有的左右括号都抵消掉了,则字符串合法;如果栈中还有元素,则说明缺少右括号,字符串不合法。

    // 判断字符串里的括号是否合法
    function isLeagle(string){
      var stack = new Stack()
      for(var i=0; i<string.length; i++){
        var item = string[i]
        // 遇到左括号入栈
        if(item === '('){
          stack.push(item)
        }else if(item === ')'){
          // 遇到右括号,判断栈是否为空
          if(stack.isEmpty()){
            return false
          }else{
            stack.pop() // 弹出左括号
          }
        }
      }
      // 如果栈为空,说明字符串合法
      return stack.isEmpty()
    }
    
    console.log(isLeagle('sdf(sd(qwe(qwe)wwe)req)ewq')) // true
    console.log(isLeagle('(sd(qwewqe)asdw(swe))')) // true
    console.log(isLeagle('()()sd()(sd()sw))(')) // false

    练习2:逆波兰表达式

    逆波兰表达式,也叫后缀表达式,它将复杂表达式转换为可以依靠简单的操作得到结果的表达式,例如: (a+b)*(c+d) 转换为 ab+cd+*

    从中缀表达式转换成后缀表达式的逻辑:

    (1)如果遇到 数字 ,就压入栈

    (2)如果遇到 运算符,就弹出栈顶的两个数字,对这两个数字进行当前运算符的运算,然后将运算结果压入栈中

    for循环结束之后,栈里只有一个元素,这个元素就是整个表达式的计算结果

    示例:

    ["4", "13", "5", "/", "+"]   等价于 (4 + (13 / 5)) = 6
    ["10", "6", "9", "3", "+", "-11",  "*", "/", "*", "17", "+", "5", "+"]   
    等价于 ((10 * (6 / ((9 + 3) * -11 ))) + 17) +5

    请编写函数 calc_exp(exp) 实现逆波兰表达式计算,exp的类型是数组

    // 计算后缀表达式
    function calc_exp(exp){
      var stack = new Stack()
      for(var i=0; i<exp.length; i++){
        var item = exp[i]
        if(["+","-","*","/"].indexOf(item) >= 0){
          var value1 = stack.pop()
          var value2 = stack.pop()
          var exp_str = value2 + item + value1
          // 计算并取整
          var res = parseInt(eval(exp_str))
          // 计算结果压入栈顶
          stack.push(res.toString())
        }else{
          stack.push(item)
        }
      }
      return stack.pop()
    }
    
    console.log(calc_exp(["4", "13", "5", "/", "+"])) // 6

    练习3:实现一个有min方法的栈

    实现一个栈,除了常见的push,pop方法以外,提供一个min方法,返回栈里最小的元素,且时间复杂度为 O(1)

    function MinStack(){
      var data_stack = new Stack() // 存储数据
      var min_stack = new Stack() // 存储最小值
    
      this.push = function(item){
        data_stack.push(item)
    
        // min_stack为空,或者栈顶元素大于item
        if(min_stack.isEmpty() || item < min_stack.top()){
          min_stack.push(item)
        }else{
          min_stack.push(min_stack.top())
        }
      }
    
      this.pop = function(){
        data_stack.pop()
        min_stack.pop()
      }
    
      this.min = function(){
        return min_stack.top()
      }
    }
    
    var minStack = new MinStack()
    minStack.push(3)
    minStack.push(5)
    minStack.push(8)
    minStack.push(1)
    console.log(minStack.min()) // 1

    练习4:使用栈,完成中序表达式转后续表达式

    输入:["12", "+", "3"]
    输出:["12", "3", "+"]
    
    (1 + (4 + 5 + 3) -3) + (9 + 8) 
    输入:["(", "1", "+", "(", "4", "+", "5", "+", "3", ")", "-", "3", ")", "+", "(", "9", "+", "8", ")"]
    输出:["1", "4", "5", "+", "3", "+", "+", "3", "-", "9", "8", "+", "+"]
    
    (1 + (4 + 5 + 3) / 4 - 3) + (6 + 8) * 3
    输入:["(", "1", "+", "(", "4", "+", "5", "+", "3", ")", "/", "4", "-", "3", ")", "+", "(", "6", "+", "8", ")", "*", "3"]
    输出:["1", "4", "5", "+", "3", "+", "4", "/", "+", "3", "-", "6", "8", "+", "3", "*", "+"]

    1、如果是数字,直接放入到postfix_list中

    2、遇到左括号入栈

    3、遇到右括号,把栈顶元素弹出,直到遇到左括号,最后弹出左括号

    4、遇到运算符,把栈顶的运算符弹出,直到栈顶的运算符优先级小于当前运算符,把弹出的运算符加入到 postfix_list,当前的运算符入栈

    5、for循环结束后,栈里可能还有元素,都弹出放入到 postfix_list 中

    var priority_map = {
      "+": 1,
      "-": 1,
      "*": 2,
      "/": 2
    }
    
    function infix_exp_2_postfix_epx(exp){
      var stack = new Stack()
      var postfix_list = []
      for(var i=0; i<exp.length; i++){
        var item = exp[i]
        // 如果是数字,直接放入到postfix_list中
        if(!isNaN(item)){
          postfix_list.push(item)
        }else if(item === "("){
          // 遇到左括号入栈
          stack.push(item)
        }else if(item === ")"){
          // 遇到右括号,把栈顶元素弹出直到遇到左括号
          while(stack.top() !== '('){
            postfix_list.push(stack.pop())
          }
          stack.pop() // 左括号出栈
        }else{
          // 遇到运算符,把栈顶的运算符弹出,直到栈顶的运算符优先级小于当前运算符
          while(!stack.isEmpty() && ["+","-","*","/"].indexOf(stack.top()) >= 0 && priority_map[stack.top()] >= priority_map[item]){
            // 把弹出的运算符加入到postfix_list
            postfix_list.push(stack.pop())
          }
          // 当前的运算符入栈
          stack.push(item)
        }
      }
    
      // for循环结束后,栈里可能还有元素,都弹出放入到 postfix_list中
      while(!stack.isEmpty()){
        postfix_list.push(stack.pop())
      }
      return postfix_list
    }
    
    console.log(infix_exp_2_postfix_epx(["12", "+", "3"]))
    console.log(infix_exp_2_postfix_epx(["(", "1", "+", "(", "4", "+", "5", "+", "3", ")", "-", "3", ")", "+", "(", "9", "+", "8", ")"]))
    console.log(infix_exp_2_postfix_epx(["(", "1", "+", "(", "4", "+", "5", "+", "3", ")", "/", "4", "-", "3", ")", "+", "(", "6", "+", "8", ")", "*", "3"]))

    队列

    队列是一种特殊的线性表,它只允许你在队列的头部删除元素,在队列的末尾添加新元素(先进先出)

    定义一个简单的 Queue 类

    function Queue(){
      var items = [] // 存储数据
    }

    队列的方法如下:

    • enqueue:从队列尾部添加一个元素
    • dequeue:从队列头部删除一个元素
    • head:返回头部的元素(不是删除)
    • size:返回队列的大小
    • clear:清空队列
    • isEmpty:判断队列是否为空
    • tall:返回队列尾节点
    function Queue(){
      var items = [] // 存储数据
    
      // 向队列尾部添加一个元素
      this.enqueue = function(item){
        items.push(item)
      }
    
      // 移除队列头部的元素
      this.dequeue = function(){
        return items.shift()
      }
    
      // 返回队列头部的元素
      this.head = function(){
        return items[0]
      }
    
      // 返回队列尾部的元素
      this.tail = function(){
        return items[items.length - 1]
      }
    
      // 返回队列大小
      this.size = function(){
        return items.length
      }
    
      // clear
      this.clear = function(){
        items = []
      }
    
      // 判断队列是否为空
      this.isEmpty = function(){
        return items.length === 0
      }
    }

    练习1:约瑟夫环(普通模式)

    有一个数组a[100] 存放 0 - 99 ;要求每隔两个数删掉一个数,到末尾时循环至开头继续进行,求最后一个被删掉的数。

    思路分析:

    前10个数是 0 1 2 3 4 5 6 7 8 9 ,所谓每隔两个数删掉一个数,其实就是把 2 5 8 删除掉

    算法步骤:

    1、从队列头部删除一个元素, index + 1

    2、如果 index % 3 === 0 ,就说明这个元素是需要删除的元素;如果不等于0,就是不需要被删除的元素,则把它添加到队列的尾部

    // 准备好数据
    var arr_list = []
    for(var i=0; i<100; i++){
      arr_list.push(i)
    }
    
    function del_ring(arr_list){
      // 把数组里的元素都放入到队列中
      var queue = new Queue()
      for(var i=0; i<arr_list.length; i++){
        queue.enqueue(arr_list[i])
      }
      var index = 0
      while(queue.size() !== 1){
        // 弹出一个元素,判断是否需要删除
        var item = queue.dequeue()
        index += 1
        // 每隔两个就要删除掉一个,那么不是被删除的元素就放回到队列尾部
        if(index % 3 !== 0){
          queue.enqueue(item)
        }
      }
    
      return queue.head()
    }
    
    console.log(del_ring(arr_list))

    练习2:斐波那契数列(普通模式

    使用队列计算斐波那契数列的第n项

    思路分析:

    斐波那契数列的前两项是 1  1 ,此后的每一项都是该项前面两项之和,即 f(n) = f(n-1) + f(n-2)

    先将两个 1 添加到队列中,之后使用 while 循环,用index计数,循环终止的条件是 index < n - 2

    • 使用 dequeue 方法从队列头部删除一个元素,该元素为 del_item
    • 使用 head 方法获得队列头部的元素,该元素为 head_item
    • del_item + head_item = next_item,将 next_item 放入队列,注意,只能从尾部添加元素
    • index + 1

    当循环结束时,队列里面有两个元素,先用dequeue删除头部元素,剩下的那个元素就是我们想要的答案

    function fibonacii(n){
      if(n <= 2){
        return 1
      }
      var queue = new Queue()
      var index = 0
      // 先放入斐波那契序列的前两个数值
      queue.enqueue(1)
      queue.enqueue(1)
      while(index < n-2){ // 循环次数:因为要求的是第n项,但是前面已经放入两个 1 了
        // 出队列一个元素
        var del_item = queue.dequeue()
        // 取队列头部元素
        var head_item = queue.head()
        var next_item = del_item + head_item
        // 将计算结果放入队列
        queue.enqueue(next_item)
        index++
      }
      queue.dequeue()
      return queue.head()
    }

    小结:

    在socket中,当大量客户端向服务器发起连接,而服务器忙不过来的时候,就会把这些请求放入到队列中,先来的先处理,后来的后处理,队列满时,新来的请求直接抛弃掉

    练习3:用队列实现栈(困难模式)

    用两个队列实现一个栈

    思路:

    队列是先进先出,而栈是先进后出,两者对数据的管理模式刚好是相反的,但是却可以用两个队列实现一个栈

    两个队列分别命名为 queue_1,queue_2,实现的思路如下:

    • push:实现push方法时,如果两个队列都为空,那么默认向 queue_1 里添加数据,如果有一个不为空,则向这个不为空的队列里添加数据
    • top:两个队列,或者都为空,或者有一个不为空,只需要返回不为空的队列的尾部元素即可
    • pop:pop方法要删除的是栈顶,但这个栈顶元素其实是队列的尾部元素。每一次做pop操作时,将不为空的队列里的元素依次删除并放入到另一个队列中,直到遇到队列中只剩下一个元素,删除这个元素,其余的元素都跑到之前为空的队列中了

    在具体的实现中,定义两个额外的变量,data_queue 和 empty_queue,data_queue 始终指向那个不为空的队列,empty_queue 始终指向那个为空的队列

    function QueueStack(){
      var queue_1 = new Queue()
      var queue_2 = new Queue()
      var data_queue = null // 放数据的队列
      var empty_queue = null // 空队列,备份使用
    
      // 确认哪个队列放数据,哪个队列做备份空队列
      var init_queue = function(){
        // 都为空,默认返回 queue_1
        if(queue_1.isEmpty() && queue_2.isEmpty()){
          data_queue = queue_1
          empty_queue = queue_2
        }else if(queue_1.isEmpty()){
          data_queue = queue_2
          empty_queue = queue_1
        }else{
          data_queue = queue_1
          empty_queue = queue_2
        }
      }
    
      this.push = function(item){
        init_queue()
        data_queue.enqueue(item)
      }
    
      this.top = function(){
        init_queue()
        return data_queue.tail()
      }
    
      /**
       * pop方法要弹出栈顶元素,这个栈顶元素,其实就是queue的队尾元素
       * 但是队尾元素是不能删除的,我们可以把data_queue里的元素(除了队尾元素)都移入到empty_queue中
       * 最后移除data_queue的队尾元素并返回
       * data_queue 和 empty_queue 交换了身份
       */
      this.pop = function(){
        init_queue()
        while(data_queue.size() > 1){
          empty_queue.enqueue(data_queue.dequeue())
        }
        return data_queue.dequeue()
      }
    }

    链表

    链表是物理存储单元上非连续的、非顺序的存储结构,由一系列节点组成,这里所提到的链表,均指单链表。

     节点

    节点包含两部分,一部分是存储数据的数据域,一部分是存储指向下一个节点的指针域。

    var Node = function(data){
      this.data = data
      this.next = null
    }
    
    var node1 = new Node(1)
    var node2 = new Node(2)
    var node3 = new Node(5)
    
    node1.next = node2
    node2.next = node3
    
    console.log(node1.data) // 1
    console.log(node1.next.data) // 2
    console.log(node1.next.next.data) // 5

    首尾节点

    链表中的第一个节点是首节点,最后一个节点是尾结点

    有头链表和无头链表

    无头链表是指第一个节点既有数据域,又有指针域,第一个节点即是首节点又是头节点

    有头链表是指第一个节点只有指针域,而没有数据域

    定义链表类

    function LinkList(){
      // 定义节点
      var Node = function(data){
        this.data = data
        this.next = null
      }
    
      var length = 0 // 长度
      var head = null // 头节点
      var tail = null // 尾结点
    }

    链表的方法:

    • append:添加一个新的元素
    • insert:在指定位置插入一个元素
    • remove:删除指定位置的节点
    • remove_head:删除首节点
    • remove_tail:删除尾结点
    • indexOf:返回指定元素的索引
    • get:返回指定索引位置的元素
    • head:返回首节点
    • tail:返回尾结点
    • length:返回链表长度
    • isEmpty:判断链表是否为空
    • clear:清空链表
    • print:打印整个链表

    append方法:

    // 在尾部添加一个节点
    this.append = function(data){
      // 创建新节点
      var new_node = new Node(data)
      if(head === null){
        head = new_node
        tail = new_node
      }else{
        tail.next = new_node
        tail = new_node
      }
      length++
      return true
    }

    print方法:

    this.print = function(){
      var curr_node = head
      while(curr_node){
        console.log(curr_node.data)
        curr_node = curr_node.next
      }
    }

    insert方法:

    append只能在链表的末尾添加元素,而insert可以在指定位置插入一个元素,新增数据的方式更加灵活,insert方法需要传入参数 index,指明要插入的索引位置。该方法的关键是找到索引为 index-1 的节点,只有找到这个节点,才能将新的节点插入到链表中。和索引相关的方法,先要考虑索引的范围是否合法,然后考虑索引的边界情况。

    • 如果 index === length ,那么可以直接调用 append方法
    • 如果 index > length 或者 index<0,索引错误,返回null
    • 如果index===0,创建新节点 new_node,这个新的节点索引是0,因此是链表的首节点,让 new_node.next = head , head = new_node
    • 如果index>0 且index<length,同样创建新节点 new_node,变量 curr_node 指向 head,insert_index = 1,表示 new_node 应该插入的位置,使用一个 while循环,循环条件是 insert_index < index,在循环体内,insert_index 加一, curr_node = curr_node.next,这样当循环结束的时候,curr_node 就是new_node的上一个节点,让 new_node 指向curr_node的下一个节点,而curr_node指向new_node
    this.insert = function(index, data){
      if(index < 0 || index > length){
        return false
      }else if(index === length){
        return this.append(data)
      }else{
        var new_node = new Node(data)
        // new_node 成为新的头结点
        if(index === 0){
          new_node.next = head
          head = new_node
        }else{
          var insert_index = 1
          var curr_node = head
          while(insert_index < index){
            insert_index++
            curr_node = curr_node.next
          }
          // 获取 next节点(这时还没将新节点插入)
          var next_node = curr_node.next
          // 改变当前节点的next,指向新节点(这时斩断了原链表,将前半部分的next指向新节点)
          curr_node.next = new_node 
          // 将新节点的next指向原链表的后半部分(将断开的链表重新连上)
          new_node.next = next_node
        }
      }
      length++
      return true
    }

    remove方法

    this.remove = function(index){
      if(index < 0 || index >= length){
        return null
      }else{
        var del_node = null
        if(index === 0){
          del_node = head
          head = head.next
        }else{
          var del_index = 0
          var pre_node = null // 被删除节点的前一个节点
          var curr_node = head // curr_node就是那个要被删除的节点
          while(del_index < index){ // 当要删除的index和传入的index相同时跳出循环
            del_index++
            pre_node = curr_node // 当前节点赋值给pre_node
            curr_node = curr_node.next // 当前节点往后挪动一位
          }
          del_node = curr_node
          // 被删除节点的前一个节点指向被删除节点的后一个节点
          pre_node.next = curr_node.next
    
          // 如果被删除的节点是尾结点
          if(curr_node.next = null){
            tail = pre_node // 更新tail节点
          }
        }
        length--
        del_node.next = null
        return del_node.data
      }
    }

    get方法

    // 返回指定位置节点的值
    this.get = function(index){
      if(index < 0 || index >= length){
        return null
      }
      var node_index = 0
      var curr_node = head
      while(node_index < index){
        node_index++
        curr_node = curr_node.next
      }
      return curr_node.data
    }

    indexOf方法

    // 返回指定元素的索引,如果没有,返回-1
    // 有多个相同元素,返回第一个
    this.indexOf = function(data){
      var index = -1
      var curr_node = head
      while(curr_node){
        index++
        if(curr_node.data === data){
          return index
        }else{
          curr_node = curr_node.next
        }
      }
      return -1
    }

    练习1:基于链表实现的Stack和Queue

    function Stack(){
      var linkList = new LinkList()
    
      // 从栈顶添加元素
      this.push = function(item){
        linkList.append(item)
      }
    
      // 弹出栈顶元素
      this.pop = function(){
        return linkList.remove_tail()
      }
    
      // 返回栈顶元素
      this.top = function(){
        return linkList.tail()
      }
    
      // 返回栈的大小
      this.size = function(){
        return linkList.length()
      }
    
      // 判断是否为空
      this.isEmpty = function(){
        return linkList.isEmpty()
      }
    
      // 清空栈
      this.clear = function(){
        linkList.clear()
      }
    }

    练习2:翻转链表

    使用迭代和递归两种方法翻转链表,下面的代码已经准备好了上下文环境,请实现函数 reverse_iter 和 reverse_digui

    var Node = function(data){
      this.data = data
      this.next = null
    }
    
    var node1 = new Node(1)
    var node2 = new Node(2)
    var node3 = new Node(3)
    var node4 = new Node(4)
    var node5 = new Node(5)
    
    node1.next = node2
    node2.next = node3
    node3.next = node4
    node4.next = node5
    
    function print(node){
      var curr_node = node
      while(curr_node){
        console.log(curr_node.data)
        curr_node = curr_node.next
      }
    }
    
    // 迭代翻转
    function reverse_iter(head){
    }
    // 递归翻转
    function reverse_digui(head){
    }

    思路分析:

    在考虑算法时,多数情况下考虑边界情况会让问题变得简单,但边界情况往往不具备普适性,因此,也要尝试考虑中间的情况,假设链表中间的某个点为 curr_node ,它的前一个节点是 pre_node,后一个节点是 next_node,现在把思路聚焦到这个curr_node节点上,只考虑在这一个点上进行翻转

    curr_node.next = pre_node

    只需要这简单的一个步骤就可以完成对 curr_node 节点的翻转,对于头结点来说,它没有上一个节点,让 pre_node=null ,表示它的上一个节点是空节点。

    在遍历的过程中,每完成一个节点的翻转,都让 curr_node = next_node,找到下一个需要翻转的节点,同时,pre_node 和 next_node 也跟随 curr_node 一起向后滑动

    // 迭代翻转
    function reverse_iter(head){
      if(!head){
        return null
      }
      var pre_node = null // 前一个节点
      var curr_node = head // 当前要翻转的节点
      while(curr_node){
        var next_node = curr_node.next // 下一个节点
        curr_node.next = pre_node // 对当前节点进行翻转
        pre_node = curr_node // pre_node向后滑动
        curr_node = next_node // curr_node向后滑动
      }
      // 最后要返回 pre_node,当循环结束时,pre_node指向翻转前链表的最后一个节点
      return pre_node
    }
    print(reverse_iter(node1))

    递归翻转链表思路分析

    递归的思想,精髓之处在于甩锅,你做不到的事情,让别人去做,等别人做完了,你在别人的基础上继续做

    甩锅一共分为四步:

    1、明确函数的功能,既然是先让别人去做,那你得清楚的告诉他做什么。函数 reverse_digui(head) 完成的功能,是从head开始翻转链表,函数返回值是翻转后的头结点

    2、正式甩锅,进行递归调用

    var new_head = reverse_digui(head.next)

    原本是翻转以head开头的链表,可是你不会啊,那就先让别人从head.next开始翻转链表,等他翻转玩,得到的 new_head 就是翻转后的头节点

    3、根据别人的结果,计算自己的结果

    在第二步中,已经完成了从 head.next 开始翻转链表,现在,只需要把 head 链接到新链表上就可以了,新链表的尾结点是 head.next,执行 head.next.next = head,这样,head 就完成了新链表的尾结点

    // 递归翻转
    function reverse_digui(head){
      // 如果head为null
      if(!head){
        return null
      }
      // 从下一个节点开始进行翻转
      var new_head = reverse_digui(head.next)
      head.next.next = head // 把当前节点连接到新链表上
      head.next = null
      return new_head
    }

    4、找到递归的终止条件

    递归必须有终止条件,否则就会进入死循环,函数最终要返回新链表的头,而新链表的头正是旧链表的尾,所以,遇到尾结点,直接返回尾结点,这就是递归终止的条件

    // 递归翻转
    function reverse_digui(head){
      // 如果head为null
      if(!head){
        return null
      }
      if(head.next === null){
        return head
      }
      // 从下一个节点开始进行翻转
      var new_head = reverse_digui(head.next)
      head.next.next = head // 把当前节点连接到新链表上
      head.next = null
      return new_head
    }
    print(reverse_digui(node1))
  • 相关阅读:
    c# 中textbox 换行符是什么??
    aspnetpager注意事项
    asp 自动弹出窗口
    用递归方法删除文件夹目录及文件
    二维数组的应用
    外边框的样式
    JS 不间断滚动
    单目标 JADE
    在Delphi中使用Action降低水平功能和业务功能的耦合
    关于TListView中的Data属性
  • 原文地址:https://www.cnblogs.com/haishen/p/11870718.html
Copyright © 2011-2022 走看看