144. Binary Tree Preorder Traversal

 

비교적 쉬운 문제이다. 트리노드 의 트리 형태의 자료구조 가 주어지면 이걸 리스트에 담아 리턴하면 된다. 

순회의 방법은 전위순회 중위순회 후위순회가 있고 이 문제에서 요구하는 바는 전위순회 이다.

예전 easy 101 할때 풀었던 문제인데 그당시 에는 bfs 를 이용해 계층 별 탐색을 했으니 이번에는 재귀로 풀어보자.

 

class Solution {
    List<Integer> list = new ArrayList<>();
    public List<Integer> preorderTraversal(TreeNode root) {
        helper(root);
        return list;
    }
    public void helper(TreeNode root){
        if(root == null) return;
        list.add(root.val);
        helper(root.left);
        helper(root.right);
    }
}

별다를 바 없는 코드이다 현재 들어온 루트 를 기점으로 다음재귀 로 넘어갈때 마다 list 에 담아준다. 

list.add 의 위치가 한칸 아래 내려오면 중위순회이고 맨아래 들어가면 후위순회가 된다. 

LeetCode BinaryTree 설명

 

Account Login - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

 

100. Same Tree

위의 문제와 다를 바 없다 동일한 순회방법을 정해 태우면서 같은 값이 나오지 않는다면 false 를 바로바로 리턴해주면 된다.

class Solution {
    public boolean isSameTree(TreeNode p, TreeNode q) {
        return helper(p,q);
    }
    public boolean helper(TreeNode p,TreeNode q){
        if(p == null || q == null)
            return  p==q;
        if(p.val != q.val) return false;
        return helper(p.left,q.left) && helper(p.right,q.right);
    }
}

좌우 노드를 이용해서 돌렸다. if 부분 의 return 값 이 의아할텐데 하나가 null 이고 하나가 null 이 아닌경우 에 대해 해결 해주기 위해 위와 같이 작성하엿다.

여기서 중점적으로 봐야 할 부분은 여기다 null == null 은 트루가 나온다(Objects.equals(null,null)도 트루이다.) 

null 이란 특정 원시타입 혹은 객체 타입이 아닌 그냥 변수이다.

이 null 의 메모리 어드레스가 0 이라고들 말하는데 메모리 한칸을 쓰고 있는거 아니겠는가 그래서 null 값이 나오면 저 하나의 포인트 로 가기 때문에 true 값이 나온다고 이해된다.

직접 확인 해보기가 어려워서 스택오버 플로 말을 믿자..(C 나 C++ 은 0을 포인팅 한다고 한다.) 위의 이유에서 null == null 이 성립한다. 

 

1443. Minimum Time to Collect All Apples in a Tree

생각보다 시간 소모를 많이한 문제이다.(문제를 이상하게 읽어서...) 어쩃든 보면 0 에서 시작해서 모든 사과를 수거해서 0 으로 돌아오면 된다. 

사과를 가지고 올 최선의 길은 ? 항상 동일하다 같던 방향 그대로 돌아오면 된다. 단 여기서 1번 노드 는 4,5 를 1번 분기를 기준으로 다녀오면 된다. 

분기를 나누는 재귀가 마구마구 떠오른다.

솔루션 의 다른 풀이 확인을 하다보니 좋은 그림이 있어 첨부한다.

 

가장 아래에서 부터 올라가면 된다. 사과 를 가지고 위 분기로 올라갈떄는 2를 더한다 왜 ? 갔다 와야하니깐

 

코드를 보자.

class Solution {
    private List<List<Integer>> graph = new ArrayList<>();
    private void init(int n,int[][] edges){
        for (int i = 0; i < n; i++) {
            graph.add(new ArrayList());
        }
        for (int i = 0; i < edges.length; i++) {
            int from = edges[i][0],to = edges[i][1];

            graph.get(from).add(to);
            graph.get(to).add(from);
        }
    }
    public int minTime(int n, int[][] edges, List<Boolean> hasApple) {
        init(n,edges);
        boolean[] visit = new boolean[n];
        return helper(0, hasApple, visit);
    }
    public int helper(int cur,List<Boolean> hasApple,boolean[] visit){
        visit[cur]  = true;
        int move = 0;

        for(int next : graph.get(cur)){
            if(visit[next]) continue;
            move += helper(next,hasApple,visit);
        }

        if(cur != 0 && (hasApple.get(cur) || move > 0)) move +=2;
        return move;
    }
}

init 을 이용해 그래프 를 그려 주었다 예전 다익스트라 알고리즘 에서 할떄 이런 방법 으로 그래프 그렸던 기억이 있어 위와 같이 그래프를 만들었고. 중복 방문을 처리하기 위해 visit 배열을 하나 생성 했다. 다른 부분은 이상하지 않으나 저기 += 2 해주는 부분을 보자.

0 이 아니라면 왜 ? 0부터 시작하니깐 사과를 집은 시점부터 이동 거리가 0 이상이라면 계속 더해주면서 가야한다

그길은 거쳐야 하니깐 계속 2 씩 증가시켜주면서 다음으로 넘어가 준다.

'PS > LeetcCode' 카테고리의 다른 글

LeetCode Daily 문제풀기  (0) 2023.01.06
LeetCode Daily 문제풀기  (0) 2023.01.05
1월4일 문제 풀기  (2) 2023.01.04
Easy 문제 풀기 Day2(83/618)  (0) 2023.01.03
Binary Tree Maximum Path Sum  (0) 2022.12.11

와 이거 구현하기 힘들었다... 바로 가보자 생각보다 신경 쓸 것이 많았고, 놓친 부분이 많아서 테스트 엄청 돌려봤다.

중첩집합 이란 ?

우리는 지금껏 노드의 관계를 표현하기 위해 새로운 테이블 혹은 패스를 기록하거나 아니면 fk를 조인시키는 등의 과정을 지나왔다.

중첩집합 은 이런 거 없이 현재 가지고 있는 엔티티에 left right 값을 추가해주어 left right 값에 의해 노드 간의 관계를 유지하는 방법이다.

 

위와 같은 사진으로 구현하는 게 중첩 집합이다. 나름 직관적이지 않은가?  바로 가보자.

엔티티

public class Comment {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String comment;
    @Column(name ="left_node")
    private int left;
    @Column(name = "right_node")
    private int right;
    private int level;

    public void updateComment(String newComment){
        this.comment = newComment;
    }
}

left 랑 right는 join 예약어로 사용되니 에러 만나요( 테스트 돌리는데 테이블이 없다고 해서 당황스러웠다. 쿼리 가져다가 콘솔에 써보니 left right 때문이더라 ㅠ)

 

Interface Repository

@Repository
public interface CommentRepository extends JpaRepository<Comment,Long> {
    Optional<Comment> findByComment(String comment);
}

- 잡소리

사실 이거 하기 전에 테이블에 데이터가 하나라도 있다면 루트 즉 최상위 계층을 넣어주거나 아니면 아래로 넣어주는 방식으로 작성하려고 쿼리를 생각하다가 쿼리를 작성하려고 보니 궁금했다. 그래서 exsit 말고 다른방법을 찾다 보니 
select * from table / select 1 from table을 해보니 두 개의 쿼리 타는 방법이 달랐다.

왼쪽이 fullscan 오른쪽이 index scan 이였다. 뭐 때문에 이렇게 타입이 나뉘어서 타는지 궁금해서 알아보니 * 이용해서 조회를 하게 되면 index 가  없는 키도 조회해야 하기 때문에 저런 식의 타입으로 expain 조회하면 나오더라. 당연히 pk fk fk 등으로 조합해서 select 찍어오면 이런 경우는 없다. 

explain으로 검색할 때 type에서 index all 은 피해야 할 타입 중 하나 이기 때문에 이걸 개선하고 싶어서 삽질 좀 했다 

select top 1 1 from table limit 1 oracle에서는 이게 지원된다 1개의 로우가 찾는 즉시 멈추는 쿼리 다 즉 row를 일일이 다 헤짚어 다니지 않아도 된다.

이런 방식이 mysql 에는 없나 궁금해서 찾다 보니 mysql 공홈에서 제안하는 방법이 있다.

prefer_ordering_index  = off로 꺼주면 range 스캔까지 끌어올릴 수 있다 이 내용을 어디서 읽은 거 같은데 이렇게 다시 보니 반갑다 ㅋ

물론 이게 옳은 방법이 아닐지 모르나 만약 데이터가 너무 많아서 속도가 안 나온다면 이 방법을 이용해서 조회 성능을 향상할 수 있는 방법 중 하나로 기억하고 나중에 써먹자.


 

바로 인서트부터 구현해 보자.

private NumberExpression<Integer> getLeftCaseBuilder(int target) {
    return new CaseBuilder()
            .when(comment1.left.goe(target))
            .then(comment1.left.add(2))
            .otherwise(comment1.left);
}
public void insertUpdateLeftRight(int target){
    queryFactory.update(comment1)
            .set(comment1.left, getLeftCaseBuilder(target))
            .set(comment1.right,comment1.right.add(2))
            .where(comment1.right.goe(target))
            .execute();
}
    
public void insertComment(Comment parents, String comment){

    insertUpdateLeftRight(parents.getRight());
    // insert
    commentRepository.save(Comment.builder()
                    .left(parents.getRight())
                    .right(parents.getRight() + 1)
                    .level(parents.getLevel() + 1)
                    .comment(comment).build()
    );
}

 

하나의 데이터가 들어갈 때마다 right와 left를 업데이트해준다. 부모를 잡고 들어가기 때문에 부모의 오른쪽을 기준으로  오른쪽 보다 큰 경우만 업데이트해주면 된다. 단 왼쪽 노드 의 업데이트는 조심해야 한다. 부모 노드보다 작은 왼쪽 노드들은  업데이트를 해줄 필요가 없기 때문에 caseBuilder를 이용해서 위와 같이 작성했다.

 

QueryDsl에서는 update set을 그냥 체이닝 해서 쓰면 원하는 만큼 할 수 있다. 

CaseBuilder의 반환 값이 Expression 인 점을 이용해 위와 같이 작성했다.

테스트 코드 기본 세팅

더보기
class CommentTest {
    @Autowired
    private CommentRepository commentRepository;
    @Autowired
    private CommentQuery commentQuery;
    @Autowired
    private EntityManager em;

    @BeforeEach
    private void init(){
        //insert root
        Comment root = Comment.builder()
                .left(1)
                .right(2)
                .level(0)
                .comment("3 대 500 인 사람 댓글 달아봐")
                .build();

        Comment save = commentRepository.save(root);
        //insert 1st Layer
        insertCommentAndFlushClear(save,"3대 500 아래는 없어요 ?");
        insertCommentAndFlushClear(save,"3대 520 언더아머 회원임");

//        //insert 2nd Layer
        Comment comment1 = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();
        insertCommentAndFlushClear(comment1,"3대 490 ㅠㅠ");
        insertCommentAndFlushClear(comment1, "300 인대요 ? 사람이세요?");

//        //insert 2nd Layer
        Comment comment2 = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        insertCommentAndFlushClear(comment2,"스벤데 몇이고");
        insertCommentAndFlushClear(comment2,"3대 660");

//        //insert 3rd Layer
        Comment comment3 = commentRepository.findByComment("스벤데 몇이고").get();
        insertCommentAndFlushClear(comment3,"구라네 10 이 비는데 ?");
    }

    @AfterEach
    public void afterInit(){
        em.flush();
        em.clear();
    }

    private void insertCommentAndFlushClear(Comment parent,String comment){
        commentQuery.insertComment(parent,comment);
        em.flush();
        em.clear();
    }

코드를 한꺼번에 넣기 때문에 flush clear를 하지 않으면 right left 노드 의 숫자가 자기 멋대로다. 꼭 해주자.

 

저거 말고 중간에 끼워 넣는 걸 해보고 싶었다. 

구현

    public void insertBetween(Comment head,Comment tail,String comment){
        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.add(1))
                .set(comment1.right, comment1.right.add(1))
                .set(comment1.level, comment1.level.add(1))
                .where(comment1.left.goe(tail.getLeft()),
                        comment1.right.loe(tail.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left,getLeftCaseBuilderWithoutRoot(tail.getLeft()))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.gt(tail.getRight()),
                        comment1.ne(tail))
                .execute();

        commentRepository.save(Comment.builder()
                .left(head.getLeft()+1)
                .right(tail.getRight()+2)
                .level(head.getLevel() + 1)
                .comment(comment).build());
    }

쿼리를 무려 3번이나 던져야 한다. 저장을 위한 쿼리와 사이에 끼여 들어간다면 사이에 끼이는 기준으로 부모가 가지고 있는 하위 노드 와 부모 보다 큰 노드 들에 대해 다른 업데이트를 날려야 하기 때문에 위와 같이 작성했다.

 

테스트 코드

    @Test
    public void insertBetween() throws Exception{
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Comment tail = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        commentQuery.insertBetween(comment,tail,"3대 1억");
        em.clear();
        em.flush();

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(18);
    }

 

삭제구현

public void deleteComment(Comment comment){
        queryFactory.update(comment1)
                .set(comment1.left, subTractWithoutLeftRoot(comment.getLeft()))
                .set(comment1.right, comment1.right.subtract(2))
                .where(comment1.right.gt(comment.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.subtract(1))
                .set(comment1.right, comment1.right.subtract(1))
                .set(comment1.level, comment1.level.subtract(1))
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .execute();


        queryFactory.delete(comment1).where(comment1.eq(comment)).execute();
    }

삭제도 마찬가지로  2번의 업데이트가 필요하다 중간에서 삭제되는 경우가 있기 때문에 이걸 고려해 주어야 한다.

 

테스트 코드

@Test
void deleteComment() throws Exception{
    Comment comment = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();

    commentQuery.deleteComment(comment);

    em.flush();
    em.clear();

    List<Comment> all = commentRepository.findAll();
    for (Comment comment1 : all) {
        System.out.println(comment1);
    }
    Comment comment1 = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
    Assertions.assertThat(comment1.getRight()).isEqualTo(14);
}

이 중첩 모델의 장점 중 하나는 바로 조회에 있다고 생각한다. 

public List<Comment> findByLayer(int level){
    return queryFactory.selectFrom(comment1)
            .where(comment1.level.eq(level))
            .fetch();
}

public List<Comment> getAllCommentFromComment(Comment comment){
    return queryFactory.selectFrom(comment1)
            .where(comment1.left.gt(comment.getLeft()),
                    comment1.right.lt(comment.getRight()))
            .fetch();
}

처음 entity에 level 필드를 생성했다. 이런 계층 별 탐색도 추가해보고 싶어서 추가했는데 나름 조회 쿼리를 짜는데 손쉽게 된다.

인접노드 블로그 글 쓸 때 조회 할 때 정말 고민을 많이 하면서 작성했는데 이렇게 쉽게 된다. 현재 노드를 기준으로 왼쪽은 크고 오른쪽은 작은 거 그냥 긁어오면 자식(즉 서브 트리) 모두를 조회할 수 있다.

 

이 중첩모델은 조회에서 어마어마한 이점이 있으나 위에서 보는 바와 같이 업데이트 삭제 삽입 하는데 생각보다 복잡했다. 서브트리의 이동 이 라던지 루트 위에 다시 루트를 생성한다는 등 배제한 경우 가 있음에도 이렇게 코드가 생각보다 길다. 

 

중첩모델을 사용함에 있어서는 인서트가 자주 일어난다면.. 다시 한번 고려해보고 사용해야 할 거 같다. 그렇지만 조회를 자주 한다면 최고인 것 같다.

 

테스트 코드 전문

더보기
package com.example.dowhateveriwant.entity;

import com.example.dowhateveriwant.repository.commet.CommentQuery;
import com.example.dowhateveriwant.repository.commet.CommentRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import java.util.List;

@SpringBootTest
@Transactional
class CommentTest {
    @Autowired
    private CommentRepository commentRepository;
    @Autowired
    private CommentQuery commentQuery;
    @Autowired
    private EntityManager em;

    @BeforeEach
    private void init(){
        //insert root
        Comment root = Comment.builder()
                .left(1)
                .right(2)
                .level(0)
                .comment("3 대 500 인 사람 댓글 달아봐")
                .build();

        Comment save = commentRepository.save(root);
        //insert 1st Layer
        insertCommentAndFlushClear(save,"3대 500 아래는 없어요 ?");
        insertCommentAndFlushClear(save,"3대 520 언더아머 회원임");

//        //insert 2nd Layer
        Comment comment1 = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();
        insertCommentAndFlushClear(comment1,"3대 490 ㅠㅠ");
        insertCommentAndFlushClear(comment1, "300 인대요 ? 사람이세요?");

//        //insert 2nd Layer
        Comment comment2 = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        insertCommentAndFlushClear(comment2,"스벤데 몇이고");
        insertCommentAndFlushClear(comment2,"3대 660");

//        //insert 3rd Layer
        Comment comment3 = commentRepository.findByComment("스벤데 몇이고").get();
        insertCommentAndFlushClear(comment3,"구라네 10 이 비는데 ?");
    }

    @AfterEach
    public void afterInit(){
        em.flush();
        em.clear();
    }

    private void insertCommentAndFlushClear(Comment parent,String comment){
        commentQuery.insertComment(parent,comment);
        em.flush();
        em.clear();
    }

    @Test
    void insertTest() throws Exception{
        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(16);
    }

    @Test
    public void insertBetween() throws Exception{
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Comment tail = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        commentQuery.insertBetween(comment,tail,"3대 1억");
        em.clear();
        em.flush();

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(18);
    }

    @Test
    public void updateComment() throws Exception{
        String updateOne = "업데이트 된 커멘트";
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        comment.updateComment(updateOne);

        em.flush();
        em.clear();

        comment = commentRepository.findByComment(updateOne).get();
        Assertions.assertThat(comment.getComment()).isEqualTo(updateOne);
    }

    @Test
    void deleteComment() throws Exception{
        Comment comment = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();

        commentQuery.deleteComment(comment);

        em.flush();
        em.clear();

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }
        Comment comment1 = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment1.getRight()).isEqualTo(14);
    }

    @Test
    void selectSpecificLayer() throws Exception{
        for (int i = 0; i < 3; i++) {
            List<Comment> byLayer = commentQuery.findByLayer(i);
            System.out.println("========= " + i + " ============");
            for (Comment comment : byLayer) {
                System.out.println(comment);
            }
        }
    }

    @Test
    void getAllChildByComment() throws Exception{
        //2번째 레이어 2개의 댓글 에 한개의 대댓글
        Comment comment = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        List<Comment> allCommentFromComment = commentQuery.getAllCommentFromComment(comment);
        Assertions.assertThat(allCommentFromComment.size()).isEqualTo(3);
        for (Comment comment1 : allCommentFromComment) {
            System.out.println(comment1);
        }
    }
}

CommentQuery 클래스 

더보기
package com.example.dowhateveriwant.repository.commet;

import com.example.dowhateveriwant.entity.Comment;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

import static com.example.dowhateveriwant.entity.QComment.comment1;


@Repository
public class CommentQuery {
    private JPAQueryFactory queryFactory;
    private CommentRepository commentRepository;

    public CommentQuery(EntityManager em,CommentRepository cm) {
        this.queryFactory = new JPAQueryFactory(em);
        this.commentRepository = cm;
    }

    public void insertComment(Comment parents, String comment){

        insertUpdateLeftRight(parents.getRight());
        // 데이터 인설트
        commentRepository.save(Comment.builder()
                        .left(parents.getRight())
                        .right(parents.getRight() + 1)
                        .level(parents.getLevel() + 1)
                        .comment(comment).build()
        );
    }
    public void insertUpdateLeftRight(int target){
        queryFactory.update(comment1)
                .set(comment1.left, getLeftCaseBuilder(target))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.goe(target))
                .execute();
    }
    public void insertBetween(Comment head,Comment tail,String comment){
        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.add(1))
                .set(comment1.right, comment1.right.add(1))
                .set(comment1.level, comment1.level.add(1))
                .where(comment1.left.goe(tail.getLeft()),
                        comment1.right.loe(tail.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left,getLeftCaseBuilderWithoutRoot(tail.getLeft()))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.gt(tail.getRight()),
                        comment1.ne(tail))
                .execute();

        commentRepository.save(Comment.builder()
                .left(head.getLeft()+1)
                .right(tail.getRight()+2)
                .level(head.getLevel() + 1)
                .comment(comment).build());
    }

    public List<Comment> findByLayer(int level){
        return queryFactory.selectFrom(comment1)
                .where(comment1.level.eq(level))
                .fetch();
    }

    public List<Comment> getAllCommentFromComment(Comment comment){
        return queryFactory.selectFrom(comment1)
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .fetch();
    }

    public void deleteComment(Comment comment){
        queryFactory.update(comment1)
                .set(comment1.left, subTractWithoutLeftRoot(comment.getLeft()))
                .set(comment1.right, comment1.right.subtract(2))
                .where(comment1.right.gt(comment.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.subtract(1))
                .set(comment1.right, comment1.right.subtract(1))
                .set(comment1.level, comment1.level.subtract(1))
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .execute();


        queryFactory.delete(comment1).where(comment1.eq(comment)).execute();
    }
    private NumberExpression<Integer> subTractWithoutLeftRoot(int target) {
        return new CaseBuilder()
                .when(comment1.left.gt(target))
                .then(comment1.left.subtract(2))
                .otherwise(comment1.left);
    }

    private NumberExpression<Integer> getLeftCaseBuilderWithoutRoot(int target) {
        return new CaseBuilder()
                .when(comment1.left.gt(target))
                .then(comment1.left.add(2))
                .otherwise(comment1.left);
    }

    private NumberExpression<Integer> getLeftCaseBuilder(int target) {
        return new CaseBuilder()
                .when(comment1.left.goe(target))
                .then(comment1.left.add(2))
                .otherwise(comment1.left);
    }
}

 

현재까지 인접노드, 열거형 모델, 클로저 테이블, 중첩모델을 알아봤다. 

인접목록 => 자식조회가 쉽고, 구현이 쉽다. 삽입과 삭제 가 쉽지만 트리를 조회하는 데 있어 문제점이 발생했다

 

그래서 제안된 방법이 열거형 클로저 중첩모델이다.

 

열거형 => 인접목록의 트리 조회를 쉽게 할 수 있었다. 다만 참조의 정합성 문제가 발생했다. 없는 노드 도 열거형에 넣을 수 있으며 얼마나 깊은 계층까지 만들어야 하는가 에 매번 신경을 써야 하는 단점이 존재했다.

 

클로저 => 두 개의 테이블을 운용해 트리의 모든 경로를 기록하기 때문에 조회가 무척 쉬웠다. 심지어 열거형, 중첩 모델의 문제인 참조의 정합성 문제도 해결해주는 완벽한 듯해 보였으나. 계층 구조를 사용하는 데 있어 많은 저장공간을 사용하는 아쉬운 점이 존재한다.

 

중첩 => 노드 의 좌우측 번호를 매겨 트리의 계층을 유지하는 모델이다. 트리를 수정하고 데이터를 집어넣는 경우 매우 효과적이지 못하다는 것을 코드를 구현하면서도 느낀다. 반면 조회에 있어서 만큼의 편리함은 이루 말할 수 없다. 

 

각각의 장단점이 존재한다. 가장 주가 되는 기준을 정하고 그 기준에 부합하는 모델을 사용하면 된다고 생각한다. 개인적으로 모든 모델을 구현하면서 중첩 모델이 가장 어려웠다.

클로저 테이블 방식

정말 단순하면서 직관적인 방식이다. 부모와 자식의 관계를 나타낼 테이블을 추가적으로 생성해 모든 관계에 대해 테이블에 기입하면 된다. 코드로 보면 더 직관적이다 오늘 은 피자 엔티티를 만들어서 이용해 보자.

public class Pizza {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;
}

@Table(uniqueConstraints={
        @UniqueConstraint(columnNames = {"parents_id", "child_id"})
})
public class PizzaPaths {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parents_id")
    private Pizza parents;

    @ManyToOne
    @JoinColumn(name = "child_id")
    private Pizza child;
}

사실 고민을 좀 했다. parents와 child를 복합 키로 걸어서 이용할까 도 고민을 했다. 그렇게 되면 추가적인 클래스를 만들어(IdClass 방식과 EmbededId 방식 이 있지만 idClass 방식이 보다 직관적이고 코드도 덜 친다.) 할까 했지만.

보다 명시적으로 부모 자식을 노출하기 위해서 위와 같이 작성했다.

 

대신 복합키를 두 개를 걸지 않았기 때문에 각각 아이디 합친 값의 유니크 설정이 필요하다 왜? 하나의 피자는 동일한 부모 자식을 가지는 건 중복된 데이터 이기 때문이다. 그래서 위와 같이 고민했고 유니크 설정을 추가해주기로 결정

 

지난번 에는 데이터를 commit으로 때려 박고 하는 반쪽 짜리 테스트이다 이번에는 실질적인 어느 누가 돌려도 돌아가는 테스트로 귀찮더라도 조금 길게 코드를 적어보자.

 

더보기
  @BeforeEach
    void init(){
        Pizza 토마토피자 = Pizza.builder().name("토마토피자").build();
        Pizza 마르게리타 = Pizza.builder().name("마르게리타").build();
        Pizza 살라미 = Pizza.builder().name("살라미").build();

        pizzaRepository.saveAll(List.of(토마토피자,마르게리타,살라미));
    }

    @Test
    void uniqueTest(){
        Pizza a = pizzaRepository.findByName("토마토피자").get();
        Pizza b = pizzaRepository.findByName("마르게리타").get();
        Pizza c = pizzaRepository.findByName("살라미").get();

        PizzaPaths path1 = PizzaPaths.builder()
                .parents(a)
                .child(b)
                .build();

        PizzaPaths path2 = PizzaPaths.builder()
                .parents(a)
                .child(b)
                .build();

        Assertions.assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
        ()->pizzaPathsRepository.saveAll(List.of(path1,path2)));
    }

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation이라고 에러 중간에 나온다. 자 유니크 테스트까지 해봤으니 실질적으로 데이터를 넣어보자.

토마토 피자
마르게리타 살라미
부라타 피자 , 브루쉐타 피자 디아볼로, 하와이안 피자

이렇게 구조를 잡는 다고 하면? 우리는 패스를 전부 기입해야 한다 다시 말해

토마토 피자를 부모로 잡고 있다면 그 연관된 모든 부분을 인서트 해줘야 한다는 소리이다. 코드로 보자.

더보기
public void insertPizza(Pizza p,Pizza parents){
        PizzaPaths save = pathsRepository.save(
                PizzaPaths.builder().parents(p).child(p).build()
        );

        List<Pizza> fetch = queryFactory.select(pizzaPaths.parents)
                .from(pizzaPaths)
                .where(pizzaPaths.child.id.eq(parents.getId()))
                .fetch();

        List<PizzaPaths> list = new ArrayList<>();
        for (int i = 0; i < fetch.size(); i++) {
            list.add(PizzaPaths.builder()
                    .parents(fetch.get(i))
                    .child(p)
                    .build());
        }

        pathsRepository.saveAll(list);
    }

먼저 본인 자신을 추가하고, 부모를 자식으로 가지고 있는 모든 피자를 가지고 와서 새로 인서트를 넣어준다. 

딱 보더라도 엄청나게 큰 카테고리 혹은 계층 구조를 구성하고 있다면 이 같은 테이블 구조는 사실 많이 힘들 것으로 생각된다. insert 한번 한번 할 때마다 비용이 너무 천차만별이다.

 

테스트 코드 및 결과 값

더보기
    @Test
    void insertEnd() throws Exception{

        Pizza 살라미 = pizzaRepository.findByName("살라미").get();
        Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
        Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
        pizzaQuery.insertPizza(save,살라미);
        pizzaQuery.insertPizza(save2,살라미);

        List<PizzaPaths> allByChild = pizzaPathsRepository.findAllByChild(save);
        for (PizzaPaths pizzaPaths : allByChild) {
            System.out.println(pizzaPaths.getParents().getName());
        }

        System.out.println("===============================================");
        List<PizzaPaths> second = pizzaPathsRepository.findAllByChild(save2);
        for (PizzaPaths pizzaPaths : second) {
            System.out.println(pizzaPaths.getParents().getName());
        }
        Assertions.assertEquals(allByChild.size(),second.size());
    }
    
디아볼로
살라미
토마토피자

하와이안
살라미
토마토피자

인서트 는 확실히 구현하는데 오래걸렸다.. ㅜ 네이티브 쿼리를 사용한다면 보다 편리하게 인서트가 가능하다.

insert into pizza_paths (parents,child)
	select p.parents, :brandNew
    from pizza_paths p
    where p.child = :parents
union all
	select :brandNew,:brandNew
    
void insertPizzaPath(@Param("parents") Long parents, @Param("barndNew") Long brandNew)

unionAll 은 본인 자신을 추가하기 위한 부분



그렇다면 사이에 들어가는 인서트는 어떻게 해야 할까? 위와 동일하게 

    public void insertBetween(Pizza brandNew,Pizza current){

        savePizzaPath(brandNew);

        List<Pizza> fetch = queryFactory.select(pizzaPaths.child)
                .from(pizzaPaths)
                .where(pizzaPaths.parents.eq(current))
                .fetch();

        List<PizzaPaths> list = new ArrayList<>();
        for (Pizza paths : fetch) {
            list.add(
                    PizzaPaths.builder()
                            .parents(brandNew)
                            .child(paths)
                            .build()
            );
        }
        updateQuery(current,brandNew);
        pathsRepository.saveAll(list);
    }
    private void updateQuery(Pizza p, Pizza n){
        queryFactory.update(pizzaPaths)
                .set(pizzaPaths.child,n)
                .where(pizzaPaths.child.eq(p),
                        pizzaPaths.child.ne(pizzaPaths.parents))
                .execute();
    }

내가 들어갈 상위로 있는 피자를 잡아서 지워주고, 그 상위 피자들 목록을 가지고 새로 인서트도 해주어야 한다.
이 아이를 자식으로 가지고 있는 모든 칼럼에 대해서 복사해서 새로 인서트를 해주면 된다.

아까 테스트에서 중간에 삽입을 하고 동일하게 돌려서 결괏값을 받아보면

은근 복잡하지만 그래도 원하는 결과 값이 나온다.

723 토마토피자 토마토피자
726 토마토피자 마르게리타
730 토마토피자 디아볼로
733 토마토피자 하와이안
738 토마토피자 중간낑겨
724 마르게리타 마르게리타
729 살라미 디아볼로
732 살라미 하와이안
728 디아볼로 디아볼로
731 하와이안 하와이안
735 중간낑겨 살라미
736 중간낑겨 디아볼로
737 중간낑겨 하와이안
734 중간낑겨 중간낑겨

보면 알겠지만 꽤 많은 쿼리가 날아간다. 물론 네이티브 쿼리로 작성하면 최적화되겠지만 

테스트 코드 

더보기
@Test
void insertEnd() throws Exception{

    Pizza 살라미 = pizzaRepository.findByName("살라미").get();
    Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
    Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
    Pizza between = pizzaRepository.save(Pizza.builder().name("중간낑겨").build());
    pizzaQuery.insertPizza(save,살라미);
    pizzaQuery.insertPizza(save2,살라미);
    pizzaQuery.insertBetween(between,살라미);

    em.flush();
    em.clear();

    List<PizzaPaths> all = pizzaPathsRepository.findAll();
    for (PizzaPaths pizzaPaths : all) {
        System.out.println(
                pizzaPaths.getId() + " "+ pizzaPaths.getParents().getName() + " "
                + pizzaPaths.getChild().getName()
        );
    }
}

업데이트를 위해서 는 먼저 서브트리 와 연관된 부분의 삭제문부터 시작한다.

    private void deleteByPizza(Pizza target){
        QPizzaPaths q2 = new QPizzaPaths("q2");
        QPizzaPaths q3 = new QPizzaPaths("q3");
        queryFactory
                .delete(pizzaPaths)
                .where(
                        pizzaPaths.child.in(
                                select(q2.child)
                                        .from(q2)
                                        .where(q2.parents.eq(target))
                        ),
                        pizzaPaths.parents.in(
                                select(q3.parents)
                                        .from(q3)
                                        .where(q3.child.eq(target),
                                                q3.parents.ne(q3.child))
                        )
                ).execute();
    }

 

 

타깃 즉 이동할 피자를 기준으로 서브트리 그리고 상위트리와 의 관계 선을 끊어주어 고아 서브 트리로 만들어주는 것이다.

즉 이 쿼리가 나간다면 관계선에 의한 트리는 총 2개가 존재한다. 기존 트리와 관계 가 끊어진 서브트리이다.

타깃 기준으로 타깃을 부모로 가지고 있는 관계 와 타겟을 기준으로 자식으로 가지고 있는 트리를 각각 서브 쿼리로 들고 와서 in 절을 이용해 날려준다. 

쿼리 

더보기
    delete 
    from
        pizza_paths 
    where
        (
            child_id in (
                select
                    pizzapaths1_.child_id 
                from
                    pizza_paths pizzapaths1_ 
                where
                    pizzapaths1_.parents_id=?
            )
        ) 
        and (
            parents_id in (
                select
                    pizzapaths2_.parents_id 
                from
                    pizza_paths pizzapaths2_ 
                where
                    pizzapaths2_.child_id=? 
                    and pizzapaths2_.parents_id<>pizzapaths2_.child_id
            )
        )

예를 들어 우리 피자 즉 살라미를 마르게리타 하위 트리로 옮겨보자.

딜리트 쿼리를 실행하면 토마토 피자 트리 한 개 와  살라미 피자 트리 한개 이렇게 구분된다. 

("살라미 -> 디아볼로", "살라미 -> 하와이안") , ("토마토->살라미") 이후 각각 in 절을 이용해서 좌우로 삭제해 준다. 그러면

자식이 디아볼로, 하와이안 그리고 부모가 토마토인 경우 인 애들만 삭제해주는 쿼리 가 되는 것이다.

 

이후 서브트리를 이어 붙이기 위해서 쿼리를 써야 하는데 이번에는 querydsl로 insert 후에 select 절을 이용해서 넣어주려고 했는데 실패해서 차선책으로 네이티브로 작성했다.

@Modifying
@Query(
        value = "insert into pizza_paths (parents_id,child_id) " +
                "select sup.parents_id,sub.child_id from pizza_paths sup " +
                "cross join pizza_paths sub "+
                "where sup.child_id = :parentsId and sub.parents_id = :childId",
        nativeQuery = true
)
void shiftInsertData(@Param("parentsId") Long parentsId,@Param("childId") Long childId);

새로운 위치의 조상들과 서브트리 자손에 해당하는 새로운 관계를 만들어주기 위해 이렇게 작성했다. 

crossJoin을 이용해 새 위치의 조상과 서브트리의 모든 노드를 대응시키는데 필요한 행을 만들어 낼 수 있다.

쿼리

더보기
Hibernate: 
    insert 
    into
        pizza_paths
        (parents_id,child_id) select
            sup.parents_id,
            sub.child_id 
        from
            pizza_paths sup cross 
        join
            pizza_paths sub 
        where
            sup.child_id = ? 
            and sub.parents_id = ?

위의 예를 이어가면 이렇게 되면 토마토 피자 가 제일 상위 노드 이기 때문에  토마토 피자로부터 생기는 서브트리 의 관계와 부모 와 의관계가 추가적으로 들어간다.

테스트 코드와 콘솔 내역

더보기
// 테스트 함수
@Test
void moveSubTreeToOther() throws Exception{
    insert();
    // 살라미 를 마르게리타 아래로 옮길꺼야 어떻게 ? 부모 를 지워주자.
    Pizza 마르게리타 = pizzaRepository.findByName("마르게리타").get();
    Pizza 살라미 = pizzaRepository.findByName("살라미").get();

    pizzaQuery.moveWithSubTree(살라미,마르게리타);

    List<PizzaPaths> all = pizzaPathsRepository.findAll();
    for (PizzaPaths pizzaPaths : all) {
        System.out.println(pizzaPaths.getParents()
                +" "+ pizzaPaths.getChild());
    }
}

private void insert() {
    Pizza 살라미 = pizzaRepository.findByName("살라미").get();
    Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
    Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
    pizzaQuery.insertPizza(save,살라미);
    pizzaQuery.insertPizza(save2,살라미);
}

// PizzaQuery 클래스 함수
public void moveWithSubTree(Pizza target,Pizza move){
    deleteByPizza(target); 
    pathsRepository.shiftInsertData(move.getId(),target.getId());
}

private void deleteByPizza(Pizza target){
    QPizzaPaths q2 = new QPizzaPaths("q2");
    QPizzaPaths q3 = new QPizzaPaths("q3");
    queryFactory
            .delete(pizzaPaths)
            .where(
                    pizzaPaths.child.in(
                            select(q2.child)
                                    .from(q2)
                                    .where(q2.parents.eq(target))
                    ),
                    pizzaPaths.parents.in(
                            select(q3.parents)
                                    .from(q3)
                                    .where(q3.child.eq(target),
                                            q3.parents.ne(q3.child))
                    )
            ).execute();
}

// PizzaPath Repository interface 함수
@Modifying
@Query(
        value = "insert into pizza_paths (parents_id,child_id) " +
                "select sup.parents_id,sub.child_id from pizza_paths sup " +
                "cross join pizza_paths sub "+
                "where sup.child_id = :parentsId and sub.parents_id = :childId",
        nativeQuery = true
)
void shiftInsertData(@Param("parentsId") Long parentsId,@Param("childId") Long childId);

 


역시나 조회 데이터 추가 삭제는 간편하지만 업데이트에 있어 어느 정도의 비용이 든다고 생각한다. 

 

이런 비용을 지출 을 하고서라도 계층을 구성하고 나서는 조회할 때의 이점은 이루 말할 수 없으며 모든 트리 구조 상에 관계와 노드의 데이터 정합성도 유지할 수 있어 열거형 방식보다 는 좋다고 생각한다.

다만 문제로는 테이블을 추가적으로 구성하는 것 과 하나의 카테고리를 추가할 때마다 생각보다 많은 rows 가 테이블에 쌓인다는 어느 정도의 트레이드오프 가 존재한다.

 

아 추가적으로 인접노드 구현(셀프조인)과 유사하게 자식을 보다 쉽게 조회하기 위해 level? 혹은 length 같은 필드를 추가하면 보다 쉽게 조회가 가능하다. 본인 자신 의 length는 0이고 아래 자식은 1 등등 

select p.* from pizza_paths p where p.parents.name = '마르게리타' and length = 1; 

이러면 마르게리타 의 바로 아래자식 들 만 조회가 가능하다.

 

이번 에는 클로저 테이블에 대해 알아 보았는데 확실히 개인적인 생각으로는 클로저 테이블이 열거형 방식보다는 좋다고 생각된다. 참조의 정합성 데이터 의 일치성을 보장한다는 점이 좋았지만 구현하는 데 있어 애를 먹었다.

querydsl insert 버그, jpql insert 테이블 값과 미일치 되어 생긴 오류,@Modifying 빠져서 생긴오류, @Parma 오류, Update 후에 clear flush 안 하고 조회하여 발생된 테스트 실패, uniqueKey 동시칼럼 적용된 테스트 케이스 확인 , 복합키 설정 등등 많은 삽질을 했다... ㅠ

 

다음 에는 중첩집합 모델 에 대해 알아보자. 

 

  

1833. Maximum Ice Cream Bars

주어진 배열 이 있고 거기서 최대 아이스크림을 살만큼 사면되는 문제이다. 비교적 어제 보다 쉬운 문제이다. 왜냐하면 노트에 표기된 순서에 상관없는 구매가 가능하다는 부분이 이문제를 직관적으로 만든다.

만약 저 노트 부분이 없다면 sliding window 를 이용해 범위를 넓혀나가면서 계산해야지 싶다.

최대범위 10k 이기 떄문에 부담 없이 for loop 돌려도 된다.

정렬을 우선 해주고 o(nlogn), while을 이용해서 작은 가격의 아이스크림부터 우선적으로 챙긴다.

더보기
    class Solution {
        public int maxIceCream(int[] costs, int coins) {
            int answer = 0;
            Arrays.sort(costs);

            if(costs[0] > coins) return answer;

            int idx = 0;
            while(coins > 0 && idx < costs.length){
                if(costs[idx] <= coins){
                    answer++;
                    coins -= costs[idx++];
                }else{
                    break;
                }
            }
            return answer;
        }
    }

 

별다를 게 없는 코드이다. 다만 index 의 변수 라던지 answer의 변수를 줄일 수 있을 거 같아 여러 번 고민했다.

class Solution {
    public int maxIceCream(int[] costs, int coins) {
        Arrays.sort(costs);
        int len = costs.length;

        for(int i=0;i<len;i++){
            if(costs[i] > coins) return i;
            coins -= costs[i];
        }

        return len;
    }
}

이렇게 압축을 시켜줄수 있다. for 문안에서 리턴되는 경우라면? 배열 끝까지 못 사는 경우 

마지막에 리턴이 된다면 ? 플렉스

이거는 미디엄 레벨 이 아니라 이지 레벨로 측정되어야 하지 않는가 싶다.

 

108. Convert Sorted Array to Binary Search Tree

정렬된 배열 을 BST로 바꾸는 로직을 작성하는 문제이다. 높이가 균등한 BST 이기 때문에 한쪽으로 편향된 경우를 제외하자.

 

여기서 BST 가 생소할 수 있다. BST 란 노드 하나 기준으로 외쪽은 노드 본인보다 작고 오른쪽은 본인보다 큰 경우 이다.

즉 다시 말해 위의 답이 2가지 가 될 수 있는 이유이다. 또한 높이가 균등한 BST라고 되어 있기 때문에 왼쪽 높이가 3이라면 오른쪽 높이는 최대 4 최소 2 즉 1의 차이를 가진 트리 그래프를 그려야 한다.

** 여기서 중요한 것은 BST를 구성할 때 노드 좌 우는 본인보다 작고 커야 한다 즉 중간 값이라는 이야기다

 

바로 구현 가보자

 

더보기
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {
        return helper(nums,0,nums.length-1);
    }
    public TreeNode helper(int[] arr,int left,int right){
        if(left >right) return null;
        int mid = (left+right)/2;
        TreeNode node = new TreeNode(arr[mid]);
        node.left = helper(arr,left,mid-1);
        node.right = helper(arr,mid+1,right);
        return node;
    }
}

재귀를 이용해서 좌우로 넣어주었다. mid 값 기준으로 좌우로 넣어주되 +1을 해야 하는 이유는 현재 mid 값은 검증된 값 이기 때문이다. 

추가적으로 검증을 할 이유가 없다 저렇게 안 한다면 아마 StackOverFlow를 마주하지 않을까 싶다.

 

110. Balanced Binary Tree

주어진 TreeNode 가 균등한 BST 인지 아닌지 판별하는 문제이다. 아까 말했다시피 높이를 판별해서 true false의 값을 반환해주어야 하는데...  푸는데 시간 좀 들였다.

더보기
class Solution {
    public boolean isBalanced(TreeNode root) {
        if(root == null) return true;
        
        int left = helper(root.left,0);
        int right = helper(root.right,0);

        return Math.abs(right-left) > 1 ? false : true;
    }
    public int helper(TreeNode node,int depth){
        if(node == null) return depth;
        int left = helper(node.left,depth+1);
        int right = helper(node.right,depth+1);
        return Math.max(left,right);
    }
}

단순 트리의 깊이를 이용해 좌우측 깊이 판정을 통해 비교하는 값을 했더니만 233번인가 203번 케이스에서 걸린다.

좌우측편향 트리인 경우 true의 값을 리턴하는 문제였다.  / \ 이런 식으로 된 트리구조라면 나의 답은 true로 나오기 때문이다 왜? 좌우측 그냥 가장 깊은 트리의 높이를 반환하니깐

 

재귀 중간중간 높이 체크할 변수가 필요하다. 방법을 찾지 못해 디스커스에 가서 몇몇 해설을 읽었다.

더보기
class Solution {
    public boolean isBalanced(TreeNode root) {
        return helper(root) != -1 ;
    }
    public int helper(TreeNode node){
        if(node == null) return 0;

        int left = helper(node.left);
        int right = helper(node.right);

        if(left == -1 || right == -1) return -1;
        if(Math.abs(right-left) > 1) return -1;

        return 1 + Math.max(left,right);
    }
}

이렇게 되면 편향 트리인 경우 2번째 높이에서 1 보다 큰 차이가 있기에 두 번째 If에서 -1 로직을 타면서 false를 리턴한다.

 

오히려 Medium 난이도 인 데일리 문제보다 이 BST 문제가 더 어려웠다.
Easy 난이도인데도 이런 트리문제가 나오면 너무 약하다. 만약 라이브 코딩 을 한다면 이런 트리문제는 흐.... 트리문제 위주로 더 풀어봐야겠다.

 

'PS > LeetcCode' 카테고리의 다른 글

LeetCode 1월9 ~ 1월 11일  (2) 2023.01.11
LeetCode Daily 문제풀기  (0) 2023.01.05
1월4일 문제 풀기  (2) 2023.01.04
Easy 문제 풀기 Day2(83/618)  (0) 2023.01.03
Binary Tree Maximum Path Sum  (0) 2022.12.11

452. Minimum Number of Arrows to Burst Balloons

풍선은 Y 축에 관계없이 주어진 배열에 따라 x 축으로 길게 형성되어있다. 최소한의 화살을 쏴서 풍선을 터트려야 하는데.. 음 어떻게 풀지 감이 잘 안 온다. 뭔가 그리디 문제 같은데..

첫 번째 접근방법

좌우 차이를 이용해 가장 큰 것부터 제거하면서 그 범위를 기록해 놓는 것이다.  (x)

-> 1-5  1-2, 4-5 이렇게 주어진다면 가장 큰 1-5 가 범위가 기록되고 1-2, 4-5 모두 한 번에 1-5 라인에 소거되기에 탈락

두 번째 접근방법

좌우 차이를 이용해 가장 작은 것부터 제거하면서 그 범위를 기록해 놓는 것

-> 1-5, 1-2, 4-5 음? 뭔가 될 거 같다. 바로 PriorityQueue를 이용해 정렬 후 뽑아가면서 계산을 해보자.

더보기
class Solution {
    public int findMinArrowShots(int[][] points) {
        PriorityQueue<int[]> q
                    = new PriorityQueue<>((a,b) ->(a[1]-a[0])-(b[1]-b[0]));

        for(int[] arr : points){
            q.offer(arr);
        }

        List<int[]> list = new ArrayList<>();
        while(!q.isEmpty()){
            int[] poll = q.poll();
            boolean range = false;
            for(int[] arr : list){
                boolean a = poll[0] <= arr[0] && arr[0] <= poll[1];
                boolean b = poll[0] <= arr[1] && arr[1] <= poll[1];
                if(a || b){
                    range = true;
                    break;
                }
            }
            if(!range) list.add(poll);
        }
        return list.size();
    }
}

36 번째 테스트 케이스에서 깨진다. 왜? 3-5, 5- 7,1-4 이런 경우 3-5가 범위가 찍히고 5-7 이 찍힌다. 이러면 화살은 5번을 쏴야 하지만 1-4 또한 소거된다. 3을 기준으로 분류되기 때문에 이렇게 범위를 이용하는 것이 아닌 하나의 점을 찍어서 구현을 해야 할 것 같은데...

사실 위 방법 말고도 다양한 방법으로 구현해봤으나 다 실패했다.

하.. 멘털 갈린다. 설루션 가서 접근법을 먼저 살펴보고 오자. 

한 번에 많은 화살로 최대한 많은 풍선을 터트려야 한다. 그렇다면 가장 많은 풍선을 터트릴 방법 중 하나는 주어진 범위 최소 혹은 최대 값을 이용하면 판별하기 쉽다. 그러나 모든 가지의 경우의 수를 계산할 필요는 없다 왜? 

정렬을 이용해 오른쪽 숫자별로 기준으로 정해서 그 포인트 별로 화살을 날려주면 되기 때문이다.

balloons = [[7,10], [1,5], [3,6], [2,4], [1,4]]
정렬 이후 
balloons = [[2,4], [1,4], [1,5], [3,6], [7,10]]

하... 이 쉬운 걸 파악 못해서 1시간을 저러고 있으니 참 그렇다.

더보기
class Solution {
    public int findMinArrowShots(int[][] points) {
        PriorityQueue<int[]> q = new PriorityQueue<>(
                    (a,b) -> a[1]-b[1]
            );
            Arrays.stream(points).forEach(q::offer);

            int cnt = 0;

            while(!q.isEmpty()){
                int[] cur = q.poll();
                while(!q.isEmpty() && q.peek()[0] <= cur[1] &&  cur[1] <= q.peek()[1]){
                    q.poll();
                }
                cnt++;
            }
            return cnt;
    }
}

항상 화살은 endPoint 기준으로 날려준다는 로직을 세우고 

우선순위 큐를 활용해 정렬을 했고, 큐를 돌면서 다음 범위를 확인한 후 지워줄지 말지를 정하고 날려준다.

 

이것 외에 배열 정렬을 통해 해결하는 방법도 있다.

 

더보기
class Solution {
    public int findMinArrowShots(int[][] points) {
        Arrays.sort(points, (a,b) -> Integer.compare(a[1], b[1]));
        int cnt = 1;
        int current = points[0][1];
        for(int i=1;i<points.length;i++){
            if(current < points[i][0]){
                cnt++;
                current = points[i][1];
            }
        }
        return cnt;
    }
}

current 가 현재 화살을 소는 타깃 즉 endPoint 가 된다. 

 

Simillar Question 찍어서 비슷한 문제 더 풀어야겠다 화나서 안 되겠다.

 

56. Merge Intervals

이번에는 합쳐주는 방식이다. 양 좌우측 사이에 겹치는 오버래핑되는 수가 있다면 합쳐서 늘려주면 된다.

위에서는 pq를 이용했으니 이번에는 배열방식으로 풀어보자.

더보기
class Solution {
    public int[][] merge(int[][] intervals) {
        Arrays.sort(intervals,(a,b)->a[0]-b[0]);
            
        List<int[]> answer = new ArrayList<>();

        int min = intervals[0][0];
        int max = intervals[0][1];

        for (int i = 1; i < intervals.length; i++) {
            if(min <= intervals[i][0] && intervals[i][0] <= max){
                max = Math.max(intervals[i][1],max);
            }else{
                answer.add(new int[]{min,max});
                min = intervals[i][0];
                max = intervals[i][1];
            }
        }
        answer.add(new int[]{min,max});
        return answer.toArray(new int[answer.size()][]);
    }
}

 

 

이번에는 합치는 로직이다 보니 위에 와는 달리 작은 값이 중요하다 현재 값 사이에  정렬된 배열의 다음 왼쪽값이  포함되느냐 가 주된 로직이다.

forLoop 가 끝나고 한 번 더 추가하는데 이는 마지막까지 모든 수를 업데이트하고 나서 추가가 안 되는 경우를 위해 이렇게 따로 추가한다.

사실 틀리기 전까지 몰랐다. 그래서 추가적으로 위와 같이 작성했다.

 

435. Non-overlapping Intervals

이번에는 겹치는 부분을 제거해주는 로직을 구현하면 된다.

위와 동일하게 좌측 기준으로 정렬 을 하고 오른쪽 값을 업데이트해나가면서 오른쪽 값과 새로 들어온 왼쪽 값을 비교하면 된다.

더보기
class Solution {
    public int eraseOverlapIntervals(int[][] intervals) {
        Arrays.sort(intervals,(a,b)->Integer.compare(a[0],b[0]));

        int cur = intervals[0][1];
        int cnt = 0;

        for(int i=1;i<intervals.length;i++){
            if(cur > intervals[i][0]){
                cnt++;
                cur = Math.min(intervals[i][1],cur);
            }else{
                cur = intervals[i][1];
            }
        }
        return cnt;
    }
}

어으 사실 이 문제도 여러 번 틀렸다. 접근방법은 비슷했으나. 어떻게 다음으로 걸러 주는지 분기를 나누는 부분에서 버벅 거렸다..

 

결론 그리디 문제 너무 싫다.

'PS > LeetcCode' 카테고리의 다른 글

LeetCode 1월9 ~ 1월 11일  (2) 2023.01.11
LeetCode Daily 문제풀기  (0) 2023.01.06
1월4일 문제 풀기  (2) 2023.01.04
Easy 문제 풀기 Day2(83/618)  (0) 2023.01.03
Binary Tree Maximum Path Sum  (0) 2022.12.11

계층형 구조 즉 대댓글 혹은 카테고리 같은 구조를 만들떄 어떤방식 을 사용하는가 ? 가장먼저 떠오르는건 셀프조인 방식이다. 

바로 구현해보자.

package com.example.dowhateveriwant.entity;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OrganizationChart {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String department;

//    @ManyToOne
//    @JoinColumn(name = "person_id")
//    private Person person;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private OrganizationChart organizationChart;
}

 

 

이런 식으로 셀프조인 을 하면 된다.

root 데이터 부터 시작해서 도식화 해보자면

Id 값만 기입을 해본다면 

 

                         115

                    116     117

               118 119  120

              121

 

이런 방식으로 구조화 되어 있다. 구현도 쉬웠고 인서트도 쉽다 그냥 루트 먼저 넣어주고 데이터를 넣을 때 넣어주기만 하면 된다. 예제 코드를 보자.

 

OrganizationChart left = organizationChartRepository.findById(116L).get();
organizationChartRepository.save(
        OrganizationChart.builder()
                .department("두번째 레이어 왼쪽 왼쪽")
                .organizationChart(left)
                .build()
);

이런식 으로 들고 와서 넣어주기만 하면 끝이다. 뭐 별다를게 없다.

업데이트 는 어떻게 하면 될까 ? 118 번 120 번 하위로 모두 잘라서 들고가고 싶다면 ? 118 번의 부모 만 변경해주면 된다.

OrganizationChart organizationChart = organizationChartRepository
                .findById(118L).get();
OrganizationChart organizationChart2 = organizationChartRepository
        .findById(120L).get();
organizationChart.updateParents(organizationChart2);

반대로 삭제 라면 ? 아래 자식들 부터 지우면서 올라가지 않는다면 에러를 마주할것이다. 

CascadeType.Remove 를 주면 번거롭게 밑에서 부터 지우지 않아도 된다. 


말 나온김에 cascade 타입을 한번 살펴보자.

Entity 의 상태변화를 전달할 범위라고 생각하면 편할꺼 같다. default 는 아무것도 변화 시키지 않는다.

cacadeType.All => 뭐그냥 할때마다 다 따라옴 저장을 하던 지우던 업데이트를 하던 flush 로 엔티티 날리던 

cacadeType.Persist => 저장할때 자식도 따라서 저장된다. 우리의 경우 부모를 저장하고 자식을 저장하고 그다음 자시을 부모에 업데이트 해주지만 이 타입 이 걸려있다면 ? 한방에 저장해준다.

cacadeType.Merge => 트랜젝션 종료이후 관계 의 변화 및 추가가 있다면 부모 가 merge 를 수행한다.

cacadeType.Remove => 연관 된 관계를 지울때 같이 날려준다. 

cascadType.Detach => 부모 가 detach 즉 영속성 컨텍스트 에서 날아가면 자식도 같이 날아간다.

cascadeType.Refresh => 부모 엔티티 의 db 에서 새로불러온 값 이 있다면 자식도 무조건 리프레쉬 


 

타팀으로 부터 어드민 페이지 에서 특정 부서 아래로 모든 부서를 조회 하고싶다는 요청이 왔다고 해보자.

116번 의 자식을 모두 가져와 보자. 116 을 부모로 하는 이들을 조회 하면 된다.

118 119 가 나온다. 그밑에는 ? 어떻게 해야하는가 ? 나온값을 기준으로 다시 조회 하면 된다. 그러면 121 이 조회 된다. 

즉 다시 말해 원하는 깊이 만큼 조인 을 다때려박아야 한다는 소리이다. 

예시 116을 기준으로 118 119 를 위한 leftJoin , 121 레이어 를 위한 leftJoin

QOrganizationChart q2 = new QOrganizationChart("q2");
QOrganizationChart q3 = new QOrganizationChart("q3");
queryFactory.select(organizationChart,q2,q3)
        .from(q2)
        .leftJoin(q2.parent,organizationChart)
        .leftJoin(organizationChart.parent,q3)
        .where(organizationChart.id.eq(id))
        .fetch();
select o1.*,o2.*,o3.*
from ORGANIZATION_CHART  as o1
    left outer join ORGANIZATION_CHART o2
		on o2.parent_id = o1.id
	left outer join ORGANIZATION_CHART o3
		on o3.parent_id = o2.id
where o1.id = 116;

그렇다면 이런 join 의 깊이를 알수없다면 ? level 이라는 추가적인 필드 를 만들어 for 문을 돌려 그냥 네이티브 쿼리를 작성해 날려야 하지 않을까 싶다.

또한 이렇게 되면 count() 와 같은 집계수치를 계산하기 어려울 뿐더러 매번 쿼리를 날릴떄 마다 깊이를 신경 써야한다.

 

어떻게 하면 이런 조회 의 오점 을 해결할수 있을까 라는 고민을 나말고도 수많은 사람이 했고 그렇게 해왔다.

그 대안으로 Closure Table, Nested Sets, Path Enumertation 이렇게 3가지 가 있는데 하나씩 알아보자.

 

1. Path Enumeration 경로열거

심플하다 부모 의 아이디 + 내아이디 를 경로에 추가로 적어주면 된다. / 를 쓰던 @ 를 쓰던 그건 사용자 마음이기 떄문에 색다르게 @ 로 가보자.
나는 부모 의 패스 + 부모 의 아이디로  구현 하겠다 특별한 이유는 없다 그려보니 이게더 편하더라.

organiation 으로 했더니 코드가 너무길어서 category 로 변경하겠다.

path 부분 이 경로를 적어 줄 필드 이다. 

    @Test
    @Commit
    void init() throws Exception{
        Category category = Category.builder()
                .name("root")
                .build();
        categoryRepository.save(category);
    }

root 는 최상단 카테고리 이기 때문에 부모가 없다 즉 path 를 넣어줄 필요가 업다. root 아래로 사과 오렌지 딸기 를 넣어보자.

음 ㅋㅋㅋ generateValue 안넣어줘서 에러 났다 ㅋㅋ 추가하고 넣어주자 

    @Test
    @Commit
    void addFirstLayer() throws Exception{
        Category category = categoryRepository.findById(122L).get();

        List<Category> list = new ArrayList<>();
        String[] name = {"사과","오렌지","딸기"};
        for (int i = 0; i < name.length; i++) {
            list.add(
                    Category.builder()
                    .name(name[i])
                    .path(category.getPath())
                    .build());
        }
        categoryRepository.saveAll(list);
    }

                                        root         

            사과                   오렌지                 딸기

          아오리      황금향 루비향 천혜향     산딸기                   대충 이런 구조이다. 이름 은 신경 쓰지말고 트리 구조를 보자.

                          한라봉                          산산딸기

 

위에서 어려웠던 조회 를 한번 해보자 오렌지 아래로 모든 아이들을 조회 하고 싶어요 혹은 한라봉 의 모든 부모를 조회 되는 기능을 추가해주세요 이런다면 ? 정말 간편하게 쿼리를 작성할수 있다.

    public List<Category> findParents(Category cat){

        List<Long> ids = Arrays.stream(cat.getPath().split("@"))
                .map(Long::parseLong)
                .collect(Collectors.toList());

        return queryFactory.selectFrom(category)
                .where(category.id.in(ids))
                .fetch();
    }

    public List<Category> findChildren(Category cat){
        return queryFactory.selectFrom(category)
                .where(category.path.contains(String.valueOf(cat.getId())))
                .fetch();
    }

단순 자바의 스플릿 기능 과 contains 를 이용해 그냥 가져오면 된다.... 나간 쿼리를 확인해보자.

// 자식 조회시
select
    category0_.id as id1_0_,
    category0_.name as name2_0_,
    category0_.path as path3_0_ 
from
    category category0_ 
where
    category0_.path like ? escape '!'
    
// 부모 조회시
select
    category0_.id as id1_0_,
    category0_.name as name2_0_,
    category0_.path as path3_0_ 
from
    category category0_ 
where
    category0_.id in (
        ? , ? , ?
    )

셀프조인 해서 구했을 때 와는 차원이 다르게 손쉽게 생각하고 쿼리를 구상할수 있다.

단 이렇게 구상 되었을때의 단점이 존재한다. 무진장 큰 단점이 각 카테고리 별로 path 에 대한 연관관계 가 없다보니

지우거나, 업데이트, 저장 등을 할떄 존재하지 않는 카테고리 를 패스 넣어서 집어넣을수 있다는 소리이다.

 

1. 업데이트 및 하위 카테고리 의 이동 할떄 ? mysql 의 replace 함수를 이용해 구간을 집어서 추가해서 리플레이스 하면 된다.

2. 삭제 할때 는 내 아이디 기준으로 삭제 날려주면 된다. 

 

위와 같은 쿼리가 날라가는데 데이터가 없어도 ? 롤백없이 삭제되고 업데이트 된다. 이렇게 데이터의 정합성이 맞지 않을수 있다면 너무 치명적인 오류이다.

또한 varchar 의 길이 는 한정적이다. 65535 characters  라고 하는데

정말 말도 안되지만 만약 url 같은거를 넣어서 카테고리를 구성한다면 몇 뎁스 가지도 않아서 난감한 상황에 처할것이다.

 

그럼에도 불구하고 조회 에서 주는 이점이 정말 크다고 생각하기 떄문에 

이미 정해진 카테고리 이미 완성된 카테고리 에 데이터 의 변동이 적다면 이렇게 마이그레이션 하는것도 나쁘지 않다고 생각한다.

 

다음 글에서는 개인적으로 선호하는 클로저 테이블 과 중첩집합 방법에 대해 알아보자. 

 

+ Recent posts