“파이썬 알고리즘 인터뷰” + https://leetcode.com/problems/palindrome-linked-list/

여름에 한국에 다녀온 이후로 백지 상태가 되었다가 처음부터 다시 시작했다. 이제서야 연결 리스트 챕터에 들어왔다.

연결 리스트(링크드 리스트)는 학부 때 자료구조 시간 초반에 배웠던 것 같다. 그때도 여전히 이쪽과는 거리가 먼 문과생 타입이었기 때문에 C로 자료구조를 만들고 노드 탐색, 추가, 삭제 기능을 만드는 데 너무 힘들었었다. 물론 한 학기가 다 되도록 이해조차 가지 않아서 죽을 쒔었지. 교재를 몇 번이고 읽어도 익숙하지 않은 개념들 뿐이라 내가 왜 이 길에 들어왔는가 고통을 느꼈던 기억이 자꾸 떠오른다.

나는 이과 애들이랑 뇌 구조가 다른 건지 이런 자료구조들이 한참만에 조금씩 조금씩 이해하기 시작했다. 알고리즘 수업을 들을 때 정도엔 잘 쓰진 못해도 거부감은 덜했지. 그렇게 조금씩 발전해 가는 것이 수포자의 기쁨이었는데 졸업하고 잘못된 곳에 취업하면서 배웠던 거의 모든 것들이 깡그리 쓸모 없는 것으로 변해버려서 길이 상처로 남아 있다. 왜 용기 있게 다른 일할 곳을 찾지 않았는가.(비빌 곳 하나 없는 취약한 상태였으니까요.)

아무튼 오늘도 잡설이 길어졌다. 이번 문제는 각 노드가 0-9로 구성된 연결 리스트가 팰린드롬인지 아닌지 판별하는 것이다. 뭘 해야하는 지는 알겠는데 리트코드 페이지가 아닌 로컬에서 이 문제를 풀려면 파이썬 리스트 []로 주어진 인자를 NodeList라는 임의의 연결 리스트 자료형 클래스로 만들어야 했다.

# Definition for singly-linked list.
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

예를 들어 입력값 [1,2,2,1]을 ListNode로 만드는 것 부터가 우선이었다. 오랜만에 쓰는 재귀함수라 조악해도 이해하자.

param = [1,2,2,1]
def createListNode(index: int) -> ListNode:
    if index + 1 >= len(param):
        return ListNode(param[index])
    else:
        return ListNode(param[index], createListNode(index + 1)]

first = createListNode(0)

1. 파이썬 리스트로 바꾸기

연결 리스트를 파이썬 리스트로 바꾸면 첫문제로 풀었던 Valid Palindrome에서처럼 쉽게 풀 수 있을 것이다. 다른 언어에서라면 어떻게 될지 의문이다.

def isPalindrome(head: Optional[ListNode]) -> bool:
    node = head
    lst = []
    while node:
        lst.append(node.val)
        node = node.next
    while len(lst) > 1:
        if lst.pop(0) != lst.pop():
            return False
    return True

리스트의 pop(0)과 pop()을 비교하는 것만으로 충분하다. 파이썬의 편리함. 하지만 예전 문제에서처럼 pop(0)는 O(1)이 아닌 O(n)의 복잡도를 가지므로 Deque를 사용하면 더 나아질 것이다. (deque는 이중 연결 리스트 구조이므로 노드의 앞뒤를 모두 참조로 가진다.)

2. deque를 이용하기

deque를 쓰는 것 말고는 1과 같다.

def isPalindrome(head: Optional[ListNode]) -> bool:
    dq = collections.deque()
    node = head
    while node:
        lst.append(node.val)
        node = node.next
    while len(lst) > 1:
        if lst.popleft() != lst.pop():
            return False
    return True

3. Runner 기법을 이용하기

책을 읽고는 이 방법의 기발함에 놀랐다. 세상엔 정말 똑똑한 사람이 많다. 러너 기법이라는 건, 리스트를 순회할 때 2개의 포인터를 동시에 사용하는데, 예를들어 포인터 하나는 (fast) 한 번에 두 칸을 이동하고 나머지 하나는 (slow) 한 칸을 이동한다. 그러면 fast가 끝에 도달할 때 slow는 리스트의 중간지점에 도달하게 되므로 slow가 거기서부터 값을 비교하거나 뒤집기를 하는 등 여러가지로 활용할 수 있다고 한다.

def isPalindrome(head: Optional[ListNode]) -> bool:
    rev: ListNode = None
    slow = fast = head
    while fast and fast.next:
        fast = fast.next.next
        rev, rev.next, slow = slow, rev, slow.next
    if fast: # 리스트 길이가 홀수인 경우 slow를 한 칸 더 전진시켜서 중간을 가리키도록 맞춘다.
        slow = slow.next
    while rev and rev.val == slow.val:
        rev, slow = rev.next, slow.next
    return not rev

참 간결하고 효율적이다. 책에서 투포인터 개념이 등장할 때부터 그랬지만 포인터 두 개를 동시에 움직인다는 생각이 나로서는 기발하다. 더군다나 fast가 두 칸, slow가 한 칸씩 움직이면 fast가 끝날 때에 slow가 중간에 가 있다는 당연한(!) 것조차 재미있다.

여기서는 slow가 중간에 갈 때까지 rev라는 역순 연결 리스트를 만든다.(전체가 [1,2,2,1]라면 rev는 [2,1]이 됨) 그리고 중간부터 slow와 비교하면 팰린드롬인지 아닌지 알 수 있겠지. 대단하다.